Services Count — API de Facturas Médicas
Microservicio de inferencia en Go que cuenta servicios facturados en facturas médicas PDF. El entrenamiento del modelo vive en el repositorio hermano services-count-facturas-medicas-ml; este repositorio despliega la API HTTP y los workers de procesamiento.
Características
- Conteo síncrono (1–50 PDFs por petición) y asíncrono (hasta 50 000 PDFs por job).
- Pipeline de inferencia: OCR → clustering DBSCAN → Random Forest vía ONNX.
- Escalado horizontal de workers con Docker Compose.
- Especificación OpenAPI y descriptor de herramientas para agentes AI.
Arquitectura
El sistema se compone de una API HTTP, workers consumidores y tres servicios de infraestructura (Redis, NATS JetStream y MinIO), definidos en docker-compose.yml.
flowchart TB
Client[Cliente_o_Agente]
API[API_Go_8080]
Worker[Worker_Go_xN]
Redis[(Redis)]
NATS[NATS_JetStream]
MinIO[(MinIO)]
Pipeline[OCR_Tesseract_ONNX]
Client -->|sync_POST_v1_count| API
Client -->|async_POST_v1_jobs| API
API --> Pipeline
API --> Redis
API --> MinIO
API -->|publish| NATS
NATS -->|consume| Worker
Worker --> MinIO
Worker --> Pipeline
Worker --> Redis
Pipeline de inferencia
Por cada PDF se procesa la primera página siguiendo este flujo (internal/pipeline):
flowchart LR
PDF[PDF_primera_pagina] --> Render[Render_MuPDF]
Render --> OCR[Tesseract_spa]
OCR --> Cluster[DBSCAN_filas]
Cluster --> Features[8_features]
Features --> Infer[ONNX_RandomForest]
Infer --> Result[service_count_y_services]
- Render — La primera página se rasteriza con MuPDF (go-fitz) a 300 DPI.
- OCR — Tesseract extrae cajas de texto en español.
- Clustering — DBSCAN agrupa palabras en filas candidatas.
- Features — Se calculan 8 atributos por fila (códigos, moneda, posición, etc.).
- Inferencia — Un Random Forest exportado a ONNX clasifica cada fila como servicio o no.
Componentes
| Componente | Binario / servicio | Paquete | Responsabilidad |
|---|---|---|---|
| API HTTP | cmd/api |
internal/api |
Endpoints REST, gestión de jobs, health probes |
| Worker | cmd/worker |
internal/worker |
Consume NATS, descarga PDFs de MinIO, persiste resultados |
| Pipeline | — | internal/pipeline |
Orquestación OCR → clustering → features → inferencia |
| Modelo ONNX | — | internal/model |
Carga model.onnx e inference.json |
| OCR | — | internal/ocr |
Render PDF (go-fitz) + Tesseract (gosseract) |
| Estado de jobs | Redis 7 | internal/store |
Metadatos, resultados y progreso (TTL 72 h) |
| Cola | NATS JetStream | internal/queue |
Stream PDF_JOBS, subject pdf.process.* |
| Object storage | MinIO | internal/storage |
Almacenamiento de PDFs en jobs asíncronos |
Estructura del repositorio
├── cmd/
│ ├── api/ # Punto de entrada HTTP
│ └── worker/ # Punto de entrada worker
├── internal/
│ ├── api/ # Servidor HTTP (chi)
│ ├── config/ # Variables de entorno
│ ├── domain/ # Tipos de dominio
│ ├── model/ # Inferencia ONNX
│ ├── ocr/ # Extracción OCR
│ ├── pipeline/ # Procesamiento por PDF
│ ├── queue/ # NATS JetStream
│ ├── storage/ # Cliente MinIO
│ ├── store/ # Estado en Redis
│ └── worker/ # Runner del worker
├── api/openapi.yaml # Especificación OpenAPI 3.1
├── mcp/tool.json # Descriptor de herramientas para agentes
├── web/ # Cliente demo (embebido en la API en /demo/)
├── scripts/ # Export ONNX, paridad con Python
└── models/latest/ # model.onnx + inference.json (montado en Docker)
Modos de operación
| Modo | Endpoint principal | Límite | Cuándo usarlo |
|---|---|---|---|
| Síncrono | POST /v1/count |
50 PDFs | Pruebas, integraciones en tiempo real, lotes pequeños |
| Asíncrono | POST /v1/jobs + poll |
50 000 PDFs/job | MinIO + NATS + workers (path producción) |
Flujo asíncrono (producción):
- Crear job (
POST /v1/jobs) — shell vacío, prefijo MinIO o lista de claves. - Subir PDFs por chunks (
POST /v1/jobs/{jobID}/upload, máx. 500 por chunk) → MinIO Put → NATS Publish. - Workers consumen la cola, descargan de MinIO, OCR, guardan en Redis.
- Consultar progreso (
GET /v1/jobs/{jobID}?wait_ms=30000para long-polling). - Obtener resultados paginados (
GET /v1/jobs/{jobID}/results?cursor=&limit=).
API REST
Especificación completa: GET /openapi.json o api/openapi.yaml.
| Método | Ruta | Descripción |
|---|---|---|
GET |
/health |
Liveness probe |
GET |
/ready |
Readiness probe |
POST |
/v1/count |
Conteo síncrono (multipart files) |
POST |
/v1/jobs |
Crear job asíncrono |
POST |
/v1/jobs/{jobID}/upload |
Subir chunk de PDFs |
GET |
/v1/jobs/{jobID} |
Estado y progreso del job |
GET |
/v1/jobs/{jobID}/results |
Resultados paginados |
DELETE |
/v1/jobs/{jobID} |
Cancelar job |
Parámetros relevantes:
include_rejected(query) — Si estrue, incluye todas las filas OCR con el campois_service.- Jobs asíncronos devuelven
202 Accepted; la cola rechaza con429si superaQUEUE_MAX_PENDING.
Respuesta por PDF:
{
"filename": "factura.pdf",
"service_count": 2,
"status": "ok",
"services": [
{ "text": "890201 Consulta ...", "probability": 0.94 }
]
}
Stack tecnológico
| Área | Tecnología |
|---|---|
| Lenguaje | Go 1.23 |
| HTTP router | go-chi/chi v5 |
| OCR | go-fitz (MuPDF) + gosseract (Tesseract) |
| ML | ONNX Runtime + Random Forest exportado |
| Cola | NATS JetStream |
| Estado | Redis 7 |
| Storage | MinIO |
| Contenedores | Docker multi-stage (Dockerfile) |
Inicio rápido
1. Exportar el modelo ONNX
Desde el repositorio de entrenamiento, generar los artefactos de inferencia:
pip install -r requirements-export.txt
python scripts/export_onnx.py \
--joblib ../services-count/models/latest/model.joblib \
--metadata ../services-count/models/v1.0.0/model_metadata.json
Esto produce models/latest/model.onnx e inference.json.
2. Levantar con Docker Compose
docker compose up --build
| Servicio | Puerto / acceso |
|---|---|
| API | http://localhost:8080 |
| MinIO Console | http://localhost:9002 |
| Workers | 1 réplica inicial; autoscaler sube hasta 8 según cola |
| Autoscaler | Escala worker vía Docker socket según PendingCount en Redis |
3. Probar
# Síncrono
curl -F "files=@factura.pdf" http://localhost:8080/v1/count
# Asíncrono (PDFs ya en MinIO bajo un prefijo)
curl -X POST http://localhost:8080/v1/jobs \
-H "Content-Type: application/json" \
-d '{"prefix":"jobs/batch-1/"}'
Cliente web de ejemplo
Con la API en ejecución, abre http://localhost:8080/demo/ para probar la integración desde el navegador. Los archivos estáticos se sirven desde la propia API (mismo origen, sin CORS).
| Pestaña | Demuestra |
|---|---|
| Síncrono | POST /v1/count con subida multipart de hasta 50 PDFs |
| Asíncrono | POST /v1/jobs → upload → polling de estado → resultados paginados |
El modo asíncrono requiere que workers, Redis, NATS y MinIO estén levantados (igual que con docker compose up). Código fuente en web/.
Desarrollo local
Requisitos adicionales a Go 1.23+:
- Tesseract con datos de idioma
spa - ONNX Runtime (la variable
ONNXRUNTIME_SHARED_LIBRARYapunta a la librería compartida) - Redis, NATS y MinIO en ejecución
go build -o api ./cmd/api
go build -o worker ./cmd/worker
Configurar las variables de entorno para apuntar a los servicios locales (ver tabla siguiente).
Configuración
Variables definidas en internal/config/config.go:
| Variable | Default | Descripción |
|---|---|---|
PORT |
8080 |
Puerto de la API |
REDIS_URL |
redis://redis:6379/0 |
Conexión Redis |
NATS_URL |
nats://nats:4222 |
Conexión NATS |
NATS_STREAM |
PDF_JOBS |
Nombre del stream JetStream |
NATS_SUBJECT_PREFIX |
pdf.process |
Prefijo de subjects |
MINIO_ENDPOINT |
minio:9000 |
Endpoint MinIO |
MINIO_ACCESS_KEY |
minioadmin |
Access key |
MINIO_SECRET_KEY |
minioadmin |
Secret key |
MINIO_BUCKET |
pdf-count |
Bucket de PDFs |
MINIO_USE_SSL |
false |
Usar SSL con MinIO |
MODEL_PATH |
/app/models/model.onnx |
Ruta al modelo ONNX |
INFERENCE_CONFIG |
/app/models/inference.json |
Metadata de inferencia |
OCR_DPI |
300 |
DPI de renderizado PDF (150 reduce latencia ~2–4×) |
OCR_LANG |
spa |
Idioma Tesseract |
DBSCAN_EPS |
10 |
Parámetro epsilon de DBSCAN |
DBSCAN_MIN_SAMPLES |
1 |
Mínimo de muestras DBSCAN |
SYNC_MAX_FILES |
50 |
Máximo de PDFs en modo síncrono |
JOB_MAX_FILES |
50000 |
Máximo de PDFs por job |
QUEUE_MAX_PENDING |
100000 |
Límite de mensajes pendientes en cola |
WORKER_CONCURRENCY |
4 |
Goroutines paralelas por worker |
MAX_PDF_SIZE_MB |
20 |
Tamaño máximo por PDF |
PDF_PROCESS_TIMEOUT_SEC |
120 |
Timeout de procesamiento por PDF |
JOB_RESULT_TTL_HOURS |
72 |
TTL de resultados en Redis |
OCR_POOL_SIZE |
4 (API) / WORKER_CONCURRENCY (worker) |
Clientes Tesseract reutilizables en pool |
SYNC_PROCESS_CONCURRENCY |
4 (o OCR_POOL_SIZE) |
PDFs procesados en paralelo en /v1/count |
JOB_INLINE_PROCESSING |
false |
true procesa uploads en la API sin MinIO (solo dev local) |
LOG_PIPELINE_TIMING |
false |
Logs de duración por etapa del pipeline |
NATS_FETCH_MAX_WAIT_MS |
300 |
Tiempo máximo de espera del worker al leer NATS |
Rendimiento
El cuello de botella de /v1/count es el OCR con Tesseract, no la inferencia ONNX. Los clientes Tesseract se crean una vez al arranque y se reutilizan desde un pool (internal/ocr/pool.go).
| Etapa | Tiempo típico (1 PDF) |
|---|---|
| Render PDF (MuPDF) | ~100–300 ms |
| OCR Tesseract | ~3–8 s |
| Clustering + ONNX | ~10–50 ms |
Latencia esperada (síncrono): ~3–10 s por PDF. Varios PDFs se procesan en paralelo (SYNC_PROCESS_CONCURRENCY, default 4). Ejemplo: 20 PDFs ≈ ceil(20/4) × 2.85s ≈ 15s.
Latencia esperada (asíncrono por upload): path producción MinIO → NATS → worker. Con WORKER_CONCURRENCY=4 y autoscaler activo, 16 PDFs ≈ ceil(16/4) × 2.85s más overhead Put/Get (~15–25 s). 100 PDFs escala hasta 8 workers (32 slots OCR).
Asíncrono por prefix MinIO (POST /v1/jobs {"prefix":"..."}): mismos workers; los PDFs ya están en el bucket.
Ajustes para reducir latencia:
OCR_DPI=150— reduce tiempo de render y OCR (~2–4× más rápido) con posible ligera pérdida de precisión. Validar conscripts/compare_parity.pyantes de usar en producción.OCR_POOL_SIZE=4yWORKER_CONCURRENCY=4— paralelismo por réplica worker.- Upload pipeline publica en NATS tras cada Put (no al final del lote).
LOG_PIPELINE_TIMING=true— logs por etapa:ocr_render,ocr_tesseract,cluster,infer,total.NATS_FETCH_MAX_WAIT_MS=300— el worker recoge mensajes con menor espera.GET /v1/jobs/{id}?wait_ms=30000— long-polling en lugar de poll cada 500 ms.
Escalado
Autoscaler (docker compose)
El servicio autoscaler lee PendingCount en Redis cada 5 s y ajusta réplicas worker:
desired = clamp(ceil(pending / WORKER_SLOTS), MIN_WORKERS, MAX_WORKERS)
| Variable | Default | Descripción |
|---|---|---|
MIN_WORKERS |
1 |
Réplicas mínimas |
MAX_WORKERS |
8 |
Réplicas máximas |
WORKER_SLOTS |
4 |
PDFs en paralelo por réplica (WORKER_CONCURRENCY) |
SCALE_DOWN_IDLE_SEC |
60 |
Segundos sin cola antes de bajar 1 réplica |
Requiere montar /var/run/docker.sock (solo entorno local). En Kubernetes usar HPA sobre lag de NATS o métricas custom.
Escalado manual
docker compose up --scale worker=8 -d
Cada worker procesa hasta WORKER_CONCURRENCY PDFs en paralelo. NATS JetStream distribuye los mensajes entre réplicas con política work-queue.
Integración con agentes (MCP)
mcp/tool.json es un descriptor declarativo de herramientas para agentes AI (Cursor, etc.). No incluye un servidor MCP embebido.
| Herramienta | Modo | Flujo |
|---|---|---|
count_pdf_services |
Síncrono | POST /v1/count con multipart |
count_pdf_services_batch |
Asíncrono | Crear job → poll estado → resultados paginados |
Paridad con Python
Scripts para validar que la inferencia Go reproduce los resultados del pipeline Python original:
set SERVICES_COUNT_ROOT=../services-count
python scripts/parity_baseline.py
python scripts/compare_parity.py
Troubleshooting
| Síntoma | Causa probable | Acción |
|---|---|---|
| API no arranca | Modelo ONNX ausente | Ejecutar scripts/export_onnx.py y verificar models/latest/ |
/ready falla |
Redis o NATS no disponibles | Comprobar healthchecks en docker compose ps |
Jobs en pending sin avance |
Workers no consumen cola | Verificar logs del worker y conectividad a NATS/MinIO |
Job atascado en running con N/N procesados |
Race en metadata del job (versiones antiguas) | Reconstruir con docker compose up --build; GetJob ahora finaliza jobs completados |
429 al crear job |
Cola saturada | Escalar workers o esperar a que baje QUEUE_MAX_PENDING |
| Resultados vacíos o erróneos | OCR o modelo desactualizado | Revisar OCR_LANG=spa, reexportar ONNX desde el repo ML |