chore: add a mechanical wiki-vs-source alignment checker
七轮人工审查的 88 条发现里,签名/导出/字段序/列数/env 键这几类是机械可 比对的,不该靠人一轮轮追。五项检查全部由源码反推,已用注入历史错误的方式 验证有效: gather_bounded(coros, limit)、source_name 排进前 11 位、列数写 成 20、EXTRA_BODY 漏文档 —— 四条全部命中。 不并入 make ci: wiki 是独立仓库,仓库里没有它时自动跳过等于静默降级(违 P5),故做成显式的 make wiki-check WIKI=<path>。
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""机械校验 Gitea Wiki 与源码的可比对事实(签名/导出/字段序/列清单/env 键)。
|
||||
|
||||
**只查机械可比对的部分**——机制语义、行为口径这类需要读懂代码才能判断的断言
|
||||
不在此列(那部分靠 `解释-治理行为` 的适用性总表做单一事实源 + 人工审查)。
|
||||
|
||||
设计动机: 2026-08 对 wiki 做了七轮人工审查,88 条发现里有相当一部分属于
|
||||
"机械可校验却写错"——`gather_bounded(coros, limit)`(实为 keyword-only 的
|
||||
`concurrency`)、`LLMResponse` 字段表把 `source_name` 排进前 11 位、遥测列数
|
||||
写成 20/21(实为 22 列)、`__version__` 在自称"全集"的页面缺席。这类偏差不该
|
||||
靠人一轮轮追,故收敛为脚本。
|
||||
|
||||
用法(wiki 是独立仓库,须显式给路径;**不做 skip 静默降级**):
|
||||
python3 tools/check_wiki_alignment.py --wiki /path/to/PolyGateway.wiki
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import polygateway
|
||||
from polygateway import EmbeddingClient, GatewayClient, LLMResponse
|
||||
from polygateway.config import _SOURCE_FIELDS
|
||||
from polygateway.ocr import OcrClient
|
||||
from polygateway.providers import register_provider
|
||||
from polygateway.telemetry.sqlite import _COLUMNS as TELEMETRY_COLUMNS
|
||||
|
||||
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
|
||||
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
|
||||
|
||||
# (符号, 可调用对象) —— 这些的签名必须在 wiki 里逐参数出现
|
||||
_SIGNATURE_TARGETS = [
|
||||
("GatewayClient.chat", GatewayClient.chat),
|
||||
("GatewayClient.from_env", GatewayClient.from_env),
|
||||
("EmbeddingClient.from_env", EmbeddingClient.from_env),
|
||||
("EmbeddingClient.embed", EmbeddingClient.embed),
|
||||
("OcrClient.from_env", OcrClient.from_env),
|
||||
("OcrClient.recognize_text", OcrClient.recognize_text),
|
||||
("gather_bounded", polygateway.gather_bounded),
|
||||
("register_provider", register_provider),
|
||||
]
|
||||
|
||||
|
||||
def _wiki_text(wiki: Path) -> dict[str, str]:
|
||||
"""读全部 .md;文件名(不含后缀)→ 正文。"""
|
||||
pages = {p.stem: p.read_text(encoding="utf-8") for p in sorted(wiki.glob("*.md"))}
|
||||
if not pages:
|
||||
raise SystemExit(f"错误: {wiki} 下没有 .md 文件,路径是否指向 wiki 克隆?")
|
||||
return pages
|
||||
|
||||
|
||||
def check_exports_documented(pages: dict[str, str]) -> list[str]:
|
||||
"""`__all__` 每一项都得在某页出现过(R1 漏 gather_bounded、R6 漏 __version__)。"""
|
||||
blob = "\n".join(pages.values())
|
||||
missing = [name for name in polygateway.__all__ if name not in blob]
|
||||
return [f"__all__ 的 {name!r} 在全部 wiki 页面中零命中(页首自称『顶层导出全集』)"
|
||||
for name in missing]
|
||||
|
||||
|
||||
def check_signatures(pages: dict[str, str]) -> list[str]:
|
||||
"""提到某个公共可调用的那一行,必须列全它的参数名。
|
||||
|
||||
只查「参数名是否出现」,不查顺序与类型——后者用自然语言表述合法。
|
||||
历史命中: gather_bounded 的 concurrency 被写成 limit;EmbeddingClient/
|
||||
OcrClient 的 from_env 用省略号承接 chat 的关键字集合,掩盖了没有 cache=。
|
||||
"""
|
||||
problems = []
|
||||
for label, func in _SIGNATURE_TARGETS:
|
||||
symbol = label.split(".")[-1]
|
||||
params = [
|
||||
p.name
|
||||
for p in inspect.signature(func).parameters.values()
|
||||
if p.name not in ("self", "cls")
|
||||
]
|
||||
# 找出提到该符号的所有行,任一行列全即算通过
|
||||
lines = [
|
||||
line
|
||||
for text in pages.values()
|
||||
for line in text.splitlines()
|
||||
if f"`{symbol}`" in line or f"{symbol}(" in line
|
||||
]
|
||||
if not lines:
|
||||
problems.append(f"{label}: wiki 里找不到任何提及")
|
||||
continue
|
||||
best_missing: list[str] | None = None
|
||||
for line in lines:
|
||||
missing = [
|
||||
p for p in params
|
||||
if p not in line and not (_PARAM_ALIASES.get(p, set()) & set(line.split()))
|
||||
]
|
||||
if not missing:
|
||||
best_missing = []
|
||||
break
|
||||
if best_missing is None or len(missing) < len(best_missing):
|
||||
best_missing = missing
|
||||
if best_missing:
|
||||
problems.append(
|
||||
f"{label}: 没有任何一行列全参数,最接近的一行仍缺 {best_missing}"
|
||||
f"(实际签名 {inspect.signature(func)})"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def check_llmresponse_field_order(pages: dict[str, str]) -> list[str]:
|
||||
"""字段表出现顺序须与 dataclass 声明顺序一致。
|
||||
|
||||
R2 命中: wiki 把 source_name 与 model/provider 并成一行(位置 5),而它实为
|
||||
第 12 个字段。迁移中的三项目按位置构造 fake,照 wiki 写会静默错位。
|
||||
"""
|
||||
page = pages.get("参考-公共API")
|
||||
if page is None:
|
||||
return ["缺少 参考-公共API.md"]
|
||||
# 只在 LLMResponse 小节内找: 别的类型(EmbeddingResponse 等)也有同名字段,
|
||||
# 全页搜索会命中它们、把顺序判断带偏
|
||||
start = page.find("## LLMResponse")
|
||||
if start < 0:
|
||||
return ["参考-公共API.md 缺少 `## LLMResponse` 小节"]
|
||||
end = page.find("\n## ", start + 1)
|
||||
section = page[start : end if end > 0 else len(page)]
|
||||
declared = [f.name for f in dataclasses.fields(LLMResponse)]
|
||||
positions = []
|
||||
for name in declared:
|
||||
# 取该字段在小节内最早的出现位置(表格首列可能写成 `a / b` 合并形式)
|
||||
cands = [
|
||||
section.find(pat)
|
||||
for pat in (f"| {name} ", f"{name} /", f"/ {name} ", f"| {name}\n")
|
||||
]
|
||||
hits = [i for i in cands if i >= 0]
|
||||
positions.append((name, min(hits) if hits else -1))
|
||||
documented = [n for n, i in positions if i >= 0]
|
||||
missing = [n for n, i in positions if i < 0]
|
||||
problems = [f"LLMResponse 字段 {missing} 未在 参考-公共API 的字段表出现"] if missing else []
|
||||
ordered = sorted((i, n) for n, i in positions if i >= 0)
|
||||
actual = [n for _, n in ordered]
|
||||
expected = [n for n in declared if n in documented]
|
||||
if actual != expected:
|
||||
problems.append(
|
||||
f"LLMResponse 字段表顺序与声明顺序不符\n"
|
||||
f" wiki 顺序: {actual}\n"
|
||||
f" 声明顺序: {expected}"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def check_telemetry_columns(pages: dict[str, str]) -> list[str]:
|
||||
"""遥测列清单与 sqlite 后端的 _COLUMNS 对齐(created_at 由 DDL 生成,单列)。"""
|
||||
page = pages.get("指南-遥测与成本")
|
||||
if page is None:
|
||||
return ["缺少 指南-遥测与成本.md"]
|
||||
expected = [*TELEMETRY_COLUMNS, "created_at"]
|
||||
missing = [c for c in expected if c not in page]
|
||||
problems = [f"遥测列 {missing} 未在 指南-遥测与成本 出现"] if missing else []
|
||||
# 列数声明: 表 = _COLUMNS + created_at;端口 = _COLUMNS。
|
||||
# 页内**所有** "N 列" 声明都必须等于真实列数——只查"正确值是否出现"会被
|
||||
# 漏改的旧数字骗过(它们同时存在时检查照样通过)
|
||||
table_n, port_n = len(expected), len(TELEMETRY_COLUMNS)
|
||||
declared_counts = {int(m) for m in re.findall(r"(\d+)\s*列", page)}
|
||||
if not declared_counts:
|
||||
problems.append(f"指南-遥测与成本 未声明表列数(应为 {table_n} 列)")
|
||||
elif declared_counts != {table_n}:
|
||||
wrong = sorted(declared_counts - {table_n})
|
||||
problems.append(
|
||||
f"指南-遥测与成本 的列数声明 {wrong} 与实际 {table_n} 列不符"
|
||||
f"(端口是 {port_n} 参数,两者差 created_at)"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def check_source_env_fields(pages: dict[str, str]) -> list[str]:
|
||||
"""`_SOURCE_FIELDS` 的每个 FIELD 段都得在 参考-配置键 出现(R1 命中 EXTRA_BODY)。"""
|
||||
page = pages.get("参考-配置键")
|
||||
if page is None:
|
||||
return ["缺少 参考-配置键.md"]
|
||||
# 用词边界匹配: `f in page` 会让 EXTRA_BODY 被 EXTRA_BODYY 蒙混过关
|
||||
missing = [f for f in _SOURCE_FIELDS if not re.search(rf"\b{re.escape(f)}\b", page)]
|
||||
return [f"源键 FIELD 段 {missing} 未在 参考-配置键 文档化"] if missing else []
|
||||
|
||||
|
||||
_CHECKS = [
|
||||
("顶层导出覆盖", check_exports_documented),
|
||||
("公共签名参数", check_signatures),
|
||||
("LLMResponse 字段序", check_llmresponse_field_order),
|
||||
("遥测列清单", check_telemetry_columns),
|
||||
("源 env 键覆盖", check_source_env_fields),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--wiki", required=True, type=Path, help="PolyGateway.wiki 克隆目录")
|
||||
args = parser.parse_args()
|
||||
if not args.wiki.is_dir():
|
||||
raise SystemExit(f"错误: {args.wiki} 不是目录")
|
||||
|
||||
pages = _wiki_text(args.wiki)
|
||||
failed = 0
|
||||
for label, check in _CHECKS:
|
||||
problems = check(pages)
|
||||
if problems:
|
||||
failed += len(problems)
|
||||
print(f"✗ {label}")
|
||||
for p in problems:
|
||||
print(f" {p}")
|
||||
else:
|
||||
print(f"✓ {label}")
|
||||
print()
|
||||
if failed:
|
||||
print(f"{failed} 处机械偏差 —— wiki 与源码不一致")
|
||||
return 1
|
||||
print(f"{len(pages)} 页机械校验通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user