fix(ppt): 导出图标门升硬 + 修 svg_to_pptx CLI 退出码不传播 + 验收改全量(bump 0.34.7)

诊断 ppt生成2(966041e5)真实产出的两个缺陷——23 页零图标、多处错位——
根因不是缺 gate 而是 gate 被打穿:

- svg_to_pptx.py 只 main() 不 sys.exit(main()),main() 里所有 return 1
  (图标门/无 SVG/坏路径)全被吞成退出 0(最致命)
- 导出侧图标检查按设计只软 WARN、照常产出
- 模型质检用 `| head` 截断,吞非零退出码 + 截掉打在最后的零图标 [ERROR]
- SKILL.md 验收本就只要求抽查 3 页,错位藏在没看的页里;差评也未阻断

改动:
- svg_to_pptx.py: sys.exit(main()) 传播退出码
- pptx_cli.py: 导出图标门从软 WARN 升为硬门(锁图标却全 deck 零
  <use data-icon> → [ERROR] 退非零、不产出 pptx),加逃生口 --allow-iconless
- SKILL.md: 阶段六验收改「默认渲整本 + 逐页过目 + 差评即阻断返工」,
  阶段四/五/反模式补「别用 | head 截断」「别只看几页」「差评必返工」

合成测试三例(默认拒 / --allow-iconless 放行 / 有图标正常)全过。
仅改 skill 侧,不改动线上跑法;导出门只兜「锁了图标却零引用」,正常 deck 不受影响。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-01 14:16:49 +08:00
parent 641c7d58aa
commit c2d24b20b4
5 changed files with 67 additions and 29 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-01(修 look_at_image/seedream 拒收容器 `/workspace` 绝对路径 + bump 0.34.6) 最后更新:2026-07-01(ppt 导出图标门升硬 + 修 CLI 退出码不传播 + 验收改全量 + bump 0.34.7)
--- ---
@ -21,6 +21,9 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-01 / ppt skill 修复 ppt生成2(966041e5):图标门升硬 + CLI 退出码传播 + 验收改全量(bump 0.34.7)
诊断真实产出 `陶瓷资源节点建设方案.pptx`(deepseek-v4-flash 跑)两个缺陷:①23 页零图标(spec_lock 锁了 chunk-filled+inventory 却全 deck 0 个 `<use data-icon>`);②不少错位。根因不是缺 gate 而是 gate 被打穿:(a) `svg_to_pptx.py:22``main()``sys.exit(main())`——**main() 里所有 `return 1`(图标门/无 SVG/坏路径)全被吞成退出 0**,这是最致命的一处;(b) 导出侧图标检查 `_warn_if_icons_unused` 按设计只软 WARN、照常产出;(c) 模型质检时 `svg_quality_checker.py ... | head -30`,管道吞非零退出码 + `head` 截掉打在最后的零图标 `[ERROR]` 结论;(d) 验收阶段 SKILL.md 本就只要求抽查 3 页,23 页里只肉眼看了 2 页,且封面 vision 已报"半成品/错位"仍未返工直接交付。改动:①`svg_to_pptx.py` → `sys.exit(main())`;②`pptx_cli.py` 把导出侧检查从软 WARN 升为**硬门**(锁图标却全 deck 零 `<use data-icon>``[ERROR]` 退非零、不产出 pptx),加显式逃生口 `--allow-iconless`(应对 lock 过期/有意无图标);③SKILL.md 阶段六验收改「默认渲整本、逐页过目、差评即阻断返工」(废掉抽查 3 页),阶段四/五/反模式补「别用 `| head` 截断质检/导出输出」「别只看几页」「看到差评必返工」。合成测试三例(默认拒/`--allow-iconless` 放行/有图标正常)全过。**注:此修仅改 skill 侧,不改动线上跑法**;导出门只兜"锁了图标却零引用",正常有图标 deck 不受影响。
### 2026-07-01 / 修 look_at_image/seedream 拒收容器绝对路径(bump 0.34.6) ### 2026-07-01 / 修 look_at_image/seedream 拒收容器绝对路径(bump 0.34.6)
现象:docker backend 下主模型被系统提示告知一切都在 `/workspace` 下,自然产出容器绝对路径(如 `/workspace/ppt生成2/ceramic-node/images/cover_bg.png`)喂给 `look_at_image`,却报「图片找不到或越界」,只有改成 working_dir 相对路径才成功。根因:`tools/image_ref.py resolve_in_root`(look_at_image + seedream 共用)只吃「working_dir 相对 / user_root 相对 / 宿主绝对」三形态,唯独不把 `/workspace/<rest>` 翻回宿主 `user_root/<rest>`——而 host-side 的 send_email 早在 `Tool._resolve_user_file` 做了这翻译。改动:`resolve_in_root` 加容器根(`/workspace`)前缀翻译,**按字符串前缀判断而非 `is_absolute()`**(Windows 上 `/workspace/...` 缺盘符不算绝对);越界仍靠原 `relative_to(root)` 兜住(`/workspace/../secret`、`/workspace/../../etc/passwd` 实测仍拒)。这样 look_at_image/seedream 接受的路径形态与 send_email/wechat_push 及系统提示告诉 agent 的口径一致。 现象:docker backend 下主模型被系统提示告知一切都在 `/workspace` 下,自然产出容器绝对路径(如 `/workspace/ppt生成2/ceramic-node/images/cover_bg.png`)喂给 `look_at_image`,却报「图片找不到或越界」,只有改成 working_dir 相对路径才成功。根因:`tools/image_ref.py resolve_in_root`(look_at_image + seedream 共用)只吃「working_dir 相对 / user_root 相对 / 宿主绝对」三形态,唯独不把 `/workspace/<rest>` 翻回宿主 `user_root/<rest>`——而 host-side 的 send_email 早在 `Tool._resolve_user_file` 做了这翻译。改动:`resolve_in_root` 加容器根(`/workspace`)前缀翻译,**按字符串前缀判断而非 `is_absolute()`**(Windows 上 `/workspace/...` 缺盘符不算绝对);越界仍靠原 `relative_to(root)` 兜住(`/workspace/../secret`、`/workspace/../../etc/passwd` 实测仍拒)。这样 look_at_image/seedream 接受的路径形态与 send_email/wechat_push 及系统提示告诉 agent 的口径一致。

