"""Blind professional evaluators (Agent F): LLM judges run on the controller with `claude --bare -p --json-schema`. They receive the rubric and the deliverables rendered as text — never the arm, the model, the node, the cost or the oracle values.""" from __future__ import annotations import json, os, subprocess, sys, tempfile from .config import ORACLE_ROOT, REPO, SECRETS_ENV, JUDGE_MODELS sys.path.insert(0, os.path.join(REPO, "benchmark", "validators")) import common # noqa: E402 MAX_CHARS_PER_DOC = 24000 def render_deliverables(task_id: str, out_dir: str, rubric: dict) -> str: parts = [] paths = rubric.get("deliverables_to_read") or [] if not paths: for dp, _, fns in os.walk(out_dir): for fn in sorted(fns): paths.append(os.path.relpath(os.path.join(dp, fn), out_dir)) for rel in paths: p = os.path.join(out_dir, rel) if not os.path.isfile(p): # text substitutes (arm A0): same base name, other extension base = os.path.splitext(rel)[0]; alts = [x for x in os.listdir(out_dir) if os.path.splitext(x)[0] == os.path.basename(base)] if os.path.isdir(out_dir) else [] if alts: p = os.path.join(out_dir, alts[0]); rel = alts[0] else: parts.append(f"=== {rel} ===\n[MISSING]\n"); continue txt = (common.any_text(p) or "[empty or unreadable]").replace("\x00", "") txt = "".join(ch for ch in txt if ch == "\n" or ch == "\t" or ord(ch) >= 32) # argv-safe: no NUL/control bytes if len(txt) > MAX_CHARS_PER_DOC: txt = txt[:MAX_CHARS_PER_DOC] + f"\n[... truncated, {len(txt)} chars total]" parts.append(f"=== {rel} ({os.path.getsize(p)} bytes) ===\n{txt}\n") return "\n".join(parts) def judge_schema(rubric: dict) -> dict: props = {c["id"]: {"type": "integer", "minimum": 0, "maximum": 4} for c in rubric["criteria"]} return {"type": "object", "required": ["scores", "overall_comment", "professionally_acceptable"], "properties": {"scores": {"type": "object", "required": list(props), "properties": props}, "overall_comment": {"type": "string"}, "professionally_acceptable": {"type": "boolean"}}} def build_prompt(task_id: str, brief: str, rubric: dict, rendered: str) -> str: def desc(c): return c.get("description") or c.get("name") or c.get("title") or c["id"] def anchors(c): a = c.get("anchors") or c.get("levels") or c.get("scale") or {} if isinstance(a, dict): return "; ".join(f"{k}={v}" for k, v in a.items()) if isinstance(a, list): return "; ".join(f"{i}={v}" for i, v in enumerate(a)) return str(a) crit = "\n".join(f"- {c['id']} (weight {c['weight']}): {desc(c)}\n anchors: {anchors(c)}" for c in rubric["criteria"]) instr = rubric.get("instructions") or rubric.get("instructions_to_judge") or "" if isinstance(instr, (list, dict)): instr = json.dumps(instr) return f"""You are an experienced professional asked to grade a deliverable package produced by a junior colleague for the assignment below. Grade strictly against the rubric; each criterion gets an integer 0-4 using the anchors. Judge only what is in the deliverables. Do not reward length; penalise unsupported numbers, missing sections and instructions that were not followed. Then say whether a demanding manager would accept this package as professionally acceptable work (true/false) and give a 3-6 sentence overall comment. ASSIGNMENT BRIEF {brief} RUBRIC {instr} {crit} DELIVERABLES (rendered as text; spreadsheets as cell listings) {rendered} """ def run_judge(model: str, prompt: str, schema: dict, budget=0.80) -> dict: env = dict(os.environ) for line in open(SECRETS_ENV): if "=" in line and not line.startswith("#"): k, v = line.strip().split("=", 1); env[k] = v prompt = prompt.replace("\x00", "") if len(prompt) > 400_000: prompt = prompt[:400_000] + "\n[... prompt truncated by the harness at 400,000 characters]" with tempfile.TemporaryDirectory() as td: pf = os.path.join(td, "prompt.md"); open(pf, "w").write(prompt) cmd = ["claude", "--bare", "-p", prompt, "--model", model, "--output-format", "json", "--max-turns", "1", "--max-budget-usd", f"{budget:.2f}", "--tools", "", "--permission-prompts", "none", "--no-chrome", "--disable-slash-commands", "--no-session-persistence", "--json-schema", json.dumps(schema)] try: r = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=td, timeout=900) except subprocess.TimeoutExpired: return {"error": "judge timeout (900 s)", "cost": 0.0} except Exception as e: # noqa: BLE001 return {"error": f"judge launch failed: {e!r}", "cost": 0.0} try: d = json.loads(r.stdout) except Exception: return {"error": r.stdout[-500:] + r.stderr[-500:], "cost": 0.0} # noqa: BLE001 out = d.get("structured_output") if out is None: try: out = json.loads(d.get("result") or "") except Exception: out = None # noqa: BLE001 return {"structured": out, "cost": d.get("total_cost_usd") or 0.0, "raw": r.stdout[-20000:], "model_usage": d.get("modelUsage")} def judge_run(task_id: str, out_dir: str, store, run_id: str, models=None) -> list[dict]: rubric = json.load(open(os.path.join(ORACLE_ROOT, task_id, "rubric.json"), encoding="utf-8")) brief = open(os.path.join(REPO, "benchmark", "scenarios", task_id, "BRIEF.md"), encoding="utf-8").read() import shutil, tempfile from .office import recalc_tree tmp_out = tempfile.mkdtemp(prefix=f"wp42judge_{run_id}_") if os.path.isdir(out_dir): shutil.copytree(out_dir, tmp_out, dirs_exist_ok=True) recalc_tree(tmp_out) rendered = render_deliverables(task_id, tmp_out, rubric) shutil.rmtree(tmp_out, ignore_errors=True) prompt = build_prompt(task_id, brief, rubric, rendered); schema = judge_schema(rubric) weights = {c["id"]: float(c["weight"]) for c in rubric["criteria"]}; tw = sum(weights.values()) results = [] for i, m in enumerate(models or JUDGE_MODELS): r = run_judge(m, prompt, schema) s = r.get("structured") or {} scores = s.get("scores") or {} score = (sum(weights[k] * float(scores.get(k, 0)) for k in weights) / tw) * 25.0 if scores else None store.set_judgment(run_id, i, m, score, {"scores": scores, "acceptable": s.get("professionally_acceptable")}, s.get("overall_comment"), r.get("cost", 0.0), r.get("raw")) store.ledger_add(run_id, f"judge{i}", r.get("cost", 0.0)) results.append({"judge": i, "model": m, "score": score, "acceptable": s.get("professionally_acceptable"), "cost": r.get("cost", 0.0), "error": r.get("error")}) return results