Spaces:
Running
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 endata/lab_mapeos/. Detalle y fases pendientes en INTEGRACION_ANALIZADORES.md.
Cómo ejecutar
# 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 +
formatJSON Schema, sin inyección de<unused95>) y Claude (tool use). 15 pruebas verdes.✅ Seguridad:
/api/interprety/api/papersrequieren 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 conANTHROPIC_API_KEYqueda como opción explícita. Antes el juez exigía esa clave y por eso nunca llegó a cablearse enrun_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:
splitdev/test yvalidadopor caso, aplicados por el runner; circuito de firma veterinaria enevals/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_derivacionse fuerza a true pase lo que pase. Lo motivó una medición: un 7B general marcófalseen 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 casofuera-de-alcance-humanopuntuó 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.pyfija ambos lados). Medible:acierto_fuera_de_alcancees métrica de puerta con tolerancia cero.✅
requiere_derivaciondejó 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: ennormal-caninocontradecí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):falsesi no hay ningún hallazgo ni patrón,trueen cuanto haya algo, con el suelo de_derivacion_obligatoriapor 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 enevals/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.pydel Space, 2026-07-27): medGemma 1.5 razona antes de responder,extract_responsedescarta ese razonamiento ymax_new_tokens=2048es 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.tscomo ú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) + helperdom.ts.auth.ts/papers.tsusan los endpoints FastAPI. - ✅
index.htmlrecableado 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/*.phpyjs/*.jslegacy: 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
medgemmausa por defecto el HF Space (Gradio) donde está alojado el modelo —app/ai/hf_space.pyporta el flujo dehf_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íaMORPHOS_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. Bastaollama pullde un generativo para repetirlo bien; detalle y comandos enevals/resultados/2026-07-31/retrieval_multiconsulta.md.✅ Probado y descartado: saltar el razonamiento en el Space. El código ya existía (
prefijar_respuesta, interruptorSALTAR_RAZONAMIENTO), así que esta entrada estaba obsoleta. Activado y revertido el 2026-07-31: empeorajuez_seguridad0.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 enevals/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 recortarduration, que es lo que consume cuota de ZeroGPU. Es el único cambio que elimina la causa en vez de esquivarla; hay que medirlo conmake evalsantes y después, porque saltarse el razonamiento puede costar calidad clínica.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) yseguridad(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_JUEZpor juez. Sobre las mismas salidas simuladas, qwen2.5:7b diohedging_apropiado1.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_SECRETyMORPHOS_COOKIE_SECURE=trueen los secrets del Space.