"""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()