"""影像 → 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