diff --git a/app/Http/Livewire/Chat/ShowConfiguracionChat.php b/app/Http/Livewire/Chat/ShowConfiguracionChat.php index adf1ca2..cd44041 100755 --- a/app/Http/Livewire/Chat/ShowConfiguracionChat.php +++ b/app/Http/Livewire/Chat/ShowConfiguracionChat.php @@ -18,6 +18,7 @@ class ShowConfiguracionChat extends Component public string $webhookResult = ''; public string $whisperResult = ''; public string $geminiResult = ''; + public string $ocrResult = ''; // ── Gemini IA ───────────────────────────────────────────── public string $gemini_api_key = ''; @@ -35,6 +36,11 @@ class ShowConfiguracionChat extends Component public string $whisper_token = ''; public string $whisper_habilitado = '0'; + // ── OCR de comprobantes (previo a Gemini) ────────────────── + public string $ocr_url = ''; + public string $ocr_token = ''; + public string $ocr_habilitado = '0'; + // ── Bre-B ───────────────────────────────────────────────── public string $recarga_banco_nombre = ''; public string $recarga_banco_llave = ''; @@ -47,6 +53,7 @@ class ShowConfiguracionChat extends Component 'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra', 'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia', 'whisper_url', 'whisper_token', 'whisper_habilitado', + 'ocr_url', 'ocr_token', 'ocr_habilitado', 'recarga_banco_nombre', 'recarga_banco_llave', 'recarga_banco_titular', ]; @@ -79,6 +86,7 @@ class ShowConfiguracionChat extends Component 'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra', 'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia', 'whisper_url', 'whisper_token', 'whisper_habilitado', + 'ocr_url', 'ocr_token', 'ocr_habilitado', 'recarga_banco_nombre', 'recarga_banco_llave', 'recarga_banco_titular', ]; @@ -172,6 +180,42 @@ class ShowConfiguracionChat extends Component } } + public function probarOcr(): void + { + $url = rtrim(trim($this->ocr_url ?: ChatConfig::get('ocr_url')), '/'); + $token = trim($this->ocr_token ?: ChatConfig::get('ocr_token')); + + if (! $url) { + $this->ocrResult = '⚠️ La URL del servicio OCR está vacía.'; + return; + } + + // 1x1 png transparente, solo para verificar que el servicio responde. + $pixel = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='); + + try { + $inicio = microtime(true); + $response = Http::withToken($token) + ->timeout(15) + ->post("{$url}/extract", [ + 'image_base64' => base64_encode($pixel), + 'mime_type' => 'image/png', + ]); + + $ms = (int) ((microtime(true) - $inicio) * 1000); + + if ($response->successful()) { + $this->ocrResult = "✅ Servicio OCR responde OK ({$ms}ms). " . + "Respuesta: " . substr($response->body(), 0, 200); + } else { + $this->ocrResult = "❌ El servicio respondió con error " . $response->status() . ": " . + substr($response->body(), 0, 200); + } + } catch (\Throwable $e) { + $this->ocrResult = '❌ No se pudo conectar con el servicio OCR: ' . $e->getMessage(); + } + } + public function registrarWebhook(): void { $token = trim($this->telegram_token); diff --git a/app/Jobs/ValidarPagoWebJob.php b/app/Jobs/ValidarPagoWebJob.php index 633227c..1a0542e 100644 --- a/app/Jobs/ValidarPagoWebJob.php +++ b/app/Jobs/ValidarPagoWebJob.php @@ -8,6 +8,7 @@ use App\Models\Saldo; use App\Models\SolicitudRecarga; use App\Models\User; use App\Services\GeminiVisionService; +use App\Services\OcrExtractionService; use App\Services\PagoValidadorService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -220,12 +221,28 @@ class ValidarPagoWebJob implements ShouldQueue return null; } - $datos = app(GeminiVisionService::class)->extraerPago( - base64_encode(file_get_contents($ruta)), - mime_content_type($ruta) ?: 'image/jpeg', - $this->usuarioId, - 'web' - ); + $base64 = base64_encode(file_get_contents($ruta)); + $mime = mime_content_type($ruta) ?: 'image/jpeg'; + $gemini = app(GeminiVisionService::class); + $datos = null; + + // Camino nuevo: OCR externo (texto) + Gemini solo-texto. Evita depender + // de la cuota/estabilidad de Gemini Vision, que es lo que fallaba antes. + $ocr = app(OcrExtractionService::class); + if ($ocr->habilitado()) { + $texto = $ocr->extraerTexto($base64, $mime); + if ($texto) { + $datos = $gemini->extraerPagoDeTexto($texto, $this->usuarioId, 'web'); + } else { + Log::warning('[ValidarPagoWebJob] OCR no devolvio texto, cae a lectura por vision.'); + } + } + + // Si el OCR no esta habilitado, no respondio, o Gemini no pudo + // estructurar su texto: cae al metodo anterior (Gemini lee la imagen). + if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) { + $datos = $gemini->extraerPago($base64, $mime, $this->usuarioId, 'web'); + } if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) { Log::warning('[ValidarPagoWebJob] Gemini: ' . ($datos['__error'] ?? $datos['__parse_error'] ?? 'sin respuesta')); diff --git a/app/Services/GeminiVisionService.php b/app/Services/GeminiVisionService.php index 8c062de..a184d82 100755 --- a/app/Services/GeminiVisionService.php +++ b/app/Services/GeminiVisionService.php @@ -20,11 +20,6 @@ class GeminiVisionService public function extraerPago(string $base64, string $mimeType, ?int $usuarioId = null, string $canal = 'telegram'): ?array { - if (! $this->apiKey) { - Log::warning('[Gemini Vision] No hay API key configurada.'); - return null; - } - $contents = [[ 'parts' => [ ['text' => $this->buildPrompt()], @@ -32,6 +27,32 @@ class GeminiVisionService ], ]]; + return $this->ejecutar($contents, $usuarioId, $canal, 'gemini_vision'); + } + + /** + * Igual que extraerPago(), pero en vez de mandar la imagen manda texto plano + * (ya extraido por un servicio de OCR externo) para que Gemini solo lo + * estructure en JSON. Mas barato y no depende de la cuota de vision. + */ + public function extraerPagoDeTexto(string $texto, ?int $usuarioId = null, string $canal = 'web'): ?array + { + $contents = [[ + 'parts' => [ + ['text' => $this->buildPromptTexto($texto)], + ], + ]]; + + return $this->ejecutar($contents, $usuarioId, $canal, 'gemini_text'); + } + + private function ejecutar(array $contents, ?int $usuarioId, string $canal, string $servicio): ?array + { + if (! $this->apiKey) { + Log::warning('[Gemini Vision] No hay API key configurada.'); + return null; + } + $body = [ 'contents' => $contents, 'generationConfig' => [ @@ -66,7 +87,7 @@ class GeminiVisionService if (! $response) { Log::warning('[Gemini Vision] API failed: ' . $lastError); AiUsageLog::registrar([ - 'servicio' => 'gemini_vision', + 'servicio' => $servicio, 'canal' => $canal, 'usuario_id' => $usuarioId, 'tiempo_ms' => $tiempoMs, @@ -92,7 +113,7 @@ class GeminiVisionService $resultado = $this->parseRespuesta($text); AiUsageLog::registrar([ - 'servicio' => 'gemini_vision', + 'servicio' => $servicio, 'canal' => $canal, 'usuario_id' => $usuarioId, 'input_tokens' => $inputTokens, @@ -134,6 +155,35 @@ Si la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_e PROMPT; } + private function buildPromptTexto(string $texto): string + { + return <<url = rtrim(ChatConfig::get('ocr_url', ''), '/'); + $this->token = ChatConfig::get('ocr_token', ''); + } + + 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): ?string + { + if (! $this->url) { + return null; + } + + try { + $response = Http::withToken($this->token) + ->timeout(20) + ->post("{$this->url}/extract", [ + 'image_base64' => $base64, + 'mime_type' => $mimeType, + ]); + + if (! $response->successful()) { + Log::warning('[OCR] HTTP ' . $response->status() . ': ' . substr($response->body(), 0, 300)); + return null; + } + + $data = $response->json(); + + if (! ($data['success'] ?? false) || empty($data['text'])) { + Log::warning('[OCR] respuesta sin texto util: ' . substr($response->body(), 0, 300)); + return null; + } + + return $data['text']; + + } catch (\Throwable $e) { + Log::warning('[OCR] excepcion: ' . $e->getMessage()); + return null; + } + } +} diff --git a/docs/ocr-service-spec.md b/docs/ocr-service-spec.md new file mode 100644 index 0000000..563630e --- /dev/null +++ b/docs/ocr-service-spec.md @@ -0,0 +1,210 @@ +# Servicio OCR para lectura de comprobantes — Especificación + +Este documento describe el microservicio que hay que desplegar (en Coolify u otro +host con Docker) para que SirPremium deje de depender únicamente de Gemini Vision +para leer comprobantes de pago. El flujo pasa a ser: + +``` +imagen del comprobante → [ESTE SERVICIO OCR] → texto plano → Gemini (solo texto) → JSON estructurado +``` + +Gemini deja de "ver" la imagen; solo recibe texto y lo ordena en JSON. Esto evita el +punto único de falla que tenías hoy (si Gemini Vision falla, cae el 100% de las +lecturas) y reduce el consumo de cuota, porque los modelos de texto son más baratos +y menos limitados que los de visión. + +## 1. Qué tiene que exponer el servicio + +Un único endpoint HTTP: + +``` +POST /extract +``` + +### Request + +``` +Content-Type: application/json +Authorization: Bearer +``` + +```json +{ + "image_base64": "", + "mime_type": "image/jpeg" +} +``` + +- `mime_type` puede ser `image/jpeg`, `image/png`, `image/webp` o `image/heic` + (son los formatos que ya acepta la app hoy). +- El token se valida por header `Authorization: Bearer`. Si no coincide, responder + `401`. + +### Response — éxito (HTTP 200) + +```json +{ + "success": true, + "text": "Transferencia exitosa\nBre-B\nComprobante 24152758409697903...\nValor de la transferencia\n$1.000,00\nEnviaste LEDYS LEON QUINTERO\nA la llave @LEON8909\nEntidad BANCOLOMBIA\n..." +} +``` + +- `text`: todo el texto detectado en la imagen, tal cual lo lee el OCR (no hace + falta que estructure nada — de eso se encarga Gemini después). Si detecta texto + en varias líneas/bloques, mejor mantener saltos de línea, ayuda a Gemini a + interpretar el layout. + +### Response — error (HTTP 4xx/5xx) + +```json +{ + "success": false, + "error": "descripción corta del error" +} +``` + +Casos que deben devolver error controlado (no un 500 crudo): +- Imagen corrupta / no decodificable. +- Imagen sin texto detectable (`"error": "sin_texto_detectado"`). +- `mime_type` no soportado. + +### Timeouts + +Laravel llama con un timeout de **20 segundos**. El servicio debe responder bien +antes de eso — el OCR no debería tardar más de 2-5 segundos por imagen en CPU. + +## 2. Motor de OCR recomendado + +Para desplegar rápido en Coolify, la opción más simple es un contenedor Docker con: + +- **Tesseract OCR** (`tesseract-ocr` + paquete de idioma `spa`) envuelto en una API + pequeña (Python + FastAPI, o Node + Express). Es la opción de menor esfuerzo. +- Si la precisión con las capturas de pantalla de apps bancarias (fondos oscuros, + fuentes pequeñas) resulta insuficiente, migrar el mismo contrato de API a + **PaddleOCR** (más preciso, pero imagen Docker más pesada — necesita Python + + PaddlePaddle). + +El contrato HTTP (`POST /extract`) es el mismo sin importar cuál motor uses por +dentro — así que se puede empezar con Tesseract y cambiar el motor después sin +tocar el lado de Laravel. + +### Preprocesamiento recomendado antes de correr OCR + +Mejora mucho la precisión con capturas de pantalla de apps: +1. Convertir a escala de grises. +2. Aumentar contraste / binarizar (umbral adaptativo). +3. Escalar la imagen si es muy pequeña (mínimo ~1000px de ancho). + +## 3. Variables de entorno del servicio (sugeridas) + +``` +OCR_TOKEN= +OCR_LANG=spa # idioma para Tesseract +PORT=8000 +``` + +## 4. Ejemplo mínimo de referencia (Python + FastAPI + Tesseract) + +Esto es solo un punto de partida — no es la implementación final, es para no +arrancar de cero. + +**Dockerfile** +```dockerfile +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr tesseract-ocr-spa libgl1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . + +EXPOSE 8000 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +**requirements.txt** +``` +fastapi +uvicorn[standard] +pytesseract +pillow +python-multipart +``` + +**app.py** +```python +import base64, io, os +from fastapi import FastAPI, Header, HTTPException +from pydantic import BaseModel +from PIL import Image, ImageOps +import pytesseract + +app = FastAPI() +OCR_TOKEN = os.environ.get("OCR_TOKEN", "") +LANG = os.environ.get("OCR_LANG", "spa") + +class ExtractRequest(BaseModel): + image_base64: str + mime_type: str = "image/jpeg" + +def preprocesar(img: Image.Image) -> Image.Image: + img = img.convert("L") # escala de grises + img = ImageOps.autocontrast(img) + if img.width < 1000: + ratio = 1000 / img.width + img = img.resize((1000, int(img.height * ratio))) + return img + +@app.post("/extract") +def extract(req: ExtractRequest, authorization: str = Header(default="")): + if OCR_TOKEN and authorization != f"Bearer {OCR_TOKEN}": + raise HTTPException(status_code=401, detail="token invalido") + + try: + raw = base64.b64decode(req.image_base64) + img = Image.open(io.BytesIO(raw)) + except Exception: + return {"success": False, "error": "imagen invalida o corrupta"} + + img = preprocesar(img) + texto = pytesseract.image_to_string(img, lang=LANG).strip() + + if not texto: + return {"success": False, "error": "sin_texto_detectado"} + + return {"success": True, "text": texto} +``` + +## 5. Despliegue en Coolify + +1. Crear un nuevo recurso tipo "Application" apuntando a un repo/carpeta con el + `Dockerfile` de arriba (o el que termines usando). +2. Configurar la variable de entorno `OCR_TOKEN` con un valor secreto largo + (ej: generado con `openssl rand -hex 32`). +3. Publicar el servicio con dominio propio (ej: `https://ocr.tu-dominio.com`) o IP + interna si Coolify y el hosting de Laravel comparten red — cualquiera de las + dos formas sirve, Laravel solo necesita una URL alcanzable por HTTPS. +4. Probar con: + ```bash + curl -X POST https://ocr.tu-dominio.com/extract \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"image_base64":"'"$(base64 -w0 comprobante.jpg)"'","mime_type":"image/jpeg"}' + ``` + +## 6. Qué necesito de ti para conectar Laravel + +Una vez el servicio esté arriba, en el panel de SirPremium (Configuración del +Chat → sección "OCR de comprobantes", ya agregada) solo tienes que llenar: + +- **URL del servicio OCR** → ej: `https://ocr.tu-dominio.com` +- **Token** → el mismo valor que pusiste en `OCR_TOKEN` +- Activar el checkbox "Habilitar OCR antes de enviar a la IA" + +Laravel hace el resto: llama a `{URL}/extract`, si responde con texto se lo pasa a +Gemini en modo texto (`estructurarTexto`), y si el OCR falla o no está habilitado, +cae automáticamente al método anterior (Gemini leyendo la imagen directamente) — +no se rompe nada si el servicio OCR está caído o aún no lo has desplegado. diff --git a/resources/views/livewire/chat/show-configuracion-chat.blade.php b/resources/views/livewire/chat/show-configuracion-chat.blade.php index de82a85..33d0d50 100755 --- a/resources/views/livewire/chat/show-configuracion-chat.blade.php +++ b/resources/views/livewire/chat/show-configuracion-chat.blade.php @@ -150,6 +150,48 @@
+ {{-- OCR de comprobantes --}} +
+

OCR de comprobantes (antes de la IA)

+

Si está habilitado, la imagen del comprobante se manda primero a este servicio para extraer el texto, y luego ese texto (no la imagen) se le pasa a Gemini para estructurarlo. Si el servicio falla o está deshabilitado, se usa el método anterior (Gemini lee la imagen directamente).

+
+
+ + +
+
+ + +
+
+ + +
+
+ + @if($ocrResult) +

+ {{ $ocrResult }} +

+ @endif +
+
+
+ +
+ {{-- Validacion de pagos --}}

Validacion de pagos por foto + correo IMAP