@topmindspace/tms-skills 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +14 -4
  3. package/bin/tms-skills.js +7 -2
  4. package/package.json +2 -2
  5. package/top-ppt-html/README.md +40 -27
  6. package/top-ppt-html/SKILL.md +20 -16
  7. package/top-ppt-html/agents/openai.yaml +5 -0
  8. package/top-ppt-html/assets/pptx-export.js +69 -25
  9. package/top-ppt-html/assets/style-gallery.html +1 -1
  10. package/top-ppt-html/assets/templates/architecture.html +69 -25
  11. package/top-ppt-html/assets/templates/presentation.html +69 -25
  12. package/top-ppt-html/assets/templates/research.html +69 -25
  13. package/top-ppt-html/evals/trigger-queries.json +181 -0
  14. package/top-ppt-html/package.json +4 -3
  15. package/top-ppt-html/references/chart-decision-tree.md +42 -0
  16. package/top-ppt-html/references/default-surface.md +3 -2
  17. package/top-ppt-html/references/illustration-layout.md +39 -0
  18. package/top-ppt-html/references/modes.md +1 -1
  19. package/top-ppt-html/references/outline-design.md +24 -0
  20. package/top-ppt-html/references/page-type-matrix.md +35 -0
  21. package/top-ppt-html/references/playbook.md +28 -89
  22. package/top-ppt-html/references/pptx-export.md +1 -1
  23. package/top-ppt-html/references/presentation-craft.md +11 -2
  24. package/top-ppt-html/references/tech-design.md +6 -0
  25. package/top-ppt-html/scripts/audit_docs.py +1 -1
  26. package/top-ppt-html/scripts/audit_skill.py +43 -4
  27. package/top-ppt-html/scripts/build_pptx.js +24 -10
  28. package/top-ppt-html/scripts/check_triggers.py +153 -0
  29. package/top-ppt-html/scripts/negative_tests.py +10 -0
  30. package/top-ppt-html/scripts/package_skill.py +17 -1
  31. package/top-ppt-html/scripts/quality_gate.py +30 -19
  32. package/top-ppt-html/scripts/test_feedback_gates.py +180 -0
  33. package/top-ppt-html/scripts/validate_pptx.py +157 -0
  34. package/top-ppt-html/scripts/validate_report.py +27 -0
@@ -1,8 +1,8 @@
1
1
  # 作战手册 · Playbook(唯一常读入口 · L1)
2
2
 
3
3
  > **每次生成都读这一份,通常就够了。**
4
- > 本文件是**决策层**:定模式、选页型、选版式组合、选图表、定配色、跑校验。
5
- > **代码、几何、穷举表不在本文件**——需要时按 `SKILL.md` 的 L2 路由读深度文件,**读完即停,不预读**。
4
+ > 决策层:定模式、选页型、选版式组合、选图表、定配色、跑校验——服务**优雅、大气、正式场合**的演讲/汇报甲板(Mode A 默认气质;B/C 按契约,勿把咨询 dump 当 A 默认)。
5
+ > **代码、几何、穷举表不在本文件**——按 `SKILL.md` L2 路由命中再读,**读完即停**;整读大 L2 = **FAIL**。
6
6
 
7
7
  ---
8
8
 
@@ -35,6 +35,7 @@
35
35
  **密度选错的症状**:A 出现双栏小字墙(投影看不清)→ 拆页/换形态;A 为「疏朗」删证据/so-what → **反模式**,按 content-rules §四-c 恢复;B 大面积留白 → 合页或补证据;C 成段正文 → 转图注。
36
36
  **节奏**:禁止连续 3 页同密度档;同一版式不连用超过 2 页;research 每 3–4 页插一个低密度页(指标带/金句/大数)。
37
37
  **反截断**:溢出顺序 = 重构承载 → 拆页/分章 → 换布局 → 有限 fontShrink;禁静默截断(见 `presentation-craft.md`)。
38
+ **插画页**:情绪/产品/占位图 ≠ 数据图;安全边距、图文不叠、icon+chart 共存、Mode A full-bleed+主张叠字规则见 `illustration-layout.md`。
38
39
 
39
40
  ---
40
41
 
@@ -42,10 +43,10 @@
42
43
 
43
44
  **轻量路径**(默认)——同时满足:≤12 页 · 材料单一完整 · 无未敲定关键判断 · 用户没要求看框架。
44
45
  ```
45
- Gate 0 参考图 → 六项问询(1 轮)→ 1 张规划卡 → scaffold_report.py
46
+ Gate 0 参考图 → 六项问询(1 轮)→ 最小大纲(outline-design「轻量最小集」)→ 1 张规划卡 → scaffold_report.py
46
47
  → **只填 REPORT_MODEL** → render_from_model.py --inplace → validate_report.py --strict → 交付
47
48
  ```
48
- (Fast Mode 跳过 Gate 0/六项,其余同链;**禁止**手改 HTML 正文与模型双写。)
49
+ (Fast Mode 跳过 Gate 0/六项,仍须最小大纲;默认 **HTML-only**,PPTX 显式 opt-in 走 B 通道;**禁止**手改 HTML 正文与模型双写。)
49
50
 
50
51
  **完整路径**(任一命中)——研究 ≥20 页 / 演示 ≥12 页 / 材料量大且杂 / 含未敲定关键判断 / 用户要看框架。
51
52
  ```
@@ -53,7 +54,7 @@ Gate 0 参考图 → 六项问询(1 轮)→ 证据表 → 故事线脑暴
53
54
  → scaffold_report.py → **只填 REPORT_MODEL** → render_from_model.py --inplace → validate_report.py --strict → 交付
54
55
  ```
55
56
 
56
- **预算(硬)**:交互轮次 ≤3;必读文件 = `SKILL.md` + 本文件(≤2 份);L2 深度文件按需且单份读完即停。
57
+ **预算(硬)**:交互轮次 ≤3;常读面仅入口两份(`SKILL.md` + 本手册)。正式演示 / Fast 演示路径额外允许工艺双件为 L1.5(`default-surface` 与 `presentation-craft`),不记作超读。深度 L2 命中再开、读完即停。
57
58
 
58
59
  ---
59
60
 
@@ -61,37 +62,9 @@ Gate 0 参考图 → 六项问询(1 轮)→ 证据表 → 故事线脑暴
61
62
 
