renderer.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. renderer.py — Procedure DSL 可视化共享模板.
  5. 每个 case 提供 case_data (一个 dict, 见 build_html 的 docstring),
  6. import 本模块并调用 build_html(case_data) → HTML 字符串.
  7. spec 引用: spec.md §12 (.html 可视化结构规范).
  8. """
  9. import html
  10. import json
  11. import re
  12. from pathlib import Path
  13. # ── 数据 loaders (从 spec/taxonomy 单一来源加载) ────────────────────
  14. def _load_json(rel_path: str) -> dict:
  15. """从 spec/ 子路径加载 JSON. rel_path 是相对 spec/ 的路径 (e.g. 'taxonomy/type.json')."""
  16. return json.loads((Path(__file__).resolve().parent.parent / rel_path).read_text(encoding='utf-8'))
  17. def _load_stdlib_types() -> dict:
  18. """type stdlib 渲染元信息. 新结构 type.json 无 type_metadata → 返回 {};
  19. stdlib 叶子的 in_tree 标记在 build_html 里按 type.json 的 $leaves 补齐."""
  20. return {}
  21. _DRAWER_TITLES = {
  22. 'effect': ('作用 (chip 上 data-prefix="作用")',
  23. '这一步在 AIGC 生产工序链中的位置 + 作用; 从 §A.1 字典树取 L3 叶子'),
  24. 'action': ('动作 (chip 上 data-prefix="动作")',
  25. '这一步的动作动词; 从 §A.2 字典树取路径'),
  26. 'type': ('类型 (chip 上 data-type)',
  27. '领域语义类型 (按功能角色分类: 程序控制/数据复用/内容/知识); 从 §A.3 字典树取叶子'),
  28. }
  29. def _add_label_suffix(node, depth=0):
  30. """递归把 dict 的 key 加 (L1)/(L2) 显示后缀, 让 drawer UI 区分层级.
  31. leaf string 保留原状; $* 元数据 key 跳过."""
  32. if not isinstance(node, dict):
  33. return node
  34. result = {}
  35. for k, v in node.items():
  36. if k.startswith('$'):
  37. continue
  38. new_k = k
  39. if isinstance(v, dict):
  40. if depth == 0:
  41. new_k = f'{k} (L1)'
  42. elif depth == 1:
  43. new_k = f'{k} (L2)'
  44. result[new_k] = _add_label_suffix(v, depth + 1)
  45. return result
  46. def _tree_to_drawer(nodes):
  47. """新结构 最终分类树 (list of {分类名称, 分类说明, 子分类}) → drawer 嵌套 dict.
  48. 叶子 → {名称: 分类说明 字符串}; 非叶 → {名称: 子树 dict}. 层级即 根→叶 路径."""
  49. out = {}
  50. for n in nodes or []:
  51. name = n.get('分类名称', '?')
  52. kids = n.get('子分类') or []
  53. out[name] = _tree_to_drawer(kids) if kids else (n.get('分类说明', '') or '')
  54. return out
  55. def _load_drawer_tree(dim: str) -> dict:
  56. """spec/taxonomy/{dim}.json → 重塑为 drawer UI 需要的 {title, desc, tree} 格式.
  57. 新结构读 最终分类树 (中文键); 自动加 (L1)/(L2) 层级标签. dim ∈ {'effect', 'action', 'type'}."""
  58. raw = _load_json(f'taxonomy/{dim}.json')
  59. title, desc = _DRAWER_TITLES[dim]
  60. nested = _tree_to_drawer(raw.get('最终分类树', []))
  61. return {
  62. 'title': title,
  63. 'desc': desc,
  64. 'tree': _add_label_suffix(nested, depth=0),
  65. }
  66. # =============================================================================
  67. # STDLIB · type registry (字典树 §A.3 叶子的 in_tree 标记基础; 现为空, build_html 按 $leaves 补齐)
  68. # =============================================================================
  69. STDLIB_TYPE_REGISTRY = _load_stdlib_types()
  70. # =============================================================================
  71. # 字典树 (spec §A.1 作用 / §A.2 动作 / §A.3 类型)
  72. # =============================================================================
  73. EFFECT_TREE = _load_drawer_tree("effect")
  74. ACTION_TREE = _load_drawer_tree("action")
  75. TYPE_TREE = _load_drawer_tree("type")
  76. def _build_type_paths() -> dict:
  77. """type.json 最终分类树 → {叶子名: '根/.../叶子' 路径}, 供 chip 显示完整路径."""
  78. raw = _load_json('taxonomy/type.json')
  79. out: dict = {}
  80. def walk(nodes, prefix):
  81. for n in nodes:
  82. path = prefix + [n.get('分类名称', '')]
  83. kids = n.get('子分类') or []
  84. if kids:
  85. walk(kids, path)
  86. else:
  87. out[n.get('分类名称', '')] = '/'.join(path)
  88. walk(raw.get('最终分类树', []), [])
  89. return out
  90. TYPE_PATHS = _build_type_paths()
  91. FEATURE_TAXONOMY = _load_json("taxonomy/feature.json")
  92. EXTERNAL_TAXONOMIES = {
  93. '实质': {'title': '实质 (内容是什么)', 'desc': '理念 / 表象 — 911 路径',
  94. 'source': 'external', 'file': '分类库导出_实质_*.json'},
  95. '形式': {'title': '形式 (内容怎么呈现)', 'desc': '呈现 / 架构 — 565 路径',
  96. 'source': 'external', 'file': '分类库导出_形式_*.json'},
  97. }
  98. # =============================================================================
  99. # Render helpers
  100. # =============================================================================
  101. def he(s):
  102. """HTML escape, 默认 quote=True 把 `"` 转成 `"`.
  103. 这点关键: title / data-* / class 等属性值如果含 `"` 会提前终止属性,
  104. 导致 tooltip 显示不完整 (典型 bug: '原文方法 3 只说"自己写动作"' 在 title 中只显示到 "只说" 就被截断).
  105. """
  106. if s is None:
  107. return ''
  108. return html.escape(str(s))
  109. def render_intent(text):
  110. """目的列: 简短自然语言句, **尽量** 把其他列里的结构化值都做成 {kind:value} token.
  111. 每个 token 底色对应其引用的列, 让人一眼看出该值来自哪里. 合法 kind:
  112. effect → 作用列 (灰, 需求组)
  113. via → 外部工具列 (浅绿 + 等宽字体, 实现组)
  114. act → 动作列 (绿, 实现组)
  115. control → 逻辑控制列 (浅青, 实现组) — 并行/遍历/分支/请求/等待
  116. in-type → 输入·类型 (黄圆胶囊) out-type → 输出·类型 (蓝圆胶囊)
  117. in-sub → 输入·实质 (黄矩形 tag) out-sub → 输出·实质 (蓝矩形 tag)
  118. in-form → 输入·形式 (黄矩形 tag 斜体) out-form → 输出·形式 (蓝矩形 tag 斜体)
  119. **特性列 (feature) 不允许在 intent 中引用** — feature 是内部执行特征 (随机/幂等/人工/读写外部),
  120. 不出现在面向使用者的描述. 写成 `ik-other` 灰色显示作为 lint 警告.
  121. 严禁变量名 token (no `{in:X}` / `{out:X}`); 严禁 dataflow 公式 / case-specific 简写.
  122. """
  123. def sub(m):
  124. kind = m.group(1)
  125. val = m.group(2)
  126. kc = {
  127. 'effect': 'ik-effect',
  128. 'via': 'ik-via',
  129. 'act': 'ik-act',
  130. 'control': 'ik-control',
  131. 'in-type': 'ik-in-type', 'out-type': 'ik-out-type',
  132. 'in-sub': 'ik-in-sub', 'out-sub': 'ik-out-sub',
  133. 'in-form': 'ik-in-form', 'out-form': 'ik-out-form',
  134. }.get(kind, 'ik-other')
  135. return f'<span class="intent-tok {kc}">{he(val)}</span>'
  136. return re.sub(r'\{([\w-]+):([^}]+)\}', sub, text or '')
  137. def render_chip(type_name):
  138. if not type_name:
  139. return ''
  140. if type_name == '-':
  141. return he(type_name)
  142. # 显示完整路径 (e.g. 程序控制类型/指令/提示词); data-type 仍存叶子名, drawer 查找不受影响.
  143. # case-specific 类型 (不在字典树) 显示原名.
  144. disp = TYPE_PATHS.get(type_name, type_name)
  145. return f'<span class="chip" data-type="{he(type_name)}">{he(disp)}</span>'
  146. def render_path(prefix, value):
  147. if not value:
  148. return ''
  149. if isinstance(value, list):
  150. spans = []
  151. for val in value:
  152. if val:
  153. spans.append(f'<span data-prefix="{prefix}" data-value="{he(val)}">{he(val)}</span>')
  154. return '\n'.join(spans)
  155. if isinstance(value, str):
  156. if '+' in value:
  157. parts = [p.strip() for p in value.split('+') if p.strip()]
  158. spans = []
  159. for val in parts:
  160. spans.append(f'<span data-prefix="{prefix}" data-value="{he(val)}">{he(val)}</span>')
  161. return '\n'.join(spans)
  162. return f'<span data-prefix="{prefix}" data-value="{he(value)}">{he(value)}</span>'
  163. return ''
  164. _VALUE_DESC_RE = re.compile(r'^<(.+)>$', re.DOTALL)
  165. def render_value(vl):
  166. """值列渲染:
  167. - 若整段以 `<...>` 括起 → 渲染为斜体浅灰背景, 表示"这是对内容的描述, 不是内容本身"
  168. (适用于无法在 cell 中直接嵌入的非文本数据: 视频/图像/音频).
  169. - 否则 → 渲染为普通文本 (适用于文本数据本身, 如 prompt 全文).
  170. """
  171. if vl is None:
  172. return ''
  173. s = str(vl).strip()
  174. m = _VALUE_DESC_RE.match(s)
  175. if m:
  176. inner = m.group(1)
  177. return f'<span class="value-desc">&lt;{he(inner)}&gt;</span>'
  178. return f'<span class="natural">{he(s)}</span>'
  179. def render_focus_class(cell_key, focus_list):
  180. return ' row-focus' if cell_key in (focus_list or []) else ''
  181. def cell_attrs(field_key, focus, inferred_marks, io_reason=None, is_empty=False):
  182. """组合 cell 的额外 class 和属性 (focus + 推断补全).
  183. field_key 例: 'action', 'in-value-0', 'out-substance-1', etc.
  184. inferred_marks: step 级 {field_key: 推断理由} dict (field 级推断, 如 "工具未指名").
  185. io_reason: 当前 cell 所属 IO item 整体被标 inferred 时, 传入 reason 字符串
  186. (优先级低于 inferred_marks 显式 field-level 标注).
  187. is_empty: 该 cell 的内容是否为空. 在推断模式下空 cell 转为低置信变体 (角标 推?), 表示
  188. "AI 想过但拿不准, 故意留空", 区别于硬补值的高置信「推」. 参 spec §推断补全标记 C.
  189. 返回 (class_suffix_str, extra_attrs_str).
  190. """
  191. cls = render_focus_class(field_key, focus)
  192. extra = ''
  193. reason = (inferred_marks or {}).get(field_key) or io_reason
  194. if reason:
  195. cls += ' is-inferred'
  196. if is_empty:
  197. cls += ' is-low-confidence'
  198. extra = f' title="推断补全: {he(reason)}"'
  199. return cls, extra
  200. def render_step_row(step, idx_label, type_reg=None):
  201. """渲染一个 step (kind: step / block / nested) 为一组 tr 行.
  202. type_reg: 已合并 STDLIB + case-specific 的类型注册表 (用于 in_tree 标记 + case-specific chip 区分).
  203. """
  204. inputs = step.get('inputs', [])
  205. outputs = step.get('outputs', [])
  206. N = max(len(inputs), len(outputs), 1)
  207. focus = step.get('focus', [])
  208. infm = step.get('inferred_marks', {})
  209. is_nested = step['kind'] == 'nested'
  210. is_block = step['kind'] == 'block'
  211. is_atom = step.get('kind') == 'atom'
  212. main_cls = 'step step-main'
  213. if is_block:
  214. main_cls = 'step block-header'
  215. if is_nested:
  216. main_cls = 'step step-main step-nested'
  217. if is_atom:
  218. main_cls = 'step atom-row'
  219. sub_cls = 'step step-sub atom-row' if is_atom else 'step step-sub'
  220. data_step = step['id']
  221. data_group = step.get('group', '')
  222. parent_step = step.get('parent_step', '')
  223. rows = []
  224. for k in range(N):
  225. tr_cls = main_cls if k == 0 else sub_cls
  226. attrs = f' data-step="{data_step}"'
  227. if data_group:
  228. attrs += f' data-group="{data_group}"'
  229. if parent_step:
  230. attrs += f' data-atom-of="{parent_step}"'
  231. attrs += f' data-focus="{",".join(focus)}"'
  232. # anchor id 给主行 (k=0), 用于跨页跳转 (能力浏览页 → 工序页特定 step/atom)
  233. if k == 0:
  234. row_id = f'{parent_step}-{data_step}' if (is_atom and parent_step) else data_step
  235. attrs = f' id="{row_id}"' + attrs
  236. cells = []
  237. if k == 0:
  238. rs = f' rowspan="{N}"' if N > 1 else ''
  239. if is_atom:
  240. cells.append(f'<td class="idx atom-idx"{rs}>↳ {he(idx_label)}</td>')
  241. else:
  242. arrow = '<span class="arrow">▼</span> ' if is_block else ''
  243. indent = ' └ ' if is_nested else ''
  244. badge = ''
  245. if step.get('atoms'):
  246. n_atoms = len(step['atoms'])
  247. badge = f'<br><span class="atom-badge" data-step="{he(data_step)}" title="显示/隐藏 {n_atoms} 个原子能力">⚛{n_atoms} <span class="atom-arrow">▸</span></span>'
  248. cells.append(f'<td class="idx{render_focus_class("idx", focus)}"{rs}>{arrow}{indent}{he(idx_label)}{badge}</td>')
  249. intent_html = render_intent(step.get('intent', ''))
  250. c, a = cell_attrs('intent', focus, infm)
  251. if is_atom:
  252. atom_name = he(step.get('name', ''))
  253. cells.append(f'<td class="intent{c}"{a}{rs}><div class="atom-name">{atom_name}</div><div class="intent-text">{intent_html}</div></td>')
  254. else:
  255. cells.append(f'<td class="intent{c}"{a}{rs}><div class="intent-text">{intent_html}</div></td>')
  256. c, a = cell_attrs('effect', focus, infm)
  257. cells.append(f'<td class="effect{c}"{a}{rs}>{render_path("作用", step.get("effect", ""))}</td>')
  258. in_item = inputs[k] if k < len(inputs) else None
  259. if in_item:
  260. sub = in_item.get('substance'); frm = in_item.get('form')
  261. tp = in_item.get('type'); nm = in_item.get('name')
  262. vl = in_item.get('value'); ac = in_item.get('anchor')
  263. io_inf = in_item.get('inferred_reason') if in_item.get('inferred') else None
  264. c, a = cell_attrs(f'in-substance-{k}', focus, infm, io_inf, is_empty=not sub)
  265. cells.append(f'<td class="in-substance{c}"{a}>{render_path("实质", sub) if sub else ""}</td>')
  266. c, a = cell_attrs(f'in-form-{k}', focus, infm, io_inf, is_empty=not frm)
  267. cells.append(f'<td class="in-form{c}"{a}>{render_path("形式", frm) if frm else ""}</td>')
  268. c, a = cell_attrs(f'in-type-{k}', focus, infm, io_inf, is_empty=not tp)
  269. cells.append(f'<td class="in-type{c}"{a}>{render_chip(tp)}</td>')
  270. c, a = cell_attrs(f'in-name-{k}', focus, infm, io_inf, is_empty=not nm)
  271. cells.append(f'<td class="in-name{c}"{a}><span class="name" data-var="{he(nm)}">{he(nm)}</span></td>')
  272. c, a = cell_attrs(f'in-value-{k}', focus, infm, io_inf, is_empty=not vl)
  273. cells.append(f'<td class="in-value{c}"{a}>{render_value(vl)}</td>')
  274. c, a = cell_attrs(f'in-anchor-{k}', focus, infm, io_inf, is_empty=not ac)
  275. cells.append(f'<td class="in-anchor{c}"{a}><span class="flow">{he(ac)}</span></td>')
  276. else:
  277. cells += ['<td class="in-substance"></td>', '<td class="in-form"></td>',
  278. '<td class="in-type"></td>', '<td class="in-name"></td>', '<td class="in-value"></td>',
  279. '<td class="in-anchor"></td>']
  280. if k == 0:
  281. rs = f' rowspan="{N}"' if N > 1 else ''
  282. c, a = cell_attrs('via', focus, infm)
  283. cells.append(f'<td class="via{c}"{a}{rs}>{he(step.get("via", ""))}</td>')
  284. c, a = cell_attrs('action', focus, infm)
  285. cells.append(f'<td class="action{c}"{a}{rs}>{render_path("动作", step.get("action", ""))}</td>')
  286. # 指令列拆为 4 个子列: directive / config / decorator / memo
  287. # memo = 其他结构化字段没能包含的实现方法信息 (经验性招法 / variant 说明 / 选型理由 等)
  288. instr_by_kind = {'directive': [], 'config': [], 'decorator': [], 'memo': []}
  289. for kind, txt in step.get('instruction', []):
  290. if kind in instr_by_kind:
  291. instr_by_kind[kind].append(txt)
  292. else:
  293. # 历史 tag (trick/note) 兼容: 一并合并到 memo
  294. instr_by_kind['memo'].append(txt)
  295. for col_name in ('directive', 'config', 'decorator', 'memo'):
  296. items = instr_by_kind[col_name]
  297. inner = ''.join(f'<div class="instr-item"><span class="natural">{he(t)}</span></div>' for t in items)
  298. c, a = cell_attrs(col_name, focus, infm)
  299. cells.append(f'<td class="{col_name}{c}"{a}{rs}>{inner}</td>')
  300. c, a = cell_attrs('control', focus, infm)
  301. cells.append(f'<td class="control{c}"{a}{rs}>{render_path("逻辑控制", step.get("control", ""))}</td>')
  302. c, a = cell_attrs('feature', focus, infm)
  303. cells.append(f'<td class="feature{c}"{a}{rs}>{render_path("特性", step.get("feature", ""))}</td>')
  304. out_item = outputs[k] if k < len(outputs) else None
  305. if out_item:
  306. sub = out_item.get('substance'); frm = out_item.get('form')
  307. tp = out_item.get('type'); nm = out_item.get('name')
  308. vl = out_item.get('value'); ac = out_item.get('anchor')
  309. io_inf = out_item.get('inferred_reason') if out_item.get('inferred') else None
  310. c, a = cell_attrs(f'out-substance-{k}', focus, infm, io_inf, is_empty=not sub)
  311. cells.append(f'<td class="out-substance{c}"{a}>{render_path("实质", sub) if sub else ""}</td>')
  312. c, a = cell_attrs(f'out-form-{k}', focus, infm, io_inf, is_empty=not frm)
  313. cells.append(f'<td class="out-form{c}"{a}>{render_path("形式", frm) if frm else ""}</td>')
  314. c, a = cell_attrs(f'out-type-{k}', focus, infm, io_inf, is_empty=not tp)
  315. cells.append(f'<td class="out-type{c}"{a}>{render_chip(tp)}</td>')
  316. c, a = cell_attrs(f'out-name-{k}', focus, infm, io_inf, is_empty=not nm)
  317. cells.append(f'<td class="out-name{c}"{a}><span class="name" data-var="{he(nm)}">{he(nm)}</span></td>')
  318. c, a = cell_attrs(f'out-value-{k}', focus, infm, io_inf, is_empty=not vl)
  319. cells.append(f'<td class="out-value{c}"{a}>{render_value(vl)}</td>')
  320. c, a = cell_attrs(f'out-anchor-{k}', focus, infm, io_inf, is_empty=not ac)
  321. cells.append(f'<td class="out-anchor{c}"{a}><span class="flow">{he(ac)}</span></td>')
  322. else:
  323. cells += ['<td class="out-substance"></td>', '<td class="out-form"></td>',
  324. '<td class="out-type"></td>', '<td class="out-name"></td>', '<td class="out-value"></td>',
  325. '<td class="out-anchor"></td>']
  326. rows.append(f'<tr class="{tr_cls}"{attrs}>{"".join(cells)}</tr>')
  327. return '\n '.join(rows)
  328. def render_declarations(case_data, procedure=None):
  329. """工序头部 + declare 块 (输入/资源/返回), 可折叠.
  330. procedure: 该工序的 dict (新 schema 下 case_data.procedures[i]). 不传时退化到
  331. 老 case_data.procedure (兼容老 case 直到全部迁移完).
  332. """
  333. proc = procedure if procedure is not None else case_data.get('procedure', {})
  334. decls = proc.get('declarations') if procedure is not None else case_data.get('declarations', {})
  335. decls = decls or {}
  336. parts = []
  337. parts.append('<details class="declarations" open>')
  338. parts.append('<summary class="decl-summary">')
  339. parts.append(f' <span class="kw">工序</span> <b class="proc-name">{he(proc.get("name", ""))}</b>')
  340. if proc.get('purpose'):
  341. parts.append(f' <span class="decl-purpose">#目的: {he(proc["purpose"])}</span>')
  342. if proc.get('category'):
  343. parts.append(f' <span class="tag-mini">类别: {he(proc["category"])}</span>')
  344. meta_bits = []
  345. for label, key in [('平台', 'platform'), ('作者', 'author')]:
  346. if proc.get(key):
  347. meta_bits.append(f'#{label}: {he(proc[key])}')
  348. if case_data.get('case_id') is not None:
  349. meta_bits.append(f'case: {he(case_data["case_id"])}')
  350. if meta_bits:
  351. parts.append(f' <span class="decl-meta">{" · ".join(meta_bits)}</span>')
  352. parts.append('</summary>')
  353. parts.append('<div class="decl-body">')
  354. def section(label, items, renderer):
  355. out = [f'<div class="decl-section"><div class="decl-label">{label}</div>']
  356. for it in items:
  357. out.append(renderer(it))
  358. out.append('</div>')
  359. return '\n'.join(out)
  360. def render_io(it):
  361. chip = render_chip(it.get('type', ''))
  362. name = he(it.get('name', ''))
  363. default = it.get('default')
  364. line = f'<div class="decl-row">{chip} <span class="name" data-var="{name}">{name}</span>'
  365. if default:
  366. line += f' <span class="decl-default">= {he(default)}</span>'
  367. if it.get('desc'):
  368. line += f' <span class="decl-desc">— {he(it["desc"])}</span>'
  369. return line + '</div>'
  370. if decls.get('inputs'):
  371. parts.append(section('输入', decls['inputs'], render_io))
  372. if decls.get('resources'):
  373. parts.append(section('资源 (跨 case 长期资产)', decls['resources'], render_io))
  374. if decls.get('returns'):
  375. ret = decls['returns']
  376. chip = render_chip(ret.get('type', ''))
  377. line = f'<div class="decl-row">{chip}'
  378. if ret.get('note'):
  379. line += f' <span class="decl-desc">— {he(ret["note"])}</span>'
  380. line += '</div>'
  381. parts.append(f'<div class="decl-section"><div class="decl-label">返回</div>{line}</div>')
  382. parts.append('</div>')
  383. parts.append('</details>')
  384. return '\n'.join(parts)
  385. _INLINE_MEDIA_RE = re.compile(r'\[(image|video):\s*(https?://[^\s\]]+)\]')
  386. def render_source_body(body_text):
  387. """把 body_text 渲染为 HTML: [image:URL] → <img>, [video:URL] → <a>, 其他按 \\n 分段."""
  388. if not body_text:
  389. return ''
  390. pieces = []
  391. last = 0
  392. for m in _INLINE_MEDIA_RE.finditer(body_text):
  393. seg = body_text[last:m.start()]
  394. if seg.strip():
  395. for para in seg.split('\n'):
  396. p = para.strip()
  397. if p:
  398. pieces.append(f'<p class="src-p">{he(p)}</p>')
  399. kind, url = m.group(1), m.group(2)
  400. if kind == 'image':
  401. pieces.append(f'<div class="src-img-wrap"><img class="src-img" src="{he(url)}" loading="lazy" referrerpolicy="no-referrer" alt=""></div>')
  402. else:
  403. pieces.append(f'<div class="src-video"><a href="{he(url)}" target="_blank" rel="noopener">▶ 视频</a></div>')
  404. last = m.end()
  405. tail = body_text[last:]
  406. if tail.strip():
  407. for para in tail.split('\n'):
  408. p = para.strip()
  409. if p:
  410. pieces.append(f'<p class="src-p">{he(p)}</p>')
  411. return '\n'.join(pieces)
  412. def render_source(case_data):
  413. """原文 折叠块: 元信息 + body_text 完整正文 (含内嵌图片/视频)."""
  414. src = case_data.get('source')
  415. if not src:
  416. return ''
  417. url = src.get('url', '')
  418. title = src.get('title', '')
  419. excerpt = src.get('excerpt', '')
  420. body_text = src.get('body_text', '')
  421. cover = src.get('cover_image', '')
  422. meta_bits = [b for b in [src.get('platform'), src.get('author'), src.get('date')] if b]
  423. parts = ['<details class="source-block">']
  424. parts.append(f'<summary>原文: <b>{he(title or "(无标题)")}</b></summary>')
  425. parts.append('<div class="source-body">')
  426. if url:
  427. parts.append(f'<div class="source-url"><a href="{he(url)}" target="_blank" rel="noopener">{he(url)}</a></div>')
  428. if meta_bits:
  429. parts.append(f'<div class="source-meta">{" · ".join(he(b) for b in meta_bits)}</div>')
  430. if excerpt:
  431. parts.append(f'<p class="source-excerpt"><b>摘要</b>: {he(excerpt)}</p>')
  432. if cover:
  433. parts.append(f'<div class="src-cover"><img class="src-img" src="{he(cover)}" loading="lazy" referrerpolicy="no-referrer" alt=""></div>')
  434. if body_text:
  435. parts.append('<hr class="src-divider">')
  436. parts.append('<div class="source-full">')
  437. parts.append(render_source_body(body_text))
  438. parts.append('</div>')
  439. parts.append('</div></details>')
  440. return '\n'.join(parts)
  441. def render_thead():
  442. return '''<thead>
  443. <tr>
  444. <th colspan="3" class="col-group-demand">需求</th>
  445. <th colspan="6" class="col-group-input">输入</th>
  446. <th colspan="8" class="col-group-impl">实现</th>
  447. <th colspan="6" class="col-group-output">输出</th>
  448. </tr>
  449. <tr>
  450. <th class="col-idx">#</th>
  451. <th class="col-intent">目的</th>
  452. <th class="col-effect">作用</th>
  453. <th class="col-in-substance">实质</th>
  454. <th class="col-in-form">形式</th>
  455. <th class="col-in-type">类型</th>
  456. <th class="col-in-name">变量名</th>
  457. <th class="col-in-value">值</th>
  458. <th class="col-in-anchor">来源</th>
  459. <th class="col-via">外部工具</th>
  460. <th class="col-action">动作</th>
  461. <th class="col-directive">指令</th>
  462. <th class="col-config">配置</th>
  463. <th class="col-decorator">运行</th>
  464. <th class="col-memo">备注</th>
  465. <th class="col-control">逻辑控制</th>
  466. <th class="col-feature">特性</th>
  467. <th class="col-out-substance">实质</th>
  468. <th class="col-out-form">形式</th>
  469. <th class="col-out-type">类型</th>
  470. <th class="col-out-name">变量名</th>
  471. <th class="col-out-value">值</th>
  472. <th class="col-out-anchor">去处</th>
  473. </tr>
  474. </thead>'''
  475. def render_legend():
  476. return '''<div class="legend">
  477. <div class="group gray">
  478. <span class="gh">需求</span>
  479. <span class="col-toggle" data-col="idx">#</span>
  480. <span class="col-toggle" data-col="intent">目的</span>
  481. <span class="col-toggle" data-col="effect">作用</span>
  482. </div>
  483. <div class="group yellow">
  484. <span class="gh">输入</span>
  485. <span class="col-toggle" data-col="in-substance">实质</span>
  486. <span class="col-toggle" data-col="in-form">形式</span>
  487. <span class="col-toggle" data-col="in-type">类型</span>
  488. <span class="col-toggle" data-col="in-name">变量名</span>
  489. <span class="col-toggle" data-col="in-value">值</span>
  490. <span class="col-toggle" data-col="in-anchor">来源</span>
  491. </div>
  492. <div class="group green">
  493. <span class="gh">实现</span>
  494. <span class="col-toggle" data-col="via">外部工具</span>
  495. <span class="col-toggle" data-col="action">动作</span>
  496. <span class="col-toggle" data-col="directive">指令</span>
  497. <span class="col-toggle" data-col="config">配置</span>
  498. <span class="col-toggle" data-col="decorator">运行</span>
  499. <span class="col-toggle" data-col="memo">备注</span>
  500. <span class="col-toggle" data-col="control">逻辑控制</span>
  501. <span class="col-toggle" data-col="feature">特性</span>
  502. </div>
  503. <div class="group blue">
  504. <span class="gh">输出</span>
  505. <span class="col-toggle" data-col="out-substance">实质</span>
  506. <span class="col-toggle" data-col="out-form">形式</span>
  507. <span class="col-toggle" data-col="out-type">类型</span>
  508. <span class="col-toggle" data-col="out-name">变量名</span>
  509. <span class="col-toggle" data-col="out-value">值</span>
  510. <span class="col-toggle" data-col="out-anchor">去处</span>
  511. </div>
  512. <span class="inferred-toggle" id="inferred-toggle" title="高亮所有标为推断补全的 cell">高亮推断</span>
  513. <span class="legend-hint">点击列名 ↔ 显示/隐藏 · 点击组名 ↔ 整组切换 · 「推」角标 hover 看推断理由</span>
  514. </div>'''
  515. # =============================================================================
  516. # CSS
  517. # =============================================================================
  518. CSS = (Path(__file__).resolve().parent / "styles.css").read_text(encoding="utf-8")
  519. # =============================================================================
  520. # JS
  521. # =============================================================================
  522. JS_TEMPLATE = (Path(__file__).resolve().parent / "script.js").read_text(encoding="utf-8")
  523. # =============================================================================
  524. # build_html — 主入口
  525. # =============================================================================
  526. def build_html(case_data: dict) -> str:
  527. """从 case_data 构建完整 HTML.
  528. case_data 结构 (新 schema):
  529. page_title: str
  530. case_id: int | str | None
  531. source: {platform, author, date, url, title, excerpt, body_text?, cover_image?}
  532. procedures: [
  533. {
  534. id: str (e.g. 'p1-simple' / 'p1')
  535. name: str
  536. purpose: str
  537. category: str
  538. platform: str
  539. author: str
  540. declarations: {inputs[], resources[], returns{}}
  541. type_registry: dict (该工序的 case-specific 类型, 跟 STDLIB 合并)
  542. steps: [{id, kind, effect, via, action, ...}]
  543. return_row: {arrow, text}
  544. },
  545. ...
  546. ]
  547. """
  548. # type_registry 合并: STDLIB + 所有 procedures.type_registry (跨工序合并)
  549. type_reg = dict(STDLIB_TYPE_REGISTRY)
  550. for proc in case_data.get('procedures', []):
  551. type_reg.update(proc.get('type_registry') or {})
  552. # 补齐 type.json $leaves: 两类问题都要修
  553. # (a) 缺 entry: STDLIB 跟 type.json 不同步, 21 个叶子在字典树但 STDLIB 没条目
  554. # → 补 stub {in_tree: True}
  555. # (b) 有 entry 但缺 in_tree 标记: e.g. type.json.type_metadata 里"提示词" 有 metadata 但没 in_tree,
  556. # 同时它又是 $leaves 之一. STDLIB 直接复制 type_metadata 段就没带 in_tree.
  557. # → in-place 加 in_tree=True
  558. # 这是 script.js isInTypeTree() 正确返 true 的硬要求.
  559. try:
  560. leaves_file = Path(__file__).resolve().parent.parent / 'taxonomy' / 'type.json'
  561. if leaves_file.exists():
  562. leaves_data = json.loads(leaves_file.read_text(encoding='utf-8'))
  563. for leaf in leaves_data.get('$leaves', []):
  564. if leaf not in type_reg:
  565. type_reg[leaf] = {'in_tree': True}
  566. elif isinstance(type_reg[leaf], dict) and not type_reg[leaf].get('in_tree'):
  567. type_reg[leaf] = {**type_reg[leaf], 'in_tree': True}
  568. except Exception:
  569. pass # 兜底失败不阻塞渲染
  570. # build thead 一次 (24 列结构跨 procedures 共享)
  571. thead = render_thead()
  572. # 逐 procedure 渲染: 每个 procedure 出一段 (declarations 折叠 + 工序表)
  573. procedure_blocks: list[str] = []
  574. for proc in case_data.get('procedures', []):
  575. proc_decl_html = render_declarations(case_data, procedure=proc)
  576. body_rows = []
  577. for step in proc.get('steps', []):
  578. body_rows.append(render_step_row(step, step['id'], type_reg))
  579. for atom in step.get('atoms', []):
  580. body_rows.append(render_step_row(atom, atom['id'], type_reg))
  581. rr = proc.get('return_row') or {}
  582. if rr:
  583. body_rows.append(f'<tr class="return-row"><td class="idx">{rr.get("arrow", "↩")}</td><td colspan="24">{rr.get("text", "")}</td></tr>')
  584. proc_tbody = '<tbody>\n ' + '\n '.join(body_rows) + '\n </tbody>'
  585. proc_id_attr = he(proc.get('id', ''))
  586. procedure_blocks.append(f'''<section class="procedure" id="proc-{proc_id_attr}">
  587. {proc_decl_html}
  588. <div class="table-wrap">
  589. <table class="proc">
  590. {thead}
  591. {proc_tbody}
  592. </table>
  593. </div>
  594. </section>''')
  595. procedures_html = '\n '.join(procedure_blocks)
  596. # taxonomy for JS
  597. taxonomy_js = {
  598. '作用': EFFECT_TREE,
  599. '动作': ACTION_TREE,
  600. '类型': TYPE_TREE,
  601. '特性': FEATURE_TAXONOMY,
  602. '实质': EXTERNAL_TAXONOMIES['实质'],
  603. '形式': EXTERNAL_TAXONOMIES['形式'],
  604. }
  605. js = (JS_TEMPLATE
  606. .replace('__TYPE_REGISTRY__', json.dumps(type_reg, ensure_ascii=False, indent=2))
  607. .replace('__TAXONOMY__', json.dumps(taxonomy_js, ensure_ascii=False, indent=2)))
  608. page_title = case_data.get('page_title', 'Procedure DSL 可视化')
  609. source_html = render_source(case_data)
  610. legend_html = render_legend()
  611. return f'''<!doctype html>
  612. <html lang="zh">
  613. <head>
  614. <meta charset="utf-8">
  615. <title>{he(page_title)}</title>
  616. <style>{CSS}</style>
  617. </head>
  618. <body>
  619. <div class="scroll-area">
  620. <header class="page-header">
  621. <h1>{he(page_title)}</h1>
  622. </header>
  623. {source_html}
  624. {legend_html}
  625. {procedures_html}
  626. </div>
  627. <div class="drawer-overlay" id="drawer-overlay"></div>
  628. <aside class="drawer" id="drawer" aria-hidden="true">
  629. <header>
  630. <h2>详情</h2>
  631. <button class="close" aria-label="关闭">×</button>
  632. </header>
  633. <div class="content"></div>
  634. </aside>
  635. <script>{js}</script>
  636. </body>
  637. </html>
  638. '''