
En resumen
- Un pipeline de OCR + LLM es una máquina de estados, no un script que lee y responde.
- La calidad del OCR se decide antes de Tesseract: escala de grises, umbral y resolución.
- Pide al modelo una salida estructurada y valídala con un esquema; si no cumple, no existe.
- Las reglas del negocio se aplican en código después del modelo, nunca dentro del prompt.
- Sin métricas por estado, una automatización que falla en silencio parece una que funciona.
La automatización de tareas con Python para OCR + LLM se suele explicar como dos llamadas: leer el texto de una imagen y preguntarle a un modelo qué dice. En producción, esas dos llamadas son la parte fácil.
Lo difícil es todo lo que pasa alrededor: preparar la imagen, obligar al modelo a responder en un formato predecible, decidir qué hacer cuando duda y saber, sin abrir el servidor, si el sistema sigue sano.
Este artículo recorre ese flujo de punta a punta a partir de un caso real. El código es ilustrativo: muestra el patrón con Python, no el código del cliente.
El caso: verificar comprobantes sin que nadie los revise
El punto de partida es el sistema de Panini Fan Zone FWC 2026. Gestionó un pool fijo de 80,000 folios y verificaba los comprobantes de compra que enviaban los participantes.
La verificación tenía dos pasos. Tesseract extraía el texto de la imagen y un modelo LLaMA 3.3 70B, servido por Groq, lo validaba contra las reglas de la promoción.
Corría solo, con un cron cada 2 minutos, sobre un VPS con AlmaLinux. No había una persona revisando comprobante por comprobante.
Esa última frase es la que define el diseño. Si nadie revisa, el sistema tiene que saber distinguir entre «aprobado», «rechazado» y «no estoy seguro». Y tiene que dejar rastro de cada decisión.
Ya escribí sobre el rendimiento de ese tipo de scripts en optimización de Python para 80,000 folios: estructuras de datos, memoria y concurrencia. Aquí el tema es otro: cómo se arma el flujo para que la automatización sea confiable.
Automatización de tareas con Python: piensa en estados
El primer cambio de mentalidad es dejar de ver el pipeline como un script lineal. Es una máquina de estados. Cada comprobante avanza por etapas, y cada etapa puede terminar bien, mal o en duda.
Un modelo mínimo de estados se ve así:
pendiente: el comprobante llegó y espera turno.ocr_listo: ya hay texto extraído y una medida de su calidad.aprobadoorechazado: el modelo y las reglas coinciden con confianza suficiente.revision: algo no cuadra y el sistema prefiere no decidir.error: una falla técnica, con el número de intentos y el último mensaje.
Guardar el estado en la base de datos, y no en memoria, tiene tres ventajas. Si el proceso se cae, la siguiente corrida retoma donde se quedó. Puedes reprocesar un estado completo cuando cambies una regla. Y cada número de tu tablero sale de un GROUP BY.
El estado revision merece una aclaración honesta. En el caso real el pipeline funcionó sin revisión manual. Aun así, cuando diseño uno nuevo, dejo esa salida desde el principio: es más barato tener la puerta y no usarla que agregarla con el sistema en producción.
Integración con OCR: la calidad se decide antes de Tesseract
Tesseract funciona mucho mejor con imágenes limpias: texto oscuro sobre fondo claro, con resolución suficiente. Un comprobante fotografiado con un celular casi nunca llega así. Trae sombras, inclinación, poca luz y compresión.
Por eso el preprocesado es el paso con mejor retorno de todo el pipeline. Un modelo de lenguaje no puede corregir números que el OCR nunca leyó.
from PIL import Image, ImageOps
import pytesseract
def extraer_texto(ruta: str) -> tuple[str, float]:
with Image.open(ruta) as img:
img = ImageOps.exif_transpose(img) # las fotos de celular llegan rotadas
gris = ImageOps.grayscale(img) # el color solo agrega ruido al OCR
if gris.width < 1500: # texto pequeño = caracteres inventados
gris = gris.resize((gris.width * 2, gris.height * 2), Image.LANCZOS)
bn = gris.point(lambda p: 255 if p > 160 else 0) # umbral: texto contra fondo
datos = pytesseract.image_to_data(bn, lang="spa", config="--psm 6", output_type=pytesseract.Output.DICT)
confs = [float(c) for c in datos["conf"] if float(c) >= 0]
return " ".join(w for w in datos["text"] if w.strip()), (sum(confs) / len(confs) if confs else 0.0)
Hay tres decisiones en ese bloque que conviene entender.
La rotación va primero. Las fotos de celular guardan la orientación en los metadatos EXIF. Si la ignoras, Tesseract lee una imagen de lado y devuelve basura con mucha seguridad.
El umbral fijo es un punto de partida. Con iluminación muy variable, un umbral adaptativo (por ejemplo, con OpenCV) da mejores resultados. La forma de elegir es medir contra muestras reales, no adivinar.
La función regresa la confianza, no solo el texto. image_to_data entrega una confianza por palabra. Ese promedio es la primera señal para decidir si vale la pena llamar al modelo.
Si la confianza es muy baja o el texto sale casi vacío, el comprobante no pasa al LLM. Se rechaza o se manda a revisión. Pagar una llamada al modelo para que lea basura solo agrega costo y ruido.
Integración con LLM: salida estructurada o nada
El error más común al integrar un LLM es pedirle una respuesta en lenguaje natural y luego buscar palabras clave en ella. Funciona en la demo. En producción, el modelo cambia una coma o agrega una explicación, y el parser se rompe.
La alternativa es tratar al modelo como una API con contrato. Le pides JSON con campos explícitos, y validas ese JSON con un esquema antes de creer una sola palabra.
from pydantic import BaseModel, Field, ValidationError
class Lectura(BaseModel):
es_comprobante: bool
tienda: str | None = None
fecha: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$")
total: float | None = Field(default=None, ge=0)
confianza: float = Field(ge=0, le=1) # el modelo dice qué tan seguro está
def interpretar(respuesta_cruda: str) -> Lectura | None:
try:
return Lectura.model_validate_json(respuesta_cruda)
except ValidationError:
return None # una respuesta que no cumple el contrato no existe
El esquema hace tres trabajos a la vez. Documenta qué esperas del modelo, rechaza respuestas mal formadas y convierte cada campo al tipo correcto.
Algunas prácticas que hacen el contrato más sólido:
- Pon el esquema en el prompt. Describe cada campo, su formato y qué hacer si no aparece en el texto: devolver
null, nunca inventar. - Usa la salida estructurada del proveedor si existe. El modo JSON reduce mucho las respuestas mal formadas. La validación sigue siendo obligatoria.
- Temperatura baja. Para extraer datos quieres la respuesta más probable, no creatividad.
- Un reintento, no un ciclo. Si la respuesta no valida, reintenta una vez. Si vuelve a fallar, el comprobante va a
erroro arevision.
Reglas deterministas después del modelo
El modelo lee. No decide. Esa separación es la que vuelve auditable el sistema.
Las reglas de la promoción —qué tiendas participan, qué fechas valen, cuál es el monto mínimo— viven en código. Se aplican sobre la lectura ya validada, y cada rechazo tiene un motivo concreto que se guarda.
TIENDAS = {"tienda-a", "tienda-b"} # catálogo real, en config, no en el prompt
VIGENCIA = ("2026-05-01", "2026-07-31")
def decidir(lec: Lectura, conf_ocr: float) -> tuple[str, str]:
if not lec.es_comprobante:
return "rechazado", "no parece un comprobante"
if lec.confianza < 0.7 or conf_ocr < 60:
return "revision", "lectura dudosa" # dudar es una salida válida
if (lec.tienda or "").lower() not in TIENDAS:
return "rechazado", "tienda fuera de la promoción"
if not lec.fecha or not (VIGENCIA[0] <= lec.fecha <= VIGENCIA[1]):
return "rechazado", "fecha fuera de vigencia"
return "aprobado", "cumple todas las reglas"
Los valores de ejemplo son inventados; los tuyos salen de las bases de la promoción.
Lo importante es el orden. Primero lo que invalida todo, después la duda, y al final las reglas del negocio. Y cada salida regresa un motivo legible: cuando un participante pregunta por qué lo rechazaron, la respuesta ya está en la base de datos.
Hay una ventaja más. Si mañana cambia una regla, no necesitas volver a llamar al modelo. Basta con volver a correr decidir sobre las lecturas guardadas.
Programación: cron o timer de systemd
En el caso real, el pipeline corría con cron cada 2 minutos. Cron es simple y está en todos lados. Su debilidad es que no sabe nada de la corrida anterior ni guarda registros por sí solo.
En un servidor con systemd, un timer resuelve eso sin código extra. Si la corrida anterior sigue viva, no arranca otra. La salida va a journald. Y puedes limitar memoria y CPU para que el pipeline nunca compita con el sitio.
# /etc/systemd/system/verificador.service
[Service]
Type=oneshot
User=verificador
ExecStart=/opt/verificador/venv/bin/python /opt/verificador/correr.py
MemoryMax=1G # si se desborda, muere el pipeline, no el sitio web
CPUQuota=50%
# /etc/systemd/system/verificador.timer
[Timer]
OnUnitInactiveSec=2min # cuenta desde que terminó: nunca se enciman
[Install]
WantedBy=timers.target
Para activarlo en producción hay que hacerlo con cuidado:
# ⚠️ Producción: respalda la tabla de comprobantes antes del primer arranque.
mysqldump --single-transaction promo comprobantes > "comprobantes-$(date +%F).sql"
sudo systemctl daemon-reload
sudo systemctl enable --now verificador.timer
# Comprueba cuándo corre y qué dijo la última ejecución.
systemctl list-timers verificador.timer
journalctl -u verificador.service --since "15 min ago" --no-pager
OnUnitInactiveSec cuenta el intervalo desde que terminó la corrida anterior, no desde que empezó. Si una ejecución tarda más de lo normal, la siguiente simplemente espera.
Mejores prácticas para procesamiento masivo
Con el flujo armado, lo que queda es poder confiar en él semanas después, cuando nadie lo está mirando.
- Guarda todo lo intermedio. Texto del OCR, confianza, respuesta cruda del modelo, lectura validada y decisión. Sin eso, un error no se puede reproducir.
- Versiona el prompt y las reglas. Cada decisión registra con qué versión se tomó. Si cambias el prompt, sabes qué comprobantes se evaluaron con cuál.
- Arma un conjunto de muestras etiquetadas. Unas decenas de comprobantes reales, con la respuesta correcta. Cada cambio de preprocesado, prompt o modelo se mide contra ellas antes de desplegar.
- Mide por estado, no por línea de log. Cuántos pendientes hay, cuántos se aprobaron, rechazaron o quedaron en duda por hora, y cuánto tarda cada etapa.
- Alerta sobre tendencias. Una cola de pendientes que crece corrida tras corrida, o una tasa de
errorque sube, avisan antes de que alguien se queje. - Calcula el hash de cada imagen. Un comprobante reenviado no debe costar otra vuelta de OCR y modelo, ni contar dos veces.
La observabilidad no necesita una plataforma cara. Una consulta que agrupe por estado y por hora, revisada por un script que avise si algo se sale de rango, cubre la mayoría de los casos.
Lo que NO debes hacer
- Meter las reglas del negocio en el prompt. «Rechaza si la fecha está fuera de vigencia» parece cómodo, pero el modelo puede equivocarse con total seguridad y no deja un motivo auditable. El modelo extrae; el código decide.
- Mandar imágenes crudas al OCR y culpar al modelo. Si el texto extraído es malo, ningún prompt lo arregla. Invierte primero en el preprocesado y mide su efecto.
- Parsear texto libre con expresiones regulares. Buscar «aprobado» en una respuesta en lenguaje natural se rompe a la primera variación. Exige JSON y valídalo con un esquema.
El siguiente paso
Automatizar con Python un pipeline de OCR + LLM no es encadenar dos llamadas. Es diseñar estados, preparar la entrada, exigir un contrato al modelo, decidir en código y medir todo lo que pasa.
Si tu equipo necesita automatizar la verificación de documentos, quitar trabajo manual de un proceso con imágenes o volver confiable una integración con LLM que hoy falla en silencio, en Conqr Digital Agency diseñamos arquitecturas que escalan. Revisemos tu caso.
El sistema que inspiró este artículo está documentado en el caso de Panini Fan Zone. Y si lo tuyo es llevar IA a procesos reales, revisa el servicio de automatización con IA.
Preguntas frecuentes
- ¿Qué preprocesado necesita una imagen antes de pasar por Tesseract?
- Como mínimo: convertirla a escala de grises, subir la resolución si viene pequeña y aplicar un umbral para separar el texto del fondo. Con fotos tomadas con celular también ayuda corregir la rotación. Cada paso se mide contra un conjunto de muestras reales.
- ¿Cómo obligo a un LLM a responder siempre en el mismo formato?
- Pide JSON con campos explícitos, usa el modo de salida estructurada si tu proveedor lo ofrece y valida la respuesta con un esquema, por ejemplo un modelo de pydantic. Si la validación falla, la respuesta se trata como error y se reintenta o se manda a revisión.
- ¿Es mejor cron o un timer de systemd para programar el pipeline?
- Ambos sirven. Cron es universal y simple. Un timer de systemd agrega registro en journald, límites de recursos y evita corridas encimadas sin código extra. En un servidor con systemd, el timer da más control con poco esfuerzo.



