From f9a0cd2369c3c4310c708893657709c371327672 Mon Sep 17 00:00:00 2001 From: LittleYellow Date: Thu, 23 Jul 2026 08:21:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20convert=20=E6=96=B0=E5=A2=9E=20--ve?= =?UTF-8?q?rify=20=E5=B0=8D=E5=B8=B3=E6=A8=A1=E5=BC=8F=20+=20=E4=BF=AE?= =?UTF-8?q?=E5=BE=A9=20Windows=20CRLF=20=E4=BD=BF=20hash=20=E5=A4=B1?= =?UTF-8?q?=E6=BA=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - convert.py --verify:純唯讀 re-hash 全部登記檔並掃未登記檔, 報 missing/mismatch/unregistered,不一致退出碼 1(供刪除/竄改後稽核) - 修 write_text 在 Windows 轉 \n→\r\n 使磁碟 bytes 與登記 SHA-256 不符 (converted md 與 web 快照原始檔),改 write_bytes 寫入所登記的那份 bytes - selfcheck 補 clean/missing/mismatch/unregistered 四情境(全程唯讀斷言) - AGENTS.md §14 raw 完整性與修復流程 + §1.4 hash==磁碟 bytes 不變式 Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 46 +++++++++++++++++++++++++- tools/convert/convert.py | 68 ++++++++++++++++++++++++++++++++++++-- tools/convert/selfcheck.py | 39 ++++++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 09c0b2c..6be8464 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,9 @@ 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 影響);完整性以 §14 `--verify` 稽核。 5. **HITL 閘門**:所有 wiki 層的變更以 git branch + PR 形式提交,經人工審核後才 合併至 main。攝入腳本永遠不直接 commit 到 main。lint 絕不擅自刪檔,一律標記 待人工核准。 @@ -300,3 +302,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/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"): -- 2.49.1 From d41cb49a45447f2a222d443b6b92e117dc7ef164 Mon Sep 17 00:00:00 2001 From: LittleYellow Date: Thu, 23 Jul 2026 08:23:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=8A=A0=20.gitattributes=20?= =?UTF-8?q?=E5=B0=8D=20raw/**=20=E9=97=9C=E9=96=89=E6=8F=9B=E8=A1=8C?= =?UTF-8?q?=E6=AD=A3=E8=A6=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.autocrlf=true 下,已 commit 的 raw/converted/*.md 於 Windows checkout 會被 git 轉成 CRLF,使磁碟 bytes 與 manifest 登記的 LF-hash 不符、--verify 誤報 mismatch——在 git 層抵銷了轉換器的 write_bytes 修復。以 `raw/** -text` 把「登記 hash == 磁碟 bytes」的不變式延伸到 git 的 checkin/checkout。 AGENTS.md §1.4 補記此機制。 Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 8 ++++++++ AGENTS.md | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .gitattributes 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 6be8464..c301c64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,8 @@ SHA-256 至 `raw/manifest.json`(結構見 §9),轉換檔條目必須回指原始檔 hash。 wiki 頁面引用來源必須帶 `source_ref`(格式見 §3.3)。登記的 hash 必須等於檔案在 磁碟上的實際 bytes(UTF-8,換行不轉換——轉換器一律以 write_bytes 寫入所登記的 - 那份 bytes,不受平台 CRLF 影響);完整性以 §14 `--verify` 稽核。 + 那份 bytes,不受平台 CRLF 影響;git 亦以 `.gitattributes` 對 `raw/**` 關閉換行 + 正規化,避免 checkout 重新引入 CRLF);完整性以 §14 `--verify` 稽核。 5. **HITL 閘門**:所有 wiki 層的變更以 git branch + PR 形式提交,經人工審核後才 合併至 main。攝入腳本永遠不直接 commit 到 main。lint 絕不擅自刪檔,一律標記 待人工核准。 -- 2.49.1