62
63
  > 自动选型:`python scripts/recommend_layout.py --mode A --intent "路演" --pages 10 --json`(或 `--from-model report.model.json`)→ `{pageType,skel,chart,rationale}`;Mode A 交付 `--strict` 已含 layout-qa。
63
64
 
64
- > 穷举表(内容形态 → 版式 → PPTX 页型)见 `components.md` §46;密度档适配见 `components.md` §46b;组合见下节 §四。
65
-
66
- | 我要表达 | 首选页型 | 次选 | 关键约束 |
67
- |---------|---------|------|---------|
68
- | 要点式并列主张(默认页) | `points` | `cards` | 每卡宜 3–5 条;更多拆卡/跟进页,勿截断条目 |
69
- | 现状基线 / 指标快照 | `metrics` | `kpi` | 4–6 个;单数压场用 `kpi` |
70
- | 单个核心数字压场 | `kpi` | `metrics` | hero 一数一结论 |
71
- | 多维对标清单 | `table` | `halftable` | ≤8 行(research ≤16) |
72
- | 时间趋势 / 爬坡 | `bar`(chart.type=line/area/dualline) | `exhibit` | 时间点 >8 改折线 |
73
- | 构成占比 | `donut`(pie/multidonut/waffle) | `halftable` / **V3 KPI** | 扇区 ≤5,最小扇区 ≥8%;**极偏(min<5% 或 max/min>20)禁 donut→V3** |
74
- | 排名 / 大小对比 | `bar`(chart.type=hbar/lollipop/pareto) | `table` | 类别 >8 取 Top 7 + 附录 |
75
- | 现状 vs 目标 | `bullet` / `comparison` | `bar`(vsbar/bulletchart) | 目标线与实际条同量纲 |
76
- | 两期升降(谁升谁降) | `bar`(slope/dumbbell) | `table` | 两端都要标数值 |
77
- | 双维分布 / 优先级 | `bar`(scatter/bubble) | `matrix` | 气泡半径 ∝ √值 |
78
- | 分布 / 离散度 | `boxplot` | `bar`(dotplot) | 组数 ≤8;箱线四件齐备 |
79
- | 增减归因(A→B) | `bar`(waterfall) | `table` | 每段标变化量 + 合计收口 |
80
- | 转化 / 筛选漏斗 | `bar`(funnel) | `sankey` | 层数 ≤5 |
81
- | 计划排期(并行) | `bar`(gantt) | `timeline` | 里程碑叙事用 `timeline`,勿混 |
82
- | 里程碑 / 路线叙事 | `timeline` | `steps` | 3–5 个,每个 ≤2 行 |
83
- | 步骤 / 实施节奏 | `steps` | `timeline` | 3–6 步;高亮步 ≤2 处 |
84
- | 二维强度对标 | `heatmap` | `matrix` | 行列 ≤5×5;只用强调色明度阶梯 |
85
- | 定位 / 优先级 / 象限 | `matrix` | `bar`(scatter) | ≤3×3 |
86
- | 层级递进 / 价值阶梯 | `pyramid` | `diagram` | 3–5 层;强调层 ≤1 |
87
- | 关键判断 / 金句休止 | `quote` | `points` | 全文 1–2 处,多了廉价 |
88
- | 完整论述(发现→证据→含义) | `twocol`(research R1) | `threecol` | 段 ≤200 字;标题必须结论句 |
89
- | 数据证据页(主力) | `exhibit`(R2) | `split` | 全篇 Exhibit 连续编号 |
90
- | 三栏并列论点 | `threecol`(R6) | `cards` | 每栏 ≤150 字 |
91
- | 系统分层架构 | `diagram` | `bar`(chart.type=network) | 节点 ≤24;层 ≤4 |
92
- | 流程 / 职责协作 | `lane` | `diagram` | 每行 ≤6 步;正交折线优先 |
93
- | 素材图片(实拍/截图) | `image` | `split` | 六版式;无图用 `image.placeholder` |
94
- | 待核实项收口 | `flags` 字段 | `table` | 标色必须配说明 |
65
+ > **穷举意图→页型表** → L2 `page-type-matrix.md`(命中才读)。`components.md` §46 / §46b;组合见 §四。
66
+
67
+ **速记(默认面)**:要点→`points`/`cards` · 指标→`metrics`/`kpi` · 证据图→`exhibit`/`split` · 对标表→`table`/`halftable` · 趋势→`bar`+line/area · 占比→`donut`(极偏→**V3**)· 架构→`diagram`/`lane` · 图文演讲→§三-b V1–V4。
95
68
 
96
69
  ---
97
70
 
@@ -154,48 +127,11 @@ Gate 0 参考图 → 六项问询(1 轮)→ 证据表 → 故事线脑暴
154
127
 
155
128
  > **默认面(8 核心图)**:`bar` · `hbar` · `line` · `donut` · `progress` · `area` · `stack` · `dualline`
156
129
  > (= `layoutSystem.defaultCharts`)。完整登记与代码 → `extract_snippet.py --chart <类型>`;误用纪律 → `charts.md`(先 `charts-discipline`,核图 8,extended 按需)。
