games-development-ai/.agents/tools/rubric-sync-gate.py
lili 6085805634 chore(gates): 四契约门挂 pre-commit + Gitea Actions + prompt 治理档口径对齐(Δ5 执行面)
无校验器不算契约(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>
2026-07-03 14:01:32 -07:00

260 lines
10 KiB
Python
Executable File
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
# -*- 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))