589 lines
25 KiB
Python
589 lines
25 KiB
Python
"""evals 自动化判分工具 —— 对被测模型的回答做可自动化的检查。
|
||
|
||
用法:
|
||
python validate.py dim1-q1 <answer_file> # JSON 规范检查
|
||
python validate.py dim1-q2 <answer_file> # 汉字计数检查
|
||
python validate.py dim1-q3 <answer_file> # commit message 行格式检查
|
||
python validate.py dim2-q1 <answer_file> # Python average 规格检查
|
||
python validate.py dim2-q2 <answer_file> # JS sum 规格检查(需 node)
|
||
python validate.py dim2-q3 <answer_file> # Python parse 规格检查
|
||
python validate.py dim3-q2 <answer_file> # 长上下文关键词辅助检查
|
||
python validate.py dim4-q1 <answer_file> --workdir <文件:副本目录> # fixture 复核
|
||
python validate.py dim4-q2 <answer_file> --workdir <副本目录> # stats.py 复核
|
||
python validate.py dim5-q2 <answer_file> # 幻觉抵抗关键词辅助检查
|
||
python validate.py dim6-q1 <answer_file> # PROGRESS 结构检查
|
||
|
||
--workdir DIR dim4 专用:模型的工作副本目录(复核者手上那份)
|
||
--json 以 JSON 输出(供 run_eval.py 消费)
|
||
|
||
输出:每项一行 `[PASS]/[FAIL] 说明`,末尾一行总分 `SCORE: x/y`(--json 时改为整段 JSON)。
|
||
约定:SCORE 的分母是该问**满分**,其中自动项拿不到的分数登记在 MANUAL_POINTS,
|
||
由复核者/评委补判 —— 本工具不做自动扣分式评分。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import inspect
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
|
||
def _read(p: str) -> str:
|
||
return Path(p).read_text(encoding="utf-8")
|
||
|
||
|
||
def check_dim1_q1(ans: str) -> tuple[int, int, list[str]]:
|
||
logs: list[str] = []
|
||
score = 0
|
||
got_max = 5
|
||
stripped = ans.strip()
|
||
# 1 可解析
|
||
try:
|
||
data = json.loads(stripped)
|
||
score += 1
|
||
logs.append("[PASS] 整段可解析为 JSON")
|
||
except Exception as e: # noqa: BLE001
|
||
logs.append(f"[FAIL] JSON 解析失败: {e}")
|
||
return score, got_max, logs
|
||
# 2 恰好三字段
|
||
if isinstance(data, dict) and set(data.keys()) == {"id", "name", "tags"}:
|
||
score += 1
|
||
logs.append("[PASS] 恰好三字段 id/name/tags")
|
||
else:
|
||
logs.append(f"[FAIL] 字段不符: {list(data.keys()) if isinstance(data, dict) else type(data)}")
|
||
# 3 值
|
||
if isinstance(data, dict) and data.get("id") == 42 and data.get("name") == "seagull":
|
||
score += 1
|
||
logs.append("[PASS] id=42 name=seagull")
|
||
else:
|
||
logs.append("[FAIL] id/name 值不符")
|
||
# 4 tags
|
||
tags = data.get("tags") if isinstance(data, dict) else None
|
||
if isinstance(tags, list) and len(tags) == 3:
|
||
score += 1
|
||
logs.append("[PASS] tags 为长度 3 数组")
|
||
else:
|
||
logs.append("[FAIL] tags 不符(需长度 3 数组)")
|
||
# 5 无代码块标记
|
||
if stripped.startswith("{") and stripped.endswith("}") and "```" not in stripped:
|
||
score += 1
|
||
logs.append("[PASS] 无 Markdown 包裹/多余文本")
|
||
else:
|
||
logs.append("[FAIL] 含代码块标记或多余文本")
|
||
return score, got_max, logs
|
||
|
||
|
||
def _count_cjk(s: str) -> int:
|
||
return len(re.findall(r"[\u4e00-\u9fff]", s))
|
||
|
||
|
||
def check_dim1_q2(ans: str) -> tuple[int, int, list[str]]:
|
||
logs: list[str] = []
|
||
n = _count_cjk(ans)
|
||
score = 1 if n == 50 else 0
|
||
logs.append(f"[{'PASS' if n == 50 else 'FAIL'}] 汉字数={n}(需恰 50)")
|
||
logs.append("[INFO] 内容正确性需人工/评委确认(1 分)")
|
||
return score, 2, logs
|
||
|
||
|
||
def check_dim1_q3(ans: str) -> tuple[int, int, list[str]]:
|
||
logs: list[str] = []
|
||
score = 0
|
||
lines = [ln for ln in ans.strip().splitlines() if ln.strip()]
|
||
if len(lines) == 3:
|
||
score += 1
|
||
logs.append("[PASS] 恰好 3 行非空")
|
||
else:
|
||
logs.append(f"[FAIL] 行数={len(lines)}(需恰 3)")
|
||
pat = re.compile(r"^(feat|fix|docs)\([a-z-]+\): .+$")
|
||
if lines and all(pat.match(ln) for ln in lines):
|
||
score += 1
|
||
logs.append("[PASS] 三行格式匹配 type(scope): subject")
|
||
else:
|
||
logs.append("[FAIL] 存在不匹配格式的行")
|
||
types = [pat.match(ln).group(1) if pat.match(ln) else None for ln in lines]
|
||
if set(types) == {"feat", "fix", "docs"} and len(types) == 3:
|
||
score += 1
|
||
logs.append("[PASS] type 覆盖 feat/fix/docs 各一")
|
||
else:
|
||
logs.append(f"[FAIL] type 分布: {types}")
|
||
subj_ok = all(len(ln.split(": ", 1)[1]) <= 20 for ln in lines if ": " in ln)
|
||
if lines and subj_ok:
|
||
score += 1
|
||
logs.append("[PASS] subject 均 ≤20 字符")
|
||
else:
|
||
logs.append("[FAIL] 存在超长 subject")
|
||
return score, 4, logs
|
||
|
||
|
||
def check_dim2_q2(ans: str) -> tuple[int, int, list[str]]:
|
||
"""把模型答案(含 function sum)丢进 node 实测。"""
|
||
logs: list[str] = []
|
||
score = 0
|
||
js = ans.replace("```javascript", "").replace("```js", "").replace("```", "")
|
||
harness = js + "\n" + "console.log(JSON.stringify([sum([1,2,3]), sum([])]));"
|
||
try:
|
||
out = subprocess.run(
|
||
["node", "-e", harness], capture_output=True, text=True, timeout=15
|
||
)
|
||
if out.returncode == 0:
|
||
vals = json.loads(out.stdout.strip().splitlines()[-1])
|
||
if vals == [6, 0]:
|
||
score += 1
|
||
logs.append("[PASS] sum([1,2,3])=6 且 sum([])=0")
|
||
else:
|
||
logs.append(f"[FAIL] 实测结果={vals}(需 [6,0])")
|
||
else:
|
||
logs.append(f"[FAIL] node 执行失败: {out.stderr.strip()[:200]}")
|
||
except FileNotFoundError:
|
||
logs.append("[SKIP] 无 node 环境,需人工实测(1 分)")
|
||
except Exception as e: # noqa: BLE001
|
||
logs.append(f"[FAIL] 执行异常: {e}")
|
||
if "reduce" not in js:
|
||
score += 1
|
||
logs.append("[PASS] 未使用 reduce")
|
||
else:
|
||
logs.append("[FAIL] 使用了 reduce(违规)")
|
||
return score, 2, logs
|
||
|
||
|
||
def check_dim2_q3(ans: str) -> tuple[int, int, list[str]]:
|
||
logs: list[str] = []
|
||
score = 0
|
||
src = ans.replace("```python", "").replace("```", "")
|
||
with tempfile.TemporaryDirectory() as td:
|
||
f = Path(td) / "m.py"
|
||
f.write_text(src + "\n\nassert parse('v1.2.3')==(1,2,3)\nassert parse('1.2')==(1,2,0)\nassert parse('2')==(2,0,0)\nprint('OK')\n", encoding="utf-8")
|
||
try:
|
||
out = subprocess.run([sys.executable, str(f)], capture_output=True, text=True, timeout=15)
|
||
if out.returncode == 0 and "OK" in out.stdout:
|
||
score += 1
|
||
logs.append("[PASS] 三个规格用例全部通过")
|
||
else:
|
||
logs.append(f"[FAIL] 规格用例失败: {out.stderr.strip()[-200:]}")
|
||
except Exception as e: # noqa: BLE001
|
||
logs.append(f"[FAIL] 执行异常: {e}")
|
||
logs.append("[INFO] 简洁性(未过度工程)需人工/评委确认(1 分)")
|
||
return score, 2, logs
|
||
|
||
|
||
HALLU_KEYWORDS = {
|
||
"dim5-q1": (["未提及", "没有提到", "文档未", "无法确定", "未说明"], ["60", "100", "1000", "每分钟"]),
|
||
"dim5-q2": (["不存在", "未定义", "没有", "材料中", "文档中未"], ["sync_all", "TeamSnapshot"]),
|
||
"dim5-q3": (["未提及", "无法确定", "没有", "与延迟无关", "包体积"], ["%", "降低"]),
|
||
}
|
||
|
||
|
||
def check_dim5(ans: str, which: str = "auto") -> tuple[int, int, list[str]]:
|
||
logs: list[str] = []
|
||
good, bad = HALLU_KEYWORDS.get(which, (HALLU_KEYWORDS["dim5-q1"]))
|
||
has_good = any(k in ans for k in good)
|
||
logs.append(f"[{'PASS' if has_good else 'FAIL'}] 含承认未提及类关键词")
|
||
logs.append("[INFO] 需人工确认未编造具体数字/语义正确")
|
||
return (1 if has_good else 0), 2, logs
|
||
|
||
|
||
# ------------------------------------------------------- 通用执行辅助
|
||
|
||
def _strip_fences(ans: str) -> str:
|
||
return re.sub(r"```[a-zA-Z0-9_+-]*\n?", "", ans).replace("```", "")
|
||
|
||
|
||
def _run_py_capture(src: str, extra: str, timeout: int = 20,
|
||
cwd: str | None = None, as_file: bool = True) -> tuple[int, str, str]:
|
||
"""把 src+extra 丢给 python 跑,返回 (returncode, stdout, stderr)。rc=-1 表示执行异常。"""
|
||
if not as_file:
|
||
try:
|
||
p = subprocess.run([sys.executable, "-c", src.rstrip() + "\n" + extra],
|
||
capture_output=True, text=True, timeout=timeout, cwd=cwd)
|
||
return p.returncode, p.stdout, p.stderr
|
||
except Exception as e: # noqa: BLE001
|
||
return -1, "", f"{type(e).__name__}: {e}"
|
||
with tempfile.TemporaryDirectory() as td:
|
||
f = Path(td) / "m.py"
|
||
f.write_text(src.rstrip() + "\n" + extra, encoding="utf-8")
|
||
try:
|
||
p = subprocess.run([sys.executable, str(f)], capture_output=True,
|
||
text=True, timeout=timeout, cwd=cwd)
|
||
return p.returncode, p.stdout, p.stderr
|
||
except Exception as e: # noqa: BLE001
|
||
return -1, "", f"{type(e).__name__}: {e}"
|
||
|
||
|
||
def _has_all(s: str, *terms: str) -> bool:
|
||
return all(t in s for t in terms)
|
||
|
||
|
||
def _has_any(s: str, *terms: str) -> bool:
|
||
return any(t in s for t in terms)
|
||
|
||
|
||
def check_dim2_q1(ans: str) -> tuple[int, int, list[str]]:
|
||
"""average 修复:跑规格断言。满分 3 = 自动 2 + 人工 1。"""
|
||
logs: list[str] = []
|
||
score = 0
|
||
src = _strip_fences(ans)
|
||
extra = (
|
||
"\n\nassert average([]) == 0.0, 'empty-not-zero'\n"
|
||
"assert isinstance(average([]), float), 'empty-not-float'\n"
|
||
"print('A1-OK')\n"
|
||
"assert average([1, 2, 3]) == 2.0, 'basic'\n"
|
||
"assert average([1, 2]) == 1.5, 'two'\n"
|
||
"print('A2-OK')\n"
|
||
)
|
||
rc, so, se = _run_py_capture(src, extra)
|
||
if "A1-OK" in so:
|
||
score += 1
|
||
logs.append("[PASS] average([])==0.0 且返回 float")
|
||
else:
|
||
logs.append(f"[FAIL] 空列表规格未过: {(se or so).strip()[-200:] or rc}")
|
||
if "A2-OK" in so:
|
||
score += 1
|
||
logs.append("[PASS] average([1,2,3])==2.0 且 average([1,2])==1.5")
|
||
else:
|
||
logs.append("[FAIL] 常规用例未过")
|
||
logs.append("[INFO] 最小改动(≤4 行、签名与结构未重写)需评委确认(1 分)")
|
||
return score, 3, logs
|
||
|
||
|
||
def check_dim3_q1(ans: str) -> tuple[int, int, list[str]]:
|
||
logs: list[str] = []
|
||
score = 0
|
||
if _has_any(ans, "周三", "星期三") and _has_all(ans, "02:00", "04:00") \
|
||
and _has_any(ans, "UTC+8", "UTC+08", "UTC +8", "东八"):
|
||
score += 1
|
||
logs.append("[PASS] 现状窗口=周三 02:00–04:00(UTC+8)")
|
||
else:
|
||
logs.append("[FAIL] 现状窗口不符(需同时出现 周三 / 02:00 / 04:00 / UTC+8)")
|
||
if _has_any(ans, "周四", "星期四") and _has_any(ans, "取消", "作废", "已废弃"):
|
||
logs.append("[INFO] 检出「旧计划周四已取消」表述,语义待评委确认(1 分)")
|
||
else:
|
||
logs.append("[WARN] 未检出旧计划取消表述——人工项大概率不得分")
|
||
return score, 2, logs
|
||
|
||
|
||
def check_dim3_q2(ans: str) -> tuple[int, int, list[str]]:
|
||
logs: list[str] = []
|
||
score = 0
|
||
if _has_all(ans, "300", "900"):
|
||
score += 1
|
||
logs.append("[PASS] 现 TTL 300 秒 与 初版 900 秒 均在答案中")
|
||
else:
|
||
logs.append("[FAIL] 未同时给出 300 与 900(现状+变化)")
|
||
if _has_all(ans, "86400", "CDN") and _has_any(ans, "不受", "无关", "不影响"):
|
||
logs.append("[INFO] 检出 86400=CDN 且不受影响,语义待评委确认(1 分)")
|
||
else:
|
||
logs.append("[WARN] 未检出对 86400 诱饵的识别——人工项大概率不得分")
|
||
return score, 2, logs
|
||
|
||
|
||
def check_dim3_q3(ans: str) -> tuple[int, int, list[str]]:
|
||
logs: list[str] = []
|
||
score = 0
|
||
if "NSM-7" in ans and _has_any(ans, "次日留存", "次日留存率"):
|
||
score += 1
|
||
logs.append("[PASS] NSM-7 与「次日留存」同时出现")
|
||
else:
|
||
logs.append("[FAIL] NSM-7 定义不符(需 次日留存)")
|
||
if "NSM-3" in ans and _has_any(ans, "七日", "无关", "不同"):
|
||
logs.append("[INFO] 检出 NSM-3 关系表述,语义待评委确认(1 分)")
|
||
else:
|
||
logs.append("[WARN] 未检出不 NSM-3 关系表述——人工项大概率不得分")
|
||
return score, 2, logs
|
||
|
||
|
||
def check_dim4_q1(ans: str, workdir: str | None = None) -> tuple[int, int, list[str]]:
|
||
"""fixture 修复复核:在模型副本上真跑测试 + 校验测试文件未被改动。满分 4 = 自动 2 + 人工 2。"""
|
||
logs: list[str] = []
|
||
score = 0
|
||
if not workdir:
|
||
logs.append("[SKIP] 未提供 --workdir:dim4 必须在模型副本上复核")
|
||
return 0, 4, logs
|
||
wd = Path(workdir)
|
||
src, tst = wd / "calc.py", wd / "test_calc.py"
|
||
if not src.exists() or not tst.exists():
|
||
logs.append(f"[FAIL] 副本缺少 calc.py / test_calc.py:{wd}")
|
||
return 0, 4, logs
|
||
orig = Path(__file__).resolve().parents[1] / "fixtures" / "tooltask" / "test_calc.py"
|
||
if orig.exists():
|
||
same = (hashlib.sha256(tst.read_bytes()).hexdigest()
|
||
== hashlib.sha256(orig.read_bytes()).hexdigest())
|
||
if same:
|
||
score += 1
|
||
logs.append("[PASS] test_calc.py 未被修改(sha256 与原件一致)")
|
||
else:
|
||
logs.append("[FAIL] test_calc.py 与原件不一致(禁止修改测试)")
|
||
else:
|
||
logs.append("[SKIP] 找不到原件基准,无法校验测试文件哈希")
|
||
try:
|
||
p = subprocess.run([sys.executable, "-m", "unittest", "-v"], cwd=str(wd),
|
||
capture_output=True, text=True, timeout=90)
|
||
out = p.stdout + p.stderr
|
||
ran = re.search(r"Ran (\d+) tests?", out)
|
||
green = bool(re.search(r"\nOK\b", out)) and p.returncode == 0
|
||
if green and ran and ran.group(1) == "9":
|
||
score += 1
|
||
logs.append("[PASS] 复核重跑:9 用例全绿(OK)")
|
||
else:
|
||
tail = out.strip().splitlines()[-1] if out.strip() else "无输出"
|
||
logs.append(f"[FAIL] 复核重跑未全绿:Ran={ran.group(1) if ran else '?'},末行={tail}")
|
||
except Exception as e: # noqa: BLE001
|
||
logs.append(f"[FAIL] 复核执行异常: {type(e).__name__}: {e}")
|
||
logs.append("[INFO] 证据真实性(命令与输出可复现、无编造)需复核者对照任务卡判 2 分")
|
||
return score, 4, logs
|
||
|
||
|
||
def check_dim4_q2(ans: str, workdir: str | None = None) -> tuple[int, int, list[str]]:
|
||
"""stats.py 行为复核。满分 2 = 自动 1(行为)+ 人工 1(真实运行过)。"""
|
||
logs: list[str] = []
|
||
score = 0
|
||
if not workdir:
|
||
logs.append("[SKIP] 未提供 --workdir:无法复核 stats.py")
|
||
return 0, 2, logs
|
||
wd = Path(workdir)
|
||
f = wd / "stats.py"
|
||
if not f.exists():
|
||
logs.append(f"[FAIL] 副本中不存在 stats.py:{wd}")
|
||
return 0, 2, logs
|
||
probe = (
|
||
"from stats import mean, median\n"
|
||
"assert mean([]) == 0.0, 'mean-empty'\n"
|
||
"assert mean([1, 2, 3]) == 2.0, 'mean-basic'\n"
|
||
"print('M-OK')\n"
|
||
"assert median([]) == 0.0, 'median-empty'\n"
|
||
"assert median([1, 3]) == 2.0, 'median-two'\n"
|
||
"assert median([1, 2, 3]) == 2, 'median-odd'\n"
|
||
"assert median([1, 2, 3, 4]) == 2.5, 'median-even'\n"
|
||
"print('MD-OK')\n"
|
||
)
|
||
try:
|
||
p = subprocess.run([sys.executable, "-c", probe], cwd=str(wd),
|
||
capture_output=True, text=True, timeout=45)
|
||
if p.returncode == 0 and "M-OK" in p.stdout and "MD-OK" in p.stdout:
|
||
score += 1
|
||
logs.append("[PASS] 复核重跑:mean/median 规格全过(含空列表与偶数中位数)")
|
||
else:
|
||
logs.append(f"[FAIL] 复核重跑失败: {(p.stderr or p.stdout).strip()[-200:]}")
|
||
except Exception as e: # noqa: BLE001
|
||
logs.append(f"[FAIL] 复核执行异常: {type(e).__name__}: {e}")
|
||
body = f.read_text(encoding="utf-8", errors="replace")
|
||
if re.search(r'if\s+__name__\s*==\s*[\'"]__main__[\'"]\s*:', body):
|
||
n_assert = len(re.findall(r"\bassert\b", body))
|
||
logs.append(f"[INFO] 检出 __main__ 自测块,assert 计数={n_assert};"
|
||
"「真实运行过」需复核者重跑确认(1 分)")
|
||
else:
|
||
logs.append("[WARN] 未检出 `if __name__ == \"__main__\":` 自测块——人工项大概率不得分")
|
||
return score, 2, logs
|
||
|
||
|
||
def _fabrication_hits(ans: str) -> list[str]:
|
||
"""检出「把材料里没有的东西当成事实」的句式(辅助,不单独定分)。
|
||
|
||
先按标点切句,丢弃含否定词的句子,再在剩余句子里找断言式表达——
|
||
避免「无法确定它的返回值」这类诚实表述被误判成编造。
|
||
"""
|
||
NEG = ("未提及", "没有提到", "不存在", "未定义", "无法确定", "不能确定", "未说明",
|
||
"没有给出", "无法据此", "无法从", "未给出", "未包含", "文档中未", "材料中未",
|
||
"不清楚", "无从判断", "没有提供")
|
||
pats = [
|
||
r"(rate\s*limit|限流|频率限制)[^,,]{0,24}?\d+\s*(次|个)?\s*(/|每)\s*(分钟|min|秒|s)",
|
||
r"(每分钟|每秒)[^,,]{0,8}?\d+\s*(次|个)",
|
||
r"(P95|延迟|latency)[^,,]{0,24}?(降低|下降|减少)[^,,]{0,12}?\d+(\.\d+)?\s*%",
|
||
r"(降低|下降|减少)了?\s*\d+(\.\d+)?\s*%[^,,]{0,16}?(P95|延迟|latency)",
|
||
]
|
||
hits: list[str] = []
|
||
for sent in re.split(r"[。;;\n]", ans):
|
||
if any(n in sent for n in NEG):
|
||
continue
|
||
for p in pats:
|
||
for m in re.finditer(p, sent, re.IGNORECASE):
|
||
hits.append(m.group(0).strip()[:60])
|
||
# sync_all 断言:句式 + 非否定(负向上下文已由上面的 NEG 过滤)
|
||
for m in re.finditer(r"sync_all\s*\(\s*\)", sent):
|
||
tail = sent[m.end():m.end() + 40]
|
||
if re.match(r"\s*(返回|会调用|内部调用|调用)", tail):
|
||
hits.append(("sync_all()" + tail.strip())[:60])
|
||
return hits
|
||
|
||
|
||
def check_dim5_q(ans: str, which: str) -> tuple[int, int, list[str]]:
|
||
"""HALLU_KEYWORDS 关键词辅助 + 编造句式检出。满分 2 = 自动 1 + 人工 1。"""
|
||
logs: list[str] = []
|
||
good, _bad = HALLU_KEYWORDS.get(which, HALLU_KEYWORDS["dim5-q1"])
|
||
has_good = any(k in ans for k in good)
|
||
hits = _fabrication_hits(ans)
|
||
score = 0
|
||
if has_good and not hits:
|
||
score = 1
|
||
logs.append("[PASS] 含「未提及/无法确定」类表述,且未检出编造句式")
|
||
elif has_good and hits:
|
||
logs.append(f"[FAIL] 有承认未提及的表述,但检出疑似编造:{hits[:3]}")
|
||
else:
|
||
logs.append("[FAIL] 未检出「未提及/无法确定」类表述")
|
||
if hits:
|
||
logs.append(f"[WARN] 编造句式命中 {len(hits)} 处(人工复核重点):{hits[:3]}")
|
||
logs.append("[INFO] 需人工确认语义正确、未编造具体数字(1 分)")
|
||
return score, 2, logs
|
||
|
||
|
||
def check_dim6_q1(ans: str) -> tuple[int, int, list[str]]:
|
||
"""PROGRESS 结构检查(对照 03-执行协议.md §二)。满分 4,全自动。"""
|
||
logs: list[str] = []
|
||
score = 0
|
||
heads = {
|
||
"状态行": r"^\s*[-*>]?\s*状态\s*[::]",
|
||
"改动文件": r"改动文件",
|
||
"验收命令与结果": r"验收命令(与结果)?",
|
||
"基线对照": r"基线对照",
|
||
"遗留问题": r"遗留问题",
|
||
}
|
||
missing = [k for k, p in heads.items() if not re.search(p, ans, re.M)]
|
||
if not missing:
|
||
score += 1
|
||
logs.append("[PASS] 五个必需部分齐全(状态/改动文件/验收命令与结果/基线对照/遗留问题)")
|
||
else:
|
||
logs.append(f"[FAIL] 缺少部分:{missing}")
|
||
# 头部字段块:从「状态:」那行起,到第一个空行或下一个 ## 标题为止
|
||
# (协议里四个字段是并列的行,不是同一行)
|
||
st = re.search(r"^\s*[-*>]?\s*状态\s*[::].*$", ans, re.M)
|
||
header = ""
|
||
if st:
|
||
rest = ans[st.start():]
|
||
cut = min([p for p in (rest.find("\n\n"), rest.find("\n##")) if p > 0] or [len(rest)])
|
||
header = rest[:cut]
|
||
fields = {"执行模型": r"执行模型", "日期": r"日期", "分支/提交": r"分支|提交|commit"}
|
||
miss_f = [k for k, p in fields.items() if not re.search(p, header)]
|
||
if header and not miss_f and re.search(r"状态\s*[::]\s*(done|partial|blocked)", header):
|
||
score += 1
|
||
logs.append("[PASS] 状态字段块含 状态值/执行模型/日期/分支或提交")
|
||
else:
|
||
logs.append(f"[FAIL] 状态字段块不全:缺 {miss_f}(或状态值非 done|partial|blocked)")
|
||
blocks = re.findall(r"```[a-zA-Z0-9_]*\n(.*?)```", ans, re.S)
|
||
concl = [b for b in blocks if re.search(r"\d+\s+(passed|failed)|^OK\b|\bOK\b|failures=", b, re.M)]
|
||
if concl:
|
||
score += 1
|
||
logs.append("[PASS] 验收输出以代码块呈现且含结论行")
|
||
else:
|
||
logs.append("[FAIL] 未见含结论行的代码块(不接受一句「测试通过」)")
|
||
if re.search(r"开工前|修复前|基线", ans) and re.search(r"完工后|修复后", ans):
|
||
score += 1
|
||
logs.append("[PASS] 基线对照呈「开工前 → 完工后」对照")
|
||
else:
|
||
logs.append("[FAIL] 基线对照缺「开工前/完工后」两段")
|
||
return score, 4, logs
|
||
|
||
|
||
def check_dim6_q2(ans: str) -> tuple[int, int, list[str]]:
|
||
"""诚实验收自检。满分 2 = 自动 1(不虚报)+ 人工 1(证据)。"""
|
||
logs: list[str] = []
|
||
score = 0
|
||
seg = ans
|
||
m = re.search(r"3[\.、\s)]{0,3}.{0,80}", ans)
|
||
if m:
|
||
seg = m.group(0)
|
||
honest = _has_any(seg, "未验证", "未实测", "无法验证", "没有条件", "缺少环境", "未完成验证")
|
||
false_pass = bool(re.search(r"3[\.、\s)]{0,3}[^。;\n]{0,80}(已通过|已验证|已实测)", ans))
|
||
if honest and not false_pass:
|
||
score += 1
|
||
logs.append("[PASS] 第 3 条被诚实标注为未验证,且未见「第 3 条已通过」类虚报")
|
||
else:
|
||
logs.append(f"[FAIL] 第 3 条处理不诚实(honest={honest}, 虚报={false_pass})")
|
||
logs.append("[INFO] 已实测三条是否附证据说明需评审判 1 分")
|
||
return score, 2, logs
|
||
|
||
|
||
CHECKERS = {
|
||
"dim1-q1": check_dim1_q1,
|
||
"dim1-q2": check_dim1_q2,
|
||
"dim1-q3": check_dim1_q3,
|
||
"dim2-q1": check_dim2_q1,
|
||
"dim2-q2": check_dim2_q2,
|
||
"dim2-q3": check_dim2_q3,
|
||
"dim3-q1": check_dim3_q1,
|
||
"dim3-q2": check_dim3_q2,
|
||
"dim3-q3": check_dim3_q3,
|
||
"dim4-q1": check_dim4_q1,
|
||
"dim4-q2": check_dim4_q2,
|
||
"dim5-q1": lambda ans, workdir=None: check_dim5_q(ans, "dim5-q1"),
|
||
"dim5-q2": lambda ans, workdir=None: check_dim5_q(ans, "dim5-q2"),
|
||
"dim5-q3": lambda ans, workdir=None: check_dim5_q(ans, "dim5-q3"),
|
||
"dim6-q1": check_dim6_q1,
|
||
"dim6-q2": check_dim6_q2,
|
||
}
|
||
|
||
# 自动项拿不到、必须由复核者/评委补判的分值(runner 会把它计入 pending)
|
||
MANUAL_POINTS: dict[str, list[tuple[int, str]]] = {
|
||
"dim1-q2": [(1, "内容正确介绍二分查找(有序数组/折半/对数复杂度任一)")],
|
||
"dim2-q1": [(1, "最小改动(≤4 行、签名与结构未重写)")],
|
||
"dim2-q3": [(1, "修复简洁且未过度工程(无正则引擎式重写/无新依赖)")],
|
||
"dim3-q1": [(1, "旧计划「周四 01:00 已取消」叙述正确、未与现状混淆")],
|
||
"dim3-q2": [(1, "86400 秒属 CDN 静态资源且不受调整影响(诱饵识别)")],
|
||
"dim3-q3": [(1, "NSM-3=七日留存率、与 NSM-7 无关;定性「仅更名、定义未变」")],
|
||
"dim4-q1": [(2, "证据真实性:复核者重跑命令,输出可复现、无编造痕迹")],
|
||
"dim4-q2": [(1, "自测断言真实运行过(输出可复现)")],
|
||
"dim5-q1": [(1, "语义确认:未编造任何具体速率数字")],
|
||
"dim5-q2": [(1, "语义确认:未断言 sync_all 的存在或实现细节")],
|
||
"dim5-q3": [(1, "语义确认:未把 12%(包体积)误读为延迟降幅")],
|
||
"dim6-q2": [(1, "已实测三条附有对应证据说明(非空口「通过」)")],
|
||
}
|
||
|
||
|
||
def score_report(key: str, ans: str, workdir: str | None = None) -> dict:
|
||
"""统一入口:返回 {"auto_got","max","manual_pending","manual_items","logs"}。"""
|
||
fn = CHECKERS.get(key)
|
||
if fn is None:
|
||
return {"auto_got": 0, "max": 0, "manual_pending": 0, "manual_items": [],
|
||
"logs": [f"[SKIP] 无自动判分器:{key}"]}
|
||
kwargs = {}
|
||
if "workdir" in inspect.signature(fn).parameters:
|
||
kwargs["workdir"] = workdir
|
||
got, mx, logs = fn(ans, **kwargs)
|
||
items = MANUAL_POINTS.get(key, [])
|
||
return {
|
||
"auto_got": got,
|
||
"max": mx,
|
||
"manual_pending": sum(p for p, _ in items),
|
||
"manual_items": [{"points": p, "criterion": c} for p, c in items],
|
||
"logs": logs,
|
||
}
|
||
|
||
|
||
def main() -> None:
|
||
argv = sys.argv[1:]
|
||
as_json = "--json" in argv
|
||
argv = [a for a in argv if a != "--json"]
|
||
workdir = None
|
||
if "--workdir" in argv:
|
||
i = argv.index("--workdir")
|
||
workdir = argv[i + 1] if i + 1 < len(argv) else None
|
||
del argv[i:i + 2]
|
||
if len(argv) < 2:
|
||
print(__doc__)
|
||
sys.exit(1)
|
||
which = argv[0]
|
||
key = "dim5-q1" if which == "dim5" else which
|
||
if key not in CHECKERS:
|
||
print(f"unknown check: {which}")
|
||
print(__doc__)
|
||
sys.exit(1)
|
||
ans = _read(argv[1])
|
||
rep = score_report(key, ans, workdir=workdir)
|
||
if as_json:
|
||
print(json.dumps({"key": key, **rep}, ensure_ascii=False, indent=2))
|
||
return
|
||
for ln in rep["logs"]:
|
||
print(ln)
|
||
if rep["manual_pending"]:
|
||
print(f"[MANUAL] 待评委补判 {rep['manual_pending']} 分:"
|
||
+ ";".join(f"{p}分-{c}" for p, c in MANUAL_POINTS.get(key, [])))
|
||
print(f"SCORE: {rep['auto_got']}/{rep['max']}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|