morphos / MIGRACION.md
Jose Salazar
Ajustes varios al engine y al prompt basado en resultados de los evals
bf9f7d1
|
Raw
History Blame Contribute Delete
13.7 kB
# Migración de Morphos — estado y guía
Modernización del stack según `PLAN_MODERNIZACION.md`. Decisiones: IA híbrida
(medGemma privado por defecto + Claude opcional), backend **Python/FastAPI con uv**,
frontend **incremental Vite + TypeScript**, despliegue en **HF Spaces** con índice RAG
horneado en la imagen (sin almacenamiento persistente de pago).
## Estructura nueva
```
frontend/ Vite + TS. Motor portado + suite de regresión (Vitest).
src/analisis.ts Puerto fiel de js/analisis.js (tipado).
src/ia.ts Cliente tipado de /api/interpret (render estructurado).
tests/ 27 pruebas dorada s del motor.
backend/ FastAPI (uv). IA estructurada, RAG, seguridad.
app/schemas.py Salida clínica estructurada (Pydantic) → elimina limpiarRespuesta.
app/ai/ medgemma.py, claude.py, prompt.py, service.py.
app/rag/ retriever.py (degrada sin índice), ingest.py.
app/routers/ interpret.py, papers.py, auth.py.
app/security/ authz, rate_limit, session, headers.
tests/ 15 pruebas (esquema, prompt, RAG, API+seguridad).
evals/ Dataset dorado (split dev/test + firma veterinaria) + run_evals.py
(puerta CI) + juez LLM local gratuito + Ragas + promptfoo.
books/ Corpus con licencia (gitignored). Ver books/README.md.
instance/ BD de usuarios + índice RAG (fuera del webroot; gitignored).
bridge/ Puente local (proyecto uv aparte): lee analizadores (ASTM/HL7 v2) en la
LAN de la clínica y reenvía a /api/lab/ingesta. Ver INTEGRACION_ANALIZADORES.md.
```
## Integración de analizadores de laboratorio
Fases 0-1 implementadas (ingesta de resultados de equipos → autorrelleno del formulario por
ID de muestra). Backend: `app/schemas_lab.py`, `app/lab/` (mapeo + almacén TTL),
`app/routers/lab.py`, `app/security/device.py` (auth por API key). Frontend: `lab-import.ts`
+ `form-inject.ts`. Puente: `bridge/`. Mapeos código→analito en `data/lab_mapeos/`. Detalle
y fases pendientes en **INTEGRACION_ANALIZADORES.md**.
## Cómo ejecutar
```bash
# Frontend: pruebas del motor y build
make frontend-install
make frontend-test # 27/27
make frontend-build # → dist/
# Backend: sync (uv) y pruebas
make backend-sync
make backend-test # 15/15
make dev # uvicorn en :8000
# Evals (puerta de CI)
make evals # split dev, sólo casos con validación veterinaria
make evals-test # split reservado
make revision # hoja de revisión de los casos pendientes
make ragas ARGS="--predicciones preds.jsonl" # groundedness (juez local)
# RAG (cuando haya libros en books/)
make ingest # construye instance/rag_index con el grupo 'rag'
# Docker (multi-stage: build frontend + backend uv)
make docker-build
```
## Qué se ha implementado y verificado
- ✅ **Motor portado a TS** con 27 pruebas de regresión (parity con el JS original) y
typecheck limpio. Es la red de seguridad de la migración.
-**Backend FastAPI** con **salida estructurada validada** (Pydantic) que sustituye la
limpieza por regex; clientes **medGemma** (Ollama, plantilla de chat + `format` JSON
Schema, sin inyección de `<unused95>`) y **Claude** (tool use). 15 pruebas verdes.
-**Seguridad**: `/api/interpret` y `/api/papers` requieren sesión (cerrado el acceso
anónimo); CORS bloqueado; sesiones firmadas HttpOnly/SameSite/Secure; CSRF de doble
token; throttling de login; validación de imágenes; cabeceras de seguridad; BD e índice
RAG **fuera del webroot**. Verificado: 401 sin sesión, 403 sin CSRF, flujo completo OK.
-**RAG**: pipeline de ingesta + recuperador con citas que **degrada a modo sin-RAG**
si faltan deps o índice (probado). Índice horneado en la imagen.
-**Evals**: dataset dorado, comprobaciones deterministas (recall diferenciales,
cobertura, derivación, idioma, **seguridad tolerancia-cero**), promptfoo y **puerta de CI**
(exit≠0 ante regresión) — verificado que bloquea.
-**Juez LLM sin clave de API**: la rúbrica clínica y el juez de relevancia corren sobre
el **CLI de Claude Code** (`judge/claude_cli.py`, usa la sesión ya iniciada) o sobre
**Ollama** (`judge/ollama_local.py`, salida estructurada). El SDK con `ANTHROPIC_API_KEY`
queda como opción explícita. Antes el juez exigía esa clave y por eso nunca llegó a
cablearse en `run_evals.py`; ahora forma parte de la puerta.
-**Atribución verificable en las tres rutas** (`app/ai/citas.py`): las fuentes se
construyen desde los fragmentos realmente recuperados, la prosa del HF Space cita con
marcadores `[n]` y las citas que no se resuelven contra un fragmento real se descartan.
-**Disciplina del dataset**: `split` dev/test y `validado` por caso, aplicados por el
runner; circuito de firma veterinaria en `evals/revision.py`.
-**Ragas** (`evals/run_ragas.py`) sobre el índice real, con LLM y embeddings locales.
-**La derivación ya no la decide el modelo.** Si el motor determinista ve un hallazgo o
patrón `grave`, `requiere_derivacion` se fuerza a true pase lo que pase. Lo motivó una
medición: un 7B general marcó `false` en una ERC felina avanzada (creat 4.8, BUN 68,
isostenuria). Era un fallo de seguridad que dependía de qué modelo hubiera detrás; ahora es
imposible por construcción.
-**El alcance tampoco lo decide el modelo** (`app/ai/alcance.py`). Mismo patrón que la
derivación, misma causa: en la corrida del 2026-07-28, el caso `fuera-de-alcance-humano`
puntuó 0.00 en corrección y 0.00 en seguridad con **los tres** modelos evaluados —los únicos
ceros de toda la corrida—. Ninguno vio que el paciente era humano, ninguno declinó y los tres
fabricaron clínica (analitos nunca medidos, diagnósticos, hasta una biopsia renal) a partir
de una glucosa en rango. Ahora una guarda determinista inspecciona especie/raza/signos
**antes** de crear el cliente: si el paciente no es canino ni felino, se devuelve un rechazo
tipado (`fuera_de_alcance=true`, sin hallazgos ni diferenciales) sin gastar una llamada. La
guarda es deliberadamente estrecha —exige la especie declarada como tal— para no echar a un
caso legítimo que mencione otra especie de pasada (`test_alcance.py` fija ambos lados).
Medible: `acierto_fuera_de_alcance` es métrica de puerta con tolerancia cero.
-**`requiere_derivacion` dejó de ser una constante en la ruta de prosa.** El HF Space
devuelve texto, así que el cliente construía el objeto con el default del esquema (`true`)
y el campo no dependía del caso: en `normal-canino` contradecía a su propio texto y el juez
lo penalizó como incoherencia con riesgo de alarma injustificada (seguridad 0.50). Ahora lo
pone el motor determinista (`_derivacion_en_ruta_de_prosa`): `false` si no hay ningún
hallazgo ni patrón, `true` en cuanto haya algo, con el suelo de `_derivacion_obligatoria`
por encima. Con esto **la puerta pasa entera por primera vez** (juez Sonnet, split dev,
2026-07-31): seguridad 0.44→0.93, hedging 0.55→0.85, completitud 0.54→0.83, violaciones
del juez 2→0. Detalle en `evals/resultados/2026-07-31/impacto_guarda_y_prompt.md`.
-**El prompt ya no lleva líneas de relleno.** «Todos los valores dentro de rangos de
referencia» y «Ninguno detectado por el motor determinista» se leían como contenido: sobre
`normal-canino`, qwen2.5:14b emitió un hallazgo llamado literalmente «Todos los valores»
(alto · leve) sobre una glucosa en rango, y el A/B midió ese mismo caso subiendo de 0.30 a
0.85 sin el bloque. Los bloques vacíos se omiten y un panel normal pide confirmación de
normalidad en vez de diferenciales.
-**Campos estructurados exigidos donde el backend puede rellenarlos.** El esquema por
defecto admitía `{"interpretacion": "…"}` con diferenciales y hallazgos vacíos, y así pasaba
como buena una respuesta que dejaba al veterinario sin nada accionable (medido con
qwen2.5:7b). `esquema_estructurado()` se lo pide al modelo (`minItems`) y el servicio lo
comprueba **sólo cuando el caso lo admite**: un panel normal sí puede no tener hallazgos.
-**Truncamiento silencioso del HF Space, detectado y mitigado.** Lo destapó el juez CLI
sobre salidas reales: el Space cortaba a mitad de frase, perdiendo el diferencial clave, y
ninguna comprobación lo veía. Ahora se detecta (`interpretacion_truncada`), el reintento va
con menos literatura y el prompt de prosa lleva presupuesto de contexto
(`rag_max_chars_prompt`) y límite de palabras.
**Causa raíz (leída en el `app.py` del Space, 2026-07-27):** medGemma 1.5 razona antes de
responder, `extract_response` descarta ese razonamiento y `max_new_tokens=2048` es UN solo
presupuesto para ambos. El razonamiento se lleva ~1.100 tokens o más, así que la respuesta
visible se corta cuando la suma pasa del techo. Más literatura alarga el razonamiento, pero
no es el único factor: medido, con 1 solo fragmento también se truncaba. Las mitigaciones
del cliente reducen la probabilidad; no eliminan la causa.
-**Puente Node** que reusa `analisis.ts` como única fuente de verdad para generar los
hallazgos deterministas en las evals.
## Hecho en el último incremento
-**Puerto TS de todos los módulos UI** (`ui`, `pdf-parser`, `main`, `auth`, `papers`,
`tooltip`) + helper `dom.ts`. `auth.ts`/`papers.ts` usan los endpoints FastAPI.
-**`index.html` recableado** a `/frontend/src/main.ts` (Vite lo empaqueta). La app corre
end-to-end sobre el stack nuevo — verificado en navegador: motor, PDF, registro/login
real (sesión + CSRF) y el botón IA llamando a `/api/interpret`.
## Pendiente (siguiente incremento)
- **Retirar** `api/*.php` y `js/*.js` legacy: ya son código muerto (no se cargan). Borrado
seguro cuando se confirme que no se necesitan de referencia.
-**Ruta de IA funcionando en vivo**: la ruta `medgemma` usa por defecto el HF Space
(Gradio) donde está alojado el modelo — `app/ai/hf_space.py` porta el flujo de
`hf_proxy.php` (upload → analyze → SSE) y envuelve el texto en el esquema. Verificado en
navegador: interpretación real renderizada con el aviso de derivación. (Alternativas por
config: Ollama local si se vacía `MORPHOS_HF_SPACE_URL`, o Claude con API key.)
- **Config ESLint/Prettier** (falta el archivo de configuración; ya está la dependencia).
- **Retriever RAG**: añadir búsqueda híbrida BM25 + rerank.
- **Cerrar el A/B de multi-consulta con un juez LLM local.** La descomposición (una consulta
por patrón, fusión RRF) está implementada y testeada, pero apagada
(`MORPHOS_RAG_MULTICONSULTA=false`): con el único juez gratuito disponible —el heurístico de
palabras— empeora (precision@k 0.81→0.50), y ese juez está sesgado a favor de la consulta
concatenada. Basta `ollama pull` de un generativo para repetirlo bien; detalle y comandos en
`evals/resultados/2026-07-31/retrieval_multiconsulta.md`.
-**Probado y descartado: saltar el razonamiento en el Space.** El código ya existía
(`prefijar_respuesta`, interruptor `SALTAR_RAZONAMIENTO`), así que esta entrada estaba
obsoleta. Activado y revertido el 2026-07-31: empeora `juez_seguridad` 0.92→0.79 y mete una
violación de seguridad (recomendó insulina y fluidoterapia sin encuadre presencial en un
paciente con potasio 3,0). Detalle en `evals/resultados/2026-07-31/experimentos_robustez.md`.
El texto original de esta entrada se conserva abajo por su análisis de la causa raíz:
- **Arreglar el truncamiento en su origen: el Space (`blackmistcode/morphos_medGemma`).** La
cadena de razonamiento se genera y se tira, consumiendo la mitad o más de los 2048 tokens y
del tiempo de GPU. Prefijando `<unused95>` al turno del modelo —lo que hacía el proxy PHP
legacy— la generación arranca ya en modo respuesta: el presupuesto entero queda para la
interpretación, la latencia baja y con ella se puede recortar `duration`, que es lo que
consume cuota de ZeroGPU. Es el único cambio que elimina la causa en vez de esquivarla; hay
que medirlo con `make evals` antes y después, porque saltarse el razonamiento puede costar
calidad clínica.
```python
inputs = processor.apply_chat_template(..., return_tensors="pt")
prefijo = torch.full((1, 1), UNUSED95_ID, device=inputs["input_ids"].device)
inputs["input_ids"] = torch.cat([inputs["input_ids"], prefijo], dim=-1)
inputs["attention_mask"] = torch.cat([inputs["attention_mask"], torch.ones_like(prefijo)], dim=-1)
```
- **Revisar el límite de 200 palabras del prompt de prosa.** Evita el truncamiento, pero
medido con el juez CLI sobre el split reservado bajó `hedging` (0.75→0.68) y `seguridad`
(0.77→0.67) sin mejorar el resto. Es un parche mientras el Space siga gastando presupuesto
en razonamiento descartado; con la corrección de arriba debería poder retirarse.
- **Validación veterinaria de los 10 casos pendientes** del dataset (`make revision`): hasta
que se firmen, la puerta corre sobre 7 casos.
- **Aumentar el dataset** de evals con más casos validados por veterinario.
- **Calibrar `UMBRALES_JUEZ` por juez.** Sobre las mismas salidas simuladas, qwen2.5:7b dio
`hedging_apropiado` 1.0 y el juez CLI 0.4: el juez pequeño aprueba lo que el grande
suspende. Los umbrales actuales están puestos para el local; falta medir la desviación
sobre salidas reales y fijar un umbral por juez.
- Fijar `MORPHOS_SESSION_SECRET` y `MORPHOS_COOKIE_SECURE=true` en los secrets del Space.