Compare commits
No commits in common. "0e02cff6c6cae647cfad25756a6197d1de2c325e" and "fcc158dff69f7a4179f4a2606236290598849672" have entirely different histories.
0e02cff6c6
...
fcc158dff6
|
|
@ -21,12 +21,6 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 2026-07-03 / ppt 对齐网格锁 + 错位/单调质检(d1285247 陶瓷 deck 复盘,bump 0.37.0)
|
||||
对 d1285247 产物(25 页陶瓷方案 PPTX)逐页几何量测 + PowerPoint COM 渲图目视复盘,三类缺陷:①跨页左基线漂移(0.656–0.75in 七个值)+ 并排块顶差 2–12px 的"想对齐没对齐"(S8/S19/S23);②5 页同为"图标+标题+三行字"卡网格,零流程箭头/零分层图形,单调;③标题语义不兑现("五层架构"画成五条等宽横条、"矩阵"画成卡片格)。根因:executor 手写绝对坐标但 spec_lock 无网格常量可依;质检只查重叠/越界不查对齐;"节奏不雷同"只约束相邻页。修四层:**A spec_lock 新增 `layout_grid` 锁段**(margin_x/content_top/footer_y/gutter,strategist 派生、executor 每页吸附、checker 强制;design_spec_reference §V 同步);**B executor-base §3 网格对齐纪律**(并排卡片同 top 同高等 gutter、打破网格 ≥16px 干净打破、同行文字 ≥0.3em 禁贴字);**C svg_quality_checker 新增 check 14**——兄弟卡片近失对齐(精确几何,2–12px error;底对齐/中心对齐/绘图区内数据柱三类豁免,71 charts 模板回归误报清零)、layout_grid 偏离 2–15px error、行内 gap 不等 warning、无锁存量项目跨页左缘聚类漂移 warning、版式指纹单调门(≥3 页同指纹 warn、≥4 或过半 error;仅对 NN_ 编号 deck 页聚合,模板库静默);**D 策略纪律升级**——同一版式原型整本 ≤2 次 + 标题语义必须被图形兑现(SKILL.md 大纲纪律 + strategist visual-floor GATE)。顺手修 comparison_columns 模板胶囊 5px 错位。新增 tests/test_svg_alignment_check.py 21 项,全量 153 过。已知边界:页面平衡类(底部大空白/重心偏移,S18/S22)误报风险高未进 checker,只进阶段五验收 checklist 眼看;错位 error 会被导出边界自动质检门连带拦截,存量项目重导出若报新 error 属预期(真缺陷)。
|
||||
|
||||
### 2026-07-03 / 进度条自愈:回放层强制单调完成(d1285247 复盘,bump 0.36.2)
|
||||
用户报 task d1285247(ppt生成3)进度条反常:后面步(质检/导出)打绿勾、前面步(摄取素材/配图)却卡红圈"…",顶部"4/6"。诊断脚本 `scripts/diag_progress_d1285247.py` 拉出 `task_progress` 调用序列定位**非渲染 bug**——`progress.js` 忠实回放了模型发的调用:模型每次推进是"标下一步 completed + 再下一步 in_progress"的跳步,**每次都漏给上一次留在 in_progress 的那步补 completed**(s1、s3 被漏),回放到最后就是 `s1=in_progress,s2=completed,s3=in_progress,s4/s5/s6=completed`。根因是模型用工具收尾不稳,纯提示拦不住(与门体系教训同构)。修在**回放层加确定性单调不变量**:`enforceMonotonicProgress`——checklist 线性推进,只要某步 completed,其之前所有步自动视为 completed;`applyProgressAction` 的 set_plan / update_step 两条出口都过一遍,漏发自愈。前端单测加 3 条(含复刻 d1285247 跳步序列 → 6/6)。已知边界:假设步骤线性顺序(现有所有 skill 成立);若将来出现真·并行/乱序 checklist 会被抹平。
|
||||
|
||||
### 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 备注)。
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.37.0"
|
||||
__version__ = "0.36.1"
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
"""Dump the task_progress tool-call sequence for a task (by id prefix). ASCII-only."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
env = Path(__file__).resolve().parent.parent / ".env"
|
||||
for line in env.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith("ZCBOT_DB_URL="):
|
||||
os.environ["ZCBOT_DB_URL"] = line.split("=", 1)[1].strip()
|
||||
from sqlalchemy import create_engine, text # noqa: E402
|
||||
|
||||
engine = create_engine(os.environ["ZCBOT_DB_URL"])
|
||||
prefix = sys.argv[1] if len(sys.argv) > 1 else "d1285247"
|
||||
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("select task_id,name,status,run_status from tasks where task_id::text like :p"),
|
||||
{"p": prefix + "%"},
|
||||
).fetchone()
|
||||
if not row:
|
||||
print("[NO TASK]", prefix)
|
||||
sys.exit(1)
|
||||
tid = row[0]
|
||||
print(f"[TASK] {tid} name={row[1]!r} status={row[2]} run={row[3]}")
|
||||
|
||||
msgs = conn.execute(
|
||||
text("select idx,payload from messages where task_id=:t order by idx"),
|
||||
{"t": tid},
|
||||
).fetchall()
|
||||
print(f"[MESSAGES] {len(msgs)}")
|
||||
|
||||
n = 0
|
||||
for idx, p in msgs:
|
||||
for tc in p.get("tool_calls") or []:
|
||||
fn = tc.get("function") or {}
|
||||
if fn.get("name") != "task_progress":
|
||||
continue
|
||||
n += 1
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
except Exception as e:
|
||||
print(f" [{idx}] PARSE-ERR: {e} raw={fn.get('arguments')!r}")
|
||||
continue
|
||||
act = args.get("action")
|
||||
if act == "set_plan":
|
||||
steps = args.get("steps") or []
|
||||
print(f" [{idx}] set_plan ({len(steps)} steps):")
|
||||
for st in steps:
|
||||
print(f" {st.get('id')!r:8} {st.get('status'):11} {st.get('title')!r}")
|
||||
elif act == "update_step":
|
||||
st = args.get("step") or {}
|
||||
print(f" [{idx}] update_step id={st.get('id')!r} status={st.get('status')!r} title={st.get('title')!r}")
|
||||
else:
|
||||
print(f" [{idx}] {act} {json.dumps(args, ensure_ascii=False)}")
|
||||
print(f"[task_progress calls] {n}")
|
||||
|
|
@ -100,14 +100,14 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
|
|||
|
||||
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
|
||||
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
|
||||
- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表尤其 —— 5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;
|
||||
- **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。**标题语义必须被图形兑现**:标题写"架构"就画层块堆叠(不是等宽横条列表)、写"矩阵"就画真象限(不是卡片网格)、写"流程/层级"就有方向/层次 —— "五层架构"画成五条一样的横条是典型名不副实。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。
|
||||
- **节奏不雷同**:相邻内容页不同版式;narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;
|
||||
- **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。
|
||||
|
||||
大纲连同 a–h **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。
|
||||
|
||||
**确认后产出两份引擎契约**(按骨架填,**只填实际用到的行**):
|
||||
- `<project_dir>/design_spec.md` —— 人读叙事(I–XI 节,见 design_spec_reference.md)
|
||||
- `<project_dir>/spec_lock.md` —— 机读执行锁(canvas/**layout_grid**/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。`layout_grid`(margin_x/content_top/footer_y/gutter)是跨页对齐的锚 —— 手写绝对坐标没有锁定基线必漂,质检会硬卡偏离网格 2–15px 的"想对齐没对齐"。
|
||||
- `<project_dir>/spec_lock.md` —— 机读执行锁(canvas/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。
|
||||
|
||||
> 公式策略 mixed/render-all 且有公式 → 写 `images/formula_manifest.json` 后渲染(ppt-master 的 latex_render 未搬;zcbot 可用现有公式渲染或转图后按 `images` 行登记)。
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
|||
|
||||
**纪律(来自 SKILL 全局 + executor-base,务必遵守)**:
|
||||
1. **逐页串行手写,不批量、不脚本生成**:每页由当前主 agent 在同一上下文里手写 SVG;**禁止写循环脚本批量产 SVG**(跨页视觉一致性靠逐页带上游上下文,生成器做不到),也不要 5 页一组。
|
||||
2. **每页前重读 `spec_lock.md`**:颜色/字体/图标/图片只能来自它;查本页 `page_rhythm`/`page_layouts`/`page_charts`;坐标吸附 `layout_grid`(左缘=margin_x、正文顶=content_top、并排卡片同 top 同高等 gutter,打破网格要 ≥16px 干净地打破,不许差几 px 的"差不多" —— 对齐纪律详见 executor-base §3)。抗上下文压缩漂移。
|
||||
2. **每页前重读 `spec_lock.md`**:颜色/字体/图标/图片只能来自它;查本页 `page_rhythm`/`page_layouts`/`page_charts`。抗上下文压缩漂移。
|
||||
3. **模板供结构不供皮**(非 mirror):继承几何/标签位置/编码逻辑,**重新上 visual_style + spec_lock.colors 的皮**;字号按 spec_lock 角色锁定值,不继承模板占位字号。
|
||||
4. **图标(锁了就必须用,非可选装饰)**:spec_lock 有 `icons.library` + 非空 `inventory` 时,**每个内容页必须放 1–3 个 inventory 内的图标**(KPI/列表/流程/对比/特性网格版式尤其要,常一卡一图标)——自由设计没有模板可继承图标,只能逐页手写 `<use data-icon>` 才有图标。封面/纯排版分节页/单数字·金句 breathing 页/尾页可不放。写法:`<use data-icon="<lib>/<name>" x= y= width= height= fill= [stroke-width=]>`,name 必须在 inventory 内、文件在 `templates/icons/<lib>/`。**质检会硬卡**:锁了 inventory 但全 deck 0 图标 → error 退非零(见阶段四)。
|
||||
5. **配图**:`<image href="../images/<file>">`,croppable 用 `preserveAspectRatio="xMidYMid slice"`,`| no-crop` 行用 `meet`;意图与版式见 image-layout-*。
|
||||
|
|
@ -142,7 +142,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
|||
```
|
||||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
|
||||
```
|
||||
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
||||
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
||||
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。
|
||||
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
|
||||
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
|
||||
|
|
@ -161,7 +161,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
|||
# (有问题的页:--fail <页名> --reason "…";只标部分页:--pass <页名…>;看状态:--status)
|
||||
```
|
||||
- **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。)
|
||||
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、**并排元素顶/底是否对齐、与上一页对比左缘/内容顶线是否一致**(跨页一致性只有连续翻看才看得出)、图标在不在(位置对不对)、节奏是否单调(连续几页同为卡片墙就该返工换形态)、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。
|
||||
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在(位置对不对)、节奏是否单调、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。
|
||||
- 🚧 **差评即阻断 + 返工回路**:任一页有排版/溢出/重叠/半成品问题(哪怕只是封面)→ **改那一页 svg_output 的 SVG → 重跑 finalize → `svg_preview.py <project_dir> --pages <N>` 重渲该页 → 复看 → 再标 pass**。机制会强制这个回路:标 pass 和导出 gate 都校验"渲图之后源文件没再改过"(sha1),改了不重渲重看,gate 过不去。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。
|
||||
- ❌ **禁止盲改**:修错位/补图标不许写脚本批量 regex 插元素、改完不看渲染结果(真实事故来源:质检提示缺图标后 regex 批量盲插,图标全压在文字上交付)。每处修改都要走上面的返工回路落到"复看"。
|
||||
|
||||
|
|
|
|||
|
|
@ -177,11 +177,6 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha
|
|||
|
||||
- **Proximity**: group related elements with tight spacing; separate unrelated groups
|
||||
- **Spec adherence**: follow color, layout, canvas format, and typography in the spec
|
||||
- **Grid & alignment discipline (HARD — checker-enforced)**: hand-written absolute coordinates drift; the fix is snapping, not eyeballing.
|
||||
- **Snap to `layout_grid`**: header title, content blocks, and footer left edges sit exactly at `spec_lock.layout_grid.margin_x`; body content starts at `content_top`; footer baseline at `footer_y`. Never write a "close enough" coordinate (63 when the grid says 60) — the quality checker errors on 2–15px deviations. Breaking the grid on purpose (full-bleed, asymmetric hero) means clearing it by ≥16px, not by a few px.
|
||||
- **Sibling cards align exactly**: cards in one row share the same `y` and `height`; cards in one column share the same `x`; gaps in a row all equal `layout_grid.gutter`. Compute one set of constants per grid (`x = margin_x + i * (card_w + gutter)`) instead of placing each card by feel. Deliberate stagger (masonry) offsets by ≥16px.
|
||||
- **Two-column layouts resolve the bottom edge**: either both columns end at the same y, or the shorter column is deliberately closed (vertical centering, a filler visual, a closing rule) — never one column dangling far above the other with dead whitespace.
|
||||
- **No glued glyphs on one line**: adjacent inline elements (arrow + number, numeral + unit, badge + label) keep ≥0.3em horizontal gap. An arrow touching a digit ("→02") reads as a typo.
|
||||
- **Template structure**: if templates exist, inherit the visual framework
|
||||
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
|
||||
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).
|
||||
|
|
|
|||
|
|
@ -650,8 +650,6 @@ The most common Strategist failure mode is missing the structural half — treat
|
|||
> - Every **content page whose shape matches a catalog Pick clause** MUST get a visual treatment — a `page_charts` entry (chart / infographic template), a `page_layouts` structural template, or an explicit §VII custom-diagram plan (`no-template-match` with the figure described). Text-in-boxes is the fallback only for pages that genuinely carry no structurable shape, and you must be able to name why.
|
||||
> - **`spec_lock.md` MUST NOT ship a content deck with `page_charts` empty AND `page_layouts` empty/free-design AND no §VII custom-diagram rows.** That combination means no visual was assigned anywhere — re-scan §IX and map each content page's shape to a figure (comparison→columns/quadrant, process/历程→timeline/process_flow, cycle/循环→concentric/segmented_wheel, share→donut/pie, trend→line/area, ranking→bar, architecture→layered_architecture, relations→hub_spoke/mind_map).
|
||||
> - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero `<path>`/`<polygon>`/`<polyline>`/`<image>` deck-wide. Leaving the visual plan empty here guarantees that failure later — assign the figures now, at the spec stage, where it is cheapest to change.
|
||||
> - **Archetype cap (anti-monotony)**: the same layout archetype — especially the icon+title+text card grid and the full-width stacked row list — may carry at most **2 pages per deck**, adjacent or not. Five "2×3 icon grid" pages read as the same slide repeated even when the copy differs (real shipped failure). When §IX assigns a third page the same shape, rework it into a different visual form via the mapping above. Downstream: the checker warns at 3 same-fingerprint pages and errors at 4.
|
||||
> - **Title semantics must be honored by the layout**: a page titled 架构/分层 gets stacked layers (NOT equal-width row bars); 矩阵 gets a real 2×2 quadrant (NOT a card grid); 流程 gets a directional flow; 层级 gets a pyramid/tree. Shipping a "五层架构" page drawn as five identical list rows is the canonical semantic mismatch — the title promises a figure the page never draws.
|
||||
|
||||
> **Reading is mandatory; the catalog is a starting point, not a copy target.**
|
||||
> - Fully read `templates/charts/charts_index.json` **before drafting the Eight Confirmations** — the read happens up front, not when you sit down to write Section VII. The file contains `meta` + `charts.<key>.summary` only; each `summary` is a selection rule (`"Pick for … Skip if …"`), not a description. There is **no category, quickLookup, or keyword index** — selection is done by semantically matching each page's content shape against all 71 summaries in one pass.
|
||||
|
|
@ -810,7 +808,7 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
|
|||
1. Read reference template: `templates/design_spec_reference.md`
|
||||
2. Generate complete spec from scratch based on analysis
|
||||
3. Save to: `projects/<project_name>.../design_spec.md`
|
||||
4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **layout_grid** / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative.
|
||||
4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative.
|
||||
- **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`.
|
||||
- **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota.
|
||||
- **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.)
|
||||
|
|
|
|||
|
|
@ -243,12 +243,6 @@ class SVGQualityChecker:
|
|||
self._deck_graphic_total = 0 # path+polyline+polygon+image across deck
|
||||
self._deck_text_total = 0 # <text> across deck (density signal)
|
||||
self._pages_no_graphic: List[str] = [] # pages with zero graphic primitives
|
||||
# Alignment / grid / monotony aggregation (check 14). Cross-page margin
|
||||
# drift and repeated layout archetypes are only visible deck-wide, so
|
||||
# per-page passes record into these and print_summary aggregates.
|
||||
self._grid_locked = False # any spec_lock carried layout_grid
|
||||
self._page_left_edges: Dict[str, float] = {} # page -> primary content left edge
|
||||
self._page_fingerprints: Dict[str, tuple] = {} # page -> layout archetype fingerprint
|
||||
|
||||
def check_file(self, svg_file: str, expected_format: str = None) -> Dict:
|
||||
"""
|
||||
|
|
@ -344,12 +338,6 @@ class SVGQualityChecker:
|
|||
if not self.template_mode:
|
||||
self._check_geometry(content, result)
|
||||
|
||||
# 14. Alignment lint: sibling-card near-miss misalignment,
|
||||
# layout_grid lock enforcement, uneven row gaps, plus
|
||||
# deck-level margin-drift / layout-monotony aggregation.
|
||||
if not self.template_mode:
|
||||
self._check_alignment(content, svg_path, result)
|
||||
|
||||
# Determine pass/fail
|
||||
result['passed'] = len(result['errors']) == 0
|
||||
|
||||
|
|
@ -1091,10 +1079,6 @@ class SVGQualityChecker:
|
|||
return 'viewBox issues'
|
||||
elif 'foreignObject' in error_msg:
|
||||
return 'foreignObject'
|
||||
elif error_msg.startswith('Alignment:'):
|
||||
return 'Alignment/grid'
|
||||
elif error_msg.startswith('Geometry:'):
|
||||
return 'Geometry'
|
||||
elif 'font' in error_msg.lower():
|
||||
return 'Font issues'
|
||||
else:
|
||||
|
|
@ -1864,359 +1848,6 @@ class SVGQualityChecker:
|
|||
f"Geometry: ... and {len(bucket) - len(shown)} more "
|
||||
f"similar issue(s) on this page")
|
||||
|
||||
# ── Alignment / grid / monotony lint (check 14) ──────────────────────
|
||||
# The shipped failures no overlap check sees: sibling cards a few px out
|
||||
# of line ("meant to align, didn't"), content blocks drifting off the
|
||||
# deck's margin line page by page, and the same card/icon-grid archetype
|
||||
# repeated until the deck reads monotone. Rect / icon coordinates are
|
||||
# exact (no width estimation), so near-miss offsets are reliable: a
|
||||
# 2-12px offset between row-mates is virtually never design intent —
|
||||
# deliberate stagger clears 16px — which is why those land error-tier,
|
||||
# unlike the estimated text boxes above.
|
||||
|
||||
_ALIGN_TOL = 2.0 # <= : aligned (authoring rounding slack)
|
||||
_ALIGN_ERR = 12.0 # (tol, err] : hard misalignment → error
|
||||
_ALIGN_INTENT = 16.0 # (err, intent): borderline → warning; >= intent: deliberate
|
||||
_CARD_MIN_W = 60.0 # smaller rects are chips / accent bars, not cards
|
||||
_CARD_MIN_H = 36.0
|
||||
_CLUSTER_TOL = 14.0 # row/col clustering tolerance (fingerprints, gaps)
|
||||
_PAGE_NUM_RE = re.compile(r'^(\d{1,3})[_\-]')
|
||||
|
||||
def _page_rhythm(self, svg_path: Path, lock) -> str:
|
||||
"""Return this page's page_rhythm tag ('' when unknown)."""
|
||||
rhythm = (lock or {}).get('page_rhythm') or {}
|
||||
m = self._PAGE_NUM_RE.match(svg_path.name)
|
||||
if not m:
|
||||
return ''
|
||||
return (rhythm.get(f'P{int(m.group(1)):02d}') or '').strip().lower()
|
||||
|
||||
def _cards_from_rects(self, rects, canvas_w, canvas_h):
|
||||
"""Card-sized visible rects, excluding full-bleed backgrounds."""
|
||||
cards = []
|
||||
for r in rects:
|
||||
w, h = r['x1'] - r['x0'], r['y1'] - r['y0']
|
||||
if w < self._CARD_MIN_W or h < self._CARD_MIN_H:
|
||||
continue
|
||||
if w * h >= 0.85 * canvas_w * canvas_h:
|
||||
continue
|
||||
cards.append(r)
|
||||
return cards
|
||||
|
||||
@staticmethod
|
||||
def _similar_size(a, b, rel=0.2, floor=8.0):
|
||||
return abs(a - b) <= max(floor, rel * max(a, b))
|
||||
|
||||
@staticmethod
|
||||
def _cluster_values(values, tol):
|
||||
"""Cluster sorted scalars; returns list of cluster-center floats."""
|
||||
centers = []
|
||||
for v in sorted(values):
|
||||
if centers and v - centers[-1][-1] <= tol:
|
||||
centers[-1].append(v)
|
||||
else:
|
||||
centers.append([v])
|
||||
return [sum(c) / len(c) for c in centers]
|
||||
|
||||
def _layout_fingerprint(self, cards, icons):
|
||||
"""Classify the page's dominant grid archetype, or None.
|
||||
|
||||
icon-grid: >=4 icons arranged in a >=2x2 grid (the icon+title+text
|
||||
card pattern — usually no visible card rect, so icons carry the
|
||||
structure). card-grid: >=4 similar-sized card rects in a grid, or a
|
||||
>=4-row single-column stack (full-width list rows). Pages without a
|
||||
dominant grid (covers, chapters, diagrams, timelines) get None and
|
||||
never count toward monotony.
|
||||
"""
|
||||
if len(icons) >= 4:
|
||||
rows = self._cluster_values([i['y0'] for i in icons], self._CLUSTER_TOL)
|
||||
cols = self._cluster_values([i['x0'] for i in icons], self._CLUSTER_TOL)
|
||||
if len(rows) >= 2 and len(cols) >= 2 \
|
||||
and len(rows) * len(cols) <= len(icons) + 2:
|
||||
return ('icon-grid', len(rows), len(cols))
|
||||
# Consider only the largest similar-size card family on the page.
|
||||
if len(cards) >= 4:
|
||||
fam = max(
|
||||
([c for c in cards
|
||||
if self._similar_size(c['x1'] - c['x0'], k['x1'] - k['x0'])
|
||||
and self._similar_size(c['y1'] - c['y0'], k['y1'] - k['y0'])]
|
||||
for k in cards),
|
||||
key=len,
|
||||
)
|
||||
if len(fam) >= 4:
|
||||
rows = self._cluster_values([c['y0'] for c in fam], self._CLUSTER_TOL)
|
||||
cols = self._cluster_values([c['x0'] for c in fam], self._CLUSTER_TOL)
|
||||
if len(rows) * len(cols) <= len(fam) + 2 \
|
||||
and (min(len(rows), len(cols)) >= 2
|
||||
or (len(cols) == 1 and len(rows) >= 4)):
|
||||
return ('card-grid', len(rows), len(cols))
|
||||
return None
|
||||
|
||||
def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> None:
|
||||
"""Sibling alignment + layout_grid enforcement + deck aggregation."""
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except ET.ParseError:
|
||||
return
|
||||
vb = re.search(r'viewBox="([^"]+)"', content)
|
||||
if not vb:
|
||||
return
|
||||
parts = vb.group(1).split()
|
||||
if len(parts) != 4:
|
||||
return
|
||||
canvas_w, canvas_h = float(parts[2]), float(parts[3])
|
||||
|
||||
texts, icons, rects = self._collect_geometry(root)
|
||||
cards = self._cards_from_rects(rects, canvas_w, canvas_h)
|
||||
|
||||
# Chart plot areas (the mandatory §3.1 marker): rects inside them are
|
||||
# data marks (bars / boxes) whose offsets encode values, not layout —
|
||||
# exclude them from every alignment check.
|
||||
plot_areas = [
|
||||
tuple(map(float, m.groups()))
|
||||
for m in re.finditer(
|
||||
r'chart-plot-area:\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)',
|
||||
content)
|
||||
]
|
||||
if plot_areas:
|
||||
cards = [
|
||||
c for c in cards
|
||||
if not any(px0 <= (c['x0'] + c['x1']) / 2 <= px1
|
||||
and py0 <= (c['y0'] + c['y1']) / 2 <= py1
|
||||
for px0, py0, px1, py1 in plot_areas)
|
||||
]
|
||||
|
||||
errors: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
def near_equal(u, v, tol=None):
|
||||
return abs(u - v) <= (self._ALIGN_TOL if tol is None else tol)
|
||||
|
||||
def cross_match(u, v):
|
||||
# Cross-axis dimension near-equality: true sibling cards share the
|
||||
# dimension perpendicular to their run (heights in a row, widths in
|
||||
# a column). Data bars / featured-emphasis cards differ more.
|
||||
return abs(u - v) <= max(6.0, 0.04 * max(u, v))
|
||||
|
||||
# 1. Sibling near-miss misalignment (exact geometry → error tier).
|
||||
# A pair only errs when NO alignment scheme fits: leading edges,
|
||||
# centers, and trailing edges all disagree. Shared centers (tree
|
||||
# nodes on an axis, symmetric emphasis growth) or shared trailing
|
||||
# edges (baseline-anchored elements) mean the offset is a scheme,
|
||||
# not an accident.
|
||||
for i in range(len(cards)):
|
||||
for j in range(i + 1, len(cards)):
|
||||
a, b = cards[i], cards[j]
|
||||
aw, ah = a['x1'] - a['x0'], a['y1'] - a['y0']
|
||||
bw, bh = b['x1'] - b['x0'], b['y1'] - b['y0']
|
||||
v_overlap = min(a['y1'], b['y1']) - max(a['y0'], b['y0'])
|
||||
h_overlap = min(a['x1'], b['x1']) - max(a['x0'], b['x0'])
|
||||
# Row-mates: share a horizontal band, horizontally disjoint.
|
||||
if (cross_match(ah, bh) and v_overlap >= 0.6 * min(ah, bh)
|
||||
and h_overlap <= self._ALIGN_TOL):
|
||||
dy = abs(a['y0'] - b['y0'])
|
||||
aligned_otherwise = (
|
||||
near_equal(a['y1'], b['y1'])
|
||||
or near_equal((a['y0'] + a['y1']) / 2,
|
||||
(b['y0'] + b['y1']) / 2))
|
||||
if dy <= self._ALIGN_TOL or aligned_otherwise:
|
||||
if dy <= self._ALIGN_TOL and self._similar_size(aw, bw):
|
||||
dh = abs(ah - bh)
|
||||
if self._ALIGN_TOL < dh:
|
||||
warnings.append(
|
||||
f"row-mate cards at x={a['x0']:.0f} and "
|
||||
f"x={b['x0']:.0f} share a top but differ "
|
||||
f"{dh:.0f}px in height — equalize or make the "
|
||||
f"difference deliberate (>=16px)")
|
||||
elif dy <= self._ALIGN_ERR:
|
||||
errors.append(
|
||||
f"row-mate cards at x={a['x0']:.0f} and x={b['x0']:.0f} have "
|
||||
f"tops {dy:.0f}px apart (y={a['y0']:.0f} vs {b['y0']:.0f}) — "
|
||||
f"meant to align; snap to one y")
|
||||
elif dy < self._ALIGN_INTENT:
|
||||
warnings.append(
|
||||
f"row-mate cards at x={a['x0']:.0f} and x={b['x0']:.0f} have "
|
||||
f"tops {dy:.0f}px apart — deliberate stagger should clear "
|
||||
f"{self._ALIGN_INTENT:.0f}px")
|
||||
# Column-mates: share a vertical band, vertically disjoint.
|
||||
elif (cross_match(aw, bw) and h_overlap >= 0.6 * min(aw, bw)
|
||||
and v_overlap <= self._ALIGN_TOL):
|
||||
dx = abs(a['x0'] - b['x0'])
|
||||
aligned_otherwise = (
|
||||
near_equal(a['x1'], b['x1'])
|
||||
or near_equal((a['x0'] + a['x1']) / 2,
|
||||
(b['x0'] + b['x1']) / 2))
|
||||
if dx <= self._ALIGN_TOL or aligned_otherwise:
|
||||
pass
|
||||
elif dx <= self._ALIGN_ERR:
|
||||
errors.append(
|
||||
f"column-mate cards at y={a['y0']:.0f} and y={b['y0']:.0f} have "
|
||||
f"left edges {dx:.0f}px apart (x={a['x0']:.0f} vs {b['x0']:.0f}) "
|
||||
f"— meant to align; snap to one x")
|
||||
elif dx < self._ALIGN_INTENT:
|
||||
warnings.append(
|
||||
f"column-mate cards at y={a['y0']:.0f} and y={b['y0']:.0f} have "
|
||||
f"left edges {dx:.0f}px apart — deliberate indent should clear "
|
||||
f"{self._ALIGN_INTENT:.0f}px")
|
||||
|
||||
# 2. Uneven gaps in a >=3-card row. Only flag near-equal-but-not-equal
|
||||
# spreads; a 2+1 grouping (gap spread comparable to the gap itself)
|
||||
# is design intent, not drift.
|
||||
top_groups: Dict[float, list] = {}
|
||||
for c in cards:
|
||||
for k in top_groups:
|
||||
if abs(c['y0'] - k) <= self._CLUSTER_TOL:
|
||||
top_groups[k].append(c)
|
||||
break
|
||||
else:
|
||||
top_groups[c['y0']] = [c]
|
||||
for row in top_groups.values():
|
||||
if len(row) < 3:
|
||||
continue
|
||||
row.sort(key=lambda r: r['x0'])
|
||||
gaps = [row[k + 1]['x0'] - row[k]['x1'] for k in range(len(row) - 1)]
|
||||
if any(g < -self._ALIGN_TOL for g in gaps):
|
||||
continue # overlapping/nested — not a simple row
|
||||
spread = max(gaps) - min(gaps)
|
||||
if 4.0 < spread and spread < 0.35 * max(gaps):
|
||||
warnings.append(
|
||||
f"{len(row)}-card row at y={row[0]['y0']:.0f} has uneven gaps "
|
||||
f"({', '.join(f'{g:.0f}' for g in gaps)}px) — equalize to one gutter")
|
||||
|
||||
# 3. layout_grid lock enforcement (spec-declared baselines → error on
|
||||
# near-miss deviation; clean break >=16px is allowed by contract).
|
||||
lock = self._get_spec_lock(svg_path)
|
||||
grid = (lock or {}).get('layout_grid') or {}
|
||||
margin_x = self._f(grid.get('margin_x'))
|
||||
content_top = self._f(grid.get('content_top'))
|
||||
footer_y = self._f(grid.get('footer_y'))
|
||||
rhythm = self._page_rhythm(svg_path, lock)
|
||||
structural = rhythm == 'anchor'
|
||||
if margin_x is not None:
|
||||
self._grid_locked = True
|
||||
if margin_x is not None and not structural:
|
||||
seen = set()
|
||||
for el, x0 in ([(f"card at y={c['y0']:.0f}", c['x0']) for c in cards]
|
||||
+ [(f"text \"{t['label']}\"", t['x0'])
|
||||
for t in texts if t['exact_left']]):
|
||||
dev = abs(x0 - margin_x)
|
||||
key = round(x0)
|
||||
if self._ALIGN_TOL < dev < self._ALIGN_INTENT and key not in seen:
|
||||
seen.add(key)
|
||||
errors.append(
|
||||
f"{el} sits at x={x0:.0f}, {dev:.0f}px off the locked "
|
||||
f"margin_x={margin_x:.0f} — snap to the grid or clear it by >=16px")
|
||||
if content_top is not None and not structural:
|
||||
seen = set()
|
||||
for el, y0 in ([(f"card at x={c['x0']:.0f}", c['y0']) for c in cards]
|
||||
+ [(f"icon {i['label']}", i['y0']) for i in icons]):
|
||||
dev = abs(y0 - content_top)
|
||||
key = round(y0)
|
||||
if self._ALIGN_TOL < dev < self._ALIGN_INTENT and key not in seen:
|
||||
seen.add(key)
|
||||
errors.append(
|
||||
f"{el} starts at y={y0:.0f}, {dev:.0f}px off the locked "
|
||||
f"content_top={content_top:.0f} — snap to the grid or clear it "
|
||||
f"by >=16px")
|
||||
if footer_y is not None and not structural:
|
||||
for t in texts:
|
||||
dev = abs(t['baseline'] - footer_y)
|
||||
if self._ALIGN_TOL < dev < self._ALIGN_INTENT:
|
||||
errors.append(
|
||||
f"text \"{t['label']}\" baseline y={t['baseline']:.0f} is "
|
||||
f"{dev:.0f}px off the locked footer_y={footer_y:.0f}")
|
||||
break
|
||||
|
||||
# 4. Deck aggregation: primary left edge (margin-drift fallback when no
|
||||
# layout_grid is locked) + layout-archetype fingerprint (monotony).
|
||||
# Only numbered deck pages (NN_*.svg) participate — a directory of
|
||||
# standalone template/chart SVGs is not a deck, and aggregating
|
||||
# across unrelated files produces meaningless drift/monotony noise.
|
||||
if not structural and self._PAGE_NUM_RE.match(svg_path.name):
|
||||
edge_candidates = ([c['x0'] for c in cards]
|
||||
+ [t['x0'] for t in texts if t['exact_left']])
|
||||
edge_candidates = [x for x in edge_candidates if 0 < x < 0.25 * canvas_w]
|
||||
if len(texts) >= 4 and edge_candidates:
|
||||
self._page_left_edges[svg_path.name] = min(edge_candidates)
|
||||
fp = self._layout_fingerprint(cards, icons)
|
||||
if fp:
|
||||
self._page_fingerprints[svg_path.name] = fp
|
||||
|
||||
for bucket, dest in ((errors, result['errors']), (warnings, result['warnings'])):
|
||||
shown = bucket[:self._GEOM_MAX_REPORTS]
|
||||
dest.extend(f"Alignment: {m}" for m in shown)
|
||||
if len(bucket) > len(shown):
|
||||
dest.append(
|
||||
f"Alignment: ... and {len(bucket) - len(shown)} more "
|
||||
f"similar issue(s) on this page")
|
||||
|
||||
def _print_alignment_summary(self):
|
||||
"""Deck-level margin-drift fallback + layout-monotony gate.
|
||||
|
||||
Margin drift: without a layout_grid lock there is no declared baseline,
|
||||
so cluster each content page's primary left edge — several distinct
|
||||
values within 16px of each other is the drift signature ("meant to be
|
||||
one margin line"), warning-tier only (legacy decks must not hard-fail).
|
||||
Monotony: >=3 content pages sharing one grid fingerprint → warning;
|
||||
>=4 or over half the deck → error (the user-visible "every page is
|
||||
the same card wall" pathology). Same-family (any dims) repetition is
|
||||
an advisory nudge. Short decks (<6 pages) exempt, like the flat gate.
|
||||
"""
|
||||
pages = self._deck_page_count
|
||||
# Margin-drift fallback (only when no layout_grid was declared).
|
||||
if not self._grid_locked and len(self._page_left_edges) >= 4:
|
||||
centers = self._cluster_values(
|
||||
self._page_left_edges.values(), self._ALIGN_TOL)
|
||||
drifting = [
|
||||
(a, b) for i, a in enumerate(centers) for b in centers[i + 1:]
|
||||
if self._ALIGN_TOL < b - a < self._ALIGN_INTENT
|
||||
]
|
||||
if drifting:
|
||||
self.summary['warnings'] += 1
|
||||
vals = sorted({round(v) for pair in drifting for v in pair})
|
||||
pages_by_edge = ', '.join(
|
||||
f"{name}@{edge:.0f}" for name, edge
|
||||
in sorted(self._page_left_edges.items())
|
||||
if any(abs(edge - v) <= self._ALIGN_TOL for v in vals))
|
||||
print(f"\n[WARN] Margin drift: content left edges cluster at "
|
||||
f"{vals}px across pages — meant to be one margin line. "
|
||||
f"Lock layout_grid in spec_lock.md and snap pages to it.")
|
||||
print(f" ({pages_by_edge})")
|
||||
# Layout monotony gate.
|
||||
if pages < 6 or not self._page_fingerprints:
|
||||
return
|
||||
by_fp: Dict[tuple, list] = defaultdict(list)
|
||||
by_family: Dict[str, list] = defaultdict(list)
|
||||
for name, fp in self._page_fingerprints.items():
|
||||
by_fp[fp].append(name)
|
||||
by_family[fp[0]].append(name)
|
||||
fp, members = max(by_fp.items(), key=lambda kv: len(kv[1]))
|
||||
n = len(members)
|
||||
label = f"{fp[1]}x{fp[2]} {fp[0]}"
|
||||
_fix = ("Rework all but 1-2 of them into a different visual form — "
|
||||
"timeline / layered architecture / quadrant / process flow / "
|
||||
"hub-spoke / chart (templates/charts/) — per the content->layout "
|
||||
"mapping in strategist.md; also check spec_lock page_layouts.")
|
||||
if n >= 4 or (n >= 3 and n > 0.5 * pages):
|
||||
self.summary['errors'] += 1
|
||||
print(f"\n[ERROR] Layout monotony: {n} pages share the same {label} "
|
||||
f"archetype ({', '.join(sorted(members))}) — the deck reads as "
|
||||
f"the same card wall repeated.")
|
||||
print(" " + _fix)
|
||||
elif n >= 3:
|
||||
self.summary['warnings'] += 1
|
||||
print(f"\n[WARN] Layout monotony: {n} pages share the same {label} "
|
||||
f"archetype ({', '.join(sorted(members))}) — consider reworking "
|
||||
f"at least {n - 2} of them.")
|
||||
print(" " + _fix)
|
||||
else:
|
||||
fam, fam_members = max(by_family.items(), key=lambda kv: len(kv[1]))
|
||||
if len(fam_members) >= 4:
|
||||
self.summary['warnings'] += 1
|
||||
print(f"\n[WARN] Layout monotony: {len(fam_members)} pages are all "
|
||||
f"{fam} layouts ({', '.join(sorted(fam_members))}) — vary the "
|
||||
f"visual form even if the grid dims differ.")
|
||||
print(" " + _fix)
|
||||
|
||||
def _print_graphic_summary(self):
|
||||
"""Deck-level flat-deck gate.
|
||||
|
||||
|
|
@ -2290,9 +1921,6 @@ class SVGQualityChecker:
|
|||
# Deck-level flat-deck gate (text-on-rectangles, no diagrams/figures).
|
||||
self._print_graphic_summary()
|
||||
|
||||
# Deck-level margin-drift fallback + layout-monotony gate.
|
||||
self._print_alignment_summary()
|
||||
|
||||
# Fix suggestions
|
||||
if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
|
||||
print(f"\n[TIP] Common fixes:")
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@
|
|||
<text x="508" y="445" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#8B5CF6" font-weight="700">✓</tspan><tspan x="532" fill="#334155">Custom Workflows</tspan></text>
|
||||
<text x="508" y="480" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#CBD5E1" font-weight="700">—</tspan><tspan x="532" fill="#CBD5E1">Dedicated Account Manager</tspan></text>
|
||||
<text x="508" y="515" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#CBD5E1" font-weight="700">—</tspan><tspan x="532" fill="#CBD5E1">SLA Guarantee</tspan></text>
|
||||
<rect x="540" y="560" width="200" height="44" rx="22" fill="#8B5CF6"/>
|
||||
<text x="640" y="587" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
<rect x="540" y="555" width="200" height="44" rx="22" fill="#8B5CF6"/>
|
||||
<text x="640" y="582" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
font-size="14" font-weight="700" fill="#FFFFFF"><tspan>Upgrade Now</tspan></text>
|
||||
</g>
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -165,7 +165,6 @@ Two views on the same font decisions — fill both, keep them consistent:
|
|||
- **Header area**: [Height and content description]
|
||||
- **Content area**: [Height and content description]
|
||||
- **Footer area**: [Height and content description]
|
||||
- **Layout grid**: [margin_x / content_top / footer_y / gutter in canvas px — the deck-wide alignment constants. Copy these verbatim into `spec_lock.md ## layout_grid`; the Executor snaps every content page to them and the quality checker errors on near-miss deviations (2–15px off). Hand-written coordinates drift across pages without this anchor.]
|
||||
|
||||
### Layout Pattern Library (combine or break as content demands)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,22 +12,7 @@
|
|||
|
||||
> Strategist: fill viewBox and format for the chosen canvas. Common values: `0 0 1280 720` (PPT 16:9), `0 0 1024 768` (PPT 4:3), `0 0 1242 1660` (Xiaohongshu), `0 0 1080 1080` (WeChat Moments), `0 0 1080 1920` (Story).
|
||||
|
||||
## layout_grid
|
||||
- margin_x: 60
|
||||
- content_top: 150
|
||||
- footer_y: 688
|
||||
- gutter: 24
|
||||
|
||||
> Deck-wide layout constants, in canvas px. Executor snaps every content page to these; `svg_quality_checker.py` hard-checks them (a content block whose left edge lands 2–15px off `margin_x` is an error — "meant to align, didn't"). This section exists because hand-written absolute coordinates drift across pages without a locked baseline — the #1 source of cross-page misalignment.
|
||||
>
|
||||
> - `margin_x` — left/right content margin. Header title, content blocks, and footer on content pages all share this left edge.
|
||||
> - `content_top` — top edge of the body content zone on content pages (below the header band). Structural pages (cover / chapter / TOC / ending) are exempt.
|
||||
> - `footer_y` — footer baseline. Omit if the deck has no footer.
|
||||
> - `gutter` — gap between side-by-side sibling cards in a row/grid. One value deck-wide.
|
||||
>
|
||||
> Strategist derives values from canvas + visual_style (defaults above suit 1280×720; scale proportionally for other canvases — e.g. 4:3 `margin_x: 50`). A page may deliberately break the grid (full-bleed image, asymmetric breathing page) — breaking means clearing it by ≥16px or going full-bleed, never a few-px "almost".
|
||||
>
|
||||
> **Missing section** → legacy deck; checker falls back to cross-page clustering (warning-tier only). New decks MUST fill it.
|
||||
## mode
|
||||
- mode: pyramid
|
||||
|
||||
> Strategist: the deck's narrative skeleton, locked at confirmation `d` Layer 1. One of `pyramid` / `narrative` / `instructional` / `showcase` / `briefing` — see [`references/modes/_index.md`](../references/modes/_index.md). Executor reads only the locked mode's file. Deck-wide. Or the literal `custom` for a bespoke direction no preset captures (a special cadence, a multi-mode fusion, a particular posture) — user-requested or Strategist-recommended (user confirms, like every lock). Then add a sibling `- mode_behavior:` paragraph (how the argument advances, title voice, page rhythm, register) that the Executor follows in place of a preset file. One deck locks one value; don't default to `custom` when a preset fits.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import test from "node:test";
|
|||
|
||||
import {
|
||||
applyProgressAction,
|
||||
enforceMonotonicProgress,
|
||||
progressActionsFromToolCalls,
|
||||
} from "../web/static/js/progress.js";
|
||||
|
||||
|
|
@ -50,70 +49,3 @@ test("tool calls can apply progress updates on top of previous task progress", (
|
|||
{ id: "s2", title: "实现功能", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a completed step force-completes earlier dangling steps (monotonic heal)", () => {
|
||||
const steps = [
|
||||
{ id: "s1", title: "摄取素材", status: "in_progress" },
|
||||
{ id: "s2", title: "策略", status: "completed" },
|
||||
{ id: "s3", title: "配图", status: "pending" },
|
||||
];
|
||||
assert.deepEqual(enforceMonotonicProgress(steps), [
|
||||
{ id: "s1", title: "摄取素材", status: "completed" },
|
||||
{ id: "s2", title: "策略", status: "completed" },
|
||||
{ id: "s3", title: "配图", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("update_step marking a later step completed heals the earlier in_progress step", () => {
|
||||
const initial = applyProgressAction([], {
|
||||
action: "set_plan",
|
||||
steps: [
|
||||
{ id: "s1", title: "摄取素材", status: "in_progress" },
|
||||
{ id: "s2", title: "策略", status: "pending" },
|
||||
{ id: "s3", title: "配图", status: "pending" },
|
||||
],
|
||||
});
|
||||
|
||||
const updated = applyProgressAction(initial, {
|
||||
action: "update_step",
|
||||
step: { id: "s2", status: "completed" },
|
||||
});
|
||||
|
||||
assert.deepEqual(updated, [
|
||||
{ id: "s1", title: "摄取素材", status: "completed" },
|
||||
{ id: "s2", title: "策略", status: "completed" },
|
||||
{ id: "s3", title: "配图", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
// Replay the exact d1285247 task_progress sequence that produced the reported
|
||||
// "green check below, red dot above" bug; the heal must yield a clean 6/6.
|
||||
test("replays the d1285247 skip-ahead sequence to a fully completed plan", () => {
|
||||
const call = (args) => ({
|
||||
function: { name: "task_progress", arguments: JSON.stringify(args) },
|
||||
});
|
||||
const seq = [
|
||||
call({
|
||||
action: "set_plan",
|
||||
steps: [
|
||||
{ id: "s1", title: "摄取素材", status: "in_progress" },
|
||||
{ id: "s2", title: "策略", status: "pending" },
|
||||
{ id: "s3", title: "配图", status: "pending" },
|
||||
{ id: "s4", title: "执行", status: "pending" },
|
||||
{ id: "s5", title: "质检", status: "pending" },
|
||||
{ id: "s6", title: "导出", status: "pending" },
|
||||
],
|
||||
}),
|
||||
call({ action: "update_step", step: { id: "s2", status: "completed" } }),
|
||||
call({ action: "update_step", step: { id: "s3", status: "in_progress" } }),
|
||||
call({ action: "update_step", step: { id: "s4", status: "completed" } }),
|
||||
call({ action: "update_step", step: { id: "s5", status: "in_progress" } }),
|
||||
call({ action: "update_step", step: { id: "s5", status: "completed" } }),
|
||||
call({ action: "update_step", step: { id: "s6", status: "completed" } }),
|
||||
];
|
||||
|
||||
const { steps } = progressActionsFromToolCalls(seq, []);
|
||||
assert.deepEqual(steps.map(s => s.status), [
|
||||
"completed", "completed", "completed", "completed", "completed", "completed",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,240 +0,0 @@
|
|||
"""svg_quality_checker 对齐/网格/单调检查(check 14)的 focused tests。
|
||||
|
||||
合成 SVG 三类病灶:兄弟卡片差几 px 不齐、内容块偏离 layout_grid 锁、
|
||||
同一卡网格原型重复 —— 对应 d1285247 陶瓷 deck 复盘出的真实缺陷。
|
||||
纯几何逻辑,不依赖渲染器。
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
SCRIPTS = Path(__file__).parent.parent / "skills" / "ppt" / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from svg_quality_checker import SVGQualityChecker # noqa: E402
|
||||
|
||||
|
||||
def _svg(body: str) -> str:
|
||||
return ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" '
|
||||
'width="1280" height="720">' + body + '</svg>')
|
||||
|
||||
|
||||
def _card(x, y, w=200, h=120):
|
||||
return (f'<rect x="{x}" y="{y}" width="{w}" height="{h}" '
|
||||
f'fill="#FFFFFF" stroke="#333333"/>')
|
||||
|
||||
|
||||
def _icon(x, y, name="home"):
|
||||
return (f'<use data-icon="tabler-outline/{name}" x="{x}" y="{y}" '
|
||||
f'width="32" height="32" fill="#C00000"/>')
|
||||
|
||||
|
||||
def _text(x, y, s="标签"):
|
||||
return f'<text x="{x}" y="{y}" font-size="16" fill="#333333">{s}</text>'
|
||||
|
||||
|
||||
def _write_page(project: Path, name: str, body: str) -> Path:
|
||||
out = project / "svg_output"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
p = out / name
|
||||
p.write_text(_svg(body), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _alignment_errors(result):
|
||||
return [e for e in result["errors"] if e.startswith("Alignment:")]
|
||||
|
||||
|
||||
def _alignment_warnings(result):
|
||||
return [w for w in result["warnings"] if w.startswith("Alignment:")]
|
||||
|
||||
|
||||
class SiblingAlignmentTests(unittest.TestCase):
|
||||
def _check(self, body: str):
|
||||
with TemporaryDirectory() as tmp:
|
||||
page = _write_page(Path(tmp), "03_content.svg", body)
|
||||
return SVGQualityChecker().check_file(str(page))
|
||||
|
||||
def test_row_mates_offset_6px_is_error(self):
|
||||
r = self._check(_card(100, 200) + _card(340, 206))
|
||||
errs = _alignment_errors(r)
|
||||
self.assertTrue(any("row-mate" in e for e in errs), errs)
|
||||
|
||||
def test_row_mates_exact_align_passes(self):
|
||||
r = self._check(_card(100, 200) + _card(340, 200))
|
||||
self.assertEqual(_alignment_errors(r), [])
|
||||
self.assertEqual(_alignment_warnings(r), [])
|
||||
|
||||
def test_deliberate_stagger_24px_passes(self):
|
||||
r = self._check(_card(100, 200) + _card(340, 224))
|
||||
self.assertEqual(_alignment_errors(r), [])
|
||||
self.assertEqual(_alignment_warnings(r), [])
|
||||
|
||||
def test_column_mates_offset_5px_is_error(self):
|
||||
r = self._check(_card(100, 200) + _card(105, 360))
|
||||
errs = _alignment_errors(r)
|
||||
self.assertTrue(any("column-mate" in e for e in errs), errs)
|
||||
|
||||
def test_row_mates_height_mismatch_warns(self):
|
||||
r = self._check(_card(100, 200, h=120) + _card(340, 200, h=125))
|
||||
warns = _alignment_warnings(r)
|
||||
self.assertTrue(any("height" in w for w in warns), warns)
|
||||
|
||||
def test_center_aligned_pair_exempt(self):
|
||||
# 树节点宽度不同但中心同轴(640):左缘差 10px 是居中方案,不是事故
|
||||
r = self._check(_card(540, 100, w=200, h=90) + _card(550, 300, w=180, h=90))
|
||||
self.assertEqual(_alignment_errors(r), [])
|
||||
|
||||
def test_baseline_anchored_pair_exempt(self):
|
||||
# 底对齐、顶差 5px(数据柱形态):存在对齐方案,不报
|
||||
r = self._check(_card(100, 200, h=120) + _card(340, 205, h=115))
|
||||
self.assertEqual(_alignment_errors(r), [])
|
||||
|
||||
def test_plot_area_bars_excluded(self):
|
||||
# 绘图区标记内的"柱子"错位不报(值编码,非版式)
|
||||
body = ('<!-- chart-plot-area: 50,100,1200,600 -->'
|
||||
+ _card(100, 200) + _card(340, 206))
|
||||
r = self._check(body)
|
||||
self.assertEqual(_alignment_errors(r), [])
|
||||
|
||||
def test_uneven_row_gaps_warn(self):
|
||||
# gaps 30 / 36 / 30 —— 近等不等,该报;2+1 分组的大差距不报
|
||||
body = (_card(60, 300) + _card(290, 300)
|
||||
+ _card(526, 300) + _card(756, 300))
|
||||
r = self._check(body)
|
||||
warns = _alignment_warnings(r)
|
||||
self.assertTrue(any("uneven gaps" in w for w in warns), warns)
|
||||
|
||||
def test_grouped_row_large_gap_spread_passes(self):
|
||||
# gaps 30 / 30 / 120 —— 明显是分组设计,不报
|
||||
body = (_card(60, 300) + _card(290, 300)
|
||||
+ _card(520, 300) + _card(840, 300))
|
||||
r = self._check(body)
|
||||
self.assertFalse(
|
||||
any("uneven gaps" in w for w in _alignment_warnings(r)))
|
||||
|
||||
|
||||
class LayoutGridLockTests(unittest.TestCase):
|
||||
def _check(self, body: str, lock: str, name="03_content.svg"):
|
||||
with TemporaryDirectory() as tmp:
|
||||
project = Path(tmp)
|
||||
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
|
||||
page = _write_page(project, name, body)
|
||||
return SVGQualityChecker().check_file(str(page))
|
||||
|
||||
LOCK = "## layout_grid\n- margin_x: 60\n- content_top: 150\n"
|
||||
|
||||
def test_card_6px_off_margin_is_error(self):
|
||||
r = self._check(_card(66, 150), self.LOCK)
|
||||
errs = _alignment_errors(r)
|
||||
self.assertTrue(any("margin_x" in e for e in errs), errs)
|
||||
|
||||
def test_card_on_grid_passes(self):
|
||||
r = self._check(_card(60, 150), self.LOCK)
|
||||
self.assertEqual(_alignment_errors(r), [])
|
||||
|
||||
def test_clean_break_over_16px_passes(self):
|
||||
r = self._check(_card(100, 200), self.LOCK)
|
||||
self.assertEqual(_alignment_errors(r), [])
|
||||
|
||||
def test_icon_8px_off_content_top_is_error(self):
|
||||
r = self._check(_icon(60, 158), self.LOCK)
|
||||
errs = _alignment_errors(r)
|
||||
self.assertTrue(any("content_top" in e for e in errs), errs)
|
||||
|
||||
def test_anchor_rhythm_page_exempt(self):
|
||||
lock = self.LOCK + "\n## page_rhythm\n- P01: anchor\n"
|
||||
r = self._check(_card(66, 150), lock, name="01_cover.svg")
|
||||
self.assertEqual(_alignment_errors(r), [])
|
||||
|
||||
|
||||
class DeckAggregationTests(unittest.TestCase):
|
||||
def _run_deck(self, pages: dict, lock: str = None):
|
||||
"""pages: {filename: body}; 返回 (checker, print_summary 输出)。"""
|
||||
with TemporaryDirectory() as tmp:
|
||||
project = Path(tmp)
|
||||
if lock is not None:
|
||||
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
|
||||
checker = SVGQualityChecker()
|
||||
for name, body in sorted(pages.items()):
|
||||
page = _write_page(project, name, body)
|
||||
checker.check_file(str(page))
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
checker.print_summary()
|
||||
return checker, buf.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _content_page(margin: float) -> str:
|
||||
return "".join(_text(margin, 100 + 40 * i, f"第{i}行") for i in range(4))
|
||||
|
||||
def test_margin_drift_across_pages_warns(self):
|
||||
pages = {
|
||||
"01_a.svg": self._content_page(60),
|
||||
"02_b.svg": self._content_page(63),
|
||||
"03_c.svg": self._content_page(66),
|
||||
"04_d.svg": self._content_page(60),
|
||||
}
|
||||
_, out = self._run_deck(pages)
|
||||
self.assertIn("Margin drift", out)
|
||||
|
||||
def test_consistent_margin_no_drift_warning(self):
|
||||
pages = {f"{i:02d}_p.svg": self._content_page(60) for i in range(1, 5)}
|
||||
_, out = self._run_deck(pages)
|
||||
self.assertNotIn("Margin drift", out)
|
||||
|
||||
def test_locked_grid_disables_drift_fallback(self):
|
||||
pages = {
|
||||
"01_a.svg": self._content_page(60),
|
||||
"02_b.svg": self._content_page(63),
|
||||
"03_c.svg": self._content_page(66),
|
||||
"04_d.svg": self._content_page(60),
|
||||
}
|
||||
# margin 值刻意与页面无关:fallback 该关闭,页级 error 才是出口
|
||||
_, out = self._run_deck(pages, lock="## layout_grid\n- margin_x: 60\n")
|
||||
self.assertNotIn("Margin drift", out)
|
||||
|
||||
@staticmethod
|
||||
def _icon_grid_page() -> str:
|
||||
body = ""
|
||||
for r in range(2):
|
||||
for c in range(3):
|
||||
body += _icon(100 + 420 * c, 200 + 240 * r)
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def _diagram_page(seed: int) -> str:
|
||||
return (f'<path d="M 100 {300 + seed} L 500 200 L 900 400" '
|
||||
f'stroke="#C00000" fill="none"/>' + _text(60, 100))
|
||||
|
||||
def test_four_same_icon_grids_is_error(self):
|
||||
pages = {f"{i:02d}_g.svg": self._icon_grid_page() for i in range(1, 5)}
|
||||
pages["05_d.svg"] = self._diagram_page(1)
|
||||
pages["06_e.svg"] = self._diagram_page(2)
|
||||
checker, out = self._run_deck(pages)
|
||||
self.assertIn("[ERROR] Layout monotony", out)
|
||||
self.assertGreaterEqual(checker.summary["errors"], 1)
|
||||
|
||||
def test_three_same_icon_grids_warns(self):
|
||||
pages = {f"{i:02d}_g.svg": self._icon_grid_page() for i in range(1, 4)}
|
||||
pages["04_d.svg"] = self._diagram_page(1)
|
||||
pages["05_e.svg"] = self._diagram_page(2)
|
||||
pages["06_f.svg"] = self._diagram_page(3)
|
||||
pages["07_h.svg"] = self._diagram_page(4)
|
||||
_, out = self._run_deck(pages)
|
||||
self.assertIn("[WARN] Layout monotony", out)
|
||||
|
||||
def test_varied_deck_no_monotony(self):
|
||||
pages = {"01_g.svg": self._icon_grid_page(),
|
||||
"02_g.svg": self._icon_grid_page()}
|
||||
for i in range(3, 8):
|
||||
pages[f"{i:02d}_d.svg"] = self._diagram_page(i)
|
||||
_, out = self._run_deck(pages)
|
||||
self.assertNotIn("Layout monotony", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -18,30 +18,13 @@ export function normalizeProgressStep(step) {
|
|||
return { id, title, status };
|
||||
}
|
||||
|
||||
// The checklist is a linear progress bar: work advances top-to-bottom. Models
|
||||
// don't always send a `completed` update before moving the in_progress marker
|
||||
// on (observed: later steps marked done while earlier ones dangle at
|
||||
// in_progress), which renders as "green check below, red dot above". Enforce
|
||||
// monotonic completion — any step before the last completed one is completed
|
||||
// too — so a missed update self-heals instead of stranding earlier steps.
|
||||
export function enforceMonotonicProgress(steps) {
|
||||
if (!Array.isArray(steps)) return [];
|
||||
let lastCompleted = -1;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (steps[i] && steps[i].status === "completed") lastCompleted = i;
|
||||
}
|
||||
if (lastCompleted <= 0) return steps.map(s => ({ ...s }));
|
||||
return steps.map((s, i) => (i < lastCompleted ? { ...s, status: "completed" } : { ...s }));
|
||||
}
|
||||
|
||||
export function applyProgressAction(progress, args) {
|
||||
const current = cloneProgressSteps(progress);
|
||||
if (!args || typeof args !== "object") return current;
|
||||
const action = args.action || "";
|
||||
if (action === "clear") return [];
|
||||
if (action === "set_plan") {
|
||||
const planned = Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : [];
|
||||
return enforceMonotonicProgress(planned);
|
||||
return Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : [];
|
||||
}
|
||||
if (action === "update_step") {
|
||||
const raw = args.step;
|
||||
|
|
@ -65,7 +48,7 @@ export function applyProgressAction(progress, args) {
|
|||
status: normalizeProgressStatus(raw.status),
|
||||
});
|
||||
}
|
||||
return enforceMonotonicProgress(next);
|
||||
return next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue