Files
pp-qa-km/tools/selfcheck_ingest.py
yellowadmin b90727fc11 feat: diag_refs 對帳診斷 + ingest 進度可見性/Ctrl-C 安全 (Closes #2) (#5)
## 摘要

本 PR 含兩個獨立關注點,各一 commit。

### 1. `diag_refs` — source_ref↔manifest 對帳診斷 (`04f4fd5`) — 回應 #4

lint 的「source_ref 不在 manifest」只報對不上、不辨成因。新增 `tools/diag_refs.py`,把每個對不上的 source_ref 分成五類(hash 不符/路徑字串岔開/檔名 hash 後綴岔開/檔在磁碟未登記/完全無對應),各附修法,讓「補帳本(§14.3)」與「修規則(§12)」的相反處置不再混為一談。純唯讀、結束碼 `0/1`,可當搬機或改 manifest 後的閘門。附 selfcheck 驗五型別分類、結束碼與零寫檔。README §3.3/§6/§7/§8 同步。

### 2. ingest 進度可見性 + Ctrl-C 安全回復 (`4add7fa`) — Closes #2

`--all-pending` 長時間本地 Ollama 攝入全程靜默,使用者不確定有沒有在跑、又不敢中斷:

- **進度可見**:每份來源 `[i/N]`、長文件逐段 `分段 k/n`、每個 item `↳ 新增/更新`、完成 `✓`,全 `flush=True` 即時顯示。
- **Ctrl-C 安全**:獨立攔 `KeyboardInterrupt`(它不是 `Exception` 子類,原本會略過善後)。因 commit 在迴圈之後,**main 必然未受影響**;中斷時印可照做的回復指令並以結束碼 130 收場。

## 測試

- `python tools/selfcheck_diag_refs.py` — ALL PASS
- `python tools/selfcheck_ingest.py` — ALL PASS(含進度標記、中斷後 main 未動、回復指令實測可回乾淨 main)
- README markdownlint 0 issues

Closes #2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: LittleYellow <crazytea@gmail.com>
Reviewed-on: #5
2026-07-24 05:42:35 +00:00

204 lines
10 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.
"""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) preflightraw/ 下未提交的 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()