Jose Salazar Claude Opus 5 commited on
Commit
ce79810
·
1 Parent(s): aa0d853

Actualizar documentación al stack nuevo

Browse files

CLAUDE.md describía js/analisis.js y api/hf_proxy.php como vigentes; se
reescribe al flujo real (bundle TS → /api/interpret → RAG → modelo) y se añade
la sección de distribución del corpus.

README.md se marca como histórico: describe la entrega XAMPP del curso, cuyas
instrucciones de instalación ya no son válidas. No se reescribe entero porque
incluye la memoria académica del proyecto.

PLAN_MODERNIZACION.md refleja la auditoría del 2026-07-26: Fase 3 (RAG) pasa a
completa —búsqueda híbrida, rerank y corpus real de 6763 fragmentos— y se
detalla lo que sigue abierto en Fase 4 (Ragas sin implementar, split reservado,
human-in-the-loop).

.gitignore: books/* salvo el README, para que un formato nuevo de corpus no se
cuele por no estar listado; y .agents/ (tooling local del asistente).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (7) hide show
  1. .gitignore +25 -1
  2. CLAUDE.md +81 -31
  3. INTEGRACION_ANALIZADORES.md +328 -0
  4. MIGRACION.md +101 -0
  5. PLAN_MODERNIZACION.md +429 -0
  6. README.md +9 -0
  7. books/README.md +55 -0
.gitignore CHANGED
@@ -26,4 +26,28 @@ Temporary Items
26
  .apdisk
27
 
28
 
29
- .env
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  .apdisk
27
 
28
 
29
+ .env
30
+ backend/.env
31
+
32
+ # Node / frontend
33
+ node_modules/
34
+ dist/
35
+
36
+ # Python / backend (uv)
37
+ .venv/
38
+ __pycache__/
39
+ *.pyc
40
+ .pytest_cache/
41
+ .ruff_cache/
42
+
43
+ # Datos de ejecución (fuera del webroot): BD de usuarios e índice RAG
44
+ instance/
45
+
46
+ # Herramientas locales del asistente (skills instaladas con `hf skills add`), no son del proyecto
47
+ .agents/
48
+
49
+ # Corpus con licencia (no versionar contenido con copyright).
50
+ # Se ignora todo el contenido de books/ salvo el README que documenta el layout de entrada,
51
+ # para que un formato nuevo (.txt, .docx, texto extraido) no se cuele por no estar listado.
52
+ books/*
53
+ !books/README.md
CLAUDE.md CHANGED
@@ -4,23 +4,36 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
 
5
  ## Project Overview
6
 
7
- Morphos is a veterinary diagnostic support tool — a single-page application (SPA) that performs real-time clinical pattern detection from lab values and optionally calls an AI model (HuggingFace or local Ollama) for clinical interpretation. It targets Canino and Felino patients.
8
 
9
- ## Running the App
10
 
11
- This is a static frontend with a PHP proxy backend. No build step required.
 
 
 
 
 
 
 
 
12
 
13
- Serve it locally with PHP's built-in server from the project root:
14
- ```bash
15
- php -S localhost:8000
16
- ```
17
 
18
- Then open `http://localhost:8000` in a browser. The PHP proxy (`api/hf_proxy.php`) requires the API key in `api/.env`:
19
- ```
20
- HF_API_KEY=<your_key>
 
 
 
21
  ```
 
 
 
22
 
23
- For local AI inference, Ollama must be running at `http://localhost:11434` with `medgemma:latest` pulled.
24
 
25
  ## Architecture
26
 
@@ -28,37 +41,45 @@ For local AI inference, Ollama must be running at `http://localhost:11434` with
28
 
29
  ```
30
  User form input
31
- → analisis.js (real-time pattern detection, no server)
32
  → UI updates (color-coded fields, pattern cards)
33
 
34
  User clicks "Análisis IA"
35
- → ia.js (constructs prompt with patient data + flagged values)
36
- [HF route] → api/hf_proxy.php HF Space Gradio API (SSE response)
37
- [Local route] Ollama chat completions API
38
- Display AI output in #salida-ia
 
 
 
39
  ```
40
 
41
  ### Key Files and Their Roles
42
 
43
- - **`js/analisis.js`** — Core engine (505 lines). Compares values against species-specific reference ranges, classifies severity (mild/moderate/severe), applies age/breed/sex adjustments, and identifies 50+ clinical patterns (anemia types, hepatic, renal, endocrine, etc.).
44
- - **`js/ia.js`** — AI abstraction layer. Builds the clinical prompt in Spanish, calls either HF Proxy or Ollama, and strips model-specific tokens from the response.
45
- - **`js/main.js`** — Orchestration: loads JSON data files, wires form events, triggers analysis, handles PDF export.
46
- - **`js/ui.js`** — Tab navigation (8 panels, 4 exam sub-tabs), swipe gestures, mobile/desktop field sync, collapsible panels.
47
- - **`js/pdf-parser.js`** — Client-side PDF extraction using PDF.js. 47 regex patterns to identify analytes in Spanish/English. Runs fully in the browser.
48
- - **`api/hf_proxy.php`** — PHP proxy that reads `api/.env`, forwards requests to HugginFace Space (`blackmistcode-morphos-medgemma.hf.space/gradio_api`), handles SSE polling, and returns `{text: ...}`.
49
- - **`data/valores_referencia.json`** — Reference ranges for 34 analytes per species.
50
- - **`data/alteraciones.json`** — 100+ clinical entities used to enrich AI prompts with etiologic context.
 
51
 
52
  ### AI Backend Configuration
53
 
54
- Stored in `localStorage`:
55
- - `mx-ia-backend`: `"hf"` (default) or `"local"`
56
- - `mx-ia-ollama-url`: custom Ollama endpoint
57
- - `mx-ia-ollama-model`: custom model name (default `medgemma:latest`)
 
58
 
59
- The HF route supports up to 4 images (vision model). The local route uses Ollama's OpenAI-compatible chat completions API, also with vision support.
 
 
 
60
 
61
- ### Pattern Detection Logic (`analisis.js`)
62
 
63
  Severity thresholds are based on deviation from the reference range. Reference ranges are dynamically adjusted for:
64
  - **Age**: puppies, adults, seniors, geriatric (age in months)
@@ -69,7 +90,36 @@ The `analizarResultados()` function is called on every `input` event and returns
69
 
70
  ### CSS Notes
71
 
72
- Do not use `!important` — use specificity or cascade ordering instead. The stylesheet is `css/styles.css` (1796 lines). The desktop grid breakpoint is `>1100px`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  ### Coding notes
75
 
 
4
 
5
  ## Project Overview
6
 
7
+ Morphos is a veterinary diagnostic support tool — a single-page application (SPA) that performs real-time clinical pattern detection from lab values and optionally calls an AI model (self-hosted medGemma, or Claude via the hybrid route) for clinical interpretation. It targets Canino and Felino patients.
8
 
9
+ ## Migration in progress (see MIGRACION.md)
10
 
11
+ The project is being modernized from a static-JS + PHP-proxy app to:
12
+ - **frontend/** — Vite + TypeScript. The vet-validated engine is ported to
13
+ `frontend/src/analisis.ts` with a Vitest regression suite (`frontend/tests/`).
14
+ - **backend/** — FastAPI service (managed with **uv**). Structured AI output (Pydantic),
15
+ hybrid medGemma/Claude clients, RAG retrieval, and all security (auth guard on the AI
16
+ endpoint, rate limiting, locked CORS, secure sessions, security headers).
17
+ - **evals/** — rigorous clinical eval harness with a CI gate (`.github/workflows/evals.yml`).
18
+ - **RAG** — LlamaIndex + LanceDB; index built offline from `books/` and baked read-only
19
+ into the image (lives in `instance/`, outside the webroot).
20
 
21
+ El legacy `js/*.js` + `api/*.php` **ya se eliminó** (2026-07-26): `index.html` cargaba el bundle
22
+ TS desde antes, así que eran código muerto. Todo el trabajo va en la estructura nueva. Ver
23
+ `MIGRACION.md` para el estado completo y cómo ejecutar cada parte.
 
24
 
25
+ ## Running the App
26
+
27
+ ### New stack (target)
28
+ ```bash
29
+ make frontend-install && make frontend-build # build the SPA → dist/
30
+ make backend-sync && make dev # FastAPI on http://localhost:8000
31
  ```
32
+ Secrets come from `backend/.env` (see `backend/.env.example`) or HF Space secrets — never
33
+ from a file under the served root. For local AI, Ollama must run at
34
+ `http://localhost:11434` with a medGemma model pulled.
35
 
36
+ No hay stack legacy: `php -S localhost:8000` y el proxy PHP ya no existen.
37
 
38
  ## Architecture
39
 
 
41
 
42
  ```
43
  User form input
44
+ → analisis.ts (real-time pattern detection, no server)
45
  → UI updates (color-coded fields, pattern cards)
46
 
47
  User clicks "Análisis IA"
48
+ → ia.ts (thin typed client, sends patient data + flagged values + CSRF token)
49
+ POST /api/interpret (FastAPI: sesión + CSRF + rate limit)
50
+ recuperación RAG (LanceDB, degrada a sin-RAG si no hay índice)
51
+ prompt endurecido (app/ai/prompt.py)
52
+ → [ruta medgemma] → HF Space Gradio (texto libre) u Ollama (salida estructurada)
53
+ → [ruta claude] → Anthropic SDK con tool use (salida estructurada validada)
54
+ → Render de la InterpretacionClinica en #salida-ia
55
  ```
56
 
57
  ### Key Files and Their Roles
58
 
59
+ - **`frontend/src/analisis.ts`** — Core engine (845 lines). Compares values against species-specific reference ranges, classifies severity (mild/moderate/severe), applies age/breed/sex adjustments, and identifies 50+ clinical patterns (anemia types, hepatic, renal, endocrine, etc.). Cubierto por 27 tests dorados en `frontend/tests/analisis.test.ts` — **es la red de regresión: no tocar sin ejecutarlos**.
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. **Es la única ruta que NO da salida estructurada**: el Space devuelve texto libre, así que `limpiar_respuesta()` mantiene la limpieza de tokens del modelo y se envuelve en el campo `interpretacion`.
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.
67
+ - **`data/alteraciones.json`** — 78 clinical entities used to enrich AI prompts with etiologic context.
68
 
69
  ### AI Backend Configuration
70
 
71
+ La selección de ruta se aplica **en el servidor** (`MORPHOS_IA_BACKEND_DEFECTO`: `medgemma` |
72
+ `claude`), no en `localStorage` como en el legacy. Dentro de `medgemma`, si
73
+ `MORPHOS_HF_SPACE_URL` está definida se usa el HF Space; si se vacía, cae a Ollama en
74
+ `MORPHOS_MEDGEMMA_BASE_URL`. Ambas rutas aceptan hasta 4 imágenes (validadas en servidor:
75
+ número, mime y tamaño).
76
 
77
+ Para la ruta Claude el modelo por defecto es `claude-opus-5`. No cambiar a `claude-fable-5`:
78
+ cuesta el doble, exige retención de datos de 30 días (incompatible con el posicionamiento de
79
+ privacidad) y sus clasificadores pueden rechazar trabajo clínico legítimo con
80
+ `stop_reason="refusal"` — ver el comentario en `backend/app/config.py`.
81
 
82
+ ### Pattern Detection Logic (`analisis.ts`)
83
 
84
  Severity thresholds are based on deviation from the reference range. Reference ranges are dynamically adjusted for:
85
  - **Age**: puppies, adults, seniors, geriatric (age in months)
 
90
 
91
  ### CSS Notes
92
 
93
+ Do not use `!important` — use specificity or cascade ordering instead. The stylesheet is `css/styles.css` (2742 lines). The desktop grid breakpoint is `>1100px`.
94
+
95
+ ### Distribución del corpus RAG
96
+
97
+ Los libros con licencia y el índice **nunca** entran en git (`books/*` y `instance/` están en
98
+ `.gitignore`). Viven en dos datasets **privados** del Hub, declarados en `scripts/hub.py` y en
99
+ `backend/app/config.py`:
100
+
101
+ | Artefacto | Repo | Tamaño | Para qué |
102
+ |---|---|---|---|
103
+ | Índice LanceDB | `blackmistcode/morphos-rag-index` | 70 MB | Lo consume la app; se hornea en la imagen |
104
+ | PDFs originales | `blackmistcode/morphos-books` | 226 MB | Sólo para reingerir |
105
+
106
+ ```bash
107
+ make ingest # construye el índice desde books/ (local, requiere grupo rag)
108
+ make publish-index # sube instance/rag_index al dataset privado
109
+ make fetch-index # lo descarga (clon limpio, otra máquina, CI)
110
+ make publish-books # respalda los PDFs (no hace falta para desplegar)
111
+ ```
112
+
113
+ Se usa la API de Python de `huggingface_hub`, **no el CLI `hf`**: en la versión instalada
114
+ (1.16.1) el CLI devuelve código 1 aunque la operación vaya bien, por una incompatibilidad
115
+ typer/click, y eso aborta cualquier Makefile o build.
116
+
117
+ En Docker, `WITH_RAG=1` es el valor por defecto y los modelos (bge-m3 + bge-reranker-v2-m3,
118
+ ~6.4 GB) se hornean en `/opt/hf` con `HF_HUB_OFFLINE=1` en runtime, para que un fallo de red no
119
+ degrade la recuperación en silencio. Si `instance/rag_index` no está en el contexto de build, la
120
+ imagen lo descarga usando `HF_TOKEN` como **secreto de build** (nunca `--build-arg`, que quedaría
121
+ en el historial de capas). Al arrancar, `_verificar_rag()` distingue en el log entre «RAG
122
+ desactivado a propósito», «faltan dependencias» y «falta el índice».
123
 
124
  ### Coding notes
125
 
INTEGRACION_ANALIZADORES.md ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Integración de analizadores de laboratorio en Morphos
2
+
3
+ > Plan de integración para que los equipos de laboratorio envíen resultados directamente
4
+ > a Morphos. Alcance actual: **Abaxis (VetScan), Scil/Horiba y Bionote Vcheck V200**.
5
+
6
+ ## Estado de implementación
7
+
8
+ - ✅ **Fase 0 (contratos):** `backend/app/schemas_lab.py`, `backend/app/lab/mapeo.py` +
9
+ `data/lab_mapeos/generico.json` (387 códigos) + 33 pruebas.
10
+ - ✅ **Fase 1 (MVP extremo-a-extremo):** auth de dispositivo (`security/device.py`), almacén
11
+ TTL (`lab/almacen.py`), endpoints `POST /api/lab/ingesta` + `GET /api/lab/resultados`
12
+ (`routers/lab.py`) + 6 pruebas; frontend `lab-import.ts` + `form-inject.ts` + campo de ID
13
+ de muestra en `index.html` + 5 pruebas; puente `bridge/` (transportes MLLP/serie,
14
+ adaptadores HL7 v2 y ASTM, reenviador con spool) + 8 pruebas. Verificado extremo-a-extremo:
15
+ HL7 del puente → validación + mapeo del backend (p. ej. `GLU 5.0 mmol/L → gluc 90.08`).
16
+ - ✅ **Fase 2 (endurecimiento):** cola `GET /api/lab/pendientes` + UI "Ver resultados
17
+ recibidos"; persistencia SQLite opcional (`lab_persistir`, tabla `resultados_lab`, recarga
18
+ al arrancar); `no_mapeados` mostrados en el toast de importación; re-drenado periódico del
19
+ spool en el puente (`spool_reintento_s`). +3 pruebas backend.
20
+ - ✅ **Fase 3 (listo para enchufar):** adaptadores por fabricante
21
+ `bridge/bridge/adaptadores/{abaxis,horiba,bionote}.py` + registro que selecciona el parser
22
+ por `fabricante`; tablas `data/lab_mapeos/{abaxis,horiba,bionote}.json` sembradas con los
23
+ códigos reales de cada panel; configuración **multi-equipo** (`MORPHOS_BRIDGE_INSTRUMENTOS`)
24
+ para varias máquinas en un puente. +3 pruebas backend, +5 puente.
25
+ **Pendiente sólo con hardware:** confirmar códigos OBX-3/R-3 y parámetros serie con una
26
+ captura real de cada equipo (ajuste = editar el JSON del fabricante).
27
+
28
+ ## Contexto
29
+
30
+ Hoy el veterinario teclea los valores a mano (o importa un PDF). El objetivo es que los
31
+ analizadores físicos alimenten los resultados directamente en la app. Los equipos objetivo
32
+ (este alcance) son **Abaxis (VetScan), Scil/Horiba y Bionote Vcheck V200**. Decisiones del
33
+ usuario: topología **nube + puente local**, **emparejamiento por ID de muestra/accesión**,
34
+ **sólo entrada de resultados** (inbound).
35
+
36
+ **IDEXX queda explícitamente fuera de alcance por ahora** (su ruta propietaria vía VetLab
37
+ Station requeriría middleware/carpeta-drop y acceso al equipo real) — pero la capa de
38
+ adaptadores se mantiene *pluggable* por fabricante para poder añadir IDEXX u otros más
39
+ adelante sin rediseñar.
40
+
41
+ Dos realidades condicionan el diseño:
42
+
43
+ 1. **Dos familias de protocolo entre los tres equipos.** Abaxis VetScan y Scil/Horiba
44
+ emiten **ASTM E1381/E1394 (LIS2-A2) sobre RS-232 / USB-serial** (el **Abaxis VS2 admite
45
+ ASCII Text / XML / ASTM**: se configura a **ASTM**, el formato que consume
46
+ `astm_generico.py`; ASCII/XML quedan como respaldo si un sitio no puede activar ASTM). El **Bionote
47
+ Vcheck V200 es estándar, no propietario: habla HL7 v2.6 PCD-01** (perfil IHE Patient
48
+ Care Device "Communicate PCD Data", es decir ORU^R01 sobre MLLP/TCP) **y POCT1-A**
49
+ (protocolo XML de punto de atención, CLSI POCT1-A2). Por tanto el puente necesita **dos
50
+ transportes**: `serial` (`pyserial`) para el ASTM de Abaxis/Horiba, y `mllp` (TCP) para
51
+ el HL7 v2.6 de Bionote. Un parser de registros ASTM se comparte entre Abaxis/Horiba; el
52
+ adaptador HL7 v2 (`hl7v2.py`) cubre Bionote con un manejo del perfil PCD-01 — **HL7 v2 ya
53
+ no es sólo "genérico/futuro", lo exige un equipo nombrado**. (POCT1-A queda como ruta
54
+ alternativa de Bionote, XML, diferida salvo que se prefiera sobre HL7.) Se necesita una
55
+ capa de adaptadores por analizador que normalice a un modelo de resultado canónico.
56
+ 2. **Morphos se despliega en HF Spaces** (un único puerto HTTP, sin alcance a la LAN, disco
57
+ efímero). El backend nunca debe abrir un listener TCP/MLLP crudo. En su lugar, un pequeño
58
+ **puente local** corre en la LAN de la clínica, habla el protocolo nativo de cada
59
+ analizador y reenvía los resultados normalizados al backend por HTTPS autenticado.
60
+
61
+ La clave de unión en toda la app es la **clave de analito** (atributo `name` del input ==
62
+ clave en `data/valores_referencia.json` == clave de entrada del motor de análisis; 90
63
+ claves, idénticas para canino/felino). `frontend/src/pdf-parser.ts` ya codifica el
64
+ vocabulario de alias + conversión de unidades y la ruta de inyección en el formulario
65
+ (`aplicarAFormulario` → `evaluar()`); lo reutilizamos.
66
+
67
+ ```
68
+ LAN de la clínica (bridge/) HF Spaces (FastAPI, un puerto HTTP)
69
+ analizadores → adaptadores → normaliza --HTTPS--> POST /api/lab/ingesta (API key equipo)
70
+ (HL7 MLLP / ASTM / serial) Bearer key GET /api/lab/resultados (cookie sesión)
71
+ almacén TTL en proceso (muestra_id → mapeado)
72
+ navegador consulta por ID de muestra → inyecta → evaluar()
73
+ ```
74
+
75
+ Dos zonas de confianza, dos mecanismos de auth: **puente→backend** usa una **API key** de
76
+ equipo (nunca la cookie del navegador); **navegador→backend** mantiene la cookie de sesión
77
+ + CSRF existentes. El mapeo código→analito y la conversión de unidades ocurren **en el
78
+ backend** (una única fuente de verdad, compartida por todo formato de entrada, testeable en
79
+ pytest).
80
+
81
+ ## Alcance de este plan
82
+
83
+ Las fases 0–1 (el MVP: contratos + extremo-a-extremo HL7 v2 / ASTM genérico → match por ID
84
+ de muestra → autorrelleno del formulario) están totalmente especificadas. Los adaptadores
85
+ por fabricante (fase 3) están estructurados pero cada uno depende de una captura del equipo
86
+ real, que no puede hacerse sólo desde código.
87
+
88
+ ---
89
+
90
+ ## Backend
91
+
92
+ ### 1. Modelos canónicos — nuevo `backend/app/schemas_lab.py`
93
+ Mantener los esquemas de lab fuera del ya enfocado `schemas.py`. Identificadores en español,
94
+ mismas convenciones `str,Enum`/`Literal`/`Field`/`@field_validator` que `schemas.py`.
95
+
96
+ - `ObservacionAnalizador`: `codigo_prueba: str` (código/nombre de prueba del fabricante,
97
+ crudo, sin mapear), `valor: str` (se mantiene como **string** en la ingesta — preserva
98
+ `"<0.1"`, `">1000"`, `"NEG"`, `"+++"`), `unidad: str = ""`, `rango_referencia`, `bandera`
99
+ (flag H/L de anormalidad, sólo informativo). `@field_validator` recorta + acota longitud.
100
+ - `PacientePistas`: opcionales libres `nombre_mascota`, `especie_texto`, `raza`, `sexo`,
101
+ `edad_texto` (para prerrellenar `pt-*`, nunca por encima de lo que teclea el veterinario).
102
+ - `ResultadoAnalizador` (una corrida de un instrumento): `muestra_id: str`
103
+ (`min_length=1, max_length=128` — la **clave de match**), `instrumento_id`,
104
+ `instrumento_modelo`, `fabricante`, `pistas_paciente: Optional[PacientePistas]`,
105
+ `observaciones: list[ObservacionAnalizador]`, `momento: datetime`,
106
+ `recibido_en` (default_factory ahora), `formato_origen: Literal["hl7v2","astm","json","manual"]`.
107
+ - Extensibilidad hacia salida (outbound): añadir `DireccionMensaje(str,Enum) {entrada,salida}`
108
+ + `direccion = entrada` en el envelope, y un stub comentado `OrdenTrabajo` — reserva el
109
+ namespace `/api/lab/*` y el esquema de almacén para órdenes de trabajo sin construirlas.
110
+ - Modelos de respuesta para el navegador: `ValorAnalito {clave, valor, unidad,
111
+ valor_original, unidad_original, es_semicuantitativo}` y `ResultadoMapeado {muestra_id,
112
+ instrumento_id, momento, analitos: dict[str, ValorAnalito], paciente: PacientePistas|None,
113
+ no_mapeados: list[str]}`. Esto es exactamente lo que inyecta el frontend.
114
+
115
+ ### 2. Capa de mapeo — nuevo paquete `backend/app/lab/` + datos `data/lab_mapeos/`
116
+ Dirigida por datos, portada desde `frontend/src/pdf-parser.ts`; las claves de salida deben
117
+ ser un subconjunto de las 90 claves canónicas (+ los nombres de select semicuantitativos
118
+ `uri-*`).
119
+
120
+ - `backend/app/lab/mapeo.py`:
121
+ - `mapear_observacion(obs, tabla)`: empareja `codigo_prueba` contra la tabla del fabricante
122
+ (código exacto primero, luego alias) → `clave` canónica (+ opcional `claveConv`).
123
+ - `convertir_unidad(clave, claveConv, valor_num, unidad)`: **port a Python de
124
+ `aplicarConversion` + `CONVERSIONES_UNIDADES`** (`pdf-parser.ts:107-186`) — factores
125
+ idénticos para que importación por PDF y por analizador coincidan.
126
+ - `parsear_valor(valor_str)`: port de `extraerValorYUnidad`/`parsearSemiCuantitativo`
127
+ (maneja `"<0.1"`, decimales con coma, `"NEG"`, `"+++"`→valores de opción `uri-*`);
128
+ descarta no-finitos / `<= 0` como el original.
129
+ - `mapear_resultado(res) -> ResultadoMapeado`: recorre observaciones, dedup primer-match-gana,
130
+ deriva % del diferencial de leucocitos desde absolutos + % de reticulocitos cuando sólo
131
+ hay absolutos (port del bloque de derivación en `parsearTextoLab`), recoge códigos
132
+ desconocidos en `no_mapeados`.
133
+ - `data/lab_mapeos/generico.json` — tabla canónica sembrada de LOINC + las abreviaturas ya
134
+ presentes en las regex de `DEFS_ANALITOS`. Forma: `{ "clave": {"codigos": ["GLU","GLUC"],
135
+ "unidad_defecto": "mg/dL", "claveConv": "..."} }`.
136
+ - `data/lab_mapeos/{abaxis,horiba,bionote}.json` — overrides por fabricante. El fabricante se
137
+ selecciona por `fabricante`/`instrumento_modelo`; desconocido → `generico.json`; código sin
138
+ match → genérico → `no_mapeados` (degradación elegante, refleja la filosofía RAG). Añadir un
139
+ equipo (incluido un futuro IDEXX) = soltar un nuevo JSON, no editar código. (Datos de
140
+ referencia no-PII; se cargan del lado del servidor vía `RAIZ_REPO / "data" / "lab_mapeos"`.)
141
+
142
+ ### 3. Almacén de resultados — nuevo `backend/app/lab/almacen.py`
143
+ **Almacén TTL en proceso**, no SQLite, para el MVP. Justificación: el disco de HF Spaces es
144
+ efímero, `instance/` se reconstruye al reiniciar, el Dockerfile corre **un único** worker de
145
+ uvicorn, y los resultados son de vida corta (se emparejan en minutos). Singleton
146
+ `AlmacenResultados` con `guardar`, `obtener`, `pendientes`, `_barrer` (TTL ~24 h + tope LRU),
147
+ `threading.Lock`. La clave es `muestra_id.strip().lower()` normalizada en lectura y escritura.
148
+ Importado como singleton de módulo por `routers/lab.py`; barrido periódico opcional vía una
149
+ tarea `asyncio` en `_lifespan` (`main.py` ya lo señala como el sitio para tareas de fondo).
150
+ - Durabilidad opcional (fase 2, tras `lab_persistir=False`): añadir una tabla `resultados_lab`
151
+ al `_ESQUEMA` de `db.py` y doble-escritura vía el patrón `_conexion()` existente. OFF por
152
+ defecto — sólo tiene sentido con un volumen persistente. Documentar el trade-off.
153
+
154
+ ### 4. Endpoints — nuevo `backend/app/routers/lab.py`, registrar en `main.py`
155
+ Registrar como los demás: `from .routers import auth, interpret, lab, papers` +
156
+ `app.include_router(lab.router, prefix="/api")`.
157
+
158
+ - **`POST /api/lab/ingesta`** (puente → backend). Firma como `interpret.py`:
159
+ `request: Request` primero, `cuerpo: ResultadoAnalizador`,
160
+ `_disp = Depends(verificar_dispositivo)` (nuevo; §Seguridad). **Sin**
161
+ `usuario_actual`/`verificar_csrf` (puente headless, sin cookie).
162
+ `@limiter.limit(obtener_config().limite_lab_ingesta)` (p. ej. `"120/minute"`, ráfagas).
163
+ Ejecuta `mapear_resultado` → `almacen.guardar` → devuelve `{ok, muestra_id,
164
+ analitos_mapeados, no_mapeados}`. Rechaza `observaciones` vacías/desmesuradas con
165
+ `HTTPException(422, "…español")`.
166
+ - **`GET /api/lab/resultados`** (navegador → backend). `request: Request`,
167
+ `muestra: str = Query(..., min_length=1, max_length=128)`,
168
+ `_sesion = Depends(usuario_actual)`, `@limiter.limit(...limite_lab_consulta)` (p. ej.
169
+ `"60/minute"`). Devuelve el `ResultadoMapeado` más reciente para `muestra` o `404` "No hay
170
+ resultados para esa muestra todavía."
171
+ - **`GET /api/lab/pendientes`** (opcional, fase 2, auth de sesión): recientes sin reclamar
172
+ `{muestra_id, instrumento_id, momento}` para una UI de cola de selección.
173
+
174
+ **Polling, no SSE**: el stack es request/response, HF Spaces complica el SSE de larga vida, y
175
+ la UX es "arma un ID de muestra, espera segundos." El frontend consulta `GET …?muestra=<id>`
176
+ cada ~3 s durante ~90 s; 404 = seguir esperando, 200 = aplicar. Cero infra de servidor nueva.
177
+ (SSE anotado como optimización futura.)
178
+
179
+ ### 5. Seguridad — `backend/app/config.py`, `backend/app/security/`
180
+ - Nueva dependencia `verificar_dispositivo(request, authorization: Header)` en `authz.py` (o
181
+ hermano `security/device.py`): `hmac.compare_digest` de tiempo constante de un token Bearer
182
+ contra las keys configuradas (misma disciplina que `verificar_password`).
183
+ - Añadidos en `config.py`: `lab_api_keys: list[str]` (desde `MORPHOS_LAB_API_KEYS`; soporta
184
+ rotación/por-clínica), `limite_lab_ingesta`, `limite_lab_consulta`, `lab_persistir`.
185
+ `validar_prod()` exige ≥1 key cuando la ingesta de lab está activada. La ingesta es
186
+ sin-cookie/sin-CSRF **por diseño** — la API key es la auth, sólo por HTTPS.
187
+ - **No aflojar CORS ni CSP.** El puente es servidor-a-servidor (CORS gobierna navegadores); el
188
+ navegador sólo llama a `/api/lab/*` del mismo origen con la cookie existente, así que
189
+ `connect-src 'self'` ya lo cubre — sin nueva cabecera de navegador, sin cambio de preflight.
190
+ - Los valores del analizador son **datos, nunca instrucciones**: pueblan inputs numéricos y
191
+ llegan al modelo sólo como los mismos `hallazgos` estructurados que ya produce el motor
192
+ determinista. Acotar todas las longitudes de string en `schemas_lab.py`.
193
+
194
+ ---
195
+
196
+ ## Frontend — nuevo `frontend/src/lab-import.ts`, ediciones a `index.html` + `main.ts`
197
+ Reflejar `pdf-parser.ts` exactamente (clave de unión = `name` de analito; `evaluar()` es el
198
+ disparador reactivo). El mapeo es del lado del servidor, así que el lado TS es delgado.
199
+
200
+ - `frontend/src/lab-import.ts` exporta `inicializarImportLab(evaluar)`:
201
+ - Input de ID de muestra + botón "Importar del analizador"; un lector de código de barras
202
+ simplemente teclea en el campo + Enter. Al armar, consulta `GET
203
+ /api/lab/resultados?muestra=<id>` con `fetch(..., {credentials:'include'})` cada 3 s hasta
204
+ ~90 s.
205
+ - En 200: inyecta `ResultadoMapeado.analitos` reusando la lógica de inyección —
206
+ `document.querySelector('[name="${clave}"]').value = valor` para números, match de opción
207
+ para selects `uri-*`, luego `evaluar()`; aplica las pistas `paciente` a `pt-*` como
208
+ `aplicarPacienteAFormulario`. **Refactor pequeño recomendado:** extraer
209
+ `aplicarAFormulario` + `aplicarPacienteAFormulario` de `pdf-parser.ts` a un
210
+ `frontend/src/form-inject.ts` compartido e importar desde ambos, evitando duplicación.
211
+ Reusar `mostrarToast` para "N valores importados del analizador."
212
+ - Marcar los campos importados con un resaltado transitorio para que el veterinario vea qué
213
+ cambió; el toast debe indicar que los valores se autorrellenaron y **deben verificarse**
214
+ (refuerza la postura de seguridad existente `requiere_derivacion`).
215
+ - `index.html`: añadir un campo de ID de muestra + control de importación en el panel de
216
+ paciente (`#panel-paciente`, junto a `pt-*`), siguiendo la convención existente
217
+ `.btn-importar-pdf`/`data-panel`. Sin cambio de CSP.
218
+ - `frontend/src/main.ts`: importar y llamar `inicializarImportLab(evaluar)` junto a la línea
219
+ existente `inicializarParserPdf(evaluar)` (~L169), pasando la misma referencia `evaluar`.
220
+
221
+ ---
222
+
223
+ ## Puente local — nuevo `bridge/` de nivel superior (proyecto uv separado)
224
+ Python + uv para coincidir con el tooling del repo y reusar las formas canónicas; su propio
225
+ `bridge/pyproject.toml` para que las deps de serial/HL7 nunca inflen la imagen de HF Spaces.
226
+
227
+ ```
228
+ bridge/
229
+ pyproject.toml # uv: httpx, pyserial, python-hl7 (o hl7apy); pytest grupo dev
230
+ bridge/config.py # BridgeConfig(BaseSettings) env_prefix MORPHOS_BRIDGE_: morphos_url, api_key, instrumentos[]
231
+ bridge/modelo.py # forma canónica ResultadoAnalizador
232
+ bridge/transporte/
233
+ serial.py # lector pyserial → tramas crudas (Abaxis/Horiba ASTM) [FASE 1]
234
+ mllp.py # servidor MLLP TCP (\x0b..\x1c\x0d) → tramas crudas (Bionote HL7) [FASE 1]
235
+ bridge/adaptadores/
236
+ base.py # AdaptadorBase: generador async → yields ResultadoAnalizador
237
+ astm_generico.py # ASTM E1381/E1394 (STX/ETX/checksum, registros H/P/O/R/L) [FASE 1]
238
+ hl7v2.py # parsea ORU^R01 (v2.6 PCD-01) → canónico; requerido por Bionote [FASE 1]
239
+ registro.py # fabricante → parser (selección automática) [HECHO]
240
+ abaxis.py # VetScan sobre astm_generico (fabricante=abaxis) [HECHO]
241
+ horiba.py # Scil/Horiba sobre astm_generico (fabricante=horiba) [HECHO]
242
+ bionote.py # Vcheck V200 sobre hl7v2 (fabricante=bionote, PCD-01) [HECHO]
243
+ bridge/normalizador.py # salida del adaptador → ResultadoAnalizador validado (+ pistas de paciente)
244
+ bridge/reenviador.py # httpx Bearer POST /api/lab/ingesta, backoff exp, cola spool en disco, idempotencia mensaje_id
245
+ bridge/main.py # supervisa adaptadores configurados → normalizador → reenviador
246
+ bridge/tests/ bridge/README.md # cableado por analizador + checklist de validación
247
+ ```
248
+ - Separar **transporte** (serial / MLLP — cómo llegan los bytes) de **adaptador** (ASTM / HL7
249
+ / fabricante — cómo las tramas se vuelven un `ResultadoAnalizador`), de modo que
250
+ Abaxis/Horiba/Bionote reusen `transporte/serial.py` y difieran sólo en el parseo. Cada
251
+ adaptador es un generador async que emite `ResultadoAnalizador`.
252
+ - `reenviador.py` es el núcleo de fiabilidad: spool local para no perder nada cuando HF esté
253
+ brevemente inalcanzable; reintentos idempotentes vía `mensaje_id` de cliente (el almacén es
254
+ último-gana por `muestra_id` de todos modos).
255
+ - **Cada uno de Abaxis / Horiba / Bionote necesita su propio adaptador + validación en sitio
256
+ contra el instrumento físico**, pero el esfuerzo difiere: Abaxis y Horiba (ASTM sobre
257
+ serie) requieren confirmar parámetros serie — baud/paridad/framing — y el layout de
258
+ registros (y en el Abaxis VS2, seleccionar la salida **ASTM** frente a sus modos ASCII/XML);
259
+ **Bionote (HL7 v2.6 PCD-01 sobre MLLP)** sólo requiere confirmar el transporte
260
+ (puerto MLLP/IP), el juego de identificadores OBX-3 del perfil PCD-01 (a menudo LOINC o
261
+ nomenclatura IEEE 11073/MDC) y las unidades — no hay que revertir ningún formato
262
+ propietario. El README incluye un checklist por analizador (capturar 3–5 corridas reales,
263
+ confirmar transporte + campo de ID de muestra + unidades, diff al JSON del fabricante).
264
+ Abaxis y Horiba caen ambos en `astm_generico.py`; **Bionote se apoya en `hl7v2.py`** con un
265
+ fino mapeo del perfil PCD-01.
266
+
267
+ ---
268
+
269
+ ## Entrega por fases
270
+ - **Fase 0 — Contratos (sólo backend, totalmente testeable):** `schemas_lab.py`,
271
+ `data/lab_mapeos/generico.json`, `lab/mapeo.py` + tests unitarios.
272
+ - **Fase 1 — MVP extremo-a-extremo:** auth de equipo + config keys; `routers/lab.py`;
273
+ `lab/almacen.py`; registrar en `main.py`; frontend `lab-import.ts` + campo de ID de muestra
274
+ + cableado en `main.ts`; esqueleto del puente con `transporte/serial.py` +
275
+ `transporte/mllp.py` + `adaptadores/astm_generico.py` + `adaptadores/hl7v2.py` +
276
+ `reenviador.py`. Entregable: una máquina ASTM genérico (serial) o HL7 v2 → match por ID de
277
+ muestra → autorrelleno.
278
+ - **Fase 2 — Endurecimiento:** durabilidad del spool, UI de cola `/api/lab/pendientes`, flag
279
+ opcional de persistencia SQLite, exponer `no_mapeados` al veterinario.
280
+ - **Fase 3 — Adaptadores por fabricante:** `abaxis.py`, `horiba.py`, `bionote.py` + sus JSON,
281
+ cada uno condicionado a captura del equipo real. (IDEXX diferido, fuera de alcance.)
282
+
283
+ ## Riesgos
284
+ 1. **Dialectos ASTM de Abaxis/Horiba (el mayor, ahora reducido).** Quitar IDEXX elimina el
285
+ riesgo de middleware propietario, y confirmar que Bionote es HL7 v2.6 PCD-01 estándar (no
286
+ propietario) elimina el riesgo de revertir un formato de línea. Riesgo residual principal:
287
+ los dialectos ASTM de Abaxis/Horiba varían en layout de registro y parámetros serie
288
+ (baud/paridad), y necesitan una captura del equipo real para cerrarse. Para Bionote el
289
+ riesgo es menor y acotado: confirmar el transporte (puerto MLLP) y el juego de códigos
290
+ OBX-3 del perfil PCD-01. El JSON dirigido por datos + la separación transporte/adaptador
291
+ contienen el radio de impacto, pero el framing/las unidades aún se validan con capturas
292
+ físicas.
293
+ 2. **Efimeridad de HF Spaces:** el almacén en proceso pierde resultados al reiniciar.
294
+ Aceptable (resultados de vida corta + reenviables desde el spool del puente) pero debe
295
+ documentarse; no prometer historial durable sin un volumen persistente.
296
+ 3. **Disciplina de ID de muestra:** el match depende de que el mismo ID esté en la máquina y
297
+ en la UI; el escaneo de código de barras mitiga typos; normalización centralizada en
298
+ `almacen.py`.
299
+ 4. **Suposición de un solo worker:** el almacén en proceso sólo es correcto con un worker de
300
+ uvicorn (actualmente cierto). Si se añaden workers, mover el almacén a SQLite/caché
301
+ compartida — señalarlo en `almacen.py`.
302
+ 5. **Deriva en conversión de unidades:** los factores viven en `pdf-parser.ts` y en
303
+ `mapeo.py` — mitigar con tests de paridad.
304
+
305
+ ## Verificación
306
+ - **pytest backend** (`TestClient(app)` + `monkeypatch`, reflejando `backend/tests/test_api.py`):
307
+ - `test_lab_mapeo.py`: código de fabricante → clave canónica; conversiones de unidad
308
+ aseverando **paridad** con los factores de `pdf-parser.ts` (p. ej. glucosa `mmol/L→mg/dL
309
+ ×18.016`, creatinina `µmol/L ÷88.4`); semicuant `"+++"→"+++"`; derivación del diferencial
310
+ de leucocitos; `no_mapeados`.
311
+ - `test_lab_ingesta.py`: ingesta con Bearer key válida → 200 + almacenado; key errónea/sin
312
+ key → 401; ingesta y luego `GET /api/lab/resultados?muestra=…` con cookie de sesión →
313
+ analitos mapeados; consulta sin sesión → 401; muestra desconocida → 404.
314
+ - Fixtures: tramas HL7 ORU^R01 (incl. una v2.6 PCD-01 estilo Bionote) + ASTM crudas y sus
315
+ dicts canónicos esperados en `backend/tests/fixtures/lab/`.
316
+ - **pytest puente** (`bridge/tests/`): fixture HL7/ASTM cruda → `ResultadoAnalizador`
317
+ normalizado; lógica de reintento/idempotencia del `reenviador` con transporte httpx mockeado.
318
+ - **Vitest frontend** (`frontend/src/lab-import.test.ts`, fixture de formulario jsdom como los
319
+ tests del motor): un JSON `ResultadoMapeado` fija los inputs `[name=…]` + selects `uri-*`
320
+ correctos y llama `evaluar` una vez.
321
+ - **Extremo-a-extremo manual (MVP):** correr backend (`make dev`) + una instancia del puente
322
+ local; enviar una muestra HL7/ASTM capturada a través del puente; en la UI teclear el ID de
323
+ muestra y confirmar que el formulario se autorrellena, los campos se resaltan y el análisis
324
+ se re-ejecuta. Comandos: `make backend-test`, `make frontend-test`, `make frontend-build`.
325
+ - **Fuera de alcance automatizado (explícito):** validación con equipo real de
326
+ Abaxis/Horiba/Bionote — la suite automatizada usa fixtures capturadas; la corrección final
327
+ de parámetros serie/campo/unidad/ID de muestra sólo se confirma contra los instrumentos
328
+ físicos en la LAN de la clínica.
MIGRACION.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Migración de Morphos — estado y guía
2
+
3
+ Modernización del stack según `PLAN_MODERNIZACION.md`. Decisiones: IA híbrida
4
+ (medGemma privado por defecto + Claude opcional), backend **Python/FastAPI con uv**,
5
+ frontend **incremental Vite + TypeScript**, despliegue en **HF Spaces** con índice RAG
6
+ horneado en la imagen (sin almacenamiento persistente de pago).
7
+
8
+ ## Estructura nueva
9
+
10
+ ```
11
+ frontend/ Vite + TS. Motor portado + suite de regresión (Vitest).
12
+ src/analisis.ts Puerto fiel de js/analisis.js (tipado).
13
+ src/ia.ts Cliente tipado de /api/interpret (render estructurado).
14
+ tests/ 27 pruebas dorada s del motor.
15
+ backend/ FastAPI (uv). IA estructurada, RAG, seguridad.
16
+ app/schemas.py Salida clínica estructurada (Pydantic) → elimina limpiarRespuesta.
17
+ app/ai/ medgemma.py, claude.py, prompt.py, service.py.
18
+ app/rag/ retriever.py (degrada sin índice), ingest.py.
19
+ app/routers/ interpret.py, papers.py, auth.py.
20
+ app/security/ authz, rate_limit, session, headers.
21
+ tests/ 15 pruebas (esquema, prompt, RAG, API+seguridad).
22
+ evals/ Dataset dorado + run_evals.py (puerta CI) + juez LLM + promptfoo.
23
+ books/ Corpus con licencia (gitignored). Ver books/README.md.
24
+ instance/ BD de usuarios + índice RAG (fuera del webroot; gitignored).
25
+ bridge/ Puente local (proyecto uv aparte): lee analizadores (ASTM/HL7 v2) en la
26
+ LAN de la clínica y reenvía a /api/lab/ingesta. Ver INTEGRACION_ANALIZADORES.md.
27
+ ```
28
+
29
+ ## Integración de analizadores de laboratorio
30
+
31
+ Fases 0-1 implementadas (ingesta de resultados de equipos → autorrelleno del formulario por
32
+ ID de muestra). Backend: `app/schemas_lab.py`, `app/lab/` (mapeo + almacén TTL),
33
+ `app/routers/lab.py`, `app/security/device.py` (auth por API key). Frontend: `lab-import.ts`
34
+ + `form-inject.ts`. Puente: `bridge/`. Mapeos código→analito en `data/lab_mapeos/`. Detalle
35
+ y fases pendientes en **INTEGRACION_ANALIZADORES.md**.
36
+
37
+ ## Cómo ejecutar
38
+
39
+ ```bash
40
+ # Frontend: pruebas del motor y build
41
+ make frontend-install
42
+ make frontend-test # 27/27
43
+ make frontend-build # → dist/
44
+
45
+ # Backend: sync (uv) y pruebas
46
+ make backend-sync
47
+ make backend-test # 15/15
48
+ make dev # uvicorn en :8000
49
+
50
+ # Evals (puerta de CI)
51
+ make evals # run_evals.py --simular → ✅/❌ + exit code
52
+
53
+ # RAG (cuando haya libros en books/)
54
+ make ingest # construye instance/rag_index con el grupo 'rag'
55
+
56
+ # Docker (multi-stage: build frontend + backend uv)
57
+ make docker-build
58
+ ```
59
+
60
+ ## Qué se ha implementado y verificado
61
+
62
+ - ✅ **Motor portado a TS** con 27 pruebas de regresión (parity con el JS original) y
63
+ typecheck limpio. Es la red de seguridad de la migración.
64
+ - ✅ **Backend FastAPI** con **salida estructurada validada** (Pydantic) que sustituye la
65
+ limpieza por regex; clientes **medGemma** (Ollama, plantilla de chat + `format` JSON
66
+ Schema, sin inyección de `<unused95>`) y **Claude** (tool use). 15 pruebas verdes.
67
+ - ✅ **Seguridad**: `/api/interpret` y `/api/papers` requieren sesión (cerrado el acceso
68
+ anónimo); CORS bloqueado; sesiones firmadas HttpOnly/SameSite/Secure; CSRF de doble
69
+ token; throttling de login; validación de imágenes; cabeceras de seguridad; BD e índice
70
+ RAG **fuera del webroot**. Verificado: 401 sin sesión, 403 sin CSRF, flujo completo OK.
71
+ - ✅ **RAG**: pipeline de ingesta + recuperador con citas que **degrada a modo sin-RAG**
72
+ si faltan deps o índice (probado). Índice horneado en la imagen.
73
+ - ✅ **Evals**: dataset dorado, comprobaciones deterministas (recall diferenciales,
74
+ cobertura, derivación, idioma, **seguridad tolerancia-cero**), juez LLM (Claude),
75
+ promptfoo y **puerta de CI** (exit≠0 ante regresión) — verificado que bloquea.
76
+ - ✅ **Puente Node** que reusa `analisis.ts` como única fuente de verdad para generar los
77
+ hallazgos deterministas en las evals.
78
+
79
+ ## Hecho en el último incremento
80
+
81
+ - ✅ **Puerto TS de todos los módulos UI** (`ui`, `pdf-parser`, `main`, `auth`, `papers`,
82
+ `tooltip`) + helper `dom.ts`. `auth.ts`/`papers.ts` usan los endpoints FastAPI.
83
+ - ✅ **`index.html` recableado** a `/frontend/src/main.ts` (Vite lo empaqueta). La app corre
84
+ end-to-end sobre el stack nuevo — verificado en navegador: motor, PDF, registro/login
85
+ real (sesión + CSRF) y el botón IA llamando a `/api/interpret`.
86
+
87
+ ## Pendiente (siguiente incremento)
88
+
89
+ - **Retirar** `api/*.php` y `js/*.js` legacy: ya son código muerto (no se cargan). Borrado
90
+ seguro cuando se confirme que no se necesitan de referencia.
91
+ - ✅ **Ruta de IA funcionando en vivo**: la ruta `medgemma` usa por defecto el HF Space
92
+ (Gradio) donde está alojado el modelo — `app/ai/hf_space.py` porta el flujo de
93
+ `hf_proxy.php` (upload → analyze → SSE) y envuelve el texto en el esquema. Verificado en
94
+ navegador: interpretación real renderizada con el aviso de derivación. (Alternativas por
95
+ config: Ollama local si se vacía `MORPHOS_HF_SPACE_URL`, o Claude con API key.)
96
+ - **Config ESLint/Prettier** (falta el archivo de configuración; ya está la dependencia).
97
+ - **Retriever RAG**: añadir búsqueda híbrida BM25 + rerank.
98
+ - **Ingesta real** de los libros con licencia (`make ingest`) y activar Ragas sobre el
99
+ índice poblado.
100
+ - **Aumentar el dataset** de evals con más casos validados por veterinario.
101
+ - Fijar `MORPHOS_SESSION_SECRET` y `MORPHOS_COOKIE_SECURE=true` en los secrets del Space.
PLAN_MODERNIZACION.md ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Morphos — Modernization, AI/RAG, Evals & Security Plan
2
+
3
+ > ## Estado de implementación (auditoría 2026-07-26)
4
+ >
5
+ > Leyenda: ✅ hecho y verificado · 🟡 parcial (núcleo hecho, falta un subpunto) · ⬜ pendiente
6
+ >
7
+ > | Fase | Estado | Resumen |
8
+ > |---|---|---|
9
+ > | 1 — Frontend (Vite/TS) | ✅ | **Todos** los módulos portados a TS (analisis, ui, pdf-parser, auth, papers, main, tooltip, ia); `index.html` recableado al bundle TS; 32 tests ✅. Menor: falta archivo de config ESLint/Prettier. |
10
+ > | 2 — Capa IA (FastAPI) | ✅ | Endpoints, salida estructurada, clientes medGemma/Claude, prompt endurecido, validación imágenes. Orquestación con `ClienteModelo` propio (no la librería Pydantic AI). |
11
+ > | 3 — RAG | ✅ | Ingesta + retriever con citas + degradación ✅. **Nuevo:** búsqueda híbrida (denso + BM25/FTS fusionados con RRF) + rerank cross-encoder `bge-reranker-v2-m3`, ambos conmutables; traducción ES→EN de consulta. **Corpus real ingerido**: 6763 fragmentos de 2 libros en `instance/rag_index/` (LanceDB, 69 MB, bge-m3). |
12
+ > | 4 — Evals | 🟡 | Dataset dorado (17 casos), juez clínico, promptfoo, puerta CI ✅ (bloqueo verificado), arnés de evaluación de recuperación aislada (`run_retrieval_eval.py`). Faltan: **código Ragas (0 líneas)**, human-in-the-loop, split reservado, informe por-ruta/por-corpus. |
13
+ > | 5 — Seguridad | ✅ | Guarda auth, rate limit, CORS, CSRF, BD/RAG fuera del webroot, cabeceras, validación imágenes — verificado. Menor: rate limit por-usuario y redirección HTTP→HTTPS. |
14
+ >
15
+ > **Verificado ejecutando (2026-07-26):** 32 tests frontend (Vitest), **97 tests backend**
16
+ > (pytest, incluye retriever híbrido + integración con índice real + ingesta/troceo +
17
+ > traducción de consulta), ruff limpio, typecheck limpio. Previamente verificado en el
18
+ > navegador contra el backend FastAPI: motor de patrones (anemia microcítica), import de PDF
19
+ > (parser TS portado), registro/login real (sesión + CSRF), y el botón "Análisis IA"
20
+ > llamando a `POST /api/interpret` con token CSRF. Puerta de evals bloquea (exit≠0), 401 sin
21
+ > sesión, 403 sin CSRF. **No verificado en vivo:** salida estructurada renderizada con un
22
+ > modelo real (falta `ollama pull` de medGemma), promptfoo ejecutado, rechazo CORS de
23
+ > orígenes externos, 429 bajo carga, `/security-review`.
24
+ >
25
+ > **Fuera de este plan:** la integración de analizadores de laboratorio (ASTM/HL7) ha
26
+ > avanzado en paralelo — `backend/app/routers/lab.py`, `bridge/`, `data/lab_mapeos/`,
27
+ > `tests/test_lab_*.py`, `frontend/tests/lab-import.test.ts`. Ver `INTEGRACION_ANALIZADORES.md`.
28
+ >
29
+ > **Legacy pendiente de retirar:** los 8 `js/*.js` y los 5 `api/*.php` siguen en el repo pero
30
+ > YA NO se cargan (`index.html` no referencia ninguno). Son código muerto a eliminar.
31
+ >
32
+ > ### Lo que queda, en orden sugerido
33
+ > 1. **Borrar el legacy** `js/` + `api/` — riesgo cero, ya es código muerto.
34
+ > 2. **Ragas + split reservado**, y ejecutar la suite completa de evals contra el índice real
35
+ > — el mayor valor pendiente, y ya desbloqueado por el corpus ingerido.
36
+ > 3. Config ESLint/Prettier; rate limit por-usuario en `/api/interpret`; informe de evals
37
+ > por-ruta-de-modelo y por-versión-de-corpus.
38
+ > 4. Human-in-the-loop (mayor alcance, requiere decisiones de producto).
39
+ >
40
+ > Ver detalle marcado por tarea abajo y `MIGRACION.md` para el siguiente incremento.
41
+
42
+ ## Context
43
+
44
+ Morphos is a veterinary diagnostic-support SPA (canine/feline) that detects clinical
45
+ patterns from lab values in real time and optionally asks an AI model (medGemma 1.5 4B
46
+ via a HuggingFace Gradio Space, or local Ollama) for a clinical interpretation. It ships
47
+ as vanilla JS (no build step) + thin PHP proxies, deployed as Docker on HF Spaces.
48
+
49
+ The app works, but three things block it from being a trustworthy diagnostic assistant:
50
+
51
+ 1. **The AI layer fights the model instead of controlling it.** `js/ia.js` builds the
52
+ prompt by string concatenation, injects a raw `<unused95>` control token, and post-
53
+ processes output through `limpiarRespuesta()` — ~70 lines of regex hacks stripping
54
+ control tokens, English-only reasoning, LaTeX, and looped paragraphs. These are
55
+ symptoms of an uncontrolled inference path (raw Gradio Space, no chat template, no
56
+ structured output, no grounding).
57
+ 2. **There are zero evals.** For a tool that suggests differentials on real patients,
58
+ there is no measurement of accuracy, groundedness, or safety. This is the highest-risk
59
+ gap.
60
+ 3. **Security holes**: the AI and papers proxies are unauthenticated with open CORS
61
+ (anyone can call `api/hf_proxy.php` and burn the HF key), there is no rate limiting
62
+ anywhere, the SQLite DB with password hashes may be web-reachable, and MySQL defaults
63
+ to `root`/empty-password.
64
+
65
+ The vet-validated pattern engine (`js/analisis.js`, `data/*.json`) is the crown jewel and
66
+ must be preserved exactly through the migration.
67
+
68
+ ### Decisions (confirmed with the user)
69
+
70
+ - **AI model strategy: Hybrid.** Keep self-hosted medGemma/Ollama as the private default;
71
+ add an optional hosted frontier-model (Claude) route for higher accuracy. The eval judge
72
+ always uses a strong model.
73
+ - **Backend: Python FastAPI** for the AI/RAG/eval service (best ecosystem).
74
+ - **Frontend: Incremental.** Add Vite + TypeScript, port modules (especially `analisis.js`)
75
+ to typed TS with unit tests, keep the existing DOM UI. No React rewrite.
76
+ - **Hosting: stay on HF Spaces (PRO).** Because the RAG corpus is static licensed books,
77
+ build the vector index offline and bake it read-only into the Docker image — **no paid
78
+ persistent storage needed**. App Space (CPU-basic, FastAPI + static assets) + the existing
79
+ ZeroGPU medGemma Space + optional Claude route.
80
+
81
+ > **HF hosting check:** Free Spaces have an ephemeral filesystem; persistent storage is a
82
+ > paid add-on ($5/mo for 20 GB up). PRO ($9/mo) gives 10 ZeroGPU/Docker Spaces + 1 TB repo
83
+ > storage. Since the RAG corpus is static, the vector index is baked into the image and
84
+ > needs no persistent-storage purchase — the plan fits inside the PRO subscription.
85
+
86
+ ---
87
+
88
+ ## Target Architecture
89
+
90
+ ```
91
+ ┌────────────────────────────────────────────────────────────────┐
92
+ │ App Space (HF, CPU-basic, Docker) │
93
+ │ │
94
+ │ Frontend (Vite build → static) Backend (FastAPI) │
95
+ │ ├─ analisis.ts (ported engine, tested) ├─ /api/auth (session)│
96
+ │ ├─ ui.ts / pdf-parser.ts / papers.ts ├─ /api/interpret (AI) │
97
+ │ └─ ia.ts (calls /api/interpret) ├─ /api/papers (PubMed)│
98
+ │ ├─ RAG retriever │
99
+ │ │ └─ baked vector idx │
100
+ │ └─ rate limit + authz │
101
+ │ │
102
+ │ Persistence: SQLite (users) outside webroot │
103
+ └───────────────┬──────────────────────────────┬──────────────────┘
104
+ │ hybrid route │
105
+ ┌──────────▼─────────┐ ┌──────────▼──────────┐
106
+ │ medGemma ZeroGPU │ │ Claude API (opt.) │
107
+ │ Space (private) │ │ (higher accuracy) │
108
+ └────────────────────┘ └─────────────────────┘
109
+ ```
110
+
111
+ Monorepo layout after migration:
112
+
113
+ ```
114
+ morphos/
115
+ ├── frontend/ # Vite + TS; ported js/ modules, unit tests (Vitest)
116
+ ├── backend/ # FastAPI: auth, interpret, papers, rag, security
117
+ │ ├── app/
118
+ │ │ ├── main.py # FastAPI app, middleware (CORS, rate limit)
119
+ │ │ ├── routers/ # auth.py, interpret.py, papers.py
120
+ │ │ ├── ai/ # model clients (medgemma, claude), prompt, schema
121
+ │ │ ├── rag/ # ingestion, chunking, embeddings, retriever
122
+ │ │ └── security/ # authz deps, rate limiter, session
123
+ │ └── tests/
124
+ ├── evals/ # golden dataset, promptfoo + Ragas configs, CI gate
125
+ ├── data/ # valores_referencia.json, alteraciones.json (unchanged)
126
+ │ └── rag_index/ # baked vector index (built in CI, shipped read-only)
127
+ ├── Dockerfile # multi-stage: build frontend + index, run FastAPI+static
128
+ └── docker-entrypoint.sh
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Phase 1 — Frontend modernization (incremental, low risk) — ✅ (salvo config ESLint)
134
+
135
+ Goal: add tooling and types **without changing behavior**. The engine is vet-validated;
136
+ migration must be behavior-preserving and proven so by tests.
137
+
138
+ - 🟡 Introduce `frontend/` with **Vite + TypeScript + Vitest**. Add `package.json`,
139
+ `tsconfig.json`, ESLint/Prettier. Keep Spanish naming per `CLAUDE.md`; no alignment
140
+ spaces; no `!important` in CSS.
141
+ — *Vite + TS + Vitest ✅ (`frontend/package.json`, `tsconfig.json`, `vite.config.ts`).
142
+ Falta: no hay archivo de config ESLint/Prettier (sólo la dependencia y el script `lint`).*
143
+ - ✅ Port `js/analisis.js` → `frontend/src/analisis.ts` first. It is pure/stateless (single
144
+ export `analizarResultados`), so it types cleanly and is fully unit-testable. **Write a
145
+ golden test suite** that pins current behavior (severity classification, age/breed/sex
146
+ adjustments, all ~60 pattern rules) using fixtures derived from `data/*.json`. This suite
147
+ doubles as the regression net for the whole migration.
148
+ — *`analisis.ts` + `tipos.ts` + 27 tests Vitest (32 en total con `lab-import.test.ts`),
149
+ typecheck limpio. Verificado.*
150
+ - ✅ Port remaining modules (`main.js`, `ui.js`, `ia.js`, `pdf-parser.js`, `auth.js`,
151
+ `papers.js`, `tooltip.js`) to `.ts`. Preserve the getter-callback wiring in `main.js` and
152
+ the `imagenesDataUrl` / `capturasMicroscopio` shared arrays in `ui.js`.
153
+ — *Todos portados (`frontend/src/*.ts`) + helper `dom.ts`. Typecheck limpio; verificados
154
+ en el navegador (motor, PDF, auth). `auth.ts`/`papers.ts` apuntan a los endpoints nuevos.*
155
+ - ✅ Keep `index.html` markup and `css/styles.css` as-is initially; wire the Vite-built bundle
156
+ in place of the `?v=N` module tag. Remove the leftover `console.log` debug blocks in
157
+ `ia.js`.
158
+ — *`index.html` carga `/frontend/src/main.ts` (Vite lo empaqueta). El `ia.js` legacy ya no
159
+ se carga (código muerto); el nuevo `ia.ts` no tiene `console.log`.*
160
+ - ✅ Update stale `CLAUDE.md` facts (analisis.js is 817 lines / 90 analytes / 78 clinical
161
+ entities; auth + papers layers exist).
162
+
163
+ Files: new `frontend/**`; modify `index.html` script tag; delete debug logging in `ia.js`.
164
+
165
+ ---
166
+
167
+ ## Phase 2 — AI layer rebuilt on FastAPI (replaces the PHP proxy + regex cleanup) — ✅
168
+
169
+ Goal: a controlled, model-agnostic inference path with **structured, validated output**,
170
+ so `limpiarRespuesta()` becomes unnecessary.
171
+
172
+ - ✅ New FastAPI service (`backend/`). Replace `api/hf_proxy.php` with `POST /api/interpret`
173
+ and `api/papers_proxy.php` with `GET /api/papers`. Keep the PubMed disk-cache logic
174
+ (port `papers_proxy.php` behavior). Keep `api/auth.php` semantics but reimplement in
175
+ FastAPI (Phase 5 hardens it).
176
+ — *Endpoints creados; caché de PubMed portada; auth reimplementada. Los `.php` legacy aún
177
+ no se han borrado (siguen en `api/`) — retirada pendiente hasta migrar la UI.*
178
+ - 🟡 **Orchestration: Pydantic AI** (model-agnostic, typed, structured outputs). Define a
179
+ `ModelClient` interface with two implementations: `MedGemmaClient` (self-hosted default;
180
+ talk to the ZeroGPU Space / Ollama using the **proper chat template**, not raw
181
+ `<unused95>` injection) and `ClaudeClient` (hybrid opt-in via the Anthropic SDK). Backend
182
+ selection replaces the current `localStorage` `mx-ia-backend` toggle and is enforced
183
+ server-side.
184
+ — *Desviación: se implementó un protocolo `ClienteModelo` propio (`app/ai/base.py`) con
185
+ `MedGemmaClient` + `ClaudeClient` y selección server-side, en vez de la librería Pydantic
186
+ AI. Cumple el objetivo (salida tipada/validada) con menos dependencias.*
187
+ - ✅ **Structured output schema** (Pydantic) — the core fix. Instead of free text, the model
188
+ returns:
189
+ `interpretacion` (es), `hallazgos_clave[]` (analyte, direccion, gravedad),
190
+ `diferenciales[]` (nombre, probabilidad, evidencia[], citas[] → RAG sources),
191
+ `siguientes_pruebas[]`, `confianza`, `requiere_derivacion` (safety flag), `idioma="es"`.
192
+ Enforce with structured/JSON output; validate server-side; on validation failure, retry
193
+ or return a typed error — **never** ship unparsed model text. This deletes the entire
194
+ `limpiarRespuesta` regex pile.
195
+ — *`app/schemas.py::InterpretacionClinica`; reintento + error tipado en `service.py`.*
196
+ - ✅ Prompt construction moves server-side into `backend/app/ai/prompt.py`, assembled from
197
+ patient data + flagged findings + detected patterns (reuse the enrichment already in
198
+ `construirPrompt`) + **retrieved RAG context** (Phase 3). System prompt hardened: Spanish-
199
+ only, scope limits, mandatory hedging/deferral language, citation requirement,
200
+ injection resistance for free-text `signos-clinicos` and citology images.
201
+ - ✅ Vision path preserved: medGemma multimodal for citology images; keep the 1024px client
202
+ resize and ≤4 images limit, but validate size/count/mime server-side (Phase 5).
203
+ - ✅ Frontend `ia.ts` becomes a thin typed client of `/api/interpret` that renders the
204
+ structured result (findings, ranked differentials **with citations**, next steps, a
205
+ visible "requiere derivación veterinaria" banner when flagged).
206
+ — *Código completo (`frontend/src/ia.ts`), cableado vía el bundle TS y ejercitado en el
207
+ navegador. Falta ver el render con salida de un modelo real (`ollama pull` pendiente).*
208
+
209
+ Files: new `backend/app/ai/**`, `backend/app/routers/interpret.py`, `.../papers.py`;
210
+ rewrite `frontend/src/ia.ts`; remove `api/hf_proxy.php`, `api/papers_proxy.php`.
211
+
212
+ ---
213
+
214
+ ## Phase 3 — RAG layer (structure now; books ingested later) — ✅
215
+
216
+ Goal: ground interpretations in the licensed veterinary literature with **verifiable
217
+ citations**, built so the corpus can be dropped in when provided.
218
+
219
+ - ✅ **Stack:** LlamaIndex for ingestion/retrieval; **LanceDB** (embedded, file-based → bakes
220
+ cleanly into the image, zero runtime services) as the vector store. Embeddings via a
221
+ **self-hosted multilingual model** (e.g. BGE-M3 through `sentence-transformers`) to keep
222
+ the privacy positioning — no document content leaves the box at index time.
223
+ — *Dependencias en el grupo `rag` de `pyproject.toml`; ingesta usa pypdf +
224
+ sentence-transformers + lancedb directamente (llama-index-core disponible).*
225
+ - ✅ **Ingestion pipeline** (`backend/app/rag/ingest.py`, run offline/CI, not at request time):
226
+ PDF/EPUB → text extraction → **structure-aware chunking** (section/heading, ~512–1024
227
+ tokens, overlap) → metadata per chunk (`libro`, `edicion`, `capitulo`, `pagina`,
228
+ `especie?`) → embed → write LanceDB table under `data/rag_index/`. A manifest records
229
+ corpus version + hash for eval reproducibility.
230
+ — *Escribe en `instance/rag_index/` (no `data/`, por seguridad — ver Fase 5). Manifiesto ✅.*
231
+ - ✅ **Retriever** (`backend/app/rag/retriever.py`): hybrid search (dense + BM25/keyword),
232
+ metadata filtering by species, top-k with a rerank step; returns chunks **with source
233
+ metadata** so the interpret schema's `citas[]` point to `libro/edicion/página`. Retrieval
234
+ is triggered by detected patterns + flagged analytes (reuse `ultimoAnalisis`), so it
235
+ targets the patient's actual findings.
236
+ — *Denso + BM25/FTS fusionados con RRF + rerank con cross-encoder `bge-reranker-v2-m3`,
237
+ filtro por especie y citas ✅. Conmutables por `rag_hibrido`/`rag_rerank` (`config.py`), con
238
+ degradación en cascada: rerank → RRF → sólo denso. Añadido `traduccion_consulta.py`
239
+ (consulta ES → corpus EN). Cubierto por `test_retriever_hibrido.py` +
240
+ `test_retriever_integracion.py`.*
241
+ - ✅ **Copyright safety:** retrieval returns short grounding snippets to the model only; the
242
+ API never exposes full book text to the client, and the endpoint is auth-gated.
243
+ - ✅ **Ship path:** index built in CI and copied into the Docker image read-only. No HF
244
+ persistent storage required. Rebuild only when the corpus changes.
245
+ — *Dockerfile copia `instance/` de sólo lectura.*
246
+ - ✅ **Placeholder now:** commit the full pipeline + an empty/sample index and a
247
+ `books/README.md` describing the expected input layout, so ingesting the real books later
248
+ is a single `make ingest` step. The interpret path must degrade gracefully to no-RAG when
249
+ the index is empty.
250
+ — *Superado: ya no es un placeholder. **Corpus real ingerido** con `make ingest` — 6763
251
+ fragmentos de 2 libros (Fundamentals of Veterinary Clinical Pathology 3e; Veterinary
252
+ Hematology, Clinical Chemistry, and Cytology 3e) en `instance/rag_index/` (LanceDB 69 MB,
253
+ bge-m3, chunks 450 tok / solape 64, troceo estructural markdown, índice FTS, manifiesto con
254
+ `hash_corpus`). Degradación a sin-RAG sigue probada. El índice NO se comitea (queda fuera
255
+ del repo; se construye en CI y se copia a la imagen).*
256
+
257
+ Files: new `backend/app/rag/**` (`ingest.py`, `retriever.py`, `traduccion_consulta.py`),
258
+ `instance/rag_index/` (artefacto construido, gitignored — **no** `data/`, por seguridad),
259
+ `books/README.md`, `Makefile` (`ingest`, `retrieval-eval`).
260
+
261
+ ---
262
+
263
+ ## Phase 4 — Rigorous evals (the core requirement for a diagnostic tool) — 🟡
264
+
265
+ Goal: measure accuracy, groundedness, and safety continuously, and gate deploys on it.
266
+
267
+ - 🟡 **Golden dataset** (`evals/dataset/`): curate expert-validated cases. The user already has
268
+ real lab results + citologies from practicing vets (per `USO_DE_IA.md`) — turn these into
269
+ labeled cases: patient signalment + lab values → expected key findings, expected
270
+ differentials (acceptable set), red-flag/safety expectations, and out-of-scope cases the
271
+ model must decline. Version the dataset; keep a held-out split.
272
+ — *`casos.jsonl` ampliado de 7 a **17 casos** (IMHA, hipertiroidismo, HAC, pancreatitis,
273
+ trombocitopenia, hipercalcemia, enteropatía perdedora, gammapatía, hepatocelular agudo,
274
+ leucocitosis inflamatoria… + el caso fuera-de-alcance) + README con esquema ✅.
275
+ Falta: **split reservado** (ningún caso lleva campo `split`) y casos reales del veterinario.*
276
+ - **Frameworks (layered):**
277
+ - ✅ **promptfoo** — config-driven prompt/response regression in CI: deterministic assertions
278
+ (language = es, valid schema, required derivation flag present when expected, no
279
+ forbidden claims), plus LLM-rubric graders.
280
+ — *`evals/promptfooconfig.yaml` presente (no ejecutado aquí, requiere modelo).*
281
+ - ⬜ **Ragas** — RAG-specific metrics: faithfulness/groundedness (no claims beyond retrieved
282
+ context), context precision/recall, answer relevance, **citation correctness**.
283
+ — *Dependencia en el grupo `evals` (`pyproject.toml`) y mencionada en los READMEs, pero
284
+ **cero líneas de código Ragas** en el repo. Ya está desbloqueado: el índice está poblado.
285
+ Es el hueco más grande que queda del plan.*
286
+ - ✅ **Evaluación de recuperación aislada** (no estaba en el plan original, añadida para
287
+ decidir por datos): `evals/run_retrieval_eval.py` + `dump_retrieval.py` miden si los
288
+ fragmentos recuperados son relevantes al diagnóstico esperado, permitiendo A/B de
289
+ modelo de embeddings × idioma de consulta (bge-m3 ES vs EN vs MedCPT) antes de invertir
290
+ en rerank. Juzga con Claude si hay `ANTHROPIC_API_KEY`, si no con heurístico de solape.
291
+ Expuesto como `make retrieval-eval`.
292
+ - ✅ **Custom clinical rubric** (`evals/judge/`) — LLM-as-judge (strong model, e.g. Claude)
293
+ scoring: differential correctness vs. expected set, appropriate hedging, safety
294
+ (no dangerous/overconfident recommendations), completeness.
295
+ — *`evals/judge/clinical_judge.py` (se activa con ANTHROPIC_API_KEY).*
296
+ - 🟡 **Metrics & thresholds:** per-metric pass bars (e.g. groundedness ≥ X, differential
297
+ recall ≥ Y, safety violations = 0). Track per model route (medGemma vs Claude) and per
298
+ corpus version so RAG changes are attributable.
299
+ — *Umbrales + agregación en `run_evals.py` ✅. Falta: informe explícito por-ruta/por-versión-de-corpus.*
300
+ - ✅ **CI gate:** GitHub Actions runs the eval suite on PRs touching `backend/app/ai`,
301
+ `backend/app/rag`, prompts, or the index; **block merge/deploy on regression** or any
302
+ safety failure. Store run artifacts/scorecards.
303
+ — *`.github/workflows/evals.yml`; bloqueo verificado (exit=1 ante violación de seguridad).*
304
+ - ⬜ **Human-in-the-loop:** a lightweight review workflow for a veterinarian to accept/correct
305
+ model outputs, feeding new cases back into the golden set. Log (privacy-safe) production
306
+ interpretations for periodic expert audit.
307
+ — *No implementado.*
308
+
309
+ Files: new `evals/**`, `.github/workflows/evals.yml`.
310
+
311
+ ---
312
+
313
+ ## Phase 5 — Security audit & rate limiting — ✅
314
+
315
+ Findings from the current code, severity-ranked, each with the fix folded into the new
316
+ FastAPI backend.
317
+
318
+ **Critical**
319
+ 1. ✅ **Unauthenticated AI/papers proxies + open CORS** (`api/hf_proxy.php`, `papers_proxy.php`
320
+ send `Access-Control-Allow-Origin: *`, no session check). Anyone can call them directly
321
+ and burn the HF key / abuse PubMed. → Require an authenticated session on `/api/interpret`
322
+ (and rate-limit papers); lock CORS to the app origin.
323
+ — *`Depends(usuario_actual)` en interpret/papers (401 verificado); CORS a orígenes fijos.*
324
+ 2. ✅ **Password-hash DB may be web-served.** SQLite fallback writes `data/morphos.db` under the
325
+ web root; `.htaccess` only denies `.env`/`setup.php`, so `/data/morphos.db` may be
326
+ downloadable, and `.htaccess` is Apache-only. → Move the DB **outside** the served root;
327
+ in FastAPI nothing under the DB path is a static route. Never rely on `.htaccess` for
328
+ secret protection.
329
+ — *BD en `instance/` (fuera del webroot); sólo `dist/` y `/data` (JSON) se montan.*
330
+
331
+ **High**
332
+ 3. 🟡 **No rate limiting anywhere.** → Add **slowapi** (or Redis-backed) limits: per-IP + per-
333
+ user on `/api/interpret` (expensive), per-IP on `/api/auth` login (brute force) and
334
+ `/api/papers`. Return 429 with retry-after.
335
+ — *slowapi por-IP en interpret/login/papers + manejador 429 con Retry-After ✅.
336
+ Falta: límite adicional por-usuario (actualmente sólo por-IP).*
337
+ 4. ✅ **Login brute force** — no throttling/lockout. → Per-account + per-IP attempt throttling
338
+ with backoff; keep the already-generic "email o contraseña incorrectos" message.
339
+ — *Tabla `intentos_login` + throttle por email+IP; mensaje genérico.*
340
+ 5. ✅ **MySQL `root` / empty password default** (`conexion.php`). → Require DB credentials from
341
+ env; no hardcoded defaults; fail closed if unset.
342
+ — *El backend nuevo no tiene credenciales por defecto (config por entorno); SQLite por defecto.*
343
+
344
+ **Medium**
345
+ 6. ✅ **Secrets via `.env` in webroot** guarded only by `.htaccess`. → Load secrets from real
346
+ env vars (HF Spaces secrets); keep `.env` out of any served directory.
347
+ — *pydantic-settings; `backend/.env` gitignored; nada bajo el root servido.*
348
+ 7. ✅ **No CSRF protection** on session POSTs. → `SameSite=Strict` + `Secure` + `HttpOnly`
349
+ session cookies, plus a CSRF token (or move to short-lived bearer tokens).
350
+ — *Cookie firmada HttpOnly/SameSite/Secure + CSRF de doble token (403 verificado).*
351
+ 8. ✅ **Server-side upload validation missing** for citology images (base64 decoded with no
352
+ size/count/mime cap → memory DoS). → Enforce ≤4 images, max bytes, allowed mime, safe
353
+ decode server-side.
354
+ — *`interpret.py::_validar_imagenes` (nº, mime, tamaño, decode seguro).*
355
+ 9. ✅ **Prompt-injection surface** via free-text `signos-clinicos` and citology images. →
356
+ Harden system prompt, treat user text as data, validate structured output, cap output
357
+ scope.
358
+ — *System prompt endurecido (trata el texto como datos) + salida estructurada validada.*
359
+
360
+ **Low**
361
+ 10. ✅ Remove `console.log` prompt/PII leakage in `ia.js`.
362
+ — *El nuevo `ia.ts` (ya en uso) no registra el prompt. El `ia.js` legacy es código muerto
363
+ (no se carga) pendiente de borrar junto al resto de `js/*.js`.*
364
+ 11. 🟡 Raise password min length (6 → ≥8) + optional breach check; registration keeps 409 but
365
+ is now rate-limited (acceptable enumeration risk).
366
+ — *Mínimo 8 ✅ (`RegistroBody`). El chequeo de brechas (opcional) no se implementó.*
367
+ 12. 🟡 Add security headers (CSP, HSTS, X-Content-Type-Options, Referrer-Policy) and enforce
368
+ HTTPS.
369
+ — *Cabeceras ✅ (`headers.py`; HSTS en prod). Falta: redirección explícita HTTP→HTTPS
370
+ (se delega en la plataforma). Nota: CSP ajustada a `worker-src blob:` para PDF.js.*
371
+
372
+ **New surface introduced by RAG:** ✅ licensed book text must never be exfiltratable —
373
+ retrieval returns only short grounding snippets to the model, the API never serves full
374
+ book content, and the corpus lives read-only in the image. — *Índice en `instance/`, endpoint auth-gated.*
375
+
376
+ Files: `backend/app/security/**`, `backend/app/routers/auth.py`, DB path change in the
377
+ connection layer, `Dockerfile`/entrypoint (secrets, DB location, headers).
378
+
379
+ ---
380
+
381
+ ## Verification
382
+
383
+ - ✅ **Engine parity (Phase 1):** the `analisis.ts` golden test suite (Vitest) passes with
384
+ outputs identical to the current JS engine across fixture cases spanning both species and
385
+ all pattern rules. This is the gate that the migration preserved validated behavior.
386
+ — *27/27 verde (32/32 con los tests de import de laboratorio). Reejecutado 2026-07-26.*
387
+ - 🟡 **AI path (Phase 2):** integration test that `/api/interpret` returns schema-valid
388
+ structured output for representative cases on both the medGemma and Claude routes; confirm
389
+ `limpiarRespuesta` is gone and no unparsed text can reach the client. Drive the real UI
390
+ to confirm findings, ranked differentials with citations, and the derivation banner render.
391
+ — *UI real verificada: el botón "Análisis IA" llama a `POST /api/interpret` con CSRF y el
392
+ backend alcanza el modelo (Ollama). Salida estructurada validada por test de integración;
393
+ `limpiarRespuesta` no existe en el código nuevo. Falta: `ollama pull` del modelo para ver
394
+ el render de diferenciales/citas con datos reales, y la ruta Claude ejercitada en vivo.*
395
+ - ✅ **RAG (Phase 3):** with a small sample corpus, verify retrieved citations resolve to the
396
+ correct `libro/página` and that an empty index degrades gracefully to no-RAG.
397
+ — *Degradación a sin-RAG ✅. Resolución de citas ✅: `test_retriever_integracion.py` construye
398
+ un índice LanceDB real (con FTS) y comprueba recuperación end-to-end y filtro por especie;
399
+ el corpus real de 2 libros está ingerido. 97 tests backend verde (2026-07-26).*
400
+ - 🟡 **Evals (Phase 4):** `promptfoo eval` + Ragas + the clinical rubric run green locally and
401
+ in CI; deliberately regress a prompt and confirm the CI gate blocks it; confirm a safety-
402
+ violation case fails the suite.
403
+ — *Puerta CI: bloqueo por violación de seguridad verificado (exit=1). Falta: ejecutar
404
+ promptfoo (requiere modelo) y Ragas (sin implementar).*
405
+ - 🟡 **Security (Phase 5):** confirm unauthenticated `/api/interpret` returns 401; CORS
406
+ rejects foreign origins; rate limits return 429 under load; the DB path is not reachable
407
+ as a static route; MySQL refuses to start without credentials. Run `/security-review` on
408
+ the diff.
409
+ — *401 sin sesión ✅, 403 sin CSRF ✅, BD fuera del webroot ✅. Falta verificar en vivo:
410
+ rechazo CORS de orígenes externos, 429 bajo carga, y ejecutar `/security-review`.*
411
+
412
+ ---
413
+
414
+ ## Sequencing & risk notes
415
+
416
+ - Ship in order: **Phase 1 → 5 → 2 → 3 → 4**. Rationale: lock behavior with tests first
417
+ (1), close the exploitable holes early (5), then rebuild the AI path (2), add grounding
418
+ (3), and stand up evals (4) — though the golden dataset from Phase 1 tests and the eval
419
+ dataset should be gathered in parallel from the start.
420
+ - Biggest risk is **regressing the vet-validated engine**; the Phase 1 golden suite mitigates
421
+ it and must exist before any TS port lands.
422
+ - ~~The real licensed books are pending~~ — **resuelto (2026-07-26)**: 2 libros ingeridos
423
+ (6763 fragmentos). Los PDF con licencia viven en `books/` **sin comitear** y el índice en
424
+ `instance/` (gitignored); ambos se reconstruyen con `make ingest`. Queda pendiente medir
425
+ con evals la mejora de exactitud que aporta el corpus (bloqueado por Ragas + promptfoo sin
426
+ ejecutar).
427
+ - Hybrid model routing and the eval judge may use a hosted model; confirm a DPA covers any
428
+ patient data sent on the Claude route (the medGemma route stays fully self-hosted for the
429
+ privacy-default positioning).
README.md CHANGED
@@ -8,6 +8,15 @@ pinned: false
8
  ---
9
  # Morphos — Intérprete de analíticas veterinarias asistido por I.A
10
 
 
 
 
 
 
 
 
 
 
11
  ## Proyecto final — Curso de Desarrollo Web 2026
12
 
13
  ---
 
8
  ---
9
  # Morphos — Intérprete de analíticas veterinarias asistido por I.A
10
 
11
+ > ⚠️ **Este README describe la entrega original del curso (stack XAMPP: PHP + JS sin build).**
12
+ > Ese stack ya no existe en el repo: `js/*.js`, `api/*.php` y `.htaccess` se eliminaron el
13
+ > 2026-07-26 al completarse la migración a **Vite + TypeScript (`frontend/`) + FastAPI
14
+ > (`backend/`)**. Las secciones de estructura, instalación (`setup.php`, XAMPP, MySQL) y
15
+ > seguridad de abajo son **históricas, no instrucciones válidas**.
16
+ >
17
+ > Para ejecutar el proyecto hoy: `CLAUDE.md` (arquitectura actual) y `MIGRACION.md` (estado y
18
+ > comandos). En resumen: `make frontend-install && make frontend-build && make backend-sync && make dev`.
19
+
20
  ## Proyecto final — Curso de Desarrollo Web 2026
21
 
22
  ---
books/README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Corpus de literatura veterinaria (RAG)
2
+
3
+ Aquí van los libros con licencia que fundamentan las interpretaciones de la IA.
4
+ **Este directorio se ignora en git** (contenido con derechos de autor) — ver `.gitignore`.
5
+
6
+ ## Layout esperado
7
+
8
+ ```
9
+ books/
10
+ ├── Thrall_Veterinary_Hematology_ed3.pdf
11
+ ├── Thrall_Veterinary_Hematology_ed3.meta.json (opcional, ver abajo)
12
+ ├── Weiss_Schalms_Veterinary_Hematology_ed7.pdf
13
+ └── ...
14
+ ```
15
+
16
+ ## Convención de nombre
17
+
18
+ `Autor_Titulo_edN.pdf` — la edición (`edN`) se detecta automáticamente.
19
+
20
+ ## Metadatos opcionales (sidecar)
21
+
22
+ Para citas más precisas, añade un archivo hermano `<nombre>.meta.json`:
23
+
24
+ ```json
25
+ {
26
+ "libro": "Thrall — Veterinary Hematology and Clinical Chemistry",
27
+ "edicion": "3.ª ed. (2022)",
28
+ "especie": ""
29
+ }
30
+ ```
31
+
32
+ - `especie`: dejar `""` si el libro cubre ambas; `"canino"` o `"felino"` si es específico
33
+ (permite filtrar la recuperación por especie del paciente).
34
+
35
+ ## Construir el índice
36
+
37
+ Una vez colocados los PDFs:
38
+
39
+ ```bash
40
+ make ingest # equivale a: uv run --group rag python -m app.rag.ingest
41
+ ```
42
+
43
+ Esto genera `instance/rag_index/` (LanceDB) con los fragmentos + embeddings y un
44
+ `manifest.json` con la versión del corpus (para reproducibilidad de las evals).
45
+
46
+ El índice se hornea de **sólo lectura** en la imagen Docker; no requiere almacenamiento
47
+ persistente en HF Spaces. Reconstruir sólo cuando cambie el corpus.
48
+
49
+ ## Privacidad y copyright
50
+
51
+ - El texto de los libros **nunca** se sirve al cliente ni sale del contenedor: la API
52
+ sólo pasa fragmentos cortos al modelo como contexto de fundamentación.
53
+ - El índice vive en `instance/` (fuera del webroot), nunca bajo `data/` (que sí se sirve).
54
+ - Los embeddings se generan con un modelo auto-alojado (`BAAI/bge-m3` por defecto), de modo
55
+ que el contenido no se envía a terceros durante la ingesta.