render-case.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. render-case.py — Procedure DSL · 阶段三 3.3 落盘&渲染脚本.
  5. 把 case_data (符合 spec/format/case-data.schema.json) 通过 spec/tools/renderer.build_html
  6. 渲染成单文件 HTML.
  7. 用法:
  8. 渲染:
  9. render-case.py --workflow outputs/case-N/workflow.json \
  10. --source-input input/case-N-raw.json \
  11. --page-title "Case N · 主题" --case-id N \
  12. --out outputs/case-N/case-N-<slug>.html
  13. 只校验, 不渲染:
  14. render-case.py --workflow outputs/case-N/workflow.json --source-input input/case-N-raw.json --validate
  15. 退出码:
  16. 0 成功 (渲染或校验通过)
  17. 1 IO / schema / 渲染异常
  18. 2 CLI 参数错误
  19. 依赖:
  20. 必需: spec/tools/renderer.py (跟本脚本同目录, 自动加入 sys.path)
  21. 可选: jsonschema (装了就用 Draft 2020-12 校验; 没装则只做最小结构检查)
  22. """
  23. from __future__ import annotations
  24. import argparse
  25. import importlib.util
  26. import json
  27. import re
  28. import sys
  29. from pathlib import Path
  30. # Windows cp1252 console 不能直接打中文/勾选号; 把 stdout/stderr 强转 UTF-8.
  31. for _stream in (sys.stdout, sys.stderr):
  32. if hasattr(_stream, 'reconfigure'):
  33. try:
  34. _stream.reconfigure(encoding='utf-8', errors='replace')
  35. except Exception:
  36. pass
  37. # DSL_ROOT = procedure-dsl/ (本脚本位于 procedure-dsl/spec/tools/)
  38. DSL_ROOT = Path(__file__).resolve().parent.parent.parent
  39. SCHEMA_PATH = DSL_ROOT / 'spec' / 'format' / 'case-data.schema.json'
  40. # renderer.py 跟本脚本同目录 (spec/tools/), 直接用 parent 最 robust.
  41. TEMPLATE_DIR = Path(__file__).resolve().parent
  42. def _import_build_html():
  43. """延迟 import build_html, 把 examples/_build 接到 sys.path. 失败时给出明确错误."""
  44. sys.path.insert(0, str(TEMPLATE_DIR))
  45. try:
  46. from renderer import build_html # type: ignore
  47. except ImportError as e:
  48. die(f'无法导入 renderer.build_html (期望路径: {TEMPLATE_DIR}/renderer.py): {e}')
  49. return build_html
  50. def _load_lint():
  51. """载入 lint-case.py (文件名带连字符, 按路径载) 复用其结构性 check, 保持单一真源."""
  52. p = Path(__file__).resolve().parent / 'lint-case.py'
  53. spec = importlib.util.spec_from_file_location('_lint_case_mod', p)
  54. mod = importlib.util.module_from_spec(spec)
  55. spec.loader.exec_module(mod)
  56. return mod
  57. def die(msg: str, code: int = 1):
  58. print(f'render-case: {msg}', file=sys.stderr)
  59. sys.exit(code)
  60. def load_json(path: Path) -> dict:
  61. if not path.exists():
  62. die(f'文件不存在: {path}')
  63. try:
  64. return json.loads(path.read_text(encoding='utf-8'))
  65. except json.JSONDecodeError as e:
  66. die(f'{path} 不是合法 JSON: {e}')
  67. def die_short(msg: str):
  68. die(msg)
  69. def merge_raw_source(case_data: dict, raw_path: Path) -> None:
  70. """从 input/case-{N}-raw.json 抽 body_text + 封面 + 图集兜底, in-place 填到 case_data.source.
  71. 设计: raw 是事实源 (原帖正文 / 封面 / 标题 / URL), case_data.source.body_text 和 cover_image
  72. 被 raw 覆盖. 其他字段 (excerpt / author / date / platform) **保留 case_data 原值** ——
  73. 那些是 Agent 推断/总结的友好版本, 不应被 raw 覆盖.
  74. 图集兜底逻辑: 微信公众号正文里有 `[image:URL]` inline markup → renderer 自动渲染就好.
  75. 小红书等"短文 + 独立图集" 平台 body_text 不含 inline markup → 把 image_url_list 里
  76. 既没 inline 也不是封面的图, 以 `[image: URL]` 形式 append 到 body_text 末尾 (附 "附图" 分隔符).
  77. 这样 renderer.render_source_body 一视同仁地把它们也渲染成 <img>, 不重复封面.
  78. """
  79. import re
  80. raw = load_json(raw_path)
  81. src = case_data.setdefault('source', {})
  82. body = raw.get('body_text', '') or ''
  83. # cover_image: image_url_list 里 image_type=2 (封面) 的第一张; 没有就 fallback 整列第一张
  84. images = raw.get('image_url_list') or []
  85. covers = [it for it in images if isinstance(it, dict) and it.get('image_type') == 2]
  86. pick = covers[0] if covers else (images[0] if images and isinstance(images[0], dict) else None)
  87. cover_url = ''
  88. if pick and pick.get('image_url'):
  89. cover_url = pick['image_url']
  90. src['cover_image'] = cover_url
  91. # 图集兜底: 检查哪些图既没在 body inline, 也不是封面 → append 到 body 末尾
  92. inlined_urls = set(re.findall(r'\[image:\s*(\S+?)\]', body))
  93. missing = []
  94. for it in images:
  95. if not isinstance(it, dict):
  96. continue
  97. u = it.get('image_url', '') or ''
  98. if not u:
  99. continue
  100. if u in inlined_urls:
  101. continue # 已 inline 不重复
  102. if u == cover_url:
  103. continue # 封面已 src-cover 单独渲染
  104. missing.append(u)
  105. if missing:
  106. suffix = '\n\n--- 附图 ---\n' + '\n'.join(f'[image: {u}]' for u in missing)
  107. body = body + suffix
  108. if body:
  109. src['body_text'] = body
  110. # title / url 兜底: case_data 漏填时从 raw 补 (Agent 一般会填)
  111. if not src.get('title') and raw.get('title'):
  112. src['title'] = raw['title']
  113. if not src.get('url') and raw.get('content_link'):
  114. src['url'] = raw['content_link']
  115. # =============================================================================
  116. # 校验
  117. # =============================================================================
  118. def validate(case_data: dict, strict: bool = True) -> list[str]:
  119. """返回错误信息列表 (空 list = 通过).
  120. 优先使用 jsonschema (Draft 2020-12); 不可用时退化到最小结构检查 (必填 key 存在 + 类型对).
  121. strict=True 时, 任何错误抛 SystemExit(1); strict=False 仅返回错误列表.
  122. """
  123. errors: list[str] = []
  124. try:
  125. from jsonschema import Draft202012Validator # type: ignore
  126. schema = load_json(SCHEMA_PATH)
  127. validator = Draft202012Validator(schema)
  128. for err in sorted(validator.iter_errors(case_data), key=lambda e: list(e.absolute_path)):
  129. path = '$' + ''.join(f'.{p}' if isinstance(p, str) else f'[{p}]' for p in err.absolute_path)
  130. errors.append(f'{path}: {err.message}')
  131. except ImportError:
  132. # 退化: 最小检查
  133. errors.extend(_minimal_check(case_data))
  134. if errors and strict:
  135. for e in errors:
  136. print(f' ✗ {e}', file=sys.stderr)
  137. print(f' ↳ schema: {SCHEMA_PATH} (要看字段约束直接 Read 这个文件, 不要猜路径)', file=sys.stderr)
  138. die(f'schema 校验失败, 共 {len(errors)} 项 (装 `pip install jsonschema` 可看完整 Draft 2020-12 报告)')
  139. return errors
  140. def _minimal_check(d: dict) -> list[str]:
  141. """无 jsonschema 时的兜底: 仅检 顶层必填 + steps 元素必填 + IO 元素必填."""
  142. errs: list[str] = []
  143. top_required = ['page_title', 'procedure', 'declarations', 'source', 'steps']
  144. for k in top_required:
  145. if k not in d:
  146. errs.append(f'$.{k}: 缺失必填字段')
  147. if not isinstance(d.get('steps'), list):
  148. errs.append('$.steps: 必须是 array')
  149. return errs
  150. # 所有 step 都要的基础字段; effect/action 仅非 block 步要 (block 是控制容器)
  151. step_required = ['id', 'kind', 'via', 'inputs', 'outputs', 'intent']
  152. exec_only = ['effect', 'action'] # kind != block 才要
  153. io_required = ['type', 'value', 'anchor']
  154. for i, step in enumerate(d['steps']):
  155. if not isinstance(step, dict):
  156. errs.append(f'$.steps[{i}]: 必须是 object'); continue
  157. need = step_required + ([] if step.get('kind') == 'block' else exec_only)
  158. for k in need:
  159. if k not in step:
  160. errs.append(f'$.steps[{i}].{k}: 缺失必填字段')
  161. for io_kind in ('inputs', 'outputs'):
  162. for j, item in enumerate(step.get(io_kind, []) or []):
  163. if not isinstance(item, dict):
  164. errs.append(f'$.steps[{i}].{io_kind}[{j}]: 必须是 object'); continue
  165. for k in io_required:
  166. if k not in item:
  167. errs.append(f'$.steps[{i}].{io_kind}[{j}].{k}: 缺失必填字段')
  168. return errs
  169. # =============================================================================
  170. # 占位门禁: 文本类 value 是 <占位> → 拒绝渲染 (强制回填真实内容)
  171. # 模态判定与 lint-case.py Check 3 一致: 文本类必须真内容, 媒体类可 <描述>, inferred 放行.
  172. # =============================================================================
  173. _PH_RE = re.compile(r'^\s*<[^>]*>\s*$')
  174. # 显式「原文未提供」标记 → 放行 (LLM 判断原文确无该信息时的轻量逃生, 等同 inferred)。
  175. # 写法如: <占位>(原文未提供) / <某分析>(原文未给出)
  176. _NOSRC_RE = re.compile(r'原文(未提供|未给出|没有|无)')
  177. _TEXT_KW = ('提示词', '描述', '参数', '评', '大纲', '脚本', '文案', '歌词', '字幕',
  178. '标题', '正文', '词', '知识', '工作流', '对标', '规格', '批处理', '模板', '版式',
  179. '数据', '分析', '报告', '记录', '方案', '思路', '设定', '依据', '标准', '清单', '列表', '文本', '文字')
  180. _MEDIA_KW = ('图', '视频', '音频', '帧', '片段', '截图', '蒙版', '音效', '配音', 'BGM',
  181. '数字人', '滤镜', '海报', '封面')
  182. def _modality(type_name: str, type_reg: dict) -> str:
  183. base, seen = type_name, set()
  184. while base in (type_reg or {}) and base not in seen:
  185. seen.add(base)
  186. ent = type_reg[base]
  187. ext = ent.get('extends') if isinstance(ent, dict) else None
  188. if not ext:
  189. break
  190. base = ext
  191. nm = base or type_name or ''
  192. if any(k in nm for k in _TEXT_KW):
  193. return 'text'
  194. if any(k in nm for k in _MEDIA_KW):
  195. return 'media'
  196. return 'unknown'
  197. def check_placeholder_values(case_data: dict) -> list[str]:
  198. """揪出文本类(非媒体、非 inferred)IO value 仍是 <占位> 的——这些必须回填真内容才放行渲染."""
  199. bad: list[str] = []
  200. for p in case_data.get('procedures') or []:
  201. pid = p.get('id') or '?'
  202. type_reg = p.get('type_registry') or {}
  203. for i, step in enumerate(p.get('steps') or []):
  204. if not isinstance(step, dict):
  205. continue
  206. for kind in ('inputs', 'outputs'):
  207. for j, io in enumerate(step.get(kind) or []):
  208. if not isinstance(io, dict) or io.get('inferred'):
  209. continue
  210. v = io.get('value')
  211. if not isinstance(v, str):
  212. continue
  213. if _NOSRC_RE.search(v):
  214. continue # 显式标了「原文未提供」→ 放行 (LLM 确认原文确无)
  215. if not _PH_RE.match(v):
  216. continue
  217. if _modality(io.get('type', '') or '', type_reg) == 'media':
  218. continue # 图/视频/音频 用 <描述> 合理
  219. bad.append(f"[{pid}] {step.get('id')}.{kind}[{j}] type={io.get('type','')!r} value={v.strip()!r}")
  220. return bad
  221. # =============================================================================
  222. # main
  223. # =============================================================================
  224. def main() -> None:
  225. ap = argparse.ArgumentParser(
  226. prog='render-case.py',
  227. description='Procedure DSL · case_data → HTML 渲染',
  228. formatter_class=argparse.RawDescriptionHelpFormatter,
  229. epilog=__doc__.split('用法:')[1] if __doc__ else '',
  230. )
  231. ap.add_argument('--workflow', type=Path, required=True,
  232. help='workflow.json (含 procedures 数组). 内部组装 case_data = '
  233. 'workflow + source-input merge + --page-title + --case-id, 不落盘 case_data.json. '
  234. '配合 --source-input / --page-title / --case-id 一起用.')
  235. ap.add_argument('--page-title', type=str, default=None,
  236. help='页面标题, 仅与 --workflow 配对. e.g. "Case 5 · 产品宣传图 AI 工作流可视化"')
  237. ap.add_argument('--case-id', type=str, default=None,
  238. help='case 编号, 仅与 --workflow 配对')
  239. ap.add_argument('--source-input', type=Path, dest='source_input',
  240. help='可选: 原帖 raw json 路径 (e.g. input/case-N-raw.json). 给了的话, 自动把 '
  241. 'raw.body_text + 封面图 / 标题 / URL 填到 case_data.source 对应字段 '
  242. '(body_text/cover_image 直接覆盖 case_data 同字段, title/url 仅在 case_data 缺时填), '
  243. '让 HTML 折叠原文区显示完整原帖正文 + 内嵌图. excerpt/author/date 等 Agent 推断字段不动.')
  244. ap.add_argument('--out', type=Path,
  245. help='输出 HTML 路径. --validate 时可省略')
  246. ap.add_argument('--validate', action='store_true',
  247. help='只做 schema 校验, 不渲染. 退出码 0=通过, 1=失败')
  248. ap.add_argument('--no-validate', action='store_true',
  249. help='跳过校验直接渲染 (不推荐)')
  250. ap.add_argument('--allow-placeholders', action='store_true',
  251. help='跳过「文本类 value 占位」门禁 (调试用; 默认: 有文本类 <占位> value 就拒绝出 HTML)')
  252. args = ap.parse_args()
  253. # workflow.json 装 procedures 主体 + cli 套 case-level meta
  254. case_data = load_json(args.workflow)
  255. if args.page_title:
  256. case_data['page_title'] = args.page_title
  257. if args.case_id is not None:
  258. try:
  259. case_data['case_id'] = int(args.case_id)
  260. except ValueError:
  261. case_data['case_id'] = args.case_id
  262. # source 由 --source-input 提供 (workflow.json 不含 source); 没给就让 schema validate 报错
  263. # raw 原帖 → case_data.source merge (在 validate 前, 让 schema 校验看到的就是完整版)
  264. if args.source_input:
  265. merge_raw_source(case_data, args.source_input)
  266. def _count_steps(cd):
  267. return sum(len(p.get('steps') or []) for p in cd.get('procedures', []))
  268. if not args.no_validate:
  269. validate(case_data, strict=not args.validate)
  270. # 占位硬门禁: 文本类 value 仍是 <占位> → 拒绝出 HTML, 逼回填真内容 (媒体类/inferred/原文未提供 放行)。
  271. # 放在这里 (validate 块外), 所以 --no-validate 也绕不过; --validate 模式也会查。
  272. if not args.allow_placeholders:
  273. ph = check_placeholder_values(case_data)
  274. if ph:
  275. print(f'render-case: ✗ {len(ph)} 处文本类 value 仍是 <占位>, 拒绝出 HTML —— 必须回填真实内容:', file=sys.stderr)
  276. for h in ph:
  277. print(f' - {h}', file=sys.stderr)
  278. print(' → 用 wf-patch 的 `@quote|<起锚>|<止锚>` + `--resolve-quotes --source <原文> --ocr <ocr.txt>` '
  279. '从原文/OCR 回填真内容;\n'
  280. ' 原文确无该信息 → value 写成 `<占位>(原文未提供)`(或给该 IO 标 `inferred:true`+reason)即可放行;\n'
  281. ' 媒体类(图/视频/音频)用 `<描述>` 本就不受此限。', file=sys.stderr)
  282. print(' (确需带占位渲染才加 --allow-placeholders)', file=sys.stderr)
  283. die(f'{len(ph)} 处文本类 value 占位未回填')
  284. # 结构完整性硬门禁: ① 干骨架没填满(via 空 / 输入既无 value 又无 anchor) ② anchor 没闭合
  285. # (透传没回填 / anchor 写成 JSON 路径) → 拒绝出 HTML, 逼回 workflow.json 修缮。
  286. # 和占位门禁一样放在 validate 块外, --no-validate 也绕不过; 都是确定性判断, 不会困在不可满足的 loop。
  287. _lint = _load_lint()
  288. struct_issues = _lint.check_skeleton_filled(case_data) + _lint.check_anchor_closure(case_data)
  289. if struct_issues:
  290. print(f'render-case: ✗ {len(struct_issues)} 处结构未完成, 拒绝出 HTML —— workflow.json 还没填好:', file=sys.stderr)
  291. for h in struct_issues:
  292. print(f' - {h}', file=sys.stderr)
  293. print(' → Phase 2.0 把骨架填满: 每步补 via; 每个输入要么填 value(@quote 拽原文)要么连 anchor ← 上游编号;\n'
  294. ' 透传输入跑 `wf-patch.py --workflow <wf> --resolve-passthrough` 自动回填;\n'
  295. ' anchor 用输出**编号** ← s1o1, 不要写 JSON 路径 ← p1.s1.outputs[0]。', file=sys.stderr)
  296. die(f'{len(struct_issues)} 处结构未完成')
  297. if not args.no_validate and args.validate:
  298. n_procs = len(case_data.get('procedures', []))
  299. print(f'render-case: ✓ {SCHEMA_PATH.name} 校验通过 '
  300. f'({n_procs} procedure{"s" if n_procs > 1 else ""}, {_count_steps(case_data)} steps)')
  301. if not args.out:
  302. return
  303. if not args.out:
  304. ap.error('--out 必填 (除非用 --validate 只校验)')
  305. build_html = _import_build_html()
  306. html = build_html(case_data)
  307. args.out.parent.mkdir(parents=True, exist_ok=True)
  308. # newline='' 保留 build_html 返回串里的换行原貌, 防 Windows 把 \n 翻译成 \r\n
  309. args.out.write_text(html, encoding='utf-8', newline='')
  310. n_procs = len(case_data.get('procedures', []))
  311. print(f'render-case: ✓ wrote {args.out} ({len(html):,} chars, '
  312. f'{n_procs} procedure{"s" if n_procs > 1 else ""}, {_count_steps(case_data)} steps)')
  313. if __name__ == '__main__':
  314. main()