Files
pp-qa-km/tools/selfcheck_diag_refs.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

115 lines
4.8 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 4 自檢:沙盒植入五種 source_ref 岔開型別hash 不符、路徑正規化、
檔名 hash 後綴岔開、檔在磁碟未登記、完全無對應)+一個正常解得開的 ref
驗證 diag_refs 逐一分類正確、報告涵蓋各型別、結束碼 1、且不修改任何檔案。
另驗全部解得開時結束碼 0。
執行:.venv/Scripts/python tools/selfcheck_diag_refs.py
"""
import io
import json
import pathlib
import shutil
import sys
import tempfile
from contextlib import redirect_stdout
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
import diag_refs
SHA_A = "abcd1234" + "0" * 56
SHA_B = "beef5678" + "0" * 56
# 帳本 key 一律 posix 正斜線+中文/連字號檔名(真實 convert 產出的形狀)
K_OK = "raw/converted/www-pluspay-faq-06b02fd9.md"
K_STEM = "raw/converted/www-pluspay-terms-11112222.md" # 同幹、不同 hash 後綴
FILES = {
K_OK: {"kind": "converted", "sha256": SHA_A, "original_path": "raw/originals/faq.html",
"original_sha256": SHA_A, "converter": "from_web", "converted_at": "2026-07-01T00:00:00"},
K_STEM: {"kind": "converted", "sha256": SHA_B, "original_path": "raw/originals/terms.html",
"original_sha256": SHA_B, "converter": "from_web", "converted_at": "2026-07-01T00:00:00"},
}
# ref → 預期分類。unregistered 的檔會實際落地absent 的不落地。
CASES = {
"resolved": f"{K_OK}#abcd1234",
"hash-mismatch": f"{K_OK}#deadbeef",
"normalize": r"raw\converted\www-pluspay-faq-06b02fd9.md#abcd1234", # 反斜線
"stem": "raw/converted/www-pluspay-terms-99998888.md#cccccccc", # 同幹、異後綴、非 key
"unregistered": "raw/converted/www-pluspay-instructions-77776666.md#77777777",
"absent": "raw/converted/www-pluspay-ghost-00001111.md#cafebabe",
}
def unit():
"""classify_ref 純函式逐型別驗證。"""
on_disk = {"raw/converted/www-pluspay-instructions-77776666.md"}
exists = lambda p: p in on_disk
for expect, ref in CASES.items():
cat, detail = diag_refs.classify_ref(ref, FILES, exists)
assert cat == expect, f"{ref!r} 應分類為 {expect},實得 {cat}{detail}"
print("PASS: classify_ref 六型別(含 resolved分類正確")
def build(root):
for sub in ("raw/converted", "wiki/summaries", "wiki/entities", "wiki/concepts"):
(root / sub).mkdir(parents=True)
(root / "raw/manifest.json").write_text(
json.dumps({"version": 1, "files": FILES}), encoding="utf-8")
# unregistered 的檔要真的存在於磁碟,才會被判為「檔在、帳本無」而非 absent
(root / "raw/converted/www-pluspay-instructions-77776666.md").write_text(
"# 未登記\n", encoding="utf-8")
today = "2026-07-01"
for i, (name, ref) in enumerate(CASES.items()):
meta = {"type": "entity", "title": f"{name}", "description": "測試頁",
"tags": ["t"], "timestamp": today, "sources": [ref], "status": "draft"}
(root / f"wiki/entities/p{i}.md").write_text(kb.dump_page(meta, "內文。"), encoding="utf-8")
def run(argv):
buf = io.StringIO()
code = 0
try:
with redirect_stdout(buf):
diag_refs.main(argv)
except SystemExit as e:
code = e.code
return code, buf.getvalue()
def main():
unit()
tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-diag-"))
try:
root = tmp / "repo"
build(root)
before = {p: p.read_bytes() for p in root.rglob("*")
if p.is_file()}
code, out = run(["--root", str(root)])
assert code == 1, f"有岔開應以結束碼 1實得 {code}\n{out}"
for cat in ("hash-mismatch", "normalize", "stem", "unregistered", "absent"):
assert diag_refs.CAT_TITLE[cat].split("")[0] in out, f"報告缺型別 {cat}\n{out}"
assert "對不上5" in out, out
after = {p: p.read_bytes() for p in root.rglob("*") if p.is_file()}
assert before == after, "diag_refs 修改了檔案!"
print("PASS: 整合——五型別全報、結束碼 1、零寫檔")
# 全部解得開 → 結束碼 0
good = {"type": "entity", "title": "好頁", "description": "d", "tags": ["t"],
"timestamp": "2026-07-01", "sources": [f"{K_OK}#abcd1234"], "status": "draft"}
for p in (root / "wiki/entities").glob("*.md"):
p.unlink()
(root / "wiki/entities/ok.md").write_text(kb.dump_page(good, "內文。"), encoding="utf-8")
code, out = run(["--root", str(root)])
assert code == 0, f"全部解得開應以結束碼 0實得 {code}\n{out}"
assert "全部解得開" in out, out
print("PASS: 全部解得開時結束碼 0")
print("ALL PASS")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()