Compare commits
3 Commits
1685004f96
...
5cf92ec71a
| Author | SHA1 | Date |
|---|---|---|
|
|
5cf92ec71a | |
|
|
2a60243c02 | |
|
|
3c60c5c736 |
|
|
@ -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 微信接入(双渠道)(✅ 均落地)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@
|
||||||
|
|
||||||
### 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.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 案。
|
||||||
- **07-15 / 0.58.31**:**撤回 §8.11 delegate(0.58.30 曾整体落地,revert + force-push 抹除)**。delegate(隔离检索子循环:FilteredExecutor + 内存态子 Session + loop 拦截 + 降 flash)机制本身没错、对标 Claude Code subagent 也成立,但**触发信号没坐实就上了**:自评时回看 `diag_search_args.py`,motivating 案子 `document_search` 122 次呈"一批批不同材料体系并行搜"形态,更像**批量扇出而非结果驱动探索**——若属实其正解与 mp_search 同为**批量工具 `document_search_batch`**(便宜/可预测/可诊断),delegate 是过度设计;叠加子循环 transcript 不落盘(诊断驱动的功能反不可诊断)、20 轮上限对 122 次负载偏低、强制 flash 对难检索可能降质等未验证坑。故整体撤回,只留验证过的批量工具(0.58.28)。**重建前置收紧**:先用 diag 确认某检索 task 的 query 是真探索(依赖前序结果、无法一次列全)而非可枚举批量,是批量走批量工具、只有真探索才 delegate(DESIGN §8.11 记该教训)。撤法:`git reset --hard` 到 0.58.29 + `--force-with-lease` 推,delegate commit 从历史抹除(远程 tip 确认无他人后续提交才 force);保留本条 PROGRESS 记录"试过并撤回"免日后盲目重建。
|
- **07-15 / 0.58.31**:**撤回 §8.11 delegate(0.58.30 曾整体落地,revert + force-push 抹除)**。delegate(隔离检索子循环:FilteredExecutor + 内存态子 Session + loop 拦截 + 降 flash)机制本身没错、对标 Claude Code subagent 也成立,但**触发信号没坐实就上了**:自评时回看 `diag_search_args.py`,motivating 案子 `document_search` 122 次呈"一批批不同材料体系并行搜"形态,更像**批量扇出而非结果驱动探索**——若属实其正解与 mp_search 同为**批量工具 `document_search_batch`**(便宜/可预测/可诊断),delegate 是过度设计;叠加子循环 transcript 不落盘(诊断驱动的功能反不可诊断)、20 轮上限对 122 次负载偏低、强制 flash 对难检索可能降质等未验证坑。故整体撤回,只留验证过的批量工具(0.58.28)。**重建前置收紧**:先用 diag 确认某检索 task 的 query 是真探索(依赖前序结果、无法一次列全)而非可枚举批量,是批量走批量工具、只有真探索才 delegate(DESIGN §8.11 记该教训)。撤法:`git reset --hard` 到 0.58.29 + `--force-with-lease` 推,delegate commit 从历史抹除(远程 tip 确认无他人后续提交才 force);保留本条 PROGRESS 记录"试过并撤回"免日后盲目重建。
|
||||||
- **07-15 / 0.58.29**:**修对话正文文件锚点两处回归(含 `·` 的 pptx 丢 chip + 正文文件链接点击整页 404)**。① chip 丢失:`media.js` 的 `_TAIL_CLS` 把间隔号 `·`(U+00B7)当尾部排除字符,而它是文件名合法分隔符(`AI赋能陶瓷产业·…-图标增强版.pptx`)→ 在 `·` 处截断成目录不挂 chip。去掉 `·`(尾部标点另有 strip 兜底);node 验证 OLD→`[]`、NEW→两路径全出。② 链接 404:模型把相对路径写成 `[<rel>](<rel>)`,`renderMd` 出 `<a>`,但 `chat.js` 的 `#chat-stream` 点击委托无 `<a>` 拦截 → 原生跳转打到 API 不存在路由返 FastAPI 默认 `{"detail":"Not Found"}`。加拦截 `.msg .body a[href]`:外链开新标签,内部相对路径 `openFilePreview(rel)`(复用预览 modal,pptx 转 PDF),`#` 锚点走默认。纯前端修复,不碰对外契约。
|
- **07-15 / 0.58.29**:**修对话正文文件锚点两处回归(含 `·` 的 pptx 丢 chip + 正文文件链接点击整页 404)**。① chip 丢失:`media.js` 的 `_TAIL_CLS` 把间隔号 `·`(U+00B7)当尾部排除字符,而它是文件名合法分隔符(`AI赋能陶瓷产业·…-图标增强版.pptx`)→ 在 `·` 处截断成目录不挂 chip。去掉 `·`(尾部标点另有 strip 兜底);node 验证 OLD→`[]`、NEW→两路径全出。② 链接 404:模型把相对路径写成 `[<rel>](<rel>)`,`renderMd` 出 `<a>`,但 `chat.js` 的 `#chat-stream` 点击委托无 `<a>` 拦截 → 原生跳转打到 API 不存在路由返 FastAPI 默认 `{"detail":"Not Found"}`。加拦截 `.msg .body a[href]`:外链开新标签,内部相对路径 `openFilePreview(rel)`(复用预览 modal,pptx 转 PDF),`#` 锚点走默认。纯前端修复,不碰对外契约。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.58.32"
|
__version__ = "0.58.35"
|
||||||
|
|
|
||||||
|
|
@ -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"表格、`` 居中插图 + 图题编号、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"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ _CANCEL_POLL_INTERVAL_S = 0.2
|
||||||
|
|
||||||
from .executor import ExecCtx, Executor, ToolResult
|
from .executor import ExecCtx, Executor, ToolResult
|
||||||
from .executor_host import HostExecutor
|
from .executor_host import HostExecutor
|
||||||
|
from .pysyntax import precheck_python
|
||||||
from .sandbox import SandboxPool
|
from .sandbox import SandboxPool
|
||||||
from tools.base import compact_tool_output
|
from tools.base import compact_tool_output
|
||||||
|
|
||||||
|
|
@ -222,6 +223,9 @@ class DockerExecutor(Executor):
|
||||||
content="[Error] bad arguments to run_python: code or script_path must be provided",
|
content="[Error] bad arguments to run_python: code or script_path must be provided",
|
||||||
exit_code=2,
|
exit_code=2,
|
||||||
)
|
)
|
||||||
|
hint = precheck_python(code)
|
||||||
|
if hint:
|
||||||
|
return ToolResult(content=hint, exit_code=2)
|
||||||
timeout = int(args.get("timeout") or 120)
|
timeout = int(args.get("timeout") or 120)
|
||||||
|
|
||||||
# tmp .py 落 host 侧 `.zcbot_tmp/<task_id>/<rand>.py`;
|
# tmp .py 落 host 侧 `.zcbot_tmp/<task_id>/<rand>.py`;
|
||||||
|
|
@ -303,6 +307,9 @@ class DockerExecutor(Executor):
|
||||||
inner = f"python {container_script}"
|
inner = f"python {container_script}"
|
||||||
display, kind = f"python {script_path.strip()}", "python"
|
display, kind = f"python {script_path.strip()}", "python"
|
||||||
elif isinstance(code, str) and code.strip():
|
elif isinstance(code, str) and code.strip():
|
||||||
|
hint = precheck_python(code)
|
||||||
|
if hint:
|
||||||
|
return ToolResult(content=hint, exit_code=2)
|
||||||
(d / "script.py").write_text(code, encoding="utf-8", newline="\n")
|
(d / "script.py").write_text(code, encoding="utf-8", newline="\n")
|
||||||
inner = f"python {cdir}/script.py"
|
inner = f"python {cdir}/script.py"
|
||||||
display, kind = f"python <inline {len(code)} chars>", "python"
|
display, kind = f"python <inline {len(code)} chars>", "python"
|
||||||
|
|
@ -396,17 +403,25 @@ class DockerExecutor(Executor):
|
||||||
host.resolve().relative_to(self.user_root)
|
host.resolve().relative_to(self.user_root)
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
return "", f"[Error] script_path out of bounds: {script_path}"
|
return "", f"[Error] script_path out of bounds: {script_path}"
|
||||||
if host.is_file():
|
if not host.is_file():
|
||||||
return container, None
|
|
||||||
if isinstance(code, str) and code.strip():
|
if isinstance(code, str) and code.strip():
|
||||||
host.parent.mkdir(parents=True, exist_ok=True)
|
host.parent.mkdir(parents=True, exist_ok=True)
|
||||||
host.write_text(code, encoding="utf-8")
|
host.write_text(code, encoding="utf-8")
|
||||||
return container, None
|
else:
|
||||||
return "", (
|
return "", (
|
||||||
f"[Error] script_path not found: {script_path}。"
|
f"[Error] script_path not found: {script_path}。"
|
||||||
"先用 write 创建该文件再执行,或直接用 code 传源码;"
|
"先用 write 创建该文件再执行,或直接用 code 传源码;"
|
||||||
"相对路径基于 task 工作目录,不要带工作目录名前缀。"
|
"相对路径基于 task 工作目录,不要带工作目录名前缀。"
|
||||||
)
|
)
|
||||||
|
# 语法预检:host 侧 compile() 拦下语法坏码(中文正文硬拼进源码的引号/标点崩坏),
|
||||||
|
# 返锐化诊断、省一次进容器往返;通过则原样放行。读盘失败不拦(交容器报)。
|
||||||
|
try:
|
||||||
|
hint = precheck_python(host.read_text(encoding="utf-8", errors="replace"))
|
||||||
|
except OSError:
|
||||||
|
hint = None
|
||||||
|
if hint:
|
||||||
|
return "", hint
|
||||||
|
return container, None
|
||||||
|
|
||||||
# ── fs tools(read/write/edit/glob/grep)──────────────────
|
# ── fs tools(read/write/edit/glob/grep)──────────────────
|
||||||
|
|
||||||
|
|
|
||||||
194
core/loop.py
194
core/loop.py
|
|
@ -11,6 +11,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -42,6 +43,21 @@ _PPTX_GUARD_TOOLS = ("shell", "run_python")
|
||||||
_CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]"
|
_CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]"
|
||||||
|
|
||||||
|
|
||||||
|
# 错误签名归一:同一类工具报错在不同参数/路径/数字下抹平,让「反复撞同一堵墙」
|
||||||
|
# 能被识别成同一签名(如 old_str 每次微调但结果始终 "old_str not found in <path>")。
|
||||||
|
# 与 core/toolfail._normalize 同源思路,此处保持 loop 自包含(不反向依赖 toolfail)。
|
||||||
|
_ERR_PATH_RE = re.compile(r"(?:[A-Za-z]:)?(?:[/\\][\w.\-一-鿿*]+){2,}")
|
||||||
|
_ERR_NUM_RE = re.compile(r"\d+")
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_err(result: str) -> str:
|
||||||
|
"""取 [Error] 结果首行、抹平路径/数字,得到稳定的错误签名(截断 120)。"""
|
||||||
|
line = (result.lstrip().splitlines() or [""])[0]
|
||||||
|
line = _ERR_PATH_RE.sub("<path>", line)
|
||||||
|
line = _ERR_NUM_RE.sub("N", line)
|
||||||
|
return line.strip()[:120]
|
||||||
|
|
||||||
|
|
||||||
class _RepeatGuard:
|
class _RepeatGuard:
|
||||||
"""检测「同名同参 + 无产出」的病理性重复调用,断掉死循环。
|
"""检测「同名同参 + 无产出」的病理性重复调用,断掉死循环。
|
||||||
|
|
||||||
|
|
@ -59,6 +75,13 @@ class _RepeatGuard:
|
||||||
顺带堵掉 `_malformed_tool_calls` 的洞:大参数畸形退化成合法空 `{}` 时,executor 每次
|
顺带堵掉 `_malformed_tool_calls` 的洞:大参数畸形退化成合法空 `{}` 时,executor 每次
|
||||||
返回同一句「缺少必填参数」→ 走 dup 分支被这同一机制拦下,无需单独特判空 `{}`。
|
返回同一句「缺少必填参数」→ 走 dup 分支被这同一机制拦下,无需单独特判空 `{}`。
|
||||||
|
|
||||||
|
第二道判据(2026-07,失败面板 #2:edit `old_str not found` 单 task 反复撞墙):模型每次
|
||||||
|
**微调参数**(old_str 改一点)重试同一操作,精确 args 指纹每次不同 → 上面的 arg 判据不累计,
|
||||||
|
只能等 _STALL_LIMIT 慢慢兜。补一条**按工具 + 归一化错误签名的连续 streak**:同一工具连续返回
|
||||||
|
同一类错误(路径/数字抹平后相同)且跨 >= 2 个不同 args → 认定「换法子撞同一堵墙」,SOFT 注入
|
||||||
|
定向提示、HARD 拦截一次(拦后重置到 SOFT,非永久封死,给换路后的重试留活口)。任一非错误结果
|
||||||
|
(真推进了)立即清零该工具的 streak。
|
||||||
|
|
||||||
状态活在单次 task run 内(AgentLoop 实例持有),不跨 task。
|
状态活在单次 task run 内(AgentLoop 实例持有),不跨 task。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -68,6 +91,8 @@ class _RepeatGuard:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# key -> {"hashes": set[str], "unproductive": int, "n": int, "blocked": int}
|
# key -> {"hashes": set[str], "unproductive": int, "n": int, "blocked": int}
|
||||||
self._h: Dict[str, dict] = {}
|
self._h: Dict[str, dict] = {}
|
||||||
|
# name -> {"esig": str, "count": int, "args": set[str]}:按工具的连续同类错误 streak
|
||||||
|
self._err_streak: Dict[str, dict] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _key(name: str, args: Any) -> str:
|
def _key(name: str, args: Any) -> str:
|
||||||
|
|
@ -112,8 +137,41 @@ class _RepeatGuard:
|
||||||
st["unproductive"] = 0
|
st["unproductive"] = 0
|
||||||
st["hashes"].add(h)
|
st["hashes"].add(h)
|
||||||
st["n"] += 1
|
st["n"] += 1
|
||||||
|
# 第二道判据:按工具的连续同类错误 streak(跨不同 args 撞同一堵墙)
|
||||||
|
if is_err:
|
||||||
|
esig = _norm_err(result)
|
||||||
|
s = self._err_streak.get(name)
|
||||||
|
if s and s["esig"] == esig:
|
||||||
|
s["count"] += 1
|
||||||
|
s["args"].add(self._key(name, args))
|
||||||
|
else:
|
||||||
|
self._err_streak[name] = {"esig": esig, "count": 1, "args": {self._key(name, args)}}
|
||||||
|
else:
|
||||||
|
self._err_streak.pop(name, None) # 真推进了 → 清零该工具 streak
|
||||||
return st["unproductive"], not (is_err or dup)
|
return st["unproductive"], not (is_err or dup)
|
||||||
|
|
||||||
|
def err_streak(self, name: str) -> Tuple[int, int, str]:
|
||||||
|
"""返回 (连续同类错误次数, 涉及的不同 args 数, 错误签名);无则 (0,0,'')。"""
|
||||||
|
s = self._err_streak.get(name)
|
||||||
|
if not s:
|
||||||
|
return 0, 0, ""
|
||||||
|
return s["count"], len(s["args"]), s["esig"]
|
||||||
|
|
||||||
|
def should_block_err(self, name: str) -> bool:
|
||||||
|
"""执行前:该工具已连续 >= HARD 次同类错误且跨 >= 2 个不同 args → 拦截一次。"""
|
||||||
|
cnt, n_args, _ = self.err_streak(name)
|
||||||
|
return cnt >= self.HARD and n_args >= 2
|
||||||
|
|
||||||
|
def register_err_block(self, name: str) -> Tuple[int, str]:
|
||||||
|
"""记一次 err-streak 拦截,返回 (拦截时的连续次数, 错误签名);拦后重置到 SOFT
|
||||||
|
(保留 esig/args)—— 非永久封死,让模型换路后的下一次重试还能进来。"""
|
||||||
|
s = self._err_streak.get(name)
|
||||||
|
if not s:
|
||||||
|
return 0, ""
|
||||||
|
cnt, esig = s["count"], s["esig"]
|
||||||
|
s["count"] = self.SOFT
|
||||||
|
return cnt, esig
|
||||||
|
|
||||||
|
|
||||||
def _extract_delta_content(chunk: Any) -> Optional[str]:
|
def _extract_delta_content(chunk: Any) -> Optional[str]:
|
||||||
"""从 stream chunk 提 delta.content(文本片段)。chunk 形态 litellm ModelResponseStream:
|
"""从 stream chunk 提 delta.content(文本片段)。chunk 形态 litellm ModelResponseStream:
|
||||||
|
|
@ -178,6 +236,89 @@ def _malformed_tool_calls(response: Any) -> List[str]:
|
||||||
return bad
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
def _toolcalls_partial_args(
|
||||||
|
response: Any, required_by_tool: Dict[str, List[str]]
|
||||||
|
) -> List[Tuple[Any, str, List[str]]]:
|
||||||
|
"""检出「JSON 能解析、但必填 key 被吞掉」的畸形 tool_call。
|
||||||
|
|
||||||
|
背景(2026-07,失败面板 #3:edit `缺少必填参数 ['path']` 跨 13 task/9 用户):流式
|
||||||
|
arguments delta 乱序把某个键的值碎片瞬移拼进相邻字符串(实证 [1]:path 的
|
||||||
|
`.../gen_final_report_v2.py` 被吞进 old_str 尾部),独立的 `"path"` 键随之消失。这类
|
||||||
|
与 char-0 前缀畸形的关键区别是 **JSON parse 成功** → 既不命中 `_malformed_tool_calls`
|
||||||
|
(只抓 parse 失败)、salvage 也救不了(parse-to-end/key 白名单对合法但错位无能),一路
|
||||||
|
漏到 executor 才在语义层报「缺必填参数」,回 [Error] 喂回模型 → 再拼再乱序,反复烧。
|
||||||
|
|
||||||
|
判据(窄,避免误伤模型真漏参):解析为**非空 dict** 且 **至少一个必填 key 在场**同时
|
||||||
|
**至少一个必填 key 缺失**(即 0 < len(missing) < len(required))。空 `{}` / 必填全缺
|
||||||
|
(纯垃圾/无关键)不算 —— 交给 executor + _RepeatGuard 现状处理。
|
||||||
|
|
||||||
|
返回 [(tc, name, missing_keys), ...]。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
msg = response.choices[0].message
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
out: List[Tuple[Any, str, List[str]]] = []
|
||||||
|
for tc in (getattr(msg, "tool_calls", None) or []):
|
||||||
|
try:
|
||||||
|
name = tc.function.name
|
||||||
|
raw = (getattr(tc.function, "arguments", None) or "").strip()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
continue # parse 失败归 _malformed_tool_calls,不重复处理
|
||||||
|
if not isinstance(obj, dict) or not obj:
|
||||||
|
continue
|
||||||
|
required = required_by_tool.get(name) or []
|
||||||
|
if not required:
|
||||||
|
continue
|
||||||
|
missing = [k for k in required if k not in obj]
|
||||||
|
if 0 < len(missing) < len(required):
|
||||||
|
out.append((tc, name, missing))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _log_partial_args(
|
||||||
|
task_id: Any, user_id: Any, model_profile: str,
|
||||||
|
partial: List[Tuple[Any, str, List[str]]], response: Any,
|
||||||
|
) -> None:
|
||||||
|
"""必填 key 被吞的畸形留痕:与 _log_malformed_args 对称,进 usage_events(kind=
|
||||||
|
tool_malformed),error 签名固定为 `missing required keys [...]` —— 在失败面板里和
|
||||||
|
char-0 型(`Expecting value`)、executor 的「缺必填参数」区分开,便于统计这条新裂缝。
|
||||||
|
任何一路失败都静默,绝不打断重试主路径。"""
|
||||||
|
try:
|
||||||
|
usage = _extract_usage_details(getattr(response, "usage", None))
|
||||||
|
for tc, name, missing in partial:
|
||||||
|
raw = (getattr(tc.function, "arguments", None) or "")
|
||||||
|
err = f"missing required keys {missing}"
|
||||||
|
print(
|
||||||
|
f"[malformed:partial] task={task_id} tool={name} len={len(raw)} "
|
||||||
|
f"{err} head={ascii(raw[:300])} tail={ascii(raw[-300:])}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
record_malformed_tool_call(
|
||||||
|
task_id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
model_profile=model_profile,
|
||||||
|
tool=name,
|
||||||
|
arg_len=len(raw),
|
||||||
|
error=err,
|
||||||
|
head=raw[:300],
|
||||||
|
tail=raw[-300:],
|
||||||
|
tokens_in=usage["tokens_in"],
|
||||||
|
tokens_out=usage["tokens_out"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _is_empty_response(response: Any) -> bool:
|
def _is_empty_response(response: Any) -> bool:
|
||||||
"""检出「空响应」:assistant 轮既无 tool_calls 又无正文(去空白后为空)。
|
"""检出「空响应」:assistant 轮既无 tool_calls 又无正文(去空白后为空)。
|
||||||
|
|
||||||
|
|
@ -575,6 +716,11 @@ class AgentLoop:
|
||||||
# 50% 进压缩区、85% 触发折叠(context_fold)。
|
# 50% 进压缩区、85% 触发折叠(context_fold)。
|
||||||
"context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN),
|
"context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN),
|
||||||
}
|
}
|
||||||
|
# 各工具必填参数(单一事实源:executor schema),供「必填 key 被吞」畸形检测。
|
||||||
|
required_by_tool = {
|
||||||
|
sc["function"]["name"]: (sc["function"].get("parameters") or {}).get("required") or []
|
||||||
|
for sc in self.executor.schemas()
|
||||||
|
}
|
||||||
for attempt in range(self._MAX_MALFORMED_ATTEMPTS):
|
for attempt in range(self._MAX_MALFORMED_ATTEMPTS):
|
||||||
use_nonstream = attempt > 0
|
use_nonstream = attempt > 0
|
||||||
# 每个 attempt 重发 llm_start(stats 同一份):非流式重试完成前零 delta 事件,
|
# 每个 attempt 重发 llm_start(stats 同一份):非流式重试完成前零 delta 事件,
|
||||||
|
|
@ -609,6 +755,25 @@ class AgentLoop:
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
# 必填 key 被吞的畸形(parse 成功、salvage 无能):非流式重试(服务端一次拼好,
|
||||||
|
# 绕开流式 delta 乱序)。耗尽尝试仍缺 → 落下面 return,交 executor 返「缺必填参数」
|
||||||
|
# 给模型(多为模型真漏参,不再空转)。
|
||||||
|
partial = _toolcalls_partial_args(response, required_by_tool)
|
||||||
|
if partial:
|
||||||
|
_log_partial_args(
|
||||||
|
self.session.task_id, self.user_id,
|
||||||
|
f"{self.caps.family}.{self.caps.variant}", partial, response,
|
||||||
|
)
|
||||||
|
names = ", ".join(f"{n}(missing={m})" for _, n, m in partial)
|
||||||
|
self._emit({
|
||||||
|
"type": "warn",
|
||||||
|
"msg": (
|
||||||
|
f"工具调用必填参数被吞 {names},丢弃本轮"
|
||||||
|
f"{'重试' if use_nonstream else ',改非流式重试'}"
|
||||||
|
f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
continue
|
||||||
return response, False
|
return response, False
|
||||||
# 先尝试就地抢救:畸形是 char-0 垃圾前缀 + 尾部完好 JSON(定层已证 provider-wire),
|
# 先尝试就地抢救:畸形是 char-0 垃圾前缀 + 尾部完好 JSON(定层已证 provider-wire),
|
||||||
# 全部畸形 tool_call 都能抠出干净 JSON 才改写并当轮继续,省掉一次非流式重试;
|
# 全部畸形 tool_call 都能抠出干净 JSON 才改写并当轮继续,省掉一次非流式重试;
|
||||||
|
|
@ -824,6 +989,26 @@ class AgentLoop:
|
||||||
})
|
})
|
||||||
return result, False
|
return result, False
|
||||||
|
|
||||||
|
# err-streak 拦截:换着参数撞同一堵墙(如 edit 反复 old_str not found)。拦一次逼换路,
|
||||||
|
# 拦后 streak 重置到 SOFT(非永久封死)。
|
||||||
|
if self._repeat_guard.should_block_err(name):
|
||||||
|
cnt, esig = self._repeat_guard.register_err_block(name)
|
||||||
|
result = (
|
||||||
|
f"[已拦截重复调用] {name} 已连续 {cnt} 次撞同一个错误「{esig}」(每次只微调了参数)。"
|
||||||
|
"再这么试下去不会有新结果。换个做法:① 先 read 目标文件/用 grep 看确切内容"
|
||||||
|
"(old_str 必须逐字匹配,含空白与缩进);② 或换工具/换思路;③ 实在推进不了就停下来"
|
||||||
|
"如实告诉用户卡在哪。"
|
||||||
|
)
|
||||||
|
self._emit({"type": "warn", "msg": f"拦截撞墙调用 {name}(连续同错第 {cnt} 次)"})
|
||||||
|
self._emit({
|
||||||
|
"type": "tool_result",
|
||||||
|
"name": name,
|
||||||
|
"result": result,
|
||||||
|
"preview": result,
|
||||||
|
"truncated": False,
|
||||||
|
})
|
||||||
|
return result, False
|
||||||
|
|
||||||
ctx = ExecCtx(
|
ctx = ExecCtx(
|
||||||
user_id=self.user_id,
|
user_id=self.user_id,
|
||||||
task_id=self.session.task_id,
|
task_id=self.session.task_id,
|
||||||
|
|
@ -879,6 +1064,15 @@ class AgentLoop:
|
||||||
f"\n\n[重复调用警告] 你已用完全相同的参数调用 {name} {unproductive + 1} 次、结果没有变化。"
|
f"\n\n[重复调用警告] 你已用完全相同的参数调用 {name} {unproductive + 1} 次、结果没有变化。"
|
||||||
"再原样重调不会有新结果——换参数/换工具/换思路,或停下来向用户说明卡在哪。"
|
"再原样重调不会有新结果——换参数/换工具/换思路,或停下来向用户说明卡在哪。"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# err-streak SOFT 提示:参数每次不同但连撞同一个错(arg 判据不累计,单独提示)。
|
||||||
|
cnt, n_args, esig = self._repeat_guard.err_streak(name)
|
||||||
|
if cnt == _RepeatGuard.SOFT and n_args >= 2:
|
||||||
|
self._emit({"type": "warn", "msg": f"{name} 连续 {cnt} 次同错「{esig[:40]}」,已提示模型换路"})
|
||||||
|
result += (
|
||||||
|
f"\n\n[撞墙警告] 你换着参数调用 {name} 已连续 {cnt} 次撞同一个错误「{esig}」。"
|
||||||
|
"光微调参数没用——先 read/grep 看清目标的确切内容再动手,或换工具/换思路。"
|
||||||
|
)
|
||||||
|
|
||||||
# 平台层产物机检(0.35.1 复发后落地):本步 shell/run_python 新产出的 .pptx
|
# 平台层产物机检(0.35.1 复发后落地):本步 shell/run_python 新产出的 .pptx
|
||||||
# 若命中「整页贴图」伪导出特征,把 ERROR 注入 tool 结果逼模型当场返工。
|
# 若命中「整页贴图」伪导出特征,把 ERROR 注入 tool 结果逼模型当场返工。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""run_python 语法预检:宿主侧 compile() 拦下语法坏码,返回中文感知的锐化诊断。
|
||||||
|
|
||||||
|
背景(2026-07,工具失败面板最大头:run_python/exit SyntaxError,20 条真实样本):
|
||||||
|
模型手写把中文正文硬拼进 Python 源码生成 docx/pptx(python-docx `T("中文…")`),
|
||||||
|
引号/标点崩坏 → SyntaxError → 改 → 再崩,同一文件一分钟内连撞 6 次烧 token。三类:
|
||||||
|
- A 引号提前闭合(最多):中文串里用了 ASCII 引号 `"` 把外层字符串提前闭合,
|
||||||
|
Python 却报 `Perhaps you forgot a comma?` / `unterminated string`(**主动误导** ——
|
||||||
|
模型照着加逗号越修越错)。
|
||||||
|
- B 全角标点漏进代码位:`center=True、`、`采购(` —— `invalid character 'X' (U+XXXX)`。
|
||||||
|
- C 括号/引号结构崩:mangled 内联命令、未闭合三引号。
|
||||||
|
|
||||||
|
预检零误伤:host 3.12 ≡ sandbox 3.12,compile() 只解析不执行(与依赖无关);compile
|
||||||
|
通过则原样放行、行为不变,不通过则本就跑不了 → 提前返更准的话 + 省一次 docker 往返。
|
||||||
|
另有协同:预检结果以 `[Error]` 前缀返回,run_python 语法失败从 `[stderr]…[exit 1]`
|
||||||
|
变成 `[Error]` 形态,loop._RepeatGuard 的 err-streak 得以兜住「反复交语法坏码」的循环
|
||||||
|
(旧的 `[exit 1]` 形态它按非错误放行、抓不到)。
|
||||||
|
|
||||||
|
不 import litellm/任何重依赖 —— 纯 stdlib,`tests/test_pysyntax.py` 本机可跑
|
||||||
|
(loop.py 顶层 import litellm 在本机导入期卡死,守卫逻辑单列才测得了)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _has_cjk(s: str) -> bool:
|
||||||
|
return any("一" <= c <= "鿿" for c in s)
|
||||||
|
|
||||||
|
|
||||||
|
def precheck_python(code: Optional[str]) -> Optional[str]:
|
||||||
|
"""对 Python 源码做语法预检。通过返回 None;不通过返回锐化的中文诊断([Error] 前缀)。"""
|
||||||
|
if not isinstance(code, str) or not code.strip():
|
||||||
|
return None # 空/非字符串交给现有 bad-arguments 分支,不在此判
|
||||||
|
try:
|
||||||
|
compile(code, "<script>", "exec")
|
||||||
|
return None
|
||||||
|
except SyntaxError as e:
|
||||||
|
return _format(code, e)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# 含 null 字节等 compile 会抛 ValueError —— 不是语法误导型,交给运行期报错
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format(code: str, e: SyntaxError) -> str:
|
||||||
|
lines = code.splitlines()
|
||||||
|
lineno = e.lineno or 0
|
||||||
|
msg = e.msg or "语法错误"
|
||||||
|
src = lines[lineno - 1] if 1 <= lineno <= len(lines) else ""
|
||||||
|
src = src.rstrip()
|
||||||
|
|
||||||
|
out = [
|
||||||
|
"[Error] Python 语法预检未通过(未进沙箱执行,先在宿主解析拦下):",
|
||||||
|
f" SyntaxError: {msg}(第 {lineno} 行)",
|
||||||
|
]
|
||||||
|
if src:
|
||||||
|
shown = src[:200]
|
||||||
|
out.append(" " + shown)
|
||||||
|
off = (e.offset or 0) - 1
|
||||||
|
if 0 <= off < len(shown):
|
||||||
|
out.append(" " + " " * off + "^")
|
||||||
|
|
||||||
|
low = msg.lower()
|
||||||
|
has_cjk = _has_cjk(src)
|
||||||
|
tips = []
|
||||||
|
if "invalid character" in low:
|
||||||
|
# Python 已点名具体字符 + 码位,补一句半角/引号内的判断
|
||||||
|
tips.append(
|
||||||
|
"报错处是全角/中文标点。若它在代码位置(用了全角 ,。、;:()等)请改半角;"
|
||||||
|
"若它本属正文,请确认整段正文在字符串引号内。"
|
||||||
|
)
|
||||||
|
elif has_cjk and any(
|
||||||
|
k in low for k in (
|
||||||
|
"forgot a comma", "unterminated string", "unmatched",
|
||||||
|
"invalid syntax", "was never closed", "closing parenthesis",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
tips.append(
|
||||||
|
"疑似中文正文里的 ASCII 引号 \" 或 ' 提前闭合了字符串。改法任选:"
|
||||||
|
"① 整段中文用三引号 '''…''' 包裹;② 正文里的引号写成中文引号 “”/‘’;③ 用 \\\" 转义。"
|
||||||
|
)
|
||||||
|
if has_cjk:
|
||||||
|
tips.append(
|
||||||
|
"根治:大段中文正文别硬拼进 .py —— 用 write 写进 .md/.txt,脚本里 read 后再灌进 "
|
||||||
|
"docx/pptx,正文就不经过 Python 语法/引号。"
|
||||||
|
)
|
||||||
|
if tips:
|
||||||
|
out.append(" 修法:" + " ".join(tips))
|
||||||
|
return "\n".join(out)
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ from __future__ import annotations
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from core.loop import _RepeatGuard # noqa: E402
|
from core.loop import _RepeatGuard, _toolcalls_partial_args # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _simulate(guard: _RepeatGuard, name: str, args, results: list[str]) -> list[str]:
|
def _simulate(guard: _RepeatGuard, name: str, args, results: list[str]) -> list[str]:
|
||||||
|
|
@ -98,5 +99,126 @@ class TestRepeatGuard(unittest.TestCase):
|
||||||
self.assertFalse(g.should_block("document_search", {"queries": ["b"]}))
|
self.assertFalse(g.should_block("document_search", {"queries": ["b"]}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrStreak(unittest.TestCase):
|
||||||
|
"""第二道判据:换着参数撞同一堵墙(如 edit 反复 old_str not found)。"""
|
||||||
|
|
||||||
|
def _run_varargs(self, g, name, errs):
|
||||||
|
"""每次用不同 args 调用,结果为 errs[i]。返回是否曾被 err-block。"""
|
||||||
|
blocked = False
|
||||||
|
for i, r in enumerate(errs):
|
||||||
|
if g.should_block_err(name):
|
||||||
|
g.register_err_block(name)
|
||||||
|
blocked = True
|
||||||
|
continue
|
||||||
|
g.record(name, {"old_str": f"variant-{i}", "path": "/x/y.py"}, r)
|
||||||
|
return blocked
|
||||||
|
|
||||||
|
def test_varied_args_same_error_blocks(self):
|
||||||
|
"""参数每次不同、错误签名相同(路径抹平)→ err-streak 累计到 HARD 后拦一次。"""
|
||||||
|
g = _RepeatGuard()
|
||||||
|
errs = [f"[Error] old_str not found in /proj/file{i}.py" for i in range(8)]
|
||||||
|
blocked = self._run_varargs(g, "edit", errs)
|
||||||
|
self.assertTrue(blocked)
|
||||||
|
|
||||||
|
def test_register_err_block_resets_to_soft(self):
|
||||||
|
"""拦截后 streak 重置到 SOFT(非永久封死),留活口。"""
|
||||||
|
g = _RepeatGuard()
|
||||||
|
for i in range(_RepeatGuard.HARD + 1):
|
||||||
|
g.record("edit", {"old_str": f"v{i}", "path": "/a/b.py"}, "[Error] old_str not found in /a/b.py")
|
||||||
|
self.assertTrue(g.should_block_err("edit"))
|
||||||
|
cnt, esig = g.register_err_block("edit")
|
||||||
|
self.assertGreaterEqual(cnt, _RepeatGuard.HARD)
|
||||||
|
# 重置后不再处于拦截态
|
||||||
|
self.assertFalse(g.should_block_err("edit"))
|
||||||
|
|
||||||
|
def test_different_errors_not_blocked(self):
|
||||||
|
"""每次撞的是不同错误 → 不算撞同一堵墙,不拦。"""
|
||||||
|
g = _RepeatGuard()
|
||||||
|
errs = [
|
||||||
|
"[Error] old_str not found in /a.py",
|
||||||
|
"[Error] old_str appears 3 times in /a.py",
|
||||||
|
"[Error] permission denied",
|
||||||
|
"[Error] file not found",
|
||||||
|
] * 2
|
||||||
|
blocked = self._run_varargs(g, "edit", errs)
|
||||||
|
self.assertFalse(blocked)
|
||||||
|
|
||||||
|
def test_productive_result_resets_streak(self):
|
||||||
|
"""撞墙几次后成功一次(非错误)→ streak 清零,不进拦截态。"""
|
||||||
|
g = _RepeatGuard()
|
||||||
|
for i in range(3):
|
||||||
|
g.record("edit", {"old_str": f"v{i}", "path": "/a.py"}, "[Error] old_str not found in /a.py")
|
||||||
|
g.record("edit", {"old_str": "vok", "path": "/a.py"}, "edited /a.py") # 成功
|
||||||
|
cnt, n_args, _ = g.err_streak("edit")
|
||||||
|
self.assertEqual(cnt, 0)
|
||||||
|
self.assertFalse(g.should_block_err("edit"))
|
||||||
|
|
||||||
|
def test_single_arg_not_err_blocked(self):
|
||||||
|
"""同一 args 反复撞同错 → 由 arg 判据管(err-block 要求 >=2 个不同 args)。"""
|
||||||
|
g = _RepeatGuard()
|
||||||
|
for _ in range(_RepeatGuard.HARD + 2):
|
||||||
|
g.record("edit", {"old_str": "same", "path": "/a.py"}, "[Error] old_str not found in /a.py")
|
||||||
|
cnt, n_args, _ = g.err_streak("edit")
|
||||||
|
self.assertGreaterEqual(cnt, _RepeatGuard.HARD)
|
||||||
|
self.assertEqual(n_args, 1)
|
||||||
|
self.assertFalse(g.should_block_err("edit")) # 单 arg 不走 err-block
|
||||||
|
|
||||||
|
|
||||||
|
def _resp_with_toolcalls(*calls):
|
||||||
|
"""calls: (name, arguments_str) → 构造带 tool_calls 的假 response。"""
|
||||||
|
tcs = [
|
||||||
|
SimpleNamespace(function=SimpleNamespace(name=n, arguments=a))
|
||||||
|
for n, a in calls
|
||||||
|
]
|
||||||
|
msg = SimpleNamespace(tool_calls=tcs)
|
||||||
|
return SimpleNamespace(choices=[SimpleNamespace(message=msg)])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPartialArgs(unittest.TestCase):
|
||||||
|
"""③ 必填 key 被吞的畸形检测(parse 成功、salvage 无能)。"""
|
||||||
|
|
||||||
|
REQ = {"edit": ["path", "old_str", "new_str"], "write": ["path", "content"]}
|
||||||
|
|
||||||
|
def test_missing_path_detected(self):
|
||||||
|
"""#3 实证形态:new_str/old_str 在、path 被吞 → 检出 missing=['path']。"""
|
||||||
|
r = _resp_with_toolcalls(("edit", '{"new_str": "b", "old_str": "a...gen.py"}'))
|
||||||
|
out = _toolcalls_partial_args(r, self.REQ)
|
||||||
|
self.assertEqual(len(out), 1)
|
||||||
|
_, name, missing = out[0]
|
||||||
|
self.assertEqual(name, "edit")
|
||||||
|
self.assertEqual(missing, ["path"])
|
||||||
|
|
||||||
|
def test_path_only_detected(self):
|
||||||
|
"""#4 反向形态:只有 path、其余被挤掉 → 检出(0<2<3)。"""
|
||||||
|
r = _resp_with_toolcalls(("edit", '{"path": "/x/y.docx\\nTASK=1"}'))
|
||||||
|
out = _toolcalls_partial_args(r, self.REQ)
|
||||||
|
self.assertEqual(len(out), 1)
|
||||||
|
self.assertEqual(sorted(out[0][2]), ["new_str", "old_str"])
|
||||||
|
|
||||||
|
def test_full_args_not_flagged(self):
|
||||||
|
r = _resp_with_toolcalls(("edit", '{"path": "a", "old_str": "b", "new_str": "c"}'))
|
||||||
|
self.assertEqual(_toolcalls_partial_args(r, self.REQ), [])
|
||||||
|
|
||||||
|
def test_empty_obj_not_flagged(self):
|
||||||
|
"""空 {} 不算(交给 executor + _RepeatGuard 现状)。"""
|
||||||
|
r = _resp_with_toolcalls(("edit", "{}"))
|
||||||
|
self.assertEqual(_toolcalls_partial_args(r, self.REQ), [])
|
||||||
|
|
||||||
|
def test_all_required_missing_not_flagged(self):
|
||||||
|
"""必填全缺(纯无关键)→ 不算 partial(len(missing)==len(required))。"""
|
||||||
|
r = _resp_with_toolcalls(("edit", '{"foo": 1}'))
|
||||||
|
self.assertEqual(_toolcalls_partial_args(r, self.REQ), [])
|
||||||
|
|
||||||
|
def test_parse_fail_skipped(self):
|
||||||
|
"""parse 失败归 _malformed_tool_calls,这里不重复处理。"""
|
||||||
|
r = _resp_with_toolcalls(("edit", 'garbage{"path":'))
|
||||||
|
self.assertEqual(_toolcalls_partial_args(r, self.REQ), [])
|
||||||
|
|
||||||
|
def test_write_missing_content(self):
|
||||||
|
r = _resp_with_toolcalls(("write", '{"path": "a.txt"}'))
|
||||||
|
out = _toolcalls_partial_args(r, self.REQ)
|
||||||
|
self.assertEqual(out[0][2], ["content"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from core.pysyntax import precheck_python # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrecheckPython(unittest.TestCase):
|
||||||
|
# ── 放行:合法码不误伤 ──
|
||||||
|
def test_clean_code_passes(self):
|
||||||
|
self.assertIsNone(precheck_python("import os\nx = 1 + 2\nprint(x)\n"))
|
||||||
|
|
||||||
|
def test_valid_chinese_in_string_passes(self):
|
||||||
|
"""中文正文在正确引号里 = 合法,绝不误报。"""
|
||||||
|
self.assertIsNone(precheck_python('x = "中文正文,含全角标点。没问题!"\n'))
|
||||||
|
|
||||||
|
def test_chinese_quotes_in_string_passes(self):
|
||||||
|
"""中文引号 “” 只是普通字符,不是分隔符,合法。"""
|
||||||
|
self.assertIsNone(precheck_python('x = "他说“你好”,然后走了。"\n'))
|
||||||
|
|
||||||
|
def test_empty_and_none_pass(self):
|
||||||
|
self.assertIsNone(precheck_python(None))
|
||||||
|
self.assertIsNone(precheck_python(""))
|
||||||
|
self.assertIsNone(precheck_python(" \n "))
|
||||||
|
self.assertIsNone(precheck_python(123)) # 非字符串
|
||||||
|
|
||||||
|
# ── A 类:中文串里 ASCII 引号提前闭合(样本 #1/#8/#16) ──
|
||||||
|
def test_ascii_quote_in_chinese_string(self):
|
||||||
|
code = 'T("(5)产学研合作:形成"产学研用"一体化链条。")\n'
|
||||||
|
hint = precheck_python(code)
|
||||||
|
self.assertIsNotNone(hint)
|
||||||
|
self.assertTrue(hint.startswith("[Error]"))
|
||||||
|
self.assertIn("第 1 行", hint)
|
||||||
|
self.assertIn("引号", hint) # 定向引号诊断
|
||||||
|
self.assertIn("write", hint) # 根治提示(正文进文件)
|
||||||
|
|
||||||
|
def test_unterminated_string_with_cjk(self):
|
||||||
|
code = "set_cell(c, '国际一流双碳大数据平台], '负责人')\n"
|
||||||
|
hint = precheck_python(code)
|
||||||
|
self.assertIsNotNone(hint)
|
||||||
|
self.assertIn("引号", hint)
|
||||||
|
|
||||||
|
# ── B 类:全角标点漏进代码位(样本 #7/#9) ──
|
||||||
|
def test_fullwidth_punct_in_code(self):
|
||||||
|
code = 'add_h("2025年12月", 14, center=True、name)\n'
|
||||||
|
hint = precheck_python(code)
|
||||||
|
self.assertIsNotNone(hint)
|
||||||
|
self.assertIn("半角", hint) # 全角→半角定向诊断
|
||||||
|
|
||||||
|
def test_fullwidth_paren_in_code(self):
|
||||||
|
code = 'set_cell(tbl, "响应函", "第五章(附件)中的要求。", "通过")\n(\n'
|
||||||
|
hint = precheck_python(code)
|
||||||
|
self.assertIsNotNone(hint)
|
||||||
|
|
||||||
|
# ── C 类:结构崩坏 ──
|
||||||
|
def test_unmatched_bracket(self):
|
||||||
|
hint = precheck_python("for i in enumerate(doc.paragraphs[:5()[:80]}'\")\n")
|
||||||
|
self.assertIsNotNone(hint)
|
||||||
|
self.assertTrue(hint.startswith("[Error]"))
|
||||||
|
|
||||||
|
# ── 非中文语法错:仍拦,但不追加中文根治提示 ──
|
||||||
|
def test_non_cjk_syntax_error(self):
|
||||||
|
hint = precheck_python("def f(:\n pass\n")
|
||||||
|
self.assertIsNotNone(hint)
|
||||||
|
self.assertTrue(hint.startswith("[Error]"))
|
||||||
|
self.assertNotIn("write 写进", hint) # 无中文正文,不追加"别拼中文进 py"
|
||||||
|
|
||||||
|
def test_lineno_reported(self):
|
||||||
|
code = "a = 1\nb = 2\nc = @\n"
|
||||||
|
hint = precheck_python(code)
|
||||||
|
self.assertIsNotNone(hint)
|
||||||
|
self.assertIn("第 3 行", hint)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from core import procs
|
from core import procs
|
||||||
|
from core.pysyntax import precheck_python
|
||||||
|
|
||||||
from .base import Tool, compact_tool_output
|
from .base import Tool, compact_tool_output
|
||||||
|
|
||||||
|
|
@ -87,8 +88,7 @@ class RunPythonTool(Tool):
|
||||||
alt = self.user_root / script_path.replace("\\", "/").strip().lstrip("./")
|
alt = self.user_root / script_path.replace("\\", "/").strip().lstrip("./")
|
||||||
if alt.is_file():
|
if alt.is_file():
|
||||||
script = alt
|
script = alt
|
||||||
if script.is_file():
|
if not script.is_file():
|
||||||
return script, None
|
|
||||||
if isinstance(code, str) and code.strip():
|
if isinstance(code, str) and code.strip():
|
||||||
if self.user_root is not None:
|
if self.user_root is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -97,12 +97,20 @@ class RunPythonTool(Tool):
|
||||||
return None, f"[Error] script_path out of bounds: {script_path}"
|
return None, f"[Error] script_path out of bounds: {script_path}"
|
||||||
script.parent.mkdir(parents=True, exist_ok=True)
|
script.parent.mkdir(parents=True, exist_ok=True)
|
||||||
script.write_text(code, encoding="utf-8")
|
script.write_text(code, encoding="utf-8")
|
||||||
return script, None
|
else:
|
||||||
return None, (
|
return None, (
|
||||||
f"[Error] script_path not found: {self._display(script)}。"
|
f"[Error] script_path not found: {self._display(script)}。"
|
||||||
"先用 write 创建该文件再执行,或直接用 code 传源码;"
|
"先用 write 创建该文件再执行,或直接用 code 传源码;"
|
||||||
"相对路径基于 task 工作目录,不要带工作目录名前缀。"
|
"相对路径基于 task 工作目录,不要带工作目录名前缀。"
|
||||||
)
|
)
|
||||||
|
# 语法预检(与 DockerExecutor 同):compile() 拦下语法坏码,返锐化诊断。
|
||||||
|
try:
|
||||||
|
hint = precheck_python(script.read_text(encoding="utf-8", errors="replace"))
|
||||||
|
except OSError:
|
||||||
|
hint = None
|
||||||
|
if hint:
|
||||||
|
return None, hint
|
||||||
|
return script, None
|
||||||
|
|
||||||
def _filtered_env(self) -> dict:
|
def _filtered_env(self) -> dict:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
|
@ -137,6 +145,9 @@ class RunPythonTool(Tool):
|
||||||
)
|
)
|
||||||
shown = self._display(script)
|
shown = self._display(script)
|
||||||
elif isinstance(code, str) and code.strip():
|
elif isinstance(code, str) and code.strip():
|
||||||
|
hint = precheck_python(code)
|
||||||
|
if hint:
|
||||||
|
return hint
|
||||||
proc_id, _ = procs.launch_host(
|
proc_id, _ = procs.launch_host(
|
||||||
anchor, self.task_id,
|
anchor, self.task_id,
|
||||||
kind="python",
|
kind="python",
|
||||||
|
|
@ -168,6 +179,9 @@ class RunPythonTool(Tool):
|
||||||
if err is not None:
|
if err is not None:
|
||||||
return err
|
return err
|
||||||
elif isinstance(code, str):
|
elif isinstance(code, str):
|
||||||
|
hint = precheck_python(code)
|
||||||
|
if hint:
|
||||||
|
return hint
|
||||||
# 写到临时文件,避免 -c 转义问题
|
# 写到临时文件,避免 -c 转义问题
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
suffix=".py", mode="w", delete=False, encoding="utf-8"
|
suffix=".py", mode="w", delete=False, encoding="utf-8"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue