fix(guard): ppt 伪导出根治三层——平台机检门 + 禁令去菜谱 + 脚本 hint 纪律(bump 0.40.0)

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 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-06 13:40:54 +08:00
parent 022eade18d
commit c52f399f07
13 changed files with 303 additions and 12 deletions

View File

@ -300,6 +300,10 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- **Phase 2(design):阈值结构化摘要**(补 Hermes 阶段③):中段轮压成固定模板(目标/约束/进展/待办),增量更新、保留 path/ID/数值原文(mem0 实测摘要会静默丢精确值);双层门槛 50%+85%。分段砍跨话题累积、摘要兜单段超长,正交。 - **Phase 2(design):阈值结构化摘要**(补 Hermes 阶段③):中段轮压成固定模板(目标/约束/进展/待办),增量更新、保留 path/ID/数值原文(mem0 实测摘要会静默丢精确值);双层门槛 50%+85%。分段砍跨话题累积、摘要兜单段超长,正交。
- **Phase 3(design):持久检索**(sqlite-vec/FTS5)解"问很久以前的精确内容";工程最重,待确认真实需求(数据没删随时能补)。 - **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) ## 附录:DeepSeek V4 关键事实(2026-04-24)

View File

@ -23,6 +23,7 @@
### 2026-07 ### 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.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.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` - **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`

View File

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

View File

