From cfd187635917d0610c337d83a53602895875726b Mon Sep 17 00:00:00 2001 From: LittleYellow Date: Tue, 21 Jul 2026 07:00:32 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BD=89=E6=8F=9B=E5=B7=A5=E5=85=B7=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=EF=BC=9A=E5=A4=B1=E6=95=97=E8=A8=BA=E6=96=B7=E8=A8=8A?= =?UTF-8?q?=E6=81=AF=20+=20=E8=88=8A=E7=89=88=E6=A0=BC=E5=BC=8F=E5=8D=87?= =?UTF-8?q?=E7=89=88=20+=20=E6=8E=83=E6=8F=8F=20PDF=20vision=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- config/models.yaml | 7 ++ tools/convert/convert.py | 140 ++++++++++++++++++++++++++--------- tools/convert/from_legacy.py | 91 +++++++++++++++++++++++ tools/convert/from_vision.py | 61 +++++++++++++++ tools/convert/selfcheck.py | 106 +++++++++++++++++++++++++- tools/convert/to_image.py | 48 ++++++++++++ tools/kb.py | 12 ++- 7 files changed, 428 insertions(+), 37 deletions(-) create mode 100644 tools/convert/from_legacy.py create mode 100644 tools/convert/from_vision.py create mode 100644 tools/convert/to_image.py diff --git a/config/models.yaml b/config/models.yaml index 1246222..8c22457 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -20,6 +20,13 @@ tasks: # 依任務指定模型,全部可調 max_retries: 3 fallback: model: "hermes3:8b" # 主模型格式驗證連續失敗時切換 + # vision(convert --vision 的掃描/圖片型 PDF fallback 才會用到):預設不啟用。 + # 要啟用時取消下方註解並填入「合規本地 vision 模型」——須自行確認非中國關聯、 + # 非 :cloud(§1.1/§1.3)。留成註解可避免 load_config 對未設定值報錯。 + # vision: + # model: "CHANGE_ME" # 例:Google Gemma 視覺版 / IBM Granite Vision 等合規本地模型 + # temperature: 0.1 + # max_retries: 2 limits: mcp_response_max_tokens: 2000 diff --git a/tools/convert/convert.py b/tools/convert/convert.py index 953f91b..dcf556f 100644 --- a/tools/convert/convert.py +++ b/tools/convert/convert.py @@ -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 升版後走上表對應轉換器)。 +# +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 fallback(AGENTS.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"\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="掃描/圖片型 PDF(needs_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) diff --git a/tools/convert/from_legacy.py b/tools/convert/from_legacy.py new file mode 100644 index 0000000..f635688 --- /dev/null +++ b/tools/convert/from_legacy.py @@ -0,0 +1,91 @@ +"""舊版 OLE 二進位格式(.doc/.xls/.ppt)→ 現代格式(.docx/.xlsx/.pptx)升版。 + +以 LibreOffice headless 在本機升版(deterministic、內容不外流,AGENTS.md §1.1), +升版後由 convert.py 回頭走既有 from_docx / from_xlsx / from_pptx 轉換器。 +升版產物是暫時中介檔,不進 raw/;raw/originals 仍保存真正的舊格式原始檔(§1.4)。 + +可獨立執行:python tools/convert/from_legacy.py [--out DIR] +""" +import argparse +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile + +LEGACY_TARGET = {".doc": "docx", ".xls": "xlsx", ".ppt": "pptx"} + +# 常見 Windows 安裝路徑;其他平台靠 PATH(soffice/libreoffice)或 SOFFICE_BIN 覆寫。 +_WIN_CANDIDATES = ( + r"C:\Program Files\LibreOffice\program\soffice.exe", + r"C:\Program Files (x86)\LibreOffice\program\soffice.exe", +) + + +def find_soffice(): + """定位 LibreOffice 執行檔。SOFFICE_BIN 環境變數優先(唯一允許的覆寫來源)。""" + env = os.environ.get("SOFFICE_BIN") + if env and pathlib.Path(env).is_file(): + return env + for c in _WIN_CANDIDATES: + if pathlib.Path(c).is_file(): + return c + return shutil.which("soffice") or shutil.which("libreoffice") + + +def upgrade(src, out_dir, timeout=180): + """升版 src 至 out_dir,回傳升版後檔案路徑。 + + 找不到 LibreOffice、格式不支援、或升版未產出檔案時拋 RuntimeError(由 + convert.py 逐檔捕捉並回報,不中斷批次)。 + """ + src = pathlib.Path(src) + ext = src.suffix.lower() + if ext not in LEGACY_TARGET: + raise RuntimeError(f"非舊版格式:{src.name}") + soffice = find_soffice() + if not soffice: + raise RuntimeError( + "找不到 LibreOffice(soffice);.doc/.xls/.ppt 升版需要它。" + "請安裝 LibreOffice,或以 SOFFICE_BIN 環境變數指定執行檔路徑。") + + out_dir = pathlib.Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + target = LEGACY_TARGET[ext] + # 每次呼叫用獨立 user profile,避免 headless 並行/殘留 profile 造成的鎖定失敗 + # (LibreOffice headless 的已知痛點,§13.3:真實環境不是規格書上的理想值)。 + profile = out_dir / ".lo-profile" + try: + subprocess.run( + [soffice, f"-env:UserInstallation=file:///{profile.as_posix().lstrip('/')}", + "--headless", "--convert-to", target, "--outdir", str(out_dir), str(src)], + check=True, capture_output=True, text=True, timeout=timeout) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"LibreOffice 升版失敗(exit {e.returncode}):{e.stderr.strip()}") + except subprocess.TimeoutExpired: + raise RuntimeError(f"LibreOffice 升版逾時(>{timeout}s):{src.name}") + finally: + shutil.rmtree(profile, ignore_errors=True) + + out = out_dir / f"{src.stem}.{target}" + if not out.is_file(): + raise RuntimeError(f"LibreOffice 未產出預期檔案:{out.name}") + return out + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("src") + ap.add_argument("--out", help="升版產物輸出目錄(預設暫存目錄)") + a = ap.parse_args(argv) + out_dir = pathlib.Path(a.out) if a.out else pathlib.Path(tempfile.mkdtemp(prefix="ppqa-legacy-")) + try: + print(upgrade(a.src, out_dir)) + except RuntimeError as e: + print(f"error: {e}", file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/convert/from_vision.py b/tools/convert/from_vision.py new file mode 100644 index 0000000..6c1b8fe --- /dev/null +++ b/tools/convert/from_vision.py @@ -0,0 +1,61 @@ +"""影像 → Markdown 轉錄(本地 vision 模型,AGENTS.md §1.1 資料落地)。 + +用於 vision fallback:文字抽取失敗的頁面 render 成影像後,逐頁交本地 vision +模型忠實轉錄為 Markdown。模型設定讀 config/models.yaml 的 tasks.vision(§1.2), +未設定即報錯、不使用預設值;:cloud 模型一律拒絕(影像不得離開本機)。 +""" +import base64 +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # tools/ 供 import kb +import kb + +VISION_PROMPT = ( + "你是文件轉錄引擎。忠實轉錄此頁影像中的所有文字與表格內容為 Markdown:" + "表格用 Markdown table,保留標題階層。只輸出轉錄內容本身,不要加任何說明或臆測。" + "影像中沒有可辨識內容時,只回覆「(本頁無可辨識文字)」。") + + +def _require_vision(cfg): + """取得並驗證 vision 模型設定。缺設定 / :cloud 皆報錯(信任邊界,§1.1/§1.2)。""" + spec = (cfg.get("tasks") or {}).get("vision") + if not isinstance(spec, dict) or not spec.get("model") or spec["model"] == "CHANGE_ME": + raise SystemExit( + "models.yaml 未設定 tasks.vision.model;--vision 需要合規本地 vision 模型" + "(請自行確認非中國關聯、非 :cloud,AGENTS.md §1.1/§1.3)。") + if str(spec["model"]).endswith(":cloud"): + raise SystemExit("tasks.vision.model 為 :cloud 模型,禁止(文件影像不得離開本機,§1.1)。") + return spec + + +def _b64(path): + return base64.b64encode(pathlib.Path(path).read_bytes()).decode("ascii") + + +def transcribe(cfg, image_paths): + """逐頁轉錄影像,回傳 (每頁 markdown 列表, 實際使用的 model)。 + + 單頁重試 max_retries 次仍失敗即拋 RuntimeError(vision 無文字 fallback 可用—— + fallback 模型非 vision,不切換)。 + """ + spec = _require_vision(cfg) + model = spec["model"] + retries = int(spec.get("max_retries", 2)) + temperature = float(spec.get("temperature", 0.1)) + pages = [] + for idx, img in enumerate(image_paths, 1): + b64 = _b64(img) + last = None + for _ in range(max(1, retries)): + try: + text = kb.ollama_chat(cfg, model, VISION_PROMPT, temperature, images=[b64]) + if text and text.strip(): + pages.append(text.strip()) + break + last = "空回應" + except Exception as e: + last = f"{type(e).__name__}: {e}" + else: + raise RuntimeError(f"vision 轉錄失敗(第 {idx} 頁,model={model}):{last}") + return pages, model diff --git a/tools/convert/selfcheck.py b/tools/convert/selfcheck.py index dfd1ce3..43601e9 100644 --- a/tools/convert/selfcheck.py +++ b/tools/convert/selfcheck.py @@ -3,19 +3,25 @@ 執行:.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__).parent)) +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 def make_fixtures(d): @@ -118,6 +124,104 @@ def main(): 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 / "notes.txt").write_text("不支援的格式", encoding="utf-8") # → ValueError + err = io.StringIO() + code = None + with contextlib.redirect_stderr(err): + try: + convert.main([str(fx / "report.docx"), str(fx / "notes.txt"), + str(fx / "missing.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 / "raw/originals/report.docx").is_file() # 好檔仍轉換並落地 + assert any(v["kind"] == "converted" for v in m3["files"].values()), m3 + print("PASS: 失敗回報(例外型別 + 尾端彙總,批次不中斷)") + + # B: 舊格式路由 —— .doc 應走 legacy(LibreOffice),而非被當「不支援格式」拒收 + 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 / "old.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 / "old.doc"), "--root", str(root4)]) + except SystemExit as e: + code = e.code + log = err.getvalue() + assert code == 1 and "old.doc" in log, log + assert "不支援的格式" not in log and "RuntimeError" in log, log # 已路由 legacy,乾淨報錯 + print("PASS: 舊格式路由至 legacy(soffice 缺失/升版失敗皆乾淨報錯)") + + # A: to_image render(deterministic,真跑) + imgs = to_image.render_pdf_pages(fx / "text.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 / "scanned.pdf"), "--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) diff --git a/tools/convert/to_image.py b/tools/convert/to_image.py new file mode 100644 index 0000000..b335328 --- /dev/null +++ b/tools/convert/to_image.py @@ -0,0 +1,48 @@ +"""PDF 頁 → PNG 影像(pymupdf,deterministic、全程本地)。 + +供 vision fallback 使用:文字抽取失敗(needs_ocr)的掃描/圖片型 PDF 逐頁 render +成影像後,交給 from_vision.py 以本地 vision 模型判讀。render 本身不含任何 LLM, +維持「deterministic 工具做結構、LLM 只做語意」的分工(AGENTS.md §3 稽核原則)。 + +可獨立執行:python tools/convert/to_image.py --out DIR [--dpi 150] +""" +import argparse +import pathlib + +import fitz # pymupdf + +# ponytail: 預設 150 DPI 是「可讀性 vs 影像大小/推論成本」的折衷;掃描件文字偏小時 +# 可調高。升級路徑:依頁面尺寸/內容自適應 DPI。 +DEFAULT_DPI = 150 + + +def render_pdf_pages(src, out_dir, dpi=DEFAULT_DPI, prefix="page"): + """將 src 每頁 render 成 PNG,回傳影像路徑列表(依頁序)。""" + src = pathlib.Path(src) + out_dir = pathlib.Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + paths = [] + doc = fitz.open(str(src)) + try: + for i, page in enumerate(doc, 1): + pix = page.get_pixmap(dpi=dpi) + p = out_dir / f"{prefix}{i}.png" + pix.save(str(p)) + paths.append(p) + finally: + doc.close() + return paths + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("src") + ap.add_argument("--out", required=True, help="影像輸出目錄") + ap.add_argument("--dpi", type=int, default=DEFAULT_DPI) + a = ap.parse_args(argv) + paths = render_pdf_pages(a.src, a.out, a.dpi) + print(f"{len(paths)} 頁 → {a.out}") + + +if __name__ == "__main__": + main() diff --git a/tools/kb.py b/tools/kb.py index a88963f..b3e01f7 100644 --- a/tools/kb.py +++ b/tools/kb.py @@ -48,11 +48,17 @@ def load_config(root=ROOT): # ---------- Ollama ---------- -def ollama_chat(cfg, model, prompt, temperature, json_format=False): - """單次呼叫本地 Ollama /api/chat(stdlib urllib,不需額外依賴)。""" +def ollama_chat(cfg, model, prompt, temperature, json_format=False, images=None): + """單次呼叫本地 Ollama /api/chat(stdlib urllib,不需額外依賴)。 + + images:base64 影像字串列表(供 vision 模型判讀);附在 user 訊息上。 + """ + msg = {"role": "user", "content": prompt} + if images: + msg["images"] = list(images) body = { "model": model, - "messages": [{"role": "user", "content": prompt}], + "messages": [msg], "stream": False, "options": {"temperature": temperature}, }