Files
pp-qa-km/tools/selfcheck_ingest.py
LittleYellow 4add7fa9d9 feat: ingest 進度可見性 + Ctrl-C 安全回復 (Closes #2)
issue #2:--all-pending 長時間本地 Ollama 攝入全程靜默,使用者不確定有沒有在
跑、又不敢中斷。三處補強:

- 進度可見:每份來源印 [i/N] 起始行、長文件逐分段印「分段 k/n」、每個
  knowledge-item 印「↳ 新增/更新」、完成印「✓」,全部 flush=True 即時顯示。
  最會靜默的分段迴圈正是重點回報處。
- Ctrl-C 安全:KeyboardInterrupt 不是 Exception 子類,原本會略過善後、把使用者
  留在 ingest 分支且無指引。改為獨立攔截——commit 在迴圈之後,故 main 必然未受
  影響——印出可照做的回復指令(git checkout main && git stash -u &&
  git branch -D <branch>),以結束碼 130 收場。

selfcheck_ingest 新增:驗進度標記出現;驗中斷後 main HEAD 未動、停在 ingest
分支、且照文件回復指令實測能回到乾淨 main(驗結果可用,非字串存在,§13.4)。
README §7 補一列。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 23:45:59 +08: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()