157
- > 下表仍保留决策提示;**生成默认只从 8 核心里选**,扩展图仅在意图明确命中时用。
158
-
159
-
160
- > 穷举代码见 `charts.md` §16–§31、§35、§52–§70;误用反例与多样性纪律见 `charts.md` §66(facade:纪律→核图→extended)。
161
- > **默认生成面(P1 收敛)**:优先 `bar / hbar / line / donut / progress / area / stack / dualline`。
162
- > **高级图型**(waterfall/gantt/funnel/slope/… 与 6 类信息图)按需选用,不进默认轮换。
163
- > **纪律**:全篇不同 `data-chart` 类型 ≥ `min(模式上限, ⌈图表页数 × 0.6⌉)`(上限:research 6 / presentation 4 / architecture 3);**相邻图表页不得同型**。多样性 **preferCoreFirst**:先拉开核图 8;advanced 计入 minTypes(不罚)但只在意图命中时用,**禁止预读** `charts-extended.md`;**禁**为凑下限硬上冷门图、简单图全幅、极偏 donut/pie。
164
-
165
- | 分析意图(读者要得出的结论) | 首选 | 次选 | 禁用 / 改判条件 |
166
- |---------------------------|------|------|----------------|
167
- | 谁大谁小 | `hbar` | `lollipop` / `bar` | 类别 >8 → Top7 + 附录 |
168
- | 随时间怎么变 | `line` | `area` / `dualline` | 时间点 >8 不标点值 |
169
- | 占比是多少 | `donut` | `pie` / `multidonut` | 扇区 >5 → 合并"其他"或 `treemap` |
170
- | 每 1% 的直觉 | `waffle` | `donut` | 类别 >3 改 `marimekko` |
171
- | 结构在变形 | `streamgraph` | `stackline` | 系列 >6 合并 |
172
- | 总量 × 构成双编码 | `marimekko` | `treemap` | 列 >6 / 段 >4 → 拆 |
173
- | 份额悬殊(头部集中) | `treemap` | `donut` | 叶 >16 → 合并小项 |
174
- | 从 A 到 B 是谁贡献的 | `waterfall` | `pareto` | 段 >6 → 合并 |
175
- | 主要矛盾是哪几个 | `pareto` | `waterfall` | 类别 >7 → 合并 |
176
- | 转化/流失在哪一环 | `funnel` | `sankey` | 层 >5 → 改 `hbar` |
177
- | 多对多的流向 | `sankey` | `network` | 节点 >12 / 流带 >24 → 拆页 |
178
- | 谁和谁相连 | `network` | `diagram` | 节点 >18 / 边 >30 → 拆页 |
179
- | 两期谁升谁降 | `slope` | `dumbbell` | 系列 >6 → 取 Top 6 |
180
- | 单指标前后对比(多对象) | `dumbbell` | `vsbar` | 行 >6 → 拆页 |
181
- | 实际 vs 目标(含区间) | `bulletchart` | `bullet` | 行 >6 → 拆页 |
182
- | 达成率(多指标同心) | `radialbar` | `gauge` | 环 >5 → 改 `bullet` |
183
- | 一个值够不够 | `gauge` | `radialbar` | 单值叙事,勿与图并存 |
184
- | 分布不是均值 | `boxplot` | `dotplot` | 组 >8 → 拆页 |
185
- | 双维定位 + 第三维权重 | `bubble` | `scatter` | 气泡 >8 → 标注关键项 |
186
- | 六维能力画像 | `radar` | `rose` | 维度 >6 → 拆两组 |
187
- | 区间波动(开高低收) | `candlestick` | `line` | 蜡烛 >12 → 聚合 |
188
- | 多项目完成度 | `progress` | `bullet` | 行 >6 → 拆页 |
189
- | 迷你趋势(卡内) | `sparkline` | — | 只用于指标卡,不与大图并存 |
190
-
191
- **七种最常见的误用(出现即改判)**:
192
- 1. 用 `bar` 表达占比 → 改 `donut`/`stackline`/`waffle`。
193
- 2. 用 `donut` 表达 8 个类别 → 合并或改 `treemap`。
194
- 3. 用 `line` 表达类别对比(无时间轴)→ 改 `hbar`。
195
- 4. 用 `table` 表达趋势 → 改 `line`/`area`。
196
- 5. 用均值型图表表达分布 → 改 `boxplot`/`dotplot`。
197
- 6. 同一页放两张同型图 → 改 `split`/`halftable` 并让两图**编码不同维度**。
198
- 7. 多系列图用单色明度阶梯(分不清) → 用 `f-c1~c5` 编码色板(见 §八)。
130
+ > **扩展决策表 + 七种误用** → L2 `chart-decision-tree.md`(命中才读)。**生成默认只从 8 核心里选**,扩展图仅意图明确命中。
131
+
132
+ **多样性(硬摘要 · 保留)**:全篇不同 `data-chart` ≥ `min(模式上限, ⌈图表页数 × 0.6⌉)`(上限 research 6 / presentation **4** / architecture 3);**相邻图表页不得同型**。**preferCoreFirst**:先拉开核图 8;advanced 计入 `minTypes`(不罚)但禁预读 `charts-extended.md`;**禁**为凑下限硬上冷门图、简单图全幅、极偏 donut/pie。
133
+
134
+ **速记**:谁大谁小→`hbar` · 时间→`line`/`area` · 占比→`donut`(扇区>5/极偏改判)· 归因→`waterfall` · 转化→`funnel` · 流向→`sankey`。
199
135
 
200
136
  ---
201
137
 
