url = self::normalizarUrl(ChatConfig::get('ocr_url', '')); $this->token = ChatConfig::get('ocr_token', ''); } /** Acepta tanto "https://host" como "https://host/extract" (error comun de configuracion). */ public static function normalizarUrl(string $url): string { $url = rtrim(trim($url), '/'); return preg_replace('#/extract$#', '', $url); } public function habilitado(): bool { return ChatConfig::get('ocr_habilitado', '0') === '1' && $this->url !== ''; } /** Envia la imagen al servicio OCR externo y devuelve el texto crudo, o null si falla. */ public function extraerTexto(string $base64, string $mimeType, ?int $usuarioId = null, string $canal = 'web'): ?string { if (! $this->url) { return null; } $inicio = microtime(true); try { $response = Http::withToken($this->token) ->timeout(20) ->post("{$this->url}/extract", [ 'image_base64' => $base64, 'mime_type' => $mimeType, ]); $tiempoMs = (int) ((microtime(true) - $inicio) * 1000); $data = $response->json(); if (! $response->successful() || ! ($data['success'] ?? false) || empty($data['text'])) { $error = $data['error'] ?? ('HTTP ' . $response->status()); Log::warning('[OCR] ' . $error . ': ' . substr($response->body(), 0, 300)); $this->registrarUso($usuarioId, $canal, $tiempoMs, 'error', ['error' => $error]); return null; } Log::info("[OCR] ok ({$tiempoMs}ms): " . substr($data['text'], 0, 200)); $this->registrarUso($usuarioId, $canal, $tiempoMs, 'ok', ['caracteres' => mb_strlen($data['text'])]); return $data['text']; } catch (\Throwable $e) { $tiempoMs = (int) ((microtime(true) - $inicio) * 1000); Log::warning('[OCR] excepcion: ' . $e->getMessage()); $this->registrarUso($usuarioId, $canal, $tiempoMs, 'error', ['error' => $e->getMessage()]); return null; } } /** Cada foto enviada al OCR cuesta lo mismo, se lea bien o no. */ private function registrarUso(?int $usuarioId, string $canal, int $tiempoMs, string $resultado, array $detalle): void { AiUsageLog::registrar([ 'servicio' => 'ocr', 'canal' => $canal, 'usuario_id' => $usuarioId, 'tiempo_ms' => $tiempoMs, 'costo' => self::COSTO_POR_FOTO, 'resultado' => $resultado, 'detalle' => $detalle, ]); } }