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。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.60.22 — 2026-08-03
|
||||||
|
|
||||||
|
- 生成通用报告 PDF 时会直接使用平台渲染能力,不再临时安装转换组件;已有 Word、PowerPoint、Excel 等文件也可以直接转换为 PDF。
|
||||||
|
|
||||||
## 0.60.21 — 2026-07-31
|
## 0.60.21 — 2026-07-31
|
||||||
|
|
||||||
- GPT 生图现在可以指定输出尺寸和质量,支持方图、横图、竖图及符合约束的自定义分辨率。
|
- GPT 生图现在可以指定输出尺寸和质量,支持方图、横图、竖图及符合约束的自定义分辨率。
|
||||||
|
|
|
||||||
10
PROGRESS.md
10
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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 变化。
|
- **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/llm.py 151 ← litellm 离线 cost map env + chat_stream(stream + include_usage)
|
||||||
core/loop.py 812 ← ReAct 主循环:_RepeatGuard/stall 熔断/热切编排(传输健壮性层已析出)
|
core/loop.py 812 ← ReAct 主循环:_RepeatGuard/stall 熔断/热切编排(传输健壮性层已析出)
|
||||||
core/llm_transport.py 438 ← wire 层健壮性:畸形/吐空检测+留痕+非流式降级重试(07-23 自 loop 析出)
|
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/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段
|
||||||
core/sinks.py 101
|
core/sinks.py 101
|
||||||
core/paths.py 50 ← task_dir db form 归一
|
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/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/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 容器池
|
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
|
main.py ~210 ← 入口:web / db / probe / user / sandbox check
|
||||||
db/migrations/versions/ 0001-0023
|
db/migrations/versions/ 0001-0023
|
||||||
web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等)
|
web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# 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.task_actions import DeferredTaskActions
|
||||||
from core.ark_client import ArkConfig
|
from core.ark_client import ArkConfig
|
||||||
from core.tool_registry import ToolContext, build_tools
|
from core.tool_registry import ToolContext, build_tools
|
||||||
|
from tools.office_to_pdf import soffice_available
|
||||||
|
|
||||||
|
|
||||||
# 媒体工具指引:仅当本 run 真的挂了对应媒体工具才追加进 system prompt —— 没 key 的
|
# 媒体工具指引:仅当本 run 真的挂了对应媒体工具才追加进 system prompt —— 没 key 的
|
||||||
|
|
@ -311,6 +312,7 @@ def _build_system_prompt(
|
||||||
task_skill: str = "",
|
task_skill: str = "",
|
||||||
media_block: str = "",
|
media_block: str = "",
|
||||||
allow_working_dir_rename: bool = True,
|
allow_working_dir_rename: bool = True,
|
||||||
|
office_to_pdf_available: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。
|
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。
|
||||||
|
|
||||||
|
|
@ -401,6 +403,11 @@ def _build_system_prompt(
|
||||||
"完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n"
|
"完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n"
|
||||||
if allow_working_dir_rename else ""
|
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 += (
|
prompt += (
|
||||||
f"\n\n## 工作目录与 task 上下文\n"
|
f"\n\n## 工作目录与 task 上下文\n"
|
||||||
f"{loc_lines}"
|
f"{loc_lines}"
|
||||||
|
|
@ -414,14 +421,18 @@ def _build_system_prompt(
|
||||||
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
|
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
|
||||||
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
|
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
|
||||||
f"{rename_line}"
|
f"{rename_line}"
|
||||||
f"\n## 生成 Word 报告 / 文档(验收 / 技术 / 评审报告等自由长文)\n"
|
f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n"
|
||||||
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
|
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
|
||||||
f"再调平台渲染器出 docx —— **别在 run_python 里手撸 python-docx 内联大段中文正文**"
|
f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**"
|
||||||
f"(中文引号 / 全角标点混进源码极易 SyntaxError、反复返工烧 token):\n\n"
|
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 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"渲染器自动管字体(正文宋体小四 / 标题黑体 / 1.5 倍行距)、目录(加 `--toc`)、"
|
||||||
f"表格、`` 居中插图 + 图题编号、mermaid 图。已加载 proposal / paper 等 skill 时"
|
f"表格、`` 居中插图 + 图题编号、mermaid 图。已加载 proposal / paper 等 skill 时"
|
||||||
f"按其 render 指引的 profile 走(更具体),通用报告用 `--profile report`。\n"
|
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"这是**软建议不是强制**:确需精细定制版式 / 改写已有 docx / 处理 xlsx 等渲染器覆盖不了的,"
|
||||||
f"照样用 run_python 自由写代码。\n"
|
f"照样用 run_python 自由写代码。\n"
|
||||||
f"\n## task 级「宪法」文件命名约定(跨 skill 通用)\n"
|
f"\n## task 级「宪法」文件命名约定(跨 skill 通用)\n"
|
||||||
|
|
@ -559,11 +570,13 @@ def build_agent(
|
||||||
reasoning_effort=caps.default_reasoning_effort or "",
|
reasoning_effort=caps.default_reasoning_effort or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
office_pdf_available = soffice_available()
|
||||||
system_prompt = _build_system_prompt(
|
system_prompt = _build_system_prompt(
|
||||||
cfg, skills, workspace_dir, tool_base, working_dir_path, uid,
|
cfg, skills, workspace_dir, tool_base, working_dir_path, uid,
|
||||||
task_id, task_state.name, task_state.skill,
|
task_id, task_state.name, task_state.skill,
|
||||||
media_block=_media_tools_block(ark_cfg is not None, image_tool_name),
|
media_block=_media_tools_block(ark_cfg is not None, image_tool_name),
|
||||||
allow_working_dir_rename=not scheduled_run,
|
allow_working_dir_rename=not scheduled_run,
|
||||||
|
office_to_pdf_available=office_pdf_available,
|
||||||
)
|
)
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
|
|
@ -597,6 +610,7 @@ def build_agent(
|
||||||
ark_cfg=ark_cfg, img_provider=img_provider, img_key=img_key,
|
ark_cfg=ark_cfg, img_provider=img_provider, img_key=img_key,
|
||||||
img_cfg=img_cfg, img_provider_cfg=img_provider_cfg,
|
img_cfg=img_cfg, img_provider_cfg=img_provider_cfg,
|
||||||
video_variant=video_variant,
|
video_variant=video_variant,
|
||||||
|
office_to_pdf_available=office_pdf_available,
|
||||||
))
|
))
|
||||||
|
|
||||||
sink = ConsoleEventSink(console) if console else None
|
sink = ConsoleEventSink(console) if console else None
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from tools.materials_project import (
|
||||||
MaterialsProjectGetStructureTool,
|
MaterialsProjectGetStructureTool,
|
||||||
MaterialsProjectSearchSummaryTool,
|
MaterialsProjectSearchSummaryTool,
|
||||||
)
|
)
|
||||||
|
from tools.office_to_pdf import OfficeToPdfTool
|
||||||
from tools.read_document import ReadDocumentTool
|
from tools.read_document import ReadDocumentTool
|
||||||
from tools.rename_working_dir import RenameWorkingDirTool
|
from tools.rename_working_dir import RenameWorkingDirTool
|
||||||
from tools.run_python import RunPythonTool
|
from tools.run_python import RunPythonTool
|
||||||
|
|
@ -77,6 +78,7 @@ class ToolContext:
|
||||||
img_cfg: Optional[dict] # 选中的 image variant 配置(None=不挂图像工具)
|
img_cfg: Optional[dict] # 选中的 image variant 配置(None=不挂图像工具)
|
||||||
img_provider_cfg: Any # 该 provider 的 ArkConfig
|
img_provider_cfg: Any # 该 provider 的 ArkConfig
|
||||||
video_variant: str # caller 指定的 video variant key(空=yaml 第一个)
|
video_variant: str # caller 指定的 video variant key(空=yaml 第一个)
|
||||||
|
office_to_pdf_available: bool # backend host 是否可调用 LibreOffice
|
||||||
|
|
||||||
|
|
||||||
def _env_set(name: str) -> Callable[[], bool]:
|
def _env_set(name: str) -> Callable[[], bool]:
|
||||||
|
|
@ -178,6 +180,11 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
|
||||||
def _run_python() -> list:
|
def _run_python() -> list:
|
||||||
return [RunPythonTool(task_id=ctx.task_id, **base)]
|
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:
|
def _image() -> list:
|
||||||
# 图像生成跨 provider 二选一(选择在 build_agent 上半部定好,与 system prompt
|
# 图像生成跨 provider 二选一(选择在 build_agent 上半部定好,与 system prompt
|
||||||
# 媒体段同源);本次 run 锁定该 variant,下一条消息可重选。
|
# 媒体段同源);本次 run 锁定该 variant,下一条消息可重选。
|
||||||
|
|
@ -241,6 +248,7 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
|
||||||
("send_email", smtp_configured, _send_email),
|
("send_email", smtp_configured, _send_email),
|
||||||
("wechat_push", wechat_push_available, _wechat_push),
|
("wechat_push", wechat_push_available, _wechat_push),
|
||||||
("run_python", lambda: ctx.caps.enable_run_python, _run_python),
|
("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),
|
("image", lambda: ctx.img_cfg is not None, _image),
|
||||||
("video", lambda: ctx.ark_cfg is not None, _video),
|
("video", lambda: ctx.ark_cfg is not None, _video),
|
||||||
("vision", lambda: ctx.ark_cfg is not None, _vision),
|
("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 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 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 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。
|
--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(
|
prompt = _build_system_prompt(
|
||||||
cfg, skills, ws, _HOST_CWD, wd, _UID, _TID, "数据资源展示", "documents",
|
cfg, skills, ws, _HOST_CWD, wd, _UID, _TID, "数据资源展示", "documents",
|
||||||
allow_working_dir_rename=allow_rename,
|
allow_working_dir_rename=allow_rename,
|
||||||
|
office_to_pdf_available=True,
|
||||||
)
|
)
|
||||||
return prompt, ws, wd
|
return prompt, ws, wd
|
||||||
|
|
||||||
|
|
@ -73,6 +74,16 @@ class TestSystemPromptPaths(unittest.TestCase):
|
||||||
self.assertIn("必须调用 `rename_working_dir`", interactive)
|
self.assertIn("必须调用 `rename_working_dir`", interactive)
|
||||||
self.assertNotIn("必须调用 `rename_working_dir`", scheduled)
|
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):
|
def test_web_search_schema_reinforces_time_rules(self):
|
||||||
from tools.web_search import WebSearchTool
|
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