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>
204 lines
10 KiB
Python
204 lines
10 KiB
Python
"""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()
|