diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8b3993b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# raw/ 是 hash 釘選的 provenance 帳本(AGENTS.md §1.4 / §9 / §14): +# git 一律不得正規化其換行,否則 checkout 後磁碟 bytes 會與 manifest 的 +# SHA-256 不符,讓 --verify 對帳失準(本專案 core.autocrlf=true)。 +# 轉換器已在寫入時以 write_bytes 固定為登記的那份 bytes;此處把同樣的 +# 保證延伸到 git 的 checkin/checkout。 +raw/originals/** -text +raw/converted/** -text +raw/manifest.json -text diff --git a/AGENTS.md b/AGENTS.md index 09c0b2c..c301c64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,10 @@ EmbeddingGemma(Google)或 snowflake-arctic-embed(Snowflake)。 4. **raw 層不可變**:`raw/` 內的文件只讀不改。原始檔與轉換後 Markdown 皆登記 SHA-256 至 `raw/manifest.json`(結構見 §9),轉換檔條目必須回指原始檔 hash。 - wiki 頁面引用來源必須帶 `source_ref`(格式見 §3.3)。 + wiki 頁面引用來源必須帶 `source_ref`(格式見 §3.3)。登記的 hash 必須等於檔案在 + 磁碟上的實際 bytes(UTF-8,換行不轉換——轉換器一律以 write_bytes 寫入所登記的 + 那份 bytes,不受平台 CRLF 影響;git 亦以 `.gitattributes` 對 `raw/**` 關閉換行 + 正規化,避免 checkout 重新引入 CRLF);完整性以 §14 `--verify` 稽核。 5. **HITL 閘門**:所有 wiki 層的變更以 git branch + PR 形式提交,經人工審核後才 合併至 main。攝入腳本永遠不直接 commit 到 main。lint 絕不擅自刪檔,一律標記 待人工核准。 @@ -300,3 +303,45 @@ output**)。單筆修正只治標;規則修正才治本。 **有效的跡象**:diff 裡不必要的變更變少、因過度複雜而重寫的次數變少、 釐清問題發生在動手**之前**而非犯錯之後、刻意的捷徑是可見的(`ponytail:`) 而非沉默的。 + +## 14. raw 完整性與修復(對帳) + +raw 層以 manifest 的 SHA-256 為信任根(§1.4、§9)。檔案被**手動刪除**或**就地改動**時, +下列為正規稽核與修復流程。核心原則:manifest 是 provenance **真相帳本**、不是 cache—— +hash 的用途是讓刪除**可復原、可驗證**,故先**復原檔案**,而非改帳本去遷就殘缺的磁碟。 + +### 14.1 稽核 + +```text +python tools/convert/convert.py --verify +``` + +純唯讀(不改任何檔,含 manifest),re-hash 全部登記檔並掃描 `raw/`,回報三類意外、 +有不一致以退出碼 1 表示(可當 CI/排程閘門): + +- **missing** — 帳本有登記,磁碟上不見了(手動刪除)。 +- **mismatch** — 檔案還在但 sha256 與登記值不符(raw 被就地改動,違反 §1.4)。 +- **unregistered** — `raw/originals`/`raw/converted` 下有檔卻不在帳本。 + `converted/assets/*`(圖片)由 markdown 連結追蹤而非帳本,故略過;`.gitkeep` 亦略過。 + +### 14.2 修復決策(可衍生 vs 信任根) + + + +1. **先還原,不改帳本**(多數「誤刪」到此為止):`raw/` 進版控,`git restore ` + 取回精確 bytes(或從備份),再跑 §14.1 確認 hash == 登記值。帳本零改動。 +2. **converted 救不回** → 從 original 重轉。注意兩個陷阱:(a) `convert.py` 以 **original + 的 hash** 去重,original 條目還在會直接 skip、**不會**因 converted 不見而重生,需先 + 自帳本移除該 converted 條目;(b) 函式庫版本變動可能使重轉 bytes 不同 → converted + hash 變 → 連累所有指向它的 wiki `source_ref`。故**能還原就別重轉**;重轉屬實質變更, + 連同 source_ref 一起走 PR。 +3. **original 救不回** → 不可逆的 provenance 損失,**不得靜默刪條目**(會連鎖 orphan 掉 + 對應 converted、再斷所有 source_ref);據實記錄該檔遺失並上報人工(HITL,§5)。 + +### 14.3 動 manifest 的紀律 + +- 任何帳本變更走 **branch + PR**(§5)——manifest 是 provenance 帳本,改它需人工審核。 +- `log.md` 追加一筆 `edit` 條目(§10),說明對了什麼、為什麼。 +- 刪任何 `converted` 條目前,先確認沒有 wiki `source_ref` 引用它(否則 §7 的 schema + 檢查會抓到斷鏈);有引用就改為還原檔案,或同 PR 一併更新引用。 diff --git a/README.md b/README.md index 5fb51f3..296c4bb 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,16 @@ ollama list # 對照 tasks.ingest.model / tasks.lint.model / tasks.fallba - **結束碼**:`0` = 無發現;`1` = 有待人工核准項目(供 CI 判斷 / 標紅通知)。 - 排程:[.gitea/workflows/lint.yaml](.gitea/workflows/lint.yaml)(每週一台北時間 05:00,草稿,部署 Gitea 時啟用)。 +> **`source_ref 不在 manifest` 的成因分類**:lint 只報「對不上」,不分辨成因。跑 +> [tools/diag_refs.py](tools/diag_refs.py) 把每個對不上的 source_ref 分成五類(hash 不符 / +> 路徑字串岔開 / 檔名 hash 後綴岔開 / 檔在磁碟未登記 / 完全無對應),各附修法—— +> 「補帳本」(§14.3)與「修規則」(§12)的處置相反,先分類再動手。純唯讀不改任何檔, +> 結束碼同 lint(`0` 全部解得開 / `1` 有對不上),可當搬機或改 manifest 後的閘門。 +> +> ```powershell +> .venv\Scripts\python tools\diag_refs.py +> ``` + ### 3.4 查詢 `search` — BM25 檢索 ```powershell @@ -180,7 +190,7 @@ wiki/entities/ # 實體頁(系統/模組/API/法規/專案) wiki/concepts/ # 概念頁(跨來源綜合) index.md # 目錄式導航(由 ingest 自動重建) log.md # append-only 操作日誌 -tools/ # convert/ ingest.py lint.py search.py kb.py(共用模組) +tools/ # convert/ ingest.py lint.py diag_refs.py search.py kb.py(共用模組) mcp/server.py # MCP 薄殼 ``` @@ -197,8 +207,10 @@ mcp/server.py # MCP 薄殼 | `須在 main 分支執行` | 攝入前先切回 `main`。 | | `工作區有 raw/ 以外的未提交變更` | 先 commit / stash 非 `raw/` 的變更再攝入。 | | `... 不在 manifest 的 converted 條目中` | 該來源尚未轉換;先跑 `convert.py`。 | +| lint 報 `source_ref 不在 manifest` | 跑 `tools/diag_refs.py` 分類成因:帳本掉條目→補登走 PR(§14.3);路徑字串岔開→修規則(§12),別逐頁改。 | | `LLM 輸出驗證失敗(含 fallback)` | 本地模型連主模型 + fallback 都無法產出合規 JSON;檢查 Ollama 是否在線、模型是否拉好。 | | `push 失敗(無 remote 或離線)` | 正常;分支已保留本地,手動 push 後開 PR。 | +| ingest 跑很久、看不到進度/想中斷 | 已逐來源 `[i/N]` +逐分段回報進度。Ctrl-C 安全:main 不受影響,中斷時會印回復指令(`git checkout main && git stash -u && git branch -D `),續跑 `--all-pending` 自動接續。 | --- @@ -210,5 +222,6 @@ mcp/server.py # MCP 薄殼 .venv\Scripts\python tools\convert\selfcheck.py .venv\Scripts\python tools\selfcheck_ingest.py .venv\Scripts\python tools\selfcheck_lint.py +.venv\Scripts\python tools\selfcheck_diag_refs.py .venv\Scripts\python tools\selfcheck_search.py ``` diff --git a/tools/convert/convert.py b/tools/convert/convert.py index 38143d5..7d467ee 100644 --- a/tools/convert/convert.py +++ b/tools/convert/convert.py @@ -7,6 +7,7 @@ 用法:python tools/convert/convert.py <檔案路徑或URL>... [--vision] [--dry-run] [--root DIR] 支援 .docx/.xlsx/.pdf/.pptx/.html 與 URL;舊版 .doc/.xls/.ppt 經 LibreOffice 升版; --vision 讓掃描/圖片型 PDF 走本地 vision 模型轉錄(見 from_vision.py)。 + --verify 對帳模式:re-hash 全部登記檔、掃出未登記檔,純唯讀不改檔,供刪除/竄改後稽核。 """ import argparse import datetime @@ -87,7 +88,10 @@ def _register_pair(m, root, orig_rel, orig_entry, md_rel, md_text, converter, dr print(f"warning: {orig_rel} 轉換結果為空白,請人工檢查來源", file=sys.stderr) if not dry: md_path = root / md_rel - md_path.write_text(md_text, encoding="utf-8") + # write_bytes(非 write_text):登記的 sha256 算在 md_text.encode() 上, + # 而 write_text 在 Windows 會把 \n 轉 \r\n,使磁碟 bytes 與登記 hash 不符, + # 令日後「還原後 re-hash 比對」與 --verify 失準。寫入即登記的那份 bytes。 + md_path.write_bytes(md_text.encode("utf-8")) m["files"][orig_rel] = orig_entry m["files"][md_rel] = { "kind": "converted", @@ -130,6 +134,54 @@ def _vision_pdf(root, pdf_path, assets, conv_name): return "\n\n".join(blocks), "ok", "from_vision" +def verify(root, m): + """對帳模式:re-hash 每個 manifest 條目對應的檔案,並掃出未登記的 raw 檔。 + 純唯讀——不改任何檔案(含 manifest),只回報。有不一致以 SystemExit(1) 表示, + 可供 CI/排程當閘門用。 + + 偵測三類意外: + missing —— 帳本有登記,磁碟上檔案不見了(手動刪除)。 + mismatch —— 檔案還在但 sha256 與登記值不符(raw 層被就地改動,違反 §1.4)。 + unregistered —— raw/originals 或 raw/converted 下有檔卻不在帳本; + converted/assets/* 由 markdown 連結而非帳本追蹤,故略過,.gitkeep 亦略過。 + """ + missing, mismatch = [], [] + for rel, e in m["files"].items(): + p = root / rel + if not p.is_file(): + missing.append(rel) + elif hashlib.sha256(p.read_bytes()).hexdigest() != e["sha256"]: + mismatch.append(rel) + + unregistered = [] + for sub in ("originals", "converted"): + base = root / "raw" / sub + if not base.is_dir(): + continue + for p in base.rglob("*"): + if not p.is_file() or p.name == ".gitkeep": + continue + if "assets" in p.relative_to(base).parts: # 由 markdown 連結,不入帳本 + continue + rel = p.relative_to(root).as_posix() + if rel not in m["files"]: + unregistered.append(rel) + + for rel in missing: + print(f"missing: {rel}(帳本有登記,磁碟上不見了)", file=sys.stderr) + for rel in mismatch: + print(f"mismatch: {rel}(sha256 與登記值不符,raw 層被就地改動)", file=sys.stderr) + for rel in sorted(unregistered): + print(f"unregistered: {rel}(磁碟上有檔,帳本未登記)", file=sys.stderr) + + if missing or mismatch or unregistered: + print(f"\nverify: {len(m['files'])} 筆登記 → " + f"{len(missing)} missing / {len(mismatch)} mismatch / " + f"{len(unregistered)} unregistered(純唯讀,未改任何檔)", file=sys.stderr) + raise SystemExit(1) + print(f"verify: {len(m['files'])} 筆登記全部對得上,raw/ 無未登記檔案") + + def process_local(root, m, path, dry, vision=False): src = pathlib.Path(path).resolve() if not src.is_file(): @@ -208,7 +260,9 @@ def process_url(root, m, url, dry): "added_at": _now(), "status": "converted", "source_url": url, "fetched_at": _now()} if not dry: - (originals / name).write_text(html, encoding="utf-8") # 完整快照(來源可能消失) + # write_bytes:同 §_register_pair,登記 hash 算在 html.encode() 上, + # 避免 Windows 換行轉換讓快照 bytes 與登記 hash 不符(完整快照,來源可能消失) + (originals / name).write_bytes(html.encode("utf-8")) md_text, status = from_web.extract(html) if status != "ok": orig_entry["status"] = "pending" @@ -223,7 +277,10 @@ def process_url(root, m, url, dry): def main(argv=None): ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("inputs", nargs="+", help="檔案路徑或 http(s) URL") + ap.add_argument("inputs", nargs="*", help="檔案路徑或 http(s) URL") + ap.add_argument("--verify", action="store_true", + help="對帳模式:re-hash 全部登記檔、掃出未登記檔,純唯讀不改檔;" + "有不一致以退出碼 1 表示(不吃 inputs)") ap.add_argument("--dry-run", action="store_true") ap.add_argument("--vision", action="store_true", help="掃描/圖片型 PDF(needs_ocr)改走本地 vision 模型轉錄," @@ -234,6 +291,11 @@ def main(argv=None): a = ap.parse_args(argv) root = pathlib.Path(a.root).resolve() m = load_manifest(root) + if a.verify: + verify(root, m) + return + if not a.inputs: + ap.error("需指定至少一個檔案/URL,或改用 --verify 對帳") failed = [] for item in a.inputs: try: diff --git a/tools/convert/selfcheck.py b/tools/convert/selfcheck.py index bddd5fc..168a571 100644 --- a/tools/convert/selfcheck.py +++ b/tools/convert/selfcheck.py @@ -158,6 +158,45 @@ def main(): assert (root / "raw/manifest.json").read_text(encoding="utf-8") == before print("PASS: 冪等(同 hash 跳過)") + # --verify 對帳(純唯讀):clean 通過、三類意外都抓到、且完全不改 manifest。 + # root 已有 docx/pdf/pptx 抽出的 assets(不入帳本)——clean 案例即證明 assets 不被誤報。 + def run_verify(rt): + mm = json.loads((rt / "raw/manifest.json").read_text(encoding="utf-8")) + err, code = io.StringIO(), 0 + with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): + try: + convert.verify(rt, mm) + except SystemExit as e: + code = e.code + return code, err.getvalue() + + before = (root / "raw/manifest.json").read_text(encoding="utf-8") + code, _ = run_verify(root) + assert code == 0, "clean 帳本應通過對帳(含未入帳本的 assets)" + + rroot = tmp / "root_del" # missing:刪掉一個 converted .md + shutil.copytree(root, rroot) + gone = next(p for p in (rroot / "raw/converted").glob("*.md")) + gone.unlink() + code, log = run_verify(rroot) + assert code == 1 and "missing" in log and gone.name in log, log + + rroot = tmp / "root_mut" # mismatch:就地改動一個 original 的 bytes + shutil.copytree(root, rroot) + tampered = next(p for p in (rroot / "raw/originals").iterdir() if p.is_file()) + tampered.write_bytes(tampered.read_bytes() + b"tampered") + code, log = run_verify(rroot) + assert code == 1 and "mismatch" in log, log + + rroot = tmp / "root_unreg" # unregistered:raw/originals 塞入未登記檔 + shutil.copytree(root, rroot) + (rroot / "raw/originals/未登記 檔.docx").write_bytes(b"stray") + code, log = run_verify(rroot) + assert code == 1 and "unregistered" in log and "未登記 檔.docx" in log, log + + assert (root / "raw/manifest.json").read_text(encoding="utf-8") == before # 全程唯讀 + print("PASS: --verify 對帳(clean/missing/mismatch/unregistered,純唯讀)") + # dry-run:全新沙盒不落地 root2 = tmp / "root2" for sub in ("raw/originals", "raw/converted"): diff --git a/tools/diag_refs.py b/tools/diag_refs.py new file mode 100644 index 0000000..40a88dc --- /dev/null +++ b/tools/diag_refs.py @@ -0,0 +1,134 @@ +"""source_ref ↔ manifest 對帳診斷(AGENTS.md §7 schema / §14 對帳的輔助工具)。 + +lint.py 對「source_ref 不在 manifest」只報一句,無法區分成因;本工具把每個 +對不上的 source_ref 分類成可據以修復的型別(見 CAT_TITLE / FIX),讓「帳本掉 +條目」「路徑字串岔開」「檔名 hash 後綴岔開」「檔真的遺失」彼此不再混為一談。 + +純唯讀——不改任何檔(含 manifest),有對不上以結束碼 1 表示(可當搬機/改 +manifest 後的閘門)。分類邏輯與 lint 一致:以最後一個 '#' 切出 path 與 sha8。 + +用法:python tools/diag_refs.py [--root DIR] +結束碼:0 = 全部解得開,1 = 有對不上。 +""" +import argparse +import json +import pathlib +import re +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).parent)) +import kb + +CAT_TITLE = { + "hash-mismatch": "Hash 不符(帳本有此路徑,但 sha256 與 ref 不同)", + "normalize": "路徑字串岔開(正規化後與帳本 key 相同:斜線/前綴/大小寫)", + "stem": "檔名 hash 後綴岔開(帳本有同檔名幹、hash 後綴不同的 key)", + "unregistered": "檔在磁碟、帳本無此條目", + "absent": "完全無對應(帳本、磁碟、檔名幹皆無)", +} +FIX = { + "hash-mismatch": "raw 被就地改動或重轉(違反 §1.4);還原檔案,或連同 source_ref 走 PR(§14.2)。", + "normalize": "修產生岔開字串的那一個環節(路徑正規化),別逐頁改 source_ref(§12 change the ruler)。", + "stem": "source_ref 指到不同 hash 後綴的檔名;核對正確檔名,別改帳本去遷就(§14.2)。", + "unregistered": "帳本掉了條目、檔還在;走 branch+PR 補登 manifest(§14.3),別動 wiki 頁。", + "absent": "source_ref 可能攝入時寫歪,或原始檔真的遺失;上報 HITL 據實記錄(§14.2)。", +} +# 分類輸出順序(愈可機械修復的排愈前) +ORDER = ["hash-mismatch", "normalize", "stem", "unregistered", "absent"] + + +def _norm(path): + """正規化路徑字串以偵測「同一檔、不同寫法」:反斜線→斜線、去開頭 ./、轉小寫。""" + s = path.replace("\\", "/") + if s.startswith("./"): + s = s[2:] + return s.lower() + + +def _stem(path): + """取檔名幹:去目錄、去 .md、去結尾的 -<8 碼 hex>(convert 的檔名 hash 後綴)。""" + name = path.replace("\\", "/").rsplit("/", 1)[-1] + name = re.sub(r"\.md$", "", name, flags=re.I) + return re.sub(r"-[0-9a-f]{8}$", "", name, flags=re.I).lower() + + +def classify_ref(source_ref, files, exists): + """把單一 source_ref 分類。files:manifest['files'](path→entry); + exists(path)→bool 判磁碟是否有該檔。回傳 (category, detail)。 + category 為 "resolved" 或 CAT_TITLE 的任一鍵。""" + path, _, sha8 = str(source_ref).rpartition("#") + entry = files.get(path) + if entry is not None: + if entry["sha256"].startswith(sha8): + return "resolved", path + return "hash-mismatch", f"帳本 sha256={entry['sha256'][:8]} ≠ ref #{sha8}" + norm = _norm(path) + for k in files: + if _norm(k) == norm: + return "normalize", f"帳本 key = '{k}'" + stem = _stem(path) + for k, e in files.items(): + if e.get("kind") == "converted" and _stem(k) == stem: + return "stem", f"帳本 key = '{k}'" + if exists(path): + return "unregistered", f"磁碟有 '{path}',帳本無此 key" + return "absent", f"'{path}' 帳本無、磁碟無、無同名幹 key" + + +def collect(root, files, exists): + """掃全庫 wiki 頁,回傳 (findings, n_refs)。findings:category→[(page, ref, detail)]。""" + findings = {c: [] for c in ORDER} + n_refs = 0 + for p in kb.iter_pages(root): + rel = p.relative_to(root).as_posix() + try: + meta, _ = kb.parse_page(p.read_text(encoding="utf-8")) + except Exception as e: + findings["absent"].append((rel, "(無法解析頁面)", str(e))) + continue + for s in meta.get("sources") or []: + n_refs += 1 + cat, detail = classify_ref(s, files, exists) + if cat != "resolved": + findings[cat].append((rel, str(s), detail)) + return findings, n_refs + + +def format_report(findings, n_refs): + total = sum(len(v) for v in findings.values()) + lines = [f"檢查 source_ref:{n_refs},對不上:{total}", ""] + if total == 0: + lines.append("全部解得開(source_ref 與 manifest 一致)。") + return "\n".join(lines) + for cat in ORDER: + items = findings[cat] + if not items: + continue + lines.append(f"## {CAT_TITLE[cat]}({len(items)})") + lines.append(f"→ 修法:{FIX[cat]}") + for page, ref, detail in items: + lines.append(f" - [{page}] {ref}") + lines.append(f" {detail}") + lines.append("") + return "\n".join(lines) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--root", default=str(kb.ROOT)) + a = ap.parse_args(argv) + root = pathlib.Path(a.root).resolve() + mpath = root / "raw" / "manifest.json" + try: + files = json.loads(mpath.read_text(encoding="utf-8"))["files"] + except (OSError, KeyError, json.JSONDecodeError) as e: + raise SystemExit(f"讀不到 / 解析不了 manifest:{mpath}({e})") + + findings, n_refs = collect(root, files, lambda p: (root / p).is_file()) + print(format_report(findings, n_refs)) + if sum(len(v) for v in findings.values()): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/ingest.py b/tools/ingest.py index e065bf8..eacf11f 100644 --- a/tools/ingest.py +++ b/tools/ingest.py @@ -123,6 +123,8 @@ def summarize(cfg, filename, text): parts = split_chunks(text, limit) partials = [] for i, part in enumerate(parts, 1): + # 長文件的分段迴圈是最容易「長時間靜默」的地方(issue #2):逐段回報進度 + print(f" 分段 {i}/{len(parts)} …", flush=True) obj, _ = kb.llm_json(cfg, "ingest", CHUNK_PROMPT.format(i=i, n=len(parts), content=part), validate_chunk) @@ -170,14 +172,16 @@ def upsert_item(root, cfg, item, source_ref, today, changed): if source_ref not in meta["sources"]: meta["sources"].append(source_ref) existing.write_text(kb.dump_page(meta, obj["updated_markdown"]), encoding="utf-8") - changed.append(f"更新 {existing.relative_to(root).as_posix()}") + msg = f"更新 {existing.relative_to(root).as_posix()}" else: p = root / kb.PAGE_DIRS[item["type"]] / f"{slug}.md" meta = {"type": item["type"], "title": item["title"], "description": item["description"], "tags": item["tags"], "timestamp": today, "sources": [source_ref], "status": "draft"} p.write_text(kb.dump_page(meta, item["facts_markdown"]), encoding="utf-8") - changed.append(f"新增 {p.relative_to(root).as_posix()}") + msg = f"新增 {p.relative_to(root).as_posix()}" + changed.append(msg) + print(f" ↳ {msg}", flush=True) # ---------- git ---------- @@ -240,7 +244,7 @@ def main(argv=None): skipped = [t for t in targets if conv[t]["sha256"][:8] in done] targets = [t for t in targets if conv[t]["sha256"][:8] not in done] for t in skipped: - print(f"skip: {t} 已有 summary 引用(--force 可重跑)") + print(f"skip: {t} 已有 summary 引用(--force 可重跑)", flush=True) if not targets: print("沒有待攝入的來源。") return @@ -261,21 +265,24 @@ def main(argv=None): run_git(root, "checkout", "-q", "-b", branch) ok, failed, changed = [], [], [] try: - for t in targets: + for i, t in enumerate(targets, 1): try: + print(f"[{i}/{len(targets)}] 攝入 {t} …", flush=True) text = (root / t).read_text(encoding="utf-8") sha8 = conv[t]["sha256"][:8] source_ref = f"{t}#{sha8}" obj, model = summarize(cfg, pathlib.Path(conv[t]["original_path"]).name, text) p = write_summary_page(root, obj, source_ref, sha8, today) - changed.append(f"新增 {p.relative_to(root).as_posix()}") + msg = f"新增 {p.relative_to(root).as_posix()}" + changed.append(msg) + print(f" ↳ {msg}", flush=True) for item in obj["knowledge_items"]: upsert_item(root, cfg, item, source_ref, today, changed) ok.append((t, model)) - print(f"ingested: {t}(model={model})") + print(f" ✓ {t} 完成(model={model})", flush=True) except Exception as e: failed.append((t, e)) - print(f"error: {t}: {e}", file=sys.stderr) + print(f" ✗ {t}: {e}", file=sys.stderr, flush=True) if not ok: run_git(root, "checkout", "-q", "main") run_git(root, "branch", "-q", "-D", branch) @@ -297,6 +304,16 @@ def main(argv=None): print(f"完成:{len(ok)} 成功、{len(failed)} 失敗。變更在分支 {branch},經 PR 審核後合併。") except SystemExit: raise + except KeyboardInterrupt: + # KeyboardInterrupt 不是 Exception 子類,會略過下方善後——故獨立攔截。 + # 中斷時尚未 commit(commit 在迴圈之後),main 必然未受影響;給一條可照做的回復路徑。 + print(f"\n已中斷(Ctrl-C)。main 未受影響——本次半成品都在分支 {branch}、尚未 commit。\n" + f"回到乾淨的 main:\n" + f" git checkout main && git stash -u && git branch -D {branch}\n" + f"(git stash -u 收起分支上未提交的半成品含未追蹤頁面;確定不要再 git stash drop)\n" + f"續跑 python tools/ingest.py --all-pending 會自動從尚未攝入的來源接續。", + file=sys.stderr, flush=True) + raise SystemExit(130) except Exception: print(f"攝入中斷。目前在分支 {branch},工作區可能有未提交變更," "請人工檢查(不自動清除以免遺失資料)。", file=sys.stderr) diff --git a/tools/selfcheck_diag_refs.py b/tools/selfcheck_diag_refs.py new file mode 100644 index 0000000..37de4fc --- /dev/null +++ b/tools/selfcheck_diag_refs.py @@ -0,0 +1,114 @@ +"""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() diff --git a/tools/selfcheck_ingest.py b/tools/selfcheck_ingest.py index 514fa4c..5abb067 100644 --- a/tools/selfcheck_ingest.py +++ b/tools/selfcheck_ingest.py @@ -5,11 +5,13 @@ 執行:.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 @@ -82,9 +84,14 @@ def main(): git(root, "add", "-A") git(root, "commit", "-q", "-m", "init") - # 1) 攝入:建分支、產頁、commit、回到 main + # 1) 攝入:建分支、產頁、commit、回到 main(並驗進度輸出可見,issue #2) kb.llm_json = fake_llm - ingest.main(["raw/converted/doc1.md", "--root", str(root)]) + 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 分支" @@ -147,6 +154,45 @@ def main(): 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