Phase 4: Lint — 五類健檢 + LLM 重複/矛盾偵測 + Gitea Actions 草稿 + selfcheck
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
211
tools/lint.py
Normal file
211
tools/lint.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""wiki 健檢(AGENTS.md §7):schema 完整性、過期主張、覆蓋缺口、孤兒頁、
|
||||
重複/矛盾頁偵測(LLM)。產出 markdown 報告;絕不修改或刪除任何檔案,
|
||||
所有項目一律標記待人工核准。
|
||||
|
||||
用法:python tools/lint.py [--output reports] [--no-llm] [--root DIR]
|
||||
結束碼:0 = 無發現,1 = 有發現(供 CI 判斷)。
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import difflib
|
||||
import itertools
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||||
import kb
|
||||
|
||||
# ponytail: 過期門檻寫死 180 天(naive heuristic);若各類頁面需要不同節奏,
|
||||
# 升級路徑:依 type/tags 分級門檻。
|
||||
STALE_DAYS = 180
|
||||
# ponytail: LLM 逐對比較上限 15 對,避免 O(n²) 呼叫爆量;升級路徑:以 embedding
|
||||
# 預篩(Phase 6 之後評估 EmbeddingGemma / snowflake-arctic-embed)。
|
||||
MAX_LLM_PAIRS = 15
|
||||
SIM_THRESHOLD = 0.6
|
||||
|
||||
PAIR_PROMPT = """判斷以下兩個 QA 知識庫 wiki 頁是否「重複」(描述同一事物,應合併)或「矛盾」(對同一事實給出不相容的主張)。只輸出 JSON:
|
||||
{{"relation": "duplicate" 或 "contradiction" 或 "none", "explanation": "一句話理由(繁體中文)"}}
|
||||
|
||||
頁 A:{a_title} — {a_desc}
|
||||
內文節錄:
|
||||
<<<
|
||||
{a_body}
|
||||
>>>
|
||||
|
||||
頁 B:{b_title} — {b_desc}
|
||||
內文節錄:
|
||||
<<<
|
||||
{b_body}
|
||||
>>>"""
|
||||
|
||||
|
||||
def validate_pair(obj):
|
||||
if obj.get("relation") not in ("duplicate", "contradiction", "none"):
|
||||
return "relation 須為 duplicate|contradiction|none"
|
||||
if not isinstance(obj.get("explanation"), str):
|
||||
return "explanation 須為字串"
|
||||
return None
|
||||
|
||||
|
||||
def load_pages(root, findings):
|
||||
pages = []
|
||||
for p in kb.iter_pages(root):
|
||||
rel = p.relative_to(root).as_posix()
|
||||
try:
|
||||
meta, body = kb.parse_page(p.read_text(encoding="utf-8"))
|
||||
pages.append((rel, meta, body))
|
||||
except Exception as e:
|
||||
findings.append(("schema", rel, f"無法解析:{e}"))
|
||||
return pages
|
||||
|
||||
|
||||
def check_schema(pages, manifest, findings):
|
||||
for rel, meta, _ in pages:
|
||||
for err in kb.validate_meta(meta):
|
||||
findings.append(("schema", rel, err))
|
||||
for s in meta.get("sources") or []:
|
||||
path, _, sha8 = str(s).rpartition("#")
|
||||
entry = manifest["files"].get(path)
|
||||
if entry is None:
|
||||
findings.append(("schema", rel, f"source_ref 不在 manifest:{s}"))
|
||||
elif not entry["sha256"].startswith(sha8):
|
||||
findings.append(("schema", rel, f"source_ref hash 與 manifest 不符:{s}"))
|
||||
|
||||
|
||||
def check_stale(pages, findings):
|
||||
today = datetime.date.today()
|
||||
for rel, meta, _ in pages:
|
||||
try:
|
||||
ts = datetime.date.fromisoformat(str(meta.get("timestamp")))
|
||||
except ValueError:
|
||||
continue # schema 檢查已報
|
||||
age = (today - ts).days
|
||||
if meta.get("status") == "stale":
|
||||
findings.append(("stale", rel, "已標記 stale,待人工複審或更新"))
|
||||
elif age > STALE_DAYS:
|
||||
findings.append(("stale", rel, f"最後更新 {age} 天前,建議人工複審後標記 stale"))
|
||||
|
||||
|
||||
def check_coverage(pages, manifest, findings):
|
||||
referenced = {str(s).rsplit("#", 1)[-1]
|
||||
for _, meta, _ in pages for s in meta.get("sources") or []}
|
||||
for path, e in manifest["files"].items():
|
||||
if e["kind"] == "converted" and e["sha256"][:8] not in referenced:
|
||||
findings.append(("coverage", path, "已轉換但沒有任何 wiki 頁引用(攝入缺口)"))
|
||||
if e["kind"] == "original" and e.get("status") == "needs_ocr":
|
||||
findings.append(("coverage", path, "needs_ocr:掃描件待人工決定 OCR 方案"))
|
||||
|
||||
|
||||
def check_orphans(root, pages, findings):
|
||||
# ponytail: 以「檔名出現在任何連結目標中」判斷被連結,不解析相對路徑;
|
||||
# 升級路徑:正規化解析所有 markdown 連結。
|
||||
link_targets = ""
|
||||
idx = root / "index.md"
|
||||
if idx.is_file():
|
||||
link_targets += idx.read_text(encoding="utf-8")
|
||||
for _, _, body in pages:
|
||||
link_targets += body
|
||||
linked = set(re.findall(r"\(([^)]+\.md)\)", link_targets))
|
||||
linked_names = {pathlib.PurePosixPath(t).name for t in linked}
|
||||
for rel, _, _ in pages:
|
||||
if pathlib.PurePosixPath(rel).name not in linked_names:
|
||||
findings.append(("orphan", rel, "不被 index.md 或任何其他頁連結"))
|
||||
|
||||
|
||||
def candidate_pairs(pages):
|
||||
cands = {}
|
||||
for (ra, ma, ba), (rb, mb, bb) in itertools.combinations(pages, 2):
|
||||
sim = difflib.SequenceMatcher(
|
||||
None, f"{ma['title']} {ma['description']}",
|
||||
f"{mb['title']} {mb['description']}").ratio()
|
||||
shared_tags = len(set(ma.get("tags") or []) & set(mb.get("tags") or []))
|
||||
if sim >= SIM_THRESHOLD or shared_tags >= 2:
|
||||
cands[(ra, rb)] = (sim, (ma, ba), (mb, bb))
|
||||
ranked = sorted(cands.items(), key=lambda kv: -kv[1][0])
|
||||
return ranked[:MAX_LLM_PAIRS]
|
||||
|
||||
|
||||
def check_pairs_llm(cfg, pages, findings):
|
||||
for (ra, rb), (sim, (ma, ba), (mb, bb)) in candidate_pairs(pages):
|
||||
try:
|
||||
obj, _ = kb.llm_json(cfg, "lint", PAIR_PROMPT.format(
|
||||
a_title=ma["title"], a_desc=ma["description"], a_body=ba[:1500],
|
||||
b_title=mb["title"], b_desc=mb["description"], b_body=bb[:1500]),
|
||||
validate_pair)
|
||||
except RuntimeError as e:
|
||||
findings.append(("llm-pair", f"{ra} ↔ {rb}", f"LLM 比對失敗:{e}"))
|
||||
continue
|
||||
if obj["relation"] == "duplicate":
|
||||
findings.append(("duplicate", f"{ra} ↔ {rb}", f"疑似重複:{obj['explanation']}"))
|
||||
elif obj["relation"] == "contradiction":
|
||||
findings.append(("contradiction", f"{ra} ↔ {rb}", f"疑似矛盾:{obj['explanation']}"))
|
||||
|
||||
|
||||
SECTION_TITLES = {
|
||||
"schema": "Schema 完整性", "stale": "過期主張(staleness)",
|
||||
"coverage": "覆蓋缺口", "orphan": "孤兒頁",
|
||||
"duplicate": "重複頁(LLM 判定)", "contradiction": "矛盾頁(LLM 判定)",
|
||||
"llm-pair": "LLM 比對失敗(需重跑)",
|
||||
}
|
||||
|
||||
|
||||
def write_report(out_dir, findings, n_pages, llm_note):
|
||||
now = datetime.datetime.now()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"lint-{now.strftime('%Y%m%d-%H%M%S')}.md"
|
||||
counts = {}
|
||||
for check, _, _ in findings:
|
||||
counts[check] = counts.get(check, 0) + 1
|
||||
lines = [f"# Lint 報告 — {now.strftime('%Y-%m-%d %H:%M')}", "",
|
||||
f"- 檢查頁面數:{n_pages}",
|
||||
f"- 發現項目:{len(findings)}(" + "、".join(
|
||||
f"{SECTION_TITLES[k]} {v}" for k, v in counts.items()) + ")"
|
||||
if findings else "- 發現項目:0",
|
||||
f"- LLM 比對:{llm_note}", "",
|
||||
"> lint 絕不修改或刪除任何檔案;以下所有項目皆**待人工核准**後才處置。", ""]
|
||||
for check in SECTION_TITLES:
|
||||
items = [(w, msg) for c, w, msg in findings if c == check]
|
||||
if not items:
|
||||
continue
|
||||
lines += [f"## {SECTION_TITLES[check]}", ""]
|
||||
lines += [f"- [ ] `{w}` — {msg}" for w, msg in items]
|
||||
lines.append("")
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--output", default="reports")
|
||||
ap.add_argument("--no-llm", action="store_true", help="跳過重複/矛盾的 LLM 比對")
|
||||
ap.add_argument("--root", default=str(kb.ROOT))
|
||||
a = ap.parse_args(argv)
|
||||
root = pathlib.Path(a.root).resolve()
|
||||
cfg = kb.load_config(root)
|
||||
manifest = json.loads((root / "raw" / "manifest.json").read_text(encoding="utf-8"))
|
||||
|
||||
findings = []
|
||||
pages = load_pages(root, findings)
|
||||
check_schema(pages, manifest, findings)
|
||||
check_stale(pages, findings)
|
||||
check_coverage(pages, manifest, findings)
|
||||
check_orphans(root, pages, findings)
|
||||
if a.no_llm:
|
||||
llm_note = "已略過(--no-llm)"
|
||||
else:
|
||||
llm_note = f"model={cfg['tasks']['lint']['model']},上限 {MAX_LLM_PAIRS} 對"
|
||||
check_pairs_llm(cfg, pages, findings)
|
||||
|
||||
out = pathlib.Path(a.output)
|
||||
report = write_report(out if out.is_absolute() else root / out,
|
||||
findings, len(pages), llm_note)
|
||||
print(f"報告:{report}")
|
||||
print(f"發現 {len(findings)} 項" if findings else "無發現")
|
||||
if findings:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user