@ -25,6 +25,10 @@ from .executor import ExecCtx, Executor
from .llm import LLM from .llm import LLM
from .session import Session from .session import Session
from .storage import record_chat_usage from .storage import record_chat_usage
from . import pptx_guard
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
_PPTX_GUARD_TOOLS = ("shell", "run_python")
_CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]" _CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]"
@ -520,6 +524,7 @@ class AgentLoop:
working_dir=self.working_dir, working_dir=self.working_dir,
cancel_check=self.cancel_check, cancel_check=self.cancel_check,
) )
tool_started_at = time.time()
result = self.executor.call_tool(name, args, ctx).content result = self.executor.call_tool(name, args, ctx).content
# 控制返回给模型的 tool 结果体量,避免炸 context # 控制返回给模型的 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] + "..." preview = result if len(result) < 400 else result[:400] + "..."
self._emit({ self._emit({
"type": "tool_result", "type": "tool_result",

138
core/pptx_guard.py Normal file
View File

@ -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 原生导出,不需要任何渲染依赖;报错就按报错修,"
"修不动就把报错原文如实告知用户)。不要另写导出脚本或改用渲染贴图。"
)

View File

@ -23,7 +23,7 @@ description: 生成科研方向简报(research direction briefing / 重要文献
## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`) ## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`)
- `references/journals.md` —— 各建材子领域主流期刊清单(Elsevier 数据库优先)+ 精确 `publication_name` + 0 命中降级法。**阶段二必读**。 - `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。 产物默认 `.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 <sections_dir> -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。 - 用户要 docx → `python /sandbox/rendering/render.py --profile brief --format docx <sections_dir> -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。
- 用户要 pdf → `python /sandbox/rendering/render.py --profile brief --format pdf <sections_dir> -o <方向>-简报.pdf`(沙盒内 chromium 渲染,同样 `--no-color` 出黑白)。**别现搓 weasyprint / 现 pip 装包** —— 直接调 render.py - 用户要 pdf → `python /sandbox/rendering/render.py --profile brief --format pdf <sections_dir> -o <方向>-简报.pdf`(沙盒内 chromium 渲染,同样 `--no-color` 出黑白)。**pdf 也只走 render.py 这一个入口**,报错就修或如实上报,不另起渲染路径
- 渲染前自查:`[CITE-]`/`<TODO>` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。 - 渲染前自查:`[CITE-]`/`<TODO>` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。
- 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。 - 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。

View File

@ -182,7 +182,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
- 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须渲过图(svg_preview)、且渲图后源未再改动、且 verdict=pass**。分两层:**"从没渲过 / 渲后又改 / finalize 前渲的"没有任何 CLI 逃生口**(渲图很便宜,没有理由交付一页没人看过的东西);`--allow-unreviewed` 只豁免"渲过但还没标 pass"这一层,**不是跳过验收的捷径**。被拒就回阶段五补验收/走返工回路。 - 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须渲过图(svg_preview)、且渲图后源未再改动、且 verdict=pass**。分两层:**"从没渲过 / 渲后又改 / finalize 前渲的"没有任何 CLI 逃生口**(渲图很便宜,没有理由交付一页没人看过的东西);`--allow-unreviewed` 只豁免"渲过但还没标 pass"这一层,**不是跳过验收的捷径**。被拒就回阶段五补验收/走返工回路。
- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory``svg_output/` 全 deck 零 `<use data-icon>` → 同样 `[ERROR]` 退非零(检测永远对 svg_output 源,与 `-s` 无关)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。 - 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory``svg_output/` 全 deck 零 `<use data-icon>` → 同样 `[ERROR]` 退非零(检测永远对 svg_output 源,与 `-s` 无关)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。
- ❌ **别加 `-s final`**:native 导出默认读 `svg_output/`(转换器自己处理图标占位与 `../images/` 相对路径),`-s final` 只会引出图片路径错位这类连锁问题;真实事故里模型为绕它把 svg_output 源里的 href 改坏了。 - ❌ **别加 `-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` - ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`
- 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。 - 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。
- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG(所有页源都变了 → **重跑阶段五全量重渲重标**,顺手把全本再过一遍眼);改版式/内容 → 重写对应页 SVG 再走阶段五返工回路 + 6.2,**不要直接 edit 成品 .pptx**。 - 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG(所有页源都变了 → **重跑阶段五全量重渲重标**,顺手把全本再过一遍眼);改版式/内容 → 重写对应页 SVG 再走阶段五返工回路 + 6.2,**不要直接 edit 成品 .pptx**。
@ -210,7 +210,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
- 用户没给材料就硬编内容(没材料只给主题 → 先补素材/反问,别凭空发挥) - 用户没给材料就硬编内容(没材料只给主题 → 先补素材/反问,别凭空发挥)
- 八条没对齐、没产出 spec_lock 就开始写 SVG - 八条没对齐、没产出 spec_lock 就开始写 SVG
- **写脚本批量生成 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 必漂色/漂字号) - **执行时不每页重读 spec_lock**(长 deck 必漂色/漂字号)
- **同 deck 混用多个图标库** / 用 inventory 外的图标名 - **同 deck 混用多个图标库** / 用 inventory 外的图标名
- 用了 `<style>`/`class`/`<mask>`/`<symbol>+<use>`/`@font-face`/`rgba()`/HTML 命名实体 等 **shared-standards 禁用特性**(导出会丢元素或报错) - 用了 `<style>`/`class`/`<mask>`/`<symbol>+<use>`/`@font-face`/`rgba()`/HTML 命名实体 等 **shared-standards 禁用特性**(导出会丢元素或报错)

View File

@ -27,7 +27,10 @@ from urllib.parse import unquote
try: try:
from PIL import Image from PIL import Image
except ImportError: except ImportError:
print("Error: PIL (Pillow) is required. Run: pip install Pillow") # 环境问题文案:说明+上报,不给安装指令 —— agent 会照打印的命令现场装包
print("Error: PIL (Pillow) missing in this environment. The sandbox image "
"should ship it; report this to the user instead of installing "
"packages in-session.")
exit(1) exit(1)

View File

@ -37,7 +37,9 @@ try:
HAS_PIL = True HAS_PIL = True
except ImportError: except ImportError:
HAS_PIL = False HAS_PIL = False
print("[WARN] PIL not installed. Install with: pip install Pillow") # 环境问题文案:说明+降级,不给安装指令 —— agent 会照打印的命令现场装包
print("[WARN] PIL missing in this environment (sandbox image should ship "
"it; mention to the user, do not install in-session).")
print(" Will try to use basic method for JPEG/PNG files.") print(" Will try to use basic method for JPEG/PNG files.")

View File

@ -351,8 +351,9 @@ def main() -> None:
except Exception: except Exception:
raise SystemExit( raise SystemExit(
"[fatal] 未找到 Chrome / Edge / Chromium,也无 cairosvg 兜底。" "[fatal] 未找到 Chrome / Edge / Chromium,也无 cairosvg 兜底。"
"沙箱镜像应自带 /usr/bin/chromium(rebuild sandbox 镜像)," "这是环境/镜像问题:沙箱镜像应自带 /usr/bin/chromium(需 rebuild "
"或 `pip install cairosvg`,或设 CHROMIUM 环境变量。") "sandbox 镜像),本机可设 CHROMIUM 环境变量指向浏览器。"
"请把本报错如实告知用户,不要在会话内安装渲染依赖或另寻渲染方式。")
done: list[tuple[Path, Path]] = [] done: list[tuple[Path, Path]] = []
for svg in files: for svg in files:

View File

@ -285,7 +285,8 @@ Compatibility mode (enabled by default):
- Automatically generates PNG fallback images, SVG embedded as extension - Automatically generates PNG fallback images, SVG embedded as extension
- Compatible with all Office versions (including Office LTSC 2021) - Compatible with all Office versions (including Office LTSC 2021)
- Newer Office still displays SVG (editable), older versions display PNG - Newer Office still displays SVG (editable), older versions display PNG
- Requires svglib: pip install svglib reportlab - PNG fallback needs a renderer from the sandbox image; if missing, native
export still works do not install renderers in-session
- Use --no-compat to disable (only Office 2019+ supported) - Use --no-compat to disable (only Office 2019+ supported)
Speaker notes (enabled by default): Speaker notes (enabled by default):

View File

@ -29,14 +29,21 @@ def get_png_renderer_info() -> tuple[str | None, str, str | None]:
Returns: Returns:
(renderer_name, status_text, install_hint) tuple. (renderer_name, status_text, install_hint) tuple.
""" """
# NOTE: hint 文案是给"人"(运维/用户)看的环境说明,不是给 agent 的指令——
# 别在这里写 `pip install ...`:真实事故里模型照着 hint 现场装包,进而绕开
# 官方管线自搓光栅导出器。缺渲染器只影响 PNG 兜底,原生导出不受影响,
# agent 的正确动作是继续原生导出并向用户说明,环境修复走镜像 rebuild。
if PNG_RENDERER == 'cairosvg': if PNG_RENDERER == 'cairosvg':
return ('cairosvg', '(full gradient/filter support)', None) return ('cairosvg', '(full gradient/filter support)', None)
elif PNG_RENDERER == 'svglib': elif PNG_RENDERER == 'svglib':
return ('svglib', '(some gradients may be lost)', return ('svglib', '(some gradients may be lost)',
'Install cairosvg for better results: pip install cairosvg') 'PNG fallback uses svglib here; native export is unaffected.')
else: else:
return (None, '(not installed)', return (None, '(not installed)',
'Install via: pip install cairosvg or pip install svglib reportlab') 'No PNG renderer in this environment. Native (editable) export '
'is unaffected — proceed with it and mention the missing PNG '
'fallback to the user. Do NOT pip-install renderers in-session; '
'the environment/image should be fixed by ops instead.')
def convert_svg_to_png( def convert_svg_to_png(

113
tests/test_pptx_guard.py Normal file
View File

@ -0,0 +1,113 @@
"""core/pptx_guard.py 的 focused tests——「整页贴图」伪 pptx 判定。
python-pptx 真实构造两类 deck:光栅贴图(每页一张整版图零文本)必须命中;
正常/边界 deck(有文本大背景图配文字单页含表格)必须放行
"""
import io
import time
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from pptx import Presentation
from pptx.util import Emu, Inches
from core import pptx_guard
def _png_bytes() -> io.BytesIO:
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (32, 18), (200, 30, 30)).save(buf, format="PNG")
buf.seek(0)
return buf
def _make_deck(path: Path, pages: list[dict]) -> Path:
"""pages: [{'pic': 覆盖率 0~1 或 None, 'text': str}]"""
prs = Presentation()
blank = prs.slide_layouts[6]
sw, sh = prs.slide_width, prs.slide_height
for spec in pages:
slide = prs.slides.add_slide(blank)
ratio = spec.get("pic")
if ratio:
side = ratio ** 0.5 # 面积占比 → 边长占比
slide.shapes.add_picture(
_png_bytes(), 0, 0, Emu(int(sw * side)), Emu(int(sh * side)))
text = spec.get("text")
if text:
box = slide.shapes.add_textbox(
Inches(1), Inches(1), Inches(4), Inches(1))
box.text_frame.text = text
prs.save(str(path))
return path
class RasterDeckDetectionTests(unittest.TestCase):
def test_full_page_paste_deck_is_flagged(self):
with TemporaryDirectory() as tmp:
p = _make_deck(Path(tmp) / "fake.pptx",
[{"pic": 1.0}] * 3)
self.assertIsNotNone(pptx_guard.check_raster_deck(p))
def test_native_text_deck_passes(self):
with TemporaryDirectory() as tmp:
p = _make_deck(Path(tmp) / "native.pptx",
[{"text": "标题"}, {"text": "正文要点"}])
self.assertIsNone(pptx_guard.check_raster_deck(p))
def test_big_background_with_text_passes(self):
with TemporaryDirectory() as tmp:
p = _make_deck(Path(tmp) / "bg.pptx",
[{"pic": 1.0, "text": "封面标题"},
{"pic": 0.95, "text": "章节页"}])
self.assertIsNone(pptx_guard.check_raster_deck(p))
def test_single_slide_poster_passes(self):
with TemporaryDirectory() as tmp:
p = _make_deck(Path(tmp) / "poster.pptx", [{"pic": 1.0}])
self.assertIsNone(pptx_guard.check_raster_deck(p))
def test_small_pictures_no_text_passes(self):
# 图片没铺满整版(<85%)就不算贴图页,哪怕全 deck 无文本
with TemporaryDirectory() as tmp:
p = _make_deck(Path(tmp) / "album.pptx",
[{"pic": 0.5}, {"pic": 0.5}])
self.assertIsNone(pptx_guard.check_raster_deck(p))
def test_unparseable_file_passes(self):
with TemporaryDirectory() as tmp:
p = Path(tmp) / "broken.pptx"
p.write_bytes(b"PK\x03\x04 not a real pptx")
self.assertIsNone(pptx_guard.check_raster_deck(p))
class ScanTests(unittest.TestCase):
def test_scan_reports_new_bad_deck_and_skips_hidden_and_old(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
since = time.time()
_make_deck(root / "bad.pptx", [{"pic": 1.0}] * 2)
hidden = root / ".build"
hidden.mkdir()
_make_deck(hidden / "ignored.pptx", [{"pic": 1.0}] * 2)
msg = pptx_guard.scan_and_report(root, since)
self.assertIsNotNone(msg)
self.assertIn("bad.pptx", msg)
self.assertIn("[产物机检 ERROR]", msg)
self.assertNotIn("ignored.pptx", msg)
# 早于 since 的文件不复检
self.assertIsNone(
pptx_guard.scan_and_report(root, time.time() + 60))
def test_scan_clean_dir_returns_none(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
_make_deck(root / "ok.pptx", [{"text": "有字"}, {"text": "有字"}])
self.assertIsNone(pptx_guard.scan_and_report(root, time.time() - 60))
if __name__ == "__main__":
unittest.main()