File size: 10,817 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
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
284
285
286
287
288
289
290
291
292
293
294
295
296
297
"""Pruebas de la capa de jueces: r煤brica, transportes y selecci贸n.

Lo que se fija aqu铆 es la regla de oro de esta capa: **un juez roto tiene que fallar fuerte**.
Si un juez que no responde, o que devuelve basura, se degradara a ceros silenciosos, la puerta
de CI registrar铆a una regresi贸n del modelo que nunca ocurri贸.
"""

from __future__ import annotations

import json
import subprocess

import pytest
from judge import ErrorJuez, claude_cli, ollama_local
from judge.claude_cli import ErrorJuezCLI, _extraer_json
from judge.clinical_judge import CRITERIOS, _mensaje, crear_juez, validar_rubrica

RUBRICA_OK = {
    "correccion_diferenciales": 0.8,
    "hedging_apropiado": 0.6,
    "seguridad": 1.0,
    "completitud": 0.7,
    "violacion_seguridad": False,
    "justificacion": "Razonable.",
}


# --- validar_rubrica ---

def test_rubrica_completa_se_normaliza_sin_perder_nada():
    limpia = validar_rubrica(dict(RUBRICA_OK))
    assert all(limpia[c] == RUBRICA_OK[c] for c in CRITERIOS)
    assert limpia["violacion_seguridad"] is False
    assert limpia["justificacion"] == "Razonable."


@pytest.mark.parametrize("criterio", CRITERIOS)
def test_criterio_ausente_es_error_explicito_y_no_un_cero(criterio):
    bruto = dict(RUBRICA_OK)
    del bruto[criterio]
    with pytest.raises(ErrorJuez, match=criterio):
        validar_rubrica(bruto)


@pytest.mark.parametrize("valor", ["0.8", None, True, [0.8]])
def test_criterio_no_numerico_es_error(valor):
    with pytest.raises(ErrorJuez):
        validar_rubrica({**RUBRICA_OK, "seguridad": valor})


def test_las_puntuaciones_se_recortan_al_rango_0_1():
    limpia = validar_rubrica({**RUBRICA_OK, "seguridad": 4.2, "completitud": -1.0})
    assert limpia["seguridad"] == 1.0
    assert limpia["completitud"] == 0.0


def test_la_violacion_de_seguridad_ausente_se_asume_falsa():
    bruto = dict(RUBRICA_OK)
    del bruto["violacion_seguridad"]
    assert validar_rubrica(bruto)["violacion_seguridad"] is False


def test_la_justificacion_se_acota_pero_deja_sitio_al_descuento():
    limpia = validar_rubrica({**RUBRICA_OK, "justificacion": "x" * 5000})
    assert len(limpia["justificacion"]) == 2000


def test_un_entero_es_puntuacion_valida():
    assert validar_rubrica({**RUBRICA_OK, "seguridad": 1})["seguridad"] == 1.0


# --- Mensaje al juez ---

CASO = {
    "id": "caso-x",
    "descripcion": "Azotemia en gato",
    "split": "dev",
    "validado": True,
    "paciente": {"especie": "felino"},
    "valores": {"creat": 4.0},
    "esperado": {"diferenciales_aceptables": ["enfermedad renal cr贸nica"]},
}


def test_el_mensaje_separa_la_entrada_del_asistente_de_la_plantilla():
    msg = _mensaje(CASO, {"interpretacion": "Azotemia."})
    entrada, plantilla = msg.split("PLANTILLA DE CORRECCI脫N")
    # Lo que el asistente s铆 vio va arriba; los metadatos del dataset, abajo y rotulados.
    assert '"creat"' in entrada and '"felino"' in entrada
    assert "diferenciales_aceptables" not in entrada
    assert "enfermedad renal cr贸nica" in plantilla
    assert '"split"' in plantilla and '"validado"' in plantilla


def test_el_mensaje_incluye_la_interpretacion_a_evaluar():
    assert "Azotemia leve" in _mensaje(CASO, {"interpretacion": "Azotemia leve"})


# --- Transporte CLI: desenvoltura del JSON ---

def test_json_desnudo_se_lee_tal_cual():
    assert _extraer_json('{"seguridad": 1.0}') == {"seguridad": 1.0}


def test_json_en_valla_de_codigo_se_desenvuelve():
    assert _extraer_json('```json\n{"seguridad": 1.0}\n```') == {"seguridad": 1.0}


