fix(rendering): route PDF conversion through platform tools
This commit is contained in:
parent
75bb482015
commit
21a90cf201
|
|
@ -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 生图现在可以指定输出尺寸和质量,支持方图、横图、竖图及符合约束的自定义分辨率。
|
||||
|
|
|
|||
10
PROGRESS.md
10
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 等)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.60.21"
|
||||
__version__ = "0.60.22"
|
||||
|
|
|
|||
|
|
@ -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(`<task_dir>/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 <task_dir>/sections/ -o <task_dir>/<报告名>.docx\n\n"
|
||||
f" {render_cmd} --profile report --format pdf <task_dir>/sections/ -o <task_dir>/<报告名>.pdf\n\n"
|
||||
f"渲染器自动管字体(正文宋体小四 / 标题黑体 / 1.5 倍行距)、目录(加 `--toc`)、"
|
||||
f"表格、`` 居中插图 + 图题编号、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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
python /sandbox/rendering/render.py --profile brief --format pdf <sections> -o out.pdf
|
||||
python /sandbox/rendering/render.py --profile paper --format docx <sections> --lang zh -o out.docx
|
||||
python /sandbox/rendering/render.py --profile proposal --format docx <sections> --fund-type key_rd -o out.docx
|
||||
python /sandbox/rendering/render.py --profile report --format pdf <sections> -o out.pdf
|
||||
|
||||
--no-color 出黑白(brief docx / 任意 pdf 生效)。<sections> 可为目录(拼接其 *.md)或单个 .md。
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
Loading…
Reference in New Issue