Files
pp-qa-km/tools/convert/convert.py
LittleYellow 3c1b268074 修復含空格/括號檔名的圖片連結失效,自檢改驗連結可 render
來源檔名含空格或不平衡括號時,convert 產出的 Markdown 圖片連結會失效——
圖檔明明存在於磁碟,整行卻退化成純文字(CommonMark 對未包住的空格與不平衡
括號視為分隔符)。用真 CommonMark parser 驗過:中文與成對括號正常,空格與
落單括號會壞。

- convert.py / from_docx.py / from_pdf.py / from_pptx.py:四處圖片連結目標
  一律以 <> 包住(![name](<rel>)),同時涵蓋空格與不平衡括號。
- selfcheck.py:新增 assert_links_ok()——以 markdown_it 真 render,斷言圖片
  連結數 == <img> 數且目標檔存在,取代原本只檢查 "![" 字串在不在的表面斷言。
  測資補內嵌圖片(docx/pdf/pptx 各一路徑)、檔名改含空格與不平衡括號。
- requirements.txt:明確宣告 markdown-it-py(原為 fastmcp 傳遞依賴,自檢直接
  使用,宣告以免上游調整依賴樹後自檢失效)。
- AGENTS.md §13.4:新增「斷言要驗結果可用,不是驗字串存在」規則。

驗證:四個自檢全通過;移除任一處 <> 修法,assert_links_ok 即失敗。
ingest / lint / search 在含括號檔名下全鏈路已另行驗證正常(source_ref 走
YAML 純量,不受影響)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 06:12:47 +08:00

264 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.
"""統一轉換入口:依副檔名/URL 分派轉換器,登記 SHA-256 至 raw/manifest.json。
流程AGENTS.md §6.1、§9
原始檔存入 raw/originals/ 並登記 hash → 轉換 → Markdown 存入 raw/converted/
並登記 hash 與原始檔對應 → 之後攝入管線只讀 converted 層。
用法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
"""
import argparse
import datetime
import hashlib
import json
import pathlib
import re
import shutil
import sys
import tempfile
import traceback
import urllib.parse
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import from_docx
import from_legacy
import from_pdf
import from_pptx
import from_web
import from_xlsx
ROOT = pathlib.Path(__file__).resolve().parents[2]
CONVERTERS = {
".docx": (from_docx.convert, "from_docx", "docx"),
".xlsx": (from_xlsx.convert, "from_xlsx", "xlsx"),
".pdf": (from_pdf.convert, "from_pdf", "pdf"),
".pptx": (from_pptx.convert, "from_pptx", "pptx"),
}
# 舊版 OLE 二進位 → 現代格式(經 LibreOffice 升版後走上表對應轉換器)。
# <!-- ASSUMPTION: media_type 記真實舊格式doc/xls/ppt擴充 §9 的列舉;
# converter 記為 "from_docx+libreoffice" 等,保留兩段式 provenance。 -->
LEGACY_MAP = {".doc": ".docx", ".xls": ".xlsx", ".ppt": ".pptx"}
def _now():
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
def _slug(s):
s = re.sub(r"[^a-z0-9一-鿿]+", "-", s.lower()).strip("-")
return s or "page"
def load_manifest(root):
p = root / "raw" / "manifest.json"
m = json.loads(p.read_text(encoding="utf-8"))
if m.get("version") != 1 or "files" not in m:
raise SystemExit(f"manifest 結構不符:{p}")
return m
def save_manifest(root, m):
p = root / "raw" / "manifest.json"
tmp = p.with_suffix(".json.tmp")
tmp.write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp.replace(p) # 原子替換,避免寫壞 manifest
def _find_by_sha(m, sha):
for path, e in m["files"].items():
if e["kind"] == "original" and e["sha256"] == sha:
return path
return None
def _dest_name(dir_, name, sha):
"""既有同名檔且內容不同時,附 hash 前 8 碼避免覆蓋raw 不可變)。"""
p = dir_ / name
if p.exists() and hashlib.sha256(p.read_bytes()).hexdigest() != sha:
stem, ext = pathlib.Path(name).stem, pathlib.Path(name).suffix
return f"{stem}-{sha[:8]}{ext}"
return name
def _register_pair(m, root, orig_rel, orig_entry, md_rel, md_text, converter, dry):
if not md_text.strip():
# 空轉換結果幾乎都是失敗(如純圖片 docx登記前示警避免空頁靜默入庫
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")
m["files"][orig_rel] = orig_entry
m["files"][md_rel] = {
"kind": "converted",
"sha256": hashlib.sha256(md_text.encode("utf-8")).hexdigest(),
"original_path": orig_rel,
"original_sha256": orig_entry["sha256"],
"converter": converter,
"converted_at": _now(),
}
print(f"converted: {orig_rel} -> {md_rel}" + (" [dry-run]" if dry else ""))
def _vision_pdf(root, pdf_path, assets, conv_name):
"""--vision fallbackAGENTS.md §3 稽核原則needs_ocr 的 PDF 逐頁 render →
本地 vision 模型轉錄。回傳 (markdown, status, converter)。
成功 → ('...', 'ok', 'from_vision')vision 執行期失敗 → 降級回 needs_ocr不遺失檔案
設定錯誤(未設 vision / :cloud由 from_vision 拋 SystemExit向上傳播不靜默降級。
"""
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # tools/ 供 import kb
import kb
import to_image
import from_vision
cfg = kb.load_config(root)
md_dir = root / "raw" / "converted"
try:
images = to_image.render_pdf_pages(pdf_path, assets)
texts, model = from_vision.transcribe(cfg, images)
except SystemExit:
raise
except Exception as e:
print(f"warning: {pathlib.Path(pdf_path).name} vision 轉錄失敗,降級為 needs_ocr"
f"{type(e).__name__}: {e}", file=sys.stderr)
return "", "needs_ocr", conv_name
blocks = []
for i, (img, text) in enumerate(zip(images, texts), 1):
rel = img.relative_to(md_dir).as_posix()
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
blocks.append(f"<!-- page {i} (vision: {model}) -->\n\n![page {i}](<{rel}>)\n\n{text}")
return "\n\n".join(blocks), "ok", "from_vision"
def process_local(root, m, path, dry, vision=False):
src = pathlib.Path(path).resolve()
if not src.is_file():
raise FileNotFoundError(src)
ext = src.suffix.lower()
sha = hashlib.sha256(src.read_bytes()).hexdigest()
if _find_by_sha(m, sha):
print(f"skip: {src.name} 已登記hash 相同)")
return
# raw/originals 一律保存「真正的原始檔」(舊格式也是),維持 provenance§1.4
originals = root / "raw" / "originals"
dest = src if src.parent == originals.resolve() else originals / _dest_name(originals, src.name, sha)
orig_rel = f"raw/originals/{dest.name}"
tmpdir = None
try:
if ext in LEGACY_MAP:
if dry:
print(f"legacy: {src.name} 需 LibreOffice 升版為 {LEGACY_MAP[ext]}dry-run 不執行)")
return
tmpdir = tempfile.mkdtemp(prefix="ppqa-legacy-")
upgraded = from_legacy.upgrade(src, tmpdir) # 找不到 soffice / 升版失敗 → RuntimeError
conv, base_name, _ = CONVERTERS[upgraded.suffix.lower()]
conv_name, media, conv_src = f"{base_name}+libreoffice", ext.lstrip("."), upgraded
elif ext in (".html", ".htm"):
conv = (lambda p, assets_dir=None, md_dir=None: from_web.extract(
pathlib.Path(p).read_text(encoding="utf-8", errors="replace")))
conv_name, media, conv_src = "from_web", "html", src
elif ext in CONVERTERS:
conv, conv_name, media = CONVERTERS[ext]
conv_src = src
else:
raise ValueError(f"不支援的格式:{src.name}")
if not dry and dest is not src and not dest.exists():
dest.write_bytes(src.read_bytes())
orig_entry = {"kind": "original", "sha256": sha, "media_type": media,
"added_at": _now(), "status": "converted"}
md_name = f"{dest.stem}-{sha[:8]}.md"
assets = None if dry else root / "raw" / "converted" / "assets" / f"{dest.stem}-{sha[:8]}"
md_text, status = conv(str(conv_src), assets_dir=assets,
md_dir=root / "raw" / "converted")
if status == "needs_ocr":
if vision and not dry:
md_text, status, conv_name = _vision_pdf(root, conv_src, assets, conv_name)
if status == "needs_ocr":
orig_entry["status"] = "needs_ocr"
if not dry:
m["files"][orig_rel] = orig_entry
print(f"needs_ocr: {orig_rel} 無文字層,已登記並跳過"
+ ("--vision 轉錄未成功)" if vision and not dry else "(不擅自 OCR")
+ (" [dry-run]" if dry else ""))
return
_register_pair(m, root, orig_rel, orig_entry, f"raw/converted/{md_name}",
md_text, conv_name, dry)
finally:
if tmpdir:
shutil.rmtree(tmpdir, ignore_errors=True)
def process_url(root, m, url, dry):
html = from_web.fetch(url)
sha = hashlib.sha256(html.encode("utf-8")).hexdigest()
if _find_by_sha(m, sha):
print(f"skip: {url} 已登記(快照 hash 相同)")
return
u = urllib.parse.urlparse(url)
slug = _slug(f"{u.netloc}{u.path}")[:80]
originals = root / "raw" / "originals"
name = _dest_name(originals, f"{slug}.html", sha)
orig_rel = f"raw/originals/{name}"
orig_entry = {"kind": "original", "sha256": sha, "media_type": "html",
"added_at": _now(), "status": "converted",
"source_url": url, "fetched_at": _now()}
if not dry:
(originals / name).write_text(html, encoding="utf-8") # 完整快照(來源可能消失)
md_text, status = from_web.extract(html)
if status != "ok":
orig_entry["status"] = "pending"
if not dry:
m["files"][orig_rel] = orig_entry
print(f"pending: {url} 萃取不到正文,僅保存快照" + (" [dry-run]" if dry else ""))
return
md_name = f"{pathlib.Path(name).stem}-{sha[:8]}.md"
_register_pair(m, root, orig_rel, orig_entry, f"raw/converted/{md_name}",
md_text, "from_web", dry)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("inputs", nargs="+", help="檔案路徑或 http(s) URL")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--vision", action="store_true",
help="掃描/圖片型 PDFneeds_ocr改走本地 vision 模型轉錄,"
"而非跳過(需 config/models.yaml 設定 tasks.vision")
ap.add_argument("--traceback", action="store_true",
help="轉換失敗時額外印出完整堆疊,供定位除錯")
ap.add_argument("--root", default=str(ROOT), help="專案根目錄(測試用)")
a = ap.parse_args(argv)
root = pathlib.Path(a.root).resolve()
m = load_manifest(root)
failed = []
for item in a.inputs:
try:
if item.startswith(("http://", "https://")):
process_url(root, m, item, a.dry_run)
else:
process_local(root, m, item, a.dry_run, vision=a.vision)
except Exception as e: # 單檔失敗不中斷批次,最後彙總報錯
failed.append((item, e))
# 帶例外型別:只印訊息字串常無法判斷成因(如 KeyError 只印 key
print(f"error: {item}: {type(e).__name__}: {e}", file=sys.stderr)
if a.traceback:
traceback.print_exc()
if not a.dry_run:
save_manifest(root, m)
if failed:
# 尾端彙總,避免失敗行淹沒在大批次輸出中而漏看
print(f"\n{len(failed)} 個項目轉換失敗:", file=sys.stderr)
for item, e in failed:
print(f" - {item}: {type(e).__name__}: {e}", file=sys.stderr)
if not a.traceback:
print("(加 --traceback 可印完整堆疊定位)", file=sys.stderr)
raise SystemExit(1)
if __name__ == "__main__":
main()