feat: leer comprobantes via OCR externo + Gemini solo-texto
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7f4053ea62
commit
7afd22d581
@@ -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 <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.
|
||||
Reference in New Issue
Block a user