# Services Count — API de Facturas Médicas ![Go](https://img.shields.io/badge/Go-1.23-blue) 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](https://github.com/builker-col/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`](docker-compose.yml). ```mermaid 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`): ```mermaid 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] ``` 1. **Render** — La primera página se rasteriza con MuPDF (go-fitz) a 300 DPI. 2. **OCR** — Tesseract extrae cajas de texto en español. 3. **Clustering** — DBSCAN agrupa palabras en filas candidatas. 4. **Features** — Se calculan 8 atributos por fila (códigos, moneda, posición, etc.). 5. **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):** 1. Crear job (`POST /v1/jobs`) — shell vacío, prefijo MinIO o lista de claves. 2. Subir PDFs por chunks (`POST /v1/jobs/{jobID}/upload`, máx. 500 por chunk) → **MinIO Put** → **NATS Publish**. 3. Workers consumen la cola, descargan de MinIO, OCR, guardan en Redis. 4. Consultar progreso (`GET /v1/jobs/{jobID}?wait_ms=30000` para long-polling). 5. Obtener resultados paginados (`GET /v1/jobs/{jobID}/results?cursor=&limit=`). ## API REST Especificación completa: `GET /openapi.json` o [`api/openapi.yaml`](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 es `true`, incluye todas las filas OCR con el campo `is_service`. - Jobs asíncronos devuelven `202 Accepted`; la cola rechaza con `429` si supera `QUEUE_MAX_PENDING`. **Respuesta por PDF:** ```json { "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](https://github.com/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`](Dockerfile)) | ## Inicio rápido ### 1. Exportar el modelo ONNX Desde el repositorio de entrenamiento, generar los artefactos de inferencia: ```bash 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 ```bash 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 ```bash # 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/](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/`](web/). ## Desarrollo local Requisitos adicionales a Go 1.23+: - Tesseract con datos de idioma `spa` - ONNX Runtime (la variable `ONNXRUNTIME_SHARED_LIBRARY` apunta a la librería compartida) - Redis, NATS y MinIO en ejecución ```bash 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`](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 con `scripts/compare_parity.py` antes de usar en producción. - `OCR_POOL_SIZE=4` y `WORKER_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 ```bash 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`](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: ```bash 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 |