Files
pp-qa-km/tools/convert/selfcheck.py
LittleYellow 0b4c2bbb15 修復中文路徑 preflight 與 BM25 核心詞落空,測資改照現實建
兩個 bug 同源於自檢測資「照能過建、不照現實建」:

- ingest preflight:git status --porcelain 預設會把含中文或空格的路徑加引號
  轉義,raw/ 白名單比對因此失效,中文檔名的 convert 產出被誤判為 raw/ 以外的
  未提交變更而中止攝入。舊測資用 ASCII 檔名,整條路徑從未被走過。

- search:BM25Okapi 的 idf 在 df >= N/2 時 <= 0(rank_bm25 對負 idf 的替代值
  epsilon x average_idf 在 average_idf 為負時同樣為負),與 search() 的 s > 0
  過濾相乘,會讓「每頁都提到的核心詞」查詢全數落空——知識庫愈小、詞愈核心
  愈嚴重,MCP 的 search_wiki 一併受害。改用 Lucene 式恆正 idf。
  舊測資每個查詢詞都只出現在一頁(df=1),恰好避開此配置。

變更:
- tools/ingest.py:preflight 比對前剝除 git 的轉義引號
- tools/search.py:_BM25 子類覆寫 _calc_idf 為 log(1 + (N-df+0.5)/(df+0.5))
- tools/selfcheck_ingest.py:新增 preflight 中文檔名放行 / 非 raw 擋下的檢查
- tools/selfcheck_search.py:新增與既有頁共用核心詞的測資,鎖住 idf 退化
- tools/convert/selfcheck.py、tools/selfcheck_lint.py:測資檔名改中文+空格
- AGENTS.md §13.4:新增「測資照現實建,不是照能過建」規則
- README.md §3.2:補 convert 產出應保持未提交、由 ingest 一併 commit 的流程

驗證:四個自檢全數通過;分別移除兩個修法後對應檢查會失敗。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:21:45 +08:00

240 lines
11 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 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 = ("壓測 報告.docx", "測試案例 清單.xlsx",
"規格 說明.pdf", "掃描件 無文字層.pdf")
PPTX, HTML, TXT, DOC = ("結案 簡報.pptx", "指引 快照.html",
"不支援 筆記.txt", "舊版 報告.doc")
def make_fixtures(d):
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.save(d / DOCX)
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "測項"
ws.append(["編號", "說明"])
ws.append(["TC-001", "逾時 | 重試"])
wb.save(d / XLSX)
import fitz
pdf = fitz.open()
page = pdf.new_page()
page.insert_text((72, 72), "Payment gateway timeout defect reproduced under load test.")
pdf.save(d / PDF)
pdf.close()
scanned = fitz.open()
scanned.new_page() # 無文字層 → 應判 needs_ocr
scanned.save(d / SCAN)
scanned.close()
from pptx import Presentation
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1])
slide.shapes.title.text = "結案報告"
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 / SCAN),
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) == 3 and len(convs) == 2, m
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 對應")
# 冪等:同檔再跑一次 → 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 跳過)")
# 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
print("PASS: convert.py --vision 端到端needs_ocr → 本地 vision 轉錄)")
print("ALL PASS")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()