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

483 lines
23 KiB
Python

#!/usr/bin/env python3
"""Prepare a reviewer-facing blind A/B output review kit."""
import argparse
import json
from pathlib import Path
from typing import Any
from adjudicate_output_review import (
build_decision_template,
confidence_value,
decision_index,
display_path,
load_json,
normalize_variant,
)
from html_rendering import html_text
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_BLIND_PACK_JSON = ROOT / "reports" / "output_blind_review_pack.json"
DEFAULT_BLIND_PACK_MD = ROOT / "reports" / "output_blind_review_pack.md"
DEFAULT_DECISIONS = ROOT / "reports" / "output_review_decisions.json"
DEFAULT_OUTPUT_JSON = ROOT / "reports" / "output_review_kit.json"
DEFAULT_OUTPUT_MD = ROOT / "reports" / "output_review_kit.md"
DEFAULT_OUTPUT_HTML = ROOT / "reports" / "output_review_kit.html"
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 pairs_from_pack(blind_pack: dict[str, Any]) -> list[dict[str, Any]]:
pairs = blind_pack.get("pairs", [])
if not isinstance(pairs, list):
return []
return [item for item in pairs if isinstance(item, dict) and item.get("case_id")]
def decision_state(decision: dict[str, Any] | None) -> dict[str, Any]:
if not decision:
return {
"status": "awaiting-decision",
"winner_variant_recorded": False,
"confidence_recorded": False,
"reason_recorded": False,
"blocking_reason": "No reviewer choice has been recorded yet.",
}
winner = 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 winner and confidence is None and not reason:
return {
"status": "awaiting-decision",
"winner_variant_recorded": False,
"confidence_recorded": False,
"reason_recorded": False,
"blocking_reason": "Decision template exists but this row is still blank.",
}
failures = []
if winner not in {"A", "B"}:
failures.append("winner_variant must be A or B")
if confidence_failure:
failures.append(confidence_failure)
if not reason:
failures.append("reason should explain the reviewer rationale")
if failures:
return {
"status": "needs-fix",
"winner_variant_recorded": winner in {"A", "B"},
"confidence_recorded": confidence is not None,
"reason_recorded": bool(reason),
"blocking_reason": "; ".join(failures),
}
return {
"status": "ready-for-adjudication",
"winner_variant_recorded": True,
"confidence_recorded": confidence is not None,
"reason_recorded": True,
"blocking_reason": "Reviewer choice is complete enough for adjudication.",
}
def build_cases(blind_pack: dict[str, Any], decisions: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
cases = []
for pair in pairs_from_pack(blind_pack):
case_id = str(pair.get("case_id", ""))
rubric = []
for item in pair.get("rubric", []) if isinstance(pair.get("rubric", []), list) else []:
if isinstance(item, dict):
rubric.append(
{
"id": str(item.get("id", "")),
"description": str(item.get("description", "")),
"weight": float(item.get("weight", 1) or 0),
}
)
cases.append(
{
"case_id": case_id,
"prompt": str(pair.get("prompt", "")),
"input_files": pair.get("input_files", []) if isinstance(pair.get("input_files", []), list) else [],
"review_instruction": str(pair.get("review_instruction", "")),
"rubric": rubric,
"variant_a": {
"blind_id": str(pair.get("variant_a", {}).get("blind_id", "")) if isinstance(pair.get("variant_a"), dict) else "",
"output": str(pair.get("variant_a", {}).get("output", "")) if isinstance(pair.get("variant_a"), dict) else "",
},
"variant_b": {
"blind_id": str(pair.get("variant_b", {}).get("blind_id", "")) if isinstance(pair.get("variant_b"), dict) else "",
"output": str(pair.get("variant_b", {}).get("output", "")) if isinstance(pair.get("variant_b"), dict) else "",
},
"decision_state": decision_state(decisions.get(case_id)),
}
)
return cases
def build_summary(cases: list[dict[str, Any]], failures: list[str], reviewer: str, reviewed_at: str) -> dict[str, Any]:
status_counts: dict[str, int] = {}
for case in cases:
status = str(case.get("decision_state", {}).get("status", "awaiting-decision"))
status_counts[status] = status_counts.get(status, 0) + 1
ready_count = status_counts.get("ready-for-adjudication", 0)
pending_count = status_counts.get("awaiting-decision", 0)
invalid_count = status_counts.get("needs-fix", 0)
return {
"case_count": len(cases),
"ready_for_adjudication_count": ready_count,
"pending_decision_count": pending_count,
"invalid_decision_count": invalid_count,
"reviewer_metadata_present": bool(str(reviewer).strip() and str(reviewed_at).strip()),
"answer_key_hidden": True,
"answer_key_path_exposed": False,
"ready_to_run_adjudication": bool(cases) and ready_count == len(cases) and invalid_count == 0,
"failure_count": len(failures),
}
def build_contract(blind_pack_md: Path, decisions_path: Path) -> dict[str, Any]:
return {
"reviewer_steps": [
f"Open {display_path(blind_pack_md)} or this kit and compare Variant A vs Variant B for each case.",
f"Record choices in {display_path(decisions_path)} without opening the answer key.",
"Use winner_variant A or B, confidence from 0 to 1, and a short reason for every case.",
"Run python3 scripts/yao.py output-review after choices are recorded.",
"Refresh python3 scripts/yao.py review-studio . before asking for release approval.",
],
"required_fields": {
"reviewer": "Human reviewer name or review group.",
"reviewed_at": "Review date or timestamp.",
"winner_variant": "A or B for every case.",
"confidence": "Optional numeric confidence from 0 to 1.",
"reason": "Short rationale based on the rubric, not on hidden labels.",
},
"privacy_contract": [
"The answer key is intentionally withheld from this kit.",
"Do not inspect hidden labels or expected winners before decisions are recorded.",
"Do not paste private user data into decision reasons.",
"Pending decisions must stay pending instead of being counted as human agreement.",
],
}
def render_rubric(rubric: list[dict[str, Any]]) -> list[str]:
if not rubric:
return ["- No rubric items found."]
return [f"- `{item['id']}` ({item['weight']}): {item['description']}" for item in rubric]
def render_markdown(payload: dict[str, Any]) -> str:
summary = payload["summary"]
contract = payload["review_contract"]
lines = [
"# Output Review Kit",
"",
"This reviewer-facing packet contains the blind A/B cases, decision fields, and command flow. It intentionally does not expose the answer key.",
"",
"## Summary",
"",
f"- cases: `{summary['case_count']}`",
f"- ready for adjudication: `{summary['ready_for_adjudication_count']}`",
f"- pending decisions: `{summary['pending_decision_count']}`",
f"- invalid decisions: `{summary['invalid_decision_count']}`",
f"- reviewer metadata present: `{str(summary['reviewer_metadata_present']).lower()}`",
f"- answer key hidden: `{str(summary['answer_key_hidden']).lower()}`",
f"- answer key path exposed: `{str(summary['answer_key_path_exposed']).lower()}`",
"",
"## Review Flow",
"",
]
lines.extend(f"{index}. {step}" for index, step in enumerate(contract["reviewer_steps"], start=1))
lines.extend(["", "## Required Fields", ""])
lines.extend(f"- `{key}`: {value}" for key, value in contract["required_fields"].items())
lines.extend(["", "## Privacy Contract", ""])
lines.extend(f"- {item}" for item in contract["privacy_contract"])
lines.extend(
[
"",
"## Decision States",
"",
"| Case | State | Winner | Confidence | Reason | Blocking Reason |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for case in payload["cases"]:
state = case["decision_state"]
lines.append(
f"| `{case['case_id']}` | `{state['status']}` | `{str(state['winner_variant_recorded']).lower()}` | "
f"`{str(state['confidence_recorded']).lower()}` | `{str(state['reason_recorded']).lower()}` | {state['blocking_reason']} |"
)
lines.extend(["", "## Blind Cases", ""])
for case in payload["cases"]:
lines.extend(
[
f"### {case['case_id']}",
"",
f"Prompt: {case['prompt']}",
"",
"Rubric:",
*render_rubric(case["rubric"]),
"",
"#### Variant A",
"",
case["variant_a"]["output"] or "_No output found._",
"",
"#### Variant B",
"",
case["variant_b"]["output"] or "_No output found._",
"",
]
)
if payload.get("failures"):
lines.extend(["## Failures", ""])
lines.extend(f"- {failure}" for failure in payload["failures"])
return "\n".join(lines).strip() + "\n"
def status_label(status: str) -> str:
return {
"awaiting-decision": "Awaiting",
"needs-fix": "Needs fix",
"ready-for-adjudication": "Ready",
}.get(status, status)
def render_html_rubric(rubric: list[dict[str, Any]]) -> str:
if not rubric:
return "<li>No rubric items found.</li>"
return "".join(
"<li><span>{id}</span><p>{description}</p><small>{weight}</small></li>".format(
id=html_text(item.get("id", "")),
description=html_text(item.get("description", "")),
weight=html_text(item.get("weight", "")),
)
for item in rubric
)
def render_html_cases(cases: list[dict[str, Any]]) -> str:
cards = []
for index, case in enumerate(cases, start=1):
state = case.get("decision_state", {})
status = str(state.get("status", "awaiting-decision"))
cards.append(
f"""
<article class="case-card" id="case-{html_text(case.get('case_id', ''))}">
<header class="case-head">
<div>
<span class="case-index">Case {index:02d}</span>
<h3>{html_text(case.get('case_id', ''))}</h3>
</div>
<span class="status-pill {html_text(status)}">{html_text(status_label(status))}</span>
</header>
<p class="prompt">{html_text(case.get('prompt', ''))}</p>
<section class="rubric"><h4>Rubric</h4><ul>{render_html_rubric(case.get('rubric', []))}</ul></section>
<section class="variants" aria-label="Blind output variants">
<div class="variant"><h4>Variant A</h4><pre>{html_text(case.get('variant_a', {}).get('output', ''))}</pre></div>
<div class="variant"><h4>Variant B</h4><pre>{html_text(case.get('variant_b', {}).get('output', ''))}</pre></div>
</section>
<footer class="case-foot">
<span>Winner recorded: {html_text(str(state.get('winner_variant_recorded', False)).lower())}</span>
<span>Confidence: {html_text(str(state.get('confidence_recorded', False)).lower())}</span>
<span>Reason: {html_text(str(state.get('reason_recorded', False)).lower())}</span>
<strong>{html_text(state.get('blocking_reason', ''))}</strong>
</footer>
</article>"""
)
return "\n".join(cards)
def decision_template_json(cases: list[dict[str, Any]]) -> str:
template = {
"schema_version": "1.0",
"reviewer": "",
"reviewed_at": "",
"decisions": [
{"case_id": case.get("case_id", ""), "winner_variant": "", "confidence": None, "reason": ""}
for case in cases
],
}
return json.dumps(template, ensure_ascii=False, indent=2)
def render_html(payload: dict[str, Any]) -> str:
summary = payload["summary"]
contract = payload["review_contract"]
stats = [
("Cases", summary["case_count"]),
("Ready", summary["ready_for_adjudication_count"]),
("Pending", summary["pending_decision_count"]),
("Invalid", summary["invalid_decision_count"]),
]
stat_html = "".join(f"<div><span>{html_text(label)}</span><strong>{html_text(value)}</strong></div>" for label, value in stats)
flow_html = "".join(f"<li>{html_text(step)}</li>" for step in contract["reviewer_steps"])
privacy_html = "".join(f"<li>{html_text(item)}</li>" for item in contract["privacy_contract"])
decision_json = html_text(decision_template_json(payload["cases"]))
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Output Review Kit</title>
<style>
:root {{ --ink:#1B365D; --text:#202124; --muted:#6f6a63; --line:#e8e1d8; --soft:#f8f6f2; --warn:#9b4d0f; --ok:#1f6f43; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#fff; color:var(--text); font:16px/1.55 Georgia, "Times New Roman", serif; }}
.shell {{ max-width:1180px; margin:0 auto; padding:36px 24px 72px; }}
.topbar {{ position:sticky; top:0; z-index:10; background:rgba(255,255,255,.96); border-bottom:1px solid var(--line); backdrop-filter:blur(8px); }}
.topbar-inner {{ max-width:1180px; margin:0 auto; padding:12px 24px; display:flex; justify-content:space-between; gap:18px; align-items:center; }}
.brand {{ color:var(--ink); font-weight:700; letter-spacing:0; }}
.links {{ display:flex; gap:12px; flex-wrap:wrap; }}
.links a {{ color:var(--ink); text-decoration:none; border-bottom:1px solid transparent; }}
.links a:hover {{ border-color:var(--ink); }}
.hero {{ border-bottom:1px solid var(--line); padding:34px 0 28px; }}
.eyebrow {{ color:var(--ink); text-transform:uppercase; font-size:12px; letter-spacing:0; font-weight:700; }}
h1 {{ margin:8px 0 12px; color:var(--ink); font-size:58px; line-height:1.02; letter-spacing:0; }}
.lede {{ max-width:760px; color:var(--muted); font-size:20px; }}
.stats {{ display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:12px; margin:28px 0; }}
.stats div, .panel, .case-card {{ border:1px solid var(--line); border-radius:8px; background:#fff; }}
.stats div {{ padding:16px; }}
.stats span {{ display:block; color:var(--muted); font-size:13px; }}
.stats strong {{ color:var(--ink); font-size:34px; line-height:1; }}
.grid {{ display:grid; grid-template-columns:minmax(0,1fr) minmax(280px,.42fr); gap:18px; align-items:start; }}
.panel {{ padding:20px; }}
h2, h3, h4 {{ color:var(--ink); letter-spacing:0; }}
h2 {{ margin:0 0 14px; font-size:28px; }}
h3 {{ margin:0; font-size:24px; }}
h4 {{ margin:0 0 10px; font-size:16px; }}
ol, ul {{ padding-left:22px; }}
code, pre {{ font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
pre {{ white-space:pre-wrap; overflow-wrap:anywhere; margin:0; color:#2b2b2b; font-size:13px; line-height:1.5; }}
.case-card {{ margin:22px 0; padding:22px; scroll-margin-top:72px; }}
.case-head, .case-foot {{ display:flex; gap:14px; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; }}
.case-index {{ color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:0; }}
.status-pill {{ border:1px solid var(--line); border-radius:999px; padding:4px 10px; color:var(--ink); background:var(--soft); font-size:13px; }}
.status-pill.ready-for-adjudication {{ color:var(--ok); }}
.status-pill.needs-fix {{ color:var(--warn); }}
.prompt {{ color:var(--muted); font-size:18px; }}
.rubric {{ margin:18px 0; padding:16px; background:var(--soft); border-radius:8px; }}
.rubric ul {{ list-style:none; padding:0; margin:0; display:grid; gap:8px; }}
.rubric li {{ display:grid; grid-template-columns:160px 1fr 44px; gap:12px; align-items:start; border-top:1px solid var(--line); padding-top:8px; }}
.rubric li:first-child {{ border-top:0; padding-top:0; }}
.rubric span {{ color:var(--ink); font-weight:700; overflow-wrap:anywhere; }}
.rubric p {{ margin:0; }}
.rubric small {{ text-align:right; color:var(--muted); }}
.variants {{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }}
.variant {{ border:1px solid var(--line); border-radius:8px; padding:16px; min-width:0; }}
.case-foot {{ margin-top:16px; padding-top:14px; border-top:1px solid var(--line); color:var(--muted); font-size:13px; }}
.case-foot strong {{ flex-basis:100%; color:var(--text); font-weight:400; }}
.template {{ background:#101820; color:#f7f2e8; border-radius:8px; padding:16px; }}
@media (max-width:820px) {{ .stats, .grid, .variants {{ grid-template-columns:1fr; }} .rubric li {{ grid-template-columns:1fr; }} .rubric small {{ text-align:left; }} h1 {{ font-size:38px; }} }}
</style>
</head>
<body>
<nav class="topbar"><div class="topbar-inner"><span class="brand">Output Review Kit</span><div class="links"><a href="#flow">Flow</a><a href="#cases">Cases</a><a href="#template">Template</a></div></div></nav>
<main class="shell">
<section class="hero">
<span class="eyebrow">Blind A/B Human Review</span>
<h1>Reviewer cockpit for output quality decisions</h1>
<p class="lede">Compare visible Variant A and Variant B outputs, fill the decision file, then run adjudication. The answer key is intentionally hidden from this page.</p>
<div class="stats">{stat_html}</div>
</section>
<section class="grid" id="flow">
<article class="panel"><h2>Review Flow</h2><ol>{flow_html}</ol></article>
<aside class="panel"><h2>Privacy</h2><ul>{privacy_html}</ul></aside>
</section>
<section id="cases">{render_html_cases(payload["cases"])}</section>
<section class="panel" id="template"><h2>Decision Template</h2><p>Use this shape in {html_text(payload['artifacts']['decisions'])}; leave a case blank when the reviewer is not ready.</p><pre class="template">{decision_json}</pre></section>
</main>
</body>
</html>
"""
def prepare_output_review_kit(
blind_pack_json: Path,
blind_pack_md: Path,
decisions_path: Path,
output_json: Path,
output_md: Path,
output_html: Path | None = None,
write_template: bool = False,
) -> dict[str, Any]:
blind_pack, failures = load_json(blind_pack_json)
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
decisions_payload, decision_failures = load_optional_decisions(decisions_path)
failures.extend(decision_failures)
decisions, index_failures = decision_index(decisions_payload)
failures.extend(index_failures)
case_ids = {str(pair.get("case_id", "")) for pair in pairs_from_pack(blind_pack)}
for case_id in decisions:
if case_id not in case_ids:
failures.append(f"decision references unknown case_id: {case_id}")
cases = build_cases(blind_pack, decisions)
summary = build_summary(cases, failures, str(decisions_payload.get("reviewer", "")), str(decisions_payload.get("reviewed_at", "")))
payload = {
"schema_version": "1.0",
"ok": not failures,
"summary": summary,
"artifacts": {
"reviewer_kit_json": display_path(output_json),
"reviewer_kit_markdown": display_path(output_md),
"reviewer_kit_html": display_path(output_html) if output_html else "",
"blind_pack_json": display_path(blind_pack_json),
"blind_pack_markdown": display_path(blind_pack_md),
"decisions": display_path(decisions_path),
"adjudication_json": "reports/output_review_adjudication.json",
"adjudication_markdown": "reports/output_review_adjudication.md",
},
"template_written": template_written,
"review_contract": build_contract(blind_pack_md, decisions_path),
"cases": cases,
"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")
if output_html:
output_html.parent.mkdir(parents=True, exist_ok=True)
output_html.write_text(render_html(payload), encoding="utf-8")
return payload
def main() -> None:
parser = argparse.ArgumentParser(description="Prepare a reviewer-facing blind A/B output review kit.")
parser.add_argument("--blind-pack-json", default=str(DEFAULT_BLIND_PACK_JSON))
parser.add_argument("--blind-pack-md", default=str(DEFAULT_BLIND_PACK_MD))
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("--output-html", default=str(DEFAULT_OUTPUT_HTML))
parser.add_argument("--write-template", action="store_true")
args = parser.parse_args()
payload = prepare_output_review_kit(
Path(args.blind_pack_json).resolve(),
Path(args.blind_pack_md).resolve(),
Path(args.decisions).resolve(),
Path(args.output_json).resolve(),
Path(args.output_md).resolve(),
Path(args.output_html).resolve() if args.output_html else None,
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()