Spaces:
Sleeping
Sleeping
File size: 6,978 Bytes
6ab7946 7dba92c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | """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
|