@topmindspace/tms-skills 0.1.0 → 0.1.2

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 (102) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +9 -3
  3. package/bin/tms-skills.js +200 -160
  4. package/package.json +12 -9
  5. package/top-ppt-html/README.md +162 -254
  6. package/top-ppt-html/SKILL.md +126 -111
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3926 -3926
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
  9. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -4325
  10. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  11. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -5527
  12. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  13. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  14. package/top-ppt-html/assets/style-gallery.html +589 -589
  15. package/top-ppt-html/assets/templates/architecture.html +3728 -3728
  16. package/top-ppt-html/assets/templates/engine.css +840 -840
  17. package/top-ppt-html/assets/templates/presentation.html +3738 -3738
  18. package/top-ppt-html/assets/templates/research.html +4017 -4017
  19. package/top-ppt-html/assets/templates/ui.js +520 -520
  20. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  21. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  22. package/top-ppt-html/assets/theme-overview.png +0 -0
  23. package/top-ppt-html/evals/prompts.csv +15 -15
  24. package/top-ppt-html/evals/rubric.schema.json +25 -25
  25. package/top-ppt-html/evals/run_evals.py +220 -220
  26. package/top-ppt-html/evals/trace.example.json +16 -16
  27. package/top-ppt-html/package-lock.json +186 -0
  28. package/top-ppt-html/package.json +11 -11
  29. package/top-ppt-html/references/charts-basic.md +624 -624
  30. package/top-ppt-html/references/charts-discipline.md +110 -108
  31. package/top-ppt-html/references/charts-extended.md +482 -482
  32. package/top-ppt-html/references/charts.md +37 -28
  33. package/top-ppt-html/references/components-atoms.md +624 -624
  34. package/top-ppt-html/references/components.md +30 -30
  35. package/top-ppt-html/references/content-rules.md +510 -490
  36. package/top-ppt-html/references/default-surface.md +61 -0
  37. package/top-ppt-html/references/design-system.md +478 -478
  38. package/top-ppt-html/references/failure-modes.md +227 -214
  39. package/top-ppt-html/references/high-fidelity.md +127 -127
  40. package/top-ppt-html/references/icons.md +176 -397
  41. package/top-ppt-html/references/infographics-stats.md +308 -308
  42. package/top-ppt-html/references/infographics-structure.md +226 -226
  43. package/top-ppt-html/references/infographics.md +43 -43
  44. package/top-ppt-html/references/layout-grammar.md +325 -315
  45. package/top-ppt-html/references/layouts-architecture.md +108 -108
  46. package/top-ppt-html/references/layouts-combo.md +600 -600
  47. package/top-ppt-html/references/layouts-research.md +160 -160
  48. package/top-ppt-html/references/modes.md +259 -254
  49. package/top-ppt-html/references/outline-design.md +275 -275
  50. package/top-ppt-html/references/playbook.md +284 -266
  51. package/top-ppt-html/references/pptx-export.md +227 -209
  52. package/top-ppt-html/references/presentation-craft.md +69 -0
  53. package/top-ppt-html/references/styles.md +370 -370
  54. package/top-ppt-html/references/tech-design.md +138 -138
  55. package/top-ppt-html/scripts/audit_css.py +109 -109
  56. package/top-ppt-html/scripts/audit_docs.py +176 -176
  57. package/top-ppt-html/scripts/audit_skill.py +231 -220
  58. package/top-ppt-html/scripts/audit_styles.py +351 -351
  59. package/top-ppt-html/scripts/build_examples.py +55 -2276
  60. package/top-ppt-html/scripts/build_pptx.js +2386 -2380
  61. package/top-ppt-html/scripts/capture_theme_overview.js +79 -78
  62. package/top-ppt-html/scripts/checks_html.py +166 -127
  63. package/top-ppt-html/scripts/cross_verify.py +298 -294
  64. package/top-ppt-html/scripts/env_probe.py +158 -158
  65. package/top-ppt-html/scripts/extract_model.py +210 -210
  66. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  67. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  68. package/top-ppt-html/scripts/layout-constants.json +111 -18
  69. package/top-ppt-html/scripts/lib_layout_regions.js +410 -412
  70. package/top-ppt-html/scripts/measure_height.py +178 -178
  71. package/top-ppt-html/scripts/negative_tests.py +433 -307
  72. package/top-ppt-html/scripts/package_skill.py +294 -291
  73. package/top-ppt-html/scripts/prepare_images.py +341 -341
  74. package/top-ppt-html/scripts/probe_image_export.py +187 -188
  75. package/top-ppt-html/scripts/quality_gate.py +309 -301
  76. package/top-ppt-html/scripts/recommend_layout.py +384 -0
  77. package/top-ppt-html/scripts/regression.py +308 -308
  78. package/top-ppt-html/scripts/render_compare.py +275 -275
  79. package/top-ppt-html/scripts/render_from_model.py +698 -698
  80. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  81. package/top-ppt-html/scripts/section-file-map.json +104 -104
  82. package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
  83. package/top-ppt-html/scripts/sync_runtime.py +659 -662
  84. package/top-ppt-html/scripts/validate_pptx.py +1579 -1510
  85. package/top-ppt-html/scripts/validate_report.py +1755 -1456
  86. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
  87. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
  88. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
  89. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
  90. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
  91. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
  92. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
  93. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
  94. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
  95. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
  96. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
  97. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
  98. package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
  99. package/top-ppt-html/references/design-system-engine.md +0 -235
  100. package/top-ppt-html/references/industry-benchmark.md +0 -105
  101. package/top-ppt-html/references/reform-plan.md +0 -252
  102. package/top-ppt-html/scripts/layout_slots.json +0 -830
