无校验器不算契约(engineering-conventions §5.2 Δ5)的机器门挂载单: - .githooks/pre-commit:按暂存路径条件触发五门并聚合失败——docs-gate 七检 / 门金标判例库 run.mjs(触判据或判例库)/ play-loop validate --suite(触 play-loop)/ rubric 双写对账(触 genre-rubrics 或品类 skill)/ check_registry(触 prompts)。 - .gitea/workflows/contract-gates.yml:与 docs-gate 平级的服务端不可绕门,四契约门全量真跑。 - 新建 .agents/tools/rubric-sync-gate.py:品类 rubric fixture↔skill 双写对账, fixture 为单源,内联副本逐条名目对齐(容措辞装饰、抓真增删改名)/ 纯指针校验可达,漂移 exit 1。 - 治理档口径对齐:prompt治理.md 三处 + contracts/prompts/README.md 挂载段,把 「registry↔文件对账由 §4 四道闸 CI 拦截」纠正为「由 check_registry.py 承担, 2026-07-03 已挂 pre-commit + Gitea Actions」(四道闸质量闸仍待接线,version 对账先行)。 四门 clean tree 全绿;各具负向演示留证(判例回归/坏样本/内联改名+指针断裂/版本漂移均 exit 1)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
260 lines
10 KiB
Python
Executable File
260 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""rubric-sync-gate.py —— 品类 rubric fixture ↔ 品类 skill 双写对账机器门。
|
||
|
||
背景:每个品类的丰富度 rubric(喂 LLM 验证 agent 的评分尺)以
|
||
`cheap-worker/fixtures/genre-rubrics/<genre>.json` 为**单一数据源**。部分品类的
|
||
设计 skill(`.agents/skills/<genre>-game-design.md`)出于人读需要,在 §10/§11 里
|
||
留了一份内联条目副本;另一些已收敛成"只留一个指针指向 fixture、不再抄表"。两种形态
|
||
都有漂移风险:内联副本会与 fixture 各改一侧而失同步,纯指针会指向一个被改名/删除的
|
||
fixture。本门把这条"改一处必须同改另一处 / 指针必须真实可达"的纪律编译成可被 CI 与
|
||
pre-commit 消费的退出码。
|
||
|
||
判据(逐品类):
|
||
1. 通用底座:fixture 文件必须存在、能解析、items 非空且每条有 name —— 否则硬失败。
|
||
2. 指针完好(所有品类):skill 正文必须引用到本品类 fixture 路径
|
||
`genre-rubrics/<genre>.json`,且该文件真实存在 —— 否则硬失败。
|
||
3. 内联对账(仅带内联条目副本的品类):
|
||
逐条按序比对 fixture 的 name 与 skill 内联表/清单抽出的条目名。
|
||
· 规范化后逐字相等 → 一致;
|
||
· 规范化后 fixture 名是 skill 名的子序列 → 判为"同条目·措辞装饰"(容忍,
|
||
打印提示不失败)——skill 是人读自检清单,允许在 canonical 名上加描述性修饰
|
||
(如"风险回报取舍"写成"风险-回报取舍真实");
|
||
· 否则 → 判为真实漂移(改名/替换),硬失败并打印差异;
|
||
条目数不一致(增删条目)→ 硬失败。
|
||
|
||
规范化 = 只保留中日韩统一表意文字(去标点/空格/ASCII/加减号等),以吸收人读排版差异、
|
||
只在"名目"层面对账。
|
||
|
||
零依赖:仅标准库(json/re/sys/pathlib),与同仓 docs-gate.py / check_registry.py /
|
||
play-loop/validate.py 同栈同风格。
|
||
|
||
用法:
|
||
python3 .agents/tools/rubric-sync-gate.py # 对本仓
|
||
python3 .agents/tools/rubric-sync-gate.py --root DIR # 对指定仓根(供负向演示跑副本)
|
||
# exit 0 = 全部对齐;exit 1 = 有真实漂移/指针断裂/fixture 损坏
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# 本文件 = <root>/.agents/tools/rubric-sync-gate.py → 仓根上溯两级
|
||
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
|
||
|
||
# 品类配置(逐一考察真实状态后编码,2026-07-03):
|
||
# mode=inline → skill 在指定小节留了内联条目表/清单,需逐条对账;
|
||
# mode=pointer → skill 已收敛为纯指针,只校验指针路径真实存在。
|
||
# section_anchor:内联小节标题前缀(用于把抽取范围钉在该节内,不误采别处的表/清单)。
|
||
# row_re:该节内"条目行"的识别正则(表行 / 编号清单行)。
|
||
GENRES = {
|
||
"puzzle": {
|
||
"skill": ".agents/skills/puzzle-game-design.md",
|
||
"mode": "inline",
|
||
"section_anchor": "## 10.",
|
||
"row_re": r"^\|\s*P\d", # §10 品类扩展表:| P1 | L2 | 谜题规则递进 | …
|
||
},
|
||
"trpg": {
|
||
"skill": ".agents/skills/trpg-game-design.md",
|
||
"mode": "inline",
|
||
"section_anchor": "## 10.",
|
||
"row_re": r"^\d+\.\s+\*\*", # §10 自检编号清单:1. **[L2] 掷骰过程可见**:…
|
||
},
|
||
"heritage": {
|
||
"skill": ".agents/skills/heritage-game-design.md",
|
||
"mode": "inline",
|
||
"section_anchor": "## 11.",
|
||
"row_re": r"^\|\s*H\d", # §11 品类 rubric 表:| H1 | L2 | **工序链成立**:… |
|
||
},
|
||
"narrative": {
|
||
"skill": ".agents/skills/narrative-game-design.md",
|
||
"mode": "pointer", # §10 已迁出为单源指针,不再维护表格副本
|
||
},
|
||
"sim-business": {
|
||
"skill": ".agents/skills/sim-business-game-design.md",
|
||
"mode": "pointer", # §10 只有通用自检 + 指向 fixture 的指针,无品类表副本
|
||
},
|
||
}
|
||
|
||
_IDEOGRAPH = re.compile(r"[一-鿿]")
|
||
|
||
|
||
def _norm(name: str) -> str:
|
||
"""规范化:只留中日韩表意文字,吸收标点/空格/ASCII/加减号等人读排版差异。"""
|
||
return "".join(_IDEOGRAPH.findall(name or ""))
|
||
|
||
|
||
def _is_subsequence(needle: str, haystack: str) -> bool:
|
||
"""needle 的字符是否按序(可不连续)出现在 haystack 中。"""
|
||
it = iter(haystack)
|
||
return all(ch in it for ch in needle)
|
||
|
||
|
||
def _fixture_path(root: Path, genre: str) -> Path:
|
||
return root / "cheap-worker" / "fixtures" / "genre-rubrics" / f"{genre}.json"
|
||
|
||
|
||
def _load_fixture_names(root: Path, genre: str):
|
||
"""读 fixture,返回 (names 列表, 错误串)。错误串非空表示通用底座判据未过。"""
|
||
fp = _fixture_path(root, genre)
|
||
if not fp.exists():
|
||
return None, f"fixture 缺失:{fp}"
|
||
try:
|
||
data = json.loads(fp.read_text(encoding="utf-8"))
|
||
except Exception as e: # noqa: BLE001 —— 解析失败即 fixture 损坏,如实报
|
||
return None, f"fixture JSON 解析失败:{e}"
|
||
items = data.get("items")
|
||
if not isinstance(items, list) or not items:
|
||
return None, "fixture 缺 items 或为空"
|
||
names = []
|
||
for i, it in enumerate(items):
|
||
nm = (it or {}).get("name")
|
||
if not nm:
|
||
return None, f"fixture items[{i}] 缺 name"
|
||
names.append(nm)
|
||
return names, None
|
||
|
||
|
||
def _slice_section(lines, anchor: str):
|
||
"""取 anchor 小节的行区间:从标题行到下一条 `---` 或下一个 `## ` 标题之前。"""
|
||
start = None
|
||
for i, ln in enumerate(lines):
|
||
if ln.startswith(anchor):
|
||
start = i
|
||
break
|
||
if start is None:
|
||
return None
|
||
end = len(lines)
|
||
for j in range(start + 1, len(lines)):
|
||
s = lines[j].strip()
|
||
if s == "---" or lines[j].startswith("## "):
|
||
end = j
|
||
break
|
||
return lines[start:end]
|
||
|
||
|
||
def _extract_inline_name(row: str) -> str:
|
||
"""从一条内联条目行抽出品类条目名。统一处理三种形态:
|
||
· 表行(含 `|`):取第 3 个单元格(条目/判据列);
|
||
· 编号清单行:取 `N. ` 之后的正文;
|
||
再:剥 markdown 粗体 `**`、去开头的 `[Lx]` 层标、截到首个中文冒号/英文冒号前。
|
||
"""
|
||
if "|" in row:
|
||
cells = row.split("|")
|
||
frag = cells[3] if len(cells) > 3 else row
|
||
else:
|
||
frag = re.sub(r"^\d+\.\s+", "", row)
|
||
frag = frag.replace("**", "")
|
||
frag = re.sub(r"^\s*\[L\d\]\s*", "", frag) # 去 trpg 的 [L2] 层标前缀
|
||
frag = re.split(r"[::]", frag, maxsplit=1)[0]
|
||
return frag.strip()
|
||
|
||
|
||
def _skill_inline_names(root: Path, cfg: dict):
|
||
"""按配置从 skill 指定小节抽出内联条目名列表,返回 (names, 错误串)。"""
|
||
sp = root / cfg["skill"]
|
||
if not sp.exists():
|
||
return None, f"skill 缺失:{sp}"
|
||
lines = sp.read_text(encoding="utf-8").splitlines()
|
||
section = _slice_section(lines, cfg["section_anchor"])
|
||
if section is None:
|
||
return None, f"skill 找不到小节 {cfg['section_anchor']}"
|
||
row_re = re.compile(cfg["row_re"])
|
||
names = [_extract_inline_name(ln) for ln in section if row_re.match(ln)]
|
||
return names, None
|
||
|
||
|
||
def _skill_has_pointer(root: Path, genre: str, cfg: dict):
|
||
"""校验 skill 正文含指向本品类 fixture 的指针,返回 (bool, 错误串)。"""
|
||
sp = root / cfg["skill"]
|
||
if not sp.exists():
|
||
return False, f"skill 缺失:{sp}"
|
||
text = sp.read_text(encoding="utf-8")
|
||
if f"genre-rubrics/{genre}.json" not in text:
|
||
return False, f"skill 未引用 fixture 指针 genre-rubrics/{genre}.json"
|
||
return True, None
|
||
|
||
|
||
def check_genre(root: Path, genre: str, cfg: dict):
|
||
"""对账单个品类,返回 (ok, 明细行列表)。ok=False 表示硬失败。"""
|
||
detail = []
|
||
|
||
# 判据 1:通用底座 —— fixture 完好
|
||
fx_names, err = _load_fixture_names(root, genre)
|
||
if err:
|
||
return False, [f" ✗ {err}"]
|
||
|
||
# 判据 2:指针完好(所有品类)
|
||
ptr_ok, ptr_err = _skill_has_pointer(root, genre, cfg)
|
||
if not ptr_ok:
|
||
return False, [f" ✗ {ptr_err}"]
|
||
detail.append(f" ✓ 指针可达:skill 引用 genre-rubrics/{genre}.json,fixture {len(fx_names)} 条")
|
||
|
||
if cfg["mode"] == "pointer":
|
||
detail.append(" · 形态=纯指针(skill 不维护副本),只校验指针 —— 通过")
|
||
return True, detail
|
||
|
||
# 判据 3:内联对账
|
||
sk_names, err = _skill_inline_names(root, cfg)
|
||
if err:
|
||
return False, detail + [f" ✗ {err}"]
|
||
|
||
if len(sk_names) != len(fx_names):
|
||
return False, detail + [
|
||
f" ✗ 条目数漂移:fixture {len(fx_names)} 条 vs skill 内联 {len(sk_names)} 条",
|
||
f" fixture:{fx_names}",
|
||
f" skill :{sk_names}",
|
||
]
|
||
|
||
drift = []
|
||
paraphrase = []
|
||
for i, (fn, sn) in enumerate(zip(fx_names, sk_names)):
|
||
fnn, snn = _norm(fn), _norm(sn)
|
||
if fnn == snn:
|
||
continue
|
||
if _is_subsequence(fnn, snn):
|
||
paraphrase.append(f" · 第{i + 1}条 同条目·措辞装饰:fixture「{fn}」→ skill「{sn}」")
|
||
else:
|
||
drift.append(f" · 第{i + 1}条 真实漂移:fixture「{fn}」 ≠ skill「{sn}」")
|
||
|
||
if drift:
|
||
return False, detail + [f" ✗ 内联副本与 fixture 名目漂移({len(drift)} 条):"] + drift + paraphrase
|
||
if paraphrase:
|
||
detail.append(f" ✓ 内联副本 {len(fx_names)} 条名目一致({len(paraphrase)} 条为措辞装饰、非漂移):")
|
||
detail.extend(paraphrase)
|
||
else:
|
||
detail.append(f" ✓ 内联副本 {len(fx_names)} 条名目逐字一致")
|
||
return True, detail
|
||
|
||
|
||
def main(argv) -> int:
|
||
root = DEFAULT_ROOT
|
||
if len(argv) >= 3 and argv[1] == "--root":
|
||
root = Path(argv[2]).resolve()
|
||
|
||
print("品类 rubric fixture ↔ skill 双写对账门")
|
||
print("=" * 60)
|
||
all_ok = True
|
||
for genre, cfg in GENRES.items():
|
||
ok, detail = check_genre(root, genre, cfg)
|
||
status = "PASS" if ok else "FAIL"
|
||
icon = "✔" if ok else "✗"
|
||
print(f"\n[{status}] {genre} {icon} ({cfg['mode']})")
|
||
for d in detail:
|
||
print(d)
|
||
if not ok:
|
||
all_ok = False
|
||
|
||
print("\n" + "=" * 60)
|
||
if all_ok:
|
||
print("对账通过:所有品类 fixture 单源与 skill 副本/指针一致。")
|
||
return 0
|
||
print("对账失败:见上 ✗ —— 改一处必须同改另一处(fixture 为单源),或修复断裂指针。")
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv))
|