Files
IPSExtractor/services/services-count-facturas-medicas-serving/README.md
T

340 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |