From 21a90cf20142f58ab42ea04772434324562b1f1f Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 3 Aug 2026 08:55:54 +0800 Subject: [PATCH] fix(rendering): route PDF conversion through platform tools --- CHANGELOG.md | 4 + PROGRESS.md | 10 ++- core/__init__.py | 2 +- core/agent_builder.py | 18 ++++- core/tool_registry.py | 8 ++ rendering/render.py | 1 + tests/test_office_to_pdf.py | 62 +++++++++++++++ tests/test_system_prompt_paths.py | 11 +++ tools/office_to_pdf.py | 125 ++++++++++++++++++++++++++++++ 9 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 tests/test_office_to_pdf.py create mode 100644 tools/office_to_pdf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 044637b..9ea76bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.60.22 — 2026-08-03 + +- 生成通用报告 PDF 时会直接使用平台渲染能力,不再临时安装转换组件;已有 Word、PowerPoint、Excel 等文件也可以直接转换为 PDF。 + ## 0.60.21 — 2026-07-31 - GPT 生图现在可以指定输出尺寸和质量,支持方图、横图、竖图及符合约束的自定义分辨率。 diff --git a/PROGRESS.md b/PROGRESS.md index ffd3fb0..73c9729 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-31(GPT 生图支持尺寸与质量参数,bump 0.60.21) +最后更新:2026-08-03(通用 PDF 路由 + host Office 转换,bump 0.60.22) --- @@ -21,6 +21,10 @@ ## 已完成关键能力 +### 2026-08-03 + +- **08-03 / 0.60.22 / 通用 PDF 路由修复 + host Office 转换**:复盘生产 task 发现通用 system prompt 只展示 `report --format docx`,模型遂误判平台渲染器只能出 Word,进而在 Docker 内探测 LibreOffice、临时安装 WeasyPrint 并手写转换脚本;现补齐 `report --format pdf` 唯一入口,新写 Markdown 报告直接由沙盒 Chromium 出 PDF。新增按 host `soffice` 实际可用性注册的 `office_to_pdf` typed tool,已有 DOCX/PPTX/XLSX/ODF 文件通过 backend host LibreOffice 转换,复用 `/workspace`→user_root 路径翻译并强制用户目录边界,Docker 内不再探测宿主命令。相关 34 项 unittest、Python 编译及 diff 格式检查通过;本机未安装 LibreOffice,真实 soffice 冒烟留部署 host 执行;无 schema、migration、HTTP API 或依赖变化,新增 host 工具无需重建沙箱镜像。 + ### 2026-07-31 - **07-31 / 0.60.21 / GPT Image 2 尺寸与质量参数**:`gpt_image` 开放 `size`(`auto` 或合法 `WIDTHxHEIGHT`)与 `quality`(`auto/low/medium/high`),按官方约束校验 16 倍数、3:1 比例、边长及总像素范围,并从 PNG IHDR 回填实际尺寸到 banner、meta 与 usage;真实 `1024x1024 + low` 请求返回同尺寸 PNG。同步确认 unifyllm `/images/edits` 的 JSON、multipart 及 Responses image_generation 三条改图路径当前均不可用,故不暴露必失败的参考图参数,继续明确引导改图切 Seedream;手工脚本显式 mock 用量记录,杜绝真实探针触库。相关 41 项 unittest 通过(1 项测试库门控跳过),Python 编译与 diff 格式检查通过;无 schema、migration 或 HTTP API 变化。 @@ -257,7 +261,7 @@ core/capabilities.py 75 ← 模型档案增加 CNY/Mtok 计费兜底字 core/llm.py 151 ← litellm 离线 cost map env + chat_stream(stream + include_usage) core/loop.py 812 ← ReAct 主循环:_RepeatGuard/stall 熔断/热切编排(传输健壮性层已析出) core/llm_transport.py 438 ← wire 层健壮性:畸形/吐空检测+留痕+非流式降级重试(07-23 自 loop 析出) -core/tool_registry.py 242 ← 声明式工具注册表((组名,gate,factory);secret 工具 env-gate 代码强制) +core/tool_registry.py 264 ← 声明式工具注册表((组名,gate,factory);secret/host 工具按实际能力 gate) core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段 core/sinks.py 101 core/paths.py 50 ← task_dir db form 归一 @@ -276,7 +280,7 @@ core/asr_xfyun.py 170 ← 讯飞语音听写 IAT wss 客户端(整段 core/asr_lfasr.py 250 ← 讯飞录音文件转写 LFASR 客户端(异步订单 + 说话人分离;transcribe_audio 工具底座,diag: scripts/diag_lfasr.py) core/agent_builder.py 649 ← 装配 lib:build_agent/system prompt(工具注册块已迁 tool_registry) core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池 -tools/{base,output,fs,shell,run_python,skill_tool,skill_authoring,media_common,seedream,seedance,gpt_image,look_at_image,read_document,image_ref,web_search,web_fetch,documents,materials_project,transcribe_audio}.py ← media_common=媒体五工具共享原语;output=输出压缩/超时结果(自 base 析出) +tools/{base,output,fs,shell,run_python,skill_tool,skill_authoring,media_common,seedream,seedance,gpt_image,look_at_image,read_document,image_ref,web_search,web_fetch,documents,materials_project,transcribe_audio,office_to_pdf}.py ← media_common=媒体五工具共享原语;office_to_pdf=host LibreOffice 安全转换 main.py ~210 ← 入口:web / db / probe / user / sandbox check db/migrations/versions/ 0001-0023 web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等) diff --git a/core/__init__.py b/core/__init__.py index cdf081b..8431600 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.60.21" +__version__ = "0.60.22" diff --git a/core/agent_builder.py b/core/agent_builder.py index 631d5d4..97cfa89 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -44,6 +44,7 @@ from core.task import TaskState from core.task_actions import DeferredTaskActions from core.ark_client import ArkConfig from core.tool_registry import ToolContext, build_tools +from tools.office_to_pdf import soffice_available # 媒体工具指引:仅当本 run 真的挂了对应媒体工具才追加进 system prompt —— 没 key 的 @@ -311,6 +312,7 @@ def _build_system_prompt( task_skill: str = "", media_block: str = "", allow_working_dir_rename: bool = True, + office_to_pdf_available: bool = False, ) -> str: """拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。 @@ -401,6 +403,11 @@ def _build_system_prompt( "完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n" if allow_working_dir_rename else "" ) + office_pdf_hint = ( + "已有 DOCX/PPTX/XLSX 等 Office 文件需要转 PDF 时,调用 host-side " + "`office_to_pdf`;LibreOffice 在 backend host,不在 Docker shell 内探测 `soffice`。\n" + if office_to_pdf_available else "" + ) prompt += ( f"\n\n## 工作目录与 task 上下文\n" f"{loc_lines}" @@ -414,14 +421,18 @@ def _build_system_prompt( f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" f"{rename_line}" - f"\n## 生成 Word 报告 / 文档(验收 / 技术 / 评审报告等自由长文)\n" + f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n" f"**优先**把正文写成 Markdown(`/sections/*.md`,纯文本、零转义 / 零语法风险)," - f"再调平台渲染器出 docx —— **别在 run_python 里手撸 python-docx 内联大段中文正文**" + f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**" f"(中文引号 / 全角标点混进源码极易 SyntaxError、反复返工烧 token):\n\n" f" {render_cmd} --profile report --format docx /sections/ -o /<报告名>.docx\n\n" + f" {render_cmd} --profile report --format pdf /sections/ -o /<报告名>.pdf\n\n" f"渲染器自动管字体(正文宋体小四 / 标题黑体 / 1.5 倍行距)、目录(加 `--toc`)、" f"表格、`![](path)` 居中插图 + 图题编号、mermaid 图。已加载 proposal / paper 等 skill 时" f"按其 render 指引的 profile 走(更具体),通用报告用 `--profile report`。\n" + f"新写 Markdown 报告要 PDF 时直接用上面的 `--format pdf`,不先生成 DOCX," + f"也不探测或临时安装 pandoc / WeasyPrint / LibreOffice。\n" + f"{office_pdf_hint}" f"这是**软建议不是强制**:确需精细定制版式 / 改写已有 docx / 处理 xlsx 等渲染器覆盖不了的," f"照样用 run_python 自由写代码。\n" f"\n## task 级「宪法」文件命名约定(跨 skill 通用)\n" @@ -559,11 +570,13 @@ def build_agent( reasoning_effort=caps.default_reasoning_effort or "", ) + office_pdf_available = soffice_available() system_prompt = _build_system_prompt( cfg, skills, workspace_dir, tool_base, working_dir_path, uid, task_id, task_state.name, task_state.skill, media_block=_media_tools_block(ark_cfg is not None, image_tool_name), allow_working_dir_rename=not scheduled_run, + office_to_pdf_available=office_pdf_available, ) meta = { @@ -597,6 +610,7 @@ def build_agent( ark_cfg=ark_cfg, img_provider=img_provider, img_key=img_key, img_cfg=img_cfg, img_provider_cfg=img_provider_cfg, video_variant=video_variant, + office_to_pdf_available=office_pdf_available, )) sink = ConsoleEventSink(console) if console else None diff --git a/core/tool_registry.py b/core/tool_registry.py index a6da17e..6540335 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -33,6 +33,7 @@ from tools.materials_project import ( MaterialsProjectGetStructureTool, MaterialsProjectSearchSummaryTool, ) +from tools.office_to_pdf import OfficeToPdfTool from tools.read_document import ReadDocumentTool from tools.rename_working_dir import RenameWorkingDirTool from tools.run_python import RunPythonTool @@ -77,6 +78,7 @@ class ToolContext: img_cfg: Optional[dict] # 选中的 image variant 配置(None=不挂图像工具) img_provider_cfg: Any # 该 provider 的 ArkConfig video_variant: str # caller 指定的 video variant key(空=yaml 第一个) + office_to_pdf_available: bool # backend host 是否可调用 LibreOffice def _env_set(name: str) -> Callable[[], bool]: @@ -178,6 +180,11 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]: def _run_python() -> list: return [RunPythonTool(task_id=ctx.task_id, **base)] + def _office_to_pdf() -> list: + # LibreOffice 只装在 backend host;Docker agent 通过 typed tool 转换用户目录内 + # 已有 Office 文件,不在沙盒 shell 里寻找/安装 soffice。 + return [OfficeToPdfTool(**wd_base)] + def _image() -> list: # 图像生成跨 provider 二选一(选择在 build_agent 上半部定好,与 system prompt # 媒体段同源);本次 run 锁定该 variant,下一条消息可重选。 @@ -241,6 +248,7 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]: ("send_email", smtp_configured, _send_email), ("wechat_push", wechat_push_available, _wechat_push), ("run_python", lambda: ctx.caps.enable_run_python, _run_python), + ("office_to_pdf", lambda: ctx.office_to_pdf_available, _office_to_pdf), ("image", lambda: ctx.img_cfg is not None, _image), ("video", lambda: ctx.ark_cfg is not None, _video), ("vision", lambda: ctx.ark_cfg is not None, _vision), diff --git a/rendering/render.py b/rendering/render.py index 9652078..2a62f21 100644 --- a/rendering/render.py +++ b/rendering/render.py @@ -5,6 +5,7 @@ python /sandbox/rendering/render.py --profile brief --format pdf -o out.pdf python /sandbox/rendering/render.py --profile paper --format docx --lang zh -o out.docx python /sandbox/rendering/render.py --profile proposal --format docx --fund-type key_rd -o out.docx + python /sandbox/rendering/render.py --profile report --format pdf -o out.pdf --no-color 出黑白(brief docx / 任意 pdf 生效)。 可为目录(拼接其 *.md)或单个 .md。 """ diff --git a/tests/test_office_to_pdf.py b/tests/test_office_to_pdf.py new file mode 100644 index 0000000..9d11265 --- /dev/null +++ b/tests/test_office_to_pdf.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from tools.office_to_pdf import OfficeToPdfTool + + +class TestOfficeToPdfTool(unittest.TestCase): + def _tool(self, root: Path, task: Path) -> OfficeToPdfTool: + return OfficeToPdfTool(base_dir=task, user_root=root) + + def test_converts_container_path_on_host_and_publishes_requested_output(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + task = root / "task" + task.mkdir() + (task / "source.docx").write_bytes(b"docx") + + def fake_run(cmd, **kwargs): + outdir = Path(cmd[cmd.index("--outdir") + 1]) + (outdir / "source.pdf").write_bytes(b"%PDF-1.4 test") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + with patch("tools.office_to_pdf.find_soffice", return_value="soffice"), patch( + "tools.office_to_pdf.subprocess.run", side_effect=fake_run + ): + result = self._tool(root, task).execute( + source="/workspace/task/source.docx", + output="converted/final.pdf", + ) + + out = task / "converted" / "final.pdf" + self.assertEqual(out.read_bytes(), b"%PDF-1.4 test") + self.assertIn("[OK] PDF created: task/converted/final.pdf", result) + + def test_rejects_source_outside_user_workspace(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) / "user" + task = root / "task" + task.mkdir(parents=True) + result = self._tool(root, task).execute(source="../../outside.docx") + self.assertIn("[Error] source path out of user workspace", result) + + def test_rejects_non_office_source(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + task = root / "task" + task.mkdir() + (task / "notes.md").write_text("x", encoding="utf-8") + result = self._tool(root, task).execute(source="notes.md") + self.assertIn("[Error] unsupported Office format", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_system_prompt_paths.py b/tests/test_system_prompt_paths.py index 84eaeee..8cb443e 100644 --- a/tests/test_system_prompt_paths.py +++ b/tests/test_system_prompt_paths.py @@ -37,6 +37,7 @@ def _build(backend: str, *, allow_rename: bool = True) -> tuple[str, Path, Path] prompt = _build_system_prompt( cfg, skills, ws, _HOST_CWD, wd, _UID, _TID, "数据资源展示", "documents", allow_working_dir_rename=allow_rename, + office_to_pdf_available=True, ) return prompt, ws, wd @@ -73,6 +74,16 @@ class TestSystemPromptPaths(unittest.TestCase): self.assertIn("必须调用 `rename_working_dir`", interactive) self.assertNotIn("必须调用 `rename_working_dir`", scheduled) + def test_report_pdf_uses_platform_renderer_directly(self): + prompt, _, _ = _build("docker") + self.assertIn( + "python /sandbox/rendering/render.py --profile report --format pdf", + prompt, + ) + self.assertIn("不先生成 DOCX", prompt) + self.assertIn("`office_to_pdf`", prompt) + self.assertIn("LibreOffice 在 backend host", prompt) + def test_web_search_schema_reinforces_time_rules(self): from tools.web_search import WebSearchTool diff --git a/tools/office_to_pdf.py b/tools/office_to_pdf.py new file mode 100644 index 0000000..330b81a --- /dev/null +++ b/tools/office_to_pdf.py @@ -0,0 +1,125 @@ +"""Office 文档转 PDF(host-side LibreOffice)。 + +Docker 沙盒内不安装 LibreOffice;本工具运行在 backend host,把用户工作区内已有的 +DOCX/PPTX/XLSX/ODF 文件交给 host `soffice --headless` 转成 PDF。新写的 Markdown +报告不走这里,统一由沙盒平台渲染器 `rendering/render.py --format pdf` 直出。 +""" +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Optional +from uuid import uuid4 + +from tools.base import FileOutOfBounds, Tool +from web.pptx_render import SofficeNotFoundError, find_soffice + + +_ALLOWED_SUFFIXES = { + ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".odt", ".odp", ".ods", +} +_DEFAULT_TIMEOUT = 120 + + +def soffice_available() -> bool: + """Host 装有 LibreOffice 时才向 agent 注册工具。""" + try: + find_soffice() + except SofficeNotFoundError: + return False + return True + + +class OfficeToPdfTool(Tool): + name = "office_to_pdf" + description = ( + "Convert an existing Office document in the current user's workspace to PDF using " + "LibreOffice on the backend host. Supports DOC/DOCX, PPT/PPTX, XLS/XLSX and ODF. " + "Use this only when an Office file already exists; for a new Markdown report, call " + "the platform rendering/render.py command with --format pdf directly." + ) + parameters = { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Existing Office file path, relative to task_dir or under /workspace.", + }, + "output": { + "type": "string", + "description": ( + "Optional output .pdf path in the user's workspace. " + "Defaults to the source path with a .pdf suffix." + ), + }, + }, + "required": ["source"], + } + + def execute(self, source: str, output: Optional[str] = None) -> str: + try: + src = self._resolve_user_file(source) + except FileOutOfBounds: + return f"[Error] source path out of user workspace: {source}" + if not src.is_file(): + return f"[Error] source file not found: {source}" + if src.suffix.lower() not in _ALLOWED_SUFFIXES: + allowed = ", ".join(sorted(_ALLOWED_SUFFIXES)) + return f"[Error] unsupported Office format {src.suffix!r}; allowed: {allowed}" + + raw_out = output.strip() if isinstance(output, str) and output.strip() else "" + try: + out = self._resolve_user_file(raw_out) if raw_out else src.with_suffix(".pdf") + except FileOutOfBounds: + return f"[Error] output path out of user workspace: {output}" + if out.suffix.lower() != ".pdf": + return f"[Error] output must end with .pdf: {output}" + + try: + soffice = find_soffice() + except SofficeNotFoundError as e: + return f"[Error] LibreOffice unavailable on backend host: {e}" + + out.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="office-pdf-") as tmp: + tmp_dir = Path(tmp) + profile_uri = (tmp_dir / "profile").resolve().as_uri() + cmd = [ + soffice, + "--headless", + "--norestore", + "--nolockcheck", + "--nodefault", + f"-env:UserInstallation={profile_uri}", + "--convert-to", + "pdf", + "--outdir", + str(tmp_dir), + str(src), + ] + proc = subprocess.run( + cmd, + capture_output=True, + timeout=_DEFAULT_TIMEOUT, + check=False, + ) + converted = tmp_dir / f"{src.stem}.pdf" + if proc.returncode != 0 or not converted.is_file() or converted.stat().st_size == 0: + detail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace")[-500:] + return f"[Error] LibreOffice conversion failed (rc={proc.returncode}): {detail}" + + staged = out.with_name(f".{out.name}.{uuid4().hex}.tmp") + try: + shutil.copyfile(converted, staged) + staged.replace(out) + finally: + staged.unlink(missing_ok=True) + except subprocess.TimeoutExpired: + return f"[Error] LibreOffice conversion timed out after {_DEFAULT_TIMEOUT}s: {source}" + except OSError as e: + return f"[Error] failed to publish PDF: {type(e).__name__}: {e}" + + return f"[OK] PDF created: {self._display(out)} ({out.stat().st_size} bytes)"