Files
pp-qa-km/tools/convert/selfcheck.py
yellowadmin 356ae9367c 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
2026-07-23 02:27:47 +00:00

316 lines
15 KiB
Python
Raw Permalink 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 2 自檢:程式化產生各格式最小測試檔,驗證轉換器與 convert.py 端到端。
執行:.venv/Scripts/python tools/convert/selfcheck.py
邏輯壞掉時 assert 會失敗AGENTS.md §13.4:一個可執行的檢查,不用框架)。
"""
import contextlib
import io
import json
import pathlib
import shutil
import sys
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # tools/ 供 import kb
sys.path.insert(0, str(pathlib.Path(__file__).parent)) # tools/convert
import convert
import from_docx
import from_pdf
import from_pptx
import from_vision
import from_web
import from_xlsx
import kb
import to_image
# 測資檔名一律用「中文 + 空格」——真實來源檔就長這樣,而 git status --porcelain
# 對這兩者都會加引號轉義。ASCII 檔名的測資曾讓 ingest 的 preflight bug 溜過§12
DOCX, XLSX, PDF, SCAN = ("壓測 報告(1).docx", "測試案例 清單.xlsx",
"規格 說明.pdf", "掃描件(2 期.pdf")
PPTX, HTML, TXT, DOC = ("結案 簡報.pptx", "指引 快照.html",
"不支援 筆記.txt", "舊版 報告.doc")
def assert_links_ok(md, base):
"""每個圖片連結都要真的 render 成 <img>、且目標檔存在。
只斷言 "![" 在不在會讓損毀連結矇混過關——含空格/不平衡括號的檔名會產出
語法有效但解析失敗的連結圖檔明明在磁碟上卻整行退化成純文字§13.4)。
"""
import re
import urllib.parse
from markdown_it import MarkdownIt
want = md.count("![")
html = MarkdownIt("commonmark").render(md)
got = html.count("<img")
assert got == want, f"{want} 個圖片連結只有 {got} 個 render 成功:\n{md[:300]}"
for m in re.finditer(r'<img src="([^"]+)"', html):
p = base / urllib.parse.unquote(m.group(1))
assert p.is_file(), f"連結目標不存在:{p}"
return want
def make_fixtures(d):
import fitz
png = d / "img.png" # 內嵌圖片用docx/pdf/pptx 三條抽圖路徑都要走到
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 8, 8))
pix.clear_with(128)
pix.save(str(png))
import docx as docxlib
doc = docxlib.Document()
doc.add_heading("支付閘道測試報告", level=1)
doc.add_paragraph("逾時缺陷於壓力測試重現。")
t = doc.add_table(rows=2, cols=2)
t.rows[0].cells[0].text = "案例"
t.rows[0].cells[1].text = "結果"
t.rows[1].cells[0].text = "TC-001"
t.rows[1].cells[1].text = "FAIL"
doc.add_picture(str(png))
doc.save(d / DOCX)
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "測項"
ws.append(["編號", "說明"])
ws.append(["TC-001", "逾時 | 重試"])
wb.save(d / XLSX)
pdf = fitz.open()
page = pdf.new_page()
page.insert_text((72, 72), "Payment gateway timeout defect reproduced under load test.")
page.insert_image(fitz.Rect(72, 100, 122, 150), filename=str(png))
pdf.save(d / PDF)
pdf.close()
scanned = fitz.open()
scanned.new_page() # 無文字層 → 應判 needs_ocr
scanned.save(d / SCAN)
scanned.close()
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1])
slide.shapes.title.text = "結案報告"
slide.shapes.add_picture(str(png), Inches(1), Inches(3))
slide.notes_slide.notes_text_frame.text = "備註:法遵項目全數通過"
prs.save(d / PPTX)
(d / HTML).write_text(
"<html><body><article><h1>AML 測試指引</h1>"
+ "<p>可疑交易監控測試需涵蓋大額交易與分散交易兩種樣態。</p>" * 8
+ "</article></body></html>", encoding="utf-8")
def main():
tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-selfcheck-"))
try:
fx = tmp / "fx"
fx.mkdir()
make_fixtures(fx)
md, st = from_docx.convert(fx / DOCX)
assert st == "ok" and "# 支付閘道測試報告" in md and "| TC-001 | FAIL |" in md, md
md, st = from_xlsx.convert(fx / XLSX)
assert st == "ok" and "## 工作表:測項" in md and "逾時 \\| 重試" in md, md
md, st = from_pdf.convert(fx / PDF)
assert st == "ok" and "<!-- page 1 -->" in md and "timeout defect" in md, md
_, st = from_pdf.convert(fx / SCAN)
assert st == "needs_ocr", st
md, st = from_pptx.convert(fx / PPTX)
assert st == "ok" and "## Slide 1" in md and "法遵項目全數通過" in md, md
md, st = from_web.extract((fx / HTML).read_text(encoding="utf-8"))
assert st == "ok" and "可疑交易監控" in md, md
print("PASS: 5 個轉換器 + needs_ocr 偵測")
# convert.py 端到端(沙盒 root
root = tmp / "root"
for sub in ("raw/originals", "raw/converted"):
(root / sub).mkdir(parents=True)
(root / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
convert.main([str(fx / DOCX), str(fx / XLSX), str(fx / PDF), str(fx / SCAN),
str(fx / PPTX), str(fx / HTML), "--root", str(root)])
m = json.loads((root / "raw/manifest.json").read_text(encoding="utf-8"))
origs = {k: v for k, v in m["files"].items() if v["kind"] == "original"}
convs = {k: v for k, v in m["files"].items() if v["kind"] == "converted"}
assert len(origs) == 6 and len(convs) == 5, m # SCAN 為 needs_ocr無 converted
assert origs[f"raw/originals/{SCAN}"]["status"] == "needs_ocr"
for k, v in convs.items():
assert (root / k).is_file(), k
assert m["files"][v["original_path"]]["sha256"] == v["original_sha256"]
assert (root / f"raw/originals/{DOCX}").is_file()
print("PASS: convert.py 端到端 + manifest 對應")
# 圖片連結必須真的能 render——DOCX/PDF/PPTX 三條抽圖路徑都要驗到,
# 且測資檔名刻意含空格與不平衡括號(最容易讓連結失效的形狀)
linked = sum(assert_links_ok((root / k).read_text(encoding="utf-8"),
root / "raw/converted") for k in convs)
assert linked >= 3, f"應至少驗到 docx/pdf/pptx 各一個圖片連結,實際 {linked}"
print(f"PASS: 圖片連結可 render 且目標存在({linked} 個,檔名含空格/括號)")
# 冪等:同檔再跑一次 → skipmanifest 不變
before = (root / "raw/manifest.json").read_text(encoding="utf-8")
convert.main([str(fx / DOCX), "--root", str(root)])
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" # unregisteredraw/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"):
(root2 / sub).mkdir(parents=True)
(root2 / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
convert.main([str(fx / DOCX), "--dry-run", "--root", str(root2)])
m2 = json.loads((root2 / "raw/manifest.json").read_text(encoding="utf-8"))
assert m2["files"] == {} and not list((root2 / "raw/originals").iterdir())
print("PASS: --dry-run 不落地")
# 失敗回報:壞輸入不中斷批次,錯誤帶例外型別 + 尾端彙總,好檔仍轉換
root3 = tmp / "root3"
for sub in ("raw/originals", "raw/converted"):
(root3 / sub).mkdir(parents=True)
(root3 / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
(fx / TXT).write_text("不支援的格式", encoding="utf-8") # → ValueError
err = io.StringIO()
code = None
with contextlib.redirect_stderr(err):
try:
convert.main([str(fx / DOCX), str(fx / TXT),
str(fx / "不存在 檔案.docx"), # 不存在 → FileNotFoundError
"--root", str(root3)])
except SystemExit as e:
code = e.code
log = err.getvalue()
assert code == 1, code
assert "ValueError" in log and "FileNotFoundError" in log, log # 錯誤帶例外型別
assert "2 個項目轉換失敗" in log, log # 尾端彙總
m3 = json.loads((root3 / "raw/manifest.json").read_text(encoding="utf-8"))
assert (root3 / f"raw/originals/{DOCX}").is_file() # 好檔仍轉換並落地
assert any(v["kind"] == "converted" for v in m3["files"].values()), m3
print("PASS: 失敗回報(例外型別 + 尾端彙總,批次不中斷)")
# B: 舊格式路由 —— .doc 應走 legacyLibreOffice而非被當「不支援格式」拒收
root4 = tmp / "root4"
for sub in ("raw/originals", "raw/converted"):
(root4 / sub).mkdir(parents=True)
(root4 / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
(fx / DOC).write_bytes(b"\xd0\xcf\x11\xe0legacy-stub") # 非有效 .doc僅測路由/錯誤
err = io.StringIO()
code = None
with contextlib.redirect_stderr(err):
try:
convert.main([str(fx / DOC), "--root", str(root4)])
except SystemExit as e:
code = e.code
log = err.getvalue()
assert code == 1 and DOC in log, log
assert "不支援的格式" not in log and "RuntimeError" in log, log # 已路由 legacy乾淨報錯
print("PASS: 舊格式路由至 legacysoffice 缺失/升版失敗皆乾淨報錯)")
# A: to_image renderdeterministic真跑
imgs = to_image.render_pdf_pages(fx / PDF, tmp / "imgs")
assert imgs and all(p.is_file() and p.stat().st_size > 0 for p in imgs), imgs
print(f"PASS: to_image render{len(imgs)} 頁 PNG")
# A: from_vision 轉錄邏輯(注入假 Ollama不需真模型+ 合規防護
vis_cfg = {"tasks": {"vision": {"model": "fake-vision", "temperature": 0.1,
"max_retries": 1}}}
orig_chat = kb.ollama_chat
calls = {}
def fake_chat(cfg, model, prompt, temperature, json_format=False, images=None):
calls["images"] = images # 記錄 base64 是否傳入
return "可疑交易監控:大額 12,000 元"
kb.ollama_chat = fake_chat
try:
texts, model = from_vision.transcribe(vis_cfg, imgs[:1])
finally:
kb.ollama_chat = orig_chat
assert model == "fake-vision" and texts and "可疑交易監控" in texts[0], texts
assert calls.get("images"), "vision 呼叫必須帶影像"
for bad in ({"tasks": {}}, {"tasks": {"vision": {"model": "foo:cloud"}}}):
try:
from_vision._require_vision(bad)
assert False, "應拒絕未設定 / :cloud"
except SystemExit:
pass
print("PASS: from_vision 轉錄 + :cloud/未設定防護")
# A: convert.py --vision 端到端(沙盒 config + 注入假 vision
root_v = tmp / "root_v"
(root_v / "config").mkdir(parents=True)
for sub in ("raw/originals", "raw/converted"):
(root_v / sub).mkdir(parents=True)
(root_v / "config/models.yaml").write_text(
'ollama:\n base_url: "http://localhost:11434"\n timeout_seconds: 300\n'
'tasks:\n ingest: {model: "x", temperature: 0.2, max_retries: 1}\n'
' lint: {model: "x", temperature: 0.1, max_retries: 1}\n'
' fallback: {model: "x"}\n'
' vision: {model: "fake-vision", temperature: 0.1, max_retries: 1}\n'
'limits:\n mcp_response_max_tokens: 2000\n ingest_chunk_max_chars: 12000\n',
encoding="utf-8")
(root_v / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
kb.ollama_chat = fake_chat
try:
convert.main([str(fx / SCAN), "--vision", "--root", str(root_v)])
finally:
kb.ollama_chat = orig_chat
mv = json.loads((root_v / "raw/manifest.json").read_text(encoding="utf-8"))
conv_rel = [k for k, v in mv["files"].items() if v["kind"] == "converted"]
assert len(conv_rel) == 1 and mv["files"][conv_rel[0]]["converter"] == "from_vision", mv
md_v = (root_v / conv_rel[0]).read_text(encoding="utf-8")
assert "可疑交易監控" in md_v and "(vision" in md_v and "![page 1]" in md_v, md_v
assert_links_ok(md_v, root_v / "raw/converted") # vision 頁的連結同樣要能 render
print("PASS: convert.py --vision 端到端needs_ocr → 本地 vision 轉錄)")
print("ALL PASS")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()