feat: convert --verify 對帳 + 修復 CRLF 使 hash 失準 + raw/** .gitattributes (#1)
## 目的 `convert` 後檔案被手動刪除/就地改動時,提供正規的稽核與修復途徑;並修掉一個讓「還原後 re-hash 比對」在 Windows 失效的既有 bug。 ## 變更 - **`convert.py --verify`(純唯讀對帳)**:re-hash 全部登記檔、掃未登記檔,回報 missing / mismatch / unregistered,不一致以退出碼 1 表示(可當 CI/排程閘門)。`converted/assets/*` 與 `.gitkeep` 正確略過。 - **修 CRLF 使 hash 失準**:`write_text` 在 Windows 把 `\n`→`\r\n`,但登記的 SHA-256 算在 `\n` bytes 上 → 磁碟 bytes 與帳本永遠對不上(converted md + web 快照原始檔)。改為 `write_bytes` 寫入所登記的那份 bytes。 - **`.gitattributes`(`raw/** -text`)**:`core.autocrlf=true` 下 checkout 會在 git 層重新引入 CRLF,抵銷上一項修復;關閉 raw 的換行正規化,把「登記 hash == 磁碟 bytes」不變式延伸到 git checkin/checkout。 - **selfcheck**:補 clean / missing / mismatch / unregistered 四情境(全程唯讀斷言;clean 案例含未入帳本的 assets,順帶證明不誤報)。 - **AGENTS.md**:新增 §14「raw 完整性與修復(對帳)」,並於 §1.4 補「登記 hash == 磁碟 bytes」不變式與 `.gitattributes` 機制。 ## 驗證 `.venv/Scripts/python.exe tools/convert/selfcheck.py` → `ALL PASS`。 ## 備註 `--verify` 是唯讀稽核工具,不改任何檔(含 manifest),符合 §7「lint 絕不擅自刪改」精神。修復決策(可衍生 vs 信任根、先 `git restore` 不改帳本、動 manifest 走 PR)詳見 AGENTS.md §14。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: LittleYellow <crazytea@gmail.com> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
@@ -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
|
||||||
47
AGENTS.md
47
AGENTS.md
@@ -26,7 +26,10 @@
|
|||||||
EmbeddingGemma(Google)或 snowflake-arctic-embed(Snowflake)。
|
EmbeddingGemma(Google)或 snowflake-arctic-embed(Snowflake)。
|
||||||
4. **raw 層不可變**:`raw/` 內的文件只讀不改。原始檔與轉換後 Markdown 皆登記
|
4. **raw 層不可變**:`raw/` 內的文件只讀不改。原始檔與轉換後 Markdown 皆登記
|
||||||
SHA-256 至 `raw/manifest.json`(結構見 §9),轉換檔條目必須回指原始檔 hash。
|
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 形式提交,經人工審核後才
|
5. **HITL 閘門**:所有 wiki 層的變更以 git branch + PR 形式提交,經人工審核後才
|
||||||
合併至 main。攝入腳本永遠不直接 commit 到 main。lint 絕不擅自刪檔,一律標記
|
合併至 main。攝入腳本永遠不直接 commit 到 main。lint 絕不擅自刪檔,一律標記
|
||||||
待人工核准。
|
待人工核准。
|
||||||
@@ -300,3 +303,45 @@ output**)。單筆修正只治標;規則修正才治本。
|
|||||||
**有效的跡象**:diff 裡不必要的變更變少、因過度複雜而重寫的次數變少、
|
**有效的跡象**:diff 裡不必要的變更變少、因過度複雜而重寫的次數變少、
|
||||||
釐清問題發生在動手**之前**而非犯錯之後、刻意的捷徑是可見的(`ponytail:`)
|
釐清問題發生在動手**之前**而非犯錯之後、刻意的捷徑是可見的(`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 信任根)
|
||||||
|
|
||||||
|
<!-- ASSUMPTION: 本決策樹為推論的正規流程,原始需求只定義 raw 不可變與 hash 登記;
|
||||||
|
依「可衍生(converted)優先還原、信任根(original)不可再生」的性質分流。 -->
|
||||||
|
|
||||||
|
1. **先還原,不改帳本**(多數「誤刪」到此為止):`raw/` 進版控,`git restore <path>`
|
||||||
|
取回精確 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 一併更新引用。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
用法:python tools/convert/convert.py <檔案路徑或URL>... [--vision] [--dry-run] [--root DIR]
|
用法:python tools/convert/convert.py <檔案路徑或URL>... [--vision] [--dry-run] [--root DIR]
|
||||||
支援 .docx/.xlsx/.pdf/.pptx/.html 與 URL;舊版 .doc/.xls/.ppt 經 LibreOffice 升版;
|
支援 .docx/.xlsx/.pdf/.pptx/.html 與 URL;舊版 .doc/.xls/.ppt 經 LibreOffice 升版;
|
||||||
--vision 讓掃描/圖片型 PDF 走本地 vision 模型轉錄(見 from_vision.py)。
|
--vision 讓掃描/圖片型 PDF 走本地 vision 模型轉錄(見 from_vision.py)。
|
||||||
|
--verify 對帳模式:re-hash 全部登記檔、掃出未登記檔,純唯讀不改檔,供刪除/竄改後稽核。
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import datetime
|
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)
|
print(f"warning: {orig_rel} 轉換結果為空白,請人工檢查來源", file=sys.stderr)
|
||||||
if not dry:
|
if not dry:
|
||||||
md_path = root / md_rel
|
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"][orig_rel] = orig_entry
|
||||||
m["files"][md_rel] = {
|
m["files"][md_rel] = {
|
||||||
"kind": "converted",
|
"kind": "converted",
|
||||||
@@ -130,6 +134,54 @@ def _vision_pdf(root, pdf_path, assets, conv_name):
|
|||||||
return "\n\n".join(blocks), "ok", "from_vision"
|
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):
|
def process_local(root, m, path, dry, vision=False):
|
||||||
src = pathlib.Path(path).resolve()
|
src = pathlib.Path(path).resolve()
|
||||||
if not src.is_file():
|
if not src.is_file():
|
||||||
@@ -208,7 +260,9 @@ def process_url(root, m, url, dry):
|
|||||||
"added_at": _now(), "status": "converted",
|
"added_at": _now(), "status": "converted",
|
||||||
"source_url": url, "fetched_at": _now()}
|
"source_url": url, "fetched_at": _now()}
|
||||||
if not dry:
|
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)
|
md_text, status = from_web.extract(html)
|
||||||
if status != "ok":
|
if status != "ok":
|
||||||
orig_entry["status"] = "pending"
|
orig_entry["status"] = "pending"
|
||||||
@@ -223,7 +277,10 @@ def process_url(root, m, url, dry):
|
|||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
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("--dry-run", action="store_true")
|
||||||
ap.add_argument("--vision", action="store_true",
|
ap.add_argument("--vision", action="store_true",
|
||||||
help="掃描/圖片型 PDF(needs_ocr)改走本地 vision 模型轉錄,"
|
help="掃描/圖片型 PDF(needs_ocr)改走本地 vision 模型轉錄,"
|
||||||
@@ -234,6 +291,11 @@ def main(argv=None):
|
|||||||
a = ap.parse_args(argv)
|
a = ap.parse_args(argv)
|
||||||
root = pathlib.Path(a.root).resolve()
|
root = pathlib.Path(a.root).resolve()
|
||||||
m = load_manifest(root)
|
m = load_manifest(root)
|
||||||
|
if a.verify:
|
||||||
|
verify(root, m)
|
||||||
|
return
|
||||||
|
if not a.inputs:
|
||||||
|
ap.error("需指定至少一個檔案/URL,或改用 --verify 對帳")
|
||||||
failed = []
|
failed = []
|
||||||
for item in a.inputs:
|
for item in a.inputs:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -158,6 +158,45 @@ def main():
|
|||||||
assert (root / "raw/manifest.json").read_text(encoding="utf-8") == before
|
assert (root / "raw/manifest.json").read_text(encoding="utf-8") == before
|
||||||
print("PASS: 冪等(同 hash 跳過)")
|
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:全新沙盒不落地
|
# dry-run:全新沙盒不落地
|
||||||
root2 = tmp / "root2"
|
root2 = tmp / "root2"
|
||||||
for sub in ("raw/originals", "raw/converted"):
|
for sub in ("raw/originals", "raw/converted"):
|
||||||
|
|||||||
Reference in New Issue
Block a user