morphos / backend /tests /test_modelos_locales.py
Jose Salazar
Léxico clínico compartido y detección de analitos fabricados
7dba92c
Raw
History Blame Contribute Delete
6.98 kB
"""Lista blanca de modelos locales elegibles desde la UI.
Lo que aquí se fija es la frontera de confianza: el navegador puede decir CUÁL de los modelos
declarados por el servidor responde, y nada más. Ni una URL (sería un SSRF: el servidor haría
peticiones a donde diga el cliente) ni un nombre arbitrario (elegiría qué pesos se descargan en
la máquina que aloja el servicio). Y el modelo elegido recorre exactamente el mismo camino que
el Space: RAG, prompt endurecido, atribución y suelos de seguridad.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.ai import service
from app.config import Configuracion, obtener_config
from app.schemas import InterpretacionClinica, PeticionInterpretacion
PETICION = {
"paciente": {"especie": "canino"},
"hallazgos": [
{
"clave": "hct",
"nombre": "Hematocrito",
"valor": 22.0,
"unidad": "%",
"direccion": "bajo",
"gravedad": "grave",
}
],
"patrones": [{"nombre": "Anemia", "descripcion": "…", "gravedad": "grave"}],
"imagenes": [],
}
@pytest.fixture
def lista_blanca(monkeypatch):
"""Config con dos modelos declarados: uno estructurado y otro de prosa."""
cfg = obtener_config().model_copy(
update={"modelos_locales": ["medgemma1.5:latest", "qwen2.5:7b=prosa"]}
)
# El validador del esquema importa `obtener_config` perezosamente desde `app.config`;
# el servicio lo tiene ya resuelto en su propio espacio de nombres. Hay que parchear ambos.
monkeypatch.setattr("app.config.obtener_config", lambda: cfg)
monkeypatch.setattr(service, "obtener_config", lambda: cfg)
return cfg
def test_parseo_de_la_lista_blanca():
cfg = Configuracion(modelos_locales="medgemma1.5:latest, qwen2.5:7b=prosa , vacio=raro")
permitidos = cfg.modelos_locales_permitidos()
# El nombre lleva ':' propio, por eso el modo se separa con '='.
assert permitidos == {
"medgemma1.5:latest": False,
"qwen2.5:7b": True,
"vacio": False, # sufijo desconocido → estructurado, sin reventar el arranque
}
def test_sin_lista_blanca_no_se_acepta_ningun_modelo():
"""Estado por defecto (instancia pública): el selector no existe y nada se cuela."""
with pytest.raises(ValidationError, match="no permitido"):
PeticionInterpretacion.model_validate({**PETICION, "modelo_local": "medgemma1.5:latest"})
def test_modelo_fuera_de_la_lista_se_rechaza(lista_blanca):
with pytest.raises(ValidationError, match="no permitido"):
PeticionInterpretacion.model_validate({**PETICION, "modelo_local": "llama3:70b"})
def test_no_hay_forma_de_inyectar_una_url(lista_blanca):
"""El campo sólo admite nombres de la lista; una URL nunca llega a httpx."""
for intento in ("http://attacker.example/api", "http://169.254.169.254/latest/meta-data"):
with pytest.raises(ValidationError):
PeticionInterpretacion.model_validate({**PETICION, "modelo_local": intento})
def test_cliente_estructurado_para_el_modelo_declarado_asi(lista_blanca, monkeypatch):
monkeypatch.setattr("app.ai.medgemma.obtener_config", lambda: lista_blanca)
cliente = service._crear_cliente("medgemma", "medgemma1.5:latest")
assert cliente.modelo == "medgemma1.5:latest"
assert cliente.prosa is False
def test_cliente_de_prosa_para_el_modelo_declarado_prosa(lista_blanca, monkeypatch):
monkeypatch.setattr("app.ai.medgemma.obtener_config", lambda: lista_blanca)
cliente = service._crear_cliente("medgemma", "qwen2.5:7b")
assert cliente.modelo == "qwen2.5:7b"
assert cliente.prosa is True
def test_el_modelo_local_gana_al_hf_space(lista_blanca, monkeypatch):
"""Con Space configurado, elegir un modelo en la UI debe llevar a Ollama igualmente.
Si no, el selector mentiría: el veterinario cree estar usando su modelo y responde el Space.
"""
assert lista_blanca.hf_space_url # el defecto trae Space
monkeypatch.setattr("app.ai.medgemma.obtener_config", lambda: lista_blanca)
cliente = service._crear_cliente("medgemma", "medgemma1.5:latest")
assert cliente.nombre.startswith("medgemma")
assert "hf" not in cliente.nombre
def test_sin_modelo_local_decide_el_servidor(lista_blanca):
"""None = comportamiento de siempre; la lista blanca no cambia la ruta por defecto."""
cliente = service._crear_cliente("medgemma", None)
assert cliente.nombre == "medgemma-hf"
async def test_el_modelo_elegido_recibe_rag_y_se_reporta_en_la_respuesta(
lista_blanca, monkeypatch
):
"""El punto de todo el ejercicio: un modelo local ve la misma literatura recuperada."""
from app.rag.retriever import Fragmento
fragmento = Fragmento(
texto="La anemia arregenerativa en el perro…",
libro="Fundamentals",
edicion="7",
capitulo="Anemia",
pagina="120",
score=1.0,
)
for nombre in ("recuperar", "recuperar_multi"):
monkeypatch.setattr(service, nombre, lambda *_a, **_k: [fragmento])
visto: dict = {}
class ClienteLocal:
nombre = "medgemma"
prosa = False
modelo = "medgemma1.5:latest"
async def interpretar(self, _sistema, mensaje_usuario, _imagenes):
visto["mensaje"] = mensaje_usuario
return InterpretacionClinica(
interpretacion="ok " * 20,
hallazgos_clave=[
{
"analito": "Hematocrito",
"direccion": "bajo",
"gravedad": "grave",
"comentario": "anemia",
}
],
diferenciales=[
{"nombre": "IMHA", "probabilidad": "alta", "evidencia": ["hct bajo"]}
],
requiere_derivacion=True,
)
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteLocal())
pet = PeticionInterpretacion.model_validate(
{**PETICION, "modelo_local": "medgemma1.5:latest"}
)
resp = await service.interpretar(pet)
assert "anemia arregenerativa" in visto["mensaje"].lower()
assert resp.fuentes_rag == 1
# La etiqueta debe nombrar el modelo QUE RESPONDIÓ, no el de la configuración.
assert resp.modelo == "medgemma:medgemma1.5:latest"
def test_las_pruebas_no_heredan_el_env_del_desarrollador():
"""La lista blanca vacía por defecto sólo se sostiene si nadie lee el `.env` local.
Este fichero afirma que sin declaración no hay modelos elegibles. Un
`MORPHOS_MODELOS_LOCALES=…` en `backend/.env` —perfectamente legítimo para trabajar en
local— hacía fallar esa afirmación en la máquina del desarrollador y pasarla en CI, que es
la peor combinación posible: la prueba deja de describir el código y describe la máquina.
"""
assert Configuracion.model_config["env_file"] is None