"""Phase 3 自檢:以假 LLM + 沙盒 git repo 驗證攝入管線的完整 plumbing—— 分支建立、頁面產出、frontmatter 合規、index/log 更新、冪等跳過、就地編輯合併, 以及 kb.llm_json 的重試與 fallback 切換。不需要 Ollama 在線。 執行:.venv/Scripts/python tools/selfcheck_ingest.py """ import hashlib import io import pathlib import shutil import subprocess import sys import tempfile from contextlib import redirect_stderr, redirect_stdout sys.path.insert(0, str(pathlib.Path(__file__).parent)) import kb import ingest 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: 200} """ def fake_llm(cfg, task, prompt, validate): if prompt.startswith("以下是一份長文件的第"): obj = {"summary_markdown": "- 分段重點"} elif '"updated_markdown"' in prompt: obj = {"updated_markdown": "- 壓測逾時\n- 新來源事實", "updated_description": "支付閘道模組(已更新)"} else: obj = {"title": "支付閘道壓測報告", "description": "壓測發現逾時缺陷", "slug": "gateway-load-test", "tags": ["load-test", "gateway"], "summary_markdown": "- 逾時缺陷於壓測重現", "knowledge_items": [{"type": "entity", "slug": "payment-gateway", "title": "支付閘道", "description": "支付閘道模組", "tags": ["gateway"], "facts_markdown": "- 壓測逾時"}]} err = validate(obj) # 假輸出也要通過真 validator,確保契約一致 assert err is None, err return obj, "fake:test" def git(root, *args): return subprocess.run(["git", *args], cwd=root, check=True, text=True, encoding="utf-8", capture_output=True).stdout.strip() def add_doc(root, name, text): p = root / "raw" / "converted" / name p.write_text(text, encoding="utf-8") sha = hashlib.sha256(text.encode("utf-8")).hexdigest() import json mp = root / "raw" / "manifest.json" m = json.loads(mp.read_text(encoding="utf-8")) m["files"][f"raw/converted/{name}"] = { "kind": "converted", "sha256": sha, "original_path": f"raw/originals/{name}", "original_sha256": sha, "converter": "from_docx", "converted_at": "2026-07-14T00:00:00"} mp.write_text(json.dumps(m, ensure_ascii=False, indent=2), encoding="utf-8") return sha def main(): tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-ingest-")) real_llm, real_chat = kb.llm_json, kb.ollama_chat try: root = tmp / "repo" for sub in ("config", "raw/originals", "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('{"version": 1, "files": {}}', encoding="utf-8") (root / "index.md").write_text("# 知識庫索引\n", encoding="utf-8") (root / "log.md").write_text("# 操作日誌\n", encoding="utf-8") git(root, "init", "-q", "-b", "main") git(root, "config", "user.name", "selfcheck") git(root, "config", "user.email", "selfcheck@local") text = "支付閘道於壓力測試下出現逾時缺陷,交易峰值時重試機制未生效。\n\n" * 12 add_doc(root, "doc1.md", text) # > 200 chars → 走分段路徑 git(root, "add", "-A") git(root, "commit", "-q", "-m", "init") # 1) 攝入:建分支、產頁、commit、回到 main(並驗進度輸出可見,issue #2) kb.llm_json = fake_llm buf = io.StringIO() with redirect_stdout(buf): ingest.main(["raw/converted/doc1.md", "--root", str(root)]) out = buf.getvalue() for marker in ("[1/1] 攝入", "分段 1/", "↳ 新增", "✓ ", "完成"): assert marker in out, f"進度輸出缺少 {marker!r}\n{out}" assert git(root, "branch", "--show-current") == "main" branch = git(root, "branch", "--list", "ingest/*").strip("* ").strip() assert branch, "未建立 ingest 分支" assert not git(root, "status", "--porcelain"), "工作區不乾淨" git(root, "checkout", "-q", branch) summary = list((root / "wiki/summaries").glob("*.md")) entity = root / "wiki/entities/payment-gateway.md" assert len(summary) == 1 and entity.is_file() meta, _ = kb.parse_page(entity.read_text(encoding="utf-8")) assert kb.validate_meta(meta) == [], kb.validate_meta(meta) assert "payment-gateway" in (root / "index.md").read_text(encoding="utf-8") assert "] ingest |" in (root / "log.md").read_text(encoding="utf-8") print("PASS: 攝入 → 分支 + summary/entity 頁 + index/log") # 2) 模擬 PR 合併後:冪等跳過;新來源觸發既有頁就地編輯 git(root, "checkout", "-q", "main") git(root, "merge", "-q", branch) ingest.main(["raw/converted/doc1.md", "--root", str(root)]) # 應 skip assert git(root, "branch", "--show-current") == "main" add_doc(root, "doc2.md", "第二份文件:支付閘道逾時已修復並通過回歸測試。") git(root, "add", "-A") git(root, "commit", "-q", "-m", "doc2") ingest.main(["raw/converted/doc2.md", "--root", str(root)]) b2 = [b.strip("* ").strip() for b in git(root, "branch", "--list", "ingest/*").splitlines() if branch not in b] git(root, "checkout", "-q", b2[0]) meta, body = kb.parse_page(entity.read_text(encoding="utf-8")) assert len(meta["sources"]) == 2 and "已更新" in meta["description"], meta assert "新來源事實" in body git(root, "checkout", "-q", "main") print("PASS: 冪等跳過 + 既有 entity 頁就地編輯(sources 追加)") # 3) preflight:raw/ 下未提交的 convert 產出(含中文檔名)不得被誤判為髒污 (root / "raw" / "converted" / "測試報告.md").write_text("中文", encoding="utf-8") ingest.git_preflight(root) # 不應拋錯 stray = root / "wiki" / "concepts" / "stray.md" stray.write_text("x", encoding="utf-8") try: ingest.git_preflight(root) raise AssertionError("raw/ 以外的未提交變更應中止") except SystemExit: pass stray.unlink() print("PASS: preflight 放行 raw/(含中文檔名)、擋下 raw/ 以外的未提交變更") # 4) kb.llm_json:重試後切 fallback;全失敗拋錯 kb.llm_json = real_llm cfg = kb.load_config(root) calls = {"n": 0} def flaky(cfg_, model, prompt, temperature, json_format=False): calls["n"] += 1 return "not-json" if calls["n"] < 3 else '{"summary_markdown": "ok"}' kb.ollama_chat = flaky obj, model = kb.llm_json(cfg, "ingest", "p", ingest.validate_chunk) assert obj["summary_markdown"] == "ok" and model == "fake:fb" and calls["n"] == 3 kb.ollama_chat = lambda *a, **k: "junk" try: kb.llm_json(cfg, "ingest", "p", ingest.validate_chunk) raise AssertionError("應拋 RuntimeError") except RuntimeError as e: assert "fallback" in str(e) print("PASS: 重試 + fallback 切換 + 全失敗報錯") # 5) Ctrl-C 安全(issue #2):中斷時 main 不受影響、以 130 收場, # 且文件宣稱的回復指令真的能回到乾淨 main。第 2 份來源開始摘要時中斷, # 此時第 1 份的頁面已寫入(未追蹤、未 commit)——正是使用者最怕的半成品狀態。 add_doc(root, "docA.md", "來源A:待攝入。") add_doc(root, "docB.md", "來源B:待攝入。") git(root, "add", "-A") git(root, "commit", "-q", "-m", "docA/docB") head_before = git(root, "rev-parse", "main") # 攝入不得動到的 main 狀態 state = {"n": 0} def boom(cfg, task, prompt, validate): state["n"] += 1 if state["n"] >= 2: # 第 2 份來源的 summarize 呼叫 raise KeyboardInterrupt return ({"title": "來源A", "description": "d", "slug": "fresh-a", "tags": ["x"], "summary_markdown": "- a", "knowledge_items": []}, "fake:test") kb.llm_json = boom err = io.StringIO() try: with redirect_stdout(io.StringIO()), redirect_stderr(err): ingest.main(["raw/converted/docA.md", "raw/converted/docB.md", "--root", str(root)]) raise AssertionError("KeyboardInterrupt 應轉為 SystemExit(130)") except SystemExit as e: assert e.code == 130, e.code msg = err.getvalue() assert "git checkout main" in msg and "main 未受影響" in msg, msg assert git(root, "rev-parse", "main") == head_before, "main 被動到了!" cur = git(root, "branch", "--show-current") assert cur.startswith("ingest/"), f"中斷後應停在 ingest 分支,實得 {cur!r}" assert git(root, "status", "--porcelain"), "應有未提交的半成品" # 照文件指令回復,斷言真的回到乾淨 main(驗「結果可用」,非「字串存在」,§13.4) git(root, "checkout", "main") git(root, "stash", "-u") git(root, "branch", "-D", cur) assert git(root, "branch", "--show-current") == "main" assert not git(root, "status", "--porcelain"), "回復後工作區仍不乾淨" print("PASS: Ctrl-C → main 未受影響、130 收場、回復指令實測可回乾淨 main") print("ALL PASS") finally: kb.llm_json, kb.ollama_chat = real_llm, real_chat shutil.rmtree(tmp, ignore_errors=True) if __name__ == "__main__": main()