#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ renderer.py — Procedure DSL 可视化共享模板. 每个 case 提供 case_data (一个 dict, 见 build_html 的 docstring), import 本模块并调用 build_html(case_data) → HTML 字符串. spec 引用: spec.md §12 (.html 可视化结构规范). """ import html import json import re from pathlib import Path # ── 数据 loaders (从 spec/taxonomy 单一来源加载) ──────────────────── def _load_json(rel_path: str) -> dict: """从 spec/ 子路径加载 JSON. rel_path 是相对 spec/ 的路径 (e.g. 'taxonomy/type.json').""" return json.loads((Path(__file__).resolve().parent.parent / rel_path).read_text(encoding='utf-8')) def _load_stdlib_types() -> dict: """type stdlib 渲染元信息. 新结构 type.json 无 type_metadata → 返回 {}; stdlib 叶子的 in_tree 标记在 build_html 里按 type.json 的 $leaves 补齐.""" return {} _DRAWER_TITLES = { 'effect': ('作用 (chip 上 data-prefix="作用")', '这一步在 AIGC 生产工序链中的位置 + 作用; 从 §A.1 字典树取 L3 叶子'), 'action': ('动作 (chip 上 data-prefix="动作")', '这一步的动作动词; 从 §A.2 字典树取路径'), 'type': ('类型 (chip 上 data-type)', '领域语义类型 (按功能角色分类: 程序控制/数据复用/内容/知识); 从 §A.3 字典树取叶子'), } def _add_label_suffix(node, depth=0): """递归把 dict 的 key 加 (L1)/(L2) 显示后缀, 让 drawer UI 区分层级. leaf string 保留原状; $* 元数据 key 跳过.""" if not isinstance(node, dict): return node result = {} for k, v in node.items(): if k.startswith('$'): continue new_k = k if isinstance(v, dict): if depth == 0: new_k = f'{k} (L1)' elif depth == 1: new_k = f'{k} (L2)' result[new_k] = _add_label_suffix(v, depth + 1) return result def _tree_to_drawer(nodes): """新结构 最终分类树 (list of {分类名称, 分类说明, 子分类}) → drawer 嵌套 dict. 叶子 → {名称: 分类说明 字符串}; 非叶 → {名称: 子树 dict}. 层级即 根→叶 路径.""" out = {} for n in nodes or []: name = n.get('分类名称', '?') kids = n.get('子分类') or [] out[name] = _tree_to_drawer(kids) if kids else (n.get('分类说明', '') or '') return out def _load_drawer_tree(dim: str) -> dict: """spec/taxonomy/{dim}.json → 重塑为 drawer UI 需要的 {title, desc, tree} 格式. 新结构读 最终分类树 (中文键); 自动加 (L1)/(L2) 层级标签. dim ∈ {'effect', 'action', 'type'}.""" raw = _load_json(f'taxonomy/{dim}.json') title, desc = _DRAWER_TITLES[dim] nested = _tree_to_drawer(raw.get('最终分类树', [])) return { 'title': title, 'desc': desc, 'tree': _add_label_suffix(nested, depth=0), } # ============================================================================= # STDLIB · type registry (字典树 §A.3 叶子的 in_tree 标记基础; 现为空, build_html 按 $leaves 补齐) # ============================================================================= STDLIB_TYPE_REGISTRY = _load_stdlib_types() # ============================================================================= # 字典树 (spec §A.1 作用 / §A.2 动作 / §A.3 类型) # ============================================================================= EFFECT_TREE = _load_drawer_tree("effect") ACTION_TREE = _load_drawer_tree("action") TYPE_TREE = _load_drawer_tree("type") def _build_type_paths() -> dict: """type.json 最终分类树 → {叶子名: '根/.../叶子' 路径}, 供 chip 显示完整路径.""" raw = _load_json('taxonomy/type.json') out: dict = {} def walk(nodes, prefix): for n in nodes: path = prefix + [n.get('分类名称', '')] kids = n.get('子分类') or [] if kids: walk(kids, path) else: out[n.get('分类名称', '')] = '/'.join(path) walk(raw.get('最终分类树', []), []) return out TYPE_PATHS = _build_type_paths() FEATURE_TAXONOMY = _load_json("taxonomy/feature.json") EXTERNAL_TAXONOMIES = { '实质': {'title': '实质 (内容是什么)', 'desc': '理念 / 表象 — 911 路径', 'source': 'external', 'file': '分类库导出_实质_*.json'}, '形式': {'title': '形式 (内容怎么呈现)', 'desc': '呈现 / 架构 — 565 路径', 'source': 'external', 'file': '分类库导出_形式_*.json'}, } # ============================================================================= # Render helpers # ============================================================================= def he(s): """HTML escape, 默认 quote=True 把 `"` 转成 `"`. 这点关键: title / data-* / class 等属性值如果含 `"` 会提前终止属性, 导致 tooltip 显示不完整 (典型 bug: '原文方法 3 只说"自己写动作"' 在 title 中只显示到 "只说" 就被截断). """ if s is None: return '' return html.escape(str(s)) def render_intent(text): """目的列: 简短自然语言句, **尽量** 把其他列里的结构化值都做成 {kind:value} token. 每个 token 底色对应其引用的列, 让人一眼看出该值来自哪里. 合法 kind: effect → 作用列 (灰, 需求组) via → 外部工具列 (浅绿 + 等宽字体, 实现组) act → 动作列 (绿, 实现组) control → 逻辑控制列 (浅青, 实现组) — 并行/遍历/分支/请求/等待 in-type → 输入·类型 (黄圆胶囊) out-type → 输出·类型 (蓝圆胶囊) in-sub → 输入·实质 (黄矩形 tag) out-sub → 输出·实质 (蓝矩形 tag) in-form → 输入·形式 (黄矩形 tag 斜体) out-form → 输出·形式 (蓝矩形 tag 斜体) **特性列 (feature) 不允许在 intent 中引用** — feature 是内部执行特征 (随机/幂等/人工/读写外部), 不出现在面向使用者的描述. 写成 `ik-other` 灰色显示作为 lint 警告. 严禁变量名 token (no `{in:X}` / `{out:X}`); 严禁 dataflow 公式 / case-specific 简写. """ def sub(m): kind = m.group(1) val = m.group(2) kc = { 'effect': 'ik-effect', 'via': 'ik-via', 'act': 'ik-act', 'in-type': 'ik-in-type', 'out-type': 'ik-out-type', }.get(kind, 'ik-other') return f'{he(val)}' return re.sub(r'\{([\w-]+):([^}]+)\}', sub, text or '') def render_chip(type_name): if not type_name: return '' if type_name == '-': return he(type_name) # 显示完整路径 (e.g. 程序控制类型/指令/提示词); data-type 仍存叶子名, drawer 查找不受影响. # case-specific 类型 (不在字典树) 显示原名. disp = TYPE_PATHS.get(type_name, type_name) return f'{he(disp)}' def render_path(prefix, value): if not value: return '' if isinstance(value, list): spans = [] for val in value: if val: spans.append(f'{he(val)}') return '\n'.join(spans) if isinstance(value, str): if '+' in value: parts = [p.strip() for p in value.split('+') if p.strip()] spans = [] for val in parts: spans.append(f'{he(val)}') return '\n'.join(spans) return f'{he(value)}' return '' _VALUE_DESC_RE = re.compile(r'^<(.+)>$', re.DOTALL) def render_value(vl): """值列渲染: - 若整段以 `<...>` 括起 → 渲染为斜体浅灰背景, 表示"这是对内容的描述, 不是内容本身" (适用于无法在 cell 中直接嵌入的非文本数据: 视频/图像/音频). - 否则 → 渲染为普通文本 (适用于文本数据本身, 如 prompt 全文). """ if vl is None: return '' s = str(vl).strip() m = _VALUE_DESC_RE.match(s) if m: inner = m.group(1) return f'<{he(inner)}>' return f'{he(s)}' def render_focus_class(cell_key, focus_list): return ' row-focus' if cell_key in (focus_list or []) else '' def cell_attrs(field_key, focus, io_reason=None, is_empty=False): """组合 cell 的额外 class 和属性 (focus + 推断补全). field_key 例: 'action', 'in-value-0', 'out-type-1', etc. io_reason: 当前 cell 所属 IO item 整体被标 inferred 时, 传入 reason 字符串. is_empty: 该 cell 内容是否为空 (留空的推断 cell 角标变 推?). 返回 (class_suffix_str, extra_attrs_str). """ cls = render_focus_class(field_key, focus) extra = '' if io_reason: cls += ' is-inferred' if is_empty: cls += ' is-low-confidence' extra = f' title="推断补全: {he(io_reason)}"' return cls, extra def render_io_value(item): """IO 值列: output 的 id 作为小标签贴在值前 (供 anchor 1:1 引用对照); input 无 id.""" iid = item.get('id') id_tag = f'{he(iid)} ' if iid else '' return id_tag + render_value(item.get('value')) def render_step_row(step, idx_label, type_reg=None): """渲染一个 step (kind: step / block / nested) 为一组 tr 行 (14 列). 需求组 (rowspan): # / 目的 / 作用 / 实质 / 形式 (后两者 step 级). 输入/输出 (逐 IO): 类型 / 值 / 来源(去处). 实现组 (rowspan): 外部工具 / 动作 / 指令. type_reg: 已合并 STDLIB + case-specific 的类型注册表 (用于 chip in_tree 标记). """ inputs = step.get('inputs', []) outputs = step.get('outputs', []) N = max(len(inputs), len(outputs), 1) focus = step.get('focus', []) is_nested = step['kind'] == 'nested' is_block = step['kind'] == 'block' main_cls = 'step step-main' if is_block: main_cls = 'step block-header' if is_nested: main_cls = 'step step-main step-nested' sub_cls = 'step step-sub' data_step = step['id'] data_group = step.get('group', '') rows = [] for k in range(N): tr_cls = main_cls if k == 0 else sub_cls attrs = f' data-step="{data_step}"' if data_group: attrs += f' data-group="{data_group}"' attrs += f' data-focus="{",".join(focus)}"' if k == 0: attrs = f' id="{data_step}"' + attrs cells = [] if k == 0: rs = f' rowspan="{N}"' if N > 1 else '' arrow = ' ' if is_block else '' indent = ' └ ' if is_nested else '' cells.append(f'{arrow}{indent}{he(idx_label)}') intent_html = render_intent(step.get('intent', '')) c, a = cell_attrs('intent', focus) cells.append(f'
{intent_html}
') c, a = cell_attrs('effect', focus) cells.append(f'{render_path("作用", step.get("effect", ""))}') c, a = cell_attrs('substance', focus) cells.append(f'{render_path("实质", step.get("substance")) if step.get("substance") else ""}') c, a = cell_attrs('form', focus) cells.append(f'{render_path("形式", step.get("form")) if step.get("form") else ""}') in_item = inputs[k] if k < len(inputs) else None if in_item: tp = in_item.get('type'); vl = in_item.get('value'); ac = in_item.get('anchor') io_inf = in_item.get('inferred_reason') if in_item.get('inferred') else None c, a = cell_attrs(f'in-type-{k}', focus, io_inf, is_empty=not tp) cells.append(f'{render_chip(tp)}') c, a = cell_attrs(f'in-value-{k}', focus, io_inf, is_empty=not vl) cells.append(f'{render_io_value(in_item)}') c, a = cell_attrs(f'in-anchor-{k}', focus, io_inf, is_empty=not ac) cells.append(f'{he(ac)}') else: cells += ['', '', ''] if k == 0: rs = f' rowspan="{N}"' if N > 1 else '' c, a = cell_attrs('via', focus) cells.append(f'{he(step.get("via", ""))}') c, a = cell_attrs('action', focus) cells.append(f'{render_path("动作", step.get("action", ""))}') c, a = cell_attrs('directive', focus) directive = step.get('directive', '') or '' inner = f'{he(directive)}' if directive else '' cells.append(f'{inner}') out_item = outputs[k] if k < len(outputs) else None if out_item: tp = out_item.get('type'); vl = out_item.get('value'); ac = out_item.get('anchor') io_inf = out_item.get('inferred_reason') if out_item.get('inferred') else None c, a = cell_attrs(f'out-type-{k}', focus, io_inf, is_empty=not tp) cells.append(f'{render_chip(tp)}') c, a = cell_attrs(f'out-value-{k}', focus, io_inf, is_empty=not vl) cells.append(f'{render_io_value(out_item)}') c, a = cell_attrs(f'out-anchor-{k}', focus, io_inf, is_empty=not ac) cells.append(f'{he(ac)}') else: cells += ['', '', ''] rows.append(f'{"".join(cells)}') return '\n '.join(rows) def render_declarations(case_data, procedure=None): """工序头部 + declare 块 (输入/资源/返回), 可折叠. procedure: 该工序的 dict (新 schema 下 case_data.procedures[i]). 不传时退化到 老 case_data.procedure (兼容老 case 直到全部迁移完). """ proc = procedure if procedure is not None else case_data.get('procedure', {}) decls = proc.get('declarations') if procedure is not None else case_data.get('declarations', {}) decls = decls or {} parts = [] parts.append('
') parts.append('') parts.append(f' 工序 {he(proc.get("name", ""))}') if proc.get('purpose'): parts.append(f' #目的: {he(proc["purpose"])}') if proc.get('category'): parts.append(f' 类别: {he(proc["category"])}') meta_bits = [] for label, key in [('平台', 'platform'), ('作者', 'author')]: if proc.get(key): meta_bits.append(f'#{label}: {he(proc[key])}') if case_data.get('case_id') is not None: meta_bits.append(f'case: {he(case_data["case_id"])}') if meta_bits: parts.append(f' {" · ".join(meta_bits)}') parts.append('') parts.append('
') def section(label, items, renderer): out = [f'
{label}
'] for it in items: out.append(renderer(it)) out.append('
') return '\n'.join(out) def render_io(it): chip = render_chip(it.get('type', '')) name = he(it.get('name', '')) default = it.get('default') line = f'
{chip} {name}' if default: line += f' = {he(default)}' if it.get('desc'): line += f' — {he(it["desc"])}' return line + '
' if decls.get('inputs'): parts.append(section('输入', decls['inputs'], render_io)) if decls.get('resources'): parts.append(section('资源 (跨 case 长期资产)', decls['resources'], render_io)) if decls.get('returns'): ret = decls['returns'] chip = render_chip(ret.get('type', '')) line = f'
{chip}' if ret.get('note'): line += f' — {he(ret["note"])}' line += '
' parts.append(f'
返回
{line}
') parts.append('
') parts.append('
') return '\n'.join(parts) _INLINE_MEDIA_RE = re.compile(r'\[(image|video):\s*(https?://[^\s\]]+)\]') def render_source_body(body_text): """把 body_text 渲染为 HTML: [image:URL] → , [video:URL] → , 其他按 \\n 分段.""" if not body_text: return '' pieces = [] last = 0 for m in _INLINE_MEDIA_RE.finditer(body_text): seg = body_text[last:m.start()] if seg.strip(): for para in seg.split('\n'): p = para.strip() if p: pieces.append(f'

{he(p)}

') kind, url = m.group(1), m.group(2) if kind == 'image': pieces.append(f'
') else: pieces.append(f'
▶ 视频
') last = m.end() tail = body_text[last:] if tail.strip(): for para in tail.split('\n'): p = para.strip() if p: pieces.append(f'

{he(p)}

') return '\n'.join(pieces) def render_source(case_data): """原文 折叠块: 元信息 + body_text 完整正文 (含内嵌图片/视频).""" src = case_data.get('source') if not src: return '' url = src.get('url', '') title = src.get('title', '') excerpt = src.get('excerpt', '') body_text = src.get('body_text', '') cover = src.get('cover_image', '') meta_bits = [b for b in [src.get('platform'), src.get('author'), src.get('date')] if b] parts = ['
'] parts.append(f'原文: {he(title or "(无标题)")}') parts.append('
') if url: parts.append(f'') if meta_bits: parts.append(f'
{" · ".join(he(b) for b in meta_bits)}
') if excerpt: parts.append(f'

摘要: {he(excerpt)}

') if cover: parts.append(f'
') if body_text: parts.append('
') parts.append('
') parts.append(render_source_body(body_text)) parts.append('
') parts.append('
') return '\n'.join(parts) def render_thead(): return ''' 需求 输入 实现 输出 # 目的 作用 实质 形式 类型 值 来源 外部工具 动作 指令 类型 值 去处 ''' def render_legend(): return '''
需求 # 目的 作用 实质 形式
输入 类型 来源
实现 外部工具 动作 指令
输出 类型 去处
高亮推断 点击列名 ↔ 显示/隐藏 · 点击组名 ↔ 整组切换 · 「推」角标 hover 看推断理由
''' # ============================================================================= # CSS # ============================================================================= CSS = (Path(__file__).resolve().parent / "styles.css").read_text(encoding="utf-8") # ============================================================================= # JS # ============================================================================= JS_TEMPLATE = (Path(__file__).resolve().parent / "script.js").read_text(encoding="utf-8") # ============================================================================= # build_html — 主入口 # ============================================================================= def build_html(case_data: dict) -> str: """从 case_data 构建完整 HTML. case_data 结构 (新 schema): page_title: str case_id: int | str | None source: {platform, author, date, url, title, excerpt, body_text?, cover_image?} procedures: [ { id: str (e.g. 'p1-simple' / 'p1') name: str purpose: str category: str platform: str author: str declarations: {inputs[], resources[], returns{}} type_registry: dict (该工序的 case-specific 类型, 跟 STDLIB 合并) steps: [{id, kind, effect, via, action, ...}] return_row: {arrow, text} }, ... ] """ # type_registry 合并: STDLIB + 所有 procedures.type_registry (跨工序合并) type_reg = dict(STDLIB_TYPE_REGISTRY) for proc in case_data.get('procedures', []): type_reg.update(proc.get('type_registry') or {}) # 补齐 type.json $leaves: 两类问题都要修 # (a) 缺 entry: STDLIB 跟 type.json 不同步, 21 个叶子在字典树但 STDLIB 没条目 # → 补 stub {in_tree: True} # (b) 有 entry 但缺 in_tree 标记: e.g. type.json.type_metadata 里"提示词" 有 metadata 但没 in_tree, # 同时它又是 $leaves 之一. STDLIB 直接复制 type_metadata 段就没带 in_tree. # → in-place 加 in_tree=True # 这是 script.js isInTypeTree() 正确返 true 的硬要求. try: leaves_file = Path(__file__).resolve().parent.parent / 'taxonomy' / 'type.json' if leaves_file.exists(): leaves_data = json.loads(leaves_file.read_text(encoding='utf-8')) for leaf in leaves_data.get('$leaves', []): if leaf not in type_reg: type_reg[leaf] = {'in_tree': True} elif isinstance(type_reg[leaf], dict) and not type_reg[leaf].get('in_tree'): type_reg[leaf] = {**type_reg[leaf], 'in_tree': True} except Exception: pass # 兜底失败不阻塞渲染 # build thead 一次 (24 列结构跨 procedures 共享) thead = render_thead() # 逐 procedure 渲染: 每个 procedure 出一段 (declarations 折叠 + 工序表) procedure_blocks: list[str] = [] for proc in case_data.get('procedures', []): proc_decl_html = render_declarations(case_data, procedure=proc) body_rows = [] for step in proc.get('steps', []): body_rows.append(render_step_row(step, step['id'], type_reg)) rr = proc.get('return_row') or {} if rr: body_rows.append(f'{rr.get("arrow", "↩")}{rr.get("text", "")}') proc_tbody = '\n ' + '\n '.join(body_rows) + '\n ' proc_id_attr = he(proc.get('id', '')) procedure_blocks.append(f'''
{proc_decl_html}
{thead} {proc_tbody}
''') procedures_html = '\n '.join(procedure_blocks) # taxonomy for JS taxonomy_js = { '作用': EFFECT_TREE, '动作': ACTION_TREE, '类型': TYPE_TREE, '实质': EXTERNAL_TAXONOMIES['实质'], '形式': EXTERNAL_TAXONOMIES['形式'], } js = (JS_TEMPLATE .replace('__TYPE_REGISTRY__', json.dumps(type_reg, ensure_ascii=False, indent=2)) .replace('__TAXONOMY__', json.dumps(taxonomy_js, ensure_ascii=False, indent=2))) page_title = case_data.get('page_title', 'Procedure DSL 可视化') source_html = render_source(case_data) legend_html = render_legend() return f''' {he(page_title)}
{source_html} {legend_html} {procedures_html}
'''