#!/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',
'control': 'ik-control',
'in-type': 'ik-in-type', 'out-type': 'ik-out-type',
'in-sub': 'ik-in-sub', 'out-sub': 'ik-out-sub',
'in-form': 'ik-in-form', 'out-form': 'ik-out-form',
}.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, inferred_marks, io_reason=None, is_empty=False):
"""组合 cell 的额外 class 和属性 (focus + 推断补全).
field_key 例: 'action', 'in-value-0', 'out-substance-1', etc.
inferred_marks: step 级 {field_key: 推断理由} dict (field 级推断, 如 "工具未指名").
io_reason: 当前 cell 所属 IO item 整体被标 inferred 时, 传入 reason 字符串
(优先级低于 inferred_marks 显式 field-level 标注).
is_empty: 该 cell 的内容是否为空. 在推断模式下空 cell 转为低置信变体 (角标 推?), 表示
"AI 想过但拿不准, 故意留空", 区别于硬补值的高置信「推」. 参 spec §推断补全标记 C.
返回 (class_suffix_str, extra_attrs_str).
"""
cls = render_focus_class(field_key, focus)
extra = ''
reason = (inferred_marks or {}).get(field_key) or io_reason
if reason:
cls += ' is-inferred'
if is_empty:
cls += ' is-low-confidence'
extra = f' title="推断补全: {he(reason)}"'
return cls, extra
def render_step_row(step, idx_label, type_reg=None):
"""渲染一个 step (kind: step / block / nested) 为一组 tr 行.
type_reg: 已合并 STDLIB + case-specific 的类型注册表 (用于 in_tree 标记 + case-specific chip 区分).
"""
inputs = step.get('inputs', [])
outputs = step.get('outputs', [])
N = max(len(inputs), len(outputs), 1)
focus = step.get('focus', [])
infm = step.get('inferred_marks', {})
is_nested = step['kind'] == 'nested'
is_block = step['kind'] == 'block'
is_atom = step.get('kind') == 'atom'
main_cls = 'step step-main'
if is_block:
main_cls = 'step block-header'
if is_nested:
main_cls = 'step step-main step-nested'
if is_atom:
main_cls = 'step atom-row'
sub_cls = 'step step-sub atom-row' if is_atom else 'step step-sub'
data_step = step['id']
data_group = step.get('group', '')
parent_step = step.get('parent_step', '')
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}"'
if parent_step:
attrs += f' data-atom-of="{parent_step}"'
attrs += f' data-focus="{",".join(focus)}"'
# anchor id 给主行 (k=0), 用于跨页跳转 (能力浏览页 → 工序页特定 step/atom)
if k == 0:
row_id = f'{parent_step}-{data_step}' if (is_atom and parent_step) else data_step
attrs = f' id="{row_id}"' + attrs
cells = []
if k == 0:
rs = f' rowspan="{N}"' if N > 1 else ''
if is_atom:
cells.append(f'
↳ {he(idx_label)} | ')
else:
arrow = '▼ ' if is_block else ''
indent = ' └ ' if is_nested else ''
badge = ''
if step.get('atoms'):
n_atoms = len(step['atoms'])
badge = f'
⚛{n_atoms} ▸'
cells.append(f'{arrow}{indent}{he(idx_label)}{badge} | ')
intent_html = render_intent(step.get('intent', ''))
c, a = cell_attrs('intent', focus, infm)
if is_atom:
atom_name = he(step.get('name', ''))
cells.append(f'{atom_name} {intent_html} | ')
else:
cells.append(f'{intent_html} | ')
c, a = cell_attrs('effect', focus, infm)
cells.append(f'{render_path("作用", step.get("effect", ""))} | ')
in_item = inputs[k] if k < len(inputs) else None
if in_item:
sub = in_item.get('substance'); frm = in_item.get('form')
tp = in_item.get('type'); nm = in_item.get('name')
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-substance-{k}', focus, infm, io_inf, is_empty=not sub)
cells.append(f'{render_path("实质", sub) if sub else ""} | ')
c, a = cell_attrs(f'in-form-{k}', focus, infm, io_inf, is_empty=not frm)
cells.append(f'{render_path("形式", frm) if frm else ""} | ')
c, a = cell_attrs(f'in-type-{k}', focus, infm, io_inf, is_empty=not tp)
cells.append(f'{render_chip(tp)} | ')
c, a = cell_attrs(f'in-name-{k}', focus, infm, io_inf, is_empty=not nm)
cells.append(f'{he(nm)} | ')
c, a = cell_attrs(f'in-value-{k}', focus, infm, io_inf, is_empty=not vl)
cells.append(f'{render_value(vl)} | ')
c, a = cell_attrs(f'in-anchor-{k}', focus, infm, 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, infm)
cells.append(f'{he(step.get("via", ""))} | ')
c, a = cell_attrs('action', focus, infm)
cells.append(f'{render_path("动作", step.get("action", ""))} | ')
# 指令列拆为 4 个子列: directive / config / decorator / memo
# memo = 其他结构化字段没能包含的实现方法信息 (经验性招法 / variant 说明 / 选型理由 等)
instr_by_kind = {'directive': [], 'config': [], 'decorator': [], 'memo': []}
for kind, txt in step.get('instruction', []):
if kind in instr_by_kind:
instr_by_kind[kind].append(txt)
else:
# 历史 tag (trick/note) 兼容: 一并合并到 memo
instr_by_kind['memo'].append(txt)
for col_name in ('directive', 'config', 'decorator', 'memo'):
items = instr_by_kind[col_name]
inner = ''.join(f'{he(t)}
' for t in items)
c, a = cell_attrs(col_name, focus, infm)
cells.append(f'{inner} | ')
c, a = cell_attrs('control', focus, infm)
cells.append(f'{render_path("逻辑控制", step.get("control", ""))} | ')
c, a = cell_attrs('feature', focus, infm)
cells.append(f'{render_path("特性", step.get("feature", ""))} | ')
out_item = outputs[k] if k < len(outputs) else None
if out_item:
sub = out_item.get('substance'); frm = out_item.get('form')
tp = out_item.get('type'); nm = out_item.get('name')
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-substance-{k}', focus, infm, io_inf, is_empty=not sub)
cells.append(f'{render_path("实质", sub) if sub else ""} | ')
c, a = cell_attrs(f'out-form-{k}', focus, infm, io_inf, is_empty=not frm)
cells.append(f'{render_path("形式", frm) if frm else ""} | ')
c, a = cell_attrs(f'out-type-{k}', focus, infm, io_inf, is_empty=not tp)
cells.append(f'{render_chip(tp)} | ')
c, a = cell_attrs(f'out-name-{k}', focus, infm, io_inf, is_empty=not nm)
cells.append(f'{he(nm)} | ')
c, a = cell_attrs(f'out-value-{k}', focus, infm, io_inf, is_empty=not vl)
cells.append(f'{render_value(vl)} | ')
c, a = cell_attrs(f'out-anchor-{k}', focus, infm, 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'
')
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))
for atom in step.get('atoms', []):
body_rows.append(render_step_row(atom, atom['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'''''')
procedures_html = '\n '.join(procedure_blocks)
# taxonomy for JS
taxonomy_js = {
'作用': EFFECT_TREE,
'动作': ACTION_TREE,
'类型': TYPE_TREE,
'特性': FEATURE_TAXONOMY,
'实质': 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}
'''