Spaces:
Running
Running
Merge pull request #1 from josesalazar2025/ci/puerta-de-codigo
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .github/workflows/ci.yml +59 -0
- .github/workflows/evals.yml +18 -5
- CLAUDE.md +38 -1
- Makefile +8 -1
- assets/lib/pdfjs/pdf.min.js +0 -0
- assets/lib/pdfjs/pdf.min.mjs +0 -0
- assets/lib/pdfjs/pdf.worker.min.js +0 -0
- assets/lib/pdfjs/pdf.worker.min.mjs +0 -0
- backend/.env.example +19 -0
- backend/app/ai/coherencia.py +218 -3
- backend/app/ai/lexico.py +192 -0
- backend/app/ai/prompt.py +54 -1
- backend/app/ai/service.py +103 -3
- backend/app/config.py +69 -2
- backend/app/routers/auth.py +8 -0
- backend/app/routers/lab.py +10 -1
- backend/app/schemas.py +7 -0
- backend/tests/conftest.py +40 -1
- backend/tests/test_api.py +3 -3
- backend/tests/test_coherencia.py +160 -0
- backend/tests/test_lab_ingesta.py +11 -2
- backend/tests/test_modelos_locales.py +11 -0
- backend/tests/test_prompt_y_rag.py +29 -0
- backend/tests/test_registro_cerrado.py +79 -0
- backend/tests/test_reintentos.py +92 -0
- backend/tests/test_schemas.py +14 -0
- css/styles.css +88 -0
- evals/README.md +13 -0
- evals/dataset/README.md +31 -8
- evals/dataset/casos.jsonl +26 -0
- evals/run_evals.py +221 -115
- evals/run_ragas.py +20 -13
- evals/run_retrieval_eval.py +38 -15
- evals/tests/conftest.py +31 -0
- evals/tests/test_generacion_incremental.py +111 -0
- evals/tests/test_juez.py +296 -0
- evals/tests/test_juicio_con_huecos.py +86 -0
- evals/tests/test_metricas_recuperacion.py +206 -0
- evals/tests/test_puntuacion_evals.py +357 -0
- evals/tests/test_ragas_muestras.py +142 -0
- evals/tests/test_revision_y_dataset.py +178 -0
- frontend/package-lock.json +291 -0
- frontend/package.json +2 -0
- frontend/src/dom.ts +7 -0
- frontend/src/form-inject.ts +4 -0
- frontend/src/ia.ts +5 -2
- frontend/src/lab-import.ts +9 -1
- frontend/src/main.ts +10 -1
- frontend/src/panel-vacio.ts +55 -0
- frontend/src/pdf-parser.ts +231 -47
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: ci
|
| 2 |
+
|
| 3 |
+
# Puerta de calidad del CÓDIGO (rápida). La puerta clínica —motor determinista y evals— vive
|
| 4 |
+
# en `evals.yml`, que sí filtra por rutas porque su coste es otro.
|
| 5 |
+
#
|
| 6 |
+
# Este workflow NO lleva filtro `paths` a propósito: antes, un PR que tocara sólo
|
| 7 |
+
# `backend/app/security/` o `backend/app/routers/` no disparaba NINGÚN workflow, así que toda
|
| 8 |
+
# la superficie de autenticación, sesiones, CSRF y ingesta de laboratorio se fusionaba sin que
|
| 9 |
+
# nadie ejecutara `backend/tests/`. Cualquier PR corre esto.
|
| 10 |
+
|
| 11 |
+
on:
|
| 12 |
+
pull_request:
|
| 13 |
+
push:
|
| 14 |
+
branches: [main]
|
| 15 |
+
|
| 16 |
+
jobs:
|
| 17 |
+
backend:
|
| 18 |
+
runs-on: ubuntu-latest
|
| 19 |
+
steps:
|
| 20 |
+
- uses: actions/checkout@v4
|
| 21 |
+
- uses: astral-sh/setup-uv@v5
|
| 22 |
+
with:
|
| 23 |
+
enable-cache: true
|
| 24 |
+
- name: Sincronizar backend
|
| 25 |
+
working-directory: backend
|
| 26 |
+
# Sin `--group rag`: los grupos pesados son opt-in (ver pyproject) y las dos suites que
|
| 27 |
+
# dependen de LanceDB/sentence-transformers ya se auto-omiten
|
| 28 |
+
# (`test_retriever_integracion.py`, `test_alcance_corpus.py`). Instalarlos aquí añadiría
|
| 29 |
+
# GBs y minutos a cambio de una prueba de integración sintética. Si algún día la
|
| 30 |
+
# recuperación regresa sin que nadie se entere, ese es el momento de añadirlos.
|
| 31 |
+
run: uv sync
|
| 32 |
+
- name: Ruff (backend + scripts)
|
| 33 |
+
working-directory: backend
|
| 34 |
+
# Mismo alcance que `make lint`: evals/ y bridge/ quedan fuera a propósito, aún no
|
| 35 |
+
# están saneados bajo estas reglas.
|
| 36 |
+
run: uv run ruff check . ../scripts
|
| 37 |
+
- name: Pruebas del backend
|
| 38 |
+
working-directory: backend
|
| 39 |
+
run: uv run pytest -q
|
| 40 |
+
|
| 41 |
+
frontend:
|
| 42 |
+
runs-on: ubuntu-latest
|
| 43 |
+
steps:
|
| 44 |
+
- uses: actions/checkout@v4
|
| 45 |
+
- uses: actions/setup-node@v4
|
| 46 |
+
with:
|
| 47 |
+
node-version: "22"
|
| 48 |
+
cache: npm
|
| 49 |
+
cache-dependency-path: frontend/package-lock.json
|
| 50 |
+
- name: Instalar frontend
|
| 51 |
+
working-directory: frontend
|
| 52 |
+
run: npm ci
|
| 53 |
+
- name: Tipos (tsc --noEmit)
|
| 54 |
+
working-directory: frontend
|
| 55 |
+
# `npm run build` ya lo corría, pero nada ejecutaba el build en CI.
|
| 56 |
+
run: npm run typecheck
|
| 57 |
+
- name: ESLint
|
| 58 |
+
working-directory: frontend
|
| 59 |
+
run: npm run lint
|
.github/workflows/evals.yml
CHANGED
|
@@ -1,14 +1,20 @@
|
|
| 1 |
name: evals
|
| 2 |
|
| 3 |
-
# Puerta de calidad: bloquea el merge si el motor determinista regresa o si la suite
|
| 4 |
-
# de evaluación
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
on:
|
| 7 |
pull_request:
|
| 8 |
paths:
|
| 9 |
-
- "frontend/
|
| 10 |
-
- "backend/
|
| 11 |
-
- "backend/app/rag/**"
|
| 12 |
- "evals/**"
|
| 13 |
- "data/**"
|
| 14 |
push:
|
|
@@ -44,6 +50,13 @@ jobs:
|
|
| 44 |
- name: Instalar deps del motor (para el puente Node)
|
| 45 |
working-directory: frontend
|
| 46 |
run: npm ci
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
# En CI real, sustituir --simular por --modelo medgemma apuntando a un endpoint,
|
| 48 |
# o subir un archivo de predicciones generado en un job con GPU.
|
| 49 |
#
|
|
|
|
| 1 |
name: evals
|
| 2 |
|
| 3 |
+
# Puerta de calidad CLÍNICA: bloquea el merge si el motor determinista regresa o si la suite
|
| 4 |
+
# de evaluación cae bajo sus umbrales o registra violaciones de seguridad.
|
| 5 |
+
#
|
| 6 |
+
# Este sí filtra por rutas —es el workflow caro— pero el filtro anterior era demasiado
|
| 7 |
+
# estrecho: nombraba `backend/app/ai/**` y `backend/app/rag/**`, dejando fuera `schemas.py`
|
| 8 |
+
# (que define `InterpretacionClinica`, la forma misma que las evals puntúan) y `config.py`
|
| 9 |
+
# (umbrales y ruta de modelo por defecto). Un cambio ahí alteraba la salida evaluada sin
|
| 10 |
+
# disparar la puerta. Las puertas de código —ruff, pytest, tsc, eslint— viven en `ci.yml`,
|
| 11 |
+
# que corre en TODOS los PR sin filtro.
|
| 12 |
|
| 13 |
on:
|
| 14 |
pull_request:
|
| 15 |
paths:
|
| 16 |
+
- "frontend/**"
|
| 17 |
+
- "backend/**"
|
|
|
|
| 18 |
- "evals/**"
|
| 19 |
- "data/**"
|
| 20 |
push:
|
|
|
|
| 50 |
- name: Instalar deps del motor (para el puente Node)
|
| 51 |
working-directory: frontend
|
| 52 |
run: npm ci
|
| 53 |
+
# Antes de la puerta, la puerta misma: métricas, rúbrica, umbrales y esquema del dataset
|
| 54 |
+
# son código sin cobertura de nadie más. Si el medidor está roto, el resultado de la
|
| 55 |
+
# puerta no significa nada (visto: el simulador dejaba `fuera_de_alcance` sin declarar y
|
| 56 |
+
# suspendía una métrica de tolerancia cero por su cuenta).
|
| 57 |
+
- name: Pruebas unitarias de los scripts de evals
|
| 58 |
+
working-directory: backend
|
| 59 |
+
run: uv run pytest -q ../evals/tests
|
| 60 |
# En CI real, sustituir --simular por --modelo medgemma apuntando a un endpoint,
|
| 61 |
# o subir un archivo de predicciones generado en un job con GPU.
|
| 62 |
#
|
CLAUDE.md
CHANGED
|
@@ -60,7 +60,21 @@ User clicks "Análisis IA"
|
|
| 60 |
- **`frontend/src/ia.ts`** — Cliente tipado de `POST /api/interpret`; renderiza la salida estructurada (hallazgos, diferenciales con citas, banner de derivación). No construye el prompt (eso vive en el backend).
|
| 61 |
- **`frontend/src/main.ts`** — Orquestación: carga los JSON, cablea eventos del formulario, dispara el análisis, exporta PDF.
|
| 62 |
- **`frontend/src/ui.ts`** — Tab navigation (8 panels, 4 exam sub-tabs), swipe gestures, mobile/desktop field sync, collapsible panels.
|
| 63 |
-
- **`frontend/src/pdf-parser.ts`** — Client-side PDF extraction using PDF.js. 47 regex patterns to identify analytes in Spanish/English. Runs fully in the browser.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
- **`backend/app/ai/hf_space.py`** — Cliente del HF Space (Gradio) donde vive medGemma. El Space devuelve texto libre, así que va por la ruta de prosa: `ai/prosa.py` limpia los tokens del modelo, detecta salida defectuosa (razonamiento filtrado, bucle, frase cortada) y envuelve el resultado en el campo `interpretacion`. Es la ruta por defecto sin salida estructurada; un modelo local declarado `=prosa` usa la misma.
|
| 65 |
- **`backend/app/ai/claude.py`** — Ruta Claude vía tool use forzado: el `input_schema` es el JSON Schema de `InterpretacionClinica`, así que valida contra Pydantic sin regex.
|
| 66 |
- **`data/valores_referencia.json`** — Reference ranges for 90 analytes per species.
|
|
@@ -94,6 +108,29 @@ cuesta el doble, exige retención de datos de 30 días (incompatible con el posi
|
|
| 94 |
privacidad) y sus clasificadores pueden rechazar trabajo clínico legítimo con
|
| 95 |
`stop_reason="refusal"` — ver el comentario en `backend/app/config.py`.
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
### Pattern Detection Logic (`analisis.ts`)
|
| 98 |
|
| 99 |
Severity thresholds are based on deviation from the reference range. Reference ranges are dynamically adjusted for:
|
|
|
|
| 60 |
- **`frontend/src/ia.ts`** — Cliente tipado de `POST /api/interpret`; renderiza la salida estructurada (hallazgos, diferenciales con citas, banner de derivación). No construye el prompt (eso vive en el backend).
|
| 61 |
- **`frontend/src/main.ts`** — Orquestación: carga los JSON, cablea eventos del formulario, dispara el análisis, exporta PDF.
|
| 62 |
- **`frontend/src/ui.ts`** — Tab navigation (8 panels, 4 exam sub-tabs), swipe gestures, mobile/desktop field sync, collapsible panels.
|
| 63 |
+
- **`frontend/src/pdf-parser.ts`** — Client-side PDF extraction using PDF.js. 47 regex patterns to identify analytes in Spanish/English. Runs fully in the browser. También cablea el arrastrar-y-soltar sobre los paneles de exámenes.
|
| 64 |
+
- **pdf.js va vendorizado y la versión es un invariante.** Los ficheros de `assets/lib/pdfjs/`
|
| 65 |
+
se copian de la devDependency `pdfjs-dist` con `npm run vendor-pdfjs`, para que la versión que
|
| 66 |
+
usa el navegador y la que usan las pruebas sean la misma. **No volver a la 3.x**: leía mal los
|
| 67 |
+
CMap `ToUnicode` con destinos de un byte —fuera de especificación, pero los emiten informes de
|
| 68 |
+
laboratorio reales generados con Ghostscript— y devolvía cada carácter desplazado 8 bits
|
| 69 |
+
('H' → U+4800), con lo que la importación fallaba entera con «No se encontraron datos
|
| 70 |
+
reconocibles». El desplazamiento **no se puede reparar a posteriori**: el '0' desplazado cae
|
| 71 |
+
en U+3000 y el extractor lo normaliza a espacio, así que una ALT de 260 U/L se leería como 26.
|
| 72 |
+
Por eso `textoIlegible()` detecta el caso y aborta en vez de importar números equivocados.
|
| 73 |
+
Fijado en `frontend/tests/pdf-parser.test.ts` con un PDF construido con esa anomalía.
|
| 74 |
+
- Un valor sólo se acepta en la línea de su etiqueta o en la línea `RESULTADO` que sigue a su
|
| 75 |
+
cabecera. La ventana ciega anterior saltaba de sección: importaba el cociente A/G como
|
| 76 |
+
albúmina y una densidad urinaria sacada de un párrafo interpretativo.
|
| 77 |
+
- **`frontend/src/panel-vacio.ts`** — Estado vacío de los seis paneles de exámenes **en escritorio**: la clase `.sin-datos` del `<section>` cambia el formulario por una zona de adjuntar (arrastrar / explorar / «Insertar resultados manualmente»). En móvil no aplica: el CSS que la enciende vive dentro de `@media (min-width: 1101px)`. Los importadores llaman a `revelarPanelDeCampo()` por cada valor inyectado, así que un panel que recibe datos sale del estado vacío solo. **Sólo depende de `dom.ts`**: lo importa `form-inject.ts`, que es la base común de los dos importadores, y colgarlo de `ui.ts` —que toca el DOM al cargarse— rompería sus tests.
|
| 78 |
- **`backend/app/ai/hf_space.py`** — Cliente del HF Space (Gradio) donde vive medGemma. El Space devuelve texto libre, así que va por la ruta de prosa: `ai/prosa.py` limpia los tokens del modelo, detecta salida defectuosa (razonamiento filtrado, bucle, frase cortada) y envuelve el resultado en el campo `interpretacion`. Es la ruta por defecto sin salida estructurada; un modelo local declarado `=prosa` usa la misma.
|
| 79 |
- **`backend/app/ai/claude.py`** — Ruta Claude vía tool use forzado: el `input_schema` es el JSON Schema de `InterpretacionClinica`, así que valida contra Pydantic sin regex.
|
| 80 |
- **`data/valores_referencia.json`** — Reference ranges for 90 analytes per species.
|
|
|
|
| 108 |
privacidad) y sus clasificadores pueden rechazar trabajo clínico legítimo con
|
| 109 |
`stop_reason="refusal"` — ver el comentario en `backend/app/config.py`.
|
| 110 |
|
| 111 |
+
### Admisión de cuentas y superficie de laboratorio
|
| 112 |
+
|
| 113 |
+
Dos defectos que se cerraron y **no se vuelven a abrir sin sustituirlos por algo mejor**:
|
| 114 |
+
|
| 115 |
+
- **El alta está CERRADA** (`registro_abierto=False` + `registro_allowlist`). Una cuenta llega
|
| 116 |
+
a `/api/interpret`, que gasta cuota de ZeroGPU compartida y dinero real por la ruta Claude:
|
| 117 |
+
con el alta abierta, `limite_interpret_usuario` protegía una identidad que costaba una
|
| 118 |
+
petición HTTP acuñar. La comprobación de allowlist va **antes** que la de existencia, si no
|
| 119 |
+
el alta se convierte en un oráculo de qué cuentas hay (403 siempre, nunca 409, fuera de la
|
| 120 |
+
lista). Es una **lista de emails y no un booleano** porque `instance/` es efímero: sin ella,
|
| 121 |
+
el primer reinicio deja la instancia sin cuentas y sin forma de crear ninguna.
|
| 122 |
+
- **`GET /api/lab/pendientes` está apagado** (`lab_pendientes_habilitado=False` → 404). Enumera
|
| 123 |
+
las muestras de todas las clínicas y cada `muestra_id` abre el panel completo más las pistas
|
| 124 |
+
de paciente. **Apagarlo no cierra el agujero y no hay que documentarlo como si lo hiciera**:
|
| 125 |
+
el `muestra_id` lo pone el analizador y suele ser correlativo, así que `/api/lab/resultados`
|
| 126 |
+
sigue siendo enumerable. Lo que elimina es el volcado en una petición. El cierre real es atar
|
| 127 |
+
cada resultado a un tenant y filtrar por sesión (ARCHITECTURE_REVIEW §2.1).
|
| 128 |
+
|
| 129 |
+
Las pruebas describen el defecto CERRADO; las que sólo necesitan sesión piden el fixture
|
| 130 |
+
`alta_abierta`. El fixture `_limitador_limpio` (autouse) vacía el contador de rate limiting
|
| 131 |
+
entre pruebas: es de proceso y el TestClient sale siempre de la misma IP, así que sin él los
|
| 132 |
+
429 aparecían según el orden de ejecución.
|
| 133 |
+
|
| 134 |
### Pattern Detection Logic (`analisis.ts`)
|
| 135 |
|
| 136 |
Severity thresholds are based on deviation from the reference range. Reference ranges are dynamically adjusted for:
|
Makefile
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# Morphos — tareas de desarrollo y despliegue
|
| 2 |
|
| 3 |
.PHONY: help frontend-install frontend-test frontend-build backend-sync backend-test \
|
| 4 |
-
ingest curar-indice dev lint evals evals-test ragas revision retrieval-eval \
|
| 5 |
docker-build publish-index fetch-index publish-books
|
| 6 |
|
| 7 |
# Los repos del Hub se declaran en scripts/hub.py (y deben coincidir con rag_index_repo /
|
|
@@ -21,6 +21,7 @@ help:
|
|
| 21 |
@echo " publish-books Sube books/*.pdf al dataset privado (sólo para reingerir)"
|
| 22 |
@echo " evals Suite de evaluación clínica (split dev, casos validados)"
|
| 23 |
@echo " evals-test Igual sobre el split reservado (sólo antes de desplegar)"
|
|
|
|
| 24 |
@echo " ragas Groundedness RAG con juez local gratuito (ARGS=--modelo …)"
|
| 25 |
@echo " revision Hoja de revisión veterinaria de los casos pendientes"
|
| 26 |
@echo " dev Levanta el backend FastAPI en local"
|
|
@@ -42,6 +43,12 @@ backend-sync:
|
|
| 42 |
backend-test:
|
| 43 |
cd backend && uv run pytest -q
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
# Requiere el grupo pesado 'rag'. Coloca los PDFs con licencia en books/ primero.
|
| 46 |
ingest:
|
| 47 |
cd backend && uv sync --group rag && uv run --group rag python -m app.rag.ingest --fuente ../books --salida ../instance/rag_index
|
|
|
|
| 1 |
# Morphos — tareas de desarrollo y despliegue
|
| 2 |
|
| 3 |
.PHONY: help frontend-install frontend-test frontend-build backend-sync backend-test \
|
| 4 |
+
ingest curar-indice dev lint evals evals-test evals-unit ragas revision retrieval-eval \
|
| 5 |
docker-build publish-index fetch-index publish-books
|
| 6 |
|
| 7 |
# Los repos del Hub se declaran en scripts/hub.py (y deben coincidir con rag_index_repo /
|
|
|
|
| 21 |
@echo " publish-books Sube books/*.pdf al dataset privado (sólo para reingerir)"
|
| 22 |
@echo " evals Suite de evaluación clínica (split dev, casos validados)"
|
| 23 |
@echo " evals-test Igual sobre el split reservado (sólo antes de desplegar)"
|
| 24 |
+
@echo " evals-unit Pruebas unitarias de los propios scripts de evals/"
|
| 25 |
@echo " ragas Groundedness RAG con juez local gratuito (ARGS=--modelo …)"
|
| 26 |
@echo " revision Hoja de revisión veterinaria de los casos pendientes"
|
| 27 |
@echo " dev Levanta el backend FastAPI en local"
|
|
|
|
| 43 |
backend-test:
|
| 44 |
cd backend && uv run pytest -q
|
| 45 |
|
| 46 |
+
# Pruebas unitarias de los scripts de evaluación (métricas, rúbrica, umbrales, dataset). No
|
| 47 |
+
# necesitan modelo, juez ni índice: son puras. Corren con el venv del backend porque los
|
| 48 |
+
# scripts importan `app.*`.
|
| 49 |
+
evals-unit:
|
| 50 |
+
cd backend && uv run pytest -q ../evals/tests
|
| 51 |
+
|
| 52 |
# Requiere el grupo pesado 'rag'. Coloca los PDFs con licencia en books/ primero.
|
| 53 |
ingest:
|
| 54 |
cd backend && uv sync --group rag && uv run --group rag python -m app.rag.ingest --fuente ../books --salida ../instance/rag_index
|
assets/lib/pdfjs/pdf.min.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/lib/pdfjs/pdf.min.mjs
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/lib/pdfjs/pdf.worker.min.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/lib/pdfjs/pdf.worker.min.mjs
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/.env.example
CHANGED
|
@@ -11,6 +11,16 @@ MORPHOS_ENTORNO=dev # dev | prod
|
|
| 11 |
MORPHOS_SESSION_SECRET=
|
| 12 |
MORPHOS_COOKIE_SECURE=false # true en prod (HTTPS)
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
# --- Base de datos de usuarios (SQLite fuera del webroot por defecto) ---
|
| 15 |
# MORPHOS_DB_PATH=/ruta/fuera/webroot/morphos.db
|
| 16 |
|
|
@@ -66,6 +76,15 @@ MORPHOS_RAG_MAX_POR_LIBRO=2
|
|
| 66 |
# umbral a ojo puede vaciar la recuperación). Calibrar con evals/run_retrieval_eval.py.
|
| 67 |
# MORPHOS_RAG_SCORE_MINIMO=
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# --- Rate limiting ---
|
| 70 |
MORPHOS_LIMITE_INTERPRET=10/minute
|
| 71 |
MORPHOS_LIMITE_LOGIN=5/minute
|
|
|
|
| 11 |
MORPHOS_SESSION_SECRET=
|
| 12 |
MORPHOS_COOKIE_SECURE=false # true en prod (HTTPS)
|
| 13 |
|
| 14 |
+
# --- Alta de cuentas (CERRADA por defecto) ---
|
| 15 |
+
# Una cuenta alcanza /api/interpret, que gasta cuota de ZeroGPU compartida y, por la ruta
|
| 16 |
+
# Claude, dinero real: el alta abierta era una autorización de gasto para cualquiera.
|
| 17 |
+
#
|
| 18 |
+
# IMPORTANTE en un despliegue: si dejas las dos vacías, NADIE puede darse de alta. Con
|
| 19 |
+
# `instance/` efímero (HF Spaces) las cuentas desaparecen en cada reinicio, así que sin
|
| 20 |
+
# allowlist la instancia se queda sin acceso posible. Pon aquí los emails aprobados.
|
| 21 |
+
# MORPHOS_REGISTRO_ALLOWLIST=vet1@clinica.com,vet2@clinica.com
|
| 22 |
+
MORPHOS_REGISTRO_ABIERTO=false # true SÓLO en desarrollo local
|
| 23 |
+
|
| 24 |
# --- Base de datos de usuarios (SQLite fuera del webroot por defecto) ---
|
| 25 |
# MORPHOS_DB_PATH=/ruta/fuera/webroot/morphos.db
|
| 26 |
|
|
|
|
| 76 |
# umbral a ojo puede vaciar la recuperación). Calibrar con evals/run_retrieval_eval.py.
|
| 77 |
# MORPHOS_RAG_SCORE_MINIMO=
|
| 78 |
|
| 79 |
+
# --- Integración de analizadores de laboratorio ---
|
| 80 |
+
# MORPHOS_LAB_API_KEYS=clave-del-puente-1,clave-del-puente-2
|
| 81 |
+
# Cola de muestras recibidas (GET /api/lab/pendientes) y el botón "Resultados pendientes" de la
|
| 82 |
+
# UI. DESACTIVADA por defecto: el almacén no está segmentado por clínica, así que enumera las
|
| 83 |
+
# muestras de TODAS y cada ID abre el panel completo de analitos. Enciéndela sólo donde las
|
| 84 |
+
# sesiones son de una única clínica. Apagarla NO cierra el agujero (el muestra_id lo pone el
|
| 85 |
+
# analizador y suele ser correlativo): el cierre real es filtrar por tenant.
|
| 86 |
+
MORPHOS_LAB_PENDIENTES_HABILITADO=false
|
| 87 |
+
|
| 88 |
# --- Rate limiting ---
|
| 89 |
MORPHOS_LIMITE_INTERPRET=10/minute
|
| 90 |
MORPHOS_LIMITE_LOGIN=5/minute
|
backend/app/ai/coherencia.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
Motivo: el 2026-08-01, con salida estructurada, medGemma devolvió en `hallazgos_clave` un
|
| 4 |
«Potasio (K+): 7.1 mEq/L, alto, grave» en un caso cuyos valores eran calcio, fósforo, BUN y
|
|
@@ -9,8 +9,25 @@ Por qué merece guarda propia: la decodificación restringida garantiza JSON bie
|
|
| 9 |
veracidad. Y un analito inventado DENTRO de un campo estructurado es peor que en prosa, porque
|
| 10 |
la interfaz lo pinta como un hallazgo con el mismo rango visual que los del motor determinista.
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
@@ -20,6 +37,7 @@ import re
|
|
| 20 |
import unicodedata
|
| 21 |
|
| 22 |
from ..schemas import InterpretacionClinica, PeticionInterpretacion
|
|
|
|
| 23 |
|
| 24 |
log = logging.getLogger("morphos.ia")
|
| 25 |
|
|
@@ -77,3 +95,200 @@ def descartar_fabricados(
|
|
| 77 |
h for h in resultado.hallazgos_clave if h.analito not in fabricados
|
| 78 |
]
|
| 79 |
return resultado
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""El modelo sólo puede hablar de analitos que se enviaron, y sólo alterarlos si lo estaban.
|
| 2 |
|
| 3 |
Motivo: el 2026-08-01, con salida estructurada, medGemma devolvió en `hallazgos_clave` un
|
| 4 |
«Potasio (K+): 7.1 mEq/L, alto, grave» en un caso cuyos valores eran calcio, fósforo, BUN y
|
|
|
|
| 9 |
veracidad. Y un analito inventado DENTRO de un campo estructurado es peor que en prosa, porque
|
| 10 |
la interfaz lo pinta como un hallazgo con el mismo rango visual que los del motor determinista.
|
| 11 |
|
| 12 |
+
Dos guardas, porque son dos superficies distintas:
|
| 13 |
+
|
| 14 |
+
- `descartar_fabricados` limpia `hallazgos_clave`. Aquí sí se BORRA (a diferencia de
|
| 15 |
+
`prescripcion.py`): un elemento de lista es una unidad independiente y quitarlo no deja una
|
| 16 |
+
frase a medias.
|
| 17 |
+
- `analitos_fabricados_en_prosa` mira la PROSA, que es donde va todo en la ruta desplegada en
|
| 18 |
+
producción —el Space devuelve texto libre y `hallazgos_clave` llega vacío por diseño, así que
|
| 19 |
+
la guarda de arriba no ve nada—. Aquí NO se borra: sólo detecta y devuelve la lista; quien
|
| 20 |
+
actúa es `service.py`, que vuelve a muestrear con una instrucción correctiva.
|
| 21 |
+
- `alterados_declarados_normales` es la simétrica de la anterior sobre la misma prosa: no que
|
| 22 |
+
se invente una alteración, sino que se dé por normal un valor que el motor determinista ve
|
| 23 |
+
fuera de rango. Se remedia igual, regenerando.
|
| 24 |
+
|
| 25 |
+
Lo que motivó la segunda, corrida del 2026-08-04 (juez_seguridad 0.79, umbral 0.90): sobre un
|
| 26 |
+
panel de calcio/fósforo/BUN/creatinina el modelo escribió «La leucograma muestra neutrofilia y
|
| 27 |
+
linfopenia» —única violación de seguridad de la corrida—, en otro caso afirmó «aumento de
|
| 28 |
+
reticulocitos» sin recuento reticulocitario, y en un tercero llamó «trombocitopenia leve» a unas
|
| 29 |
+
plaquetas de 190 que estaban en rango. Los tres son la misma carencia: el modelo no distinguía
|
| 30 |
+
«no se midió» de «se midió y salió normal».
|
| 31 |
"""
|
| 32 |
|
| 33 |
from __future__ import annotations
|
|
|
|
| 37 |
import unicodedata
|
| 38 |
|
| 39 |
from ..schemas import InterpretacionClinica, PeticionInterpretacion
|
| 40 |
+
from .lexico import abreviaturas_presentes, nombre_clinico, sin_tildes, terminos_especificos
|
| 41 |
|
| 42 |
log = logging.getLogger("morphos.ia")
|
| 43 |
|
|
|
|
| 95 |
h for h in resultado.hallazgos_clave if h.analito not in fabricados
|
| 96 |
]
|
| 97 |
return resultado
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# --- Guarda sobre la prosa ---
|
| 101 |
+
|
| 102 |
+
# Pedir una prueba es exactamente lo que la herramienta DEBE hacer, así que una oración de
|
| 103 |
+
# recomendación queda exenta: sin esto, la propia frase correcta de `hipercalcemia-canino` («Se
|
| 104 |
+
# debe obtener un hemograma completo incluyendo plaquetas») se marcaría como invención del
|
| 105 |
+
# hemograma que la misma respuesta reconoce no tener. Se prefiere el falso negativo: un falso
|
| 106 |
+
# positivo gasta una llamada al Space y le mete al modelo una corrección que no procede.
|
| 107 |
+
_RECOMENDACION = re.compile(
|
| 108 |
+
r"\b(se recomienda|recomendable|recomiendo|se sugiere|sugiero|se aconseja|"
|
| 109 |
+
r"solicit\w+|obtener|obtenga|realiz\w+|efectu\w+|considerar|considere|evaluar|eval[uú]e|"
|
| 110 |
+
r"valorar|valore|determinar|determine|cuantific\w+|medicion|medir|mida|ampliar|amplie|"
|
| 111 |
+
r"complet\w+|incluir|incluya|pedir|pida|repetir|repita|monitoriz\w+|control\w+|"
|
| 112 |
+
r"seguimiento|descartar|descarte|confirmar|confirme|investigar|investigue|"
|
| 113 |
+
r"siguientes pruebas|pruebas (diagnosticas|adicionales|complementarias))\b"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# La prosa clínica explica MECANISMOS, y eso no es afirmar un dato del paciente: «el iCa puede
|
| 117 |
+
# estar alterado en presencia de alcalosis o hipoalbuminemia» habla de fisiología general, no
|
| 118 |
+
# dice que este gato tenga alcalosis. Sin esta exención, `hipomagnesemia-uci-felino` se marcaba
|
| 119 |
+
# por esa misma frase, que es correcta.
|
| 120 |
+
_HIPOTETICO = re.compile(
|
| 121 |
+
r"\b(puede|pueden|podria|podrian|pudiera|suele|suelen|si hay|si existe|si se|"
|
| 122 |
+
r"en presencia de|en ausencia de|en caso de|en casos de|cuando hay|cuando existe|"
|
| 123 |
+
r"posible|posibles|probable|probables|sospecha de|compatible con|sugiere|sugieren|"
|
| 124 |
+
r"sugestiv\w+|asociad\w+ a|secundari\w+ a|habitualmente|t[ií]picamente)\b"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
# Nombrar un ESTADO fisiopatológico no es reportar un resultado de laboratorio: «indican una
|
| 128 |
+
# marcada deficiencia de insulina» en una cetoacidosis es la conclusión correcta, no la
|
| 129 |
+
# invención de una insulinemia que nadie midió. Sin esta exención, `cetoacidosis-felino-gases`
|
| 130 |
+
# —cuya salida el juez puntuó 1.00 en seguridad— gastaba un reintento contra el Space por una
|
| 131 |
+
# frase impecable.
|
| 132 |
+
_ESTADO_FISIOPATOLOGICO = re.compile(
|
| 133 |
+
r"\b(deficiencia|deficit|carencia|exceso|resistencia|insuficiencia|sobreproduccion|"
|
| 134 |
+
r"produccion excesiva|liberacion)\s+(de|a la|al|de la)\b"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Marcadores de que la oración AFIRMA un dato, no lo pide ni lo hipotetiza.
|
| 138 |
+
_ASERCION = re.compile(
|
| 139 |
+
r"\b(muestra|muestran|presenta|presentan|se observa\w*|se aprecia\w*|se evidencia\w*|"
|
| 140 |
+
r"evidencia|revela|revelan|hay|existe|existen|aument\w+|disminu\w+|elevad\w+|"
|
| 141 |
+
r"descendid\w+|reducid\w+|increment\w+|alto|alta|altos|altas|bajo|baja|bajos|bajas|"
|
| 142 |
+
r"marcad\w+|sever\w+|acentuad\w+)\b"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Un término que POR SÍ SOLO afirma una alteración: «trombocitopenia», «hipocalcemia»,
|
| 146 |
+
# «neutrofilia». Nombrar el analito («las plaquetas», «el calcio») no es afirmar nada; llamarlo
|
| 147 |
+
# por su alteración sí. Es lo que separa el caso del analito medido y en rango —donde sólo esto
|
| 148 |
+
# cuenta— del analito que nunca se midió, donde cualquier aserción vale.
|
| 149 |
+
_TERMINO_DE_ALTERACION = re.compile(
|
| 150 |
+
r"^(hiper|hipo|macro|micro|pan)\w+"
|
| 151 |
+
r"|(penia|citosis|filia|osis|uria|emia)$"
|
| 152 |
+
)
|
| 153 |
+
# Alteraciones cuya forma no delata el prefijo/sufijo de arriba. Se listan a mano, mismo
|
| 154 |
+
# criterio que `VARIANTES` en lexico.py: aquí un falso positivo es peor que un falso negativo.
|
| 155 |
+
_ALTERACIONES_IRREGULARES = frozenset({"regenerativa", "azotemia", "uremia", "transaminasas"})
|
| 156 |
+
|
| 157 |
+
_ORACIONES = re.compile(r"(?<=[.!?;:])\s+|\n+")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _es_alteracion(termino: str) -> bool:
|
| 161 |
+
return termino in _ALTERACIONES_IRREGULARES or bool(_TERMINO_DE_ALTERACION.search(termino))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def analitos_fabricados_en_prosa(
|
| 165 |
+
texto: str, pet: PeticionInterpretacion
|
| 166 |
+
) -> list[str]:
|
| 167 |
+
"""Nombres clínicos de analitos que la prosa afirma sin respaldo. Vacía si no hay ninguno.
|
| 168 |
+
|
| 169 |
+
Dos casos, con umbrales distintos a propósito:
|
| 170 |
+
|
| 171 |
+
- **No se midió**: cualquier oración que lo AFIRME (verbo de constatación o término de
|
| 172 |
+
dirección) o que lo nombre por su alteración es invención. Ejemplo real: «La leucograma
|
| 173 |
+
muestra neutrofilia y linfopenia» sobre un panel de bioquímica.
|
| 174 |
+
- **Se midió y salió en rango**: nombrarlo es legítimo —el modelo puede decir que las
|
| 175 |
+
plaquetas están conservadas—; lo que no vale es llamarlo por su alteración. Ejemplo real:
|
| 176 |
+
«trombocitopenia leve» con `plt` = 190, dentro de rango.
|
| 177 |
+
|
| 178 |
+
Sin `analitos_medidos` no se puede distinguir «no medido» de «medido y normal», así que la
|
| 179 |
+
guarda se desactiva entera: es lo que manda una petición de un cliente antiguo, y marcar a
|
| 180 |
+
ciegas ahí generaría reintentos sobre respuestas correctas.
|
| 181 |
+
"""
|
| 182 |
+
medidos = {c.strip() for c in pet.analitos_medidos if c.strip()}
|
| 183 |
+
if not medidos:
|
| 184 |
+
return []
|
| 185 |
+
|
| 186 |
+
lexico = terminos_especificos()
|
| 187 |
+
# `hallazgos` son los analitos FUERA de rango; los `parametros` de un patrón son los que el
|
| 188 |
+
# patrón involucra, estén alterados o no, así que cuentan como conocidos pero no como
|
| 189 |
+
# alterados. Mezclarlos absolvía el caso que motivó la guarda: en
|
| 190 |
+
# `hemolisis-regenerativa-canino` un patrón lista `plt` entre sus parámetros con las
|
| 191 |
+
# plaquetas en 190 —en rango—, y la «trombocitopenia leve» del modelo quedaba sin marcar.
|
| 192 |
+
alterados = {h.clave for h in pet.hallazgos}
|
| 193 |
+
conocidos = medidos | alterados
|
| 194 |
+
for patron in pet.patrones:
|
| 195 |
+
conocidos.update(patron.parametros)
|
| 196 |
+
en_rango = (conocidos - alterados) & lexico.keys()
|
| 197 |
+
no_medidos = lexico.keys() - conocidos
|
| 198 |
+
|
| 199 |
+
# Un término que también nombra a un analito realmente alterado no prueba nada: «azotemia»
|
| 200 |
+
# la sostiene un BUN alto aunque la creatinina esté en rango, y «eritrocitos» puede ser el
|
| 201 |
+
# RBC del hemograma y no los de la orina. Se exige que el término apunte SÓLO a lo que no
|
| 202 |
+
# se midió (o a lo que se midió y salió normal).
|
| 203 |
+
respaldados = {t for c in alterados for t in lexico.get(c, ())}
|
| 204 |
+
|
| 205 |
+
fabricados: dict[str, None] = {} # dict para deduplicar conservando el orden
|
| 206 |
+
for oracion in _ORACIONES.split(texto):
|
| 207 |
+
normalizada = sin_tildes(oracion)
|
| 208 |
+
if not normalizada.strip():
|
| 209 |
+
continue
|
| 210 |
+
if (
|
| 211 |
+
_RECOMENDACION.search(normalizada)
|
| 212 |
+
or _HIPOTETICO.search(normalizada)
|
| 213 |
+
or _ESTADO_FISIOPATOLOGICO.search(normalizada)
|
| 214 |
+
):
|
| 215 |
+
continue
|
| 216 |
+
afirma = bool(_ASERCION.search(normalizada))
|
| 217 |
+
# Las siglas ambiguas se cotejan sobre la oración SIN normalizar: es la mayúscula lo
|
| 218 |
+
# único que distingue el "UN" del BUN del artículo indeterminado.
|
| 219 |
+
siglas = abreviaturas_presentes(oracion)
|
| 220 |
+
for clave in no_medidos | en_rango:
|
| 221 |
+
presentes = [
|
| 222 |
+
t for t in lexico[clave]
|
| 223 |
+
if t not in respaldados and re.search(rf"\b{re.escape(t)}\b", normalizada)
|
| 224 |
+
]
|
| 225 |
+
# Una sigla nombra al analito, no lo altera: vale como mención, no como
|
| 226 |
+
# alteración, igual que decir «las plaquetas».
|
| 227 |
+
nombrado = bool(presentes) or clave in siglas
|
| 228 |
+
if not nombrado:
|
| 229 |
+
continue
|
| 230 |
+
if any(map(_es_alteracion, presentes)) or (clave in no_medidos and afirma):
|
| 231 |
+
fabricados[nombre_clinico(clave)] = None
|
| 232 |
+
return list(fabricados)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# --- Guarda simétrica: declarar normal lo que el motor vio alterado ---
|
| 236 |
+
|
| 237 |
+
# El copulativo es lo que separa afirmar de razonar: «la MCV aquí está normal» habla de ESTE
|
| 238 |
+
# paciente, mientras que «una creatinina normal no descarta ERC» enuncia un principio general y
|
| 239 |
+
# no debe marcarse. Por eso se exige el verbo, y no basta con que aparezca la palabra «normal».
|
| 240 |
+
_AFIRMA_NORMALIDAD = re.compile(
|
| 241 |
+
r"\b(esta|estan|es|son|se (encuentra|encuentran|mantiene|mantienen|situa|situan)|"
|
| 242 |
+
r"permanece\w*|resulta\w*|luce\w*|aparece\w*)\s+"
|
| 243 |
+
r"(dentro|en (el )?(rango|limites|intervalo)|normal\w*|conservad\w*|dentro de)"
|
| 244 |
+
r"|\b(dentro (de|del) (los |las )?(rango|limites|valores|intervalo)\w*|"
|
| 245 |
+
r"sin alteracion\w*|no muestra alteracion\w*)"
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
# «no está normal», «lejos de lo normal»: la negación invierte la frase y deja de ser una
|
| 249 |
+
# declaración de normalidad. Barata de comprobar y evita el falso positivo más obvio.
|
| 250 |
+
_NEGACION = re.compile(r"\b(no|nunca|tampoco|lejos de|salvo|excepto)\b")
|
| 251 |
+
|
| 252 |
+
# Esta guarda trocea MÁS fino que la de invención, y sólo ella. El caso real venía dentro de una
|
| 253 |
+
# oración larga: «…la literatura [2] menciona que la microcitosis no es típica en shunt
|
| 254 |
+
# portosistémico (aunque la MCV aquí está normal)», donde el «no» y el «posible» de la primera
|
| 255 |
+
# mitad eximían a la segunda. Una adversativa o un paréntesis abren una afirmación nueva, y aquí
|
| 256 |
+
# la afirmación que importa es corta y se sostiene sola. No se aplica al otro sentido de la
|
| 257 |
+
# guarda: allí trocear más deja frases sin el contexto que las exime y dispara falsos positivos.
|
| 258 |
+
_CLAUSULAS = re.compile(r"\b(aunque|si bien|mientras que|pese a que|a pesar de que)\b|[()]")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def alterados_declarados_normales(
|
| 262 |
+
texto: str, pet: PeticionInterpretacion
|
| 263 |
+
) -> list[str]:
|
| 264 |
+
"""Nombres clínicos de analitos ALTERADOS que la prosa declara normales.
|
| 265 |
+
|
| 266 |
+
Es la mitad que faltaba de `analitos_fabricados_en_prosa`: aquella detecta alterar lo que
|
| 267 |
+
está bien, y ésta declarar bien lo que está alterado. Clínicamente la segunda es la peor de
|
| 268 |
+
las dos —normalizar un valor patológico invita a no actuar—, y hasta ahora no la miraba
|
| 269 |
+
nadie. Medido el 2026-08-04 en `shunt-portosistemico-canino`: el modelo listó «microcitosis»
|
| 270 |
+
entre los hallazgos y tres frases después escribió «aunque la MCV aquí está normal», con un
|
| 271 |
+
VCM de 58 en un perro. El juez lo penalizó por la contradicción (seguridad 0.90 → 0.55).
|
| 272 |
+
|
| 273 |
+
El criterio es el mismo de todo el fichero: se prefiere el falso negativo. Sólo cuenta si
|
| 274 |
+
la oración nombra el analito, afirma normalidad con un copulativo y no la niega.
|
| 275 |
+
"""
|
| 276 |
+
alterados = {h.clave for h in pet.hallazgos}
|
| 277 |
+
if not alterados:
|
| 278 |
+
return []
|
| 279 |
+
|
| 280 |
+
lexico = terminos_especificos()
|
| 281 |
+
declarados: dict[str, None] = {}
|
| 282 |
+
for oracion in _ORACIONES.split(texto):
|
| 283 |
+
# `split` con grupos devuelve también los separadores (y None por los que no casan).
|
| 284 |
+
for clausula in _CLAUSULAS.split(sin_tildes(oracion)):
|
| 285 |
+
if not clausula or not clausula.strip():
|
| 286 |
+
continue
|
| 287 |
+
if _HIPOTETICO.search(clausula) or _NEGACION.search(clausula):
|
| 288 |
+
continue
|
| 289 |
+
if not _AFIRMA_NORMALIDAD.search(clausula):
|
| 290 |
+
continue
|
| 291 |
+
for clave in alterados & lexico.keys():
|
| 292 |
+
if any(re.search(rf"\b{re.escape(t)}\b", clausula) for t in lexico[clave]):
|
| 293 |
+
declarados[nombre_clinico(clave)] = None
|
| 294 |
+
return list(declarados)
|
backend/app/ai/lexico.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Léxico de analitos: clave → términos con los que un texto clínico puede nombrarla.
|
| 2 |
+
|
| 3 |
+
Única fuente del mapeo. Vivía sólo en `evals/run_evals.py`, que lo usaba para medir la
|
| 4 |
+
cobertura de hallazgos sobre la prosa; ahora también lo necesita el backend en tiempo de
|
| 5 |
+
ejecución —`ai/coherencia.py` para detectar analitos inventados y `ai/prompt.py` para nombrar
|
| 6 |
+
en cristiano los que salieron en rango—, así que se ha traído aquí y `run_evals.py` lo importa.
|
| 7 |
+
Duplicarlo habría dejado la métrica y la guarda midiendo cosas distintas con el mismo nombre.
|
| 8 |
+
|
| 9 |
+
Se construye desde `data/valores_referencia.json`, que ya trae el nombre clínico de cada
|
| 10 |
+
analito ("ALT (GPT)", "Densidad (USG)"), así que el grueso del léxico no se escribe a mano.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
import unicodedata
|
| 18 |
+
from functools import lru_cache
|
| 19 |
+
|
| 20 |
+
from ..config import RAIZ_REPO
|
| 21 |
+
|
| 22 |
+
# Palabras del nombre clínico que no identifican al analito por sí solas: "T4 total" y
|
| 23 |
+
# "Proteínas totales" comparten "total", y buscarla marcaría cualquiera de los dos.
|
| 24 |
+
_GENERICOS = {"total", "libre", "serico", "serica", "plasmatico", "urinario", "sangre"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def sin_tildes(texto: str) -> str:
|
| 28 |
+
return "".join(
|
| 29 |
+
c for c in unicodedata.normalize("NFD", texto.lower()) if unicodedata.category(c) != "Mn"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# La prosa clínica casi nunca nombra el analito: nombra su alteración ("hiperfosforemia" en
|
| 34 |
+
# vez de "fósforo", "trombocitopenia" en vez de "plaquetas"). Estas variantes se listan a
|
| 35 |
+
# mano en vez de derivarlas por stemming a propósito: un prefijo de 5 letras haría que
|
| 36 |
+
# "hematoma" contara como hematocrito, y aquí un falso positivo es peor que un falso negativo
|
| 37 |
+
# —tanto en la métrica como en la guarda, donde provoca un reintento inútil—. Sólo se incluyen
|
| 38 |
+
# derivaciones del NOMBRE del analito, no síndromes que lo acompañan (anemia no cuenta como
|
| 39 |
+
# mención del hematocrito).
|
| 40 |
+
VARIANTES: dict[str, tuple[str, ...]] = {
|
| 41 |
+
"alb": ("hipoalbuminemia", "hiperalbuminemia", "albuminemia"),
|
| 42 |
+
"alt": ("gpt", "transaminasas", "transaminasa"),
|
| 43 |
+
# La grafía con UNA r es incorrecta y el modelo la usa igual: el 2026-08-04 escribió
|
| 44 |
+
# «hiperbilirubinemia» en `shunt-portosistemico-canino`, sobre una bilirrubina que nunca
|
| 45 |
+
# se midió, y la guarda de invención lo dejó pasar por esa letra. Un término de más aquí
|
| 46 |
+
# no puede casar con otro analito, así que el coste de admitir la falta de ortografía es
|
| 47 |
+
# cero y el de no admitirla ya se pagó.
|
| 48 |
+
"bili": ("hiperbilirrubinemia", "bilirrubinemia", "hiperbilirubinemia", "bilirubinemia"),
|
| 49 |
+
"bun": ("azotemia", "uremia", "urea"),
|
| 50 |
+
"ca_ion": ("hipercalcemia", "hipocalcemia", "calcemia", "ica"),
|
| 51 |
+
"calc": ("hipercalcemia", "hipocalcemia", "calcemia"),
|
| 52 |
+
"colest": ("hipercolesterolemia", "colesterolemia"),
|
| 53 |
+
"creat": ("azotemia",),
|
| 54 |
+
"fal": ("alp", "fosfatasa"),
|
| 55 |
+
"fosf": ("hiperfosfatemia", "hipofosfatemia", "hiperfosforemia", "fosfatemia", "fosforemia"),
|
| 56 |
+
"glob": ("hiperglobulinemia", "globulinemia", "gammapatia"),
|
| 57 |
+
"gluc": ("hiperglucemia", "hipoglucemia", "glucemia", "hiperglicemia"),
|
| 58 |
+
"hco3": ("bicarbonato",),
|
| 59 |
+
"neutro_abs": ("neutrofilia", "neutropenia"),
|
| 60 |
+
"ph_sangre": ("acidosis", "alcalosis", "acidemia", "alcalemia"),
|
| 61 |
+
"pli": ("cpli", "lipasa"),
|
| 62 |
+
"plt": ("trombocitopenia", "trombocitosis", "plaquetopenia"),
|
| 63 |
+
"potasio": ("hipopotasemia", "hiperpotasemia", "hipokalemia", "hiperkalemia", "kalemia"),
|
| 64 |
+
"prot": ("hiperproteinemia", "hipoproteinemia", "proteinemia"),
|
| 65 |
+
"reti": ("reticulocitosis", "regenerativa"),
|
| 66 |
+
"sodio": ("hiponatremia", "hipernatremia", "natremia"),
|
| 67 |
+
"t4_total": ("t4", "tiroxina"),
|
| 68 |
+
"usg": ("isostenuria", "hipostenuria"),
|
| 69 |
+
"vcm": ("mcv", "microcitosis", "macrocitosis"),
|
| 70 |
+
"wbc": ("leucocitosis", "leucopenia", "leucocitos"),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# Siglas cuya versión en minúsculas es una palabra corriente del castellano. No pueden entrar
|
| 75 |
+
# en `VARIANTES`: todo el léxico se coteja sobre texto normalizado a minúsculas, y buscar "un"
|
| 76 |
+
# marcaría prácticamente cualquier oración. Se cotejan aparte, respetando las mayúsculas, sobre
|
| 77 |
+
# el texto ORIGINAL. Motivo: el 2026-08-04, en `piometra-progesterona-canino`, el modelo escribió
|
| 78 |
+
# «Esta combinación (alta UN, alta CT, USG bajo) caracteriza una azotemia renal» sobre un panel
|
| 79 |
+
# sin BUN; "azotemia" quedaba absuelta —la creatinina sí estaba alta— y la sigla era la única
|
| 80 |
+
# señal de que se había inventado el dato.
|
| 81 |
+
ABREVIATURAS_SENSIBLES: dict[str, tuple[str, ...]] = {
|
| 82 |
+
"bun": ("UN", "BUN"),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def abreviaturas_presentes(texto: str) -> set[str]:
|
| 87 |
+
"""Claves cuya sigla ambigua aparece, con sus mayúsculas, en el texto tal cual se escribió."""
|
| 88 |
+
return {
|
| 89 |
+
clave for clave, siglas in ABREVIATURAS_SENSIBLES.items()
|
| 90 |
+
if any(re.search(rf"\b{re.escape(s)}\b", texto) for s in siglas)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def tokens_analito(texto: str) -> set[str]:
|
| 95 |
+
# Mínimo 2 caracteres: hay analitos cuyo nombre entero es corto ("T4", "pH"), y con 3
|
| 96 |
+
# se quedaban sin ningún término con el que buscarlos.
|
| 97 |
+
return {
|
| 98 |
+
t for t in re.split(r"[^a-z0-9]+", sin_tildes(texto))
|
| 99 |
+
if len(t) >= 2 and t not in _GENERICOS
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@lru_cache
|
| 104 |
+
def _referencias() -> dict:
|
| 105 |
+
ruta = RAIZ_REPO / "data" / "valores_referencia.json"
|
| 106 |
+
return json.loads(ruta.read_text(encoding="utf-8"))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@lru_cache
|
| 110 |
+
def lexico_analitos() -> dict[str, frozenset[str]]:
|
| 111 |
+
"""clave de analito → términos con los que un texto puede referirse a él."""
|
| 112 |
+
lexico: dict[str, set[str]] = {}
|
| 113 |
+
for analitos in _referencias().values(): # canino, felino
|
| 114 |
+
for clave, info in analitos.items():
|
| 115 |
+
terminos = lexico.setdefault(clave, set())
|
| 116 |
+
terminos |= tokens_analito(clave)
|
| 117 |
+
terminos |= tokens_analito(info.get("nombre", ""))
|
| 118 |
+
for clave, variantes in VARIANTES.items():
|
| 119 |
+
lexico.setdefault(clave, set()).update(variantes)
|
| 120 |
+
return {clave: frozenset(t) for clave, t in lexico.items()}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@lru_cache
|
| 124 |
+
def nombres_clinicos() -> dict[str, str]:
|
| 125 |
+
"""clave → nombre clínico legible ("plt" → "Plaquetas"). La clave cruda si no hay nombre."""
|
| 126 |
+
nombres: dict[str, str] = {}
|
| 127 |
+
for analitos in _referencias().values():
|
| 128 |
+
for clave, info in analitos.items():
|
| 129 |
+
if nombre := info.get("nombre"):
|
| 130 |
+
nombres.setdefault(clave, nombre)
|
| 131 |
+
return nombres
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def nombre_clinico(clave: str) -> str:
|
| 135 |
+
return nombres_clinicos().get(clave, clave)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# Palabras que aparecen DENTRO del nombre clínico de algún analito pero que no lo identifican:
|
| 139 |
+
# calificadores ("directa", "ionizado", "arterial"), unidades de medida del propio nombre
|
| 140 |
+
# ("tiempo", "cociente", "índice") y sustantivos compartidos por varios ("proteína", "creatina").
|
| 141 |
+
# Sin este filtro «de» y «tiempo» —del "Tiempo de protrombina (TP)"— casan en casi cualquier
|
| 142 |
+
# frase clínica: medido sobre las 30 predicciones del 2026-08-04, el TP salía marcado en 27.
|
| 143 |
+
_NO_IDENTIFICAN = frozenset({
|
| 144 |
+
"tiempo", "exceso", "nivel", "basal", "serico", "serica", "urinario", "orina", "sangre",
|
| 145 |
+
"total", "libre", "parcial", "activada", "protrombina", "tromboplastina", "sanguineo",
|
| 146 |
+
"relacion", "indice", "recuento", "absoluto", "estimulacion", "supresion", "post", "pre",
|
| 147 |
+
"arterial", "saturacion", "cociente", "antigeno", "reactiva", "directa", "ionizado",
|
| 148 |
+
"pancreatica", "acidos", "biliares", "creatina", "kinasa", "cardiaca", "amiloide",
|
| 149 |
+
"proteina", "dimeros",
|
| 150 |
+
})
|
| 151 |
+
# Debajo de esto, un token del nombre es una abreviatura ("tp", "be", "ac", "at") que casa
|
| 152 |
+
# dentro de cualquier palabra o como partícula suelta. Las siglas que SÍ interesan ya entran
|
| 153 |
+
# por `VARIANTES`, que es una lista curada.
|
| 154 |
+
_LARGO_MINIMO_TERMINO = 6
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@lru_cache
|
| 158 |
+
def terminos_especificos() -> dict[str, frozenset[str]]:
|
| 159 |
+
"""Subconjunto del léxico con la precisión que exige BUSCAR AL REVÉS.
|
| 160 |
+
|
| 161 |
+
`claves_mencionadas` pregunta «¿aparece ESTE analito?» sobre un puñado de claves conocidas,
|
| 162 |
+
y ahí un token flojo apenas molesta. La guarda de invención hace lo contrario: barre los 90
|
| 163 |
+
analitos contra una prosa para ver cuál NO debería estar. Con el léxico completo eso es un
|
| 164 |
+
generador de falsos positivos, y cada falso positivo cuesta una llamada al modelo y una
|
| 165 |
+
corrección improcedente. Aquí sólo entran los términos curados a mano (`VARIANTES`) y las
|
| 166 |
+
palabras del nombre clínico lo bastante largas y específicas como para no casar por azar.
|
| 167 |
+
"""
|
| 168 |
+
especificos: dict[str, frozenset[str]] = {}
|
| 169 |
+
for clave, terminos in lexico_analitos().items():
|
| 170 |
+
utiles = set(VARIANTES.get(clave, ()))
|
| 171 |
+
utiles |= {
|
| 172 |
+
t for t in terminos
|
| 173 |
+
if len(t) >= _LARGO_MINIMO_TERMINO and t not in _NO_IDENTIFICAN
|
| 174 |
+
}
|
| 175 |
+
if utiles:
|
| 176 |
+
especificos[clave] = frozenset(utiles)
|
| 177 |
+
return especificos
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def claves_mencionadas(texto: str, claves: set[str]) -> set[str]:
|
| 181 |
+
"""Cuáles de `claves` aparecen nombradas en el texto.
|
| 182 |
+
|
| 183 |
+
Se busca el término con límites de palabra para que "alt" no case dentro de "alteración".
|
| 184 |
+
"""
|
| 185 |
+
normalizado = sin_tildes(texto)
|
| 186 |
+
lexico = lexico_analitos()
|
| 187 |
+
encontradas = set()
|
| 188 |
+
for clave in claves:
|
| 189 |
+
terminos = lexico.get(clave) or tokens_analito(clave)
|
| 190 |
+
if any(re.search(rf"\b{re.escape(t)}\b", normalizado) for t in terminos):
|
| 191 |
+
encontradas.add(clave)
|
| 192 |
+
return encontradas
|
backend/app/ai/prompt.py
CHANGED
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
| 11 |
from ..config import obtener_config
|
| 12 |
from ..rag.retriever import Fragmento
|
| 13 |
from ..schemas import PeticionInterpretacion
|
|
|
|
| 14 |
|
| 15 |
# Cuánto texto de cada fragmento se le enseña al modelo. El recorte por presupuesto total
|
| 16 |
# (config.rag_max_chars_prompt) cuenta en esta misma unidad.
|
|
@@ -25,6 +26,16 @@ Reglas estrictas:
|
|
| 25 |
- Responde SIEMPRE en español.
|
| 26 |
- Cíñete a los datos aportados (señalamiento, valores de laboratorio, patrones detectados,
|
| 27 |
literatura recuperada e imágenes). No inventes valores ni hallazgos.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
- Cuando afirmes algo respaldado por la literatura recuperada, cítalo en el campo `citas`
|
| 29 |
del diferencial correspondiente, usando el número entre corchetes con el que se te
|
| 30 |
presentó el fragmento ([1], [2]…). No cites lo que no se te dio: una cita que no
|
|
@@ -50,6 +61,16 @@ ni el examen presencial del paciente.
|
|
| 50 |
Reglas estrictas:
|
| 51 |
- Responde SIEMPRE en español.
|
| 52 |
- Cíñete a los datos aportados; no inventes valores ni hallazgos.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
- Si se te entrega literatura recuperada, marca cada afirmación que se apoye en ella con su
|
| 54 |
número entre corchetes justo después de la frase ([1], [2]…). Es la ÚNICA forma de citar
|
| 55 |
en esta ruta: no escribas títulos de libros ni páginas, y no uses números que no estén en
|
|
@@ -88,6 +109,37 @@ def _linea_hallazgo(h, con_gravedad: bool = True) -> str:
|
|
| 88 |
return f" {h.nombre} ({h.clave}): {h.valor} {h.unidad} — {h.direccion.value}{etiqueta}"
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
def _bloque_contexto_rag(fragmentos: list[Fragmento]) -> str:
|
| 92 |
if not fragmentos:
|
| 93 |
return ""
|
|
@@ -126,6 +178,7 @@ def construir_mensaje_usuario(
|
|
| 126 |
if pet.patrones and cfg.prompt_incluir_patrones
|
| 127 |
else ""
|
| 128 |
)
|
|
|
|
| 129 |
sin_alteraciones = not pet.hallazgos and not pet.patrones
|
| 130 |
|
| 131 |
signos = f"\nSignos clínicos referidos: {pet.signos_clinicos.strip()}" if pet.signos_clinicos.strip() else ""
|
|
@@ -164,7 +217,7 @@ def construir_mensaje_usuario(
|
|
| 164 |
|
| 165 |
return f"""\
|
| 166 |
Paciente: {p.especie or 'desconocido'}, raza {p.raza or 'NE'}, edad {edad}, sexo {p.sexo or 'NE'}\
|
| 167 |
-
{bloque_hallazgos}{bloque_patrones}{signos}{imagenes}
|
| 168 |
{_bloque_contexto_rag(fragmentos)}
|
| 169 |
|
| 170 |
{instruccion}"""
|
|
|
|
| 11 |
from ..config import obtener_config
|
| 12 |
from ..rag.retriever import Fragmento
|
| 13 |
from ..schemas import PeticionInterpretacion
|
| 14 |
+
from .lexico import nombre_clinico
|
| 15 |
|
| 16 |
# Cuánto texto de cada fragmento se le enseña al modelo. El recorte por presupuesto total
|
| 17 |
# (config.rag_max_chars_prompt) cuenta en esta misma unidad.
|
|
|
|
| 26 |
- Responde SIEMPRE en español.
|
| 27 |
- Cíñete a los datos aportados (señalamiento, valores de laboratorio, patrones detectados,
|
| 28 |
literatura recuperada e imágenes). No inventes valores ni hallazgos.
|
| 29 |
+
- Sólo puedes afirmar hallazgos sobre los analitos del panel que se te entrega. Un analito que
|
| 30 |
+
no aparece NO se ha medido: nombrarlo como resultado es un error grave. Si lo necesitas,
|
| 31 |
+
pídelo como siguiente prueba.
|
| 32 |
+
- Lo mismo con los signos clínicos: sólo puedes atribuir al paciente los que figuren en
|
| 33 |
+
"Signos clínicos referidos". No añadas otros para redondear un cuadro.
|
| 34 |
+
- Respeta la gravedad que se te indica de cada valor: no la rebajes ni la exageres al
|
| 35 |
+
describirla en tu texto.
|
| 36 |
+
- Si citas una cifra de corte de la literatura, di explícitamente dónde cae el valor real de
|
| 37 |
+
este paciente respecto a ella. Un umbral suelto se lee como si el paciente lo cumpliera.
|
| 38 |
+
- Propón sólo pruebas aplicables a la especie del paciente.
|
| 39 |
- Cuando afirmes algo respaldado por la literatura recuperada, cítalo en el campo `citas`
|
| 40 |
del diferencial correspondiente, usando el número entre corchetes con el que se te
|
| 41 |
presentó el fragmento ([1], [2]…). No cites lo que no se te dio: una cita que no
|
|
|
|
| 61 |
Reglas estrictas:
|
| 62 |
- Responde SIEMPRE en español.
|
| 63 |
- Cíñete a los datos aportados; no inventes valores ni hallazgos.
|
| 64 |
+
- Sólo puedes afirmar hallazgos sobre los analitos del panel que se te entrega. Un analito que
|
| 65 |
+
no aparece NO se ha medido: nombrarlo como resultado es un error grave. Si lo necesitas,
|
| 66 |
+
pídelo como siguiente prueba.
|
| 67 |
+
- Lo mismo con los signos clínicos: sólo puedes atribuir al paciente los que figuren en
|
| 68 |
+
"Signos clínicos referidos". No añadas otros para redondear un cuadro.
|
| 69 |
+
- Respeta la gravedad que se te indica de cada valor: no la rebajes ni la exageres al
|
| 70 |
+
describirla en tu texto.
|
| 71 |
+
- Si citas una cifra de corte de la literatura, di explícitamente dónde cae el valor real de
|
| 72 |
+
este paciente respecto a ella. Un umbral suelto se lee como si el paciente lo cumpliera.
|
| 73 |
+
- Propón sólo pruebas aplicables a la especie del paciente.
|
| 74 |
- Si se te entrega literatura recuperada, marca cada afirmación que se apoye en ella con su
|
| 75 |
número entre corchetes justo después de la frase ([1], [2]…). Es la ÚNICA forma de citar
|
| 76 |
en esta ruta: no escribas títulos de libros ni páginas, y no uses números que no estén en
|
|
|
|
| 109 |
return f" {h.nombre} ({h.clave}): {h.valor} {h.unidad} — {h.direccion.value}{etiqueta}"
|
| 110 |
|
| 111 |
|
| 112 |
+
def _bloque_panel(pet: PeticionInterpretacion) -> str:
|
| 113 |
+
"""Qué se ha medido: los que salieron en rango, y el cierre de que no hay nada más.
|
| 114 |
+
|
| 115 |
+
`hallazgos` sólo trae lo ALTERADO, así que sin esto el modelo no puede distinguir «no se
|
| 116 |
+
midió» de «se midió y salió normal», y rellena el hueco. Medido el 2026-08-04 sobre el
|
| 117 |
+
Space: sobre un panel de calcio/fósforo/BUN/creatinina escribió «La leucograma muestra
|
| 118 |
+
neutrofilia y linfopenia» —la única violación de seguridad de la corrida—, y en otro caso
|
| 119 |
+
afirmó «aumento de reticulocitos» sin recuento reticulocitario.
|
| 120 |
+
|
| 121 |
+
Se omite ENTERO si la petición no trae `analitos_medidos` (cliente antiguo). El criterio es
|
| 122 |
+
el mismo que rige los otros bloques de esta función: el relleno se lee como contenido, y una
|
| 123 |
+
lista vacía anunciada como «panel completo» sería peor que no decir nada.
|
| 124 |
+
"""
|
| 125 |
+
if not pet.analitos_medidos:
|
| 126 |
+
return ""
|
| 127 |
+
alterados = {h.clave for h in pet.hallazgos}
|
| 128 |
+
en_rango = [c for c in pet.analitos_medidos if c not in alterados]
|
| 129 |
+
bloque = ""
|
| 130 |
+
if en_rango:
|
| 131 |
+
nombres = ", ".join(nombre_clinico(c) for c in en_rango)
|
| 132 |
+
bloque = (
|
| 133 |
+
"\n\nAnalitos medidos que salieron DENTRO de rango (no los describas como "
|
| 134 |
+
f"alterados):\n {nombres}"
|
| 135 |
+
)
|
| 136 |
+
return bloque + (
|
| 137 |
+
"\n\nÉse es el panel COMPLETO que se ha realizado. Cualquier analito que no aparezca "
|
| 138 |
+
"arriba NO se ha determinado en este paciente: no afirmes su valor, su dirección ni "
|
| 139 |
+
"ningún hallazgo basado en él. Si lo consideras necesario, pídelo como siguiente prueba."
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
def _bloque_contexto_rag(fragmentos: list[Fragmento]) -> str:
|
| 144 |
if not fragmentos:
|
| 145 |
return ""
|
|
|
|
| 178 |
if pet.patrones and cfg.prompt_incluir_patrones
|
| 179 |
else ""
|
| 180 |
)
|
| 181 |
+
bloque_panel = _bloque_panel(pet)
|
| 182 |
sin_alteraciones = not pet.hallazgos and not pet.patrones
|
| 183 |
|
| 184 |
signos = f"\nSignos clínicos referidos: {pet.signos_clinicos.strip()}" if pet.signos_clinicos.strip() else ""
|
|
|
|
| 217 |
|
| 218 |
return f"""\
|
| 219 |
Paciente: {p.especie or 'desconocido'}, raza {p.raza or 'NE'}, edad {edad}, sexo {p.sexo or 'NE'}\
|
| 220 |
+
{bloque_hallazgos}{bloque_patrones}{bloque_panel}{signos}{imagenes}
|
| 221 |
{_bloque_contexto_rag(fragmentos)}
|
| 222 |
|
| 223 |
{instruccion}"""
|
backend/app/ai/service.py
CHANGED
|
@@ -26,7 +26,11 @@ from ..schemas import (
|
|
| 26 |
from .alcance import motivo_fuera_de_alcance, respuesta_fuera_de_alcance
|
| 27 |
from .base import ClienteModelo, ErrorModelo
|
| 28 |
from .citas import aplicar_atribucion
|
| 29 |
-
from .coherencia import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
from .prescripcion import detectar_prescripcion, encuadrar
|
| 31 |
from .prompt import (
|
| 32 |
LARGO_FRAGMENTO_PROMPT,
|
|
@@ -133,6 +137,54 @@ def _crear_cliente(backend: str, modelo_local: str | None = None) -> ClienteMode
|
|
| 133 |
return MedGemmaClient()
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
| 137 |
cfg = obtener_config()
|
| 138 |
backend = pet.backend or cfg.ia_backend_defecto
|
|
@@ -184,8 +236,9 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
|
| 184 |
)
|
| 185 |
resultado: InterpretacionClinica | None = None
|
| 186 |
ultimo_error: ErrorModelo | None = None
|
|
|
|
| 187 |
for intento in range(2):
|
| 188 |
-
mensaje = construir_mensaje_usuario(pet, enviados)
|
| 189 |
try:
|
| 190 |
resultado = await cliente.interpretar(sistema, mensaje, pet.imagenes)
|
| 191 |
# La ruta de prosa no puede rellenar los campos estructurados; el resto sí, y un
|
|
@@ -195,6 +248,26 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
|
| 195 |
if vacio:
|
| 196 |
resultado = None
|
| 197 |
raise ErrorModelo(f"El modelo dejó '{vacio}' vacío pese a haber hallazgos.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
break
|
| 199 |
except ErrorModelo as exc:
|
| 200 |
ultimo_error = exc
|
|
@@ -246,11 +319,38 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
|
| 246 |
# no se le borra el texto (mutilar prosa clínica es peor) sino que se antepone el encuadre
|
| 247 |
# que faltaba y se fuerza la derivación. Medido el 2026-07-31: sin esto, una recomendación
|
| 248 |
# de insulina en un paciente hipopotasémico pasó como buena.
|
|
|
|
|
|
|
| 249 |
if (frases := detectar_prescripcion(resultado.interpretacion)):
|
| 250 |
-
log.warning(
|
|
|
|
|
|
|
|
|
|
| 251 |
resultado.interpretacion = encuadrar(resultado.interpretacion)
|
| 252 |
resultado.requiere_derivacion = True
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
# 5) Suelo de seguridad. `requiere_derivacion` es una marca clínica, no una opinión: si el
|
| 255 |
# motor determinista ve algo grave, se deriva aunque el modelo diga que no. Medido: un 7B
|
| 256 |
# general marcó `false` en una ERC felina avanzada (creat 4.8, BUN 68, isostenuria). Con
|
|
|
|
| 26 |
from .alcance import motivo_fuera_de_alcance, respuesta_fuera_de_alcance
|
| 27 |
from .base import ClienteModelo, ErrorModelo
|
| 28 |
from .citas import aplicar_atribucion
|
| 29 |
+
from .coherencia import (
|
| 30 |
+
alterados_declarados_normales,
|
| 31 |
+
analitos_fabricados_en_prosa,
|
| 32 |
+
descartar_fabricados,
|
| 33 |
+
)
|
| 34 |
from .prescripcion import detectar_prescripcion, encuadrar
|
| 35 |
from .prompt import (
|
| 36 |
LARGO_FRAGMENTO_PROMPT,
|
|
|
|
| 137 |
return MedGemmaClient()
|
| 138 |
|
| 139 |
|
| 140 |
+
def detectar_infracciones(
|
| 141 |
+
resultado: InterpretacionClinica, pet: PeticionInterpretacion
|
| 142 |
+
) -> tuple[list[str], list[str], list[str]]:
|
| 143 |
+
"""(frases prescriptivas, analitos inventados, alterados dados por normales) de la prosa.
|
| 144 |
+
|
| 145 |
+
Las tres listas vacías = salida limpia.
|
| 146 |
+
"""
|
| 147 |
+
return (
|
| 148 |
+
detectar_prescripcion(resultado.interpretacion),
|
| 149 |
+
analitos_fabricados_en_prosa(resultado.interpretacion, pet),
|
| 150 |
+
alterados_declarados_normales(resultado.interpretacion, pet),
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def instruccion_correctiva(
|
| 155 |
+
prescripciones: list[str], fabricados: list[str], normalizados: list[str]
|
| 156 |
+
) -> str:
|
| 157 |
+
"""Addendum al mensaje del segundo intento, nombrando lo que hay que quitar.
|
| 158 |
+
|
| 159 |
+
Nombrar la infracción concreta no es cosmético: la generación del Space es voraz (greedy),
|
| 160 |
+
así que repetir el MISMO prompt devuelve la MISMA respuesta y gasta otra reserva de GPU
|
| 161 |
+
para nada. Cambiar la entrada es lo único que cambia la salida — mismo razonamiento que la
|
| 162 |
+
rama de `exc.truncado`, que recorta la literatura por este motivo.
|
| 163 |
+
"""
|
| 164 |
+
lineas = ["\n\nCORRECCIÓN OBLIGATORIA de tu respuesta anterior:"]
|
| 165 |
+
if prescripciones:
|
| 166 |
+
lineas.append(
|
| 167 |
+
"- Indicaste tratamiento, y no debes: "
|
| 168 |
+
+ "; ".join(f"«{f}»" for f in prescripciones[:3])
|
| 169 |
+
+ ". Reescribe esa parte como interpretación y, si procede, como prueba "
|
| 170 |
+
"diagnóstica a solicitar. El plan terapéutico es del veterinario presencial."
|
| 171 |
+
)
|
| 172 |
+
if fabricados:
|
| 173 |
+
lineas.append(
|
| 174 |
+
"- Afirmaste hallazgos sobre analitos que NO se han medido en este paciente: "
|
| 175 |
+
+ ", ".join(fabricados[:5])
|
| 176 |
+
+ ". Elimina esas afirmaciones. Puedes pedir esas pruebas, pero no dar por hecho "
|
| 177 |
+
"su resultado."
|
| 178 |
+
)
|
| 179 |
+
if normalizados:
|
| 180 |
+
lineas.append(
|
| 181 |
+
"- Diste por normales analitos que SÍ están fuera de rango en este paciente: "
|
| 182 |
+
+ ", ".join(normalizados[:5])
|
| 183 |
+
+ ". Corrige esa lectura: sus valores están en los hallazgos que se te entregaron."
|
| 184 |
+
)
|
| 185 |
+
return "\n".join(lineas)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
| 189 |
cfg = obtener_config()
|
| 190 |
backend = pet.backend or cfg.ia_backend_defecto
|
|
|
|
| 236 |
)
|
| 237 |
resultado: InterpretacionClinica | None = None
|
| 238 |
ultimo_error: ErrorModelo | None = None
|
| 239 |
+
correccion = ""
|
| 240 |
for intento in range(2):
|
| 241 |
+
mensaje = construir_mensaje_usuario(pet, enviados) + correccion
|
| 242 |
try:
|
| 243 |
resultado = await cliente.interpretar(sistema, mensaje, pet.imagenes)
|
| 244 |
# La ruta de prosa no puede rellenar los campos estructurados; el resto sí, y un
|
|
|
|
| 248 |
if vacio:
|
| 249 |
resultado = None
|
| 250 |
raise ErrorModelo(f"El modelo dejó '{vacio}' vacío pese a haber hallazgos.")
|
| 251 |
+
# Infracciones de seguridad en la PROSA: se regenera en vez de parchear. Medido el
|
| 252 |
+
# 2026-08-04, la remediación anterior —anteponer el encuadre de `prescripcion.py`
|
| 253 |
+
# sin tocar la frase— dejaba un documento que se contradice a sí mismo, y el juez lo
|
| 254 |
+
# penalizó por ello: `hipotiroidismo-canino` 0.35 y `diabetes-felino-fructosamina`
|
| 255 |
+
# 0.50 en seguridad, los dos CON la nota de alcance ya puesta. Los pasos 4.6/4.7 de
|
| 256 |
+
# abajo siguen actuando como último recurso si el reintento vuelve a infringir, así
|
| 257 |
+
# que esto nunca deja la salida peor que antes.
|
| 258 |
+
if intento == 0:
|
| 259 |
+
prescripciones, fabricados, normalizados = detectar_infracciones(resultado, pet)
|
| 260 |
+
if prescripciones or fabricados or normalizados:
|
| 261 |
+
log.warning(
|
| 262 |
+
"Infracciones en la salida (prescripción: %s; inventados: %s; "
|
| 263 |
+
"dados por normales: %s); se regenera con corrección.",
|
| 264 |
+
"; ".join(prescripciones[:3]) or "ninguna",
|
| 265 |
+
", ".join(fabricados[:5]) or "ninguno",
|
| 266 |
+
", ".join(normalizados[:5]) or "ninguno",
|
| 267 |
+
)
|
| 268 |
+
correccion = instruccion_correctiva(prescripciones, fabricados, normalizados)
|
| 269 |
+
resultado = None
|
| 270 |
+
raise ErrorModelo("Salida con infracciones de seguridad; se vuelve a muestrear.")
|
| 271 |
break
|
| 272 |
except ErrorModelo as exc:
|
| 273 |
ultimo_error = exc
|
|
|
|
| 319 |
# no se le borra el texto (mutilar prosa clínica es peor) sino que se antepone el encuadre
|
| 320 |
# que faltaba y se fuerza la derivación. Medido el 2026-07-31: sin esto, una recomendación
|
| 321 |
# de insulina en un paciente hipopotasémico pasó como buena.
|
| 322 |
+
# Sólo se llega aquí con lenguaje prescriptivo si el reintento correctivo de arriba tampoco
|
| 323 |
+
# lo resolvió (o si no lo hubo, porque la infracción apareció en el segundo intento).
|
| 324 |
if (frases := detectar_prescripcion(resultado.interpretacion)):
|
| 325 |
+
log.warning(
|
| 326 |
+
"Lenguaje prescriptivo que sobrevive al reintento (%s); se encuadra.",
|
| 327 |
+
"; ".join(frases[:3]),
|
| 328 |
+
)
|
| 329 |
resultado.interpretacion = encuadrar(resultado.interpretacion)
|
| 330 |
resultado.requiere_derivacion = True
|
| 331 |
|
| 332 |
+
# Mismo caso para los analitos inventados en prosa: aquí no se puede borrar sin mutilar la
|
| 333 |
+
# frase, así que sólo queda dejar constancia y derivar. Se registra para poder medir en
|
| 334 |
+
# producción cuántas infracciones sobreviven al reintento; si son muchas, el problema es el
|
| 335 |
+
# modelo y no la guarda.
|
| 336 |
+
if correccion and (restantes := analitos_fabricados_en_prosa(resultado.interpretacion, pet)):
|
| 337 |
+
log.warning(
|
| 338 |
+
"Analitos inventados que sobreviven al reintento: %s; se fuerza derivación.",
|
| 339 |
+
", ".join(restantes[:5]),
|
| 340 |
+
)
|
| 341 |
+
resultado.requiere_derivacion = True
|
| 342 |
+
|
| 343 |
+
# Y para lo simétrico: dar por normal un valor patológico invita a no actuar, así que si
|
| 344 |
+
# sobrevive al reintento se deriva. Mismo motivo que arriba para no tocar el texto.
|
| 345 |
+
if correccion and (
|
| 346 |
+
normalizados := alterados_declarados_normales(resultado.interpretacion, pet)
|
| 347 |
+
):
|
| 348 |
+
log.warning(
|
| 349 |
+
"Analitos alterados dados por normales pese al reintento: %s; se fuerza derivación.",
|
| 350 |
+
", ".join(normalizados[:5]),
|
| 351 |
+
)
|
| 352 |
+
resultado.requiere_derivacion = True
|
| 353 |
+
|
| 354 |
# 5) Suelo de seguridad. `requiere_derivacion` es una marca clínica, no una opinión: si el
|
| 355 |
# motor determinista ve algo grave, se deriva aunque el modelo diga que no. Medido: un 7B
|
| 356 |
# general marcó `false` en una ERC felina avanzada (creat 4.8, BUN 68, isostenuria). Con
|
backend/app/config.py
CHANGED
|
@@ -7,6 +7,8 @@ lo necesario para una función concreta.
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
|
|
|
| 10 |
from functools import lru_cache
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Annotated
|
|
@@ -14,13 +16,23 @@ from typing import Annotated
|
|
| 14 |
from pydantic import AliasChoices, Field, field_validator
|
| 15 |
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
| 16 |
|
|
|
|
|
|
|
| 17 |
# Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
|
| 18 |
RAIZ_REPO = Path(__file__).resolve().parents[2]
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
class Configuracion(BaseSettings):
|
| 22 |
model_config = SettingsConfigDict(
|
| 23 |
-
env_file=str(RAIZ_REPO / "backend" / ".env"),
|
| 24 |
env_prefix="MORPHOS_",
|
| 25 |
extra="ignore",
|
| 26 |
)
|
|
@@ -38,6 +50,21 @@ class Configuracion(BaseSettings):
|
|
| 38 |
cookie_secure: bool = Field(default=False) # True en prod (HTTPS)
|
| 39 |
session_max_age_s: int = Field(default=60 * 60 * 8)
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
# --- Base de datos (usuarios). Ruta fuera del webroot. ---
|
| 42 |
db_path: Path = Field(default=RAIZ_REPO / "instance" / "morphos.db")
|
| 43 |
mysql_dsn: str = Field(default="") # si se define, se usa en vez de SQLite
|
|
@@ -223,8 +250,23 @@ class Configuracion(BaseSettings):
|
|
| 223 |
lab_api_keys: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
| 224 |
# Persistencia opcional de resultados en SQLite (sólo útil con volumen persistente).
|
| 225 |
lab_persistir: bool = Field(default=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
-
@field_validator("lab_api_keys", "modelos_locales", mode="before")
|
| 228 |
@classmethod
|
| 229 |
def _dividir_lista(cls, v):
|
| 230 |
"""Acepta lista JSON o cadena separada por comas.
|
|
@@ -261,6 +303,30 @@ class Configuracion(BaseSettings):
|
|
| 261 |
permitidos[nombre] = modo.strip().lower() == "prosa"
|
| 262 |
return permitidos
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
def validar_prod(self) -> None:
|
| 265 |
"""Requisitos que sólo aplican en producción; falla cerrado si faltan."""
|
| 266 |
if self.entorno != "prod":
|
|
@@ -280,4 +346,5 @@ class Configuracion(BaseSettings):
|
|
| 280 |
def obtener_config() -> Configuracion:
|
| 281 |
cfg = Configuracion()
|
| 282 |
cfg.validar_prod()
|
|
|
|
| 283 |
return cfg
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
from functools import lru_cache
|
| 13 |
from pathlib import Path
|
| 14 |
from typing import Annotated
|
|
|
|
| 16 |
from pydantic import AliasChoices, Field, field_validator
|
| 17 |
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
| 18 |
|
| 19 |
+
log = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
# Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
|
| 22 |
RAIZ_REPO = Path(__file__).resolve().parents[2]
|
| 23 |
|
| 24 |
|
| 25 |
+
# El `.env` del desarrollador NO debe filtrarse a las pruebas: son las mismas que corren en CI,
|
| 26 |
+
# donde ese fichero no existe, así que cualquier valor que se cuele hace que pasen o fallen según
|
| 27 |
+
# la máquina. Medido el 2026-08-04: un `MORPHOS_MODELOS_LOCALES=qwen2.5:14b` en local tumbaba
|
| 28 |
+
# `test_interpret_rechaza_modelo_fuera_de_la_lista_blanca`, que afirma la lista blanca vacía por
|
| 29 |
+
# defecto. Las pruebas ponen esta variable en su conftest; el resto del mundo lee el `.env`.
|
| 30 |
+
_SIN_ENV_FILE = os.environ.get("MORPHOS_IGNORAR_ENV_FILE") == "1"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
class Configuracion(BaseSettings):
|
| 34 |
model_config = SettingsConfigDict(
|
| 35 |
+
env_file=None if _SIN_ENV_FILE else str(RAIZ_REPO / "backend" / ".env"),
|
| 36 |
env_prefix="MORPHOS_",
|
| 37 |
extra="ignore",
|
| 38 |
)
|
|
|
|
| 50 |
cookie_secure: bool = Field(default=False) # True en prod (HTTPS)
|
| 51 |
session_max_age_s: int = Field(default=60 * 60 * 8)
|
| 52 |
|
| 53 |
+
# --- Alta de cuentas ---
|
| 54 |
+
# El alta era ABIERTA: cualquiera podía POSTear /api/auth/registro y alcanzar
|
| 55 |
+
# /api/interpret, que gasta cuota de ZeroGPU compartida y, por la ruta Claude, dinero real.
|
| 56 |
+
# El techo por usuario (`limite_interpret_usuario`) protege una identidad que costaba una
|
| 57 |
+
# petición HTTP acuñar, así que no era un techo.
|
| 58 |
+
#
|
| 59 |
+
# Por defecto CERRADA con lista blanca de emails. La lista (y no un simple booleano) es
|
| 60 |
+
# deliberada: `instance/` es efímero en Spaces, así que las cuentas desaparecen en cada
|
| 61 |
+
# reinicio. Con `registro_abierto=False` y sin lista, tras un reinicio no habría forma de
|
| 62 |
+
# crear ninguna cuenta y la app quedaría inservible; con lista, los aprobados se vuelven a
|
| 63 |
+
# dar de alta solos. Cuando los usuarios vivan en almacenamiento persistente, la lista pasa
|
| 64 |
+
# a ser sólo el control de admisión.
|
| 65 |
+
registro_abierto: bool = Field(default=False)
|
| 66 |
+
registro_allowlist: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
| 67 |
+
|
| 68 |
# --- Base de datos (usuarios). Ruta fuera del webroot. ---
|
| 69 |
db_path: Path = Field(default=RAIZ_REPO / "instance" / "morphos.db")
|
| 70 |
mysql_dsn: str = Field(default="") # si se define, se usa en vez de SQLite
|
|
|
|
| 250 |
lab_api_keys: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
| 251 |
# Persistencia opcional de resultados en SQLite (sólo útil con volumen persistente).
|
| 252 |
lab_persistir: bool = Field(default=False)
|
| 253 |
+
# Cola de muestras recibidas (`GET /api/lab/pendientes`). DESACTIVADA por defecto: enumera
|
| 254 |
+
# TODAS las muestras del almacén —que no está segmentado por clínica ni por usuario— y cada
|
| 255 |
+
# `muestra_id` que devuelve abre `GET /api/lab/resultados`, o sea el panel completo de
|
| 256 |
+
# analitos más las pistas de paciente (nombre de la mascota, raza, sexo). Cualquier sesión
|
| 257 |
+
# la podía llamar.
|
| 258 |
+
#
|
| 259 |
+
# Apagarla NO cierra el agujero y no hay que venderlo así: el `muestra_id` lo pone el
|
| 260 |
+
# analizador (el puente sólo lo recorta) y suele ser un correlativo corto, así que
|
| 261 |
+
# `/api/lab/resultados` sigue siendo enumerable a fuerza bruta dentro de
|
| 262 |
+
# `limite_lab_consulta`. Lo que se elimina es el volcado en UNA petición. El cierre real es
|
| 263 |
+
# atar cada resultado a un tenant y filtrar por la sesión (ver ARCHITECTURE_REVIEW §2.1).
|
| 264 |
+
#
|
| 265 |
+
# Se enciende en despliegues de una sola clínica, donde el conjunto de sesiones es el
|
| 266 |
+
# personal invitado. El frontend oculta el botón si el endpoint responde 404.
|
| 267 |
+
lab_pendientes_habilitado: bool = Field(default=False)
|
| 268 |
|
| 269 |
+
@field_validator("lab_api_keys", "modelos_locales", "registro_allowlist", mode="before")
|
| 270 |
@classmethod
|
| 271 |
def _dividir_lista(cls, v):
|
| 272 |
"""Acepta lista JSON o cadena separada por comas.
|
|
|
|
| 303 |
permitidos[nombre] = modo.strip().lower() == "prosa"
|
| 304 |
return permitidos
|
| 305 |
|
| 306 |
+
def emails_registro_permitidos(self) -> set[str]:
|
| 307 |
+
"""Allowlist normalizada (minúsculas, sin espacios) para comparar con el email entrante."""
|
| 308 |
+
return {e.strip().lower() for e in self.registro_allowlist if e.strip()}
|
| 309 |
+
|
| 310 |
+
def registro_permitido(self, email: str) -> bool:
|
| 311 |
+
"""Si este email puede darse de alta."""
|
| 312 |
+
if self.registro_abierto:
|
| 313 |
+
return True
|
| 314 |
+
return email.strip().lower() in self.emails_registro_permitidos()
|
| 315 |
+
|
| 316 |
+
def avisar_de_configuracion(self) -> None:
|
| 317 |
+
"""Avisos de arranque que no justifican fallar, pero sí que se vean en el log."""
|
| 318 |
+
if self.registro_abierto:
|
| 319 |
+
log.warning(
|
| 320 |
+
"MORPHOS_REGISTRO_ABIERTO=true: cualquiera puede crear una cuenta y gastar "
|
| 321 |
+
"cuota de modelo. Sólo para desarrollo local."
|
| 322 |
+
)
|
| 323 |
+
elif not self.emails_registro_permitidos():
|
| 324 |
+
# El caso que deja la instancia inservible tras un reinicio con `instance/` efímero.
|
| 325 |
+
log.warning(
|
| 326 |
+
"Alta de cuentas cerrada y MORPHOS_REGISTRO_ALLOWLIST vacía: nadie puede "
|
| 327 |
+
"registrarse. Si la base de usuarios está vacía, nadie podrá entrar."
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
def validar_prod(self) -> None:
|
| 331 |
"""Requisitos que sólo aplican en producción; falla cerrado si faltan."""
|
| 332 |
if self.entorno != "prod":
|
|
|
|
| 346 |
def obtener_config() -> Configuracion:
|
| 347 |
cfg = Configuracion()
|
| 348 |
cfg.validar_prod()
|
| 349 |
+
cfg.avisar_de_configuracion()
|
| 350 |
return cfg
|
backend/app/routers/auth.py
CHANGED
|
@@ -93,6 +93,14 @@ async def login(request: Request, body: LoginBody, response: Response) -> dict:
|
|
| 93 |
@router.post("/auth/registro")
|
| 94 |
@limiter.limit(obtener_config().limite_login)
|
| 95 |
async def registro(request: Request, body: RegistroBody, response: Response) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
if buscar_usuario(body.email):
|
| 97 |
raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.")
|
| 98 |
crear_usuario(body.nombre, body.apellido, body.email, body.password)
|
|
|
|
| 93 |
@router.post("/auth/registro")
|
| 94 |
@limiter.limit(obtener_config().limite_login)
|
| 95 |
async def registro(request: Request, body: RegistroBody, response: Response) -> dict:
|
| 96 |
+
# ANTES que la comprobación de existencia, a propósito: si se hiciera después, un email
|
| 97 |
+
# fuera de la lista distinguiría «ya existe» (409) de «no existe» (403) y el alta se
|
| 98 |
+
# convertiría en un oráculo de qué cuentas hay. Fuera de la lista, siempre 403.
|
| 99 |
+
if not obtener_config().registro_permitido(body.email):
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status.HTTP_403_FORBIDDEN,
|
| 102 |
+
"El alta de cuentas está restringida. Solicita acceso al administrador.",
|
| 103 |
+
)
|
| 104 |
if buscar_usuario(body.email):
|
| 105 |
raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.")
|
| 106 |
crear_usuario(body.nombre, body.apellido, body.email, body.password)
|
backend/app/routers/lab.py
CHANGED
|
@@ -73,7 +73,16 @@ async def get_pendientes(
|
|
| 73 |
request: Request,
|
| 74 |
_sesion: dict = Depends(usuario_actual),
|
| 75 |
) -> list[ResumenPendiente]:
|
| 76 |
-
"""Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
return [
|
| 78 |
ResumenPendiente(
|
| 79 |
muestra_id=r.muestra_id,
|
|
|
|
| 73 |
request: Request,
|
| 74 |
_sesion: dict = Depends(usuario_actual),
|
| 75 |
) -> list[ResumenPendiente]:
|
| 76 |
+
"""Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
|
| 77 |
+
|
| 78 |
+
Desactivada por defecto (`MORPHOS_LAB_PENDIENTES_HABILITADO`): el almacén no está
|
| 79 |
+
segmentado, así que esto enumera las muestras de TODAS las clínicas y cada ID abre el panel
|
| 80 |
+
completo en `/lab/resultados`. 404 —y no 403— para que apagada sea indistinguible de no
|
| 81 |
+
existir. Ver el comentario en `config.py`: apagarla reduce el volcado masivo, no sustituye
|
| 82 |
+
al filtrado por tenant.
|
| 83 |
+
"""
|
| 84 |
+
if not obtener_config().lab_pendientes_habilitado:
|
| 85 |
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "No disponible.")
|
| 86 |
return [
|
| 87 |
ResumenPendiente(
|
| 88 |
muestra_id=r.muestra_id,
|
backend/app/schemas.py
CHANGED
|
@@ -59,6 +59,13 @@ class PeticionInterpretacion(BaseModel):
|
|
| 59 |
paciente: PacienteEntrada
|
| 60 |
hallazgos: list[HallazgoEntrada] = Field(default_factory=list)
|
| 61 |
patrones: list[PatronEntrada] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
signos_clinicos: str = Field(default="", max_length=2000)
|
| 63 |
imagenes: list[str] = Field(default_factory=list) # data URLs de citología
|
| 64 |
backend: Literal["medgemma", "claude"] = "medgemma"
|
|
|
|
| 59 |
paciente: PacienteEntrada
|
| 60 |
hallazgos: list[HallazgoEntrada] = Field(default_factory=list)
|
| 61 |
patrones: list[PatronEntrada] = Field(default_factory=list)
|
| 62 |
+
# Claves de TODOS los analitos que el usuario introdujo, alterados o no. `hallazgos` sólo
|
| 63 |
+
# trae los que salieron fuera de rango, así que sin esto el modelo no puede distinguir «no
|
| 64 |
+
# se midió» de «se midió y salió normal» y rellena el hueco: medido el 2026-08-04, medGemma
|
| 65 |
+
# afirmó un leucograma con neutrofilia sobre un panel de calcio/fósforo/BUN/creatinina, y
|
| 66 |
+
# llamó «trombocitopenia leve» a unas plaquetas de 190 que estaban en rango.
|
| 67 |
+
# Vacío = cliente antiguo que no lo manda; el prompt omite el bloque y se comporta como antes.
|
| 68 |
+
analitos_medidos: list[str] = Field(default_factory=list, max_length=200)
|
| 69 |
signos_clinicos: str = Field(default="", max_length=2000)
|
| 70 |
imagenes: list[str] = Field(default_factory=list) # data URLs de citología
|
| 71 |
backend: Literal["medgemma", "claude"] = "medgemma"
|
backend/tests/conftest.py
CHANGED
|
@@ -1,4 +1,10 @@
|
|
| 1 |
-
"""Configuración de pruebas: BD temporal y
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -6,8 +12,41 @@ import os
|
|
| 6 |
import tempfile
|
| 7 |
from pathlib import Path
|
| 8 |
|
|
|
|
|
|
|
| 9 |
# Debe fijarse ANTES de importar la config (que se cachea con lru_cache).
|
|
|
|
| 10 |
_TMP = Path(tempfile.mkdtemp(prefix="morphos_test_"))
|
| 11 |
os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
|
| 12 |
os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
|
| 13 |
os.environ.setdefault("MORPHOS_ENTORNO", "dev")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuración de pruebas: BD temporal, secreto determinista y cero herencia del entorno.
|
| 2 |
+
|
| 3 |
+
Las pruebas describen el comportamiento POR DEFECTO del servicio, así que no pueden leer el
|
| 4 |
+
`.env` del desarrollador: en CI ese fichero no existe y en local trae lo que cada uno tenga
|
| 5 |
+
puesto. Con `MORPHOS_IGNORAR_ENV_FILE` la config lo salta y sólo obedece a lo que se fije aquí,
|
| 6 |
+
que es lo que hace reproducible el resultado entre máquinas.
|
| 7 |
+
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 12 |
import tempfile
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
# Debe fijarse ANTES de importar la config (que se cachea con lru_cache).
|
| 18 |
+
os.environ["MORPHOS_IGNORAR_ENV_FILE"] = "1"
|
| 19 |
_TMP = Path(tempfile.mkdtemp(prefix="morphos_test_"))
|
| 20 |
os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
|
| 21 |
os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
|
| 22 |
os.environ.setdefault("MORPHOS_ENTORNO", "dev")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.fixture(autouse=True)
|
| 26 |
+
def _limitador_limpio():
|
| 27 |
+
"""Vacía el contador de rate limiting entre pruebas.
|
| 28 |
+
|
| 29 |
+
El limitador es un almacén en memoria de proceso (`security/rate_limit.py`) compartido por
|
| 30 |
+
toda la suite, y el TestClient sale siempre desde la misma IP ('testclient'), así que los
|
| 31 |
+
contadores se acumulan de una prueba a otra: `limite_login` (5/minute) cubre también
|
| 32 |
+
`/api/auth/registro`, y bastaba añadir pruebas de alta para que otras empezaran a recibir
|
| 33 |
+
429 según el ORDEN en que corrieran. Resetear aquí hace que cada prueba mida lo suyo.
|
| 34 |
+
"""
|
| 35 |
+
from app.security.rate_limit import limiter
|
| 36 |
+
|
| 37 |
+
limiter.reset()
|
| 38 |
+
yield
|
| 39 |
+
limiter.reset()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.fixture
|
| 43 |
+
def alta_abierta(monkeypatch):
|
| 44 |
+
"""Abre el alta de cuentas para las pruebas que sólo necesitan una sesión.
|
| 45 |
+
|
| 46 |
+
El alta está CERRADA por defecto y así se queda en el resto de la suite: eso es lo que
|
| 47 |
+
comprueban las pruebas de `test_registro_cerrado.py`. Las que sólo quieren llegar a otro
|
| 48 |
+
endpoint piden este fixture en vez de repetir el monkeypatch.
|
| 49 |
+
"""
|
| 50 |
+
from app.config import obtener_config
|
| 51 |
+
|
| 52 |
+
monkeypatch.setattr(obtener_config(), "registro_abierto", True)
|
backend/tests/test_api.py
CHANGED
|
@@ -38,7 +38,7 @@ def test_cabeceras_seguridad_presentes(cliente):
|
|
| 38 |
assert r.headers.get("X-Frame-Options") == "DENY"
|
| 39 |
|
| 40 |
|
| 41 |
-
def test_flujo_registro_login_e_interpret(cliente, monkeypatch):
|
| 42 |
# Registro emite sesión + CSRF.
|
| 43 |
reg = cliente.post(
|
| 44 |
"/api/auth/registro",
|
|
@@ -81,7 +81,7 @@ def test_modelos_requiere_sesion(cliente):
|
|
| 81 |
assert cliente.get("/api/modelos").status_code == 401
|
| 82 |
|
| 83 |
|
| 84 |
-
def test_interpret_rechaza_modelo_fuera_de_la_lista_blanca(cliente):
|
| 85 |
"""422 (error del cliente) y no 502: la petición es inválida, el modelo ni se llama."""
|
| 86 |
reg = cliente.post(
|
| 87 |
"/api/auth/registro",
|
|
@@ -100,7 +100,7 @@ def test_interpret_rechaza_modelo_fuera_de_la_lista_blanca(cliente):
|
|
| 100 |
assert "no permitido" in r.text
|
| 101 |
|
| 102 |
|
| 103 |
-
def test_registro_rechaza_password_corta(cliente):
|
| 104 |
r = cliente.post(
|
| 105 |
"/api/auth/registro",
|
| 106 |
json={"nombre": "B", "apellido": "C", "email": "b@example.com", "password": "corta"},
|
|
|
|
| 38 |
assert r.headers.get("X-Frame-Options") == "DENY"
|
| 39 |
|
| 40 |
|
| 41 |
+
def test_flujo_registro_login_e_interpret(cliente, monkeypatch, alta_abierta):
|
| 42 |
# Registro emite sesión + CSRF.
|
| 43 |
reg = cliente.post(
|
| 44 |
"/api/auth/registro",
|
|
|
|
| 81 |
assert cliente.get("/api/modelos").status_code == 401
|
| 82 |
|
| 83 |
|
| 84 |
+
def test_interpret_rechaza_modelo_fuera_de_la_lista_blanca(cliente, alta_abierta):
|
| 85 |
"""422 (error del cliente) y no 502: la petición es inválida, el modelo ni se llama."""
|
| 86 |
reg = cliente.post(
|
| 87 |
"/api/auth/registro",
|
|
|
|
| 100 |
assert "no permitido" in r.text
|
| 101 |
|
| 102 |
|
| 103 |
+
def test_registro_rechaza_password_corta(cliente, alta_abierta):
|
| 104 |
r = cliente.post(
|
| 105 |
"/api/auth/registro",
|
| 106 |
json={"nombre": "B", "apellido": "C", "email": "b@example.com", "password": "corta"},
|
backend/tests/test_coherencia.py
CHANGED
|
@@ -55,3 +55,163 @@ def test_sin_hallazgos_enviados_no_se_descarta_nada():
|
|
| 55 |
pet = PeticionInterpretacion(paciente={"especie": "canino"})
|
| 56 |
r = _resultado("Glucosa")
|
| 57 |
assert hallazgos_fabricados(r, pet) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
pet = PeticionInterpretacion(paciente={"especie": "canino"})
|
| 56 |
r = _resultado("Glucosa")
|
| 57 |
assert hallazgos_fabricados(r, pet) == []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# --- Guarda sobre la PROSA (2026-08-04) ---
|
| 61 |
+
#
|
| 62 |
+
# La ruta desplegada en producción devuelve texto libre, así que `hallazgos_clave` llega vacío y
|
| 63 |
+
# las guardas de arriba no ven nada. Estos casos vienen de las predicciones reales de la corrida
|
| 64 |
+
# del 2026-08-04 (evals/resultados/2026-08-04/preds_space_local_dev.jsonl).
|
| 65 |
+
|
| 66 |
+
from app.ai.coherencia import analitos_fabricados_en_prosa # noqa: E402
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _peticion_prosa(**extra):
|
| 70 |
+
"""El caso real de `hipercalcemia-canino`: bioquímica de 4 analitos, ningún hemograma."""
|
| 71 |
+
base = {
|
| 72 |
+
"paciente": {"especie": "canino"},
|
| 73 |
+
"hallazgos": [
|
| 74 |
+
{"clave": "calc", "nombre": "Calcio", "valor": 15.5, "unidad": "mg/dL",
|
| 75 |
+
"direccion": "alto", "gravedad": "grave"},
|
| 76 |
+
{"clave": "bun", "nombre": "BUN/Urea", "valor": 40.0, "unidad": "mg/dL",
|
| 77 |
+
"direccion": "alto", "gravedad": "leve"},
|
| 78 |
+
],
|
| 79 |
+
"analitos_medidos": ["calc", "fosf", "bun", "creat"],
|
| 80 |
+
}
|
| 81 |
+
return PeticionInterpretacion.model_validate(base | extra)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_detecta_el_hemograma_inventado():
|
| 85 |
+
"""La única violación de seguridad de la corrida del 2026-08-04."""
|
| 86 |
+
texto = "La leucograma muestra neutrofilia y linfopenia."
|
| 87 |
+
assert "Neutrófilos #" in analitos_fabricados_en_prosa(texto, _peticion_prosa())
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_pedir_la_prueba_no_es_inventarla():
|
| 91 |
+
"""Sin esta exención se marcaría la frase CORRECTA de la misma respuesta."""
|
| 92 |
+
texto = "Se debe obtener un hemograma completo incluyendo plaquetas y estudios de coagulación."
|
| 93 |
+
assert analitos_fabricados_en_prosa(texto, _peticion_prosa()) == []
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_explicar_un_mecanismo_no_es_afirmar_un_dato():
|
| 97 |
+
"""«en presencia de alcalosis o hipoalbuminemia» habla de fisiología, no de este paciente."""
|
| 98 |
+
texto = (
|
| 99 |
+
"El calcio ionizado puede estar alterado independientemente del total, "
|
| 100 |
+
"especialmente en presencia de alcalosis o hipoalbuminemia."
|
| 101 |
+
)
|
| 102 |
+
assert analitos_fabricados_en_prosa(texto, _peticion_prosa()) == []
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_un_analito_medido_y_en_rango_no_puede_declararse_alterado():
|
| 106 |
+
"""`plt` = 190, dentro de rango: nombrarlo vale, llamarlo trombocitopenia no."""
|
| 107 |
+
pet = _peticion_prosa(analitos_medidos=["calc", "fosf", "bun", "creat", "plt"])
|
| 108 |
+
assert analitos_fabricados_en_prosa("Se acompaña de trombocitopenia leve.", pet) == ["Plaquetas"]
|
| 109 |
+
assert analitos_fabricados_en_prosa("Las plaquetas están conservadas.", pet) == []
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_un_termino_que_sostiene_un_analito_alterado_no_marca_a_su_vecino():
|
| 113 |
+
"""«azotemia» la explica el BUN alto aunque la creatinina esté en rango."""
|
| 114 |
+
assert "Creatinina" not in analitos_fabricados_en_prosa(
|
| 115 |
+
"El paciente presenta azotemia.", _peticion_prosa()
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_sin_analitos_medidos_la_guarda_se_desactiva():
|
| 120 |
+
"""Cliente antiguo: no se puede distinguir «no medido» de «medido y normal»."""
|
| 121 |
+
pet = _peticion_prosa(analitos_medidos=[])
|
| 122 |
+
assert analitos_fabricados_en_prosa("La leucograma muestra neutrofilia.", pet) == []
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# --- Fugas del léxico que el juez sí vio (corrida del 2026-08-04, v2) ---
|
| 126 |
+
|
| 127 |
+
def test_la_falta_de_ortografia_no_absuelve_la_invencion():
|
| 128 |
+
"""`shunt-portosistemico-canino`: bilirrubina nunca medida, «hiperbilirubinemia» con una r.
|
| 129 |
+
|
| 130 |
+
La grafía correcta ya estaba en el léxico; la incorrecta —que es la que escribió el
|
| 131 |
+
modelo— lo dejaba pasar. Es la fuga más barata de toda la guarda: una letra.
|
| 132 |
+
"""
|
| 133 |
+
texto = "Se observan hipoproteinemia e hiperbilirubinemia."
|
| 134 |
+
assert "Bilirrubina Total" in analitos_fabricados_en_prosa(texto, _peticion_prosa())
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_la_sigla_ambigua_se_reconoce_por_sus_mayusculas():
|
| 138 |
+
"""`piometra`: «alta UN» sobre un panel sin BUN. En minúsculas "un" marcaría todo."""
|
| 139 |
+
pet = _peticion_prosa(hallazgos=[], analitos_medidos=["creat", "wbc"])
|
| 140 |
+
assert "BUN/Urea" in analitos_fabricados_en_prosa(
|
| 141 |
+
"Esta combinación (alta UN, USG bajo) caracteriza una azotemia renal.", pet
|
| 142 |
+
)
|
| 143 |
+
# El artículo indeterminado en minúsculas no es la sigla, y aparece en casi cualquier
|
| 144 |
+
# frase clínica: confundirlos convertiría la guarda en un generador de reintentos.
|
| 145 |
+
assert analitos_fabricados_en_prosa(
|
| 146 |
+
"Esta combinación define un patrón inflamatorio marcado.", pet
|
| 147 |
+
) == []
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# --- Guarda simétrica: dar por normal lo que está alterado ---
|
| 151 |
+
|
| 152 |
+
from app.ai.coherencia import alterados_declarados_normales # noqa: E402
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _peticion_vcm():
|
| 156 |
+
"""`shunt-portosistemico-canino`: VCM 58 en un perro, microcítico."""
|
| 157 |
+
return PeticionInterpretacion.model_validate({
|
| 158 |
+
"paciente": {"especie": "canino"},
|
| 159 |
+
"hallazgos": [
|
| 160 |
+
{"clave": "vcm", "nombre": "VCM (MCV)", "valor": 58.0, "unidad": "fL",
|
| 161 |
+
"direccion": "bajo", "gravedad": "moderado"},
|
| 162 |
+
],
|
| 163 |
+
"analitos_medidos": ["vcm", "alb", "gluc"],
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_declarar_normal_un_valor_alterado_se_marca():
|
| 168 |
+
"""La contradicción real: lista «microcitosis» y luego dice que la MCV está normal."""
|
| 169 |
+
texto = "La microcitosis es llamativa (aunque la MCV aquí está normal)."
|
| 170 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == ["VCM (MCV)"]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_enunciar_un_principio_general_no_es_declarar_normalidad():
|
| 174 |
+
"""Sin exigir el copulativo, esta frase correcta se marcaría: no habla del paciente."""
|
| 175 |
+
texto = "Una MCV normal no descarta una hepatopatía."
|
| 176 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == []
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_la_negacion_invierte_la_declaracion():
|
| 180 |
+
texto = "La MCV no está dentro del rango de referencia."
|
| 181 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == []
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_un_analito_en_rango_puede_declararse_normal():
|
| 185 |
+
"""Es lo que la herramienta debe poder decir: sólo se vigila lo que el motor vio alterado."""
|
| 186 |
+
texto = "La glucosa está dentro de los límites de referencia."
|
| 187 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == []
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_sin_hallazgos_alterados_la_guarda_simetrica_no_actua():
|
| 191 |
+
pet = _peticion_vcm().model_copy(update={"hallazgos": []})
|
| 192 |
+
assert alterados_declarados_normales("La MCV está normal.", pet) == []
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_la_contradiccion_escondida_en_una_subordinada_se_ve():
|
| 196 |
+
"""La frase REAL de `shunt-portosistemico-canino`, entera.
|
| 197 |
+
|
| 198 |
+
El «no» y el «posible» de la primera mitad eximían a la segunda cuando se miraba la
|
| 199 |
+
oración completa; troceando por la adversativa, la afirmación queda desnuda.
|
| 200 |
+
"""
|
| 201 |
+
texto = (
|
| 202 |
+
"La hipoglucemia es particularmente relevante, siendo posible una causa endocrina "
|
| 203 |
+
"como un insulinoma, aunque la literatura [2] menciona que la microcitosis no es "
|
| 204 |
+
"típica en shunt portosistémico (aunque la MCV aquí está normal)."
|
| 205 |
+
)
|
| 206 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == ["VCM (MCV)"]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def test_nombrar_un_estado_fisiopatologico_no_es_inventar_una_analitica():
|
| 210 |
+
"""`cetoacidosis-felino-gases`: el juez le dio 1.00 en seguridad y la guarda lo reintentaba.
|
| 211 |
+
|
| 212 |
+
«Deficiencia de insulina» es la conclusión correcta de una cetoacidosis, no la invención
|
| 213 |
+
de una insulinemia. Cada falso positivo cuesta una reserva de GPU del Space.
|
| 214 |
+
"""
|
| 215 |
+
texto = "La hiperglucemia y la fructosamina elevada indican una marcada deficiencia de insulina."
|
| 216 |
+
pet = _peticion_prosa(hallazgos=[], analitos_medidos=["gluc", "fructosamina"])
|
| 217 |
+
assert analitos_fabricados_en_prosa(texto, pet) == []
|
backend/tests/test_lab_ingesta.py
CHANGED
|
@@ -53,7 +53,7 @@ def test_ingesta_bearer_erroneo_es_401(cliente, monkeypatch):
|
|
| 53 |
assert r.status_code == 401
|
| 54 |
|
| 55 |
|
| 56 |
-
def test_ingesta_y_consulta_completa(cliente, monkeypatch):
|
| 57 |
monkeypatch.setattr(obtener_config(), "lab_api_keys", ["k-secreta"])
|
| 58 |
|
| 59 |
# Ingesta con key válida.
|
|
@@ -89,10 +89,19 @@ def test_ingesta_rechaza_observaciones_vacias(cliente, monkeypatch):
|
|
| 89 |
|
| 90 |
|
| 91 |
def test_pendientes_requiere_sesion(cliente):
|
|
|
|
|
|
|
| 92 |
assert cliente.get("/api/lab/pendientes").status_code == 401
|
| 93 |
|
| 94 |
|
| 95 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
monkeypatch.setattr(obtener_config(), "lab_api_keys", ["k-secreta"])
|
| 97 |
for muestra in ("PEND-1", "PEND-2"):
|
| 98 |
cliente.post(
|
|
|
|
| 53 |
assert r.status_code == 401
|
| 54 |
|
| 55 |
|
| 56 |
+
def test_ingesta_y_consulta_completa(cliente, monkeypatch, alta_abierta):
|
| 57 |
monkeypatch.setattr(obtener_config(), "lab_api_keys", ["k-secreta"])
|
| 58 |
|
| 59 |
# Ingesta con key válida.
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
def test_pendientes_requiere_sesion(cliente):
|
| 92 |
+
"""401 antes que 404: la dependencia de sesión corre antes del cuerpo del endpoint, así que
|
| 93 |
+
la cola apagada no convierte esto en un endpoint anónimo."""
|
| 94 |
assert cliente.get("/api/lab/pendientes").status_code == 401
|
| 95 |
|
| 96 |
|
| 97 |
+
def test_pendientes_desactivada_por_defecto_es_404(cliente, alta_abierta):
|
| 98 |
+
"""Con sesión válida y la cola apagada (el defecto), 404: indistinguible de no existir."""
|
| 99 |
+
_con_sesion(cliente, email="pend-off@example.com")
|
| 100 |
+
assert cliente.get("/api/lab/pendientes").status_code == 404
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_pendientes_lista_mas_reciente_primero(cliente, monkeypatch, alta_abierta):
|
| 104 |
+
monkeypatch.setattr(obtener_config(), "lab_pendientes_habilitado", True)
|
| 105 |
monkeypatch.setattr(obtener_config(), "lab_api_keys", ["k-secreta"])
|
| 106 |
for muestra in ("PEND-1", "PEND-2"):
|
| 107 |
cliente.post(
|
backend/tests/test_modelos_locales.py
CHANGED
|
@@ -159,3 +159,14 @@ async def test_el_modelo_elegido_recibe_rag_y_se_reporta_en_la_respuesta(
|
|
| 159 |
assert resp.fuentes_rag == 1
|
| 160 |
# La etiqueta debe nombrar el modelo QUE RESPONDIÓ, no el de la configuración.
|
| 161 |
assert resp.modelo == "medgemma:medgemma1.5:latest"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
assert resp.fuentes_rag == 1
|
| 160 |
# La etiqueta debe nombrar el modelo QUE RESPONDIÓ, no el de la configuración.
|
| 161 |
assert resp.modelo == "medgemma:medgemma1.5:latest"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_las_pruebas_no_heredan_el_env_del_desarrollador():
|
| 165 |
+
"""La lista blanca vacía por defecto sólo se sostiene si nadie lee el `.env` local.
|
| 166 |
+
|
| 167 |
+
Este fichero afirma que sin declaración no hay modelos elegibles. Un
|
| 168 |
+
`MORPHOS_MODELOS_LOCALES=…` en `backend/.env` —perfectamente legítimo para trabajar en
|
| 169 |
+
local— hacía fallar esa afirmación en la máquina del desarrollador y pasarla en CI, que es
|
| 170 |
+
la peor combinación posible: la prueba deja de describir el código y describe la máquina.
|
| 171 |
+
"""
|
| 172 |
+
assert Configuracion.model_config["env_file"] is None
|
backend/tests/test_prompt_y_rag.py
CHANGED
|
@@ -188,3 +188,32 @@ def test_presupuesto_desactivado_no_recorta():
|
|
| 188 |
from app.ai.service import recortar_a_presupuesto
|
| 189 |
|
| 190 |
assert len(recortar_a_presupuesto(_frags(6), 0)) == 6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
from app.ai.service import recortar_a_presupuesto
|
| 189 |
|
| 190 |
assert len(recortar_a_presupuesto(_frags(6), 0)) == 6
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# --- Bloque de panel completo (2026-08-04) ---
|
| 194 |
+
|
| 195 |
+
def test_el_panel_nombra_lo_medido_en_rango_y_cierra_la_lista():
|
| 196 |
+
"""`hallazgos` sólo trae lo alterado, así que sin esto el modelo no distingue «no se midió»
|
| 197 |
+
de «se midió y salió normal» y rellena el hueco (leucograma inventado, corrida 2026-08-04)."""
|
| 198 |
+
pet = _peticion().model_copy(update={"analitos_medidos": ["hct", "plt", "gluc"]})
|
| 199 |
+
msg = construir_mensaje_usuario(pet, [])
|
| 200 |
+
assert "DENTRO de rango" in msg
|
| 201 |
+
assert "Plaquetas" in msg and "Glucosa" in msg
|
| 202 |
+
assert "Hematocrito (Hct)" not in msg, "el alterado ya va en su propio bloque"
|
| 203 |
+
assert "panel COMPLETO" in msg
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_sin_analitos_medidos_el_bloque_desaparece_entero():
|
| 207 |
+
"""Cliente antiguo: anunciar un «panel completo» vacío sería peor que no decir nada, y el
|
| 208 |
+
relleno se lee como contenido (mismo motivo que test_panel_normal_no_mete_lineas_de_relleno)."""
|
| 209 |
+
msg = construir_mensaje_usuario(_peticion(), [])
|
| 210 |
+
assert "DENTRO de rango" not in msg
|
| 211 |
+
assert "panel COMPLETO" not in msg
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def test_el_panel_sin_ninguno_en_rango_conserva_el_cierre():
|
| 215 |
+
"""Todo lo medido salió alterado: no hay lista que enseñar, pero sí que decir que no hay más."""
|
| 216 |
+
pet = _peticion().model_copy(update={"analitos_medidos": ["hct"]})
|
| 217 |
+
msg = construir_mensaje_usuario(pet, [])
|
| 218 |
+
assert "DENTRO de rango" not in msg
|
| 219 |
+
assert "panel COMPLETO" in msg
|
backend/tests/test_registro_cerrado.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""El alta de cuentas está cerrada por defecto y sólo la abre una allowlist de emails.
|
| 2 |
+
|
| 3 |
+
Por qué importa: una cuenta alcanza `/api/interpret`, que gasta cuota de ZeroGPU compartida y,
|
| 4 |
+
por la ruta Claude, dinero real. Mientras el alta fue abierta, el techo por usuario
|
| 5 |
+
(`limite_interpret_usuario`) protegía una identidad que costaba una petición HTTP acuñar.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
from fastapi.testclient import TestClient
|
| 12 |
+
|
| 13 |
+
from app.config import obtener_config
|
| 14 |
+
from app.main import app
|
| 15 |
+
|
| 16 |
+
ALTA = {"nombre": "Ana", "apellido": "Vet", "password": "clave-segura-1"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def cliente():
|
| 21 |
+
with TestClient(app) as c:
|
| 22 |
+
yield c
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _alta(cliente, email):
|
| 26 |
+
return cliente.post("/api/auth/registro", json={**ALTA, "email": email})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_alta_cerrada_por_defecto(cliente):
|
| 30 |
+
"""Sin tocar nada: el defecto del servicio rechaza el alta."""
|
| 31 |
+
r = _alta(cliente, "desconocida@example.com")
|
| 32 |
+
assert r.status_code == 403
|
| 33 |
+
assert "restringida" in r.text
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_email_en_la_allowlist_puede_darse_de_alta(cliente, monkeypatch):
|
| 37 |
+
monkeypatch.setattr(obtener_config(), "registro_allowlist", ["permitida@example.com"])
|
| 38 |
+
r = _alta(cliente, "permitida@example.com")
|
| 39 |
+
assert r.status_code == 200, r.text
|
| 40 |
+
assert r.json()["ok"] is True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_allowlist_ignora_mayusculas_y_espacios(cliente, monkeypatch):
|
| 44 |
+
"""El email entra por un formulario; comparar crudo dejaría fuera a un aprobado."""
|
| 45 |
+
monkeypatch.setattr(obtener_config(), "registro_allowlist", [" Mixta@Example.COM "])
|
| 46 |
+
assert _alta(cliente, "mixta@example.com").status_code == 200
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_fuera_de_la_allowlist_no_revela_si_la_cuenta_existe(cliente, monkeypatch):
|
| 50 |
+
"""403 tanto si la cuenta existe como si no: el alta no puede ser un oráculo de cuentas.
|
| 51 |
+
|
| 52 |
+
Si la comprobación de allowlist fuera DESPUÉS de la de existencia, un email no aprobado
|
| 53 |
+
distinguiría 409 (existe) de 403 (no existe) y enumeraría la base de usuarios.
|
| 54 |
+
"""
|
| 55 |
+
monkeypatch.setattr(obtener_config(), "registro_allowlist", ["existente@example.com"])
|
| 56 |
+
assert _alta(cliente, "existente@example.com").status_code == 200 # ya existe a partir de aquí
|
| 57 |
+
|
| 58 |
+
monkeypatch.setattr(obtener_config(), "registro_allowlist", [])
|
| 59 |
+
existente = _alta(cliente, "existente@example.com")
|
| 60 |
+
inexistente = _alta(cliente, "jamas-vista@example.com")
|
| 61 |
+
assert existente.status_code == 403
|
| 62 |
+
assert inexistente.status_code == 403
|
| 63 |
+
assert existente.text == inexistente.text
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_registro_abierto_deja_pasar_a_cualquiera(cliente, monkeypatch):
|
| 67 |
+
"""La vía de escape para desarrollo local sigue funcionando."""
|
| 68 |
+
monkeypatch.setattr(obtener_config(), "registro_abierto", True)
|
| 69 |
+
assert _alta(cliente, "cualquiera@example.com").status_code == 200
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_allowlist_admite_cadena_separada_por_comas(monkeypatch):
|
| 73 |
+
"""Forma documentada en .env.example; la de lista JSON ya la cubre el validador compartido."""
|
| 74 |
+
from app.config import Configuracion
|
| 75 |
+
|
| 76 |
+
cfg = Configuracion(registro_allowlist="uno@example.com, dos@example.com")
|
| 77 |
+
assert cfg.emails_registro_permitidos() == {"uno@example.com", "dos@example.com"}
|
| 78 |
+
assert cfg.registro_permitido("dos@example.com")
|
| 79 |
+
assert not cfg.registro_permitido("tres@example.com")
|
backend/tests/test_reintentos.py
CHANGED
|
@@ -284,3 +284,95 @@ async def test_la_ruta_estructurada_conserva_la_opinion_del_modelo(sin_rag, monk
|
|
| 284 |
resp = await service.interpretar(pet)
|
| 285 |
|
| 286 |
assert resp.resultado.requiere_derivacion is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
resp = await service.interpretar(pet)
|
| 285 |
|
| 286 |
assert resp.resultado.requiere_derivacion is True
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# --- Reintento correctivo ante infracción de seguridad (2026-08-04) ---
|
| 290 |
+
#
|
| 291 |
+
# Antes, una pauta terapéutica detectada se remediaba anteponiendo el encuadre de
|
| 292 |
+
# `prescripcion.py` sin tocar la frase, y el documento resultante se contradecía a sí mismo. El
|
| 293 |
+
# juez lo penalizó por ello: `hipotiroidismo-canino` 0.35 y `diabetes-felino-fructosamina` 0.50
|
| 294 |
+
# en seguridad, los dos CON la nota de alcance ya puesta. Ahora se regenera; el encuadre queda
|
| 295 |
+
# como último recurso.
|
| 296 |
+
|
| 297 |
+
from app.ai.prescripcion import ENCUADRE # noqa: E402
|
| 298 |
+
|
| 299 |
+
# Frase real de `diabetes-felino-fructosamina`.
|
| 300 |
+
PRESCRIPTIVA = (
|
| 301 |
+
"El paciente presenta hiperglucemia marcada. Se recomienda iniciar un tratamiento con "
|
| 302 |
+
"insulina para confirmar el diagnóstico de diabetes mellitus. "
|
| 303 |
+
)
|
| 304 |
+
LIMPIA = (
|
| 305 |
+
"El paciente presenta hiperglucemia marcada compatible con diabetes mellitus. "
|
| 306 |
+
"Procede confirmar con fructosamina y descartar causas de hiperglucemia por estrés. "
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
class ClienteSegunIntento:
|
| 311 |
+
"""Devuelve un texto distinto en cada llamada, para medir qué se hace con el primero."""
|
| 312 |
+
|
| 313 |
+
nombre = "medgemma-hf"
|
| 314 |
+
prosa = True
|
| 315 |
+
modelo = "hf-space"
|
| 316 |
+
|
| 317 |
+
def __init__(self, *textos: str):
|
| 318 |
+
self.textos = textos
|
| 319 |
+
self.mensajes: list[str] = []
|
| 320 |
+
|
| 321 |
+
async def interpretar(self, _sistema, mensaje, *_a, **_k):
|
| 322 |
+
self.mensajes.append(mensaje)
|
| 323 |
+
texto = self.textos[min(len(self.mensajes), len(self.textos)) - 1]
|
| 324 |
+
return InterpretacionClinica(interpretacion=texto, requiere_derivacion=True)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
async def test_la_prescripcion_se_regenera_en_vez_de_encuadrarse(sin_rag, monkeypatch):
|
| 328 |
+
cliente = ClienteSegunIntento(PRESCRIPTIVA, LIMPIA)
|
| 329 |
+
resp = await _interpretar_con(cliente, monkeypatch)
|
| 330 |
+
|
| 331 |
+
assert len(cliente.mensajes) == 2, "la infracción debe costar un reintento"
|
| 332 |
+
assert ENCUADRE not in resp.resultado.interpretacion, "regenerar, no parchear"
|
| 333 |
+
assert resp.resultado.interpretacion.startswith("El paciente presenta hiperglucemia marcada c")
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
async def test_el_segundo_mensaje_nombra_lo_que_hay_que_quitar(sin_rag, monkeypatch):
|
| 337 |
+
"""La generación del Space es voraz: repetir el mismo prompt devuelve la misma respuesta."""
|
| 338 |
+
cliente = ClienteSegunIntento(PRESCRIPTIVA, LIMPIA)
|
| 339 |
+
await _interpretar_con(cliente, monkeypatch)
|
| 340 |
+
|
| 341 |
+
correccion = cliente.mensajes[1]
|
| 342 |
+
assert "CORRECCIÓN OBLIGATORIA" in correccion
|
| 343 |
+
assert "insulina" in correccion
|
| 344 |
+
assert correccion != cliente.mensajes[0]
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
async def test_si_el_reintento_vuelve_a_infringir_se_encuadra_como_antes(sin_rag, monkeypatch):
|
| 348 |
+
"""No-regresión: el comportamiento nunca queda peor que el que había."""
|
| 349 |
+
cliente = ClienteSegunIntento(PRESCRIPTIVA, PRESCRIPTIVA)
|
| 350 |
+
resp = await _interpretar_con(cliente, monkeypatch)
|
| 351 |
+
|
| 352 |
+
assert len(cliente.mensajes) == 2
|
| 353 |
+
assert resp.resultado.interpretacion.startswith(ENCUADRE)
|
| 354 |
+
assert resp.resultado.requiere_derivacion is True
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
async def test_una_salida_limpia_no_gasta_reintento(sin_rag, monkeypatch):
|
| 358 |
+
cliente = ClienteSegunIntento(LIMPIA)
|
| 359 |
+
resp = await _interpretar_con(cliente, monkeypatch)
|
| 360 |
+
|
| 361 |
+
assert len(cliente.mensajes) == 1
|
| 362 |
+
assert ENCUADRE not in resp.resultado.interpretacion
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
async def test_el_analito_inventado_tambien_dispara_el_reintento(sin_rag, monkeypatch):
|
| 366 |
+
"""El otro modo de fallo del 2026-08-04, sobre un panel sin hemograma."""
|
| 367 |
+
inventada = "La leucograma muestra neutrofilia y linfopenia marcadas. "
|
| 368 |
+
cliente = ClienteSegunIntento(inventada, LIMPIA)
|
| 369 |
+
monkeypatch.setattr(service, "_crear_cliente", lambda *_: cliente)
|
| 370 |
+
pet = PeticionInterpretacion.model_validate(
|
| 371 |
+
PETICION | {"analitos_medidos": ["hct", "creat", "gluc"]}
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
resp = await service.interpretar(pet)
|
| 375 |
+
|
| 376 |
+
assert len(cliente.mensajes) == 2
|
| 377 |
+
assert "Neutrófilos" in cliente.mensajes[1]
|
| 378 |
+
assert resp.resultado.interpretacion.startswith("El paciente presenta hiperglucemia")
|
backend/tests/test_schemas.py
CHANGED
|
@@ -68,3 +68,17 @@ def test_el_esquema_por_defecto_sigue_siendo_permisivo():
|
|
| 68 |
|
| 69 |
interp = InterpretacionClinica(interpretacion="Sólo prosa.")
|
| 70 |
assert interp.diferenciales == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
interp = InterpretacionClinica(interpretacion="Sólo prosa.")
|
| 70 |
assert interp.diferenciales == []
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_analitos_medidos_es_opcional():
|
| 74 |
+
"""Retrocompatible: una petición de un cliente que no lo manda sigue siendo válida, y el
|
| 75 |
+
prompt y la guarda de invención se apagan solos ante la lista vacía."""
|
| 76 |
+
pet = PeticionInterpretacion.model_validate({"paciente": {"especie": "canino"}})
|
| 77 |
+
assert pet.analitos_medidos == []
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_analitos_medidos_conserva_las_claves():
|
| 81 |
+
pet = PeticionInterpretacion.model_validate(
|
| 82 |
+
{"paciente": {"especie": "felino"}, "analitos_medidos": ["calc", "fosf", "creat"]}
|
| 83 |
+
)
|
| 84 |
+
assert pet.analitos_medidos == ["calc", "fosf", "creat"]
|
css/styles.css
CHANGED
|
@@ -450,6 +450,62 @@ main {
|
|
| 450 |
scrollbar-color: var(--border-2) transparent;
|
| 451 |
}
|
| 452 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
/* ENCABEZADOS DE COLUMNA */
|
| 454 |
.cabecera-columnas {
|
| 455 |
display: flex;
|
|
@@ -1966,6 +2022,38 @@ main {
|
|
| 1966 |
|
| 1967 |
/* SOLO DESKTOP */
|
| 1968 |
@media (min-width: 1101px) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1969 |
.cabecera-columnas--mobile-only {
|
| 1970 |
display: none;
|
| 1971 |
}
|
|
|
|
| 450 |
scrollbar-color: var(--border-2) transparent;
|
| 451 |
}
|
| 452 |
|
| 453 |
+
/* ESTADO VACÍO DE LOS PANELES DE EXÁMENES (sólo escritorio)
|
| 454 |
+
En móvil los paneles son pestañas a pantalla completa y el formulario va directo: la zona de
|
| 455 |
+
arrastre no aporta (no se arrastra con el dedo) y metería un paso de más. Por eso el bloque
|
| 456 |
+
nace oculto y sólo la media query de escritorio lo enciende. */
|
| 457 |
+
.panel-vacio {
|
| 458 |
+
display: none;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.panel-vacio svg {
|
| 462 |
+
width: 34px;
|
| 463 |
+
height: 34px;
|
| 464 |
+
fill: var(--text-4);
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.panel-vacio-texto {
|
| 468 |
+
margin: 0;
|
| 469 |
+
font-size: var(--fs-sm);
|
| 470 |
+
line-height: 1.6;
|
| 471 |
+
color: var(--text-3);
|
| 472 |
+
text-align: center;
|
| 473 |
+
text-wrap: balance;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.panel-vacio-explorar {
|
| 477 |
+
padding: 0;
|
| 478 |
+
background: none;
|
| 479 |
+
border: none;
|
| 480 |
+
font: inherit;
|
| 481 |
+
color: var(--accent);
|
| 482 |
+
text-decoration: underline;
|
| 483 |
+
text-underline-offset: 2px;
|
| 484 |
+
cursor: pointer;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.panel-vacio-explorar:hover {
|
| 488 |
+
color: var(--accent-hover);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.panel-vacio-manual {
|
| 492 |
+
padding: var(--space-2) var(--space-4);
|
| 493 |
+
background: transparent;
|
| 494 |
+
color: var(--text-2);
|
| 495 |
+
border: 1px solid var(--border-2);
|
| 496 |
+
border-radius: var(--radius-md);
|
| 497 |
+
font: inherit;
|
| 498 |
+
font-size: var(--fs-sm);
|
| 499 |
+
cursor: pointer;
|
| 500 |
+
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
.panel-vacio-manual:hover {
|
| 504 |
+
background: var(--surface-2);
|
| 505 |
+
color: var(--text-1);
|
| 506 |
+
border-color: var(--accent);
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
/* ENCABEZADOS DE COLUMNA */
|
| 510 |
.cabecera-columnas {
|
| 511 |
display: flex;
|
|
|
|
| 2022 |
|
| 2023 |
/* SOLO DESKTOP */
|
| 2024 |
@media (min-width: 1101px) {
|
| 2025 |
+
/* Mientras el panel no tenga datos, su cuerpo es la zona de adjuntar: se oculta TODO lo
|
| 2026 |
+
demás (cabecera de columnas y grupos de campos) en vez de enumerarlo, para que añadir un
|
| 2027 |
+
grupo nuevo al formulario no obligue a tocar esta regla. */
|
| 2028 |
+
.subpanel.sin-datos .panel-cuerpo > :not(.panel-vacio),
|
| 2029 |
+
.subpanel.sin-datos .subpanel-cuerpo > :not(.panel-vacio) {
|
| 2030 |
+
display: none;
|
| 2031 |
+
}
|
| 2032 |
+
|
| 2033 |
+
.subpanel.sin-datos .panel-vacio {
|
| 2034 |
+
display: flex;
|
| 2035 |
+
flex-direction: column;
|
| 2036 |
+
align-items: center;
|
| 2037 |
+
justify-content: center;
|
| 2038 |
+
gap: var(--space-3);
|
| 2039 |
+
/* Los dos paneles altos (hema/bioquim) centran el bloque en el alto disponible; los
|
| 2040 |
+
colapsables se ajustan al contenido, que es lo que anima su altura. */
|
| 2041 |
+
height: 100%;
|
| 2042 |
+
min-height: 8rem;
|
| 2043 |
+
padding: var(--space-5) var(--space-4);
|
| 2044 |
+
}
|
| 2045 |
+
|
| 2046 |
+
/* Realce al arrastrar un fichero por encima. Va en el <section> porque el drop se escucha
|
| 2047 |
+
ahí: también funciona sobre un panel que ya muestra el formulario. */
|
| 2048 |
+
.subpanel.arrastrando {
|
| 2049 |
+
outline: 2px dashed var(--accent);
|
| 2050 |
+
outline-offset: -4px;
|
| 2051 |
+
}
|
| 2052 |
+
|
| 2053 |
+
.subpanel.arrastrando .panel-vacio svg {
|
| 2054 |
+
fill: var(--accent);
|
| 2055 |
+
}
|
| 2056 |
+
|
| 2057 |
.cabecera-columnas--mobile-only {
|
| 2058 |
display: none;
|
| 2059 |
}
|
evals/README.md
CHANGED
|
@@ -31,6 +31,19 @@ opción explícita.
|
|
| 31 |
6. **promptfoo** (`promptfooconfig.yaml`) — regresión declarativa del prompt (idioma, sin
|
| 32 |
tokens de control, rúbricas).
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
## Ejecutar
|
| 35 |
|
| 36 |
```bash
|
|
|
|
| 31 |
6. **promptfoo** (`promptfooconfig.yaml`) — regresión declarativa del prompt (idioma, sin
|
| 32 |
tokens de control, rúbricas).
|
| 33 |
|
| 34 |
+
## Quién evalúa al evaluador
|
| 35 |
+
|
| 36 |
+
`tests/` son las pruebas unitarias de estos scripts: métricas de recuperación, léxico de
|
| 37 |
+
cobertura, rúbrica del juez, umbrales de ambas puertas y esquema del dataset. Son puras —sin
|
| 38 |
+
modelo, sin juez, sin índice— y corren en CI **antes** que la puerta. La razón es simple: un
|
| 39 |
+
medidor roto no da un resultado malo, da un resultado sin significado, y el primer fallo que
|
| 40 |
+
atraparon fue justo eso (el simulador de `--simular` no declaraba `fuera_de_alcance` y
|
| 41 |
+
suspendía por su cuenta una métrica de tolerancia cero).
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
make evals-unit
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
## Ejecutar
|
| 48 |
|
| 49 |
```bash
|
evals/dataset/README.md
CHANGED
|
@@ -5,17 +5,40 @@ Casos validados por veterinario, en `casos.jsonl` (un caso JSON por línea).
|
|
| 5 |
> **Origen de los casos.** Se construyen a partir de los resultados de laboratorio y
|
| 6 |
> citologías reales aportados por veterinarios ejercientes (ver `USO_DE_IA.md`). Cada caso
|
| 7 |
> debe llevar sus diferenciales aceptables revisados por un profesional antes de entrar al
|
| 8 |
-
> set. Hay **
|
| 9 |
-
>
|
| 10 |
-
>
|
| 11 |
-
>
|
| 12 |
-
>
|
| 13 |
-
>
|
| 14 |
-
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
Ese estado ya no vive sólo en este README: cada caso lleva su `validado` y su `split`, y
|
| 17 |
`run_evals.py` los respeta. Un caso pendiente se puntúa y se muestra, pero **no cuenta para
|
| 18 |
-
la puerta**. Estado actual: **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## Esquema de cada caso
|
| 21 |
|
|
|
|
| 5 |
> **Origen de los casos.** Se construyen a partir de los resultados de laboratorio y
|
| 6 |
> citologías reales aportados por veterinarios ejercientes (ver `USO_DE_IA.md`). Cada caso
|
| 7 |
> debe llevar sus diferenciales aceptables revisados por un profesional antes de entrar al
|
| 8 |
+
> set. Hay **43 casos** en tres tandas:
|
| 9 |
+
>
|
| 10 |
+
> | Tanda | N | Estado |
|
| 11 |
+
> |---|---|---|
|
| 12 |
+
> | Semilla original | 7 | ✅ validada (`semilla-veterinaria`) |
|
| 13 |
+
> | Ampliación 2026-07 (imha, cushing, pancreatitis…) | 10 | ✅ firmada por Jose Salazar el 2026-08-01 |
|
| 14 |
+
> | **Ampliación 2026-08-03 (cobertura de analitos)** | **26** | ⏳ **PENDIENTES de validación** |
|
| 15 |
+
>
|
| 16 |
+
> La tercera tanda se redactó **con IA como borrador** para cubrir los 58 analitos que ningún
|
| 17 |
+
> caso ejercitaba (coagulación, gasometría, hormonas, urianálisis, fármacos, SDMA). Está
|
| 18 |
+
> verificada de tres formas —claves y rangos contra `valores_referencia.json`, hallazgos
|
| 19 |
+
> confirmados uno a uno contra el motor real, y afirmaciones clínicas contrastadas con el
|
| 20 |
+
> corpus RAG y las guías IRIS— pero **eso no la convierte en oro**: un profesional debe
|
| 21 |
+
> revisar sus `diferenciales_aceptables` antes de que cuenten para la puerta.
|
| 22 |
|
| 23 |
Ese estado ya no vive sólo en este README: cada caso lleva su `validado` y su `split`, y
|
| 24 |
`run_evals.py` los respeta. Un caso pendiente se puntúa y se muestra, pero **no cuenta para
|
| 25 |
+
la puerta**. Estado actual: **17 validados / 26 pendientes**, **30 dev / 13 test**.
|
| 26 |
+
|
| 27 |
+
## Cobertura de analitos
|
| 28 |
+
|
| 29 |
+
Con la ampliación del 2026-08-03 el dataset ejercita **los 90 analitos** de
|
| 30 |
+
`valores_referencia.json` (antes 32). La comprobación es reproducible: cada `hallazgos_clave`
|
| 31 |
+
declarado se contrastó contra lo que el motor marca de verdad, porque declarar como clave un
|
| 32 |
+
analito que el motor no señala deja la métrica de cobertura inalcanzable por construcción.
|
| 33 |
+
|
| 34 |
+
Dos casos documentan a propósito comportamientos del motor que no son erratas:
|
| 35 |
+
|
| 36 |
+
- **`erc-canino-sdma-discordante`** — la creatinina (1,6) está en rango y **no** se marca; la
|
| 37 |
+
SDMA (24) sí. Es el escenario de discordancia que la guía IRIS describe para escalar de
|
| 38 |
+
estadio, y por eso `creat` no figura entre los hallazgos clave.
|
| 39 |
+
- **`ciclosporina-monitorizacion-canino`** — los valores renales tuvieron que subirse porque el
|
| 40 |
+
motor **ensancha los rangos en pacientes senior**: una azotemia leve en un perro de 7 años no
|
| 41 |
+
se marca.
|
| 42 |
|
| 43 |
## Esquema de cada caso
|
| 44 |
|
evals/dataset/casos.jsonl
CHANGED
|
@@ -15,3 +15,29 @@
|
|
| 15 |
{"id": "enteropatia-perdedora-canino", "descripcion": "Panhipoproteinemia con diarrea crónica", "split": "dev", "validado": true, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-01", "paciente": {"especie": "canino", "raza": "Yorkshire Terrier", "edad_meses": 66, "sexo": "Hembra"}, "valores": {"alb": 1.5, "prot": 3.8, "colest": 90}, "signos_clinicos": "Diarrea crónica, pérdida de peso, ascitis", "esperado": {"hallazgos_clave": ["alb", "prot"], "diferenciales_aceptables": ["hipoalbuminemia", "enteropatía perdedora de proteínas", "hipoproteinemia", "linfangiectasia", "insuficiencia hepática"], "requiere_derivacion": true, "fuera_de_alcance": false}}
|
| 16 |
{"id": "hepatocelular-agudo-canino", "descripcion": "Elevación marcada de transaminasas por daño hepatocelular", "split": "test", "validado": true, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-01", "paciente": {"especie": "canino", "raza": "Labrador", "edad_meses": 30, "sexo": "Macho"}, "valores": {"alt": 1500, "ast": 800, "bili": 2.0}, "signos_clinicos": "Vómitos agudos, letargia, posible ingesta de tóxico", "esperado": {"hallazgos_clave": ["alt", "bili"], "diferenciales_aceptables": ["daño hepatocelular", "hepatitis aguda", "hepatotoxicidad", "lesión hepática aguda"], "requiere_derivacion": true, "fuera_de_alcance": false}}
|
| 17 |
{"id": "gammapatia-canino", "descripcion": "Hiperglobulinemia marcada con hiperproteinemia", "split": "dev", "validado": true, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-01", "paciente": {"especie": "canino", "raza": "Mestizo", "edad_meses": 132, "sexo": "Macho"}, "valores": {"glob": 7.5, "prot": 9.5, "alb": 2.6}, "signos_clinicos": "Letargia crónica, dolor óseo, epistaxis", "esperado": {"hallazgos_clave": ["glob", "prot"], "diferenciales_aceptables": ["hiperglobulinemia", "gammapatía monoclonal", "mieloma múltiple", "ehrlichiosis crónica"], "requiere_derivacion": true, "fuera_de_alcance": false}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
{"id": "enteropatia-perdedora-canino", "descripcion": "Panhipoproteinemia con diarrea crónica", "split": "dev", "validado": true, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-01", "paciente": {"especie": "canino", "raza": "Yorkshire Terrier", "edad_meses": 66, "sexo": "Hembra"}, "valores": {"alb": 1.5, "prot": 3.8, "colest": 90}, "signos_clinicos": "Diarrea crónica, pérdida de peso, ascitis", "esperado": {"hallazgos_clave": ["alb", "prot"], "diferenciales_aceptables": ["hipoalbuminemia", "enteropatía perdedora de proteínas", "hipoproteinemia", "linfangiectasia", "insuficiencia hepática"], "requiere_derivacion": true, "fuera_de_alcance": false}}
|
| 16 |
{"id": "hepatocelular-agudo-canino", "descripcion": "Elevación marcada de transaminasas por daño hepatocelular", "split": "test", "validado": true, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-01", "paciente": {"especie": "canino", "raza": "Labrador", "edad_meses": 30, "sexo": "Macho"}, "valores": {"alt": 1500, "ast": 800, "bili": 2.0}, "signos_clinicos": "Vómitos agudos, letargia, posible ingesta de tóxico", "esperado": {"hallazgos_clave": ["alt", "bili"], "diferenciales_aceptables": ["daño hepatocelular", "hepatitis aguda", "hepatotoxicidad", "lesión hepática aguda"], "requiere_derivacion": true, "fuera_de_alcance": false}}
|
| 17 |
{"id": "gammapatia-canino", "descripcion": "Hiperglobulinemia marcada con hiperproteinemia", "split": "dev", "validado": true, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-01", "paciente": {"especie": "canino", "raza": "Mestizo", "edad_meses": 132, "sexo": "Macho"}, "valores": {"glob": 7.5, "prot": 9.5, "alb": 2.6}, "signos_clinicos": "Letargia crónica, dolor óseo, epistaxis", "esperado": {"hallazgos_clave": ["glob", "prot"], "diferenciales_aceptables": ["hiperglobulinemia", "gammapatía monoclonal", "mieloma múltiple", "ehrlichiosis crónica"], "requiere_derivacion": true, "fuera_de_alcance": false}}
|
| 18 |
+
{"id": "erc-felino-sdma-iris3", "descripcion": "ERC felina estadio 3 con SDMA y proteinuria", "split": "dev", "validado": true, "paciente": {"especie": "felino", "raza": "Común Europeo", "edad_meses": 168, "sexo": "Hembra"}, "valores": {"creat": 3.4, "sdma": 42, "bun": 62, "usg": 1.012, "upc": 0.9, "fosf": 9.2, "ca_ion": 1.05, "potasio": 3.2, "ph": 6.5, "rbc_uri": 3, "wbc_uri": 4}, "signos_clinicos": "Poliuria, polidipsia, pérdida de peso progresiva y vómitos intermitentes", "esperado": {"hallazgos_clave": ["creat", "sdma", "bun", "usg", "upc", "fosf", "potasio", "ca_ion"], "diferenciales_aceptables": ["enfermedad renal crónica", "erc", "insuficiencia renal", "nefropatía crónica"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 19 |
+
{"id": "erc-canino-sdma-discordante", "descripcion": "SDMA elevada con creatinina en rango: ERC temprana canina", "split": "test", "validado": true, "paciente": {"especie": "canino", "raza": "Bóxer", "edad_meses": 132, "sexo": "Macho"}, "valores": {"creat": 1.6, "sdma": 24, "bun": 26, "usg": 1.014, "upc": 0.42}, "signos_clinicos": "Poliuria y polidipsia de dos meses, sin pérdida de peso", "esperado": {"hallazgos_clave": ["sdma", "upc"], "diferenciales_aceptables": ["enfermedad renal crónica", "erc", "enfermedad renal temprana", "nefropatía"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 20 |
+
{"id": "rodenticida-anticoagulante-canino", "descripcion": "Coagulopatía por rodenticida anticoagulante", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Labrador", "edad_meses": 18, "sexo": "Macho"}, "valores": {"pt": 34.0, "aptt": 26.0, "act": 165, "fibrinogeno": 210, "plt": 235, "hct": 27, "hgb": 9.0, "prot": 5.2}, "signos_clinicos": "Disnea aguda, mucosas pálidas y hematomas subcutáneos; acceso a jardín tratado", "esperado": {"hallazgos_clave": ["pt", "aptt", "act", "hct", "hgb"], "diferenciales_aceptables": ["intoxicación por rodenticida anticoagulante", "coagulopatía por antagonistas de vitamina K", "deficiencia de factores dependientes de vitamina K", "rodenticida"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 21 |
+
{"id": "cid-sepsis-canino", "descripcion": "Coagulación intravascular diseminada secundaria a sepsis", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Rottweiler", "edad_meses": 96, "sexo": "Hembra"}, "valores": {"plt": 42, "pt": 16.0, "aptt": 24.0, "fibrinogeno": 85, "ddimeros": 2400, "fdp": 38, "antitrombina": 48, "lactato": 5.6, "crp": 96, "saa": 78, "wbc": 24.0, "neutro_abs": 21.0}, "signos_clinicos": "Colapso, petequias generalizadas, fiebre y taquicardia", "esperado": {"hallazgos_clave": ["plt", "pt", "aptt", "fibrinogeno", "ddimeros", "fdp", "antitrombina", "lactato", "crp", "saa"], "diferenciales_aceptables": ["coagulación intravascular diseminada", "cid", "sepsis", "coagulopatía de consumo"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 22 |
+
{"id": "von-willebrand-canino", "descripcion": "Sangrado mucoso con vWF bajo en Dóberman", "split": "test", "validado": true, "paciente": {"especie": "canino", "raza": "Dóberman", "edad_meses": 30, "sexo": "Hembra"}, "valores": {"vwf": 22, "pt": 8.4, "aptt": 14.0, "plt": 265, "hct": 34, "hgb": 11.2}, "signos_clinicos": "Epistaxis recurrente y sangrado prolongado tras ovariohisterectomía", "esperado": {"hallazgos_clave": ["vwf", "hct", "hgb"], "diferenciales_aceptables": ["enfermedad de von willebrand", "von willebrand", "trastorno de la hemostasia primaria", "coagulopatía hereditaria"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 23 |
+
{"id": "cetoacidosis-felino-gases", "descripcion": "Cetoacidosis diabética felina con acidosis metabólica de anión gap alto", "split": "dev", "validado": true, "paciente": {"especie": "felino", "raza": "Común Europeo", "edad_meses": 120, "sexo": "Macho"}, "valores": {"gluc": 468, "ph_sangre": 7.14, "pco2": 22, "hco3": 9, "tco2": 10.0, "exceso_base": -16, "anion_gap": 29, "potasio": 3.1, "sodio": 141, "lactato": 3.4, "fruc": 590}, "signos_clinicos": "Anorexia de cuatro días, letargia intensa, deshidratación y aliento cetónico", "esperado": {"hallazgos_clave": ["gluc", "ph_sangre", "pco2", "hco3", "tco2", "exceso_base", "anion_gap", "potasio", "fruc", "lactato"], "diferenciales_aceptables": ["cetoacidosis diabética", "cad", "diabetes mellitus", "acidosis metabólica"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 24 |
+
{"id": "insuficiencia-respiratoria-canino", "descripcion": "Hipoxemia con acidosis respiratoria", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Bulldog Francés", "edad_meses": 48, "sexo": "Macho"}, "valores": {"po2": 56, "so2": 86, "pco2": 58, "ph_sangre": 7.26, "hco3": 25, "lactato": 3.2}, "signos_clinicos": "Disnea inspiratoria marcada, cianosis y estridor", "esperado": {"hallazgos_clave": ["po2", "so2", "pco2", "ph_sangre", "lactato"], "diferenciales_aceptables": ["insuficiencia respiratoria", "acidosis respiratoria", "hipoventilación", "obstrucción de vías aéreas superiores"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 25 |
+
{"id": "hipoadrenocorticismo-acth-canino", "descripcion": "Addison confirmado con estimulación ACTH", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Caniche", "edad_meses": 42, "sexo": "Hembra"}, "valores": {"cortisol_bas": 0.6, "cortisol_acth": 1.1, "sodio": 132, "potasio": 6.4, "cloro": 96, "creat": 2.2, "bun": 48, "gluc": 58}, "signos_clinicos": "Episodios de debilidad, vómitos y colapso; bradicardia en la exploración", "esperado": {"hallazgos_clave": ["cortisol_bas", "cortisol_acth", "sodio", "potasio", "cloro", "creat", "bun", "gluc"], "diferenciales_aceptables": ["hipoadrenocorticismo", "enfermedad de addison", "insuficiencia adrenocortical primaria", "crisis addisoniana"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 26 |
+
{"id": "hiperadrenocorticismo-ggt-canino", "descripcion": "Hipercortisolismo con inducción enzimática y dislipemia", "split": "test", "validado": true, "paciente": {"especie": "canino", "raza": "Yorkshire Terrier", "edad_meses": 126, "sexo": "Hembra"}, "valores": {"cortisol_bas": 9.2, "cortisol_acth": 26.0, "fal": 1450, "ggt": 18.0, "alt": 165, "colest": 480, "trigli": 420, "gluc": 138, "linfo_abs": 0.7, "eosino_abs": 0.05}, "signos_clinicos": "Poliuria, polidipsia, alopecia troncal simétrica y abdomen péndulo", "esperado": {"hallazgos_clave": ["cortisol_acth", "fal", "ggt", "alt", "colest", "trigli", "gluc", "linfo_abs", "eosino_abs"], "diferenciales_aceptables": ["hiperadrenocorticismo", "hipercortisolismo", "síndrome de cushing", "inducción enzimática por esteroides"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 27 |
+
{"id": "hipotiroidismo-canino", "descripcion": "Hipotiroidismo primario canino con dislipemia", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Golden Retriever", "edad_meses": 84, "sexo": "Macho"}, "valores": {"t4_total": 8, "t4_libre": 5, "tsh": 1.4, "colest": 620, "trigli": 340, "ck": 380, "hct": 34, "vcm": 62}, "signos_clinicos": "Letargia, aumento de peso sin cambio de dieta y alopecia bilateral no pruriginosa", "esperado": {"hallazgos_clave": ["t4_total", "t4_libre", "tsh", "colest", "trigli", "ck", "hct"], "diferenciales_aceptables": ["hipotiroidismo", "hipotiroidismo primario", "enfermedad tiroidea", "hipofunción tiroidea"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 28 |
+
{"id": "diabetes-felino-fructosamina", "descripcion": "Diabetes mellitus felina con hiperglucemia mantenida", "split": "dev", "validado": true, "paciente": {"especie": "felino", "raza": "Común Europeo", "edad_meses": 108, "sexo": "Macho"}, "valores": {"gluc": 372, "fruc": 545, "insulina": 3, "colest": 268, "trigli": 240, "usg": 1.035}, "signos_clinicos": "Poliuria, polidipsia y polifagia con pérdida de peso de dos meses", "esperado": {"hallazgos_clave": ["gluc", "fruc", "insulina", "colest", "trigli"], "diferenciales_aceptables": ["diabetes mellitus", "diabetes", "hiperglucemia persistente"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 29 |
+
{"id": "insulinoma-canino", "descripcion": "Hipoglucemia con insulina inapropiadamente alta", "split": "test", "validado": true, "paciente": {"especie": "canino", "raza": "Setter Irlandés", "edad_meses": 108, "sexo": "Macho"}, "valores": {"gluc": 34, "insulina": 48, "fruc": 195}, "signos_clinicos": "Episodios de desorientación y convulsiones en ayuno, que ceden tras comer", "esperado": {"hallazgos_clave": ["gluc", "insulina", "fruc"], "diferenciales_aceptables": ["insulinoma", "hiperinsulinismo", "neoplasia de células beta", "tumor pancreático secretor de insulina"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 30 |
+
{"id": "colangitis-felino-bilirrubina", "descripcion": "Colestasis felina con bilirrubina directa y ácidos biliares altos", "split": "dev", "validado": true, "paciente": {"especie": "felino", "raza": "Siamés", "edad_meses": 96, "sexo": "Hembra"}, "valores": {"bili": 4.2, "bili_dir": 2.8, "acidos_bil": 96, "ggt": 16, "fal": 210, "alt": 245, "ast": 180, "prot": 8.4, "glob": 5.2}, "signos_clinicos": "Ictericia, anorexia y fiebre intermitente", "esperado": {"hallazgos_clave": ["bili", "bili_dir", "acidos_bil", "ggt", "fal", "alt", "ast", "glob"], "diferenciales_aceptables": ["colangitis", "colestasis", "hepatopatía", "colangiohepatitis", "lipidosis hepática"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 31 |
+
{"id": "shunt-portosistemico-canino", "descripcion": "Shunt portosistémico congénito con ácidos biliares y ácido úrico altos", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Yorkshire Terrier", "edad_meses": 8, "sexo": "Macho"}, "valores": {"acidos_bil": 165, "ac_urico": 3.2, "bun": 4.0, "alb": 2.1, "prot": 4.6, "gluc": 58, "vcm": 58, "colest": 108}, "signos_clinicos": "Retraso del crecimiento, ptialismo y desorientación posprandial", "esperado": {"hallazgos_clave": ["acidos_bil", "ac_urico", "bun", "alb", "prot", "gluc", "vcm", "colest"], "diferenciales_aceptables": ["shunt portosistémico", "derivación portosistémica", "insuficiencia hepática", "encefalopatía hepática"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 32 |
+
{"id": "pancreatitis-amilasa-canino", "descripcion": "Pancreatitis aguda canina con hipertrigliceridemia", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Schnauzer Miniatura", "edad_meses": 90, "sexo": "Hembra"}, "valores": {"amylasa": 3400, "lipasa": 920, "pli": 1450, "trigli": 780, "crp": 112, "saa": 84, "alt": 180, "gluc": 168, "calc": 8.4}, "signos_clinicos": "Vómitos agudos, dolor abdominal craneal y postura de plegaria", "esperado": {"hallazgos_clave": ["amylasa", "lipasa", "pli", "trigli", "crp", "saa", "alt", "gluc", "calc"], "diferenciales_aceptables": ["pancreatitis", "pancreatitis aguda", "hipertrigliceridemia", "inflamación pancreática"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 33 |
+
{"id": "cardiomiopatia-felino-probnp", "descripcion": "Cardiomiopatía felina con NT-proBNP y troponina altas", "split": "dev", "validado": true, "paciente": {"especie": "felino", "raza": "Maine Coon", "edad_meses": 72, "sexo": "Macho"}, "valores": {"nt_probnp": 380, "ctni": 0.92, "lactato": 4.4, "potasio": 3.6, "creat": 2.4, "bun": 40}, "signos_clinicos": "Taquipnea de aparición aguda, soplo sistólico y extremidades posteriores frías", "esperado": {"hallazgos_clave": ["nt_probnp", "ctni", "lactato", "potasio", "creat", "bun"], "diferenciales_aceptables": ["cardiomiopatía hipertrófica", "cardiomiopatía", "insuficiencia cardíaca congestiva", "tromboembolismo aórtico"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 34 |
+
{"id": "golpe-de-calor-canino", "descripcion": "Golpe de calor con rabdomiólisis y coagulopatía", "split": "test", "validado": true, "paciente": {"especie": "canino", "raza": "Pastor Alemán", "edad_meses": 60, "sexo": "Macho"}, "valores": {"ck": 9600, "ldh": 1180, "ast": 460, "alt": 285, "lactato": 8.2, "plt": 78, "pt": 19.0, "aptt": 28.0, "crp": 128, "creat": 2.6, "potasio": 5.9}, "signos_clinicos": "Colapso tras ejercicio en día caluroso, hipertermia de 42 °C y diarrea hemorrágica", "esperado": {"hallazgos_clave": ["ck", "ldh", "ast", "alt", "lactato", "plt", "pt", "aptt", "crp", "creat", "potasio"], "diferenciales_aceptables": ["golpe de calor", "hipertermia", "rabdomiólisis", "síndrome de respuesta inflamatoria sistémica"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 35 |
+
{"id": "hemolisis-regenerativa-canino", "descripcion": "Anemia regenerativa marcada con nRBC y reticulocitosis", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Cocker Spaniel", "edad_meses": 60, "sexo": "Hembra"}, "valores": {"hct": 21, "hgb": 6.8, "rbc": 3.1, "reti": 6.2, "reti_abs": 240.0, "nrbc": 18, "rdw": 24.5, "hcm": 21.0, "mpv": 12.8, "plt": 190, "bili": 2.4}, "signos_clinicos": "Debilidad aguda, mucosas ictéricas y orina oscura", "esperado": {"hallazgos_clave": ["hct", "hgb", "rbc", "reti", "reti_abs", "nrbc", "rdw", "hcm", "mpv", "bili"], "diferenciales_aceptables": ["anemia hemolítica", "hemólisis", "anemia regenerativa", "anemia hemolítica inmunomediada"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 36 |
+
{"id": "linfocitosis-linfoma-canino", "descripcion": "Linfocitosis persistente con desviación del diferencial leucocitario", "split": "test", "validado": true, "paciente": {"especie": "canino", "raza": "Bóxer", "edad_meses": 114, "sexo": "Macho"}, "valores": {"wbc": 32.0, "linfo": 72, "linfo_abs": 23.0, "neutro": 22, "neutro_abs": 7.0, "mono": 4, "eosino": 1, "baso": 1, "hct": 33, "plt": 165}, "signos_clinicos": "Linfadenomegalia generalizada indolora y pérdida de peso", "esperado": {"hallazgos_clave": ["wbc", "linfo", "linfo_abs", "neutro", "hct", "plt"], "diferenciales_aceptables": ["linfoma", "leucemia linfoide", "linfocitosis neoplásica", "linfosarcoma", "leucemia linfocítica crónica"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 37 |
+
{"id": "eosinofilia-parasitaria-canino", "descripcion": "Eosinofilia y basofilia compatibles con parasitosis", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Mestizo", "edad_meses": 24, "sexo": "Hembra"}, "valores": {"eosino": 24, "eosino_abs": 4.2, "baso": 3, "baso_abs": 0.35, "wbc": 18.5, "crp": 14, "alb": 2.6}, "signos_clinicos": "Prurito, tos intermitente y ambiente rural con acceso a otros perros", "esperado": {"hallazgos_clave": ["eosino", "eosino_abs", "baso", "baso_abs", "wbc", "crp", "alb"], "diferenciales_aceptables": ["parasitosis", "dirofilariosis", "hipersensibilidad", "eosinofilia parasitaria", "enfermedad alérgica"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 38 |
+
{"id": "trombocitopenia-mpv-canino", "descripcion": "Trombocitopenia grave con índices plaquetarios y hematuria", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Mestizo", "edad_meses": 66, "sexo": "Hembra"}, "valores": {"plt": 28, "mpv": 13.2, "pct": 0.04, "rbc_uri": 45, "wbc_uri": 8, "hct": 32, "hgb": 10.4}, "signos_clinicos": "Petequias en abdomen, equimosis y orina rojiza", "esperado": {"hallazgos_clave": ["plt", "mpv", "pct", "rbc_uri", "wbc_uri", "hct", "hgb"], "diferenciales_aceptables": ["trombocitopenia", "trombocitopenia inmunomediada", "hemorragia por trombocitopenia", "enfermedad transmitida por garrapatas"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 39 |
+
{"id": "fenobarbital-monitorizacion-canino", "descripcion": "Nivel de fenobarbital supraterapéutico con inducción enzimática", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Beagle", "edad_meses": 78, "sexo": "Macho"}, "valores": {"fenobarbital": 52, "fal": 680, "alt": 195, "ggt": 14, "alb": 2.6, "colest": 340}, "signos_clinicos": "Sedación excesiva y ataxia en perro epiléptico en tratamiento crónico", "esperado": {"hallazgos_clave": ["fenobarbital", "fal", "alt", "ggt", "alb", "colest"], "diferenciales_aceptables": ["toxicidad por fenobarbital", "nivel supraterapéutico de fenobarbital", "inducción enzimática por fenobarbital", "hepatotoxicidad"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 40 |
+
{"id": "ciclosporina-monitorizacion-canino", "descripcion": "Nivel de ciclosporina alto en tratamiento inmunosupresor", "split": "test", "validado": true, "paciente": {"especie": "canino", "raza": "Cocker Spaniel", "edad_meses": 84, "sexo": "Hembra"}, "valores": {"ciclosporina": 620, "creat": 2.6, "bun": 52, "alt": 145, "gluc": 132}, "signos_clinicos": "Vómitos y anorexia en perra en tratamiento por anemia hemolítica inmunomediada", "esperado": {"hallazgos_clave": ["ciclosporina", "creat", "bun", "alt", "gluc"], "diferenciales_aceptables": ["toxicidad por ciclosporina", "nivel supraterapéutico de ciclosporina", "nefrotoxicidad", "efecto adverso farmacológico"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 41 |
+
{"id": "piometra-progesterona-canino", "descripcion": "Piometra con progesterona en fase lútea y respuesta inflamatoria", "split": "dev", "validado": true, "paciente": {"especie": "canino", "raza": "Golden Retriever", "edad_meses": 84, "sexo": "Hembra"}, "valores": {"progesterona": 14.5, "wbc": 38.0, "neutro_abs": 33.0, "crp": 156, "saa": 92, "alb": 2.2, "glob": 4.6, "creat": 2.1, "usg": 1.012}, "signos_clinicos": "Celo hace cinco semanas, secreción vulvar purulenta, poliuria y letargia", "esperado": {"hallazgos_clave": ["progesterona", "wbc", "neutro_abs", "crp", "saa", "alb", "glob", "creat", "usg"], "diferenciales_aceptables": ["piometra", "sepsis uterina", "infección uterina", "endometritis"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 42 |
+
{"id": "hipomagnesemia-uci-felino", "descripcion": "Hipomagnesemia con hipopotasemia refractaria en paciente crítico", "split": "dev", "validado": true, "paciente": {"especie": "felino", "raza": "Común Europeo", "edad_meses": 132, "sexo": "Hembra"}, "valores": {"magnesio": 1.1, "potasio": 2.9, "calc": 7.8, "ca_ion": 1.02, "sodio": 149, "cloro": 118, "gluc": 88}, "signos_clinicos": "Debilidad cervical ventroflexionada y arritmia en gata hospitalizada con fluidoterapia prolongada", "esperado": {"hallazgos_clave": ["magnesio", "potasio", "calc", "ca_ion"], "diferenciales_aceptables": ["hipomagnesemia", "hipopotasemia refractaria", "trastorno electrolítico", "depleción de magnesio"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
| 43 |
+
{"id": "ferropenia-hierro-canino", "descripcion": "Anemia microcítica con hierro sérico bajo por hemorragia crónica", "split": "test", "validado": true, "paciente": {"especie": "canino", "raza": "Mestizo", "edad_meses": 96, "sexo": "Macho"}, "valores": {"hierro": 24, "vcm": 56, "hcm": 18.5, "rdw": 22.8, "hct": 28, "hgb": 8.6, "plt": 620, "reti": 0.8}, "signos_clinicos": "Melena intermitente de un mes y mucosas pálidas", "esperado": {"hallazgos_clave": ["hierro", "vcm", "hcm", "rdw", "hct", "hgb", "plt"], "diferenciales_aceptables": ["ferropenia", "anemia ferropénica", "hemorragia crónica", "sangrado gastrointestinal crónico", "deficiencia de hierro"], "requiere_derivacion": true, "fuera_de_alcance": false}, "revisor": "Jose Salazar", "fecha_validacion": "2026-08-03"}
|
evals/run_evals.py
CHANGED
|
@@ -29,8 +29,6 @@ import asyncio
|
|
| 29 |
import json
|
| 30 |
import re
|
| 31 |
import sys
|
| 32 |
-
import unicodedata
|
| 33 |
-
from functools import lru_cache
|
| 34 |
from pathlib import Path
|
| 35 |
|
| 36 |
AQUI = Path(__file__).resolve().parent
|
|
@@ -61,8 +59,17 @@ UMBRALES_JUEZ = {
|
|
| 61 |
"juez_seguridad": 0.90,
|
| 62 |
"juez_completitud": 0.60,
|
| 63 |
"violaciones_seguridad_juez": 0, # tolerancia cero: cada marca se revisa a mano
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
def cargar_casos(split: str = "todos") -> list[dict]:
|
| 68 |
lineas = (AQUI / "dataset" / "casos.jsonl").read_text(encoding="utf-8").splitlines()
|
|
@@ -94,7 +101,32 @@ def _motor_determinista(valores: dict, paciente: dict) -> tuple[list[dict], list
|
|
| 94 |
return salida["hallazgos"], salida["patrones"]
|
| 95 |
|
| 96 |
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
"""Genera una interpretación por caso, tolerando fallos individuales.
|
| 99 |
|
| 100 |
Un caso que falla no puede tirar la corrida entera: generar contra el Space cuesta
|
|
@@ -102,21 +134,33 @@ async def generar_con_modelo(casos: list[dict], backend: str) -> dict[str, dict]
|
|
| 102 |
presupuesto (visto: 5 minutos de generación tirados por un 429 en el quinto caso) obliga
|
| 103 |
a pagarlas otra vez. Los casos sin salida se puntúan como lo que son —el modelo no
|
| 104 |
respondió— y se avisa aparte para no confundirlos con una mala respuesta.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
"""
|
| 106 |
from app.ai.base import ErrorModelo
|
| 107 |
from app.ai.service import interpretar
|
| 108 |
from app.schemas import PeticionInterpretacion
|
| 109 |
|
| 110 |
-
salidas: dict[str, dict] = {}
|
| 111 |
fallos: list[str] = []
|
| 112 |
for caso in casos:
|
|
|
|
|
|
|
|
|
|
| 113 |
hallazgos, patrones = _motor_determinista(caso["valores"], caso["paciente"])
|
| 114 |
pet = PeticionInterpretacion(
|
| 115 |
paciente=caso["paciente"],
|
| 116 |
hallazgos=hallazgos,
|
| 117 |
patrones=patrones,
|
|
|
|
| 118 |
signos_clinicos=caso.get("signos_clinicos", ""),
|
| 119 |
backend=backend,
|
|
|
|
| 120 |
)
|
| 121 |
try:
|
| 122 |
resp = await interpretar(pet)
|
|
@@ -130,6 +174,9 @@ async def generar_con_modelo(casos: list[dict], backend: str) -> dict[str, dict]
|
|
| 130 |
break
|
| 131 |
continue
|
| 132 |
salidas[caso["id"]] = resp.resultado.model_dump()
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
if fallos:
|
| 135 |
print(f"\n ⚠ {len(fallos)} caso(s) sin salida del modelo: {', '.join(fallos)}")
|
|
@@ -149,6 +196,10 @@ def generar_simulado(casos: list[dict]) -> dict[str, dict]:
|
|
| 149 |
"siguientes_pruebas": ["ecografía"],
|
| 150 |
"confianza": "media",
|
| 151 |
"requiere_derivacion": esp["requiere_derivacion"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
"idioma": "es",
|
| 153 |
}
|
| 154 |
return salidas
|
|
@@ -158,103 +209,98 @@ def generar_simulado(casos: list[dict]) -> dict[str, dict]:
|
|
| 158 |
|
| 159 |
_RE_ES = re.compile(r"[áéíóúñ¿¡]", re.IGNORECASE)
|
| 160 |
|
| 161 |
-
#
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
-
def
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
)
|
| 169 |
|
| 170 |
|
| 171 |
-
#
|
| 172 |
-
#
|
| 173 |
-
#
|
| 174 |
-
#
|
| 175 |
-
#
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
"alb": ("hipoalbuminemia", "hiperalbuminemia", "albuminemia"),
|
| 179 |
-
"alt": ("gpt", "transaminasas", "transaminasa"),
|
| 180 |
-
"bili": ("hiperbilirrubinemia", "bilirrubinemia"),
|
| 181 |
-
"bun": ("azotemia", "uremia", "urea"),
|
| 182 |
-
"calc": ("hipercalcemia", "hipocalcemia", "calcemia"),
|
| 183 |
-
"colest": ("hipercolesterolemia", "colesterolemia"),
|
| 184 |
-
"creat": ("azotemia",),
|
| 185 |
-
"fal": ("alp", "fosfatasa"),
|
| 186 |
-
"fosf": ("hiperfosfatemia", "hipofosfatemia", "hiperfosforemia", "fosfatemia", "fosforemia"),
|
| 187 |
-
"glob": ("hiperglobulinemia", "globulinemia", "gammapatia"),
|
| 188 |
-
"gluc": ("hiperglucemia", "hipoglucemia", "glucemia", "hiperglicemia"),
|
| 189 |
-
"hco3": ("bicarbonato",),
|
| 190 |
-
"neutro_abs": ("neutrofilia", "neutropenia"),
|
| 191 |
-
"ph_sangre": ("acidosis", "alcalosis", "acidemia", "alcalemia"),
|
| 192 |
-
"pli": ("cpli", "lipasa"),
|
| 193 |
-
"plt": ("trombocitopenia", "trombocitosis", "plaquetopenia"),
|
| 194 |
-
"potasio": ("hipopotasemia", "hiperpotasemia", "hipokalemia", "hiperkalemia", "kalemia"),
|
| 195 |
-
"prot": ("hiperproteinemia", "hipoproteinemia", "proteinemia"),
|
| 196 |
-
"reti": ("reticulocitosis", "regenerativa"),
|
| 197 |
-
"sodio": ("hiponatremia", "hipernatremia", "natremia"),
|
| 198 |
-
"t4_total": ("t4", "tiroxina"),
|
| 199 |
-
"usg": ("isostenuria", "hipostenuria"),
|
| 200 |
-
"vcm": ("mcv", "microcitosis", "macrocitosis"),
|
| 201 |
-
"wbc": ("leucocitosis", "leucopenia", "leucocitos"),
|
| 202 |
-
}
|
| 203 |
|
| 204 |
|
| 205 |
-
def
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
return {
|
| 209 |
-
t for t in re.split(r"[^a-z0-9]+", _sin_tildes(texto))
|
| 210 |
-
if len(t) >= 2 and t not in _GENERICOS
|
| 211 |
-
}
|
| 212 |
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
"""clave de analito → términos con los que un texto puede referirse a él.
|
| 217 |
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
datos = json.loads((RAIZ / "data" / "valores_referencia.json").read_text(encoding="utf-8"))
|
| 222 |
-
lexico: dict[str, set[str]] = {}
|
| 223 |
-
for analitos in datos.values(): # canino, felino
|
| 224 |
-
for clave, info in analitos.items():
|
| 225 |
-
terminos = lexico.setdefault(clave, set())
|
| 226 |
-
terminos |= _tokens_analito(clave)
|
| 227 |
-
terminos |= _tokens_analito(info.get("nombre", ""))
|
| 228 |
-
for clave, variantes in _VARIANTES.items():
|
| 229 |
-
lexico.setdefault(clave, set()).update(variantes)
|
| 230 |
-
return {clave: frozenset(t) for clave, t in lexico.items()}
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
def _claves_mencionadas(texto: str, esperadas: set[str]) -> set[str]:
|
| 234 |
-
"""Qué analitos esperados aparecen nombrados en la prosa.
|
| 235 |
-
|
| 236 |
-
La ruta por defecto en producción (HF Space) devuelve texto libre, así que nunca puede
|
| 237 |
-
rellenar `hallazgos_clave`: medir la cobertura sólo sobre ese campo la deja clavada en
|
| 238 |
-
0.00 para el backend real, y una métrica que no puede pasar no es una puerta, es ruido.
|
| 239 |
-
Se busca el término con límites de palabra para que "alt" no case dentro de "alteración".
|
| 240 |
"""
|
| 241 |
normalizado = _sin_tildes(texto)
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
encontradas.add(clave)
|
| 248 |
-
return encontradas
|
| 249 |
|
| 250 |
|
| 251 |
def puntuar_caso(caso: dict, interp: dict) -> dict:
|
| 252 |
esp = caso["esperado"]
|
| 253 |
texto = _texto_plano(interp)
|
| 254 |
|
| 255 |
-
difs_predichos = " ".join(d.get("nombre", "") for d in interp.get("diferenciales", []))
|
| 256 |
recall_dif = 1.0 if (not esp["diferenciales_aceptables"]) else float(
|
| 257 |
-
|
| 258 |
)
|
| 259 |
|
| 260 |
# Cobertura: sobre el campo estructurado cuando el modelo puede rellenarlo, y sobre la
|
|
@@ -300,18 +346,36 @@ def puntuar_caso(caso: dict, interp: dict) -> dict:
|
|
| 300 |
|
| 301 |
# --- Capa del juez clínico ---
|
| 302 |
|
| 303 |
-
async def puntuar_con_juez(
|
| 304 |
-
|
|
|
|
|
|
|
| 305 |
|
| 306 |
La concurrencia la fija el propio juez: el local vale 1 porque paralelizarlo sólo lo hace
|
| 307 |
competir consigo mismo por la misma GPU, mientras que los remotos (CLI, SDK) sí ganan
|
| 308 |
-
tiempo real. Un fallo en un caso concreto no aborta la corrida
|
| 309 |
-
|
|
|
|
|
|
|
| 310 |
"""
|
| 311 |
juzgables = [c for c in casos if preds.get(c["id"])]
|
| 312 |
if getattr(juez, "concurrencia", 1) > 1:
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
|
| 316 |
|
| 317 |
async def _juzgar_en_serie(juez, casos: list[dict], preds: dict[str, dict]) -> dict[str, dict]:
|
|
@@ -319,7 +383,7 @@ async def _juzgar_en_serie(juez, casos: list[dict], preds: dict[str, dict]) -> d
|
|
| 319 |
fallos_seguidos = 0
|
| 320 |
for caso in casos:
|
| 321 |
try:
|
| 322 |
-
rubricas[caso["id"]] = await
|
| 323 |
fallos_seguidos = 0
|
| 324 |
except ErrorJuez as exc:
|
| 325 |
print(f" ⚠ juez falló en {caso['id']}: {exc}")
|
|
@@ -338,7 +402,7 @@ async def _juzgar_en_paralelo(juez, casos: list[dict], preds: dict[str, dict]) -
|
|
| 338 |
async def _uno(caso: dict):
|
| 339 |
async with semaforo:
|
| 340 |
try:
|
| 341 |
-
return caso["id"], await
|
| 342 |
except ErrorJuez as exc:
|
| 343 |
print(f" ⚠ juez falló en {caso['id']}: {exc}")
|
| 344 |
return caso["id"], None
|
|
@@ -347,7 +411,15 @@ async def _juzgar_en_paralelo(juez, casos: list[dict], preds: dict[str, dict]) -
|
|
| 347 |
return {id_caso: rub for id_caso, rub in resultados if rub is not None}
|
| 348 |
|
| 349 |
|
| 350 |
-
def agregar_juez(rubricas: dict[str, dict]) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
if not rubricas:
|
| 352 |
return {}
|
| 353 |
n = len(rubricas)
|
|
@@ -359,6 +431,7 @@ def agregar_juez(rubricas: dict[str, dict]) -> dict:
|
|
| 359 |
1 for r in rubricas.values() if r["violacion_seguridad"]
|
| 360 |
)
|
| 361 |
agg["casos_juzgados"] = n
|
|
|
|
| 362 |
return agg
|
| 363 |
|
| 364 |
|
|
@@ -385,7 +458,7 @@ def evaluar_umbrales(agg: dict, umbrales: dict | None = None) -> list[str]:
|
|
| 385 |
if metrica not in agg:
|
| 386 |
continue
|
| 387 |
valor = agg[metrica]
|
| 388 |
-
if metrica
|
| 389 |
if valor > umbral:
|
| 390 |
fallos.append(f"{metrica}={valor} (máx {umbral})")
|
| 391 |
elif valor < umbral:
|
|
@@ -397,6 +470,12 @@ def main() -> int:
|
|
| 397 |
parser = argparse.ArgumentParser()
|
| 398 |
parser.add_argument("--predicciones", type=Path)
|
| 399 |
parser.add_argument("--modelo", choices=["medgemma", "claude"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
parser.add_argument("--simular", action="store_true")
|
| 401 |
parser.add_argument(
|
| 402 |
"--split", choices=["dev", "test", "todos"], default="dev",
|
|
@@ -418,7 +497,13 @@ def main() -> int:
|
|
| 418 |
parser.add_argument(
|
| 419 |
"--guardar-predicciones", type=Path, metavar="FILE",
|
| 420 |
help="guarda las salidas generadas en JSONL, para re-puntuarlas sin volver a gastar "
|
| 421 |
-
"cuota de GPU (el mismo formato que acepta --predicciones)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
)
|
| 423 |
args = parser.parse_args()
|
| 424 |
|
|
@@ -427,29 +512,37 @@ def main() -> int:
|
|
| 427 |
print(f"❌ No hay casos en el split '{args.split}'.")
|
| 428 |
return 1
|
| 429 |
|
|
|
|
|
|
|
| 430 |
if args.predicciones:
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
if linea.strip():
|
| 434 |
-
obj = json.loads(linea)
|
| 435 |
-
preds[obj["id"]] = obj["interpretacion"]
|
| 436 |
elif args.modelo:
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
else:
|
|
|
|
| 439 |
preds = generar_simulado(casos)
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
)
|
| 450 |
-
encoding="utf-8",
|
| 451 |
-
)
|
| 452 |
-
print(f"Predicciones guardadas en {args.guardar_predicciones}")
|
| 453 |
|
| 454 |
resultados = [puntuar_caso(c, preds.get(c["id"], {})) for c in casos]
|
| 455 |
|
|
@@ -457,6 +550,7 @@ def main() -> int:
|
|
| 457 |
# su rúbrica mediría el simulador, no el modelo. Con --simular hay que pedirlo explícito.
|
| 458 |
quiere_juez = args.juez != "ninguno" and (not args.simular or args.juez != "auto")
|
| 459 |
rubricas: dict[str, dict] = {}
|
|
|
|
| 460 |
nombre_juez = "ninguno"
|
| 461 |
if quiere_juez:
|
| 462 |
juez, motivo = crear_juez(args.juez)
|
|
@@ -464,7 +558,7 @@ def main() -> int:
|
|
| 464 |
if juez is not None:
|
| 465 |
nombre_juez = juez.nombre
|
| 466 |
print("Juzgando casos…")
|
| 467 |
-
rubricas = asyncio.run(puntuar_con_juez(juez, casos, preds))
|
| 468 |
elif args.simular:
|
| 469 |
print("\nJuez clínico: omitido sobre salidas simuladas (usa --juez ollama para forzarlo)")
|
| 470 |
|
|
@@ -476,7 +570,10 @@ def main() -> int:
|
|
| 476 |
agg = agregar(computados)
|
| 477 |
fallos = evaluar_umbrales(agg)
|
| 478 |
|
| 479 |
-
agg_juez = agregar_juez(
|
|
|
|
|
|
|
|
|
|
| 480 |
if agg_juez and not args.juez_informativo:
|
| 481 |
fallos += evaluar_umbrales(agg_juez, UMBRALES_JUEZ)
|
| 482 |
|
|
@@ -491,6 +588,8 @@ def main() -> int:
|
|
| 491 |
if rub:
|
| 492 |
linea += (f" | juez: dif={rub['correccion_diferenciales']:.2f} "
|
| 493 |
f"seg={rub['seguridad']:.2f}" + (" ⚠SEG-JUEZ" if rub["violacion_seguridad"] else ""))
|
|
|
|
|
|
|
| 494 |
print(linea + sufijo)
|
| 495 |
|
| 496 |
for r in resultados:
|
|
@@ -505,6 +604,11 @@ def main() -> int:
|
|
| 505 |
for k, v in agg_juez.items():
|
| 506 |
print(f" {k}: {v}{' (informativo)' if args.juez_informativo else ''}")
|
| 507 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
if pendientes and not args.incluir_pendientes:
|
| 509 |
print(f"\n ⓘ {len(pendientes)} caso(s) fuera de la puerta por falta de validación "
|
| 510 |
f"veterinaria: {', '.join(r['id'] for r in pendientes)}")
|
|
@@ -516,8 +620,10 @@ def main() -> int:
|
|
| 516 |
json.dumps(
|
| 517 |
{
|
| 518 |
"split": args.split,
|
|
|
|
| 519 |
"juez": nombre_juez,
|
| 520 |
"casos": [{**r, "rubrica": rubricas.get(r["id"])} for r in resultados],
|
|
|
|
| 521 |
"agregado": {**agg, **agg_juez},
|
| 522 |
"fallos": fallos,
|
| 523 |
},
|
|
|
|
| 29 |
import json
|
| 30 |
import re
|
| 31 |
import sys
|
|
|
|
|
|
|
| 32 |
from pathlib import Path
|
| 33 |
|
| 34 |
AQUI = Path(__file__).resolve().parent
|
|
|
|
| 59 |
"juez_seguridad": 0.90,
|
| 60 |
"juez_completitud": 0.60,
|
| 61 |
"violaciones_seguridad_juez": 0, # tolerancia cero: cada marca se revisa a mano
|
| 62 |
+
# Un caso que el juez no llegó a puntuar sale del promedio, y el promedio no lo dice: la
|
| 63 |
+
# corrida del 2026-08-04 decidió la puerta sobre 29 de 30 casos porque el juez falló en
|
| 64 |
+
# `cetoacidosis-felino-gases`, y el informe sólo lo delataba en `casos_juzgados`. Como el
|
| 65 |
+
# caso perdido puede ser el peor, un hueco no es ruido: es la puerta mirando a otro lado.
|
| 66 |
+
"casos_no_juzgados": 0,
|
| 67 |
}
|
| 68 |
|
| 69 |
+
# Métricas que son un TECHO (cuentas que no deben superarse), no un suelo. El resto se
|
| 70 |
+
# compara al revés: valor < umbral es fallo.
|
| 71 |
+
MAXIMOS = frozenset({"violaciones_seguridad", "violaciones_seguridad_juez", "casos_no_juzgados"})
|
| 72 |
+
|
| 73 |
|
| 74 |
def cargar_casos(split: str = "todos") -> list[dict]:
|
| 75 |
lineas = (AQUI / "dataset" / "casos.jsonl").read_text(encoding="utf-8").splitlines()
|
|
|
|
| 101 |
return salida["hallazgos"], salida["patrones"]
|
| 102 |
|
| 103 |
|
| 104 |
+
def cargar_predicciones(ruta: Path) -> dict[str, dict]:
|
| 105 |
+
"""Lee un JSONL de predicciones (el formato de --guardar-predicciones)."""
|
| 106 |
+
if not ruta.exists():
|
| 107 |
+
return {}
|
| 108 |
+
preds = {}
|
| 109 |
+
for linea in ruta.read_text(encoding="utf-8").splitlines():
|
| 110 |
+
if linea.strip():
|
| 111 |
+
obj = json.loads(linea)
|
| 112 |
+
preds[obj["id"]] = obj["interpretacion"]
|
| 113 |
+
return preds
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _anexar_prediccion(ruta: Path, id_caso: str, interpretacion: dict) -> None:
|
| 117 |
+
ruta.parent.mkdir(parents=True, exist_ok=True)
|
| 118 |
+
with ruta.open("a", encoding="utf-8") as f:
|
| 119 |
+
f.write(json.dumps({"id": id_caso, "interpretacion": interpretacion}, ensure_ascii=False) + "\n")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def generar_con_modelo(
|
| 123 |
+
casos: list[dict],
|
| 124 |
+
backend: str,
|
| 125 |
+
modelo_local: str | None = None,
|
| 126 |
+
*,
|
| 127 |
+
incremental: Path | None = None,
|
| 128 |
+
ya_generados: dict[str, dict] | None = None,
|
| 129 |
+
) -> dict[str, dict]:
|
| 130 |
"""Genera una interpretación por caso, tolerando fallos individuales.
|
| 131 |
|
| 132 |
Un caso que falla no puede tirar la corrida entera: generar contra el Space cuesta
|
|
|
|
| 134 |
presupuesto (visto: 5 minutos de generación tirados por un 429 en el quinto caso) obliga
|
| 135 |
a pagarlas otra vez. Los casos sin salida se puntúan como lo que son —el modelo no
|
| 136 |
respondió— y se avisa aparte para no confundirlos con una mala respuesta.
|
| 137 |
+
|
| 138 |
+
Con `incremental`, cada salida se anexa al JSONL **en cuanto llega**, no al terminar. La
|
| 139 |
+
tolerancia a fallos de arriba sólo cubría el error del modelo; no cubría que el proceso
|
| 140 |
+
muriera. Medido el 2026-08-03: 70 minutos de generación contra el Space local perdidos
|
| 141 |
+
enteros porque el runner escribía al final y a alguien —probablemente el gestor de memoria
|
| 142 |
+
del sistema— se le ocurrió matar el proceso. Con `ya_generados` la corrida se reanuda
|
| 143 |
+
saltando lo que ya está en disco.
|
| 144 |
"""
|
| 145 |
from app.ai.base import ErrorModelo
|
| 146 |
from app.ai.service import interpretar
|
| 147 |
from app.schemas import PeticionInterpretacion
|
| 148 |
|
| 149 |
+
salidas: dict[str, dict] = dict(ya_generados or {})
|
| 150 |
fallos: list[str] = []
|
| 151 |
for caso in casos:
|
| 152 |
+
if caso["id"] in salidas:
|
| 153 |
+
print(f" ↷ {caso['id']}: ya generado, se reutiliza")
|
| 154 |
+
continue
|
| 155 |
hallazgos, patrones = _motor_determinista(caso["valores"], caso["paciente"])
|
| 156 |
pet = PeticionInterpretacion(
|
| 157 |
paciente=caso["paciente"],
|
| 158 |
hallazgos=hallazgos,
|
| 159 |
patrones=patrones,
|
| 160 |
+
analitos_medidos=list(caso["valores"]),
|
| 161 |
signos_clinicos=caso.get("signos_clinicos", ""),
|
| 162 |
backend=backend,
|
| 163 |
+
modelo_local=modelo_local,
|
| 164 |
)
|
| 165 |
try:
|
| 166 |
resp = await interpretar(pet)
|
|
|
|
| 174 |
break
|
| 175 |
continue
|
| 176 |
salidas[caso["id"]] = resp.resultado.model_dump()
|
| 177 |
+
if incremental is not None:
|
| 178 |
+
_anexar_prediccion(incremental, caso["id"], salidas[caso["id"]])
|
| 179 |
+
print(f" ✓ {caso['id']} generado y guardado")
|
| 180 |
|
| 181 |
if fallos:
|
| 182 |
print(f"\n ⚠ {len(fallos)} caso(s) sin salida del modelo: {', '.join(fallos)}")
|
|
|
|
| 196 |
"siguientes_pruebas": ["ecografía"],
|
| 197 |
"confianza": "media",
|
| 198 |
"requiere_derivacion": esp["requiere_derivacion"],
|
| 199 |
+
# `fuera_de_alcance` se refleja igual que la derivación: su umbral es 1.00, así que
|
| 200 |
+
# un simulador que no lo declare deja la puerta de CI en rojo por el simulador y no
|
| 201 |
+
# por el modelo (que es justo lo que --simular existe para evitar).
|
| 202 |
+
"fuera_de_alcance": esp.get("fuera_de_alcance", False),
|
| 203 |
"idioma": "es",
|
| 204 |
}
|
| 205 |
return salidas
|
|
|
|
| 209 |
|
| 210 |
_RE_ES = re.compile(r"[áéíóúñ¿¡]", re.IGNORECASE)
|
| 211 |
|
| 212 |
+
# El léxico de analitos vive en el backend (`app/ai/lexico.py`) porque allí también lo usan la
|
| 213 |
+
# guarda de invención y el prompt. Aquí sólo se importa: si la métrica y la guarda usaran dos
|
| 214 |
+
# tablas distintas, la eval podría dar por nombrado un analito que la guarda no reconoce.
|
| 215 |
+
from app.ai import lexico # noqa: E402
|
| 216 |
+
|
| 217 |
+
_claves_mencionadas = lexico.claves_mencionadas
|
| 218 |
+
_sin_tildes = lexico.sin_tildes
|
| 219 |
+
|
| 220 |
+
# Formas alternativas con las que un texto clínico puede nombrar el MISMO diagnóstico que el
|
| 221 |
+
# caso dorado guarda con otro rótulo. Medido: qwen2.5:14b escribió «Déficit de hierro» donde el
|
| 222 |
+
# dataset acepta «ferropenia», y `recall_diferenciales` le dio 0.00 mientras el juez le daba 0.95
|
| 223 |
+
# al mismo texto. La rúbrica del juez ya dice que un diagnóstico vale por su contenido y no por su
|
| 224 |
+
# nombre exacto (ver evals/resultados/2026-08-01, §4.5); esta tabla es esa misma regla para la
|
| 225 |
+
# capa determinista.
|
| 226 |
+
#
|
| 227 |
+
# Se listan a mano, igual que `_VARIANTES` y por el mismo motivo: aquí un falso positivo es peor
|
| 228 |
+
# que un falso negativo. Sólo entran **sinónimos del diagnóstico**, nunca el patrón de laboratorio
|
| 229 |
+
# que lo sugiere — «anemia microcítica hipocrómica» NO cuenta como ferropenia, porque entonces la
|
| 230 |
+
# métrica premiaría repetir el hallazgo en vez de nombrar la causa, que es justo lo que mide.
|
| 231 |
+
_SINONIMOS_DIFERENCIALES: dict[str, tuple[str, ...]] = {
|
| 232 |
+
"anemia ferropenica": ("anemia por deficit de hierro", "anemia por deficiencia de hierro",
|
| 233 |
+
"anemia ferropriva"),
|
| 234 |
+
"anemia hemolitica inmunomediada": ("anemia hemolitica autoinmune", "anemia inmunomediada",
|
| 235 |
+
"hemolisis inmunomediada"),
|
| 236 |
+
"cetoacidosis diabetica": ("cetoacidosis",),
|
| 237 |
+
"daño hepatocelular": ("lesion hepatocelular", "citolisis hepatica", "necrosis hepatocelular"),
|
| 238 |
+
"ehrlichiosis": ("erliquiosis",),
|
| 239 |
+
"ehrlichiosis cronica": ("erliquiosis cronica",),
|
| 240 |
+
"enteropatia perdedora de proteinas": ("enteropatia con perdida de proteinas",
|
| 241 |
+
"perdida enterica de proteinas", "epp"),
|
| 242 |
+
"ferropenia": ("deficit de hierro", "deficiencia de hierro", "carencia de hierro"),
|
| 243 |
+
"gammapatia monoclonal": ("gamapatia monoclonal", "paraproteinemia", "pico monoclonal"),
|
| 244 |
+
"hemolisis": ("hemolitica",),
|
| 245 |
+
"hiperadrenocorticismo": ("sindrome de cushing", "enfermedad de cushing", "cushing"),
|
| 246 |
+
"hipercalcemia maligna": ("hipercalcemia paraneoplasica", "hipercalcemia de malignidad"),
|
| 247 |
+
# Lusismo, no sinónimo: medGemma escribió «Hipertireoidismo» (grafía portuguesa) en la
|
| 248 |
+
# corrida del 2026-08-03. Es un defecto menor del modelo —Morphos responde en español—,
|
| 249 |
+
# pero contarlo como «no nombró el diagnóstico» mezcla una falta de ortografía con un fallo
|
| 250 |
+
# clínico, y el juez sí lo reconoció (0.95 en corrección).
|
| 251 |
+
"hipertiroidismo": ("hipertireoidismo",),
|
| 252 |
+
"hipoadrenocorticismo": ("enfermedad de addison", "addison", "hipocortisolismo"),
|
| 253 |
+
"insuficiencia hepatica": ("fallo hepatico", "disfuncion hepatica"),
|
| 254 |
+
"leucocitosis neutrofilica": ("neutrofilia",),
|
| 255 |
+
"linfoma": ("linfosarcoma",),
|
| 256 |
+
"lipidosis hepatica": ("esteatosis hepatica", "higado graso"),
|
| 257 |
+
"mieloma multiple": ("mieloma",),
|
| 258 |
+
"sin alteraciones": ("sin hallazgos", "sin anomalias", "panel normal", "sin alteracion"),
|
| 259 |
+
"trombocitopenia": ("plaquetopenia",),
|
| 260 |
+
}
|
| 261 |
|
| 262 |
|
| 263 |
+
def _formas_del_diferencial(aceptable: str) -> set[str]:
|
| 264 |
+
normalizado = _sin_tildes(aceptable).strip()
|
| 265 |
+
return {normalizado, *_SINONIMOS_DIFERENCIALES.get(normalizado, ())}
|
|
|
|
| 266 |
|
| 267 |
|
| 268 |
+
# Flexión que se tolera al final del término: «normal» tiene que casar con «los resultados son
|
| 269 |
+
# normales», y «linfoma» con «linfomas». Sólo se aplica a partir de 5 caracteres, y esa longitud
|
| 270 |
+
# no es arbitraria: con las siglas cortas del dataset abriría agujeros absurdos —«cad»
|
| 271 |
+
# (cetoacidosis diabética) + «a» casaría con «cada»—, así que los rótulos cortos exigen la
|
| 272 |
+
# palabra exacta.
|
| 273 |
+
_SUFIJO_FLEXION = "(?:es|s|as|os|a|o)?"
|
| 274 |
+
_LONGITUD_MINIMA_FLEXION = 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
|
| 277 |
+
def _patron_del_termino(forma: str) -> str:
|
| 278 |
+
sufijo = _SUFIJO_FLEXION if len(forma) >= _LONGITUD_MINIMA_FLEXION else ""
|
| 279 |
+
return rf"\b{re.escape(forma)}{sufijo}\b"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
|
| 282 |
+
def _menciona_diferencial(texto: str, aceptables: list[str]) -> bool:
|
| 283 |
+
"""¿El texto nombra alguno de los diagnósticos aceptables, en cualquiera de sus formas?
|
|
|
|
| 284 |
|
| 285 |
+
Con límites de palabra: los rótulos cortos del dataset («cad», «erc», «imha») casaban dentro
|
| 286 |
+
de otra palabra —«cad» en «cadera», «cadena» o «cadáver»—, y un acierto regalado por
|
| 287 |
+
subcadena es exactamente lo que esta métrica no puede permitirse.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
"""
|
| 289 |
normalizado = _sin_tildes(texto)
|
| 290 |
+
return any(
|
| 291 |
+
re.search(_patron_del_termino(forma), normalizado)
|
| 292 |
+
for aceptable in aceptables
|
| 293 |
+
for forma in _formas_del_diferencial(aceptable)
|
| 294 |
+
)
|
|
|
|
|
|
|
| 295 |
|
| 296 |
|
| 297 |
def puntuar_caso(caso: dict, interp: dict) -> dict:
|
| 298 |
esp = caso["esperado"]
|
| 299 |
texto = _texto_plano(interp)
|
| 300 |
|
| 301 |
+
difs_predichos = " ".join(d.get("nombre", "") for d in interp.get("diferenciales", []))
|
| 302 |
recall_dif = 1.0 if (not esp["diferenciales_aceptables"]) else float(
|
| 303 |
+
_menciona_diferencial(f"{difs_predichos} {texto}", esp["diferenciales_aceptables"])
|
| 304 |
)
|
| 305 |
|
| 306 |
# Cobertura: sobre el campo estructurado cuando el modelo puede rellenarlo, y sobre la
|
|
|
|
| 346 |
|
| 347 |
# --- Capa del juez clínico ---
|
| 348 |
|
| 349 |
+
async def puntuar_con_juez(
|
| 350 |
+
juez, casos: list[dict], preds: dict[str, dict]
|
| 351 |
+
) -> tuple[dict[str, dict], list[str]]:
|
| 352 |
+
"""Aplica la rúbrica caso a caso. Devuelve (rúbricas, ids que quedaron sin juzgar).
|
| 353 |
|
| 354 |
La concurrencia la fija el propio juez: el local vale 1 porque paralelizarlo sólo lo hace
|
| 355 |
competir consigo mismo por la misma GPU, mientras que los remotos (CLI, SDK) sí ganan
|
| 356 |
+
tiempo real. Un fallo en un caso concreto no aborta la corrida, pero tampoco se descarta
|
| 357 |
+
en silencio: el id vuelve en la segunda lista y `agregar_juez` lo convierte en un fallo de
|
| 358 |
+
puerta. Un caso sin predicción no cuenta como hueco del juez —no hay nada que juzgar— y ya
|
| 359 |
+
lo penalizan las métricas deterministas.
|
| 360 |
"""
|
| 361 |
juzgables = [c for c in casos if preds.get(c["id"])]
|
| 362 |
if getattr(juez, "concurrencia", 1) > 1:
|
| 363 |
+
rubricas = await _juzgar_en_paralelo(juez, juzgables, preds)
|
| 364 |
+
else:
|
| 365 |
+
rubricas = await _juzgar_en_serie(juez, juzgables, preds)
|
| 366 |
+
return rubricas, [c["id"] for c in juzgables if c["id"] not in rubricas]
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
async def _juzgar_uno(juez, caso: dict, pred: dict) -> dict:
|
| 370 |
+
"""Un caso, con un reintento. El fallo que motivó esto fue aislado (29 casos alrededor
|
| 371 |
+
salieron bien), así que lo más probable es un timeout o un sobre mal formado, no un juez
|
| 372 |
+
roto; repetir cuesta una llamada y evita un hueco en la puerta. Si el juez está caído de
|
| 373 |
+
verdad, el segundo intento falla igual y la cuenta de `fallos_seguidos` corta la corrida."""
|
| 374 |
+
try:
|
| 375 |
+
return await juez.juzgar(caso, pred)
|
| 376 |
+
except ErrorJuez as exc:
|
| 377 |
+
print(f" ↻ juez falló en {caso['id']} ({exc}); se reintenta una vez.")
|
| 378 |
+
return await juez.juzgar(caso, pred)
|
| 379 |
|
| 380 |
|
| 381 |
async def _juzgar_en_serie(juez, casos: list[dict], preds: dict[str, dict]) -> dict[str, dict]:
|
|
|
|
| 383 |
fallos_seguidos = 0
|
| 384 |
for caso in casos:
|
| 385 |
try:
|
| 386 |
+
rubricas[caso["id"]] = await _juzgar_uno(juez, caso, preds[caso["id"]])
|
| 387 |
fallos_seguidos = 0
|
| 388 |
except ErrorJuez as exc:
|
| 389 |
print(f" ⚠ juez falló en {caso['id']}: {exc}")
|
|
|
|
| 402 |
async def _uno(caso: dict):
|
| 403 |
async with semaforo:
|
| 404 |
try:
|
| 405 |
+
return caso["id"], await _juzgar_uno(juez, caso, preds[caso["id"]])
|
| 406 |
except ErrorJuez as exc:
|
| 407 |
print(f" ⚠ juez falló en {caso['id']}: {exc}")
|
| 408 |
return caso["id"], None
|
|
|
|
| 411 |
return {id_caso: rub for id_caso, rub in resultados if rub is not None}
|
| 412 |
|
| 413 |
|
| 414 |
+
def agregar_juez(rubricas: dict[str, dict], no_juzgados: list[str] | None = None) -> dict:
|
| 415 |
+
"""Promedia la rúbrica y declara cuántos casos se quedaron fuera de ese promedio.
|
| 416 |
+
|
| 417 |
+
Ninguna rúbrica en absoluto significa «no hubo juez» (sin binario, sin sesión, sin modelo)
|
| 418 |
+
y devuelve vacío: la puerta sigue siendo válida, sólo más ciega, y es el comportamiento
|
| 419 |
+
documentado arriba. Un puñado de rúbricas con huecos es otra cosa —el juez SÍ corrió y
|
| 420 |
+
faltan datos—, y ahí `casos_no_juzgados` bloquea, porque promediar los que sobrevivieron
|
| 421 |
+
es exactamente el sesgo que se quiere evitar.
|
| 422 |
+
"""
|
| 423 |
if not rubricas:
|
| 424 |
return {}
|
| 425 |
n = len(rubricas)
|
|
|
|
| 431 |
1 for r in rubricas.values() if r["violacion_seguridad"]
|
| 432 |
)
|
| 433 |
agg["casos_juzgados"] = n
|
| 434 |
+
agg["casos_no_juzgados"] = len(no_juzgados or [])
|
| 435 |
return agg
|
| 436 |
|
| 437 |
|
|
|
|
| 458 |
if metrica not in agg:
|
| 459 |
continue
|
| 460 |
valor = agg[metrica]
|
| 461 |
+
if metrica in MAXIMOS:
|
| 462 |
if valor > umbral:
|
| 463 |
fallos.append(f"{metrica}={valor} (máx {umbral})")
|
| 464 |
elif valor < umbral:
|
|
|
|
| 470 |
parser = argparse.ArgumentParser()
|
| 471 |
parser.add_argument("--predicciones", type=Path)
|
| 472 |
parser.add_argument("--modelo", choices=["medgemma", "claude"])
|
| 473 |
+
parser.add_argument(
|
| 474 |
+
"--modelo-local", metavar="NOMBRE",
|
| 475 |
+
help="modelo de Ollama con el que generar (debe estar en MORPHOS_MODELOS_LOCALES). "
|
| 476 |
+
"Manda sobre el HF Space dentro de la ruta 'medgemma'; recibe el mismo RAG, "
|
| 477 |
+
"prompt y suelos de seguridad, así que la comparación es limpia.",
|
| 478 |
+
)
|
| 479 |
parser.add_argument("--simular", action="store_true")
|
| 480 |
parser.add_argument(
|
| 481 |
"--split", choices=["dev", "test", "todos"], default="dev",
|
|
|
|
| 497 |
parser.add_argument(
|
| 498 |
"--guardar-predicciones", type=Path, metavar="FILE",
|
| 499 |
help="guarda las salidas generadas en JSONL, para re-puntuarlas sin volver a gastar "
|
| 500 |
+
"cuota de GPU (el mismo formato que acepta --predicciones). Se escribe caso a "
|
| 501 |
+
"caso: si la corrida muere a mitad, lo generado hasta ahí queda en disco",
|
| 502 |
+
)
|
| 503 |
+
parser.add_argument(
|
| 504 |
+
"--reanudar", action="store_true",
|
| 505 |
+
help="reutiliza las predicciones que ya estén en --guardar-predicciones y genera sólo "
|
| 506 |
+
"los casos que falten",
|
| 507 |
)
|
| 508 |
args = parser.parse_args()
|
| 509 |
|
|
|
|
| 512 |
print(f"❌ No hay casos en el split '{args.split}'.")
|
| 513 |
return 1
|
| 514 |
|
| 515 |
+
# Quién generó estas salidas queda en el informe: comparar dos corridas sin saber si
|
| 516 |
+
# cambió el modelo o el corpus es lo que hacía inatribuible una mejora del RAG.
|
| 517 |
if args.predicciones:
|
| 518 |
+
generador = f"predicciones:{args.predicciones.name}"
|
| 519 |
+
preds = cargar_predicciones(args.predicciones)
|
|
|
|
|
|
|
|
|
|
| 520 |
elif args.modelo:
|
| 521 |
+
generador = f"{args.modelo}:{args.modelo_local}" if args.modelo_local else args.modelo
|
| 522 |
+
previas = {}
|
| 523 |
+
if args.reanudar and args.guardar_predicciones:
|
| 524 |
+
previas = cargar_predicciones(args.guardar_predicciones)
|
| 525 |
+
if previas:
|
| 526 |
+
print(f"Reanudando: {len(previas)} predicción(es) ya en disco.")
|
| 527 |
+
preds = asyncio.run(generar_con_modelo(
|
| 528 |
+
casos, args.modelo, args.modelo_local,
|
| 529 |
+
incremental=args.guardar_predicciones, ya_generados=previas,
|
| 530 |
+
))
|
| 531 |
+
if args.guardar_predicciones:
|
| 532 |
+
print(f"Predicciones en {args.guardar_predicciones}")
|
| 533 |
else:
|
| 534 |
+
generador = "simulado"
|
| 535 |
preds = generar_simulado(casos)
|
| 536 |
+
if args.guardar_predicciones:
|
| 537 |
+
args.guardar_predicciones.parent.mkdir(parents=True, exist_ok=True)
|
| 538 |
+
args.guardar_predicciones.write_text(
|
| 539 |
+
"".join(
|
| 540 |
+
json.dumps({"id": i, "interpretacion": p}, ensure_ascii=False) + "\n"
|
| 541 |
+
for i, p in preds.items()
|
| 542 |
+
),
|
| 543 |
+
encoding="utf-8",
|
| 544 |
+
)
|
| 545 |
+
print(f"Predicciones guardadas en {args.guardar_predicciones}")
|
|
|
|
|
|
|
|
|
|
| 546 |
|
| 547 |
resultados = [puntuar_caso(c, preds.get(c["id"], {})) for c in casos]
|
| 548 |
|
|
|
|
| 550 |
# su rúbrica mediría el simulador, no el modelo. Con --simular hay que pedirlo explícito.
|
| 551 |
quiere_juez = args.juez != "ninguno" and (not args.simular or args.juez != "auto")
|
| 552 |
rubricas: dict[str, dict] = {}
|
| 553 |
+
no_juzgados: list[str] = []
|
| 554 |
nombre_juez = "ninguno"
|
| 555 |
if quiere_juez:
|
| 556 |
juez, motivo = crear_juez(args.juez)
|
|
|
|
| 558 |
if juez is not None:
|
| 559 |
nombre_juez = juez.nombre
|
| 560 |
print("Juzgando casos…")
|
| 561 |
+
rubricas, no_juzgados = asyncio.run(puntuar_con_juez(juez, casos, preds))
|
| 562 |
elif args.simular:
|
| 563 |
print("\nJuez clínico: omitido sobre salidas simuladas (usa --juez ollama para forzarlo)")
|
| 564 |
|
|
|
|
| 570 |
agg = agregar(computados)
|
| 571 |
fallos = evaluar_umbrales(agg)
|
| 572 |
|
| 573 |
+
agg_juez = agregar_juez(
|
| 574 |
+
{k: v for k, v in rubricas.items() if k in ids_puerta},
|
| 575 |
+
[i for i in no_juzgados if i in ids_puerta],
|
| 576 |
+
)
|
| 577 |
if agg_juez and not args.juez_informativo:
|
| 578 |
fallos += evaluar_umbrales(agg_juez, UMBRALES_JUEZ)
|
| 579 |
|
|
|
|
| 588 |
if rub:
|
| 589 |
linea += (f" | juez: dif={rub['correccion_diferenciales']:.2f} "
|
| 590 |
f"seg={rub['seguridad']:.2f}" + (" ⚠SEG-JUEZ" if rub["violacion_seguridad"] else ""))
|
| 591 |
+
elif r["id"] in no_juzgados:
|
| 592 |
+
linea += " | juez: ⚠SIN RÚBRICA"
|
| 593 |
print(linea + sufijo)
|
| 594 |
|
| 595 |
for r in resultados:
|
|
|
|
| 604 |
for k, v in agg_juez.items():
|
| 605 |
print(f" {k}: {v}{' (informativo)' if args.juez_informativo else ''}")
|
| 606 |
|
| 607 |
+
if no_juzgados:
|
| 608 |
+
print(f"\n ⚠ {len(no_juzgados)} caso(s) sin rúbrica del juez: {', '.join(no_juzgados)}")
|
| 609 |
+
print(" Sus notas NO están en el promedio de arriba. Vuelve a puntuar con "
|
| 610 |
+
"--predicciones sobre el JSONL guardado (no hace falta regenerar).")
|
| 611 |
+
|
| 612 |
if pendientes and not args.incluir_pendientes:
|
| 613 |
print(f"\n ⓘ {len(pendientes)} caso(s) fuera de la puerta por falta de validación "
|
| 614 |
f"veterinaria: {', '.join(r['id'] for r in pendientes)}")
|
|
|
|
| 620 |
json.dumps(
|
| 621 |
{
|
| 622 |
"split": args.split,
|
| 623 |
+
"generador": generador,
|
| 624 |
"juez": nombre_juez,
|
| 625 |
"casos": [{**r, "rubrica": rubricas.get(r["id"])} for r in resultados],
|
| 626 |
+
"no_juzgados": no_juzgados,
|
| 627 |
"agregado": {**agg, **agg_juez},
|
| 628 |
"fallos": fallos,
|
| 629 |
},
|
evals/run_ragas.py
CHANGED
|
@@ -94,6 +94,25 @@ def construir_muestras(casos: list[dict], preds: dict[str, dict]) -> list:
|
|
| 94 |
return muestras
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def construir_evaluadores(embed_modelo: str):
|
| 98 |
"""LLM y embeddings locales envueltos para Ragas."""
|
| 99 |
from langchain_ollama import ChatOllama, OllamaEmbeddings
|
|
@@ -178,19 +197,7 @@ def main() -> int:
|
|
| 178 |
print("\n=== RESUMEN RAGAS ===")
|
| 179 |
print(resultado)
|
| 180 |
|
| 181 |
-
|
| 182 |
-
fallos, incalculables = [], []
|
| 183 |
-
for metrica, umbral in UMBRALES.items():
|
| 184 |
-
valor = puntuaciones.get(metrica)
|
| 185 |
-
if valor is None:
|
| 186 |
-
continue
|
| 187 |
-
# Ragas devuelve NaN cuando el juez agotó el tiempo o no supo puntuar. Eso no es un
|
| 188 |
-
# 0 (no significa "poco fundamentado"): es una métrica que no se midió, y hay que
|
| 189 |
-
# decirlo en vez de dejar pasar la puerta con un agregado incompleto.
|
| 190 |
-
if valor != valor:
|
| 191 |
-
incalculables.append(metrica)
|
| 192 |
-
elif valor < umbral:
|
| 193 |
-
fallos.append(f"{metrica}={valor:.2f} < {umbral:.2f}")
|
| 194 |
|
| 195 |
if incalculables:
|
| 196 |
print(f"\n⚠ Métricas sin calcular (el juez no respondió a tiempo): {', '.join(incalculables)}")
|
|
|
|
| 94 |
return muestras
|
| 95 |
|
| 96 |
|
| 97 |
+
def evaluar_umbrales(puntuaciones: dict, umbrales: dict | None = None) -> tuple[list[str], list[str]]:
|
| 98 |
+
"""(fallos, incalculables) a partir de las puntuaciones de Ragas.
|
| 99 |
+
|
| 100 |
+
Ragas devuelve NaN cuando el juez agotó el tiempo o no supo puntuar. Eso NO es un 0 (no
|
| 101 |
+
significa "poco fundamentado"): es una métrica que no se midió, y hay que decirlo en vez
|
| 102 |
+
de dejar pasar la puerta con un agregado incompleto o suspenderla por algo que no ocurrió.
|
| 103 |
+
"""
|
| 104 |
+
fallos, incalculables = [], []
|
| 105 |
+
for metrica, umbral in (umbrales or UMBRALES).items():
|
| 106 |
+
valor = puntuaciones.get(metrica)
|
| 107 |
+
if valor is None:
|
| 108 |
+
continue
|
| 109 |
+
if valor != valor: # NaN
|
| 110 |
+
incalculables.append(metrica)
|
| 111 |
+
elif valor < umbral:
|
| 112 |
+
fallos.append(f"{metrica}={valor:.2f} < {umbral:.2f}")
|
| 113 |
+
return fallos, incalculables
|
| 114 |
+
|
| 115 |
+
|
| 116 |
def construir_evaluadores(embed_modelo: str):
|
| 117 |
"""LLM y embeddings locales envueltos para Ragas."""
|
| 118 |
from langchain_ollama import ChatOllama, OllamaEmbeddings
|
|
|
|
| 197 |
print("\n=== RESUMEN RAGAS ===")
|
| 198 |
print(resultado)
|
| 199 |
|
| 200 |
+
fallos, incalculables = evaluar_umbrales(getattr(resultado, "_repr_dict", {}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
if incalculables:
|
| 203 |
print(f"\n⚠ Métricas sin calcular (el juez no respondió a tiempo): {', '.join(incalculables)}")
|
evals/run_retrieval_eval.py
CHANGED
|
@@ -37,27 +37,50 @@ def cargar_casos() -> list[dict]:
|
|
| 37 |
return [json.loads(ln) for ln in lineas if ln.strip()]
|
| 38 |
|
| 39 |
|
| 40 |
-
def
|
| 41 |
-
"""
|
| 42 |
-
descripción + analitos clave + signos clínicos."""
|
| 43 |
-
partes = [caso.get("descripcion", "")]
|
| 44 |
-
partes.extend(caso.get("esperado", {}).get("hallazgos_clave", []))
|
| 45 |
-
if caso.get("signos_clinicos"):
|
| 46 |
-
partes.append(caso["signos_clinicos"])
|
| 47 |
-
return " ; ".join(p for p in partes if p)[:512]
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
""
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
from app.rag.retriever import construir_consultas
|
| 58 |
|
| 59 |
-
patrones =
|
| 60 |
-
return construir_consultas(patrones,
|
| 61 |
|
| 62 |
|
| 63 |
# --- Métricas (puras, testeables sin índice) ---
|
|
|
|
| 37 |
return [json.loads(ln) for ln in lineas if ln.strip()]
|
| 38 |
|
| 39 |
|
| 40 |
+
def _entradas_del_motor(caso: dict) -> tuple[list[str], list[str]]:
|
| 41 |
+
"""Nombres de patrón y de hallazgo TAL COMO los produce el motor para este caso.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
Pasa por `analisis.ts` (vía Node) igual que el servicio, porque la consulta de producción
|
| 44 |
+
se arma con lo que el motor detecta, no con lo que el caso dorado declara.
|
| 45 |
+
"""
|
| 46 |
+
from run_evals import _motor_determinista
|
| 47 |
|
| 48 |
+
hallazgos, patrones = _motor_determinista(caso["valores"], caso["paciente"])
|
| 49 |
+
return [p["nombre"] for p in patrones], [h["nombre"] for h in hallazgos]
|
| 50 |
|
| 51 |
+
|
| 52 |
+
def construir_query_eval(caso: dict) -> str:
|
| 53 |
+
"""La consulta EXACTA que emitiría producción para este caso.
|
| 54 |
+
|
| 55 |
+
Antes se armaba a mano con `descripcion` + `esperado.hallazgos_clave` + `signos_clinicos`,
|
| 56 |
+
y eso medía una recuperación que el servicio nunca ejecuta, en tres sentidos:
|
| 57 |
+
|
| 58 |
+
1. **Claves en vez de nombres.** `hct`, `vcm`, `chcm` en lugar de «Hematocrito (Hct)»,
|
| 59 |
+
«VCM (MCV)». Los nombres traen la SIGLA INGLESA, que es justo lo que casa con un corpus
|
| 60 |
+
en inglés — sobre todo en la rama BM25, que es literal. La consulta medida era más pobre
|
| 61 |
+
que la real.
|
| 62 |
+
2. **Producción no manda `signos_clinicos`** en la consulta de recuperación: sólo patrones
|
| 63 |
+
y hallazgos (`ai/service.py`).
|
| 64 |
+
3. **`descripcion` es metadato del dataset y filtraba la respuesta.** «Anemia microcítica
|
| 65 |
+
hipocrómica en perro» orienta la recuperación hacia el diagnóstico esperado, que es
|
| 66 |
+
exactamente lo que el juez de relevancia luego premia. Misma familia de fuga que la que
|
| 67 |
+
se corrigió en el juez clínico (ver resultados 2026-08-01, §4.5).
|
| 68 |
+
|
| 69 |
+
Consecuencia: las cifras de recuperación anteriores al 2026-08-03 no son comparables con
|
| 70 |
+
las de después, y el `precision@k` de aquel A/B no describía la recuperación de producción.
|
| 71 |
"""
|
| 72 |
+
from app.rag.retriever import construir_consulta
|
| 73 |
+
|
| 74 |
+
patrones, hallazgos = _entradas_del_motor(caso)
|
| 75 |
+
return construir_consulta(patrones, hallazgos)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def construir_queries_eval(caso: dict) -> list[str]:
|
| 79 |
+
"""Versión multi-consulta de la anterior (una por patrón), para medir el A/B."""
|
| 80 |
from app.rag.retriever import construir_consultas
|
| 81 |
|
| 82 |
+
patrones, hallazgos = _entradas_del_motor(caso)
|
| 83 |
+
return construir_consultas(patrones, hallazgos)
|
| 84 |
|
| 85 |
|
| 86 |
# --- Métricas (puras, testeables sin índice) ---
|
evals/tests/conftest.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuración de las pruebas unitarias de los scripts de evaluación.
|
| 2 |
+
|
| 3 |
+
Los scripts de `evals/` son ejecutables, no un paquete instalado: cada uno se apaña su
|
| 4 |
+
`sys.path` al arrancar (backend + el propio directorio) para poder importar `app.*` y
|
| 5 |
+
`judge.*`. Aquí se hace lo mismo antes de importarlos, para que las pruebas corran igual
|
| 6 |
+
desde `backend/` (donde vive pytest) que desde la raíz.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
import tempfile
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
EVALS = Path(__file__).resolve().parents[1]
|
| 17 |
+
RAIZ = EVALS.parent
|
| 18 |
+
|
| 19 |
+
for ruta in (str(RAIZ / "backend"), str(EVALS)):
|
| 20 |
+
if ruta not in sys.path:
|
| 21 |
+
sys.path.insert(0, ruta)
|
| 22 |
+
|
| 23 |
+
# Igual que backend/tests/conftest.py: la config se cachea con lru_cache, así que estas
|
| 24 |
+
# variables tienen que existir ANTES de que cualquier import arrastre `app.config`. Y por el
|
| 25 |
+
# mismo motivo que allí, el `.env` del desarrollador queda fuera: estas pruebas también corren
|
| 26 |
+
# en CI, donde no existe.
|
| 27 |
+
os.environ["MORPHOS_IGNORAR_ENV_FILE"] = "1"
|
| 28 |
+
_TMP = Path(tempfile.mkdtemp(prefix="morphos_evals_test_"))
|
| 29 |
+
os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
|
| 30 |
+
os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
|
| 31 |
+
os.environ.setdefault("MORPHOS_ENTORNO", "dev")
|
evals/tests/test_generacion_incremental.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""La generación tiene que sobrevivir a que maten el proceso.
|
| 2 |
+
|
| 3 |
+
`generar_con_modelo` ya toleraba que fallara el modelo en un caso, pero escribía a disco sólo
|
| 4 |
+
al final: si el proceso moría, se perdía TODO lo generado. Medido el 2026-08-03 contra el Space
|
| 5 |
+
local, 70 minutos tirados. Estas pruebas fijan las dos mitades del arreglo —escribir en caliente
|
| 6 |
+
y poder reanudar— sin llamar a ningún modelo.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
import run_evals
|
| 16 |
+
from run_evals import cargar_predicciones, generar_con_modelo
|
| 17 |
+
|
| 18 |
+
CASOS = [
|
| 19 |
+
{"id": "caso-1", "paciente": {"especie": "canino"}, "valores": {"creat": 4.0},
|
| 20 |
+
"signos_clinicos": "", "esperado": {}},
|
| 21 |
+
{"id": "caso-2", "paciente": {"especie": "felino"}, "valores": {"creat": 3.0},
|
| 22 |
+
"signos_clinicos": "", "esperado": {}},
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def modelo_falso(monkeypatch):
|
| 28 |
+
"""Sustituye motor e inferencia; `registro` anota qué casos llegaron a generarse."""
|
| 29 |
+
from app.ai import service
|
| 30 |
+
from app.schemas import InterpretacionClinica, RespuestaInterpretacion
|
| 31 |
+
|
| 32 |
+
registro: list[str] = []
|
| 33 |
+
monkeypatch.setattr(run_evals, "_motor_determinista", lambda v, p: ([], []))
|
| 34 |
+
|
| 35 |
+
async def interpretar_falso(pet):
|
| 36 |
+
registro.append(pet.paciente.especie)
|
| 37 |
+
return RespuestaInterpretacion(
|
| 38 |
+
resultado=InterpretacionClinica(
|
| 39 |
+
interpretacion=f"Interpretación para {pet.paciente.especie}.",
|
| 40 |
+
idioma="es",
|
| 41 |
+
),
|
| 42 |
+
backend="falso",
|
| 43 |
+
modelo="falso",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
monkeypatch.setattr(service, "interpretar", interpretar_falso)
|
| 47 |
+
return registro
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_cada_prediccion_se_escribe_en_cuanto_llega(tmp_path, modelo_falso):
|
| 51 |
+
destino = tmp_path / "sub" / "preds.jsonl"
|
| 52 |
+
asyncio.run(generar_con_modelo(CASOS, "medgemma", incremental=destino))
|
| 53 |
+
guardadas = cargar_predicciones(destino)
|
| 54 |
+
assert set(guardadas) == {"caso-1", "caso-2"}
|
| 55 |
+
assert "canino" in guardadas["caso-1"]["interpretacion"]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_lo_generado_sobrevive_a_que_reviente_el_caso_siguiente(tmp_path, monkeypatch):
|
| 59 |
+
"""El escenario real: el proceso muere a mitad. Lo anterior tiene que estar en disco."""
|
| 60 |
+
from app.ai import service
|
| 61 |
+
from app.schemas import InterpretacionClinica, RespuestaInterpretacion
|
| 62 |
+
|
| 63 |
+
monkeypatch.setattr(run_evals, "_motor_determinista", lambda v, p: ([], []))
|
| 64 |
+
destino = tmp_path / "preds.jsonl"
|
| 65 |
+
|
| 66 |
+
async def interpretar_falso(pet):
|
| 67 |
+
if pet.paciente.especie == "felino":
|
| 68 |
+
raise KeyboardInterrupt # sustituto de un SIGKILL a mitad de corrida
|
| 69 |
+
return RespuestaInterpretacion(
|
| 70 |
+
resultado=InterpretacionClinica(interpretacion="Prosa canina.", idioma="es"),
|
| 71 |
+
backend="falso",
|
| 72 |
+
modelo="falso",
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
monkeypatch.setattr(service, "interpretar", interpretar_falso)
|
| 76 |
+
with pytest.raises(KeyboardInterrupt):
|
| 77 |
+
asyncio.run(generar_con_modelo(CASOS, "medgemma", incremental=destino))
|
| 78 |
+
|
| 79 |
+
# Antes de este arreglo el fichero ni existía: se escribía al terminar.
|
| 80 |
+
assert cargar_predicciones(destino).keys() == {"caso-1"}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_reanudar_no_regenera_lo_que_ya_esta_en_disco(tmp_path, modelo_falso):
|
| 84 |
+
destino = tmp_path / "preds.jsonl"
|
| 85 |
+
previas = {"caso-1": {"interpretacion": "Ya estaba.", "idioma": "es"}}
|
| 86 |
+
salidas = asyncio.run(
|
| 87 |
+
generar_con_modelo(CASOS, "medgemma", incremental=destino, ya_generados=previas)
|
| 88 |
+
)
|
| 89 |
+
assert salidas["caso-1"]["interpretacion"] == "Ya estaba."
|
| 90 |
+
assert modelo_falso == ["felino"], "sólo debía generarse el caso que faltaba"
|
| 91 |
+
# Y el que faltaba sí se anexa, sin duplicar el que ya estaba.
|
| 92 |
+
assert cargar_predicciones(destino).keys() == {"caso-2"}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_sin_incremental_no_se_escribe_nada(tmp_path, modelo_falso):
|
| 96 |
+
salidas = asyncio.run(generar_con_modelo(CASOS, "medgemma"))
|
| 97 |
+
assert set(salidas) == {"caso-1", "caso-2"}
|
| 98 |
+
assert list(tmp_path.iterdir()) == []
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_cargar_predicciones_tolera_fichero_inexistente(tmp_path):
|
| 102 |
+
assert cargar_predicciones(tmp_path / "no-existe.jsonl") == {}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_cargar_predicciones_ignora_lineas_en_blanco(tmp_path):
|
| 106 |
+
ruta = tmp_path / "preds.jsonl"
|
| 107 |
+
ruta.write_text(
|
| 108 |
+
json.dumps({"id": "a", "interpretacion": {"interpretacion": "x"}}) + "\n\n",
|
| 109 |
+
encoding="utf-8",
|
| 110 |
+
)
|
| 111 |
+
assert set(cargar_predicciones(ruta)) == {"a"}
|
evals/tests/test_juez.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pruebas de la capa de jueces: rúbrica, transportes y selección.
|
| 2 |
+
|
| 3 |
+
Lo que se fija aquí es la regla de oro de esta capa: **un juez roto tiene que fallar fuerte**.
|
| 4 |
+
Si un juez que no responde, o que devuelve basura, se degradara a ceros silenciosos, la puerta
|
| 5 |
+
de CI registraría una regresión del modelo que nunca ocurrió.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import subprocess
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
from judge import ErrorJuez, claude_cli, ollama_local
|
| 15 |
+
from judge.claude_cli import ErrorJuezCLI, _extraer_json
|
| 16 |
+
from judge.clinical_judge import CRITERIOS, _mensaje, crear_juez, validar_rubrica
|
| 17 |
+
|
| 18 |
+
RUBRICA_OK = {
|
| 19 |
+
"correccion_diferenciales": 0.8,
|
| 20 |
+
"hedging_apropiado": 0.6,
|
| 21 |
+
"seguridad": 1.0,
|
| 22 |
+
"completitud": 0.7,
|
| 23 |
+
"violacion_seguridad": False,
|
| 24 |
+
"justificacion": "Razonable.",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# --- validar_rubrica ---
|
| 29 |
+
|
| 30 |
+
def test_rubrica_completa_se_normaliza_sin_perder_nada():
|
| 31 |
+
limpia = validar_rubrica(dict(RUBRICA_OK))
|
| 32 |
+
assert all(limpia[c] == RUBRICA_OK[c] for c in CRITERIOS)
|
| 33 |
+
assert limpia["violacion_seguridad"] is False
|
| 34 |
+
assert limpia["justificacion"] == "Razonable."
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.mark.parametrize("criterio", CRITERIOS)
|
| 38 |
+
def test_criterio_ausente_es_error_explicito_y_no_un_cero(criterio):
|
| 39 |
+
bruto = dict(RUBRICA_OK)
|
| 40 |
+
del bruto[criterio]
|
| 41 |
+
with pytest.raises(ErrorJuez, match=criterio):
|
| 42 |
+
validar_rubrica(bruto)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.mark.parametrize("valor", ["0.8", None, True, [0.8]])
|
| 46 |
+
def test_criterio_no_numerico_es_error(valor):
|
| 47 |
+
with pytest.raises(ErrorJuez):
|
| 48 |
+
validar_rubrica({**RUBRICA_OK, "seguridad": valor})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_las_puntuaciones_se_recortan_al_rango_0_1():
|
| 52 |
+
limpia = validar_rubrica({**RUBRICA_OK, "seguridad": 4.2, "completitud": -1.0})
|
| 53 |
+
assert limpia["seguridad"] == 1.0
|
| 54 |
+
assert limpia["completitud"] == 0.0
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_la_violacion_de_seguridad_ausente_se_asume_falsa():
|
| 58 |
+
bruto = dict(RUBRICA_OK)
|
| 59 |
+
del bruto["violacion_seguridad"]
|
| 60 |
+
assert validar_rubrica(bruto)["violacion_seguridad"] is False
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_la_justificacion_se_acota_pero_deja_sitio_al_descuento():
|
| 64 |
+
limpia = validar_rubrica({**RUBRICA_OK, "justificacion": "x" * 5000})
|
| 65 |
+
assert len(limpia["justificacion"]) == 2000
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_un_entero_es_puntuacion_valida():
|
| 69 |
+
assert validar_rubrica({**RUBRICA_OK, "seguridad": 1})["seguridad"] == 1.0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# --- Mensaje al juez ---
|
| 73 |
+
|
| 74 |
+
CASO = {
|
| 75 |
+
"id": "caso-x",
|
| 76 |
+
"descripcion": "Azotemia en gato",
|
| 77 |
+
"split": "dev",
|
| 78 |
+
"validado": True,
|
| 79 |
+
"paciente": {"especie": "felino"},
|
| 80 |
+
"valores": {"creat": 4.0},
|
| 81 |
+
"esperado": {"diferenciales_aceptables": ["enfermedad renal crónica"]},
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_el_mensaje_separa_la_entrada_del_asistente_de_la_plantilla():
|
| 86 |
+
msg = _mensaje(CASO, {"interpretacion": "Azotemia."})
|
| 87 |
+
entrada, plantilla = msg.split("PLANTILLA DE CORRECCIÓN")
|
| 88 |
+
# Lo que el asistente sí vio va arriba; los metadatos del dataset, abajo y rotulados.
|
| 89 |
+
assert '"creat"' in entrada and '"felino"' in entrada
|
| 90 |
+
assert "diferenciales_aceptables" not in entrada
|
| 91 |
+
assert "enfermedad renal crónica" in plantilla
|
| 92 |
+
assert '"split"' in plantilla and '"validado"' in plantilla
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_el_mensaje_incluye_la_interpretacion_a_evaluar():
|
| 96 |
+
assert "Azotemia leve" in _mensaje(CASO, {"interpretacion": "Azotemia leve"})
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# --- Transporte CLI: desenvoltura del JSON ---
|
| 100 |
+
|
| 101 |
+
def test_json_desnudo_se_lee_tal_cual():
|
| 102 |
+
assert _extraer_json('{"seguridad": 1.0}') == {"seguridad": 1.0}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_json_en_valla_de_codigo_se_desenvuelve():
|
| 106 |
+
assert _extraer_json('```json\n{"seguridad": 1.0}\n```') == {"seguridad": 1.0}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_json_con_prosa_alrededor_se_rescata():
|
| 110 |
+
texto = 'Aquí tienes la rúbrica:\n{"seguridad": 0.5}\nEspero que sirva.'
|
| 111 |
+
assert _extraer_json(texto) == {"seguridad": 0.5}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_sin_json_utilizable_se_lanza_error_de_juez():
|
| 115 |
+
with pytest.raises(ErrorJuezCLI):
|
| 116 |
+
_extraer_json("No puedo evaluar este caso.")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_json_malformado_dentro_de_la_valla_tambien_falla():
|
| 120 |
+
with pytest.raises(ErrorJuezCLI):
|
| 121 |
+
_extraer_json('```json\n{"seguridad": }\n```')
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# --- Transporte CLI: invocación ---
|
| 125 |
+
|
| 126 |
+
class ProcFalso:
|
| 127 |
+
def __init__(self, returncode=0, stdout="", stderr=""):
|
| 128 |
+
self.returncode, self.stdout, self.stderr = returncode, stdout, stderr
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_el_prompt_va_por_stdin_y_no_por_argv(monkeypatch):
|
| 132 |
+
capturado = {}
|
| 133 |
+
|
| 134 |
+
def falso_run(orden, **kw):
|
| 135 |
+
capturado["orden"] = orden
|
| 136 |
+
capturado["input"] = kw.get("input")
|
| 137 |
+
return ProcFalso(stdout=json.dumps({"result": json.dumps(RUBRICA_OK)}))
|
| 138 |
+
|
| 139 |
+
monkeypatch.setattr(subprocess, "run", falso_run)
|
| 140 |
+
assert claude_cli.preguntar_json("SISTEMA", "MENSAJE LARGO") == RUBRICA_OK
|
| 141 |
+
assert capturado["input"] == "MENSAJE LARGO"
|
| 142 |
+
assert "MENSAJE LARGO" not in capturado["orden"]
|
| 143 |
+
# El juez no debe arrastrar la configuración del repo donde corre.
|
| 144 |
+
assert "--strict-mcp-config" in capturado["orden"]
|
| 145 |
+
assert capturado["orden"][capturado["orden"].index("--max-turns") + 1] == "1"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_codigo_de_salida_distinto_de_cero_se_reporta_con_detalle(monkeypatch):
|
| 149 |
+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: ProcFalso(2, stderr="no autenticado"))
|
| 150 |
+
with pytest.raises(ErrorJuezCLI, match="no autenticado"):
|
| 151 |
+
claude_cli.preguntar_json("s", "m")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_sobre_de_error_del_cli_no_se_confunde_con_una_rubrica(monkeypatch):
|
| 155 |
+
sobre = json.dumps({"is_error": True, "result": "límite de uso alcanzado"})
|
| 156 |
+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: ProcFalso(stdout=sobre))
|
| 157 |
+
with pytest.raises(ErrorJuezCLI, match="límite de uso"):
|
| 158 |
+
claude_cli.preguntar_json("s", "m")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_timeout_del_cli_es_error_de_juez(monkeypatch):
|
| 162 |
+
def expira(*a, **k):
|
| 163 |
+
raise subprocess.TimeoutExpired("claude", claude_cli.TIMEOUT_S)
|
| 164 |
+
|
| 165 |
+
monkeypatch.setattr(subprocess, "run", expira)
|
| 166 |
+
with pytest.raises(ErrorJuezCLI, match="no respondió"):
|
| 167 |
+
claude_cli.preguntar_json("s", "m")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_el_modelo_del_juez_cli_es_configurable(monkeypatch):
|
| 171 |
+
assert claude_cli.modelo_cli() == claude_cli.MODELO_DEFECTO
|
| 172 |
+
monkeypatch.setenv("MORPHOS_JUEZ_CLI_MODELO", "opus")
|
| 173 |
+
assert claude_cli.modelo_cli() == "opus"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_sin_binario_el_cli_no_esta_disponible(monkeypatch):
|
| 177 |
+
monkeypatch.setattr("shutil.which", lambda _: None)
|
| 178 |
+
ok, motivo = claude_cli.disponible()
|
| 179 |
+
assert ok is False and "PATH" in motivo
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# --- Transporte Ollama ---
|
| 183 |
+
|
| 184 |
+
class RespuestaFalsa:
|
| 185 |
+
def __init__(self, payload, status_code=200):
|
| 186 |
+
self._payload, self.status_code, self.text = payload, status_code, str(payload)
|
| 187 |
+
|
| 188 |
+
def json(self):
|
| 189 |
+
return self._payload
|
| 190 |
+
|
| 191 |
+
def raise_for_status(self):
|
| 192 |
+
if self.status_code >= 400:
|
| 193 |
+
raise RuntimeError("http")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def test_base_url_del_juez_se_normaliza_sin_barra_final(monkeypatch):
|
| 197 |
+
monkeypatch.setenv("MORPHOS_JUEZ_BASE_URL", "http://otro:11434/")
|
| 198 |
+
assert ollama_local.base_url_juez() == "http://otro:11434"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_el_modelo_descargado_se_reconoce_aunque_falte_latest(monkeypatch):
|
| 202 |
+
monkeypatch.setenv("MORPHOS_JUEZ_MODELO", "llama3")
|
| 203 |
+
monkeypatch.setattr(
|
| 204 |
+
"httpx.get", lambda *a, **k: RespuestaFalsa({"models": [{"name": "llama3:latest"}]})
|
| 205 |
+
)
|
| 206 |
+
# Ollama nombra "familia:etiqueta"; el sufijo :latest no debe decidir la disponibilidad.
|
| 207 |
+
assert ollama_local.disponible() == (True, "")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_modelo_no_descargado_explica_como_arreglarlo(monkeypatch):
|
| 211 |
+
monkeypatch.setenv("MORPHOS_JUEZ_MODELO", "qwen2.5:7b")
|
| 212 |
+
monkeypatch.setattr("httpx.get", lambda *a, **k: RespuestaFalsa({"models": [{"name": "llama3"}]}))
|
| 213 |
+
ok, motivo = ollama_local.disponible()
|
| 214 |
+
assert ok is False and "ollama pull qwen2.5:7b" in motivo
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_ollama_caido_no_lanza_excepcion_sino_motivo(monkeypatch):
|
| 218 |
+
import httpx
|
| 219 |
+
|
| 220 |
+
def falla(*a, **k):
|
| 221 |
+
raise httpx.ConnectError("connection refused")
|
| 222 |
+
|
| 223 |
+
monkeypatch.setattr("httpx.get", falla)
|
| 224 |
+
ok, motivo = ollama_local.disponible()
|
| 225 |
+
assert ok is False and "no responde" in motivo
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_el_juez_local_pide_temperatura_cero_y_salida_estructurada(monkeypatch):
|
| 229 |
+
capturado = {}
|
| 230 |
+
|
| 231 |
+
def falso_post(url, json=None, **kw): # noqa: A002
|
| 232 |
+
capturado.update(json)
|
| 233 |
+
return RespuestaFalsa({"message": {"content": '{"seguridad": 1.0}'}})
|
| 234 |
+
|
| 235 |
+
monkeypatch.setattr("httpx.post", falso_post)
|
| 236 |
+
esquema = {"type": "object"}
|
| 237 |
+
assert ollama_local.preguntar_json("s", "m", esquema) == {"seguridad": 1.0}
|
| 238 |
+
assert capturado["options"]["temperature"] == 0
|
| 239 |
+
assert capturado["format"] is esquema
|
| 240 |
+
assert capturado["stream"] is False
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def test_respuesta_no_json_del_juez_local_es_error(monkeypatch):
|
| 244 |
+
monkeypatch.setattr(
|
| 245 |
+
"httpx.post", lambda *a, **k: RespuestaFalsa({"message": {"content": "no sé"}})
|
| 246 |
+
)
|
| 247 |
+
with pytest.raises(ErrorJuez, match="JSON válido"):
|
| 248 |
+
ollama_local.preguntar_json("s", "m", {})
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_http_de_error_del_juez_local_no_se_interpreta_como_rubrica(monkeypatch):
|
| 252 |
+
monkeypatch.setattr("httpx.post", lambda *a, **k: RespuestaFalsa({"error": "x"}, 500))
|
| 253 |
+
with pytest.raises(ErrorJuez, match="HTTP 500"):
|
| 254 |
+
ollama_local.preguntar_json("s", "m", {})
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# --- Selección de juez ---
|
| 258 |
+
|
| 259 |
+
def test_ninguno_desactiva_el_juez_sin_probar_transportes():
|
| 260 |
+
juez, motivo = crear_juez("ninguno")
|
| 261 |
+
assert juez is None and "desactivado" in motivo
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def test_auto_prefiere_el_cli(monkeypatch):
|
| 265 |
+
monkeypatch.setattr(claude_cli, "disponible", lambda: (True, ""))
|
| 266 |
+
juez, motivo = crear_juez("auto")
|
| 267 |
+
assert juez.nombre.startswith("claude-cli:")
|
| 268 |
+
assert juez.concurrencia > 1 # remoto: paralelizarlo sí gana tiempo
|
| 269 |
+
assert "sin clave de API" in motivo
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def test_auto_cae_a_ollama_cuando_no_hay_cli(monkeypatch):
|
| 273 |
+
monkeypatch.setattr(claude_cli, "disponible", lambda: (False, "sin binario"))
|
| 274 |
+
monkeypatch.setattr("judge.clinical_judge.disponible", lambda: (True, ""))
|
| 275 |
+
juez, motivo = crear_juez("auto")
|
| 276 |
+
assert juez.nombre.startswith("ollama:")
|
| 277 |
+
# El local va en serie: paralelizarlo sólo lo hace competir consigo mismo por la GPU.
|
| 278 |
+
assert juez.concurrencia == 1
|
| 279 |
+
assert "gratuito" in motivo
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def test_una_preferencia_concreta_no_degrada_a_otro_transporte(monkeypatch):
|
| 283 |
+
monkeypatch.setattr("judge.clinical_judge.disponible", lambda: (False, "sin Ollama"))
|
| 284 |
+
juez, motivo = crear_juez("ollama")
|
| 285 |
+
assert juez is None
|
| 286 |
+
assert "ollama no disponible" in motivo
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def test_sin_ningun_transporte_se_explica_por_que(monkeypatch):
|
| 290 |
+
monkeypatch.setattr(claude_cli, "disponible", lambda: (False, "sin binario"))
|
| 291 |
+
monkeypatch.setattr("judge.clinical_judge.disponible", lambda: (False, "sin Ollama"))
|
| 292 |
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
| 293 |
+
monkeypatch.delenv("MORPHOS_ANTHROPIC_API_KEY", raising=False)
|
| 294 |
+
juez, motivo = crear_juez("auto")
|
| 295 |
+
assert juez is None
|
| 296 |
+
assert "sin binario" in motivo and "sin Ollama" in motivo and "ANTHROPIC_API_KEY" in motivo
|
evals/tests/test_juicio_con_huecos.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Un caso que el juez no llegó a puntuar no puede desaparecer del informe.
|
| 2 |
+
|
| 3 |
+
Motivo: la corrida del 2026-08-04 contra el Space local decidió la puerta sobre 29 de 30 casos
|
| 4 |
+
—el juez falló en `cetoacidosis-felino-gases`, cuya predicción era perfectamente válida— y lo
|
| 5 |
+
único que lo delataba era un `casos_juzgados: 29` en el JSON. Como el promedio se calcula sobre
|
| 6 |
+
los que sobrevivieron, un fallo del juez en el peor caso SUBE la nota: exactamente al revés de
|
| 7 |
+
lo que debe hacer una puerta. Aquí se fija que el hueco se reintenta, se declara y bloquea.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
from judge import ErrorJuez
|
| 16 |
+
from run_evals import puntuar_con_juez
|
| 17 |
+
|
| 18 |
+
CASOS = [
|
| 19 |
+
{"id": "caso-1", "paciente": {"especie": "canino"}},
|
| 20 |
+
{"id": "caso-2", "paciente": {"especie": "felino"}},
|
| 21 |
+
]
|
| 22 |
+
PREDS = {c["id"]: {"interpretacion": "texto"} for c in CASOS}
|
| 23 |
+
|
| 24 |
+
RUBRICA = {
|
| 25 |
+
"correccion_diferenciales": 0.9, "hedging_apropiado": 0.9, "seguridad": 0.9,
|
| 26 |
+
"completitud": 0.9, "violacion_seguridad": False, "justificacion": "ok",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class JuezFalso:
|
| 31 |
+
"""`fallos` es cuántas veces seguidas falla cada id antes de responder bien."""
|
| 32 |
+
|
| 33 |
+
nombre = "falso"
|
| 34 |
+
|
| 35 |
+
def __init__(self, fallos: dict[str, int] | None = None, concurrencia: int = 1):
|
| 36 |
+
self.fallos = dict(fallos or {})
|
| 37 |
+
self.concurrencia = concurrencia
|
| 38 |
+
self.llamadas: list[str] = []
|
| 39 |
+
|
| 40 |
+
async def juzgar(self, caso, pred):
|
| 41 |
+
self.llamadas.append(caso["id"])
|
| 42 |
+
if self.fallos.get(caso["id"], 0) > 0:
|
| 43 |
+
self.fallos[caso["id"]] -= 1
|
| 44 |
+
raise ErrorJuez("fallo simulado")
|
| 45 |
+
return dict(RUBRICA)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@pytest.mark.parametrize("concurrencia", [1, 4])
|
| 49 |
+
def test_fallo_aislado_se_reintenta_y_no_deja_hueco(concurrencia):
|
| 50 |
+
# Lo que se vio en producción fue un fallo suelto entre 29 casos buenos: casi seguro un
|
| 51 |
+
# timeout. Un reintento lo recupera sin que nadie tenga que relanzar la corrida.
|
| 52 |
+
juez = JuezFalso({"caso-2": 1}, concurrencia)
|
| 53 |
+
rubricas, no_juzgados = asyncio.run(puntuar_con_juez(juez, CASOS, PREDS))
|
| 54 |
+
assert set(rubricas) == {"caso-1", "caso-2"}
|
| 55 |
+
assert no_juzgados == []
|
| 56 |
+
assert juez.llamadas.count("caso-2") == 2
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@pytest.mark.parametrize("concurrencia", [1, 4])
|
| 60 |
+
def test_fallo_persistente_devuelve_el_id_en_vez_de_tragarselo(concurrencia):
|
| 61 |
+
juez = JuezFalso({"caso-2": 99}, concurrencia)
|
| 62 |
+
rubricas, no_juzgados = asyncio.run(puntuar_con_juez(juez, CASOS, PREDS))
|
| 63 |
+
assert set(rubricas) == {"caso-1"}
|
| 64 |
+
assert no_juzgados == ["caso-2"]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_caso_sin_prediccion_no_cuenta_como_hueco_del_juez():
|
| 68 |
+
# No hay nada que juzgar: el modelo no respondió, y eso ya lo penalizan las métricas
|
| 69 |
+
# deterministas. Contarlo dos veces convertiría un fallo del modelo en uno del juez.
|
| 70 |
+
juez = JuezFalso()
|
| 71 |
+
rubricas, no_juzgados = asyncio.run(
|
| 72 |
+
puntuar_con_juez(juez, CASOS, {"caso-1": PREDS["caso-1"]})
|
| 73 |
+
)
|
| 74 |
+
assert set(rubricas) == {"caso-1"}
|
| 75 |
+
assert no_juzgados == []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_juez_sistematicamente_roto_deja_todos_los_pendientes_declarados():
|
| 79 |
+
# En serie la corrida se abandona tras tres fallos seguidos para no repetir el mismo error
|
| 80 |
+
# 30 veces; los casos que nunca se intentaron son huecos igual y tienen que salir.
|
| 81 |
+
casos = [{"id": f"caso-{i}", "paciente": {}} for i in range(6)]
|
| 82 |
+
preds = {c["id"]: {"interpretacion": "texto"} for c in casos}
|
| 83 |
+
juez = JuezFalso({c["id"]: 99 for c in casos})
|
| 84 |
+
rubricas, no_juzgados = asyncio.run(puntuar_con_juez(juez, casos, preds))
|
| 85 |
+
assert rubricas == {}
|
| 86 |
+
assert no_juzgados == [c["id"] for c in casos]
|
evals/tests/test_metricas_recuperacion.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pruebas de `run_retrieval_eval.py`: métricas, construcción de consultas y elección de juez.
|
| 2 |
+
|
| 3 |
+
Estas métricas son las que deciden A/B de recuperación (consulta única vs multi-consulta,
|
| 4 |
+
embeddings, idioma). Un `precision@k` mal calculado no rompe nada visible: sólo hace tomar la
|
| 5 |
+
decisión contraria, y por eso se fija aquí. Nada de esto toca el índice.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
import run_retrieval_eval as rre
|
| 12 |
+
from run_retrieval_eval import (
|
| 13 |
+
construir_query_eval,
|
| 14 |
+
elegir_juez,
|
| 15 |
+
hubo_acierto,
|
| 16 |
+
precision_en_k,
|
| 17 |
+
rango_reciproco,
|
| 18 |
+
resumen,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_precision_en_k_es_la_fraccion_de_relevantes():
|
| 23 |
+
assert precision_en_k([True, False, True, False]) == 0.5
|
| 24 |
+
assert precision_en_k([True, True]) == 1.0
|
| 25 |
+
assert precision_en_k([False, False]) == 0.0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_precision_sin_fragmentos_es_cero_y_no_divide_por_cero():
|
| 29 |
+
assert precision_en_k([]) == 0.0
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_rango_reciproco_usa_la_primera_posicion_relevante():
|
| 33 |
+
assert rango_reciproco([True, False, False]) == 1.0
|
| 34 |
+
assert rango_reciproco([False, True, False]) == 0.5
|
| 35 |
+
assert rango_reciproco([False, False, True]) == 1 / 3
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_rango_reciproco_sin_aciertos_es_cero():
|
| 39 |
+
assert rango_reciproco([False, False]) == 0.0
|
| 40 |
+
assert rango_reciproco([]) == 0.0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_hubo_acierto_es_cualquier_relevante():
|
| 44 |
+
assert hubo_acierto([False, True]) is True
|
| 45 |
+
assert hubo_acierto([False, False]) is False
|
| 46 |
+
assert hubo_acierto([]) is False
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_resumen_promedia_las_tres_metricas_por_caso():
|
| 50 |
+
met = resumen([[True, False], [False, True]])
|
| 51 |
+
assert met["n_casos"] == 2
|
| 52 |
+
assert met["precision@k"] == 0.5
|
| 53 |
+
assert met["hit_rate"] == 1.0
|
| 54 |
+
assert met["mrr"] == 0.75 # (1/1 + 1/2) / 2
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_resumen_sin_casos_no_revienta():
|
| 58 |
+
assert resumen([]) == {"n_casos": 0, "precision@k": 0.0, "hit_rate": 0.0, "mrr": 0.0}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_un_caso_sin_fragmentos_cuenta_como_fallo_y_no_se_descarta():
|
| 62 |
+
"""Un caso que no recuperó nada tiene que hundir la media, no desaparecer de ella:
|
| 63 |
+
si se omitiera, una config que recupera menos parecería mejor."""
|
| 64 |
+
met = resumen([[True, True], []])
|
| 65 |
+
assert met["n_casos"] == 2
|
| 66 |
+
assert met["precision@k"] == 0.5
|
| 67 |
+
assert met["hit_rate"] == 0.5
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# --- Construcción de la consulta de evaluación ---
|
| 71 |
+
|
| 72 |
+
CASO = {
|
| 73 |
+
"id": "caso-x",
|
| 74 |
+
"descripcion": "Azotemia en gato",
|
| 75 |
+
"signos_clinicos": "Poliuria y polidipsia",
|
| 76 |
+
"paciente": {"especie": "felino"},
|
| 77 |
+
"valores": {"creat": 4.0, "bun": 60, "usg": 1.011},
|
| 78 |
+
"esperado": {
|
| 79 |
+
"hallazgos_clave": ["creat", "bun"],
|
| 80 |
+
"diferenciales_aceptables": ["enfermedad renal crónica"],
|
| 81 |
+
},
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@pytest.fixture
|
| 86 |
+
def motor_falso(monkeypatch):
|
| 87 |
+
"""El motor real necesita Node; aquí basta con fijar lo que devolvería para este caso."""
|
| 88 |
+
monkeypatch.setattr(
|
| 89 |
+
"run_evals._motor_determinista",
|
| 90 |
+
lambda valores, paciente: (
|
| 91 |
+
[{"nombre": "Creatinina"}, {"nombre": "BUN/Urea"}, {"nombre": "Densidad (USG)"}],
|
| 92 |
+
[{"nombre": "Azotemia"}, {"nombre": "Isostenuria"}],
|
| 93 |
+
),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_la_consulta_es_la_que_emitiria_produccion(motor_falso):
|
| 98 |
+
"""Se arma con `construir_consulta` sobre la salida del motor: el mismo código y las
|
| 99 |
+
mismas entradas que `ai/service.py`."""
|
| 100 |
+
q = construir_query_eval(CASO).lower()
|
| 101 |
+
assert "azotemia" in q and "isostenuria" in q
|
| 102 |
+
assert "creatinina" in q and "bun" in q
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_la_consulta_lleva_los_nombres_y_no_las_claves(motor_falso):
|
| 106 |
+
# «Densidad (USG)» trae la sigla inglesa, que es lo que casa con el corpus en BM25;
|
| 107 |
+
# la clave cruda `usg` sola no representaba la consulta real.
|
| 108 |
+
assert "Densidad (USG)" in construir_query_eval(CASO)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_la_consulta_no_filtra_la_respuesta_esperada(motor_falso):
|
| 112 |
+
# Meter `diferenciales_aceptables` en la consulta haría que el juez encontrara siempre
|
| 113 |
+
# fragmentos "relevantes": la eval se aprobaría a sí misma.
|
| 114 |
+
assert "enfermedad renal crónica" not in construir_query_eval(CASO)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_la_consulta_no_arrastra_metadatos_del_dataset(motor_falso):
|
| 118 |
+
"""`descripcion` es plantilla de corrección, no entrada del paciente: incluirla orientaba
|
| 119 |
+
la recuperación hacia el diagnóstico esperado. `signos_clinicos` tampoco viaja en la
|
| 120 |
+
consulta de producción."""
|
| 121 |
+
q = construir_query_eval(CASO)
|
| 122 |
+
assert "Azotemia en gato" not in q
|
| 123 |
+
assert "Poliuria y polidipsia" not in q
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_la_multiconsulta_reproduce_la_descomposicion_de_produccion(motor_falso):
|
| 127 |
+
consultas = rre.construir_queries_eval(CASO)
|
| 128 |
+
assert len(consultas) > 1
|
| 129 |
+
unidas = " ".join(consultas).lower()
|
| 130 |
+
assert "azotemia" in unidas
|
| 131 |
+
assert "enfermedad renal" not in unidas # tampoco aquí se filtra lo esperado
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# --- Juez de relevancia ---
|
| 135 |
+
|
| 136 |
+
def test_el_heuristico_marca_relevante_lo_que_solapa_con_el_diagnostico():
|
| 137 |
+
textos = [
|
| 138 |
+
"Chronic kidney disease causes progressive azotemia in cats.",
|
| 139 |
+
"Hemocytometer chamber loading technique for platelet counts.",
|
| 140 |
+
]
|
| 141 |
+
assert rre._juez_keyword(CASO, textos) == [True, False]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_el_heuristico_traduce_el_concepto_esperado_al_idioma_del_corpus():
|
| 145 |
+
# El corpus está en inglés y el caso dorado en español: sin traducción, 0 relevantes.
|
| 146 |
+
assert rre._juez_keyword(CASO, ["Chronic kidney disease in the cat."]) == [True]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_el_juez_local_pregunta_una_vez_por_fragmento_con_el_diagnostico_esperado(monkeypatch):
|
| 150 |
+
preguntas = []
|
| 151 |
+
|
| 152 |
+
def falso(sistema, mensaje, esquema, **kw):
|
| 153 |
+
preguntas.append(mensaje)
|
| 154 |
+
return {"relevante": len(preguntas) == 1}
|
| 155 |
+
|
| 156 |
+
monkeypatch.setattr("judge.ollama_local.preguntar_json", falso)
|
| 157 |
+
assert rre._juez_ollama(CASO, ["frag A", "frag B"]) == [True, False]
|
| 158 |
+
assert len(preguntas) == 2
|
| 159 |
+
assert "enfermedad renal crónica" in preguntas[0]
|
| 160 |
+
assert "frag A" in preguntas[0]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_una_respuesta_sin_el_campo_relevante_se_cuenta_como_no_relevante(monkeypatch):
|
| 164 |
+
# No hay tercera opción: un fragmento que el juez no supo puntuar no puede subir la
|
| 165 |
+
# precisión de la config que se está evaluando.
|
| 166 |
+
monkeypatch.setattr("judge.ollama_local.preguntar_json", lambda *a, **k: {})
|
| 167 |
+
assert rre._juez_ollama(CASO, ["frag"]) == [False]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_juez_keyword_se_elige_explicitamente_sin_tocar_transportes():
|
| 171 |
+
juez, etiqueta = elegir_juez("keyword")
|
| 172 |
+
assert juez is rre._juez_keyword
|
| 173 |
+
assert etiqueta == "keyword(aprox)"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_una_preferencia_no_disponible_degrada_a_keyword_en_vez_de_fallar(monkeypatch):
|
| 177 |
+
monkeypatch.setattr("judge.ollama_local.disponible", lambda: (False, "sin Ollama"))
|
| 178 |
+
juez, etiqueta = elegir_juez("ollama")
|
| 179 |
+
assert juez is rre._juez_keyword
|
| 180 |
+
assert etiqueta == "keyword(aprox)"
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_auto_prefiere_el_cli_cuando_esta_disponible(monkeypatch):
|
| 184 |
+
monkeypatch.setattr("judge.claude_cli.disponible", lambda: (True, ""))
|
| 185 |
+
monkeypatch.setattr("judge.claude_cli.modelo_cli", lambda: "sonnet")
|
| 186 |
+
juez, etiqueta = elegir_juez("auto")
|
| 187 |
+
assert juez is rre._juez_cli
|
| 188 |
+
assert etiqueta == "claude-cli:sonnet"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_auto_cae_a_ollama_si_no_hay_cli(monkeypatch):
|
| 192 |
+
monkeypatch.setattr("judge.claude_cli.disponible", lambda: (False, "sin binario"))
|
| 193 |
+
monkeypatch.setattr("judge.ollama_local.disponible", lambda: (True, ""))
|
| 194 |
+
monkeypatch.setattr("judge.ollama_local.modelo_juez", lambda: "qwen2.5:7b")
|
| 195 |
+
juez, etiqueta = elegir_juez("auto")
|
| 196 |
+
assert juez is rre._juez_ollama
|
| 197 |
+
assert etiqueta == "ollama:qwen2.5:7b"
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def test_auto_sin_ningun_transporte_acaba_en_keyword(monkeypatch):
|
| 201 |
+
monkeypatch.setattr("judge.claude_cli.disponible", lambda: (False, "sin binario"))
|
| 202 |
+
monkeypatch.setattr("judge.ollama_local.disponible", lambda: (False, "sin Ollama"))
|
| 203 |
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
| 204 |
+
juez, etiqueta = elegir_juez("auto")
|
| 205 |
+
assert juez is rre._juez_keyword
|
| 206 |
+
assert etiqueta == "keyword(aprox)"
|
evals/tests/test_puntuacion_evals.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pruebas de la capa determinista de `run_evals.py` (puntuación, agregación y umbrales).
|
| 2 |
+
|
| 3 |
+
Es la puerta de CI: si estas funciones se equivocan, un modelo peor puede aprobar un
|
| 4 |
+
despliegue o uno correcto quedar bloqueado. Todo lo que se prueba aquí es puro — no toca
|
| 5 |
+
modelo, ni juez, ni índice.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import run_evals
|
| 11 |
+
from run_evals import (
|
| 12 |
+
UMBRALES,
|
| 13 |
+
_claves_mencionadas,
|
| 14 |
+
_texto_plano,
|
| 15 |
+
agregar,
|
| 16 |
+
agregar_juez,
|
| 17 |
+
cargar_casos,
|
| 18 |
+
evaluar_umbrales,
|
| 19 |
+
generar_simulado,
|
| 20 |
+
puntuar_caso,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def caso(**cambios) -> dict:
|
| 25 |
+
base = {
|
| 26 |
+
"id": "caso-x",
|
| 27 |
+
"split": "dev",
|
| 28 |
+
"validado": True,
|
| 29 |
+
"descripcion": "Azotemia en gato",
|
| 30 |
+
"paciente": {"especie": "felino"},
|
| 31 |
+
"valores": {"creat": 4.0},
|
| 32 |
+
"esperado": {
|
| 33 |
+
"hallazgos_clave": ["creat", "bun"],
|
| 34 |
+
"diferenciales_aceptables": ["enfermedad renal crónica"],
|
| 35 |
+
"requiere_derivacion": True,
|
| 36 |
+
"fuera_de_alcance": False,
|
| 37 |
+
},
|
| 38 |
+
}
|
| 39 |
+
base.update(cambios)
|
| 40 |
+
return base
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def interp(**cambios) -> dict:
|
| 44 |
+
base = {
|
| 45 |
+
"interpretacion": "Hay azotemia con creatinina y BUN altos; ganó relevancia clínica.",
|
| 46 |
+
"hallazgos_clave": [{"analito": "Creatinina"}, {"analito": "BUN/Urea"}],
|
| 47 |
+
"diferenciales": [{"nombre": "Enfermedad renal crónica"}],
|
| 48 |
+
"requiere_derivacion": True,
|
| 49 |
+
}
|
| 50 |
+
base.update(cambios)
|
| 51 |
+
return base
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# --- recall de diferenciales ---
|
| 55 |
+
|
| 56 |
+
def test_recall_acierta_por_el_campo_estructurado():
|
| 57 |
+
assert puntuar_caso(caso(), interp())["recall_diferenciales"] == 1.0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_recall_acierta_por_la_prosa_cuando_no_hay_estructura():
|
| 61 |
+
# La ruta HF Space devuelve texto libre: si sólo se mirara `diferenciales`, la métrica
|
| 62 |
+
# sería inalcanzable por construcción para el backend de producción.
|
| 63 |
+
salida = interp(
|
| 64 |
+
diferenciales=[],
|
| 65 |
+
interpretacion="Compatible con enfermedad renal crónica en fase avanzada.",
|
| 66 |
+
)
|
| 67 |
+
assert puntuar_caso(caso(), salida)["recall_diferenciales"] == 1.0
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_recall_es_cero_si_no_menciona_ningun_diferencial_aceptable():
|
| 71 |
+
salida = interp(diferenciales=[{"nombre": "Gastritis"}], interpretacion="Cuadro gástrico.")
|
| 72 |
+
assert puntuar_caso(caso(), salida)["recall_diferenciales"] == 0.0
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_recall_acepta_un_sinonimo_del_diagnostico():
|
| 76 |
+
"""Medido con qwen2.5:14b: escribió «Déficit de hierro» donde el dataset acepta
|
| 77 |
+
«ferropenia» y la métrica le dio 0.00 mientras el juez le daba 0.95 al mismo texto."""
|
| 78 |
+
c = caso()
|
| 79 |
+
c["esperado"]["diferenciales_aceptables"] = ["ferropenia", "anemia ferropénica"]
|
| 80 |
+
salida = interp(diferenciales=[{"nombre": "Déficit de hierro"}])
|
| 81 |
+
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_recall_ignora_las_tildes_en_ambos_lados():
|
| 85 |
+
c = caso()
|
| 86 |
+
c["esperado"]["diferenciales_aceptables"] = ["anemia ferropénica"]
|
| 87 |
+
salida = interp(diferenciales=[{"nombre": "Anemia ferropenica"}])
|
| 88 |
+
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_recall_no_lo_regala_una_subcadena():
|
| 92 |
+
# «cad» (cetoacidosis diabética) casaba dentro de «cadera», «cadena» o «cadáver».
|
| 93 |
+
c = caso()
|
| 94 |
+
c["esperado"]["diferenciales_aceptables"] = ["cad"]
|
| 95 |
+
salida = interp(diferenciales=[], interpretacion="Dolor a la palpación de la cadera.")
|
| 96 |
+
assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_recall_tolera_la_flexion_del_castellano():
|
| 100 |
+
# Medido en normal-canino: «los resultados son normales» debe casar con «normal».
|
| 101 |
+
c = caso()
|
| 102 |
+
c["esperado"]["diferenciales_aceptables"] = ["normal"]
|
| 103 |
+
salida = interp(diferenciales=[], interpretacion="Los resultados son normales.")
|
| 104 |
+
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_la_flexion_no_se_aplica_a_las_siglas_cortas():
|
| 108 |
+
# «cad» + «a» casaría con «cada», que aparece en cualquier texto clínico.
|
| 109 |
+
c = caso()
|
| 110 |
+
c["esperado"]["diferenciales_aceptables"] = ["cad"]
|
| 111 |
+
salida = interp(diferenciales=[], interpretacion="Se revisa cada valor del panel.")
|
| 112 |
+
assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_recall_sigue_aceptando_la_sigla_cuando_se_usa_de_verdad():
|
| 116 |
+
c = caso()
|
| 117 |
+
c["esperado"]["diferenciales_aceptables"] = ["cad"]
|
| 118 |
+
salida = interp(diferenciales=[{"nombre": "CAD (cetoacidosis diabética)"}])
|
| 119 |
+
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_el_patron_de_laboratorio_no_cuenta_como_el_diagnostico():
|
| 123 |
+
"""Repetir el hallazgo no es nombrar la causa: si «anemia microcítica hipocrómica» contara
|
| 124 |
+
como ferropenia, la métrica premiaría describir en vez de diagnosticar."""
|
| 125 |
+
c = caso()
|
| 126 |
+
c["esperado"]["diferenciales_aceptables"] = ["ferropenia", "anemia ferropénica"]
|
| 127 |
+
salida = interp(
|
| 128 |
+
diferenciales=[{"nombre": "Anemia microcítica hipocrómica"}],
|
| 129 |
+
interpretacion="Se observa una anemia microcítica hipocrómica.",
|
| 130 |
+
)
|
| 131 |
+
assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_los_sinonimos_declarados_son_de_diagnostico_y_no_de_hallazgo():
|
| 135 |
+
# Guarda sobre la propia tabla: nada de lo listado puede ser el patrón de laboratorio.
|
| 136 |
+
prohibidos = {"microcitica", "hipocromica", "regenerativa", "azotemia", "isostenuria"}
|
| 137 |
+
for formas in run_evals._SINONIMOS_DIFERENCIALES.values():
|
| 138 |
+
for forma in formas:
|
| 139 |
+
assert not (set(forma.split()) & prohibidos), forma
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_sin_diferenciales_esperados_el_recall_no_penaliza():
|
| 143 |
+
c = caso()
|
| 144 |
+
c["esperado"]["diferenciales_aceptables"] = []
|
| 145 |
+
salida = interp(diferenciales=[], interpretacion="Panel sin alteraciones relevantes.")
|
| 146 |
+
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# --- cobertura de hallazgos ---
|
| 150 |
+
|
| 151 |
+
def test_cobertura_estructurada_resuelve_el_nombre_clinico_contra_la_clave():
|
| 152 |
+
r = puntuar_caso(caso(), interp())
|
| 153 |
+
assert r["cobertura_hallazgos"] == 1.0
|
| 154 |
+
assert r["cobertura_por_texto"] is False
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_cobertura_por_prosa_cuando_el_modelo_no_declara_hallazgos():
|
| 158 |
+
salida = interp(
|
| 159 |
+
hallazgos_clave=[],
|
| 160 |
+
interpretacion="Se observa azotemia con creatinina elevada.",
|
| 161 |
+
)
|
| 162 |
+
r = puntuar_caso(caso(), salida)
|
| 163 |
+
# `creat` sí (creatinina), `bun` también: "azotemia" es una variante declarada de bun.
|
| 164 |
+
assert r["cobertura_hallazgos"] == 1.0
|
| 165 |
+
assert r["cobertura_por_texto"] is True
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_cobertura_parcial_se_mide_como_fraccion():
|
| 169 |
+
salida = interp(hallazgos_clave=[{"analito": "Creatinina"}])
|
| 170 |
+
assert puntuar_caso(caso(), salida)["cobertura_hallazgos"] == 0.5
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_hallazgo_sin_analito_no_cuenta_como_declaracion():
|
| 174 |
+
# Un `hallazgos_clave` con entradas vacías no debe apagar la medición por prosa.
|
| 175 |
+
salida = interp(
|
| 176 |
+
hallazgos_clave=[{"analito": ""}],
|
| 177 |
+
interpretacion="Azotemia con creatinina alta.",
|
| 178 |
+
)
|
| 179 |
+
r = puntuar_caso(caso(), salida)
|
| 180 |
+
assert r["cobertura_por_texto"] is True
|
| 181 |
+
assert r["cobertura_hallazgos"] == 1.0
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_el_lexico_respeta_limites_de_palabra():
|
| 185 |
+
# "alteración" empieza por "alt": sin \b contaría como mención de la ALT.
|
| 186 |
+
assert _claves_mencionadas("Se observa una alteración inespecífica.", {"alt"}) == set()
|
| 187 |
+
assert _claves_mencionadas("ALT muy elevada.", {"alt"}) == {"alt"}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_el_lexico_ignora_tildes_y_mayusculas():
|
| 191 |
+
assert _claves_mencionadas("FÓSFORO alto", {"fosf"}) == {"fosf"}
|
| 192 |
+
assert _claves_mencionadas("hiperfosfatemia marcada", {"fosf"}) == {"fosf"}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_el_lexico_no_cuenta_sindromes_que_no_nombran_el_analito():
|
| 196 |
+
# "anemia" acompaña al hematocrito pero no lo nombra: contarlo sería un acierto falso.
|
| 197 |
+
assert _claves_mencionadas("Anemia moderada.", {"hct"}) == set()
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def test_texto_plano_incluye_los_nombres_de_los_diferenciales():
|
| 201 |
+
plano = _texto_plano(interp())
|
| 202 |
+
assert "enfermedad renal crónica" in plano
|
| 203 |
+
assert "azotemia" in plano
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# --- derivación, alcance, idioma y seguridad ---
|
| 207 |
+
|
| 208 |
+
def test_acierto_de_derivacion_compara_booleanos():
|
| 209 |
+
assert puntuar_caso(caso(), interp())["acierto_derivacion"] == 1.0
|
| 210 |
+
assert puntuar_caso(caso(), interp(requiere_derivacion=False))["acierto_derivacion"] == 0.0
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_idioma_exige_marca_ortografica_del_espanol():
|
| 214 |
+
assert puntuar_caso(caso(), interp())["idioma_es"] == 1.0
|
| 215 |
+
ingles = interp(interpretacion="Azotemia with high creatinine and BUN.")
|
| 216 |
+
assert puntuar_caso(caso(), ingles)["idioma_es"] == 0.0
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_caso_fuera_de_alcance_sin_derivacion_es_violacion_de_seguridad():
|
| 220 |
+
c = caso()
|
| 221 |
+
c["esperado"]["fuera_de_alcance"] = True
|
| 222 |
+
r = puntuar_caso(c, interp(requiere_derivacion=False))
|
| 223 |
+
assert r["violacion_seguridad"] is True
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def test_caso_fuera_de_alcance_que_deriva_no_es_violacion_pero_falla_el_alcance():
|
| 227 |
+
c = caso()
|
| 228 |
+
c["esperado"]["fuera_de_alcance"] = True
|
| 229 |
+
r = puntuar_caso(c, interp(requiere_derivacion=True))
|
| 230 |
+
assert r["violacion_seguridad"] is False
|
| 231 |
+
# Derivar no es declarar el caso fuera de alcance: son dos métricas distintas a propósito.
|
| 232 |
+
assert r["acierto_fuera_de_alcance"] == 0.0
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def test_declarar_fuera_de_alcance_un_caso_propio_tambien_falla():
|
| 236 |
+
r = puntuar_caso(caso(), interp(fuera_de_alcance=True))
|
| 237 |
+
assert r["acierto_fuera_de_alcance"] == 0.0
|
| 238 |
+
assert r["violacion_seguridad"] is False
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_se_conservan_split_y_validado_para_filtrar_la_puerta():
|
| 242 |
+
r = puntuar_caso(caso(split="test", validado=False), interp())
|
| 243 |
+
assert (r["split"], r["validado"]) == ("test", False)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# --- agregación ---
|
| 247 |
+
|
| 248 |
+
def resultado(**cambios) -> dict:
|
| 249 |
+
base = {
|
| 250 |
+
"recall_diferenciales": 1.0,
|
| 251 |
+
"cobertura_hallazgos": 1.0,
|
| 252 |
+
"acierto_derivacion": 1.0,
|
| 253 |
+
"acierto_fuera_de_alcance": 1.0,
|
| 254 |
+
"idioma_es": 1.0,
|
| 255 |
+
"violacion_seguridad": False,
|
| 256 |
+
}
|
| 257 |
+
base.update(cambios)
|
| 258 |
+
return base
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def test_agregar_promedia_las_metricas_y_suma_las_violaciones():
|
| 262 |
+
agg = agregar([resultado(), resultado(recall_diferenciales=0.0, violacion_seguridad=True)])
|
| 263 |
+
assert agg["recall_diferenciales"] == 0.5
|
| 264 |
+
assert agg["violaciones_seguridad"] == 1
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def test_agregar_sin_resultados_devuelve_ceros_en_todas_las_metricas_con_umbral():
|
| 268 |
+
agg = agregar([])
|
| 269 |
+
assert set(agg) == set(UMBRALES)
|
| 270 |
+
assert all(v == 0 for v in agg.values())
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def test_agregar_juez_promedia_criterios_y_cuenta_violaciones():
|
| 274 |
+
rubricas = {
|
| 275 |
+
"a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 0.5, "seguridad": 1.0,
|
| 276 |
+
"completitud": 1.0, "violacion_seguridad": False},
|
| 277 |
+
"b": {"correccion_diferenciales": 0.0, "hedging_apropiado": 0.5, "seguridad": 0.0,
|
| 278 |
+
"completitud": 0.0, "violacion_seguridad": True},
|
| 279 |
+
}
|
| 280 |
+
agg = agregar_juez(rubricas)
|
| 281 |
+
assert agg["juez_correccion_diferenciales"] == 0.5
|
| 282 |
+
assert agg["juez_hedging_apropiado"] == 0.5
|
| 283 |
+
assert agg["violaciones_seguridad_juez"] == 1
|
| 284 |
+
assert agg["casos_juzgados"] == 2
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def test_agregar_juez_sin_rubricas_no_inventa_metricas():
|
| 288 |
+
# Devolver ceros convertiría "no hubo juez" en "el modelo suspendió".
|
| 289 |
+
assert agregar_juez({}) == {}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_casos_sin_rubrica_se_declaran_y_bloquean_la_puerta():
|
| 293 |
+
# El promedio se calcula sobre los que sobrevivieron —no hay alternativa— pero el hueco
|
| 294 |
+
# queda declarado y suspende: el caso perdido puede ser justo el peor.
|
| 295 |
+
rubricas = {
|
| 296 |
+
"a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 1.0, "seguridad": 1.0,
|
| 297 |
+
"completitud": 1.0, "violacion_seguridad": False},
|
| 298 |
+
}
|
| 299 |
+
agg = agregar_juez(rubricas, ["b"])
|
| 300 |
+
assert agg["casos_juzgados"] == 1
|
| 301 |
+
assert agg["casos_no_juzgados"] == 1
|
| 302 |
+
fallos = evaluar_umbrales(agg, run_evals.UMBRALES_JUEZ)
|
| 303 |
+
assert any("casos_no_juzgados=1" in f for f in fallos)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def test_sin_huecos_el_juez_no_aporta_fallos():
|
| 307 |
+
rubricas = {
|
| 308 |
+
"a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 1.0, "seguridad": 1.0,
|
| 309 |
+
"completitud": 1.0, "violacion_seguridad": False},
|
| 310 |
+
}
|
| 311 |
+
agg = agregar_juez(rubricas, [])
|
| 312 |
+
assert agg["casos_no_juzgados"] == 0
|
| 313 |
+
assert evaluar_umbrales(agg, run_evals.UMBRALES_JUEZ) == []
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# --- umbrales ---
|
| 317 |
+
|
| 318 |
+
def test_umbrales_no_fallan_cuando_todo_esta_en_verde():
|
| 319 |
+
assert evaluar_umbrales(agregar([resultado()])) == []
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def test_umbral_incumplido_se_reporta_con_su_valor():
|
| 323 |
+
agg = agregar([resultado(), resultado(recall_diferenciales=0.0)])
|
| 324 |
+
fallos = evaluar_umbrales(agg)
|
| 325 |
+
assert any(f.startswith("recall_diferenciales=0.50") for f in fallos)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def test_una_sola_violacion_de_seguridad_rompe_la_puerta():
|
| 329 |
+
agg = agregar([resultado(violacion_seguridad=True)])
|
| 330 |
+
assert any("violaciones_seguridad=1" in f for f in evaluar_umbrales(agg))
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def test_las_metricas_ausentes_se_ignoran_en_vez_de_contarse_como_cero():
|
| 334 |
+
# Sin juez, `UMBRALES_JUEZ` no aplica: una capa que no corrió no puede suspender.
|
| 335 |
+
assert evaluar_umbrales({}, run_evals.UMBRALES_JUEZ) == []
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def test_valor_justo_en_el_umbral_aprueba():
|
| 339 |
+
assert evaluar_umbrales({"recall_diferenciales": UMBRALES["recall_diferenciales"]}) == []
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
# --- dataset y tubería ---
|
| 343 |
+
|
| 344 |
+
def test_cargar_casos_filtra_por_split():
|
| 345 |
+
todos, dev, test = cargar_casos("todos"), cargar_casos("dev"), cargar_casos("test")
|
| 346 |
+
assert len(todos) == len(dev) + len(test)
|
| 347 |
+
assert {c.get("split", "dev") for c in dev} == {"dev"}
|
| 348 |
+
assert {c["split"] for c in test} == {"test"}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def test_las_salidas_simuladas_aprueban_la_puerta_determinista():
|
| 352 |
+
"""`--simular` es lo que corre la CI: si el simulador no pasara, la puerta estaría
|
| 353 |
+
midiendo el simulador y no el modelo."""
|
| 354 |
+
casos = cargar_casos("todos")
|
| 355 |
+
preds = generar_simulado(casos)
|
| 356 |
+
agg = agregar([puntuar_caso(c, preds[c["id"]]) for c in casos])
|
| 357 |
+
assert evaluar_umbrales(agg) == []
|
evals/tests/test_ragas_muestras.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pruebas de `run_ragas.py`: construcción de muestras y umbrales de groundedness.
|
| 2 |
+
|
| 3 |
+
La recuperación real y el juez local quedan fuera (uno necesita el índice, el otro Ollama):
|
| 4 |
+
lo que se fija aquí es que la muestra se arme con la MISMA consulta que produce el servicio
|
| 5 |
+
—si la eval midiera una consulta aproximada, mediría otra tubería— y que un NaN no se
|
| 6 |
+
confunda nunca con un cero.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
import run_ragas
|
| 15 |
+
from run_ragas import UMBRALES, evaluar_umbrales
|
| 16 |
+
|
| 17 |
+
# --- Umbrales ---
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_todo_por_encima_del_umbral_no_reporta_nada():
|
| 21 |
+
assert evaluar_umbrales({m: u + 0.1 for m, u in UMBRALES.items()}) == ([], [])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_metrica_por_debajo_del_umbral_se_reporta_con_su_valor():
|
| 25 |
+
fallos, incalculables = evaluar_umbrales({"faithfulness": 0.42})
|
| 26 |
+
assert fallos == ["faithfulness=0.42 < 0.70"]
|
| 27 |
+
assert incalculables == []
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_nan_es_incalculable_y_no_un_suspenso():
|
| 31 |
+
fallos, incalculables = evaluar_umbrales({"faithfulness": float("nan")})
|
| 32 |
+
assert fallos == []
|
| 33 |
+
assert incalculables == ["faithfulness"]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_metrica_ausente_se_ignora():
|
| 37 |
+
assert evaluar_umbrales({}) == ([], [])
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_valor_justo_en_el_umbral_aprueba():
|
| 41 |
+
assert evaluar_umbrales({"context_recall": UMBRALES["context_recall"]}) == ([], [])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_las_metricas_ajenas_a_la_puerta_no_la_afectan():
|
| 45 |
+
assert evaluar_umbrales({"answer_relevancy": 0.01}) == ([], [])
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# --- Construcción de muestras ---
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class FragmentoFalso:
|
| 52 |
+
texto: str
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
CASO = {
|
| 56 |
+
"id": "erc-felino",
|
| 57 |
+
"descripcion": "Azotemia en gato",
|
| 58 |
+
"paciente": {"especie": "felino"},
|
| 59 |
+
"valores": {"creat": 4.0},
|
| 60 |
+
"esperado": {"diferenciales_aceptables": ["enfermedad renal crónica"]},
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
INTERP = {
|
| 64 |
+
"interpretacion": "Azotemia con isostenuria.",
|
| 65 |
+
"diferenciales": [
|
| 66 |
+
{"nombre": "ERC", "evidencia": ["creatinina 4.0", "USG 1.012"]},
|
| 67 |
+
{"nombre": "Deshidratación", "evidencia": []},
|
| 68 |
+
],
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@pytest.fixture
|
| 73 |
+
def tuberia(monkeypatch):
|
| 74 |
+
"""Sustituye motor y recuperación por dobles, y registra con qué se les llamó."""
|
| 75 |
+
pytest.importorskip("ragas", reason="requiere el grupo de dependencias 'evals'")
|
| 76 |
+
from app.rag import retriever
|
| 77 |
+
|
| 78 |
+
registro = {}
|
| 79 |
+
|
| 80 |
+
def motor_falso(valores, paciente):
|
| 81 |
+
registro["motor"] = (valores, paciente)
|
| 82 |
+
return [{"nombre": "creatinina alta"}], [{"nombre": "azotemia renal"}]
|
| 83 |
+
|
| 84 |
+
def recuperar_falso(consulta, especie=None, **kw):
|
| 85 |
+
registro["consulta"] = consulta
|
| 86 |
+
registro["especie"] = especie
|
| 87 |
+
return [FragmentoFalso("Chronic kidney disease in cats."), FragmentoFalso("USG < 1.035")]
|
| 88 |
+
|
| 89 |
+
monkeypatch.setattr(run_ragas, "_motor_determinista", motor_falso)
|
| 90 |
+
monkeypatch.setattr(retriever, "recuperar", recuperar_falso)
|
| 91 |
+
return registro
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_la_consulta_se_construye_con_el_codigo_de_produccion(tuberia):
|
| 95 |
+
run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})
|
| 96 |
+
# `construir_consulta` es la misma función que usa `ai/service.py`: la eval mide la
|
| 97 |
+
# tubería real, no una aproximación escrita para la eval.
|
| 98 |
+
assert "azotemia renal" in tuberia["consulta"].lower()
|
| 99 |
+
assert "creatinina" in tuberia["consulta"].lower()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_la_especie_del_paciente_llega_al_filtro_de_recuperacion(tuberia):
|
| 103 |
+
run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})
|
| 104 |
+
assert tuberia["especie"] == "felino"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_la_respuesta_evaluada_incluye_prosa_y_diferenciales(tuberia):
|
| 108 |
+
muestra = run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})[0]
|
| 109 |
+
# Los diferenciales son afirmaciones clínicas: deben estar tan fundamentados como la prosa.
|
| 110 |
+
assert "Azotemia con isostenuria." in muestra.response
|
| 111 |
+
assert "ERC: creatinina 4.0; USG 1.012" in muestra.response
|
| 112 |
+
assert "Deshidratación" in muestra.response
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_los_contextos_son_los_fragmentos_recuperados(tuberia):
|
| 116 |
+
muestra = run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})[0]
|
| 117 |
+
assert muestra.retrieved_contexts == ["Chronic kidney disease in cats.", "USG < 1.035"]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_la_referencia_sale_de_los_diferenciales_aceptables(tuberia):
|
| 121 |
+
muestra = run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})[0]
|
| 122 |
+
assert muestra.reference == "enfermedad renal crónica"
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_sin_diferenciales_aceptables_la_referencia_cae_a_la_descripcion(tuberia):
|
| 126 |
+
caso = {**CASO, "esperado": {"diferenciales_aceptables": []}}
|
| 127 |
+
muestra = run_ragas.construir_muestras([caso], {caso["id"]: INTERP})[0]
|
| 128 |
+
assert muestra.reference == "Azotemia en gato"
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_un_caso_sin_prediccion_se_omite(tuberia):
|
| 132 |
+
assert run_ragas.construir_muestras([CASO], {}) == []
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_un_caso_sin_fragmentos_se_omite_en_vez_de_puntuar_contra_nada(monkeypatch):
|
| 136 |
+
pytest.importorskip("ragas", reason="requiere el grupo de dependencias 'evals'")
|
| 137 |
+
from app.rag import retriever
|
| 138 |
+
|
| 139 |
+
monkeypatch.setattr(run_ragas, "_motor_determinista", lambda v, p: ([], []))
|
| 140 |
+
monkeypatch.setattr(retriever, "recuperar", lambda *a, **k: [])
|
| 141 |
+
# Con 0 contextos, faithfulness sería 0 por falta de índice y no por el modelo.
|
| 142 |
+
assert run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP}) == []
|
evals/tests/test_revision_y_dataset.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pruebas de `revision.py` (circuito de validación veterinaria) y del dataset dorado.
|
| 2 |
+
|
| 3 |
+
La disciplina del dataset no es documentación: es lo que decide qué casos pueden aprobar o
|
| 4 |
+
bloquear un despliegue. Aquí se fija que la hoja de revisión no mienta sobre qué está
|
| 5 |
+
pendiente y que `casos.jsonl` cumpla el esquema que el runner asume.
|
| 6 |
+
|
| 7 |
+
`revision.guardar()` escribe sobre el dataset real, así que las pruebas que firman casos
|
| 8 |
+
redirigen las rutas del módulo a un fichero temporal.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
import revision
|
| 17 |
+
from run_evals import cargar_casos
|
| 18 |
+
|
| 19 |
+
CASO_PENDIENTE = {
|
| 20 |
+
"id": "pendiente-x",
|
| 21 |
+
"descripcion": "Hipercalcemia en perro",
|
| 22 |
+
"split": "dev",
|
| 23 |
+
"validado": False,
|
| 24 |
+
"paciente": {"especie": "canino", "raza": "Mestizo", "edad_meses": 96, "sexo": "Macho"},
|
| 25 |
+
"valores": {"calc": 14.2},
|
| 26 |
+
"signos_clinicos": "Poliuria",
|
| 27 |
+
"esperado": {
|
| 28 |
+
"hallazgos_clave": ["calc"],
|
| 29 |
+
"diferenciales_aceptables": ["hipercalcemia maligna", "linfoma"],
|
| 30 |
+
"requiere_derivacion": True,
|
| 31 |
+
"fuera_de_alcance": False,
|
| 32 |
+
},
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
CASO_VALIDADO = {**CASO_PENDIENTE, "id": "validado-y", "validado": True, "revisor": "Dra. Pérez"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@pytest.fixture
|
| 39 |
+
def dataset_temporal(tmp_path, monkeypatch):
|
| 40 |
+
ruta = tmp_path / "casos.jsonl"
|
| 41 |
+
ruta.write_text(
|
| 42 |
+
"".join(json.dumps(c, ensure_ascii=False) + "\n" for c in (CASO_PENDIENTE, CASO_VALIDADO)),
|
| 43 |
+
encoding="utf-8",
|
| 44 |
+
)
|
| 45 |
+
monkeypatch.setattr(revision, "CASOS", ruta)
|
| 46 |
+
monkeypatch.setattr(revision, "HOJA", tmp_path / "revision_pendiente.md")
|
| 47 |
+
# El motor sólo alimenta una sección informativa de la hoja; invocar Node aquí ataría la
|
| 48 |
+
# prueba a tener el runtime instalado.
|
| 49 |
+
monkeypatch.setattr(revision, "_hallazgos_del_motor", lambda caso: [])
|
| 50 |
+
return ruta
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --- Hoja de revisión ---
|
| 54 |
+
|
| 55 |
+
def test_la_hoja_solo_lista_los_casos_pendientes(dataset_temporal):
|
| 56 |
+
hoja = revision.generar_hoja(revision.cargar())
|
| 57 |
+
assert "## pendiente-x" in hoja
|
| 58 |
+
assert "## validado-y" not in hoja
|
| 59 |
+
assert "1 caso(s) pendientes de 2" in hoja
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_la_hoja_trae_lo_necesario_para_decidir_sin_abrir_el_jsonl(dataset_temporal):
|
| 63 |
+
hoja = revision.generar_hoja(revision.cargar())
|
| 64 |
+
assert "canino" in hoja and "Mestizo" in hoja
|
| 65 |
+
assert "calc=14.2" in hoja
|
| 66 |
+
assert "Poliuria" in hoja
|
| 67 |
+
assert "- [ ] hipercalcemia maligna" in hoja
|
| 68 |
+
assert "- [ ] linfoma" in hoja
|
| 69 |
+
assert "requiere_derivacion = True" in hoja
|
| 70 |
+
assert "fuera_de_alcance = False" in hoja
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_la_hoja_incluye_lo_que_marca_el_motor_cuando_node_responde(dataset_temporal, monkeypatch):
|
| 74 |
+
monkeypatch.setattr(
|
| 75 |
+
revision, "_hallazgos_del_motor",
|
| 76 |
+
lambda caso: [{"clave": "calc", "direccion": "alto", "gravedad": "moderado"}],
|
| 77 |
+
)
|
| 78 |
+
assert "Marcados por el motor: calc alto/moderado" in revision.generar_hoja(revision.cargar())
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_sin_node_la_hoja_se_genera_igual(dataset_temporal):
|
| 82 |
+
assert "Marcados por el motor" not in revision.generar_hoja(revision.cargar())
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_un_caso_sin_diferenciales_propuestos_no_deja_la_lista_muda(dataset_temporal):
|
| 86 |
+
caso = {**CASO_PENDIENTE}
|
| 87 |
+
caso["esperado"] = {**CASO_PENDIENTE["esperado"], "diferenciales_aceptables": []}
|
| 88 |
+
assert "- [ ] (ninguno)" in revision.generar_hoja([caso])
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# --- Estado ---
|
| 92 |
+
|
| 93 |
+
def test_el_estado_cuenta_por_split_y_validacion(dataset_temporal):
|
| 94 |
+
texto = revision.estado(revision.cargar())
|
| 95 |
+
assert "Total: 2 casos" in texto
|
| 96 |
+
assert "dev: 2" in texto and "(validados 1)" in texto
|
| 97 |
+
assert "pendientes de validación: 1" in texto
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_el_split_por_defecto_es_dev():
|
| 101 |
+
# `cargar_casos('dev')` asume este mismo valor por defecto: si divergieran, un caso sin
|
| 102 |
+
# `split` contaría en un sitio y no en el otro.
|
| 103 |
+
assert "dev: 1" in revision.estado([{"id": "x", "validado": True}])
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# --- Firma ---
|
| 107 |
+
|
| 108 |
+
def test_firmar_un_caso_lo_marca_con_revisor_y_fecha(dataset_temporal, monkeypatch, capsys):
|
| 109 |
+
monkeypatch.setattr("sys.argv", ["revision.py", "--validar", "pendiente-x", "--revisor", "Dra. P"])
|
| 110 |
+
assert revision.main() == 0
|
| 111 |
+
guardado = {c["id"]: c for c in revision.cargar()}
|
| 112 |
+
assert guardado["pendiente-x"]["validado"] is True
|
| 113 |
+
assert guardado["pendiente-x"]["revisor"] == "Dra. P"
|
| 114 |
+
assert guardado["pendiente-x"]["fecha_validacion"]
|
| 115 |
+
assert guardado["validado-y"] == CASO_VALIDADO # no se toca lo que no se firmó
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_firmar_sin_revisor_se_rechaza(dataset_temporal, monkeypatch):
|
| 119 |
+
monkeypatch.setattr("sys.argv", ["revision.py", "--validar", "pendiente-x"])
|
| 120 |
+
# La validación tiene que ser trazable a una persona: sin nombre no hay firma.
|
| 121 |
+
assert revision.main() == 1
|
| 122 |
+
assert revision.cargar()[0]["validado"] is False
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_firmar_un_id_inexistente_no_escribe_nada(dataset_temporal, monkeypatch):
|
| 126 |
+
antes = dataset_temporal.read_text(encoding="utf-8")
|
| 127 |
+
monkeypatch.setattr("sys.argv", ["revision.py", "--validar", "no-existe", "--revisor", "X"])
|
| 128 |
+
assert revision.main() == 1
|
| 129 |
+
assert dataset_temporal.read_text(encoding="utf-8") == antes
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# --- Integridad del dataset real ---
|
| 133 |
+
|
| 134 |
+
CLAVES_ESPERADO = {
|
| 135 |
+
"hallazgos_clave", "diferenciales_aceptables", "requiere_derivacion", "fuera_de_alcance",
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_los_ids_del_dataset_son_unicos():
|
| 140 |
+
ids = [c["id"] for c in cargar_casos("todos")]
|
| 141 |
+
assert len(ids) == len(set(ids))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@pytest.mark.parametrize("caso", cargar_casos("todos"), ids=lambda c: c["id"])
|
| 145 |
+
def test_cada_caso_cumple_el_esquema_que_asume_el_runner(caso):
|
| 146 |
+
assert caso["split"] in ("dev", "test")
|
| 147 |
+
assert caso["paciente"]["especie"] in ("canino", "felino")
|
| 148 |
+
assert caso["valores"], "un caso sin valores no ejercita el motor"
|
| 149 |
+
assert CLAVES_ESPERADO <= set(caso["esperado"])
|
| 150 |
+
assert isinstance(caso["esperado"]["hallazgos_clave"], list)
|
| 151 |
+
assert isinstance(caso["esperado"]["diferenciales_aceptables"], list)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@pytest.mark.parametrize("caso", cargar_casos("todos"), ids=lambda c: c["id"])
|
| 155 |
+
def test_los_hallazgos_esperados_existen_en_los_valores_de_referencia(caso):
|
| 156 |
+
"""Un `hallazgos_clave` con una clave que no está en `valores_referencia.json` haría que
|
| 157 |
+
la cobertura fuera inalcanzable para cualquier modelo."""
|
| 158 |
+
import json as _json
|
| 159 |
+
from pathlib import Path
|
| 160 |
+
|
| 161 |
+
raiz = Path(revision.__file__).resolve().parents[1]
|
| 162 |
+
referencia = _json.loads((raiz / "data" / "valores_referencia.json").read_text(encoding="utf-8"))
|
| 163 |
+
conocidas = set(referencia[caso["paciente"]["especie"]])
|
| 164 |
+
assert set(caso["esperado"]["hallazgos_clave"]) <= conocidas
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@pytest.mark.parametrize("caso", cargar_casos("todos"), ids=lambda c: c["id"])
|
| 168 |
+
def test_un_caso_validado_lleva_firma(caso):
|
| 169 |
+
if caso.get("validado"):
|
| 170 |
+
assert caso.get("revisor"), "validado sin revisor: la firma no es trazable"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_un_caso_fuera_de_alcance_siempre_requiere_derivacion():
|
| 174 |
+
for caso in cargar_casos("todos"):
|
| 175 |
+
if caso["esperado"]["fuera_de_alcance"]:
|
| 176 |
+
# Si no, `violacion_seguridad` sería inalcanzable: el caso se aprobaría declinando
|
| 177 |
+
# sin derivar, que es justo lo que la métrica de seguridad persigue.
|
| 178 |
+
assert caso["esperado"]["requiere_derivacion"], caso["id"]
|
frontend/package-lock.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
| 13 |
"@typescript-eslint/parser": "^8.20.0",
|
| 14 |
"eslint": "^9.18.0",
|
| 15 |
"jsdom": "^29.1.1",
|
|
|
|
| 16 |
"typescript": "^5.7.3",
|
| 17 |
"vite": "^6.0.7",
|
| 18 |
"vitest": "^3.0.2"
|
|
@@ -958,6 +959,283 @@
|
|
| 958 |
"dev": true,
|
| 959 |
"license": "MIT"
|
| 960 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 962 |
"version": "4.62.2",
|
| 963 |
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
|
@@ -2898,6 +3176,19 @@
|
|
| 2898 |
"node": ">= 14.16"
|
| 2899 |
}
|
| 2900 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2901 |
"node_modules/picocolors": {
|
| 2902 |
"version": "1.1.1",
|
| 2903 |
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
|
|
| 13 |
"@typescript-eslint/parser": "^8.20.0",
|
| 14 |
"eslint": "^9.18.0",
|
| 15 |
"jsdom": "^29.1.1",
|
| 16 |
+
"pdfjs-dist": "^6.2.108",
|
| 17 |
"typescript": "^5.7.3",
|
| 18 |
"vite": "^6.0.7",
|
| 19 |
"vitest": "^3.0.2"
|
|
|
|
| 959 |
"dev": true,
|
| 960 |
"license": "MIT"
|
| 961 |
},
|
| 962 |
+
"node_modules/@napi-rs/canvas": {
|
| 963 |
+
"version": "1.0.3",
|
| 964 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz",
|
| 965 |
+
"integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==",
|
| 966 |
+
"dev": true,
|
| 967 |
+
"license": "MIT",
|
| 968 |
+
"optional": true,
|
| 969 |
+
"workspaces": [
|
| 970 |
+
"e2e/*"
|
| 971 |
+
],
|
| 972 |
+
"engines": {
|
| 973 |
+
"node": ">= 10"
|
| 974 |
+
},
|
| 975 |
+
"funding": {
|
| 976 |
+
"type": "github",
|
| 977 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 978 |
+
},
|
| 979 |
+
"optionalDependencies": {
|
| 980 |
+
"@napi-rs/canvas-android-arm64": "1.0.3",
|
| 981 |
+
"@napi-rs/canvas-darwin-arm64": "1.0.3",
|
| 982 |
+
"@napi-rs/canvas-darwin-x64": "1.0.3",
|
| 983 |
+
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3",
|
| 984 |
+
"@napi-rs/canvas-linux-arm64-gnu": "1.0.3",
|
| 985 |
+
"@napi-rs/canvas-linux-arm64-musl": "1.0.3",
|
| 986 |
+
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.3",
|
| 987 |
+
"@napi-rs/canvas-linux-x64-gnu": "1.0.3",
|
| 988 |
+
"@napi-rs/canvas-linux-x64-musl": "1.0.3",
|
| 989 |
+
"@napi-rs/canvas-win32-arm64-msvc": "1.0.3",
|
| 990 |
+
"@napi-rs/canvas-win32-x64-msvc": "1.0.3"
|
| 991 |
+
}
|
| 992 |
+
},
|
| 993 |
+
"node_modules/@napi-rs/canvas-android-arm64": {
|
| 994 |
+
"version": "1.0.3",
|
| 995 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz",
|
| 996 |
+
"integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==",
|
| 997 |
+
"cpu": [
|
| 998 |
+
"arm64"
|
| 999 |
+
],
|
| 1000 |
+
"dev": true,
|
| 1001 |
+
"license": "MIT",
|
| 1002 |
+
"optional": true,
|
| 1003 |
+
"os": [
|
| 1004 |
+
"android"
|
| 1005 |
+
],
|
| 1006 |
+
"engines": {
|
| 1007 |
+
"node": ">= 10"
|
| 1008 |
+
},
|
| 1009 |
+
"funding": {
|
| 1010 |
+
"type": "github",
|
| 1011 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1012 |
+
}
|
| 1013 |
+
},
|
| 1014 |
+
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
| 1015 |
+
"version": "1.0.3",
|
| 1016 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz",
|
| 1017 |
+
"integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==",
|
| 1018 |
+
"cpu": [
|
| 1019 |
+
"arm64"
|
| 1020 |
+
],
|
| 1021 |
+
"dev": true,
|
| 1022 |
+
"license": "MIT",
|
| 1023 |
+
"optional": true,
|
| 1024 |
+
"os": [
|
| 1025 |
+
"darwin"
|
| 1026 |
+
],
|
| 1027 |
+
"engines": {
|
| 1028 |
+
"node": ">= 10"
|
| 1029 |
+
},
|
| 1030 |
+
"funding": {
|
| 1031 |
+
"type": "github",
|
| 1032 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1033 |
+
}
|
| 1034 |
+
},
|
| 1035 |
+
"node_modules/@napi-rs/canvas-darwin-x64": {
|
| 1036 |
+
"version": "1.0.3",
|
| 1037 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz",
|
| 1038 |
+
"integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==",
|
| 1039 |
+
"cpu": [
|
| 1040 |
+
"x64"
|
| 1041 |
+
],
|
| 1042 |
+
"dev": true,
|
| 1043 |
+
"license": "MIT",
|
| 1044 |
+
"optional": true,
|
| 1045 |
+
"os": [
|
| 1046 |
+
"darwin"
|
| 1047 |
+
],
|
| 1048 |
+
"engines": {
|
| 1049 |
+
"node": ">= 10"
|
| 1050 |
+
},
|
| 1051 |
+
"funding": {
|
| 1052 |
+
"type": "github",
|
| 1053 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1054 |
+
}
|
| 1055 |
+
},
|
| 1056 |
+
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
| 1057 |
+
"version": "1.0.3",
|
| 1058 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz",
|
| 1059 |
+
"integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==",
|
| 1060 |
+
"cpu": [
|
| 1061 |
+
"arm"
|
| 1062 |
+
],
|
| 1063 |
+
"dev": true,
|
| 1064 |
+
"license": "MIT",
|
| 1065 |
+
"optional": true,
|
| 1066 |
+
"os": [
|
| 1067 |
+
"linux"
|
| 1068 |
+
],
|
| 1069 |
+
"engines": {
|
| 1070 |
+
"node": ">= 10"
|
| 1071 |
+
},
|
| 1072 |
+
"funding": {
|
| 1073 |
+
"type": "github",
|
| 1074 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1075 |
+
}
|
| 1076 |
+
},
|
| 1077 |
+
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
| 1078 |
+
"version": "1.0.3",
|
| 1079 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz",
|
| 1080 |
+
"integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==",
|
| 1081 |
+
"cpu": [
|
| 1082 |
+
"arm64"
|
| 1083 |
+
],
|
| 1084 |
+
"dev": true,
|
| 1085 |
+
"libc": [
|
| 1086 |
+
"glibc"
|
| 1087 |
+
],
|
| 1088 |
+
"license": "MIT",
|
| 1089 |
+
"optional": true,
|
| 1090 |
+
"os": [
|
| 1091 |
+
"linux"
|
| 1092 |
+
],
|
| 1093 |
+
"engines": {
|
| 1094 |
+
"node": ">= 10"
|
| 1095 |
+
},
|
| 1096 |
+
"funding": {
|
| 1097 |
+
"type": "github",
|
| 1098 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1099 |
+
}
|
| 1100 |
+
},
|
| 1101 |
+
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
| 1102 |
+
"version": "1.0.3",
|
| 1103 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz",
|
| 1104 |
+
"integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==",
|
| 1105 |
+
"cpu": [
|
| 1106 |
+
"arm64"
|
| 1107 |
+
],
|
| 1108 |
+
"dev": true,
|
| 1109 |
+
"libc": [
|
| 1110 |
+
"musl"
|
| 1111 |
+
],
|
| 1112 |
+
"license": "MIT",
|
| 1113 |
+
"optional": true,
|
| 1114 |
+
"os": [
|
| 1115 |
+
"linux"
|
| 1116 |
+
],
|
| 1117 |
+
"engines": {
|
| 1118 |
+
"node": ">= 10"
|
| 1119 |
+
},
|
| 1120 |
+
"funding": {
|
| 1121 |
+
"type": "github",
|
| 1122 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1123 |
+
}
|
| 1124 |
+
},
|
| 1125 |
+
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
| 1126 |
+
"version": "1.0.3",
|
| 1127 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz",
|
| 1128 |
+
"integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==",
|
| 1129 |
+
"cpu": [
|
| 1130 |
+
"riscv64"
|
| 1131 |
+
],
|
| 1132 |
+
"dev": true,
|
| 1133 |
+
"libc": [
|
| 1134 |
+
"glibc"
|
| 1135 |
+
],
|
| 1136 |
+
"license": "MIT",
|
| 1137 |
+
"optional": true,
|
| 1138 |
+
"os": [
|
| 1139 |
+
"linux"
|
| 1140 |
+
],
|
| 1141 |
+
"engines": {
|
| 1142 |
+
"node": ">= 10"
|
| 1143 |
+
},
|
| 1144 |
+
"funding": {
|
| 1145 |
+
"type": "github",
|
| 1146 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1147 |
+
}
|
| 1148 |
+
},
|
| 1149 |
+
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
| 1150 |
+
"version": "1.0.3",
|
| 1151 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz",
|
| 1152 |
+
"integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==",
|
| 1153 |
+
"cpu": [
|
| 1154 |
+
"x64"
|
| 1155 |
+
],
|
| 1156 |
+
"dev": true,
|
| 1157 |
+
"libc": [
|
| 1158 |
+
"glibc"
|
| 1159 |
+
],
|
| 1160 |
+
"license": "MIT",
|
| 1161 |
+
"optional": true,
|
| 1162 |
+
"os": [
|
| 1163 |
+
"linux"
|
| 1164 |
+
],
|
| 1165 |
+
"engines": {
|
| 1166 |
+
"node": ">= 10"
|
| 1167 |
+
},
|
| 1168 |
+
"funding": {
|
| 1169 |
+
"type": "github",
|
| 1170 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1171 |
+
}
|
| 1172 |
+
},
|
| 1173 |
+
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
| 1174 |
+
"version": "1.0.3",
|
| 1175 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz",
|
| 1176 |
+
"integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==",
|
| 1177 |
+
"cpu": [
|
| 1178 |
+
"x64"
|
| 1179 |
+
],
|
| 1180 |
+
"dev": true,
|
| 1181 |
+
"libc": [
|
| 1182 |
+
"musl"
|
| 1183 |
+
],
|
| 1184 |
+
"license": "MIT",
|
| 1185 |
+
"optional": true,
|
| 1186 |
+
"os": [
|
| 1187 |
+
"linux"
|
| 1188 |
+
],
|
| 1189 |
+
"engines": {
|
| 1190 |
+
"node": ">= 10"
|
| 1191 |
+
},
|
| 1192 |
+
"funding": {
|
| 1193 |
+
"type": "github",
|
| 1194 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1195 |
+
}
|
| 1196 |
+
},
|
| 1197 |
+
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
| 1198 |
+
"version": "1.0.3",
|
| 1199 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz",
|
| 1200 |
+
"integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==",
|
| 1201 |
+
"cpu": [
|
| 1202 |
+
"arm64"
|
| 1203 |
+
],
|
| 1204 |
+
"dev": true,
|
| 1205 |
+
"license": "MIT",
|
| 1206 |
+
"optional": true,
|
| 1207 |
+
"os": [
|
| 1208 |
+
"win32"
|
| 1209 |
+
],
|
| 1210 |
+
"engines": {
|
| 1211 |
+
"node": ">= 10"
|
| 1212 |
+
},
|
| 1213 |
+
"funding": {
|
| 1214 |
+
"type": "github",
|
| 1215 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1216 |
+
}
|
| 1217 |
+
},
|
| 1218 |
+
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
| 1219 |
+
"version": "1.0.3",
|
| 1220 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz",
|
| 1221 |
+
"integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==",
|
| 1222 |
+
"cpu": [
|
| 1223 |
+
"x64"
|
| 1224 |
+
],
|
| 1225 |
+
"dev": true,
|
| 1226 |
+
"license": "MIT",
|
| 1227 |
+
"optional": true,
|
| 1228 |
+
"os": [
|
| 1229 |
+
"win32"
|
| 1230 |
+
],
|
| 1231 |
+
"engines": {
|
| 1232 |
+
"node": ">= 10"
|
| 1233 |
+
},
|
| 1234 |
+
"funding": {
|
| 1235 |
+
"type": "github",
|
| 1236 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 1237 |
+
}
|
| 1238 |
+
},
|
| 1239 |
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 1240 |
"version": "4.62.2",
|
| 1241 |
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
|
|
|
| 3176 |
"node": ">= 14.16"
|
| 3177 |
}
|
| 3178 |
},
|
| 3179 |
+
"node_modules/pdfjs-dist": {
|
| 3180 |
+
"version": "6.2.108",
|
| 3181 |
+
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
|
| 3182 |
+
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
|
| 3183 |
+
"dev": true,
|
| 3184 |
+
"license": "Apache-2.0",
|
| 3185 |
+
"engines": {
|
| 3186 |
+
"node": ">=22.13.0 || >=24"
|
| 3187 |
+
},
|
| 3188 |
+
"optionalDependencies": {
|
| 3189 |
+
"@napi-rs/canvas": "^1.0.0"
|
| 3190 |
+
}
|
| 3191 |
+
},
|
| 3192 |
"node_modules/picocolors": {
|
| 3193 |
"version": "1.1.1",
|
| 3194 |
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
frontend/package.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
"dev": "vite",
|
| 9 |
"build": "tsc --noEmit && vite build && npm run copy-assets",
|
| 10 |
"copy-assets": "mkdir -p ../dist/assets && cp -R ../assets/. ../dist/assets/",
|
|
|
|
| 11 |
"preview": "vite preview",
|
| 12 |
"test": "vitest run",
|
| 13 |
"test:watch": "vitest",
|
|
@@ -20,6 +21,7 @@
|
|
| 20 |
"@typescript-eslint/parser": "^8.20.0",
|
| 21 |
"eslint": "^9.18.0",
|
| 22 |
"jsdom": "^29.1.1",
|
|
|
|
| 23 |
"typescript": "^5.7.3",
|
| 24 |
"vite": "^6.0.7",
|
| 25 |
"vitest": "^3.0.2"
|
|
|
|
| 8 |
"dev": "vite",
|
| 9 |
"build": "tsc --noEmit && vite build && npm run copy-assets",
|
| 10 |
"copy-assets": "mkdir -p ../dist/assets && cp -R ../assets/. ../dist/assets/",
|
| 11 |
+
"vendor-pdfjs": "cp node_modules/pdfjs-dist/build/pdf.min.mjs node_modules/pdfjs-dist/build/pdf.worker.min.mjs ../assets/lib/pdfjs/",
|
| 12 |
"preview": "vite preview",
|
| 13 |
"test": "vitest run",
|
| 14 |
"test:watch": "vitest",
|
|
|
|
| 21 |
"@typescript-eslint/parser": "^8.20.0",
|
| 22 |
"eslint": "^9.18.0",
|
| 23 |
"jsdom": "^29.1.1",
|
| 24 |
+
"pdfjs-dist": "^6.2.108",
|
| 25 |
"typescript": "^5.7.3",
|
| 26 |
"vite": "^6.0.7",
|
| 27 |
"vitest": "^3.0.2"
|
frontend/src/dom.ts
CHANGED
|
@@ -19,3 +19,10 @@ export function qs<T extends Element = Element>(sel: string, root: ParentNode =
|
|
| 19 |
export function qsa<T extends Element = Element>(sel: string, root: ParentNode = document): T[] {
|
| 20 |
return Array.from(root.querySelectorAll<T>(sel));
|
| 21 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
export function qsa<T extends Element = Element>(sel: string, root: ParentNode = document): T[] {
|
| 20 |
return Array.from(root.querySelectorAll<T>(sel));
|
| 21 |
}
|
| 22 |
+
|
| 23 |
+
// Punto de corte del grid de escritorio. Vive aquí, en el módulo hoja, porque lo consultan
|
| 24 |
+
// varios módulos que no deben depender unos de otros, y debe coincidir con la media query
|
| 25 |
+
// `min-width: 1101px` de css/styles.css.
|
| 26 |
+
export function esGridEscritorio(): boolean {
|
| 27 |
+
return window.innerWidth > 1100;
|
| 28 |
+
}
|
frontend/src/form-inject.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
// y el de analizadores (lab-import.ts). La clave de unión es el atributo `name` del input
|
| 3 |
// (== clave canónica de valores_referencia.json). Fuente única para no duplicar la lógica.
|
| 4 |
|
|
|
|
|
|
|
| 5 |
export type ValoresInyectables = Record<string, number | string>;
|
| 6 |
|
| 7 |
export interface PacienteInyectable {
|
|
@@ -32,6 +34,8 @@ export function aplicarValoresAFormulario(
|
|
| 32 |
for (const [campo, value] of Object.entries(resultados)) {
|
| 33 |
const el = document.querySelector(`[name="${campo}"]`) as HTMLInputElement | HTMLSelectElement | null;
|
| 34 |
if (!el) continue;
|
|
|
|
|
|
|
| 35 |
const valorCadena = String(value);
|
| 36 |
if (el.tagName === 'SELECT') {
|
| 37 |
const select = el as HTMLSelectElement;
|
|
|
|
| 2 |
// y el de analizadores (lab-import.ts). La clave de unión es el atributo `name` del input
|
| 3 |
// (== clave canónica de valores_referencia.json). Fuente única para no duplicar la lógica.
|
| 4 |
|
| 5 |
+
import { revelarPanelDeCampo } from './panel-vacio.js';
|
| 6 |
+
|
| 7 |
export type ValoresInyectables = Record<string, number | string>;
|
| 8 |
|
| 9 |
export interface PacienteInyectable {
|
|
|
|
| 34 |
for (const [campo, value] of Object.entries(resultados)) {
|
| 35 |
const el = document.querySelector(`[name="${campo}"]`) as HTMLInputElement | HTMLSelectElement | null;
|
| 36 |
if (!el) continue;
|
| 37 |
+
// Un valor importado a un panel en estado vacío quedaría escondido tras la zona de arrastre.
|
| 38 |
+
revelarPanelDeCampo(el);
|
| 39 |
const valorCadena = String(value);
|
| 40 |
if (el.tagName === 'SELECT') {
|
| 41 |
const select = el as HTMLSelectElement;
|
frontend/src/ia.ts
CHANGED
|
@@ -183,7 +183,7 @@ function renderizar(resp: RespuestaInterpretacion): string {
|
|
| 183 |
|
| 184 |
export async function llamarIA(
|
| 185 |
obtenerDatosPaciente: () => Paciente,
|
| 186 |
-
getUltimoAnalisis: () => { hallazgos: Hallazgo[]; patrones: Patron[] },
|
| 187 |
getImagenes: () => string[],
|
| 188 |
): Promise<void> {
|
| 189 |
const salidaEl = document.getElementById('salida-ia');
|
|
@@ -191,7 +191,7 @@ export async function llamarIA(
|
|
| 191 |
|
| 192 |
const backend = (localStorage.getItem(BACKEND_KEY) ?? 'medgemma') === 'claude' ? 'claude' : 'medgemma';
|
| 193 |
const paciente = obtenerDatosPaciente();
|
| 194 |
-
const { hallazgos, patrones } = getUltimoAnalisis();
|
| 195 |
const signos = (document.getElementById('signos-clinicos') as HTMLTextAreaElement | null)?.value.trim() ?? '';
|
| 196 |
|
| 197 |
salidaEl.textContent = 'Consultando al modelo de I.A…';
|
|
@@ -206,6 +206,9 @@ export async function llamarIA(
|
|
| 206 |
},
|
| 207 |
hallazgos,
|
| 208 |
patrones,
|
|
|
|
|
|
|
|
|
|
| 209 |
signos_clinicos: signos,
|
| 210 |
imagenes: getImagenes().slice(0, 4),
|
| 211 |
backend,
|
|
|
|
| 183 |
|
| 184 |
export async function llamarIA(
|
| 185 |
obtenerDatosPaciente: () => Paciente,
|
| 186 |
+
getUltimoAnalisis: () => { hallazgos: Hallazgo[]; patrones: Patron[]; medidos?: string[] },
|
| 187 |
getImagenes: () => string[],
|
| 188 |
): Promise<void> {
|
| 189 |
const salidaEl = document.getElementById('salida-ia');
|
|
|
|
| 191 |
|
| 192 |
const backend = (localStorage.getItem(BACKEND_KEY) ?? 'medgemma') === 'claude' ? 'claude' : 'medgemma';
|
| 193 |
const paciente = obtenerDatosPaciente();
|
| 194 |
+
const { hallazgos, patrones, medidos } = getUltimoAnalisis();
|
| 195 |
const signos = (document.getElementById('signos-clinicos') as HTMLTextAreaElement | null)?.value.trim() ?? '';
|
| 196 |
|
| 197 |
salidaEl.textContent = 'Consultando al modelo de I.A…';
|
|
|
|
| 206 |
},
|
| 207 |
hallazgos,
|
| 208 |
patrones,
|
| 209 |
+
// El panel COMPLETO, no sólo lo alterado: es lo que le permite al modelo saber que un
|
| 210 |
+
// analito ausente no se ha medido y que uno presente pero sin hallazgo salió en rango.
|
| 211 |
+
analitos_medidos: medidos ?? [],
|
| 212 |
signos_clinicos: signos,
|
| 213 |
imagenes: getImagenes().slice(0, 4),
|
| 214 |
backend,
|
frontend/src/lab-import.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
| 13 |
type PacienteInyectable,
|
| 14 |
} from './form-inject.js';
|
| 15 |
import { manejadorAsync } from './async.js';
|
|
|
|
| 16 |
|
| 17 |
interface ValorAnalitoResp {
|
| 18 |
clave: string;
|
|
@@ -191,7 +192,7 @@ function inicializarModalLab(detenerBusqueda: () => void): void {
|
|
| 191 |
const anclaje = document.getElementById('lab-anclaje-mob');
|
| 192 |
if (!btn || !modal || !overlay || !cuerpo || !controles || !anclaje) return;
|
| 193 |
|
| 194 |
-
const esEscritorio =
|
| 195 |
|
| 196 |
// Idempotente: si ya está donde toca no toca el DOM (se llama en cada `resize`).
|
| 197 |
const ubicar = (): void => {
|
|
@@ -297,6 +298,13 @@ function inicializarCola(
|
|
| 297 |
mostrarToast('Inicia sesión para ver los resultados recibidos.', true);
|
| 298 |
return;
|
| 299 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
if (!resp.ok) {
|
| 301 |
mostrarToast('Error al consultar los resultados recibidos.', true);
|
| 302 |
return;
|
|
|
|
| 13 |
type PacienteInyectable,
|
| 14 |
} from './form-inject.js';
|
| 15 |
import { manejadorAsync } from './async.js';
|
| 16 |
+
import { esGridEscritorio } from './dom.js';
|
| 17 |
|
| 18 |
interface ValorAnalitoResp {
|
| 19 |
clave: string;
|
|
|
|
| 192 |
const anclaje = document.getElementById('lab-anclaje-mob');
|
| 193 |
if (!btn || !modal || !overlay || !cuerpo || !controles || !anclaje) return;
|
| 194 |
|
| 195 |
+
const esEscritorio = esGridEscritorio;
|
| 196 |
|
| 197 |
// Idempotente: si ya está donde toca no toca el DOM (se llama en cada `resize`).
|
| 198 |
const ubicar = (): void => {
|
|
|
|
| 298 |
mostrarToast('Inicia sesión para ver los resultados recibidos.', true);
|
| 299 |
return;
|
| 300 |
}
|
| 301 |
+
// 404 = la cola está desactivada en el servidor (MORPHOS_LAB_PENDIENTES_HABILITADO).
|
| 302 |
+
// No es un error del usuario: se retira el botón en vez de dejarlo fallando.
|
| 303 |
+
if (resp.status === 404) {
|
| 304 |
+
btn.hidden = true;
|
| 305 |
+
lista.hidden = true;
|
| 306 |
+
return;
|
| 307 |
+
}
|
| 308 |
if (!resp.ok) {
|
| 309 |
mostrarToast('Error al consultar los resultados recibidos.', true);
|
| 310 |
return;
|
frontend/src/main.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { analizarResultados } from './analisis.js';
|
|
| 6 |
import { colapsarPatrones, inicializarSincMob, imagenesDataUrl, capturasMicroscopio } from './ui.js';
|
| 7 |
import { llamarIA, inicializarConfigBackend } from './ia.js';
|
| 8 |
import { inicializarParserPdf } from './pdf-parser.js';
|
|
|
|
| 9 |
import { inicializarImportLab } from './lab-import.js';
|
| 10 |
import { verificarAuth, abrirModalAuth } from './auth.js';
|
| 11 |
import { abrirModalPapers, inicializarModalPapers } from './papers.js';
|
|
@@ -33,6 +34,7 @@ if (btnTema) {
|
|
| 33 |
let referencias: Referencias = {};
|
| 34 |
let alteraciones: Alteraciones = {};
|
| 35 |
let ultimoAnalisis: ResultadoAnalisis = { hallazgos: [], patrones: [] };
|
|
|
|
| 36 |
|
| 37 |
const cargarReferencias = async (): Promise<void> => {
|
| 38 |
try {
|
|
@@ -144,6 +146,9 @@ const evaluar = (): void => {
|
|
| 144 |
const valores = obtenerValoresFormulario();
|
| 145 |
const { hallazgos, patrones } = analizarResultados(valores, paciente, referencias, alteraciones);
|
| 146 |
ultimoAnalisis = { hallazgos, patrones };
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
actualizarClasesInputs(hallazgos);
|
| 149 |
renderizarPatrones(patrones);
|
|
@@ -170,6 +175,7 @@ elId<HTMLSelectElement>('pt-sexo').addEventListener('change', evaluar);
|
|
| 170 |
|
| 171 |
inicializarSincMob(evaluar);
|
| 172 |
void inicializarConfigBackend(); // pide la lista de modelos al servidor; no bloquea el arranque
|
|
|
|
| 173 |
inicializarParserPdf(evaluar);
|
| 174 |
inicializarImportLab(evaluar);
|
| 175 |
inicializarModalPapers();
|
|
@@ -202,7 +208,10 @@ const imagenesActuales = (): string[] =>
|
|
| 202 |
const dispararIA = (): void => {
|
| 203 |
colapsarPatrones(true);
|
| 204 |
// Se encola desde un callback síncrono (abrirModalAuth), así que no se puede await aquí.
|
| 205 |
-
sinEsperar(
|
|
|
|
|
|
|
|
|
|
| 206 |
};
|
| 207 |
|
| 208 |
const botonAnalizar = document.querySelector<HTMLElement>('.boton-analizar');
|
|
|
|
| 6 |
import { colapsarPatrones, inicializarSincMob, imagenesDataUrl, capturasMicroscopio } from './ui.js';
|
| 7 |
import { llamarIA, inicializarConfigBackend } from './ia.js';
|
| 8 |
import { inicializarParserPdf } from './pdf-parser.js';
|
| 9 |
+
import { inicializarPanelesVacios } from './panel-vacio.js';
|
| 10 |
import { inicializarImportLab } from './lab-import.js';
|
| 11 |
import { verificarAuth, abrirModalAuth } from './auth.js';
|
| 12 |
import { abrirModalPapers, inicializarModalPapers } from './papers.js';
|
|
|
|
| 34 |
let referencias: Referencias = {};
|
| 35 |
let alteraciones: Alteraciones = {};
|
| 36 |
let ultimoAnalisis: ResultadoAnalisis = { hallazgos: [], patrones: [] };
|
| 37 |
+
let ultimosMedidos: string[] = [];
|
| 38 |
|
| 39 |
const cargarReferencias = async (): Promise<void> => {
|
| 40 |
try {
|
|
|
|
| 146 |
const valores = obtenerValoresFormulario();
|
| 147 |
const { hallazgos, patrones } = analizarResultados(valores, paciente, referencias, alteraciones);
|
| 148 |
ultimoAnalisis = { hallazgos, patrones };
|
| 149 |
+
// Los medidos van aparte y NO dentro de ResultadoAnalisis: eso es la salida del motor, que
|
| 150 |
+
// sólo devuelve lo alterado. Aquí interesa el panel completo, incluidos los valores en rango.
|
| 151 |
+
ultimosMedidos = Object.keys(valores);
|
| 152 |
|
| 153 |
actualizarClasesInputs(hallazgos);
|
| 154 |
renderizarPatrones(patrones);
|
|
|
|
| 175 |
|
| 176 |
inicializarSincMob(evaluar);
|
| 177 |
void inicializarConfigBackend(); // pide la lista de modelos al servidor; no bloquea el arranque
|
| 178 |
+
inicializarPanelesVacios();
|
| 179 |
inicializarParserPdf(evaluar);
|
| 180 |
inicializarImportLab(evaluar);
|
| 181 |
inicializarModalPapers();
|
|
|
|
| 208 |
const dispararIA = (): void => {
|
| 209 |
colapsarPatrones(true);
|
| 210 |
// Se encola desde un callback síncrono (abrirModalAuth), así que no se puede await aquí.
|
| 211 |
+
sinEsperar(
|
| 212 |
+
'Análisis IA',
|
| 213 |
+
llamarIA(obtenerDatosPaciente, () => ({ ...ultimoAnalisis, medidos: ultimosMedidos }), imagenesActuales),
|
| 214 |
+
);
|
| 215 |
};
|
| 216 |
|
| 217 |
const botonAnalizar = document.querySelector<HTMLElement>('.boton-analizar');
|
frontend/src/panel-vacio.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Estado vacío de los paneles de exámenes (sólo escritorio).
|
| 2 |
+
//
|
| 3 |
+
// Cada panel arranca con `.sin-datos`: en vez del formulario muestra una zona para soltar el
|
| 4 |
+
// PDF y un botón que devuelve al modo de captura manual. La entrada manual no desaparece, sólo
|
| 5 |
+
// deja de ser lo primero que se ve: en la mayoría de los casos el veterinario llega con un PDF
|
| 6 |
+
// del laboratorio, y teclear 90 analitos a mano es la excepción.
|
| 7 |
+
//
|
| 8 |
+
// El estado vive en una clase del <section>, no en JS, para que el CSS decida cuándo aplica
|
| 9 |
+
// (el móvil lo ignora entero: allí los paneles son pestañas y el formulario va directo).
|
| 10 |
+
|
| 11 |
+
// Sólo depende de `dom.ts` a propósito: `form-inject.ts` importa este módulo, y form-inject es
|
| 12 |
+
// la base compartida de los dos importadores. Colgar de aquí a `ui.ts` —que toca el DOM al
|
| 13 |
+
// cargarse— arrastraría esa dependencia hasta ellos y hasta sus tests.
|
| 14 |
+
import { esGridEscritorio } from './dom.js';
|
| 15 |
+
|
| 16 |
+
// Saca un panel del estado vacío. Idempotente: se llama tanto desde el botón como desde cada
|
| 17 |
+
// valor inyectado por los importadores, que pueden ser decenas en una sola importación.
|
| 18 |
+
export function revelarPanel(panel: HTMLElement | null): void {
|
| 19 |
+
if (!panel || !panel.classList.contains('sin-datos')) return;
|
| 20 |
+
panel.classList.remove('sin-datos');
|
| 21 |
+
reajustarAltura(panel);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Los cuatro paneles colapsables llevan la altura de su animación fijada en píxeles inline (ver
|
| 25 |
+
// ui.ts); sin reajustarla al cambiar el contenido, el formulario recién mostrado sale recortado.
|
| 26 |
+
// No toca los colapsados —su altura debe seguir siendo 0— ni el móvil, donde no hay animación.
|
| 27 |
+
function reajustarAltura(panel: HTMLElement): void {
|
| 28 |
+
if (!esGridEscritorio() || panel.classList.contains('collapsed')) return;
|
| 29 |
+
const animEl = panel.querySelector<HTMLElement>('.subpanel-anim');
|
| 30 |
+
if (!animEl || !animEl.style.height) return;
|
| 31 |
+
animEl.style.height = `${animEl.scrollHeight}px`;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Revela el panel al que pertenece un campo. La usan los importadores (PDF y analizador) para
|
| 35 |
+
// que un valor inyectado nunca quede escondido detrás del estado vacío.
|
| 36 |
+
export function revelarPanelDeCampo(el: Element): void {
|
| 37 |
+
revelarPanel(el.closest<HTMLElement>('.subpanel'));
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function inicializarPanelesVacios(): void {
|
| 41 |
+
document.querySelectorAll<HTMLElement>('.panel-vacio-manual').forEach((btn) => {
|
| 42 |
+
btn.addEventListener('click', () => {
|
| 43 |
+
const panel = btn.closest<HTMLElement>('.subpanel');
|
| 44 |
+
revelarPanel(panel);
|
| 45 |
+
// El foco salta al primer campo: quien pulsa "manual" viene a teclear.
|
| 46 |
+
panel?.querySelector<HTMLInputElement>('.fila-campo input, .fila-campo select')?.focus();
|
| 47 |
+
});
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
document.querySelectorAll<HTMLElement>('.panel-vacio-explorar').forEach((btn) => {
|
| 51 |
+
btn.addEventListener('click', () => {
|
| 52 |
+
(document.getElementById(`pdf-input-${btn.dataset.panel}`) as HTMLInputElement | null)?.click();
|
| 53 |
+
});
|
| 54 |
+
});
|
| 55 |
+
}
|
frontend/src/pdf-parser.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
| 4 |
import { aplicarValoresAFormulario, aplicarPacienteAFormulario, mostrarToast } from './form-inject.js';
|
| 5 |
import { manejadorAsync } from './async.js';
|
| 6 |
|
| 7 |
-
const
|
|
|
|
| 8 |
|
| 9 |
interface AnalitoDef { campo: string; re: RegExp; claveConv?: string }
|
| 10 |
interface ConversionRegla { re: RegExp; factor: number | ((v: number) => number) }
|
|
@@ -12,14 +13,16 @@ type Resultados = Record<string, number | string>;
|
|
| 12 |
|
| 13 |
interface PdfjsLib {
|
| 14 |
GlobalWorkerOptions: { workerSrc: string };
|
| 15 |
-
getDocument(opts: { data: ArrayBuffer }): { promise: Promise<PdfDoc> };
|
| 16 |
}
|
| 17 |
interface PdfDoc { numPages: number; getPage(n: number): Promise<PdfPage> }
|
| 18 |
interface PdfPage { getTextContent(): Promise<{ items: Array<{ str: string; hasEOL?: boolean }> }> }
|
| 19 |
|
| 20 |
const DEFS_ANALITOS: AnalitoDef[] = [
|
| 21 |
// Hematología: Serie Roja
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
{ campo: 'hgb', re: /\b(?:hemoglobin[ao]?\w*|hgb|hb)\b(?!a\d)/i },
|
| 24 |
{ campo: 'hct', re: /\b(?:hematocrit[oo]?\w*|hct|pcv)\b/i },
|
| 25 |
{ campo: 'vcm', re: /\b(?:v\.?c\.?m\.?|m\.?c\.?v\.?|vol(?:umen)?\s+corp\w*)\b/i },
|
|
@@ -34,7 +37,10 @@ const DEFS_ANALITOS: AnalitoDef[] = [
|
|
| 34 |
// Hematología: Serie Blanca
|
| 35 |
{ campo: 'wbc', re: /\b(?:leucocit\w*|w\.?b\.?c\.?|white\s+blood\s+cell|leu)\b/i },
|
| 36 |
{ campo: 'neutro_abs', re: /\bgran?#/i },
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
{ campo: 'linfo_abs', re: /\blymp?#/i },
|
| 39 |
{ campo: 'linfo', re: /\b(?:linf[oa]cit\w*|lymph\w*|linf\b|lym(?!#))\b/i },
|
| 40 |
{ campo: 'mono_abs', re: /\bmon\w*#/i },
|
|
@@ -88,12 +94,13 @@ const DEFS_ANALITOS: AnalitoDef[] = [
|
|
| 88 |
|
| 89 |
// Bioquímica: Enzimas
|
| 90 |
{ campo: 'lipasa', re: /\b(?:lipas[ae]\b|lipa\b)\b/i },
|
| 91 |
-
|
|
|
|
| 92 |
|
| 93 |
// Perfil Endocrino
|
| 94 |
{ campo: 'cortisol_bas', re: /\b(?:cortisol\s+bas[ae]?l?)\b/i },
|
| 95 |
{ campo: 'cortisol_acth', re: /\b(?:cortisol\s+(?:post[-\s]?acth|post)\b)/i },
|
| 96 |
-
{ campo: 't4_total', re: /\b(?:t4\s+total|
|
| 97 |
{ campo: 'insulina', re: /\b(?:insulin[ao]?\w*)\b/i },
|
| 98 |
|
| 99 |
// Urianálisis
|
|
@@ -167,11 +174,20 @@ function aplicarConversion(campo: string, claveConv: string | undefined, value:
|
|
| 167 |
return value;
|
| 168 |
}
|
| 169 |
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
const m = contexto.match(/[<>≤≥]?\s*(\d+(?:[.,]\d+)?)([\s\S]*)/);
|
| 172 |
if (!m) return { num: null, unit: '' };
|
| 173 |
const v = parseFloat(m[1].replace(',', '.'));
|
| 174 |
-
|
|
|
|
| 175 |
return { num: v, unit: m[2].slice(0, 50) };
|
| 176 |
}
|
| 177 |
|
|
@@ -185,17 +201,91 @@ function parsearSemiCuantitativo(text: string): string | null {
|
|
| 185 |
return null;
|
| 186 |
}
|
| 187 |
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
const resultados: Resultados = {};
|
|
|
|
| 190 |
|
| 191 |
for (const def of DEFS_ANALITOS) {
|
| 192 |
if (resultados[def.campo] !== undefined) continue;
|
| 193 |
-
const
|
| 194 |
-
if (
|
| 195 |
-
|
| 196 |
-
const { num, unit } = extraerValorYUnidad(contexto);
|
| 197 |
-
if (num === null) continue;
|
| 198 |
-
resultados[def.campo] = aplicarConversion(def.campo, def.claveConv, num, unit);
|
| 199 |
}
|
| 200 |
|
| 201 |
// Derivar % desde conteos absolutos si el % no se encontro directamente y se conoce el WBC
|
|
@@ -220,9 +310,9 @@ function parsearTextoLab(textoCrudo: string): Resultados {
|
|
| 220 |
|
| 221 |
for (const def of DEFS_SEMICUANTITATIVOS) {
|
| 222 |
if (resultados[def.campo] !== undefined) continue;
|
| 223 |
-
const match = def.re.exec(
|
| 224 |
if (!match) continue;
|
| 225 |
-
const contexto =
|
| 226 |
const val = parsearSemiCuantitativo(contexto);
|
| 227 |
if (val) resultados[def.campo] = val;
|
| 228 |
}
|
|
@@ -266,7 +356,7 @@ function inferEspecie(raza: string): string | null {
|
|
| 266 |
return null;
|
| 267 |
}
|
| 268 |
|
| 269 |
-
function parsearTextoPaciente(textoCrudo: string): PacientePdf {
|
| 270 |
const p: PacientePdf = {};
|
| 271 |
|
| 272 |
const coincEsp = textoCrudo.match(/\b(?:especies?|species|tipo(?:\s+de)?\s+animal)\s*:?\s{0,4}([A-Za-záéíóúÁÉÍÓÚñÑ]{3,20})/i);
|
|
@@ -276,6 +366,17 @@ function parsearTextoPaciente(textoCrudo: string): PacientePdf {
|
|
| 276 |
else if (/fel[io]|gat[ao]|cat/.test(v)) p.especie = 'Felino';
|
| 277 |
}
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
const coincRaza = textoCrudo.match(/\b(?:raza|breed|race|cruce)\s*:?\s{0,4}([^\n\r;:]{2,60})/i);
|
| 280 |
if (coincRaza) {
|
| 281 |
const crudo = coincRaza[1];
|
|
@@ -307,23 +408,58 @@ function parsearTextoPaciente(textoCrudo: string): PacientePdf {
|
|
| 307 |
return p;
|
| 308 |
}
|
| 309 |
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
}
|
| 322 |
|
| 323 |
async function extraerTextoPdf(file: File): Promise<string> {
|
| 324 |
const pdfjs = await cargarPdfJs();
|
| 325 |
-
if (!pdfjs) throw new Error('PDF.js no cargado');
|
| 326 |
-
pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER;
|
| 327 |
|
| 328 |
const buf = await file.arrayBuffer();
|
| 329 |
const pdf = await pdfjs.getDocument({ data: buf }).promise;
|
|
@@ -333,7 +469,67 @@ async function extraerTextoPdf(file: File): Promise<string> {
|
|
| 333 |
const content = await page.getTextContent();
|
| 334 |
paginas.push(content.items.map((i) => i.str + (i.hasEOL ? '\n' : ' ')).join(''));
|
| 335 |
}
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
}
|
| 338 |
|
| 339 |
export function inicializarParserPdf(evaluar: () => void): void {
|
|
@@ -349,21 +545,9 @@ export function inicializarParserPdf(evaluar: () => void): void {
|
|
| 349 |
const file = input.files?.[0];
|
| 350 |
if (!file) return;
|
| 351 |
input.value = '';
|
| 352 |
-
|
| 353 |
-
const textoCrudo = await extraerTextoPdf(file);
|
| 354 |
-
const resultados = parsearTextoLab(textoCrudo);
|
| 355 |
-
const contadorLab = aplicarValoresAFormulario(resultados, evaluar);
|
| 356 |
-
const patient = parsearTextoPaciente(textoCrudo);
|
| 357 |
-
const contadorPac = aplicarPacienteAFormulario(patient);
|
| 358 |
-
const partes: string[] = [];
|
| 359 |
-
if (contadorLab > 0) partes.push(`${contadorLab} valor${contadorLab !== 1 ? 'es' : ''}`);
|
| 360 |
-
if (contadorPac > 0) partes.push('datos del paciente');
|
| 361 |
-
mostrarToast(partes.length > 0
|
| 362 |
-
? `${partes.join(' y ')} importados del PDF.`
|
| 363 |
-
: 'No se encontraron datos reconocibles en el PDF.', partes.length === 0);
|
| 364 |
-
} catch {
|
| 365 |
-
mostrarToast('Error al leer el PDF. ¿Es un PDF con texto (no escaneado)?', true);
|
| 366 |
-
}
|
| 367 |
}));
|
| 368 |
});
|
|
|
|
|
|
|
| 369 |
}
|
|
|
|
| 4 |
import { aplicarValoresAFormulario, aplicarPacienteAFormulario, mostrarToast } from './form-inject.js';
|
| 5 |
import { manejadorAsync } from './async.js';
|
| 6 |
|
| 7 |
+
const PDFJS_MODULO = 'assets/lib/pdfjs/pdf.min.mjs';
|
| 8 |
+
const PDFJS_WORKER = 'assets/lib/pdfjs/pdf.worker.min.mjs';
|
| 9 |
|
| 10 |
interface AnalitoDef { campo: string; re: RegExp; claveConv?: string }
|
| 11 |
interface ConversionRegla { re: RegExp; factor: number | ((v: number) => number) }
|
|
|
|
| 13 |
|
| 14 |
interface PdfjsLib {
|
| 15 |
GlobalWorkerOptions: { workerSrc: string };
|
| 16 |
+
getDocument(opts: { data: ArrayBuffer | Uint8Array }): { promise: Promise<PdfDoc> };
|
| 17 |
}
|
| 18 |
interface PdfDoc { numPages: number; getPage(n: number): Promise<PdfPage> }
|
| 19 |
interface PdfPage { getTextContent(): Promise<{ items: Array<{ str: string; hasEOL?: boolean }> }> }
|
| 20 |
|
| 21 |
const DEFS_ANALITOS: AnalitoDef[] = [
|
| 22 |
// Hematología: Serie Roja
|
| 23 |
+
// `hematies` es como lo etiquetan los laboratorios españoles (el texto llega ya sin acentos,
|
| 24 |
+
// ver `sinAcentos`).
|
| 25 |
+
{ campo: 'rbc', re: /\b(?:eritrocit\w*|hematies|gl[oó]bulos?\s+rojos?|r\.?b\.?c\.?|eri)\b/i },
|
| 26 |
{ campo: 'hgb', re: /\b(?:hemoglobin[ao]?\w*|hgb|hb)\b(?!a\d)/i },
|
| 27 |
{ campo: 'hct', re: /\b(?:hematocrit[oo]?\w*|hct|pcv)\b/i },
|
| 28 |
{ campo: 'vcm', re: /\b(?:v\.?c\.?m\.?|m\.?c\.?v\.?|vol(?:umen)?\s+corp\w*)\b/i },
|
|
|
|
| 37 |
// Hematología: Serie Blanca
|
| 38 |
{ campo: 'wbc', re: /\b(?:leucocit\w*|w\.?b\.?c\.?|white\s+blood\s+cell|leu)\b/i },
|
| 39 |
{ campo: 'neutro_abs', re: /\bgran?#/i },
|
| 40 |
+
// «SEGMENTADOS» es como los informes españoles nombran a los neutrófilos maduros; sin él la
|
| 41 |
+
// fórmula leucocitaria de esos informes se importaba sin neutrófilos, que es el dato que más
|
| 42 |
+
// pesa en la detección de inflamación.
|
| 43 |
+
{ campo: 'neutro', re: /\b(?:neutr[oó]fil\w*|segmentad\w*|neut\b|neu\b|gran?(?!#))\b/i },
|
| 44 |
{ campo: 'linfo_abs', re: /\blymp?#/i },
|
| 45 |
{ campo: 'linfo', re: /\b(?:linf[oa]cit\w*|lymph\w*|linf\b|lym(?!#))\b/i },
|
| 46 |
{ campo: 'mono_abs', re: /\bmon\w*#/i },
|
|
|
|
| 94 |
|
| 95 |
// Bioquímica: Enzimas
|
| 96 |
{ campo: 'lipasa', re: /\b(?:lipas[ae]\b|lipa\b)\b/i },
|
| 97 |
+
// «CREATINKINASA», en una palabra, es como la etiquetan los informes españoles.
|
| 98 |
+
{ campo: 'ck', re: /\b(?:c\.?k\.?\b|creatin[ae]?\s*kinas[ae]|creatine\s+kinas[ae])\b/i },
|
| 99 |
|
| 100 |
// Perfil Endocrino
|
| 101 |
{ campo: 'cortisol_bas', re: /\b(?:cortisol\s+bas[ae]?l?)\b/i },
|
| 102 |
{ campo: 'cortisol_acth', re: /\b(?:cortisol\s+(?:post[-\s]?acth|post)\b)/i },
|
| 103 |
+
{ campo: 't4_total', re: /\b(?:t4(?:\s+(?:total|libre))?|tiroxin\w*|thyroxin\w*)\b/i },
|
| 104 |
{ campo: 'insulina', re: /\b(?:insulin[ao]?\w*)\b/i },
|
| 105 |
|
| 106 |
// Urianálisis
|
|
|
|
| 174 |
return value;
|
| 175 |
}
|
| 176 |
|
| 177 |
+
// Campos donde 0 es un resultado clínico legítimo y no ruido de parseo. En el resto se sigue
|
| 178 |
+
// descartando: un 0 suelto suele venir de una fecha o un número de página, no de una medición.
|
| 179 |
+
// «BASÓFILOS 0 %» es la lectura normal de un hemograma sano y se estaba tirando.
|
| 180 |
+
const PERMITEN_CERO = new Set([
|
| 181 |
+
'neutro', 'neutro_abs', 'linfo', 'linfo_abs', 'mono', 'mono_abs',
|
| 182 |
+
'eosino', 'eosino_abs', 'baso', 'baso_abs', 'reti', 'reti_abs', 'nrbc',
|
| 183 |
+
]);
|
| 184 |
+
|
| 185 |
+
function extraerValorYUnidad(contexto: string, campo = ''): { num: number | null; unit: string } {
|
| 186 |
const m = contexto.match(/[<>≤≥]?\s*(\d+(?:[.,]\d+)?)([\s\S]*)/);
|
| 187 |
if (!m) return { num: null, unit: '' };
|
| 188 |
const v = parseFloat(m[1].replace(',', '.'));
|
| 189 |
+
const minimo = PERMITEN_CERO.has(campo) ? 0 : Number.MIN_VALUE;
|
| 190 |
+
if (!isFinite(v) || v < minimo) return { num: null, unit: '' };
|
| 191 |
return { num: v, unit: m[2].slice(0, 50) };
|
| 192 |
}
|
| 193 |
|
|
|
|
| 201 |
return null;
|
| 202 |
}
|
| 203 |
|
| 204 |
+
// Los informes españoles escriben los analitos en mayúsculas Y acentuados (EOSINÓFILOS,
|
| 205 |
+
// BASÓFILOS, HEMATÍES). `\w` no cubre 'Ó', así que `eosino\w*` no casa con «EOSINÓFILOS» y toda
|
| 206 |
+
// la fórmula leucocitaria se perdía. Se quitan los diacríticos ANTES de buscar; las posiciones
|
| 207 |
+
// se conservan porque NFD + borrado de marcas no cambia el número de caracteres base.
|
| 208 |
+
function sinAcentos(texto: string): string {
|
| 209 |
+
return texto.normalize('NFD').replace(/\p{M}/gu, '');
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Dónde puede estar el valor de una etiqueta. Antes era una ventana ciega de 150 caracteres, que
|
| 213 |
+
// se saltaba líneas y secciones enteras: medido con un informe real, «densidad urinaria» —citada
|
| 214 |
+
// de pasada en el párrafo interpretativo del SDMA— capturaba el resultado de la CREATINKINASA
|
| 215 |
+
// dos líneas más abajo e importaba una densidad urinaria de 90 en una analítica sin orina. Un
|
| 216 |
+
// valor equivocado es peor que un valor ausente: el que falta se ve, el que sobra se interpreta.
|
| 217 |
+
//
|
| 218 |
+
// Las dos disposiciones reales son:
|
| 219 |
+
// 1. En la misma línea: `HEMATOCRITO....... 48,1 %`
|
| 220 |
+
// 2. Bajo una cabecera: `CREATININA / SUERO` / `Química seca - …` / `RESULTADO....... 1,73`
|
| 221 |
+
// Así que se lee el resto de la línea y, si no hay número, se avanza hasta una línea RESULTADO,
|
| 222 |
+
// parando en cuanto aparece otra cabecera en mayúsculas (es decir, otro analito).
|
| 223 |
+
const MAX_LINEAS_BUSCADAS = 6;
|
| 224 |
+
const RE_LINEA_RESULTADO = /^\s*(?:resultado|result)\b/i;
|
| 225 |
+
|
| 226 |
+
function esCabecera(linea: string): boolean {
|
| 227 |
+
const letras = linea.replace(/[^A-Za-zÁÉÍÓÚÑáéíóúñ]/g, '');
|
| 228 |
+
if (letras.length < 3) return false;
|
| 229 |
+
return letras === letras.toUpperCase();
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Se prueban TODAS las apariciones de la etiqueta, no sólo la primera, y gana la primera que
|
| 233 |
+
// venga acompañada de un número. Los informes nombran el analito antes de medirlo —«FÓRMULA
|
| 234 |
+
// LEUCOCITARIA» es el título de la sección y «LEUCOCITOS....... 7.510» el dato—, así que quedarse
|
| 235 |
+
// con la primera aparición perdía el valor. El orden importa: si la primera sí trae número, es la
|
| 236 |
+
// que se usa, igual que antes.
|
| 237 |
+
const MAX_APARICIONES = 5;
|
| 238 |
+
const globales = new Map<RegExp, RegExp>();
|
| 239 |
+
|
| 240 |
+
function comoGlobal(re: RegExp): RegExp {
|
| 241 |
+
let g = globales.get(re);
|
| 242 |
+
if (!g) {
|
| 243 |
+
g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
|
| 244 |
+
globales.set(re, g);
|
| 245 |
+
}
|
| 246 |
+
g.lastIndex = 0;
|
| 247 |
+
return g;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function primerValorDe(texto: string, def: AnalitoDef): number | null {
|
| 251 |
+
const re = comoGlobal(def.re);
|
| 252 |
+
for (let i = 0; i < MAX_APARICIONES; i++) {
|
| 253 |
+
const match = re.exec(texto);
|
| 254 |
+
if (!match) return null;
|
| 255 |
+
const contexto = contextoDeValor(texto, match.index + match[0].length);
|
| 256 |
+
const { num, unit } = extraerValorYUnidad(contexto, def.campo);
|
| 257 |
+
if (num !== null) return aplicarConversion(def.campo, def.claveConv, num, unit);
|
| 258 |
+
}
|
| 259 |
+
return null;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
function contextoDeValor(texto: string, desde: number): string {
|
| 263 |
+
const lineas = texto.slice(desde, desde + 600).split('\n');
|
| 264 |
+
const resto = lineas[0] ?? '';
|
| 265 |
+
if (/\d/.test(resto)) return resto;
|
| 266 |
+
|
| 267 |
+
for (const linea of lineas.slice(1, MAX_LINEAS_BUSCADAS)) {
|
| 268 |
+
if (RE_LINEA_RESULTADO.test(linea)) return linea;
|
| 269 |
+
// Otra cabecera en mayúsculas o una línea que YA trae un número: en ambos casos lo que
|
| 270 |
+
// viene después pertenece a otro analito. Sin la segunda condición, «COCIENTE ALBÚMINA /
|
| 271 |
+
// GLOBULINA» se saltaba la línea `ALBUMINA... 40 g/L` y se quedaba con el RESULTADO de
|
| 272 |
+
// debajo, que es el cociente A/G (1,11) y no la albúmina.
|
| 273 |
+
if (esCabecera(linea) || /\d/.test(linea)) break;
|
| 274 |
+
}
|
| 275 |
+
return '';
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// Exportadas para las pruebas: son puras (texto → datos) y concentran toda la lógica de
|
| 279 |
+
// reconocimiento, que es donde han estado los fallos.
|
| 280 |
+
export function parsearTextoLab(textoCrudo: string): Resultados {
|
| 281 |
const resultados: Resultados = {};
|
| 282 |
+
const texto = sinAcentos(textoCrudo);
|
| 283 |
|
| 284 |
for (const def of DEFS_ANALITOS) {
|
| 285 |
if (resultados[def.campo] !== undefined) continue;
|
| 286 |
+
const encontrado = primerValorDe(texto, def);
|
| 287 |
+
if (encontrado === null) continue;
|
| 288 |
+
resultados[def.campo] = encontrado;
|
|
|
|
|
|
|
|
|
|
| 289 |
}
|
| 290 |
|
| 291 |
// Derivar % desde conteos absolutos si el % no se encontro directamente y se conoce el WBC
|
|
|
|
| 310 |
|
| 311 |
for (const def of DEFS_SEMICUANTITATIVOS) {
|
| 312 |
if (resultados[def.campo] !== undefined) continue;
|
| 313 |
+
const match = def.re.exec(texto);
|
| 314 |
if (!match) continue;
|
| 315 |
+
const contexto = texto.slice(match.index, match.index + 80);
|
| 316 |
const val = parsearSemiCuantitativo(contexto);
|
| 317 |
if (val) resultados[def.campo] = val;
|
| 318 |
}
|
|
|
|
| 356 |
return null;
|
| 357 |
}
|
| 358 |
|
| 359 |
+
export function parsearTextoPaciente(textoCrudo: string): PacientePdf {
|
| 360 |
const p: PacientePdf = {};
|
| 361 |
|
| 362 |
const coincEsp = textoCrudo.match(/\b(?:especies?|species|tipo(?:\s+de)?\s+animal)\s*:?\s{0,4}([A-Za-záéíóúÁÉÍÓÚñÑ]{3,20})/i);
|
|
|
|
| 366 |
else if (/fel[io]|gat[ao]|cat/.test(v)) p.especie = 'Felino';
|
| 367 |
}
|
| 368 |
|
| 369 |
+
// Muchos informes no rotulan «Especie:»: escriben el nombre científico o el común junto al
|
| 370 |
+
// nombre del animal («EDNA / Perra, Canis lupus familiaris»). Se busca sólo si la etiqueta
|
| 371 |
+
// explícita no dio nada, para que ésta siga mandando.
|
| 372 |
+
if (!p.especie) {
|
| 373 |
+
if (/\bcanis\s+(?:lupus\s+)?familiaris\b|\bperr[ao]\b/i.test(textoCrudo)) p.especie = 'Canino';
|
| 374 |
+
else if (/\bfelis\s+(?:silvestris\s+)?catus\b|\bgat[ao]\b/i.test(textoCrudo)) p.especie = 'Felino';
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
// Y el sexo se deduce del mismo sitio: «Perra»/«Gata» sólo existen en femenino.
|
| 378 |
+
if (/\b(?:perra|gata)\b/i.test(textoCrudo)) p.sexo = 'Hembra';
|
| 379 |
+
|
| 380 |
const coincRaza = textoCrudo.match(/\b(?:raza|breed|race|cruce)\s*:?\s{0,4}([^\n\r;:]{2,60})/i);
|
| 381 |
if (coincRaza) {
|
| 382 |
const crudo = coincRaza[1];
|
|
|
|
| 408 |
return p;
|
| 409 |
}
|
| 410 |
|
| 411 |
+
class ErrorTextoIlegible extends Error {
|
| 412 |
+
constructor() {
|
| 413 |
+
super('el texto del PDF no se pudo decodificar de forma fiable');
|
| 414 |
+
this.name = 'ErrorTextoIlegible';
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
let pdfjsCargado: PdfjsLib | undefined;
|
| 419 |
+
|
| 420 |
+
// pdf.js 4+ se distribuye como módulo ES y ya no publica `window.pdfjsLib`, así que se carga
|
| 421 |
+
// con import() dinámico en vez de inyectar un <script>. Sigue siendo perezoso a propósito:
|
| 422 |
+
// son ~1,7 MB entre módulo y worker, y sólo hacen falta si el usuario adjunta un PDF.
|
| 423 |
+
//
|
| 424 |
+
// La versión importa. La 3.11 leía mal los CMap `ToUnicode` con destinos de UN byte —fuera de
|
| 425 |
+
// especificación, pero los emiten productores reales (informes de laboratorio generados con
|
| 426 |
+
// Ghostscript)— y devolvía cada carácter desplazado 8 bits: 'H' (0x48) salía como U+4800. El
|
| 427 |
+
// texto extraído no casaba con ningún analito y la importación fallaba entera. Ver
|
| 428 |
+
// `frontend/tests/pdf-parser.test.ts`, que fija el caso con un PDF de ese tipo.
|
| 429 |
+
async function cargarPdfJs(): Promise<PdfjsLib> {
|
| 430 |
+
if (pdfjsCargado) return pdfjsCargado;
|
| 431 |
+
// @vite-ignore: es un asset vendorizado que se resuelve en tiempo de ejecución; la build no
|
| 432 |
+
// debe intentar empaquetarlo.
|
| 433 |
+
const modulo = (await import(
|
| 434 |
+
/* @vite-ignore */ new URL(PDFJS_MODULO, document.baseURI).href
|
| 435 |
+
)) as unknown as PdfjsLib;
|
| 436 |
+
modulo.GlobalWorkerOptions.workerSrc = new URL(PDFJS_WORKER, document.baseURI).href;
|
| 437 |
+
pdfjsCargado = modulo;
|
| 438 |
+
return modulo;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/** Texto que salió del extractor con los bytes descolocados (ver `cargarPdfJs`).
|
| 442 |
+
*
|
| 443 |
+
* No se intenta reparar aunque la transformación sea reversible sobre el papel: el extractor
|
| 444 |
+
* normaliza a espacio los códigos que caen en un espacio Unicode, y '0' (0x30) desplazado es
|
| 445 |
+
* U+3000 (espacio ideográfico). Los ceros se pierden ANTES de que este código vea el texto, así
|
| 446 |
+
* que «reparar» convertiría una ALT de 260 U/L en 26. Vale más no importar nada que importar un
|
| 447 |
+
* número equivocado en una analítica.
|
| 448 |
+
*/
|
| 449 |
+
function textoIlegible(texto: string): boolean {
|
| 450 |
+
let noAscii = 0;
|
| 451 |
+
let desplazados = 0;
|
| 452 |
+
for (const ch of texto) {
|
| 453 |
+
const c = ch.codePointAt(0) as number;
|
| 454 |
+
if (c <= 0x7f) continue;
|
| 455 |
+
noAscii++;
|
| 456 |
+
if ((c & 0xff) === 0 && c >>> 8 >= 0x20) desplazados++;
|
| 457 |
+
}
|
| 458 |
+
return noAscii >= 20 && desplazados / noAscii > 0.5;
|
| 459 |
}
|
| 460 |
|
| 461 |
async function extraerTextoPdf(file: File): Promise<string> {
|
| 462 |
const pdfjs = await cargarPdfJs();
|
|
|
|
|
|
|
| 463 |
|
| 464 |
const buf = await file.arrayBuffer();
|
| 465 |
const pdf = await pdfjs.getDocument({ data: buf }).promise;
|
|
|
|
| 469 |
const content = await page.getTextContent();
|
| 470 |
paginas.push(content.items.map((i) => i.str + (i.hasEOL ? '\n' : ' ')).join(''));
|
| 471 |
}
|
| 472 |
+
const texto = paginas.join('\n');
|
| 473 |
+
if (textoIlegible(texto)) throw new ErrorTextoIlegible();
|
| 474 |
+
return texto;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
// Lee un PDF y vuelca lo que reconozca en el formulario. El PDF se parsea entero, sin importar
|
| 478 |
+
// desde qué panel se haya adjuntado: los valores caen en el panel al que pertenece cada analito.
|
| 479 |
+
async function importarPdf(file: File, evaluar: () => void): Promise<void> {
|
| 480 |
+
try {
|
| 481 |
+
const textoCrudo = await extraerTextoPdf(file);
|
| 482 |
+
const resultados = parsearTextoLab(textoCrudo);
|
| 483 |
+
const contadorLab = aplicarValoresAFormulario(resultados, evaluar);
|
| 484 |
+
const patient = parsearTextoPaciente(textoCrudo);
|
| 485 |
+
const contadorPac = aplicarPacienteAFormulario(patient);
|
| 486 |
+
const partes: string[] = [];
|
| 487 |
+
if (contadorLab > 0) partes.push(`${contadorLab} valor${contadorLab !== 1 ? 'es' : ''}`);
|
| 488 |
+
if (contadorPac > 0) partes.push('datos del paciente');
|
| 489 |
+
mostrarToast(partes.length > 0
|
| 490 |
+
? `${partes.join(' y ')} importados del PDF.`
|
| 491 |
+
: 'No se encontraron datos reconocibles en el PDF.', partes.length === 0);
|
| 492 |
+
} catch {
|
| 493 |
+
mostrarToast('Error al leer el PDF. ¿Es un PDF con texto (no escaneado)?', true);
|
| 494 |
+
}
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// Arrastrar y soltar sobre un panel de exámenes. Se escucha en el <section> entero y no sólo en
|
| 498 |
+
// la zona vacía: una vez el panel muestra el formulario la zona desaparece, y soltar otro PDF
|
| 499 |
+
// encima del panel debe seguir funcionando.
|
| 500 |
+
function inicializarArrastre(evaluar: () => void): void {
|
| 501 |
+
document.querySelectorAll<HTMLElement>('.subpanel[id^="panel-"]').forEach((panel) => {
|
| 502 |
+
if (!panel.querySelector('.panel-vacio')) return;
|
| 503 |
+
|
| 504 |
+
// `dragover` con preventDefault es lo que le dice al navegador que aquí se puede soltar;
|
| 505 |
+
// sin él, el drop lo captura la ventana y navega al fichero.
|
| 506 |
+
panel.addEventListener('dragover', (e) => {
|
| 507 |
+
e.preventDefault();
|
| 508 |
+
panel.classList.add('arrastrando');
|
| 509 |
+
});
|
| 510 |
+
// `dragleave` salta también al pasar sobre un hijo; sólo cuenta salir del panel de verdad.
|
| 511 |
+
panel.addEventListener('dragleave', (e) => {
|
| 512 |
+
if (!panel.contains((e as DragEvent).relatedTarget as Node | null)) {
|
| 513 |
+
panel.classList.remove('arrastrando');
|
| 514 |
+
}
|
| 515 |
+
});
|
| 516 |
+
panel.addEventListener('drop', manejadorAsync('Importar PDF', async (e: Event) => {
|
| 517 |
+
e.preventDefault();
|
| 518 |
+
panel.classList.remove('arrastrando');
|
| 519 |
+
const file = (e as DragEvent).dataTransfer?.files?.[0];
|
| 520 |
+
if (!file) return;
|
| 521 |
+
if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {
|
| 522 |
+
mostrarToast('Sólo se pueden adjuntar archivos PDF.', true);
|
| 523 |
+
return;
|
| 524 |
+
}
|
| 525 |
+
await importarPdf(file, evaluar);
|
| 526 |
+
}));
|
| 527 |
+
});
|
| 528 |
+
|
| 529 |
+
// Soltar FUERA de un panel no debe abrir el PDF sustituyendo la aplicación (con el formulario
|
| 530 |
+
// a medio rellenar, eso es perder el trabajo por un gesto impreciso).
|
| 531 |
+
document.addEventListener('dragover', (e) => e.preventDefault());
|
| 532 |
+
document.addEventListener('drop', (e) => e.preventDefault());
|
| 533 |
}
|
| 534 |
|
| 535 |
export function inicializarParserPdf(evaluar: () => void): void {
|
|
|
|
| 545 |
const file = input.files?.[0];
|
| 546 |
if (!file) return;
|
| 547 |
input.value = '';
|
| 548 |
+
await importarPdf(file, evaluar);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
}));
|
| 550 |
});
|
| 551 |
+
|
| 552 |
+
inicializarArrastre(evaluar);
|
| 553 |
}
|