Antes, si Gemini Vision fallaba (cuota/API), el 100% de los comprobantes del chat web quedaban sin poder leerse. Ahora el job intenta primero un servicio OCR externo configurable (imagen -> texto) y le pasa el texto a Gemini para que solo lo estructure en JSON, más barato y sin la cuota de visión. Si el OCR no está habilitado o falla, cae al método anterior (Gemini leyendo la imagen directamente) sin romper nada. Agrega configuración en el panel (URL/token/habilitado + probar conexión) y la spec del servicio OCR a desplegar en docs/ocr-service-spec.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
211 lines
6.7 KiB
Markdown
211 lines
6.7 KiB
Markdown
# 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 <OCR_TOKEN>
|
|
```
|
|
|
|
```json
|
|
{
|
|
"image_base64": "<imagen en base64, SIN el prefijo data: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=<token secreto que Laravel debe enviar en el Authorization header>
|
|
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 <OCR_TOKEN>" \
|
|
-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.
|