"""read_document: 让纯文本主模型"读"扫描件 / 图片型 PDF。 markitdown 只能抽 PDF 的文本层,扫描件(纯图页)抽出来是空的 —— 这条死路由本 tool 补上:整份 PDF base64 内联喂豆包 seed-2.0-lite 的文档理解(方舟按页栅格化后视觉 OCR),产出 markdown 全文。格式 / 上限 / 成本均经 scripts/probe_ark_doc.py 实测: file 内容块 + `data:application/pdf;base64,` 前缀;单页栅格化 3600 万像素上限; 输入约 1300 token/页(100 页约 ¥0.09)。 与 look_at_image 同一 model / key / 记账通道(usage_events kind="vision"), 配置在 config/media/doubao.yaml 的 vision 段(max_pdf_mb / max_pdf_pages)。 """ from __future__ import annotations from pathlib import Path from typing import Any, Optional from uuid import UUID from core.ark_client import ArkConfig from core.storage.usage import record_vision_usage from .base import Tool from .output import compact_tool_output from .image_ref import _CONTAINER_ROOT, load_pdf_as_data_url, resolve_in_root from .media_common import ark_chat_with_retry, extract_chat_answer, record_usage_safe _DEFAULT_QUESTION = ( "这是一份多页 PDF 文档。请逐页把其中的文字完整 OCR 成 markdown:" "每页以「== 第N页 ==」开头;表格转成 markdown 表格;保留标题层级与段落换行;" "公式尽量用 LaTeX;不要总结、不要遗漏、不要自行补充原文没有的内容。" ) # 保存到文件时,tool 返回值里带的正文预览长度(全文在文件里,预览只为让模型确认质量) _PREVIEW_CHARS = 1500 def _count_pdf_pages(pdf: Path) -> Optional[int]: """软探页数(pdfminer 随 markitdown[pdf] 已在依赖里);解析不了返 None 不拦路。""" try: from pdfminer.pdfdocument import PDFDocument from pdfminer.pdfpage import PDFPage from pdfminer.pdfparser import PDFParser with open(pdf, "rb") as f: return sum(1 for _ in PDFPage.create_pages(PDFDocument(PDFParser(f)))) except Exception: return None class ReadDocumentTool(Tool): name = "read_document" description = ( "Read a PDF (including SCANNED/image-only PDFs that markitdown can't extract) using " "Doubao Seed 2.0 Lite document understanding — OCRs every page into markdown. " "Use when markitdown output for a PDF is empty/near-empty (scanned document), or to " "ask a specific question about a PDF's content. Pass the PDF path; optionally " "`question` (default: full per-page OCR to markdown) and `save_md` (relative path to " "write the full text, e.g. 'source/xxx.md' — recommended for multi-page docs so the " "full text lands in a file instead of flooding context). Costs roughly 0.001-0.002 " "CNY per page; docs over the page limit must be split first." ) parameters = { "type": "object", "properties": { "document": { "type": "string", "description": ( "PDF 相对路径(task_dir 内,如 'source/xxx.pdf',或用户消息里" "`[用户上传的文件]` 行给的路径)。" ), }, "question": { "type": "string", "description": ( "想从文档里知道什么(可选)。如「第3章的检测指标是什么」。" "不传则默认逐页完整 OCR 成 markdown。" ), }, "save_md": { "type": "string", "description": ( "把全文写到这个相对路径(可选,如 'source/xxx.md')。多页 OCR 建议必传:" "全文落文件,tool 只返回开头预览,不撑爆上下文。" ), }, }, "required": ["document"], } def __init__( self, *, ark_cfg: ArkConfig, vision_variant_cfg: dict, variant_key: str, working_dir: Path, task_id: UUID, user_id: UUID, base_dir: Optional[Path] = None, user_root: Optional[Path] = None, ) -> None: super().__init__(base_dir, user_root=user_root) self.ark_cfg = ark_cfg self.cfg = vision_variant_cfg self.variant_key = variant_key self.working_dir = Path(working_dir) self.task_id = task_id self.user_id = user_id def execute( self, document: str, question: Optional[str] = None, save_md: Optional[str] = None, ) -> str: if not (document or "").strip(): return "[Error] document(PDF 路径)不能为空" cfg = self.cfg max_bytes = int(float(cfg.get("max_pdf_mb", 30)) * 1024 * 1024) data_url, disp, err = load_pdf_as_data_url( document.strip(), working_dir=self.working_dir, user_root=self.user_root, display_fn=self._display, max_bytes=max_bytes, ) if err: return err # 页数软闸:约 1300 token/页,超过上限会撞模型上下文窗口 → 白付一次失败调用。 # pdfminer 解析不了(加密/损坏)不拦,让 API 报错兜底。 max_pages = int(cfg.get("max_pdf_pages", 100)) resolved = resolve_in_root(document.strip(), self.working_dir, self.user_root) n_pages = _count_pdf_pages(resolved) if resolved is not None else None if n_pages is not None and n_pages > max_pages: return ( f"[Error] 文档 {n_pages} 页超过单次 {max_pages} 页上限(上下文约束)。" f"先把 PDF 拆成不超过 {max_pages} 页的分卷再逐卷调用。" ) q = (question or "").strip() or _DEFAULT_QUESTION model_id = cfg["model_id"] timeout_s = float(cfg.get("doc_request_timeout_s", 600)) endpoint = cfg.get("endpoint", "/chat/completions") body: dict[str, Any] = { "model": model_id, "messages": [ { "role": "user", "content": [ {"type": "text", "text": q}, { "type": "file", "file": {"filename": Path(disp).name, "file_data": data_url}, }, ], } ], } # 超时透明重试(细节见 media_common.ark_chat_with_retry) resp, api_err = ark_chat_with_retry( self.ark_cfg, endpoint, body, timeout_s=timeout_s, retries=int(cfg.get("timeout_retries", 1)), tool_name="read_document", ) if api_err: return api_err assert resp is not None answer, truncated = extract_chat_answer(resp) if not answer: return ( "[Error] 文档理解响应缺内容(模型未返回文本)。" "可能 PDF 损坏 / 页面超单页像素上限,稍后重试或先重存该 PDF。" ) usage = resp.get("usage") or {} tin = int(usage.get("prompt_tokens", 0) or 0) tout = int(usage.get("completion_tokens", 0) or 0) cost = record_usage_safe( "read_document", record_vision_usage, task_id=self.task_id, user_id=self.user_id, model_profile=f"doubao.{self.variant_key}", prompt_tokens=tin, completion_tokens=tout, input_cny_per_mtoken=float(cfg.get("price_cny_per_mtoken_input", 0)), output_cny_per_mtoken=float(cfg.get("price_cny_per_mtoken_output", 0)), extra_units={"document": disp}, ) cost_cny = float(cost or 0) banner = ( f"[read_document] model={model_id} · document={disp}" + (f" · pages={n_pages}" if n_pages else "") + f" · tokens={tin}+{tout} · cost=¥{cost_cny:.4f}" ) trunc_note = ( "\n[注意] 输出被模型 token 上限截断,末尾页可能缺失 —— " "用 question 按页段分次问(如「只 OCR 第 51-100 页」)补齐。" ) if truncated else "" if save_md and save_md.strip(): saved_disp, save_err = self._save_md(save_md.strip(), answer) if save_err: # 保存失败不吞掉 OCR 结果:降级为直接返回(截断保护) return f"{banner}\n{save_err}(全文改为直接返回)\n\n{compact_tool_output(answer)}{trunc_note}" preview = answer[:_PREVIEW_CHARS] more = f"\n...(预览截断,全文 {len(answer)} 字符在文件里)" if len(answer) > _PREVIEW_CHARS else "" return f"{banner}\nsaved: {saved_disp}\n\n{preview}{more}{trunc_note}" return f"{banner}\n\n{compact_tool_output(answer)}{trunc_note}" def _save_md(self, rel: str, text: str) -> tuple[str, str]: """把全文写到 task 内。返回 (display_path, error)。 与读取侧 resolve_in_root 同款三形态(相对 / 宿主绝对 / 容器 `/workspace/...`), 但目标是写入 —— 不要求文件已存在,只做 user_root 边界校验。 """ p = Path(rel) is_container = rel == _CONTAINER_ROOT or rel.startswith(_CONTAINER_ROOT + "/") if self.user_root is not None and is_container: target = self.user_root / rel[len(_CONTAINER_ROOT):].lstrip("/") elif p.is_absolute(): target = p else: target = self.working_dir / p target = target.resolve() root = (self.user_root or self.working_dir).resolve() try: target.relative_to(root) except ValueError: return "", f"[Error] save_md 越界: {rel!r}(须在 task 目录内)" try: target.parent.mkdir(parents=True, exist_ok=True) target.write_text(text, encoding="utf-8") except OSError as e: return "", f"[Error] 写入 {rel} 失败: {type(e).__name__}: {e}" return self._display(target), ""