Spaces:
Sleeping
Sleeping
File size: 17,416 Bytes
445de93 ce79810 445de93 ce79810 445de93 ce79810 445de93 ce79810 445de93 ce79810 445de93 ce79810 445de93 ce79810 445de93 ce79810 445de93 ce79810 445de93 ce79810 eaa81c2 6ab7946 ce79810 5b69117 ce79810 445de93 ce79810 445de93 6ab7946 ce79810 445de93 61cd0db 1339cdc 61cd0db ce79810 445de93 ce79810 bf9f7d1 ce79810 bf9f7d1 ce79810 bf9f7d1 ce79810 445de93 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
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.
## Migration in progress (see MIGRACION.md)
The project is being modernized from a static-JS + PHP-proxy app to:
- **frontend/** — Vite + TypeScript. The vet-validated engine is ported to
`frontend/src/analisis.ts` with a Vitest regression suite (`frontend/tests/`).
- **backend/** — FastAPI service (managed with **uv**). Structured AI output (Pydantic),
hybrid medGemma/Claude clients, RAG retrieval, and all security (auth guard on the AI
endpoint, rate limiting, locked CORS, secure sessions, security headers).
- **evals/** — rigorous clinical eval harness with a CI gate (`.github/workflows/evals.yml`).
- **RAG** — LlamaIndex + LanceDB; index built offline from `books/` and baked read-only
into the image (lives in `instance/`, outside the webroot).
El legacy `js/*.js` + `api/*.php` **ya se eliminó** (2026-07-26): `index.html` cargaba el bundle
TS desde antes, así que eran código muerto. Todo el trabajo va en la estructura nueva. Ver
`MIGRACION.md` para el estado completo y cómo ejecutar cada parte.
## Running the App
### New stack (target)
```bash
make frontend-install && make frontend-build # build the SPA → dist/
make backend-sync && make dev # FastAPI on http://localhost:8000
```
Secrets come from `backend/.env` (see `backend/.env.example`) or HF Space secrets — never
from a file under the served root. For local AI, Ollama must run at
`http://localhost:11434` with a medGemma model pulled.
No hay stack legacy: `php -S localhost:8000` y el proxy PHP ya no existen.
## Architecture
### Data Flow
```
User form input
→ analisis.ts (real-time pattern detection, no server)
→ UI updates (color-coded fields, pattern cards)
User clicks "Análisis IA"
→ ia.ts (thin typed client, sends patient data + flagged values + CSRF token)
→ POST /api/interpret (FastAPI: sesión + CSRF + rate limit)
→ recuperación RAG (LanceDB, degrada a sin-RAG si no hay índice)
→ prompt endurecido (app/ai/prompt.py)
→ [ruta medgemma] → HF Space Gradio (texto libre) u Ollama (salida estructurada)
→ [ruta claude] → Anthropic SDK con tool use (salida estructurada validada)
→ Render de la InterpretacionClinica en #salida-ia
```
### Key Files and Their Roles
- **`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**.
- **`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).
- **`frontend/src/main.ts`** — Orquestación: carga los JSON, cablea eventos del formulario, dispara el análisis, exporta PDF.
- **`frontend/src/ui.ts`** — Tab navigation (8 panels, 4 exam sub-tabs), swipe gestures, mobile/desktop field sync, collapsible panels.
- **`frontend/src/pdf-parser.ts`** — Client-side PDF extraction using PDF.js. 47 regex patterns to identify analytes in Spanish/English. Runs fully in the browser. También cablea el arrastrar-y-soltar sobre los paneles de exámenes.
- **pdf.js va vendorizado y la versión es un invariante.** Los ficheros de `assets/lib/pdfjs/`
se copian de la devDependency `pdfjs-dist` con `npm run vendor-pdfjs`, para que la versión que
usa el navegador y la que usan las pruebas sean la misma. **No volver a la 3.x**: leía mal los
CMap `ToUnicode` con destinos de un byte —fuera de especificación, pero los emiten informes de
laboratorio reales generados con Ghostscript— y devolvía cada carácter desplazado 8 bits
('H' → U+4800), con lo que la importación fallaba entera con «No se encontraron datos
reconocibles». El desplazamiento **no se puede reparar a posteriori**: el '0' desplazado cae
en U+3000 y el extractor lo normaliza a espacio, así que una ALT de 260 U/L se leería como 26.
Por eso `textoIlegible()` detecta el caso y aborta en vez de importar números equivocados.
Fijado en `frontend/tests/pdf-parser.test.ts` con un PDF construido con esa anomalía.
- Un valor sólo se acepta en la línea de su etiqueta o en la línea `RESULTADO` que sigue a su
cabecera. La ventana ciega anterior saltaba de sección: importaba el cociente A/G como
albúmina y una densidad urinaria sacada de un párrafo interpretativo.
- **`frontend/src/panel-vacio.ts`** — Estado vacío de los seis paneles de exámenes **en escritorio**: la clase `.sin-datos` del `<section>` cambia el formulario por una zona de adjuntar (arrastrar / explorar / «Insertar resultados manualmente»). En móvil no aplica: el CSS que la enciende vive dentro de `@media (min-width: 1101px)`. Los importadores llaman a `revelarPanelDeCampo()` por cada valor inyectado, así que un panel que recibe datos sale del estado vacío solo. **Sólo depende de `dom.ts`**: lo importa `form-inject.ts`, que es la base común de los dos importadores, y colgarlo de `ui.ts` —que toca el DOM al cargarse— rompería sus tests.
- **`backend/app/ai/hf_space.py`** — Cliente del HF Space (Gradio) donde vive medGemma. El Space devuelve texto libre, así que va por la ruta de prosa: `ai/prosa.py` limpia los tokens del modelo, detecta salida defectuosa (razonamiento filtrado, bucle, frase cortada) y envuelve el resultado en el campo `interpretacion`. Es la ruta por defecto sin salida estructurada; un modelo local declarado `=prosa` usa la misma.
- **`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.
- **`data/valores_referencia.json`** — Reference ranges for 90 analytes per species.
- **`data/ajustes_clinicos.json`** — **Fuente única de las reglas del suelo de seguridad**:
umbrales de gravedad, límites de las categorías de edad y factores de ajuste por edad y raza.
Lo leen LOS DOS motores —`frontend/src/analisis.ts` (fetch, como los demás datos) y
`backend/app/motor/gravedad.py` (disco)— y también `evals/engine_runner.ts`.
- **No volver a incrustar estos valores en el código.** Estaban duplicados como constantes en
ambos motores, y eso es lo que de verdad se desincroniza: la lógica de comparar no cambia
casi nunca, los umbrales sí, y son justo lo que un veterinario querría ajustar sin pasar por
un build. Hay una prueba a cada lado (`test_el_motor_obedece_al_json_y_no_a_constantes` y su
gemela en `analisis.test.ts`) que muta el JSON y exige que el veredicto cambie: si alguien
vuelve a fijar los umbrales en el código, fallan.
- **Tampoco empaquetarlo en el bundle.** Un `import` de JSON lo inlinearía en tiempo de build
y editar el fichero dejaría de tener efecto sin recompilar, que es justo lo que se quiere
evitar. Se carga con `fetch`, como `valores_referencia.json`.
- `moderado_hasta: null` significa que ese analito **nunca** llega a `grave` por ese lado (es
el caso de `upc`, que la guía IRIS no subestadia más allá de «proteinúrico»).
- `backend/tests/test_paridad_motor.py` ejecuta el motor TS REAL contra el puerto Python sobre
18 casos: es el guardarraíl de que las dos implementaciones sigan de acuerdo.
- **`data/alteraciones.json`** — 78 clinical entities used to enrich AI prompts with etiologic context.
### AI Backend Configuration
La selección de ruta se aplica **en el servidor** (`MORPHOS_IA_BACKEND_DEFECTO`: `medgemma` |
`claude`), no en `localStorage` como en el legacy. Dentro de `medgemma`, si
`MORPHOS_HF_SPACE_URL` está definida se usa el HF Space; si se vacía, cae a Ollama en
`MORPHOS_MEDGEMMA_BASE_URL`. Ambas rutas aceptan hasta 4 imágenes (validadas en servidor:
número, mime y tamaño).
**Modelos locales elegibles desde la UI.** `MORPHOS_MODELOS_LOCALES` declara una lista blanca
(`nombre[=prosa]`, vacía por defecto → selector oculto). Si el usuario elige uno, ese modelo
manda sobre el Space y recibe **exactamente el mismo tratamiento**: RAG, prompt endurecido,
atribución de citas y suelos de seguridad viven en `ai/service.py`, no en los clientes, así que
son agnósticos del modelo. Dos invariantes que no se tocan:
- **Nombres, nunca URLs.** La base_url se queda en `medgemma_base_url`; aceptar una del cliente
convierte `/api/interpret` en un SSRF. El nombre se valida contra la lista blanca en el
esquema (`PeticionInterpretacion`, → 422) y otra vez en `_crear_cliente` (para las evals).
- **El modo de salida se declara, no se infiere.** `=prosa` manda el modelo por
`ai/prosa.py` (limpieza + envoltura) en vez de por la decodificación restringida de Ollama.
Existe porque qwen2.5:7b acepta el `format` y devuelve JSON válido con `hallazgos_clave`,
`diferenciales` y `siguientes_pruebas` vacíos. `cliente.prosa` —no el nombre del cliente— es
lo que el servicio consulta para elegir system prompt y suplir `requiere_derivacion`.
Para la ruta Claude el modelo por defecto es `claude-opus-5`. No cambiar a `claude-fable-5`:
cuesta el doble, exige retención de datos de 30 días (incompatible con el posicionamiento de
privacidad) y sus clasificadores pueden rechazar trabajo clínico legítimo con
`stop_reason="refusal"` — ver el comentario en `backend/app/config.py`.
### Admisión de cuentas y superficie de laboratorio
Dos defectos que se cerraron y **no se vuelven a abrir sin sustituirlos por algo mejor**:
- **El alta está CERRADA** (`registro_abierto=False` + `registro_allowlist`). Una cuenta llega
a `/api/interpret`, que gasta cuota de ZeroGPU compartida y dinero real por la ruta Claude:
con el alta abierta, `limite_interpret_usuario` protegía una identidad que costaba una
petición HTTP acuñar. La comprobación de allowlist va **antes** que la de existencia, si no
el alta se convierte en un oráculo de qué cuentas hay (403 siempre, nunca 409, fuera de la
lista). Es una **lista de emails y no un booleano** porque `instance/` es efímero: sin ella,
el primer reinicio deja la instancia sin cuentas y sin forma de crear ninguna.
- **Los resultados de analizador están segmentados por CLÍNICA (tenant).** El tenant lo pone
siempre el servidor: de la API key del dispositivo en la ingesta (`clinica:clave`) y de la
cookie firmada en la lectura. **Nunca del cuerpo ni de un parámetro** — si el puente pudiera
declarar su clínica, mentir en un campo bastaría para escribir en la de otro. Una muestra de
otra clínica devuelve 404, no 403. Sin tenants declarados todo cae en `principal`, así que un
despliegue de una sola clínica no nota nada.
- **`GET /api/lab/pendientes` sigue apagado por defecto** (`lab_pendientes_habilitado=False` →
404). Ya no es un volcado global —sólo lista la clínica de la sesión—, pero dentro de ella
enumera todas las muestras, así que se enciende a propósito.
Las pruebas describen el defecto CERRADO; las que sólo necesitan sesión piden el fixture
`alta_abierta`. El fixture `_limitador_limpio` (autouse) vacía el contador de rate limiting
entre pruebas: es de proceso y el TestClient sale siempre de la misma IP, así que sin él los
429 aparecían según el orden de ejecución.
### Pattern Detection Logic (`analisis.ts`)
Severity thresholds are based on deviation from the reference range. Reference ranges are dynamically adjusted for:
- **Age**: puppies, adults, seniors, geriatric (age in months)
- **Breed**: Greyhounds (lower platelets normal), Akita/Shiba (different RBC ranges), etc.
- **Sex**: Male felines have a higher creatinine tolerance
The `analizarResultados()` function is called on every `input` event and returns flagged findings + matched clinical patterns.
### CSS Notes
Do not use `!important` — use specificity or cascade ordering instead. The stylesheet is `css/styles.css` (2742 lines). The desktop grid breakpoint is `>1100px`.
### Distribución del corpus RAG
Los libros con licencia y el índice **nunca** entran en git (`books/*` y `instance/` están en
`.gitignore`). Viven en dos datasets **privados** del Hub, declarados en `scripts/hub.py` y en
`backend/app/config.py`:
| Artefacto | Repo | Tamaño | Para qué |
|---|---|---|---|
| Índice LanceDB | `blackmistcode/morphos-rag-index` | 33 MB | Lo consume la app; se hornea en la imagen |
| PDFs originales | `blackmistcode/morphos-books` | 226 MB | Sólo para reingerir |
```bash
make ingest # construye el índice desde books/ (local, requiere grupo rag)
# Añadir un documento nuevo sin reprocesar los libros grandes (OCR sobre cientos de MB):
# uv run --group rag python -m app.rag.ingest --fuente ../books --salida ../instance/rag_index --anexar
make curar-indice # descarta índices alfabéticos + reetiqueta especie (ARGS=--aplicar)
make publish-index # sube instance/rag_index al dataset privado
make fetch-index # lo descarga (clon limpio, otra máquina, CI)
make publish-books # respalda los PDFs (no hace falta para desplegar)
```
Se usa la API de Python de `huggingface_hub`, **no el CLI `hf`**: en la versión instalada
(1.16.1) el CLI devuelve código 1 aunque la operación vaya bien, por una incompatibilidad
typer/click, y eso aborta cualquier Makefile o build.
### Alcance del corpus: qué entra y con qué especie
**`data/rag_alcance.json`** declara, por rangos de página, las dos decisiones que la ingesta y
`make curar-indice` aplican por igual (`app/rag/alcance_corpus.py`):
- **`descartes`** — rangos de página que no entran en el corpus: los preliminares (portada,
créditos, índice general, colaboradores, prefacio; el contenido empieza en la p. 19 en ambos
libros), el índice alfabético del final y la lista de casos que abre la SECTION VII.
- **`umbral_lideres_de_puntos`** — descarte por CONTENIDO: cualquier fragmento cuya fracción de
líderes de puntos («Urine Samples . . . . . . 6») supere el umbral. Fundamentals repite un
sumario al principio de cada capítulo, 23 bloques por todo el libro, que por rangos serían 23
entradas a mano; la firma tipográfica los coge de una vez. El reparto real es bimodal (164
fragmentos por encima de 0.20, 8 entre 0.05 y 0.18 con contenido real), así que 0.20 se
equivoca por el lado de conservar.
- **`rangos`** — restringidos a una especie (abajo).
Total fuera: **571 fragmentos, 6772 → 6201 (8,4 %)**. Ninguno contiene prosa clínica.
`curar-indice` **compacta al terminar**, y no es cosmético: LanceDB versiona, así que
sobrescribir deja los datos viejos en disco y el índice *crece* al quitarle filas (medido:
73 MB → 140 MB → 33 MB tras compactar). Este artefacto se sube al Hub y se hornea en la imagen.
### Sólo canino y felino
Morphos atiende **sólo canino y felino**, pero los dos libros son de patología clínica
veterinaria **comparada** y traen secciones enteras de aves, reptiles y peces (5,7 % de los
fragmentos mencionan aves). `retriever.py` filtra por especie, pero sólo excluye un fragmento
si su metadato `especie` está relleno — y hasta el 2026-08-01 **los 6772 chunks lo tenían
vacío**, así que el filtro estaba inerte sobre el índice real aunque sus tests pasaran contra un
índice sintético que sí lo traía.
Sólo se etiquetan **secciones declaradas por el propio libro en su índice** (488 fragmentos,
7,2 %, como `no_domestico`). El material comparado que menciona caballo o vaca de pasada se deja
intacto: enseña el principio general y sirve igual para un perro. Etiquetar no es descartar —esas
secciones siguen en el corpus, sólo quedan fuera del alcance de un paciente canino o felino.
**Si se reingiere o se reetiqueta, hay que `make publish-index`**: si no, el arreglo se queda en
local y las builds siguen bajando el índice sin etiquetar. `test_alcance_corpus.py` comprueba el
índice REAL (se omite donde no está) precisamente porque un fixture sintético no puede ver este
fallo.
En Docker, `WITH_RAG=1` es el valor por defecto y los modelos (bge-m3 + bge-reranker-v2-m3,
~6.4 GB) se hornean en `/opt/hf` con `HF_HUB_OFFLINE=1` en runtime, para que un fallo de red no
degrade la recuperación en silencio. Si `instance/rag_index` no está en el contexto de build, la
imagen lo descarga usando `HF_TOKEN` como **secreto de build** (nunca `--build-arg`, que quedaría
en el historial de capas). Al arrancar, `_verificar_rag()` distingue en el log entre «RAG
desactivado a propósito», «faltan dependencias» y «falta el índice».
### Coding notes
All variables should be named in spanish unless they're referencing common technical names like tab, input, output, etc.
Always use descriptive names for variables and functions keeping legibility as a priority.
Don't use aligment spaces.
|