"""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 SRC_OK = "raw/converted/好來源 報告.md" SRC_UNCOVERED = "raw/converted/未覆蓋 來源.md" 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") # 來源檔名一律「中文 + 空格」——真實 QA 文件就長這樣(§12:測資要照現實建,不是照能過建) (root / "raw/manifest.json").write_text(json.dumps({"version": 1, "files": { SRC_OK: {"kind": "converted", "sha256": SHA_A, "original_path": "raw/originals/好來源 報告.docx", "original_sha256": SHA_A, "converter": "from_docx", "converted_at": "2026-07-01T00:00:00"}, SRC_UNCOVERED: {"kind": "converted", "sha256": SHA_B, "original_path": "raw/originals/未覆蓋 來源.docx", "original_sha256": SHA_B, "converter": "from_docx", "converted_at": "2026-07-01T00:00:00"}, "raw/originals/掃描件 無文字層.pdf": {"kind": "original", "sha256": SHA_B, "media_type": "pdf", "added_at": "2026-07-01T00:00:00", "status": "needs_ocr"}, }}), encoding="utf-8") ref = f"{SRC_OK}#{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", "建議人工複審", "未覆蓋 來源.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()