@@ -1,210 +1,210 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- TopPPT HTML· 从 HTML 报告抽取 PPTX 内容模型
5
- 用法:
6
- python extract_model.py <报告.html> [输出.json]
7
-
8
- 从报告的 window.REPORT_MODEL(JSON)抽取内容模型,供 build_pptx.js --model= 使用。
9
- 保证 PPTX 与页面同源——模型即唯一事实源,不再两处维护。
10
- 校验双端同源:页型字段与必填约束读 scripts/model-schema.json(与浏览器端 validateModel
11
- 消费同一份 schema,经 sync_runtime.py 注入 assets/pptx-export.js,杜绝漂移)。
12
- 含模型-正文一致性抽查(标题/agenda 条数/页数粗对齐),不一致打印警告(硬门禁在 validate_report.py)。
13
- """
14
- import sys
15
- import re
16
- import json
17
- from pathlib import Path
18
-
19
- # Windows GBK 控制台兜底:强制 UTF-8 输出
20
- try:
21
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
22
- sys.stderr.reconfigure(encoding='utf-8', errors='replace')
23
- except Exception:
24
- pass
25
-
26
- SCHEMA_PATH = Path(__file__).resolve().parent / 'model-schema.json'
27
-
28
-
29
- def load_schema():
30
- """DSL schema 单源(model-schema.json);与浏览器端 validateModel 同一份定义。"""
31
- try:
32
- return json.loads(SCHEMA_PATH.read_text(encoding='utf-8'))
33
- except (OSError, json.JSONDecodeError) as e:
34
- print(f"警告: 无法读取 schema 单源 {SCHEMA_PATH}: {e}")
35
- return None
36
-
37
-
38
- def schema_get(obj, path):
39
- cur = obj
40
- for k in path.split('.'):
41
- if cur is None or not isinstance(cur, dict):
42
- return None
43
- cur = cur.get(k)
44
- return cur
45
-
46
-
47
- def schema_field_ok(sec, spec):
48
- """spec = 'a.b:array' / 'a.b:str' / 'a.b'(真值检查)/ 'anyof:a|b:c'(任一满足)。"""
49
- if spec.startswith('anyof:'):
50
- return any(schema_field_ok(sec, alt) for alt in spec[len('anyof:'):].split('|') if alt)
51
- m = re.match(r'^(.*?)(?::(array|str))?$', spec)
52
- path, kind = m.group(1), m.group(2)
53
- v = schema_get(sec, path)
54
- if kind == 'array':
55
- return isinstance(v, list) and len(v) > 0
56
- if kind == 'str':
57
- return isinstance(v, str) and bool(v.strip())
58
- return bool(v)
59
-
60
-
61
- def validate_against_schema(model, schema):
62
- """按 schema 单源校验模型;返回 (缺失列表, 警告列表)。与浏览器端 validateModel 同语义。"""
63
- missing, warnings = [], []
64
- if not schema:
65
- return missing, warnings
66
- ms = schema.get('model') or {}
67
- for spec in ms.get('required', []):
68
- if not schema_field_ok(model, spec):
69
- missing.append(f"{spec}(顶层必填)")
70
- mode = model.get('mode') or 'presentation'
71
- ag = model.get('agenda') or []
72
- ag_min = (ms.get('agendaMin') or {}).get(mode, 0)
73
- if ag_min > 0 and len(ag) < ag_min:
74
- missing.append(f"agenda(大纲 ≥{ag_min} 条)")
75
- secs = model.get('sections') or []
76
- if not isinstance(secs, list) or len(secs) < ms.get('sectionsMin', 1):
77
- missing.append("sections(章节页 ≥1)")
78
- page_types = schema.get('pageTypes') or {}
79
- for i, sec in enumerate(secs if isinstance(secs, list) else []):
80
- t = (sec.get('type') if isinstance(sec, dict) else None) or 'points'
81
- defn = page_types.get(t)
82
- if not defn:
83
- missing.append(f"sections[{i}].type={t!r}(未知页型)")
84
- continue
85
- if not (isinstance(sec, dict) and sec.get('title')):
86
- missing.append(f"sections[{i}].title(第 {i + 1} 章标题)")
87
- for spec in defn.get('required', []):
88
- if not schema_field_ok(sec, spec):
89
- missing.append(f"sections[{i}].{spec}({defn.get('label', t)}必填)")
90
- if defn.get('modes') and mode not in defn['modes']:
91
- warnings.append(f"sections[{i}] 页型 {t!r} 适用于 {'/'.join(defn['modes'])},当前 mode={mode!r}")
92
- if schema.get('modes') and model.get('mode') and model['mode'] not in schema['modes']:
93
- warnings.append(f"mode={model['mode']!r} 未知,按 presentation 处理")
94
- if secs and len(secs) < ms.get('sectionsRecommended', 3):
95
- warnings.append(f"章节页仅 {len(secs)} 页,正式报告建议 ≥3")
96
- if len(ag) > ms.get('agendaComfortMax', 16):
97
- warnings.append(f"agenda {len(ag)} 条超出单页舒适上限,建议拆分")
98
- return missing, warnings
99
-
100
-
101
- def main():
102
- if len(sys.argv) < 2:
103
- print(__doc__)
104
- return 2
105
- path = Path(sys.argv[1])
106
- if not path.exists():
107
- print(f"文件不存在: {path}")
108
- return 2
109
- txt = path.read_text(encoding='utf-8')
110
-
111
- m = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
112
- if not m:
113
- print("未找到 window.REPORT_MODEL —— 报告未内嵌内容模型。")
114
- print("请按 references/pptx-export.md 在报告 <script> 中补齐模型后再抽取。")
115
- return 1
116
- raw = m.group(1)
117
- try:
118
- model = json.loads(raw)
119
- except json.JSONDecodeError as e:
120
- print(f"REPORT_MODEL 不是合法 JSON: {e}")
121
- print("注意:模型必须是严格 JSON(双引号、无尾逗号、无注释)。")
122
- return 1
123
-
124
- # 模型字符串字段净化:禁止把 HTML 标签当纯文本写入(cite 只允许 [n])。
125
- # 泄漏标签会在 PPTX/预览里原样露出 <a class="cite"…>,属交付硬缺陷。
126
- stripped = {'count': 0, 'samples': []}
127
-
128
- def _scrub_assign(obj, path=''):
129
- if isinstance(obj, dict):
130
- for k in list(obj.keys()):
131
- v = obj[k]
132
- p = f'{path}.{k}' if path else k
133
- if isinstance(v, str) and re.search(r'</?[a-zA-Z][^>]*>', v):
134
- obj[k] = re.sub(r'\s+', ' ', re.sub(r'</?[a-zA-Z][^>]*>', '', v)).strip()
135
- stripped['count'] += 1
136
- if len(stripped['samples']) < 5:
137
- stripped['samples'].append(p)
138
- else:
139
- _scrub_assign(v, p)
140
- elif isinstance(obj, list):
141
- for i, v in enumerate(obj):
142
- p = f'{path}[{i}]'
143
- if isinstance(v, str) and re.search(r'</?[a-zA-Z][^>]*>', v):
144
- obj[i] = re.sub(r'\s+', ' ', re.sub(r'</?[a-zA-Z][^>]*>', '', v)).strip()
145
- stripped['count'] += 1
146
- if len(stripped['samples']) < 5:
147
- stripped['samples'].append(p)
148
- else:
149
- _scrub_assign(v, p)
150
-
151
- _scrub_assign(model)
152
- if stripped['count']:
153
- print(f"[净化] 剥离 {stripped['count']} 处模型字段中的 HTML 标签: {stripped['samples']}")
154
- print(" 注意:引用在模型里写 [n] 纯文本;HTML 正文才用 <a class=\"cite\">。禁止把标签写进模型。")
155
-
156
- # 风格/模式/主题兜底:模型未写时取页面 data-style / data-mode / data-theme
157
- if not model.get('style'):
158
- sm = re.search(r'<html[^>]*data-style="([^"]+)"', txt)
159
- if sm:
160
- model['style'] = sm.group(1)
161
- if not model.get('mode'):
162
- mm = re.search(r'<html[^>]*data-mode="([^"]+)"', txt)
163
- if mm:
164
- model['mode'] = mm.group(1)
165
- if not model.get('theme'):
166
- tm = re.search(r'<html[^>]*data-theme="([^"]+)"', txt)
167
- if tm:
168
- model['theme'] = tm.group(1)
169
-
170
- # schema 单源校验(与浏览器端 validateModel 同一份 model-schema.json)
171
- schema = load_schema()
172
- missing, schema_warn = validate_against_schema(model, schema)
173
- if missing:
174
- print(f"模型缺字段 {len(missing)} 项(导出的 PPTX 相应页面会为空或跳过):")
175
- for msg in missing:
176
- print(f" - {msg}")
177
- for msg in schema_warn:
178
- print(f"[schema 警告] {msg}")
179
-
180
- # 模型-正文一致性抽查(标题 / agenda 条数 / 页数粗对齐)
181
- issues = []
182
- plain = re.sub(r'<[^>]+>', ' ', txt)
183
- secs = model.get('sections') or []
184
- miss_titles = [str(s.get('title'))[:14] for s in secs
185
- if s.get('title') and str(s.get('title')) not in plain]
186
- if miss_titles:
187
- issues.append(f"{len(miss_titles)} 个章节标题未在正文出现: {miss_titles[:3]}")
188
- n_ag = len(model.get('agenda') or [])
189
- n_html_ag = txt.count('class="agenda__a"')
190
- if n_ag and n_html_ag and n_ag != n_html_ag:
191
- issues.append(f"agenda 条数不一致: model={n_ag} 正文={n_html_ag}")
192
- n_bands = len(re.findall(r'<section class="band', txt))
193
- if secs and not (len(secs) + 3 <= n_bands <= len(secs) + 7):
194
- issues.append(f"页数粗不匹配: 正文 {n_bands} 页 vs 模型 {len(secs)}+3~7")
195
- for msg in issues:
196
- print(f"[一致性警告] {msg}")
197
-
198
- out = Path(sys.argv[2]) if len(sys.argv) > 2 else path.with_suffix('.model.json')
199
- out.write_text(json.dumps(model, ensure_ascii=False, indent=2), encoding='utf-8')
200
- n_sec = len(model.get('sections', []))
201
- print(f"已抽取: {out} (agenda {len(model.get('agenda', []))} 条 · sections {n_sec} 页 · style={model.get('style', 'business-blue')} · theme={model.get('theme', 'light')})")
202
- if missing:
203
- print("注意:模型不完整,正式交付前应回 AI 对话补全(页面预览模态含可复制提示词)。")
204
- print("下一步:")
205
- print(f' NODE_PATH=<pptxgenjs 所在 node_modules> <node> scripts/build_pptx.js "报告.pptx" --model="{out}"')
206
- return 0
207
-
208
-
209
- if __name__ == '__main__':
210
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ TopPPT HTML· 从 HTML 报告抽取 PPTX 内容模型
5
+ 用法:
6
+ python extract_model.py <报告.html> [输出.json]
7
+
8
+ 从报告的 window.REPORT_MODEL(JSON)抽取内容模型,供 build_pptx.js --model= 使用。
9
+ 保证 PPTX 与页面同源——模型即唯一事实源,不再两处维护。
10
+ 校验双端同源:页型字段与必填约束读 scripts/model-schema.json(与浏览器端 validateModel
11
+ 消费同一份 schema,经 sync_runtime.py 注入 assets/pptx-export.js,杜绝漂移)。
12
+ 含模型-正文一致性抽查(标题/agenda 条数/页数粗对齐),不一致打印警告(硬门禁在 validate_report.py)。
13
+ """
14
+ import sys
15
+ import re
16
+ import json
17
+ from pathlib import Path
18
+
19
+ # Windows GBK 控制台兜底:强制 UTF-8 输出
20
+ try:
21
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
22
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
23
+ except Exception:
24
+ pass
25
+
26
+ SCHEMA_PATH = Path(__file__).resolve().parent / 'model-schema.json'
27
+
28
+
29
+ def load_schema():
30
+ """DSL schema 单源(model-schema.json);与浏览器端 validateModel 同一份定义。"""
31
+ try:
32
+ return json.loads(SCHEMA_PATH.read_text(encoding='utf-8'))
33
+ except (OSError, json.JSONDecodeError) as e:
34
+ print(f"警告: 无法读取 schema 单源 {SCHEMA_PATH}: {e}")
35
+ return None
36
+
37
+
38
+ def schema_get(obj, path):
39
+ cur = obj
40
+ for k in path.split('.'):
41
+ if cur is None or not isinstance(cur, dict):
42
+ return None
43
+ cur = cur.get(k)
44
+ return cur
45
+
46
+
47
+ def schema_field_ok(sec, spec):
48
+ """spec = 'a.b:array' / 'a.b:str' / 'a.b'(真值检查)/ 'anyof:a|b:c'(任一满足)。"""
49
+ if spec.startswith('anyof:'):
50
+ return any(schema_field_ok(sec, alt) for alt in spec[len('anyof:'):].split('|') if alt)
51
+ m = re.match(r'^(.*?)(?::(array|str))?$', spec)
52
+ path, kind = m.group(1), m.group(2)
53
+ v = schema_get(sec, path)
54
+ if kind == 'array':
55
+ return isinstance(v, list) and len(v) > 0
56
+ if kind == 'str':
57
+ return isinstance(v, str) and bool(v.strip())
58
+ return bool(v)
59
+
60
+
61
+ def validate_against_schema(model, schema):
62
+ """按 schema 单源校验模型;返回 (缺失列表, 警告列表)。与浏览器端 validateModel 同语义。"""
63
+ missing, warnings = [], []
64
+ if not schema:
65
+ return missing, warnings
66
+ ms = schema.get('model') or {}
67
+ for spec in ms.get('required', []):
68
+ if not schema_field_ok(model, spec):
69
+ missing.append(f"{spec}(顶层必填)")
70
+ mode = model.get('mode') or 'presentation'
71
+ ag = model.get('agenda') or []
72
+ ag_min = (ms.get('agendaMin') or {}).get(mode, 0)
73
+ if ag_min > 0 and len(ag) < ag_min:
74
+ missing.append(f"agenda(大纲 ≥{ag_min} 条)")
75
+ secs = model.get('sections') or []
76
+ if not isinstance(secs, list) or len(secs) < ms.get('sectionsMin', 1):
77
+ missing.append("sections(章节页 ≥1)")
78
+ page_types = schema.get('pageTypes') or {}
79
+ for i, sec in enumerate(secs if isinstance(secs, list) else []):
80
+ t = (sec.get('type') if isinstance(sec, dict) else None) or 'points'
81
+ defn = page_types.get(t)
82
+ if not defn:
83
+ missing.append(f"sections[{i}].type={t!r}(未知页型)")
84
+ continue
85
+ if not (isinstance(sec, dict) and sec.get('title')):
86
+ missing.append(f"sections[{i}].title(第 {i + 1} 章标题)")
87
+ for spec in defn.get('required', []):
88
+ if not schema_field_ok(sec, spec):
89
+ missing.append(f"sections[{i}].{spec}({defn.get('label', t)}必填)")
90
+ if defn.get('modes') and mode not in defn['modes']:
91
+ warnings.append(f"sections[{i}] 页型 {t!r} 适用于 {'/'.join(defn['modes'])},当前 mode={mode!r}")
92
+ if schema.get('modes') and model.get('mode') and model['mode'] not in schema['modes']:
93
+ warnings.append(f"mode={model['mode']!r} 未知,按 presentation 处理")
94
+ if secs and len(secs) < ms.get('sectionsRecommended', 3):
95
+ warnings.append(f"章节页仅 {len(secs)} 页,正式报告建议 ≥3")
96
+ if len(ag) > ms.get('agendaComfortMax', 16):
97
+ warnings.append(f"agenda {len(ag)} 条超出单页舒适上限,建议拆分")
98
+ return missing, warnings
99
+
100
+
101
+ def main():
102
+ if len(sys.argv) < 2:
103
+ print(__doc__)
104
+ return 2
105
+ path = Path(sys.argv[1])
106
+ if not path.exists():
107
+ print(f"文件不存在: {path}")
108
+ return 2
109
+ txt = path.read_text(encoding='utf-8')
110
+
111
+ m = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
112
+ if not m:
113
+ print("未找到 window.REPORT_MODEL —— 报告未内嵌内容模型。")
114
+ print("请按 references/pptx-export.md 在报告 <script> 中补齐模型后再抽取。")
115
+ return 1
116
+ raw = m.group(1)
117
+ try:
118
+ model = json.loads(raw)
119
+ except json.JSONDecodeError as e:
120
+ print(f"REPORT_MODEL 不是合法 JSON: {e}")
121
+ print("注意:模型必须是严格 JSON(双引号、无尾逗号、无注释)。")
122
+ return 1
123
+
124
+ # 模型字符串字段净化:禁止把 HTML 标签当纯文本写入(cite 只允许 [n])。
125
+ # 泄漏标签会在 PPTX/预览里原样露出 <a class="cite"…>,属交付硬缺陷。
126
+ stripped = {'count': 0, 'samples': []}
127
+
128
+ def _scrub_assign(obj, path=''):
129
+ if isinstance(obj, dict):
130
+ for k in list(obj.keys()):
131
+ v = obj[k]
132
+ p = f'{path}.{k}' if path else k
133
+ if isinstance(v, str) and re.search(r'</?[a-zA-Z][^>]*>', v):
134
+ obj[k] = re.sub(r'\s+', ' ', re.sub(r'</?[a-zA-Z][^>]*>', '', v)).strip()
135
+ stripped['count'] += 1
136
+ if len(stripped['samples']) < 5:
137
+ stripped['samples'].append(p)
138
+ else:
139
+ _scrub_assign(v, p)
140
+ elif isinstance(obj, list):
141
+ for i, v in enumerate(obj):
142
+ p = f'{path}[{i}]'
143
+ if isinstance(v, str) and re.search(r'</?[a-zA-Z][^>]*>', v):
144
+ obj[i] = re.sub(r'\s+', ' ', re.sub(r'</?[a-zA-Z][^>]*>', '', v)).strip()
145
+ stripped['count'] += 1
146
+ if len(stripped['samples']) < 5:
147
+ stripped['samples'].append(p)
148
+ else:
149
+ _scrub_assign(v, p)
150
+
151
+ _scrub_assign(model)
152
+ if stripped['count']:
153
+ print(f"[净化] 剥离 {stripped['count']} 处模型字段中的 HTML 标签: {stripped['samples']}")
154
+ print(" 注意:引用在模型里写 [n] 纯文本;HTML 正文才用 <a class=\"cite\">。禁止把标签写进模型。")
155
+
156
+ # 风格/模式/主题兜底:模型未写时取页面 data-style / data-mode / data-theme
157
+ if not model.get('style'):
158
+ sm = re.search(r'<html[^>]*data-style="([^"]+)"', txt)
159
+ if sm:
160
+ model['style'] = sm.group(1)
161
+ if not model.get('mode'):
162
+ mm = re.search(r'<html[^>]*data-mode="([^"]+)"', txt)
163
+ if mm:
164
+ model['mode'] = mm.group(1)
165
+ if not model.get('theme'):
166
+ tm = re.search(r'<html[^>]*data-theme="([^"]+)"', txt)
167
+ if tm:
168
+ model['theme'] = tm.group(1)
169
+
170
+ # schema 单源校验(与浏览器端 validateModel 同一份 model-schema.json)
171
+ schema = load_schema()
172
+ missing, schema_warn = validate_against_schema(model, schema)
173
+ if missing:
174
+ print(f"模型缺字段 {len(missing)} 项(导出的 PPTX 相应页面会为空或跳过):")
175
+ for msg in missing:
176
+ print(f" - {msg}")
177
+ for msg in schema_warn:
178
+ print(f"[schema 警告] {msg}")
179
+
180
+ # 模型-正文一致性抽查(标题 / agenda 条数 / 页数粗对齐)
181
+ issues = []
182
+ plain = re.sub(r'<[^>]+>', ' ', txt)
183
+ secs = model.get('sections') or []
184
+ miss_titles = [str(s.get('title'))[:14] for s in secs
185
+ if s.get('title') and str(s.get('title')) not in plain]
186
+ if miss_titles:
187
+ issues.append(f"{len(miss_titles)} 个章节标题未在正文出现: {miss_titles[:3]}")
188
+ n_ag = len(model.get('agenda') or [])
189
+ n_html_ag = txt.count('class="agenda__a"')
190
+ if n_ag and n_html_ag and n_ag != n_html_ag:
191
+ issues.append(f"agenda 条数不一致: model={n_ag} 正文={n_html_ag}")
192
+ n_bands = len(re.findall(r'<section class="band', txt))
193
+ if secs and not (len(secs) + 3 <= n_bands <= len(secs) + 7):
194
+ issues.append(f"页数粗不匹配: 正文 {n_bands} 页 vs 模型 {len(secs)}+3~7")
195
+ for msg in issues:
196
+ print(f"[一致性警告] {msg}")
197
+
198
+ out = Path(sys.argv[2]) if len(sys.argv) > 2 else path.with_suffix('.model.json')
199
+ out.write_text(json.dumps(model, ensure_ascii=False, indent=2), encoding='utf-8')
200
+ n_sec = len(model.get('sections', []))
201
+ print(f"已抽取: {out} (agenda {len(model.get('agenda', []))} 条 · sections {n_sec} 页 · style={model.get('style', 'business-blue')} · theme={model.get('theme', 'light')})")
202
+ if missing:
203
+ print("注意:模型不完整,正式交付前应回 AI 对话补全(页面预览模态含可复制提示词)。")
204
+ print("下一步:")
205
+ print(f' NODE_PATH=<pptxgenjs 所在 node_modules> <node> scripts/build_pptx.js "报告.pptx" --model="{out}"')
206
+ return 0
207
+
208
+
209
+ if __name__ == '__main__':
210
+ sys.exit(main())