@@ -252,33 +188,36 @@ python scripts/validate_pptx.py 2026-09-15-主题.pptx --strict --model=….mode
252
188
  ```
253
189
 
254
190
  **交付前必过**:`validate_report.py --strict` 0/0(**Mode A / presentation 隐含 `--layout-qa`**;research/architecture 不强制);含 PPTX 时 `validate_pptx.py --strict --model=` 0/0。
255
- **推荐一键门禁**:`python scripts/quality_gate.py 报告.html [--pptx x.pptx --model x.model.json]`(presentation 同口径附带 layout-qa;交付加 `--deliver` 出七要素,不要手拼)。
256
- **A/B 全文对等**:`cross_verify.py` 默认 SKIP,仅 `python scripts/cross_verify.py --full-ab` 或 `regression.py --full-ab` 时启用。
191
+ **PPTX 通道**:默认交付只跑 **B 通道**(`build_pptx.js`);**A 通道**(`pptx-export.js` / `gen_channel_a.js`)仅预览与 `cross_verify` / 回归——**勿把 A 产物当交付**。
192
+ **推荐一键门禁**:`python scripts/quality_gate.py 报告.html [--pptx x.pptx --model x.model.json]`(HTML/PPTX/evals **并行**;presentation 附 layout-qa;`--deliver` 出七要素,勿手拼)。
193
+ **A/B 全文对等**:`cross_verify.py` 默认 SKIP,仅 `--full-ab` / `regression.py --full-ab` 启用。
257
194
  **PPTX 冒烟**:`bash scripts/smoke_pptx.sh`(extract_model → build_pptx → validate_pptx --strict)。
258
- **失败自修复**:按输出逐条修 → 重跑,直到 0/0;修复顺序见 `failure-modes.md`。
195
+ **失败自修复**:按输出逐条修 → 重跑至 0/0;顺序见 `failure-modes.md`。
259
196
 
260
197
  ---
261
198
 
262
199
  ## 十、L2 节级路由(按任务只读这些,读完即停)
263
200
 
264
- > 默认生成面:`default-surface.md`(12 页型 + 8 图 + V1–V4)。Mode A 工艺清单:`presentation-craft.md`。
265
- > **不要整读** `components.md` / `charts.md` 背后的物理拆分大文件。需要代码时优先:
266
- > `python scripts/extract_snippet.py --list` · `--task <名>` · `--chart <类型>` · `--page-type <页型>` · `--file components.md --section 46`(逻辑名自动路由)
201
+ > 常读仍为入口两份;演示路径可加 **L1.5 工艺双件**:`default-surface.md` + `presentation-craft.md`。
202
+ > 默认生成面:`default-surface.md`(12 页型 + 8 图 + V1–V4)。Mode A 工艺:`presentation-craft.md`。插画:`illustration-layout.md`。
203
+ >
204
+ > **整读下列文件 = FAIL / 不合格**(强制 `extract_snippet.py`):`layouts-combo.md` · `components-atoms.md` · `charts-basic.md` · `charts-extended.md` · `content-rules.md` 全文 · `pptx-export.md` · 模式模板 HTML(走 `scaffold_report.py`)。
205
+ > 取码:`python scripts/extract_snippet.py --list` · `--task <名>` · `--chart <类型>` · `--page-type <页型>` · `--file components.md --section 46`(逻辑名自动路由)
267
206
 
268
207
  | 任务 | 只读 |
269
208
  |------|------|
270
- | research 证据页 / Exhibit / 密表 | `components.md` §36d/§46/§46c · 本文件 §三/§四 · `extract_snippet.py --task research-evidence` |
271
- | 正式演示 / Mode A 工艺 | `default-surface.md` · `presentation-craft.md` · 本文件 §三-b/§五 |
209
+ | research 证据页 / Exhibit / 密表 | `components.md` §36d/§46/§46c · 本文件 §三/§四 · `page-type-matrix.md` · `extract_snippet.py --task research-evidence` |
210
+ | 正式演示 / Mode A 工艺 | `default-surface.md` · `presentation-craft.md` · 本文件 §三-b/§五 · `page-type-matrix.md` |
272
211
  | 演示组合版式 | `components.md` §39–§42/§46/§46b/§46c · `--task presentation-combo` |
273
212
  | 架构 / 泳道 / 分层 | `components.md` §37–§38b · `infographics.md` §78–§80 · `--task architecture-diagram` |
274
- | 选图 / 取图表代码 | 本文件 §五 · `--chart <类型>` · 误用见 `charts.md` §66 |
213
+ | 选图 / 取图表代码 | 本文件 §五 · `chart-decision-tree.md` · `--chart <类型>` · 误用见 `charts.md` §66 |
275
214
  | 密度 / 字数 / 去 AI 味 | `content-rules.md` §一/§四 · 本文件 §六 · `--task content-rules` |
276
- | 素材图片 / 占位 | `components.md` §11c · `--task image-layout` |
215
+ | 素材图片 / 占位 / 插画页 | `illustration-layout.md` · `components.md` §11c · `--task image-layout` |
277
216
  | PPTX 精导字段 | `pptx-export.md` · 本文件 §九 · `--task pptx-export` |
278
217
  | 高保真 / 锚点 | `high-fidelity.md` · `--task high-fidelity` |
279
218
  | 配色 / 风格 | `styles.md` · `design-system.md` §1a–§1b/§9 · `--task style-theme` |
280
219
  | 图标 / 语义速查 | `icons.md` · `--task icons` |
281
220
  | 起骨架用节奏包 | `scaffold_report.py --list-types` → `--preset consulting` 等 |
282
221
 
283
- **纪律**:L0+L1 是默认全部所需;上表命中才开 L2;**读完即执行,不预读下一份**。页型语义槽位见 `layout-constants.json` → `layoutSlots`(双通道同源 IR)。
222
+ **纪律**:L0+L1 默认全部所需(Mode A/Fast 可加 L1.5);命中才开 L2;**读完即执行,不预读**;大文件整读 = **FAIL**,一律 `extract_snippet.py`。页型槽位见 `layout-constants.json` → `layoutSlots`(双通道同源 IR)。
284
223
  **维护者**:改常量/schema/引擎后必跑 `sync_runtime.py` → `audit_styles.py` → `audit_docs.py` → `audit_skill.py` → `build_examples.py` → `regression.py`。
@@ -21,7 +21,7 @@
21
21
  ```
22
22
 
23
23
  - **页面预览**:收到/打开报告的人点 header「预览 PPTX」(快捷键 P)即可查看页序列,无需任何环境。预览模态与精导通道使用**同一序列化语义**(`slidesOf → slideXml`)——**版式与文本所见即所得**;带数据的原生图表在交付 PPTX 中为真 chart part(双击可编辑数据),预览侧为形状近似,**图表形态以精导产物为准**。**页面不直接导出文件**:浏览器端跑不了 strict 质检硬门禁,达不到交付质量;预览模态常显**可复制 AI 提示词**,用户想获得 PPTX 时复制给 AI,由智能体走精导通道生成(`?` 帮助弹窗 / 快捷键 H 有同样说明)。
24
- - **智能体精导**:正式 PPTX 的唯一交付路径——从报告抽模型 → 生成 → **过 strict 质检** → 交付。生成时用户选了「HTML+PPTX」交付格式的,交付时一并生成;交付后用户随时可通过页面提示词回来补生成。
24
+ - **智能体精导(B 通道 · 唯一交付)**:`extract_model.py` → `build_pptx.js` → `validate_pptx.py --strict` 0/0。用户选「HTML+PPTX」或事后要 PPT 时才跑;**不要**用 A 通道(`pptx-export.js` / `gen_channel_a.js`)产物交付——A 仅页面预览与回归双裁判。
25
25
  - **双单源**:页面几何/三模式独立比例尺(`typeScale` 基准 + `modeTypeScale` 模式取值)/页型几何/9 风格 token 全在 `scripts/layout-constants.json`;**页型 DSL schema(字段/必填/适用模式)在 `scripts/model-schema.json`**。通道 B 直接 `require`;页面运行时由 `sync_runtime.py` 注入(同时注入三模板的引擎/UI/运行时内联副本)。**改常量只改 JSON,改 schema 只改 schema JSON,然后跑 `python scripts/sync_runtime.py`**。
26
26
 
