File size: 11,716 Bytes
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
1e215c9
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
1e215c9
70e641d
 
 
 
 
 
 
af530a3
 
70e641d
af530a3
 
 
 
70e641d
af530a3
 
bf9f7d1
af530a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
af530a3
 
 
 
 
 
 
 
bf9f7d1
 
af530a3
 
bf9f7d1
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e215c9
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e215c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
1e215c9
70e641d
 
 
1e215c9
 
 
70e641d
 
 
 
1e215c9
70e641d
 
 
1e215c9
 
70e641d
 
 
 
 
 
1e215c9
bf9f7d1
 
 
1e215c9
 
 
 
bf9f7d1
 
 
 
 
 
 
1e215c9
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
70e641d
bf9f7d1
70e641d
 
 
1e215c9
70e641d
 
 
bf9f7d1
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
70e641d
 
 
 
 
 
 
 
1e215c9
bf9f7d1
 
1e215c9
 
bf9f7d1
 
 
 
 
70e641d
 
 
bf9f7d1
70e641d
 
 
 
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
"""Evaluación de RECUPERACIÓN RAG (aislada de la generación) para decidir por datos.

Objetivo: comparar configuraciones de recuperación (modelo de embeddings × idioma de
consulta) sobre los casos dorados, midiendo si los fragmentos recuperados son relevantes
al diagnóstico esperado. Permite el A/B bge-m3(ES) vs bge-m3(EN) vs MedCPT(EN) antes de
invertir en el reranking del Tier 2.

Uso (el índice debe existir para la config activa):
    # 1) baseline actual
    MORPHOS_RAG_EMBED_MODEL=BAAI/bge-m3 MORPHOS_RAG_QUERY_LANG=es \
        cd backend && make ingest && cd ../evals && uv run --group evals python run_retrieval_eval.py --etiqueta bge-m3-es
    # 2) misma indexación, consulta en inglés (no requiere reindexar)
    MORPHOS_RAG_QUERY_LANG=en uv run --group evals python run_retrieval_eval.py --etiqueta bge-m3-en

La relevancia la juzga un LLM (robusto a ES-concepto/EN-corpus): por defecto el juez LOCAL y
GRATUITO servido por Ollama, y Claude si se pide con --juez claude y hay ANTHROPIC_API_KEY.
Sin ninguno de los dos cae a un heurístico de solape de palabras clave (aproximado, se marca
como tal en el resumen).
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from pathlib import Path

AQUI = Path(__file__).resolve().parent
RAIZ = AQUI.parent
sys.path.insert(0, str(RAIZ / "backend"))
sys.path.insert(0, str(AQUI))  # para importar el paquete `judge`


def cargar_casos() -> list[dict]:
    lineas = (AQUI / "dataset" / "casos.jsonl").read_text(encoding="utf-8").splitlines()
    return [json.loads(ln) for ln in lineas if ln.strip()]


def _entradas_del_motor(caso: dict) -> tuple[list[str], list[str]]:
    """Nombres de patrón y de hallazgo TAL COMO los produce el motor para este caso.

    Pasa por `analisis.ts` (vía Node) igual que el servicio, porque la consulta de producción
    se arma con lo que el motor detecta, no con lo que el caso dorado declara.
    """
    from run_evals import _motor_determinista

    hallazgos, patrones = _motor_determinista(caso["valores"], caso["paciente"])
    return [p["nombre"] for p in patrones], [h["nombre"] for h in hallazgos]


def construir_query_eval(caso: dict) -> str:
    """La consulta EXACTA que emitiría producción para este caso.

    Antes se armaba a mano con `descripcion` + `esperado.hallazgos_clave` + `signos_clinicos`,
    y eso medía una recuperación que el servicio nunca ejecuta, en tres sentidos:

    1. **Claves en vez de nombres.** `hct`, `vcm`, `chcm` en lugar de «Hematocrito (Hct)»,
       «VCM (MCV)». Los nombres traen la SIGLA INGLESA, que es justo lo que casa con un corpus
       en inglés — sobre todo en la rama BM25, que es literal. La consulta medida era más pobre
       que la real.
    2. **Producción no manda `signos_clinicos`** en la consulta de recuperación: sólo patrones
       y hallazgos (`ai/service.py`).
    3. **`descripcion` es metadato del dataset y filtraba la respuesta.** «Anemia microcítica
       hipocrómica en perro» orienta la recuperación hacia el diagnóstico esperado, que es
       exactamente lo que el juez de relevancia luego premia. Misma familia de fuga que la que
       se corrigió en el juez clínico (ver resultados 2026-08-01, §4.5).

    Consecuencia: las cifras de recuperación anteriores al 2026-08-03 no son comparables con
    las de después, y el `precision@k` de aquel A/B no describía la recuperación de producción.
    """
    from app.rag.retriever import construir_consulta

    patrones, hallazgos = _entradas_del_motor(caso)
    return construir_consulta(patrones, hallazgos)


def construir_queries_eval(caso: dict) -> list[str]:
    """Versión multi-consulta de la anterior (una por patrón), para medir el A/B."""
    from app.rag.retriever import construir_consultas

    patrones, hallazgos = _entradas_del_motor(caso)
    return construir_consultas(patrones, hallazgos)


# --- Métricas (puras, testeables sin índice) ---

def precision_en_k(relevancias: list[bool]) -> float:
    return sum(relevancias) / len(relevancias) if relevancias else 0.0


def rango_reciproco(relevancias: list[bool]) -> float:
    for i, rel in enumerate(relevancias, 1):
        if rel:
            return 1.0 / i
    return 0.0


def hubo_acierto(relevancias: list[bool]) -> bool:
    return any(relevancias)


def resumen(relevancias_por_caso: list[list[bool]]) -> dict:
    if not relevancias_por_caso:
        return {"n_casos": 0, "precision@k": 0.0, "hit_rate": 0.0, "mrr": 0.0}
    n = len(relevancias_por_caso)
    return {
        "n_casos": n,
        "precision@k": round(sum(precision_en_k(r) for r in relevancias_por_caso) / n, 3),
        "hit_rate": round(sum(hubo_acierto(r) for r in relevancias_por_caso) / n, 3),
        "mrr": round(sum(rango_reciproco(r) for r in relevancias_por_caso) / n, 3),
    }


# --- Juez de relevancia ---

def _juez_keyword(caso: dict, textos: list[str]) -> list[bool]:
    """Heurístico: traduce los conceptos esperados a inglés y busca solape de palabras."""
    from app.rag.traduccion_consulta import traducir_consulta

    aceptables = caso.get("esperado", {}).get("diferenciales_aceptables", [])
    claves = set()
    for concepto in aceptables:
        for palabra in traducir_consulta(concepto, "en").lower().split():
            if len(palabra) > 4:
                claves.add(palabra)
    return [any(c in t.lower() for c in claves) for t in textos]


_PREGUNTA_RELEVANCIA = (
    "Diagnóstico(s) esperado(s): {dx}\n\nFRAGMENTO:\n{texto}\n\n"
    "¿Es este fragmento clínicamente relevante para razonar ese diagnóstico?"
)

_ESQUEMA_RELEVANCIA = {
    "type": "object",
    "properties": {"relevante": {"type": "boolean"}},
    "required": ["relevante"],
}


def _juez_cli(caso: dict, textos: list[str]) -> list[bool]:
    """Juez servido por el CLI de Claude Code: sin clave de API, con un modelo grande."""
    from judge.claude_cli import preguntar_json

    dx = ", ".join(caso.get("esperado", {}).get("diferenciales_aceptables", []))
    return [
        bool(
            preguntar_json(
                "Eres un patólogo clínico veterinario. Responde SOLO con el JSON pedido, "
                'con esta forma exacta: {"relevante": true|false}',
                _PREGUNTA_RELEVANCIA.format(dx=dx, texto=texto[:1200]),
            ).get("relevante", False)
        )
        for texto in textos
    ]


def _juez_ollama(caso: dict, textos: list[str]) -> list[bool]:
    """Juez LLM local y gratuito, con salida estructurada (booleano, sin parseo de prosa)."""
    from judge.ollama_local import preguntar_json

    dx = ", ".join(caso.get("esperado", {}).get("diferenciales_aceptables", []))
    return [
        bool(
            preguntar_json(
                "Eres un patólogo clínico veterinario. Responde SOLO con el JSON pedido.",
                _PREGUNTA_RELEVANCIA.format(dx=dx, texto=texto[:1200]),
                _ESQUEMA_RELEVANCIA,
                max_tokens=20,
            ).get("relevante", False)
        )
        for texto in textos
    ]


def _juez_claude(caso: dict, textos: list[str]) -> list[bool]:
    """Juez LLM de pago: ¿cada fragmento es relevante al diagnóstico esperado del caso?"""
    from anthropic import Anthropic

    cliente = Anthropic()
    # Sin fallback a Fable: cuesta el doble y exige retención de datos de 30 días (ver el
    # comentario en backend/app/config.py). El modelo lo fija la config del backend.
    modelo = os.environ.get("MORPHOS_CLAUDE_MODEL", "claude-opus-5")
    dx = ", ".join(caso.get("esperado", {}).get("diferenciales_aceptables", []))
    relevancias: list[bool] = []
    for texto in textos:
        msg = cliente.messages.create(
            model=modelo,
            max_tokens=5,
            messages=[{
                "role": "user",
                "content": _PREGUNTA_RELEVANCIA.format(dx=dx, texto=texto[:1200])
                + " Responde SOLO 'si' o 'no'.",
            }],
        )
        relevancias.append(msg.content[0].text.strip().lower().startswith("si"))
    return relevancias


def elegir_juez(preferencia: str):
    """Devuelve (funcion_juez, etiqueta). Mismo orden que el juez clínico: CLI de Claude
    Code → Ollama local → SDK de Claude → heurístico de palabras clave."""
    from judge import claude_cli
    from judge.ollama_local import disponible, modelo_juez

    if preferencia == "keyword":
        return _juez_keyword, "keyword(aprox)"
    if preferencia in ("auto", "cli"):
        ok, motivo = claude_cli.disponible()
        if ok:
            return _juez_cli, f"claude-cli:{claude_cli.modelo_cli()}"
        if preferencia == "cli":
            print(f"  ⚠ juez cli no disponible: {motivo}")
            return _juez_keyword, "keyword(aprox)"
    if preferencia in ("auto", "ollama"):
        ok, motivo = disponible()
        if ok:
            return _juez_ollama, f"ollama:{modelo_juez()}"
        if preferencia == "ollama":
            print(f"  ⚠ juez ollama no disponible: {motivo}")
            return _juez_keyword, "keyword(aprox)"
    if preferencia in ("auto", "claude") and os.environ.get("ANTHROPIC_API_KEY"):
        return _juez_claude, "claude"
    return _juez_keyword, "keyword(aprox)"


def evaluar(k: int, preferencia_juez: str, multiconsulta: bool = False) -> int:
    from app.config import obtener_config
    from app.rag.retriever import recuperar, recuperar_multi

    cfg = obtener_config()
    casos = cargar_casos()
    juez, etiqueta_juez = elegir_juez(preferencia_juez)

    relevancias_por_caso: list[list[bool]] = []
    for caso in casos:
        especie = caso.get("paciente", {}).get("especie")
        if multiconsulta:
            frags = recuperar_multi(construir_queries_eval(caso), especie=especie, top_k=k)
        else:
            frags = recuperar(construir_query_eval(caso), especie=especie, top_k=k)
        if not frags:
            print(f"  ⚠ {caso['id']}: 0 fragmentos (¿índice construido para esta config?)")
            relevancias_por_caso.append([])
            continue
        rel = juez(caso, [f.texto for f in frags])
        relevancias_por_caso.append(rel)
        print(f"  {caso['id']}: {sum(rel)}/{len(rel)} relevantes")

    met = resumen(relevancias_por_caso)
    print("\n=== RESUMEN RECUPERACIÓN ===")
    print(f"config: embed={cfg.rag_embed_model} query_lang={cfg.rag_query_lang} "
          f"hibrido={cfg.rag_hibrido} rerank={cfg.rag_rerank} k={k} "
          f"multiconsulta={multiconsulta} max_por_libro={cfg.rag_max_por_libro} "
          f"score_minimo={cfg.rag_score_minimo} juez={etiqueta_juez}")
    print(json.dumps(met, ensure_ascii=False))
    return 0


def main() -> None:
    parser = argparse.ArgumentParser(description="Eval de recuperación RAG (A/B de configs)")
    parser.add_argument("--k", type=int, default=6)
    parser.add_argument("--etiqueta", default="", help="etiqueta informativa de la config")
    parser.add_argument(
        "--juez", choices=["auto", "cli", "ollama", "claude", "keyword"], default="auto",
        help="auto: CLI de Claude Code → Ollama local → SDK de Claude → keyword",
    )
    parser.add_argument("--keyword", action="store_true", help="atajo de --juez keyword")
    parser.add_argument(
        "--multiconsulta", action="store_true",
        help="descompone el caso en una consulta por entidad y fusiona con RRF (A/B del "
             "comportamiento de producción, controlado por MORPHOS_RAG_MULTICONSULTA)",
    )
    args = parser.parse_args()
    if args.etiqueta:
        print(f"# config: {args.etiqueta}")
    sys.exit(evaluar(args.k, "keyword" if args.keyword else args.juez, args.multiconsulta))


if __name__ == "__main__":
    main()