Spaces:
Running
Actualizar documentación al stack nuevo
Browse filesCLAUDE.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>
- .gitignore +25 -1
- CLAUDE.md +81 -31
- INTEGRACION_ANALIZADORES.md +328 -0
- MIGRACION.md +101 -0
- PLAN_MODERNIZACION.md +429 -0
- README.md +9 -0
- books/README.md +55 -0
|
@@ -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
|
|
@@ -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 (
|
| 8 |
|
| 9 |
-
##
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
```
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 21 |
```
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 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.
|
| 32 |
→ UI updates (color-coded fields, pattern cards)
|
| 33 |
|
| 34 |
User clicks "Análisis IA"
|
| 35 |
-
→ ia.
|
| 36 |
-
→
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
| 39 |
```
|
| 40 |
|
| 41 |
### Key Files and Their Roles
|
| 42 |
|
| 43 |
-
- **`
|
| 44 |
-
- **`
|
| 45 |
-
- **`
|
| 46 |
-
- **`
|
| 47 |
-
- **`
|
| 48 |
-
- **`
|
| 49 |
-
- **`
|
| 50 |
-
- **`data/
|
|
|
|
| 51 |
|
| 52 |
### AI Backend Configuration
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
### Pattern Detection Logic (`analisis.
|
| 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` (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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).
|
|
@@ -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 |
---
|
|
@@ -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.
|