27
27
  ### 会场与字号(venue · P1-4)
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **何时读**:Mode A / 正式演示 / 路演 / 演讲 / Fast 演示路径。与 `default-surface.md` 同级常读。
4
4
  > **何时不读**:Mode B 密排、Mode C 架构深读(能力保留,不反向污染 A)。
5
- > **定位**:大气、演讲友好;一屏一主张;图标/图表克制配文——非 gadget deck、非咨询 dump。
5
+ > **定位**:优雅 · 美观 · 大气;正式场合演讲友好;版式/排版/色彩/内容组织优先;图标/图表克制配文——非 gadget deck、非咨询 dump。
6
6
 
7
7
  ## 铁律速查(Hard rules)
8
8
 
@@ -32,7 +32,8 @@
32
32
 
33
33
  ```
34
34
  validate_report.py --strict # presentation 自动隐含 --layout-qa
35
- quality_gate.py … --deliver # 同口径
35
+ quality_gate.py … --deliver # HTML/PPTX/evals 并行;同口径
36
+ # PPTX 交付仅 B 通道 build_pptx.js(A 通道不交付)
36
37
  ```
37
38
 
38
39
  检查:V 契约 · 简单全幅 · 骨架连用 · 截断/溢出未拆页 · 半空卡 · 对齐节奏 · 主张标题 WARN · 核图多样性 · WCAG。
@@ -46,6 +47,14 @@ quality_gate.py … --deliver # 同口径
46
47
  - [ ] `motion = none`;翻页靠内容节奏
47
48
 
48
49
 
50
+ ## 插画页(非数据配图)
51
+
52
+ - 情绪/产品/占位图与数据图**勿同页抢主位**;规则见 `illustration-layout.md`。
53
+ - **安全边距** ≥24px;文字不压照片焦点;标签/图例不重叠。
54
+ - **icon + chart**:icon 在注释列,不进绘图区。
55
+ - **full-bleed + 主张叠字**(Mode A):仅封面/章节幕/金句/收尾;遮罩保对比度;必有 caption/来源;证据另页。
56
+ - 配图页写 **caption + so-what**(氛围休止页可免 so-what);校验对缺项 WARN。
57
+
49
58
  ## 长文与信息承载(anti-truncation)
50
59
 
51
60
  > **硬原则**:大量文字**不得**为了「看起来像稀疏 demo 甲板」而被故意压缩或删除。留白是设计;**删掉证据 / so-what / 口径不是设计**。
@@ -136,3 +136,9 @@
136
136
  | `evals/*` | Eval 框架:`prompts.csv`(14 条,含负对照)+ `rubric.schema.json`(风格目标评分契约)+ `run_evals.py`(结果/过程/风格/效率四类目标) |
137
137
  | `assets/templates/*` | 三模式模板 + 公共引擎/UI(标记块由 `sync_runtime.py` 注入) |
138
138
  | `assets/examples/*` | 示例矩阵(含信息图页型与原生图表技巧) |
139
+
140
+ ## 附:第三方校验与实验 frontmatter
141
+
142
+ - **主门禁**:`audit_skill.py` / `package_skill.py --check`(体积、披露、Gate0、触发覆盖)。
143
+ - **可选**:`npx --yes skills-ref@0.1.5 validate .`(Agent Skills 开放标准;本技能已通过)。**不**作为 CI 硬依赖。
144
+ - **不启用**:Claude-only `when_to_use`(会破坏严格 allowlist);跨端 `allowed-tools`(宿主语法分叉)——需要时由宿主侧实验,不进本仓库 frontmatter。
@@ -139,7 +139,7 @@ def main() -> int:
139
139
  # ⑤ 篇数与必需文件
140
140
  n_ref = len(list(REF.glob('*.md')))
141
141
  sk = (ROOT / 'SKILL.md').read_text(encoding='utf-8')
142
- d = re.search(r'^description:\s*"(.*)"\s*$', sk, re.M | re.S)
142
+ d = re.search(r'^description:\s*"(.*)"\s*$', sk, re.M)
143
143
  dlen = len(d.group(1)) if d else -1
144
144
  nm = re.search(r'^name:\s*(\S+)\s*$', sk, re.M)
145
145
  missing = [r for r in REQUIRED if not (ROOT / r).exists()]
@@ -13,7 +13,7 @@
13
13
 
14
14
  审计项(任一不过 → 退出码 1):
15
15
  ① 体积预算 —— SKILL.md / 常读入口 playbook.md / 单份 reference / 模式模板 的上限
16
- ② 元数据 —— frontmatter name 与技能名一致;description ≤ DESC_HARD,且 ≤ DESC_SOFT(触发精度)
16
+ ② 元数据 —— frontmatter name 与技能名一致;description ≤ DESC_HARD/SOFT;可选 license/compatibility/metadata(开放标准)
17
17
  ③ 披露分层 —— SKILL.md 必须声明 L0/L1/L2 三档,且 L1(常读入口)恰好 1 份
18
18
  ④ 交互门禁 —— SKILL.md 必须含「Gate 0 参考图先行」,且其出现位置在「六项问询」之前;若声明 Fast Mode,须同时有豁免句且标准路径仍为硬门禁
19
19
  ⑤ 效率预算 —— SKILL.md 必须显式声明交互轮次上限与必读文件数上限
@@ -139,7 +139,7 @@ def main() -> int:
139
139
  f'最大 {kb(max((p.stat().st_size for p in TPL.glob("*.html")), default=0))} / 上限 {kb(BUDGET["template"])}')
140
140
 
141
141
  # ② 元数据
142
- d = re.search(r'^description:\s*"(.*)"\s*$', sk, re.M | re.S)
142
+ d = re.search(r'^description:\s*"(.*)"\s*$', sk, re.M)
143
143
  dlen = len(d.group(1)) if d else -1
144
144
  nm = re.search(r'^name:\s*(\S+)\s*$', sk, re.M)
