Spaces:
Sleeping
Sleeping
Jose Salazar commited on
Commit ·
6ab7946
1
Parent(s): 02b6489
Allow list para modelos locales configurables por el usuario
Browse files- CLAUDE.md +16 -1
- backend/.env.example +9 -0
- backend/app/ai/base.py +7 -0
- backend/app/ai/claude.py +2 -0
- backend/app/ai/hf_space.py +13 -133
- backend/app/ai/medgemma.py +20 -6
- backend/app/ai/prosa.py +158 -0
- backend/app/ai/service.py +26 -11
- backend/app/config.py +66 -8
- backend/app/routers/interpret.py +15 -0
- backend/app/schemas.py +25 -0
- backend/tests/test_api.py +24 -0
- backend/tests/test_modelos_locales.py +161 -0
- backend/tests/test_prescripcion.py +3 -1
- backend/tests/test_prompt_y_rag.py +3 -1
- backend/tests/test_reintentos.py +18 -4
- css/styles.css +8 -5
- frontend/src/ia.ts +48 -4
- frontend/src/main.ts +1 -1
- index.html +7 -4
CLAUDE.md
CHANGED
|
@@ -61,7 +61,7 @@ User clicks "Análisis IA"
|
|
| 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.
|
| 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.
|
|
@@ -74,6 +74,21 @@ La selección de ruta se aplica **en el servidor** (`MORPHOS_IA_BACKEND_DEFECTO`
|
|
| 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
|
|
|
|
| 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. 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.
|
| 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.
|
|
|
|
| 74 |
`MORPHOS_MEDGEMMA_BASE_URL`. Ambas rutas aceptan hasta 4 imágenes (validadas en servidor:
|
| 75 |
número, mime y tamaño).
|
| 76 |
|
| 77 |
+
**Modelos locales elegibles desde la UI.** `MORPHOS_MODELOS_LOCALES` declara una lista blanca
|
| 78 |
+
(`nombre[=prosa]`, vacía por defecto → selector oculto). Si el usuario elige uno, ese modelo
|
| 79 |
+
manda sobre el Space y recibe **exactamente el mismo tratamiento**: RAG, prompt endurecido,
|
| 80 |
+
atribución de citas y suelos de seguridad viven en `ai/service.py`, no en los clientes, así que
|
| 81 |
+
son agnósticos del modelo. Dos invariantes que no se tocan:
|
| 82 |
+
|
| 83 |
+
- **Nombres, nunca URLs.** La base_url se queda en `medgemma_base_url`; aceptar una del cliente
|
| 84 |
+
convierte `/api/interpret` en un SSRF. El nombre se valida contra la lista blanca en el
|
| 85 |
+
esquema (`PeticionInterpretacion`, → 422) y otra vez en `_crear_cliente` (para las evals).
|
| 86 |
+
- **El modo de salida se declara, no se infiere.** `=prosa` manda el modelo por
|
| 87 |
+
`ai/prosa.py` (limpieza + envoltura) en vez de por la decodificación restringida de Ollama.
|
| 88 |
+
Existe porque qwen2.5:7b acepta el `format` y devuelve JSON válido con `hallazgos_clave`,
|
| 89 |
+
`diferenciales` y `siguientes_pruebas` vacíos. `cliente.prosa` —no el nombre del cliente— es
|
| 90 |
+
lo que el servicio consulta para elegir system prompt y suplir `requiere_derivacion`.
|
| 91 |
+
|
| 92 |
Para la ruta Claude el modelo por defecto es `claude-opus-5`. No cambiar a `claude-fable-5`:
|
| 93 |
cuesta el doble, exige retención de datos de 30 días (incompatible con el posicionamiento de
|
| 94 |
privacidad) y sus clasificadores pueden rechazar trabajo clínico legítimo con
|
backend/.env.example
CHANGED
|
@@ -24,6 +24,15 @@ MORPHOS_IA_BACKEND_DEFECTO=medgemma # medgemma | claude
|
|
| 24 |
MORPHOS_MEDGEMMA_BASE_URL=http://localhost:11434
|
| 25 |
MORPHOS_MEDGEMMA_MODEL=medgemma:latest
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# Claude (ruta híbrida opcional). NO usar claude-fable-5: cuesta el doble, exige retención de
|
| 28 |
# datos de 30 días y sus clasificadores pueden rechazar trabajo clínico legítimo. Ver el
|
| 29 |
# comentario en app/config.py.
|
|
|
|
| 24 |
MORPHOS_MEDGEMMA_BASE_URL=http://localhost:11434
|
| 25 |
MORPHOS_MEDGEMMA_MODEL=medgemma:latest
|
| 26 |
|
| 27 |
+
# Modelos locales que el usuario puede ELEGIR en la UI (lista blanca; vacía = selector oculto).
|
| 28 |
+
# Formato `nombre[=prosa|=estructurado]`; sin sufijo se asume estructurado. Ponle `=prosa` al
|
| 29 |
+
# modelo que acepta el `format` de Ollama pero devuelve los campos estructurados en hueco.
|
| 30 |
+
# El modelo elegido recibe el mismo RAG, prompt y suelos de seguridad que el HF Space, y manda
|
| 31 |
+
# sobre él. NO hay variable de URL por petición a propósito: la base_url es la de arriba, del
|
| 32 |
+
# lado servidor (aceptar una del cliente convertiría /api/interpret en un SSRF). Déjala vacía
|
| 33 |
+
# donde no haya un Ollama alcanzable desde el servidor, como el HF Space.
|
| 34 |
+
# MORPHOS_MODELOS_LOCALES=medgemma1.5:latest,qwen2.5:7b=prosa
|
| 35 |
+
|
| 36 |
# Claude (ruta híbrida opcional). NO usar claude-fable-5: cuesta el doble, exige retención de
|
| 37 |
# datos de 30 días y sus clasificadores pueden rechazar trabajo clínico legítimo. Ver el
|
| 38 |
# comentario en app/config.py.
|
backend/app/ai/base.py
CHANGED
|
@@ -46,6 +46,13 @@ class ErrorModelo(Exception):
|
|
| 46 |
|
| 47 |
class ClienteModelo(Protocol):
|
| 48 |
nombre: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
async def interpretar(
|
| 51 |
self,
|
|
|
|
| 46 |
|
| 47 |
class ClienteModelo(Protocol):
|
| 48 |
nombre: str
|
| 49 |
+
modelo: str
|
| 50 |
+
# True si el cliente devuelve TEXTO libre envuelto en `interpretacion` en vez de rellenar
|
| 51 |
+
# los campos estructurados. El servicio lo consulta para elegir el system prompt, para no
|
| 52 |
+
# exigir campos que esta ruta no puede rellenar y para suplir `requiere_derivacion` desde
|
| 53 |
+
# el motor determinista. Antes se deducía del nombre del cliente, que dejó de bastar en
|
| 54 |
+
# cuanto la prosa pudo venir también de un modelo local.
|
| 55 |
+
prosa: bool
|
| 56 |
|
| 57 |
async def interpretar(
|
| 58 |
self,
|
backend/app/ai/claude.py
CHANGED
|
@@ -40,6 +40,7 @@ def _bloques_imagen(imagenes: list[str]) -> list[dict]:
|
|
| 40 |
|
| 41 |
class ClaudeClient:
|
| 42 |
nombre = "claude"
|
|
|
|
| 43 |
|
| 44 |
def __init__(self) -> None:
|
| 45 |
cfg = obtener_config()
|
|
@@ -52,6 +53,7 @@ class ClaudeClient:
|
|
| 52 |
|
| 53 |
self._cliente = AsyncAnthropic(api_key=cfg.anthropic_api_key)
|
| 54 |
self._modelo = cfg.claude_model
|
|
|
|
| 55 |
|
| 56 |
async def interpretar(
|
| 57 |
self, sistema: str, mensaje_usuario: str, imagenes: list[str]
|
|
|
|
| 40 |
|
| 41 |
class ClaudeClient:
|
| 42 |
nombre = "claude"
|
| 43 |
+
prosa = False
|
| 44 |
|
| 45 |
def __init__(self) -> None:
|
| 46 |
cfg = obtener_config()
|
|
|
|
| 53 |
|
| 54 |
self._cliente = AsyncAnthropic(api_key=cfg.anthropic_api_key)
|
| 55 |
self._modelo = cfg.claude_model
|
| 56 |
+
self.modelo = cfg.claude_model
|
| 57 |
|
| 58 |
async def interpretar(
|
| 59 |
self, sistema: str, mensaje_usuario: str, imagenes: list[str]
|
backend/app/ai/hf_space.py
CHANGED
|
@@ -20,115 +20,18 @@ from ..config import obtener_config
|
|
| 20 |
from ..schemas import InterpretacionClinica, esquema_estructurado
|
| 21 |
from .base import ErrorModelo
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
#
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
r"|thinking\s+process\s+to\s+arrive"
|
| 33 |
-
r"|proceso\s+de\s+(pensamiento|razonamiento)"
|
| 34 |
)
|
| 35 |
|
| 36 |
-
|
| 37 |
-
def limpiar_respuesta(text: str) -> str:
|
| 38 |
-
"""Versión compacta de la limpieza que antes vivía en ia.js (limpiarRespuesta).
|
| 39 |
-
|
| 40 |
-
Sólo se aplica a la ruta HF Space (texto crudo de medGemma); las rutas Ollama/Claude
|
| 41 |
-
usan salida estructurada y no la necesitan.
|
| 42 |
-
"""
|
| 43 |
-
if "<start_of_turn>model" in text:
|
| 44 |
-
text = text.split("<start_of_turn>model")[-1]
|
| 45 |
-
if "<end_of_turn>" in text:
|
| 46 |
-
text = text[: text.index("<end_of_turn>")]
|
| 47 |
-
if "<unused95>" in text:
|
| 48 |
-
text = text.split("<unused95>")[-1]
|
| 49 |
-
elif "<unused94>" in text:
|
| 50 |
-
text = "".join(text.split("<unused94>")[1:]).strip()
|
| 51 |
-
text = re.sub(r"<unused\d+>", "", text)
|
| 52 |
-
text = re.sub(r"<start_of_turn>\w+\n?", "", text)
|
| 53 |
-
text = re.sub(r"^\d+\s+(medical assistant|assistant|model)\s*", "", text, flags=re.I)
|
| 54 |
-
# LaTeX y bloques matemáticos
|
| 55 |
-
text = re.sub(r"\$\\boxed\{[^}]*\}\$", "", text)
|
| 56 |
-
text = re.sub(r"\\[a-zA-Z]+(\{[^}]*\})?", "", text)
|
| 57 |
-
text = re.sub(r"\$[^$]*\$", "", text)
|
| 58 |
-
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 59 |
-
text = _cortar_bucle_lineas(text)
|
| 60 |
-
# Corta al primer párrafo repetido (bucle del modelo)
|
| 61 |
-
vistos: set[str] = set()
|
| 62 |
-
sin_rep = []
|
| 63 |
-
for p in re.split(r"\n\n+", text):
|
| 64 |
-
clave = p.strip()[:80]
|
| 65 |
-
if clave in vistos:
|
| 66 |
-
break
|
| 67 |
-
vistos.add(clave)
|
| 68 |
-
sin_rep.append(p)
|
| 69 |
-
return "\n\n".join(sin_rep).strip() or "Sin respuesta del modelo."
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def _cortar_bucle_lineas(text: str) -> str:
|
| 73 |
-
"""Trunca en cuanto una línea sustantiva se repite por 3.ª vez (bucle a nivel de viñeta,
|
| 74 |
-
que la deduplicación por párrafos `\\n\\n` no detecta)."""
|
| 75 |
-
conteo: dict[str, int] = {}
|
| 76 |
-
salida: list[str] = []
|
| 77 |
-
for linea in text.split("\n"):
|
| 78 |
-
clave = linea.strip()
|
| 79 |
-
if len(clave) > 15:
|
| 80 |
-
conteo[clave] = conteo.get(clave, 0) + 1
|
| 81 |
-
if conteo[clave] >= 3:
|
| 82 |
-
break
|
| 83 |
-
salida.append(linea)
|
| 84 |
-
return "\n".join(salida)
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
# Caracteres que pueden ir DESPUÉS del punto final sin que la frase esté cortada: énfasis
|
| 88 |
-
# markdown, comillas y cierres de paréntesis.
|
| 89 |
-
_ADORNOS_FINALES = "*_`\"'”»)]}"
|
| 90 |
-
_FIN_DE_FRASE = ".!?…:"
|
| 91 |
-
# Una línea final corta puede ser un ítem de lista o un encabezado legítimos sin puntuación
|
| 92 |
-
# ("- Ecografía abdominal"). Sólo una línea larga sin cierre delata una frase interrumpida.
|
| 93 |
-
_LARGO_MINIMO_TRUNCADO = 40
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def interpretacion_truncada(text: str) -> bool:
|
| 97 |
-
"""True si la respuesta se corta a mitad de frase.
|
| 98 |
-
|
| 99 |
-
El Space puede devolver una interpretación cortada en seco (visto en producción:
|
| 100 |
-
«…los diferenciales incluyen la lip», justo antes de nombrar el diferencial clave). La
|
| 101 |
-
limpieza no la toca —el corte viene de arriba— y el resto de comprobaciones no la ven:
|
| 102 |
-
el texto es largo, no repite y no filtra razonamiento. Sin esto, una interpretación
|
| 103 |
-
incompleta llega al veterinario con aspecto de completa, callando justo lo que importa.
|
| 104 |
-
"""
|
| 105 |
-
limpio = text.rstrip()
|
| 106 |
-
if not limpio:
|
| 107 |
-
return True
|
| 108 |
-
ultima_linea = limpio.split("\n")[-1].strip()
|
| 109 |
-
if len(ultima_linea) < _LARGO_MINIMO_TRUNCADO:
|
| 110 |
-
return False
|
| 111 |
-
return limpio.rstrip(_ADORNOS_FINALES)[-1:] not in _FIN_DE_FRASE
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def interpretacion_defectuosa(text: str) -> bool:
|
| 115 |
-
"""True si la salida limpiada no es una interpretación válida: demasiado corta, cadena de
|
| 116 |
-
razonamiento filtrada, bucle de repetición o frase cortada. Se usa para forzar un
|
| 117 |
-
reintento."""
|
| 118 |
-
if len(text.strip()) < 40:
|
| 119 |
-
return True
|
| 120 |
-
if _MARCADOR_PENSAMIENTO.search(text[:500]):
|
| 121 |
-
return True
|
| 122 |
-
if interpretacion_truncada(text):
|
| 123 |
-
return True
|
| 124 |
-
conteo: dict[str, int] = {}
|
| 125 |
-
for linea in text.split("\n"):
|
| 126 |
-
clave = linea.strip()
|
| 127 |
-
if len(clave) > 15:
|
| 128 |
-
conteo[clave] = conteo.get(clave, 0) + 1
|
| 129 |
-
if conteo[clave] >= 3:
|
| 130 |
-
return True
|
| 131 |
-
return False
|
| 132 |
|
| 133 |
|
| 134 |
class HFSpaceClient:
|
|
@@ -143,7 +46,9 @@ class HFSpaceClient:
|
|
| 143 |
def __init__(self) -> None:
|
| 144 |
cfg = obtener_config()
|
| 145 |
self.estructurado = cfg.hf_space_estructurado
|
|
|
|
| 146 |
self.nombre = "medgemma-hf-json" if self.estructurado else "medgemma-hf"
|
|
|
|
| 147 |
if not cfg.hf_space_url:
|
| 148 |
raise ErrorModelo(
|
| 149 |
"MORPHOS_HF_SPACE_URL no configurada para la ruta HF Space.", reintentable=False
|
|
@@ -248,32 +153,7 @@ class HFSpaceClient:
|
|
| 248 |
if self.estructurado:
|
| 249 |
return self._parsear_estructurado(texto)
|
| 250 |
|
| 251 |
-
|
| 252 |
-
# Salida defectuosa (razonamiento filtrado / bucle / frase cortada) → error
|
| 253 |
-
# reintentable: el servicio vuelve a muestrear una vez y suele obtener una respuesta
|
| 254 |
-
# correcta. Se distingue el motivo porque una respuesta truncada se ve completa y el
|
| 255 |
-
# mensaje genérico despistaría al diagnosticar.
|
| 256 |
-
if interpretacion_defectuosa(limpio):
|
| 257 |
-
truncada = interpretacion_truncada(limpio)
|
| 258 |
-
motivo = (
|
| 259 |
-
"la respuesta llegó cortada a mitad de frase"
|
| 260 |
-
if truncada
|
| 261 |
-
else "el modelo devolvió razonamiento o texto repetido, no la interpretación"
|
| 262 |
-
)
|
| 263 |
-
raise ErrorModelo(
|
| 264 |
-
f"Respuesta inutilizable del modelo: {motivo}.", truncado=truncada
|
| 265 |
-
)
|
| 266 |
-
|
| 267 |
-
# `requiere_derivacion=True` es un marcador de posición conservador, NO una lectura del
|
| 268 |
-
# modelo: en esta ruta el Space devuelve prosa y no puede rellenar campos estructurados.
|
| 269 |
-
# Quien le pone valor real es el servicio, desde el motor determinista
|
| 270 |
-
# (`_derivacion_en_ruta_de_prosa`). Dejarlo constante hacía que `acierto_derivacion`
|
| 271 |
-
# midiera este default y no al modelo, y contradecía al propio texto en un panel normal.
|
| 272 |
-
return InterpretacionClinica(
|
| 273 |
-
interpretacion=limpio,
|
| 274 |
-
requiere_derivacion=True,
|
| 275 |
-
idioma="es",
|
| 276 |
-
)
|
| 277 |
|
| 278 |
@staticmethod
|
| 279 |
def _parsear_estructurado(texto: str) -> InterpretacionClinica:
|
|
|
|
| 20 |
from ..schemas import InterpretacionClinica, esquema_estructurado
|
| 21 |
from .base import ErrorModelo
|
| 22 |
|
| 23 |
+
# La higiene de la prosa vive en `prosa.py` desde que también la usan los modelos locales
|
| 24 |
+
# declarados como `prosa` en la lista blanca. Se reexporta aquí porque era la API pública de
|
| 25 |
+
# este módulo (y lo que importan sus tests de regresión).
|
| 26 |
+
from .prosa import ( # noqa: F401
|
| 27 |
+
_cortar_bucle_lineas,
|
| 28 |
+
interpretacion_defectuosa,
|
| 29 |
+
interpretacion_desde_prosa,
|
| 30 |
+
interpretacion_truncada,
|
| 31 |
+
limpiar_respuesta,
|
|
|
|
|
|
|
| 32 |
)
|
| 33 |
|
| 34 |
+
_DATA_URL = re.compile(r"^data:(image/[\w+]+);base64,(.+)$", re.DOTALL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class HFSpaceClient:
|
|
|
|
| 46 |
def __init__(self) -> None:
|
| 47 |
cfg = obtener_config()
|
| 48 |
self.estructurado = cfg.hf_space_estructurado
|
| 49 |
+
self.prosa = not self.estructurado
|
| 50 |
self.nombre = "medgemma-hf-json" if self.estructurado else "medgemma-hf"
|
| 51 |
+
self.modelo = "hf-space"
|
| 52 |
if not cfg.hf_space_url:
|
| 53 |
raise ErrorModelo(
|
| 54 |
"MORPHOS_HF_SPACE_URL no configurada para la ruta HF Space.", reintentable=False
|
|
|
|
| 153 |
if self.estructurado:
|
| 154 |
return self._parsear_estructurado(texto)
|
| 155 |
|
| 156 |
+
return interpretacion_desde_prosa(texto)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
@staticmethod
|
| 159 |
def _parsear_estructurado(texto: str) -> InterpretacionClinica:
|
backend/app/ai/medgemma.py
CHANGED
|
@@ -18,6 +18,7 @@ import httpx
|
|
| 18 |
from ..config import obtener_config
|
| 19 |
from ..schemas import InterpretacionClinica, esquema_estructurado
|
| 20 |
from .base import ErrorModelo
|
|
|
|
| 21 |
|
| 22 |
_DATA_URL = re.compile(r"^data:image/(?:jpeg|png|gif|webp);base64,(.+)$", re.DOTALL)
|
| 23 |
|
|
@@ -32,12 +33,22 @@ def _base64_imagenes(imagenes: list[str]) -> list[str]:
|
|
| 32 |
|
| 33 |
|
| 34 |
class MedGemmaClient:
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
cfg = obtener_config()
|
| 39 |
self._url = cfg.medgemma_base_url.rstrip("/")
|
| 40 |
-
self.
|
|
|
|
|
|
|
| 41 |
self._timeout = cfg.medgemma_timeout_s
|
| 42 |
self._esquema = esquema_estructurado()
|
| 43 |
|
|
@@ -50,13 +61,14 @@ class MedGemmaClient:
|
|
| 50 |
mensaje_user["images"] = b64
|
| 51 |
|
| 52 |
payload = {
|
| 53 |
-
"model": self.
|
| 54 |
"messages": [{"role": "system", "content": sistema}, mensaje_user],
|
| 55 |
-
"format": self._esquema, # salida estructurada nativa de Ollama
|
| 56 |
"stream": False,
|
| 57 |
"think": False,
|
| 58 |
"options": {"temperature": 0.2, "num_predict": 1500},
|
| 59 |
}
|
|
|
|
|
|
|
| 60 |
|
| 61 |
try:
|
| 62 |
async with httpx.AsyncClient(timeout=self._timeout) as cliente:
|
|
@@ -83,7 +95,9 @@ class MedGemmaClient:
|
|
| 83 |
)
|
| 84 |
|
| 85 |
contenido = resp.json().get("message", {}).get("content", "")
|
|
|
|
|
|
|
| 86 |
try:
|
| 87 |
return InterpretacionClinica.model_validate_json(contenido)
|
| 88 |
except Exception as exc: # noqa: BLE001
|
| 89 |
-
raise ErrorModelo(f"Salida de
|
|
|
|
| 18 |
from ..config import obtener_config
|
| 19 |
from ..schemas import InterpretacionClinica, esquema_estructurado
|
| 20 |
from .base import ErrorModelo
|
| 21 |
+
from .prosa import interpretacion_desde_prosa
|
| 22 |
|
| 23 |
_DATA_URL = re.compile(r"^data:image/(?:jpeg|png|gif|webp);base64,(.+)$", re.DOTALL)
|
| 24 |
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
class MedGemmaClient:
|
| 36 |
+
"""Cliente de Ollama. `modelo` lo puede fijar el usuario desde la UI, pero SÓLO con un
|
| 37 |
+
nombre ya validado contra la lista blanca (`PeticionInterpretacion.modelo_local`); la
|
| 38 |
+
base_url nunca sale de la configuración del servidor.
|
| 39 |
|
| 40 |
+
`prosa` desactiva la decodificación restringida para los modelos que la aceptan pero la
|
| 41 |
+
rellenan en hueco (ver `config.modelos_locales`). El nombre distinto NO es cosmético: el
|
| 42 |
+
servicio decide por `prosa` si usa el system prompt de prosa, si exige campos
|
| 43 |
+
estructurados y si suple `requiere_derivacion`.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, modelo: str | None = None, *, prosa: bool = False) -> None:
|
| 47 |
cfg = obtener_config()
|
| 48 |
self._url = cfg.medgemma_base_url.rstrip("/")
|
| 49 |
+
self.modelo = modelo or cfg.medgemma_model
|
| 50 |
+
self.prosa = prosa
|
| 51 |
+
self.nombre = "medgemma-prosa" if prosa else "medgemma"
|
| 52 |
self._timeout = cfg.medgemma_timeout_s
|
| 53 |
self._esquema = esquema_estructurado()
|
| 54 |
|
|
|
|
| 61 |
mensaje_user["images"] = b64
|
| 62 |
|
| 63 |
payload = {
|
| 64 |
+
"model": self.modelo,
|
| 65 |
"messages": [{"role": "system", "content": sistema}, mensaje_user],
|
|
|
|
| 66 |
"stream": False,
|
| 67 |
"think": False,
|
| 68 |
"options": {"temperature": 0.2, "num_predict": 1500},
|
| 69 |
}
|
| 70 |
+
if not self.prosa:
|
| 71 |
+
payload["format"] = self._esquema # salida estructurada nativa de Ollama
|
| 72 |
|
| 73 |
try:
|
| 74 |
async with httpx.AsyncClient(timeout=self._timeout) as cliente:
|
|
|
|
| 95 |
)
|
| 96 |
|
| 97 |
contenido = resp.json().get("message", {}).get("content", "")
|
| 98 |
+
if self.prosa:
|
| 99 |
+
return interpretacion_desde_prosa(contenido)
|
| 100 |
try:
|
| 101 |
return InterpretacionClinica.model_validate_json(contenido)
|
| 102 |
except Exception as exc: # noqa: BLE001
|
| 103 |
+
raise ErrorModelo(f"Salida de {self.modelo} no valida el esquema: {exc}") from exc
|
backend/app/ai/prosa.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Higiene de la salida de los modelos que devuelven TEXTO libre.
|
| 2 |
+
|
| 3 |
+
Vivía dentro de `hf_space.py` cuando el Space era la única ruta de prosa. Ahora también la
|
| 4 |
+
usa `medgemma.py` cuando el modelo local elegido está declarado como `prosa` en la lista
|
| 5 |
+
blanca: no todo modelo que corre en Ollama sabe respetar una decodificación restringida
|
| 6 |
+
—medido con qwen2.5:7b, que devolvía JSON válido con los tres campos estructurados
|
| 7 |
+
vacíos—, y para ésos es mejor pedir prosa y envolverla que aceptar un objeto hueco.
|
| 8 |
+
|
| 9 |
+
Los marcadores de `<unused95>` y `<start_of_turn>` son de la familia Gemma, pero el resto
|
| 10 |
+
(razonamiento filtrado, bucle de repetición, frase cortada) son modos de fallo genéricos de
|
| 11 |
+
cualquier modelo pequeño, así que se aplican a las dos rutas por igual.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
|
| 18 |
+
from ..schemas import InterpretacionClinica
|
| 19 |
+
from .base import ErrorModelo
|
| 20 |
+
|
| 21 |
+
# medGemma es un modelo con "pensamiento": de forma intermitente emite una cadena de
|
| 22 |
+
# razonamiento en inglés (etiquetada `thought` / "thinking process") en lugar de responder,
|
| 23 |
+
# y a veces degenera en un bucle de repetición que agota el presupuesto de tokens sin llegar
|
| 24 |
+
# a la respuesta. Estos marcadores permiten detectar y descartar esa salida defectuosa.
|
| 25 |
+
_MARCADOR_PENSAMIENTO = re.compile(
|
| 26 |
+
r"(?im)^\s*(thought|thinking)\s*:?\s*$"
|
| 27 |
+
r"|here'?s\s+(a|my)\s+thinking\s+process"
|
| 28 |
+
r"|thinking\s+process\s+to\s+arrive"
|
| 29 |
+
r"|proceso\s+de\s+(pensamiento|razonamiento)"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def limpiar_respuesta(text: str) -> str:
|
| 34 |
+
"""Versión compacta de la limpieza que antes vivía en ia.js (limpiarRespuesta).
|
| 35 |
+
|
| 36 |
+
Sólo se aplica a las rutas de PROSA (texto crudo del modelo); las rutas con salida
|
| 37 |
+
estructurada validan contra el esquema y no la necesitan.
|
| 38 |
+
"""
|
| 39 |
+
if "<start_of_turn>model" in text:
|
| 40 |
+
text = text.split("<start_of_turn>model")[-1]
|
| 41 |
+
if "<end_of_turn>" in text:
|
| 42 |
+
text = text[: text.index("<end_of_turn>")]
|
| 43 |
+
if "<unused95>" in text:
|
| 44 |
+
text = text.split("<unused95>")[-1]
|
| 45 |
+
elif "<unused94>" in text:
|
| 46 |
+
text = "".join(text.split("<unused94>")[1:]).strip()
|
| 47 |
+
text = re.sub(r"<unused\d+>", "", text)
|
| 48 |
+
text = re.sub(r"<start_of_turn>\w+\n?", "", text)
|
| 49 |
+
text = re.sub(r"^\d+\s+(medical assistant|assistant|model)\s*", "", text, flags=re.I)
|
| 50 |
+
# LaTeX y bloques matemáticos
|
| 51 |
+
text = re.sub(r"\$\\boxed\{[^}]*\}\$", "", text)
|
| 52 |
+
text = re.sub(r"\\[a-zA-Z]+(\{[^}]*\})?", "", text)
|
| 53 |
+
text = re.sub(r"\$[^$]*\$", "", text)
|
| 54 |
+
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 55 |
+
text = _cortar_bucle_lineas(text)
|
| 56 |
+
# Corta al primer párrafo repetido (bucle del modelo)
|
| 57 |
+
vistos: set[str] = set()
|
| 58 |
+
sin_rep = []
|
| 59 |
+
for p in re.split(r"\n\n+", text):
|
| 60 |
+
clave = p.strip()[:80]
|
| 61 |
+
if clave in vistos:
|
| 62 |
+
break
|
| 63 |
+
vistos.add(clave)
|
| 64 |
+
sin_rep.append(p)
|
| 65 |
+
return "\n\n".join(sin_rep).strip() or "Sin respuesta del modelo."
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _cortar_bucle_lineas(text: str) -> str:
|
| 69 |
+
"""Trunca en cuanto una línea sustantiva se repite por 3.ª vez (bucle a nivel de viñeta,
|
| 70 |
+
que la deduplicación por párrafos `\\n\\n` no detecta)."""
|
| 71 |
+
conteo: dict[str, int] = {}
|
| 72 |
+
salida: list[str] = []
|
| 73 |
+
for linea in text.split("\n"):
|
| 74 |
+
clave = linea.strip()
|
| 75 |
+
if len(clave) > 15:
|
| 76 |
+
conteo[clave] = conteo.get(clave, 0) + 1
|
| 77 |
+
if conteo[clave] >= 3:
|
| 78 |
+
break
|
| 79 |
+
salida.append(linea)
|
| 80 |
+
return "\n".join(salida)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# Caracteres que pueden ir DESPUÉS del punto final sin que la frase esté cortada: énfasis
|
| 84 |
+
# markdown, comillas y cierres de paréntesis.
|
| 85 |
+
_ADORNOS_FINALES = "*_`\"'”»)]}"
|
| 86 |
+
_FIN_DE_FRASE = ".!?…:"
|
| 87 |
+
# Una línea final corta puede ser un ítem de lista o un encabezado legítimos sin puntuación
|
| 88 |
+
# ("- Ecografía abdominal"). Sólo una línea larga sin cierre delata una frase interrumpida.
|
| 89 |
+
_LARGO_MINIMO_TRUNCADO = 40
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def interpretacion_truncada(text: str) -> bool:
|
| 93 |
+
"""True si la respuesta se corta a mitad de frase.
|
| 94 |
+
|
| 95 |
+
El Space puede devolver una interpretación cortada en seco (visto en producción:
|
| 96 |
+
«…los diferenciales incluyen la lip», justo antes de nombrar el diferencial clave). La
|
| 97 |
+
limpieza no la toca —el corte viene de arriba— y el resto de comprobaciones no la ven:
|
| 98 |
+
el texto es largo, no repite y no filtra razonamiento. Sin esto, una interpretación
|
| 99 |
+
incompleta llega al veterinario con aspecto de completa, callando justo lo que importa.
|
| 100 |
+
"""
|
| 101 |
+
limpio = text.rstrip()
|
| 102 |
+
if not limpio:
|
| 103 |
+
return True
|
| 104 |
+
ultima_linea = limpio.split("\n")[-1].strip()
|
| 105 |
+
if len(ultima_linea) < _LARGO_MINIMO_TRUNCADO:
|
| 106 |
+
return False
|
| 107 |
+
return limpio.rstrip(_ADORNOS_FINALES)[-1:] not in _FIN_DE_FRASE
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def interpretacion_defectuosa(text: str) -> bool:
|
| 111 |
+
"""True si la salida limpiada no es una interpretación válida: demasiado corta, cadena de
|
| 112 |
+
razonamiento filtrada, bucle de repetición o frase cortada. Se usa para forzar un
|
| 113 |
+
reintento."""
|
| 114 |
+
if len(text.strip()) < 40:
|
| 115 |
+
return True
|
| 116 |
+
if _MARCADOR_PENSAMIENTO.search(text[:500]):
|
| 117 |
+
return True
|
| 118 |
+
if interpretacion_truncada(text):
|
| 119 |
+
return True
|
| 120 |
+
conteo: dict[str, int] = {}
|
| 121 |
+
for linea in text.split("\n"):
|
| 122 |
+
clave = linea.strip()
|
| 123 |
+
if len(clave) > 15:
|
| 124 |
+
conteo[clave] = conteo.get(clave, 0) + 1
|
| 125 |
+
if conteo[clave] >= 3:
|
| 126 |
+
return True
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def interpretacion_desde_prosa(texto: str) -> InterpretacionClinica:
|
| 131 |
+
"""Limpia el texto crudo y lo envuelve en el contrato estructurado, o lanza ErrorModelo.
|
| 132 |
+
|
| 133 |
+
Salida defectuosa (razonamiento filtrado / bucle / frase cortada) → error reintentable:
|
| 134 |
+
el servicio vuelve a muestrear una vez y suele obtener una respuesta correcta. Se
|
| 135 |
+
distingue el motivo porque una respuesta truncada se ve completa y el mensaje genérico
|
| 136 |
+
despistaría al diagnosticar.
|
| 137 |
+
|
| 138 |
+
`requiere_derivacion=True` es un marcador de posición conservador, NO una lectura del
|
| 139 |
+
modelo: en esta ruta el modelo devuelve prosa y no puede rellenar campos estructurados.
|
| 140 |
+
Quien le pone valor real es el servicio, desde el motor determinista
|
| 141 |
+
(`_derivacion_en_ruta_de_prosa`). Dejarlo constante hacía que `acierto_derivacion`
|
| 142 |
+
midiera este default y no al modelo, y contradecía al propio texto en un panel normal.
|
| 143 |
+
"""
|
| 144 |
+
limpio = limpiar_respuesta(texto)
|
| 145 |
+
if interpretacion_defectuosa(limpio):
|
| 146 |
+
truncada = interpretacion_truncada(limpio)
|
| 147 |
+
motivo = (
|
| 148 |
+
"la respuesta llegó cortada a mitad de frase"
|
| 149 |
+
if truncada
|
| 150 |
+
else "el modelo devolvió razonamiento o texto repetido, no la interpretación"
|
| 151 |
+
)
|
| 152 |
+
raise ErrorModelo(f"Respuesta inutilizable del modelo: {motivo}.", truncado=truncada)
|
| 153 |
+
|
| 154 |
+
return InterpretacionClinica(
|
| 155 |
+
interpretacion=limpio,
|
| 156 |
+
requiere_derivacion=True,
|
| 157 |
+
idioma="es",
|
| 158 |
+
)
|
backend/app/ai/service.py
CHANGED
|
@@ -98,15 +98,32 @@ def _derivacion_en_ruta_de_prosa(pet: PeticionInterpretacion) -> bool:
|
|
| 98 |
return bool(pet.hallazgos or pet.patrones)
|
| 99 |
|
| 100 |
|
| 101 |
-
def _crear_cliente(backend: str) -> ClienteModelo:
|
| 102 |
if backend == "claude":
|
| 103 |
from .claude import ClaudeClient
|
| 104 |
|
| 105 |
return ClaudeClient()
|
| 106 |
|
| 107 |
-
# Ruta 'medgemma': por defecto el HF Space (donde vive medGemma); si no hay Space
|
| 108 |
-
# configurado, cae a Ollama local.
|
| 109 |
cfg = obtener_config()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
if cfg.hf_space_url:
|
| 111 |
from .hf_space import HFSpaceClient
|
| 112 |
|
|
@@ -152,8 +169,8 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
|
| 152 |
|
| 153 |
# 2-3) Prompt endurecido con contexto recuperado y llamada al modelo, con un reintento
|
| 154 |
# ante salida malformada. El HF Space devuelve texto libre → system prompt de prosa.
|
| 155 |
-
cliente = _crear_cliente(backend)
|
| 156 |
-
es_prosa = cliente.
|
| 157 |
sistema = SISTEMA_PROSA if es_prosa else SISTEMA
|
| 158 |
# Los fragmentos ENVIADOS pueden ser menos que los recuperados —por presupuesto de prompt
|
| 159 |
# aquí, o por el recorte del reintento más abajo— y son ésos, no los recuperados, los que
|
|
@@ -242,12 +259,10 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
|
| 242 |
log.warning("El modelo no marcó derivación con hallazgos graves; se fuerza.")
|
| 243 |
resultado.requiere_derivacion = True
|
| 244 |
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
else:
|
| 250 |
-
etiqueta = cfg.medgemma_model
|
| 251 |
|
| 252 |
return RespuestaInterpretacion(
|
| 253 |
resultado=resultado,
|
|
|
|
| 98 |
return bool(pet.hallazgos or pet.patrones)
|
| 99 |
|
| 100 |
|
| 101 |
+
def _crear_cliente(backend: str, modelo_local: str | None = None) -> ClienteModelo:
|
| 102 |
if backend == "claude":
|
| 103 |
from .claude import ClaudeClient
|
| 104 |
|
| 105 |
return ClaudeClient()
|
| 106 |
|
|
|
|
|
|
|
| 107 |
cfg = obtener_config()
|
| 108 |
+
|
| 109 |
+
# Modelo local elegido por el usuario. El nombre ya viene validado contra la lista blanca
|
| 110 |
+
# por `PeticionInterpretacion`; aquí se revalida porque este camino también lo usan las
|
| 111 |
+
# evals, que pueden construir la petición de otras formas. Tiene prioridad sobre el Space:
|
| 112 |
+
# elegir un modelo en la UI y que el servidor llamara igualmente al Space sería mentirle al
|
| 113 |
+
# usuario. Todo lo demás —RAG, prompt endurecido, atribución y suelos de seguridad— es
|
| 114 |
+
# idéntico, porque vive en este servicio y no en el cliente.
|
| 115 |
+
if modelo_local:
|
| 116 |
+
permitidos = cfg.modelos_locales_permitidos()
|
| 117 |
+
if modelo_local not in permitidos:
|
| 118 |
+
raise ErrorModelo(
|
| 119 |
+
f"Modelo local no permitido: {modelo_local!r}.", reintentable=False
|
| 120 |
+
)
|
| 121 |
+
from .medgemma import MedGemmaClient
|
| 122 |
+
|
| 123 |
+
return MedGemmaClient(modelo_local, prosa=permitidos[modelo_local])
|
| 124 |
+
|
| 125 |
+
# Ruta 'medgemma' por defecto: el HF Space (donde vive medGemma); si no hay Space
|
| 126 |
+
# configurado, cae a Ollama local.
|
| 127 |
if cfg.hf_space_url:
|
| 128 |
from .hf_space import HFSpaceClient
|
| 129 |
|
|
|
|
| 169 |
|
| 170 |
# 2-3) Prompt endurecido con contexto recuperado y llamada al modelo, con un reintento
|
| 171 |
# ante salida malformada. El HF Space devuelve texto libre → system prompt de prosa.
|
| 172 |
+
cliente = _crear_cliente(backend, pet.modelo_local)
|
| 173 |
+
es_prosa = cliente.prosa
|
| 174 |
sistema = SISTEMA_PROSA if es_prosa else SISTEMA
|
| 175 |
# Los fragmentos ENVIADOS pueden ser menos que los recuperados —por presupuesto de prompt
|
| 176 |
# aquí, o por el recorte del reintento más abajo— y son ésos, no los recuperados, los que
|
|
|
|
| 259 |
log.warning("El modelo no marcó derivación con hallazgos graves; se fuerza.")
|
| 260 |
resultado.requiere_derivacion = True
|
| 261 |
|
| 262 |
+
# La etiqueta sale del propio cliente y no de la configuración: con un modelo elegido en la
|
| 263 |
+
# UI, `cfg.medgemma_model` ya no es el que respondió, y la línea "Modelo:" de la tarjeta
|
| 264 |
+
# clínica es lo único que le dice al veterinario con qué se generó lo que está leyendo.
|
| 265 |
+
etiqueta = cliente.modelo
|
|
|
|
|
|
|
| 266 |
|
| 267 |
return RespuestaInterpretacion(
|
| 268 |
resultado=resultado,
|
backend/app/config.py
CHANGED
|
@@ -9,9 +9,10 @@ from __future__ import annotations
|
|
| 9 |
|
| 10 |
from functools import lru_cache
|
| 11 |
from pathlib import Path
|
|
|
|
| 12 |
|
| 13 |
from pydantic import AliasChoices, Field, field_validator
|
| 14 |
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 15 |
|
| 16 |
# Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
|
| 17 |
RAIZ_REPO = Path(__file__).resolve().parents[2]
|
|
@@ -54,6 +55,29 @@ class Configuracion(BaseSettings):
|
|
| 54 |
# cuantizado tarda minutos en frío y luego responde en segundos. Con 120 s la primera
|
| 55 |
# llamada se caía por timeout y las evals lo veían como "no se pudo conectar".
|
| 56 |
medgemma_timeout_s: int = Field(default=300)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
hf_space_url: str = Field(default="https://blackmistcode-morphos-medgemma.hf.space/gradio_api")
|
| 58 |
# Salida ESTRUCTURADA del Space: se le manda el JSON Schema de InterpretacionClinica y el
|
| 59 |
# Space restringe la decodificación a producirlo (como `format` en Ollama). Es la corrección
|
|
@@ -102,8 +126,11 @@ class Configuracion(BaseSettings):
|
|
| 102 |
# fragmentos (~3.600 caracteres) la respuesta se cortaba a mitad de frase en ~220 tokens,
|
| 103 |
# con 2 salía completa en ~950. Medido contra el Space el 2026-07-27.
|
| 104 |
#
|
| 105 |
-
# No se aplica a
|
| 106 |
-
# presupuesto con la respuesta, y más contexto
|
|
|
|
|
|
|
|
|
|
| 107 |
rag_max_chars_prompt: int = Field(default=1800)
|
| 108 |
rag_habilitado: bool = Field(default=True)
|
| 109 |
# Idioma de la consulta de recuperación. "en" (por defecto) traduce el vocabulario clínico
|
|
@@ -191,18 +218,49 @@ class Configuracion(BaseSettings):
|
|
| 191 |
# --- Integración de analizadores de laboratorio ---
|
| 192 |
# Claves de API de los puentes locales (dispositivos headless). Autoriza /api/lab/ingesta.
|
| 193 |
# Si está vacía, la ingesta queda DESHABILITADA (falla cerrado con 503). Acepta lista JSON
|
| 194 |
-
# o cadena separada por comas en MORPHOS_LAB_API_KEYS
|
| 195 |
-
|
|
|
|
| 196 |
# Persistencia opcional de resultados en SQLite (sólo útil con volumen persistente).
|
| 197 |
lab_persistir: bool = Field(default=False)
|
| 198 |
|
| 199 |
-
@field_validator("lab_api_keys", mode="before")
|
| 200 |
@classmethod
|
| 201 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
if isinstance(v, str):
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
return v
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def validar_prod(self) -> None:
|
| 207 |
"""Requisitos que sólo aplican en producción; falla cerrado si faltan."""
|
| 208 |
if self.entorno != "prod":
|
|
|
|
| 9 |
|
| 10 |
from functools import lru_cache
|
| 11 |
from pathlib import Path
|
| 12 |
+
from typing import Annotated
|
| 13 |
|
| 14 |
from pydantic import AliasChoices, Field, field_validator
|
| 15 |
+
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
| 16 |
|
| 17 |
# Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
|
| 18 |
RAIZ_REPO = Path(__file__).resolve().parents[2]
|
|
|
|
| 55 |
# cuantizado tarda minutos en frío y luego responde en segundos. Con 120 s la primera
|
| 56 |
# llamada se caía por timeout y las evals lo veían como "no se pudo conectar".
|
| 57 |
medgemma_timeout_s: int = Field(default=300)
|
| 58 |
+
# Modelos locales que el usuario puede ELEGIR desde la UI. Lista blanca cerrada: vacía por
|
| 59 |
+
# defecto, lo que deja el selector oculto y el comportamiento de siempre (la ruta 'medgemma'
|
| 60 |
+
# la decide el servidor). Formato de cada entrada: `nombre[=prosa|=estructurado]`.
|
| 61 |
+
#
|
| 62 |
+
# MORPHOS_MODELOS_LOCALES="medgemma1.5:latest, qwen2.5:7b=prosa"
|
| 63 |
+
#
|
| 64 |
+
# Por qué una lista blanca y no un campo de texto libre: el nombre viaja del navegador al
|
| 65 |
+
# servidor y de ahí a Ollama, así que un campo libre deja al cliente decidir qué pesos se
|
| 66 |
+
# descargan en la máquina que aloja el servicio. Y por eso NO hay campo de URL: la base_url
|
| 67 |
+
# se queda en `medgemma_base_url`, del lado servidor. Aceptar una URL del cliente convierte
|
| 68 |
+
# /api/interpret en un SSRF (el servidor haría peticiones a donde diga el navegador).
|
| 69 |
+
#
|
| 70 |
+
# El sufijo declara si el modelo sabe emitir salida ESTRUCTURADA (decodificación restringida
|
| 71 |
+
# por JSON Schema) o hay que pedirle prosa y envolverla. No se infiere: qwen2.5:7b acepta el
|
| 72 |
+
# `format` de Ollama y devuelve JSON válido con `hallazgos_clave`, `diferenciales` y
|
| 73 |
+
# `siguientes_pruebas` VACÍOS, que valida el esquema y deja al veterinario sin lo que vino a
|
| 74 |
+
# buscar. Por defecto se asume `estructurado`, que es lo que hace medGemma.
|
| 75 |
+
#
|
| 76 |
+
# Sólo tiene sentido donde el servicio tiene un Ollama alcanzable: "local" es local al
|
| 77 |
+
# SERVIDOR, no al navegador. En el HF Space se deja vacía.
|
| 78 |
+
# `NoDecode`: sin él, la fuente de entorno intenta json.loads() del valor ANTES de que corra
|
| 79 |
+
# `_dividir_lista` y la forma separada por comas revienta el arranque con SettingsError.
|
| 80 |
+
modelos_locales: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
| 81 |
hf_space_url: str = Field(default="https://blackmistcode-morphos-medgemma.hf.space/gradio_api")
|
| 82 |
# Salida ESTRUCTURADA del Space: se le manda el JSON Schema de InterpretacionClinica y el
|
| 83 |
# Space restringe la decodificación a producirlo (como `format` en Ollama). Es la corrección
|
|
|
|
| 126 |
# fragmentos (~3.600 caracteres) la respuesta se cortaba a mitad de frase en ~220 tokens,
|
| 127 |
# con 2 salía completa en ~950. Medido contra el Space el 2026-07-27.
|
| 128 |
#
|
| 129 |
+
# No se aplica a las rutas con salida estructurada (Ollama por defecto, Claude): ahí el
|
| 130 |
+
# razonamiento va desactivado o no comparte presupuesto con la respuesta, y más contexto
|
| 131 |
+
# sólo mejora la fundamentación. Sí se aplica a un modelo local declarado `prosa` en
|
| 132 |
+
# `modelos_locales`: es el mismo modo de fallo (un modelo pequeño razonando en voz alta
|
| 133 |
+
# dentro del mismo presupuesto de generación), aunque no se haya medido caso por caso.
|
| 134 |
rag_max_chars_prompt: int = Field(default=1800)
|
| 135 |
rag_habilitado: bool = Field(default=True)
|
| 136 |
# Idioma de la consulta de recuperación. "en" (por defecto) traduce el vocabulario clínico
|
|
|
|
| 218 |
# --- Integración de analizadores de laboratorio ---
|
| 219 |
# Claves de API de los puentes locales (dispositivos headless). Autoriza /api/lab/ingesta.
|
| 220 |
# Si está vacía, la ingesta queda DESHABILITADA (falla cerrado con 503). Acepta lista JSON
|
| 221 |
+
# o cadena separada por comas en MORPHOS_LAB_API_KEYS (`NoDecode`, ver `modelos_locales`:
|
| 222 |
+
# sin él la forma con comas fallaba al arrancar pese a estar documentada).
|
| 223 |
+
lab_api_keys: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
| 224 |
# Persistencia opcional de resultados en SQLite (sólo útil con volumen persistente).
|
| 225 |
lab_persistir: bool = Field(default=False)
|
| 226 |
|
| 227 |
+
@field_validator("lab_api_keys", "modelos_locales", mode="before")
|
| 228 |
@classmethod
|
| 229 |
+
def _dividir_lista(cls, v):
|
| 230 |
+
"""Acepta lista JSON o cadena separada por comas.
|
| 231 |
+
|
| 232 |
+
El decodificado JSON lo hacía antes la fuente de entorno, pero se ejecutaba ANTES que
|
| 233 |
+
este validador y hacía fallar el arranque con la forma de comas (que es la documentada).
|
| 234 |
+
Con `NoDecode` el valor llega crudo y se decide aquí: JSON si lo parece, comas si no.
|
| 235 |
+
"""
|
| 236 |
if isinstance(v, str):
|
| 237 |
+
crudo = v.strip()
|
| 238 |
+
if crudo.startswith("["):
|
| 239 |
+
import json
|
| 240 |
+
|
| 241 |
+
try:
|
| 242 |
+
return json.loads(crudo)
|
| 243 |
+
except json.JSONDecodeError:
|
| 244 |
+
pass
|
| 245 |
+
return [k.strip() for k in crudo.split(",") if k.strip()]
|
| 246 |
return v
|
| 247 |
|
| 248 |
+
def modelos_locales_permitidos(self) -> dict[str, bool]:
|
| 249 |
+
"""Lista blanca parseada: nombre del modelo → si hay que pedirle PROSA.
|
| 250 |
+
|
| 251 |
+
Se separa por '=' y no por ':' porque el nombre de un modelo de Ollama ya lleva ':'
|
| 252 |
+
(`medgemma1.5:latest`). Un sufijo desconocido se trata como `estructurado`, que es el
|
| 253 |
+
valor por defecto; no se lanza, para que una errata en el .env no impida arrancar el
|
| 254 |
+
servicio entero por un selector opcional.
|
| 255 |
+
"""
|
| 256 |
+
permitidos: dict[str, bool] = {}
|
| 257 |
+
for entrada in self.modelos_locales:
|
| 258 |
+
nombre, _, modo = entrada.partition("=")
|
| 259 |
+
nombre = nombre.strip()
|
| 260 |
+
if nombre:
|
| 261 |
+
permitidos[nombre] = modo.strip().lower() == "prosa"
|
| 262 |
+
return permitidos
|
| 263 |
+
|
| 264 |
def validar_prod(self) -> None:
|
| 265 |
"""Requisitos que sólo aplican en producción; falla cerrado si faltan."""
|
| 266 |
if self.entorno != "prod":
|
backend/app/routers/interpret.py
CHANGED
|
@@ -42,6 +42,21 @@ def _validar_imagenes(imagenes: list[str]) -> None:
|
|
| 42 |
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Imagen demasiado grande.")
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
@router.post("/interpret", response_model=RespuestaInterpretacion)
|
| 46 |
@limiter.limit(obtener_config().limite_interpret)
|
| 47 |
# Segundo límite, con clave por usuario: el de arriba (por IP) frena ráfagas puntuales, éste
|
|
|
|
| 42 |
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Imagen demasiado grande.")
|
| 43 |
|
| 44 |
|
| 45 |
+
@router.get("/modelos")
|
| 46 |
+
async def get_modelos(_sesion: dict = Depends(usuario_actual)) -> dict:
|
| 47 |
+
"""Modelos locales que el usuario puede elegir, para poblar el selector de la UI.
|
| 48 |
+
|
| 49 |
+
Devuelve sólo NOMBRES: la URL de Ollama no sale del servidor ni entra desde el cliente.
|
| 50 |
+
Lista vacía = el selector no se muestra y la ruta la sigue decidiendo el servidor, que es
|
| 51 |
+
el caso de la instancia pública (allí no hay Ollama que valga).
|
| 52 |
+
"""
|
| 53 |
+
cfg = obtener_config()
|
| 54 |
+
return {
|
| 55 |
+
"locales": sorted(cfg.modelos_locales_permitidos()),
|
| 56 |
+
"defecto": cfg.medgemma_model,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
@router.post("/interpret", response_model=RespuestaInterpretacion)
|
| 61 |
@limiter.limit(obtener_config().limite_interpret)
|
| 62 |
# Segundo límite, con clave por usuario: el de arriba (por IP) frena ráfagas puntuales, éste
|
backend/app/schemas.py
CHANGED
|
@@ -62,12 +62,37 @@ class PeticionInterpretacion(BaseModel):
|
|
| 62 |
signos_clinicos: str = Field(default="", max_length=2000)
|
| 63 |
imagenes: list[str] = Field(default_factory=list) # data URLs de citología
|
| 64 |
backend: Literal["medgemma", "claude"] = "medgemma"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
@field_validator("imagenes")
|
| 67 |
@classmethod
|
| 68 |
def _limitar_imagenes(cls, v: list[str]) -> list[str]:
|
| 69 |
return v[:4]
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
# --- Salida estructurada del modelo ---
|
| 73 |
|
|
|
|
| 62 |
signos_clinicos: str = Field(default="", max_length=2000)
|
| 63 |
imagenes: list[str] = Field(default_factory=list) # data URLs de citología
|
| 64 |
backend: Literal["medgemma", "claude"] = "medgemma"
|
| 65 |
+
# Modelo local elegido por el usuario en la UI. Sólo se acepta si está en la lista blanca
|
| 66 |
+
# `MORPHOS_MODELOS_LOCALES`; si lo está, sustituye a la ruta que el servidor usaría por
|
| 67 |
+
# defecto para 'medgemma' (Space u Ollama). None = decide el servidor, como siempre.
|
| 68 |
+
modelo_local: str | None = Field(default=None, max_length=100)
|
| 69 |
|
| 70 |
@field_validator("imagenes")
|
| 71 |
@classmethod
|
| 72 |
def _limitar_imagenes(cls, v: list[str]) -> list[str]:
|
| 73 |
return v[:4]
|
| 74 |
|
| 75 |
+
@field_validator("modelo_local")
|
| 76 |
+
@classmethod
|
| 77 |
+
def _validar_lista_blanca(cls, v: str | None) -> str | None:
|
| 78 |
+
"""Rechaza cualquier modelo fuera de la lista blanca.
|
| 79 |
+
|
| 80 |
+
Se valida en el ESQUEMA y no en el router para que valga igual para las evals, que
|
| 81 |
+
llaman a `interpretar()` sin pasar por HTTP, y para que FastAPI conteste 422 (error del
|
| 82 |
+
cliente) en vez del 502 al que el router traduce los ErrorModelo.
|
| 83 |
+
"""
|
| 84 |
+
if v is None:
|
| 85 |
+
return None
|
| 86 |
+
from .config import obtener_config
|
| 87 |
+
|
| 88 |
+
permitidos = obtener_config().modelos_locales_permitidos()
|
| 89 |
+
if v not in permitidos:
|
| 90 |
+
disponibles = ", ".join(sorted(permitidos)) or "ninguno"
|
| 91 |
+
raise ValueError(
|
| 92 |
+
f"Modelo local no permitido: {v!r}. Configurados: {disponibles}."
|
| 93 |
+
)
|
| 94 |
+
return v
|
| 95 |
+
|
| 96 |
|
| 97 |
# --- Salida estructurada del modelo ---
|
| 98 |
|
backend/tests/test_api.py
CHANGED
|
@@ -76,6 +76,30 @@ def test_flujo_registro_login_e_interpret(cliente, monkeypatch):
|
|
| 76 |
assert ok.json()["resultado"]["idioma"] == "es"
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
def test_registro_rechaza_password_corta(cliente):
|
| 80 |
r = cliente.post(
|
| 81 |
"/api/auth/registro",
|
|
|
|
| 76 |
assert ok.json()["resultado"]["idioma"] == "es"
|
| 77 |
|
| 78 |
|
| 79 |
+
def test_modelos_requiere_sesion(cliente):
|
| 80 |
+
"""La lista de modelos describe la configuración del servidor: no es pública."""
|
| 81 |
+
assert cliente.get("/api/modelos").status_code == 401
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_interpret_rechaza_modelo_fuera_de_la_lista_blanca(cliente):
|
| 85 |
+
"""422 (error del cliente) y no 502: la petición es inválida, el modelo ni se llama."""
|
| 86 |
+
reg = cliente.post(
|
| 87 |
+
"/api/auth/registro",
|
| 88 |
+
json={"nombre": "Leo", "apellido": "Vet", "email": "leo@example.com", "password": "clave-segura-1"},
|
| 89 |
+
)
|
| 90 |
+
csrf = reg.json()["csrf"]
|
| 91 |
+
|
| 92 |
+
assert cliente.get("/api/modelos").json()["locales"] == [] # nada declarado por defecto
|
| 93 |
+
|
| 94 |
+
r = cliente.post(
|
| 95 |
+
"/api/interpret",
|
| 96 |
+
json={"paciente": {"especie": "canino"}, "modelo_local": "llama3:70b"},
|
| 97 |
+
headers={"X-CSRF-Token": csrf},
|
| 98 |
+
)
|
| 99 |
+
assert r.status_code == 422, r.text
|
| 100 |
+
assert "no permitido" in r.text
|
| 101 |
+
|
| 102 |
+
|
| 103 |
def test_registro_rechaza_password_corta(cliente):
|
| 104 |
r = cliente.post(
|
| 105 |
"/api/auth/registro",
|
backend/tests/test_modelos_locales.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lista blanca de modelos locales elegibles desde la UI.
|
| 2 |
+
|
| 3 |
+
Lo que aquí se fija es la frontera de confianza: el navegador puede decir CUÁL de los modelos
|
| 4 |
+
declarados por el servidor responde, y nada más. Ni una URL (sería un SSRF: el servidor haría
|
| 5 |
+
peticiones a donde diga el cliente) ni un nombre arbitrario (elegiría qué pesos se descargan en
|
| 6 |
+
la máquina que aloja el servicio). Y el modelo elegido recorre exactamente el mismo camino que
|
| 7 |
+
el Space: RAG, prompt endurecido, atribución y suelos de seguridad.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
from pydantic import ValidationError
|
| 14 |
+
|
| 15 |
+
from app.ai import service
|
| 16 |
+
from app.config import Configuracion, obtener_config
|
| 17 |
+
from app.schemas import InterpretacionClinica, PeticionInterpretacion
|
| 18 |
+
|
| 19 |
+
PETICION = {
|
| 20 |
+
"paciente": {"especie": "canino"},
|
| 21 |
+
"hallazgos": [
|
| 22 |
+
{
|
| 23 |
+
"clave": "hct",
|
| 24 |
+
"nombre": "Hematocrito",
|
| 25 |
+
"valor": 22.0,
|
| 26 |
+
"unidad": "%",
|
| 27 |
+
"direccion": "bajo",
|
| 28 |
+
"gravedad": "grave",
|
| 29 |
+
}
|
| 30 |
+
],
|
| 31 |
+
"patrones": [{"nombre": "Anemia", "descripcion": "…", "gravedad": "grave"}],
|
| 32 |
+
"imagenes": [],
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.fixture
|
| 37 |
+
def lista_blanca(monkeypatch):
|
| 38 |
+
"""Config con dos modelos declarados: uno estructurado y otro de prosa."""
|
| 39 |
+
cfg = obtener_config().model_copy(
|
| 40 |
+
update={"modelos_locales": ["medgemma1.5:latest", "qwen2.5:7b=prosa"]}
|
| 41 |
+
)
|
| 42 |
+
# El validador del esquema importa `obtener_config` perezosamente desde `app.config`;
|
| 43 |
+
# el servicio lo tiene ya resuelto en su propio espacio de nombres. Hay que parchear ambos.
|
| 44 |
+
monkeypatch.setattr("app.config.obtener_config", lambda: cfg)
|
| 45 |
+
monkeypatch.setattr(service, "obtener_config", lambda: cfg)
|
| 46 |
+
return cfg
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_parseo_de_la_lista_blanca():
|
| 50 |
+
cfg = Configuracion(modelos_locales="medgemma1.5:latest, qwen2.5:7b=prosa , vacio=raro")
|
| 51 |
+
permitidos = cfg.modelos_locales_permitidos()
|
| 52 |
+
# El nombre lleva ':' propio, por eso el modo se separa con '='.
|
| 53 |
+
assert permitidos == {
|
| 54 |
+
"medgemma1.5:latest": False,
|
| 55 |
+
"qwen2.5:7b": True,
|
| 56 |
+
"vacio": False, # sufijo desconocido → estructurado, sin reventar el arranque
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_sin_lista_blanca_no_se_acepta_ningun_modelo():
|
| 61 |
+
"""Estado por defecto (instancia pública): el selector no existe y nada se cuela."""
|
| 62 |
+
with pytest.raises(ValidationError, match="no permitido"):
|
| 63 |
+
PeticionInterpretacion.model_validate({**PETICION, "modelo_local": "medgemma1.5:latest"})
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_modelo_fuera_de_la_lista_se_rechaza(lista_blanca):
|
| 67 |
+
with pytest.raises(ValidationError, match="no permitido"):
|
| 68 |
+
PeticionInterpretacion.model_validate({**PETICION, "modelo_local": "llama3:70b"})
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_no_hay_forma_de_inyectar_una_url(lista_blanca):
|
| 72 |
+
"""El campo sólo admite nombres de la lista; una URL nunca llega a httpx."""
|
| 73 |
+
for intento in ("http://attacker.example/api", "http://169.254.169.254/latest/meta-data"):
|
| 74 |
+
with pytest.raises(ValidationError):
|
| 75 |
+
PeticionInterpretacion.model_validate({**PETICION, "modelo_local": intento})
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_cliente_estructurado_para_el_modelo_declarado_asi(lista_blanca, monkeypatch):
|
| 79 |
+
monkeypatch.setattr("app.ai.medgemma.obtener_config", lambda: lista_blanca)
|
| 80 |
+
cliente = service._crear_cliente("medgemma", "medgemma1.5:latest")
|
| 81 |
+
assert cliente.modelo == "medgemma1.5:latest"
|
| 82 |
+
assert cliente.prosa is False
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_cliente_de_prosa_para_el_modelo_declarado_prosa(lista_blanca, monkeypatch):
|
| 86 |
+
monkeypatch.setattr("app.ai.medgemma.obtener_config", lambda: lista_blanca)
|
| 87 |
+
cliente = service._crear_cliente("medgemma", "qwen2.5:7b")
|
| 88 |
+
assert cliente.modelo == "qwen2.5:7b"
|
| 89 |
+
assert cliente.prosa is True
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_el_modelo_local_gana_al_hf_space(lista_blanca, monkeypatch):
|
| 93 |
+
"""Con Space configurado, elegir un modelo en la UI debe llevar a Ollama igualmente.
|
| 94 |
+
|
| 95 |
+
Si no, el selector mentiría: el veterinario cree estar usando su modelo y responde el Space.
|
| 96 |
+
"""
|
| 97 |
+
assert lista_blanca.hf_space_url # el defecto trae Space
|
| 98 |
+
monkeypatch.setattr("app.ai.medgemma.obtener_config", lambda: lista_blanca)
|
| 99 |
+
cliente = service._crear_cliente("medgemma", "medgemma1.5:latest")
|
| 100 |
+
assert cliente.nombre.startswith("medgemma")
|
| 101 |
+
assert "hf" not in cliente.nombre
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_sin_modelo_local_decide_el_servidor(lista_blanca):
|
| 105 |
+
"""None = comportamiento de siempre; la lista blanca no cambia la ruta por defecto."""
|
| 106 |
+
cliente = service._crear_cliente("medgemma", None)
|
| 107 |
+
assert cliente.nombre == "medgemma-hf"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
async def test_el_modelo_elegido_recibe_rag_y_se_reporta_en_la_respuesta(
|
| 111 |
+
lista_blanca, monkeypatch
|
| 112 |
+
):
|
| 113 |
+
"""El punto de todo el ejercicio: un modelo local ve la misma literatura recuperada."""
|
| 114 |
+
from app.rag.retriever import Fragmento
|
| 115 |
+
|
| 116 |
+
fragmento = Fragmento(
|
| 117 |
+
texto="La anemia arregenerativa en el perro…",
|
| 118 |
+
libro="Fundamentals",
|
| 119 |
+
edicion="7",
|
| 120 |
+
capitulo="Anemia",
|
| 121 |
+
pagina="120",
|
| 122 |
+
score=1.0,
|
| 123 |
+
)
|
| 124 |
+
for nombre in ("recuperar", "recuperar_multi"):
|
| 125 |
+
monkeypatch.setattr(service, nombre, lambda *_a, **_k: [fragmento])
|
| 126 |
+
|
| 127 |
+
visto: dict = {}
|
| 128 |
+
|
| 129 |
+
class ClienteLocal:
|
| 130 |
+
nombre = "medgemma"
|
| 131 |
+
prosa = False
|
| 132 |
+
modelo = "medgemma1.5:latest"
|
| 133 |
+
|
| 134 |
+
async def interpretar(self, _sistema, mensaje_usuario, _imagenes):
|
| 135 |
+
visto["mensaje"] = mensaje_usuario
|
| 136 |
+
return InterpretacionClinica(
|
| 137 |
+
interpretacion="ok " * 20,
|
| 138 |
+
hallazgos_clave=[
|
| 139 |
+
{
|
| 140 |
+
"analito": "Hematocrito",
|
| 141 |
+
"direccion": "bajo",
|
| 142 |
+
"gravedad": "grave",
|
| 143 |
+
"comentario": "anemia",
|
| 144 |
+
}
|
| 145 |
+
],
|
| 146 |
+
diferenciales=[
|
| 147 |
+
{"nombre": "IMHA", "probabilidad": "alta", "evidencia": ["hct bajo"]}
|
| 148 |
+
],
|
| 149 |
+
requiere_derivacion=True,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteLocal())
|
| 153 |
+
pet = PeticionInterpretacion.model_validate(
|
| 154 |
+
{**PETICION, "modelo_local": "medgemma1.5:latest"}
|
| 155 |
+
)
|
| 156 |
+
resp = await service.interpretar(pet)
|
| 157 |
+
|
| 158 |
+
assert "anemia arregenerativa" in visto["mensaje"].lower()
|
| 159 |
+
assert resp.fuentes_rag == 1
|
| 160 |
+
# La etiqueta debe nombrar el modelo QUE RESPONDIÓ, no el de la configuración.
|
| 161 |
+
assert resp.modelo == "medgemma:medgemma1.5:latest"
|
backend/tests/test_prescripcion.py
CHANGED
|
@@ -61,6 +61,8 @@ def test_el_encuadre_no_se_duplica():
|
|
| 61 |
|
| 62 |
class ClientePrescriptor:
|
| 63 |
nombre = "medgemma-hf"
|
|
|
|
|
|
|
| 64 |
|
| 65 |
async def interpretar(self, *_a, **_k):
|
| 66 |
return InterpretacionClinica(
|
|
@@ -77,7 +79,7 @@ async def test_el_servicio_encuadra_y_deriva(monkeypatch):
|
|
| 77 |
|
| 78 |
for nombre in ("recuperar", "recuperar_multi"):
|
| 79 |
monkeypatch.setattr(S, nombre, lambda *_a, **_k: [])
|
| 80 |
-
monkeypatch.setattr(S, "_crear_cliente", lambda
|
| 81 |
|
| 82 |
pet = PeticionInterpretacion(
|
| 83 |
paciente={"especie": "canino"},
|
|
|
|
| 61 |
|
| 62 |
class ClientePrescriptor:
|
| 63 |
nombre = "medgemma-hf"
|
| 64 |
+
prosa = True
|
| 65 |
+
modelo = "hf-space"
|
| 66 |
|
| 67 |
async def interpretar(self, *_a, **_k):
|
| 68 |
return InterpretacionClinica(
|
|
|
|
| 79 |
|
| 80 |
for nombre in ("recuperar", "recuperar_multi"):
|
| 81 |
monkeypatch.setattr(S, nombre, lambda *_a, **_k: [])
|
| 82 |
+
monkeypatch.setattr(S, "_crear_cliente", lambda *_: ClientePrescriptor())
|
| 83 |
|
| 84 |
pet = PeticionInterpretacion(
|
| 85 |
paciente={"especie": "canino"},
|
backend/tests/test_prompt_y_rag.py
CHANGED
|
@@ -130,11 +130,13 @@ async def test_el_servicio_elige_la_ruta_de_recuperacion_segun_la_bandera(monkey
|
|
| 130 |
|
| 131 |
class ClienteQueNoResponde:
|
| 132 |
nombre = "medgemma"
|
|
|
|
|
|
|
| 133 |
|
| 134 |
async def interpretar(self, *_a, **_k):
|
| 135 |
raise ErrorModelo("no importa: sólo se mira la recuperación", reintentable=False)
|
| 136 |
|
| 137 |
-
monkeypatch.setattr(service, "_crear_cliente", lambda
|
| 138 |
with pytest.raises(ErrorModelo):
|
| 139 |
await service.interpretar(_peticion())
|
| 140 |
|
|
|
|
| 130 |
|
| 131 |
class ClienteQueNoResponde:
|
| 132 |
nombre = "medgemma"
|
| 133 |
+
prosa = False
|
| 134 |
+
modelo = "medgemma:test"
|
| 135 |
|
| 136 |
async def interpretar(self, *_a, **_k):
|
| 137 |
raise ErrorModelo("no importa: sólo se mira la recuperación", reintentable=False)
|
| 138 |
|
| 139 |
+
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteQueNoResponde())
|
| 140 |
with pytest.raises(ErrorModelo):
|
| 141 |
await service.interpretar(_peticion())
|
| 142 |
|
backend/tests/test_reintentos.py
CHANGED
|
@@ -35,6 +35,8 @@ PETICION = {
|
|
| 35 |
|
| 36 |
class ClienteFalso:
|
| 37 |
nombre = "medgemma-hf"
|
|
|
|
|
|
|
| 38 |
|
| 39 |
def __init__(self, error: ErrorModelo | None, exito_en: int | None = None):
|
| 40 |
self.error = error
|
|
@@ -56,7 +58,7 @@ def sin_rag(monkeypatch):
|
|
| 56 |
|
| 57 |
|
| 58 |
async def _interpretar_con(cliente, monkeypatch):
|
| 59 |
-
monkeypatch.setattr(service, "_crear_cliente", lambda
|
| 60 |
return await service.interpretar(PeticionInterpretacion.model_validate(PETICION))
|
| 61 |
|
| 62 |
|
|
@@ -96,6 +98,8 @@ class ClienteProsa:
|
|
| 96 |
"""Ruta HF Space: devuelve prosa con marcadores [n], sin `diferenciales`."""
|
| 97 |
|
| 98 |
nombre = "medgemma-hf"
|
|
|
|
|
|
|
| 99 |
|
| 100 |
def __init__(self, texto: str):
|
| 101 |
self.texto = texto
|
|
@@ -129,6 +133,8 @@ class ClienteTruncaConContexto:
|
|
| 129 |
siempre; sin ella (o con poca) sale completa."""
|
| 130 |
|
| 131 |
nombre = "medgemma-hf"
|
|
|
|
|
|
|
| 132 |
|
| 133 |
def __init__(self, umbral_fragmentos: int):
|
| 134 |
self.umbral = umbral_fragmentos
|
|
@@ -166,6 +172,8 @@ class ClienteEstructuraVacia:
|
|
| 166 |
"""Reproduce lo medido con qwen2.5:7b: JSON válido con los campos estructurados vacíos."""
|
| 167 |
|
| 168 |
nombre = "medgemma"
|
|
|
|
|
|
|
| 169 |
|
| 170 |
def __init__(self, llenar_en: int | None = None):
|
| 171 |
self.llenar_en = llenar_en
|
|
@@ -205,6 +213,8 @@ async def test_la_ruta_de_prosa_no_exige_campos_estructurados(sin_rag, monkeypat
|
|
| 205 |
|
| 206 |
class ClienteSinDerivacion:
|
| 207 |
nombre = "medgemma"
|
|
|
|
|
|
|
| 208 |
|
| 209 |
async def interpretar(self, *_a, **_k):
|
| 210 |
return InterpretacionClinica(
|
|
@@ -224,6 +234,8 @@ async def test_la_derivacion_no_la_decide_el_modelo(sin_rag, monkeypatch):
|
|
| 224 |
|
| 225 |
class ClienteProsaSimple:
|
| 226 |
nombre = "medgemma-hf"
|
|
|
|
|
|
|
| 227 |
|
| 228 |
async def interpretar(self, *_a, **_k):
|
| 229 |
# Igual que el cliente del Space: sólo prosa, con el default del esquema.
|
|
@@ -233,7 +245,7 @@ class ClienteProsaSimple:
|
|
| 233 |
async def test_prosa_sin_alteraciones_no_deriva(sin_rag, monkeypatch):
|
| 234 |
"""El default del esquema hacía que un panel normal pidiera derivación contradiciendo su
|
| 235 |
propio texto; el juez lo penalizó como incoherencia (2026-07-31, `normal-canino`)."""
|
| 236 |
-
monkeypatch.setattr(service, "_crear_cliente", lambda
|
| 237 |
pet = PeticionInterpretacion(paciente={"especie": "canino"}, hallazgos=[], patrones=[])
|
| 238 |
|
| 239 |
resp = await service.interpretar(pet)
|
|
@@ -242,7 +254,7 @@ async def test_prosa_sin_alteraciones_no_deriva(sin_rag, monkeypatch):
|
|
| 242 |
|
| 243 |
|
| 244 |
async def test_prosa_con_alteraciones_sigue_derivando(sin_rag, monkeypatch):
|
| 245 |
-
monkeypatch.setattr(service, "_crear_cliente", lambda
|
| 246 |
|
| 247 |
resp = await service.interpretar(PeticionInterpretacion.model_validate(PETICION))
|
| 248 |
|
|
@@ -255,6 +267,8 @@ async def test_la_ruta_estructurada_conserva_la_opinion_del_modelo(sin_rag, monk
|
|
| 255 |
|
| 256 |
class ClienteEstructurado:
|
| 257 |
nombre = "medgemma"
|
|
|
|
|
|
|
| 258 |
|
| 259 |
async def interpretar(self, *_a, **_k):
|
| 260 |
return InterpretacionClinica(
|
|
@@ -264,7 +278,7 @@ async def test_la_ruta_estructurada_conserva_la_opinion_del_modelo(sin_rag, monk
|
|
| 264 |
requiere_derivacion=True,
|
| 265 |
)
|
| 266 |
|
| 267 |
-
monkeypatch.setattr(service, "_crear_cliente", lambda
|
| 268 |
pet = PeticionInterpretacion(paciente={"especie": "canino"}, hallazgos=[], patrones=[])
|
| 269 |
|
| 270 |
resp = await service.interpretar(pet)
|
|
|
|
| 35 |
|
| 36 |
class ClienteFalso:
|
| 37 |
nombre = "medgemma-hf"
|
| 38 |
+
prosa = True
|
| 39 |
+
modelo = "hf-space"
|
| 40 |
|
| 41 |
def __init__(self, error: ErrorModelo | None, exito_en: int | None = None):
|
| 42 |
self.error = error
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
async def _interpretar_con(cliente, monkeypatch):
|
| 61 |
+
monkeypatch.setattr(service, "_crear_cliente", lambda *_: cliente)
|
| 62 |
return await service.interpretar(PeticionInterpretacion.model_validate(PETICION))
|
| 63 |
|
| 64 |
|
|
|
|
| 98 |
"""Ruta HF Space: devuelve prosa con marcadores [n], sin `diferenciales`."""
|
| 99 |
|
| 100 |
nombre = "medgemma-hf"
|
| 101 |
+
prosa = True
|
| 102 |
+
modelo = "hf-space"
|
| 103 |
|
| 104 |
def __init__(self, texto: str):
|
| 105 |
self.texto = texto
|
|
|
|
| 133 |
siempre; sin ella (o con poca) sale completa."""
|
| 134 |
|
| 135 |
nombre = "medgemma-hf"
|
| 136 |
+
prosa = True
|
| 137 |
+
modelo = "hf-space"
|
| 138 |
|
| 139 |
def __init__(self, umbral_fragmentos: int):
|
| 140 |
self.umbral = umbral_fragmentos
|
|
|
|
| 172 |
"""Reproduce lo medido con qwen2.5:7b: JSON válido con los campos estructurados vacíos."""
|
| 173 |
|
| 174 |
nombre = "medgemma"
|
| 175 |
+
prosa = False
|
| 176 |
+
modelo = "medgemma:test"
|
| 177 |
|
| 178 |
def __init__(self, llenar_en: int | None = None):
|
| 179 |
self.llenar_en = llenar_en
|
|
|
|
| 213 |
|
| 214 |
class ClienteSinDerivacion:
|
| 215 |
nombre = "medgemma"
|
| 216 |
+
prosa = False
|
| 217 |
+
modelo = "medgemma:test"
|
| 218 |
|
| 219 |
async def interpretar(self, *_a, **_k):
|
| 220 |
return InterpretacionClinica(
|
|
|
|
| 234 |
|
| 235 |
class ClienteProsaSimple:
|
| 236 |
nombre = "medgemma-hf"
|
| 237 |
+
prosa = True
|
| 238 |
+
modelo = "hf-space"
|
| 239 |
|
| 240 |
async def interpretar(self, *_a, **_k):
|
| 241 |
# Igual que el cliente del Space: sólo prosa, con el default del esquema.
|
|
|
|
| 245 |
async def test_prosa_sin_alteraciones_no_deriva(sin_rag, monkeypatch):
|
| 246 |
"""El default del esquema hacía que un panel normal pidiera derivación contradiciendo su
|
| 247 |
propio texto; el juez lo penalizó como incoherencia (2026-07-31, `normal-canino`)."""
|
| 248 |
+
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteProsaSimple())
|
| 249 |
pet = PeticionInterpretacion(paciente={"especie": "canino"}, hallazgos=[], patrones=[])
|
| 250 |
|
| 251 |
resp = await service.interpretar(pet)
|
|
|
|
| 254 |
|
| 255 |
|
| 256 |
async def test_prosa_con_alteraciones_sigue_derivando(sin_rag, monkeypatch):
|
| 257 |
+
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteProsaSimple())
|
| 258 |
|
| 259 |
resp = await service.interpretar(PeticionInterpretacion.model_validate(PETICION))
|
| 260 |
|
|
|
|
| 267 |
|
| 268 |
class ClienteEstructurado:
|
| 269 |
nombre = "medgemma"
|
| 270 |
+
prosa = False
|
| 271 |
+
modelo = "medgemma:test"
|
| 272 |
|
| 273 |
async def interpretar(self, *_a, **_k):
|
| 274 |
return InterpretacionClinica(
|
|
|
|
| 278 |
requiere_derivacion=True,
|
| 279 |
)
|
| 280 |
|
| 281 |
+
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteEstructurado())
|
| 282 |
pet = PeticionInterpretacion(paciente={"especie": "canino"}, hallazgos=[], patrones=[])
|
| 283 |
|
| 284 |
resp = await service.interpretar(pet)
|
css/styles.css
CHANGED
|
@@ -1293,16 +1293,19 @@ footer {
|
|
| 1293 |
outline-offset: 1px;
|
| 1294 |
}
|
| 1295 |
|
| 1296 |
-
#ia-ollama-url,
|
| 1297 |
-
#ia-ollama-model {
|
| 1298 |
-
color: var(--color-placeholder);
|
| 1299 |
-
}
|
| 1300 |
-
|
| 1301 |
.ia-model-input {
|
| 1302 |
max-width: 130px;
|
| 1303 |
flex: 0 0 auto;
|
| 1304 |
}
|
| 1305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1306 |
.ia-hf-fields {
|
| 1307 |
width: 100%;
|
| 1308 |
}
|
|
|
|
| 1293 |
outline-offset: 1px;
|
| 1294 |
}
|
| 1295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1296 |
.ia-model-input {
|
| 1297 |
max-width: 130px;
|
| 1298 |
flex: 0 0 auto;
|
| 1299 |
}
|
| 1300 |
|
| 1301 |
+
/* `display: flex` de las reglas de arriba gana al `display: none` del agente de usuario, así
|
| 1302 |
+
que sin esto el atributo `hidden` que pone ia.ts no ocultaba nada. Se resuelve con
|
| 1303 |
+
especificidad ([hidden] añade un selector de atributo), no con !important. */
|
| 1304 |
+
.ia-backend-config[hidden],
|
| 1305 |
+
.ia-ollama-fields[hidden] {
|
| 1306 |
+
display: none;
|
| 1307 |
+
}
|
| 1308 |
+
|
| 1309 |
.ia-hf-fields {
|
| 1310 |
width: 100%;
|
| 1311 |
}
|
frontend/src/ia.ts
CHANGED
|
@@ -7,19 +7,62 @@
|
|
| 7 |
import type { Hallazgo, Paciente, Patron } from './tipos.js';
|
| 8 |
|
| 9 |
const BACKEND_KEY = 'mx-ia-backend';
|
|
|
|
| 10 |
|
| 11 |
-
//
|
| 12 |
-
//
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
const guardado = localStorage.getItem(BACKEND_KEY);
|
| 16 |
if (guardado !== 'claude' && guardado !== 'medgemma') {
|
| 17 |
localStorage.setItem(BACKEND_KEY, 'medgemma');
|
| 18 |
}
|
| 19 |
|
|
|
|
| 20 |
const radioLocal = document.getElementById('ia-backend-local') as HTMLInputElement | null;
|
| 21 |
const radioHF = document.getElementById('ia-backend-hf') as HTMLInputElement | null;
|
| 22 |
const camposOllama = document.getElementById('ia-ollama-fields') as HTMLElement | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
const aplicar = () => {
|
| 25 |
if (camposOllama) camposOllama.hidden = !(radioLocal?.checked);
|
|
@@ -166,6 +209,7 @@ export async function llamarIA(
|
|
| 166 |
signos_clinicos: signos,
|
| 167 |
imagenes: getImagenes().slice(0, 4),
|
| 168 |
backend,
|
|
|
|
| 169 |
};
|
| 170 |
|
| 171 |
try {
|
|
|
|
| 7 |
import type { Hallazgo, Paciente, Patron } from './tipos.js';
|
| 8 |
|
| 9 |
const BACKEND_KEY = 'mx-ia-backend';
|
| 10 |
+
const MODELO_LOCAL_KEY = 'mx-ia-modelo-local';
|
| 11 |
|
| 12 |
+
// Modelos locales que el servidor admite. Se pide una vez al arrancar; lista vacía = la
|
| 13 |
+
// instancia no tiene Ollama alcanzable y el selector entero se queda oculto.
|
| 14 |
+
let modelosLocales: string[] = [];
|
| 15 |
+
|
| 16 |
+
// Modelo local elegido, o null si se usa la ruta que decide el servidor. Se lee al enviar la
|
| 17 |
+
// petición: es lo único que el cliente puede decir sobre qué modelo responde, y aun así el
|
| 18 |
+
// servidor lo revalida contra su lista blanca.
|
| 19 |
+
function modeloLocalSeleccionado(): string | null {
|
| 20 |
+
const radioLocal = document.getElementById('ia-backend-local') as HTMLInputElement | null;
|
| 21 |
+
if (!radioLocal?.checked) return null;
|
| 22 |
+
const guardado = localStorage.getItem(MODELO_LOCAL_KEY);
|
| 23 |
+
return guardado && modelosLocales.includes(guardado) ? guardado : (modelosLocales[0] ?? null);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Inicializa el panel de ajustes de backend. La ruta ('medgemma' | 'claude') la decide el
|
| 27 |
+
// servidor; lo único que el usuario elige aquí es CUÁL de los modelos locales declarados
|
| 28 |
+
// responde. No hay campo de URL: la de Ollama vive en la configuración del servidor.
|
| 29 |
+
export async function inicializarConfigBackend(): Promise<void> {
|
| 30 |
const guardado = localStorage.getItem(BACKEND_KEY);
|
| 31 |
if (guardado !== 'claude' && guardado !== 'medgemma') {
|
| 32 |
localStorage.setItem(BACKEND_KEY, 'medgemma');
|
| 33 |
}
|
| 34 |
|
| 35 |
+
const contenedor = document.getElementById('ia-backend-config') as HTMLElement | null;
|
| 36 |
const radioLocal = document.getElementById('ia-backend-local') as HTMLInputElement | null;
|
| 37 |
const radioHF = document.getElementById('ia-backend-hf') as HTMLInputElement | null;
|
| 38 |
const camposOllama = document.getElementById('ia-ollama-fields') as HTMLElement | null;
|
| 39 |
+
const selector = document.getElementById('ia-modelo-local') as HTMLSelectElement | null;
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
const res = await fetch('/api/modelos', { credentials: 'same-origin' });
|
| 43 |
+
if (res.ok) modelosLocales = (await res.json()).locales ?? [];
|
| 44 |
+
} catch {
|
| 45 |
+
modelosLocales = []; // sin lista, el selector no aparece y todo sigue como antes
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (!modelosLocales.length) return;
|
| 49 |
+
if (contenedor) contenedor.hidden = false;
|
| 50 |
+
|
| 51 |
+
if (selector) {
|
| 52 |
+
const previo = localStorage.getItem(MODELO_LOCAL_KEY);
|
| 53 |
+
selector.replaceChildren(
|
| 54 |
+
...modelosLocales.map((m) => {
|
| 55 |
+
const opcion = document.createElement('option');
|
| 56 |
+
opcion.value = m;
|
| 57 |
+
opcion.textContent = m;
|
| 58 |
+
opcion.selected = m === previo;
|
| 59 |
+
return opcion;
|
| 60 |
+
}),
|
| 61 |
+
);
|
| 62 |
+
selector.addEventListener('change', () => {
|
| 63 |
+
localStorage.setItem(MODELO_LOCAL_KEY, selector.value);
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
|
| 67 |
const aplicar = () => {
|
| 68 |
if (camposOllama) camposOllama.hidden = !(radioLocal?.checked);
|
|
|
|
| 209 |
signos_clinicos: signos,
|
| 210 |
imagenes: getImagenes().slice(0, 4),
|
| 211 |
backend,
|
| 212 |
+
modelo_local: modeloLocalSeleccionado(),
|
| 213 |
};
|
| 214 |
|
| 215 |
try {
|
frontend/src/main.ts
CHANGED
|
@@ -169,7 +169,7 @@ elId<HTMLSelectElement>('pt-edad-unidad').addEventListener('change', evaluar);
|
|
| 169 |
elId<HTMLSelectElement>('pt-sexo').addEventListener('change', evaluar);
|
| 170 |
|
| 171 |
inicializarSincMob(evaluar);
|
| 172 |
-
inicializarConfigBackend();
|
| 173 |
inicializarParserPdf(evaluar);
|
| 174 |
inicializarImportLab(evaluar);
|
| 175 |
inicializarModalPapers();
|
|
|
|
| 169 |
elId<HTMLSelectElement>('pt-sexo').addEventListener('change', evaluar);
|
| 170 |
|
| 171 |
inicializarSincMob(evaluar);
|
| 172 |
+
void inicializarConfigBackend(); // pide la lista de modelos al servidor; no bloquea el arranque
|
| 173 |
inicializarParserPdf(evaluar);
|
| 174 |
inicializarImportLab(evaluar);
|
| 175 |
inicializarModalPapers();
|
index.html
CHANGED
|
@@ -1030,13 +1030,16 @@
|
|
| 1030 |
<div class="seccion-resultado">
|
| 1031 |
<h3 class="titulo-seccion-resultado">Interpretación IA</h3>
|
| 1032 |
<div id="salida-ia">Envía todas las analíticas y citologías al modelo medGemma para obtener una evaluación integrada...</div>
|
| 1033 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
<span class="ia-backend-label">Entorno IA</span>
|
| 1035 |
-
<label class="ia-backend-opt"><input type="radio" name="ia-backend" value="hf" id="ia-backend-hf" checked>
|
| 1036 |
<label class="ia-backend-opt"><input type="radio" name="ia-backend" value="local" id="ia-backend-local">Local</label>
|
| 1037 |
<div class="ia-ollama-fields" id="ia-ollama-fields">
|
| 1038 |
-
<
|
| 1039 |
-
<input type="text" id="ia-ollama-model" class="ia-text-input ia-model-input" placeholder="medgemma1.5:latest" value="medgemma1.5:latest" aria-label="Modelo Ollama">
|
| 1040 |
</div>
|
| 1041 |
</div>
|
| 1042 |
</div>
|
|
|
|
| 1030 |
<div class="seccion-resultado">
|
| 1031 |
<h3 class="titulo-seccion-resultado">Interpretación IA</h3>
|
| 1032 |
<div id="salida-ia">Envía todas las analíticas y citologías al modelo medGemma para obtener una evaluación integrada...</div>
|
| 1033 |
+
<!-- Selector de entorno. Se muestra sólo si el servidor declara modelos locales
|
| 1034 |
+
en MORPHOS_MODELOS_LOCALES; si no, lo oculta ia.ts. La URL de Ollama NO se
|
| 1035 |
+
configura aquí a propósito: la fija el servidor (aceptar una del cliente
|
| 1036 |
+
convertiría /api/interpret en un SSRF). -->
|
| 1037 |
+
<div class="ia-backend-config" id="ia-backend-config" hidden>
|
| 1038 |
<span class="ia-backend-label">Entorno IA</span>
|
| 1039 |
+
<label class="ia-backend-opt"><input type="radio" name="ia-backend" value="hf" id="ia-backend-hf" checked>Alojado</label>
|
| 1040 |
<label class="ia-backend-opt"><input type="radio" name="ia-backend" value="local" id="ia-backend-local">Local</label>
|
| 1041 |
<div class="ia-ollama-fields" id="ia-ollama-fields">
|
| 1042 |
+
<select id="ia-modelo-local" class="ia-text-input" aria-label="Modelo local"></select>
|
|
|
|
| 1043 |
</div>
|
| 1044 |
</div>
|
| 1045 |
</div>
|