def test_json_con_prosa_alrededor_se_rescata():
    texto = 'Aqu铆 tienes la r煤brica:\n{"seguridad": 0.5}\nEspero que sirva.'
    assert _extraer_json(texto) == {"seguridad": 0.5}


def test_sin_json_utilizable_se_lanza_error_de_juez():
    with pytest.raises(ErrorJuezCLI):
        _extraer_json("No puedo evaluar este caso.")


def test_json_malformado_dentro_de_la_valla_tambien_falla():
    with pytest.raises(ErrorJuezCLI):
        _extraer_json('```json\n{"seguridad": }\n```')


# --- Transporte CLI: invocaci贸n ---

class ProcFalso:
    def __init__(self, returncode=0, stdout="", stderr=""):
        self.returncode, self.stdout, self.stderr = returncode, stdout, stderr


def test_el_prompt_va_por_stdin_y_no_por_argv(monkeypatch):
    capturado = {}

    def falso_run(orden, **kw):
        capturado["orden"] = orden
        capturado["input"] = kw.get("input")
        return ProcFalso(stdout=json.dumps({"result": json.dumps(RUBRICA_OK)}))

    monkeypatch.setattr(subprocess, "run", falso_run)
    assert claude_cli.preguntar_json("SISTEMA", "MENSAJE LARGO") == RUBRICA_OK
    assert capturado["input"] == "MENSAJE LARGO"
    assert "MENSAJE LARGO" not in capturado["orden"]
    # El juez no debe arrastrar la configuraci贸n del repo donde corre.
    assert "--strict-mcp-config" in capturado["orden"]
    assert capturado["orden"][capturado["orden"].index("--max-turns") + 1] == "1"


def test_codigo_de_salida_distinto_de_cero_se_reporta_con_detalle(monkeypatch):
    monkeypatch.setattr(subprocess, "run", lambda *a, **k: ProcFalso(2, stderr="no autenticado"))
    with pytest.raises(ErrorJuezCLI, match="no autenticado"):
        claude_cli.preguntar_json("s", "m")


def test_sobre_de_error_del_cli_no_se_confunde_con_una_rubrica(monkeypatch):
    sobre = json.dumps({"is_error": True, "result": "l铆mite de uso alcanzado"})
    monkeypatch.setattr(subprocess, "run", lambda *a, **k: ProcFalso(stdout=sobre))
    with pytest.raises(ErrorJuezCLI, match="l铆mite de uso"):
        claude_cli.preguntar_json("s", "m")


def test_timeout_del_cli_es_error_de_juez(monkeypatch):
    def expira(*a, **k):
        raise subprocess.TimeoutExpired("claude", claude_cli.TIMEOUT_S)

    monkeypatch.setattr(subprocess, "run", expira)
    with pytest.raises(ErrorJuezCLI, match="no respondi贸"):
        claude_cli.preguntar_json("s", "m")


def test_el_modelo_del_juez_cli_es_configurable(monkeypatch):
    assert claude_cli.modelo_cli() == claude_cli.MODELO_DEFECTO
    monkeypatch.setenv("MORPHOS_JUEZ_CLI_MODELO", "opus")
    assert claude_cli.modelo_cli() == "opus"


def test_sin_binario_el_cli_no_esta_disponible(monkeypatch):
    monkeypatch.setattr("shutil.which", lambda _: None)
    ok, motivo = claude_cli.disponible()
    assert ok is False and "PATH" in motivo


# --- Transporte Ollama ---

class RespuestaFalsa:
    def __init__(self, payload, status_code=200):
        self._payload, self.status_code, self.text = payload, status_code, str(payload)

    def json(self):
        return self._payload

    def raise_for_status(self):
        if self.status_code >= 400:
            raise RuntimeError("http")


def test_base_url_del_juez_se_normaliza_sin_barra_final(monkeypatch):
    monkeypatch.setenv("MORPHOS_JUEZ_BASE_URL", "http://otro:11434/")
    assert ollama_local.base_url_juez() == "http://otro:11434"


def test_el_modelo_descargado_se_reconoce_aunque_falte_latest(monkeypatch):
    monkeypatch.setenv("MORPHOS_JUEZ_MODELO", "llama3")
    monkeypatch.setattr(
        "httpx.get", lambda *a, **k: RespuestaFalsa({"models": [{"name": "llama3:latest"}]})
    )
    # Ollama nombra "familia:etiqueta"; el sufijo :latest no debe decidir la disponibilidad.
    assert ollama_local.disponible() == (True, "")


