# 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 `
` 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.