fix(ppt): 门体系二轮硬化——逃生口收紧+导出自动质检+svg_final 嵌图修复(bump 0.36.1)
0.36.0 重跑复盘:门都触发了,但弱模型 8 秒内连按 --allow-iconless + --allow-unreviewed 绕过,质检/渲图验收仍 0 调用,4/25 页错位漏出。修五处: - A 验收门分层:"从没渲过/渲后又改/finalize 前渲的"= 硬问题,任何 CLI flag 不豁免;--allow-unreviewed 只豁免"渲过但没标 pass";运维兜底走 ZCBOT_PPT_FORCE_EXPORT=1 环境变量(不进 --help/SKILL) - B 拔 -s final 雷:图标门永远对 svg_output 源检测(消除 svg_final 展开 后误报"零图标"),wrapper docstring 老示例删除 - C 导出自动质检门:svg_to_pptx 导出前内嵌复跑 quality checker 逐页硬 错误,error 拒绝导出、无豁免参数 - E 几何质检加"文字骑卡片边缘"检测(warn 带坐标,P12/P14/P18 类命中) - F 修 svg_final 嵌图失效:copytree 后 ../images/ 解析必落空,所有 deck 的 svg_final 一直嵌不进外链图(验收 PNG 图片为空);resolve 加 rebase 回 svg_output 兜底 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3c712031d5
commit
fcc158dff6
|
|
@ -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 <tspan>(35%)</tspan>` 是一行不是两段),71 个 charts 模板 0 error 误报、复刻事故的 fixture 全命中。**C 管线顺序+反模式(文档)**——SKILL.md 管线改"后处理→渲图验收→导出"(验收在导出前),阶段五=finalize+全量渲图+逐页过目+标记,阶段六=拆备注+导出(验收门+图标门双硬门);反模式加"没看 PNG 就 --pass-all"和"为消警告脚本批量盲插元素不复看"。SKILL_LIST 同步。已知边界:gate 只能强制"渲过、源没改",看没看 PNG 无法机器验证(--pass-all 仍可被糊弄,但本次事故"从不渲图"的直接通路已封死)。
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/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 素材摄取
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.36.0"
|
||||
__version__ = "0.36.1"
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ references/visual-styles/<locked-style>.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/<locked-style>.md # 锁定的视觉风格
|
|||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
|
||||
# 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新)
|
||||
```
|
||||
- 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须 verdict=pass 且渲图后源未改动**,否则导出 `[ERROR]` 退非零、不产出 pptx(`| head` 绕不过)。被拒就回阶段五补验收/走返工回路;`--allow-unreviewed` 只留给"确实不需要视觉验收"的极端场景,**不是跳过验收的捷径**。
|
||||
- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但全 deck 零 `<use data-icon>` → 同样 `[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 零 `<use data-icon>` → 同样 `[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。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# (<project>/.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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <project_path> -s final
|
||||
python3 scripts/svg_to_pptx.py <project_path>
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 ``<use data-icon>`` 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'<use\b[^>]*\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 "
|
||||
"<project_dir>\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 <project_dir> "
|
||||
"(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 <project_dir> "
|
||||
"(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
|
||||
|
|
|
|||
Loading…
Reference in New Issue