Files
pp-qa-km/tools/lint.py

212 lines
8.3 KiB
Python
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.
"""wiki 健檢AGENTS.md §7schema 完整性、過期主張、覆蓋缺口、孤兒頁、
重複/矛盾頁偵測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()