diff --git a/PROGRESS.md b/PROGRESS.md index 47d3522..94969b8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-02(ppt 渲图验收闭环 + 导出验收硬门 + 几何质检,bump 0.36.0) +最后更新:2026-07-03(ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复,bump 0.36.1) --- @@ -21,6 +21,9 @@ ## 已完成关键能力 +### 2026-07-03 / ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复(139a59c5 重跑复盘,bump 0.36.1) +0.36.0 上线后同 task 重跑(仍 deepseek-v4-flash):产物整体大幅好转,但仍有 4/25 页错位(P12 色带裁两行标题+正文跑出卡外 / P14·P18 文字骑卡片边框 / P21 手画饼图弧线劈叉)。轨迹显示**两道新门都触发了、都被模型 8 秒内用逃生口按过去**:质检+渲图验收 0 调用,`--allow-iconless` + `--allow-unreviewed` 连按直接导出——门有了,逃生口对弱模型等于"报错时该加的参数"。且 `--allow-iconless` 的"正当理由"是我们自己给的:wrapper docstring 老示例教它 `-s final`,而图标门检查的是 svg_final(data-icon 已展开)→ 误报零图标;`-s final` 还连锁出图片路径连环坑(见 F)。二轮修五处:**A 验收门分层**——"从没渲过/渲后又改/finalize 前渲的"为硬问题,**任何 CLI flag 不豁免**(渲图便宜且机器可验,没理由交付没人能看过的页);`--allow-unreviewed` 只豁免"渲过但没标 pass";运维兜底走 `ZCBOT_PPT_FORCE_EXPORT=1` 环境变量(不进 --help/SKILL)。**B 拔 `-s final` 雷**——图标门永远对 svg_output 源检测(误报根除);wrapper docstring 示例去掉 `-s final` 并注明勿用。**C 导出自动质检门**——svg_to_pptx 导出前内嵌复跑 quality checker 逐页硬错误(坏 XML/禁用特性/图片缺失/几何 error),error 拒绝导出、无豁免参数(fail-open 于 import 失败)——"忘跑/不跑质检"从此无效。**D** 验收门报错计数措辞修正。**E 几何质检加"文字骑卡片边缘"检测**(warning 带坐标:文字与可见矩形交叠面积占比 0.2–0.85 即骑边,P12/P14/P18 三类当场可命中;P21 饼图弧线错误静态无解,只能渲图过目)。**F 修 svg_final 嵌图失效 bug**——finalize 先 copytree 到 `.build/svg_final` 再就地嵌图,`../images/` 从 svg_final 解析必落空 → **所有 deck 的 svg_final 一直嵌不进外链图**(渲图验收 PNG 里图片也是空的);`_resolve_image_path` 加"rebase 回 svg_output 同相对路径"兜底,实测 data:URI 落位。本机全链路回归:未渲→硬拒(带 flag 也拒)/ pending→拒、flag 放 / pass→放行 / 质检 error→拒 / env 强制→放;71 charts 模板几何 0 error。已知边界:P21 类"图形画错但不重叠不越界"仍只有渲图过目能拦——"看没看"无法机器验证,治本要平台层 vision 验收(待做,同 0.35.1 备注)。 + ### 2026-07-02 / ppt 渲图验收闭环 + 导出验收硬门 + 几何质检(139a59c5 复盘,bump 0.36.0) 复盘 task 139a59c5(deepseek-v4-flash,25 页陶瓷节点方案):用户实报"很多地方错位"。本机 PowerPoint COM 渲全部 25 页定位三类错位:①图标压字/游离(P4/P5/P8/P10/P16/P24——质检报"缺图标"后模型写 `add_icons.py` **regex 批量盲插坐标**,插完没看);②大字号数字压说明文字(P5 万亿/26%);③目录溢出页底(P2)。**根因:SKILL 阶段六"全量渲图验收"被整个跳过**——进度步骤标 completed 但唯一动作是 `echo 交付清单`,`svg_preview` 全程 0 调用;文档要求了但无机制强制(与 0.35.1 教训同构:纯文档约束拦不住弱模型)。改动三层:**A 验收闭环+导出硬门(机制)**——`svg_preview.py` 渲 project 时登记 `.build/acceptance.json`(每页 svg_output 源 sha1 + rendered_from + verdict;svg_output 比 svg_final 新的页拒登记);新增 `accept_pages.py`(`--pass/--pass-all/--fail --reason/--status`,标 pass 前校验"渲过 + PNG 在 + 渲后源没改");`svg_to_pptx` 导出边界加验收门(spec_lock 存在时每页须 verdict=pass 且源 sha1 未变,finalize 前渲的也拒;`--allow-unreviewed` 逃生口)——"从没渲过就交付"和"改页不复看"在导出边界被确定性挡下,单页返工回路(`--pages N` 重渲 merge 记录)已本机全链路验证。**B 几何质检(提前拦截)**——`svg_quality_checker` 新增 check 13:按字符估宽(CJK≈1em/Latin≈0.5-0.7em)+ translate 累加构包围盒;**图标压字、基线出画布=ERROR**(几何精确),**文字-文字重叠一律 WARN 带精确坐标**(估宽分不清擦边与压字,词云/象限图等密排设计会误伤,判断权交渲图验收;SKILL 阶段四明确 Geometry warn 渲图时必须对着坐标看);tspan 按"视觉行"归组续排(`$4.2B (35%)` 是一行不是两段),71 个 charts 模板 0 error 误报、复刻事故的 fixture 全命中。**C 管线顺序+反模式(文档)**——SKILL.md 管线改"后处理→渲图验收→导出"(验收在导出前),阶段五=finalize+全量渲图+逐页过目+标记,阶段六=拆备注+导出(验收门+图标门双硬门);反模式加"没看 PNG 就 --pass-all"和"为消警告脚本批量盲插元素不复看"。SKILL_LIST 同步。已知边界:gate 只能强制"渲过、源没改",看没看 PNG 无法机器验证(--pass-all 仍可被糊弄,但本次事故"从不渲图"的直接通路已封死)。 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index fca0832..83d833b 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -186,7 +186,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + - **19 种视觉风格 + 5 种叙事骨架**:editorial / swiss-minimal / glassmorphism / dark-tech / data-journalism… × pyramid / narrative / instructional / showcase / briefing —— 去 AI 味的关键 - **模板库**:layouts(版式)/ decks(整套:中汽研/招商银行/重庆大学等)/ brands(品牌)/ charts(71 个图表信息图)/ icons(5 套共 1.1w+ 图标,finalize 自动内嵌) - **逐页节奏纪律**:论断式标题、page_rhythm(anchor/dense/breathing,breathing 页禁卡片墙)、内容→版式映射、图文版式 72 式 -- **SVG 质检** `svg_quality_checker.py`:禁用特性 / viewBox / spec_lock 漂移 / 配色越界 / **几何检测**(文本·图标包围盒估算,拦大字压说明、图标压字、行溢出画布)(error 必改,回写 SVG) +- **SVG 质检** `svg_quality_checker.py`:禁用特性 / viewBox / spec_lock 漂移 / 配色越界 / **几何检测**(文本·图标包围盒估算,拦大字压说明、图标压字、行溢出画布、文字骑卡片边缘)(error 必改,回写 SVG;**导出边界自动复跑同套硬错误,error 拒绝导出、无豁免参数**) - **渲图验收闭环** `svg_preview.py` + `accept_pages.py`:无头 Chrome 全量渲 PNG 肉眼/vision 验版面,逐页标 pass/fail 落 `.build/acceptance.json`;**导出 gate 只认"渲过 + 看过标 pass + 渲后源未改(sha1)"**,跳验收/盲改混不过去;`update_spec.py` 一键改色/字体传播到所有 SVG - AI 配图走 imagegen skill;markitdown 素材摄取 diff --git a/core/__init__.py b/core/__init__.py index 7b00afb..3cccbb5 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.36.0" +__version__ = "0.36.1" diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index c6c78b7..ad85cad 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -146,6 +146,7 @@ references/visual-styles/.md # 锁定的视觉风格 - `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。 +- 跳过本阶段没有意义:导出边界会**自动复跑同一套逐页硬错误检查**(见阶段六质检门),error 到那里一样拒绝导出 —— 在这里主动跑并连警告一起读,能更早返工。 ## 阶段五:后处理 + 渲图验收(强制门)—— 全量,不抽查 @@ -175,8 +176,10 @@ references/visual-styles/.md # 锁定的视觉风格 .venv/Scripts/python.exe /scripts/svg_to_pptx.py # 产物:exports/_.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新) ``` -- 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须 verdict=pass 且渲图后源未改动**,否则导出 `[ERROR]` 退非零、不产出 pptx(`| head` 绕不过)。被拒就回阶段五补验收/走返工回路;`--allow-unreviewed` 只留给"确实不需要视觉验收"的极端场景,**不是跳过验收的捷径**。 -- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但全 deck 零 `` → 同样 `[ERROR]` 退非零。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。 +- 🚧 **导出边界质检门(硬,无豁免参数)**:导出前自动复跑阶段四质检的逐页硬错误(禁用特性 / 坏 XML / 图片文件缺失 / 图标压字·出画布几何错误等),**有 error 直接拒绝导出**。没有任何 `--allow-*` 能绕过 —— 这些是真缺陷,回 svg_output 修完再来。 +- 🚧 **导出边界验收门(硬)**: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 核心价值直接归零、判废**。官方脚本跑不动就读它的报错按流程修 / 反馈,不要另起平行管线。 - ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`。 - 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。 diff --git a/skills/ppt/scripts/svg_finalize/align_embed_images.py b/skills/ppt/scripts/svg_finalize/align_embed_images.py index d946efb..32578a0 100644 --- a/skills/ppt/scripts/svg_finalize/align_embed_images.py +++ b/skills/ppt/scripts/svg_finalize/align_embed_images.py @@ -116,9 +116,20 @@ def _resolve_image_path(href: str, svg_dir: Path) -> Path | None: return None if os.path.isabs(decoded): candidate = Path(decoded) - else: - candidate = (svg_dir / decoded).resolve() - return candidate if candidate.exists() else None + return candidate if candidate.exists() else None + candidate = (svg_dir / decoded).resolve() + if candidate.exists(): + return candidate + # svg_final is a copytree of svg_output living two levels deeper + # (/.build/svg_final), but authored hrefs like ../images/x.png + # are relative to svg_output — from the copy they land on the + # nonexistent .build/images and every external image silently stayed + # un-embedded. Rebase the same relative path onto svg_output. + if svg_dir.name == 'svg_final' and svg_dir.parent.name == '.build': + authored = (svg_dir.parent.parent / 'svg_output' / decoded).resolve() + if authored.exists(): + return authored + return None def _load_pil_image(img_path: Path) -> 'PILImage' | None: diff --git a/skills/ppt/scripts/svg_quality_checker.py b/skills/ppt/scripts/svg_quality_checker.py index 41bec98..e88be2e 100644 --- a/skills/ppt/scripts/svg_quality_checker.py +++ b/skills/ppt/scripts/svg_quality_checker.py @@ -1541,18 +1541,20 @@ class SVGQualityChecker: except (TypeError, ValueError): return default - def _collect_geometry(self, root) -> Tuple[list, list]: - """Walk the tree collecting estimated text boxes and exact icon boxes. + def _collect_geometry(self, root) -> Tuple[list, list, list]: + """Walk the tree collecting text boxes, icon boxes and visible rects. Only translate() transforms are followed; any other transform makes coordinates unknowable without a full matrix engine, so that subtree is skipped (better silent than wrong). Boxes are dicts: {x0, y0, x1, y1, fs, label, exact_left} — exact_left marks a start-anchored text whose left edge is exact (only the right edge is - estimated). + estimated). Rects (cards / bands, ≥12px on both axes, visible fill or + stroke) feed the text-straddles-edge check. """ texts: list = [] icons: list = [] + rects: list = [] translate_re = re.compile( r'^\s*translate\(\s*(-?[\d.]+)(?:[\s,]+(-?[\d.]+))?\s*\)\s*$') skip_tags = {'defs', 'clipPath', 'marker', 'symbol', 'pattern', @@ -1667,6 +1669,19 @@ class SVGQualityChecker: 'label': el.get('data-icon'), }) return + if tag == 'rect': + x, y = self._f(el.get('x'), 0.0), self._f(el.get('y'), 0.0) + w, h = self._f(el.get('width')), self._f(el.get('height')) + visible = (el.get('fill') or '').strip().lower() != 'none' \ + or el.get('stroke') not in (None, 'none') + # ≥12px both axes: hairline rules / accent bars / top bands are + # legitimate text neighbors, only card/band-sized boxes matter. + if visible and w and h and w >= 12 and h >= 12: + rects.append({ + 'x0': x + tx, 'y0': y + ty, + 'x1': x + w + tx, 'y1': y + h + ty, + }) + return inh_fs = self._f(el.get('font-size'), inh_fs) inh_anchor = el.get('text-anchor') or inh_anchor inh_op = effective_opacity(el, inh_op) @@ -1676,7 +1691,7 @@ class SVGQualityChecker: walk(c, tx, ty, inh_fs, inh_anchor, inh_op) walk(root, 0.0, 0.0, None, 'start', 1.0) - return texts, icons + return texts, icons, rects @staticmethod def _box_intersection(a: Dict, b: Dict) -> Tuple[float, float]: @@ -1698,7 +1713,7 @@ class SVGQualityChecker: return canvas_w, canvas_h = float(parts[2]), float(parts[3]) - texts, icons = self._collect_geometry(root) + texts, icons, rects = self._collect_geometry(root) errors: List[str] = [] warnings: List[str] = [] @@ -1802,6 +1817,29 @@ class SVGQualityChecker: f"icons {a['label']} and {b['label']} overlap at " f"({max(a['x0'], b['x0']):.0f},{max(a['y0'], b['y0']):.0f})") + # 5. Text straddling a card/band edge (warning). A run that is partly + # inside and partly outside a visible rect is either clipped by the + # band (header text taller than its band) or poking out of its card + # (mis-centered captions) — both shipped in a real deck. Ratio + # bounds leave room for the width estimate; a deliberate badge + # overlapping a card edge is the FP case, hence warning-tier. + for t in texts: + t_area = (t['x1'] - t['x0']) * (t['y1'] - t['y0']) + if t_area <= 0: + continue + for r in rects: + iw, ih = self._box_intersection(t, r) + if iw <= 0 or ih <= 0: + continue + ratio = (iw * ih) / t_area + if 0.2 <= ratio <= 0.85: + warnings.append( + f"text \"{t['label']}\" straddles a card/band edge at " + f"({r['x0']:.0f},{r['y0']:.0f})-({r['x1']:.0f},{r['y1']:.0f}) " + f"(~{ratio * 100:.0f}% inside) — likely clipped by the band " + f"or poking out of the card; eyeball at render acceptance") + break # one report per text run is enough + for bucket, dest in ((errors, result['errors']), (warnings, result['warnings'])): shown = bucket[:self._GEOM_MAX_REPORTS] dest.extend(f"Geometry: {m}" for m in shown) diff --git a/skills/ppt/scripts/svg_to_pptx.py b/skills/ppt/scripts/svg_to_pptx.py index 0b388bc..7eb70a0 100644 --- a/skills/ppt/scripts/svg_to_pptx.py +++ b/skills/ppt/scripts/svg_to_pptx.py @@ -2,7 +2,12 @@ """PPT Master - SVG to PPTX Tool (thin wrapper). Delegates to the svg_to_pptx package. Kept for CLI backward compatibility: - python3 scripts/svg_to_pptx.py -s final + python3 scripts/svg_to_pptx.py + +Do NOT pass `-s final` by default: native export reads svg_output/ (the +authored source; icons/preserveAspectRatio handled by the converter). A real +run that copied `-s final` from an old docstring here cascaded into a false +icon-gate alarm and hand-patched image paths in the sources. """ import sys diff --git a/skills/ppt/scripts/svg_to_pptx/pptx_cli.py b/skills/ppt/scripts/svg_to_pptx/pptx_cli.py index 928fd93..4b2abad 100644 --- a/skills/ppt/scripts/svg_to_pptx/pptx_cli.py +++ b/skills/ppt/scripts/svg_to_pptx/pptx_cli.py @@ -77,6 +77,13 @@ def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path] 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. + + Detection always reads ``svg_output/`` (the authored source of truth) + when it exists, regardless of which directory the export consumes: + finalize EXPANDS ```` placeholders in svg_final, so + checking an ``-s final`` file set produced a false "zero icons" alarm — + which in a real run handed the model a legitimate-looking reason to pass + ``--allow-iconless`` on a deck whose icons were perfectly fine. """ try: import re @@ -94,8 +101,10 @@ def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path] _empty = ('', 'none', '(none)', '-', 'n/a') if library in _empty or inventory in _empty: return False + source_dir = project_path / 'svg_output' + sources = sorted(source_dir.glob('*.svg')) if source_dir.is_dir() else svg_files total = 0 - for p in svg_files: + for p in sources: try: total += len(re.findall(r']*\bdata-icon\s*=', p.read_text(encoding='utf-8'))) except Exception: @@ -105,7 +114,50 @@ def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path] return False -def _acceptance_problems(project_path: Path, svg_files: list[Path]) -> list[str]: +def _quality_errors(project_path: Path) -> list[str]: + """Export-boundary structural quality gate. + + Runs svg_quality_checker's per-file checks on ``svg_output/`` right before + export and returns every hard error (forbidden features, malformed XML, + missing image files, icon-on-text / off-canvas geometry, spec_lock + violations). The stage-4 quality check is documented as mandatory, but a + real 25-page run simply never invoked it — embedding the same checks at + the export boundary makes "forgot / skipped the checker" impossible. + + Only applies to spec_lock'd projects with an svg_output/ (same scope as + the other gates). Fails open on import/internal errors — the checker + lives one directory above this package and is optional at runtime; a + broken checker must not break exports. Deck-level aggregates (icon + totals, visual richness) are NOT re-derived here; icons already have + their own export gate and richness is advisory. + """ + try: + if not (project_path / 'spec_lock.md').exists(): + return [] + source_dir = project_path / 'svg_output' + if not source_dir.is_dir(): + return [] + try: + import sys as _sys + _scripts_dir = str(Path(__file__).resolve().parent.parent) + if _scripts_dir not in _sys.path: + _sys.path.insert(0, _scripts_dir) + from svg_quality_checker import SVGQualityChecker + except Exception: + return [] + checker = SVGQualityChecker() + problems: list[str] = [] + for svg in sorted(source_dir.glob('*.svg')): + result = checker.check_file(str(svg)) + for err in result.get('errors', []): + problems.append(f"{svg.stem}: {err}") + return problems + except Exception: + return [] + + +def _acceptance_problems(project_path: Path, + svg_files: list[Path]) -> tuple[list[str], list[str]]: """Export-boundary visual-acceptance gate (companion to the icon gate). A spec_lock'd deck must have every exported page visually accepted: @@ -113,61 +165,70 @@ def _acceptance_problems(project_path: Path, svg_files: list[Path]) -> list[str] ``.build/acceptance.json``), eyeballed, and marked ``pass`` via accept_pages.py — with the svg_output source unchanged since that render. - Returns a list of human-readable problems; empty means the gate passes or - does not apply (no spec_lock.md — bare/ad-hoc conversions stay unblocked). - Unexpected internal errors fail open (return []): the gate must never - itself break the export path. A missing or unparseable acceptance record - is NOT an internal error — it is exactly the "never rendered, never - looked" failure this gate exists to stop. + Returns ``(hard, waivable)`` problem lists; both empty means the gate + passes or does not apply (no spec_lock.md — bare/ad-hoc conversions stay + unblocked). ``hard`` problems (never rendered / record unreadable / source + edited after render / pre-finalize render) block even under + ``--allow-unreviewed`` — rendering is cheap and machine-checkable, so + there is no legitimate reason to export a page nobody could have seen. + ``waivable`` problems (verdict not yet pass) yield to the flag. The only + bypass for hard problems is the ZCBOT_PPT_FORCE_EXPORT=1 environment + variable (operator emergency hatch, deliberately absent from --help). + + Unexpected internal errors fail open: the gate must never itself break + the export path. A missing or unparseable acceptance record is NOT an + internal error — it is exactly the "never rendered, never looked" + failure this gate exists to stop. Motivation: a real delivery shipped 25 hand-written pages with icon-on-text and numeral-on-caption collisions because the acceptance stage was skipped - outright — the SKILL doc demanded full-render review, but nothing enforced - it. Like the icon gate above, a refusal to write the pptx cannot be piped - away with ``| head``. + outright; the re-run then skipped rendering again by reaching straight for + --allow-unreviewed eight seconds after the gate fired. Like the icon gate + above, a refusal to write the pptx cannot be piped away with ``| head``. """ try: if not (project_path / 'spec_lock.md').exists(): - return [] + return [], [] acc_path = project_path / '.build' / 'acceptance.json' if not acc_path.exists(): - return ["no acceptance record (.build/acceptance.json) — the deck was " - "never rendered for review (svg_preview.py never ran)"] + return (["no acceptance record (.build/acceptance.json) — the deck was " + "never rendered for review (svg_preview.py never ran)"], []) try: data = json.loads(acc_path.read_text(encoding='utf-8')) pages = data.get('pages') if isinstance(data, dict) else None if not isinstance(pages, dict): raise ValueError('missing "pages" object') except (json.JSONDecodeError, ValueError, OSError) as exc: - return [f"acceptance record unreadable ({exc}) — re-run svg_preview.py"] + return ([f"acceptance record unreadable ({exc}) — re-run svg_preview.py"], []) import hashlib - problems: list[str] = [] + hard: list[str] = [] + waivable: list[str] = [] for svg in svg_files: stem = svg.stem entry = pages.get(stem) if not isinstance(entry, dict): - problems.append(f"{stem}: never rendered / reviewed") - continue - verdict = entry.get('verdict') - if verdict != 'pass': - problems.append(f"{stem}: verdict is '{verdict or 'pending'}', not 'pass'") + hard.append(f"{stem}: never rendered / reviewed") continue if entry.get('rendered_from') == 'svg_output': - problems.append( - f"{stem}: accepted from a pre-finalize render (icons/images " - f"not embedded) — re-run finalize_svg + svg_preview") + hard.append( + f"{stem}: rendered pre-finalize (icons/images not embedded, " + f"the PNG shows the wrong page) — re-run finalize_svg + svg_preview") continue source = project_path / 'svg_output' / f'{stem}.svg' if not source.exists(): source = svg sha = hashlib.sha1(source.read_bytes()).hexdigest() if sha != entry.get('source_sha1'): - problems.append(f"{stem}: source edited AFTER the accepted render — " - f"re-render and re-review this page") - return problems + hard.append(f"{stem}: source edited AFTER the last render — " + f"re-render and re-review this page") + continue + verdict = entry.get('verdict') + if verdict != 'pass': + waivable.append(f"{stem}: verdict is '{verdict or 'pending'}', not 'pass'") + return hard, waivable except Exception: - return [] + return [], [] def main(argv: list[str] | None = None) -> int: @@ -451,36 +512,85 @@ Recorded narration: ) return 1 + import os as _os + force_export = _os.environ.get('ZCBOT_PPT_FORCE_EXPORT') == '1' + + # Export-boundary structural quality gate: the stage-4 checker's per-file + # hard errors, re-run here so "never ran the checker" cannot ship a deck. + # No CLI flag waives this — these are real defects to fix, not judgments. + quality_problems = [] if force_export else _quality_errors(project_path) + if quality_problems: + print( + f"[ERROR] quality check failed with {len(quality_problems)} error(s) — " + "refusing to export:", + file=sys.stderr, + ) + for p in quality_problems[:20]: + print(f" - {p}", file=sys.stderr) + if len(quality_problems) > 20: + print(f" ... and {len(quality_problems) - 20} more", file=sys.stderr) + print( + " Fix the listed pages in svg_output/ (run svg_quality_checker.py " + "\n" + " for the full report incl. warnings), then re-run finalize + " + "preview + export.", + file=sys.stderr, + ) + return 1 + # Export-boundary visual-acceptance gate: every page must have been # rendered (svg_preview.py), eyeballed, and marked pass (accept_pages.py) - # with its source unchanged since. See _acceptance_problems for rationale. - acceptance_problems = _acceptance_problems(project_path, ref_files) - if acceptance_problems: + # with its source unchanged since. Hard problems (never rendered / stale + # render) block even under --allow-unreviewed; only un-passed verdicts + # yield to the flag. See _acceptance_problems for rationale. + hard_problems, waivable_problems = ( + ([], []) if force_export else _acceptance_problems(project_path, ref_files)) + if hard_problems: + print( + f"[ERROR] {len(hard_problems)} blocking acceptance problem(s) — pages " + "never rendered for review, or changed since their last render. " + "Refusing to export; --allow-unreviewed does NOT cover this: rendering " + "is cheap and machine-checkable, there is no reason to ship a page " + "nobody could have seen.", + file=sys.stderr, + ) + for p in hard_problems[:20]: + print(f" - {p}", file=sys.stderr) + if len(hard_problems) > 20: + print(f" ... and {len(hard_problems) - 20} more", file=sys.stderr) + print( + " Fix: run finalize_svg.py, then svg_preview.py " + "(full deck),\n" + " look at every PNG under .build/preview/, fix bad pages and " + "re-render,\n" + " then mark verdicts with accept_pages.py --pass ... " + "(or --pass-all).", + file=sys.stderr, + ) + return 1 + if waivable_problems: if args.allow_unreviewed: print( - f"[WARN] visual acceptance incomplete on {len(acceptance_problems)} " - "page(s) — exporting anyway (--allow-unreviewed).", + f"[WARN] {len(waivable_problems)} page(s) rendered but not marked " + "pass — exporting anyway (--allow-unreviewed).", file=sys.stderr, ) else: print( - "[ERROR] visual acceptance incomplete — refusing to export a deck " - "whose pages were never rendered and reviewed:", + f"[ERROR] {len(waivable_problems)} page(s) rendered but not yet " + "reviewed/passed — refusing to export:", file=sys.stderr, ) - for p in acceptance_problems[:20]: + for p in waivable_problems[:20]: print(f" - {p}", file=sys.stderr) - if len(acceptance_problems) > 20: - print(f" ... and {len(acceptance_problems) - 20} more", + if len(waivable_problems) > 20: + print(f" ... and {len(waivable_problems) - 20} more", file=sys.stderr) print( - " Fix: run finalize_svg.py, then svg_preview.py " - "(full deck),\n" - " look at every PNG under .build/preview/, fix bad pages and " - "re-render,\n" - " then mark verdicts with accept_pages.py --pass ... " - "(or --pass-all).\n" - " Escape hatch (NOT for skipping review): --allow-unreviewed.", + " Look at every PNG under .build/preview/, then mark verdicts " + "with\n" + " accept_pages.py --pass ... (or --pass-all after a full " + "review).", file=sys.stderr, ) return 1