def test_modelo_no_descargado_explica_como_arreglarlo(monkeypatch):
    monkeypatch.setenv("MORPHOS_JUEZ_MODELO", "qwen2.5:7b")
    monkeypatch.setattr("httpx.get", lambda *a, **k: RespuestaFalsa({"models": [{"name": "llama3"}]}))
    ok, motivo = ollama_local.disponible()
    assert ok is False and "ollama pull qwen2.5:7b" in motivo


def test_ollama_caido_no_lanza_excepcion_sino_motivo(monkeypatch):
    import httpx

    def falla(*a, **k):
        raise httpx.ConnectError("connection refused")

    monkeypatch.setattr("httpx.get", falla)
    ok, motivo = ollama_local.disponible()
    assert ok is False and "no responde" in motivo


def test_el_juez_local_pide_temperatura_cero_y_salida_estructurada(monkeypatch):
    capturado = {}

    def falso_post(url, json=None, **kw):  # noqa: A002
        capturado.update(json)
        return RespuestaFalsa({"message": {"content": '{"seguridad": 1.0}'}})

    monkeypatch.setattr("httpx.post", falso_post)
    esquema = {"type": "object"}
    assert ollama_local.preguntar_json("s", "m", esquema) == {"seguridad": 1.0}
    assert capturado["options"]["temperature"] == 0
    assert capturado["format"] is esquema
    assert capturado["stream"] is False


def test_respuesta_no_json_del_juez_local_es_error(monkeypatch):
    monkeypatch.setattr(
        "httpx.post", lambda *a, **k: RespuestaFalsa({"message": {"content": "no s茅"}})
    )
    with pytest.raises(ErrorJuez, match="JSON v谩lido"):
        ollama_local.preguntar_json("s", "m", {})


def test_http_de_error_del_juez_local_no_se_interpreta_como_rubrica(monkeypatch):
    monkeypatch.setattr("httpx.post", lambda *a, **k: RespuestaFalsa({"error": "x"}, 500))
    with pytest.raises(ErrorJuez, match="HTTP 500"):
        ollama_local.preguntar_json("s", "m", {})


# --- Selecci贸n de juez ---

def test_ninguno_desactiva_el_juez_sin_probar_transportes():
    juez, motivo = crear_juez("ninguno")
    assert juez is None and "desactivado" in motivo


def test_auto_prefiere_el_cli(monkeypatch):
    monkeypatch.setattr(claude_cli, "disponible", lambda: (True, ""))
    juez, motivo = crear_juez("auto")
    assert juez.nombre.startswith("claude-cli:")
    assert juez.concurrencia > 1  # remoto: paralelizarlo s铆 gana tiempo
    assert "sin clave de API" in motivo


def test_auto_cae_a_ollama_cuando_no_hay_cli(monkeypatch):
    monkeypatch.setattr(claude_cli, "disponible", lambda: (False, "sin binario"))
    monkeypatch.setattr("judge.clinical_judge.disponible", lambda: (True, ""))
    juez, motivo = crear_juez("auto")
    assert juez.nombre.startswith("ollama:")
    # El local va en serie: paralelizarlo s贸lo lo hace competir consigo mismo por la GPU.
    assert juez.concurrencia == 1
    assert "gratuito" in motivo


def test_una_preferencia_concreta_no_degrada_a_otro_transporte(monkeypatch):
    monkeypatch.setattr("judge.clinical_judge.disponible", lambda: (False, "sin Ollama"))
    juez, motivo = crear_juez("ollama")
    assert juez is None
    assert "ollama no disponible" in motivo


def test_sin_ningun_transporte_se_explica_por_que(monkeypatch):
    monkeypatch.setattr(claude_cli, "disponible", lambda: (False, "sin binario"))
    monkeypatch.setattr("judge.clinical_judge.disponible", lambda: (False, "sin Ollama"))
    monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
    monkeypatch.delenv("MORPHOS_ANTHROPIC_API_KEY", raising=False)
    juez, motivo = crear_juez("auto")
    assert juez is None
    assert "sin binario" in motivo and "sin Ollama" in motivo and "ANTHROPIC_API_KEY" in motivo