340 lines
14 KiB
Markdown
340 lines
14 KiB
Markdown
# 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](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 |
|