File size: 5,185 Bytes
af530a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Pruebas de `run_ragas.py`: construcción de muestras y umbrales de groundedness.

La recuperación real y el juez local quedan fuera (uno necesita el índice, el otro Ollama):
lo que se fija aquí es que la muestra se arme con la MISMA consulta que produce el servicio
—si la eval midiera una consulta aproximada, mediría otra tubería— y que un NaN no se
confunda nunca con un cero.
"""

from __future__ import annotations

from dataclasses import dataclass

import pytest
import run_ragas
from run_ragas import UMBRALES, evaluar_umbrales

# --- Umbrales ---


def test_todo_por_encima_del_umbral_no_reporta_nada():
    assert evaluar_umbrales({m: u + 0.1 for m, u in UMBRALES.items()}) == ([], [])


def test_metrica_por_debajo_del_umbral_se_reporta_con_su_valor():
    fallos, incalculables = evaluar_umbrales({"faithfulness": 0.42})
    assert fallos == ["faithfulness=0.42 < 0.70"]
    assert incalculables == []


def test_nan_es_incalculable_y_no_un_suspenso():
    fallos, incalculables = evaluar_umbrales({"faithfulness": float("nan")})
    assert fallos == []
    assert incalculables == ["faithfulness"]


def test_metrica_ausente_se_ignora():
    assert evaluar_umbrales({}) == ([], [])


def test_valor_justo_en_el_umbral_aprueba():
    assert evaluar_umbrales({"context_recall": UMBRALES["context_recall"]}) == ([], [])


def test_las_metricas_ajenas_a_la_puerta_no_la_afectan():
    assert evaluar_umbrales({"answer_relevancy": 0.01}) == ([], [])


# --- Construcción de muestras ---

@dataclass
class FragmentoFalso:
    texto: str


CASO = {
    "id": "erc-felino",
    "descripcion": "Azotemia en gato",
    "paciente": {"especie": "felino"},
    "valores": {"creat": 4.0},
    "esperado": {"diferenciales_aceptables": ["enfermedad renal crónica"]},
}

INTERP = {
    "interpretacion": "Azotemia con isostenuria.",
    "diferenciales": [
        {"nombre": "ERC", "evidencia": ["creatinina 4.0", "USG 1.012"]},
        {"nombre": "Deshidratación", "evidencia": []},
    ],
}


@pytest.fixture
def tuberia(monkeypatch):
    """Sustituye motor y recuperación por dobles, y registra con qué se les llamó."""
    pytest.importorskip("ragas", reason="requiere el grupo de dependencias 'evals'")
    from app.rag import retriever

    registro = {}

    def motor_falso(valores, paciente):
        registro["motor"] = (valores, paciente)
        return [{"nombre": "creatinina alta"}], [{"nombre": "azotemia renal"}]

    def recuperar_falso(consulta, especie=None, **kw):
        registro["consulta"] = consulta
        registro["especie"] = especie
        return [FragmentoFalso("Chronic kidney disease in cats."), FragmentoFalso("USG < 1.035")]

    monkeypatch.setattr(run_ragas, "_motor_determinista", motor_falso)
    monkeypatch.setattr(retriever, "recuperar", recuperar_falso)
    return registro


def test_la_consulta_se_construye_con_el_codigo_de_produccion(tuberia):
    run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})
    # `construir_consulta` es la misma función que usa `ai/service.py`: la eval mide la
    # tubería real, no una aproximación escrita para la eval.
    assert "azotemia renal" in tuberia["consulta"].lower()
    assert "creatinina" in tuberia["consulta"].lower()


def test_la_especie_del_paciente_llega_al_filtro_de_recuperacion(tuberia):
    run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})
    assert tuberia["especie"] == "felino"


def test_la_respuesta_evaluada_incluye_prosa_y_diferenciales(tuberia):
    muestra = run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})[0]
    # Los diferenciales son afirmaciones clínicas: deben estar tan fundamentados como la prosa.
    assert "Azotemia con isostenuria." in muestra.response
    assert "ERC: creatinina 4.0; USG 1.012" in muestra.response
    assert "Deshidratación" in muestra.response


def test_los_contextos_son_los_fragmentos_recuperados(tuberia):
    muestra = run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})[0]
    assert muestra.retrieved_contexts == ["Chronic kidney disease in cats.", "USG < 1.035"]


def test_la_referencia_sale_de_los_diferenciales_aceptables(tuberia):
    muestra = run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP})[0]
    assert muestra.reference == "enfermedad renal crónica"


def test_sin_diferenciales_aceptables_la_referencia_cae_a_la_descripcion(tuberia):
    caso = {**CASO, "esperado": {"diferenciales_aceptables": []}}
    muestra = run_ragas.construir_muestras([caso], {caso["id"]: INTERP})[0]
    assert muestra.reference == "Azotemia en gato"


def test_un_caso_sin_prediccion_se_omite(tuberia):
    assert run_ragas.construir_muestras([CASO], {}) == []


def test_un_caso_sin_fragmentos_se_omite_en_vez_de_puntuar_contra_nada(monkeypatch):
    pytest.importorskip("ragas", reason="requiere el grupo de dependencias 'evals'")
    from app.rag import retriever

    monkeypatch.setattr(run_ragas, "_motor_determinista", lambda v, p: ([], []))
    monkeypatch.setattr(retriever, "recuperar", lambda *a, **k: [])
    # Con 0 contextos, faithfulness sería 0 por falta de índice y no por el modelo.
    assert run_ragas.construir_muestras([CASO], {CASO["id"]: INTERP}) == []