feat(rendering): 通用报告走平台渲染器 report profile + 基座引导,少让模型手撸 python-docx(bump 0.58.35)

接 0.58.34(语法预检=止血),这条根治那一头。关键发现:平台早有成熟 md→docx(+pdf)
渲染器 rendering/(bind-mount /sandbox/rendering:ro,任何 run_python 可达,与 skill
无关),标题/正文/列表/表格/图片图题/mermaid/化学式下标/目录/house 字体全有,
paper/proposal/brief 三 profile 在用。烧 token 的验收报告没走它、在裸手撸 python-docx,
唯一原因是没引导把"随手写 Word 报告"指向它。故不造轮子,只两件小事:

1. rendering/docx_manuscript.py 加 report profile(复用全套渲染;差异=报告不是申报书:
   目录默认无[--toc 才带]/章节不强制分页/页边距 2.5-2.5/列表含"第X章节"不含"条"),
   render.py --profile 加 report 选项 + 路由。
2. core/agent_builder.py 基座 prompt 加软引导:出 Word 报告→正文写成 sections/*.md
   调 render.py --profile report,别手撸 python-docx 内联中文。

取舍红线(与用户对齐):渲染器是首选加法不是替代——绝不硬拦 run_python 生成文档,
确需精细定制版式/改写已有 docx/处理 xlsx 等渲染器覆盖不了的照样自由写代码;软引导
让位于更具体的 skill render 指引(proposal/paper)。刻意不把渲染器指路塞进语法预检
hint(会 overload 只该管语法的检查、误导合法手撸),预检保持只诊断语法。三层叠加:
渲染器(根治)> 预检(0.58.34)> RepeatGuard err-streak(0.58.33),run_python 能力不削。

测试 tests/test_rendering.py(四 profile 端到端 + report 目录可选)+ CLI e2e
(render.py --profile report 出 37KB docx)全绿;py_compile 校三文件无语法错。
DESIGN §8.6 记该 profile + 并存红线。additive,不碰对外 API/DB 契约。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-17 09:32:02 +08:00
parent 2a60243c02
commit 5cf92ec71a
7 changed files with 59 additions and 5 deletions

View File

@ -278,7 +278,9 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
### 8.6 平台渲染层 rendering/(✅ 2026-06-23) ### 8.6 平台渲染层 rendering/(✅ 2026-06-23)
**心智:文档渲染是平台能力不是 skill 内容**。起因:化学式白名单在三份 render_docx 逐字重复 + brief 缺 PDF 路径致线上手搓 weasyprint。**不放 `skills/_shared/`**:skill 走自包含/可 fork 标准,跨 skill import 破坏 fork。抽顶层 `rendering/` bind-mount `/sandbox/rendering:ro`:common(叶子原语单一事实源)+ docx_manuscript(paper/proposal 双 profile)+ docx_brief + pdf(chromium 不用 weasyprint——镜像已有,保真更高)+ render.py 统一 CLI。重构前后 docx 字节一致零回归;brief 不强并 manuscript(差异大,只共叶子)。与 §8.3 分工:pptx 预览在 host 面向用户,本层在沙盒面向 agent 交付物。 **心智:文档渲染是平台能力不是 skill 内容**。起因:化学式白名单在三份 render_docx 逐字重复 + brief 缺 PDF 路径致线上手搓 weasyprint。**不放 `skills/_shared/`**:skill 走自包含/可 fork 标准,跨 skill import 破坏 fork。抽顶层 `rendering/` bind-mount `/sandbox/rendering:ro`:common(叶子原语单一事实源)+ docx_manuscript(paper/proposal/report 三 profile)+ docx_brief + pdf(chromium 不用 weasyprint——镜像已有,保真更高)+ render.py 统一 CLI。重构前后 docx 字节一致零回归;brief 不强并 manuscript(差异大,只共叶子)。与 §8.3 分工:pptx 预览在 host 面向用户,本层在沙盒面向 agent 交付物。
**`report` 通用 profile + 基座引导(2026-07-17)**:起因是工具失败面板最大头——建材院验收/技术/评审报告(无对应 skill)由模型裸手撸 python-docx 内联中文正文,引号/全角标点崩成 SyntaxError 反复返工烧 token(定层见 `core/pysyntax.py`)。根因不是缺渲染器(paper/proposal 早有全套 md→docx),而是**这类自由报告没有指路**。补 `report` profile(复用 manuscript 全套,差异:目录默认无 / 章节不强制分页 / 通用页边距)+ 基座 prompt 加软引导「出 Word 报告→正文写成 md sections 调 render.py --profile report,别手撸 python-docx」。**取舍(红线):渲染器是"首选加法"不是"替代"——绝不硬拦 run_python 生成文档**。确需精细定制版式 / 改写已有 docx / 处理 xlsx 等渲染器覆盖不了的,run_python 照样自由写代码;软引导让位于更具体的 skill render 指引(proposal/paper)。三层防御叠加:渲染器(根治,正文进 md 不进代码)> run_python 语法预检(`core/pysyntax.py`,手撸时的安全网,只诊断语法不限制工具选择)> RepeatGuard err-streak(撞墙兜底)。
### 8.7 微信接入(双渠道)(✅ 均落地) ### 8.7 微信接入(双渠道)(✅ 均落地)

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-17 / 0.58.35**:**通用报告走平台渲染器(`report` profile)+ 基座引导——从根上少让模型手撸 python-docx**(接 0.58.34:语法预检是止血,这条是根治那一头)。**关键发现**:平台**早有**成熟 md→docx(+pdf)渲染器 `rendering/`(bind-mount `/sandbox/rendering:ro`,任何 run_python/shell 可达,与 skill 无关),标题/正文/列表/表格/图片图题/mermaid/内联/化学式下标/目录/house 字体(宋体小四·黑体·Times·1.5 行距)全有,paper/proposal/brief 三 profile 在用。烧 token 的验收报告没走它、在裸手撸 python-docx,**唯一原因是没 skill/引导把"随手写 Word 报告"指向它**(只有 proposal/paper 知道)。故**不造轮子**,只两件小事:① `rendering/docx_manuscript.py``report` profile(复用全套渲染,差异=报告不是申报书:目录默认无[`--toc` 才带]、章节不强制分页、通用页边距 2.5/2.5、列表含"第X章/节"不含"条"),`render.py --profile` 加 `report` 选项 + 路由;② `core/agent_builder.py` 基座 prompt 加软引导「出 Word 报告→正文写成 `<task_dir>/sections/*.md`(纯文本零转义)调 `render.py --profile report`,别手撸 python-docx 内联中文」。**取舍红线(与用户对齐)**:渲染器是**首选加法不是替代**——绝不硬拦 run_python 生成文档,确需精细定制版式/改写已有 docx/处理 xlsx 等渲染器覆盖不了的照样自由写代码;软引导让位于更具体的 skill render 指引(proposal/paper)。**刻意不做**:(c) 把渲染器指路塞进语法预检 hint——会把只该管语法的检查overload成它做不可靠的工具选择判断(分不清"报告正文该走渲染器"vs"定制 python-docx 就该手撸"),误导合法手撸,故预检保持只诊断语法。三层叠加:渲染器(根治)> 预检(0.58.34 安全网)> RepeatGuard err-streak(0.58.33 兜底),run_python 能力不削。测试 `tests/test_rendering.py`(四 profile 端到端 + report 目录可选)+ CLI e2e(`render.py --profile report` 出 37KB docx)全绿;py_compile 校 agent_builder/render/manuscript 无语法错。DESIGN §8.6 记该 profile + 并存红线。additive,不碰对外 API/DB 契约。相关 memory:skill 禁令不带配方 / 高轮数烧 token 三根因。
- **07-17 / 0.58.34**:**run_python 语法预检:宿主 compile() 拦下中文正文硬拼进源码的语法坏码**(失败面板最大头,20 条真实样本实证:模型手写 python-docx 生成 docx/报告时把中文正文内联进 `T("中文…")`,引号/标点崩坏 → SyntaxError → 改 → 再崩,`gen_acceptance_report_v3.py` 一分钟内连撞 6 次)。三类:**A 引号提前闭合**(最多,中文串里用 ASCII 引号 `"` 把外层字符串提前闭合;Python 却报 `Perhaps you forgot a comma?`/`unterminated string`,**主动误导**模型照着加逗号越修越错)、**B 全角标点漏进代码位**(`center=True、`/`采购(`,`invalid character 'X'(U+XXXX)`)、**C 括号/引号结构崩**。**根因判断**:症结不是"缺写长文的工具"(write 早能写长文进文件),而是**docx/报告没有渲染管线**——documents skill 只做文献检索、不管 docx 生成,模型裸手撸 python-docx 内联中文;而 ppt skill 有 SVG→pptx 管线(正文进 SVG 文本节点),所以 ppt 崩得少。**本轮先落机检止血**(对标 [[feedback_skill_ban_no_recipe]] "硬约束靠平台机检"):新增独立模块 `core/pysyntax.py`(不 import litellm,`tests/test_pysyntax.py` 本机可跑,11 用例覆盖 A/B/C + 合法中文串/中文引号不误伤)`precheck_python(code)`——host 上 `compile()`(host 3.12 ≡ sandbox python:3.12-slim,零版本偏差;只解析不执行、与依赖无关),命中 SyntaxError 返**锐化中文诊断**(出错行+光标+按错误类型定向:invalid-character→全角改半角、forgot-comma/unterminated+含中文→ASCII 引号提前闭合的三改法、含中文统一追加"正文别拼进 .py,write 进 .md/.txt 再 read"根治提示)。接进 **run_python 所有派发路径**(inline/script_path/background × docker+host 两执行器,`_prepare_script` 内嵌覆盖 script_path 全路径 + inline 分支各加一处):命中即 `ToolResult(hint, exit_code=2)` 早返、**跳过 docker 往返**。**零误伤**:只拦本就跑不了的码(compile 不过=运行必失败),通过则原样放行、行为不变;既有 30 个 executor/run_python 测试(合法码 fixture)全绿印证。**白捡协同**:预检把 run_python 语法失败从 `[stderr]…[exit 1]` 改成 `[Error]` 前缀+固定首行,`loop._RepeatGuard` 的 err-streak(0.58.33 ②)得以兜住"反复交语法坏码"的循环(旧 `[exit 1]` 形态它按非错误放行、抓不到)——① 诊断准(修得快)+ ② 撞墙拦(停得住)合围。**下一步(未做,单列选型)**:docx 渲染管线(正文 write 进纯文本文件 → 渲染工具 read 生成 docx,正文永不进 Python 源码,**也不能走"大正文当工具入参"**——那会把长中文塞进 tool_call arguments,触发 0.58.33 修的 wire 乱序畸形);沙箱现有 python-docx+markitdown(docx→md 单向)、无 pandoc/libreoffice,自建渲染器 vs 装 pandoc 待拍板。additive 内部机制,不碰对外 API/DB 契约。相关 memory:skill 禁令不带配方 / deepseek 畸形 salvage 案。 - **07-17 / 0.58.34**:**run_python 语法预检:宿主 compile() 拦下中文正文硬拼进源码的语法坏码**(失败面板最大头,20 条真实样本实证:模型手写 python-docx 生成 docx/报告时把中文正文内联进 `T("中文…")`,引号/标点崩坏 → SyntaxError → 改 → 再崩,`gen_acceptance_report_v3.py` 一分钟内连撞 6 次)。三类:**A 引号提前闭合**(最多,中文串里用 ASCII 引号 `"` 把外层字符串提前闭合;Python 却报 `Perhaps you forgot a comma?`/`unterminated string`,**主动误导**模型照着加逗号越修越错)、**B 全角标点漏进代码位**(`center=True、`/`采购(`,`invalid character 'X'(U+XXXX)`)、**C 括号/引号结构崩**。**根因判断**:症结不是"缺写长文的工具"(write 早能写长文进文件),而是**docx/报告没有渲染管线**——documents skill 只做文献检索、不管 docx 生成,模型裸手撸 python-docx 内联中文;而 ppt skill 有 SVG→pptx 管线(正文进 SVG 文本节点),所以 ppt 崩得少。**本轮先落机检止血**(对标 [[feedback_skill_ban_no_recipe]] "硬约束靠平台机检"):新增独立模块 `core/pysyntax.py`(不 import litellm,`tests/test_pysyntax.py` 本机可跑,11 用例覆盖 A/B/C + 合法中文串/中文引号不误伤)`precheck_python(code)`——host 上 `compile()`(host 3.12 ≡ sandbox python:3.12-slim,零版本偏差;只解析不执行、与依赖无关),命中 SyntaxError 返**锐化中文诊断**(出错行+光标+按错误类型定向:invalid-character→全角改半角、forgot-comma/unterminated+含中文→ASCII 引号提前闭合的三改法、含中文统一追加"正文别拼进 .py,write 进 .md/.txt 再 read"根治提示)。接进 **run_python 所有派发路径**(inline/script_path/background × docker+host 两执行器,`_prepare_script` 内嵌覆盖 script_path 全路径 + inline 分支各加一处):命中即 `ToolResult(hint, exit_code=2)` 早返、**跳过 docker 往返**。**零误伤**:只拦本就跑不了的码(compile 不过=运行必失败),通过则原样放行、行为不变;既有 30 个 executor/run_python 测试(合法码 fixture)全绿印证。**白捡协同**:预检把 run_python 语法失败从 `[stderr]…[exit 1]` 改成 `[Error]` 前缀+固定首行,`loop._RepeatGuard` 的 err-streak(0.58.33 ②)得以兜住"反复交语法坏码"的循环(旧 `[exit 1]` 形态它按非错误放行、抓不到)——① 诊断准(修得快)+ ② 撞墙拦(停得住)合围。**下一步(未做,单列选型)**:docx 渲染管线(正文 write 进纯文本文件 → 渲染工具 read 生成 docx,正文永不进 Python 源码,**也不能走"大正文当工具入参"**——那会把长中文塞进 tool_call arguments,触发 0.58.33 修的 wire 乱序畸形);沙箱现有 python-docx+markitdown(docx→md 单向)、无 pandoc/libreoffice,自建渲染器 vs 装 pandoc 待拍板。additive 内部机制,不碰对外 API/DB 契约。相关 memory:skill 禁令不带配方 / deepseek 畸形 salvage 案。
- **07-17 / 0.58.33**:**堵工具失败聚集里两条反复烧 token 的裂缝**(扫近 14 天失败面板 top13 定位,`scripts/standalone` 复现口径)。**裂缝①「必填 key 被吞」畸形(面板 #3 `edit 缺少必填参数 ['path']`,14 次 / 13 task / 9 用户,最广)**:定层查一条原始 tool_call 坐实是**流式 wire 乱序**——某键的值碎片被瞬移拼进相邻字符串(实证:path 的 `.../gen_final_report_v2.py` 被吞进 `old_str` 尾部,独立的 `"path"` 键随之消失),跨 provider(`call_00_` 网关档 + `toolu_` Anthropic 系),非模型漏参。这类与既有 char-0 前缀畸形的关键区别是 **JSON parse 成功** → 既不命中 `_malformed_tool_calls`(只抓 parse 失败)、salvage 也救不了(parse-to-end/key 白名单对合法但错位无能),一路漏到 executor 才在语义层报错、回 `[Error]` 喂回模型 → 再拼再乱序反复烧。**修**:`_toolcalls_partial_args`(解析为非空 dict 且 `0<len(missing_required)<len(required)`,即至少一个必填在场、至少一个缺失=错位吞键特征;空 `{}`/必填全缺不算,仍交 executor+RepeatGuard)接进 `_stream_llm` 的 attempt 循环,和畸形/空响应同类:丢弃本轮走**非流式重试**(服务端一次拼好绕开流式 delta 乱序),耗尽仍缺才落回 executor 返「缺必填参数」(多为真漏参,不再空转);`_log_partial_args` 落 `usage_events`(kind=`tool_malformed`,error 签名固定 `missing required keys [...]`,和 char-0 型区分)——这类此前伪装成普通 messages `[Error]`、面板完全看不见,现纳入可观测。**裂缝②「换参数撞同一堵墙」(面板 #2 `edit old_str not found` 单 task 9 次 / 24h 7 次)**:模型每次微调 old_str 重试,精确 args 指纹每次不同 → `_RepeatGuard` 的 arg 判据不累计,只能等 `_STALL_LIMIT` 慢慢兜。**修**:`_RepeatGuard` 补第二道判据——按工具的**连续同类错误 streak**(`_norm_err` 抹平路径/数字后签名相同、跨 ≥2 个不同 args),SOFT 注入定向提示(先 read/grep 看确切内容再动手)、HARD 拦截一次并重置到 SOFT(非永久封死,换路后重试留活口);任一非错误结果立即清零该工具 streak。测试 `tests/test_loop_repeat_guard.py` +12(err-streak 5 + partial-args 7),全 23 相邻测试(salvage/toolfail_malformed)绿。**本机验证坑**:`core/loop.py` 顶层 `import litellm` 本机导入死卡(GitHub cost map),纯逻辑测试用 `sys.modules` 注入 litellm+子模块 stub 绕开跑真代码;失败面板扫描脚本因 import 链同样卡,改直连 psycopg 复刻 `scan_tool_failures` 口径查(结果与走 `core` 的交叉验证一致)。盲区:provider-wire 抖动本机复现不出、/verify 端到端跑不了,靠单测 + 生产灰度。additive 内部机制,不碰对外 API/DB 契约。剩 ①run_python 中文全角标点/未闭合字符串致 SyntaxError(面板最大头,产物质量,待单独立项)、③超时 19 次未动。相关 memory:deepseek 畸形 salvage 案 / 高轮数烧 token 三根因。 - **07-17 / 0.58.33**:**堵工具失败聚集里两条反复烧 token 的裂缝**(扫近 14 天失败面板 top13 定位,`scripts/standalone` 复现口径)。**裂缝①「必填 key 被吞」畸形(面板 #3 `edit 缺少必填参数 ['path']`,14 次 / 13 task / 9 用户,最广)**:定层查一条原始 tool_call 坐实是**流式 wire 乱序**——某键的值碎片被瞬移拼进相邻字符串(实证:path 的 `.../gen_final_report_v2.py` 被吞进 `old_str` 尾部,独立的 `"path"` 键随之消失),跨 provider(`call_00_` 网关档 + `toolu_` Anthropic 系),非模型漏参。这类与既有 char-0 前缀畸形的关键区别是 **JSON parse 成功** → 既不命中 `_malformed_tool_calls`(只抓 parse 失败)、salvage 也救不了(parse-to-end/key 白名单对合法但错位无能),一路漏到 executor 才在语义层报错、回 `[Error]` 喂回模型 → 再拼再乱序反复烧。**修**:`_toolcalls_partial_args`(解析为非空 dict 且 `0<len(missing_required)<len(required)`,即至少一个必填在场、至少一个缺失=错位吞键特征;空 `{}`/必填全缺不算,仍交 executor+RepeatGuard)接进 `_stream_llm` 的 attempt 循环,和畸形/空响应同类:丢弃本轮走**非流式重试**(服务端一次拼好绕开流式 delta 乱序),耗尽仍缺才落回 executor 返「缺必填参数」(多为真漏参,不再空转);`_log_partial_args` 落 `usage_events`(kind=`tool_malformed`,error 签名固定 `missing required keys [...]`,和 char-0 型区分)——这类此前伪装成普通 messages `[Error]`、面板完全看不见,现纳入可观测。**裂缝②「换参数撞同一堵墙」(面板 #2 `edit old_str not found` 单 task 9 次 / 24h 7 次)**:模型每次微调 old_str 重试,精确 args 指纹每次不同 → `_RepeatGuard` 的 arg 判据不累计,只能等 `_STALL_LIMIT` 慢慢兜。**修**:`_RepeatGuard` 补第二道判据——按工具的**连续同类错误 streak**(`_norm_err` 抹平路径/数字后签名相同、跨 ≥2 个不同 args),SOFT 注入定向提示(先 read/grep 看确切内容再动手)、HARD 拦截一次并重置到 SOFT(非永久封死,换路后重试留活口);任一非错误结果立即清零该工具 streak。测试 `tests/test_loop_repeat_guard.py` +12(err-streak 5 + partial-args 7),全 23 相邻测试(salvage/toolfail_malformed)绿。**本机验证坑**:`core/loop.py` 顶层 `import litellm` 本机导入死卡(GitHub cost map),纯逻辑测试用 `sys.modules` 注入 litellm+子模块 stub 绕开跑真代码;失败面板扫描脚本因 import 链同样卡,改直连 psycopg 复刻 `scan_tool_failures` 口径查(结果与走 `core` 的交叉验证一致)。盲区:provider-wire 抖动本机复现不出、/verify 端到端跑不了,靠单测 + 生产灰度。additive 内部机制,不碰对外 API/DB 契约。剩 ①run_python 中文全角标点/未闭合字符串致 SyntaxError(面板最大头,产物质量,待单独立项)、③超时 19 次未动。相关 memory:deepseek 畸形 salvage 案 / 高轮数烧 token 三根因。
- **07-15 / 0.58.32**:**空响应防御:provider 吐空不再被当正常收尾静默 done**(复盘 task 2a1bc25d "ppt3":unifyllm 网关对某档 Claude 偶发把 tool_use 漏成正文 / 直接吐空,回来的轮 `tool_calls=[]` 且正文空,`loop.py` 的 `if not tool_calls: done` 当"模型答完"收尾——run_status=idle、无报错、无终态失败,表现为"自己中断",只能人肉挖 DB 才发现;复测该 bug 现已消失/瞬态,但"空 tool_calls 直接 done"这个失败模式本身太隐蔽,任何 provider 未来吐一次空都会静默卡死,故加防御)。**两层**(对称既有畸形 salvage 链):① 自动重试——`_is_empty_response`(tc 空且 content 去空白为空;纯 tool_call 轮不误判)接进 `_stream_llm` 的 attempt 循环,和畸形同类:丢弃本轮走非流式重试(多数瞬态重发一次即好,用户无感),`_log_empty_response` stdout+`usage_events`(kind=`empty_response`,cost 0)双写留痕、DB 失败静默不阻塞;② 重试耗尽仍空——`run()` 收尾点分空-空分支:不发静默 `done`,改发**可见 warn**「模型返回空响应(已自动重试仍为空),已停止,回复『继续』可重试」+ done(复用 stall 熔断的 warn+自停话术,run_status 落 idle 可续),**不引入终态 error**(空响应可恢复,红 error 过重且与既有路径不对称)。③ 可观测——`core/toolfail.py` 加第四段扫描 kind=empty_response 聚成 tool=`(empty)`/kind=`empty` 的 cluster(sample=model_profile 看哪个网关档在吐空),即便自动救回也留痕、跨 task 系统性吐空才冒头(单次瞬态 <阈值不触发)。**刻意不做**内容嗅探式"narrated tool_call 特征重试":marker 每次变且与助手正常解释/贴代码的正文高度重叠,假阳性(把合法回答判坏反复重试)比它防的罕见静默停更糟——那类只靠面板留痕兜不改热路径行为测试:`tests/test_loop_empty_response.py`(_is_empty_response 四态含纯 tool_call 不误判 + 重试恢复/耗尽返空/正常文本不重试)、`tests/test_toolfail_malformed.py` +2(空响应聚集 / 单次瞬态不触发),顺手补回 `test_loop_malformed_retry.py` salvage(0.58.24)落地后失修的 `loop.executor` ( schemas salvage False 走既有重试)。 247 测试绿盲区:provider-wire 抖动本机复现不出、/verify 端到端跑不了,靠单测+生产灰度additive 内部机制,不碰对外 API/DB 契约相关 memory:unifyllm tool_use 漏正文案 / deepseek 畸形 salvage - **07-15 / 0.58.32**:**空响应防御:provider 吐空不再被当正常收尾静默 done**(复盘 task 2a1bc25d "ppt3":unifyllm 网关对某档 Claude 偶发把 tool_use 漏成正文 / 直接吐空,回来的轮 `tool_calls=[]` 且正文空,`loop.py` 的 `if not tool_calls: done` 当"模型答完"收尾——run_status=idle、无报错、无终态失败,表现为"自己中断",只能人肉挖 DB 才发现;复测该 bug 现已消失/瞬态,但"空 tool_calls 直接 done"这个失败模式本身太隐蔽,任何 provider 未来吐一次空都会静默卡死,故加防御)。**两层**(对称既有畸形 salvage 链):① 自动重试——`_is_empty_response`(tc 空且 content 去空白为空;纯 tool_call 轮不误判)接进 `_stream_llm` 的 attempt 循环,和畸形同类:丢弃本轮走非流式重试(多数瞬态重发一次即好,用户无感),`_log_empty_response` stdout+`usage_events`(kind=`empty_response`,cost 0)双写留痕、DB 失败静默不阻塞;② 重试耗尽仍空——`run()` 收尾点分空-空分支:不发静默 `done`,改发**可见 warn**「模型返回空响应(已自动重试仍为空),已停止,回复『继续』可重试」+ done(复用 stall 熔断的 warn+自停话术,run_status 落 idle 可续),**不引入终态 error**(空响应可恢复,红 error 过重且与既有路径不对称)。③ 可观测——`core/toolfail.py` 加第四段扫描 kind=empty_response 聚成 tool=`(empty)`/kind=`empty` 的 cluster(sample=model_profile 看哪个网关档在吐空),即便自动救回也留痕、跨 task 系统性吐空才冒头(单次瞬态 <阈值不触发)。**刻意不做**内容嗅探式"narrated tool_call 特征重试":marker 每次变且与助手正常解释/贴代码的正文高度重叠,假阳性(把合法回答判坏反复重试)比它防的罕见静默停更糟——那类只靠面板留痕兜不改热路径行为测试:`tests/test_loop_empty_response.py`(_is_empty_response 四态含纯 tool_call 不误判 + 重试恢复/耗尽返空/正常文本不重试)、`tests/test_toolfail_malformed.py` +2(空响应聚集 / 单次瞬态不触发),顺手补回 `test_loop_malformed_retry.py` salvage(0.58.24)落地后失修的 `loop.executor` ( schemas salvage False 走既有重试)。 247 测试绿盲区:provider-wire 抖动本机复现不出、/verify 端到端跑不了,靠单测+生产灰度additive 内部机制,不碰对外 API/DB 契约相关 memory:unifyllm tool_use 漏正文案 / deepseek 畸形 salvage

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.58.34" __version__ = "0.58.35"

View File

@ -398,6 +398,16 @@ def _build_system_prompt(
f"普通产物(sections / slides / 终稿 .docx/.pptx)按 SKILL 文档落路径;" f"普通产物(sections / slides / 终稿 .docx/.pptx)按 SKILL 文档落路径;"
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n" f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
f"\n## 生成 Word 报告 / 文档(验收 / 技术 / 评审报告等自由长文)\n"
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
f"再调平台渲染器出 docx —— **别在 run_python 里手撸 python-docx 内联大段中文正文**"
f"(中文引号 / 全角标点混进源码极易 SyntaxError、反复返工烧 token):\n\n"
f" python /sandbox/rendering/render.py --profile report --format docx <task_dir>/sections/ -o <task_dir>/<报告名>.docx\n\n"
f"渲染器自动管字体(正文宋体小四 / 标题黑体 / 1.5 倍行距)、目录(加 `--toc`)、"
f"表格、`![](path)` 居中插图 + 图题编号、mermaid 图。已加载 proposal / paper 等 skill 时"
f"按其 render 指引的 profile 走(更具体),通用报告用 `--profile report`。\n"
f"这是**软建议不是强制**:确需精细定制版式 / 改写已有 docx / 处理 xlsx 等渲染器覆盖不了的,"
f"照样用 run_python 自由写代码。\n"
f"\n## task 级「宪法」文件命名约定(跨 skill 通用)\n" f"\n## task 级「宪法」文件命名约定(跨 skill 通用)\n"
f"跟 task 1:1 绑定、后续步骤会**反复 read** 的「宪法」性文件(如 proposal/ppt 的 " f"跟 task 1:1 绑定、后续步骤会**反复 read** 的「宪法」性文件(如 proposal/ppt 的 "
f"spec、outline),统一落 task_dir 根、按此格式命名:\n\n" f"spec、outline),统一落 task_dir 根、按此格式命名:\n\n"

View File

@ -57,6 +57,21 @@ PROFILES = {
"always_toc": True, "always_toc": True,
"trailing_page_break": True, "trailing_page_break": True,
}, },
# 通用报告(建材院验收/技术/评审报告等自由长文):复用 manuscript 全套渲染,
# 差异是"报告不是申报书"——目录默认无(--toc 才带)、章节间连续排版不强制分页、
# 通用页边距。刻意让模型把正文写成 md 交给它渲,别再手撸 python-docx 内联中文
# (失败面板最大头,见 core/pysyntax.py)。
"report": {
"left_margin": Cm(2.5),
"right_margin": Cm(2.5),
"list_patterns": _BASE_LIST_PATTERNS + [
re.compile(r"^第[一二三四五六七八九十百]+[章节]"), # 第一章 / 第一节(不含"条")
],
"toc_title": "目 录",
"toc_placeholder": "[在 Word 中按 F9 或右键此处选择 “更新域” 即可生成完整目录]",
"always_toc": False,
"trailing_page_break": False,
},
} }

View File

@ -26,7 +26,8 @@ from rendering import docx_brief, docx_manuscript, pdf # noqa: E402
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="md(sections 目录或单 .md)→ docx / pdf") ap = argparse.ArgumentParser(description="md(sections 目录或单 .md)→ docx / pdf")
ap.add_argument("src", type=Path, help="sections 目录(拼接其 *.md)或单个 .md") ap.add_argument("src", type=Path, help="sections 目录(拼接其 *.md)或单个 .md")
ap.add_argument("--profile", required=True, choices=["brief", "paper", "proposal"]) ap.add_argument("--profile", required=True,
choices=["brief", "paper", "proposal", "report"])
ap.add_argument("--format", default="docx", choices=["docx", "pdf"]) ap.add_argument("--format", default="docx", choices=["docx", "pdf"])
ap.add_argument("-o", "--output", type=Path, required=True, help="输出路径") ap.add_argument("-o", "--output", type=Path, required=True, help="输出路径")
ap.add_argument("--no-color", dest="color", action="store_false", ap.add_argument("--no-color", dest="color", action="store_false",
@ -53,6 +54,9 @@ def main(argv: list[str] | None = None) -> int:
elif args.profile == "paper": elif args.profile == "paper":
docx_manuscript.render_sections("paper", args.src, args.output, docx_manuscript.render_sections("paper", args.src, args.output,
lang=args.lang, toc=args.toc) lang=args.lang, toc=args.toc)
elif args.profile == "report":
docx_manuscript.render_sections("report", args.src, args.output,
lang=args.lang, toc=args.toc)
else: # proposal else: # proposal
docx_manuscript.render_sections("proposal", args.src, args.output, docx_manuscript.render_sections("proposal", args.src, args.output,
fund_type=args.fund_type) fund_type=args.fund_type)

View File

@ -51,7 +51,7 @@ def _docx_paragraphs(path: Path) -> int:
class TestDocxProfiles(unittest.TestCase): class TestDocxProfiles(unittest.TestCase):
def test_three_profiles_render(self): def test_four_profiles_render(self):
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
d = Path(td) d = Path(td)
sec = _write_sections(d) sec = _write_sections(d)
@ -68,11 +68,33 @@ class TestDocxProfiles(unittest.TestCase):
out_r = d / "proposal.docx" out_r = d / "proposal.docx"
docx_manuscript.render_sections("proposal", sec, out_r, fund_type="key_rd") docx_manuscript.render_sections("proposal", sec, out_r, fund_type="key_rd")
self.assertTrue(out_r.exists() and out_r.stat().st_size > 0) self.assertTrue(out_r.exists() and out_r.stat().st_size > 0)
# report(通用报告)
out_g = d / "report.docx"
docx_manuscript.render_sections("report", sec, out_g)
self.assertTrue(out_g.exists() and out_g.stat().st_size > 0)
self.assertGreater(_docx_paragraphs(out_g), 0)
# 每份都应有 1 张表 # 每份都应有 1 张表
for f in (out_b, out_p, out_r): for f in (out_b, out_p, out_r, out_g):
with zipfile.ZipFile(f) as z: with zipfile.ZipFile(f) as z:
self.assertIn("word/document.xml", z.namelist()) self.assertIn("word/document.xml", z.namelist())
def test_report_toc_optional(self):
"""report 默认无目录,--toc 才带(与 proposal 始终带目录相区分)。"""
with tempfile.TemporaryDirectory() as td:
d = Path(td)
sec = _write_sections(d)
out_no = d / "report_notoc.docx"
docx_manuscript.render_sections("report", sec, out_no, toc=False)
out_yes = d / "report_toc.docx"
docx_manuscript.render_sections("report", sec, out_yes, toc=True)
def _has_toc_field(path: Path) -> bool:
with zipfile.ZipFile(path) as z:
xml = z.read("word/document.xml").decode("utf-8", "replace")
return "TOC \\o" in xml
self.assertFalse(_has_toc_field(out_no))
self.assertTrue(_has_toc_field(out_yes))
class TestChemSingleSource(unittest.TestCase): class TestChemSingleSource(unittest.TestCase):
def test_pdf_uses_common_chem(self): def test_pdf_uses_common_chem(self):