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