Files
yao-meta-skill/scripts/adjudicate_output_review.py
T
2026-06-14 06:56:44 +08:00

461 lines
19 KiB
Python

#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_BLIND_PACK = ROOT / "reports" / "output_blind_review_pack.json"
DEFAULT_ANSWER_KEY = ROOT / "reports" / "output_blind_answer_key.json"
DEFAULT_DECISIONS = ROOT / "reports" / "output_review_decisions.json"
DEFAULT_OUTPUT_JSON = ROOT / "reports" / "output_review_adjudication.json"
DEFAULT_OUTPUT_MD = ROOT / "reports" / "output_review_adjudication.md"
def display_path(path: Path) -> str:
try:
return str(path.resolve().relative_to(ROOT.resolve()))
except ValueError:
return str(path.resolve())
def load_json(path: Path) -> tuple[dict[str, Any], list[str]]:
if not path.exists():
return {}, [f"Missing JSON file: {display_path(path)}"]
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return {}, [f"Invalid JSON file {display_path(path)}: {exc}"]
if not isinstance(payload, dict):
return {}, [f"JSON root must be an object: {display_path(path)}"]
return payload, []
def load_optional_decisions(path: Path) -> tuple[dict[str, Any], list[str]]:
if not path.exists():
return {"schema_version": "1.0", "decisions": []}, []
return load_json(path)
def normalize_variant(value: Any) -> str:
normalized = str(value or "").strip().upper()
if normalized in {"VARIANT A", "A"}:
return "A"
if normalized in {"VARIANT B", "B"}:
return "B"
return normalized
def confidence_value(value: Any) -> tuple[float | None, str | None]:
if value is None or value == "":
return None, None
try:
parsed = float(value)
except (TypeError, ValueError):
return None, f"confidence must be numeric, got {value!r}"
if parsed < 0 or parsed > 1:
return None, f"confidence must be between 0 and 1, got {parsed}"
return round(parsed, 3), None
def answer_index(answer_key: dict[str, Any]) -> dict[str, dict[str, Any]]:
answers = answer_key.get("answers", [])
if not isinstance(answers, list):
return {}
return {str(item.get("case_id", "")): item for item in answers if isinstance(item, dict) and item.get("case_id")}
def pair_index(blind_pack: dict[str, Any]) -> dict[str, dict[str, Any]]:
pairs = blind_pack.get("pairs", [])
if not isinstance(pairs, list):
return {}
return {str(item.get("case_id", "")): item for item in pairs if isinstance(item, dict) and item.get("case_id")}
def decision_index(decisions_payload: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str]]:
decisions = decisions_payload.get("decisions", [])
failures: list[str] = []
indexed: dict[str, dict[str, Any]] = {}
if not isinstance(decisions, list):
return {}, ["decisions must be a list"]
for index, item in enumerate(decisions, start=1):
if not isinstance(item, dict):
failures.append(f"decision #{index} must be an object")
continue
case_id = str(item.get("case_id", "")).strip()
if not case_id:
failures.append(f"decision #{index} is missing case_id")
continue
if case_id in indexed:
failures.append(f"duplicate decision for case_id: {case_id}")
continue
indexed[case_id] = item
return indexed, failures
def build_decision_template(blind_pack: dict[str, Any]) -> dict[str, Any]:
pairs = blind_pack.get("pairs", [])
template_decisions = []
if isinstance(pairs, list):
for pair in pairs:
if not isinstance(pair, dict):
continue
template_decisions.append(
{
"case_id": str(pair.get("case_id", "")),
"winner_variant": "",
"confidence": None,
"reason": "",
}
)
return {
"schema_version": "1.0",
"reviewer": "",
"reviewed_at": "",
"decision_contract": {
"winner_variant": "Use A or B after reading the blind review pack. Leave blank when pending.",
"confidence": "Optional number from 0 to 1.",
"reason": "Short reviewer rationale. Do not reveal baseline or with-skill labels before adjudication.",
},
"decisions": template_decisions,
}
def adjudicate_pair(
case_id: str,
pair: dict[str, Any],
answer: dict[str, Any] | None,
decision: dict[str, Any] | None,
) -> tuple[dict[str, Any], list[str]]:
failures: list[str] = []
expected = normalize_variant(answer.get("expected_winner_variant", "") if answer else "")
if expected not in {"A", "B"}:
failures.append(f"{case_id}: answer key is missing expected_winner_variant")
if decision is None:
return (
{
"case_id": case_id,
"status": "pending",
"expected_winner_variant": "",
"expected_revealed": False,
"reviewer_winner_variant": "",
"confidence": None,
"reason": "",
"prompt": str(pair.get("prompt", "")),
},
failures,
)
reviewer = normalize_variant(
decision.get("winner_variant", decision.get("reviewer_winner_variant", decision.get("winner", "")))
)
confidence, confidence_failure = confidence_value(decision.get("confidence"))
reason = str(decision.get("reason", "")).strip()
if not reviewer:
return (
{
"case_id": case_id,
"status": "pending",
"expected_winner_variant": "",
"expected_revealed": False,
"reviewer_winner_variant": "",
"confidence": confidence,
"reason": reason,
"prompt": str(pair.get("prompt", "")),
},
failures,
)
if expected not in {"A", "B"}:
status = "invalid"
elif reviewer not in {"A", "B"}:
failures.append(f"{case_id}: winner_variant must be A or B")
status = "invalid"
elif confidence_failure:
failures.append(f"{case_id}: {confidence_failure}")
status = "invalid"
else:
status = "match" if reviewer == expected else "disagree"
expected_revealed = status in {"match", "disagree"}
return (
{
"case_id": case_id,
"status": status,
"expected_winner_variant": expected if expected_revealed else "",
"expected_revealed": expected_revealed,
"reviewer_winner_variant": reviewer,
"confidence": confidence,
"reason": reason,
"prompt": str(pair.get("prompt", "")),
},
failures,
)
def build_summary(pairs: list[dict[str, Any]], failures: list[str]) -> dict[str, Any]:
pair_count = len(pairs)
judgment_count = sum(1 for item in pairs if item["status"] in {"match", "disagree"})
pending_count = sum(1 for item in pairs if item["status"] == "pending")
agreement_count = sum(1 for item in pairs if item["status"] == "match")
disagreement_count = sum(1 for item in pairs if item["status"] == "disagree")
invalid_decision_count = sum(1 for item in pairs if item["status"] == "invalid")
answer_revealed_count = sum(1 for item in pairs if item.get("expected_revealed"))
agreement_rate = round(agreement_count / judgment_count * 100, 2) if judgment_count else None
return {
"pair_count": pair_count,
"judgment_count": judgment_count,
"pending_count": pending_count,
"agreement_count": agreement_count,
"disagreement_count": disagreement_count,
"invalid_decision_count": invalid_decision_count,
"answer_revealed_count": answer_revealed_count,
"pending_answer_hidden_count": sum(1 for item in pairs if item["status"] in {"pending", "invalid"} and not item.get("expected_revealed")),
"agreement_rate": agreement_rate,
"needs_review": pending_count > 0,
"failure_count": len(failures),
}
def checklist_readiness(pair: dict[str, Any]) -> tuple[str, str]:
status = pair.get("status")
if status in {"match", "disagree"}:
return "adjudicated", "Reviewer decision is valid; answer key is revealed for this case."
if status == "invalid":
return "fix-decision", "Reviewer decision exists but failed validation; answer key remains hidden."
return "awaiting-decision", "Reviewer has not selected A or B yet; answer key remains hidden."
def build_reviewer_checklist(
pairs: list[dict[str, Any]],
blind_pack_path: Path,
decisions_path: Path,
) -> list[dict[str, Any]]:
checklist = []
for pair in pairs:
readiness, blocking_reason = checklist_readiness(pair)
checklist.append(
{
"case_id": pair.get("case_id", ""),
"readiness": readiness,
"blocking_reason": blocking_reason,
"status": pair.get("status", "pending"),
"reviewer_winner_variant": pair.get("reviewer_winner_variant", ""),
"answer_key_visible": bool(pair.get("expected_revealed")),
"prompt": pair.get("prompt", ""),
"blind_pack_path": display_path(blind_pack_path),
"decisions_path": display_path(decisions_path),
"commands": {
"prepare_review_kit": "python3 scripts/yao.py output-review-kit",
"write_template": "python3 scripts/adjudicate_output_review.py --write-template",
"adjudicate": "python3 scripts/yao.py output-review",
"refresh_review_studio": "python3 scripts/yao.py review-studio .",
},
"required_fields": {
"winner_variant": "A or B after reading only the blind review pack.",
"confidence": "Optional number from 0 to 1.",
"reason": "Short rationale; do not reveal baseline or with-skill labels before adjudication.",
},
"privacy_contract": [
"Do not paste raw private user data into the decision reason.",
"Do not open the answer key before reviewer choices are recorded.",
"Leave winner_variant blank when the reviewer is not ready to decide.",
],
}
)
return checklist
def add_checklist_summary(summary: dict[str, Any], checklist: list[dict[str, Any]]) -> dict[str, Any]:
enriched = dict(summary)
enriched["reviewer_checklist_count"] = len(checklist)
enriched["reviewer_checklist_pending_count"] = sum(1 for item in checklist if item["readiness"] == "awaiting-decision")
enriched["reviewer_checklist_invalid_count"] = sum(1 for item in checklist if item["readiness"] == "fix-decision")
enriched["reviewer_checklist_ready_count"] = sum(1 for item in checklist if item["readiness"] == "adjudicated")
return enriched
def render_reviewer_checklist(checklist: list[dict[str, Any]]) -> list[str]:
lines = [
"## Reviewer Checklist",
"",
"| Case | Readiness | Answer key | Decision file |",
"| --- | --- | --- | --- |",
]
if not checklist:
lines.append("| `none` | `adjudicated` | n/a | none |")
return lines
for item in checklist:
answer_key = "visible" if item.get("answer_key_visible") else "hidden"
lines.append(
f"| `{item['case_id']}` | `{item['readiness']}` | `{answer_key}` | `{item['decisions_path']}` |"
)
for item in checklist:
lines.extend(["", f"### {item['case_id']}", ""])
lines.append(f"- readiness: `{item['readiness']}`")
lines.append(f"- blocking reason: {item['blocking_reason']}")
lines.append(f"- answer key visible: `{str(item['answer_key_visible']).lower()}`")
lines.append(f"- blind pack: `{item['blind_pack_path']}`")
lines.append(f"- decisions: `{item['decisions_path']}`")
lines.extend(["", "#### Commands", ""])
for label, command in item.get("commands", {}).items():
lines.append(f"- {label}: `{command}`")
lines.extend(["", "#### Required Fields", ""])
for label, description in item.get("required_fields", {}).items():
lines.append(f"- {label}: {description}")
lines.extend(["", "#### Privacy Contract", ""])
lines.extend(f"- {contract}" for contract in item.get("privacy_contract", []))
return lines
def render_markdown(payload: dict[str, Any]) -> str:
summary = payload["summary"]
lines = [
"# Output Review Adjudication",
"",
"This report adjudicates reviewer choices from the blind A/B output review pack against the separate answer key.",
"",
f"- Pairs: `{summary['pair_count']}`",
f"- Judgments: `{summary['judgment_count']}`",
f"- Pending: `{summary['pending_count']}`",
f"- Agreement rate: `{summary['agreement_rate'] if summary['agreement_rate'] is not None else 'n/a'}`",
f"- Invalid decisions: `{summary['invalid_decision_count']}`",
f"- Answer keys revealed: `{summary['answer_revealed_count']}`",
f"- Pending/invalid answers hidden: `{summary['pending_answer_hidden_count']}`",
f"- Reviewer checklist: `{summary['reviewer_checklist_ready_count']}` ready / `{summary['reviewer_checklist_count']}` total",
"",
]
if summary["judgment_count"] == 0:
lines.extend(
[
"No reviewer decisions recorded yet.",
"",
"Generate a template with `--write-template`, fill `winner_variant` with `A` or `B`, then rerun adjudication.",
"Expected winners stay hidden until a valid reviewer decision is recorded.",
"",
]
)
lines.extend(
[
"## Case Adjudication",
"",
"| Case | Reviewer | Expected | Status | Confidence | Reason |",
"| --- | --- | --- | --- | ---: | --- |",
]
)
for item in payload["pairs"]:
confidence = "" if item.get("confidence") is None else str(item["confidence"])
reason = str(item.get("reason", "")).replace("|", "\\|") or ""
expected = item.get("expected_winner_variant", "") if item.get("expected_revealed") else "hidden"
lines.append(
f"| {item['case_id']} | {item.get('reviewer_winner_variant', '') or 'pending'} | "
f"{expected} | {item['status']} | {confidence} | {reason} |"
)
if payload.get("failures"):
lines.extend(["", "## Failures", ""])
for failure in payload["failures"]:
lines.append(f"- {failure}")
lines.extend(["", *render_reviewer_checklist(payload.get("reviewer_checklist", []))])
lines.extend(
[
"",
"## Next Fixes",
"",
"- Keep the blind review pack separate from the answer key until decisions are recorded.",
"- Treat disagreement cases as prompts for rubric tuning or output improvement.",
"- Add model-executed holdout runs after this human adjudication harness is stable.",
]
)
return "\n".join(lines).strip() + "\n"
def adjudicate_output_review(
blind_pack_path: Path,
answer_key_path: Path,
decisions_path: Path,
output_json: Path,
output_md: Path,
write_template: bool = False,
) -> dict[str, Any]:
blind_pack, failures = load_json(blind_pack_path)
answer_key, answer_failures = load_json(answer_key_path)
failures.extend(answer_failures)
decisions_payload, decision_failures = load_optional_decisions(decisions_path)
failures.extend(decision_failures)
template_written = False
if write_template and blind_pack and not decisions_path.exists():
decisions_path.parent.mkdir(parents=True, exist_ok=True)
decisions_path.write_text(
json.dumps(build_decision_template(blind_pack), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
template_written = True
pairs_by_id = pair_index(blind_pack)
answers_by_id = answer_index(answer_key)
decisions_by_id, index_failures = decision_index(decisions_payload)
failures.extend(index_failures)
for case_id in decisions_by_id:
if case_id not in pairs_by_id:
failures.append(f"decision references unknown case_id: {case_id}")
adjudicated_pairs: list[dict[str, Any]] = []
for case_id, pair in pairs_by_id.items():
adjudicated, pair_failures = adjudicate_pair(case_id, pair, answers_by_id.get(case_id), decisions_by_id.get(case_id))
adjudicated_pairs.append(adjudicated)
failures.extend(pair_failures)
summary = build_summary(adjudicated_pairs, failures)
reviewer_checklist = build_reviewer_checklist(adjudicated_pairs, blind_pack_path, decisions_path)
summary = add_checklist_summary(summary, reviewer_checklist)
payload = {
"schema_version": "1.0",
"ok": not failures,
"summary": summary,
"reviewer": decisions_payload.get("reviewer", ""),
"reviewed_at": decisions_payload.get("reviewed_at", ""),
"artifacts": {
"blind_pack": display_path(blind_pack_path),
"answer_key": display_path(answer_key_path),
"decisions": display_path(decisions_path),
"json": display_path(output_json),
"markdown": display_path(output_md),
},
"template_written": template_written,
"pairs": adjudicated_pairs,
"reviewer_checklist": reviewer_checklist,
"failures": failures,
}
output_json.parent.mkdir(parents=True, exist_ok=True)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
output_md.write_text(render_markdown(payload), encoding="utf-8")
return payload
def main() -> None:
parser = argparse.ArgumentParser(description="Adjudicate blind A/B output review decisions against the answer key.")
parser.add_argument("--blind-pack", default=str(DEFAULT_BLIND_PACK))
parser.add_argument("--answer-key", default=str(DEFAULT_ANSWER_KEY))
parser.add_argument("--decisions", default=str(DEFAULT_DECISIONS))
parser.add_argument("--output-json", default=str(DEFAULT_OUTPUT_JSON))
parser.add_argument("--output-md", default=str(DEFAULT_OUTPUT_MD))
parser.add_argument("--write-template", action="store_true")
args = parser.parse_args()
payload = adjudicate_output_review(
Path(args.blind_pack).resolve(),
Path(args.answer_key).resolve(),
Path(args.decisions).resolve(),
Path(args.output_json).resolve(),
Path(args.output_md).resolve(),
write_template=args.write_template,
)
print(json.dumps(payload, ensure_ascii=False, indent=2))
raise SystemExit(0 if payload["ok"] else 2)
if __name__ == "__main__":
main()