View File

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

View File

@ -143,6 +143,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。 - **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。 - `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
## 阶段五:后处理 + 导出 ## 阶段五:后处理 + 导出
@ -156,16 +157,20 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir> .venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
# 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新) # 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新)
``` ```
- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但全 deck 零 `<use data-icon>` → 导出**直接 `[ERROR]` 退非零、不产出 pptx**(这是最后一道,`| head` 绕不过)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。
- ❌ 别用 `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 再跑 5.25.3,**不要直接 edit 成品 .pptx**。 - 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG;改版式/内容 → 重写对应页 SVG 再跑 5.25.3,**不要直接 edit 成品 .pptx**。
## 阶段六:验收(渲图肉眼/vision 看) ## 阶段六:验收(渲图肉眼/vision 看)—— 全量,不抽查
``` ```
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir> --pages 1,3,5 .venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir>
``` ```
PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配图已内嵌,最忠实),没有则渲 `svg_output/`(无 chromium 时走 cairosvg 兜底、会就地展开图标)。`read` 渲出的 PNG 亲眼过:封面、一个内容页、一个 breathing 页 —— 看标题层级、卡片过挤/过空、文字是否都正常、节奏是否单调、配图位置。不通过的回阶段三改对应页 SVG 重跑。 - **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。)
- PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配图已内嵌,最忠实),没有则渲 `svg_output/`(无 chromium 时走 cairosvg 兜底、会就地展开图标)。
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在、节奏是否单调、配图位置。
- 🚧 **差评即阻断**:任一页被判出排版/溢出/重叠/半成品问题(哪怕只是封面)→ **回阶段三改那一页 SVG、重渲、复看,直到通过才算验收完**。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。
> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。 > svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。
@ -199,6 +204,8 @@ PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配
- **breathing 页堆多卡网格**(违节奏,显 AI 味) - **breathing 页堆多卡网格**(违节奏,显 AI 味)
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号) - 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿 - 质检没过就交付 / 直接 edit 成品 .pptx 改稿
- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付)
- **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设)
- 起名 `output.pptx` —— 按主题命名 - 起名 `output.pptx` —— 按主题命名
## 输出 ## 输出

View File

@ -19,4 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
from svg_to_pptx import main from svg_to_pptx import main
if __name__ == '__main__': if __name__ == '__main__':
main() # Propagate main()'s return code as the process exit code — otherwise every
# `return 1` guard in main() (icon gate / no-SVG / bad path) silently exits 0
# and callers (and `&&` chains) can't tell success from a refused export.
sys.exit(main())

View File

