Spaces:
Running
Running
| # 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/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. | |
| - **`GET /api/lab/pendientes` está apagado** (`lab_pendientes_habilitado=False` → 404). Enumera | |
| las muestras de todas las clínicas y cada `muestra_id` abre el panel completo más las pistas | |
| de paciente. **Apagarlo no cierra el agujero y no hay que documentarlo como si lo hiciera**: | |
| el `muestra_id` lo pone el analizador y suele ser correlativo, así que `/api/lab/resultados` | |
| sigue siendo enumerable. Lo que elimina es el volcado en una petición. El cierre real es atar | |
| cada resultado a un tenant y filtrar por sesión (ARCHITECTURE_REVIEW §2.1). | |
| 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. | |