Files
vscode-workbench/PLANNING/evals/tools/run_eval.py
T

842 lines
36 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
"""evals runner —— 把六维题库串成一次可复现的模型评估(标准库,零依赖)。
用法:
# 0) 题库自检:题面/满分/判分器/语料/fixture 一致性
python run_eval.py --audit
# 1) 只看题面清单(确认解析正确、且不含评分点)
python run_eval.py --list-questions
# 2) 干跑:产出将发送的 prompt 全文,不发网络请求
python run_eval.py --model glm-5.3-flash --transport dry-run
# 3) 真跑(需网关可达 + 环境变量里的 key)
set DSH_API_KEY=sk-xxx
python run_eval.py --model glm-5.3-flash --transport openai \
--base-url http://<dsh-gateway>/v1 --api-key-env DSH_API_KEY
# 4) 离线重放(用已录制的回答目录,answers/<qid>.txt)
python run_eval.py --model demo --transport replay --answers-dir ../fixtures/selftest/good
# 5) 评委补判后定稿(产出 grade)
python run_eval.py --model glm-5.3-flash --manual results/glm-5.3-flash-2026-09-12.manual.json
设计要点:
- 只发「给模型的题面」段;**评分点段永不进入 prompt**(解析时硬校验)。
- 自动判分复用 validate.py;人工项不自动给分,登记为 pending。
- 评估协议(evals/README.md):全新会话、逐题单发、每维 ≤10 分钟。
- 密钥只从环境变量读取,本脚本不读任何配置文件里的凭据。
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
import time
import urllib.error
import urllib.request
from datetime import date
from pathlib import Path
HERE = Path(__file__).resolve().parent
EVALS = HERE.parent # evals/
PLANNING = EVALS.parent # PLANNING/
RESULTS = EVALS / "results"
FIXTURE_SRC = EVALS / "fixtures" / "tooltask"
CORPUS = EVALS / "assets" / "longctx-corpus.md"
PROTOCOL = PLANNING / "03-执行协议.md"
SCORING = EVALS / "scoring.md"
REGISTRY = PLANNING / "model-registry.json"
sys.path.insert(0, str(HERE))
import validate as V # noqa: E402
DIM_FILES = {
"dim1": "dim1-instruction.md",
"dim2": "dim2-code.md",
"dim3": "dim3-context.md",
"dim4": "dim4-tools.md",
"dim5": "dim5-hallucination.md",
"dim6": "dim6-delivery.md",
}
# 维度满分(= scoring.md 的维度表,与题面文件的「(N 分)」声明交叉校验)
EXPECTED_DIM_MAX = {"dim1": 11, "dim2": 7, "dim3": 6, "dim4": 6, "dim5": 6, "dim6": 6}
# 各维题量:dim4/dim6 是 2 题(见 evals/README 目录说明与 I-04 卡的资产表),其余 3 题 → 共 16 题
EXPECTED_DIM_QTY = {"dim1": 3, "dim2": 3, "dim3": 3, "dim4": 2, "dim5": 3, "dim6": 2}
# 总分上限 = 维度表求和。注意:README/scoring.md/registry 的标题写「36 分」,
# 与维度表合计 42 不符 —— 这是规格内部的算术矛盾,登记为 SPEC-DEFECT-1,由规划者裁定;
# 本 runner 一律以题面文件为准(题面是唯一同时被评分点和判分器约束的源)。
EXPECTED_TOTAL_MAX = sum(EXPECTED_DIM_MAX.values()) # 42
SPEC_HEADLINE_TOTAL = 36 # scoring.md / README / registry 的标题数字
SPEC_DEFECTS = {
"SPEC-DEFECT-1": (
f"README §四 / evals/README §四 / scoring.md §一 称“满分 {SPEC_HEADLINE_TOTAL} 分”,"
f"但 scoring.md 维度表各行相加 = {EXPECTED_TOTAL_MAX},且与 16 道题面声明的分值合计一致。"
f"等级换算用的是百分比,故不影响评级;但绝对总分口径需规划者裁定后统一。"
),
}
DIM_TITLES = {
"dim1": "指令遵循", "dim2": "代码修改", "dim3": "长上下文",
"dim4": "工具调用", "dim5": "幻觉抵抗", "dim6": "交付规范",
}
HEAD_RE = re.compile(r"^##\s*Q(\d)\.(\d)\s*[·::]?\s*(.*)$", re.M)
POINTS_RE = re.compile(r"[((]\s*(\d+)\s*分\s*[))]")
API_KEY_HINT = "DSH_API_KEY"
# ------------------------------------------------------------------ 题面解析
class Question:
def __init__(self, qid: str, dim: str, title: str, declared: int | None, prompt: str, src: Path):
self.qid = qid
self.dim = dim
self.title = title.strip()
self.declared = declared
self.prompt = prompt.strip()
self.src = src
def __repr__(self) -> str: # pragma: no cover
return f"<Q {self.qid} {self.title!r} declared={self.declared}>"
def parse_question_file(path: Path) -> list[Question]:
"""从一份维度题面里抽出全部问题;只取「给模型的题面」围栏块。"""
text = path.read_text(encoding="utf-8")
marks = list(HEAD_RE.finditer(text))
out: list[Question] = []
for i, m in enumerate(marks):
dim_no, q_no = m.group(1), m.group(2)
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
block = text[m.start():end]
qid = f"dim{dim_no}-q{q_no}"
prompt = _extract_prompt(block, path.name, qid)
out.append(Question(qid, f"dim{dim_no}", m.group(3), _declared_points(m.group(3)),
prompt, path))
return out
def _declared_points(title: str) -> int | None:
m = POINTS_RE.search(title)
return int(m.group(1)) if m else None
def _extract_prompt(block: str, fname: str, qid: str) -> str:
seg = re.search(r"###\s*给模型的题面\s*\n(.*?)(?=\n###|\Z)", block, re.S)
if not seg:
raise ValueError(f"{fname} {qid}: 找不到「给模型的题面」段")
fences = re.findall(r"```[a-zA-Z0-9_+-]*\n(.*?)```", seg.group(1), re.S)
if not fences:
raise ValueError(f"{fname} {qid}: 「给模型的题面」段里没有围栏块")
prompt = fences[0].strip()
# 硬校验:评分点绝不允许进入 prompt
for banned in ("评分点", "勿发给被测模型", "判定方式", "自动(", "人工/评委"):
if banned in prompt:
raise ValueError(f"{fname} {qid}: prompt 含禁用内容 {banned!r}")
return prompt
def load_questions() -> list[Question]:
qs: list[Question] = []
for dim, fname in DIM_FILES.items():
qs.extend(parse_question_file(EVALS / "questions" / fname))
return qs
# ------------------------------------------------------------------ 传输层
class TransportError(RuntimeError):
pass
class DryRunTransport:
name = "dry-run"
def ask(self, qid, messages, model, timeout) -> tuple[str, dict]:
return "", {"note": "dry-run:未发送请求"}
class ReplayTransport:
"""离线重放:从目录读 <qid>.txt|md 作为「模型回答」。"""
name = "replay"
def __init__(self, answers_dir: Path):
self.dir = answers_dir
def ask(self, qid, messages, model, timeout) -> tuple[str, dict]:
for ext in (".txt", ".md"):
f = self.dir / f"{qid}{ext}"
if f.exists():
return f.read_text(encoding="utf-8"), {"note": f"replay:{f.name}"}
raise TransportError(f"replay 目录里没有 {qid}.txt|.md —— {self.dir}")
class OpenAITransport:
"""OpenAI 兼容 /chat/completions。密钥只从环境变量取,绝不落盘、绝不打印。"""
name = "openai"
def __init__(self, base_url: str, api_key: str, temperature: float | None = None,
max_tokens: int | None = None, retries: int = 2):
self.url = base_url.rstrip("/") + "/chat/completions"
self.key = api_key
self.temperature = temperature
self.max_tokens = max_tokens
self.retries = retries
self.usage: list[dict] = []
def ask(self, qid, messages, model, timeout) -> tuple[str, dict]:
body: dict = {"model": model, "messages": messages}
if self.temperature is not None:
body["temperature"] = self.temperature
if self.max_tokens:
body["max_tokens"] = self.max_tokens
data = json.dumps(body).encode("utf-8")
last = ""
for attempt in range(self.retries + 1):
req = urllib.request.Request(
self.url, data=data, method="POST",
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {self.key}"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode("utf-8", "replace"))
text = _pick_content(payload)
meta = {"note": "openai", "usage": payload.get("usage") or {}}
self.usage.append({"qid": qid, **meta["usage"]})
return text, meta
except urllib.error.HTTPError as e:
detail = _safe_err(e)
last = f"HTTP {e.code}: {detail}"
if e.code in (408, 409, 429, 500, 502, 503, 504) and attempt < self.retries:
time.sleep(2 * (attempt + 1))
continue
raise TransportError(last) from None
except Exception as e: # noqa: BLE001
last = f"{type(e).__name__}: {e}"
if attempt < self.retries:
time.sleep(2 * (attempt + 1))
continue
raise TransportError(last) from None
raise TransportError(last)
def _pick_content(payload: dict) -> str:
try:
msg = payload["choices"][0]["message"]
except Exception: # noqa: BLE001
raise TransportError(f"响应结构异常:{json.dumps(payload, ensure_ascii=False)[:300]}") from None
content = msg.get("content")
if isinstance(content, list): # 多段 content
content = "".join(seg.get("text", "") for seg in content if isinstance(seg, dict))
return (content or "").strip()
def _safe_err(e: urllib.error.HTTPError) -> str:
try:
raw = e.read().decode("utf-8", "replace")
except Exception: # noqa: BLE001
return "(无法读取响应体)"
raw = re.sub(r"(sk-|Bearer\s+)[A-Za-z0-9._\-]{6,}", r"\1<redacted>", raw)
return raw[:300]
# ------------------------------------------------------------------ prompt 组装
def progress_template() -> str:
"""抽 03-执行协议.md 的 PROGRESS 章节,作为 dim6-q1 的题面附件。"""
if not PROTOCOL.exists():
return ""
text = PROTOCOL.read_text(encoding="utf-8")
blocks = [b for b in re.split(r"\n(?=## )", text) if b.startswith("## 二、PROGRESS")]
return blocks[0].strip() if blocks else ""
def build_messages(q: Question, workdir: Path | None, corpus: str) -> list[dict]:
prompt = q.prompt
if q.qid in ("dim4-q1", "dim4-q2"):
if workdir is None:
raise TransportError("dim4 需要副本目录")
prompt = prompt.replace("<fixtures 副本路径>", str(workdir))
prompt = prompt.replace("<fixtures 副本路径>", str(workdir))
if q.qid == "dim3-q1":
pass # 语料单独给
if q.qid == "dim6-q1":
tpl = progress_template()
if tpl:
prompt += "\n\n(以下是 PLANNING/03-执行协议.md 中规定的 PROGRESS 格式章节)\n\n" + tpl
if q.dim == "dim3":
return [
{"role": "user", "content": "这是一份运维文档,请先阅读,随后我会提问。\n\n" + corpus},
{"role": "assistant", "content": "已读完这份文档,请提问。"},
{"role": "user", "content": prompt},
]
return [{"role": "user", "content": prompt}]
# ------------------------------------------------------------------ 判分汇总
def _pct(got: int, mx: int) -> float:
return (got / mx) if mx else 0.0
def compute_grade(total_got: int, total_max: int, dim_scores: dict, veto_flags: list[str]):
if veto_flags:
return "C", "硬否决:" + ";".join(veto_flags)
pct = _pct(total_got, total_max)
d2 = _pct(dim_scores.get("dim2", {}).get("got", 0), dim_scores.get("dim2", {}).get("max", 1))
d4 = _pct(dim_scores.get("dim4", {}).get("got", 0), dim_scores.get("dim4", {}).get("max", 1))
if pct >= 0.90 and d2 >= 0.90 and d4 >= 0.90:
return "S", f"总分 {pct:.0%},dim2 {d2:.0%} / dim4 {d4:.0%}"
if pct >= 0.75:
return "A", f"总分 {pct:.0%}"
if pct >= 0.60:
return "B", f"总分 {pct:.0%}"
return "C", f"总分 {pct:.0%} < 60%"
def score_answers(answers: dict[str, str], questions: list[Question],
workdir: Path | None) -> tuple[dict, dict, list[dict]]:
per_q: dict[str, dict] = {}
dim_scores: dict[str, dict] = {}
pending: list[dict] = []
for q in questions:
ans = answers.get(q.qid)
if ans is None:
continue
rep = V.score_report(q.qid, ans, workdir=str(workdir) if q.qid.startswith("dim4") else None)
per_q[q.qid] = rep
d = dim_scores.setdefault(q.dim, {"got": 0, "max": 0, "pending": 0})
d["got"] += rep["auto_got"]
d["max"] += rep["max"]
d["pending"] += rep["manual_pending"]
for item in rep["manual_items"]:
pending.append({"qid": q.qid, **item})
return per_q, dim_scores, pending
# ------------------------------------------------------------------ 主流程
def sanitize(model: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]", "_", model)
def prepare_workdir(model: str, reuse: bool) -> Path:
wd = RESULTS / "work" / sanitize(model) / "tooltask"
if wd.exists() and reuse:
return wd
if wd.exists():
shutil.rmtree(wd)
wd.mkdir(parents=True)
for f in ("calc.py", "test_calc.py"):
src = FIXTURE_SRC / f
if src.exists():
shutil.copy2(src, wd / f)
return wd
def run_eval(args) -> int:
questions = load_questions()
if args.only:
# 同时接受维度(dim1)与题号(dim1-q1),避免静默选空
sel = {s.strip() for s in args.only.split(",") if s.strip()}
unknown = {s for s in sel if s not in DIM_FILES and not re.fullmatch(r"dim\d-q\d", s)}
if unknown:
print(f"--only 里有无法识别的选择器:{sorted(unknown)}")
print("可用:" + "、".join(DIM_FILES) + " 或 dim1-q1 形式")
return 1
questions = [q for q in questions if q.dim in sel or q.qid in sel]
if args.questions:
want = {s.strip() for s in args.questions.split(",") if s.strip()}
unknown = {s for s in want if not re.fullmatch(r"dim\d-q\d", s)}
if unknown:
print(f"--questions 里有无法识别的题号:{sorted(unknown)}")
return 1
questions = [q for q in questions if q.qid in want]
if not questions:
print("选中的题目为空 —— 检查 --only / --questions 的选择器")
return 1
if args.list_questions:
print(f"{'QID':<10} {'满分':<4} 标题")
print("-" * 70)
for q in questions:
print(f"{q.qid:<10} {str(q.declared or '?'):<4} {q.title}")
print("-" * 70)
print(f"共 {len(questions)} 题;题面长度合计 "
f"{sum(len(q.prompt) for q in questions)} 字符")
return 0
if args.audit:
return run_audit(questions)
if args.manual:
return merge_manual(Path(args.manual))
model = args.model
if not model:
print("需要 --model(或 --audit / --list-questions / --manual)")
return 1
RESULTS.mkdir(parents=True, exist_ok=True)
corpus = CORPUS.read_text(encoding="utf-8") if any(q.dim == "dim3" for q in questions) else ""
if not any(q.dim == "dim4" for q in questions):
workdir = None
elif args.workdir:
workdir = Path(args.workdir).resolve()
if not (workdir / "calc.py").exists():
print(f"--workdir 里没有 calc.py:{workdir}")
return 1
else:
workdir = prepare_workdir(model, args.reuse_workdir)
# ---- 传输层选择
if args.transport == "dry-run":
transport = DryRunTransport()
if args.api_key_from_env:
print(f"[WARN] dry-run 不需要密钥,忽略 --api-key-env {args.api_key_from_env}")
elif args.transport == "replay":
if not args.answers_dir:
print("--transport replay 需要 --answers-dir")
return 1
transport = ReplayTransport(Path(args.answers_dir))
else:
base_url = args.base_url or _config_get(args, "base_url")
if not base_url:
print("--transport openai 需要 --base-url(或在 eval-config.json 里配 base_url)")
return 1
env_name = args.api_key_env or _config_get(args, "api_key_env") or API_KEY_HINT
import os
key = os.environ.get(env_name, "")
if not key:
print(f"环境变量 {env_name} 未设置 —— 密钥只从环境变量读取(本工具不读配置文件里的凭据)")
print(f" PowerShell: $env:{env_name}='<your-key>'")
print(f" bash: export {env_name}='<your-key>'")
return 1
transport = OpenAITransport(base_url, key,
temperature=args.temperature,
max_tokens=args.max_tokens)
stamp = date.today().isoformat()
raw_dir = RESULTS / "raw" / sanitize(model)
raw_dir.mkdir(parents=True, exist_ok=True)
print(f"模型={model} 传输={transport.name} 题目={len(questions)} "
f"超时={args.timeout}s 分数上限={sum(q.declared or 0 for q in questions)}")
answers: dict[str, str] = {}
sent: dict[str, str] = {}
for q in questions:
try:
messages = build_messages(q, workdir, corpus)
except TransportError as e:
print(f" [SKIP] {q.qid}: {e}")
continue
sent[q.qid] = messages[-1]["content"]
if args.transport == "dry-run":
(raw_dir / f"{q.qid}.prompt.md").write_text(
"\n\n=== role boundary ===\n\n".join(
f"[{m['role']}]\n{m['content']}" for m in messages), encoding="utf-8")
print(f" [DRY ] {q.qid} prompt={len(messages[-1]['content'])} 字符 → {q.qid}.prompt.md")
continue
t0 = time.time()
try:
text, meta = transport.ask(q.qid, messages, model, args.timeout)
except TransportError as e:
print(f" [FAIL] {q.qid}: {e}")
answers[q.qid] = ""
(raw_dir / f"{q.qid}.error.txt").write_text(str(e), encoding="utf-8")
continue
dt = time.time() - t0
answers[q.qid] = text
(raw_dir / f"{q.qid}.txt").write_text(text, encoding="utf-8")
note = meta.get("note", "")
print(f" [ OK ] {q.qid} {dt:5.1f}s {len(text):6d} 字符 {note}")
if args.transport == "dry-run":
print(f"\n干跑完成:prompt 已写入 {raw_dir}")
return 0
got_any = {k: v for k, v in answers.items() if v.strip()}
if not got_any:
print("\n全部题目都没有拿到回答(网关不可达 / 凭据无效 / 题目全被跳过)——"
"不产出结果文件,避免留下 0/0 的空成绩。")
return 1
per_q, dim_scores, pending = score_answers(answers, questions, workdir)
auto_got = sum(v["auto_got"] for v in per_q.values())
total_max = sum(v["max"] for v in per_q.values())
total_pending = sum(v["manual_pending"] for v in per_q.values())
dim5 = dim_scores.get("dim5", {"got": 0, "max": 0})
veto_hint: list[str] = []
if dim5["max"] and _pct(dim5["got"], dim5["max"]) < 0.5:
veto_hint.append(f"dim5 自动项 {dim5['got']}/{dim5['max']} <50%(待人工复核后裁定)")
if any(v == "" for v in answers.values()):
failed = [k for k, v in answers.items() if v == ""]
veto_hint.append(f"存在无回答题:{failed}")
grade, basis = (None, "待评委补判人工项")
if total_pending == 0:
grade, basis = compute_grade(auto_got, total_max, dim_scores, [])
payload = {
"model": model,
"group": args.group or _registry_group(model),
"date": stamp,
"runner": "run_eval.py",
"transport": transport.name,
"base_url": (args.base_url or _config_get(args, "base_url") or None) if transport.name == "openai" else None,
"scores": {d: {"got": s["got"], "max": s["max"],
"manual_pending": s["pending"],
"notes": _dim_notes(per_q, d)}
for d, s in sorted(dim_scores.items())},
"per_question": {q: {"auto_got": r["auto_got"], "max": r["max"],
"manual_pending": r["manual_pending"], "logs": r["logs"]}
for q, r in sorted(per_q.items())},
"total": {"auto_got": auto_got, "manual_pending": total_pending,
"max": total_max, "got": auto_got if total_pending == 0 else None},
"hard_veto": bool(veto_hint),
"hard_veto_hint": veto_hint,
"grade": grade,
"grade_basis": basis,
"transcript": f"evals/results/{sanitize(model)}-{stamp}.log.md",
"score_scale": {"expected_total_max": EXPECTED_TOTAL_MAX,
"expected_dims": EXPECTED_DIM_MAX},
}
out_json = RESULTS / f"{sanitize(model)}-{stamp}.json"
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
_write_transcript(RESULTS / f"{sanitize(model)}-{stamp}.log.md", model, stamp, questions,
answers, per_q, transport, sent)
_write_report(RESULTS / f"{sanitize(model)}-{stamp}.md", payload)
if total_pending:
tpl = RESULTS / f"{sanitize(model)}-{stamp}.manual.template.json"
tpl.write_text(json.dumps({
"model": model, "judge": "", "date": stamp,
"items": {p["qid"]: None for p in pending},
"criteria": [{"qid": p["qid"], "points": p["points"], "criterion": p["criterion"]}
for p in pending],
"veto_flags": [],
"notes": {},
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"\n人工项待补判 {total_pending} 分 → 模板:{tpl}")
print(f"补判后执行:python run_eval.py --manual {tpl.name}")
print(f"\n自动项 {auto_got}/{total_max}(待评委 {total_pending} 分)")
for d, s in sorted(dim_scores.items()):
flag = f" 待人工 {s['pending']}" if s["pending"] else ""
print(f" {d} {DIM_TITLES[d]:<6} {s['got']:>2}/{s['max']:<2}{flag}")
print(f"\n结果:{out_json}")
if veto_hint:
print("[硬否决提示] " + ";".join(veto_hint))
return 0
def _dim_notes(per_q: dict, dim: str) -> str:
bits = []
for qid, rep in per_q.items():
if qid.startswith(dim + "-") and rep["auto_got"] < rep["max"]:
failed = [ln for ln in rep["logs"] if ln.startswith("[FAIL]")]
if failed:
bits.append(f"{qid}: " + failed[0][7:][:60])
return ";".join(bits)
def _write_transcript(path: Path, model: str, stamp: str, questions: list[Question],
answers: dict[str, str], per_q: dict, transport,
sent: dict[str, str]) -> None:
lines = [f"# 评估 transcript — {model}({stamp})",
"", f"- 传输:`{transport.name}`", f"- 题目数:{len(answers)}",
"- 协议:全新会话 / 逐题单发 / 不预告下一题(见 evals/README.md)", "", "---", ""]
for q in questions:
if q.qid not in answers:
continue
lines += [f"## {q.qid} · {q.title}", "",
"### 题面(实际发送,末条 user 消息)", "", "```text",
sent.get(q.qid, ""), "```", "",
"### 模型回答", "", "```text", answers[q.qid], "```", "",
"### 自动判分", ""]
for ln in per_q.get(q.qid, {}).get("logs", []):
lines.append(f"- {ln}")
lines.append("")
path.write_text("\n".join(lines), encoding="utf-8")
def _write_report(path: Path, payload: dict) -> None:
t = payload["total"]
lines = [f"# 评估报告 — {payload['model']}({payload['date']})", "",
f"- 传输:`{payload['transport']}`",
f"- 自动得分:**{t['auto_got']}/{t['max']}**,待评委:**{t['manual_pending']}**",
f"- 等级:**{payload['grade'] or '待评委补判'}**({payload['grade_basis']})", "",
"| 维度 | 自动 | 满分 | 待人工 |", "|---|---|---|---|"]
for d, s in sorted(payload["scores"].items()):
lines.append(f"| {d} {DIM_TITLES.get(d, '')} | {s['got']} | {s['max']} | {s['manual_pending']} |")
lines += ["", "## 逐题", "", "| 题 | 自动 | 满分 | 待人工 |", "|---|---|---|---|"]
for q, r in sorted(payload["per_question"].items()):
lines.append(f"| {q} | {r['auto_got']} | {r['max']} | {r['manual_pending']} |")
if payload["hard_veto_hint"]:
lines += ["", "## 硬否决提示", ""] + [f"- {x}" for x in payload["hard_veto_hint"]]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
# ------------------------------------------------------------------ 评委补判
def merge_manual(manual_path: Path) -> int:
m = json.loads(manual_path.read_text(encoding="utf-8"))
model = m.get("model")
if not model:
print("manual 文件缺 model 字段")
return 1
# 排除评委产物(.manual.json / .manual.template.json),只留 runner 主结果
cands = sorted(c for c in RESULTS.glob(f"{sanitize(model)}-*.json") if ".manual" not in c.name)
if not cands:
print(f"找不到 {model} 的评估结果 json")
return 1
payload = json.loads(cands[-1].read_text(encoding="utf-8"))
items = m.get("items") or {}
if any(v is None for v in items.values()):
print("manual.items 里还有 null —— 补判未完成")
return 1
# 校验不超过各题待判分值
cap: dict[str, int] = {}
for qid, rep in payload["per_question"].items():
cap[qid] = rep["manual_pending"]
for qid, val in items.items():
if qid not in cap:
print(f"manual.items 有未知题号 {qid}")
return 1
if val > cap[qid]:
print(f"{qid} 人工给分 {val} 超过待判上限 {cap[qid]}")
return 1
if sum(items.values()) < sum(cap.values()):
missing = [q for q, c in cap.items() if items.get(q) is None]
print(f"仍有未判题:{missing}")
return 1
manual_got = sum(items.values())
for qid, rep in payload["per_question"].items():
rep["manual_got"] = items.get(qid, 0)
for d, s in payload["scores"].items():
s["manual_got"] = sum(items.get(q, 0) for q in payload["scores"] and
[x for x in payload["per_question"] if x.startswith(d + "-")])
s["got_total"] = s["got"] + s["manual_got"]
veto = m.get("veto_flags") or []
total_max = payload["total"]["max"]
got = payload["total"]["auto_got"] + manual_got
grade, basis = compute_grade(got, total_max, {
d: {"got": payload["scores"][d]["got_total"], "max": payload["scores"][d]["max"]}
for d in payload["scores"]}, veto)
payload["total"].update({"got": got, "manual_got": manual_got, "manual_pending": 0})
payload["grade"] = grade
payload["grade_basis"] = basis
payload["hard_veto"] = bool(veto)
payload["hard_veto_flags"] = veto
payload["judge"] = {"name": m.get("judge"), "date": m.get("date"), "notes": m.get("notes")}
out = cands[-1]
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
_write_report(out.with_suffix(".md"), payload)
print(f"已定稿:{out}")
print(f"总分 {got}/{total_max}(自动 {payload['total']['auto_got']} + 人工 {manual_got})"
f" → 等级 {grade}({basis})")
print("提醒:成绩回填 PLANNING/model-registry.json 由规划者执行,本工具不自动写注册表。")
return 0
# ------------------------------------------------------------------ 题库自检
def _parse_scoring_md() -> dict[str, tuple[int, int]]:
"""从 scoring.md 的维度表里读出 {dim: (题数, 满分)}。"""
if not SCORING.exists():
return {}
out: dict[str, tuple[int, int]] = {}
for ln in SCORING.read_text(encoding="utf-8").splitlines():
m = re.match(r"\|\s*(dim\d)\s*[^|]*\|\s*(\d+)\s*\|\s*(\d+)\s*\|", ln)
if m:
out[m.group(1)] = (int(m.group(2)), int(m.group(3)))
return out
def run_audit(questions: list[Question]) -> int:
checks: list[tuple[str, bool, str]] = []
def add(name: str, ok: bool, detail: str = "") -> None:
checks.append((name, ok, detail))
# 1 题面解析 + 声明满分
by_dim: dict[str, list[Question]] = {}
for q in questions:
by_dim.setdefault(q.dim, []).append(q)
add(f"题量合计 16", len(questions) == sum(EXPECTED_DIM_QTY.values()),
f"实际 {len(questions)} 题")
for dim, qty in EXPECTED_DIM_QTY.items():
qs = by_dim.get(dim, [])
got = sum(q.declared or 0 for q in qs)
add(f"{dim} 题量 {qty}", len(qs) == qty, f"实际 {len(qs)} 题")
add(f"{dim} 满分 {EXPECTED_DIM_MAX[dim]}", got == EXPECTED_DIM_MAX[dim],
f"题面声明合计 {got}")
total_declared = sum(q.declared or 0 for q in questions)
add(f"总满分 {EXPECTED_TOTAL_MAX}(维度表求和)", total_declared == EXPECTED_TOTAL_MAX,
f"题面声明合计 {total_declared}")
# 2 与 scoring.md 对照
sm = _parse_scoring_md()
add("scoring.md 可解析", len(sm) == 6, f"解析到 {len(sm)} 行维度")
for dim, (n, mx) in sm.items():
add(f"scoring.md {dim} 满分 {mx} 与题面一致",
mx == sum(q.declared or 0 for q in by_dim.get(dim, [])),
f"题面 {sum(q.declared or 0 for q in by_dim.get(dim, []))}")
# 规格已知缺陷:标题分 ≠ 维度表合计。不静默、不改规格,登记为 FAIL 提请裁定。
add(f"[规格缺陷 SPEC-DEFECT-1] 标题分 {SPEC_HEADLINE_TOTAL} 与维度表合计 {EXPECTED_TOTAL_MAX} 不一致",
False, SPEC_DEFECTS["SPEC-DEFECT-1"])
# 3 判分器覆盖 + 自动/人工配平
for q in questions:
has = q.qid in V.CHECKERS
rep_max = None
if has:
dummy = "" if q.qid != "dim4-q1" else ""
rep = V.score_report(q.qid, dummy, workdir=None)
rep_max = rep["max"]
add(f"{q.qid} 有自动判分器", has, "" if has else "缺 checker")
if has and q.declared is not None:
pending = sum(p for p, _ in V.MANUAL_POINTS.get(q.qid, []))
add(f"{q.qid} 自动+人工={q.declared}",
rep_max == q.declared and rep_max == (q.declared - pending) + pending,
f"checker.max={rep_max} 声明={q.declared} 人工登记={pending}")
# 4 语料
if CORPUS.exists():
t = CORPUS.read_text(encoding="utf-8")
add("语料 51,483 字符", len(t) == 51483, f"实际 {len(t)}")
for name, key, lo, hi in [("针 XN-7742", "XN-7742", 0, 0.10),
("针 300 秒", "300 秒", 0.45, 0.55),
("针 NSM-7(末次)", "NSM-7", 0.88, 0.96),
("诱饵 周四01:00", "周四 01:00", 0.15, 0.25),
("诱饵 86400", "86400", 0.30, 0.40)]:
idx = t.rfind(key) if name.endswith("(末次)") else t.find(key)
pct = idx / len(t) if idx >= 0 else -1
add(f"{name} 位置 {lo:.0%}–{hi:.0%}", 0 <= idx and lo <= pct <= hi,
f"实测 {pct:.2%}" if idx >= 0 else "未找到")
else:
add("语料存在", False, str(CORPUS))
# 5 fixture 基线
import subprocess
import tempfile
if FIXTURE_SRC.exists():
with tempfile.TemporaryDirectory() as td:
for f in ("calc.py", "test_calc.py"):
shutil.copy2(FIXTURE_SRC / f, Path(td) / f)
p = subprocess.run([sys.executable, "-m", "unittest", "-v"], cwd=td,
capture_output=True, text=True, timeout=90)
out = p.stdout + p.stderr
m = re.search(r"Ran (\d+) tests", out)
f_m = re.search(r"failures=(\d+)", out)
e_m = re.search(r"errors=(\d+)", out)
add("fixture 基线 9 用例", bool(m) and m.group(1) == "9",
f"Ran={m.group(1) if m else '?'}")
add("fixture 基线 failures=2, errors=2",
bool(f_m) and bool(e_m) and f_m.group(1) == "2" and e_m.group(1) == "2",
f"failures={f_m.group(1) if f_m else '?'} errors={e_m.group(1) if e_m else '?'}")
else:
add("fixture 存在", False, str(FIXTURE_SRC))
# 6 registry 刻度一致
if REGISTRY.exists():
reg = json.loads(REGISTRY.read_text(encoding="utf-8"))
scale = reg.get("scale", {})
add("registry total_max 与规格缺陷一致(应为待裁定的 36)",
scale.get("total_max") == SPEC_HEADLINE_TOTAL,
f"实际 {scale.get('total_max')};裁定后需与 scoring.md 一并改口径")
add("registry dims 与 scoring 一致", scale.get("dims") == EXPECTED_DIM_MAX,
f"实际 {json.dumps(scale.get('dims'), ensure_ascii=False)}")
add("registry 覆盖 13 模型", len(reg.get("models", [])) >= 13,
f"实际 {len(reg.get('models', []))}")
else:
add("model-registry.json 存在", False, str(REGISTRY))
# 7 题面不含评分点
for q in questions:
add(f"{q.qid} 题面不含评分点",
"评分点" not in q.prompt and "判定方式" not in q.prompt, "")
hard = [c for c in checks if "SPEC-DEFECT" not in c[0] and not c[0].startswith("[规格缺陷")]
known = [c for c in checks if c not in hard]
ok = sum(1 for _, o, _ in hard if o)
print(f"{'检查项':<50} 结果")
print("-" * 90)
for name, o, detail in checks:
mark = "[PASS]" if o else ("[KNOWN]" if name in [k[0] for k in known] else "[FAIL]")
print(f"{mark} {name:<50} {detail}")
print("-" * 90)
print(f"AUDIT: {ok}/{len(hard)} structural checks passed"
f"(另有 {len(known)} 项已登记规格缺陷,需规划者裁定)")
return 0 if ok == len(hard) else 2
# ------------------------------------------------------------------ 配置读取
def _load_config() -> dict:
f = HERE / "eval-config.json"
if f.exists():
try:
return json.loads(f.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return {}
return {}
def _config_get(args, field: str):
cfg = _load_config()
model_cfg = (cfg.get("models") or {}).get(args.model or "", {}) or {}
return model_cfg.get(field) or cfg.get(field)
def _registry_group(model: str) -> str | None:
if not REGISTRY.exists():
return None
try:
reg = json.loads(REGISTRY.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return None
for m in reg.get("models", []):
if m.get("id") == model:
g = m.get("groups") or []
return g[0] if g else None
return None
def main() -> int:
ap = argparse.ArgumentParser(description="evals runner(六维评估流水线)")
ap.add_argument("--model")
ap.add_argument("--group", default=None)
ap.add_argument("--transport", choices=["dry-run", "openai", "replay"], default="dry-run")
ap.add_argument("--base-url", default=None)
ap.add_argument("--api-key-env", default=None)
ap.add_argument("--api-key-from-env", action="store_true",
help="显式声明「密钥只从环境变量来」")
ap.add_argument("--answers-dir", default=None)
ap.add_argument("--temperature", type=float, default=None)
ap.add_argument("--max-tokens", type=int, default=None)
ap.add_argument("--timeout", type=int, default=600, help="单题超时秒数(协议:每维 ≤10 分钟)")
ap.add_argument("--only", default=None, help="只跑某些维度,如 dim1,dim3")
ap.add_argument("--questions", default=None, help="只跑某些题,如 dim1-q1,dim4-q2")
ap.add_argument("--reuse-workdir", action="store_true", help="dim4 复用上次的副本目录")
ap.add_argument("--workdir", default=None, help="dim4 指定副本目录(自检/复核用)")
ap.add_argument("--list-questions", action="store_true")
ap.add_argument("--audit", action="store_true")
ap.add_argument("--manual", default=None, help="评委补判结果 json,合并后定稿等级")
args = ap.parse_args()
return run_eval(args)
if __name__ == "__main__":
sys.exit(main())