@ -63,48 +63,46 @@ def _recorded_narration_on_click_slides(
return blocked return blocked
def _warn_if_icons_unused(project_path: Path, svg_files: list[Path]) -> None: def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path]) -> bool:
"""Export-boundary defense-in-depth (mirrors svg_quality_checker's icon gate). """Detect the export-boundary icon violation.
If ``spec_lock.md`` locks an icon library + non-empty inventory but the source Returns True when ``spec_lock.md`` locks an icon library + non-empty
SVGs carry zero ``<use data-icon>`` placeholders, the deck exports flat / inventory but the source SVGs carry ZERO ``<use data-icon>`` placeholders
icon-less. Warn loudly on stderr so it isn't silent when someone exports i.e. the deck would export flat / icon-less despite the strategist intending
without first running ``svg_quality_checker.py`` (the hard gate). Non-fatal: icons. Returns False otherwise (including on any internal error: detection
export still proceeds the lock may be stale or icons intentionally absent. must never itself break the export path).
Fully defensive: any failure here must never break the export.
The caller turns a True into a fatal abort (unless ``--allow-iconless``).
This mirrors svg_quality_checker's deck-level icon gate, but at the export
boundary it is the LAST line of defense: the quality gate can be reordered
before export or have its non-zero exit swallowed by ``| head``, whereas a
refusal to write the pptx cannot be piped away.
""" """
try: try:
import re import re
lock_path = project_path / 'spec_lock.md' lock_path = project_path / 'spec_lock.md'
if not lock_path.exists(): if not lock_path.exists():
return return False
try: try:
from update_spec import parse_lock from update_spec import parse_lock
icons = (parse_lock(lock_path) or {}).get('icons') or {} icons = (parse_lock(lock_path) or {}).get('icons') or {}
except Exception: except Exception:
return return False
library = (icons.get('library') or '').strip().lower() library = (icons.get('library') or '').strip().lower()
inventory = (icons.get('inventory') or '').strip().lower() inventory = (icons.get('inventory') or '').strip().lower()
_empty = ('', 'none', '(none)', '-', 'n/a') _empty = ('', 'none', '(none)', '-', 'n/a')
if library in _empty or inventory in _empty: if library in _empty or inventory in _empty:
return return False
total = 0 total = 0
for p in svg_files: for p in svg_files:
try: try:
total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8'))) total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8')))
except Exception: except Exception:
continue continue
if total == 0: return total == 0
print(
"[WARN] spec_lock locks an icon library + inventory, but the source SVGs "
"contain ZERO <use data-icon> — this deck exports flat / icon-less. "
"Run svg_quality_checker.py and add inventory icons to content pages "
"before delivering.",
file=sys.stderr,
)
except Exception: except Exception:
return return False
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@ -202,6 +200,12 @@ Recorded narration:
parser.add_argument('--no-compat', action='store_true', parser.add_argument('--no-compat', action='store_true',
help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)') help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)')
parser.add_argument('--allow-iconless', action='store_true', default=False,
help='Allow export even when spec_lock locks an icon inventory but '
'the SVGs author zero <use data-icon> (default: refuse — the deck '
'would render flat / icon-less). Use only for a stale lock or an '
'intentionally icon-less deck.')
mode_group = parser.add_mutually_exclusive_group() mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None, mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
help='Only generate one version: native (editable shapes) or legacy (SVG image)') help='Only generate one version: native (editable shapes) or legacy (SVG image)')
@ -351,9 +355,30 @@ Recorded narration:
print("Error: No SVG files found") print("Error: No SVG files found")
return 1 return 1
# Export-boundary icon check: warn (non-fatal) if an inventory is locked but # Export-boundary icon gate: a locked icon inventory with ZERO authored
# no <use data-icon> is authored — defense-in-depth behind the quality gate. # <use data-icon> means the deck exports flat / icon-less. This is the last
_warn_if_icons_unused(project_path, ref_files) # line of defense (the quality gate can be reordered before export or its
# non-zero exit swallowed by `| head`), so it is FATAL by default — refuse to
# produce a pptx that the strategist's own spec_lock says is wrong.
# --allow-iconless is the explicit escape hatch (stale lock / intentional).
if _deck_locks_icons_but_authors_none(project_path, ref_files):
if args.allow_iconless:
print(
"[WARN] spec_lock locks an icon library + inventory but the source SVGs "
"contain ZERO <use data-icon> — exporting flat / icon-less anyway "
"(--allow-iconless).",
file=sys.stderr,
)
else:
print(
"[ERROR] spec_lock locks an icon library + inventory, but the source SVGs "
"contain ZERO <use data-icon> — this deck would export flat / icon-less.\n"
" Add inventory icons to content pages (KPI / list / process /\n"
" comparison layouts especially), then re-run. If the lock is stale\n"
" or icons are intentionally absent, pass --allow-iconless.",
file=sys.stderr,
)
return 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")