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.