145
145
  chk('② name 一致', bool(nm) and nm.group(1) == NAME,
@@ -149,9 +149,37 @@ def main() -> int:
149
149
  chk('② description 软上限(触发精度)', 0 < dlen <= BUDGET['desc_soft'],
150
150
  f'{dlen} / 建议 ≤ {BUDGET["desc_soft"]}')
151
151
 
152
+ # ②b 开放标准可选 frontmatter(允许额外键;校验存在与粗约束)
153
+ lic = re.search(r'^license:\s*(.+)$', sk, re.M)
154
+ chk('② license', bool(lic) and 'MIT' in (lic.group(1) if lic else ''),
155
+ '缺 license: MIT(开放标准可选,本技能应对齐 package.json)',
156
+ 'license=MIT')
157
+ compat = re.search(r'^compatibility:\s*"(.*)"\s*$', sk, re.M | re.S)
158
+ clen = len(compat.group(1)) if compat else -1
159
+ chk('② compatibility', 0 < clen <= 500,
160
+ f'compatibility 缺失或超 500 字符({clen})',
161
+ f'{clen} 字符')
162
+ meta_ver = re.search(r'^\s+version:\s*"?([\w.\-]+)"?\s*$', sk, re.M)
163
+ meta_author = re.search(r'^\s+author:\s*(\S+)\s*$', sk, re.M)
164
+ pkg_ver = ''
165
+ try:
166
+ import json as _json
167
+ pkg_ver = str(_json.loads((ROOT / 'package.json').read_text(encoding='utf-8')).get('version') or '')
168
+ except Exception:
169
+ pass
170
+ chk('② metadata.version', bool(meta_ver) and (not pkg_ver or meta_ver.group(1) == pkg_ver),
171
+ f'metadata.version 缺失或与 package.json 不一致(fm={meta_ver.group(1) if meta_ver else None} pkg={pkg_ver})',
172
+ f'{meta_ver.group(1) if meta_ver else "?"}')
173
+ chk('② metadata.author', bool(meta_author),
174
+ '缺 metadata.author',
175
+ meta_author.group(1) if meta_author else '')
176
+
152
177
  # ③ 渐进式披露分层(机器可读标记:L0=… · L1=… · L2=…)
153
- m_l1 = re.search(r'L1\s*=\s*`?([^`\s·|]+)`?', sk)
154
- has_l0 = bool(re.search(r'L0\s*=', sk))
178
+ # Prefer machine-readable disclosure marker `L1=references/...`(忽略正文「L0+L1 共 2」等)
179
+ m_l1 = re.search(r'L1\s*=\s*`?(references/[a-z0-9\-./]+\.md)`?', sk)
180
+ if not m_l1:
181
+ m_l1 = re.search(r'L1\s*=\s*`?([^`\s·|]+)`?', sk)
182
+ has_l0 = bool(re.search(r'L0\s*=\s*`?SKILL', sk)) or bool(re.search(r'`L0=SKILL', sk))
155
183
  has_l2 = bool(re.search(r'L2\s*=', sk))
156
184
  l1_files = [f.strip() for f in re.split(r'[,、]', m_l1.group(1)) if f.strip()] if m_l1 else []
157
185
  chk('③ 披露分层 L0/L1/L2', has_l0 and has_l2 and len(l1_files) == BUDGET['l1_files'],
@@ -182,6 +210,17 @@ def main() -> int:
182
210
  'SKILL.md 未声明交互轮次上限', '已声明(≤3 轮)')
183
211
  chk('⑤ 必读文件预算', bool(re.search(r'必读.{0,6}上限|必读文件数|只读当前需要的|不预读', sk)),
184
212
  'SKILL.md 未声明必读文件预算/不预读纪律', '已声明(不预读纪律)')
213
+ # ⑤b 反过读硬门禁文案(整读大 L2 = FAIL)+ B 通道交付口径
214
+ ok_snip = bool(re.search(r'extract_snippet', sk)) and bool(
215
+ re.search(r'整读.{0,40}(?:FAIL|不合格)|(?:FAIL|不合格).{0,40}整读', sk))
216
+ chk('⑤ extract_snippet 整读=FAIL', ok_snip,
217
+ 'SKILL.md 须同时声明 extract_snippet 与「整读…FAIL/不合格」',
218
+ '已声明 FAIL 口径')
219
+ ok_b = bool(re.search(r'B\s*通道|build_pptx', sk)) and bool(
220
+ re.search(r'A\s*通道|pptx-export|cross_verify', sk))
221
+ chk('⑤ B 通道交付 / A 预览', ok_b,
222
+ 'SKILL.md 须区分 B 通道交付与 A 通道预览/cross_verify',
223
+ '已区分')
185
224
 
186
225
  # ⑥ 引用完整性
187
226
  missing: list[str] = []
@@ -174,6 +174,17 @@ function modeFloorPt() {
174
174
  const f = FS_POLICY.floorPt[m];
175
175
  return f != null ? f : 10;
176
176
  }
177
+ function snapFont(pt, ladder, floor) {
178
+ /* Snap arbitrary pt to nearest ladder step (≥ floor). fitFont + callers (fz-1) must use this. */
179
+ const lad = (ladder || FONT_LADDER).filter(v => v >= (floor == null ? 0 : floor));
180
+ if (!lad.length) return floor == null ? pt : Math.max(floor, pt);
181
+ let best = lad[0], bd = Math.abs(lad[0] - pt);
182
+ for (let i = 1; i < lad.length; i++) {
183
+ const d = Math.abs(lad[i] - pt);
184
+ if (d < bd) { bd = d; best = lad[i]; }
185
+ }
186
+ return best;
187
+ }
177
188
  function fitFont(items, widthIn, availIn, opts) {
178
189
  opts = opts || {};
179
190
  const floor = opts.floor != null ? opts.floor : modeFloorPt();
@@ -536,7 +547,7 @@ function imageLayoutShapes(s, layout, img, items, points, isPh, fit, y0, y1) {
536
547
  const ptsI = points || [];
537
548
  const rowHI = fitRowH(ih - 0.1, ptsI.length, IM.noteRowH || 0.62, 0.3);
538
549
  const fzI = fitFont(ptsI.map(p => (p[0] || '') + (p[1] || '')), rwI - 0.3, ih, { max: 14, gapFactor: 0.6 });
539
- const fz = sz(fzI), fzSm = sz(fzI - 1);
550
+ const fz = sz(fzI), fzSm = sz(snapFont(fzI - 1));
540
551
  ptsI.forEach((pt, i) => {
541
552
  const lyI = iy + i * rowHI;
542
553
  s.addShape('rect', { x: rxI, y: lyI + rowHI / 2 - 0.06, w: 0.12, h: 0.12,
@@ -1490,8 +1501,11 @@ function infoStreamgraph(s, sec, y0, y1) {
1490
1501
  totals.push(t);
1491
1502
  }
1492
1503
  const maxTot = Math.max.apply(null, totals.concat([1]));
1493
- const plotH = P.y1 - P.y0;
1494
- const cyMid = (P.y0 + P.y1) / 2;
1504
+ /* Reserve legend INSIDE the plot box — 6 series must not crush the annotation band. */
1505
+ const legH = Math.min(1.2, Math.max(0.28, series.length * 0.22 + 0.08));
1506
+ const plotBottom = P.y1 - legH;
1507
+ const plotH = Math.max(0.6, plotBottom - P.y0);
1508
+ const cyMid = P.y0 + plotH / 2;
1495
1509
  const xAt = (i) => MX + (nPts > 1 ? i / (nPts - 1) : 0.5) * CW;
1496
1510
  const yAt = (i) => cyMid - (totals[i] / maxTot) * plotH / 2;
1497
1511
  const palette = [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line, STYLE.soft, STYLE.surface];
@@ -1512,14 +1526,14 @@ function infoStreamgraph(s, sec, y0, y1) {
1512
1526
  }
1513
1527
  ribbonRects(s, pts, palette[si % palette.length]);
1514
1528
  const lx = MX;
1515
- s.addShape('rect', { x: lx, y: P.y1 + 0.06 + si * 0.26, w: 0.12, h: 0.12,
1529
+ s.addShape('rect', { x: lx, y: plotBottom + 0.04 + si * 0.2, w: 0.12, h: 0.12,
1516
1530
  fill: { color: palette[si % palette.length] }, line: { type: 'none' } });
1517
- s.addText(String(se.name || ('系列' + (si + 1))), { x: lx + 0.16, y: P.y1 + si * 0.26, w: 1.5, h: 0.26,
1531
+ s.addText(String(se.name || ('系列' + (si + 1))), { x: lx + 0.16, y: plotBottom + 0.02 + si * 0.2, w: 1.5, h: 0.2,
1518
1532
  fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body, valign: 'middle' });
1519
1533
  base = upper;
1520
1534
  });
1521
1535
  labels.forEach((lb, i) => {
1522
- s.addText(String(lb), { x: xAt(i) - 0.4, y: P.y1 - 0.02, w: 0.8, h: 0.24, align: 'center',
1536
+ s.addText(String(lb), { x: xAt(i) - 0.4, y: plotBottom - 0.22, w: 0.8, h: 0.2, align: 'center',
1523
1537
  fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1524
1538
  });
1525
1539
  }
@@ -1745,8 +1759,8 @@ CONTENT.sections.forEach((sec) => {
1745
1759
  const iy = ireg.y;
1746
1760
  const capH = img.caption ? 0.32 : 0;
1747
1761
  const ih = Math.max(1.2, ireg.h);
1748
- imageLayoutShapes(s, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
1749
- if (img.caption) s.addText(img.caption, { x: MX, y: iy + ih + 0.04, w: CW, h: capH,
1762
+ const capYImg = imageLayoutShapes(s, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
1763
+ if (img.caption) s.addText(img.caption, { x: MX, y: capYImg + 0.04, w: CW, h: capH,
1750
1764
  fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
1751
1765
  } else if (type === 'donut') {
1752
1766
  const dcn = sec.chart || {};
@@ -2133,7 +2147,7 @@ CONTENT.sections.forEach((sec) => {
2133
2147
  const ly0 = bodyY, availP = bodyBottom - ly0;
2134
2148
  const rowH = fitRowH(availP, pts.length, SP.rowH, 0.3);
2135
2149
  const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), w - 0.4, availP, { max: 14, gapFactor: 0.6 });
2136
- const fz = sz(fzB), fzSm = sz(fzB - 1);
2150
+ const fz = sz(fzB), fzSm = sz(snapFont(fzB - 1));
2137
2151
  pts.forEach((pt, i) => {
2138
2152
  const ly = ly0 + i * rowH;
2139
2153
  s.addShape('rect', { x, y: ly + rowH / 2 - 0.06, w: 0.12, h: 0.12, fill: { color: STYLE.accent }, line: { type: 'none' } });
@@ -2277,7 +2291,7 @@ CONTENT.sections.forEach((sec) => {
2277
2291
  const rowH = fitRowH(avail, pts.length, P.rowH, 0.3);
2278
2292
  const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), textW - 0.32, avail,
2279
2293
  { max: 15, gapFactor: 0.55 });
2280
- const fz = sz(fzB), fzSm = sz(fzB - 1);
2294
+ const fz = sz(fzB), fzSm = sz(snapFont(fzB - 1));
2281
2295
  pts.forEach((p, i) => {
2282
2296
  const y = bodyY + i * rowH;
2283
2297
  s.addShape('rect', { x: MX, y: y + rowH / 2 - P.markSize / 2, w: P.markSize, h: P.markSize,
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """TopPPT HTML · description trigger coverage (keyword/heuristic, no LLM).
4
+
5
+ Gates regressions when SKILL.md description drifts away from eval queries.
6
+ See evals/trigger-queries.json → how_to_extend.
7
+
8
+ Usage:
9
+ python3 scripts/check_triggers.py
10
+ python3 scripts/check_triggers.py --json
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ try:
20
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
21
+ except Exception:
22
+ pass
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ SKILL = ROOT / 'SKILL.md'
26
+ QUERIES = ROOT / 'evals' / 'trigger-queries.json'
27
+
28
+ # Tokens that, if present in a query AND in description (or Do-NOT zone for negatives),
29
+ # count toward a hit. Multi-char CJK / ASCII keywords preferred over single chars.
30
+ POS_KEYWORDS = [
31
+ '报告', '演示', '汇报', '路演', '调研', '白皮书', '评测', '对标', '经营分析', '复盘',
32
+ 'PPT', 'PPTX', 'pptx', 'slides', 'deck', 'HTML', '网页报告',
33
+ '架构图', '拓扑', '流程图', '泳道', '方案图',
34
+ '排版', '版式', '配色', '图文',
35
+ '快速模式', 'fast', '一键出稿', '直接生成', '少问一句',
36
+ '可视化', '高保真', '可编辑',
37
+ '咨询报告', '分析报告', '研究报告', '项目汇报', '商务',
38
+ ]
39
+
40
+ # Negative-zone cues in description (Do NOT …) — if a miss-query matches these
41
+ # boundary phrases more than positive product cues, treat as correctly excluded.
42
+ NEG_BOUNDARY = [
43
+ '纯代码', '代码工程', '非报告类', '视频', '图片生成',
44
+ 'Word', '源文件', '应用开发', 'coding', 'Do NOT',
45
+ ]
46
+
47
+ # Extra strong negative query cues (if query has these AND lacks report cues → expect miss)
48
+ NEG_QUERY_CUES = [
49
+ 'react', '组件', '单测', 'debug', 'python 报错', '视频', 'mp4', 'midjourney',
50
+ '海报图', '已有的 pptx', '已有 pptx', 'word 文档', '电商官网', 'sql',
51
+ 'nginx', 'refactor', 'typescript', '剪辑', '录像', '字幕', '迁移脚本',
52
+ ]
53
+
54
+
55
+ def load_description() -> str:
56
+ txt = SKILL.read_text(encoding='utf-8')
57
+ m = re.search(r'^description:\s*"(.*)"\s*$', txt, re.M)
58
+ if not m:
59
+ raise SystemExit('SKILL.md missing description frontmatter')
60
+ return m.group(1)
61
+
62
+
63
+ def tokenize_hits(query: str, desc: str) -> list[str]:
64
+ q_low = query.lower()
65
+ d_low = desc.lower()
66
+ hits = []
67
+ for kw in POS_KEYWORDS:
68
+ if kw.lower() in q_low and kw.lower() in d_low:
69
+ hits.append(kw)
70
+ return hits
71
+
72
+
73
+ def looks_negative_query(query: str) -> bool:
74
+ q = query.lower()
75
+ return any(c in q for c in NEG_QUERY_CUES)
76
+
77
+
78
+ def boundary_covered(desc: str) -> bool:
79
+ return sum(1 for b in NEG_BOUNDARY if b.lower() in desc.lower()) >= 2
80
+
81
+
82
+ def evaluate(desc: str, data: dict) -> tuple[list[dict], int, int]:
83
+ rows = []
84
+ fails = 0
85
+ for bucket, default_expect in (('positive', 'hit'), ('negative', 'miss')):
86
+ for item in data.get(bucket, []):
87
+ q = item['query']
88
+ expect = item.get('expect', default_expect)
89
+ hits = tokenize_hits(q, desc)
90
+ if expect == 'hit':
91
+ ok = len(hits) >= 1
92
+ detail = f'keywords={hits}' if ok else 'no shared trigger keyword with description'
93
+ else:
94
+ # miss: either no positive keyword overlap, OR query is clearly
95
+ # out-of-scope and description has Do-NOT boundaries.
96
+ ok = (len(hits) == 0) or (
97
+ looks_negative_query(q) and boundary_covered(desc) and len(hits) <= 1
98
+ )
99
+ # Stricter: if query is a known out-of-scope cue, require miss even
100
+ # when a weak overlapping token exists (e.g. "pptx" in "改已有 pptx").
101
+ if looks_negative_query(q) and boundary_covered(desc):
102
+ ok = True
103
+ detail = (
104
+ f'miss-ok boundary; weak_hits={hits}' if ok
105
+ else f'unexpected trigger overlap={hits}'
106
+ )
107
+ rows.append({
108
+ 'id': item.get('id'),
109
+ 'expect': expect,
110
+ 'ok': ok,
111
+ 'query': q,
112
+ 'detail': detail,
113
+ })
114
+ if not ok:
115
+ fails += 1
116
+ return rows, fails, len(rows)
117
+
118
+
119
+ def main() -> int:
120
+ as_json = '--json' in sys.argv
121
+ if not QUERIES.exists():
122
+ print(f'MISSING {QUERIES}')
123
+ return 1
124
+ data = json.loads(QUERIES.read_text(encoding='utf-8'))
125
+ desc = load_description()
126
+ rows, fails, total = evaluate(desc, data)
127
+ n_pos = sum(1 for r in rows if r['expect'] == 'hit')
128
+ n_neg = sum(1 for r in rows if r['expect'] == 'miss')
129
+ if as_json:
130
+ print(json.dumps({
131
+ 'total': total, 'fails': fails,
132
+ 'positive': n_pos, 'negative': n_neg,
133
+ 'description_chars': len(desc),
134
+ 'rows': rows,
135
+ }, ensure_ascii=False, indent=2))
136
+ else:
137
+ print(f'trigger coverage · {total - fails}/{total} '
138
+ f'(+{n_pos} / −{n_neg}) · description {len(desc)} chars')
139
+ for r in rows:
140
+ mark = 'OK' if r['ok'] else 'FAIL'
141
+ print(f' [{mark}] {r["id"]} expect={r["expect"]} {r["detail"]}')
142
+ if not r['ok']:
143
+ print(f' Q: {r["query"]}')
144
+ if fails:
145
+ print(f'\nFAILED {fails} query(ies). Extend keywords in SKILL description '
146
+ f'or adjust evals/trigger-queries.json (see how_to_extend).')
147
+ else:
148
+ print('\nAll trigger heuristics passed.')
149
+ return 1 if fails else 0
150
+
151
+
152
+ if __name__ == '__main__':
153
+ sys.exit(main())
@@ -23,6 +23,7 @@
23
23
  L4 截断迹象(列表项省略号砍义)
24
24
  L5 溢出未拆页
25
25
  L6 混排缺对齐(ALIGN_RHYTHM)
26
+ N18 配图页缺 caption/图注(IMAGE_CAPTION)
26
27
 
27
28
  用法: python scripts/negative_tests.py
28
29
  """
@@ -176,6 +177,15 @@ def main() -> int:
176
177
  '"footnote": "<strong>x</strong> ', 1)
177
178
  cases.append(('N17 模型夹带标签', t17, 'HTML_TAG_IN_TEXT'))
178
179
 
180
+ # N18 配图页缺 caption(有 <img>/media 无图注类)
181
+ t18 = txt.replace(
182
+ '</body>',
183
+ '<section class="band" data-page-type="image">'
184
+ '<div class="media media--full">'
185
+ '<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="注入无图注">'
186
+ '</div></section></body>', 1)
187
+ cases.append(('N18 配图缺 caption', t18, 'IMAGE_CAPTION'))
188
+
179
189
  ok = True
180
190
  for name, content, keyword in cases:
181
191
  if content == txt: