轉換工具強化:失敗診斷訊息 + 舊版格式升版 + 掃描 PDF vision fallback

- convert.py:轉換失敗改印例外型別、批次尾端彙總、--traceback 旗標;
  空白轉換結果示警(避免純圖片 docx 等假成功靜默入庫)
- from_legacy.py(B):.doc/.xls/.ppt 經本地 LibreOffice headless 升版為
  現代格式後再走既有轉換器;raw/originals 仍保存真正的舊格式原始檔
- to_image.py + from_vision.py(A):needs_ocr 的掃描/圖片型 PDF 逐頁
  render → 本地 vision 模型轉錄(--vision);:cloud/未設定防護,
  模型設定走 config/models.yaml 的 tasks.vision(預設不啟用)
- kb.ollama_chat 加 images 參數(向後相容);models.yaml 加 vision 範例
- selfcheck 補 legacy 路由、to_image 真測、from_vision 注入測、
  --vision 端到端,全數 ALL PASS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LittleYellow
2026-07-21 07:00:32 +08:00
parent 1d1454e1b9
commit cfd1876359
7 changed files with 428 additions and 37 deletions

View File

@@ -4,7 +4,9 @@
原始檔存入 raw/originals/ 並登記 hash → 轉換 → Markdown 存入 raw/converted/
並登記 hash 與原始檔對應 → 之後攝入管線只讀 converted 層。
用法python tools/convert/convert.py <檔案路徑或URL>... [--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 升版;
--vision 讓掃描/圖片型 PDF 走本地 vision 模型轉錄(見 from_vision.py
"""
import argparse
import datetime
@@ -12,11 +14,15 @@ 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
@@ -29,6 +35,10 @@ CONVERTERS = {
".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():
@@ -72,6 +82,9 @@ def _dest_name(dir_, name, sha):
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")
@@ -87,49 +100,96 @@ def _register_pair(m, root, orig_rel, orig_entry, md_rel, md_text, converter, dr
print(f"converted: {orig_rel} -> {md_rel}" + (" [dry-run]" if dry else ""))
def process_local(root, m, path, dry):
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()
if ext in (".html", ".htm"):
conv, conv_name, media = (
lambda p, assets_dir=None, md_dir=None: from_web.extract(
pathlib.Path(p).read_text(encoding="utf-8", errors="replace")),
"from_web", "html")
elif ext in CONVERTERS:
conv, conv_name, media = CONVERTERS[ext]
else:
raise ValueError(f"不支援的格式:{src.name}")
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"
if src.parent == originals.resolve():
dest = src
else:
dest = originals / _dest_name(originals, src.name, sha)
if not dry and not dest.exists():
dest.write_bytes(src.read_bytes())
dest = src if src.parent == originals.resolve() else originals / _dest_name(originals, src.name, sha)
orig_rel = f"raw/originals/{dest.name}"
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(src), assets_dir=assets, md_dir=root / "raw" / "converted")
if status == "needs_ocr":
orig_entry["status"] = "needs_ocr"
if not dry:
m["files"][orig_rel] = orig_entry
print(f"needs_ocr: {orig_rel} 無文字層,已登記並跳過(不擅自 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)
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):
@@ -164,6 +224,11 @@ 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()
@@ -174,13 +239,22 @@ def main(argv=None):
if item.startswith(("http://", "https://")):
process_url(root, m, item, a.dry_run)
else:
process_local(root, m, item, a.dry_run)
process_local(root, m, item, a.dry_run, vision=a.vision)
except Exception as e: # 單檔失敗不中斷批次,最後彙總報錯
failed.append((item, e))
print(f"error: {item}: {e}", file=sys.stderr)
# 帶例外型別:只印訊息字串常無法判斷成因(如 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)