Spaces:
Sleeping
Sleeping
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
|