Jose Salazar commited on
Commit
6ab7946
·
1 Parent(s): 02b6489

Allow list para modelos locales configurables por el usuario

Browse files
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. **Es la única ruta que NO da salida estructurada**: el Space devuelve texto libre, así que `limpiar_respuesta()` mantiene la limpieza de tokens del modelo y se envuelve en el campo `interpretacion`.
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
- _DATA_URL = re.compile(r"^data:(image/[\w+]+);base64,(.+)$", re.DOTALL)
24
-
25
- # medGemma es un modelo con "pensamiento": de forma intermitente emite una cadena de
26
- # razonamiento en inglés (etiquetada `thought` / "thinking process") en lugar de responder,
27
- # y a veces degenera en un bucle de repetición que agota el presupuesto de tokens sin llegar
28
- # a la respuesta. Estos marcadores permiten detectar y descartar esa salida defectuosa.
29
- _MARCADOR_PENSAMIENTO = re.compile(
30
- r"(?im)^\s*(thought|thinking)\s*:?\s*$"
31
- r"|here'?s\s+(a|my)\s+thinking\s+process"
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
- limpio = limpiar_respuesta(texto)
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
- nombre = "medgemma"
 
 
36
 
37
- def __init__(self) -> None:
 
 
 
 
 
 
38
  cfg = obtener_config()
39
  self._url = cfg.medgemma_base_url.rstrip("/")
40
- self._modelo = cfg.medgemma_model
 
 
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._modelo,
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 medGemma no valida el esquema: {exc}") from exc
 
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.nombre == "medgemma-hf"
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
- if backend == "claude":
246
- etiqueta = cfg.claude_model
247
- elif cliente.nombre.startswith("medgemma-hf"):
248
- etiqueta = "hf-space"
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 Ollama ni a Claude: ahí el razonamiento va desactivado o no comparte
106
- # presupuesto con la respuesta, y más contexto sólo mejora la fundamentación.
 
 
 
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
- lab_api_keys: list[str] = Field(default_factory=list)
 
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 _dividir_keys(cls, v):
 
 
 
 
 
 
202
  if isinstance(v, str):
203
- return [k.strip() for k in v.split(",") if k.strip()]
 
 
 
 
 
 
 
 
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 _b: ClientePrescriptor())
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 _b: ClienteQueNoResponde())
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 _b: cliente)
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 _b: ClienteProsaSimple())
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 _b: ClienteProsaSimple())
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 _b: ClienteEstructurado())
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
- // Inicializa el panel de ajustes de backend. En el nuevo diseño el servidor decide la
12
- // ruta self-hosted concreta (Ollama vs Space) por configuración, así que las opciones
13
- // "local"/"hf" del formulario se colapsan en 'medgemma'; 'claude' queda como ruta híbrida.
14
- export function inicializarConfigBackend(): void {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div class="ia-backend-config">
 
 
 
 
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>HugginFace</label>
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
- <input type="url" id="ia-ollama-url" class="ia-text-input" placeholder="http://localhost:11434" value="http://localhost:11434" aria-label="URL de Ollama">
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>