Files
playbook/cangjie-skill/scripts/run_output_evals.py
T
2026-08-31 05:00:46 +08:00

128 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""run_output_evals.py — 输出评测的确定性环节(Phase 3,路线 C)。
prepare 为每条 output case 生成 old/new/without 三个匿名任务包(盲测:包名随机化标签,
映射表单独存放,不给评审者)
score 对记录的输出跑机械断言(contains/not_contains/regex/file_exists/json_path),
机械断言先于 LLM judge;盲评分歧样本留给人工复核
outputs 目录约定: <outputs>/<case_id>/<variant-label>.mdvariant-label 来自 prepare 的映射表)
用法:
python3 scripts/run_output_evals.py prepare <suite.json> --out <dir> [--variants old_skill,new_skill,without_skill]
python3 scripts/run_output_evals.py score <suite.json> --outputs <dir> --mapping <mapping.json> --out <report.md>
"""
from __future__ import annotations
import argparse
import json
import random
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cangjie_common import dump_json, load_json # noqa: E402
def check_assertion(a: dict, text: str, base: Path) -> bool:
kind, value = a["kind"], a["value"]
if kind == "contains":
return value in text
if kind == "not_contains":
return value not in text
if kind == "regex":
return re.search(value, text) is not None
if kind == "file_exists":
return (base / value).exists()
if kind == "json_path": # 简化版: 顶层 key 存在于输出中的 JSON 块
try:
data = json.loads(re.search(r"\{.*\}", text, re.S).group(0))
except Exception:
return False
cur = data
for part in value.lstrip("$.").split("."):
if not isinstance(cur, dict) or part not in cur:
return False
cur = cur[part]
return True
raise ValueError(f"未知断言类型: {kind}")
def cmd_prepare(suite_path: Path, out_dir: Path, variants: list[str]) -> int:
suite = load_json(suite_path)
rng = random.Random(suite.get("split_seed", 42))
out_dir.mkdir(parents=True, exist_ok=True)
mapping: dict[str, dict[str, str]] = {}
for c in suite.get("output_cases", []):
labels = [f"v{chr(65 + i)}" for i in range(len(variants))]
rng.shuffle(labels)
mapping[c["case_id"]] = dict(zip(labels, variants))
case_dir = out_dir / c["case_id"]
case_dir.mkdir(exist_ok=True)
for label in labels:
dump_json(case_dir / f"task-{label}.json", {
"case_id": c["case_id"], "variant_label": label, "prompt": c["prompt"],
"input_files": c.get("input_files", []),
"instruction": "按 prompt 完成任务,输出保存为同目录 <label>.md。评审者不知道你是哪个版本。",
})
dump_json(out_dir / "mapping.json", mapping)
print(f"已生成 {len(mapping)} 条 output 任务(每条 {len(variants)} 个匿名变体)→ {out_dir}\n"
f"映射表 {out_dir / 'mapping.json'} 不要给评审 sub-agent。")
return 0
def cmd_score(suite_path: Path, outputs: Path, mapping_path: Path, out_path: Path) -> int:
suite = load_json(suite_path)
mapping = load_json(mapping_path)
lines = [f"# 输出评测机械断言判分 — {suite['target']}", ""]
totals: dict[str, list[int]] = {}
for c in suite.get("output_cases", []):
case_map = mapping.get(c["case_id"], {})
lines.append(f"## {c['case_id']}\n")
lines.append("| 变体 | 断言通过 | 明细 |")
lines.append("|---|---|---|")
for label, variant in sorted(case_map.items()):
f = outputs / c["case_id"] / f"{label}.md"
if not f.exists():
lines.append(f"| {variant} | — | 输出缺失: {f.name} |")
continue
text = f.read_text(encoding="utf-8")
results = [(a, check_assertion(a, text, f.parent)) for a in c["assertions"]]
passed = sum(1 for _, ok in results if ok)
detail = "; ".join(f"{'✓' if ok else '✗'}{a['kind']}:{a['value'][:24]}" for a, ok in results)
lines.append(f"| {variant} | {passed}/{len(results)} | {detail} |")
totals.setdefault(variant, []).append(int(passed == len(results)))
lines.append("")
lines.append("## 汇总(全部断言通过的 case 比例)\n")
for variant, arr in sorted(totals.items()):
lines.append(f"- {variant}: {sum(arr)}/{len(arr)}")
lines.append("\n> 机械断言先于 LLM judge;A/B 盲评与分歧样本人工复核另行进行,此处不自动宣布非劣。")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print("\n".join(lines))
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
sub = ap.add_subparsers(dest="mode", required=True)
p = sub.add_parser("prepare")
p.add_argument("suite")
p.add_argument("--out", required=True)
p.add_argument("--variants", default="old_skill,new_skill,without_skill")
c = sub.add_parser("score")
c.add_argument("suite")
c.add_argument("--outputs", required=True)
c.add_argument("--mapping", required=True)
c.add_argument("--out", required=True)
args = ap.parse_args()
if args.mode == "prepare":
return cmd_prepare(Path(args.suite), Path(args.out), args.variants.split(","))
return cmd_score(Path(args.suite), Path(args.outputs), Path(args.mapping), Path(args.out))
if __name__ == "__main__":
raise SystemExit(main())