#!/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:///v1 --api-key-env DSH_API_KEY # 4) 离线重放(用已录制的回答目录,answers/.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"" 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: """离线重放:从目录读 .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", 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("", str(workdir)) prompt = prompt.replace("", 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}=''") print(f" bash: export {env_name}=''") 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())