From c52f399f07fc52fd1fe1542029a1c50d813de937 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 6 Jul 2026 13:40:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(guard):=20ppt=20=E4=BC=AA=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E4=B8=89=E5=B1=82=E2=80=94=E2=80=94=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=9C=BA=E6=A3=80=E9=97=A8=20+=20=E7=A6=81=E4=BB=A4?= =?UTF-8?q?=E5=8E=BB=E8=8F=9C=E8=B0=B1=20+=20=E8=84=9A=E6=9C=AC=20hint=20?= =?UTF-8?q?=E7=BA=AA=E5=BE=8B(bump=200.40.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.35.1 复发复盘:模型照 SKILL 禁令里的"配方"装 cairosvg 手搓 SVG→PNG 整页贴图导出,文档层禁令两次被证不够。三层治理: - 平台层:core/pptx_guard.py + loop 钩子——shell/run_python 本步新产出 .pptx 命中"≥80% 页整版位图 + 全 deck 零原生文本"即在 tool 结果注入 [产物机检 ERROR] 当场逼返工;判定刻意保守(≥2 页、表格/图表算原生、 解析失败放行),tests/test_pptx_guard.py 8 例全过 - 禁令去菜谱:ppt SKILL.md:185/213、brief SKILL.md:26/103 否定式指令 改"正面窄门"(唯一入口 + 报错就修/如实上报),删掉可照做的违规配方 - 脚本 hint 纪律:pptx_media / svg_preview / crop_images / fix_image_aspect / pptx_cli 共 5 处 pip install 提示改环境说明—— 打印给 agent 的安装指令就是执行指令 DESIGN 新增 §8.9(推翻 0.35.1"平台层暂缓"拍板);PROGRESS 同步。 Co-Authored-By: Claude Fable 5 --- DESIGN.md | 4 + PROGRESS.md | 1 + core/__init__.py | 2 +- core/loop.py | 21 +++ core/pptx_guard.py | 138 ++++++++++++++++++ skills/brief/SKILL.md | 4 +- skills/ppt/SKILL.md | 4 +- .../ppt/scripts/svg_finalize/crop_images.py | 5 +- .../scripts/svg_finalize/fix_image_aspect.py | 4 +- skills/ppt/scripts/svg_preview.py | 5 +- skills/ppt/scripts/svg_to_pptx/pptx_cli.py | 3 +- skills/ppt/scripts/svg_to_pptx/pptx_media.py | 11 +- tests/test_pptx_guard.py | 113 ++++++++++++++ 13 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 core/pptx_guard.py create mode 100644 tests/test_pptx_guard.py diff --git a/DESIGN.md b/DESIGN.md index 6a65ddd..5030585 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -300,6 +300,10 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) - **Phase 2(design):阈值结构化摘要**(补 Hermes 阶段③):中段轮压成固定模板(目标/约束/进展/待办),增量更新、保留 path/ID/数值原文(mem0 实测摘要会静默丢精确值);双层门槛 50%+85%。分段砍跨话题累积、摘要兜单段超长,正交。 - **Phase 3(design):持久检索**(sqlite-vec/FTS5)解"问很久以前的精确内容";工程最重,待确认真实需求(数据没删随时能补)。 +### 8.9 产物机检门 + 提示层禁令纪律(✅ 2026-07-06) + +**根因**(同类事故两次):①文档层禁令拦不住绕开官方管线的产物——管线内的门只在模型用了官方脚本时生效;②否定式禁令若自带完整违规配方(精确命令/文件名/分步做法),压力下反成模型的执行菜谱(粉红大象:否定词丢了,菜谱留下);③官方脚本报错文案里的 `pip install` 对 agent 就是一条会被照办的指令。**三层治理**:平台层 `core/pptx_guard.py`(loop 在 shell/run_python 后对本步新 .pptx 机检"整页贴图"特征,tool 结果注入 ERROR 当场逼返工;判定刻意保守——≥2 页、≥80% 页整版位图、全 deck 零文本/表格/图表才判废,解析失败一律放行)——**检产物不检命令**,换什么工具造都绕不开;skill 撰写纪律——**禁令只点名不给配方**,正面写"唯一入口 + 报错就修/如实上报";脚本文案纪律——**打印给 agent 的报错不得含安装/替代管线指令**,缺依赖=环境问题,说明并上报。本条推翻 0.35.1"平台层自动检测暂缓"的拍板(复发即证据)。 + --- ## 附录:DeepSeek V4 关键事实(2026-04-24) diff --git a/PROGRESS.md b/PROGRESS.md index 7b1d030..07c11c7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-07 +- **07-06 / 0.40.0 ppt 伪导出根治(三层)**:0.35.1 复发(模型照禁令里的"配方"装 cairosvg 手搓整页贴图导出)复盘——①平台层机检门落地(`core/pptx_guard.py` + loop 钩子:shell/run_python 新产出 .pptx 命中"≥80% 页整版位图 + 全 deck 零原生文本"即注入 `[产物机检 ERROR]` 当场逼返工;判定刻意保守,`tests/test_pptx_guard.py` 8 例);②禁令去菜谱:ppt/brief SKILL.md 否定式指令改"正面窄门"(唯一入口 + 报错就修/上报),删掉可照做的违规配方;③官方脚本 5 处 `pip install` hint 改环境说明(pptx_media/svg_preview/crop_images/fix_image_aspect/pptx_cli)——打印给 agent 的安装指令就是执行指令。详 DESIGN §8.9。 - **07-06 / 0.39.2 research 对接 paper_server API Key**:paper_server GET 接口收紧要 Key 后,`skills/research/paper.py` 全部请求带 `?api_key=`(env `PAPER_SERVER_API_KEY`),认证失败(paper_server 异常处理器压成 HTTP 400 + err_code)转成带指引的 RuntimeError;这把低价值只读 key 是唯一刻意放进 sandbox 的凭证——`run_python` env 过滤器加 allowlist 放行、docker 模式 `_sandbox_env()` 透传进容器(顺带修掉 `PAPER_SERVER_URL` 覆盖不进 docker 的缺口)。 - **07-06 / 0.39.1 文档精简**:PROGRESS(865→188 行)与 DESIGN(797→218 行)按各自边界契约压缩——PROGRESS 每条收成一行(做了啥+关键判断,细节查 git log),DESIGN 保留全部决策/取舍/硬协议清单/升级信号、砍实现复述(协议细节指向代码与 probe 脚本),顺手把 §2 架构树补齐实际模块。 - **07-06 / 0.39.0 skill 定向模型**:内置 skill 可在 frontmatter 声明 `model:`(ppt→GLM-5.2),对话中 `load_skill` 命中即 run 内热切(持久化 + emit `model_switch` + 前端分隔线,失败降级原模型);只信内置 skill、跳档位门控、不自动切回,"开关"=frontmatter 那一行(admin 总开关 + 预切方案经架构评审砍掉——第二事实源/第二执行点)。详 DESIGN §3.5;新增 `tests/test_skill_model_pinning.py`。 diff --git a/core/__init__.py b/core/__init__.py index a8f1092..cb2d6fa 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.39.2" +__version__ = "0.40.0" diff --git a/core/loop.py b/core/loop.py index d73f40a..6c6f5eb 100644 --- a/core/loop.py +++ b/core/loop.py @@ -25,6 +25,10 @@ from .executor import ExecCtx, Executor from .llm import LLM from .session import Session from .storage import record_chat_usage +from . import pptx_guard + +# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义) +_PPTX_GUARD_TOOLS = ("shell", "run_python") _CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]" @@ -520,6 +524,7 @@ class AgentLoop: working_dir=self.working_dir, cancel_check=self.cancel_check, ) + tool_started_at = time.time() result = self.executor.call_tool(name, args, ctx).content # 控制返回给模型的 tool 结果体量,避免炸 context @@ -569,6 +574,22 @@ class AgentLoop: "再原样重调不会有新结果——换参数/换工具/换思路,或停下来向用户说明卡在哪。" ) + # 平台层产物机检(0.35.1 复发后落地):本步 shell/run_python 新产出的 .pptx + # 若命中「整页贴图」伪导出特征,把 ERROR 注入 tool 结果逼模型当场返工。 + # 官方管线内的门只在模型用了官方脚本时生效,绕开管线的产物只能在这拦。 + # 注入在 repeat_guard.record 之后 —— 不进指纹,免得干扰重复检测。 + if name in _PPTX_GUARD_TOOLS: + try: + guard_msg = pptx_guard.scan_and_report(self.working_dir, tool_started_at) + except Exception: + guard_msg = None # 机检自身故障绝不拖垮工具链路 + if guard_msg: + result += "\n\n" + guard_msg + self._emit({ + "type": "warn", + "msg": "产物机检:检测到「整页贴图」式 .pptx,已注入 ERROR 要求返工", + }) + preview = result if len(result) < 400 else result[:400] + "..." self._emit({ "type": "tool_result", diff --git a/core/pptx_guard.py b/core/pptx_guard.py new file mode 100644 index 0000000..b740ddd --- /dev/null +++ b/core/pptx_guard.py @@ -0,0 +1,138 @@ +"""pptx_guard.py: 平台层 .pptx 产物机检——拦「整页贴图」式伪导出。 + +背景(PROGRESS 0.35.1 及本次复发):模型两次绕开 ppt skill 官方导出器 +svg_to_pptx.py,用外部渲染器把每页渲成整页 PNG 贴进幻灯片交付——产物零原生 +文本、不可编辑,skill 价值归零。官方管线内的门只在模型用了官方脚本时生效, +绕开管线的产物只能在平台层拦:shell / run_python 每步执行后,对本步新产出的 +.pptx 就地机检,命中特征即在 tool 结果里注入 [产物机检 ERROR],逼模型当场 +返工回官方管线。 + +判定刻意保守,只拦"确证的伪产物":整本 >= _RASTER_PAGE_RATIO 的页是 +「一张覆盖 >= _PIC_AREA_RATIO 版面的图片 + 本页零文本」,且全 deck 原生文本 +字符数为 0,且页数 >= 2。正常 deck(原生导出、带背景大图但有文字、单页海报、 +含表格图表)都不会命中。解析失败一律放行——这门不做通用校验。 +""" +from __future__ import annotations + +from pathlib import Path +from typing import Iterator, List, Optional + +try: + from pptx import Presentation + from pptx.enum.shapes import MSO_SHAPE_TYPE +except Exception: # pragma: no cover - 宿主缺 python-pptx 时静默降级为不检 + Presentation = None + MSO_SHAPE_TYPE = None + +# 单页判「贴图页」:存在一张覆盖 >= 85% 版面的图片,且本页无任何非空文本 +_PIC_AREA_RATIO = 0.85 +# 整本判废:贴图页占比 >= 80%(容忍手搓时混入个别空白/封面页) +_RASTER_PAGE_RATIO = 0.8 +# mtime 与 host 时钟的容差(秒) +_MTIME_EPSILON = 2.0 + + +def _iter_shapes(shapes) -> Iterator: + """展平 group,产出所有叶子 shape。""" + for sh in shapes: + try: + if sh.shape_type == MSO_SHAPE_TYPE.GROUP: + yield from _iter_shapes(sh.shapes) + continue + except Exception: + pass + yield sh + + +def find_new_pptx(root: Path, since: float) -> List[Path]: + """root 下 mtime >= since - epsilon 的 .pptx;跳过隐藏目录(.build 等)。""" + found: List[Path] = [] + try: + for p in root.rglob("*.pptx"): + try: + rel_parts = p.relative_to(root).parts[:-1] + except ValueError: + rel_parts = () + if any(part.startswith(".") for part in rel_parts): + continue + try: + if p.stat().st_mtime >= since - _MTIME_EPSILON: + found.append(p) + except OSError: + continue + except OSError: + pass + return found + + +def check_raster_deck(path: Path) -> Optional[str]: + """判「整页贴图」deck。返回 None = 通过;返回字符串 = 判废原因。 + + 打不开 / 解析失败一律放行:这门只拦确证的伪产物,不承担通用文件校验。 + """ + if Presentation is None: + return None + try: + prs = Presentation(str(path)) + slides = list(prs.slides) + if len(slides) < 2: + return None + page_area = float(prs.slide_width or 0) * float(prs.slide_height or 0) + if page_area <= 0: + return None + + raster_pages = 0 + total_text_chars = 0 + has_native_content = False # 表格/图表也算原生内容 + for slide in slides: + big_pic = False + page_text = 0 + for sh in _iter_shapes(slide.shapes): + try: + if sh.has_text_frame: + page_text += len(sh.text_frame.text.strip()) + st = sh.shape_type + if st in (MSO_SHAPE_TYPE.PICTURE, MSO_SHAPE_TYPE.LINKED_PICTURE): + w = float(sh.width or 0) + h = float(sh.height or 0) + if w * h / page_area >= _PIC_AREA_RATIO: + big_pic = True + elif st in (MSO_SHAPE_TYPE.TABLE, MSO_SHAPE_TYPE.CHART): + has_native_content = True + except Exception: + continue + total_text_chars += page_text + if big_pic and page_text == 0: + raster_pages += 1 + + if ( + total_text_chars == 0 + and not has_native_content + and raster_pages / len(slides) >= _RASTER_PAGE_RATIO + ): + return ( + f"共 {len(slides)} 页,其中 {raster_pages} 页仅为一张覆盖整版的位图," + f"且全文档没有任何原生文本/表格/图表 —— 判定为「SVG 渲 PNG 整页贴图」" + f"式伪导出(用户拿到的是不可编辑的图片册,不是 PPT)" + ) + return None + except Exception: + return None + + +def scan_and_report(root: Path, since: float) -> Optional[str]: + """扫 root 下本步新产出的 .pptx,命中伪导出则返回注入 tool 结果的 ERROR 文本。""" + bad: List[str] = [] + for p in find_new_pptx(root, since): + reason = check_raster_deck(p) + if reason: + bad.append(f"- `{p.name}`:{reason}") + if not bad: + return None + return ( + "[产物机检 ERROR] 检测到不合格的 .pptx 产物:\n" + + "\n".join(bad) + + "\n该文件不可交付:请删除它,回 ppt skill 官方管线用 scripts/svg_to_pptx.py " + "重新导出(它是纯 Python 原生导出,不需要任何渲染依赖;报错就按报错修," + "修不动就把报错原文如实告知用户)。不要另写导出脚本或改用渲染贴图。" + ) diff --git a/skills/brief/SKILL.md b/skills/brief/SKILL.md index 3214484..f4219d5 100644 --- a/skills/brief/SKILL.md +++ b/skills/brief/SKILL.md @@ -23,7 +23,7 @@ description: 生成科研方向简报(research direction briefing / 重要文献 ## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`) - `references/journals.md` —— 各建材子领域主流期刊清单(Elsevier 数据库优先)+ 精确 `publication_name` + 0 命中降级法。**阶段二必读**。 -- **平台渲染层 `/sandbox/rendering/render.py`**(各 skill 通用,不再自带 render 脚本)—— `--profile brief --format docx|pdf`。docx:商务红主题 + 列表 `[n]` 锚点 + 正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 超链 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+);pdf:沙盒自带 chromium 渲染(`md→HTML→chromium`),同套主题 + DOI/URL 超链 + 化学式下标。**渲染一律调它,禁止自己手搓 HTML / pip 装 weasyprint。** +- **平台渲染层 `/sandbox/rendering/render.py`**(各 skill 通用,不再自带 render 脚本)—— `--profile brief --format docx|pdf`。docx:商务红主题 + 列表 `[n]` 锚点 + 正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 超链 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+);pdf:沙盒自带 chromium 渲染(`md→HTML→chromium`),同套主题 + DOI/URL 超链 + 化学式下标。**渲染只有这一条路;它报错就按报错修或如实告知用户,不自行寻找替代渲染方式。** 产物默认 `.md`;要 docx/pdf 调 `render.py --profile brief`;要 deck 转 `ppt` skill。 @@ -100,7 +100,7 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites", ## 阶段五:渲染验收 - 用户要 docx → `python /sandbox/rendering/render.py --profile brief --format docx -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。 -- 用户要 pdf → `python /sandbox/rendering/render.py --profile brief --format pdf -o <方向>-简报.pdf`(沙盒内 chromium 渲染,同样 `--no-color` 出黑白)。**别现搓 weasyprint / 现 pip 装包** —— 直接调 render.py。 +- 用户要 pdf → `python /sandbox/rendering/render.py --profile brief --format pdf -o <方向>-简报.pdf`(沙盒内 chromium 渲染,同样 `--no-color` 出黑白)。**pdf 也只走 render.py 这一个入口**,报错就修或如实上报,不另起渲染路径。 - 渲染前自查:`[CITE-]`/`` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。 - 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。 diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index 4c9f86b..488bbb2 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -182,7 +182,7 @@ references/visual-styles/.md # 锁定的视觉风格 - 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须渲过图(svg_preview)、且渲图后源未再改动、且 verdict=pass**。分两层:**"从没渲过 / 渲后又改 / finalize 前渲的"没有任何 CLI 逃生口**(渲图很便宜,没有理由交付一页没人看过的东西);`--allow-unreviewed` 只豁免"渲过但还没标 pass"这一层,**不是跳过验收的捷径**。被拒就回阶段五补验收/走返工回路。 - 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但 `svg_output/` 全 deck 零 `` → 同样 `[ERROR]` 退非零(检测永远对 svg_output 源,与 `-s` 无关)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。 - ❌ **别加 `-s final`**:native 导出默认读 `svg_output/`(转换器自己处理图标占位与 `../images/` 相对路径),`-s final` 只会引出图片路径错位这类连锁问题;真实事故里模型为绕它把 svg_output 源里的 href 改坏了。 -- 🛑 **导出唯一入口 = 官方 `svg_to_pptx.py`,严禁自写导出器**:它**默认产出原生可编辑 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改),是**纯 Python、不依赖任何外部渲染器**(cairosvg / inkscape / rsvg-convert 一个都不需要)。所以**"某某渲染器没装"永远不是理由**——别 `pip install cairosvg` 也别手搓"SVG→PNG→整页贴图"的 `export_pptx.py`。自搓光栅导出器 = 整份变成一叠不可编辑的贴图(每页一张整页 PNG、零原生文本),**skill 核心价值直接归零、判废**。官方脚本跑不动就读它的报错按流程修 / 反馈,不要另起平行管线。 +- 🛑 **导出唯一入口 = 官方 `svg_to_pptx.py`**:它默认产出**原生可编辑 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改),纯 Python、不依赖任何外部渲染器,任何环境都跑得起来——**不存在"依赖缺失所以要换个导出办法"的情形**。它报错时:读报错原文 → 按指引回对应阶段修 → 重跑;修不动就把报错原文如实报给用户并停下。交付物的每一页都必须保有原生文本/形状;**平台会对产出的 .pptx 自动机检,无原生文本的 deck 会被判废打回**,别的产出路径不会更快,只会白做。 - ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`。 - 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。 - 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py ` 传播到所有 SVG(所有页源都变了 → **重跑阶段五全量重渲重标**,顺手把全本再过一遍眼);改版式/内容 → 重写对应页 SVG 再走阶段五返工回路 + 6.2,**不要直接 edit 成品 .pptx**。 @@ -210,7 +210,7 @@ references/visual-styles/.md # 锁定的视觉风格 - 用户没给材料就硬编内容(没材料只给主题 → 先补素材/反问,别凭空发挥) - 八条没对齐、没产出 spec_lock 就开始写 SVG - **写脚本批量生成 SVG**(破坏跨页一致性,禁;逐页手写) -- **绕开官方管线、自搓 SVG→PPTX 导出器**(`pip install cairosvg`/`inkscape` + 手写 `export_pptx.py` 把每页渲成 PNG 整页贴进幻灯片)—— 产物变一叠**不可编辑的整页贴图**(零原生文本/形状、还发虚、外链配图丢失),skill 全部价值作废。官方 `svg_to_pptx.py` 默认就是原生可编辑、纯 Python 无需外部渲染器,**"渲染器没装"不是造轮子的借口**;导出/后处理/质检/验收**只走 §16 资源里那几个官方脚本**,缺一步就补一步,别另起平行流程 +- **不经官方 `svg_to_pptx.py` 产出 .pptx**(无论用什么办法)—— 导出/后处理/质检/验收**只走 §16 资源里那几个官方脚本**,缺一步就补一步,别另起平行流程;官方导出器纯 Python、任何环境可跑,它报错就修或如实上报。平台对成品 .pptx 有机检,非原生产物直接判废 - **执行时不每页重读 spec_lock**(长 deck 必漂色/漂字号) - **同 deck 混用多个图标库** / 用 inventory 外的图标名 - 用了 `