diff --git a/.gitea/workflows/.gitkeep b/.gitea/workflows/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml new file mode 100644 index 0000000..d4bfc7c --- /dev/null +++ b/.gitea/workflows/lint.yaml @@ -0,0 +1,31 @@ +# kb-lint 定時健檢(Phase 4 草稿,部署到 Gitea 時啟用) +# +# 部署前提(ASSUMPTION:部署環境未定,以下依常見 self-hosted 配置假設): +# - self-hosted runner 與 Ollama 同機或內網可達(必要時以 OLLAMA_BASE_URL 覆寫, +# 這是唯一允許的設定覆寫來源,AGENTS.md §1.2) +# - runner 具 Python 3.11+;文件內容全程不離開內網(AGENTS.md §1.1) +# - 報告以 artifact 上傳供人工下載審核;lint 絕不修改 repo 內容 +name: kb-lint +on: + schedule: + - cron: "0 21 * * 0" # UTC 週日 21:00 = 台北時間週一 05:00 + workflow_dispatch: {} + +jobs: + lint: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - name: Set up venv + run: | + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt + - name: Run lint + # 結束碼 1 = 有待人工核准項目 → workflow 標紅作為通知信號 + run: .venv/bin/python tools/lint.py --output reports + - name: Upload report + if: always() + uses: actions/upload-artifact@v3 + with: + name: lint-report + path: reports/ diff --git a/.gitignore b/.gitignore index 17cd2fc..bc155ea 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc .pytest_cache/ +reports/ diff --git a/tools/lint.py b/tools/lint.py new file mode 100644 index 0000000..4922024 --- /dev/null +++ b/tools/lint.py @@ -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() diff --git a/tools/selfcheck_lint.py b/tools/selfcheck_lint.py new file mode 100644 index 0000000..fd484b4 --- /dev/null +++ b/tools/selfcheck_lint.py @@ -0,0 +1,115 @@ +"""Phase 4 自檢:沙盒植入各類違規(schema 缺欄位、壞 source_ref、過期頁、 +攝入缺口、needs_ocr、孤兒頁、相似頁對),驗證 lint 全部抓到、產出報告、 +且不修改任何檔案。LLM 比對以假 LLM 驗證矛盾偵測路徑。 + +執行:.venv/Scripts/python tools/selfcheck_lint.py +""" +import json +import pathlib +import shutil +import sys +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).parent)) +import kb +import lint + +CFG = """ollama: {base_url: "http://localhost:11434", timeout_seconds: 5} +tasks: + ingest: {model: "fake:test", temperature: 0.2, max_retries: 2} + lint: {model: "fake:test", temperature: 0.1, max_retries: 2} + fallback: {model: "fake:fb"} +limits: {mcp_response_max_tokens: 2000, ingest_chunk_max_chars: 12000} +""" +SHA_A = "abcd1234" + "0" * 56 +SHA_B = "beef5678" + "0" * 56 + + +def page(root, rel, meta, body="內文。"): + p = root / rel + p.write_text(kb.dump_page(meta, body), encoding="utf-8") + + +def build(root): + import datetime + today = datetime.date.today().isoformat() + for sub in ("config", "raw/converted", "wiki/summaries", "wiki/entities", "wiki/concepts"): + (root / sub).mkdir(parents=True) + (root / "config/models.yaml").write_text(CFG, encoding="utf-8") + (root / "raw/manifest.json").write_text(json.dumps({"version": 1, "files": { + "raw/converted/good-src.md": {"kind": "converted", "sha256": SHA_A, + "original_path": "raw/originals/g.docx", + "original_sha256": SHA_A, "converter": "from_docx", + "converted_at": "2026-07-01T00:00:00"}, + "raw/converted/uncovered.md": {"kind": "converted", "sha256": SHA_B, + "original_path": "raw/originals/u.docx", + "original_sha256": SHA_B, "converter": "from_docx", + "converted_at": "2026-07-01T00:00:00"}, + "raw/originals/scan.pdf": {"kind": "original", "sha256": SHA_B, + "media_type": "pdf", "added_at": "2026-07-01T00:00:00", + "status": "needs_ocr"}, + }}), encoding="utf-8") + ref = f"raw/converted/good-src.md#{SHA_A[:8]}" + good = {"type": "entity", "title": "支付閘道", "description": "支付閘道模組", + "tags": ["gateway"], "timestamp": today, "sources": [ref], "status": "draft"} + page(root, "wiki/entities/good.md", good) + bad = {"type": "entity", "title": "壞頁", "description": "缺 tags", + "timestamp": today, "sources": ["raw/converted/ghost.md#deadbeef"], + "status": "draft", "type": "entity"} + page(root, "wiki/entities/bad-schema.md", bad) + old = dict(good, title="舊概念", type="concept", timestamp="2024-01-01") + page(root, "wiki/concepts/old.md", old) + dup_a = dict(good, title="支付閘道逾時缺陷", tags=["gateway", "timeout"], + description="逾時缺陷模式") + dup_b = dict(good, title="支付閘道逾時缺陷模式", tags=["gateway", "timeout"], + description="逾時的缺陷模式") + page(root, "wiki/entities/dup-a.md", dup_a, "重試機制在峰值失效。") + page(root, "wiki/entities/dup-b.md", dup_b, "重試機制在峰值一律生效。") + (root / "index.md").write_text( + "- [支付閘道](wiki/entities/good.md)\n- [舊概念](wiki/concepts/old.md)\n" + "- [A](wiki/entities/dup-a.md)\n- [B](wiki/entities/dup-b.md)\n", encoding="utf-8") + + +def run_lint(args): + try: + lint.main(args) + return 0 + except SystemExit as e: + return e.code + + +def main(): + tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-lint-")) + real = kb.llm_json + try: + root = tmp / "repo" + build(root) + before = {p: p.read_bytes() for p in root.rglob("*.md")} + + code = run_lint(["--no-llm", "--root", str(root)]) + assert code == 1, code + report = next((root / "reports").glob("lint-*.md")).read_text(encoding="utf-8") + for expected in ("缺欄位 tags", "source_ref 不在 manifest", + "old.md", "建議人工複審", "uncovered.md", "攝入缺口", + "needs_ocr", "bad-schema.md` — 不被 index.md"): + assert expected in report, f"報告缺少:{expected}\n{report}" + assert "good.md" not in report + after = {p: p.read_bytes() for p in root.rglob("*.md") if "reports" not in str(p)} + assert all(before[p] == after[p] for p in after), "lint 修改了檔案!" + print("PASS: schema/stale/coverage/orphan 全抓到,報告產出,檔案零修改") + + kb.llm_json = lambda cfg, task, prompt, validate: ( + {"relation": "contradiction", "explanation": "重試機制生效與否說法相反"}, "fake:test") + code = run_lint(["--root", str(root)]) + assert code == 1 + report = sorted((root / "reports").glob("lint-*.md"))[-1].read_text(encoding="utf-8") + assert "疑似矛盾" in report and "dup-a.md ↔ wiki/entities/dup-b.md" in report, report + print("PASS: LLM 矛盾頁偵測(Phase 6 成功標準的機制驗證)") + print("ALL PASS") + finally: + kb.llm_json = real + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main()