File size: 4,260 Bytes
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Transporte del juez servido por el CLI de Claude Code (`claude -p`).

Es la tercera vía entre las dos que ya había, y la que mejor encaja en este proyecto:

- El juez Ollama es gratis y corre headless, pero un 7B discrimina poco.
- El juez por SDK necesita ANTHROPIC_API_KEY y saldo de API — el requisito que dejó la capa
  de juez sin cablear durante toda la fase 3.
- Este usa la sesión de Claude Code que el desarrollador ya tiene abierta: sin clave de API,
  con un modelo grande detrás.

A cambio: sólo existe donde está el binario y hay sesión iniciada (es decir, en local, NO en
un runner de GitHub Actions), consume los límites de uso de la suscripción, y no expone
temperatura, así que es algo menos reproducible que la ruta Ollama. Por eso `crear_juez`
degrada solo a Ollama cuando el binario no está.
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess

from . import ErrorJuez

# Sonnet como juez por defecto: la rúbrica es una tarea de clasificación corta y no justifica
# el coste de Opus en cada caso del dataset. Para una auditoría puntual, MORPHOS_JUEZ_CLI_MODELO=opus.
MODELO_DEFECTO = "sonnet"
TIMEOUT_S = 180


class ErrorJuezCLI(ErrorJuez):
    """Fallo al invocar el CLI o al interpretar su salida."""


def modelo_cli() -> str:
    return os.environ.get("MORPHOS_JUEZ_CLI_MODELO", MODELO_DEFECTO)


def disponible() -> tuple[bool, str]:
    """(disponible, motivo). Sólo comprueba el binario: verificar la sesión exigiría gastar
    una petición real, así que un fallo de autenticación se reporta en la primera llamada."""
    if shutil.which("claude") is None:
        return False, "el CLI `claude` no está en el PATH"
    return True, ""


def preguntar_json(sistema: str, mensaje: str) -> dict:
    """Una llamada de juicio. Lanza ErrorJuezCLI ante cualquier fallo."""
    orden = [
        "claude",
        "-p",
        "--system-prompt", sistema,
        "--output-format", "json",
        "--model", modelo_cli(),
        # El juez no necesita herramientas ni servidores MCP, y no debe arrastrar la
        # configuración del repo donde se ejecuta: sin MCP, sin sesión persistida y con un
        # solo turno. Las herramientas quedan denegadas por defecto en modo -p.
        "--strict-mcp-config",
        "--no-session-persistence",
        "--max-turns", "1",
    ]
    try:
        # El prompt va por stdin, no por argv: los casos con muchos hallazgos generan
        # mensajes de varios KB y argv tiene un límite de tamaño.
        proc = subprocess.run(
            orden, input=mensaje, capture_output=True, text=True, timeout=TIMEOUT_S
        )
    except FileNotFoundError as exc:
        raise ErrorJuezCLI("El CLI `claude` no está instalado.") from exc
    except subprocess.TimeoutExpired as exc:
        raise ErrorJuezCLI(f"El juez CLI no respondió en {TIMEOUT_S}s.") from exc

    if proc.returncode != 0:
        detalle = (proc.stderr or proc.stdout or "").strip()[:300]
        raise ErrorJuezCLI(f"`claude -p` falló (código {proc.returncode}): {detalle}")

    try:
        sobre = json.loads(proc.stdout)
    except json.JSONDecodeError as exc:
        raise ErrorJuezCLI(f"El CLI no devolvió el sobre JSON esperado: {exc}") from exc

    if sobre.get("is_error"):
        raise ErrorJuezCLI(f"El juez CLI devolvió error: {str(sobre.get('result'))[:300]}")

    return _extraer_json(str(sobre.get("result", "")))


def _extraer_json(texto: str) -> dict:
    """El modelo devuelve el JSON pedido, pero a veces envuelto en una valla de código.
    No hay `format` que forzar como en Ollama, así que se desenvuelve aquí."""
    limpio = texto.strip()
    if limpio.startswith("```"):
        limpio = limpio.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
    try:
        return json.loads(limpio)
    except json.JSONDecodeError as exc:
        error = exc
    # Último recurso: el primer objeto JSON del texto.
    inicio, fin = limpio.find("{"), limpio.rfind("}")
    if inicio != -1 and fin > inicio:
        try:
            return json.loads(limpio[inicio : fin + 1])
        except json.JSONDecodeError as exc:
            error = exc
    raise ErrorJuezCLI(f"El juez no devolvió JSON válido: {error}") from error