Explorar el Código

add procedure batch extraction

guantao hace 4 días
padre
commit
5078dd2bef
Se han modificado 100 ficheros con 9381 adiciones y 188 borrados
  1. 4 1
      .claude/settings.local.json
  2. 4 2
      agent/core/runner.py
  3. 25 3
      agent/llm/openrouter.py
  4. 0 1
      agent/tools/builtin/bash.py
  5. 26 5
      agent/tools/builtin/file/read.py
  6. 7 3
      agent/tools/builtin/subagent.py
  7. 5 1
      agent/tools/models.py
  8. 3 0
      agent/trace/compaction.py
  9. 5 0
      config/pricing.yaml
  10. 272 0
      examples/process_pipeline/script/search_eval/build_workflows.py
  11. 526 21
      examples/process_pipeline/script/search_eval/index.html
  12. 10 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/08f4a3ca7cb65d1557941447.txt
  13. 27 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/0c6166c50677ad63615fbc0e.txt
  14. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/14285aaabe520561b405b9ce.txt
  15. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/148e908015d6aa03e638612f.txt
  16. 22 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/1582c9a8cb03a10cfd1535f3.txt
  17. 14 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/17e1a0676261e50c412abba6.txt
  18. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/1963dea2d49e9d17b147bf92.txt
  19. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/1c65ec448ae8e0b2cde76505.txt
  20. 15 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/1d9529a7a224f2dd465fae74.txt
  21. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/2775aa0c0ae49e777365e013.txt
  22. 17 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/27ed6e765a1027c7dde6bbc8.txt
  23. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/3047486f5d30fcd165562a70.txt
  24. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/31a9169a762f66145bdd20a1.txt
  25. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/33a57a682574b3bdc7a4ef1d.txt
  26. 12 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/3727ced81fe37aae0859a7e1.txt
  27. 22 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/3bc179a02a0278efb717ea8e.txt
  28. 29 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/4567df46247690afb86a8097.txt
  29. 6 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/48f9262920544d7152293a11.txt
  30. 22 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/4c513f3a1f1970419a931545.txt
  31. 21 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/4c98650ae0998b2da7e47a9c.txt
  32. 28 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/541f80bd1ef3bab31c476e01.txt
  33. 6 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/557245b887fe0642644c658e.txt
  34. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/5bb32f5fc34afa930e5a3b97.txt
  35. 71 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/5f0a1ee0b3029234d178bda5.txt
  36. 20 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/5f0bc0eae44d80bf769c4342.txt
  37. 5 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/60b5eb5faa236679ef9ff458.txt
  38. 13 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/65bd0e3e0f16e2a1ec9775cb.txt
  39. 5 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/6cf8a1b10da9781390462239.txt
  40. 27 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/84a20ed8bc0f363da8feeb2d.txt
  41. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/8d38c837c6372c41a22085f7.txt
  42. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/918b8a43ba9e5809d92f570f.txt
  43. 19 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/9e82de5a607081a08c9ffe8d.txt
  44. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/9eb0720ed0183e0ff9c1f0ac.txt
  45. 13 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/a1232b3d838cbd3b3dfe9e42.txt
  46. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/a51151e08849c6b951e7f293.txt
  47. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/a61d1e0adab19616ab6ffb94.txt
  48. 12 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/aa5cdbdc5bff1013e3d742de.txt
  49. 8 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/abcfbf2003e241cd0340ae71.txt
  50. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/ad7df89a60dc6bf7091c33fd.txt
  51. 21 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/b04b94561e3ed4ef672b314f.txt
  52. 2 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/b588afedf24996422f62710d.txt
  53. 15 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/bb175172314c2add2ce5bb2c.txt
  54. 2 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/bfcca905e20a869037b5bdd7.txt
  55. 69 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/bfeb53ad6b0098b713d470e9.txt
  56. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/c1a842ee24a3372186bf7cff.txt
  57. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/ce275ae370fe3ba045f9c12a.txt
  58. 4 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/cff210e674272f54f3f296c6.txt
  59. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/d9bd2541efd711e6dd4e371b.txt
  60. 7 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/e5c8bbc7a5d7fe6c23253d22.txt
  61. 4 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/eb1cb6f8633ded53f748afe9.txt
  62. 25 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/efd441ad8e23be4f136358b5.txt
  63. 25 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/f06e6b4c1a750fd19ee075d7.txt
  64. 1 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/f16f2398164fec79059fc6d7.txt
  65. 10 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/f43ed5d6ec8493cd42e97c06.txt
  66. 9 0
      examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/fdb86c305830424408033714.txt
  67. 3 0
      examples/process_pipeline/script/search_eval/procedure-dsl/input/aaa_test.json
  68. 3 0
      examples/process_pipeline/script/search_eval/procedure-dsl/input/case-5_clean.json
  69. 289 0
      examples/process_pipeline/script/search_eval/procedure-dsl/plan_tool.py
  70. 0 24
      examples/process_pipeline/script/search_eval/procedure-dsl/presets_cyber.json
  71. 657 68
      examples/process_pipeline/script/search_eval/procedure-dsl/run_cyber.py
  72. 15 59
      examples/process_pipeline/script/search_eval/procedure-dsl/run_procedure_dsl.py
  73. 188 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/README.md
  74. 275 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/extraction/phase1-skeleton.md
  75. 197 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/extraction/phase2-normalize.md
  76. 34 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/extraction/phase3-finalize.md
  77. 268 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/format/case-data.schema.json
  78. 402 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/action.json
  79. 69 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/effect.json
  80. 21 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/feature.json
  81. 562 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/type.json
  82. 383 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/type_suggestions.md
  83. 238 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools.md
  84. 588 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/lint-case.py
  85. 239 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/quote-source.py
  86. 346 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/render-case.py
  87. 661 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/renderer.py
  88. 194 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/script.js
  89. 306 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/styles.css
  90. 242 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/verify-io.py
  91. 909 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/wf-patch.py
  92. 235 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/README.md
  93. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/control-flow.md
  94. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/fields.md
  95. 50 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/phase1-skeleton.md
  96. 125 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/phase2-normalize.md
  97. 30 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/phase3-finalize.md
  98. 327 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/format/case-data.schema.json
  99. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/format/md-structure.md
  100. 0 0
      examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/format/procedure-table.md

+ 4 - 1
.claude/settings.local.json

@@ -19,7 +19,10 @@
       "mcp__ide__getDiagnostics",
       "Bash(TOOL_AGENT_ROUTER_URL=\"http://43.106.118.91:8001\" python:*)",
       "Bash(rm /Users/sunlit/.claude/skills/agent /Users/sunlit/.claude/skills/toolhub /Users/sunlit/.claude/skills/knowhub /Users/sunlit/.claude/skills/content-search)",
-      "Read(//Users/sunlit/.claude/skills/**)"
+      "Read(//Users/sunlit/.claude/skills/**)",
+      "Read(//c/c/Users/11304/gitlab/cybertogether/Agent-tao_test/**)",
+      "Read(//c/c/Users/11304/gitlab/cybertogether/Agent-tao_test/agent/tools/**)",
+      "Read(//c/c/Users/11304/gitlab/cybertogether/Agent-tao_test/agent/llm/**)"
     ],
     "deny": [],
     "ask": []

+ 4 - 2
agent/core/runner.py

@@ -124,6 +124,7 @@ class RunConfig:
     name: Optional[str] = None                 # 显示名称(空则由 utility_llm 自动生成)
     enable_prompt_caching: bool = True         # 启用 Anthropic Prompt Caching(仅 Claude 模型有效)
     parallel_tool_execution: bool = False      # 是否启用并发 Tool Call 执行(慎用,需确保无资源冲突)
+    context_injection_interval: int = 5        # 每 N 轮自动注入一次 get_current_context(GoalTree+协作者+IM 通知);**0=完全关闭**(含第 0 轮)
 
     # --- Trace 控制 ---
     trace_id: Optional[str] = None             # None = 新建
@@ -1277,8 +1278,9 @@ class AgentRunner:
             cache_creation_tokens = result.get("cache_creation_tokens")
             cache_read_tokens = result.get("cache_read_tokens")
 
-            # 周期性自动注入上下文(仅主路径)
-            if not side_branch_ctx and iteration % CONTEXT_INJECTION_INTERVAL == 0:
+            # 周期性自动注入上下文(仅主路径;config.context_injection_interval=0 时完全关闭)
+            _cii = getattr(config, "context_injection_interval", CONTEXT_INJECTION_INTERVAL)
+            if not side_branch_ctx and _cii > 0 and iteration % _cii == 0:
                 # 检查是否已经调用了 get_current_context
                 if tool_calls:
                     has_context_call = any(

+ 25 - 3
agent/llm/openrouter.py

@@ -679,10 +679,29 @@ async def _openrouter_anthropic_call(
     else:
         raise last_exception  # type: ignore[misc]
 
+    # OpenRouter 有时返回 HTTP 200 但 body 里是 error 对象 (上游 provider 限流/封禁),
+    # raise_for_status() 拦不住。不显式检查的话会被解析成空 content, 上层 agent loop
+    # 误判"无 tool_call = 任务完成"而静默收尾 (极难排查)。这里转成显式异常。
+    if isinstance(result, dict) and result.get("error"):
+        err = result["error"]
+        raise RuntimeError(f"[OpenRouter/Anthropic] 200 但 body 含 error: {json.dumps(err, ensure_ascii=False)[:500]}")
+
     # 解析 Anthropic 响应 → 统一格式
     parsed = _parse_anthropic_response(result)
+
+    # 空 completion (无 content 也无 tool_calls) 对 agent loop 是死局, 大概率是上游
+    # 限流/图片被拦返回了空。显式报错而非让 loop 以为"完成"。
+    if not parsed["content"] and not parsed["tool_calls"]:
+        raise RuntimeError(
+            f"[OpenRouter/Anthropic] 空 completion (无 content/tool_calls). "
+            f"stop_reason={result.get('stop_reason')!r}, 顶层keys={list(result.keys())}. "
+            f"常见原因: Claude+图走 OpenRouter 共享池被上游限流, 换原生 Anthropic key 或 BYOK。"
+        )
+
     usage = parsed["usage"]
-    cost = calculate_cost(model, usage)
+    # 优先用 OpenRouter 返回的真实 cost (probe 确认 /messages 端点默认带 usage.cost)。
+    _or_cost = (result.get("usage") or {}).get("cost")
+    cost = _or_cost if _or_cost is not None else calculate_cost(model, usage)
 
     return {
         "content": parsed["content"],
@@ -799,6 +818,7 @@ async def openrouter_llm_call(
     payload = {
         "model": model,
         "messages": messages,
+        "usage": {"include": True},   # 让 OpenRouter 在响应里带真实 cost (权威账单, 不依赖本地价表)
     }
 
     # 添加可选参数
@@ -888,8 +908,10 @@ async def openrouter_llm_call(
     raw_usage = result.get("usage", {})
     usage = _parse_openrouter_usage(raw_usage, model)
 
-    # 计算费用
-    cost = calculate_cost(model, usage)
+    # 优先用 OpenRouter 返回的真实 cost (权威账单, 自动覆盖新模型); 没返回再按本地价表估算。
+    cost = raw_usage.get("cost")
+    if cost is None:
+        cost = calculate_cost(model, usage)
 
     return {
         "content": content,

+ 0 - 1
agent/tools/builtin/bash.py

@@ -220,7 +220,6 @@ async def bash_command(
         venv_ok = await _ensure_venv(venv_dir)
         if venv_ok:
             actual_command = _wrap_command_with_venv(command, venv_dir)
-            print(f"[bash] 🐍 使用虚拟环境: {venv_dir}")
             logger.info(f"[bash] 使用虚拟环境: {venv_dir}")
         else:
             logger.warning(f"[bash] 虚拟环境不可用,回退到系统环境: {venv_dir}")

+ 26 - 5
agent/tools/builtin/file/read.py

@@ -32,6 +32,7 @@ async def read_file(
     file_path: str,
     offset: int = 0,
     limit: int = DEFAULT_READ_LIMIT,
+    char_offset: int = 0,
     context: Optional[ToolContext] = None
 ) -> ToolResult:
     """
@@ -46,6 +47,9 @@ async def read_file(
         file_path: 文件路径(绝对路径、相对路径或 HTTP/HTTPS URL)
         offset: 起始行号(从 0 开始)
         limit: 读取行数(默认 2000 行)
+        char_offset: 在**起始行**内跳过的字符数(默认 0)。用于分段续读超长单行:
+            当某行超过单行上限被截断时,输出会提示用 char_offset=<位置> 接着读该行。
+            适合 JSON 等"长字符串挤在一行"的数据文件(行级 offset 翻不动单行)。
         context: 工具上下文
 
     Returns:
@@ -139,23 +143,36 @@ async def read_file(
         output_lines = []
         total_bytes = 0
         truncated_by_bytes = False
+        line_continues_at = None   # (行号0based, 该行已读到的字符位置) — 超长行可续读
 
         for i in range(offset, end_line):
             line = lines[i].rstrip('\n\r')
 
-            # 行长度限制(参考 opencode:104)
+            # char_offset 只作用于起始行: 跳过已读过的前缀, 实现"接着读这一行"
+            start = char_offset if (i == offset and char_offset > 0) else 0
+            if start:
+                line = line[start:]
+
+            # 行长度限制(参考 opencode:104)。超长不再是"砍掉就没了":
+            # 记录可续读的字符位置, 让 Agent 用 char_offset 分段读完整行。
             if len(line) > MAX_LINE_LENGTH:
                 line = line[:MAX_LINE_LENGTH] + "..."
+                line_continues_at = (i, start + MAX_LINE_LENGTH)
 
             # 字节限制(参考 opencode:105-112)
             line_bytes = len(line.encode('utf-8')) + (1 if output_lines else 0)
             if total_bytes + line_bytes > MAX_BYTES:
                 truncated_by_bytes = True
+                line_continues_at = None   # 这行没真正放进去, 别误导续读
                 break
 
             output_lines.append(line)
             total_bytes += line_bytes
 
+            # 长行被截断: 先停在这, 让 Agent 用 char_offset 续读该行 (而非跳到下一行漏内容)
+            if line_continues_at is not None:
+                break
+
         # 格式化输出(参考 opencode:114-134)
         formatted = []
         for idx, line in enumerate(output_lines):
@@ -166,10 +183,14 @@ async def read_file(
 
         last_read_line = offset + len(output_lines)
         has_more = total_lines > last_read_line
-        truncated = has_more or truncated_by_bytes
-
-        # 添加提示
-        if truncated_by_bytes:
+        truncated = has_more or truncated_by_bytes or (line_continues_at is not None)
+
+        # 添加提示 (优先提示"续读超长行", 否则才是行级翻页)
+        if line_continues_at is not None:
+            li, cpos = line_continues_at
+            output += (f"\n\n(第 {li + 1} 行过长被截断, 已读到第 {cpos} 字符。"
+                       f"续读该行剩余内容: read_file(file_path=..., offset={li}, char_offset={cpos}))")
+        elif truncated_by_bytes:
             output += f"\n\n(输出在 {MAX_BYTES} 字节处被截断。使用 'offset' 参数读取第 {last_read_line} 行之后的内容)"
         elif has_more:
             output += f"\n\n(文件还有更多内容。使用 'offset' 参数读取第 {last_read_line} 行之后的内容)"

+ 7 - 3
agent/tools/builtin/subagent.py

@@ -498,7 +498,11 @@ async def _run_agents(
         debug = getattr(runner, 'debug', False)
         agent_label = (agent_type or ("delegate" if single else f"explore-{i+1}"))
         debug_printer = _make_event_printer(agent_label) if debug else None
-        # 为了彻底封死逃逸风险,禁用由于缺省 tool_groups 注入进来的 agent 和 bash_command
+        # 元工具 agent/evaluate 始终禁用(防递归 spawn)。bash_command 分模式:
+        #   - delegate (single): 委托型子 Agent 常需跑脚本(如 taxonomy-lookup.py /
+        #     wf-patch.py),放开 bash_command,否则这类子 Agent 干不了被分发的活。
+        #   - explore (multi): 只读探索,保持封死 bash_command 防并行逃逸。
+        sub_exclude = ["agent", "evaluate"] if single else ["agent", "evaluate", "bash_command"]
         on_event = _make_interactive_handler(
             runner, cur_stid, trace_id, debug_printer=debug_printer
         )
@@ -512,8 +516,8 @@ async def _run_agents(
                 model=parent_trace.model if parent_trace else "gpt-4o",
                 uid=parent_trace.uid if parent_trace else None,
                 tools=allowed_tools,
-                tool_groups=[], # 清空默认的 "core" 组,防止把 agent 和 bash_command 偷渡进来
-                exclude_tools=["agent", "evaluate", "bash_command"], # 严格锁死危险元工具
+                tool_groups=[], # 清空默认的 "core" 组,防止把 agent 偷渡进来
+                exclude_tools=sub_exclude, # delegate 放开 bash,explore 仍锁死
                 name=task_item[:50],
                 skills=skills,
                 knowledge=context.get("knowledge_config"),

+ 5 - 1
agent/tools/models.py

@@ -54,8 +54,12 @@ class ToolResult:
 		Returns:
 			给 LLM 的消息字符串
 		"""
-		# 如果有错误,优先返回错误
+		# 有错误: 错误摘要 + 详细输出一起返回。output 常含命令 stdout/stderr 的真实报错
+		# (如 wf-patch 的「✗ 某字段 不合法」), 不能丢 —— 否则 LLM 只看到 "exit code 1"
+		# 无从修复, 只能瞎重试到死。
 		if self.error:
+			if self.output and self.output.strip() and self.output.strip() != "(命令无输出)":
+				return f"Error: {self.error}\n\n{self.output}"
 			return f"Error: {self.error}"
 
 		# 构建消息

+ 3 - 0
agent/trace/compaction.py

@@ -61,6 +61,9 @@ MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
     "gemini-1.5-flash": 1_000_000,
     "gemini-3.1-pro": 1_000_000,     # 2026 补充
     "gemini-3-flash": 1_000_000,     # 2026 补充
+    "gemini-3.1-flash": 1_000_000,        # 2026 补充
+    "gemini-3.1-flash-lite": 1_000_000,   # 2026 补充 (run_cyber 默认弱模型; 漏登记会落 200k 默认 → 阈值仅 10w)
+    "gemini-3.5-flash": 1_000_000,        # 2026 补充
 
     # --- Alibaba Qwen (通义千问) ---
     "qwen3.5-plus": 1_000_000,      # 2026 补充:最新旗舰

+ 5 - 0
config/pricing.yaml

@@ -225,6 +225,11 @@ models:
     reasoning_price: 3
     provider: openrouter
 
+  - model: google/gemini-3.1-flash-lite
+    input_price: 0.25
+    output_price: 1.50
+    provider: openrouter
+
   - model: google/gemini*
     input_price: 0.30
     output_price: 2.50

+ 272 - 0
examples/process_pipeline/script/search_eval/build_workflows.py

@@ -0,0 +1,272 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""把一个 run 目录(如 runs_full/q0000)里**每个帖子**的 workflow.json,
+与它对应的 post 信息(含 llm_evaluation)以及 query 词合并成一个 JSON。
+以帖子为单位:一个 procedure 输出一个文件,文件落在 search_eval/workflows/ 下。
+(例:q0000 有 3 个 procedure -> 写出 3 个 json)
+
+映射逻辑:
+  procedure 文件夹名形如  {FORM}_{platform}_{hash前缀}   例: A_gzh_8f5fbfb0
+  -> 读 form_{FORM}.json,在 results[] 里找 case_id 以 "{platform}_{hash前缀}" 开头的那条
+  -> 该 result 即对应的 post(post / comments / llm_evaluation / source_url ...)
+  -> query / original_q 取自 form_{FORM}.json 顶层
+
+输出文件名: {run_id}_{folder}.json    例: q0000_A_gzh_8f5fbfb0.json
+
+本模块既是 build 脚本,也是一个 HTTP 接口:
+  * build 函数(build_run / write_run)保留,供外部 import 调用或经 POST /build 触发;
+  * 接口本身实时扫描 workflows/ 目录,把里面所有 json 以数组形式返回。
+
+用法(build):
+  python build_workflows.py              # 默认处理 q0000
+  python build_workflows.py q0003        # 处理指定 run
+  python build_workflows.py --all        # 处理 runs_full 下所有 q* 目录
+
+用法(接口):
+  python build_workflows.py serve [port] # 默认 8771
+    GET  /workflows   -> 实时扫描 workflows/*.json,返回数组
+    GET  /            -> 同上(方便直接访问)
+    POST /build       -> body {"q":"q0003"} 或 {"all":true},触发 build 后返回结果
+"""
+import json
+import os
+import re
+import sys
+import glob
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+RUNS_DIR = os.path.join(HERE, "runs_full")
+OUT_DIR = os.path.join(HERE, "workflows")
+DEFAULT_PORT = 8771
+
+# 文件夹名: 表单字母 _ 平台 _ case_id 哈希前缀
+FOLDER_RE = re.compile(r"^([A-Za-z])_([a-z0-9]+)_([0-9a-fA-F]+)$")
+
+
+def load_json(path):
+    with open(path, encoding="utf-8") as f:
+        return json.load(f)
+
+
+def build_run(run_id, runs_dir=None, only_folder=None):
+    """为单个 run 目录构建合并结果。
+
+    以帖子为单位:返回一个 list,每个元素 is (folder, merged_dict),
+    merged_dict 即单个帖子的合并 JSON(query + post + llm_evaluation + workflow)。
+    找不到 procedures 时返回空 list。
+    runs_dir 缺省用模块的 RUNS_DIR;外部脚本(如 batch_extract_procedures.py 用了
+    --output-dir)可传入自己的 runs_full,避免两边路径不一致。
+    only_folder 非空时只处理那一个 folder(其余直接跳过、不刷 warn)——给 write_one
+    用,避免同 q 里别的未完成 folder 每次都被扫一遍刷一堆 warn。"""
+    run_dir = os.path.join(runs_dir or RUNS_DIR, run_id)
+    proc_root = os.path.join(run_dir, "procedures")
+    if not os.path.isdir(proc_root):
+        print(f"[skip] {run_id}: 没有 procedures/ 目录")
+        return []
+
+    # 缓存已加载的 form_{X}.json,并记录 query(取第一个见到的)
+    forms = {}
+    query = original_q = None
+    platforms = None
+
+    def get_form(letter):
+        nonlocal query, original_q, platforms
+        if letter not in forms:
+            forms[letter] = load_json(os.path.join(run_dir, f"form_{letter}.json"))
+            if query is None:
+                query = forms[letter].get("query")
+                original_q = forms[letter].get("original_q")
+                platforms = forms[letter].get("platforms")
+        return forms[letter]
+
+    out = []
+    for folder in sorted(os.listdir(proc_root)):
+        if only_folder and folder != only_folder:
+            continue                       # write_one 只要这一个, 其余静默跳过
+        folder_path = os.path.join(proc_root, folder)
+        if not os.path.isdir(folder_path):
+            continue
+        m = FOLDER_RE.match(folder)
+        if not m:
+            print(f"[warn] {run_id}/{folder}: 文件夹名不符合命名规则,跳过")
+            continue
+        form_letter, platform, hash_prefix = m.groups()
+
+        wf_path = os.path.join(folder_path, "workflow.json")
+        if not os.path.isfile(wf_path):
+            print(f"[warn] {run_id}/{folder}: 没有 workflow.json,跳过")
+            continue
+        workflow = load_json(wf_path)
+
+        # 在对应 form 里按 case_id 前缀找匹配的 post
+        form_data = get_form(form_letter)
+        want_prefix = f"{platform}_{hash_prefix}"
+        hits = [r for r in form_data.get("results", [])
+                if r.get("case_id", "").startswith(want_prefix)]
+        if len(hits) != 1:
+            print(f"[warn] {run_id}/{folder}: 匹配到 {len(hits)} 条 result(期望 1),跳过")
+            continue
+        result = hits[0]
+
+        # 可选:用 _source.json 的 link 校验映射没串台
+        src_path = os.path.join(folder_path, "_source.json")
+        if os.path.isfile(src_path):
+            src = load_json(src_path)
+            if src.get("link") and src["link"] != result.get("source_url"):
+                print(f"[warn] {run_id}/{folder}: _source.link 与 result.source_url 不一致")
+
+        # 以帖子为单位合并,只保留 5 个字段
+        merged = {
+            "query_id": run_id,
+            "query": query,
+            "platform": result.get("platform", platform),
+            "post": result.get("post"),
+            "llm_evaluation": result.get("llm_evaluation"),
+            "workflow": workflow,
+        }
+        out.append((folder, merged))
+
+    if not out:
+        print(f"[skip] {run_id}: 没有可合并的 procedure")
+    return out
+
+
+def _dump_merged(run_id, folder, merged):
+    """把单个帖子的 merged 写成 workflows/{run_id}_{folder}.json。"""
+    os.makedirs(OUT_DIR, exist_ok=True)
+    out_path = os.path.join(OUT_DIR, f"{run_id}_{folder}.json")
+    with open(out_path, "w", encoding="utf-8") as f:
+        json.dump(merged, f, ensure_ascii=False, indent=2)
+    print(f"[ok] {run_id}/{folder} -> {os.path.basename(out_path)}")
+
+
+def write_run(run_id, runs_dir=None):
+    entries = build_run(run_id, runs_dir=runs_dir)
+    if not entries:
+        return 0
+    for folder, merged in entries:
+        _dump_merged(run_id, folder, merged)
+    return len(entries)
+
+
+def write_one(run_id, folder, runs_dir=None):
+    """只把指定 procedure folder 的合并 json 写出 —— 跑完一个工序就立刻出一个,
+    无需等同 q 其他帖子。命中并写出返回 1, 没匹配到 (缺 workflow.json 等) 返回 0。
+    复用 build_run 的解析/校验逻辑, 只处理 folder 那一个 (only_folder 让同 q 其余
+    未完成目录被静默跳过, 不刷 warn)。"""
+    for f, merged in build_run(run_id, runs_dir=runs_dir, only_folder=folder):
+        if f == folder:
+            _dump_merged(run_id, folder, merged)
+            return 1
+    return 0
+
+
+def build_runs(run_ids):
+    """对一组 run 执行 write_run,返回写出的帖子 json 总数。"""
+    total = 0
+    for run_id in run_ids:
+        total += write_run(run_id)
+    return total
+
+
+def all_run_ids():
+    """runs_full 下所有 q* 目录。"""
+    return sorted(d for d in os.listdir(RUNS_DIR)
+                  if re.match(r"^q\d+$", d)
+                  and os.path.isdir(os.path.join(RUNS_DIR, d)))
+
+
+# ---------- 接口:实时扫描 workflows/ 并以数组返回 ----------
+
+def scan_workflows():
+    """实时扫描 workflows/*.json,把每个文件读成 dict,按文件名排序返回数组。
+
+    每次调用都重新读盘,所以 build 新写入的文件会立刻在接口里出现(无缓存)。"""
+    items = []
+    for fp in sorted(glob.glob(os.path.join(OUT_DIR, "*.json"))):
+        try:
+            items.append(load_json(fp))
+        except Exception as e:
+            print(f"[warn] 读取 {os.path.basename(fp)} 失败:{e}")
+    return items
+
+
+class Handler(BaseHTTPRequestHandler):
+    def _send(self, code, obj):
+        body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+        self.send_response(code)
+        self.send_header("Content-Type", "application/json; charset=utf-8")
+        self.send_header("Access-Control-Allow-Origin", "*")
+        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+        self.send_header("Access-Control-Allow-Headers", "Content-Type")
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+    def do_OPTIONS(self):  # CORS 预检
+        self._send(204, {})
+
+    def do_GET(self):
+        path = self.path.split("?")[0]
+        if path in ("/", "/workflows", "/api/workflows"):
+            self._send(200, scan_workflows())
+        else:
+            self._send(404, {"error": "not found"})
+
+    def do_POST(self):
+        if self.path.split("?")[0] != "/build":
+            self._send(404, {"error": "not found"}); return
+        length = int(self.headers.get("Content-Length") or 0)
+        raw = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
+        try:
+            payload = json.loads(raw)
+        except Exception as e:
+            self._send(400, {"error": f"bad json: {e}"}); return
+        if payload.get("all"):
+            run_ids = all_run_ids()
+        else:
+            q = (payload.get("q") or "").strip()
+            if not re.match(r"^q\d+$", q):  # 限定 qNN 形式,避免路径注入
+                self._send(400, {"error": f"bad q (expect 'qNN' or all=true): {q!r}"}); return
+            run_ids = [q]
+        try:
+            n = build_runs(run_ids)
+            self._send(200, {"status": "ok", "runs": run_ids, "written": n})
+        except Exception as e:
+            self._send(500, {"error": f"build failed: {e}"})
+
+    def log_message(self, *a):
+        pass
+
+
+def serve(port):
+    n = len(scan_workflows())
+    print(f"workflows 接口:http://0.0.0.0:{port}/workflows   "
+          f"(workflows/ 下当前 {n} 个 json,实时扫描)")
+    ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()
+
+
+def main(argv):
+    args = argv[1:]
+    if args and args[0] == "serve":
+        port = int(args[1]) if len(args) > 1 else DEFAULT_PORT
+        serve(port)
+        return
+
+    if "--all" in args:
+        run_ids = all_run_ids()
+    elif args:
+        run_ids = args
+    else:
+        run_ids = ["q0000"]
+    total_files = build_runs(run_ids)
+    print(f"\n完成:处理 {len(run_ids)} 个 run,共写出 {total_files} 个帖子 json")
+
+
+if __name__ == "__main__":
+    try:  # Windows 控制台默认 cp1252,中文 print 会崩,统一切 utf-8
+        sys.stdout.reconfigure(encoding="utf-8")
+    except Exception:
+        pass
+    main(sys.argv)

+ 526 - 21
examples/process_pipeline/script/search_eval/index.html

@@ -1328,6 +1328,38 @@
     .sc-row .info-icon:hover {
       color: #3b82f6;
     }
+    #reevalBtn {
+      background: #faf7f1;
+      color: var(--amber);
+      border-color: rgba(184, 121, 24, 0.3);
+      height: 38px;
+      padding: 0 16px;
+      font-size: 13px;
+      font-weight: 600;
+      border-radius: 8px;
+      box-shadow: var(--shadow);
+    }
+    #reevalBtn:hover {
+      background: var(--soft-amber);
+      color: var(--amber);
+      border-color: var(--amber);
+    }
+    #editSpecBtn {
+      background: #f0f7f6;
+      color: var(--cyan);
+      border-color: rgba(42, 111, 143, 0.3);
+      height: 38px;
+      padding: 0 16px;
+      font-size: 13px;
+      font-weight: 600;
+      border-radius: 8px;
+      box-shadow: var(--shadow);
+    }
+    #editSpecBtn:hover {
+      background: var(--soft-cyan);
+      color: var(--cyan);
+      border-color: var(--cyan);
+    }
   </style>
 </head>
 
@@ -1433,8 +1465,8 @@
                  onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#d1d5db'">
         </div>
 
-        <button id="reevalBtn" onclick="reevalCurrentQuery()" title="只对当前 query 的所有 form/帖子复评(不重新搜索)">♻️ 重评当前
-          query</button>
+        <button id="reevalBtn" onclick="reevalCurrentQuery()" title="只对当前 query 的所有 form/帖子复评(不重新搜索)">♻️ 重评当前 query</button>
+        <button id="editSpecBtn" onclick="openSpecEditor()" title="查看或修改 spec/ 提示词规范文件">📝 编辑 Spec 提示词</button>
         <select id="sort">
           <option value="score">按综合分排序</option>
           <option value="date">按发布时间排序</option>
@@ -1480,13 +1512,224 @@
       </aside>
     </div>
 
-    <div id="modalContentProcedure" style="display: none; height: 600px;">
-      <iframe id="procedureIframe" style="width: 100%; height: 100%; border: none; background: #fff;" referrerpolicy="no-referrer"></iframe>
+    <div id="modalContentProcedure" style="display: none; min-height: 500px; height: calc(100vh - 180px); max-height: 700px; padding: 16px; box-sizing: border-box; flex-direction: column;">
+      <!-- Top Action Bar (inside tab) -->
+      <div id="procActionBar" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--line); flex-shrink: 0;">
+        <div style="font-size: 14px; font-weight: bold; color: var(--ink);" id="procStatusText">工序状态: 检测中...</div>
+        <div style="display: flex; gap: 8px;" id="procActionBtns">
+          <!-- Dynamically populated buttons (Regenerate, View Logs, etc) -->
+        </div>
+      </div>
+      
+      <!-- Main view container -->
+      <div style="flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column;">
+        <!-- Configuration / Setup panel (when not generated) -->
+        <div id="procSetupPanel" style="display: none; flex-direction: column; align-items: center; justify-content: center; text-align: center; height: 100%; padding: 40px 20px;">
+          <div style="font-size: 36px; margin-bottom: 16px;">✨</div>
+          <h4 style="margin: 0 0 10px; font-size: 18px;">提取本帖工序</h4>
+          <p style="color: var(--muted); font-size: 14px; max-width: 500px; margin: 0 0 24px;">该帖子目前尚未生成对应的结构化工序。请在下方选择提取引擎和模型,点击开始提取。</p>
+          
+          <div style="display: flex; gap: 16px; margin-bottom: 24px; text-align: left; background: #fff; border: 1px solid var(--line); padding: 16px; border-radius: 8px; box-shadow: var(--shadow);">
+            <div>
+              <label style="display: block; font-size: 12px; font-weight: bold; margin-bottom: 6px; color: var(--muted);">提取引擎 (Engine)</label>
+              <select id="procEngineSelect" style="min-width: 180px; padding: 6px 10px;" onchange="onProcEngineChange()">
+                <option value="cyber_runner">Cyber Runner (自研/OpenRouter)</option>
+                <option value="claude_sdk">Claude SDK (OAuth)</option>
+              </select>
+            </div>
+            <div>
+              <label style="display: block; font-size: 12px; font-weight: bold; margin-bottom: 6px; color: var(--muted);">AI 模型 (Model)</label>
+              <select id="procModelSelect" style="min-width: 240px; padding: 6px 10px;">
+                <!-- Dynamically populated options -->
+              </select>
+            </div>
+          </div>
+          
+          <button id="startProcBtn" onclick="startProcedureExtraction()" style="background: var(--mint); color: #fff; border-color: var(--mint); padding: 10px 24px; font-size: 15px; font-weight: bold; border-radius: 8px; cursor: pointer; transition: all 0.2s;">开始提取工序</button>
+        </div>
+        
+        <!-- Live Log Terminal View -->
+        <div id="procConsolePanel" style="display: none; flex-direction: column; height: 100%; min-height: 0; background: #1e1b18; border: 1px solid #3d352e; border-radius: 8px; overflow: hidden; box-shadow: inset 0 2px 10px rgba(0,0,0,0.5);">
+          <!-- Console header -->
+          <div style="background: #2b2520; padding: 6px 12px; border-bottom: 1px solid #3d352e; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; font-family: monospace; font-size: 12px; color: #a3968d;">
+            <span>TERMINAL CONSOLE</span>
+            <span id="procConsoleStatus">idle</span>
+          </div>
+          <!-- Console log output -->
+          <pre id="procConsoleOutput" style="flex: 1; margin: 0; padding: 12px; overflow: auto; font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 13px; line-height: 1.45; color: #cbbba9; background: #1e1b18; white-space: pre-wrap; word-break: break-all;"></pre>
+        </div>
+        
+        <!-- Iframe Panel for completed HTML -->
+        <iframe id="procedureIframe" style="display: none; width: 100%; height: 100%; border: 1px solid var(--line); border-radius: 8px; background: #fff;" referrerpolicy="no-referrer"></iframe>
+      </div>
+    </div>
+  </dialog>
+
+  <!-- Spec Editor Dialog -->
+  <dialog id="specEditorDialog" style="width: 850px; max-width: 95%; border: 1px solid var(--line); border-radius: 12px; padding: 0; box-shadow: var(--shadow); background: var(--panel);">
+    <div style="display: flex; justify-content: space-between; align-items: center; background: #faf7f1; border-bottom: 1px solid var(--line); padding: 16px 20px;">
+      <h3 style="margin: 0; font-size: 18px; color: var(--ink); font-weight: 800;">📝 编辑 Spec 提示词规范</h3>
+      <button onclick="document.getElementById('specEditorDialog').close()" style="background: none; border: 1px solid var(--line); padding: 5px 14px; border-radius: 8px; cursor: pointer; color: var(--muted); font-weight: 600; font-size: 12px;">关闭</button>
+    </div>
+    <div style="padding: 20px;">
+      <div style="margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
+        <span style="font-size: 13.5px; font-weight: 700; color: var(--ink);">选择提示词文件:</span>
+        <select id="specFileSelect" onchange="loadSpecFileContent()" style="padding: 6px 12px; border-radius: 8px; border: 1px solid #d1d5db; outline: none; min-width: 320px; font-family: monospace; font-size: 13px; font-weight: 600;">
+          <!-- Spec files options will be populated -->
+        </select>
+        <span id="specLoadStatus" style="font-size: 13px; font-weight: 600;"></span>
+      </div>
+      <div style="position: relative; margin-bottom: 20px;">
+        <textarea id="specContentTextarea" style="width: 100%; height: 500px; font-family: ui-monospace, Menlo, Monaco, Consolas, 'Courier New', monospace; font-size: 13.5px; line-height: 1.55; padding: 14px; border: 1px solid #d1d5db; border-radius: 8px; box-sizing: border-box; outline: none; background: #fafaf9; color: #1f2937; resize: vertical; border-left: 4px solid var(--cyan); box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);"></textarea>
+      </div>
+      <div style="display: flex; justify-content: flex-end; gap: 12px; align-items: center; border-top: 1px solid var(--line); padding-top: 16px;">
+        <span id="specSaveStatus" style="font-size: 13.5px; font-weight: 700; margin-right: auto;"></span>
+        <button onclick="document.getElementById('specEditorDialog').close()" class="btn" style="background: #f3f4f6; height: 38px; padding: 0 18px; border-radius: 8px; font-weight: 600;">取消</button>
+        <button onclick="saveSpecFileContent()" class="btn" style="background: var(--mint); color: #fff; border-color: var(--mint); font-weight: bold; height: 38px; padding: 0 20px; border-radius: 8px; box-shadow: var(--shadow);">💾 保存修改</button>
+      </div>
     </div>
   </dialog>
 
   <script>
     let DATA = { queries: [], actions: [], types: [], matrix: [] }, st = { form: 'A', lens: '工序', tools: [], tier: 0, qi: 0, fi: 0, channel: "all", matrixView: 'full' }, VIEW = [];
+    let currentProcTask = null;
+    let procPollInterval = null;
+    let isLogViewActive = false;
+    let reevalPollIntervals = {};
+
+    function startReevalPolling(q) {
+      if (reevalPollIntervals[q]) return;
+      const btn = document.getElementById('reevalBtn');
+      
+      const poll = () => {
+        fetch(`/api/reeval_status?q=${q}`)
+          .then(r => r.json())
+          .then(d => {
+            const isCurrent = DATA.queries[st.qi] && DATA.queries[st.qi].key === q;
+            if (d.status === "success") {
+              clearInterval(reevalPollIntervals[q]);
+              delete reevalPollIntervals[q];
+              if (isCurrent) {
+                btn.disabled = false;
+                btn.textContent = '♻️ 重评当前 query';
+              }
+              // Mark local status as finished
+              if (DATA.active_reevals) {
+                delete DATA.active_reevals[q];
+              }
+              loadData(true);
+              alert(`Query ${q} 重评完成!已自动重新扫描并更新数据。`);
+            } else if (d.status === "failed") {
+              clearInterval(reevalPollIntervals[q]);
+              delete reevalPollIntervals[q];
+              if (isCurrent) {
+                btn.disabled = false;
+                btn.textContent = '♻️ 重评当前 query';
+              }
+              if (DATA.active_reevals) {
+                delete DATA.active_reevals[q];
+              }
+              alert(`Query ${q} 重评失败:${d.error}`);
+            } else if (d.status === "running") {
+              if (isCurrent) {
+                btn.disabled = true;
+                btn.textContent = `♻️ 重评中 ${q}...`;
+              }
+            }
+          })
+          .catch(err => console.error("Poll error:", err));
+      };
+      
+      poll();
+      reevalPollIntervals[q] = setInterval(poll, 3000);
+    }
+
+    // Spec Prompt Editor Functions
+    const ALLOWED_SPEC_FILES = [
+      "README.md",
+      "tools.md",
+      "extraction/phase1-skeleton.md",
+      "extraction/phase2-normalize.md",
+      "extraction/phase3-finalize.md",
+      "taxonomy/type_suggestions.md"
+    ];
+
+    function openSpecEditor() {
+      const select = document.getElementById("specFileSelect");
+      select.innerHTML = ALLOWED_SPEC_FILES.map(f => `<option value="${esc(f)}">${esc(f)}</option>`).join("");
+      
+      document.getElementById("specLoadStatus").textContent = "";
+      document.getElementById("specSaveStatus").textContent = "";
+      document.getElementById("specContentTextarea").value = "";
+      
+      const dialog = document.getElementById("specEditorDialog");
+      dialog.showModal();
+      loadSpecFileContent();
+    }
+
+    function loadSpecFileContent() {
+      const file = document.getElementById("specFileSelect").value;
+      const status = document.getElementById("specLoadStatus");
+      const textarea = document.getElementById("specContentTextarea");
+      
+      status.textContent = "⏳ 正在读取...";
+      status.style.color = "var(--amber)";
+      textarea.disabled = true;
+      
+      fetch(`/api/spec_content?file=${encodeURIComponent(file)}`)
+        .then(r => r.json().then(d => ({ ok: r.ok, d })))
+        .then(({ ok, d }) => {
+          textarea.disabled = false;
+          if (ok) {
+            textarea.value = d.content || "";
+            status.textContent = "✓ 读取成功";
+            status.style.color = "var(--mint)";
+          } else {
+            status.textContent = "❌ 读取失败: " + (d.error || "未知错误");
+            status.style.color = "var(--rose)";
+          }
+        })
+        .catch(err => {
+          textarea.disabled = false;
+          status.textContent = "❌ 读取失败: " + err;
+          status.style.color = "var(--rose)";
+        });
+    }
+
+    function saveSpecFileContent() {
+      const file = document.getElementById("specFileSelect").value;
+      const content = document.getElementById("specContentTextarea").value;
+      const status = document.getElementById("specSaveStatus");
+      
+      status.textContent = "⏳ 正在保存到磁盘...";
+      status.style.color = "var(--amber)";
+      
+      fetch("/api/save_spec", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ file, content })
+      })
+      .then(r => r.json().then(d => ({ ok: r.ok, d })))
+      .then(({ ok, d }) => {
+        if (ok) {
+          status.textContent = "✅ 保存成功!新的 Prompt 规则已在本地生效。";
+          status.style.color = "var(--mint)";
+          setTimeout(() => {
+            if (status.textContent.includes("保存成功")) {
+              status.textContent = "";
+            }
+          }, 4000);
+        } else {
+          status.textContent = "❌ 保存失败: " + (d.error || "未知错误");
+          status.style.color = "var(--rose)";
+        }
+      })
+      .catch(err => {
+        status.textContent = "❌ 网络错误: " + err;
+        status.style.color = "var(--rose)";
+      });
+    }
+
     function esc(s) { return (s === undefined || s === null ? "" : String(s)).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;"); }
 
     const ACTIONS = [{ "name": "检索", "l1": "获取", "l2": "搜索" }, { "name": "下载", "l1": "获取", "l2": "搜索" }, { "name": "调取", "l1": "获取", "l2": "查询" }, { "name": "上传", "l1": "获取", "l2": "录入" }, { "name": "拍摄", "l1": "获取", "l2": "录入" }, { "name": "录音", "l1": "获取", "l2": "录入" }, { "name": "键入", "l1": "获取", "l2": "录入" }, { "name": "选取", "l1": "获取", "l2": "引用" }, { "name": "裁切", "l1": "提取", "l2": "物理提取" }, { "name": "抠取", "l1": "提取", "l2": "物理提取" }, { "name": "抽帧", "l1": "提取", "l2": "物理提取" }, { "name": "识别", "l1": "提取", "l2": "化学提取" }, { "name": "反推", "l1": "提取", "l2": "化学提取" }, { "name": "解构", "l1": "提取", "l2": "化学提取" }, { "name": "元素生成", "l1": "生成", "l2": "元素生成" }, { "name": "数组生成", "l1": "生成", "l2": "关系生成" }, { "name": "结构生成", "l1": "生成", "l2": "关系生成" }, { "name": "添加", "l1": "修改", "l2": "增" }, { "name": "叠加", "l1": "修改", "l2": "增" }, { "name": "抹除", "l1": "修改", "l2": "删" }, { "name": "剪除", "l1": "修改", "l2": "删" }, { "name": "重述", "l1": "修改", "l2": "变" }, { "name": "风格化", "l1": "修改", "l2": "变" }, { "name": "转换", "l1": "修改", "l2": "变" }, { "name": "替换", "l1": "修改", "l2": "变" }, { "name": "调整", "l1": "修改", "l2": "变" }, { "name": "增强", "l1": "修改", "l2": "变" }];
@@ -2168,27 +2411,28 @@
     }
 
     // POST /api/reeval —— 后台只对当前 query 的所有 form 文件复评(不重新搜索);
-    // server.py 立即返回 {status:'started', pid, log},前端只显示状态、不轮询,刷新页面看新数据
+    // server.py 立即返回 {status:'started', pid, log},前端开启轮询自动扫描刷新
     function reevalCurrentQuery() {
       if (st.qi === -1 || !DATA.queries[st.qi]) { alert('请先选一个 query 再重评'); return; }
       const q = DATA.queries[st.qi].key;
-      if (!confirm(`重评 ${q} 的所有帖子(A/B/C 三种 form)?\n约 1-3 分钟(视帖子数),过程中页面可继续浏览。\n完成后刷新页面看新数据。`)) return;
+      if (!confirm(`重评 ${q} 的所有帖子(A/B/C 三种 form)?\n约 1-3 分钟(视帖子数),过程中页面可继续浏览。\n完成后会自动重新扫描并更新页面。`)) return;
       const btn = document.getElementById('reevalBtn');
-      const oldText = btn.textContent;
       btn.disabled = true; btn.textContent = '♻️ 提交中…';
       fetch('/api/reeval', {
         method: 'POST', headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ q }),
       }).then(r => r.json().then(d => ({ ok: r.ok, d }))).then(({ ok, d }) => {
         if (ok && d.status === 'started') {
-          btn.textContent = `♻️ 重评中 ${q} (PID ${d.pid}) · 完成后刷新页面`;
-          // 不自动恢复 —— 让按钮停留在"重评中"状态直到用户主动刷新;日志见 runs/${q}/_reeval.log
+          if (!DATA.active_reevals) DATA.active_reevals = {};
+          DATA.active_reevals[q] = "running";
+          btn.textContent = `♻️ 重评中 ${q}...`;
+          startReevalPolling(q);
         } else {
-          btn.disabled = false; btn.textContent = oldText;
+          btn.disabled = false; btn.textContent = '♻️ 重评当前 query';
           alert('启动失败:' + (d.error || JSON.stringify(d)));
         }
       }).catch(e => {
-        btn.disabled = false; btn.textContent = oldText;
+        btn.disabled = false; btn.textContent = '♻️ 重评当前 query';
         alert('请求失败:' + e);
       });
     }
@@ -2288,6 +2532,253 @@
       }).join("") || (st.qi === -1 ? '<p style="color:var(--muted);padding: 20px 0;text-align:center;grid-column: 1 / -1;">该组合暂无数据库扫描结果。</p>' : '<p style="color:var(--muted);grid-column: 1 / -1;">该渠道无结果</p>');
     }
 
+    const ENGINE_MODELS = {
+      cyber_runner: [
+        { value: "google/gemini-3.1-flash-lite", text: "google/gemini-3.1-flash-lite (默认)" },
+        { value: "google/gemini-2.5-flash", text: "google/gemini-2.5-flash" },
+        { value: "openai/gpt-4o", text: "openai/gpt-4o" },
+        { value: "anthropic/claude-3.5-sonnet", text: "anthropic/claude-3.5-sonnet" }
+      ],
+      claude_sdk: [
+        { value: "claude-sonnet-4-6", text: "claude-sonnet-4-6 (默认)" },
+        { value: "claude-haiku-4-6", text: "claude-haiku-4-6" }
+      ]
+    };
+
+    function onProcEngineChange() {
+      const engine = document.getElementById("procEngineSelect").value;
+      const modelSelect = document.getElementById("procModelSelect");
+      modelSelect.innerHTML = ENGINE_MODELS[engine].map(m => `<option value="${m.value}">${m.text}</option>`).join("");
+    }
+
+    function getShortCaseFolder(form, case_id) {
+      const match = case_id.match(/^([a-z]+)_([0-9a-f]{8})/i);
+      const short = match ? `${match[1]}_${match[2]}` : case_id.substring(0, 20);
+      return `${form}_${short}`;
+    }
+
+    function checkProcedureState(it) {
+      if (procPollInterval) {
+        clearInterval(procPollInterval);
+        procPollInterval = null;
+      }
+      
+      currentProcTask = {
+        q: it.run,
+        form: st.form,
+        case_id: it.case_id,
+        procedure_html: it.procedure_html
+      };
+      
+      isLogViewActive = false;
+      document.getElementById("procStatusText").textContent = "正在检测工序状态...";
+      document.getElementById("procActionBtns").innerHTML = "";
+      
+      document.getElementById("procSetupPanel").style.display = "none";
+      document.getElementById("procConsolePanel").style.display = "none";
+      document.getElementById("procedureIframe").style.display = "none";
+      
+      const url = `/api/procedure_status?q=${it.run}&form=${st.form}&case_id=${it.case_id}`;
+      fetch(url)
+        .then(r => r.json())
+        .then(d => {
+          if (d.status === "running") {
+            showProcConsole("running");
+            pollProcLogs();
+          } else if (d.status === "success") {
+            showProcIframe(it.procedure_html);
+          } else if (d.status === "failed") {
+            showProcSetup(d.error);
+          } else {
+            showProcSetup();
+          }
+        })
+        .catch(err => {
+          showProcSetup("检查状态失败: " + err);
+        });
+    }
+
+    function showProcSetup(errorMsg) {
+      document.getElementById("procSetupPanel").style.display = "flex";
+      document.getElementById("procConsolePanel").style.display = "none";
+      document.getElementById("procedureIframe").style.display = "none";
+      
+      const statusText = document.getElementById("procStatusText");
+      if (errorMsg) {
+        statusText.innerHTML = `<span style="color:var(--rose)">提取失败: ${esc(errorMsg)}</span>`;
+      } else {
+        statusText.textContent = "未生成工序";
+      }
+      
+      document.getElementById("procActionBtns").innerHTML = "";
+      document.getElementById("startProcBtn").disabled = false;
+      document.getElementById("startProcBtn").textContent = "开始提取工序";
+      
+      onProcEngineChange();
+    }
+    
+    function showProcConsole(status) {
+      document.getElementById("procSetupPanel").style.display = "none";
+      document.getElementById("procConsolePanel").style.display = "flex";
+      document.getElementById("procedureIframe").style.display = "none";
+      
+      const statusText = document.getElementById("procStatusText");
+      const consoleStatus = document.getElementById("procConsoleStatus");
+      
+      if (status === "running") {
+        statusText.innerHTML = `<span>⏳ 正在提取工序中...</span>`;
+        consoleStatus.textContent = "running";
+        document.getElementById("procActionBtns").innerHTML = `<button class="btn" style="background:#f3f4f6" disabled>正在生成...</button>`;
+      } else if (status === "failed") {
+        statusText.innerHTML = `<span style="color:var(--rose)">❌ 提取失败</span>`;
+        consoleStatus.textContent = "failed";
+        document.getElementById("procActionBtns").innerHTML = `
+          <button class="btn" onclick="toggleCompletedLogs()">查看日志</button>
+          <button class="btn" style="background:var(--rose); color:#fff; border-color:var(--rose)" onclick="resetToSetup()">重试提取</button>
+        `;
+      }
+    }
+    
+    function resetToSetup() {
+      showProcSetup();
+    }
+    
+    function showProcIframe(htmlPath) {
+      document.getElementById("procSetupPanel").style.display = "none";
+      document.getElementById("procConsolePanel").style.display = "none";
+      
+      const iframe = document.getElementById("procedureIframe");
+      iframe.style.display = "block";
+      iframe.src = "/" + htmlPath;
+      
+      document.getElementById("procStatusText").innerHTML = `<span style="color:var(--mint)">✓ 工序已生成</span>`;
+      
+      document.getElementById("procActionBtns").innerHTML = `
+        <button class="btn" id="btnToggleLogs" onclick="toggleCompletedLogs()">📋 查看提取日志</button>
+        <button class="btn" style="background:var(--rose); color:#fff; border-color:var(--rose);" onclick="regenerateProcedureConfirm()">♻️ 重新生成</button>
+      `;
+    }
+    
+    function toggleCompletedLogs() {
+      const iframe = document.getElementById("procedureIframe");
+      const consolePanel = document.getElementById("procConsolePanel");
+      const toggleBtn = document.getElementById("btnToggleLogs");
+      
+      if (isLogViewActive) {
+        consolePanel.style.display = "none";
+        iframe.style.display = "block";
+        if (toggleBtn) toggleBtn.textContent = "📋 查看提取日志";
+        isLogViewActive = false;
+      } else {
+        iframe.style.display = "none";
+        consolePanel.style.display = "flex";
+        if (toggleBtn) toggleBtn.textContent = "👁️ 返回工序效果";
+        isLogViewActive = true;
+        fetchLogsOnce();
+      }
+    }
+    
+    function fetchLogsOnce() {
+      const consoleOutput = document.getElementById("procConsoleOutput");
+      consoleOutput.textContent = "正在加载日志...";
+      const url = `/api/procedure_log?q=${currentProcTask.q}&form=${currentProcTask.form}&case_id=${currentProcTask.case_id}`;
+      fetch(url)
+        .then(r => r.json())
+        .then(d => {
+          consoleOutput.textContent = d.log || "没有提取日志。";
+          consoleOutput.scrollTop = consoleOutput.scrollHeight;
+        })
+        .catch(err => {
+          consoleOutput.textContent = "加载日志失败: " + err;
+        });
+    }
+
+    function startProcedureExtraction() {
+      const engine = document.getElementById("procEngineSelect").value;
+      const model = document.getElementById("procModelSelect").value;
+      
+      const startBtn = document.getElementById("startProcBtn");
+      startBtn.disabled = true;
+      startBtn.textContent = "正在启动...";
+      
+      const payload = {
+        q: currentProcTask.q,
+        form: currentProcTask.form,
+        case_id: currentProcTask.case_id,
+        engine: engine,
+        model: model
+      };
+      
+      fetch("/api/generate_procedure", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload)
+      })
+      .then(r => r.json().then(d => ({ ok: r.ok, d })))
+      .then(({ ok, d }) => {
+        if (ok && d.status === "started") {
+          showProcConsole("running");
+          pollProcLogs();
+        } else {
+          showProcSetup(d.error || "启动失败");
+        }
+      })
+      .catch(err => {
+        showProcSetup("网络请求错误: " + err);
+      });
+    }
+    
+    function regenerateProcedureConfirm() {
+      if (confirm("确定要重新提取并生成工序吗?这会覆盖原有的工序文件和修改记录。")) {
+        showProcSetup();
+      }
+    }
+    
+    function pollProcLogs() {
+      if (procPollInterval) {
+        clearInterval(procPollInterval);
+      }
+      
+      const consoleOutput = document.getElementById("procConsoleOutput");
+      consoleOutput.textContent = "正在连接后台提取进程...\n";
+      
+      procPollInterval = setInterval(() => {
+        const statusUrl = `/api/procedure_status?q=${currentProcTask.q}&form=${currentProcTask.form}&case_id=${currentProcTask.case_id}`;
+        fetch(statusUrl)
+          .then(r => r.json())
+          .then(d => {
+            if (d.status === "success") {
+              clearInterval(procPollInterval);
+              procPollInterval = null;
+              loadData(true);
+              if (d.procedure_html) {
+                showProcIframe(d.procedure_html);
+              } else {
+                const folder = getShortCaseFolder(currentProcTask.form, currentProcTask.case_id);
+                showProcIframe(`runs_full/${currentProcTask.q}/procedures/${folder}/case-${currentProcTask.case_id}.html`);
+              }
+            } else if (d.status === "failed") {
+              clearInterval(procPollInterval);
+              procPollInterval = null;
+              showProcConsole("failed");
+            }
+          });
+          
+        const logUrl = `/api/procedure_log?q=${currentProcTask.q}&form=${currentProcTask.form}&case_id=${currentProcTask.case_id}`;
+        fetch(logUrl)
+          .then(r => r.json())
+          .then(d => {
+            if (d.log) {
+              const scrollAtBottom = consoleOutput.scrollTop + consoleOutput.clientHeight >= consoleOutput.scrollHeight - 50;
+              consoleOutput.textContent = d.log;
+              if (scrollAtBottom) {
+                consoleOutput.scrollTop = consoleOutput.scrollHeight;
+              }
+            }
+          });
+      }, 2000);
+    }
+
     function switchModalTab(tabName) {
       const detailTab = document.getElementById("tabDetailBtn");
       const procTab = document.getElementById("tabProcedureBtn");
@@ -2303,7 +2794,7 @@
         detailTab.classList.remove("active");
         procTab.classList.add("active");
         detailContent.style.display = "none";
-        procContent.style.display = "block";
+        procContent.style.display = "flex";
       }
     }
 
@@ -2336,14 +2827,9 @@
       }
       
       const tabs = document.getElementById("modalTabs");
-      const iframe = document.getElementById("procedureIframe");
-      if (it.procedure_html) {
-        tabs.style.display = "flex";
-        iframe.src = "/" + it.procedure_html;
-      } else {
-        tabs.style.display = "none";
-        iframe.src = "about:blank";
-      }
+      tabs.style.display = "flex";
+      
+      checkProcedureState(it);
       
       switchModalTab('detail');
       detailDialog.showModal();
@@ -2399,7 +2885,7 @@
       }
     }
 
-    // Automatically close tooltip when modal is closed
+    // Automatically close tooltip and clear interval when modal is closed
     document.getElementById('detailDialog').addEventListener('close', () => {
       const tip = document.getElementById('scoreTip');
       if (tip) {
@@ -2409,6 +2895,10 @@
         currentPinnedScoreEl.style.color = '';
         currentPinnedScoreEl = null;
       }
+      if (procPollInterval) {
+        clearInterval(procPollInterval);
+        procPollInterval = null;
+      }
     });
 
     // Close tooltip when dialog scrolls
@@ -2447,6 +2937,21 @@
       renderFormsChan();
       renderHead();
       renderGrid();
+
+      // Sync reevalBtn state
+      const reevalBtn = document.getElementById('reevalBtn');
+      if (reevalBtn && DATA.queries[st.qi]) {
+        const q = DATA.queries[st.qi].key;
+        const status = DATA.active_reevals && DATA.active_reevals[q];
+        if (status === "running") {
+          reevalBtn.disabled = true;
+          reevalBtn.textContent = `♻️ 重评中 ${q}...`;
+          startReevalPolling(q);
+        } else {
+          reevalBtn.disabled = false;
+          reevalBtn.textContent = '♻️ 重评当前 query';
+        }
+      }
     }
 
     function setMatrixView(mode) {

+ 10 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/08f4a3ca7cb65d1557941447.txt

@@ -0,0 +1,10 @@
+🍀特征污染(Feature Contamination)
+🌼画面串味
+🌼AI 把画面中不同物体的特征混在一起了。
+🌼如同调色盘没洗干净。画完红色的苹果,接着画白色的兔子,结果兔子耳朵变成了红色。
+🌼导致画面错误。比如人物的头发长到了墙上,或者背景的树变成了人的手指。
+🍀帧插值(Frame Interpolation)
+🌼补帧
+🌼在原有两帧画面之间,AI 自动计算并生成过渡画面。
+🌼如同翻书动画。原本每秒翻 5 页(5 帧)很卡顿,AI 帮你在每页之间加了 11 页,变成每秒 60 页,动作就丝滑了。
+🌼决定观感。开启后,视频从“PPT”变成“电影”,解决低帧率带来的卡顿感。

+ 27 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/0c6166c50677ad63615fbc0e.txt

@@ -0,0 +1,27 @@
+Qwen3-VL
++ 新建对话
+请把这段视频拆分成不同分...
+
+。 主体特征:中年女性,丹凤眼、柳叶眉,唇色朱红,表情冷峻。
+。 服装/物品:红色金线龙凤袍,头戴蓝金凤凰冠,垂挂珍珠与红宝石流苏。
+。 动作描述:微微侧首,目光锐利扫视前方,嘴角微扬似冷笑。
+。 镜头变化:近景推至特写,聚焦眼部与冠饰细节。
+。 构图:中心构图,人物占满画面,背景深褐色帷幕衬托华服。
+。 光影:正面柔光打亮面部,冠饰金属部分高光闪烁。
+。 台词/旁白:“你,可知罪?”(低沉女声)
+。 音效:环境寂静,仅留呼吸声与衣料摩擦声。
+。 画质参数:4K,帧率30fps,ISO 400,f/2.2,色温3500K。
+。 时长:1.0秒。
+
+分镜 4:白衣女子跪地哭泣
+。 画面风格:悲情戏剧风,面部特写强化情绪张力。
+。 主体特征:年轻女性,黑发湿漉漉贴面,泪痕斑驳,眼神绝望。
+。 服装/物品:素白交领襦裙,发间插白玉簪与小花饰。
+。 动作描述:低头垂泪,泪水沿脸颊滑落,肩部颤抖。
+。 镜头变化:特写→轻微动模拟呼吸节奏,后拉至中景。
+。 构图:斜侧角度拍摄,突出泪滴与湿润发丝,背景烛光成散景。
+。 光影:逆光勾勒轮廓,面部阴影加深悲伤感,烛光在泪珠上形成高光。
+。 台词/旁白:“求娘娘开恩…”(哽咽女声)
+。 音效:压抑抽泣声+远处钟声回响。
+。 画质参数:4K,帧率24fps,ISO 600,f/2.0,色温3000K。
+。 时长:1.5秒。

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/14285aaabe520561b405b9ce.txt

@@ -0,0 +1 @@
+空

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/148e908015d6aa03e638612f.txt

@@ -0,0 +1 @@
+空

+ 22 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/1582c9a8cb03a10cfd1535f3.txt

@@ -0,0 +1,22 @@
+OUTER FABRIC
+(DURABLE WATER REPELLENT)
+
+WATERPROOF/BREATHABLE
+MEMBRANE
+
+INNER LINING
+
+1. RAIN
+BLOCKED & ROLLS OFF
+
+2. SWEAT VAPOR
+ESCAPES
+
+WATERPROOF LAYER
+(BLOCKS RAIN)
+
+SWEAT VAPOR
+(PASSES THROUGH)
+
+3. STAY DRY & COMFORTABLE
+(TEMPERATURE REGULATION)

+ 14 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/17e1a0676261e50c412abba6.txt

@@ -0,0 +1,14 @@
+关键参数设置(配合指令使用)
+参数项 推荐数值 备注
+🌺Seed(种子) 固定数字(如666888) 想要重绘时,仅修改此数值
+🌺Denoising Strength(去噪强度) 0.15 - 0.25 数值越低,越接近参考图
+🌺Reference Image Weight(参考图权重) 0.9 确保风格与参考图一致
+🌺Frame Interpolation(补帧) 开启 提升视频流畅度至60帧/秒
+
+专业词汇(补充)
+通俗别名 一句话定义 直观类比 对视频质量的影响
+🍀交叉注意力控制(Cross Attention Control)
+🌼特征锁定器
+🌼AI在生成每一帧时,记住“谁是谁、什么是什么”的机制。
+🌼你在画画时,时刻盯着模特,确保画到眼睛时不会画成嘴巴。
+🌼决定稳定性。控制不好,人物会“换脸”、衣服会“融化”成背景。

+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/1963dea2d49e9d17b147bf92.txt


+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/1c65ec448ae8e0b2cde76505.txt


+ 15 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/1d9529a7a224f2dd465fae74.txt

@@ -0,0 +1,15 @@
+剧本输入进去,一个成品视频就出来了。假千金装可怜、真千金霸气
+2026年02月16日 09:51
+
+播放数据
+
+完播率             平均播放时长             播放
+52.09%             9.75秒                   20.9万
+
+三年历练,谢你当年退婚之恩#即梦AI#AI短剧#Seedance
+2026年02月08日 20:14
+
+播放数据
+
+完播率             平均播放时长             播放
+40.03%             14.52秒                  12.5万

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/2775aa0c0ae49e777365e013.txt

@@ -0,0 +1 @@
+一张为高性能男士冲锋衣宣传设计的极简写实背景图,画面具有大面积留白与平衡的渐层构图,主色调为深钢蓝、松绿色与冷白色,整体偏深,具科技感与冷冽氛围。可见轻微的雾气、风流纹理、冰冷空气光影反射等细节,呈现坚韧与理性的视觉印象。无人物、无产品,仅保留宁静且有质感的背景层次,哑光光效,适合叠加文字与产品图使用。

+ 17 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/27ed6e765a1027c7dde6bbc8.txt

@@ -0,0 +1,17 @@
+豆包
+内容由 AI 生成
+
+帮我生成一个视频:【0-3秒 | 当众羞辱】 - **场景**...
+你的视频生成好啦。
+
+以下是为你生成的视频:
+AI 生成
+
+图片 视频
+
+KRIS 3D
+KRIS 3D
+
+创建分身 模型 2.0 Fast 玩法 原比
+
+一把扯下假千金头上的皇冠戴在自己头上。【动作】她整理裙摆坐下,霸气侧漏。假千金面如死灰,被保安拖走。【对白口型指导】“这是我家,滚。”

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/3047486f5d30fcd165562a70.txt

@@ -0,0 +1 @@
+无

+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/31a9169a762f66145bdd20a1.txt


+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/33a57a682574b3bdc7a4ef1d.txt

@@ -0,0 +1 @@
+Columbia

+ 12 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/3727ced81fe37aae0859a7e1.txt

@@ -0,0 +1,12 @@
+Lovart 让设计更简单
+懂你的设计代理,帮你搞定一切
+
+发现一个超... 请以 JSON 结构化数据的形式,提取这张图片的视觉风格。请重点分析:颜色、构图、排版、主体、材质质感、光影、特效、画面氛围和适合复刻的关键词。输出要清晰、简洁,控制在 500 字以内。
+
+GPT Image 2
+Seedance 2.0
+Nano Banana Pro
+Design
+Branding
+E-Commerce
+Video

+ 22 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/3bc179a02a0278efb717ea8e.txt

@@ -0,0 +1,22 @@
+1.你的起点:C 2.剧本类型:都市爱情 3.总集数:15集 4.核心故事: 【风格】豪门恩怨 (High Society),真假千金梗,极致反转,撕绿茶爽文,高定礼服,美艳御姐风。 5.目标受众:通用。
+
+【状态摘要 v1.0】已锁定
+Step1 创意定型·完成
+• 起点: C 已有完整创意
+• 类型: 都市爱情 + 豪门恩怨
+• 总集数: 15 集
+• 核心故事: 真假千金梗,极致反转,撕绿茶爽文,高定礼服,美艳御姐风
+• 受众: 通用
+
+【Step2 全剧设计·自动生成】
+一、全剧大纲 (15集节奏)
+1.真千金流落民间,被找回豪门,初入豪宅受尽冷眼
+2.假千金伪装善良,暗中使绊,众人皆被蒙蔽
+3.真千金展露天赋,惊艳全场,初步动摇假千金地位
+4.假千金设计陷害,真千金陷入危机
+5.男主登场,识破阴谋,对真千金产生兴趣
+6.真千金开始反击,小试牛刀打脸绿茶
+7.豪门宴会高光,真千金穿高定礼服艳压全场
+
+发消息或输入“/”选择技能
+🔗 ⚡ 快速 Ⓟ PPT 生成 免费 ✍ 帮我写作 🖼 图像生成 ◉ 视频生成 </> 编程 ∷ 更多 🎙️

+ 29 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/4567df46247690afb86a8097.txt

@@ -0,0 +1,29 @@
+我已通过考试入群,邀请你,你敢吗?
+小灯塔
+主办方:小灯塔官方团队
+被写作种草的人
+都在这里相遇
+高贵而坚定的
+【写作种草群】
+入群趣味测试
+《极简写作能力综合测试》
+网感 用户感 变现
+技巧 新媒体 调性
+五分钟扫码参与
+经200人内测,本次测试通过率约为25%
+
+我已关注可视化行动派,邀请你,你来吗?
+行动派
+提升可视化表达力
+喜欢AI绘画的人
+AI绘画
+都在这里相遇
+高贵而坚定的
+【可视化行动群】
+入群趣味测试
+《极简可视化表达力测试》
+网感 用户感 变现
+技巧 新媒体 调性
+五分钟扫码参与
+经200人内测,本次测试通
+过率约为25%

+ 6 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/48f9262920544d7152293a11.txt

@@ -0,0 +1,6 @@
+华姐AI笔记
+中国大陆
+
+扫一扫上面的二维码图案,加我为朋友。
+
+公众号·华姐AI笔记

+ 22 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/4c513f3a1f1970419a931545.txt

@@ -0,0 +1,22 @@
+Qwen3-VL
++ 新建对话
+请把这段视频拆分...
+
+jimeng-2026-03-1....mp4
+11 MB
+请把这段视频拆分成不同分镜,并对每个分镜进行细致分析。每个分镜请包含:画面风格、主体特征、服装/物品、动作描述、镜头变化、构图、光影、人物台词或旁白、背景音乐/音效、画质参数和时长。最后请生成一段可用于生成相似视频的完整提示词,格式为:[视频内容描述], [风格], [主体动作], [运镜], [声音], [画质参数]。
+
+Qwen3-VL
+分镜拆解与分析
+分镜 1: 茶盏倾洒
+◦ 画面风格:古典宫廷风,柔焦特写,慢动作捕捉液体飞溅的瞬间。
+◦ 主体特征:一位身着红色华服的女性(仅手部入镜),手指纤细,指甲修剪整齐。
+◦ 服装/物品:红色刺绣长袍(金线龙纹),白瓷茶盏与托盘,茶汤呈琥珀色。
+◦ 动作描述:女子右手持杯,手腕微倾,茶水从杯口泼洒而出,形成弧形水花。
+◦ 镜头变化:特写→慢镜头拉远,聚焦飞溅水珠轨迹。
+◦ 构图:左侧主体占画面1/3,右侧留白突出动态水花,背景虚化。
+◦ 光影:暖黄主光从左上方打亮手部与茶盏,水珠反光晶莹剔透。
+◦ 台词/旁白:无。
+◦ 音效:清脆的瓷器碰撞声+水流泼洒的“哗啦”声。
+◦ 画质参数:4K,帧率60fps(慢动作),ISO 200,f/2.8,色温3200K。
+◦ 时长:0.2秒。

+ 21 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/4c98650ae0998b2da7e47a9c.txt

@@ -0,0 +1,21 @@
+Business Card.json
+{
+  "name": "Jamez Bondos",
+  "title": "Your Title",
+  "email": "your@email.com",
+  "link": "yourwebsite"
+}
+Coca-Cola
+TASTE THE FEELING
+OOTD
+TSLA
+TESLA INC.
+#0006 $96.8B 2025
+TESLA INC.
+速效快乐胶囊
+请在悲伤难过时服用,一日三次,一次两粒
+立即购买
+$9
+请遵医嘱酌情购买
+Awesome
+SMASH THAT ★ IF YOU LIKE IT!

+ 28 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/541f80bd1ef3bab31c476e01.txt

@@ -0,0 +1,28 @@
+《御姐归来:真假千金的豪门对决》第
+1-5 集剧本
+
+剧本基础信息
+
+[视频封面]
+苏落
+芳归来
+[字幕]:苏晓,
+[字幕]:既然进货产品绷平肩
+
+剧名:《御姐归来:真假千金的豪门对决》
+类型:都市爱情+豪门恩怨
+集数:第1-5集(共15集)
+受众:通用
+格式:AI视频适配(景号+时长+场景+镜头+台词+音效+字幕)
+
+第1集:尘埃里的真千金
+
+景号 | 时长 | 场景 | 镜头 | 台词 | 音效 | 字幕
+--- | --- | --- | --- | --- | --- | ---
+1 | 0:00-0:08 | 苏家豪宅 | 全景→近 | (苏晚穿着简 | 豪门环境 | 真千金流落
+
+大门外 | 景 | 约白裙,持着旧行李箱。站在金碧辉煌的豪宅前,眼神平静却带锋芒) 管家(傲慢):“苏小姐,老爷夫人在客厅等你,跟我来。” | 音、行李箱滚轮声 | 十八年,终被找回
+
+2 | 0:09-0:25 | 苏家客厅 | 中景→特写 | (客厅奢华,苏家父母坐上位,林薇薇依偎在母亲身边,穿公主裙) 苏母(冷淡):“既然找回来了,就安分住着。薇薇从小娇养,你多让着她。”林薇薇(假笑):“姐姐,以后我们就是一家人啦,我的房间可以分你一半~”苏晚(抬眼):“不必了,我住客房就好。” | 轻柔背景音乐、茶杯碰撞声 | 豪门冷眼,假千金虚伪示好
+
+3 | 0:26-0:40 | 苏家楼梯口 | 跟拍→特写 | (林薇薇故意绊苏晚,苏晚稳回头,眼神锐利)(委 | 脚步声、惊呼声 | 初次交锋,真千金绝不示弱

+ 6 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/557245b887fe0642644c658e.txt

@@ -0,0 +1,6 @@
+AI生成
+Coca-Cola
+AI生成
+Coca-Cola
+AI生成
+Coca-Cola

+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/5bb32f5fc34afa930e5a3b97.txt


+ 71 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/5f0a1ee0b3029234d178bda5.txt

@@ -0,0 +1,71 @@
+2.把AI给出的指令输入作图软件
+
+将获得的提示词喂给AI即可,提示词可进行适当
+修改。
+
+商业美食海报,主体为2个层次分明、金黄焦香的葡式蛋挞,挞皮酥脆起酥纹路清晰,挞液嫩滑饱满带自然焦糖斑点,呈现
+刚出炉的诱人光泽。极简浅暖米色纯色背景,无多余杂物。现代简约风格,融合日系清新与西式图片 4.1 | 智能比例 | 2K
+
+EGG TART
+嫩滑浓郁
+现烤好味
+CRISPY
+CRUST
+SMOOTH
+CUSTARD
+(新鲜现做)
+
+鲜>>-:
+鲜
+
+EGG
+TART
+CRISPY CRUST
+SMOOTH CUSTARD
+(嫩滑现做)
+(新)
+重
+榜
+来
+袭
+
+(嫩滑
+浓郁 鲜>>-: 鲜
+
+EGG
+TART
+CRISPY CRUST
+SMOOTH CUSTARD
+(新鲜现做)
+(重榜来袭)
+FRESHLY
+BAKED
+(嫩滑浓郁)
+(现烤好味)
+
+-负>> 鲜
+
+EGG TART
+FRESHLY
+BAKED
+嫩滑浓郁
+现烤好味
+
+(新鲜现做)
+鲜>>-负 鲜
+重榜来袭
+
+重新编辑 再次生成 ...
+
+商业美食海报,主体为2个层次分明、金黄焦香的葡式蛋挞,挞皮酥脆起酥纹路清晰,挞液嫩滑
+饱满带自然焦糖斑点,呈现刚出炉的诱人光泽。极简浅暖米色纯色背景,无多余杂物。现代简约
+风格,融合日系清新与西式烘焙质感。色调为暖焦糖色蛋挞+浅暖米白背景+黑色文字。近距离
+特写视角,蛋挞堆叠在画面中下部,文字分布在画面左上、左下、右上区域,排版平衡。文字包
+含英文“EGG TART”“CRISPY CRUST, SMOOTH CUSTARD”“FRESHLY BAKED”,中文
+“嫩滑浓郁 现烤好味”“新鲜现做”“重磅来袭”“鲜>>>-负”“鲜”,字体为无衬线黑体,
+部分文字带圆形边框,整体视觉干净高级,突出现烤美食的食欲感。
+
+图片生成 图片 4.1 智能比例 高清 2K T.
+1/张
+
+*我这里使用的是即梦

+ 20 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/5f0bc0eae44d80bf769c4342.txt

@@ -0,0 +1,20 @@
+二、核心人物
+1. 女主: 苏晚
+◦ 身份: 真千金,前期隐忍,后期美艳御姐
+◦ 人设: 智商在线、气场强大、有仇必报、穿搭高级
+◦ 核心: 从尘埃回归王座,手撕仇人,搞事业 + 谈恋爱
+2. 女配 (反派): 林薇薇
+◦ 身份: 假千金
+◦ 人设: 表面温柔单纯,实则心机绿茶、嫉妒心极强
+◦ 核心: 靠窃取人活着,最终全盘皆输
+3. 男主: 顾晏辰
+◦ 身份: 顶级豪门总裁、女主坚定支持者
+◦ 人设: 冷漠腹黑、眼光毒辣、只对女主温柔
+◦ 核心: 鉴婊达人,全程帮女主撑腰
+4. 苏家父母
+◦ 前期偏心假千金,后期追悔莫及
+
+三、世界观与风格
+• 背景: 顶级豪门圈、高定时尚、商业酒会、豪宅名车
+• 风格: 美艳御姐 + 爽文打脸 + 豪门拉扯 + 极致反转
+• 爽点密集: 鉴婊、反击、逆袭、换装、当众打脸、巨爽清晰

+ 5 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/60b5eb5faa236679ef9ff458.txt

@@ -0,0 +1,5 @@
+LULUY
+GOURMET
+CHOCOLAITES
+CANDLE
+Marshall

+ 13 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/65bd0e3e0f16e2a1ec9775cb.txt

@@ -0,0 +1,13 @@
+豆包
+内容由 AI 生成
+
+该内容,换个主题再试试,生成额度未扣除,
+今天还剩余 48 个视频生成额度。
+
+帮我生成一个视频:【风格】豪门恩怨 (High Societ...
+你的视频生成好啦。
+
+图片 视频
++
+参考图
+创建分身 模型 2.0 Fast 玩法 比例

+ 5 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/6cf8a1b10da9781390462239.txt

@@ -0,0 +1,5 @@
+豆包P图
+强到可怕的
+1:1反推提示词
+
+直接抄即可 建议收藏!

+ 27 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/84a20ed8bc0f363da8feeb2d.txt

@@ -0,0 +1,27 @@
+请帮我生成一张真实感人物图片,纯图片,不要文字,比例 9:16。请参考以下视觉风格结构,保留其颜色、构图、质感、光影和整体氛围: [{
+"视觉风格分析": {
+"颜色": {
+"主色调": "淡雅绿色、米白色",
+"辅助色": "粉色 (花朵)、深棕色 (木窗框) ",
+"色彩特征": "低饱和度、高明度、清新淡雅、春日感",
+"色彩关系": "绿粉撞色、冷暖平衡"
+},
+"构图": {
+"类型": "对角线构图",
+"视角": "平视偏俯视",
+"主体位置": "画面中心偏左,倚靠窗台",
+"空间层次": "前景花枝、中景人物、后景虚化庭院"
+},
+"排版": {
+"画幅比例": "9:16 竖构图",
+"留白": "上方和右侧留白较多,营造呼吸感",
+"视觉引导": "花枝自然框架引导视线至人物面部"
+},
+"主体": {
+"人物": "年轻女性,古风造型",
+"姿态": "慵懒侧卧,托腮,赤足",
+"服饰": "薄纱绿色古风长裙,露肩设计",
+"发饰": "编发盘发,白色小花点缀",
+"神态": "温柔恬静,眼神清澈"
+},
+"材质质感": {

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/8d38c837c6372c41a22085f7.txt

@@ -0,0 +1 @@
+The end

+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/918b8a43ba9e5809d92f570f.txt


+ 19 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/9e82de5a607081a08c9ffe8d.txt

@@ -0,0 +1,19 @@
+HONGYA
+CASTLE·洪崖洞
+A Mountain of Layered Lights·千叠灯火之山
+Cliffside Illumination·崖畔华灯
+Where wooden stilts meet the river's glow, a cityscape climbs.
+Nighttime Reverie·夜阑幽梦
+Through lantern-lit alleys,
+above reflecting waves-
+vertical poetry.
+
+CANTON TOWER
+GUANGZHOU·广州塔
+A Spire of Neon Rhythm·霓虹律动之塔
+Pearl River Glow·珠江流光
+Where the skyline meets the river,
+light rises into the night.
+Urban Nightfall·城市夜幕
+Through shimmering reflections, beneath
+a vivid tower-modern poetry.

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/9eb0720ed0183e0ff9c1f0ac.txt

@@ -0,0 +1 @@
+空

+ 13 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/a1232b3d838cbd3b3dfe9e42.txt

@@ -0,0 +1,13 @@
+1/4
+冬至
+DONGZHI
+南圆 北饺
+其实都是家的味道
+网络原图(可能也是AI生成)
+
+AI生成
+冬至
+DONGZHI
+南圆 北饺
+其实都是家的味道
+AI复刻效果图

+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/a51151e08849c6b951e7f293.txt


+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/a61d1e0adab19616ab6ffb94.txt


+ 12 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/aa5cdbdc5bff1013e3d742de.txt

@@ -0,0 +1,12 @@
+ai 生成视频核心要点
+🌺原理:AI 视频并非逐帧生成,关键是精准分配交叉注意力权重,避免特征污染与画面融化。
+🌺方法:不盲目堆提示词,要聚焦去噪路径;固定种子值,按参考图写精准提示词(含光线、构图、
+情绪、质感)。
+🌺摒弃“抽卡式”碰运气,通过控制交叉注意力权重与固定生成路径来 100% 复刻高质量视频。
+核心逻辑在于:
+AI视频并非逐帧独立,而是依赖特征的连续性。
+关键操作包括:
+🌼固定种子值(Seed)以锁定基础画面;
+🌼撰写包含光线、构图、情绪、质感的四维精准提示词;
+🌼控制去噪强度,避免特征污染导致画面“融化”;
+🌼利用参考图功能强化风格一致性,适用于电影级名场面高精度复刻场景。

+ 8 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/abcfbf2003e241cd0340ae71.txt

@@ -0,0 +1,8 @@
+知命不惧
+日日自新
+
+顺其自然
+心自安然
+
+心有微光
+便有方向

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/ad7df89a60dc6bf7091c33fd.txt

@@ -0,0 +1 @@
+空

+ 21 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/b04b94561e3ed4ef672b314f.txt

@@ -0,0 +1,21 @@
+1.上传想复刻的图片及万能指令
+
+提示词:分析这张图片,并生成一个能够指导AI作图工具重新创作类似作品的文生图提示词。提示词中需从主体内容、场景设定、风格参考、色调色彩、构图视角、细节补充这些角度用文字描述图片。
+
+BREAD
+CROISSANT
+少油低卡
+健康新选择
+新鲜
+现做
+5.22
+正式上新
+鲜
+
+分析这张图片,并生成一个能够指导AI作图工具重新创作类似作品的文生图提示词。提示词中需从主体内容、场景设定、风格参考、色调色彩、构图视角、细节补充这些角度用文字描述图片。
+
+文生图提示词
+
+主体内容
+•主体:2个层次分明的可颂面包(牛角包),表面呈现金黄酥脆的酥皮纹理,带有自然的黄油光泽,酥皮褶皱清晰可见,质感蓬松饱满。
+•文字信息:包含英文“BREAD CROISSANT”“CRISPY AND DELICIOUS LAYERED”“HOKKAIDO MILK, FRESHLY MADE”,中文“少油低卡 健康新选择”“新鲜现做”“5.22 正式上新”,以及“鲜 >>> - 负“鲜”的标识文字,字体为简洁无衬线体,排版错落分布。

+ 2 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/b588afedf24996422f62710d.txt

@@ -0,0 +1,2 @@
+AI生成
+Coca-Cola

+ 15 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/bb175172314c2add2ce5bb2c.txt

@@ -0,0 +1,15 @@
+🍀 种子值(Seed)
+🌼 生成DNA
+🌼 一串数字,是AI生成画面的“初始密码”。同一密码,同一结果。
+🌼 如同烘焙的配方。只要配方(Seed)不变,原料(提示词)不变,烤出来的面包(画面)就永远一样。
+🌼 决定可复刻性。固定它,你就告别了“抽卡式”生成,改参数时心中有数。
+🍀 去噪强度(Denoising Strength)
+🌼 自由发挥度
+🌼 控制AI在生成新帧时,保留多少上一帧信息的参数。
+🌼 如同描红。数值越低,AI越“照抄”原图(描红);数值越高,AI越“自由创作”(原创)。
+🌼 决定流畅度。数值过高(>0.3),画面会剧烈跳动、变形;过低则像幻灯片。
+🍀 参考图权重(Ref. Weight)
+🌼 听话程度
+🌼 控制AI“模仿”参考图片的严格程度。
+🌼 如同临摹作业。权重1.0是“复印”,权重0.5是“神似”,权重0是“视而不见”。
+🌼 决定还原度。做IP复刻(如假面骑士)时,必须拉满(0.8-1.0)才能保证不像盗版。

+ 2 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/bfcca905e20a869037b5bdd7.txt

@@ -0,0 +1,2 @@
+SMART WEAVE
+FIBER-OPTIC SENSING TECHNOLOGY

+ 69 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/bfeb53ad6b0098b713d470e9.txt

@@ -0,0 +1,69 @@
+案例演示
+
+NEW PRODUCT
+却不二
+泡鲁达
+限时加购
+19.9
+元
+小程序加购泡鲁达清补凉
+活动时间
+9.30-10.7
+COCONUT
+泡鲁达清补凉
+扫码下单享优惠
+(泡)
+新鲜百香果
+现捣现做
+8元
+小红书
+搜蜜雪冰城
+
+蜜雪冰城
+限时特惠
+百香果双响炮
+8元
+新鲜百香果
+现捣现做
+扫码下单享优惠
+蜜雪冰城
+蜜雪冰城
+限时特惠
+百香果双响炮
+新鲜百香果
+现捣现做
+8元
+蜜雪冰城
+限时特惠
+百香果双响炮
+新鲜百香果
+现捣现做
+8元
+蜜雪冰城
+限时特惠
+百香果双响炮
+新鲜百香果
+现捣现做
+8元
+冰/热
+
+重新编辑
+再次生成
+...
++
+新中式简约商业美食海报,主体为一杯分层清晰的蜜雪冰城百香果双响炮饮品:底层橙黄色百香
+果果肉+椰果小料,中层浅黄茶汤,上层带气泡感冰爽顶液,顶部装饰新鲜百香果切片、翠绿薄
+荷叶与椰果碎,杯身印有蜜雪冰城logo。饮品放置在浅棕色粗糙小石块上,岩石下方铺垫自然褶
+皱的米白色棉麻布料,背景为浅米色纯色质感背景。色调为暖米白+浅棕+百香果橙黄+茶汤浅黄
++薄荷叶翠绿,搭配蜜雪冰城橙红色文字。居中对称竖版构图,近距离特写视角,饮品位于画面
+中心,文字分布在左上、右上、右侧中部、左下区域,排版平衡。文字包含“蜜雪冰城”“百香
+果双响炮”“限时特惠”“新鲜百香果 现捣现做”“8元”“扫码下单享优惠”等,字体含活泼
+品牌字体与无衬线黑体,部分文字带圆形印章边框,还有“冰/热”标识,整体视觉清爽干净,
+突出平价新鲜茶饮的食欲感与亲民感。
+
+图片生成
+图片 4.1
+智能比例
+高清 2K
+T
+1/张

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/c1a842ee24a3372186bf7cff.txt

@@ -0,0 +1 @@
+空

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/ce275ae370fe3ba045f9c12a.txt

@@ -0,0 +1 @@
+空

+ 4 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/cff210e674272f54f3f296c6.txt

@@ -0,0 +1,4 @@
+一位约30岁的亚洲男性, 短发干净利落, 穿着与参考图相同的冲锋衣, 在高山徒步场景
+中行走, 背景为松林和雾气笼罩的山峰, 阳光自然洒在衣物上, 展示衣物质地与颜色。
+人物背着登山包, 脚穿登山鞋, 姿态自然、有力量, 户外品牌广告级摄影风格, 细节丰
+富、真实。

+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/d9bd2541efd711e6dd4e371b.txt


+ 7 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/e5c8bbc7a5d7fe6c23253d22.txt

@@ -0,0 +1,7 @@
+核心知识点与操作参数表
+核心维度 避坑指南 专业操作参数 适用场景
+🌺生成原理/ 避免逐帧特征漂移 /启用交叉注意力控制,关闭随机特征刷新 /所有需要画面稳定的视频
+🌺种子值 (Seed) /不使用随机种子 /固定种子值/ (如: 123456),确保生成路径一致复刻同一场景、微调动作
+🌺提示词策略 /不堆砌无效词汇 /四维公式: 光线+构图+情绪+质感 /电影感、写实风、高精度IP
+🌺去噪控制 /去噪强度过高会“ 融化” /关 键 帧去噪强度≤ 0.3,中间帧保持 0.1-0.2 人物特写、快速运动镜头
+🌺参考图运用 /不使用模糊参考图 /上传高清关键帧作为,权重设为 0.8-1.0 /角色变身、固定造型特写。

+ 4 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/eb1cb6f8633ded53f748afe9.txt

@@ -0,0 +1,4 @@
+AI+纺织/材料
+新资讯 | 新技术 | 新实践
+Weave AI Innovation
+元 | 编织

+ 25 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/efd441ad8e23be4f136358b5.txt

@@ -0,0 +1,25 @@
+全部
+已添加 00:16
+第2集.mp4
+已添加 00:16
+第四集.mp4
+已添加 00:16
+第三集 家族会议.mp4
+第2集 早餐.mp4
+已添加 00:16
+第1集 尘埃里的真干.mp4
+2月21日.png
+2月21日(1).png
+《2.绿茶的第一次陷害》
+00:00:16:14 00:00:54:24
+清晰 比例 比例
+00:00 00:10 00:20 00:30 00:40
+《1.尘埃里的真干》
+第1集 尘埃里的真干.mp4 00:00:15:01
+《2.绿茶的第一次陷害》
+变速 0.8X 第2集.mp4 00:00:09:21
+《3.惊艳全场的天》
+第三集 家族会议.mp4 00:00:15:01
+《4.小试牛刀,打
+第四集.mp4 00:0
+S 封面

+ 25 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/f06e6b4c1a750fd19ee075d7.txt

@@ -0,0 +1,25 @@
+Qwen3-VL
++ 新建对话
+请把这段视频拆分成分...
+
+时长: 0.2秒。
+
+分镜 2: 烛火摇曳与飞雪
+画面风格: 梦幻朦胧,景深极浅,营造神秘氛围。
+主体特征: 模糊的烛光与飘落的白色颗粒(似雪或花瓣)。
+服装/物品: 无明确主体,仅见暖黄色烛焰与银色链状装饰物。
+动作描述: 白色颗粒从上至下飘落,烛光随气流轻微晃动。
+镜头变化: 固定镜头,焦点在前景飘落物,背景完全虚化。
+构图: 对角线构图,飘落物贯穿画面,烛光点缀中右区域。
+光影: 暖黄烛光为唯一光源,颗粒反射高光,整体偏暗调。
+台词/旁白: 无。
+音效: 风声轻拂+微弱的铃铛叮当声。
+画质参数: 4K,帧率24fps,ISO 800,f/1.4,色温2800K。
+时长: 0.5秒。
+
+分镜 3: 红衣贵妃特写
+画面风格: 精致工笔画风,面部妆容浓烈,强调权力和威严。
+主体特征: 中年女性,丹凤眼、柳叶眉,唇色朱红,表情冷峻。
+服装/物品: 红色金线龙凤袍,头戴蓝金凤凰冠,垂挂珍珠与红宝石流苏。
+动作描述: 微微侧首,目光锐利扫视前方,嘴角微扬似冷笑。
+镜头变化: 近景推至特写,聚焦眼部与冠饰

+ 1 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/f16f2398164fec79059fc6d7.txt

@@ -0,0 +1 @@
+空

+ 10 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/f43ed5d6ec8493cd42e97c06.txt

@@ -0,0 +1,10 @@
+kris
+LIONSGATE
+kris
+kri
+LIONSGATE
+kris
+kris
+LIONSGATE
+audible
+kris

+ 9 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/.ocr_cache/fdb86c305830424408033714.txt

@@ -0,0 +1,9 @@
+始终如一
+从山野到城市
+
+采用防风防泼水复合面料,兼具透气性与耐用性。简约剪裁适应户外与城市双
+场景,内外兼修的设计,让每一次出行都自信舒适。
+
+露营
+生活
+徒步

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 3 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/input/aaa_test.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 3 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/input/case-5_clean.json


+ 289 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/plan_tool.py

@@ -0,0 +1,289 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""plan_tool.py — procedure-dsl 的「计划」内置工具 (技能本地, 仅 Cyber/run_cyber 引擎)。
+
+为什么是工具而不是"写 understanding.md":
+  弱模型读了"先想清楚工序"的 prompt 也照样跳过、直接堆骨架。把这一步做成**工具**,
+  LLM 必须**调用一次**把【工序分解 + 每工序步骤逐条展开 + 每工序覆盖原文哪些章节】
+  作为结构化参数交上来 —— 工具当场校验完整性 (混合门禁), 通过就**自动据计划生成
+  workflow.json 骨架**, 结果回灌进对话锚定后续填充。于是:
+    · understanding 重新独立成一步 (但由工具承载, 不靠自觉)
+    · 工序由计划显式决定 (章节认领是声明式, 不再靠词汇模糊匹配)
+    · workflow.json 结构严格按计划生成 (工序数/步骤数/顺序锁定, LLM 只填值)
+
+设计边界 (遵守"runner 零业务知识"): 本模块是**技能本地**, 不进 agent/tools/builtin/,
+run_cyber 仅 `import plan_tool` 触发 @tool 注册 + 调 set_plan_context 注入原文/路径。
+Claude SDK 路 (run_procedure_dsl) 没有 repo 工具系统, 那条路 understanding 仍走 prompt。
+"""
+# ⚠ 不能用 `from __future__ import annotations`: 它把类型注解变成字符串, 而框架
+#   SchemaGenerator 靠运行时 get_origin() 内省真实类型 — 拿字符串会把 procedures 这种
+#   List[Dict] 退化成 "string", LLM 就不知道该传数组了。
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+
+# ===========================================================================
+# 运行时上下文 (run_cyber.main() 在跑 runner 前注入; 工具闭包读它, 避免让 LLM 传路径)
+# ===========================================================================
+
+_PLAN_CTX: Dict[str, Any] = {}
+
+
+def set_plan_context(*, body_text: str = "", out_dir: Optional[Path] = None,
+                     case_id: Any = None, source: Optional[dict] = None,
+                     ocr: str = "") -> None:
+    """run_cyber 在执行前调用, 把原文正文 / 配图 OCR / 输出目录 / source 元信息交给工具。"""
+    _PLAN_CTX.clear()
+    _PLAN_CTX.update({
+        "body_text": body_text or "",
+        "ocr": ocr or "",
+        "out_dir": Path(out_dir) if out_dir else None,
+        "case_id": case_id,
+        "source": source or {},
+    })
+
+
+# ===========================================================================
+# 原文章节解析 (与 lint-case.py 同口径: 行首 0N| 标号, 排除 "图 0N|" 配图说明)
+# ===========================================================================
+
+_SEC_RE = re.compile(r'(?m)^\s*(0\d)\s*[||]')
+
+
+def _source_sections(body: str) -> List[tuple]:
+    """返回 [(章节号, 标题)],如 [('01','从一个案例开始'), ('02','结构化 Prompt 框架'), ...]。"""
+    marks = [(m.start(), m.group(1)) for m in _SEC_RE.finditer(body or "")]
+    out: List[tuple] = []
+    for i, (pos, num) in enumerate(marks):
+        end = marks[i + 1][0] if i + 1 < len(marks) else len(body)
+        seg = body[pos:end]
+        after = re.split(r'[||]', seg, 1)
+        tail = after[-1] if len(after) > 1 else seg
+        title = ""
+        for ln in tail.splitlines():
+            ln = ln.strip()
+            if ln:
+                title = ln[:24]
+                break
+        out.append((num, title))
+    return out
+
+
+def _norm_sec(s: Any) -> str:
+    """把 '1' / '01' / '第1章' 之类归一成两位章节号 '01'。无数字返回原串。"""
+    m = re.search(r'\d+', str(s))
+    return f"0{int(m.group())}"[-2:] if m and int(m.group()) < 10 else (m.group() if m else str(s).strip())
+
+
+# ===========================================================================
+# 入参容错 (弱模型常把 list 传成 JSON 字符串 / 包一层 {"procedures": [...]})
+# ===========================================================================
+
+def _coerce_procedures(procedures: Any) -> List[dict]:
+    if isinstance(procedures, str):
+        try:
+            procedures = json.loads(procedures)
+        except Exception:
+            return []
+    if isinstance(procedures, dict):
+        procedures = procedures.get("procedures", procedures.get("list", []))
+    return procedures if isinstance(procedures, list) else []
+
+
+# ===========================================================================
+# 据计划生成 workflow.json 骨架
+# ===========================================================================
+
+def _build_skeleton(summary: str, procedures: List[dict]) -> dict:
+    src = _PLAN_CTX.get("source") or {}
+    skeleton: Dict[str, Any] = {
+        "source": {
+            "platform": src.get("platform", ""),
+            "author": src.get("author", ""),
+            "date": src.get("date", ""),
+            "url": src.get("url", ""),
+            "title": src.get("title", ""),
+            "excerpt": src.get("excerpt", "") or (summary or "")[:120],
+        },
+        "procedures": [],
+    }
+    for pi, proc in enumerate(procedures, 1):
+        pid = f"p{pi}"
+        steps_out: List[dict] = []
+        for si, st in enumerate(proc.get("steps") or [], 1):
+            if not isinstance(st, dict):
+                continue
+            sid = f"s{si}"
+            in_label = (st.get("input") or "").strip()
+            out_label = (st.get("output") or "").strip()
+            step: Dict[str, Any] = {
+                "id": sid,
+                "kind": "step",
+                "via": (st.get("tool") or "").strip(),
+                "directive": "",   # 待 LLM 用 wf-patch 填真实 prompt
+                "inputs": ([{"type": in_label[:40], "value": "", "anchor": ""}]
+                           if in_label else []),
+                "outputs": ([{"id": f"{sid}o1", "type": out_label[:40], "value": "", "anchor": ""}]
+                            if out_label else []),
+            }
+            steps_out.append(step)
+        skeleton["procedures"].append({
+            "id": pid,
+            "name": (proc.get("name") or "").strip(),
+            "purpose": (proc.get("final_product") or proc.get("purpose") or "").strip(),
+            "category": (proc.get("category") or "").strip(),
+            "platform": src.get("platform", ""),
+            "author": src.get("author", ""),
+            "declarations": {"inputs": [], "resources": [], "returns": {}},
+            "steps": steps_out,
+        })
+    return skeleton
+
+
+# ===========================================================================
+# 工具本体
+# ===========================================================================
+
+_PLAN_DESC = (
+    "【第一步必做·只调用一次】提交你对这篇教程的工序计划 (understanding)。读完原文+配图后, "
+    "把它拆成若干工序、每个工序的步骤逐条展开、并声明每个工序覆盖原文哪些 0N 章节。"
+    "工具会校验完整性 (有章节没被任何工序认领 → 报错让你补全; 工序只有单步 → 警告), "
+    "通过后**自动据此生成 workflow.json 骨架** (工序/步骤/顺序锁定), 你之后只在骨架上填 "
+    "value/directive/anchor, 不再增删工序或步骤。\n"
+    "procedures 每项形如:\n"
+    '  {"name":"工序名", "category":"产物创造|资产建设|自动化|分析|学习", '
+    '"final_product":"终态产物", "source_sections":["01","03"], '
+    '"steps":[{"tool":"工具名/human", "input":"输入数据的短标签(如 提示词)", '
+    '"does":"这步做什么的一句话自由描述", "output":"产出的短标签(如 场景图)"}, ...]}\n'
+    "要点: source_sections 必填 (原文每个 0N 章节都要被某个工序认领, 别漏整段); "
+    "steps 逐步展开别压成单步; input/output 写**短标签**(它们会变成步骤输入/输出的 type); "
+    "does 只是计划期的自由描述, **不是** workflow 的 taxonomy 动作词 (那留到 Phase 2 查 action.json 归类)。"
+)
+
+
+@tool(description=_PLAN_DESC, hidden_params=["context"], groups=["core"])
+async def plan_procedures(
+    summary: str,
+    procedures: List[Dict[str, Any]],
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """提交工序计划 → 校验 → 自动生成 workflow.json 骨架。
+
+    Args:
+        summary: 2-4 句说清这篇在教什么、分几大板块。
+        procedures: 工序列表 (结构见工具描述)。
+    """
+    procs = _coerce_procedures(procedures)
+    if not procs:
+        return ToolResult(
+            title="计划为空",
+            output="procedures 解析为空。请传一个工序数组, 每项含 name/category/"
+                   "final_product/source_sections/steps (steps 逐步展开)。",
+            error="empty plan",
+        )
+
+    body = _PLAN_CTX.get("body_text", "")
+    secs = _source_sections(body)
+    present = {num for num, _ in secs}
+    sec_title = dict(secs)
+
+    # ---- 收集声明 + 基础校验 ----
+    claimed: set = set()
+    warnings: List[str] = []
+    for pi, proc in enumerate(procs, 1):
+        if not isinstance(proc, dict):
+            warnings.append(f"procedures[{pi-1}] 不是对象, 已跳过")
+            continue
+        name = (proc.get("name") or "").strip() or f"(工序{pi})"
+        steps = proc.get("steps") or []
+        for s in (proc.get("source_sections") or []):
+            claimed.add(_norm_sec(s))
+        if not steps:
+            warnings.append(f"工序『{name}』没有 steps — 至少要有步骤序列")
+        elif len(steps) == 1:
+            warnings.append(f"工序『{name}』只有 1 个步骤 — 确认它真的无法再展开? (多数工序是多步)")
+        for sj, st in enumerate(steps, 1):
+            if not isinstance(st, dict):
+                continue
+            miss = [k for k in ("tool", "input", "does", "output") if not (st.get(k) or "").strip()]
+            if miss:
+                warnings.append(f"工序『{name}』step{sj} 缺 {'/'.join(miss)} (四要素: 工具 tool·输入 input·动作 does·输出 output)")
+
+    # ---- 硬门禁: 章节覆盖 (只在原文确有 0N 分章时启用) ----
+    if present:
+        unclaimed = sorted(present - claimed)
+        if unclaimed:
+            lines = "\n".join(f"  · 章节 {n} 『{sec_title.get(n,'')}』" for n in unclaimed)
+            return ToolResult(
+                title=f"计划漏了 {len(unclaimed)} 个章节",
+                output=(
+                    f"原文有 {len(present)} 个章节, 你的计划只认领了 {sorted(claimed) or '无'}。"
+                    f"下面这些章节**没有任何工序认领**, 极可能被整段漏抽:\n{lines}\n\n"
+                    f"请逐个判断: 它是一条独立工序 (有自己的做法/产物), 还是某工序的若干步骤? "
+                    f"想清楚后**重新调用 plan_procedures**, 让每个工序的 source_sections 把这些章节都覆盖上。"
+                    f"(无独立做法、纯展示的章节可以并进相邻工序的 steps, 但要在某工序的 source_sections 里出现。)"
+                ),
+                error=f"unclaimed sections: {','.join(unclaimed)}",
+            )
+        bogus = sorted(claimed - present - {""})
+        if bogus:
+            warnings.append(f"source_sections 里 {bogus} 在原文里找不到对应 0N 章节 (写错章节号?)")
+
+    # ---- 通过: 生成骨架 + 落盘 ----
+    skeleton = _build_skeleton(summary, procs)
+    out_dir: Optional[Path] = _PLAN_CTX.get("out_dir")
+    nproc = len(skeleton["procedures"])
+    nstep = sum(len(p["steps"]) for p in skeleton["procedures"])
+
+    written = []
+    if out_dir:
+        scratch = out_dir / "_scratch"
+        scratch.mkdir(parents=True, exist_ok=True)
+        wf_path = out_dir / "workflow.json"
+        wf_path.write_text(json.dumps(skeleton, ensure_ascii=False, indent=2), encoding="utf-8")
+        written.append(wf_path.as_posix())
+        # 计划原文留档 (供审计 workflow 是否仍按计划; 也方便你回看)
+        plan_path = scratch / "understanding.json"
+        plan_path.write_text(json.dumps(
+            {"summary": summary, "procedures": procs}, ensure_ascii=False, indent=2), encoding="utf-8")
+        written.append(plan_path.as_posix())
+
+    # ---- 回灌进对话: 计划摘要 + 骨架结构 + 下一步指令 ----
+    recap = [f"✅ 计划已通过校验, 已据此生成 workflow.json 骨架: {nproc} 工序 / {nstep} 步。"]
+    if present:
+        recap.append(f"章节覆盖: 原文 {sorted(present)} 全部被认领 ✓")
+    recap.append("")
+    for p in skeleton["procedures"]:
+        recap.append(f"【{p['id']}】{p['name']} ({p['category']}) — {len(p['steps'])} 步")
+        for st in p["steps"]:
+            ins = st["inputs"][0]["type"] if st["inputs"] else "—"
+            outs = st["outputs"][0]["type"] if st["outputs"] else "—"
+            recap.append(f"    {st['id']} via={st['via'] or '—'}: [{ins}] → [{outs}]")
+    if warnings:
+        recap.append("")
+        recap.append("⚠ 警告 (不阻塞, 但请核对):")
+        for w in warnings:
+            recap.append(f"  - {w}")
+    recap.append("")
+    recap.append(
+        "下一步 (在已生成的骨架上, 别增删工序/步骤):\n"
+        "  1. 逐步用 wf-patch.py 填 inputs/outputs 的 value(文本类用 @quote 拽**完整逐字**原文)、"
+        "anchor(数据流 ← / →); **提示词建成 type=提示词 的输入/输出 value(整段逐字), 别塞 directive**"
+        "(directive 只放严格反推/比例 2:3 这类元指令, 多数生图步留空);\n"
+        "  2. 填完后**必须跑 `python spec/tools/verify-io.py --workflow <wf> --source <原文> --ocr <ocr>`**: "
+        "它校验文本 IO 的 value 逐字、生成步有没有 type=提示词 输入、提示词 value 完不完整、declarations; "
+        "报问题就修(此时可重读原文, 提示词用 @quote 提全), 修完重跑直到通过, 才进 Phase 2;\n"
+        "  3. type 现在是描述性短标签, Phase 2 再归一化到词表 + 补 effect/action/substance/form/intent;\n"
+        "  4. 结构已按计划锁定 —— 若发现确实要加工序/步骤, 说明计划不完整, 重新调 plan_procedures。"
+    )
+    return ToolResult(
+        title=f"计划通过: {nproc} 工序 / {nstep} 步",
+        output="\n".join(recap),
+        metadata={"procedures": nproc, "steps": nstep,
+                  "sections_present": sorted(present), "written": written,
+                  "warnings": len(warnings)},
+    )

+ 0 - 24
examples/process_pipeline/script/search_eval/procedure-dsl/presets_cyber.json

@@ -1,24 +0,0 @@
-{
-  "main": {
-    "max_iterations": 300,
-    "temperature": 0.3,
-    "skills": [],
-    "description": "procedure-dsl 主 Agent (Cyber Agent 版): 自驱读 spec/、跑三阶段提取、Phase 2 并行分发 phase-2a/2b 子 Agent。工具走 core 组 (read_file/write_file/edit_file/bash_command/glob_files/grep_content/agent)。"
-  },
-  "phase-2a-normalizer": {
-    "allowed_tools": ["read_file", "grep_content", "glob_files", "write_file", "edit_file", "bash_command"],
-    "max_iterations": 40,
-    "temperature": 0.2,
-    "skills": [],
-    "description": "Phase 2A 归一化子 Agent: 读 _scratch/task_2a.json, 归一 effect/action/type, 管 procedure 级 type_registry, 产出 _scratch/patch_2a.json ({path,value} 扁平数组, 符合 wf-patch.py 契约)。",
-    "system_prompt": "You are a dedicated Phase 2A normalization sub-agent.\nYour goal is to process the inputs and outputs of a workflow for effect, action, and type normalization:\n1. Read the outputs/case-N/_scratch/task_2a.json file to get the steps and IO variables.\n2. Normalize every step's `effect` and `action` against the taxonomy specs in `spec/taxonomy/effect.json` and `spec/taxonomy/action.json`.\n3. Normalize every IO variable's `type` against `spec/taxonomy/type.json`. If a custom type is used, register it in the procedure's `type_registry` with extends and description.\n4. Output a standard `patch_2a.json` JSON file under outputs/case-N/_scratch/.\nIMPORTANT: The format of `patch_2a.json` MUST be a flat JSON array of objects, where each object has a \"path\" and a \"value\" key (exactly conforming to the `wf-patch.py` tool contract).\nExample format:\n[\n  {\"path\": \"p1.s1.effect\", \"value\": \"预处理\"},\n  {\"path\": \"p1.s1.action\", \"value\": \"提取/化学提取/反推\"},\n  {\"path\": \"p1.s1.inputs[0].type\", \"value\": \"工具选型标准\"},\n  {\"path\": \"p1.type_registry.工具配置.extends\", \"value\": \"评语\"},\n  {\"path\": \"p1.type_registry.工具配置.desc\", \"value\": \"工具选型依据...\"}\n]\nDo not output raw dictionary structure or any other nesting. Do not touch or modify other files.\n\nNOTE (Cyber Agent runtime): the file tools here are `read_file(path=...)`, `write_file(path=..., content=...)`, `edit_file(...)`, `bash_command(command=...)` — NOT the Claude-style Read/Write/Bash. Call external scripts (taxonomy-lookup.py / wf-patch.py) via `bash_command`, never read their source."
-  },
-  "phase-2b-matcher": {
-    "allowed_tools": ["read_file", "grep_content", "glob_files", "write_file", "edit_file", "bash_command"],
-    "max_iterations": 40,
-    "temperature": 0.2,
-    "skills": [],
-    "description": "Phase 2B 分类匹配子 Agent: 读 _scratch/task_2b.json, 调 taxonomy-lookup.py 查 substance/form 路径, 产出 _scratch/patch_2b.json ({path,value} 扁平数组)。",
-    "system_prompt": "You are a dedicated Phase 2B taxonomy matching sub-agent.\nYour goal is to query and match the substance and form for each workflow IO variable:\n1. Read the outputs/case-N/_scratch/task_2b.json file to get the variables to match.\n2. For each variable, run `python spec/tools/taxonomy-lookup.py --dim 实质 --match \"...\"` and `--dim 形式 --match \"...\"` to search for the most precise taxonomy paths matching the variable's value, name, and related_images.\n3. Output a standard `patch_2b.json` JSON file under outputs/case-N/_scratch/.\nIMPORTANT: The format of `patch_2b.json` MUST be a flat JSON array of objects, where each object has a \"path\" and a \"value\" key (exactly conforming to the `wf-patch.py` tool contract). Substance and form values can be single string paths, multiple paths separated by ' + ', or JSON arrays of strings for multi-path matching.\nExample format:\n[\n  {\"path\": \"p1.s1.inputs[0].substance\", \"value\": \"/理念/知识/思想/概念范畴/性质属性/功能效用\"},\n  {\"path\": \"p1.s1.inputs[0].form\", \"value\": \"/呈现/视觉/视觉制作/构图编排/版面设计/版面结构\"},\n  {\"path\": \"p1.s2.inputs[0].substance\", \"value\": [\"/理念/知识/商业/前沿技术/AI智能/AI应用\", \"/理念/知识/思想\"]}\n]\nDo not output raw dictionary structure or any other nesting. Do not touch or modify other files.\n\nNOTE (Cyber Agent runtime): the file tools here are `read_file(path=...)`, `write_file(path=..., content=...)`, `bash_command(command=...)` — NOT the Claude-style Read/Write/Bash. Call taxonomy-lookup.py via `bash_command`."
-  }
-}

+ 657 - 68
examples/process_pipeline/script/search_eval/procedure-dsl/run_cyber.py

@@ -14,10 +14,9 @@ run_cyber.py — run_procedure_dsl.py 的 Cyber Agent 移植版 (POC)。
   - 图片抽取 (_images_from_source) + 客户端下载缓存 (_url_to_cached_path)。
 图片块从 Anthropic base64 格式转成 OpenRouter 要的 OpenAI `image_url` data-URL 格式。
 
-子 Agent (phase-2a-normalizer / phase-2b-matcher) 定义见同目录 presets_cyber.json,
-对位原脚本的 AgentDefinition。Phase 2 并行靠 RunConfig.parallel_tool_execution=True:
-主 Agent 在一轮里同时发两个 `agent(agent_type=..., task=...)` 调用即并行 (各自保留写权限);
-不走 explore 模式 (那会把子 Agent 降级为只读, 无法写 patch)。
+单 Agent 全程跑 (与 spec 对齐, 见 spec/tools.md §7): Phase 2 (归类标注) 由主 Agent
+自己一趟做完, 不再分发 phase-2a/2b 子 Agent。presets_cyber.json 只保留 main preset;
+exclude_tools 关掉 agent/evaluate 两个分发工具, 防弱模型自作主张去 delegate。
 
 用法 (与 run_procedure_dsl.py 对齐):
     python run_cyber.py input/case-2-raw.json --out-dir case-2-cyber
@@ -36,7 +35,9 @@ run_cyber.py — run_procedure_dsl.py 的 Cyber Agent 移植版 (POC)。
 import argparse
 import asyncio
 import base64
+import httpx
 import importlib.util
+import json
 import logging
 import os
 import sys
@@ -62,6 +63,103 @@ for _p in (str(REPO_ROOT), str(DSL_ROOT)):
     if _p not in sys.path:
         sys.path.insert(0, _p)
 
+# 技能本地「计划」内置工具 (plan_procedures): import 即把它注册进全局工具表 (groups=["core"]),
+# 主 Agent 因 tool_groups 含 core 而能看到它。Phase 1 第一步让 LLM 调用它做 understanding +
+# 自动生成 workflow.json 骨架。run_cyber 仅做注册 + 注入原文上下文, 业务逻辑全在 plan_tool.py。
+import plan_tool  # noqa: E402  (必须在 sys.path 设好之后)
+
+
+# 阶段文件 → 阶段标识 (监听 read_file 调用判断 agent 有没有读对应阶段规格)
+_PHASE_FILES = {"phase1-skeleton": "phase1", "phase2-normalize": "phase2", "phase3-finalize": "phase3"}
+
+
+def _phase_read_gaps(out_dir: Path, read_phase: set) -> List[str]:
+    """检查 agent 有没有「做了某阶段的活却没读那个阶段的规格文件」(弱模型惯犯, 导致格式/规则全靠瞎猜)。
+
+    判「做了某阶段」: Phase 2 = 填了 effect/action/intent; Phase 3 = 出了 HTML。
+    做了却没 read_file 对应文件 → 报出来, 续跑时逼它先读再修。
+    """
+    gaps: List[str] = []
+    wf = out_dir / "workflow.json"
+    if not wf.exists():
+        return gaps
+    try:
+        d = json.loads(wf.read_text(encoding="utf-8"))
+    except Exception:
+        return gaps
+    did_p1 = any(p.get("steps") for p in d.get("procedures", []))
+    did_p2 = any((s.get("effect") or s.get("action") or s.get("intent"))
+                 for p in d.get("procedures", []) for s in (p.get("steps") or []) if isinstance(s, dict))
+    did_p3 = bool(list(out_dir.glob("*.html")))
+    if did_p1 and "phase1" not in read_phase:
+        gaps.append("你建了 workflow(Phase 1)却**没 read_file** spec/extraction/phase1-skeleton.md —— "
+                    "先读它(字段填法、@quote 用法、IO 校验要求), 再对照检查你的 Phase 1 输出。")
+    if did_p2 and "phase2" not in read_phase:
+        gaps.append("你做了 Phase 2(填了 effect/action/intent)却**没 read_file** "
+                    "spec/extraction/phase2-normalize.md —— 先读它(尤其 §5 intent 的 {in-type:}/{out-type:} "
+                    "标记格式、词表归类规则), 再对照修正你的 Phase 2 输出。")
+    if did_p3 and "phase3" not in read_phase:
+        gaps.append("你做了 Phase 3(出了 HTML)却**没 read_file** "
+                    "spec/extraction/phase3-finalize.md —— 先读它的收尾检查清单再确认。")
+    return gaps
+
+
+def _completion_gaps(out_dir: Path) -> List[str]:
+    """跑完后查 workflow 是否真做完了 (防弱模型吐空消息提前自停)。返回未完成项清单, 空=完成。
+
+    判据: ① 每步填了 effect+action(Phase 2 归类完); ② intent 用了 {in-type:}/{out-type:} 标记格式
+    (Phase 2 §5, 渲染成彩色类型胶囊); ③ 出了 HTML(Phase 3 渲染完)。
+    """
+    gaps: List[str] = []
+    wf = out_dir / "workflow.json"
+    if not wf.exists():
+        return ["workflow.json 还没建 (Phase 1 没做完)"]
+    try:
+        d = json.loads(wf.read_text(encoding="utf-8"))
+    except Exception:
+        return ["workflow.json 不是合法 JSON (修好再继续)"]
+    _MARKERS = ("in-type:", "out-type:", "act:", "via:", "effect:")
+    missing, intent_bad = [], []
+    for p in d.get("procedures", []):
+        for s in p.get("steps", []):
+            if not isinstance(s, dict) or s.get("kind") == "block":
+                continue   # 控制块不要求 effect/action
+            sid = f"{p.get('id')}.{s.get('id')}"
+            if not (s.get("effect") or "").strip() or not (s.get("action") or "").strip():
+                missing.append(sid)
+            # intent 标记格式: 有 IO 的步, intent 必须带 {in-type:}/{out-type:} 等标记
+            has_io = bool(s.get("inputs") or s.get("outputs"))
+            intent = (s.get("intent") or "").strip()
+            if has_io and (not intent or "{" not in intent or not any(m in intent for m in _MARKERS)):
+                intent_bad.append(sid)
+    if missing:
+        gaps.append(f"{len(missing)} 个步骤缺 effect/action (Phase 2 没做完): "
+                    f"{', '.join(missing[:8])}{' …' if len(missing) > 8 else ''}")
+    if intent_bad:
+        gaps.append(f"{len(intent_bad)} 个步骤的 intent 没用标记格式 (Phase 2 §5: 写成带 "
+                    f"{{in-type:X}}/{{out-type:Y}}/{{act:Z}} 的句子, 如 `{{act:元素生成}}从{{in-type:提示词}}得到{{out-type:场景图}}`): "
+                    f"{', '.join(intent_bad[:8])}{' …' if len(intent_bad) > 8 else ''}")
+    if not list(out_dir.glob("*.html")):
+        gaps.append("还没渲染出 HTML (Phase 3 没做: 跑 render-case.py)")
+    return gaps
+
+
+def _load_env() -> None:
+    """加载仓库根 .env 到环境变量。
+
+    各 provider 的 create_*_llm_call 直接 os.getenv 读 key / base_url
+    (OPEN_ROUTER_API_KEY / QWEN_API_KEY / QWEN_BASE_URL 等), 但本脚本绕过
+    agent.client (那里才 load_dotenv), 故在此显式加载, 否则 .env 里的配置读不到。
+    override=False: 已在 shell 里 export 的值优先, 不被 .env 覆盖。
+    """
+    try:
+        from dotenv import load_dotenv
+    except ImportError:
+        return
+    env_file = REPO_ROOT / ".env"
+    if env_file.exists():
+        load_dotenv(env_file, override=False)
+
 
 def _load_sibling_module(name: str, path: Path):
     """按文件路径 import 同目录脚本 (run_procedure_dsl.py 不是包, 用 spec 加载)。"""
@@ -81,43 +179,64 @@ _resolve_out_dir = _rpd._resolve_out_dir
 _MEDIA_TYPE = _rpd._MEDIA_TYPE
 
 
-# 追加给主 Agent 的 Cyber 运行时说明 (原 prompt 是 Claude 工具名, 这里给映射 + 子 agent 分发约定)。
+# 极简引导: 只告诉主 Agent「你是 Cyber Agent + 工具名 + 去读 README 的运行时约定节」。
+# 其余所有运行时规则都搬进了 spec/README.md 的「🛠 运行时约定」节(agent 本来就先读 README)。
 _CYBER_RUNTIME_NOTE = """
 
-## ⚙️ Cyber Agent 运行时差异 (本次执行引擎不是 Claude Code, 工具名不同!)
-
-上文出现的 Claude 风格工具名, 在本运行时对应如下 (调用时用**右边**的名字):
-- `Read(file_path=X)`  → `read_file(path=X)`
-- `Write(file_path=X, content=Y)` → `write_file(path=X, content=Y)`
-- `Edit(file_path=X, ...)` → `edit_file(...)`
-- `Bash(command=C)` → `bash_command(command=C)`
-- `Glob` → `glob_files`,`Grep` → `grep_content`
-- 读图用 `read_images`(若需要主动看本地图)。
+⚠️ 你的执行引擎是 **Cyber Agent**(不是 Claude Code):可用工具是 `read_file` / `write_file` / `edit_file` / `bash_command` / `glob_files` / `grep_content` / `read_images`(**不是** Read/Write/Edit/Bash/Glob/Grep)。
+**第一步就 read_file `spec/README.md`**(在你 cwd `procedure-dsl/` 下),然后**严格照它的「🛠 运行时约定」节操作** —— 尤其『仅 Cyber Agent 引擎』那部分(`bash_command` 是 cmd.exe、`goal` 用纯数字 focus、spec 相对链接补 `spec/` 前缀、read_file 会截断长文本)。「怎么建 workflow.json / 怎么用 @quote 填 value / patch 路径怎么写」全以 README 那节 + 各阶段文件为准, 不要自行发挥。
+"""
 
-## 🔱 Phase 2 子 Agent 分发 (对应原 `Task`/`Agent` 工具)
 
-进入 Phase 2 (归一化与分类匹配) 时, **不要**自己手调 taxonomy-lookup.py 逐条决策。
-先用 prepare-subtask.py 生成 _scratch/task_2a.json 和 task_2b.json, 然后**在同一轮里**
-发出两个子 Agent 调用 (它们会并行执行, 各自写 patch 文件):
+def _downscale_image(raw: bytes, max_dim: int, quality: int) -> tuple:
+    """把图片下采样 + 重压缩成 JPEG, 返回 (bytes, mime)。
 
-    agent(agent_type="phase-2a-normalizer", task="处理 <case_dir>/_scratch/task_2a.json, 产出 patch_2a.json")
-    agent(agent_type="phase-2b-matcher",    task="处理 <case_dir>/_scratch/task_2b.json, 产出 patch_2b.json")
+    为什么必须做: 实测 case 的 14 张原图 base64 合计 ~12MB, 直接发会把
+    OpenRouter→Claude 的上游流打断 (api_error: internal stream ended unexpectedly);
+    减到 ~3MB 内就稳。同时大幅省 input token (PNG 截图 base64 极占 token)。
 
-两个子 Agent 都返回后, 用 `bash_command` 跑 wf-patch.py 把 patch_2a.json / patch_2b.json
-回填进 workflow.json, 再继续 Phase 3。
-"""
+    策略: 最长边 > max_dim 才缩放 (保持比例); 一律转 JPEG (PNG 截图转 JPEG 体积降一个量级);
+    有透明通道的拍平到白底 (截图场景安全)。max_dim<=0 表示关闭, 原样返回。
+    PIL 不可用或处理失败 → 原样返回 (降级不阻塞)。
+    """
+    if max_dim <= 0:
+        return raw, "image/jpeg"
+    try:
+        import io
+        from PIL import Image
+        im = Image.open(io.BytesIO(raw))
+        if im.mode in ("RGBA", "LA", "P"):
+            bg = Image.new("RGB", im.size, (255, 255, 255))
+            im = im.convert("RGBA")
+            bg.paste(im, mask=im.split()[-1])
+            im = bg
+        else:
+            im = im.convert("RGB")
+        w, h = im.size
+        if max(w, h) > max_dim:
+            scale = max_dim / max(w, h)
+            im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
+        out = io.BytesIO()
+        im.save(out, format="JPEG", quality=quality, optimize=True)
+        return out.getvalue(), "image/jpeg"
+    except Exception as e:
+        print(f"[image] downscale 失败, 用原图: {type(e).__name__}: {e}", flush=True)
+        return raw, "image/png"
 
 
-def _to_openai_content(text: str, images: List[str]) -> List[Dict[str, Any]]:
+def _to_openai_content(text: str, images: List[str],
+                       max_dim: int = 1280, quality: int = 85) -> List[Dict[str, Any]]:
     """把 (text, 图URL列表) 拼成 OpenAI 格式的 content blocks (OpenRouter / 各家通吃)。
 
     - 文本块: {"type": "text", "text": ...}
     - 图片块: {"type": "image_url", "image_url": {"url": "data:<mime>;base64,<...>"}}
       URL 先经 run_procedure_dsl._url_to_cached_path 客户端下载缓存 (绕图床 robots.txt)。
+      每张图经 _downscale_image 下采样+转 JPEG (max_dim<=0 关闭), 防大 payload 打断上游流。
     单张图失败不阻塞整批。
     """
     blocks: List[Dict[str, Any]] = [{"type": "text", "text": text}]
     n_ok, n_fail = 0, 0
+    bytes_before, bytes_after = 0, 0
     for ref in images:
         try:
             if ref.startswith(("http://", "https://")):
@@ -126,8 +245,11 @@ def _to_openai_content(text: str, images: List[str]) -> List[Dict[str, Any]]:
                 local = Path(ref).expanduser().resolve()
                 if not local.exists():
                     raise FileNotFoundError(ref)
-            mime = _MEDIA_TYPE.get(local.suffix.lower(), "image/png")
-            data = base64.standard_b64encode(local.read_bytes()).decode()
+            raw = local.read_bytes()
+            small, mime = _downscale_image(raw, max_dim, quality)
+            bytes_before += len(raw)
+            bytes_after += len(small)
+            data = base64.standard_b64encode(small).decode()
             blocks.append({
                 "type": "image_url",
                 "image_url": {"url": f"data:{mime};base64,{data}"},
@@ -136,21 +258,189 @@ def _to_openai_content(text: str, images: List[str]) -> List[Dict[str, Any]]:
         except Exception as e:
             n_fail += 1
             print(f"[image] skip {ref[:80]}... ({type(e).__name__}: {e})", flush=True)
+    if n_ok and max_dim > 0:
+        print(f"[image] 下采样: {bytes_before//1024}KB → {bytes_after//1024}KB "
+              f"(max_dim={max_dim}, q={quality})", flush=True)
     if images:
         print(f"[image] {n_ok}/{len(images)} 成功 base64 化, {n_fail} 失败已跳过", flush=True)
     return blocks
 
 
+# ──── 执行前预 OCR: 每张配图 → 文本 (供 quote-source --ocr 搜) ──────────────────
+# 截图教程的 prompt / JSON / 参数常只在图里, body_text 抽不到。执行前 OCR 成文本,
+# 让 LLM 也能从图片内容里 quote 出真实 value/directive。按图字节 hash 缓存, 不重复花钱。
+
+def _ocr_one(raw: bytes, model: str, api_key: str,
+             max_dim: int = 2000, quality: int = 90) -> str:
+    """单张图 → OCR 文本 (OpenRouter 视觉调用)。失败抛异常由上层兜。"""
+    small, mime = _downscale_image(raw, max_dim, quality)
+    data = base64.standard_b64encode(small).decode()
+    instr = ("请把这张图片里的所有文字逐字提取出来, 按从上到下、从左到右的阅读顺序输出。"
+             "只输出图中文字本身, 不要翻译、不要解释、不要添加任何说明。"
+             "图中若有代码/JSON/提示词, 请保留其原始换行与格式。若图中无文字, 输出空。")
+    payload = {
+        "model": model,
+        "messages": [{"role": "user", "content": [
+            {"type": "text", "text": instr},
+            {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{data}"}},
+        ]}],
+    }
+    r = httpx.post("https://openrouter.ai/api/v1/chat/completions",
+                   headers={"Authorization": f"Bearer {api_key}"}, json=payload, timeout=120)
+    r.raise_for_status()
+    j = r.json()
+    return (j.get("choices") or [{}])[0].get("message", {}).get("content", "") or ""
+
+
+def _ocr_images(refs: List[str], model: str, api_key: str, cache_dir: Path) -> str:
+    """对每张图 OCR, 合并成带分段标记的文本。按图字节 hash 缓存, 单张失败跳过不阻塞。"""
+    import hashlib
+    cache_dir.mkdir(exist_ok=True)
+    out, n_ok = [], 0
+    for n, ref in enumerate(refs, 1):
+        try:
+            if ref.startswith(("http://", "https://")):
+                local = _url_to_cached_path(ref)
+            else:
+                local = Path(ref).expanduser().resolve()
+            raw = local.read_bytes()
+            h = hashlib.sha256(raw).hexdigest()[:24]
+            cf = cache_dir / f"{h}.txt"
+            if cf.exists():
+                txt, tag = cf.read_text(encoding="utf-8"), " (cache)"
+            else:
+                txt, tag = _ocr_one(raw, model, api_key), ""
+                cf.write_text(txt, encoding="utf-8")
+            out.append(f"\n===== [图 {n}] 来源: {ref[:90]} =====\n{txt.strip()}\n")
+            n_ok += 1
+            print(f"[ocr] 图 {n}/{len(refs)}: {len(txt.strip())} 字{tag}", flush=True)
+        except Exception as e:
+            print(f"[ocr] 图 {n}/{len(refs)} 失败跳过: {type(e).__name__}: {e}", flush=True)
+    print(f"[ocr] {n_ok}/{len(refs)} 张成功", flush=True)
+    return "".join(out)
+
+
 def _trace_append(trace_path: Path, chunk: str) -> None:
     with trace_path.open("a", encoding="utf-8") as f:
         f.write(chunk)
 
 
+def _content_to_text(content: Any) -> str:
+    """把 Message.content 归一成纯文本。
+
+    不同 provider 的 content 形态不一:
+      - str: 直接用 (OpenRouter / 多数情况)。
+      - list[block]: OpenAI/Qwen 多模态格式 [{"type":"text","text":...}, ...],
+        抽出各块的 text 字段拼起来。
+      - dict: 单个 block, 取其 text / content 字段, 取不到就 str() 兜底。
+    切片 (content[:2000]) 前必须先过这里, 否则对 dict/list 切片会抛 KeyError/TypeError。
+    """
+    if isinstance(content, str):
+        return content
+    if isinstance(content, list):
+        parts = []
+        for b in content:
+            if isinstance(b, str):
+                parts.append(b)
+            elif isinstance(b, dict):
+                parts.append(b.get("text") or b.get("content") or "")
+        return "".join(parts)
+    if isinstance(content, dict):
+        # Qwen/DeepSeek assistant: text 可能为空, 真正的话在 reasoning_content。
+        # tool 结果消息: 内容在 "result" 键 (之前漏读它, 导致 [tool result] 控制台全空白)。
+        # 都取不到就返回 "" (不要 str(content) 把整个 dict 当文本 dump 出来)。
+        return (content.get("text") or content.get("reasoning_content")
+                or content.get("result") or content.get("content") or "")
+    return str(content)
+
+
+# 两段式 (--phase1-model): Pass 1 只做 Phase 1, 然后换模型 resume 做 Phase 2+。
+_PHASE1_STOP_NOTE = """
+
+## ⏸️ 本段任务: 先完成 Phase 1, 然后**暂停等待下一步指示**
+
+这是一个**分阶段协作**的任务, 你负责的是**第一阶段**。本段请专注做完 Phase 1:
+  - Phase 1.1 心智模型 → 写 understanding.md
+  - Phase 1.2 workflow.json 骨架 (procedures/steps/IO 结构 + name/purpose/declarations)
+  - Phase 1.3 anchor 闭合 (IO 引用)
+
+完成 Phase 1.3 后, 请**暂停**: 用一句话报告产出, **本轮不再发任何工具调用**, 等待后续指示来推进 Phase 2/3。
+(注意: 这**不是禁止** Phase 2, 只是分工上**这一段先到 Phase 1 为止**; 后续会有新指示让你或另一协作者继续。)
+你的 Phase 1 产出质量直接决定后续阶段, 所以 understanding.md 和 workflow.json 骨架务必扎实、完整。
+"""
+
+
+# ── 实验模式 note (--exp) ──────────────────────────────────────────────────
+# 实验只比 Phase 1 骨架质量, 两方案都"产出 workflow.json 骨架+anchor 后停", 不跑 Phase 2/3。
+
+# 方案 1 (direct): 强模型不写 understanding.md, 边想边直接出 workflow.json。
+_EXP_DIRECT_NOTE = """
+
+## 🧪 实验模式 (direct): 不写 understanding.md, 直接产 workflow.json
+
+本次**跳过 Phase 1.1 的 understanding.md 文件** (spec 里提到的这一步本次作废, 不要 Write 它)。
+把"有几个独立工序、每个工序的步骤/IO/控制流"的分析**直接写在你的文字回复里**(简明扼要), 然后:
+- 直接 Write `workflow.json` 骨架 (Phase 1.2: procedures/steps/IO 结构 + name/purpose/declarations);
+- 用 wf-patch.py 加 anchor (Phase 1.3: IO 闭合)。
+完成 anchor 闭合后**立即停止**, 一句话总结即可, **不要进入 Phase 2** (不填 effect/action/type/substance/form, 不分发子 Agent)。
+"""
+
+# 方案 2 第一步 (split-A): 强模型只产 understanding.md, 不碰 workflow.json。精简读单。
+_EXP_UNDERSTANDING_ONLY_NOTE = """
+
+## 🧪 实验模式 (split · 第一步): 只产 understanding.md
+
+本次**只做 Phase 1.1**: 通读原文(含图)建立心智模型, 写进 understanding.md, 然后**立即停止**
+(**不要 Write workflow.json**, 不进 Phase 1.2+, **本轮不再发任何工具调用**)。
+
+### ⚡ 精简读单 (覆盖上面起手指令里的完整清单 — 以本节为准)
+本步**只读**这几样, 其余一律不读 (读了纯烧 context, 它们是给下游做 workflow.json 的步骤用的):
+  - `spec/README.md` (已在起手读过, 别重读)
+  - `spec/syntax.md` (DSL 概念: procedure/step/IO/effect/action — 让心智模型用对术语)
+  - `spec/extraction/phase1-skeleton.md` (**多工序判断标准** — 怎么判定有几个独立工序)
+  - 原文 case json (body_text + 元数据) + 本消息所附的图
+**明确不要读** (本步用不上):
+  - `spec/tools.md` (脚本接口 — 本步不调任何脚本)
+  - `spec/extraction/fields.md` (23 字段填法)
+  - `spec/extraction/control-flow.md` (block/nested 的 JSON 建模)
+  - `spec/format/md-structure.md` (.md 产物结构)
+
+### understanding.md 要写到"能让另一个模型照着填出 workflow.json"的程度
+- 有几个独立工序 (按 phase1-skeleton 判断标准), 每个: 工序名 + 终态产物 + 大致步骤数 + 工艺类型;
+- 每个工序的步骤序列, 每步的输入/输出 (是什么数据、从哪来、到哪去);
+- **控制流用大白话讲清** (哪步是循环/并行/分支、循环什么、并行几路) —— 你不必读 control-flow.md,
+  文字描述即可, 下游模型据此建 block/nested。
+后续由另一个模型读你的 understanding.md + JSON schema 生成 workflow.json。
+"""
+
+# 方案 2 第二步 (split-B): 全新一段、不给图, 弱模型只凭 understanding.md + schema 产 workflow.json。
+_EXP_WORKFLOW_FROM_UNDERSTANDING_NOTE = """
+
+## 🧪 实验模式 (split · 第二步): 据 understanding.md 产 workflow.json (本次不附原图)
+
+Phase 1.1 的心智模型已由**另一个(更强的)模型**写好 —— 就是上面"输出目录"里的 **understanding.md**。
+本次你的任务是 **Phase 1.2 + 1.3**, 且**只依据 understanding.md + schema**(本消息不附原图, 你看不到截图):
+  1. read_file 输出目录里的 `understanding.md`, 以及 spec 的 `format/case-data.schema.json`;
+  2. 按 understanding.md 的工序划分, Write `workflow.json` 骨架 (procedures/steps/IO 结构 + name/purpose/declarations);
+  3. 用 bash_command 跑 wf-patch.py 加 anchor (IO 闭合, 单条命令不要拼 `;`)。
+完成 anchor 后**立即停止**, **不要进入 Phase 2**, 也**不要重写 understanding.md**。
+"""
+
+
 async def run(args: argparse.Namespace) -> int:
-    from agent.core.runner import AgentRunner, RunConfig
+    from agent.core.runner import AgentRunner, RunConfig, KnowledgeConfig
     from agent.core.presets import load_presets_from_json
     from agent.trace import FileSystemTraceStore, Trace, Message
-    from agent.llm import create_openrouter_llm_call
+    from agent.llm import create_openrouter_llm_call, create_qwen_llm_call
+
+    # provider 选择: 决定 llm_call 走哪家端点。
+    #   openrouter → OPEN_ROUTER_API_KEY, 一个 URL 通打各家 (model 形如 qwen/qwen-max)。
+    #   qwen       → QWEN_API_KEY + QWEN_BASE_URL (.env), 阿里 dashscope 原生
+    #                (model 形如 qwen-plus / qwen-max, 无 "qwen/" 前缀)。
+    if args.provider == "qwen":
+        make_llm_call = lambda: create_qwen_llm_call(model=args.model)
+    else:
+        make_llm_call = lambda: create_openrouter_llm_call(model=args.model)
 
     workdir = DSL_ROOT
     source_path = Path(args.source).expanduser().resolve()
@@ -164,12 +454,12 @@ async def run(args: argparse.Namespace) -> int:
     trace_id_file = out_dir / ".trace_id"
     trace_path = out_dir / "_trace_cyber.md"
 
-    # 注册子 Agent presets (phase-2a-normalizer / phase-2b-matcher / main)。
+    # 注册 main preset (单 Agent; phase-2a/2b 子 Agent 已废弃, 见 spec/tools.md §7)。
     presets_json = DSL_ROOT / "presets_cyber.json"
     if presets_json.exists():
         load_presets_from_json(str(presets_json))
     else:
-        print(f"⚠️ 缺少 {presets_json}, 子 Agent 分发会失败", file=sys.stderr)
+        print(f"⚠️ 缺少 {presets_json}, 用 runner 默认 main preset", file=sys.stderr)
 
     # source 路径给 Agent (workdir 相对优先)。
     try:
@@ -187,20 +477,129 @@ async def run(args: argparse.Namespace) -> int:
 
     images = _images_from_source(source_path) + (args.extra_image or [])
 
-    if resume_tid:
+    # 执行前预 OCR: 把每张配图的文字提取成文本, 落 _scratch/ocr.txt, 供 quote-source --ocr 搜。
+    # 只在 fresh run 做 (resume 时上次的 ocr.txt 还在); 按图字节 hash 缓存, 重跑不重复花钱。
+    ocr_path = out_dir / "_scratch" / "ocr.txt"
+    if not resume_tid and not getattr(args, "no_ocr", False) and images:
+        api_key = os.getenv("OPEN_ROUTER_API_KEY")
+        if not api_key:
+            print("[ocr] 跳过: 未设 OPEN_ROUTER_API_KEY", flush=True)
+        else:
+            print(f"[ocr] 对 {len(images)} 张配图预 OCR (model={args.ocr_model}) ...", flush=True)
+            ocr_text = _ocr_images(images, args.ocr_model, api_key, DSL_ROOT / ".ocr_cache")
+            if ocr_text.strip():
+                ocr_path.write_text(ocr_text, encoding="utf-8")
+                print(f"[ocr] -> {ocr_path} (共 {len(ocr_text)} 字)", flush=True)
+
+    spec_name = "spec" if not getattr(args, "spec_version", None) else f"spec-{args.spec_version}"
+
+    if resume_tid and getattr(args, "_phase2_handoff", False):
+        # 两段式 Pass 2: Phase 1 已由另一模型做完, 从 Phase 2 开始。
+        # ⚠️ 必须强硬作废历史里 Pass1 的"只做 Phase1 就停"指令, 否则弱模型会跟着旧指令
+        #    重做 Phase1 再停 (实测 gemini-flash-lite 就这么干了)。
+        cd = out_dir.as_posix()
+        msgs = [{"role": "user", "content": (
+            f"【阶段交接 — 之前的指令已变更, 请严格按本条执行】\n\n"
+            f"Phase 1 (understanding.md + workflow.json 骨架 + anchor 闭合) **已经全部完成并落盘**, 是上一个模型做的。\n\n"
+            f"⚠️ 历史里那条『本次只做 Phase 1、做完即停、不要进 Phase 2』的指令**现已作废**。"
+            f"你现在的唯一任务是完成 **Phase 2 和 Phase 3**。\n\n"
+            f"❌ **绝对不要**重写 / 重新生成 understanding.md 或 workflow.json 的骨架 —— 它们已经做好了, 重做即错误。\n"
+            f"✅ 现在立刻执行 (用 bash_command, 单条命令不要拼 `;`):\n"
+            f"  1. read_file `{cd}/workflow.json` 看当前骨架 (不要凭记忆, 也不要重写它)。\n"
+            f"  2. 读 spec/extraction/phase2-normalize.md, 由你**自己一趟做完 Phase 2**: 作用/动作/类型 对词表、"
+            f"实质/形式 直接提炼元素点 (不查词表)、每步填 intent。**不要**切任务 / 分发子 Agent。\n"
+            f"  3. 用 wf-patch.py (bash_command, --set 或 --patch) 回填 effect/action/type/substance/form/intent 到 workflow.json。\n"
+            f"  4. Phase 3: 跑 lint-case.py 校验, 再 render-case.py 出 HTML (.html 是唯一产物, .md 已取消)。"
+        )}]
+    elif resume_tid:
         msgs = [{"role": "user", "content": (
             f"上次中断了, 接续做 case-{args.case_id} 的提取流程。\n"
             f"先用 bash_command `ls` 看 {out_dir.as_posix()}/ 当前已落盘哪些产物, "
             f"再 read_file 这些**当前磁盘版本** (understanding.md / workflow.json) 接着跑, "
-            f"不要凭记忆。Phase 2 仍按子 Agent 分发约定 (agent(agent_type=...))。"
+            f"不要凭记忆。Phase 2 由你自己一趟做完 (wf-patch.py 落盘, 不分发子 Agent)。"
         )}]
     else:
         # 复用原脚本的起手 prompt 全文 (取 text 块), 再补 Cyber 运行时说明。
         anth_blocks = _build_initial_blocks(
-            source_for_agent, args.case_id, args.out_dir, images, workdir, "spec"
+            source_for_agent, args.case_id, args.out_dir, images, workdir, spec_name
         )
         base_text = anth_blocks[0]["text"] + _CYBER_RUNTIME_NOTE
-        msgs = [{"role": "user", "content": _to_openai_content(base_text, images)}]
+        if ocr_path.exists():
+            base_text += (
+                f"\n\n## 🖼️ 配图已 OCR 成文本\n"
+                f"原文配图的文字已 OCR 提取到 `{ocr_path.as_posix()}`。"
+                f"填 value/directive 需要图里的文字(prompt/JSON/参数常只在图中)时, "
+                f"用 `python spec/tools/quote-source.py --source {source_for_agent} --query \"<短语>\" --ocr {ocr_path.as_posix()}` "
+                f"一并搜原文+图片 (quote-source 读全文件, 不受 read_file 截断影响; 别用 read_file 通读大 ocr.txt)。"
+            )
+        # 内联完整正文: read_file 会把 body_text 这种超长单行砍在 2000 字 (弱模型常不续 char_offset
+        # → 正文后半段静默丢失)。把完整 body_text 直接附进 prompt, agent 理解正文以这份为准。
+        try:
+            _sd = json.loads(source_path.read_text(encoding="utf-8"))
+            _body = _sd.get("body_text") if isinstance(_sd, dict) else None
+        except Exception:
+            _body = None
+        if _body:
+            base_text += (
+                f"\n\n## 📄 原文正文 (完整版, 已内联 — 别再 read_file 原文取正文)\n"
+                f"⚠️ read_file 读 `{source_for_agent}` 会把 body_text 这一长行砍在 2000 字 → 丢后半段。"
+                f"理解正文、提取 value/directive **以下面这份完整正文为准**; read_file 原文文件只为取 "
+                f"title/link/publish_timestamp 等短字段。\n\n```\n{_body}\n```"
+            )
+        # 给「计划」+「IO 校验」工具注入原文 + 配图 OCR + 输出目录 + source 元信息。
+        _sd2 = _sd if isinstance(_sd, dict) else {}
+        _ocr_text = ocr_path.read_text(encoding="utf-8") if ocr_path.exists() else ""
+        _lint_ocr = f" --ocr {ocr_path.as_posix()}" if ocr_path.exists() else ""  # verify-io / lint 共用
+        plan_tool.set_plan_context(
+            body_text=_body or "",
+            ocr=_ocr_text,
+            out_dir=out_dir,
+            case_id=args.case_id,
+            source={
+                "platform": "",                                   # LLM/后续可补
+                "author": _sd2.get("channel_account_name", ""),
+                "url": _sd2.get("link", ""),
+                "title": _sd2.get("title", ""),
+                "date": str(_sd2.get("publish_timestamp", "") or ""),
+                "excerpt": (_body or "")[:120],
+            },
+        )
+        base_text += (
+            f"\n\n## 🧭 第一步(必做): 调用 plan_procedures 工具做计划\n"
+            f"在动手建 workflow.json 前, **先调用一次 `plan_procedures` 工具**, 交上你的工序计划: "
+            f"把这篇拆成几个工序、每个工序的步骤逐条展开(工具·输入·动作·输出四要素)、并声明每个工序"
+            f"覆盖原文哪些 `0N` 章节(source_sections)。工具会校验: 有章节没被任何工序认领 → 报错让你补; "
+            f"通过后**自动据计划生成 workflow.json 骨架**(工序/步骤/顺序锁定)。之后你只在骨架上用 "
+            f"wf-patch 填 value/directive/anchor, **不要再 write_file 重写 workflow.json、也不增删工序/步骤**。\n"
+            f"填完 value/directive/anchor(这是 **Phase 2.0 填内容**)后, **必须跑一次 IO 校验脚本**(照抄):\n"
+            f"```bash\npython spec/tools/verify-io.py --workflow {out_dir.as_posix()}/workflow.json "
+            f"--source {source_for_agent}{_lint_ocr}\n```\n"
+            f"它校验每个文本 IO 的 value 是否逐字、**每个生成步有没有 type=提示词 的输入**(提示词是数据不是 directive)、"
+            f"提示词 value 是否完整不截断、declarations 是否补全; "
+            f"报 ✗ 就修(此时可重读原文, 提示词用 @quote 提**全**别缩写/截断)再跑, 校验通过才进 **Phase 2.1 归类标注**(effect/action/type/substance/form/intent)。"
+        )
+        # Phase 3 lint 必须带 --source(+--ocr)才会跑「章节覆盖」+「value 逐字」两条结构/值强制;
+        # 这里钉死精确路径, 免得 agent 用错文件名导致校验静默跳过。(_lint_ocr 已在上面定义)
+        base_text += (
+            f"\n\n## ✅ Phase 3 lint 命令(照抄, 别省 --source)\n"
+            f"```bash\npython spec/tools/lint-case.py --workflow {out_dir.as_posix()}/workflow.json "
+            f"--case-id {args.case_id} --source {source_for_agent}{_lint_ocr}\n```\n"
+            f"带 `--source` 才会查**章节覆盖**(原文每个 `0N` 章节都要落进某工序/步骤, 别整段漏抽)"
+            f"和 **value 逐字**(文本类 value 要是原文一整段连续文本, 别抄开头后缩写)。"
+            f"报「章节疑似漏抽」回 Phase 1 补工序; 报「value 疑似缩写」回 Phase 2.0 用 `@quote` 重填。"
+        )
+        imgs_for_prompt = images
+        if getattr(args, "_exp_direct", False):
+            base_text += _EXP_DIRECT_NOTE                  # 方案1: 不写 understanding, 直接 workflow.json
+        elif getattr(args, "_exp_understanding_only", False):
+            base_text += _EXP_UNDERSTANDING_ONLY_NOTE      # 方案2 第一步: 只产 understanding
+        elif getattr(args, "_exp_workflow_from_understanding", False):
+            base_text += _EXP_WORKFLOW_FROM_UNDERSTANDING_NOTE  # 方案2 第二步: 据 understanding 产 workflow
+            imgs_for_prompt = []                           # 不给图, 纯凭 understanding.md + schema
+        elif getattr(args, "phase1_only", False):
+            base_text += _PHASE1_STOP_NOTE                 # 两段式 Pass 1: 做完整 Phase 1 即停
+        msgs = [{"role": "user", "content": _to_openai_content(
+            base_text, imgs_for_prompt, max_dim=args.max_image_dim, quality=args.image_quality)}]
 
     cfg = RunConfig(
         model=args.model,
@@ -208,14 +607,32 @@ async def run(args: argparse.Namespace) -> int:
         max_iterations=args.max_turns,
         agent_type="main",
         name=f"procedure-dsl case-{args.case_id} (cyber)",
-        tool_groups=["core"],
-        parallel_tool_execution=True,   # Phase 2 同轮发两个 agent() 即并行
+        tool_groups=["core", "system"],  # core=read/write/edit/glob/grep (agent 工具下面 exclude 掉);
+                                         # system=bash_command
+                                         # ⚠️ 没 system 组 → 主 Agent 无 bash, 跑不了 spec/tools/*.py
+                                         # (wf-patch / lint-case / render-case 全靠 bash)
+        parallel_tool_execution=True,   # 允许同轮并行工具调用 (如多个 read_file); 子 Agent 分发已废弃
+        context_injection_interval=0,   # 关掉周期性自动注入 get_current_context: procedure-dsl 单 Agent
+                                        # 不用 goal/协作者/IM, 那些注入只是给弱模型添乱 + 烧 token
+
         enable_prompt_caching=False,    # 非 Claude 模型无效, 关掉省得干扰
-        goal_compression="on_overflow",
+        # 关掉 goal 压缩: 它会在 goal 完成后把详细消息压成 [[SUMMARY]], 而弱模型 (如
+        # gemini-flash-lite) 一丢细节就倾向"推倒重做 Phase 1", 覆盖掉已完成的 Phase 2
+        # 归一化数据。单 case 运行上下文有限, 保留全量更安全。
+        goal_compression="none",
+        # 关掉知识沉淀: 否则任务结束会被自动注入"复盘→knowledge_save_pending"prompt
+        # (上次 Claude 在 seq6 被它带跑偏、qwen 浪费 turn51-52)。procedure-dsl 不需要它。
+        knowledge=KnowledgeConfig(
+            enable_extraction=False,            # 压缩时不反思
+            enable_completion_extraction=False, # 结束后不复盘 (核心: 去掉那段收尾 prompt)
+            enable_injection=False,             # focus goal 时不注入知识
+        ),
+        exclude_tools=["knowledge_save_pending", "agent", "evaluate"],  # 去知识沉淀 + 子 Agent 分发工具 (agent/evaluate): 单 Agent 全程
         trace_id=resume_tid,
     )
 
-    print(f"[setup] engine     = Cyber AgentRunner (OpenRouter)")
+    print(f"[setup] engine     = Cyber AgentRunner")
+    print(f"[setup] provider   = {args.provider}")
     print(f"[setup] model      = {args.model}")
     print(f"[setup] source     = {source_path}")
     print(f"[setup] case_id    = {args.case_id}")
@@ -229,39 +646,104 @@ async def run(args: argparse.Namespace) -> int:
     _trace_append(trace_path, f"\n\n---\n\n## ▶ {'Resume' if resume_tid else 'Fresh'} @ {now}\n"
                               f"- model: `{args.model}` · case: `{args.case_id}` · images: `{len(images)}`\n")
 
+    # ⚠️ trace store 必须放**短路径**(仓库根 .trace), 不能放 out_dir/.trace。
+    # 原因 (Windows MAX_PATH=260): 子 Agent 的 trace_id 是 <父UUID>@delegate-<时间戳>-NNN,
+    # 消息文件名还把整个 id 重复一次。若 base 是深层的 outputs/<case>/.trace,
+    # 子 agent 消息文件路径会到 ~285 字符 > 260, 落盘报 [Errno 2] 子 Agent 直接失败。
+    # 放仓库根 .trace 后同样路径 ~204 < 260。各 case 的 trace 按 trace_id 区分, 不冲突。
+    trace_store_base = REPO_ROOT / ".trace"
     runner = AgentRunner(
-        llm_call=create_openrouter_llm_call(model=args.model),
-        trace_store=FileSystemTraceStore(base_path=str(out_dir / ".trace")),
+        llm_call=make_llm_call(),
+        trace_store=FileSystemTraceStore(base_path=str(trace_store_base)),
+        debug=True,   # subagent.py 据此打印子 Agent (phase-2a/2b) 的实时执行过程,
+                      # 否则子 Agent 全程静默, 只有最后 delegate 汇总可见。
     )
 
     turn = 0
     t0 = time.time()
     status = "unknown"
+    # token / 成本累计 (主 trace; 子 Agent 的 token 在各自子 trace, 不计入此处)。
+    usage = {"in": 0, "out": 0, "cache_w": 0, "cache_r": 0, "cost": 0.0}
+
+    # 完成度兜底: 一轮跑完若 workflow 没填全/没出 HTML(弱模型常吐空消息提前自停),
+    # 带「还差哪些」的具体清单**续同一条 trace** 再跑, 直到完成或达上限。
+    # 实验/两段式模式(phase1_only/_exp_*)是故意中途停的, 不兜底。
+    _exp_mode = (getattr(args, "phase1_only", False) or getattr(args, "_exp_direct", False)
+                 or getattr(args, "_exp_understanding_only", False)
+                 or getattr(args, "_exp_workflow_from_understanding", False))
+    max_auto = 0 if _exp_mode else getattr(args, "max_auto_continue", 2)
+    run_msgs = msgs
+    cur_trace = resume_tid
+    attempt = 0
+    read_phase: set = set()    # agent 读过哪些阶段规格文件 (监听 read_file 累计)
     try:
-        async for item in runner.run(messages=msgs, config=cfg):
-            if isinstance(item, Trace):
-                status = item.status
-                if item.trace_id:
-                    trace_id_file.write_text(item.trace_id, encoding="utf-8")
-                print(f"[trace] {item.trace_id} status={item.status}", flush=True)
-            elif isinstance(item, Message):
-                role = getattr(item, "role", "?")
-                content = getattr(item, "content", "") or ""
-                tool_calls = getattr(item, "tool_calls", None)
-                if role == "assistant":
-                    turn += 1
-                    if content:
-                        print(f"\n[turn {turn} · text]\n{content}\n", flush=True)
-                        _trace_append(trace_path, f"\n### Turn {turn}\n> {content[:2000]}\n")
-                    for tc in (tool_calls or []):
-                        fn = (tc.get("function") or {}) if isinstance(tc, dict) else {}
-                        nm = fn.get("name", tc.get("name", "?") if isinstance(tc, dict) else "?")
-                        ar = str(fn.get("arguments", ""))[:200]
-                        print(f"[turn {turn} · tool] {nm}({ar})", flush=True)
-                        _trace_append(trace_path, f"- `{nm}` — `{ar}`\n")
-                elif role == "tool":
-                    preview = str(content)[:300]
-                    print(f"  ↳ [tool result] {preview}", flush=True)
+        while True:
+            async for item in runner.run(messages=run_msgs, config=cfg):
+                if isinstance(item, Trace):
+                    status = item.status
+                    if item.trace_id:
+                        cur_trace = item.trace_id
+                        trace_id_file.write_text(item.trace_id, encoding="utf-8")
+                    print(f"[trace] {item.trace_id} status={item.status}", flush=True)
+                elif isinstance(item, Message):
+                    role = getattr(item, "role", "?")
+                    raw_content = getattr(item, "content", "") or ""
+                    tool_calls = getattr(item, "tool_calls", None)
+                    # Qwen 原生: 整条消息塞在 content dict 里, tool_calls 也嵌在其中,
+                    # item.tool_calls 属性反而是空 —— 从 content 兜底捞出来。
+                    if not tool_calls and isinstance(raw_content, dict):
+                        tool_calls = raw_content.get("tool_calls")
+                    content = _content_to_text(raw_content)
+                    # 累计 token/成本 (token 字段挂在 assistant 消息上; tool 消息为 None → or 0)
+                    usage["in"]      += getattr(item, "prompt_tokens", 0) or 0
+                    usage["out"]     += getattr(item, "completion_tokens", 0) or 0
+                    usage["cache_w"] += getattr(item, "cache_creation_tokens", 0) or 0
+                    usage["cache_r"] += getattr(item, "cache_read_tokens", 0) or 0
+                    usage["cost"]    += getattr(item, "cost", 0.0) or 0.0
+                    if role == "assistant":
+                        turn += 1
+                        if content:
+                            print(f"\n[turn {turn} · text]\n{content}\n", flush=True)
+                            _trace_append(trace_path, f"\n### Turn {turn}\n> {content[:2000]}\n")
+                        for tc in (tool_calls or []):
+                            fn = (tc.get("function") or {}) if isinstance(tc, dict) else {}
+                            nm = fn.get("name", tc.get("name", "?") if isinstance(tc, dict) else "?")
+                            args_full = str(fn.get("arguments", ""))
+                            ar = args_full[:200]
+                            print(f"[turn {turn} · tool] {nm}({ar})", flush=True)
+                            _trace_append(trace_path, f"- `{nm}` — `{ar}`\n")
+                            # 监听阶段文件读取 (read_file 的 file_path 里命中阶段文件名)
+                            if nm == "read_file":
+                                for _key, _ph in _PHASE_FILES.items():
+                                    if _key in args_full:
+                                        read_phase.add(_ph)
+                    elif role == "tool":
+                        preview = str(content)[:300]
+                        print(f"  ↳ [tool result] {preview}", flush=True)
+
+            # 一轮跑完 → 查完成度 (阶段文件没读的排最前: 先读规则再修输出)
+            gaps = _phase_read_gaps(out_dir, read_phase) + _completion_gaps(out_dir)
+            if not gaps:
+                break
+            if attempt >= max_auto:
+                if max_auto > 0:
+                    print(f"\n⚠️ 达自动续跑上限({max_auto})仍未完成: {'; '.join(gaps)}", flush=True)
+                    _trace_append(trace_path, f"\n### ⚠ 达续跑上限仍未完成: {'; '.join(gaps)}\n")
+                break
+            attempt += 1
+            nudge = (
+                "⚠️ 任务还没做完, 别停。当前还差(**按顺序处理**):\n"
+                + "\n".join(f"  - {g}" for g in gaps)
+                + "\n**先 read_file 上面点名没读过的阶段规格文件**(里面写了格式/词表/检查规则), "
+                "再据规则修后面的问题, **别重做已完成的部分**。提示: 缺 effect/action 的步骤用 "
+                "wf-patch.py --set 补(action 要对到 action.json 的合法叶子, 如 `元素生成`/`提取/化学提取`, "
+                "别拼 `图像生成/文生图` 这种不存在的; wf-patch 部分应用, 对的会留下、只补错的); "
+                "intent 要写成带 {in-type:}/{out-type:}/{act:} 标记的句子; 没出 HTML 就跑 render-case.py。"
+            )
+            print(f"\n[auto-continue {attempt}/{max_auto}] 续跑补完: {'; '.join(gaps)}\n", flush=True)
+            _trace_append(trace_path, f"\n### ↻ auto-continue {attempt}: {'; '.join(gaps)}\n")
+            cfg.trace_id = cur_trace                       # 续同一条 trace (不重开)
+            run_msgs = [{"role": "user", "content": nudge}]
     except KeyboardInterrupt:
         print(f"\n⚠️ 中断. {out_dir}/ 产物已保留. 续跑: --resume", file=sys.stderr)
         return 130
@@ -272,7 +754,16 @@ async def run(args: argparse.Namespace) -> int:
 
     elapsed = time.time() - t0
     print(f"\n[done] status={status} turns={turn} wall={elapsed:.1f}s", flush=True)
-    _trace_append(trace_path, f"\n### ◀ done · status={status} · turns={turn} · {elapsed:.1f}s\n")
+    print(f"[usage] tokens in={usage['in']:,} out={usage['out']:,} "
+          f"cache_w={usage['cache_w']:,} cache_r={usage['cache_r']:,} · cost=${usage['cost']:.4f} "
+          f"(model={args.model}; 不含子 Agent)", flush=True)
+    _trace_append(
+        trace_path,
+        f"\n### ◀ done · status={status} · turns={turn} · {elapsed:.1f}s\n"
+        f"- tokens: in={usage['in']:,} out={usage['out']:,} "
+        f"cache_w={usage['cache_w']:,} cache_r={usage['cache_r']:,} · cost=${usage['cost']:.4f}\n"
+    )
+    args._last_stats = dict(usage)   # 供 main() 两段式汇总
     return 0 if status in ("completed", "unknown") else 2
 
 
@@ -287,12 +778,39 @@ def _parse_args() -> argparse.Namespace:
                    help="输出目录名, 落在 outputs/ 下. case_id 自动从 basename 推。")
     p.add_argument("--extra-image", action="append", default=[],
                    help="额外配图 (本地路径 or URL), 可多次。")
+    p.add_argument("--provider", default="openrouter", choices=["openrouter", "qwen"],
+                   help="LLM 端点: openrouter (默认, OPEN_ROUTER_API_KEY, 一个 URL 通打各家) "
+                        "或 qwen (阿里 dashscope 原生, 读 .env 的 QWEN_API_KEY + QWEN_BASE_URL)。")
     p.add_argument("--model", default="openai/gpt-4o",
-                   help="OpenRouter 模型名 (default: openai/gpt-4o). "
-                        "如 google/gemini-2.5-pro / qwen/qwen-max / anthropic/claude-sonnet-4.5")
+                   help="模型名。provider=openrouter 时形如 openai/gpt-4o / qwen/qwen-max / "
+                        "anthropic/claude-sonnet-4.5; provider=qwen 时形如 qwen-plus / qwen-max (无前缀)。")
+    p.add_argument("--phase1-model", default=None,
+                   help="启用两段式: Phase 1 (心智模型+骨架+anchor) 用这个模型跑完即停, "
+                        "Phase 2+ 换 --model resume 续跑。不传=全程单模型。"
+                        "例: --phase1-model anthropic/claude-sonnet-4.6 --model google/gemini-3.1-flash-lite")
+    p.add_argument("--phase1-provider", default=None, choices=["openrouter", "qwen"],
+                   help="Phase 1 段的 provider, 默认继承 --provider。")
+    p.add_argument("--exp", default=None, choices=["direct", "split"],
+                   help="Phase 1 实验模式 (产出 workflow.json 骨架后即停, 不跑 Phase 2/3):\n"
+                        "  direct = 强模型(--model)不写 understanding, 边想边直接出 workflow.json;\n"
+                        "  split  = 强模型(--phase1-model)只产 understanding → 弱模型(--model)据 understanding+schema 产 workflow.json。")
+    p.add_argument("--spec-version", default=None, metavar="SUFFIX",
+                   help="用 spec-<SUFFIX>/ 目录而非默认 spec/ (实验变体, 不污染原 spec)。")
     p.add_argument("--max-turns", type=int, default=300, help="最大迭代轮数 (default: 300)")
+    p.add_argument("--max-image-dim", type=int, default=1280,
+                   help="图片下采样最长边像素 (default: 1280, 0=关闭)。多张大图 base64 合计过大会"
+                        "打断 OpenRouter→Claude 上游流 (internal stream ended); 下采样+转JPEG 防此并省 token。")
+    p.add_argument("--image-quality", type=int, default=85,
+                   help="下采样后 JPEG 质量 (default: 85)。截图含文字, 别压太低伤可读性。")
     p.add_argument("--resume", action="store_true",
                    help="从 outputs/<out-dir>/.trace_id 读 trace 续跑")
+    p.add_argument("--no-ocr", action="store_true",
+                   help="跳过执行前的配图预 OCR (默认开启: 每张图 OCR 成文本落 _scratch/ocr.txt, 供 quote-source --ocr 搜)")
+    p.add_argument("--ocr-model", default="google/gemini-3.1-flash-lite",
+                   help="预 OCR 用的视觉模型 (default: google/gemini-3.1-flash-lite, 走 OpenRouter)")
+    p.add_argument("--max-auto-continue", type=int, default=2,
+                   help="完成度兜底: 跑完若 workflow 没填全/没出 HTML(弱模型常吐空消息自停), "
+                        "自动带'还差X'续跑的最大次数 (default: 2, 0=关闭)")
     return p.parse_args()
 
 
@@ -301,9 +819,80 @@ def main() -> None:
         if hasattr(stream, "reconfigure"):
             stream.reconfigure(encoding="utf-8", errors="replace")
     logging.basicConfig(level=logging.WARNING)
+    _load_env()   # 把 .env (OPEN_ROUTER_API_KEY / QWEN_API_KEY / QWEN_BASE_URL) 载入环境
     args = _parse_args()
     args.case_id = _derive_case_id(args.out_dir)
-    sys.exit(asyncio.run(run(args)))
+    args.phase1_only = False
+    args._phase2_handoff = False
+    args._exp_direct = False
+    args._exp_understanding_only = False
+    args._exp_workflow_from_understanding = False
+    args._last_stats = {}
+
+    def _g(d, k):
+        return d.get(k, 0) if d else 0
+
+    # ── 实验模式 (--exp): 只产 workflow.json 骨架, 不跑 Phase 2/3 ──
+    if args.exp == "direct":
+        # 方案1: 强模型(--model)不写 understanding, 边想边直接出 workflow.json。
+        print(f"\n{'='*64}\n  [exp:direct] {args.provider}/{args.model} · 直接产 workflow.json\n{'='*64}", flush=True)
+        args._exp_direct = True
+        sys.exit(asyncio.run(run(args)))
+
+    if args.exp == "split":
+        # 方案2: 强模型(--phase1-model)只产 understanding → 弱模型(--model)据其产 workflow.json。
+        if not args.phase1_model:
+            print("❌ --exp split 需要 --phase1-model (强模型, 产 understanding)。", file=sys.stderr)
+            sys.exit(2)
+        main_provider, main_model = args.provider, args.model
+        p1_provider = args.phase1_provider or args.provider
+        print(f"\n{'='*64}\n  [exp:split] A · understanding · {p1_provider}/{args.phase1_model}\n{'='*64}", flush=True)
+        args.provider, args.model = p1_provider, args.phase1_model
+        args.resume, args._exp_understanding_only = False, True
+        rcA = asyncio.run(run(args)); statsA = dict(args._last_stats)
+        if rcA != 0:
+            print(f"❌ split-A (understanding) 退出码={rcA}, 不继续。", file=sys.stderr)
+            sys.exit(rcA)
+        print(f"\n{'='*64}\n  [exp:split] B · workflow.json · {main_provider}/{main_model}\n{'='*64}", flush=True)
+        args.provider, args.model = main_provider, main_model
+        args.resume = False   # 全新一段: 不继承强模型历史(含图), 只凭 understanding.md + schema
+        args._exp_understanding_only, args._exp_workflow_from_understanding = False, True
+        rcB = asyncio.run(run(args)); statsB = dict(args._last_stats)
+        print(f"\n{'='*64}\n  [exp:split] 成本汇总 (case {args.case_id})\n{'='*64}", flush=True)
+        print(f"  A understanding [{args.phase1_model}]: in={_g(statsA,'in'):,} out={_g(statsA,'out'):,} · ${_g(statsA,'cost'):.4f}")
+        print(f"  B workflow.json [{main_model}]: in={_g(statsB,'in'):,} out={_g(statsB,'out'):,} · ${_g(statsB,'cost'):.4f}")
+        print(f"  合计: ${_g(statsA,'cost') + _g(statsB,'cost'):.4f}", flush=True)
+        sys.exit(rcB)
+
+    # 单模型: 直接跑。
+    if not args.phase1_model:
+        sys.exit(asyncio.run(run(args)))
+
+    # 两段式: Pass 1 (Phase 1, 模型A) → Pass 2 (Phase 2+, 模型B, resume 同一 trace)。
+    main_provider, main_model = args.provider, args.model
+    p1_provider = args.phase1_provider or args.provider
+
+    print(f"\n{'='*64}\n  Pass 1/2 · Phase 1 only · {p1_provider}/{args.phase1_model}\n{'='*64}", flush=True)
+    args.provider, args.model = p1_provider, args.phase1_model
+    args.resume, args.phase1_only, args._phase2_handoff = False, True, False
+    rc1 = asyncio.run(run(args))
+    stats1 = dict(args._last_stats)
+    if rc1 != 0:
+        print(f"❌ Pass 1 退出码={rc1}, 不继续 Phase 2。", file=sys.stderr)
+        sys.exit(rc1)
+
+    print(f"\n{'='*64}\n  Pass 2/2 · Phase 2+ (resume) · {main_provider}/{main_model}\n{'='*64}", flush=True)
+    args.provider, args.model = main_provider, main_model
+    args.resume, args.phase1_only, args._phase2_handoff = True, False, True
+    rc2 = asyncio.run(run(args))
+    stats2 = dict(args._last_stats)
+
+    # 两段成本汇总
+    print(f"\n{'='*64}\n  两段式成本汇总 (case {args.case_id})\n{'='*64}", flush=True)
+    print(f"  Pass1 [{args.phase1_model}]: in={_g(stats1,'in'):,} out={_g(stats1,'out'):,} · ${_g(stats1,'cost'):.4f}")
+    print(f"  Pass2 [{main_model}]: in={_g(stats2,'in'):,} out={_g(stats2,'out'):,} · ${_g(stats2,'cost'):.4f}")
+    print(f"  合计: ${_g(stats1,'cost') + _g(stats2,'cost'):.4f} (不含子 Agent)", flush=True)
+    sys.exit(rc2)
 
 
 if __name__ == "__main__":

+ 15 - 59
examples/process_pipeline/script/search_eval/procedure-dsl/run_procedure_dsl.py

@@ -274,20 +274,18 @@ def _build_initial_blocks(
 
 1. `Read(file_path="{spec_readme}")` — self-driven skill 的入口, 含完整 phase 加载指南 (累积式) + 自查清单 + 工具调用规则. 读完不要再 Read 它.
 2. `Read(file_path="{spec_tools}")` — 外部脚本接口手册. 读完不要重读.
-3. 按 README phase 加载指南**累积式**前进, 中间产物是**单个 `workflow.json` 文件**, 各 phase 都 in-place Edit 它 (不写多个中间快照):
-   - Phase 1.1 心智模型 → `{case_dir}/understanding.md` (含 procedure 数量判断)
-   - Phase 1.2 骨架 → **Write** `{case_dir}/workflow.json` (procedures 数组骨架)
-   - Phase 1.3 IO 闭合 → **Edit** workflow.json 加 anchor
-   - Phase 2A/2B/2C 归一化 → **Edit** workflow.json 加 effect/action/type/sub/form + procedures[i].type_registry
-   - Phase 3 lint + 渲染 → 调 render-case.py + lint-case.py (不写 case_data.json, renderer 内存组装)
-   - Phase 3 .md → Write `{case_dir}/case-{case_id}-<slug>.md`
+3. 按 README phase 加载指南**累积式**前进, 中间产物是**单个 `workflow.json` 文件**, 全程用 `wf-patch.py` 演化它 (绝不 Write/Edit 手改; 怎么用见 README「🛠 运行时约定」+ tools.md):
+   - Phase 1 骨架 (想清楚 + 直接建, **不必单独写 understanding.md**) → 先在文字回复里**简述**「有几个独立工序 / 每个工序的步骤序列(每步: 用什么工具·对什么输入·做什么动作·得什么输出)」, 想清楚多步拆分(**别把多步流水线压成单步**), 然后**直接** `wf-patch.py --patch` 建 `{case_dir}/workflow.json` (扁平 patch, proc/step/IO 一次建全; 缺路径默认自动建, 填字段也不会越界)
+   - Phase 1.3 IO 闭合 → `wf-patch.py` 加 anchor + 用 `@quote` 标记 + `--resolve-quotes` 让工具自动填真实 value/directive
+   - Phase 2 归类标注 → `wf-patch.py` 加 effect/action/type/substance/form/intent + type_registry (单 Agent 一趟做完, 无 A/B/C 子步划分; 细则见 phase2-normalize.md)
+   - Phase 3 lint + 渲染 → 调 lint-case.py + render-case.py 产出 `{case_dir}/case-{case_id}-<slug>.html` (不写 case_data.json, renderer 内存组装; **.md 已取消, HTML 是唯一产物**)
 
 ## ❌ 重复读取禁令 (CRITICAL: ZERO REPEATED READS)
 
 你拥有完美的长期记忆(由于 Context 累积,你读取过的所有文件内容会永远保留在你的 Context 中)。
 请**绝对不要**重复读取任何文件!任何重复的 `Read` 动作都是对 Token 和回合数 Budget 的极大浪费。
 - **禁忌 1**:不要因为看到 spec 文档中写了 `详见 [tools.md §2]` 就去重新 `Read(file_path="{spec_tools}")`。你已经在 Turn 3 读过它了,直接检索你的记忆!
-- **禁忌 2**:不要因为进入了 Phase 2B,就去重新 `Read` 任何 Phase 2 的 spec 文件(如 `phase2-normalize.md`)。你已经在 Phase 2A 开始时读过它了,它就在你的记忆中,直接使用它!
+- **禁忌 2**:不要因为还在 Phase 2 里推进,就去重新 `Read` 任何 Phase 2 的 spec 文件(如 `phase2-normalize.md`)。你在进入 Phase 2 时已经读过它了,它就在你的记忆中,直接使用它!
 - 在发出任何 `Read` 指令前,必须自我核对:“我之前读过这个文件吗?”。如果读过,绝对不要再次 Read!
 
 ## 输入
@@ -342,13 +340,12 @@ python {spec_dir}/tools/render-case.py \
     --page-title "Case {case_id} · <主题>" \
     --case-id {case_id} \
     --out {case_dir}/case-{case_id}-<slug>.html
-
-# Step C: Write {case_dir}/case-{case_id}-<slug>.md (DSL 文本版, 按 {spec_name}/output/md-structure.md §11 结构)
+# 渲染出的 .html 就是唯一产物 (spec/tools.md §6: .md 已取消, 不要再 Write .md)
 ```
 
 **`--source-input` 行为**: renderer 自动从 raw 抽 body_text + 封面图 + 图集兜底填到 case_data.source (内存里). 微信公众号长文走 inline 图 + 封面; 小红书短文走 body + "--- 附图 ---" 末尾追加非封面图. 你**不必手工**复制 raw.body_text — 完全交给 renderer.
 
-`workflow.json` 的契约见 [{spec_name}/output/case-data.schema.json]({spec_name}/output/case-data.schema.json) 里 Procedure 的 definition (顶层 `{{procedures: [{{id, name, purpose, declarations, type_registry?, steps, return_row?}}]}}`). **不要参考其他 `outputs/case-*/` 下任何文件**.
+`workflow.json` 的契约见 [{spec_name}/format/case-data.schema.json]({spec_name}/format/case-data.schema.json) 里 Procedure 的 definition (顶层 `{{procedures: [{{id, name, purpose, declarations, type_registry?, steps, return_row?}}]}}`). **不要参考其他 `outputs/case-*/` 下任何文件**.
 
 ## 其他约束
 
@@ -482,7 +479,6 @@ async def run(args: argparse.Namespace) -> None:
         RateLimitEvent,
         ResultMessage,
         TextBlock,
-        AgentDefinition,
     )
 
     workdir = Path(args.workdir or DSL_ROOT).resolve()
@@ -528,10 +524,12 @@ async def run(args: argparse.Namespace) -> None:
         blocks = [{"type": "text", "text": (
             f"上次中断了, 接续做 case-{args.case_id} 的提取流程.\n\n"
             f"先 ls {rel_out}/ 看当前已落盘哪些产物;\n"
-            f"用户可能在中断期间编辑过任何中间产物 (understanding.md / workflow.json) "
+            f"用户可能在中断期间编辑过中间产物 (workflow.json, 及 understanding.md 草稿若有) "
             f"或改过 spec/ 内任何文件 — 务必 Read 这些**当前磁盘版本**, "
             f"不要凭之前记忆继续. 如发现明显人工修订痕迹, 沿用用户改过的版本.\n\n"
-            f"⚠️【重要禁令与强制要求】:如果流程进行到 Phase 2(归一化与分类匹配),主 Agent **绝对禁止**手动调用 taxonomy-lookup.py 查询或手动决策!你必须强制阅读最新的 `spec/extraction/phase2-normalize.md` 规范,通过运行 `prepare-subtask.py` 生成物理任务切片,然后调用 `Agent`(或 `Task`)工具将任务分别分发给预定义好的 `phase-2a-normalizer` 和 `phase-2b-matcher` 子 Agent 并行协作执行!"
+            f"⚠️ 第二阶段 (归类标注) 由你**自己一趟做完** (见 spec/extraction/phase2-normalize.md): "
+            f"作用/动作/类型 对词表、实质/形式 直接提炼元素点, 用 wf-patch.py 逐字段落盘. "
+            f"**不要**切任务、不要召唤子 Agent (该流程已废弃)."
         )}]
     else:
         blocks = _build_initial_blocks(source_for_agent, args.case_id, args.out_dir, images, workdir, args.spec_name)
@@ -567,55 +565,13 @@ async def run(args: argparse.Namespace) -> None:
             stderr_buf.append(line)
             print(f"[stderr] {line}", flush=True)
 
-    agents = {
-        "phase-2a-normalizer": AgentDefinition(
-            description="Expert in Phase 2A (effect/action/type normalization). Use this agent to read task_2a.json, normalize action/effect/type against spec trees, manage procedure-level type_registry, and generate outputs/case-N/_scratch/patch_2a.json.",
-            prompt="""You are a dedicated Phase 2A normalization sub-agent.
-Your goal is to process the inputs and outputs of a workflow for effect, action, and type normalization:
-1. Read the outputs/case-N/_scratch/task_2a.json file to get the steps and IO variables.
-2. Normalize every step's `effect` and `action` against the taxonomy specs in `spec/taxonomy/effect.json` and `spec/taxonomy/action.json`.
-3. Normalize every IO variable's `type` against `spec/taxonomy/type.json`. If a custom type is used, register it in the procedure's `type_registry` with extends and description.
-4. Output a standard `patch_2a.json` JSON file under outputs/case-N/_scratch/.
-IMPORTANT: The format of `patch_2a.json` MUST be a flat JSON array of objects, where each object has a "path" and a "value" key (exactly conforming to the `wf-patch.py` tool contract).
-Example format:
-[
-  {"path": "p1.s1.effect", "value": "预处理"},
-  {"path": "p1.s1.action", "value": "提取/化学提取/反推"},
-  {"path": "p1.s1.inputs[0].type", "value": "工具选型标准"},
-  {"path": "p1.type_registry.工具配置.extends", "value": "评语"},
-  {"path": "p1.type_registry.工具配置.desc", "value": "工具选型依据..."}
-]
-Do not output raw dictionary structure or any other nesting. Do not touch or modify other files.""",
-            tools=["Read", "Grep", "Glob", "Write", "Edit", "Bash"],
-            model="sonnet",
-        ),
-        "phase-2b-matcher": AgentDefinition(
-            description="Expert in Phase 2B (substance/form taxonomy matching). Use this agent to read task_2b.json, invoke taxonomy-lookup.py to query substance and form paths for each variable, and generate outputs/case-N/_scratch/patch_2b.json.",
-            prompt="""You are a dedicated Phase 2B taxonomy matching sub-agent.
-Your goal is to query and match the substance and form for each workflow IO variable:
-1. Read the outputs/case-N/_scratch/task_2b.json file to get the variables to match.
-2. For each variable, run `python spec/tools/taxonomy-lookup.py --dim 实质 --match "..."` and `--dim 形式 --match "..."` to search for the most precise taxonomy paths matching the variable's value, name, and related_images.
-3. Output a standard `patch_2b.json` JSON file under outputs/case-N/_scratch/.
-IMPORTANT: The format of `patch_2b.json` MUST be a flat JSON array of objects, where each object has a "path" and a "value" key (exactly conforming to the `wf-patch.py` tool contract). Substance and form values can be single string paths, multiple paths separated by ' + ', or JSON arrays of strings for multi-path matching.
-Example format:
-[
-  {"path": "p1.s1.inputs[0].substance", "value": "/理念/知识/思想/概念范畴/性质属性/功能效用"},
-  {"path": "p1.s1.inputs[0].form", "value": "/呈现/视觉/视觉制作/构图编排/版面设计/版面结构"},
-  {"path": "p1.s2.inputs[0].substance", "value": ["/理念/知识/商业/前沿技术/AI智能/AI应用", "/理念/知识/思想"]}
-]
-Do not output raw dictionary structure or any other nesting. Do not touch or modify other files.""",
-            tools=["Read", "Grep", "Glob", "Write", "Edit", "Bash"],
-            model="sonnet",
-        )
-    }
-
     options = ClaudeAgentOptions(
         model=args.model,
         cwd=str(workdir),
         resume=resume_sid,
-        # ⚠️ 支持旧版 "Task" 与新版 "Agent" 标识符以确保在所有 Claude Code / SDK 版本中均可激活子 agent
-        allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Task", "Agent"],
-        agents=agents,
+        # 单 Agent 全程跑 (spec/tools.md §7「第二阶段不再用子助手」): Phase 2 归类标注
+        # 由主流程自己一趟做完, 不再切任务 / 召唤 phase-2a·2b 子 Agent, 故不开 Task/Agent.
+        allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
         max_turns=args.max_turns,
         permission_mode="bypassPermissions",
         setting_sources=[],

+ 188 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/README.md

@@ -0,0 +1,188 @@
+# 工序提取 SKILL · 总览
+
+> 这道 skill 做一件事:**读一篇 AI 创作教程/案例,把它背后的"做法"还原成一张工序表,存成 `workflow.json`,再渲染成一个网页**。
+>
+> 本文是总览。具体操作和字段规则在 [extraction/](extraction/) 子目录的三个阶段文件里,按阶段读即可。
+
+---
+
+**本目录是一个自包含的 skill**:跑提取流程时需要的所有说明都在这里。外部的案例原文(`input/case-N-raw.json`)和你产出的成果(`outputs/case-N/`)不算 skill 的一部分。
+
+## 输入 / 输出
+
+**输入**:任意一篇创作案例——公众号 / 小红书 / 推文 / 博客(正文 + 配图)、视频教程(带转写)、或你自己的工作复盘。
+
+**输出**:一个网页 `outputs/case-{N}/case-{N}-<slug>.html`。
+
+---
+
+## 概念速览
+
+读懂这几条,就能看懂工序表在表达什么。
+
+**工序表把"一步操作"拆成几个层面**(对应 `workflow.json` 里的字段):
+
+| 层面 | 通俗讲 | 在 workflow.json 里 |
+|---|---|---|
+| 数据类型 | 这份数据算什么角色(参考图/提示词…) | 输入输出的 `type` |
+| 用的工具 | 哪个具体产品(manus/nano_banana…) | 步骤的 `via` |
+| 做的动作 | 干了什么(生成/反推…) | 步骤的 `action` |
+| 工序位置 | 处在流水线哪个环节(预处理/主体生成…) | 步骤的 `effect` |
+| 内容实质 | 这步本质上涉及什么(人物/场景/观念…) | 步骤的 `substance` |
+| 呈现形式 | 内容怎么组织(光影/构图/叙事…) | 步骤的 `form` |
+| 工序模板 | 一整套可复用的做法 | 一个 `procedure` |
+| 实际内容 | 这次具体填进去的真实值 | 输入输出的 `value` |
+| 目的 | 一句话概括这步在干嘛 | 步骤的 `intent` |
+
+**一篇案例可以有多个工序**:比如一篇文章同时讲"简单做法"和"进阶做法",那就是两个工序,放在顶层 `procedures: []` 数组里(只有一个时也用长度 1 的数组)。
+
+**自造的类型要"挂靠"**:类型词表里没有的词,要在这个工序的 `type_registry` 里写明它"算作"哪个标准词,例如 `主角图` 挂靠 `参考图`。这样既能自由起名,又不弄乱标准词表。
+
+**命名约定**:类型名、动作名用中文;工具品牌名用英文(`nano_banana_pro`);每个输出都要有不重复的编号(如 `s2o1`)供后面引用。其中"作用 / 实质 / 形式 / 动作 / 目的"是**整步一个**,"类型 / 值 / 来源 / 编号"是**每个输入输出各一个**。
+
+> 三个分类词表(作用/动作/类型 要对到它们):作用 = [effect.json](taxonomy/effect.json)、动作 = [action.json](taxonomy/action.json)、类型 = [type.json](taxonomy/type.json)。**实质/形式 没有词表**——读懂步骤后直接提炼,不查词表。
+
+---
+
+## 目录里有什么
+
+### 操作流程(extraction/ —— 分三阶段,按阶段读)
+
+| 文件 | 内容 | 什么时候读 |
+|---|---|---|
+| [extraction/phase1-skeleton.md](extraction/phase1-skeleton.md) | 第一阶段:搭骨架(判断有几个工序 / 切步骤 / 连数据流);含第一阶段字段说明 | 第一阶段 |
+| [extraction/phase2-normalize.md](extraction/phase2-normalize.md) | 第二阶段:归类标注 + 填目的列;含第二阶段字段说明 | 第二阶段 |
+| [extraction/phase3-finalize.md](extraction/phase3-finalize.md) | 第三阶段:检查 + 渲染出网页 | 第三阶段 |
+
+### 格式契约(format/)
+
+| 文件 | 内容 |
+|---|---|
+| [case-data.schema.json](format/case-data.schema.json) | **给机器看的字段清单**(必填项/可选值的最终裁判) |
+
+### 分类词表(taxonomy/ —— 作用/动作/类型 三个受控词表)
+
+| 文件 | 维度 | 规模 | 怎么用 |
+|---|---|---|---|
+| [effect.json](taxonomy/effect.json) | 作用 | 9 个词 | 第二阶段读,把每步的作用对上 |
+| [action.json](taxonomy/action.json) | 动作 | 30+ 个词 | 同上 |
+| [type.json](taxonomy/type.json) | 类型 | 50 个词 | 同上;自造类型写进 `workflow.json` 的 `type_registry` |
+| [type_suggestions.md](taxonomy/type_suggestions.md) | 新类型登记 | 只增不改 | 第三阶段跑 `lint-case.py` 时**工具自动登记**,不用手写 |
+
+> **实质/形式 没有词表**:第二阶段直接提炼,不查词表。
+
+### 工具(tools/)
+
+[tools.md](tools.md) 是各脚本(建骨架/批量改 `wf-patch` · 原文捞引 `quote-source` · 渲染 `render-case` · 检查 `lint-case`)的**接口手册**。脚本本身**不用读源码**,会用就行;渲染网页的样式(`renderer.py`/`styles.css`/`script.js`)也不用碰。
+
+---
+
+## 🛠 运行时约定(开工前必看)
+
+### 通用(所有执行引擎)
+
+- **workflow.json 全程用 `wf-patch.py`,绝不手写 / edit 改它**(手写嵌套 JSON 易漏逗号崩、edit 在 p1/p2 重复结构上会撞「多个匹配」):
+  - **建骨架**:写一份扁平 patch(`[{"path":...,"value":...}]`),**proc/step 头 + 每个 step 的 `inputs[i]`/`outputs[i]` 的 type、value、anchor 都写进同一份**,跑 `wf-patch.py --patch <清单>`——缺的 procedure/step/IO **默认自动建**(upsert)、output 的 id 自动补、文件不存在也从空建。路径**统一用 id 式 `p1.s1.inputs[0].type`**(下标式 `procedures[0].steps[0].inputs[0].type` 工具也接受)。**`--set` 别用单引号包参数**(cmd.exe 不剥 → 路径会带 `'`;值含空格用双引号,或写进 `--patch` 文件)。
+  - **填 / 改字段**:`wf-patch.py --set 'path=值'` 或 `--patch <清单>`。缺路径默认自动建,所以填 IO 不会撞「越界」;**想严格抓路径 typo,加 `--no-create`**(纯填已存在结构时用)。
+- **填真实 value/directive:你只挑锚点,工具取原文**——把值写成 `@quote|<起锚>|<止锚>`(或 `@quote|<关键词>`),patch 时带 `--resolve-quotes --source <原文json> --ocr <case_dir>/_scratch/ocr.txt`,工具自动把标记换成原文 / 配图 OCR 里的真实内容。别自己读原文、粘长内容。
+  - **文本类 value(提示词/数据/报告/JSON)必须是【完整逐字】真内容**——整段 prompt / 整段 JSON 原样搬全,**别概括/截断/只填一句**(长内容用 `@quote|<起锚>|<止锚>` 框整段, 工具逐字回填)。⚠️ 第三阶段 `render-case.py` 见到文本类 `<占位>` value 会**拒绝出 HTML**。原文确实没有 → value 写成 `<占位>(原文未提供)`(或标 `inferred:true`)即可放行;媒体类(图/视频/音频)用 `<具体描述>` 不受此限。
+- **分阶段读**:每进一阶段**先读对应 phase 文件**(phase1/2/3),别只读本总览 + tools.md 就开干(操作细则都在阶段文件里)。
+- **第一阶段别碰归类**:type 随便起个描述标签即可(不查词表、不注册 type_registry);作用/动作/类型归类 + 自造类型挂靠 type_registry 是**第二阶段**的活(那时才读三张词表)。
+
+### 仅 Cyber Agent 引擎(执行引擎是 Claude Code 则跳过本节)
+
+- **工具名**:`Read→read_file` · `Write→write_file` · `Edit→edit_file` · `Bash→bash_command` · `Glob→glob_files` · `Grep→grep_content` · 看本地图 `read_images`。
+- **`bash_command` 是 cmd.exe**:一条命令一次调用;别用 `;` 串(要串用 `&&`);别加 `| cat` / `; type ...` / `$?` 这类尾巴(返回已含 stdout+stderr+exit code);路径用正斜杠、含空格的值用双引号。
+- **`goal` 规划工具**:`focus` 只认计划里的**纯数字序号**(`goal(focus="4")`,不是标题);`goal(done="总结")` 完成当前焦点(须先 focus 成功)。报错只改成纯数字重试一次,别反复换写法(会死循环烧回合)。
+- **路径**:cwd 是 `procedure-dsl/`(不是 spec/),spec 文档里的相对链接(`extraction/x.md`)读取时补 `spec/` 前缀。read_file 有 2000 字/行、50KB/次 上限会**静默截断**长内容,长文本用 `quote-source.py`(读全文件)别 read_file 通读。
+
+---
+
+## 操作流程
+
+整个流程围绕**一个文件 `workflow.json`** 滚动演化——从第一阶段搭好骨架,后面每阶段都在它上面**就地补字段**,不另存新文件。
+
+```
+第一阶段 · 搭骨架
+   1.0 读懂案例 → 调 plan_procedures 工具交计划(工序/每工序步骤展开/章节认领)
+           (Cyber 引擎: 工具校验完整性后自动生成 workflow.json 骨架; 其他引擎: 写 understanding.md 再建)
+   1.1 在骨架上填 value/directive(别增删工序/步骤)
+   1.3 把步骤间的数据流连起来      → 改 workflow.json(补"来源/去处")
+第二阶段 · 归类标注
+   · 作用/动作/类型 归到分类词表
+   · 实质/形式 直接提炼元素点(不查词表)
+   · 自造类型登记 → 写进 type_registry
+   · 每步填目的列(intent)
+第三阶段 · 检查收尾
+   · 跑 lint-case.py 检查
+   · 跑 render-case.py 渲染出网页
+```
+
+### 起手(只做一次)
+
+把本文读**一遍**,之后**别再回头读它**——需要某阶段细节时直接读对应的阶段文件。再把 [tools.md](tools.md) 读一遍(后面要调的脚本接口都在那),也别重读。
+
+### 重要:读过的文件别重复读
+
+上下文是累积的,**你读过的文件内容会一直留在记忆里**。**不要**因为某处写了"详见 tools.md"就又去读一遍;**不要**进了第二阶段又去重读阶段文件。需要时从记忆里翻,别重复 Read——那会白白浪费预算。
+
+### 第一阶段 · 搭骨架(新读这些)
+
+要做的事:通读案例 → 想清楚有几个工序、每个工序怎么走 → 切步骤 → 填骨架 → 把数据流连起来。
+
+| 该读(还没读过的) | 干嘛用 |
+|---|---|
+| [extraction/phase1-skeleton.md](extraction/phase1-skeleton.md) | 第一阶段具体怎么做 + 字段填写规则 |
+| `input/case-{N}-raw.json` | 案例原文(你的素材) |
+
+产物(写到 `outputs/case-{N}/`):`workflow.json`——先调 `plan_procedures` 工具交计划(几个工序 / 每工序步骤展开 / 每工序认领哪些 `0N` 章节),Cyber 引擎据此自动生成骨架;再用 `wf-patch.py` 在骨架上填 value/directive、补"来源/去处"(别增删工序/步骤)。
+过关条件:workflow.json 自查通过(数据流都连上了、类型一致)→ 进第二阶段。
+
+### 第二阶段 · 归类标注(新读这些)
+
+要做的事:把"作用/动作/类型"归到分类词表;把"实质/形式"直接提炼成元素点;填每步的目的列。
+
+| 新读 | 干嘛用 |
+|---|---|
+| [extraction/phase2-normalize.md](extraction/phase2-normalize.md) | 第二阶段怎么做 |
+| [taxonomy/effect.json](taxonomy/effect.json) / [action.json](taxonomy/action.json) / [type.json](taxonomy/type.json) | 三张分类词表 |
+
+产物:用 `wf-patch.py`(`--set`/`--patch`)在 `workflow.json` 上补 作用/动作/类型 + 实质/形式 + 自造类型登记 + 目的列。**别 edit/手写改它,也不要另存新文件**。
+过关条件:每步的作用/动作都命中标准词、每个类型要么命中词表要么登记了挂靠、每步该有的实质/形式都提炼好、每步都有目的列 → 进第三阶段。
+
+### 第三阶段 · 检查 + 渲染(新读这些)
+
+要做的事:跑检查 → 渲染出网页(脚本自动组装,不另存中间文件)。
+
+| 新读 | 干嘛用 |
+|---|---|
+| [extraction/phase3-finalize.md](extraction/phase3-finalize.md) | 检查清单 + 脚本命令 |
+| [format/case-data.schema.json](format/case-data.schema.json) | 字段的机器清单(最终裁判) |
+
+脚本命令:
+```bash
+# 1. 检查 + 自动登记新类型(轻量, 不卡流程); 带 --source 才查「章节覆盖」+「value 逐字」
+python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N} \
+    --source input/case-{N}.json --ocr outputs/case-{N}/_scratch/ocr.txt
+
+# 2. 渲染网页(脚本在内存里组装好数据直接出 HTML)
+python spec/tools/render-case.py \
+    --workflow outputs/case-{N}/workflow.json \
+    --source-input input/case-{N}-raw.json \
+    --page-title "Case {N} · <主题>" \
+    --case-id {N} \
+    --out outputs/case-{N}/case-{N}-<slug>.html
+```
+
+产物:`outputs/case-{N}/case-{N}-<slug>.html`(唯一产物)。
+
+### 每次 Read 前自查
+
+- 这个文件**之前读过吗**?读过就别再读。
+- 只想确认"提没提过某概念"?用 Grep,别整篇 Read。
+- 只想看"目录里有啥"?用 Glob。
+- 中断后接着做:用户**可能改过**的 `workflow.json`(及 `understanding.md` 草稿,若有)要重读;spec 没变,不用重读。
+
+### 卡住了怎么办
+
+某阶段的过关条件过不去 → **别硬闯下一阶段**,回当前阶段修。修两次还过不去 → 在产物里挂个 `inferred: true, inferred_reason: "反复过不去, 需人工看"` 标记,再往下走。

+ 275 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/extraction/phase1-skeleton.md

@@ -0,0 +1,275 @@
+## 第一阶段 · 搭骨架
+
+### 步骤
+
+
+| 小步      | 做什么                                                                                                                                                                                       | 产出                          |
+| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
+| **1.0 计划 (understanding)** | **先想清楚再动手**:通读原文(含配图),把这篇拆成一份**工序计划**:(a) 有**几条独立做法**(几个工序);(b) 每个工序的**步骤序列逐条展开**(每步:用什么工具·对什么输入·做什么动作·得什么输出,**别压成单步**,详细度见 §1.1);(c) 每个工序**覆盖原文哪些 `0N` 章节**(原文每个章节都要被某个工序认领,别整段漏抽)。**Cyber 引擎**:调用一次 `plan_procedures` 工具把这份计划交上去——它校验完整性(有章节没认领→报错让你补;工序单步→警告),通过后**自动据计划生成 workflow.json 骨架**。**其他引擎**:把这份计划写进 `understanding.md`,再据它建骨架。 | 工序计划 + `workflow.json`(骨架版,结构按计划锁定) |
+| **1.1 填骨架** | **在已生成的骨架上填内容**(别增删工序/步骤、别 write_file 重写整个 workflow.json):逐步用 `wf-patch.py` 填每个输入/输出的 `value`(文字类用 `@quote` 拽**完整逐字**原文;**提示词建成 `type=提示词` 的 IO value,别塞 `directive`**,见下)。结构已按计划定死,你只补血肉。 | `workflow.json`(填好 value) |
+| **1.3 连数据流** | **把数据流连起来** —— 给每个输入输出补上"来源/去处":输入写 `← 上游某输出的编号`(如 `← s2o1`)、`← 工序输入`、`← 某编号[i]`(循环里逐个取);输出写 `→ sN`、`→ 某列表.追加`。连完自查:每个输入引用的编号都能在前面找到对应输出。连错就回改。几十处要连时用 `wf-patch.py` 一次过(见下),别写脚本硬塞。 | `workflow.json`(补好"来源/去处")  |
+| **1.4 IO 校验(强制)** | 填完后、进 Phase 2 前,**必须跑 `python spec/tools/verify-io.py --workflow <wf> --source input/case-N.json --ocr <ocr>`**:它逐项校验 ①每个**文本类** input/output 的 `value`、②每个**生成步**的 `directive`(提示词)是否在原文里**逐字且完整**(媒体类 IO 不要求对应;directive 改写/截断都会被点名)、③每个工序的 `declarations`(inputs/resources/returns)是否补全。报 ✗ 就回去修(**此时可重新读原文**;提示词用 `@quote` 把整段**提全**,别缩写/只取第一层),修完重跑直到通过。 | `workflow.json`(文本 value/提示词逐字完整、declarations 补全) |
+
+
+---
+
+## workflow.json 整体结构
+
+一个 `workflow.json` 描述一篇案例,里面可以有一个或多个工序(比如一篇文章同时讲了"简单做法"和"进阶做法",那就是两个工序)。
+
+```
+{
+  "source":     原帖信息,            # 平台/作者/日期/标题/摘要;正文和配图由 --source-input 自动补
+  "procedures": [ 一个或多个工序 ]   # 只有一个工序时也用长度为 1 的数组
+}
+# page_title 不写进文件,由第三阶段 --page-title 命令传入
+```
+
+每个**工序**长这样:
+
+```
+{
+  "id":           "p1",
+  "name":         工序名字,
+  "purpose":      一句话说明这个工序在做什么,
+  "category":     类别(产物创造 / 资产建设 / 自动化 / 分析 / 学习),
+  "platform":     平台,  "author": 作者,
+  "declarations": 这个工序需要哪些外部输入和长期资源,最后返回什么,
+  "type_registry": 这个工序自己造的类型词(没有可不写),第二阶段归类时填,
+  "steps":        [ 一步步的操作 ],
+  "return_row":   表格最后一行,写明最终交付什么(可不写)
+}
+```
+
+---
+
+## 第一阶段要填的字段
+
+每个工序填:`id` / `name` / `purpose` / `category` / `platform` / `author` / `declarations` / `steps`。
+
+### 步骤级字段
+
+
+| 字段          | 什么意思          | 怎么填                                                                          | 别这样                       |
+| ----------- | ------------- | ---------------------------------------------------------------------------- | ------------------------- |
+| `id`        | 第几步           | `s1`、`s2`;控制块子步用点号 `s6.1`                                                    | —                         |
+| `kind`      | 步骤类型          | 普通步写 `step`;控制块用 `block` / `nested`(见下)                                      | —                         |
+| `via`       | 用了哪个具体工具/产品   | 写工具标准名:`manus`、`nano_banana_pro`、`human`(人工);原文没点名就括号占位 `(AI 生图工具)`;控制块写 `-` | 写一句话描述(那是 `directive` 的活) |
+| `directive` | 给工具的**元指令**(不是提示词本身)| 只放"怎么用工具"的简短指示,如"严格反推,别发挥"、"比例 2:3"、"保持主角一致";多数生图步**留空**即可 | **别把提示词原文塞这里**——提示词是**数据**,归 `type=提示词` 的输入/输出 `value`(见下) |
+
+
+### 输入 / 输出的字段
+
+
+| 字段         | 什么意思                | 怎么填                                                                         | 别这样                       |
+| ---------- | ------------------- | --------------------------------------------------------------------------- | ------------------------- |
+| `type`     | 这份数据是什么角色(参考图?提示词?) | **Phase 1 起个描述性标签即可**(如 `复刻图片`、`视觉风格分析`),不必查词表、不校验;**对到标准叶子 / `type_registry` 挂靠是 Phase 2 归类**时做(那时才读 type.json) | Phase 1 就去查类型词表、注册 type_registry(那是 Phase 2 的活)|
+| `value`    | 数据内容本身              | **文字类(提示词/JSON/报告/文案…)→ 填原文里那段的【完整逐字内容】**:整段 prompt、整段 JSON 都要**原样搬全**,别缩写/概括/截断/凭记忆改写。**推荐用 `@quote\|<起锚>\|<止锚>`(或 `@quote\|<关键词>` 落 JSON 块)标记 + `wf-patch --resolve-quotes` 让工具把那整段从原文/OCR 逐字回填**——长内容你只写两个短锚点,不用手抄。图片/视频用尖括号写**具体**描述 `<一张穿冲锋衣的登山者暴雨场景图>` | 把整段 prompt/JSON **缩成一句概括**;写"见上一步"引用;写"参考图"这种笼统空壳 |
+| `anchor`   | 来源 / 去处             | 输入写来源:`← 工序输入`、`← s2o1`、`← s2o1[i]`(循环逐个取);输出写去处:`→ s7`、`→ 某列表.追加`、`→ 返回 X` | 在这里描述内容(那是 `value` 的活)    |
+| `id`(输出专属) | 输出的编号               | 工序内唯一,如 `s2o1`;只有一个输出时 `s2` 也行                                              | 重复编号                      |
+
+> 🔑 **提示词是数据,不是指令——建成 `type=提示词` 的 IO**(这是最容易建错的地方)。标准范式(对齐参考实现):
+> - 一个 `human`「写提示词」步:输入 `type=描述`(用户的需求阐述)→ 输出 **`type=提示词`**,`value` = 原文那段**整段、逐字、完整**的 prompt(所有分层/要素都要:主题+核心要素+输出要求,或视觉层+技术层+氛围层…别只取第一段)。
+> - 下游生成步(`via=nano_banana` 等):输入 **`type=提示词`**(`anchor: ← 上游那个提示词输出`,`value` 同样填整段)→ 输出 `type=样图/成品图`。
+> - **`directive` 留空**(提示词不在这里)。原文没给详细 prompt 的步(如"生成符合调性的背景图"只一句),才在 `value` 写那一句或标 `inferred`。
+> - 第三阶段 `verify-io.py` 会检查:生成步**必须有 `type=提示词` 输入**、`directive` 里**不准**装提示词原文、提示词 `value` 不准截断——建错会被打回。
+
+
+> ⚠ **值一定要写真实内容**:哪怕某个输入就是上一步输出原样传过来的,"值"里也要把内容**完整抄一遍**,不能写"(同上)""见 s2o1"。嫌麻烦的话:只在源头填一次真内容,其余地方把"来源"写对,然后跑一次 `wf-patch.py --resolve-passthrough`,它会顺着编号自动把内容抄过去。
+
+**输入不用编号**——没人会反过来引用一个输入。
+
+### 命名约定
+
+- 类型名用**中文**;工具品牌名用**英文标准写法**(`seedream_4_5`,不要写成 `ByteDance-Seedream-4.5`)。
+- 每个输出都要有编号(工序内不重复),供后续步骤按编号引用。
+- 自造的类型要挂靠:在工序的 `type_registry` 里写明它"算作"哪个标准词 + 一句说明,例如 `"主角图": {"extends": "参考图", "desc": "本案例的女主肖像"}`。
+
+---
+
+### 1.1 怎么判断"有几个工序"
+
+⚠ 跟原文的章节标题无关:不管原文把某段叫"案例示范"还是"进阶玩法",只要满足下面三条,就算一条独立工序。**判断的单位是"一条完整的 输入→最终产物 链",不是原文的段落结构。**
+
+**怎么扫**:以原文里**每一张出现的成品图**(或明确写出的最终产物)为起点,逐个看它有没有对应的"输入→做法"链条。有就记为一个候选工序,再用下面三条筛。**别按段落扫。**
+
+> 🔒 **扫完做一次「章节覆盖」交叉核对**(结构强制,防整段漏抽):原文常按 `01 | … 02 | … 03 |` 分章。逐章节过一遍,确认**每个章节的核心内容都落进了某个工序或它的某个步骤**。"按成品图扫"最容易漏掉**没有独立成品图的方法论/框架章节**(如"结构化 Prompt 框架""更多案例欣赏")——这种章节本身就是一条工序(讲一套做法)或要拆成多个子步骤,**不能因为它没配一张独立成品图就跳过**。核对结论简记成"章节 0N → 工序 pX / 步骤 sY"对照;有章节对不上任何工序,回去补。**Phase 3 `lint-case.py --source` 会按章节覆盖率把漏抽的章节抓出来**,到时还得回这一步补,不如一次做全。
+
+1. 有明确产物:这条链能产出一个看得见的结果(图、素材、成品)。
+2. 有具体做法:链条里有可操作的方法(提示词、框架、流程)。
+3. 产物或做法有差异:跟别的工序比,产物不同 **或** 做法不同,满足一条即可。哪怕产物类型一样(都是场景图),只要提示词不同,就算两个工序,**不能合并**。
+
+边界判断:
+
+- 同一条链内部的中间步骤,归进这条工序,不单拆(比如做海报时先生成背景图,背景图是中间步骤,不是独立工序)。
+- 只展示了成品、完全没讲输入和做法的,不算工序。
+- 产物类似但做法不同 → 算两个工序。**不能**因为"产物类型相同""方法论相似"就合并。
+
+### 1.1 计划(understanding)要想清楚到什么程度——这就是你交给 `plan_procedures` 的内容
+
+下面这些就是 §1.0 那份"计划"的全部内容——**它直接决定 workflow.json 切几个工序、每工序几步**。想得越细,workflow 步骤越完整,**别压成单步**。**核心是第 3、4、6 条**(多工序判断 + 每工序逐步展开 + 章节认领),它们一对一变成 `plan_procedures` 的 `procedures[]`(每工序的 `steps[]` 四要素、`source_sections`)。Cyber 引擎把它作为工具参数交上去;其他引擎写进 `understanding.md`:
+
+1. **原文信息**:标题 / 来源平台 / 作者 / 发布时间。
+2. **内容概述**:2-4 句说清这篇在教什么、分几大板块(→ 工具的 `summary`)。
+3. **多工序判断**:以每个成品图 / 最终产物为起点列一张扫描表(产物 | 有无独立做法 | 有无完整 输入→输出 链 | 判断),最后给"共 N 个工序"的结论。
+4. **每个工序一节**,每节都要有:
+   - **终态产物**:这个工序最后交付什么(→ `final_product`)。
+   - **工艺类型**:产物创造 / 资产建设 / 自动化 / 分析 / 学习(→ `category`)。
+   - **步骤逐条展开**:不要只写"4 步",要把每一步**列出来**,每步讲清「用什么工具 · 对什么输入 · 做什么动作 · 得到什么输出」(→ 每个 step 的 `tool/input/does/output` 四要素,input/output 写**短标签**会变成步骤的输入/输出 type;`does` 只是自由描述、**不是** taxonomy 动作词);有循环 / 并行的,点明"对每个 X 重复""同时跑 N 路"。
+   - **关键工具**:这个工序用到的具体工具 / 产品名。
+   - **隐含工艺规约**:原文没明说但工艺上必然存在的前置 / 约定(如"生成要带参考图""多模型并行降随机性")——这是第二阶段补 `inferred` 输入的依据。
+5. **关键发现**:3-5 条这篇最值得记的方法论 / 工具选型 / 工序结构洞察。
+6. **章节认领**:原文每个 `0N` 章节,判断它归哪个工序(独立做法→单独工序;纯展示/补充→并进相邻工序)——**每个章节都要被某工序的 `source_sections` 认领**,这是工具卡"整段漏抽"的依据。最易漏:**没有独立成品图的方法论/框架章节**(如"结构化 Prompt 框架""更多案例"),它们也是工序,别因为没配成品图就跳过。
+
+> **颗粒度对照**:一步要写成"用豆包对〔题材思路〕生成〔剧本大纲+分集剧情+台词〕"这种程度(工具+输入+动作+输出俱全),而不是"写剧本"这种一个词。多步时各步侧重不同,别都写成一个模子。
+
+### 1.2 哪些"没写出来"的要主动补
+
+真实教程经常会省略一些理所当然的中间产物。比如某步要"自己写动作序列",原文没提它得先有一张主角图当参考——但工艺上这张图必不可少。这种**该有却没写出来**的输入/输出,要主动补上并标注推断原因;某个字段实在判断不出就留空,**不要硬编一个假的**。
+
+原文常省略的几类:
+
+- **工具常识**:生成模型一般要带模型/参数;召回要有检索依据;视频生成要有主角图/分镜图当参考。
+- **前后对不上**:下游用到了 X 但上游没产出 X → 是不是漏抽了?某个输出后面没人用 → 是不是漏了使用它的步骤?
+- **工艺常识**:短剧 = 分镜图 → 视频片段 → 拼接;建素材库 = 收集 + 入库 + 建索引。
+
+补出来的输入输出标上 `inferred: true` 和原因(让后面复核),别悄悄塞进去:
+
+```json
+{
+  "type": "参考图",
+  "value": "<一张主角肖像>",
+  "anchor": "← s2o1",
+  "inferred": true,
+  "inferred_reason": "原文只说'自己写动作',没提主角图;但写动作序列需要它当角色参考"
+}
+```
+
+- ✅ **该补**:工艺上必然需要的中间产物;原文用了复数("这些素材库")但只列了一个,把其余补全。
+- ❌ **不用补**:归一化分类(那是第二阶段的常规活);只是把原文换个说法;原文细节确实没写全(那是信息缺失,不算"推断")。
+- ⚠ 推断标记只能加在**步骤的输入/输出**上,不要加在工序的 `declarations` 上(那里只收 `type`/`name`/`desc` 三个字段)。
+
+### 1.2 有循环/并行/分支怎么切
+
+原文里如果有"对每个 X 重复""同时做几个版本""分情况",切步骤时要展开成**控制块 + 子步**,别硬压成一个步骤:
+
+- **块本身**(`"kind": "block"`):`via` 写 `-`,块的输入输出表达"整个循环在做什么"。
+- **块里的每一步**(`"kind": "nested"`,并写上 `"group": "块的编号"`):编号带点号(块是 `s5`,子步就是 `s5.1`),表达"每一次具体怎么做"。
+
+```json
+{ "id":"s5", "kind":"block", "via":"-",
+  "inputs": [{"type":"分镜脚本","value":"...","anchor":"← s4o1"}],
+  "outputs":[{"id":"s5o1","type":"分镜图列表","value":"...","anchor":"→ s6"}] }
+{ "id":"s5.1", "kind":"nested", "group":"s5",
+  "via":"nano_banana", "directive":"按场景提示词生成,保持主角一致",
+  "inputs": [{"type":"提示词","value":"...","anchor":"← s4o1[i]"},
+             {"type":"参考图","value":"...","anchor":"← s5o1[-1]"}],
+  "outputs":[{"id":"s5.1o1","type":"分镜图","value":"...","anchor":"→ 分镜图列表.追加"}] }
+```
+
+- **循环里取数据**:来源写 `← s4o1[i]`(逐个取)、`← s5o1[-1]`(取上一次的产出,做"接力"参考)。
+- **往列表里攒结果**:去处写 `→ 分镜图列表.追加`。
+- **一步产出好几样东西**:就写好几个输出(每个都有自己的编号),别合并成一个。
+- 抽样、重试这类小修饰,直接写进 `directive` 里说明即可。
+
+### 1.3 连数据流时自查这几条
+
+- 每个输入的"来源"(`← 某编号`)都能找到已存在的输出编号,或是 `← 工序输入`、字面量。
+- 输入的类型,和它"来源"指向的那个输出的类型一致。
+- 循环索引用在合理位置(`[i]` 在循环里,`[-1]` 指最近一项)。
+- 接力/链式引用关系正确。
+- **逐个输入/输出按模态查 value**:文本类(提示词/数据/报告,或 `type_registry` 挂靠到它们的 case 类型)必须填**从原文匹配到的【完整逐字内容】**——整段 prompt / 整段 JSON 都要原样搬全,**别概括、别截断、别只填一句**(用 `@quote|<起锚>|<止锚>` 框出整段 + `wf-patch --resolve-quotes` 让工具从原文/OCR 逐字回填,长内容你只写两个短锚点);原文确实没有 → value 写成 `<占位>(原文未提供)`(或标 `inferred: true` + 原因);媒体类(图/视频/音频)用 `<具体描述>`。
+  - 🔒 **值强制(防"开头逐字后就缩写")**:一段真·逐字 value 应该是原文里**一整段连续文本**。最常见的偷懒是**抄了开头第一句、后面用原文小标题拼盘**——比如原文提示词是「主题:…登山者…。人物:一名年轻的登山者,表情坚毅…。产品:…水珠滚落…压胶细节…。环境:…乌云密布…」整整 ~350 字,却写成「主题:…登山者…。核心要素:人物、产品、环境、视觉风格」。后半「人物、产品、环境…」这些词单看都在原文里,但**连起来不是任何一段原文**——逐字细节全丢了。这种**必须用 `@quote|主题|<止锚>` 把整段拽进来**。**Phase 3 `lint-case.py --source` 会算"最长连续命中原文"比例,<80% 判缩写并报出**,到时还得回来用 @quote 重填。
+  - ⚠️ **第三阶段 `render-case.py` 对文本类 `<占位>` value 会直接拒绝出 HTML**——必须回填真内容、或标「原文未提供」/inferred 才放行(媒体描述不受限)。
+
+### 1.3 怎么批量连(重要)
+
+`workflow.json` 由你**直接演化,绝不写 Python 脚本去生成/批改它**(脚本拼 JSON 容易踩转义坑、把文件弄坏)。几十处"来源/去处"用 `wf-patch.py` 一次搞定:
+
+1. 把要改的清单写到 `_scratch/anchors.json` —— `[{"path":"p1.s1.inputs[0].anchor","value":"← s0o1"}, ...]`,**每条都是你的判断**(连到哪个编号是语义决定,不是机械套用);
+2. 跑 `python spec/tools/wf-patch.py --workflow workflow.json --patch _scratch/anchors.json`。
+
+工具负责安全写 JSON + 检查格式(有一条不合法就整批不写),你从不直接碰 JSON 文本。零星单处改,用 Edit 就行。路径写法见 [tools.md](../tools.md)。
+
+### 1.3 内容自动抄写
+
+"来源"连好后,跑一次 `python spec/tools/wf-patch.py --workflow workflow.json --resolve-passthrough`。对那些"原样接收上游输出"的输入(来源是 `← 某编号`),你**不用手抄上游内容**——只在源头那个输出里填一次真内容,工具会顺着编号把它逐字抄到所有引用处("值"先留空即可)。**别把"(同上)"这种引用当最终内容留着**(检查会报)。
+
+### 输出格式要求
+
+> **Cyber 引擎**:骨架由 `plan_procedures` 工具**自动生成**,你不用手建——工具按你的计划把 procedures/steps(含 id、via、输入/输出 type 标签)都搭好了,你只在它上面用 `wf-patch.py` 填 `value`/`directive`/`anchor`。下面模板只是让你看清生成出来的结构长什么样。
+
+**其他引擎(无 plan 工具时)用 `wf-patch.py --create` 建骨架**:把下面模板里要填的字段写成扁平 `[{"path":...,"value":...}]` 清单(如 `p1.s1.inputs[0].type` / `p1.s1.outputs[0].anchor`),一条 `--create --patch` 命令拼成合法 JSON(缺的 procedure/step/IO 自动建、output 的 id 自动补;见 tools.md §1)——**扁平清单好写、不会漏逗号崩 JSON**。下面模板供参考结构,也可直接复制 Write(但手写长嵌套 JSON 易出错)。
+
+```json
+{
+  "source": {
+    "platform": "<填:原文平台 e.g. wechat-gzh>",
+    "author":   "<填:原文作者>",
+    "date":     "<填:发布日期 e.g. 2026 上半年>",
+    "url":      "<填:原文 URL>",
+    "title":    "<填:原文标题>",
+    "excerpt":  "<填:摘要 1-3 句>"
+  },
+  "procedures": [
+    {
+      "id":       "p1",
+      "name":     "<填:工序名 e.g. 产品场景图生成>",
+      "purpose":  "<填:一句话目的>",
+      "category": "<填:产物创造|资产建设|自动化|分析|学习>",
+      "platform": "<填:平台>",
+      "author":   "<填:作者>",
+      "declarations": {
+        "inputs":    [{ "type": "<填:类型>", "name": "<填:声明名>", "desc": "<填:说明 (可选)>" }],
+        "resources": [],
+        "returns":   { "type": "<填:返回类型>" }
+      },
+      "steps": [
+        {
+          "id":        "s1",
+          "kind":      "step",
+          "via":       "<填:工具名 e.g. human / nano_banana / (AI 生图工具)>",
+          "directive": "<填:给工具的 prompt 文本 (可选;人工/控制流步留空)>",
+          "inputs": [
+            {
+              "type":   "<填:类型>",
+              "value":  "<填:数据内容本身>",
+              "anchor": "<填:← 工序输入 / ← s1o1>"
+            }
+          ],
+          "outputs": [
+            {
+              "id":     "s1o1",
+              "type":   "<填:类型>",
+              "value":  "<填:数据内容本身>",
+              "anchor": "<填:→ s2 / → 返回 X>"
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}
+```
+
+有循环 / 并行 / 分支时,用控制块写法(无则不需要):
+
+```json
+{
+  "id":   "s1",
+  "kind": "block",
+  "via":  "-",
+  "inputs":  [{ "type": "<填:类型>", "value": "<填>", "anchor": "<填:← 上游 id>" }],
+  "outputs": [{ "id": "s1o1", "type": "<填:类型>", "value": "<填:累积结果>", "anchor": "<填:→ sN>" }]
+},
+{
+  "id":    "s1.1",
+  "kind":  "nested",
+  "group": "s1",
+  "via":       "<填:工具名>",
+  "directive": "<填:给工具的 prompt (可选)>",
+  "inputs":  [{ "type": "<填:类型>", "value": "<填>", "anchor": "<填:← 被遍历序列 id[i]>" }],
+  "outputs": [{ "id": "s1.1o1", "type": "<填:类型>", "value": "<填:单次产出>", "anchor": "<填:→ 累积结果.追加>" }]
+}
+```
+

+ 197 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/extraction/phase2-normalize.md

@@ -0,0 +1,197 @@
+## 第二阶段 · 归类标注
+
+基于第一阶段产出的骨架,给每个步骤补充分类和标注字段。
+
+要补五样东西:
+
+1. **作用(effect)** —— 每步对到作用词表的一个标准词。
+2. **动作(action)** —— 每步对到动作词表的一条路径。
+3. **类型(type)** —— 每个输入输出对到类型词表;词表没有的自造词要"挂靠"。
+4. **实质 / 形式(substance / form)** —— **每步直接提炼几个元素点**。
+5. **目的列(intent)** —— 每步一句话概括,≤25 字,跨步骤一起看再填。
+
+### 起手:把三张分类词表读进来
+
+`spec/taxonomy/effect.json`(作用)、`spec/taxonomy/action.json`(动作)、`spec/taxonomy/type.json`(类型)——三个都不大,整读;每个词条带 `分类说明` 帮你判断边界。读一遍即可,后面别重读。**路径带 `spec/` 前缀**(你的 cwd 是 procedure-dsl/,不是 spec/)。
+
+
+### 1. 作用(effect)
+
+每步"处在什么工艺环节"对到 effect.json 的一个标准词:工艺规约 / 预准备 / 预处理 / 主体生成 / 装配 / 后期 / 配套伴生 / 检验 / 交付。**必须命中**;实在对不上,说明第一阶段这步抽错了,回去改。
+
+❌ 别这样:自己造词;把"动作"当成"作用"——"反推"是动作不是作用。
+
+### 2. 动作(action)
+
+每步的动作(第一阶段写的大白话)对到 action.json 的路径,如 `提取/化学提取/反推`、`生成/元素生成`。
+
+❌ 别这样:自己造动词,不对到词表路径。
+
+### 3. 类型(type)——漏斗式,别跳步
+
+对每个输入输出的类型:
+
+1. **先列候选**:根据这份数据的内容,列 3-5 个候选类型词(覆盖不同抽象层:通用 + 具体 + 邻近)。
+   - 例:内容是"苏晚 25 岁女性肖像,卧室床上" → 候选 `[参考图, 主角图, 人物肖像, 人物参考, 分镜图]`。
+2. **匹配词表**(命中即停):
+   - 候选里有直接命中 type.json 标准词的(如"参考图")→ 用它。
+   - 都没命中、但某候选很贴近某个标准词 → 挑最近的标准词做"挂靠",在这个工序的 `type_registry` 里登记:`"主角图": {"extends": "参考图", "desc": "本案例的女主肖像"}`。⚠ 登记在 **procedure 顶层的 `type_registry`**,不要写在输入输出里面。
+3. **自查**:每个类型要么是标准词、要么在 `type_registry` 里有挂靠 + 说明。**不允许**写个自造词却不登记(第三阶段 `lint-case.py` 会抓出来)。
+
+
+### 4. 实质 / 形式(substance / form)
+
+实质和形式本质上是**作用域**——这一步的方法能作用到哪些场景,以及那些场景最核心的内容维度是什么。
+
+- **实质**(`substance`):这一步能作用的**内容域**。填该域最核心的名词,如 `人物`、`视觉风格`、`场景`。❌ 不要写描述性长句,"图片视觉风格(颜色、光影、质感)"→ 应改为 `视觉风格`。
+- **形式**(`form`):内容的**呈现方式**。填该域最核心的呈现维度,如 `古风`、`氛围感`。没有明显形式特征可留空(null)。
+
+多个域都重要时用顿号并列:`人物、场景`。纯技术性步骤(无业务内容,如纯循环/纯参数配置)两者均可留空。
+
+**关键:基于方法的作用域,不是基于举例。**
+
+提炼前先判断:这篇文章在讲**通用方法**,还是分享**一个具体案例**?
+
+- **通用方法文**:作者用古风美女举例,但讲的是"如何反推提示词"——这套方法能作用到任何图片 → 实质填方法的作用域(如 `图片`),形式可为 null。不要填 `古风美女`。
+- **具体案例文**:作者只是复盘这一次怎么做,没有暗示推广 → 实质填案例实际涉及的域(如 `人物`),形式填案例的呈现特征(如 `古风`)。
+
+判断依据:文章有无"这个方法可以用于……""换个对象也能用"这类泛化表述?有 → 通用方法;没有、纯流水账 → 具体案例。判断不清时偏宽泛,形式留空。
+
+### 5. 目的列(intent)——跨步骤一起看再填
+
+每步补一个 `intent`:一句话概括这一步在做什么,≤25 字。**跨步骤一起看**(为了让每行各有侧重,避免都写成一个模子),不要逐步孤立地填。
+
+写法规则:
+
+1. **像句人话**(≤25 字),读出来通顺。**别写成公式**(不要出现 `→`、`:` 这种符号串、不要 `A: B → C` 这种结构)。
+2. **关键词用 `{类别:值}` 标出来**,直接用这一行其他列已经有的值。中间可以加"用、把、到、和、得到、为参考"这类连接词。**别用只有你懂的简写**(锚、链、抽卡这种)。
+3. **同类的几个值各标各的**:写"得到 `{out-type:提示词}` 和 `{out-type:负向提示词}`",别揉成"得到正负 `{out-type:提示词}`"。
+4. **能用的类别只有 5 个**:`{effect:}`、`{via:}`、`{act:}`、`{in-type:}`、`{out-type:}`。类型必须带 `in-`/`out-` 前缀区分输入输出。
+5. **不要用变量名当标记**(`{in:参考视频}` ❌ → 改 `{in-type:参考视频}`);标的词必须在这一行真实出现过。
+
+例子:
+- ✅ 用 `{via:manus}` `{act:反推}` `{in-type:参考视频}`,得到 `{out-type:提示词}` 和 `{out-type:负向提示词}`
+- ✅ 以 `{in-type:参考图}` 和上一张 `{in-type:分镜图}` 为参考,`{act:元素生成}` 当前 `{out-type:分镜图}`
+- ❌ `{act:反推}: {in-type:视频} → {out-type:提示词}`(写成了公式)
+- ❌ 得到正负 `{out-type:提示词}`(揉成一个了)
+
+### 怎么落盘
+
+逐字段填用 `wf-patch.py`(它边写边校验作用/动作/类型,有错整批不写):
+
+```bash
+python spec/tools/wf-patch.py --workflow outputs/case-N/workflow.json \
+    --set 'p1.s1.effect=预处理' \
+    --set 'p1.s1.action=提取/化学提取/反推' \
+    --set 'p1.s1.inputs[0].type=参考图' \
+    --set 'p1.s1.substance=人物、卧室场景' \
+    --set 'p1.s1.form=特写构图、低光影调' \
+    --set 'p1.type_registry.主角图.extends=参考图' \
+    --set 'p1.type_registry.主角图.desc=本案例的女主肖像'
+```
+
+几十处一起改,把清单写到 `_scratch/patch2.json`(`[{"path":...,"value":...}, ...]`)再 `--patch` 一次过。**不要写 Python 脚本去拼/改 workflow.json。**
+
+过关条件:每步的作用/动作都命中标准词、每个类型要么命中词表要么登记了挂靠、每步该有的实质/形式都提炼好了、每步都有目的列 → 进第三阶段。
+
+---
+
+## 完整 workflow.json 模板(第二阶段完成后)
+
+第一阶段已填字段以 `Phase 1` 注释标出;第二阶段新增字段带占位符。
+
+```json
+{
+  "source": {
+    "platform": "<Phase 1 填>",
+    "author":   "<Phase 1 填>",
+    "date":     "<Phase 1 填>",
+    "url":      "<Phase 1 填>",
+    "title":    "<Phase 1 填>",
+    "excerpt":  "<Phase 1 填>"
+  },
+  "procedures": [
+    {
+      "id":       "p1",
+      "name":     "<Phase 1 填>",
+      "purpose":  "<Phase 1 填>",
+      "category": "<Phase 1 填>",
+      "platform": "<Phase 1 填>",
+      "author":   "<Phase 1 填>",
+      "declarations": {
+        "inputs":    [{ "type": "<Phase 1 填>", "name": "<Phase 1 填>", "desc": "<Phase 1 填>" }],
+        "resources": [],
+        "returns":   { "type": "<Phase 1 填>" }
+      },
+      "type_registry": {
+        "主角图": { "extends": "参考图", "desc": "<填:case-specific 说明>" }
+      },
+      "steps": [
+        {
+          "id":        "s1",
+          "kind":      "step",
+          "effect":    "<填:作用词表叶子之一>",
+          "substance": "<填:作用域名词,如 人物、视觉风格,或 null>",
+          "form":      "<填:呈现维度名词,如 古风,或 null>",
+          "via":       "<Phase 1 填>",
+          "action":    "<填:动作路径 e.g. 提取/化学提取/反推>",
+          "directive": "<Phase 1 填>",
+          "inputs": [
+            {
+              "type":   "<填:归一化后类型>",
+              "value":  "<Phase 1 填>",
+              "anchor": "<Phase 1 填>"
+            }
+          ],
+          "outputs": [
+            {
+              "id":     "s1o1",
+              "type":   "<填:归一化后类型>",
+              "value":  "<Phase 1 填>",
+              "anchor": "<Phase 1 填>"
+            }
+          ],
+          "intent": "<填:目的列,e.g. 用 {via:X} {act:Y} 从 {in-type:Z} 得到 {out-type:W}>"
+        }
+      ]
+    },
+    {
+      "id":       "p2",
+      "name":     "<填:第二工序名 — 此 p2 示范控制块写法。单工序删整个 p2>",
+      "purpose":  "<填:一句话目的>",
+      "category": "<填:类别>",
+      "platform": "<填:平台>",
+      "author":   "<填:作者>",
+      "declarations": {
+        "inputs":    [{ "type": "<填:类型>", "name": "<填:声明名>" }],
+        "resources": [],
+        "returns":   { "type": "<填:返回类型>" }
+      },
+      "steps": [
+        {
+          "id":   "s1",
+          "kind": "block",
+          "via":  "-",
+          "intent":  "<填:控制块整体在做什么>",
+          "inputs":  [{ "type": "<填:类型>", "value": "<填>", "anchor": "<填:← 上游 id>" }],
+          "outputs": [{ "id": "s1o1", "type": "<填:类型>", "value": "<填:累积结果>", "anchor": "<填:→ sN>" }]
+        },
+        {
+          "id":    "s1.1",
+          "kind":  "nested",
+          "group": "s1",
+          "effect":    "<填:作用叶子>",
+          "substance": "<填:作用域名词或 null>",
+          "form":      "<填:作用域名词或 null>",
+          "via":       "<填:工具名>",
+          "action":    "<填:动作路径>",
+          "directive": "<填:给工具的 prompt (可选)>",
+          "inputs":  [{ "type": "<填:类型>", "value": "<填>", "anchor": "<填:← 被遍历序列 id[i]>" }],
+          "outputs": [{ "id": "s1.1o1", "type": "<填:类型>", "value": "<填:单次产出>", "anchor": "<填:→ 累积结果.追加>" }],
+          "intent": "<填:这一次迭代在做什么>"
+        }
+      ]
+    }
+  ]
+}
+```

+ 34 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/extraction/phase3-finalize.md

@@ -0,0 +1,34 @@
+## 第三阶段 · 检查 + 渲染
+
+> 前两阶段已经把内容都填进了 `workflow.json`,这里只做两件事:跑检查、渲染出网页。**产物只有网页(HTML)。**
+
+| 小步 | 做什么 |
+|------|------|
+| **3.1 检查 + 渲染** | 跑两个脚本:① `lint-case.py` 检查类型完整性、内容有没有偷懒写引用、**章节有没有整段漏抽、文本 value 是不是缩写**(后两条要带 `--source` 原文才比对),并自动登记新类型;② `render-case.py` 渲染出网页(脚本在内存里组装数据,不另存中间文件)。检查报问题就回对应阶段改(**章节漏抽 / value 缩写都要回 Phase 1 补**)。 |
+
+```bash
+# lint 一定要带 --source 原文 (+ --ocr 配图文本, 有就带) 才会跑「章节覆盖」+「value 逐字」两条结构/值强制
+python spec/tools/lint-case.py --workflow outputs/case-N/workflow.json --case-id N \
+    --source input/case-N.json --ocr outputs/case-N/_scratch/ocr.txt
+python spec/tools/render-case.py --workflow outputs/case-N/workflow.json \
+    --source-input input/case-N-raw.json --page-title "Case N · 主题" --case-id N \
+    --out outputs/case-N/case-N-<slug>.html
+```
+
+### 收尾检查清单
+
+下面每条都要过,过不去就回对应步骤改:
+
+| 检查 | 规则 | 反面例子 |
+|------|------|------|
+| **输出编号唯一** | 每个输出都有编号,且工序内不重复 | 两个输出都叫 `s2o1` |
+| **数据流连得上** | 每个输入的"来源"(`← 某编号`)都能找到已存在的输出编号(或 `← 工序输入`、字面量) | 引用了 `← s2o9` 但根本没有这个编号 |
+| **类型对得上** | 输入的类型,和它"来源"指向的那个输出的类型一致 | 输入写 `分镜图` 但来源那个输出是 `参考图` |
+| **循环索引合理** | `[i]`/`[-1]` 用在循环里或合理位置 | 不是循环却用了 `[i]` |
+| **"值"写真内容** | "值"写数据本身(文字写全文;图片视频用 `<整段描述>`);不写 `← sN` 引用、不写 `[图N显示]` 这种说明 | 值 = `← s1o1`;值 = `<描述>开头的` |
+| **作用/动作命中词表** | 作用是 9 个标准词之一;动作是动作词表里的路径 | 作用写成"开端"(词表里没有) |
+| **类型命中词表或挂靠** | 类型是词表里的词,或自造但在本工序 `type_registry` 里写了"算作"哪个标准词 | 类型写"小品"却没说挂靠 `视频成品` |
+| **自造类型登记齐全**(脚本自动查) | 跑 `lint-case.py`,若提示"类型完整性 N 个问题",说明有自造类型没在 `type_registry` 登记 / 缺挂靠 / 缺说明 → 回去补 | `s1 输出的 type='主角图' 是自造但没登记` |
+| **章节全覆盖**(脚本带 `--source` 查) | 原文每个 `0N` 章节都对应到某工序/步骤;lint 报"章节覆盖 X 个章节疑似漏抽"就是有整段没抽 → 回 Phase 1 补工序 | 原文"02 结构化框架""04 更多案例"整章没抽 |
+| **文本 value 逐字**(脚本带 `--source` 查) | 文本类 value 是原文一整段连续文本;lint 报"value 逐字 X 处疑似缩写"就是抄了开头后缩写 → 回 Phase 1 用 `@quote` 重填 | 350 字提示词被写成"主题:…。核心要素:人物、产品、环境" |
+| **"目的"列规范** | ≤25 字一句话;标记取自本行真实有的值;合法类别 5 个;别写成公式 | `{act:反推}: {in-type:视频} → {out-type:提示词}`(写成公式了) |

+ 268 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/format/case-data.schema.json

@@ -0,0 +1,268 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://procedure-dsl/spec/format/case-data.schema.json",
+  "title": "Procedure DSL · case_data",
+  "description": "Canonical structure consumed by spec/tools/renderer.build_html(case_data) -> str. 一篇 case 可含 1 ~ N 个独立工序 (procedures), 共享同一份 source 原帖. 单工序 case 也用 procedures 数组长度 1 的形式 — schema 不区分.",
+  "type": "object",
+  "required": [
+    "page_title",
+    "source",
+    "procedures"
+  ],
+  "additionalProperties": false,
+  "properties": {
+    "page_title": {
+      "type": "string",
+      "description": "HTML <title> + <h1>. e.g. 'Case 5 · 产品宣传图 AI 工作流可视化'"
+    },
+    "case_id": {
+      "type": ["integer", "string", "null"],
+      "description": "Optional case identifier shown in UI badge."
+    },
+    "source": {
+      "$ref": "#/$defs/Source",
+      "description": "case-level 原帖信息, 跨所有 procedures 共享 (一篇文章对应一份 source)."
+    },
+    "procedures": {
+      "type": "array",
+      "minItems": 1,
+      "description": "一篇 case 包含的工序数组. 多工序场景 (e.g. case-5 '简单工作流 + 进阶工作流 + 附加案例'). 单工序 case 也用长度 1 数组. 每个 procedure 独立含 declarations / steps / type_registry / return_row.",
+      "items": { "$ref": "#/$defs/Procedure" }
+    }
+  },
+
+  "$defs": {
+
+    "Procedure": {
+      "type": "object",
+      "description": "一个独立工序: 工序头部 (name/purpose/...) + declare 块 + 步骤 + 返回行. 一个 procedure 内的 type_registry 是该工序的 case-specific 类型扩展.",
+      "required": ["id", "name", "purpose", "category", "platform", "author", "declarations", "steps"],
+      "additionalProperties": false,
+      "properties": {
+        "id":       { "type": "string", "description": "procedure 局部 id (单 case 内唯一). e.g. 'p1' / 'p1-simple' / 'p2-advanced'." },
+        "name":     { "type": "string", "description": "工序名称, e.g. '简单工作流: 一步生成'." },
+        "purpose":  { "type": "string", "description": "One-sentence intent (该工序在做什么)." },
+        "category": { "type": "string", "description": "Top-level taxonomy, e.g. '产物创造'." },
+        "platform": { "type": "string", "description": "原帖来源 platform; 通常跟 case-level source.platform 一致." },
+        "author":   { "type": "string", "description": "原作者; 通常跟 case-level source.author 一致." },
+        "declarations": { "$ref": "#/$defs/Declarations" },
+        "type_registry": {
+          "type": "object",
+          "description": "Per-procedure case-specific type entries; 渲染期跟 spec/taxonomy/type.json 的 stdlib 叶子合并 — case 项覆盖 stdlib 项. 跨工序共享的 case-specific type 可在多个 procedure 各自声明 (允许冗余).",
+          "additionalProperties": { "$ref": "#/$defs/TypeRegistryEntry" }
+        },
+        "steps": {
+          "type": "array",
+          "description": "工序的步骤数组, 按执行顺序排. block/step 在顶层; nested 子项 kind:nested + group 指向父 block id.",
+          "items": { "$ref": "#/$defs/Step" }
+        },
+        "return_row": { "$ref": "#/$defs/ReturnRow" }
+      }
+    },
+
+    "Declarations": {
+      "type": "object",
+      "description": "工序的声明: declared inputs / resources / return type. 渲染成 declare 折叠块.",
+      "required": ["inputs", "resources", "returns"],
+      "additionalProperties": false,
+      "properties": {
+        "inputs": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/DeclareItem" },
+          "description": "Caller-provided inputs to the procedure."
+        },
+        "resources": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/DeclareItem" },
+          "description": "Long-lived resources (libraries, knowledge bases) read or written across runs."
+        },
+        "returns": { "$ref": "#/$defs/DeclareReturn" }
+      }
+    },
+
+    "DeclareItem": {
+      "type": "object",
+      "required": ["type", "name"],
+      "additionalProperties": false,
+      "properties": {
+        "type":    { "type": "string", "description": "Type leaf (§A.3) or case-extended type." },
+        "name":    { "type": "string" },
+        "default": { "type": "string", "description": "Optional default value/template." },
+        "desc":    { "type": "string", "description": "Optional human description." }
+      }
+    },
+
+    "DeclareReturn": {
+      "type": "object",
+      "required": ["type"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "note": { "type": "string", "description": "e.g. 'extends 视频成品 (序列关系)'." }
+      }
+    },
+
+    "Source": {
+      "type": "object",
+      "description": "case-level 原帖信息. 渲染成 foldable 折叠块 (default closed). 跨所有 procedures 共享.",
+      "required": ["platform", "author", "date", "title", "excerpt"],
+      "additionalProperties": false,
+      "properties": {
+        "platform":    { "type": "string" },
+        "author":      { "type": "string" },
+        "date":        { "type": "string", "description": "Free-text date, e.g. '2026 上半年'." },
+        "url":         { "type": ["string", "null"], "description": "原帖 URL; 没有干净 URL 时填 null 或省略 (不强制 uri 格式)." },
+        "title":       { "type": "string" },
+        "excerpt":     { "type": "string", "description": "1-2 sentence pipeline summary." },
+        "body_text":   { "type": "string", "description": "Optional full body of the source article." },
+        "cover_image": { "type": "string", "description": "Optional cover image URL." }
+      }
+    },
+
+    "TypeRegistryEntry": {
+      "type": "object",
+      "description": "Case-specific 类型定义 (procedures[*].type_registry). 桥接到一个 stdlib 叶子 + 人类可读描述.",
+      "required": ["extends", "desc"],
+      "additionalProperties": false,
+      "properties": {
+        "extends": { "type": "string", "description": "桥接到的 stdlib 叶子名 (spec/taxonomy/type.json $leaves 之一)." },
+        "desc":    { "type": "string", "description": "case-specific 类型的人类可读说明 (renderer drawer + type_suggestions 用)." }
+      }
+    },
+
+    "Step": {
+      "type": "object",
+      "description": "One row group in the 14-column procedure table. Renders to N <tr> where N = max(len(inputs), len(outputs), 1). effect/action MUST hit canonical leaves per spec §A; substance/form are STEP-LEVEL taxonomy paths (whole step's content, not per-IO). kind=block is a control container (effect/action/substance/form may be omitted; carries intent + via='-' + io); kind=nested/step are execution steps.",
+      "required": ["id", "kind", "via", "inputs", "outputs", "intent"],
+      "additionalProperties": false,
+      "properties": {
+        "id": {
+          "type": "string",
+          "pattern": "^s\\d+(\\.\\d+)*$",
+          "description": "Step id. Plain step / block / nested use 's' prefix (block children dotted, e.g. 's3.1')."
+        },
+        "kind": {
+          "enum": ["step", "block", "nested"],
+          "description": "step=plain; block=control-flow parent (grouping container); nested=child of a block."
+        },
+        "group": {
+          "type": "string",
+          "description": "When kind=nested, the parent block's id (e.g. 's3')."
+        },
+        "effect": {
+          "enum": [
+            "预处理", "主体生成", "装配", "后期",
+            "工艺规约", "预准备", "配套伴生", "检验", "交付"
+          ],
+          "description": "§A.1 L3 leaf. Required on step/nested — non-leaf = extraction error."
+        },
+        "substance": {
+          "type": ["string", "array", "null"],
+          "items": { "type": "string" },
+          "description": "STEP-LEVEL. What this step's content IS (理念 vs 表象 layer). Path from `分类库导出_实质_*.json`. Single path string, array of path strings, or null."
+        },
+        "form": {
+          "type": ["string", "array", "null"],
+          "items": { "type": "string" },
+          "description": "STEP-LEVEL. How this step's content is PRESENTED (呈现 vs 架构 layer). Path from `分类库导出_形式_*.json`. Single path string, array of path strings, or null."
+        },
+        "via": {
+          "type": "string",
+          "description": "Canonical L1 tool name (manus / nano_banana_pro / human / <llm-agent>) or generic placeholder for unspecified tools ('(AI 生图工具)'). Use '-' for control-flow blocks."
+        },
+        "action": {
+          "type": "string",
+          "pattern": "^[^/]+(/[^/]+)*$",
+          "description": "§A.2 path, e.g. '生成/元素生成', '提取/化学提取/反推'."
+        },
+        "directive": {
+          "type": "string",
+          "description": "字面 prompt 文本 — 真正喂给工具的 prompt 字符串. 人工/控制流 step 无 prompt 时省略或空串."
+        },
+        "inputs":  {
+          "type": "array",
+          "items": { "$ref": "#/$defs/IOItem" }
+        },
+        "outputs": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/IOItem" }
+        },
+        "intent": {
+          "type": "string",
+          "description": "≤ ~25 chars natural-language sentence. Tokens `{kind:value}` with kind ∈ {effect, via, act, in-type, out-type}. NEVER use variable-name tokens, NEVER use 'X: Y → Z' pseudo-code."
+        },
+        "focus": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "Column-cell keys to highlight on this row. Format: bare key for step-level cells ('via', 'action', 'effect', 'substance', 'form', 'idx', 'intent'), or '<col>-<io_index>' for IO cells ('in-type-0', 'out-type-1', etc.)."
+        }
+      },
+
+      "allOf": [
+        {
+          "if": { "properties": { "kind": { "enum": ["step", "nested"] } } },
+          "then": { "required": ["effect", "action"] }
+        },
+        {
+          "if": { "properties": { "kind": { "const": "nested" } } },
+          "then": { "required": ["group"] }
+        }
+      ]
+    },
+
+    "IOItem": {
+      "type": "object",
+      "description": "One row in inputs[] or outputs[]. Outputs carry an `id` (the 1:1 reference handle other steps point to); inputs reference an upstream output's id in their anchor.",
+      "required": ["type", "value", "anchor"],
+      "additionalProperties": false,
+      "properties": {
+        "id": {
+          "type": "string",
+          "description": "Short machine handle, unique within the procedure. REQUIRED on outputs (the reference target). Convention: 's<step>o<n>' (e.g. 's2o1'); single-output step may use 's<step>'. Inputs omit it. NOT a display column — surfaces only inside anchors."
+        },
+        "type": {
+          "type": "string",
+          "description": "§A.3 leaf or case-extended type name."
+        },
+        "value": {
+          "type": "string",
+          "description": "Content itself. Text data: full prose, no wrappers. Non-text data: '<整段描述>' (the whole description angle-bracketed). NEVER metadata ('[图 N 显示]', '原文: ...') or upstream refs ('← s2o1' — those go in anchor)."
+        },
+        "anchor": {
+          "type": "string",
+          "description": "Data-flow link by id. Inputs: upstream ref ('← 工序输入', '← s2o1' 指向某 output 的 id, '← s2o1[i]' 容器索引). Outputs: downstream destination ('→ s5', '→ 视频片段列表.追加', '→ 返回 X')."
+        },
+        "inferred": {
+          "type": "boolean",
+          "description": "True when this whole IO item was filled by 工艺补全 (not in source material). Renderer dyes the row's data-flow cells."
+        },
+        "inferred_reason": {
+          "type": "string",
+          "description": "REQUIRED when inferred=true. Shown on hover."
+        }
+      },
+      "if": {
+        "required": ["inferred"],
+        "properties": { "inferred": { "const": true } }
+      },
+      "then": { "required": ["inferred_reason"] }
+    },
+
+    "ReturnRow": {
+      "type": "object",
+      "description": "Bottom row of the table announcing what the procedure returns.",
+      "required": ["arrow", "text"],
+      "additionalProperties": false,
+      "properties": {
+        "arrow": {
+          "type": "string",
+          "description": "Usually '↩'."
+        },
+        "text": {
+          "type": "string",
+          "description": "May contain inline HTML fragments (chip / name / em spans) — emitted verbatim into a colspan cell."
+        }
+      }
+    }
+  }
+}

+ 402 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/action.json

@@ -0,0 +1,402 @@
+{
+  "$comment": "动作 字典树 (新结构: 获取/提取/生成/修改/存储). chip data-prefix=\"动作\". action 字段值用 根→叶 路径 (e.g. 提取/化学提取/解构). 单一来源, agent Phase 2 读. $control 是控制流类型 (与内容动作正交).",
+  "$kind": "taxonomy",
+  "$dimension": "动作",
+  "$field": "action",
+  "$path_separator": "/",
+  "$control": [
+    "并行",
+    "遍历",
+    "分支",
+    "请求",
+    "等待"
+  ],
+  "最终分类树": [
+    {
+      "分类名称": "获取",
+      "分类说明": "把已经存在的、完整的对象原样取来作为素材、参考或依据使用——动作本身不创造、不拆解、不改造对象。典型:检索 / 下载 / 调取 / 上传 / 拍摄 / 录音 / 键入 / 选取。判别口诀:输入与输出指向同一个完整对象(或对象集合),只是发生了'转移 / 引用',没有从内部抽出局部信息,也没有改变其形态。与'提取'的边界:获取拿到的是整体本身(如把一张参考图拿进工作流),提取拿到的是整体的派生物(如从这张图反推出文字 prompt)。与'生成'的边界:获取的对象是预先存在的,生成的对象是当前动作创造出来的新产物。\n\n【L2 切分维度】按'对象的来源位格'切分——搜索(工序外·公共域)/ 查询(工序外·私有·预存)/ 录入(工序外·私有·当场)/ 引用(工序内·自产)。一个获取动作的对象只可能位于这 4 个位置之一,MECE 完备。L2 本身只承担'来源位格'的目录定位,不作为 actions[] 取值。\n\n【L3 切分维度】每个 L2 内按'具体动作动词'切分。L3 节点直接作为 actions[] 字段的规范取值——本树即动作词典,不用'直接元素'承载同义词,所有近义动词在 L3 的分类说明中显式标注规范化目标。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "搜索",
+          "分类说明": "对象来自工序之外、面向公众开放的资源——互联网、公开素材库、搜索引擎、公共知识库、平台预设 / 模板、开源模型 / LoRA。判别:所有权 = 公共,任何人可访问。与'查询'的边界:搜索面向公共域,查询面向创作者 / 团队的私有积累(所有权差异)。与'录入'的边界:搜索是从公共域取来(创作者并未持有),录入是用户从自己手上当场提供。与'引用'的边界:搜索的对象来自工序外部,引用的对象来自工序内部的自产中间件。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "检索",
+              "分类说明": "输入查询 / 关键词 / 筛选条件 / 问题,从公共域里主动找出对象。涵盖:搜索引擎检索、库内关键词查询、对 LLM 的提问式知识获取、无明确 query 的浏览翻找(本质都是'在公共域里找')。与'下载'的边界:检索强调'查找定位'的过程(不知道精确位置),下载是'已知地址直接拿'(无需查询过程)。同义动作(写 actions[] 时统一规范为「检索」):查找 / 浏览 / 翻找 / 搜寻 / 问答式查询(对 LLM)。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "下载",
+              "分类说明": "已知精确地址(URL、ID、文件路径、API endpoint),直接获取指定对象。涵盖:URL 下载、API 拉取、网络抓取、按链接导入。与'检索'的边界:检索需要查询过程(不知精确位置),下载是已知位置直接取。与 L1'提取'的边界:下载是文件级整体获取(拿到完整对象本身),提取是从对象内部抽出局部 / 派生信息(拿到对象的一片或派生物)。同义动作(写 actions[] 时统一规范为「下载」):拉取 / 抓取 / 导入 / URL 拉取 / API 拉取。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "查询",
+          "分类说明": "对象来自工序之外、创作者或团队过去预存的私有资源——自建提示词库、个人素材库、团队成品库、自训风格 LoRA、参数预设、过去的成品。判别:所有权 = 私有 + 先于本次工序就已存在。与'搜索'的边界:查询走私有积累(创作者 / 团队专属),搜索走公共域。与'录入'的边界:查询的对象是预存的(先于本次工序),录入的对象是当下提供的(为本次工序专门接入)。与'引用'的边界:查询的对象在本次工序开始前就已存在于私库,引用的对象由本次工序内部前序步骤产出。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "调取",
+              "分类说明": "取用为复用目的而预存的'零件级'私有资产——这些资产以可被反复调取的单件形态存在。涵盖:自建 prompt 库的提示词、自训 LoRA / Embedding / 参数预设、私库素材(单件参考图 / 单段文案)、本地模型权重加载、个人收藏夹里的素材。与'检索'的边界:调取走的是已知地址直接拿(私库内单件资产有明确 ID / 路径),检索是面向公共域的查找定位过程。与 L1'修改'的边界:调取只是把已有资产'取来用'(对象不变),修改要在已有对象上做改动。同义动作(写 actions[] 时统一规范为「调取」):调用 / 复用 / 加载 / 引用(私库语境)/ 套用(私库模板 / 预设)。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "录入",
+          "分类说明": "对象来自用户 / 创作者在本次工序里当下提供的素材——为这次工序专门接入,不属于过去的预存积累。判别:素材的'接入时点'是本次工序开始或进行中(而非更早预存)。与'查询'的边界:录入是为本次工序专门提供(即使来自用户本地相册),查询取的是创作者过去就有的、可反复调用的私有库资产。与'搜索'的边界:来源是用户 / 创作者自己(私有),不是公共域。与'引用'的边界:录入是工序外的人为输入(用户从工序外送进来),引用是工序内的前序产物(DAG 内自循环)。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "上传",
+              "分类说明": "用户提供'工序前就已存在'的既有素材——从设备本地、剪贴板等已有内容里拿出来给到工序。涵盖:本地文件上传、拖拽文件、粘贴既有内容(剪贴板里复制好的图 / 文 / 段落)、从本地相册选图。与'拍摄 / 录音 / 键入'的边界:上传的素材在用户提供时已经存在(既有),非现场制作。与'选取'(引用-工序内)的边界:上传是从工序外送入既有素材,选取是从工序内前序候选中挑选。同义动作(写 actions[] 时统一规范为「上传」):导入(本地)/ 粘贴 / 拖拽 / 上送 / 文件添加。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "拍摄",
+              "分类说明": "用户在工序中现场视觉捕获或产出图像 / 视频素材。涵盖:实时拍照、现场录像、屏幕录制(录自己的操作演示)、手绘 / 速写 / 涂鸦草图(现场画的简笔图也算'现场视觉产出')。与'上传'的边界:拍摄是现场制作(不是从既有里拿),上传是从既有里拿。与'录音 / 键入'的边界:输出形态是图像 / 视频。同义动作(写 actions[] 时统一规范为「拍摄」):录像 / 录屏(自己的)/ 手绘。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "录音",
+              "分类说明": "用户现场进行音频捕获或产出。涵盖:现场录音、口述录入、即兴配音、现场录歌。与'拍摄 / 键入'的边界:输出形态是音频。与'上传'的边界:现场录制,非既有音频文件提供。同义动作(写 actions[] 时统一规范为「录音」):录口述 / 配音录制。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "键入",
+              "分类说明": "用户现场进行文本或结构化参数录入——通过键盘、滑块、表单、选项控件等向工序输入文本或参数。涵盖:打字输入 prompt / 描述 / 文案 / 标题、调参(拖滑块 / 选下拉 / 勾选项)、表单填写。与'拍摄 / 录音'的边界:输出形态是文本或结构化参数。与'上传'的边界:现场敲入,非粘贴既有大段文本(粘贴既有归 上传)。同义动作(写 actions[] 时统一规范为「键入」):输入 / 调参 / 填写。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "引用",
+          "分类说明": "对象来自同一工序内已完成步骤的输出,被后续步骤再次拿来使用——来源是 DAG 内部,对象是工序自产的中间产物或前序成品。判别:source_id 指向的是同一工序内某个 outputs[*].id(而不是 init_input_*)。与'搜索 / 查询 / 录入'的边界:前三者来源都是工序外部(外取),引用来源是工序自身(内引)。与 L1'提取'的边界:引用拿到的是前序输出的整体本身(原样转交),提取是从前序输出里抽出局部 / 派生信息。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "选取",
+              "分类说明": "从前序步骤产生的多个候选对象中挑选一个或若干个进入下一步。涵盖:从模型出的多张候选图里挑、从生成的多个 prompt 候选里选、从多个分镜方案里取一个、从批量生成结果里筛优。判别口诀:必有'多候选 → 挑选'的过程,候选来自同一工序内的前序步骤;如果只是无差别地引用前序唯一输出(无挑选),不归选取。与'调取'的边界:选取的对象来源是工序内自产的候选池(DAG 内),调取的对象来源是工序外的私有库。与'上传'的边界:选取从工序内候选池里挑,上传从工序外(设备本地 / 剪贴板)拿既有素材。同义动作(写 actions[] 时统一规范为「选取」):挑选 / 筛选 / 评选 / 优选 / 圈选(多候选场景)。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    },
+    {
+      "分类名称": "提取",
+      "分类说明": "从一个已有对象中抽出局部内容、属性、特征或派生信息——动作的输入是完整对象,输出是这个对象的'派生物'(描述、标签、文字、结构化数据、局部区域、轮廓、关键帧等)。典型:反推 / 识别 / OCR / 提取 / 抽取 / 分割 / 解析。判别口诀:输出在'形态'或'粒度'上不等同于输入——例如从图反推出文本 prompt(形态变了)、从图中识别物体名称(粒度变了)、从画面切出主体蒙版(粒度变了)。与'获取'的边界:获取得到的是整体本身,提取得到的是整体的派生物。与'修改'的边界:提取的输出与输入不是'同类对象的改前/改后',而是'整体与它的派生物'。\n\n【本质】提取是'信息由多到少'的压缩动作——从一个完整对象里减信息、得到一个更小的派生产物。这种压缩有两条不同的实现路径,对应 L2 切分。\n\n【L2 切分维度】按'输出与原对象的关系'切分——实体片段(输出是原对象的实体一片,能在原对象里指出来)/ 派生信息(输出是关于原对象的派生信息,原对象里指不出来)。一个提取动作的输出只可能位于这 2 类之一,MECE 完备。\n\n【L3 切分维度】每个 L2 内按'具体动作动词'切分。L3 节点直接作为 actions[] 字段的规范取值——本树即动作词典,不用'直接元素'承载同义词,所有近义动词在 L3 的分类说明中显式标注规范化目标。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "物理提取",
+          "分类说明": "输出是原对象的实体片段——能在原对象里直接指出'就是这一块',输出与输入同质且是其物理子集。判别口诀:能不能在原对象里圈出输出对应的那部分(地理 / 时间 / 结构上的子区域)?能 = 实体片段。与'派生信息'的边界:实体片段的输出是原对象的'一片'(同质、可指认),派生信息的输出是关于原对象的'描述 / 特征 / 分析'(异质、原对象里指不出来)。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "裁切",
+              "分类说明": "按几何区域、时间区间或位置索引等明确规则把原对象的某一块切出来——切的边界由规则定义,不依赖语义判断。涵盖:矩形 / 自由形状裁剪、按时间轴截取视频 / 音频片段、按段落 / 句子位置取文本片段。与'抠取'的边界:裁切的边界是规则定义的几何区域(不需要理解内容),抠取的边界是语义边界(需要识别对象轮廓)。与'抽帧'(时序场景)的边界:裁切按规则切'连续段',抽帧按重要性抽'离散关键点'。同义动作(写 actions[] 时统一规范为「裁切」):裁剪 / 截取片段 / 切片 / 取段。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "抠取",
+              "分类说明": "按语义边界把特定对象 / 组件从原对象中切出——切的边界由对象本身的形状 / 层次 / 结构决定,需要识别后再切。涵盖:抠图主体(按对象轮廓切)、人物 / 物体分割、拆 PSD 图层(按图层结构切)、demux 音视频轨道(按轨道结构切)。与'裁切'的边界:抠取按对象语义切,裁切按几何规则切。同义动作(写 actions[] 时统一规范为「抠取」):分割 / 抠图 / 拆图层 / 拆轨道。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "抽帧",
+              "分类说明": "从时序媒体(视频 / 音频 / 动画)中抽出离散的关键时刻或代表性片段——抽出来的每一帧 / 段都是原时序的一个截面。涵盖:视频关键帧、代表性时刻、典型片段、采样帧。与'裁切'(时间区间切)的边界:裁切是按规则切'连续段',抽帧是按重要性抽'离散关键点'。同义动作(写 actions[] 时统一规范为「抽帧」):取关键帧 / 采样 / 取代表帧。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "化学提取",
+          "分类说明": "输出是关于原对象的派生信息——原对象里直接指不出来,是从原对象推算 / 解读 / 提炼出来的派生产物。判别口诀:能不能在原对象里圈出输出对应的那部分?不能 = 派生信息。涵盖:表层显式信息的读取(识别)、深层隐含信息的推理(反推:风格 / 情感 / 意图 / 深度 / 姿态等)、内部组成方式的结构化拆解(解构:章节 / 框架 / 要点 / 摘要)。与'实体片段'的边界:派生信息的输出与输入异质(图 → 文 / 数 / 标签 / 表征图),不是原对象的物理子集。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "识别",
+              "分类说明": "读取原对象表层显式存在、可直接观察到的信息——把眼睛能看到、耳朵能听到的东西转成可处理的标签 / 文字 / 数据。涵盖:OCR 文字识别、物体识别(detection + classification)、人脸识别、场景识别、语音转文字、命名实体识别(NER)。与'反推'的边界:识别处理的是表层显式信息(信息就在眼前,只是换种形态),反推处理的是深层隐含信息(需要推理才能得到)。同义动作(写 actions[] 时统一规范为「识别」):检测 / OCR / 转录 / 标注。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "反推",
+              "分类说明": "推理出原对象深层隐含、不可直接观察的信息——需要'从结果推回原因 / 构成'的推理过程。涵盖两大类:(1)语义反推:反推 prompt、风格分析、情感分析、意图推断、创作手法推断;(2)结构表征反推:depth map / canny 边缘 / normal map / pose 骨架 / segmentation mask 等 ControlNet 类预处理(这些 map 都不在原图直接可见,是从 RGB 推算出来的隐含结构)。与'识别'的边界:识别读表层显式(图里的字、物体),反推推深层隐含(图的风格、深度、生成原因)。与'解构'的边界:反推推理'隐含的属性 / 原因 / 表征'(风格 / 情感 / depth 结构),解构分析'显式的内部组成方式'(层级 / 框架)。同义动作(写 actions[] 时统一规范为「反推」):推断 / 分析 / 预处理(ControlNet 类)/ 估计(深度 / 姿态)/ 风格分析 / 情感分析。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "解构",
+              "分类说明": "系统性地把原对象拆解成结构化的层级分析——输出是关于原对象内部组成方式的'结构描述',而不是原对象本身的物理碎片。涵盖:拆剧情结构(场景 / 冲突 / 转折)、拆构图层级(前景 / 中景 / 背景,三分法 / 对称)、拆文章章节框架(引言 / 论点 / 论据 / 结论)、拆角色构成(服饰 / 发型 / 姿态 / 表情)、内容浓缩与提炼(摘要 / 要点提炼 / 核心信息抽取)。与'抠取'的边界:抠取是物理拆分(输出是原对象的实体片段),解构是分析拆分(输出是关于原对象组成的描述)。与'反推'的边界:反推推理'隐含的属性 / 原因 / 表征'(风格 / 情感 / depth 结构),解构分析'显式的内部组成方式'(章节 / 层次 / 框架 / 要点)。同义动作(写 actions[] 时统一规范为「解构」):拆解(结构)/ 结构分析 / 框架分析 / 层级分析 / 摘要 / 要点提炼。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    },
+    {
+      "分类名称": "生成",
+      "分类说明": "从无到有产出新的对象——动作本身不依赖某个被改造的'前身'对象,仅依靠引导信息(如 prompt、参考、规则、知识)创造出之前不存在的产物。典型:生成 / 文生图 / 文生视频 / 合成 / 补全 / 生成草图 / 起草。判别口诀:输出是一个新对象(图、文本、视频、音频),其'实体'在输入中并不存在;输入即使含参考素材,参考也只是引导而非'被改造的本体'。与'修改'的边界:生成的输出没有可对应的'同一个对象的前一版本',修改的输出能与输入对应到'同一个对象的不同形态'。与'获取'的边界:生成的对象是当前动作产出的全新产物,获取的对象是预先就存在的。\n\n【本质】生成是'产生信息增量'的动作——给出一个之前不存在的新信息片段。\n\n【L2 切分维度】按'增量结果的形态'切分——元素生成(结果是一个自成一体的独立单元)/ 关系生成(结果是多项之间的明确关系结构)。两类 MECE 完备:要么是单件独立单元,要么是多项 + 关系。L2'元素生成'本身即叶子节点,不再向下切分;L2'关系生成'下再按关系几何形态切分 L3。\n\n【L3 切分维度】关系生成下按'关系的几何形态'切分——数组生成(一维线性顺序)/ 结构生成(非线性结构 / 共时并置)。两类 MECE 完备:要么是先后排列,要么是同时并置。\n\n【actions[] 规范取值】叶子节点(元素生成 / 数组生成 / 结构生成)直接作为 actions[] 字段的规范取值。本树即动作词典,所有近义动词在叶子分类说明中显式标注规范化目标。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "元素生成",
+          "分类说明": "生成结果是一个自成一体的单个对象——对外是一个独立单元,不论内部是简单还是复合。涵盖:文生图(一张图)/ 图生图(产出新的一张图)/ 文生视频片段(一段独立短片)/ 起草文案(一段独立文本)/ 起草脚本(一份独立脚本)/ 生成单个角色 / 生成 BGM / 生成单个模板 / 生成单条 prompt。判别口诀:结果能否被指称为'一个 X'(一张图 / 一段视频 / 一份文案 / 一条 prompt)?能即归此。与'数组生成 / 结构生成'的边界:元素生成的产物对外呈一个独立单元(即使内部含多部件也对外呈一个),关系生成的产物本质是'多项 + 关系结构'。同义动作(写 actions[] 时统一规范为「元素生成」):起草 / 写稿 / 文生图 / 文生视频(单段)/ 图生图(单张产出)/ 草图生成 / 单件生成 / 撰写。",
+          "直接元素": [],
+          "子分类": [],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "关系生成",
+          "分类说明": "生成结果是多项之间的明确关系结构——多个东西按某种顺序 / 结构关联在一起。判别口诀:结果是否本质上是'多项 + 关系'(一组镜头序列、一套配色方案、一幅多元素布置)?是 = 关系。与'原子'的边界:原子结果对外是一个独立单元,关系结果是多项的关联结构。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "数组生成",
+              "分类说明": "生成结果是多项的线性顺序——给一组项确定它们的先后排列。涵盖:剧本时序、镜头序列、步骤清单、台本顺序、视频片段时间线、分镜先后、大纲排序、章节排序、教程步骤排布。判别口诀:产物的核心信息是'若干项 + 一条单向时间 / 顺序轴',调换顺序会破坏产物意义。与'元素生成'的边界:数组生成必含多项 + 先后约束(关系是核心信息),元素生成只是单个对象(无内部先后关系)。与'结构生成'的边界:数组是一维顺序(先后排列),结构是非线性 / 共时并置。同义动作(写 actions[] 时统一规范为「数组生成」):编排 / 串联 / 排序 / 时序排布 / 序列生成 / 时间轴编排 / 章节排序 / 剧本编排。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "结构生成",
+              "分类说明": "生成结果是多项的非线性结构——多个项以某种结构关系(融合 / 配合 / 空间布置 / 层级叠加)组合在一起。涵盖:场景画面合成、配色方案、UI 布局、多元素拼贴、风格 + 内容搭配、图层组合、版式构图、信息图组装、多视图布置。判别口诀:产物的核心信息是'多项 + 共时并置 / 空间层级关系',去掉任一元素或挪位置会破坏整体。与'数组生成'的边界:结构是非线性 / 共时并置(同时呈现),数组是一维线性顺序(先后排列)。与'元素生成'的边界:结构生成必含'多项 + 组合关系',元素生成是单件独立单元。同义动作(写 actions[] 时统一规范为「结构生成」):合成 / 搭配 / 布置 / 组合 / 拼贴 / 布局生成 / 配色生成 / 图层组合生成 / 版式生成。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    },
+    {
+      "分类名称": "修改",
+      "分类说明": "在已有对象的基础上做改动——输入是一个待改造的本体对象,输出是它被改动后的版本,对象的身份保留但形态 / 属性 / 局部已发生变化。典型:编辑 / 局部重绘 / 上色 / 重打光 / 去背景 / 换脸 / 换装 / 修饰 / 增强 / 改写 / 翻译 / 风格化 / 转换 / 超分。判别口诀:能在输入与输出之间指认出'同一个对象'的'改前 / 改后'对应关系——超分前后是同一张图,改写前后是同一段文本。与'生成'的边界:修改有明确的'被改造本体',生成没有;若产物的主体来自输入对象的延续 → 修改,若产物的主体由动作新造 → 生成。与'提取'的边界:修改的输出仍是同类完整对象,提取的输出是输入的派生物(不同粒度或不同形态)。\n\n【L2 切分维度】按'改动性质'分 3 类——增(添加新的信息片段)/ 删(去除已有的信息片段)/ 变(改变已有部分:原位置上的替换 / 重述 / 风格化 / 格式转换 / 渐变调整 / 自动增强)。三类 MECE 完备:要么加新(信息总量增加)、要么去旧(信息总量减少)、要么原位置改变(信息总量基本不变,只是某部分变成不一样的)。\n\n【L3 切分维度】每个 L2 内按'具体动作动词'切分。L3 节点直接作为 actions[] 字段的规范取值——本树即动作词典,不用'直接元素'承载同义词,所有近义动词在 L3 的分类说明中显式标注规范化目标。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "增",
+          "分类说明": "添加新的信息片段到原对象上——原对象的现有部分保留,新内容 / 形态 / 装饰被叠加 / 嵌入进去。判别口诀:动作前后比较,原对象多了一些之前没有的信息。与'删'的边界:增是加新的(信息总量增加),删是去已有的(信息总量减少),方向相反。与'变'的边界:增是单纯加新(原位置不变,多出一份),变是在原位置上把已有部分改成不一样的(替换 / 渐变,无新增独立单元)。\n\n【L3 切分维度】按增加对象的层级切分——添加(substantive 内容元素的增)/ 叠加(form 形态层 / 装饰层的增)。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "添加",
+              "分类说明": "在原对象上增加新的语义内容元素——给画面新增一个主体 / 物体 / 角色,给文案新增一段内容,给视频新增一段片段,给脚本新增一段动作。判别口诀:动作前后比较,原对象多出了一个独立的语义内容元素,这个元素本身就是'被增加的内容'(有独立语义身份:人 / 物 / 段 / 镜)。与'叠加'的边界:添加加的是内容层(substantive,多出一个独立语义元素),叠加加的是形态层(form,多出一层装饰性 / 标识性 / 修饰性形态)。与'替换'的边界:添加只加不去(原对象不变,多出一份),替换是去旧 + 换新(一删一加)。与'增强'的边界:添加是主动放入新元素(创作者指定内容),增强是自动质量提升(无新增内容)。同义动作(写 actions[] 时统一规范为「添加」):新增 / 添入 / 加入 / 加段 / 加镜 / 加角色 / 加物体 / 补段 / 补镜。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "叠加",
+              "分类说明": "在原对象上叠加新的形态层 / 装饰层——给画面叠加水印 / logo / 装饰图层 / 边框、给视频叠加字幕轨 / 弹幕、给文档叠加页眉页脚、给图叠加蒙层 / 滤色层。判别口诀:动作前后比较,原对象多出了一层不属于'语义内容主体'的装饰性 / 标识性 / 修饰性形态。与'添加'的边界:叠加加的是形态层(装饰 / 标识 / 修饰,不构成独立语义内容),添加加的是内容层(独立语义元素:人 / 物 / 段 / 镜)。与'风格化'(变)的边界:叠加是在原对象上多出一层独立可见的形态元素(水印 / logo 是可指认的一层),风格化是对原对象整体外观做转换(无独立可指认的新层)。同义动作(写 actions[] 时统一规范为「叠加」):加水印 / 加 logo / 加图层 / 加装饰 / 加边框 / 加蒙层 / 加字幕轨 / overlay / 嵌入标识。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "删",
+          "分类说明": "去除原对象的某部分信息——原对象的其余部分保留,被去除的部分从对象中消失。判别口诀:动作前后比较,原对象少了一些之前有的信息(信息总量减少)。与'增'的边界:删是去已有的(少一份),增是加新的(多一份),方向相反。与'变'下'替换'的边界:删只去不补(原位置变空),替换是去掉再补上不同的(原位置仍有内容,只是换成了别的)。\n\n【L3 切分维度】按被删除信息的空间 / 时间维度切分——抹除(空间维度内的内容删除)/ 剪除(时间维度上的片段删除)。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "抹除",
+              "分类说明": "去除空间内的内容元素——把对象空间维度上的某部分内容擦掉 / 去掉。涵盖:去背景、去水印、去画面里的某个角色 / 物体、抹除画面瑕疵。与'剪除'的边界:抹除针对'空间内'的内容(同一画面 / 同一帧里的某部分),剪除针对'时间维度'的片段(视频 / 序列里的某段时间)。同义动作(写 actions[] 时统一规范为「抹除」):擦除 / 移除 / 抠除 / erase。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "剪除",
+              "分类说明": "剪掉时间维度的片段——把对象时间序列上的某段去掉。涵盖:剪掉视频中的一段、删除文章中的一节、截短音频、去掉序列中的某项。与'抹除'的边界:剪除针对'时间维度'的片段,抹除针对'空间内'的内容。同义动作(写 actions[] 时统一规范为「剪除」):剪 / 截短 / 删段 / cut。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "变",
+          "分类说明": "改变已有部分——原对象的内容 / 形态 / 属性 在原位置发生变化,无论是离散切换(替换:A → B)还是连续渐变(重述 / 风格化 / 转换 / 调整 / 增强)。判别口诀:动作前后比较,原对象的某部分'变成不一样的'(不是新增、不是删除,而是原位置改变);信息总量基本不变。与'增 / 删'的边界:变不改变信息总量(原位置改),增加新信息(多一份),删去除信息(少一份)。\n\n【L3 切分维度】按改变方式切分——替换(categorical 离散切换:A → B 实体不同)/ 重述(保原意改表达)/ 风格化(换审美 / 视觉风格)/ 转换(换技术形式 / 媒介 / 格式)/ 调整(continuous 手动微调属性)/ 增强(自动质量提升)。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "重述",
+              "分类说明": "实质改述——保留原意,改造表达 / 表述方式。涵盖:改写文段、翻译(中 → 英 / 英 → 中)、重构表述结构、转写口吻(书面 → 口语 / 正式 → 随意)、改述要点 / 段落润色。与'替换'的边界:重述保原意改表达(同一段意思的不同说法),替换是把整段内容主体换成不同的另一段(意思可不同)。与'风格化'的边界:重述改文字的'表达方式'(语种 / 用词 / 结构),风格化改文字 / 画面的'审美调性'(如转某文风、转某腔调、转视觉风格)。与'转换'的边界:重述走表达层(同语种内换说法 / 改写),转换走形式承载层(语种 / 格式 / 媒介整体切换)。同义动作(写 actions[] 时统一规范为「重述」):改写 / 翻译 / 重写 / 重构 / 改述 / 润色 / paraphrase / 转写口吻。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "风格化",
+              "分类说明": "换审美 / 视觉风格——同一对象在视觉风格上的转换,本体内容大致保留。涵盖:卡通化 / 油画化 / 写实化 / 水彩化、转赛博朋克风、转日漫风、文字转某文风。与'转换'的边界:风格化是'审美层面'的风格转换(卡通 / 油画 / 水彩等审美调性),转换是'技术形式'的格式 / 媒介 / 语种切换。同义动作(写 actions[] 时统一规范为「风格化」):风格转换 / 风格迁移 / stylize / style transfer。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "转换",
+              "分类说明": "换技术形式 / 媒介 / 格式 / 语种——同一对象在技术承载形式上的转换。涵盖:jpg → png(格式)、mp4 → gif(媒介格式)、横屏 → 竖屏(画幅形式)、PDF → Word(文档格式)、中 → 英(语种形式,作为形式转换而非内容改述时)、文 → 音(TTS 形式承载)。与'风格化'的边界:转换是'技术形式 / 格式'变化(容器变,呈现方式可能变),风格化是'审美 / 视觉风格'变化(容器不变,审美调性变)。与'重述'的边界:转换走形式承载层(语种 / 格式 / 媒介整体切换),重述走表达层(同语种内换说法)。与'调整'的边界:转换是 categorical 形式切换(jpg → png),调整是 continuous 属性微调(在原值附近滑动)。同义动作(写 actions[] 时统一规范为「转换」):转格式 / 转媒介 / 改格式 / convert / 转语种 / 转分辨率(格式语境)/ 转编码。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "替换",
+              "分类说明": "用不同的对象 / 元素 / 内容把原位置上的整个对应部分换掉——离散切换(categorical),新旧之间是不同的实体,不是渐变。涵盖:换脸 / 换装 / 换背景 / 换发型 / 换角色 / 换台词段 / 整段文案替换 / 整段镜头替换 / 换字体 / 换配色(指定枚举值)。判别口诀:动作前后比较,原位置上'这个东西'被换成'另一个东西'(A → B 的离散切换),不是 A 自身的渐变改造。与'重述'的边界:替换换内容主体(A 段换成 B 段,意思可不同),重述保原意改表达(同一段意思的不同说法)。与'调整'的边界:替换是 categorical 离散切换(A → B 实体不同),调整是 continuous 渐变(在原值附近滑动)。与'添加'的边界:替换是去旧 + 换新一并发生(原位置仍有内容,只是变了),添加是单纯加新不动旧。与'风格化'的边界:替换是某个具体元素被换成另一个具体元素(局部对象层),风格化是整体审美调性的切换(整图 / 整段层)。同义动作(写 actions[] 时统一规范为「替换」):换 / 替 / 换脸 / 换装 / 换背景 / 换角色 / replace / swap / 替代。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "调整",
+              "分类说明": "渐变属性值(手动微调)——属性值在原位置附近做连续 / 渐变性的微调。涵盖:调色 / 调亮 / 调对比 / 调饱和、调音量 / 调音色、调速度 / 调节奏、调透明度、调位置(局部微调)/ 调大小(局部微调)。与'替换'的边界:调整是 continuous 微调(在原值附近滑动),替换是 categorical 切换(A → B 离散值)。与'增强'的边界:调整是'手动渐变'(创作者主动指定方向 / 幅度),增强是'自动质量提升'(系统智能修复 / 提升,无创作者主观方向)。与'风格化'的边界:调整改的是低层属性数值(亮度 / 对比 / 速度),风格化改的是高层审美调性(卡通化 / 油画化)。同义动作(写 actions[] 时统一规范为「调整」):调 / 校 / 微调 / fine-tune / adjust / 校色 / 调音。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "增强",
+              "分类说明": "自动质量提升——系统智能算法对对象做自动质量改善,无需创作者指定具体方向 / 数值。涵盖:超分(提分辨率)、降噪、锐化、补帧、修复瑕疵 / 自动修图、画质增强、音频降噪 / 增强、人像自动美化、自动去模糊。判别口诀:算法自己决定怎么改、改多少(创作者只决定'要不要做'),且不增减语义内容(不改变'画面里有什么')。与'调整'的边界:增强是'自动算法'提升质量(创作者只按按钮),调整是'手动渐变'微调属性(创作者指定方向 / 幅度)。与'添加'的边界:增强是属性维度的整体质量提升(不增减语义内容),添加是新增独立语义元素(多出一个新对象)。与'替换'的边界:增强不改变对象身份(只是更清晰 / 更干净),替换是把对象换成不同的另一个。同义动作(写 actions[] 时统一规范为「增强」):超分 / 降噪 / 锐化 / 补帧 / 修复 / 画质提升 / enhance / 自动修图 / 一键美化。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    },
+    {
+      "分类名称": "存储",
+      "分类说明": "把当前持有的对象保存到某个存储位格,使其在未来可被再次取用——动作本身不创造、不改变对象,只是把对象的拷贝放到指定的存储位格。典型:保存 / 暂存 / 入库 / 沉淀 / 归档 / 留底。判别口诀:输入是工序内已有的对象,输出是'同一对象被放到了某个存储位格里'(对象本身不变,只是落地位置发生变化)。与'获取'的边界:存储是把工序内的对象往外送到存储位格(工序内 → 存储位格,写入侧),获取是从存储位格取回到工序内(存储位格 → 工序内,读取侧),二者方向相反。与'生成'的边界:存储不产生新对象(只是搬运现有对象的拷贝),生成产出之前不存在的新产物。与作用树'交付'的边界:交付是把产物推向终态对外(功能性归宿,发布意义),存储侧重把产物保留到可被未来调用的位置(持久化意义,可不对外)。\n\n【L2 切分维度】按'存储位格的位置 / 时效'切分——暂存(工序内·临时缓存,per-batch 用完即释)/ 沉淀(工序外·私有·长效,跨工序反复调用)/ 归档(工序外·长期封存,被动留底)。三种位格 MECE 完备,且与'获取'L2 形成对称关系(沉淀对应查询、暂存对应引用)。\n\n【L3 切分维度】每个 L2 内按'具体动作动词'切分。L3 节点直接作为 actions[] 字段的规范取值——本树即动作词典,不用'直接元素'承载同义词,所有近义动词在 L3 的分类说明中显式标注规范化目标。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "暂存",
+          "分类说明": "对象被存放在本次工序内 / 当前会话内的临时缓存——只在本次工序内可被再次引用,工序结束后不保证存活。判别:存放位置是工序自身的中间缓存,不是跨工序的持久化位格。与'沉淀'的边界:暂存是 per-batch / 本次工序内的临时态(用完即释),沉淀是跨工序的长效私库(可被未来工序反复调取)。与获取树'引用'的边界:暂存是'把对象放到工序内缓存里'(写入侧),引用是'从工序内缓存取出来用'(读取侧),二者配对完成 DAG 内自循环。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "缓存",
+              "分类说明": "把对象放到工序内 / 会话内的临时缓存里,供本次工序后续步骤复用。涵盖:节点输出缓存 / 中间产物保留 / 临时变量存放 / session 级 cache / 草稿暂存。判别:存放期限仅限本次工序 / 当前会话,工序结束后通常被释放。同义动作(写 actions[] 时统一规范为「缓存」):暂存 / 临时保存 / 草稿存 / cache / hold / 中间产物保存。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "沉淀",
+          "分类说明": "对象被存进创作者 / 团队的私有积累库,作为未来跨工序复用的资产。判别:存放位置是跨工序的长效私库(个人库 / 团队库 / 模板库),未来可被反复'查询.调取'取出。与'暂存'的边界:沉淀是跨工序长效(可被未来反复调用),暂存是本工序临时(用完即释)。与'归档'的边界:沉淀的目的是'供未来反复调用'(活跃资产 / 高频复用),归档的目的是'长期封存留底'(被动留底 / 低频访问)。与获取树'查询'的边界:沉淀是'把对象放进私库里'(写入侧),查询.调取是'从私库里取出来用'(读取侧),二者配对完成私库读写循环。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "入库",
+              "分类说明": "把对象作为私有零件级资产存进库里,供未来反复调用。涵盖:提示词入库 / 素材入库 / 模板入库 / 风格预设入库 / 参考图收藏入库 / 文案模板入库 / 镜头库入库 / LoRA 与 Embedding 等权重文件入库。判别:被存入的对象以'可被反复调取的单件形态'存在,目的是为下一次生产复用。与'存档'的边界:入库的对象是为'活跃复用'而存(高频调取),存档的对象是为'长期留底'而存(低频回溯)。同义动作(写 actions[] 时统一规范为「入库」):存入私库 / 收藏 / 加入收藏 / 模板沉淀 / 提示词沉淀 / 素材沉淀 / 加入素材库 / 权重入库。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "归档",
+          "分类说明": "对象被长期封存留底——目的不是供未来反复调用,而是保留历史记录 / 留底备查 / 合规存档 / 法定保留期。判别:存放位置是冷存储 / 历史归档,调用频率极低(被动访问 / 偶发回溯)。与'沉淀'的边界:归档强调'长期封存留底'(被动留底,访问稀少),沉淀强调'供未来反复调用'(活跃资产,频繁取用)。与作用树'交付'的边界:交付是把产物推到对外终态(功能完成意义),归档是把产物(无论是否已交付)存进历史档案(留底意义,可发生在已交付之后)。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "存档",
+              "分类说明": "把对象(含成片、底稿、流程配置、版本快照)存进长期档案中,留底备查。涵盖:项目归档 / 版本快照存档 / 成片冷存档 / 流程配置存档 / 历史版本封存 / 工程文件归档 / 工作流配置存档。判别:存储动作的目的是'留底'而非'复用'。同义动作(写 actions[] 时统一规范为「存档」):归档 / 封存 / 冷存 / 留底 / archive / 版本快照 / 工程归档。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    }
+  ],
+  "$leaves": [
+    "检索",
+    "下载",
+    "调取",
+    "上传",
+    "拍摄",
+    "录音",
+    "键入",
+    "选取",
+    "裁切",
+    "抠取",
+    "抽帧",
+    "识别",
+    "反推",
+    "解构",
+    "元素生成",
+    "数组生成",
+    "结构生成",
+    "添加",
+    "叠加",
+    "抹除",
+    "剪除",
+    "重述",
+    "风格化",
+    "转换",
+    "替换",
+    "调整",
+    "增强",
+    "缓存",
+    "入库",
+    "存档"
+  ]
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 69 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/effect.json


+ 21 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/feature.json

@@ -0,0 +1,21 @@
+{
+  "title": "特性 (执行特征 + 控制流)",
+  "desc": "随机/幂等/写外部/读外部/人工/本地; 控制流: 并行/遍历/分支/请求/等待",
+  "tree": {
+    "执行特征": {
+      "随机": "非确定性输出",
+      "幂等": "相同输入相同输出",
+      "人工": "由人执行",
+      "本地": "本地完成",
+      "写外部": "修改外部状态",
+      "读外部": "读取外部状态"
+    },
+    "控制流": {
+      "并行": "同时执行多分支",
+      "遍历": "对序列每项",
+      "分支": "条件分支",
+      "请求": "请求外部",
+      "等待": "等待外部"
+    }
+  }
+}

+ 562 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/type.json

@@ -0,0 +1,562 @@
+{
+  "$comment": "类型 字典树 (新结构, 按功能角色组织: 程序控制/数据复用/内容/知识). 单一来源, agent Phase 2 直接读. $leaves 由 最终分类树 终端节点自动派生 (scratch/build_taxonomy.py 生成), 供 lint-case.py 校验 + renderer in_tree 标记. case-specific 新 type 走 workflow.json 各 procedure 的 type_registry (extends 一个本表叶子).",
+  "$kind": "taxonomy",
+  "$dimension": "类型",
+  "$field": "type",
+  "最终分类树": [
+    {
+      "分类名称": "程序控制类型",
+      "分类说明": "L0 大类——这个产出物的作用是控制一次或多次内容产出'要做成什么、用什么参数、怎么评、按什么流程跑'。判别口诀:它本身不是内容、不参与组装产物,而是被某次加工参考来左右生成过程或评判结果的控制信号 / 自动化配置。下辖的指令 / 参数 / 评估 / 流程 都是程序控制的一种:指令直接指挥单个内容单元'要什么 / 不要什么 / 长什么样',参数设定生成 / 加工的技术配置或装载件,评估通过对已产出物的评判驱动修改或取舍,流程把多步加工编排成可被自动跑通的配置。与'内容类型'的边界:程序控制不构成交付内容本身,内容类型是被生产、被交付的内容实体。与'数据复用类型'的边界:程序控制服务于生产过程的执行控制,数据复用是被反复装载 / 填充的内容性资产。与'知识类型'的边界:程序控制是被执行的指令 / 配置(机械生效),知识是被理解 / 借鉴的认知储备。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "指令",
+          "分类说明": "作用类别——这个产出物会被一次生成 / 加工当作'要做成什么'的规约来执行,下游步骤照着它产出一个内容单元。判别口诀:它对应一次具体生成、一个内容单元,规定这一个单元的内容长什么样。与'编排'的边界:指令管单个生成单元的内容(这一个具体要什么),编排管多部分作品的骨架与顺序(整体分几块、什么顺序)。与'约束'的边界:指令规定'要什么内容'(内容侧),约束规定'结构落在哪个区域 / 骨架'(空间侧)。与'参照'的边界:指令是被读懂、被执行的文字规约,参照是被看着模仿的样本实体。与'复用'的边界:指令是为当次产出专门写的、用完即弃的规约,复用是跨任务反复取用的。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "提示词",
+              "分类说明": "向生成模型发出的正向提示词,作为模型调用的指令载体——可以是一条,也可以是成库的多条。与'负向提示词'的边界:方向相反(一个吸引、一个排斥),是独立的字段位。同义术语(写 category 时统一规范为「提示词」):prompt / 正向提示词 / positive prompt / 提示词库。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "负向提示词",
+              "分类说明": "向生成模型发出的负向约束词,告诉模型'不要出现什么'。与'提示词'的边界:方向相反(一个吸引、一个排斥),是独立的字段位,不要混入正向提示词。同义术语(写 category 时统一规范为「负向提示词」):negative prompt / 反向提示词。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "描述",
+              "分类说明": "对某一对象的文字刻画——以'描述'这个制作形式角色命名,被描述的具体对象(人物 / 场景 / 妆容 / 风格 / 构图 / 氛围 等内容实质)写入 value 字段,不进入叶子名。判别:是描述性文字,在工序里作为生成的内容依据被执行。与'提示词'的边界:描述是'被描述的对象内容',提示词是'喂给模型的指令'(描述可作为提示词的一部分,但其本身的角色是描述文本)。同义术语(写 category 时统一规范为「描述」):人物描述 / 场景描述 / 妆容描述 / 风格描述 / 运镜描述(若不带时序)。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "参数",
+          "分类说明": "作用类别——这个产出物是控制一次生成 / 加工'技术参数 / 工具运转方式'的配置。判别口诀:它不规定内容、不限定空间,而是设定模型 / 工具按什么参数跑、产出什么规格。与'指令'的边界:指令管'要什么内容',参数管'用什么参数生成'。与'约束'的边界:约束限定空间边界,参数设定技术配置。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "生成参数",
+              "分类说明": "一次生成 / 加工所用的模型运行参数——涵盖:采样步数 / CFG / 采样器 / 随机种子 seed / 去噪强度 / LLM 温度 等。被设定的具体目的由 value 承载。与'规格参数'的边界:生成参数是'模型怎么算',规格参数是'产出物什么格式'。同义术语(写 category 时统一规范为「生成参数」):采样参数 / 模型参数 / seed / 生成配置。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "规格参数",
+              "分类说明": "对产出物格式规格的设定——涵盖:分辨率 / 尺寸 / 画幅比例 / 时长 / 帧率 / 码率 / 文件格式 等。与'生成参数'的边界:规格参数是产出物的格式形态,生成参数是模型运行的算法参数。同义术语(写 category 时统一规范为「规格参数」):分辨率 / 画幅 / 比例 / 时长设定 / 输出规格。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "模型权重",
+              "分类说明": "训练或微调得到的模型权重文件——套用时加载到生成模型上,改变模型的生成行为。涵盖:LoRA / Embedding / checkpoint / Hypernetwork / DreamBooth。被定制的具体目标由 value 字段承载,不进入叶子名。同义术语(写 category 时统一规范为「模型权重」):LoRA / Embedding / checkpoint / 权重包。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "评估",
+          "分类说明": "作用类别——这个产出物是对另一个产出物的评判 / 意见,会被采纳来驱动对被评对象的修改或取舍。判别口诀:它一定指向一个已存在的产出物、对它下判断(好不好、改哪里、选哪个),而不是凭空规定要做什么。与'指令'的边界:指令是前置的、凭空规定要做什么的规约,评估是后置的、针对已有产出的评判(评估必然预设一个被评对象,指令不预设)。与'知识'的边界:评估针对某一个具体产出、一次性、驱动当次迭代,知识是跨任务沉淀的通用认知。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "评分",
+              "分类说明": "对某个产出物给出的定量评判——分数 / 等级 / 排名,用于驱动取舍(如从候选里挑高分的)。与'评语'的边界:评分是定量结论(数字 / 等级),评语是定性的文字意见。同义术语(写 category 时统一规范为「评分」):打分 / 评级 / 排名 / score。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "评语",
+              "分类说明": "对某个产出物给出的定性文字评判——指出问题、给出修改方向,用于驱动下一轮修改。涵盖:审核意见 / 修改意见 / 质检报告 / 选择理由 / 测评结论。与'评分'的边界:评语是定性文字意见,评分是定量结论。与'指令'的边界:评语针对一个已存在的产出物提改进意见,指令是凭空规定要做什么。同义术语(写 category 时统一规范为「评语」):评审意见 / 审核意见 / 修改意见 / 反馈意见 / 质检报告 / review。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "流程",
+          "分类说明": "作用类别——可反复套用的'流程':描述多个加工步骤如何串接,套用时驱动整条流水线运转。判别口诀:它是一套'套上去能自动跑完多步'的编排配置。与'结构'的边界:流程是会跑起来的多步编排,结构是一份静态骨架。与'编排(控制)'的边界:流程是'制作过程'的步骤配置(可反复套用),编排是某一次内容作品的骨架。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "工作流",
+              "分类说明": "单任务的多步流程编排——一套流程跑一份输入得一份输出。涵盖:ComfyUI workflow / 自动化 pipeline 配置 / 跨工具协作流程 / Agent 流程编排。与'批处理'的边界:工作流是单任务编排(一次跑一份输入),批处理是多任务批量执行(一次跑多份输入)。同义术语(写 category 时统一规范为「工作流」):workflow / pipeline / 节点图。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "批处理",
+              "分类说明": "同一流程跑多份输入的批量执行配置——套用时对一组输入按同一套流程处理,得到一组对应输出。涵盖:X/Y/Z plot / 批量生成脚本 / Grid 出图配置 / 批量调参方案。与'工作流'的边界:工作流是单任务的多步流程,批处理是多任务的批量执行。同义术语(写 category 时统一规范为「批处理」):batch / 批量任务 / X/Y/Z plot。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    },
+    {
+      "分类名称": "数据复用类型",
+      "分类说明": "L0 大类——这个产出物是跨任务沉淀下来、被反复装载或填充的'内容性资产':不服务于某一次具体产出,而是攒下来在很多次生产里反复被装载或填充。判别口诀:它的价值在'一次做好、多次复用',且自身是带内容性的实体(虚拟形象 / 视觉骨架 / 内容槽位),而非控制信号或评判。下辖的原子 / 序列 按结构形态切分:原子是不依赖串接的独立单件,序列是把多个槽位按顺序串接的有序骨架。与'程序控制类型'的边界:数据复用是带内容性的可装载 / 可填充资产,程序控制是左右生产过程的控制信号 / 配置。与'内容类型'的边界:数据复用是跨任务常备、反复取用的资产储备,内容是某一次产出的实体。与'知识类型'的边界:数据复用是被机械装上 / 填上即生效的资产,知识要被理解借鉴后才起作用。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "原子",
+          "分类说明": "作用类别——可反复套用的'单件原子级复用件':每一个本身就是一个完整的、不可再拆的复用单元,可独立装载到生成环节使用,不依赖与其他单元串接。判别口诀:装上 / 套上即用,不需要顺序衔接。与'序列'的边界:原子是单件不带顺序,序列是把多个槽位按顺序串接成连续骨架。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "数字人",
+              "分类说明": "一个可被反复驱动来产出口播 / 表演视频的完整虚拟人物形象——给它脚本 / 音频,它就产出该形象说话、表演的视频,套到不同脚本上产出不同视频、自身不变。与'模型权重'的边界:模型权重是加载进别的生成模型、改变其行为的增量权重(LoRA / Embedding / checkpoint),数字人是本身就能被驱动、独立产出视频的完整虚拟形象。与'视频成品(成品)'的边界:数字人是可复用的虚拟形象,某条数字人口播的具体成片是视频成品(value 写口播内容)。与'参考图(参照)'的边界:参考图是被静态模仿的图,数字人是被驱动产出视频的形象。同义术语(写 category 时统一规范为「数字人」):数字分身 / 虚拟人 / AI 分身 / 虚拟主播 / avatar / 口播数字人。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "版式",
+              "分类说明": "以视觉空间布局为核心的可复用骨架——套用时把内容填入预定义的空间位置。涵盖:海报版式 / PPT 版式 / 杂志版式 / 网页版式 / 信息图版式。与'模板'的边界:版式侧重视觉空间布局,模板侧重内容侧的可填充槽位。与'构图布局(约束)'的边界:构图布局是某一次具体内容的空间安排(一次性),版式是带占位槽位的可反复套用骨架。同义术语(写 category 时统一规范为「版式」):layout / 版面 / 排版骨架。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "序列",
+          "分类说明": "作用类别——可反复套用的'有序骨架':把多个槽位按顺序排成一条结构链,套用时按位置依次填入新内容,骨架本身不变。判别口诀:套用时需要按顺序填入多份内容,单点拆出来不成立。与'原子'的边界:序列由多个有序槽位组成,原子是单件不带顺序。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "模板",
+              "分类说明": "带可填充槽位的内容侧结构骨架——套用时把新内容填进对应槽位,骨架本身保持不变。涵盖:提示词模板 / 视频剪辑模板 / 设计模板 / 文档模板 / 分镜模板。被填充的具体内容类型由 value 字段承载,不进入叶子名。与'版式'的边界:模板侧重内容侧的可填充槽位(哪里填什么),版式侧重视觉空间布局(哪里放哪里)。同义术语(写 category 时统一规范为「模板」):template / 骨架 / 提示词模板 / 剪辑模板。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    },
+    {
+      "分类名称": "内容类型",
+      "分类说明": "L0 大类——这个产出物本身就是被生产、流转、最终交付的内容实体。判别口诀:它是产线主流上的'那个东西'本身,不是用来左右它的控制信号、也不是跨任务的复用资产。下辖的素材 / 半成品 / 准成品 / 成品 是同一份内容在生命周期不同阶段的形态:素材是被拼接 / 影响生成的零件态(含被原样剪入的物理变化、与参与改变生成性质的化学变化),半成品是还要被继续改造 / 筛选 / 换形态的在制态,准成品是已定稿但需嵌入或陪同更大成品的组件级终态,成品是独立完整、可作为最终交付物的终态。与'程序控制类型'的边界:内容是被加工的对象本身,程序控制是左右加工结果的信号。与'数据复用类型'的边界:内容是某一次产出的实体,数据复用是跨任务反复取用的资产储备。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "素材",
+          "分类说明": "作用类别——这个产出物会被组装 / 剪辑 / 合成进更大的产物,当作其中一个零件,自身形态基本不变。判别口诀:它是更大产物的一个现成零件,被拼接 / 混入而不是被改造。与'半成品'的边界:素材被拼装进产物(形态不变,只是被放进去),半成品是被继续改造的对象(形态会变)。与'参照'的边界:素材被'吃进'产物,参照只被'看'。与'成品'的边界:素材服务于下游组装,不是工序终点。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "化学变化",
+              "分类说明": "作用类别——这类素材在被引入工序时不直接成为产物的'内容零件',而是参与改变后续生成的'内容性质':可作为被生成器模仿的范本、框定生成结果的空间 / 运动边界、或叠加到产出物的呈现层。判别口诀:它本身不被剪入产物,但参与决定'生成出来长什么样'——类比化学反应,影响产物性质而非堆叠位置。与'物理变化'的边界:化学变化改变结果性质 / 行为,物理变化是把素材原样搬进产物。",
+              "直接元素": [],
+              "子分类": [
+                {
+                  "分类名称": "参考图",
+                  "分类说明": "被生成步骤看着模仿的图像——以'参考'这个用途角色命名,被参考的具体对象(人物 / 场景 / 风格 / 构图 / 姿态 等内容实质)写入 value 字段,不进入叶子名;可以是一张,也可以是成库的多张。判别:在工序里承担'范本'角色(自身形态不被改造,也不被拼进产物)。与'底图'的边界:参考图被引用为锚(自身不被改造),底图是后续编辑会落在其上改造的对象。与'控制图'的边界:参考图作软引导(影响风格 / 主体形象),控制图作强空间约束。同义术语(写 category 时统一规范为「参考图」):人物参考图 / 场景参考图 / 风格参考图 / ref / style ref / 参考图库。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "参考视频",
+                  "分类说明": "被生成步骤看着模仿的视频——常用于动作迁移、风格迁移、镜头模仿的输入。判别:在工序里承担'范本'角色(生成器看着模仿,自身不被改造、不被剪入产物)。与'视频片段(素材)'的边界:参考视频只被'看',视频片段会被剪入更大的视频产物。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "参考音频",
+                  "分类说明": "被生成步骤看着模仿的音频样本——影响音色 / 语气 / 唱腔 / 配乐风格,自身不被剪入产物。判别:在工序里承担'声音范本'角色(被模仿,不被混入成片)。与'参考视频'的边界:模态不同、作用相同,都是被模仿的范本。与'配音 / BGM(素材)'的边界:参考音频只被'听着模仿',配音 / BGM 是被混入成片的音轨。同义术语(写 category 时统一规范为「参考音频」):参考音色 / 音色样本 / 参考配乐 / voice reference。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "对标内容",
+                  "分类说明": "被整体研究、借鉴打法的成功案例作品——一条爆款视频 / 一篇高赞笔记 / 一个标杆案例,用来学它的整体结构、钩子与表达方式。判别:它是一个完整的成品案例,被当作'整体范本'来对标。与'参考图 / 参考视频'的边界:参考图 / 参考视频是低层模仿(风格 / 动作 / 镜头等局部特征),对标内容是高层借鉴(整篇作品的打法与结构)。与'知识.方法论'的边界:对标内容是那个案例本身(被模仿的样本),方法论是从一批案例里提炼出的做法规律(被理解的认知)。同义术语(写 category 时统一规范为「对标内容」):对标案例 / 标杆案例 / 爆款参考 / benchmark / 参考范例。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "分镜图",
+                  "分类说明": "脚本规划阶段画出的逐镜设定图——每张对应一个镜头,供后续拍摄 / 生成时看着还原。判别:在工序里作为镜头画面的'范本'被模仿。与'分镜脚本'的边界:分镜图是画出来的视觉设定,分镜脚本是文字形态的逐镜规划。与'关键帧(素材)'的边界:分镜图是规划阶段的镜头设定图,关键帧是已有 / 将有视频里某时刻抽出的帧。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "转场",
+                  "分类说明": "作用于两段内容之间衔接处的过渡效果配置——套用时定义前一段结尾到后一段开头的过渡方式。涵盖:淡入淡出 / 推拉 / 擦除 / 闪白 / 模糊过渡 / 缩放过渡。与'特效'的边界:转场作用在段间衔接,特效作用在段内。与'转场片段(素材)'的边界:转场片段是已渲染出的实际视频,转场是可反复套用的过渡效果配置。同义术语(写 category 时统一规范为「转场」):transition / 过渡效果。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "蒙版",
+                  "分类说明": "标记图像中可编辑 / 受保护区域的黑白图——承载'哪里能改、哪里不能改'的空间信息。与'控制图'的边界:蒙版给的是区域划分(哪块能改),控制图给的是结构骨架。同义术语(写 category 时统一规范为「蒙版」):mask / 遮罩。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "控制图",
+                  "分类说明": "用于约束生成模型空间结构的图——涵盖:pose 骨架图 / canny 边缘图 / depth 深度图 / normal 法线图 等。与'蒙版'的边界:控制图给的是结构骨架(pose / 边缘 / 深度),蒙版给的是可编辑 / 受保护的区域划分。同义术语(写 category 时统一规范为「控制图」):ControlNet 输入 / 结构图。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "运动轨迹",
+                  "分类说明": "约束画面主体或镜头随时间运动路径的输入——给生成模型一条轨迹 / 运动笔刷,限定'谁、往哪、怎么动'。判别:它约束的是运动路径(空间位置随时间变化),而非单帧的静态结构。与'控制图'的边界:控制图约束单帧的静态结构骨架(pose / 边缘 / 深度),运动轨迹约束的是跨帧的运动路径。同义术语(写 category 时统一规范为「运动轨迹」):运动笔刷 / motion brush / 运镜轨迹 / 轨迹控制 / motion path。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "滤镜",
+                  "分类说明": "作用于内容呈现层的静态效果配置——套用时改变颜色 / 质感 / 氛围 / 视觉风格 / 音色,呈现层定型不动。涵盖:风格滤镜 / LUT / 调色方案 / 美颜方案 / 音色滤镜 / EQ。与'特效'的边界:滤镜是静态效果(套上不动),特效是动态效果(套上动起来)。同义术语(写 category 时统一规范为「滤镜」):filter / LUT / 调色预设 / 风格化方案。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "构图布局",
+                  "分类说明": "对画面中多元素空间位置 / 大小 / 主次关系的安排方案——作为后续生成的空间约束被遵守,以布局结构为核心信息,不强调元素本身的实例化细节。与'版式(复用·结构)'的边界:构图布局是某一次具体内容的空间安排(一次性产物),版式是带占位槽位的可反复套用的布局骨架。同义术语(写 category 时统一规范为「构图布局」):layout / 构图 / 布局方案。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                }
+              ],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "物理变化",
+              "分类说明": "作用类别——这类素材会被原样'搬进'产物:被截取 / 剪入 / 混入到更大的成品中,作为它的物质组成,自身形态基本不变。判别口诀:它就是产物的一块现成零件,被堆叠 / 拼接 / 混合——类比物理变化,只移动位置不改性质。涵盖图像 / 视频 / 音频 各模态的现成零件。与'化学变化'的边界:物理变化是被堆进产物的物质本体,化学变化是参与决定生成结果的影响信号。",
+              "直接元素": [],
+              "子分类": [
+                {
+                  "分类名称": "截图",
+                  "分类说明": "从屏幕、视频或公共内容中截取得到的图像——作为素材被投入后续加工 / 组装。与'底图 / 成品图'的边界:截图来源于现成呈现的截取,不是工序生成或精修产出。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "视频片段",
+                  "分类说明": "作为素材被剪入更大视频产物的视频——一个镜头、一个动作演示、一段独立画面;可以是一段,也可以是成库的多段(镜头库)。与'参考视频'的边界:视频片段会被剪入产物,参考视频只被看着模仿。同义术语(写 category 时统一规范为「视频片段」):clip / 单镜 / 镜头库 / 素材镜头。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "转场片段",
+                  "分类说明": "用于连接前后两个镜头的过渡视频——本身是一段视频,会被剪入更大视频产物的衔接处。与'转场(复用·效果)'的边界:转场片段是已渲染出的实际视频,转场是可反复套用的过渡效果配置。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "关键帧",
+                  "分类说明": "视频 / 动画中具有代表性的单帧画面——作为静止图像被抽出、供后续步骤取用。与'分镜图'的边界:关键帧是已有 / 将有视频里某时刻抽出的帧,分镜图是规划阶段画的镜头设定图。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "音效",
+                  "分类说明": "用于点缀 / 强调的声音效果——作为音轨素材被混入视频产物;可以是一条,也可以是成库的多条(音效库)。同义术语(写 category 时统一规范为「音效」):sound effect / SFX / 音效库。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "特效",
+                  "分类说明": "作用于单段内容内部的动态效果配置——套用时叠加时间维度上的变化(动画、粒子、变形、闪烁、回声、混响等)。涵盖:粒子特效 / 动画特效 / 变形特效 / 慢动作 / 快放 / 音频混响。与'滤镜'的边界:滤镜套上不动,特效套上动起来。与'转场'的边界:特效作用在单段内容内部,转场作用在两段内容之间。同义术语(写 category 时统一规范为「特效」):effect / VFX / 动态效果。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                }
+              ],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "半成品",
+          "分类说明": "作用类别——这个产出物是工序中产出、还要被后续步骤继续加工 / 改造 / 筛选的中间态内容,尚未定稿。判别口诀:它处在'还要被改 / 被挑'的状态,后续步骤会落在它身上把它推向最终形态。与'素材'的边界:半成品会被改造(形态会变),素材是被原样拼装进产物的零件(形态不变)。与'成品'的边界:半成品是可被进一步处置的中间态,成品是不再改动、直接交付的终态。与'参照'的边界:半成品被改造,参照只被看、不被动。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "序列",
+              "分类说明": "作用类别——这类半成品本身是一份'有序的多段文字骨架'(章节 / 场 / 镜 / 时间轴 / 段落),尚未定稿,还要被后续步骤继续展开、拍摄、生成、剪辑或录音渲染。判别口诀:内部能数出有序的多个部分(章 / 场 / 镜 / 段),且整段还在被处置——按它去拍、去生、去剪、去读。与'半成品·原子'的边界:序列是有序的多段文字骨架,原子是独立的视觉中间件。与'半成品·组合'的边界:序列是文字层面的顺序骨架,组合是多层 / 多块拼合的视觉工作文件。",
+              "直接元素": [],
+              "子分类": [
+                {
+                  "分类名称": "大纲",
+                  "分类说明": "按章节 / 段落 / 议题组织的纲要文字——以条目化层级列出主体内容的骨架,指挥后续展开创作。涵盖:文章大纲 / 视频大纲 / 教程大纲 / 章节纲要 / 内容提纲 / 演讲提纲。判别:只列骨架要点不展开。与'脚本'的边界:大纲只列骨架要点,脚本展开具体叙事 / 动作 / 对白。与'分镜脚本'的边界:大纲是内容层骨架(章节 / 议题),分镜脚本是制作层骨架(镜头 / 画面)。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "脚本",
+                  "分类说明": "按时间 / 情节 / 操作顺序组织的有序文本——包含若干场景 / 段落 / 动作 / 对白等元素,指挥后续拍摄 / 生成。被组织的具体内容(剧情对白 / 动作流程 / 操作步骤 / 教程演示 等内容实质)写入 value 字段,不进入叶子名。与'分镜脚本'的边界:脚本以叙事 / 动作 / 操作为基本单位,分镜脚本以镜头切分为基本单位。与'大纲'的边界:脚本展开具体叙事 / 动作 / 对白,大纲只列骨架要点。同义术语(写 category 时统一规范为「脚本」):剧本 / 文本脚本 / 动作脚本 / 操作脚本 / 教程脚本。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "分镜脚本",
+                  "分类说明": "按镜头组织的拍摄 / 生成规划文本——每个镜头有编号、画面、动作、运镜、时长等,指挥后续逐镜拍摄 / 生成。涵盖运镜描述(作为分镜脚本中每个镜头的子字段而非独立产物)。与'脚本'的边界:分镜脚本以镜头为基本单位、包含视觉化细节,脚本以叙事 / 动作 / 操作为基本单位。与'分镜图'的边界:分镜脚本是文字形态的逐镜规划,分镜图是画出来的逐镜设定图。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "剪辑脚本",
+                  "分类说明": "成片剪辑阶段的时间轴编排——规定'用哪段素材、按什么顺序、各自的入出点与时长'拼成最终视频。判别:它是面向'已有素材如何组装成片'的骨架,处在剪辑环节。与'分镜脚本'的边界:分镜脚本是拍摄 / 生成前的镜头规划(要产出哪些镜头),剪辑脚本是把已有素材组装成片的时间轴安排。与'工作流(复用·流程)'的边界:剪辑脚本是这一条片子的具体编排,工作流是可反复套用的制作流程配置。同义术语(写 category 时统一规范为「剪辑脚本」):剪辑表 / 时间轴 / timeline / 卡点表。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "配音文案",
+                  "分类说明": "配音 / 解说内容的文字形态——承载'要被读出来'的语义内容,是配音的在制态:经配音 / 录音步骤渲染成音频。与'配音(素材)'的边界:配音文案是文字形态的在制内容,配音是已渲染出的音频音轨。与'脚本(编排)'的边界:配音文案是单纯的旁白 / 解说文本本身,脚本是带分镜 / 动作 / 台词的多段落骨架。与'正文(成品)'的边界:正文直接以文字交付、即为终态,配音文案要再被渲染成音频才到终态。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                }
+              ],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "原子",
+              "分类说明": "作用类别——这类半成品本身是一份独立的、形态完整的视觉中间件(一张图 / 一段视频 / 一组分镜),尚未定稿,等着被后续步骤继续编辑 / 挑选 / 合成推向最终形态。判别口诀:它是一份独立单件的视觉中间产物,单点拿出来就能看,但还要被处置。与'半成品·序列'的边界:原子是独立的视觉中间件,序列是有序的多段文字骨架。与'半成品·组合'的边界:原子是单一中间件,组合是多层 / 多块拼装而成的可编辑整体。",
+              "直接元素": [],
+              "子分类": [
+                {
+                  "分类名称": "底图",
+                  "分类说明": "作为后续编辑(局部重绘、风格化、叠加)基础的图像——判别口诀:身份后续会被保留并继续改造的对象。与'成品图'的边界:底图是会被继续改造的中间态,成品图是不再改动的终态。与'参考图'的边界:底图是后续编辑会落在其上改造的对象,参考图只被引用为锚、自身不被改造。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "样图",
+                  "分类说明": "工序中产出、用于挑选或对照的候选图——常用于'从候选里挑选'的场景;可以是一张候选,也可以是一批候选(候选图集)。与'成品图'的边界:样图是待挑选 / 待定的候选态,成品图是定稿态。同义术语(写 category 时统一规范为「样图」):候选图 / draft / 候选图集。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "分镜视频",
+                  "分类说明": "按镜头顺序排出的多段视频——尚未做最终合成,作为粗剪半成品等待后续合成 / 调整。与'视频成品'的边界:分镜视频是未做最终合成的镜头序列(待加工),视频成品是已合成交付的整片。与'视频片段(素材)'的边界:分镜视频是面向最终成片的粗剪半成品,视频片段是被剪进来的零件。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                }
+              ],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "组合",
+              "分类说明": "作用类别——这类半成品由多个子元素按图层 / 格子拼装而成的可编辑整体,每个子元素仍可辨识 / 可调整,未拍平定稿。判别口诀:它内部能看出多层 / 多块的拼合结构,且整体还能被继续改动(换层、挪位、调比例)。与'半成品·序列'的边界:组合是空间维度的拼合(多层 / 多块),序列是文字层面的顺序骨架。与'半成品·原子'的边界:组合是多层 / 多块拼装而成,原子是单一独立的视觉中间件。与'成品·合成图'的边界:组合保留子元素的可辨识 / 可调整性(半成品态),合成图是已拍平 / 已融合定稿的成品。",
+              "直接元素": [],
+              "子分类": [
+                {
+                  "分类名称": "图层组合",
+                  "分类说明": "多个图层按层级叠加形成的可编辑整体——每层有独立内容,常见于设计稿、PSD 文件、合成模板,作为可继续调整的工作文件。与'合成图(成品)'的边界:图层组合保留分层、可继续改动(半成品),合成图是已拍平交付的成品。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                },
+                {
+                  "分类名称": "拼图",
+                  "分类说明": "将多个素材格子化并置在一张图上的图像——多元素同时呈现、各自仍可辨认,作为整体交付。被并置的具体对象(多视角 / 多变体 / 多角度 / 多人物 / 对比 等内容实质)写入 value 字段,不进入叶子名。与'合成图'的边界:拼图强调并置式格子布局,合成图强调融合 / 叠加成新画面。同义术语(写 category 时统一规范为「拼图」):九宫格 / 对比图 / collage。",
+                  "直接元素": [],
+                  "子分类": [],
+                  "分类性质": "内容"
+                }
+              ],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "准成品",
+          "分类说明": "作用类别——这类产出物本身已是定稿的'组件级终态'(不再被改造 / 筛选),但相比成品粒度更小,通常作为更大成品的组件被搭载或陪同交付。判别口诀:它自己已经定稿,但单独交付往往不是完整作品,要嵌进或陪同更大的载体。与'半成品'的边界:准成品已定稿不再被改 / 筛选,半成品还要被继续加工 / 换形态。与'成品'的边界:成品是独立完整的最终交付物,准成品是单模态的组件级定稿、常嵌入或陪同更大的成品。与'素材'的边界:素材是通用、可候选取用的现成零件(往往多份备选),准成品是为这一次作品专门定稿好的组件(单一确定件)。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "歌词",
+              "分类说明": "歌曲内容的文字形态(词作)——承载'要被唱出来'的内容,是歌曲的在制态:经谱曲 / 演唱渲染成歌曲音频。与'配音文案'的边界:歌词被唱(终态是歌曲),配音文案被读(终态是配音)。与'正文(成品)'的边界:正文直接以文字交付即为终态,歌词要再被渲染成歌曲音频。同义术语(写 category 时统一规范为「歌词」):词 / lyrics / 歌词文案。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "配音",
+              "分类说明": "为视频 / 内容配上的人声音频——作为音轨素材被混入视频产物,与 BGM / 音效 同为音轨。与'配音文案'的边界:配音文案是文字形态的在制内容,配音是已读出的音频音轨。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "BGM",
+              "分类说明": "作为背景音乐的音频——作为音轨素材被混入视频产物;可以是一段,也可以是成库的多段(BGM库)。同义术语(写 category 时统一规范为「BGM」):背景音乐 / bgm / BGM库。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "字幕",
+              "分类说明": "覆盖在视频画面上的文字内容——作为视频成品的呈现层组成被交付。与'配音文案'的边界:字幕是呈现在画面上的文字,配音文案是被读出的文字稿。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "标题",
+              "分类说明": "作品 / 内容的标题文本——简短文字,用于命名 / 概括 / 引流。涵盖:视频标题 / 文章标题 / 笔记标题 / 章节标题 等。与'正文'的边界:标题是高度浓缩的命名 / 概括文字(通常一行),正文是展开陈述的主体文字(多段连续)。与'描述(指令)'的边界:标题是被交付的终态文字,描述是喂给生成的依据。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "正文",
+              "分类说明": "作品 / 内容的主体文字部分——较长的连续陈述文本,承载完整叙述 / 说明 / 论述,作为终态文字被交付。涵盖:文章正文 / 帖子正文 / 公众号文章主体 / 笔记正文 / 长文案。与'标题'的边界:正文是展开陈述的主体(通常多段),标题是浓缩命名(通常一行)。与'脚本(编排)'的边界:正文是被交付的终态文字本身,脚本是指挥后续拍摄 / 生成的规约。与'配音文案(半成品)'的边界:正文直接以文字交付、即为终态,配音文案要再被渲染成音频才到终态。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        },
+        {
+          "分类名称": "成品",
+          "分类说明": "作用类别——这个产出物会被作为终态内容交付 / 呈现给受众,不再进入后续加工。判别口诀:它是工序链路的终点,被受众消费或作为最终交付物。与'半成品'的边界:成品定稿不再改,半成品还要被继续处置。与'素材'的边界:成品是工序终点,素材是给下游组装当零件。",
+          "直接元素": [],
+          "子分类": [
+            {
+              "分类名称": "成品图",
+              "分类说明": "作为终态内容交付的图像——处于工序末端、不再被继续改造;可以是一张,也可以是成组交付的多张(成品图集 / 图集)。被汇集时的具体对象(多角度 / 多视角 / 多变体 等内容实质)写入 value 字段。与'底图 / 样图'的边界:成品图是定稿态,底图 / 样图是仍会被改造 / 挑选的中间态。同义术语(写 category 时统一规范为「成品图」):成品图集 / 图集 / 定稿图。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "视频成品",
+              "分类说明": "经过分镜剪辑、合成、配音后形成的完整视频——作为终态内容整体交付。与'分镜视频(半成品)'的边界:视频成品是已合成交付的整片,分镜视频是未做最终合成的粗剪半成品。与'视频片段(素材)'的边界:视频成品是交付的整片,视频片段是被剪进来的零件。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            },
+            {
+              "分类名称": "合成图",
+              "分类说明": "将多张素材融合为一张的图像——元素之间有空间叠加 / 拼接 / 融合关系,已拍平为一张交付。与'图层组合(半成品)'的边界:合成图是已拍平交付的成品,图层组合保留分层、可继续改动。与'拼图'的边界:合成图强调融合 / 叠加成新画面(看不出独立单元),拼图强调格子化并置(各元素仍可辨)。同义术语(写 category 时统一规范为「合成图」):融合图 / 拼合图。",
+              "直接元素": [],
+              "子分类": [],
+              "分类性质": "内容"
+            }
+          ],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    },
+    {
+      "分类名称": "知识类型",
+      "分类说明": "L0 大类——这个产出物是沉淀下来、跨任务复用的认知性内容,会被检索 / 学习 / 借鉴,为未来的生产提供认知依据。判别口诀:它不服务于某一次具体产出,而是攒下来反复查、反复用的认知储备;不会被装载 / 填充 / 剪入产物,而是要被理解后才起作用。与'数据复用类型'的边界:数据复用是可机械装载 / 填充的资产实体(装上即生效),知识要被理解 / 借鉴后才起作用。与'程序控制·指令'的边界:指令是为当次产出专门写的、用完即弃的规约,知识是跨任务沉淀、反复复用的。与'程序控制·评估'的边界:知识是跨任务的通用认知,评估是针对某一个具体产出的一次性评判。与'内容类型'的边界:知识不是被生产 / 交付的内容实体,而是支撑生产的认知背景。",
+      "直接元素": [],
+      "子分类": [
+        {
+          "分类名称": "知识库",
+          "分类说明": "由知识条目(事实 / 概念 / 规则 / 操作要点)汇集而成的资料库——沉淀下来供检索 / 引用 / 喂给模型作背景,是陈述性知识的复用储备。与'方法论'的边界:知识库是'是什么'的事实 / 概念资料,方法论是'怎么做'的做法经验。同义术语(写 category 时统一规范为「知识库」):knowledge base / 资料库 / 背景资料。",
+          "直接元素": [],
+          "子分类": [],
+          "分类性质": "内容"
+        }
+      ],
+      "分类性质": "内容"
+    }
+  ],
+  "$leaves": [
+    "提示词",
+    "负向提示词",
+    "描述",
+    "生成参数",
+    "规格参数",
+    "模型权重",
+    "评分",
+    "评语",
+    "工作流",
+    "批处理",
+    "数字人",
+    "版式",
+    "模板",
+    "参考图",
+    "参考视频",
+    "参考音频",
+    "对标内容",
+    "分镜图",
+    "转场",
+    "蒙版",
+    "控制图",
+    "运动轨迹",
+    "滤镜",
+    "构图布局",
+    "截图",
+    "视频片段",
+    "转场片段",
+    "关键帧",
+    "音效",
+    "特效",
+    "大纲",
+    "脚本",
+    "分镜脚本",
+    "剪辑脚本",
+    "配音文案",
+    "底图",
+    "样图",
+    "分镜视频",
+    "图层组合",
+    "拼图",
+    "歌词",
+    "配音",
+    "BGM",
+    "字幕",
+    "标题",
+    "正文",
+    "成品图",
+    "视频成品",
+    "合成图",
+    "知识库"
+  ]
+}

+ 383 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/taxonomy/type_suggestions.md

@@ -0,0 +1,383 @@
+# Type 字典扩展建议 · Agent 跑 case 时累积
+
+> **这个文件由 Agent 在 Phase 2 类型归一时自动 append**, 不要手工编辑 (除非 spec 维护者在做"审阅 → 合并到 type.json" 的工作).
+>
+> 何时写: Phase 2 把 case-specific 新类型挂靠进 `type_registry`(extends 桥到标准叶子)后, 第三阶段跑 `lint-case.py` 会**自动 record** 一条到下面的「累积条目」段(工具代劳, 不用手写)。
+>
+> spec 维护者怎么用: 定期扫这个文件, 看哪些新类型在多个 case 反复出现 / 语义独立性强 — 这些值得升级到 `type.json` 的 `$leaves` (顶级叶子) 或长驻 `registry` (跨 case extends 库).
+
+---
+
+## 升级判断准则 (维护者用)
+
+| 信号 | 应该升级 |
+|---|---|
+| 同一新类型在 ≥3 个不同 case 出现 | ✅ 升 `$leaves` (说明跨 case 通用) |
+| 单 case 出现但语义跟 `$leaves` 无重叠 + 内容创作领域常用 | ✅ 升 `registry` (跨 case extends 库) |
+| 跟某 `$leaves` 是同义异写 (e.g. "人物图" vs 已有 "参考图") | ❌ 别升, 在 spec 提一句"X 直接归到 leaf Y" |
+| 只是单 case 的窄场景 (e.g. "苏晚的肖像图") | ❌ 留在该 case 的 `case_type_registry`, 不升 |
+
+---
+
+## 累积条目
+
+> Format: `- \`<新类型名>\`: <一句描述>  (来自 case-{N}, extends \`<leaf>\`, 出现位置: step.{io_kind}[{i}])`
+> 同一 case 同名类型只 append 一条.
+
+- `主角图`: case-specific 主角肖像图, 后续作为人物参考图链的源头  (来自 case-1, extends `参考图`)
+- `剧本`: 完整剧集本/分集剧情, 含人物关系与情节冲突  (来自 case-2, extends `脚本`)
+- `题材`: 短剧创作的题材/类型方向 (含人设标签 + 爽点元素)  (来自 case-2, extends `描述`)
+- `人物设定`: 短剧角色形象与气质设定 (外貌/性格/气场描述)  (来自 case-2, extends `描述`)
+- `生成积分`: AI 工具的每日配额/积分计数 (用量管理用)  (来自 case-7, extends `?` ⚠ 字典缺"数值/配额"类叶子)
+- `安装状态`: App 安装就绪的前置状态标识  (来自 case-7, extends `?` ⚠ 字典缺"状态/前提"类叶子)
+- `产品场景图`: AI文生图工作流中生成的产品应用场景创意图候选集  (来自 case-new_pat, extends `样图`, 出现位置: step.outputs[0], step.inputs[0])
+- `背景素材`: 专门生成的无主体氛围背景图,用于后期叠加文字/产品的合成底层  (来自 case-new_pat, extends `底图`, 出现位置: step.outputs[0])
+- `方法论文档`: 跨case可复用的AI生成操作框架/Prompt编写方法论(如5W故事化法/摄影分层法)  (来自 case-new_pat, extends `模板`, 出现位置: resources[0])
+
+- `设计需求`: 产品功能、材质、品牌调性、视觉要求等具体项目需求描述文本  (来自 case-new_pat, extends `描述`)
+- `品牌素材`: 品牌Logo等品牌视觉标识素材  (来自 case-new_pat, extends `参考图`)
+- `文本描述`: 产品宣传文案,含标语、功能描述、场景标签等排版文字元素  (来自 case-new_pat, extends `正文`)
+- `产品宣传图`: 经Photoshop精修、添加Logo和文字排版后的最终可用产品宣传图  (来自 case-new_pat, extends `成品图`)
+- `试穿场景图`: AI生成的模特穿着产品的多场景展示图,用于海报版式合成  (来自 case-new_pat, extends `样图`)
+- `产品宣传海报`: 多素材拼接合成的产品宣传版式海报(背景+产品图+试穿图+文案)  (来自 case-new_pat, extends `合成图`)
+
+- `场景图备选集`: 3款AI工具并行生成的吸湿排汗背心应用场景候选图集合  (来自 case-new—str, extends `样图`)
+- `调性背景图`: 冲锋衣宣传海报用极简写实背景图,无人物无产品,哑光光效,大面积留白,供合成叠加使用  (来自 case-new—str, extends `样图`)
+- `试穿场景图`: AI生成的冲锋衣应用场景人物试穿图(单场景单张)  (来自 case-new—str, extends `样图`)
+- `试穿场景图集`: 露营/城市生活/徒步三场景的冲锋衣试穿图集合  (来自 case-new—str, extends `样图`)
+- `海报文案`: 宣传海报上的品牌tagline与产品功能描述文字(由外部提供,非工序内生成)  (来自 case-new—str, extends `正文`)
+- `产品创意图`: 智能纺织品SEM/光纤光学风格微观创意图,体现科技感与可持续发展理念  (来自 case-new—str, extends `成品图`)
+- `功能示意图`: 冲锋衣面料结构防水透湿功能半剖面3D示意图,颜色编码+箭头可视化功能流向  (来自 case-new—str, extends `成品图`)
+
+- `场景图备选集`: 3款AI工具并行生成的吸湿排汗背心应用场景候选图集合  (来自 case-5, extends `样图`)
+- `调性背景图`: 冲锋衣宣传海报用极简写实背景图,无人物无产品,哑光光效,大面积留白,供合成叠加使用  (来自 case-5, extends `样图`)
+- `试穿场景图`: AI生成的冲锋衣应用场景人物试穿图(单场景单张)  (来自 case-5, extends `样图`)
+- `试穿场景图集`: 露营/城市生活/徒步三场景的冲锋衣试穿图集合  (来自 case-5, extends `样图`)
+- `海报文案`: 宣传海报上的品牌tagline与产品功能描述文字(由外部提供,非工序内生成)  (来自 case-5, extends `正文`)
+- `产品创意图`: 智能纺织品SEM/光纤光学风格微观创意图,体现科技感与可持续发展理念  (来自 case-5, extends `成品图`)
+- `功能示意图`: 冲锋衣面料结构防水透湿功能半剖面3D示意图,颜色编码+箭头可视化功能流向  (来自 case-5, extends `成品图`)
+
+- `产品平铺图`: 产品正反面白底平铺图,用于海报合成的主视觉元素  (来自 case-new_dir, extends `参考图`)
+- `试穿场景图`: AI生成的模特穿着产品的单场景试穿候选图  (来自 case-new_dir, extends `样图`)
+- `试穿场景图集`: 多场景试穿图的集合(露营/生活/徒步三场景)  (来自 case-new_dir, extends `样图`)
+- `产品创意图`: 智能纺织品科学可视化风格创意图,体现科技感与可持续发展理念  (来自 case-new_dir, extends `成品图`)
+- `功能示意图`: 产品功能半剖视3D示意图,颜色编码+箭头可视化功能流向  (来自 case-new_dir, extends `成品图`)
+
+- `需求简述`: 冲锋衣功能示意图的设计需求:防水透湿三层结构+3D剖面图风格  (来自 case-my_test, extends `描述`)
+- `场景描述列表`: 多个使用场景的名称/描述列表,如['露营','生活','徒步']  (来自 case-my_test, extends `大纲`)
+- `场景描述`: 单个使用场景的文字描述,用于引导单次试穿图生成  (来自 case-my_test, extends `描述`)
+- `试穿场景图`: AI生成的模特穿着产品的单场景候选图  (来自 case-my_test, extends `样图`)
+- `海报文案`: 海报用品牌文案(tagline + 产品功能描述文字,由品牌方提供)  (来自 case-my_test, extends `正文`)
+- `产品创意图`: 智能纺织品SEM/光纤光学风格科技展示创意图,以伪彩色纤维可视化为核心  (来自 case-my_test, extends `成品图`)
+- `功能示意图`: 冲锋衣防水透湿功能半剖面3D示意图,颜色编码+箭头可视化功能流向  (来自 case-my_test, extends `成品图`)
+
+- `需求简述`: 冲锋衣功能示意图的设计需求:防水透湿三层结构+3D剖面图风格  (来自 case-?, extends `描述`)
+- `场景描述列表`: 多个使用场景的名称/描述列表,如['露营','生活','徒步']  (来自 case-?, extends `大纲`)
+- `场景描述`: 单个使用场景的文字描述,用于引导单次试穿图生成  (来自 case-?, extends `描述`)
+- `试穿场景图`: AI生成的模特穿着产品的单场景候选图  (来自 case-?, extends `样图`)
+- `海报文案`: 海报用品牌文案(tagline + 产品功能描述文字,由品牌方提供)  (来自 case-?, extends `正文`)
+- `产品创意图`: 智能纺织品SEM/光纤光学风格科技展示创意图,以伪彩色纤维可视化为核心  (来自 case-?, extends `成品图`)
+- `功能示意图`: 冲锋衣防水透湿功能半剖面3D示意图,颜色编码+箭头可视化功能流向  (来自 case-?, extends `成品图`)
+
+- `调色预设`: 在百度网盘AI修图中保存的调色参数套装(色温/饱和度/色调等),可跨次调用复刻同款色调风格  (来自 case-?, extends `滤镜`)
+
+- `账户权限`: 使用AI生图功能的前提状态——账户已登录、功能已解锁、算力充足  (来自 case-14, extends `描述`, 出现位置: p1.s1.inputs[0], p2.s1.inputs[0])
+- `小程序界面`: AI生图小程序的UI界面屏幕状态(主界面及各子页面)  (来自 case-14, extends `描述`, 出现位置: p1.s1.outputs[0], p2.s1.outputs[0])
+- `模板库界面`: 模板参考库UI界面(含行业筛选、缩略图网格、应用提示词按钮)  (来自 case-14, extends `描述`, 出现位置: p1.s2.outputs[0])
+- `门店信息`: 用户门店专属信息(店铺名称、活动内容、时间、优惠规则等需填入海报的要素)  (来自 case-14, extends `描述`, 出现位置: p1.s5.inputs[1])
+- `设计意图`: 用户对海报的完整自定义设计意图(尺寸/比例、风格、文案、画面元素、特殊需求)  (来自 case-14, extends `描述`, 出现位置: p2.s2.inputs[0])
+
+- `账户权限`: 使用优客赢文字生图功能的前提:账户已登录、功能已解锁、当前算力充足  (来自 case-case_14, extends `描述`)
+- `小程序界面`: 优客赢小程序文字生成图功能的UI界面状态  (来自 case-case_14, extends `描述`)
+- `模板库界面`: 优客赢模板参考库的UI界面:含行业分类筛选、模板缩略图网格、应用提示词按钮  (来自 case-case_14, extends `描述`)
+- `门店信息`: 用户门店专属信息:店铺名称、活动内容、时间、优惠规则等需填入海报的信息  (来自 case-case_14, extends `描述`)
+- `设计意图`: 用户对海报的完整设计意图:尺寸/比例、整体风格、文案内容、画面元素、特殊需求  (来自 case-case_14, extends `描述`)
+
+- `写真图集`: AI生成的主题人像写真图片集合,多张统一主题写真图构成一套交付物  (来自 case-6, extends `成品图`, 出现位置: p1-p6.s2.outputs[0], s3.inputs[0], s3.outputs[0])
+
+- `写真图集`: AI生成的主题人像写真图片集合,多张统一主题写真图构成一套交付物  (来自 case-case_6, extends `成品图`)
+
+- `写真图集`: 豆包AI批量生成的九张氛围感写真照片集合,同一角色不同姿态动作,2:3竖版比例  (来自 case-case_10, extends `成品图`)
+
+- `背景图`: 冲锋衣宣传海报用极简写实背景图,无人物无产品,哑光光效,渐层构图,供合成叠加使用  (来自 case-case_5, extends `底图`)
+- `场景设定列表`: 要生成试穿图的场景名称列表,如[露营, 城市生活, 徒步]  (来自 case-case_5, extends `大纲`)
+- `场景试穿图列表`: 遍历各场景生成的产品试穿图集合(露营/城市生活/徒步三场景)  (来自 case-case_5, extends `样图`)
+- `场景试穿图`: AI基于产品参考图生成的单场景产品试穿候选图  (来自 case-case_5, extends `样图`)
+- `宣传海报`: 背景图+产品参考图+试穿图+文案合成的最终冲锋衣宣传海报  (来自 case-case_5, extends `合成图`)
+- `创意图`: 智能纺织品SEM/光纤光学风格科学可视化创意图,体现科技感与可持续发展理念  (来自 case-case_5, extends `成品图`)
+- `功能示意图`: 冲锋衣防水透湿功能半剖面3D示意图,颜色编码+箭头可视化功能流向  (来自 case-case_5, extends `成品图`)
+
+- `背景图`: 冲锋衣宣传海报用极简写实背景图,无人物无产品,哑光光效,渐层构图,供合成叠加使用  (来自 case-5, extends `底图`)
+- `场景设定列表`: 要生成试穿图的场景名称列表,如[露营, 城市生活, 徒步]  (来自 case-5, extends `大纲`)
+- `场景试穿图列表`: 遍历各场景生成的产品试穿图集合(露营/城市生活/徒步三场景)  (来自 case-5, extends `样图`)
+- `场景试穿图`: AI基于产品参考图生成的单场景产品试穿候选图  (来自 case-5, extends `样图`)
+- `宣传海报`: 背景图+产品参考图+试穿图+文案合成的最终冲锋衣宣传海报  (来自 case-5, extends `合成图`)
+- `创意图`: 智能纺织品SEM/光纤光学风格科学可视化创意图,体现科技感与可持续发展理念  (来自 case-5, extends `成品图`)
+
+- `工具配置`: 对多个候选工具的评估筛选结论,包含选定工具名称及推荐理由,作为后续生成步骤的工具选型依据  (来自 case-case_13, extends `评语`)
+
+- `工具配置`: 对所选AI工具的名称、核心特点及推荐理由的文字描述,作为后续生成步骤中使用该工具的选型依据  (来自 case-case_13_test, extends `描述`)
+- `海报方案`: AI生成的海报候选设计方案,包含标题文字、图片元素、配色方案、版式排版等完整设计内容,经人工筛选后进入后续编辑阶段  (来自 case-case_13_test, extends `样图`)
+
+- `参考图集`: 多张同风格参考图构成的图组,是参考图的集合形态,功能角色与参考图相同(被生成步骤模仿,不被剪入产物)。  (来自 case-eval_2, extends `参考图`)
+- `约束条件清单`: 从参考图集中解构提炼的结构性约束条件列表,涵盖构图、视角、空间关系、人物等维度,作为后续风格结构模块生成的输入依据。  (来自 case-eval_2, extends `描述`)
+- `共性特征描述`: 从多张参考图中反推出的跨图共性风格特征描述,涵盖色彩、构图、人物气质、材质、光线等维度,是对隐含风格属性的推断性文字汇总。  (来自 case-eval_2, extends `描述`)
+- `风格结构模块`: 多维度并置的风格框架文档,按整体风格定位、色彩系统、空间与构图、人物与动作风格、材质与质感、光线与氛围等模块组织,是生成最终提示词的结构化中间输入。  (来自 case-eval_2, extends `描述`)
+
+- `创意需求`: 视频创作方向与业务目标的文字刻画,如品牌氛围片主题、探店场景、企业 B-Roll 等,含主体类型、场景、风格大致方向,作为撰写六要素 Prompt 的起始输入  (来自 case-eval_4, extends `描述`)
+- `参数集`: 用于 Best-of-5 并行试稿的多个随机种子序列(如 [42, 123, 456, 789, 1024]),一次传入 5 个 seed 驱动 5 路并发生成,是批量生成参数的集合形态  (来自 case-eval_4, extends `生成参数`)
+- `候选视频`: HappyHorse T2V 在 720P 规格下并行生成的多条候选视频(每条对应一个 seed),用于人工对比筛选最优 seed,属于待挑选的候选中间态,未进入定稿流程  (来自 case-eval_4, extends `样图`)
+- `定稿视频`: HappyHorse T2V 以 1080P 规格、锁定最优 seed 生成的定稿原片,尚未经过字幕、BGM、片头尾等后期处理,将作为剪映后处理的主体素材被组装进最终成片  (来自 case-eval_4, extends `视频片段`)
+
+- `视觉风格JSON`: 从参考图通过反推提取的结构化视觉风格描述(JSON格式),包含色彩、构图、排版、主体、材质质感、光影、特效、画面氛围及复刻关键词等字段,作为后续图像生成步骤的内容依据被执行。  (来自 case-A_gzh_8f5fbfb0, extends `描述`)
+- `分镜分析`: 通过对参考视频逐分镜解构得到的结构化分镜分析文本,每个分镜包含画面风格、主体动作、镜头运动、构图、光影、台词、音效、画质参数及格式化生成提示词,作为逐镜视频生成步骤的内容依据被执行。  (来自 case-A_gzh_8f5fbfb0, extends `描述`)
+
+- `需求文本`: 用户以自然语言描述的内容创作需求,如人物外貌、场景、服装、姿态等要素的综合描述,作为生成提示词的原始输入  (来自 case-A_gzh_d2b3f06f, extends `描述`)
+- `写真图`: AI文生图工序产出的人物写真照片,包含人物外貌、场景、服装等完整画面,可作为终态交付或后续换脸步骤的输入  (来自 case-A_gzh_d2b3f06f, extends `成品图`)
+- `换脸写真图`: 将写真图中的人物面貌替换为参考人像后得到的写真图片,是写真图的换脸变体,面部已替换为用户指定的参考形象  (来自 case-A_gzh_d2b3f06f, extends `成品图`)
+- `图片文件`: 保存至本地设备的图片文件,是写真图经保存操作后落地于本地存储的终态交付物  (来自 case-A_gzh_d2b3f06f, extends `成品图`)
+- `短视频`: 时长较短的视频内容,既可作为参考视频被截图模仿风格,也可作为AI图转视频生成的最终短视频成品  (来自 case-A_gzh_d2b3f06f, extends `视频成品`)
+
+- `分析报告`: 对视频内容进行逐镜结构化解析后生成的分析性文档,包含镜头语言、叙事焦点、环境氛围、光影风格、声音设计等多维度拆解与AI提示词输出  (来自 case-B_gzh_4a948bbb, extends `正文`)
+- `提示词序列`: 按分镜顺序排列的多条AI视频生成提示词,每条独立对应一个镜头,可逐条喂给生成模型以重建原视频各镜头  (来自 case-B_gzh_4a948bbb, extends `提示词`)
+- `视频序列`: 按分镜顺序排列的多个AI生成视频片段的有序列表,与原视频各镜头一一对应,等待后续对比评估或合成  (来自 case-B_gzh_4a948bbb, extends `分镜视频`)
+
+- `生成图`: AI生成模型直接输出的图像,在本工序中作为图生视频步骤的参考帧输入,承担视频内容风格与画面构成的锚定作用。  (来自 case-C_gzh_789c9e49, extends `成品图`)
+- `需求说明`: 用户对本次内容生产的具体要求说明,包含主题、目标受众、风格偏好、平台、关键元素等信息,用于指导后续提示词构造。  (来自 case-C_gzh_789c9e49, extends `描述`)
+- `文案`: 面向社交媒体平台(如小红书)的种草/营销文案,包含标题、正文亮点和标签,作为终态文字内容直接交付。  (来自 case-C_gzh_789c9e49, extends `正文`)
+- `海报`: AI生成的节日/营销主题海报,包含文字、图案、色彩等完整视觉设计,适配社交媒体传播规格,作为终态图像内容交付。  (来自 case-C_gzh_789c9e49, extends `成品图`)
+
+- `视觉风格JSON`: 多模态AI从参考图片中反推提取的JSON格式视觉风格结构,包含颜色/构图/光影/材质/氛围等字段,作为生图工具的结构化风格指令  (来自 case-B_gzh_8f5fbfb0, extends `描述`)
+- `分镜序列`: 由多模态AI对参考视频按分镜拆解后生成的结构化文本序列,每个分镜条目包含画面风格/主体/动作/运镜/光影/声音/画质参数及完整生成提示词,用于逐镜驱动视频生成工具复刻  (来自 case-B_gzh_8f5fbfb0, extends `分镜脚本`)
+
+- `URL`: 平台页面URL地址,用于指定参考视频的获取来源(如微信视频号/抖音等)  (来自 case-A_gzh_c1c709a5, extends `描述`)
+- `分析报告`: AI视觉语言模型对视频的结构化反推分析,包含分镜描述(画面风格/主体/动作/镜头/台词/音效/画质)及可复用提示词  (来自 case-A_gzh_c1c709a5, extends `评语`)
+- `工具列表`: 视频反推AI工具的清单列表,包含工具名、链接、收费情况、特点等  (来自 case-A_gzh_c1c709a5, extends `描述`)
+
+- `仿制商业海报`: AI文生图工具(如即梦)依据提示词生成的仿制商业海报候选图,风格与参考图高度近似,通常批量出图后人工挑选最佳一张  (来自 case-C_xhs_69832685, extends `样图`)
+
+- `API密钥`: 第三方平台 API 访问凭证字符串,用于接口鉴权  (来自 case-B_zhihu_20432492, extends `生成参数`)
+- `视频元数据`: 短视频平台 API 返回的媒体元数据 JSON 对象,含 video_url(真实播放地址)、封面图 URL、视频描述等字段  (来自 case-B_zhihu_20432492, extends `描述`)
+- `消息`: API 调用返回的状态消息字符串,指示成功或错误原因  (来自 case-B_zhihu_20432492, extends `评语`)
+- `链接`: 资源定位符字符串(URL),作为 API 调用的目标地址输入,指向待处理的视频资源  (来自 case-B_zhihu_20432492, extends `生成参数`)
+
+- `受众画像`: 对目标受众群体特征、使用习惯、痛点与认知现状的文字刻画,作为后续内容策划的定位依据  (来自 case-C_zhihu_20369358, extends `描述`)
+- `认知框架`: 对某一领域核心概念关系与运作方式的比喻性描述,帮助受众建立正确的认知模型,作为后续写作的核心论点依据  (来自 case-C_zhihu_20369358, extends `描述`)
+- `课程结构表`: 按学习递进顺序排列的多阶段课程骨架,每个阶段含名称、学习目标与前后衔接逻辑,作为专栏内容规划的结构性蓝图  (来自 case-C_zhihu_20369358, extends `大纲`)
+- `专栏序章`: 以专栏开篇文章形式发布的正文内容,介绍系列课程的目标受众、核心理念与章节结构,作为整个专栏的引导性终态文字  (来自 case-C_zhihu_20369358, extends `正文`)
+
+- `JSON提示词`: 以JSON结构描述的图像提示词,涵盖subject/color_palette/lighting/composition/aesthetic等多维层级,由豆包深度思考模式反推生成  (来自 case-A_gzh_4d6f09a4, extends `提示词`)
+
+- `JSON提示词`: 以JSON结构描述的图像提示词,涵盖subject/color_palette/lighting/composition/aesthetic等多维层级,由豆包深度思考模式反推生成  (来自 case-?, extends `提示词`)
+
+- `扩展插件`: 安装到 ComfyUI 运行环境的自定义节点扩展包,提供额外的节点类型供工作流调用,可被反复装载复用(如 ComfyUI-WD14-Tagger、One Button Prompt 等)  (来自 case-A_xhs_66d730d4, extends `模型权重`)
+- `节点配置`: ComfyUI 工作流中已添加并配置完毕的单个节点实例,包含节点类型与所有参数设定,是工作流节点图的组成单元  (来自 case-A_xhs_66d730d4, extends `工作流`)
+
+- `元提示词`: 用于驱动AI(Gemini Canvas)生成代码/网页界面的结构化系统提示词,内含RCCUWI框架槽位,与图像生成正向prompt不同  (来自 case-B_xhs_695b81a3, extends `提示词`)
+- `修改需求`: 用户以自然语言表达的针对当前网页版本的改动指令  (来自 case-B_xhs_695b81a3, extends `描述`)
+- `网页应用`: 由AI生成的可在浏览器中运行的交互式网页工具,含界面布局/交互逻辑/AI接口调用  (来自 case-B_xhs_695b81a3, extends `工作流`)
+- `产品信息`: 用户录入的产品基本信息,含产品类型/品类/核心卖点  (来自 case-B_xhs_695b81a3, extends `描述`)
+- `风格配置`: 用户在工作站中设定的风格参数集合,含风格关键词/时间段/分类标签,约束AI生成MJ提示词方向  (来自 case-B_xhs_695b81a3, extends `生成参数`)
+
+- `Token序列`: Tokenizer将自然语言文本切分后得到的字符串token列表,如['今天','天气','很','好'],是文本在词表粒度上的离散序列表示  (来自 case-C_xhs_69ee4674, extends `描述`)
+- `Token_ID序列`: Token序列中每个token在词表中对应的整数索引列表,形状[n,],值域[0, vocab_size-1],是模型实际消费的数字化输入  (来自 case-C_xhs_69ee4674, extends `描述`)
+- `注意力掩码`: 与padded token_ids同形状的二元矩阵,有效token位置=1,padding位置=0,用于在注意力计算中屏蔽padding位置  (来自 case-C_xhs_69ee4674, extends `蒙版`)
+- `嵌入矩阵`: 词嵌入查表后得到的token向量矩阵,形状[n, d_model],每行是对应token在连续语义空间中的浮点表示  (来自 case-C_xhs_69ee4674, extends `模型权重`)
+- `隐状态矩阵`: Transformer各层输入/输出的中间表征矩阵,形状[n, d_model],承载当前层对序列的语义理解,是后续层持续改造的对象  (来自 case-C_xhs_69ee4674, extends `底图`)
+- `QKV矩阵组`: 注意力机制中Query、Key、Value三个线性投影矩阵的组合,各形状[n, d_model],分别负责查询匹配、键索引和值聚合  (来自 case-C_xhs_69ee4674, extends `底图`)
+- `多头QKV`: 将QKV矩阵reshape为多头形式后的张量,形状[n, n_heads, d_head],支持多头并行注意力计算  (来自 case-C_xhs_69ee4674, extends `底图`)
+- `注意力分数矩阵`: QK点积经缩放和因果掩码处理后的注意力分数,形状[n_heads, n, n],未来位置被置为负无穷  (来自 case-C_xhs_69ee4674, extends `底图`)
+- `注意力输出矩阵`: 经softmax归一化后对V做加权求和得到的注意力输出,形状[n, n_heads, d_head],是多头注意力的直接产出  (来自 case-C_xhs_69ee4674, extends `底图`)
+- `位置向量`: 最终隐状态矩阵最后一行XL[n-1],形状[d_model,],代表模型处理完整prompt后的上下文压缩表征  (来自 case-C_xhs_69ee4674, extends `底图`)
+- `归一化向量`: 经Final LayerNorm处理后的最终特征向量,形状[d_model,],数值尺度对齐到LM Head期望输入范围  (来自 case-C_xhs_69ee4674, extends `底图`)
+- `logits向量`: LM Head对最终表征线性投影得到的未归一化预测分数,形状[vocab_size,],每个位置对应词表中一个token的预测得分  (来自 case-C_xhs_69ee4674, extends `底图`)
+- `KV缓存`: prefill阶段按层和注意力头缓存的K/V矩阵,每层形状[2, n_heads, n, d_head],供后续自回归decode阶段复用以避免重复计算  (来自 case-C_xhs_69ee4674, extends `生成参数`)
+- `解码配置`: 控制解码行为的参数集合,包含temperature缩放系数、logits_bias词级偏置、repetition_penalty重复惩罚系数和bad_words_ids屏蔽列表  (来自 case-C_xhs_69ee4674, extends `生成参数`)
+- `候选Token列表`: 经Top-k/Top-p截断后保留的候选token子集,形状仍[vocab_size,]但非候选位置logits被置为负无穷,等待最终采样决策  (来自 case-C_xhs_69ee4674, extends `样图`)
+- `输出Token`: 经采样或argmax决策后产出的第一个生成token,对应词表中某个整数ID及其解码后的子词或字符,是本次推理的最终交付物  (来自 case-C_xhs_69ee4674, extends `正文`)
+
+- `关键词列表`: 按核心词、场景词、问题词三类分类整理的关键词条目集合,用于后续嵌入文章结构的指定位置  (来自 case-creative_1, extends `知识库`)
+- `含关键词骨架`: 在文章章节骨架基础上标注了关键词嵌入位置规则(标题含核心词、首段前三行融入核心词、正文每300字一个场景词、小标题含问题词)的结构化内容规划  (来自 case-creative_1, extends `大纲`)
+- `文章草稿`: 按价值密度、表达节奏、视觉辅助三维准则展开填充的完整正文中间态,含加粗重点、案例数据、设问、配图说明,尚未添加互动钩子,需后续步骤继续处置  (来自 case-creative_1, extends `脚本`)
+
+- `写作触发`: 触发写作冲动的灵感事件或感受,如读书感受、被某篇文章触动、某事件引发的感慨与写作冲动  (来自 case-creative_2, extends `描述`)
+- `账号定位`: 公众号账号的写作定位与目标受众范围,是跨批次复用的账号策略约束(如:公众号运营、写作成长、退休生活等领域)  (来自 case-creative_2, extends `描述`)
+- `选题方向`: 经爆款潜力与定位符合双重筛选后确定的粗粒度写作话题方向,尚未细化为具体角度  (来自 case-creative_2, extends `描述`)
+- `竞品数据`: 在公众号、知乎、百度等平台搜索同话题历史文章所得的阅读量数据,用于验证选题是否具有读者基础  (来自 case-creative_2, extends `知识库`)
+- `选题`: 经阅读量验证与核心卖点确认后的具体写作选题角度,比选题方向更精细、可直接指导创作  (来自 case-creative_2, extends `描述`)
+- `素材`: 从公众号、知乎、百度及书籍中一次性收集的、支撑各分论点的观点、论据、案例与数据集合  (来自 case-creative_2, extends `知识库`)
+- `草稿`: 包含开头、各分论点展开、段落过渡与结尾的完整文章初稿,已具备正文形态但尚未定稿,需继续修改打磨后才能发布  (来自 case-creative_2, extends `正文`)
+
+- `关键词`: 用于搜索或检索的单个关键词,作为信息获取步骤的查询输入,指向某一主题领域(如「减肥」)  (来自 case-creative_3, extends `提示词`)
+- `竞品内容`: 同行账号在抖音/小红书等平台发布的高播放量内容,作为被研究、借鉴打法的整体范本(含评论区)  (来自 case-creative_3, extends `对标内容`)
+- `痛点列表`: 从单一渠道(评论区或社群)收集到的用户痛点陈述条目列表,各条目独立成行,是进一步汇聚的原始素材  (来自 case-creative_3, extends `大纲`)
+- `社群`: 创作者所在的行业微信群/QQ群等社群,作为用户真实吐槽与痛点的信息采集渠道  (来自 case-creative_3, extends `知识库`)
+- `关键词列表`: 搜索引擎/平台输入关键词后出现的下拉联想词条目列表,反映用户真实搜索意图与痛点  (来自 case-creative_3, extends `大纲`)
+- `痛点清单`: 汇聚自评论区、社群、搜索下拉词三个渠道的用户痛点问题汇总清单,是选题筛选的直接依据  (来自 case-creative_3, extends `大纲`)
+- `选题问题`: 从痛点清单中提炼出的核心问题表述(通常为一个问句),是后续标题创作与内容策划的锚点  (来自 case-creative_3, extends `描述`)
+- `人设规格`: 创作者在短视频平台的角色定位描述,包含职业身份、专业角度与表达风格(如「营养师(专家型)——从代谢角度分析」)  (来自 case-creative_3, extends `描述`)
+- `选题方案`: 结合人设视角对钩子标题进行匹配调整后输出的完整选题设计方案,包含人设切入角度与具体标题  (来自 case-creative_3, extends `描述`)
+- `内容素材`: 支撑选题内容生产的多类型素材集合,包含真实案例故事、权威数据来源与证据截图等,供脚本/正文撰写取用  (来自 case-creative_3, extends `知识库`)
+
+- `需求信号列表`: 由多渠道并行采集汇聚的需求信号集合,包含搜索热点词、同行爆款样本、评论区痛点、头部创作者选题规律等,供后续选题转化步骤消费  (来自 case-creative_4, extends `知识库`)
+- `热搜词`: 从搜索平台检索得到的与内容方向相关的热搜关键词列表,含热度趋势标注,反映当前平台用户搜索需求  (来自 case-creative_4, extends `知识库`)
+- `用户反馈`: 从高互动内容评论区提取的用户高频痛点与诉求聚类,反映目标受众的真实需求与困惑  (来自 case-creative_4, extends `知识库`)
+- `规律总结`: 通过研究头部创作者内容提炼出的选题规律与打法模式,如常用结构组合、高频内容类型、标题惯用套路等  (来自 case-creative_4, extends `知识库`)
+- `候选选题`: 基于选题框架批量生成的选题候选列表,每条注明所用框架,作为待进一步优化打磨的选题骨架  (来自 case-creative_4, extends `大纲`)
+- `选题`: 经优化打磨后具备高传播潜力的内容选题,含差异化亮点标注,作为内容创作的最终选定方向  (来自 case-creative_4, extends `标题`)
+
+- `题目图片`: 包含数学题目内容(公式+图形+文字说明)的截图,用作 GPT Image 2.0 的视觉理解输入  (来自 case-C_gzh_8a75847e, extends `截图`)
+- `课文套组`: 由同一提示词一次生成的两张配套成品图:课文学习海报(知识板块+情境插画)和同步练习卷(分栏题型+分值标注),用于课文教学整套配套  (来自 case-C_gzh_8a75847e, extends `成品图`)
+- `词卡系列`: 批量生成的多张单词卡片集合,每张风格迥异、互不雷同;单次超级提示词产出,各卡自包含单词、音标/读法、中文释义和视觉场景  (来自 case-C_gzh_8a75847e, extends `成品图`)
+
+- `图片策划序列`: LLM输出的N个结构化图片策划块的有序序列,每块含图片定位/核心信息/文字内容/视觉提示词  (来自 case-A_xhs_69663d9d, extends `提示词`)
+- `图片策划`: 单张信息图的结构化策划块,含图片定位/核心信息/文字内容(主标题+副标题+补充说明)/视觉提示词  (来自 case-A_xhs_69663d9d, extends `提示词`)
+- `信息图`: NanoBanana生成的小红书简笔画素描风格单张信息图(竖版3:4,手写体文字),终态成品直接交付  (来自 case-A_xhs_69663d9d, extends `成品图`)
+- `信息图序列`: N张小红书简笔画风格信息图的有序列表,构成完整信息图系列,终态成品集合直接交付  (来自 case-A_xhs_69663d9d, extends `成品图`)
+- `文本`: 用户提供的待可视化原始文字内容(如某主题的教程、知识点或经验分享),在工序中作为LLM内容分析的输入素材  (来自 case-A_xhs_69663d9d, extends `正文`)
+
+- `参数列表`: 从需求描述中提取出的结构化变量参数清单,列出可替换参数与固定元素,供后续提示词模板组合使用  (来自 case-A_zhihu_20382372, extends `描述`)
+
+- `参数列表`: 从需求描述中提取出的结构化变量参数清单,列出可替换参数与固定元素,供后续提示词模板组合使用  (来自 case-?, extends `描述`)
+
+- `文本`: 文本形式的主题关键词、场景描述或内容要求,作为图像生成的描述性输入依据  (来自 case-B_zhihu_20418774, extends `描述`)
+- `生成图`: 由 GPT-Image-2 文生图步骤产出的终态图像,作为最终交付成品  (来自 case-B_zhihu_20418774, extends `成品图`)
+
+- `平台工具`: 操作入口所在的平台及工具信息,说明使用哪个 Web 平台访问哪个 AI 工具  (来自 case-A_xhs_6a16c1d0, extends `生成参数`)
+- `工具配置`: 工具与模型的综合配置项,含平台模型选型、参考模式、画幅规格、时长及配音等  (来自 case-A_xhs_6a16c1d0, extends `生成参数`)
+
+- `平台账号`: 访问特定平台所需的账号凭证,包含登录信息及可用配额(如即梦平台每日赠送的88-100免费积分)。  (来自 case-C_gzh_e55e66b8, extends `生成参数`)
+- `关键词`: 提示词中具有特定视觉或语义影响力的单个词项,用于控制生图结果的局部属性(如光照时段、渲染风格、色调),可被单独替换以观察画面变化。  (来自 case-C_gzh_e55e66b8, extends `提示词`)
+- `提示词能力`: 通过大量关键词替换实验积累形成的感知性知识——掌握「关键词→画面变化」映射规律,能快速将脑中意图转化为AI可理解的精准提示词。  (来自 case-C_gzh_e55e66b8, extends `知识库`)
+- `参考样本`: 已成功完成的样本实例(如已排版的网页框架、成功的文案或代码结构),整体作为AI处理新内容时的风格/结构/逻辑参照。  (来自 case-C_gzh_e55e66b8, extends `对标内容`)
+- `原始素材`: 待按参考样本风格处理的原始内容(如未排版的新文章),在工序中作为被加工的主体输入,经AI处理后产出风格对齐的结果。  (来自 case-C_gzh_e55e66b8, extends `正文`)
+- `AI处理结果`: AI按填入变量后的提示词模板执行任务后产出的内容结果(如产品经理视角的用户留存策略分析),质量稳定可预期,作为工序的最终交付物。  (来自 case-C_gzh_e55e66b8, extends `正文`)
+- `变量值`: 用于填入提示词模板中各占位符的具体参数值组合(如职业/角色、任务描述、受众特征、输出格式等),驱动模板实例化并执行AI任务。  (来自 case-C_gzh_e55e66b8, extends `生成参数`)
+- `提示词武器库`: 持续积累的个人提示词资产库,以飞书或Chat Memo为载体,存储经过验证的可复用提示词与模板,构成长期护城河。  (来自 case-C_gzh_e55e66b8, extends `知识库`)
+- `提示词模板`: 带[变量名]占位符的可复用提示词骨架,固定结构部分不变,可变参数用占位符标记,使用时填入具体变量值即可直接执行。  (来自 case-C_gzh_e55e66b8, extends `模板`)
+
+- `产品图`: 本次工序中待迁移风格的目标产品图(雅诗兰黛 Re-Nutriv 护肤水官方图),被 AI 分析以提取目标产品的外观特征,并在生图时作为图像参考上传到 Jimeng;其角色是'被模仿/被迁移风格的目标实体',而非单纯的风格范本,因此以 case-specific 类型独立标注。  (来自 case-A_xhs_6968aa4d, extends `参考图`)
+- `工具配置`: Jimeng 文生图工具的综合生图配置集合,包含:已粘贴的反推提示词、已上传的目标产品参考图、画面比例(2:3)、参考强度(100%)、使用模型(图片 4.0)、输出规格(高清 2K);属于本次生图任务的完整参数束,超出单一生成参数范畴故作 case-specific 注册。  (来自 case-A_xhs_6968aa4d, extends `生成参数`)
+
+- `生成图`: 由文生图模型依据提示词输出的复刻成品图,是本工序的最终交付物;用于区分来自 AI 生成器(而非摄影或设计工具)的成品图像  (来自 case-B_xhs_693adc7f, extends `成品图`)
+
+- `需求文档`: 对目标广告效果等创作需求的文字说明,作为后续生成步骤的输入依据  (来自 case-A_gzh_9091d1e7, extends `描述`)
+- `提示词块`: 用于后续拼合为完整生成提示词的分段描述文本,如卖点描述块、场景构图块、光线风格块  (来自 case-A_gzh_9091d1e7, extends `提示词`)
+- `广告图提示词`: 由多个描述块合并而成的完整文生图提示词,用于驱动广告图像的AI生成  (来自 case-A_gzh_9091d1e7, extends `提示词`)
+- `广告图`: 以实物照为基础、经AI图生图生成的最终交付广告图像  (来自 case-A_gzh_9091d1e7, extends `成品图`)
+- `实体产品`: 待拍摄的实体商品,作为摄影采集的主体对象,其视觉呈现将通过拍摄步骤转化为数字素材  (来自 case-A_gzh_9091d1e7, extends `参考图`)
+- `产品照片`: 手机拍摄得到的产品实物照片,作为图生图换背景的基础输入图像  (来自 case-A_gzh_9091d1e7, extends `底图`)
+- `图生图提示词`: 用于图生图(换背景)操作的提示词,描述目标场景、光线和风格,保留产品主体  (来自 case-A_gzh_9091d1e7, extends `提示词`)
+
+- `图片描述`: AI对参考图结构化识别后生成的描述文本,含主体、背景、视觉风格、色调、构图、光影等维度  (来自 case-C_zhihu_20169233, extends `描述`)
+- `节气海报`: 以中国传统节气为主题的AI批量生成海报图,含节气文字排版、传统文化元素和装饰艺术风格  (来自 case-C_zhihu_20169233, extends `成品图`)
+
+- `创作需求`: 图片主题、用途与内容要点的文字描述,是图像创作的原始需求输入  (来自 case-?, extends `描述`)
+- `图片定位`: 图像的使用场景、目标平台与版式方向的定位性描述  (来自 case-?, extends `描述`)
+- `风格标签`: 图像视觉风格方向的关键词组合标签(如水墨风/微缩景观/诗意高端)  (来自 case-?, extends `描述`)
+- `新创作目标`: 新主题的替换要素说明,含城市名、地标、诗词文案等待替换的核心内容变量  (来自 case-?, extends `描述`)
+
+- `主题构想`: 用户对本次生成目标的主题意图描述,包含风格期望(水墨禅意)与构图诉求(留白供排版),作为构建提示词的原始输入依据  (来自 case-B_zhihu_20365059, extends `描述`)
+- `封面图`: 科技新闻杂志风格的封面图像,包含标题文字、视觉主体与配色排版,可作为底图被后续指令迭代修改  (来自 case-B_zhihu_20365059, extends `成品图`)
+- `修改需求`: 针对已生成背景图提出的变体修改意见,指定画面主体与色调调整方向,驱动下一轮图像迭代  (来自 case-B_zhihu_20365059, extends `评语`)
+- `背景图`: 水墨风格的PPT横版背景图,以远山云雾或竹林为主体,含大面积留白供文字排版,可被后续指令迭代生成变体  (来自 case-B_zhihu_20365059, extends `成品图`)
+
+- `操作界面`: 工具平台的配置操作界面,带有多个可填写区域(如基础设定、提示词、高级设定等),是智能体配置流程的交互载体  (来自 case-A_gzh_7dfd3085, extends `模板`)
+- `配置项`: 智能体配置中已填写完成的具体设定条目,记录名称、简介、头像、提示词等配置内容  (来自 case-A_gzh_7dfd3085, extends `描述`)
+- `AI智能体`: 已配置并发布到平台的AI对话式智能体实例,包含基础设定与系统提示词,可被用户调用执行多步对话任务  (来自 case-A_gzh_7dfd3085, extends `工作流`)
+- `分析报告`: 对图像或内容按结构化公式所做的系统性分析,拆解风格、主题、构图、色彩、情绪等维度,以定性文字输出  (来自 case-A_gzh_7dfd3085, extends `评语`)
+
+- `创意方向`: 视频创作方向与主题意图的文字刻画,含人物设定、场景风格、叙事核心等创意要素,作为手写提示词步骤的原始输入依据  (来自 case-C_gzh_5bdae160, extends `描述`)
+- `生成图`: AI文生图模型(豆包)直接输出的像素风格图像,含水印或去水印后均以此类型流转,后续经去水印处理并作为图生视频步骤的参考帧输入,是图像→视频转换链路的中间态  (来自 case-C_gzh_5bdae160, extends `底图`)
+- `生成视频`: AI图生视频模型(即梦)直接输出的像素风格动态视频片段,含水印或去水印后均以此类型流转,最终作为剪映剪辑合成步骤的主体素材被组装进成片  (来自 case-C_gzh_5bdae160, extends `视频片段`)
+- `音频素材`: 从爆款短视频平台或个人音乐库中挑选的背景音乐,与像素风赛博朋克视频风格匹配,被混入剪映剪辑合成步骤的音轨  (来自 case-C_gzh_5bdae160, extends `BGM`)
+- `合成视频`: 剪映合成后的完整短视频,含配乐BGM、字幕/对话气泡及0.7倍变速处理,时长约7.2秒,是发布前的完整成品形态  (来自 case-C_gzh_5bdae160, extends `视频成品`)
+- `短视频`: 发布到视频号/抖音/小红书等公域平台的像素风格赛博朋克AI短视频,是工序链路的最终交付物  (来自 case-C_gzh_5bdae160, extends `视频成品`)
+
+- `主题词`: 单个主题词条,用于定向替换或标识特定语义对象的极短文字片段(如节气名称、风格标签等)  (来自 case-B_zhihu_20400839, extends `描述`)
+
+- `视觉风格JSON`: 从参考图中提取的多维度视觉风格结构化描述(JSON格式),涵盖颜色、排版、构图、特效、色调、表现手法、氛围等维度,作为后续图像生成步骤的风格控制依据被消费。  (来自 case-B_gzh_e9677a53, extends `描述`)
+
+- `风格描述`: 豆包从参考图提取的JSON格式视觉风格描述,含颜色/排版/构图/特效四个维度,作为生图提示词的风格模板来源  (来自 case-B_gzh_25318352, extends `描述`)
+
+- `迭代记录`: 记录多轮提示词迭代的完整过程日志——每条记录包含该轮的提示词版本、执行结果(是否达预期)、发现的问题以及下一步的修改方向;是对已产出物逐轮评判+修改意见的历史汇总,兼具过程审查与版本溯源作用。  (来自 case-C_zhihu_20313114, extends `评语`)
+
+- `创意意图`: 用户对想生成图像的初始模糊描述,尚未结构化,常表现为堆叠关键词  (来自 case-C_zhihu_19056944, extends `提示词`)
+- `8要素骨架`: 按「主体+场景+动作/关系+构图+光线+风格/媒介+质感+镜头参数」公式填写的结构化要素清单  (来自 case-C_zhihu_19056944, extends `提示词`)
+- `完整提示词方案`: 包含正向提示词(含参考系、构图控制词)与负向提示词的完整 AI 生图提示词集合,可直接粘贴至生图工具  (来自 case-C_zhihu_19056944, extends `提示词`)
+- `出片规划`: 二段式出片的操作计划:草图阶段(先对构图)→ 细节强化阶段(皮肤/眼睛/材质/背景)  (来自 case-C_zhihu_19056944, extends `脚本`)
+- `一致性配置`: 用于系列图输出的一致性策略配置:参考图上传设置 + 固定 Seed + 单变量迭代规则  (来自 case-C_zhihu_19056944, extends `生成参数`)
+
+- `视觉风格JSON`: 多模态AI对图片视觉风格的结构化JSON分析结果,含颜色/构图/排版/主体/质感/光影/特效/氛围/复刻关键词等字段,用作后续生图步骤的引导输入  (来自 case-aaa_test_1, extends `提示词`)
+- `生成图`: 由生成模型按提示词产出的终态图像,不再进入后续加工,直接作为本次工序的交付成品  (来自 case-aaa_test_1, extends `成品图`)
+- `分镜分析列表`: Qwen3-VL对视频各分镜的结构化分析结果列表,每项含画面风格/主体特征/服装/动作/镜头变化/构图/光影/台词/音效/画质参数等,作为逐镜视频生成的批量输入  (来自 case-aaa_test_1, extends `提示词`)
+- `分镜分析`: 单个分镜的详细分析数据,包含画面风格、主体特征、服装物品、动作描述、镜头变化、构图、光影、台词旁白、音效、画质参数和时长,作为单段视频生成的引导指令  (来自 case-aaa_test_1, extends `提示词`)
+
+- `视觉风格分析JSON`: (无 desc)  (来自 case-aaa-test-2, extends `知识库`)
+- `视频分镜拆解分析`: (无 desc)  (来自 case-aaa-test-2, extends `知识库`)
+
+- `视频`: 待复刻的视频片段  (来自 case-aaa-test-3, extends `参考视频`)
+
+- `视觉风格分析 JSON`: AI 对图片视觉风格的 JSON 分析  (来自 case-aaa-test-4, extends `提示词`)
+- `复刻图`: 根据分析生成的复刻古风人物图  (来自 case-aaa-test-4, extends `成品图`)
+- `分镜拆解分析`: AI 对视频分镜的详细拆解分析  (来自 case-aaa-test-4, extends `提示词`)
+- `复刻视频`: 根据分镜分析生成的复刻视频片段  (来自 case-aaa-test-4, extends `视频成品`)
+
+- `视觉风格分析`: AI 分析出的结构化风格数据  (来自 case-aaa-test-7, extends `提示词`)
+- `复刻图片`: AI 生成的复刻图片  (来自 case-aaa-test-7, extends `成品图`)
+- `分镜分析`: AI 拆解的分镜分析  (来自 case-aaa-test-7, extends `分镜脚本`)
+- `复刻视频`: AI 生成的复刻视频  (来自 case-aaa-test-7, extends `视频成品`)
+
+- `视觉风格分析`: (无 desc)  (来自 case-aaa-test-14, extends `知识库`)
+- `复刻图片`: (无 desc)  (来自 case-aaa-test-14, extends `成品图`)
+- `分镜分析`: (无 desc)  (来自 case-aaa-test-14, extends `知识库`)
+- `复刻视频`: (无 desc)  (来自 case-aaa-test-14, extends `视频成品`)
+
+- `视觉风格分析`: AI 提取的 JSON 结构化视觉风格数据  (来自 case-aaa-test-17, extends `描述`)
+- `分镜分析`: AI 拆解的视频分镜详细分析数据  (来自 case-aaa-test-17, extends `描述`)
+
+- `产品需求`: 产品设计需求描述  (来自 case-5-test-5, extends `描述`)
+- `背景需求`: 背景设计需求描述  (来自 case-5-test-5, extends `描述`)
+- `背景图`: 海报背景图  (来自 case-5-test-5, extends `成品图`)
+- `试穿需求`: 试穿场景需求描述  (来自 case-5-test-5, extends `描述`)
+- `试穿场景图`: 产品试穿场景图  (来自 case-5-test-5, extends `成品图`)
+- `最终海报`: 最终宣传海报  (来自 case-5-test-5, extends `合成图`)
+
+- `需求描述`: 产品应用场景图的设计需求  (来自 case-5-test-7, extends `提示词`)
+- `结构化 Prompt`: 用于 AI 生图的结构化提示词  (来自 case-5-test-7, extends `提示词`)
+- `产品概念图`: AI 生成的产品应用场景图  (来自 case-5-test-7, extends `参考图`)
+- `最终海报`: 精修后的产品宣传海报  (来自 case-5-test-7, extends `参考图`)
+- `产品调性`: 产品风格与色系描述  (来自 case-5-test-7, extends `提示词`)
+- `背景图`: 海报背景素材  (来自 case-5-test-7, extends `参考图`)
+- `试穿场景图`: 产品多场景试穿效果图  (来自 case-5-test-7, extends `参考图`)
+- `海报效果`: 最终合成的海报  (来自 case-5-test-7, extends `参考图`)
+
+- `产品信息`: 产品试穿场景描述  (来自 case-5-test-7, extends `提示词`)
+
+- `需求描述`: 用户对产品应用场景的初步描述。  (来自 case-5-test-9, extends `参考图`)
+- `结构化提示词`: 经过结构化框架处理后的 AI 提示词。  (来自 case-5-test-9, extends `提示词`)
+- `产品概念图`: AI 生成的产品应用场景效果图。  (来自 case-5-test-9, extends `参考图`)
+- `海报需求`: 海报制作的背景与风格需求。  (来自 case-5-test-9, extends `参考图`)
+- `背景图`: 用于海报的背景图像。  (来自 case-5-test-9, extends `参考图`)
+- `产品图与场景描述`: 产品在不同场景下的试穿描述。  (来自 case-5-test-9, extends `参考图`)
+- `试穿场景图`: 产品试穿的场景效果图。  (来自 case-5-test-9, extends `参考图`)
+- `最终海报`: 拼接完成的最终宣传海报。  (来自 case-5-test-9, extends `参考图`)
+- `工作流总结`: 对 AI 绘图工作流的总结性描述。  (来自 case-5-test-9, extends `参考图`)
+- `工作流建议`: 基于工作流的操作建议。  (来自 case-5-test-9, extends `参考图`)
+- `特定需求描述`: 针对特定技术功能的描述。  (来自 case-5-test-9, extends `参考图`)
+- `创意图/示意图`: 生成的专业创意图或功能示意图。  (来自 case-5-test-9, extends `参考图`)
+
+- `需求描述`: (无 desc)  (来自 case-5-test-12, extends `描述`)
+- `场景图`: (无 desc)  (来自 case-5-test-12, extends `成品图`)
+- `背景需求`: (无 desc)  (来自 case-5-test-12, extends `描述`)
+- `背景图`: (无 desc)  (来自 case-5-test-12, extends `成品图`)
+- `试穿需求`: (无 desc)  (来自 case-5-test-12, extends `描述`)
+- `试穿图`: (无 desc)  (来自 case-5-test-12, extends `成品图`)
+- `海报`: (无 desc)  (来自 case-5-test-12, extends `成品图`)
+- `示意图需求`: (无 desc)  (来自 case-5-test-12, extends `描述`)
+- `示意图`: (无 desc)  (来自 case-5-test-12, extends `合成图`)
+
+- `视觉风格分析JSON`: AI 反推的图片视觉风格分析  (来自 case-aaa-test-18, extends `提示词`)
+- `复刻图片`: 根据视觉风格分析生成的复刻图片  (来自 case-aaa-test-18, extends `成品图`)
+- `分镜分析报告`: AI 对视频分镜的详细分析  (来自 case-aaa-test-18, extends `提示词`)
+- `视频提示词`: 用于生成相似视频的提示词集合  (来自 case-aaa-test-18, extends `提示词`)

+ 238 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools.md

@@ -0,0 +1,238 @@
+# 脚本怎么用(工具手册)
+
+> 跑这道 skill 时会用命令行调用几个脚本,这里讲它们**怎么用**。脚本都在 `spec/tools/` 下,**你不用读脚本源码,会调就行**。
+
+## 1. `spec/tools/wf-patch.py` — workflow.json 安全批量字段设置器
+
+**做什么**:批量给 `workflow.json` 的步骤/输入输出填字段(第一阶段连"来源",第二阶段填作用/动作/类型/实质/形式)。**凡是"给一批字段赋值"都用它,别写 Python 脚本去拼/改 workflow.json**(脚本拼 JSON 容易把文件弄坏)。分工:**你出判断(哪个字段填什么),工具负责安全写入 + 当场检查对错**。
+
+**两个保证**:
+- **不会写坏文件**:由工具读取→修改→写回,你从不直接碰 JSON 文本。
+- **当场检查,有错全不写**:每条赋值都立刻对照词表/类型登记/格式校验;**只要有一条不合法,就报出是哪条、整批都不写**(不会产出悄悄出错的文件)。检查规则和最后的 lint 一致——**这里通过了,lint 一定过**。
+
+**用法**:
+```bash
+# 单条 / 多条 --set (path=value, 只在第一个 = 处切; value 可含 = 和空格, 整体加引号)
+python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json \
+    --set 'p1.s1.inputs[0].anchor=← s0.主角图' \
+    --set 'p1.s2.effect=主体生成' \
+    --set 'p1.s2.action=生成/图像生成/文生图'
+
+# 批量: 几十处 anchor / 字段一次过 — 写一份 patch 清单 (你仍逐条显式决策每个值), 一条命令应用
+python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json --patch _scratch/anchors.json
+#   anchors.json = [{"path":"p1.s1.inputs[0].anchor","value":"← s0.x"}, ...]
+
+# 只校验不写
+python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
+
+# 透传回填: anchor 设好后, 自动把"原样透传"的 value/directive 从源逐字抄过来
+python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json --resolve-passthrough
+
+# 建骨架: 缺失的 procedure/step/IO 元素**默认自动创建 (upsert)**、文件不存在也从空建 —— 不必 write_file 手写嵌套 JSON
+python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json --patch _scratch/skeleton.json
+#   skeleton.json = 扁平 [{"path":"p1.s1.inputs[0].type","value":"参考图"}, ...]: 列全所有 step/IO 路径, 一条命令建出整个骨架
+#   (你只写扁平清单——工具负责拼成合法嵌套 JSON; output 的 id 会自动补 sNo1)
+#   ⚠️ 默认自动建; 只有「纯填已存在结构、想抓路径 typo」时才加 --no-create (路径不存在就报错)
+
+# @quote 回填 --resolve-quotes: value/directive 写短标记 @quote|起锚|止锚 (或 @quote|关键词),
+#   工具顺标记从 --source 原文 / --ocr 配图文本匹配真实内容【批量替换】(省得手动粘长内容; 跟 anchor patch 一起跑)
+python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json --patch _scratch/anchors.json \
+    --resolve-quotes --source input/case-{N}.json --ocr outputs/case-{N}/_scratch/ocr.txt
+```
+
+> **`@quote` 回填(强烈推荐填真实 value/directive 的方式)**: 别手动 quote-source 再粘长内容——直接把 value/directive 的值设成 `@quote|<起锚>|<止锚>`(两个独特短串, 框出原文那段)或 `@quote|<关键词>`(命中落 JSON 块返回整块, 否则返回所在行/段), 然后跑 `--resolve-quotes --source <原文> [--ocr <ocr.txt>]`, 工具按空白无关匹配从原文/配图把真实内容**一次性批量**替换进去。匹配不到会 `⚠` 提示(标记原样留着, 回去改锚点)。
+
+**`--resolve-passthrough`(省得重复抄内容)**:规矩是"值"要写真实内容,不能写"(同上)""见 s1o1"这类引用(那是"来源"该干的,lint 会报)。但同一段内容被后面好几步原样复用时,手抄好几遍很烦。用这个:**只在源头那个输出里填一次真内容**,其余引用处把"来源"设成 `← 源的编号`,工具就顺着编号把真内容**逐字抄到**每个"值"还空着的输入(链式引用也会一路抄到底)。源头找不到的会用 `⚠` 提示。可单独跑,也可跟在 `--set/--patch` 后面。
+
+**路径语法** (**文档统一用 id 式** `pN.sM.字段`: proc 用 id `p1`、step 用 id `s1`、`inputs/outputs` 才用 `[i]` 下标、嵌套步 id 带点如 `s2.1`。**下标式 `procedures[N].steps[M].inputs[i].字段` 工具也接受**——镜像 workflow 结构时不会挂):
+
+| 路径 | 设的字段 |
+|---|---|
+| `p1.s2.effect` | step 标量 (effect / substance / form / via / action / directive / kind / intent / group) |
+| `p1.s1.inputs[0].anchor` | IO 字段 (anchor / type / value / id) |
+| `p1.s2.1.outputs[0].type` | 嵌套步的 IO |
+| `p1.s2.focus` | step 的 focus 数组 (逗号分隔: `focus=via,action,out-type-0`) |
+| `p1.purpose` | procedure 头部 (name / purpose / category / platform / author) |
+| `p1.declarations.inputs[0].desc` | declarations 内任意字段 (通用下钻) |
+| `source.url` | case-level 原帖信息 (platform / author / date / url / title / excerpt) |
+| `p1.type_registry.场景图.extends` | 注册 case-specific 类型 (自动建 type_registry 段) |
+
+⚠️ **`--set` 别用单引号包参数**(Cyber 引擎跑 cmd.exe,**不剥单引号** → 路径会带个 `'` → 报「找不到 procedure id='p1」)。工具已兜底自动剥成对引号,但仍建议:值含空格/特殊字符用**双引号**,或干脆把一批改动写进 `--patch` 清单文件(JSON,无引号坑)。
+
+**`--unset PATH`** (删字段, 可重复): 删掉某字段, 取代手 Edit 删. e.g. `--unset p1.declarations.inputs[0].inferred` (declarations 不收 inferred, schema 会拒). 字段本就不存在 → 跳过 (幂等, 不报错).
+
+**各字段校验规则**:
+- `effect` → effect.json 叶子 (给全路径会自动归一到叶名)
+- `action` → action.json 叶子 / 叶路径 (给叶名自动展开成全路径)
+- `type` → **自由文本, 不校验**(Phase 1 随便起描述标签;归一到标准叶子 / 注册 type_registry 是 Phase 2 的事,最终由 lint Check 1 + render schema 兜底)
+- `extends` → 必须桥到 type.json 叶子
+- `substance` / `form` → 自由提炼的元素点 (字符串或数组), **不查词表、不校验**; `url` → 自由文本; 三者均可传 `__null__` 设 JSON null
+- `anchor` → 必须 `←` (输入引用, 指向某 output id) 或 `→` (输出去向) 开头
+- `kind` → {step / block / nested}
+- `focus` → 逗号分隔 → 数组 (空串 → `[]`)
+- 其余 (name / value / intent / via / desc / purpose / ...) → 自由文本, 不校验
+
+**骨架创建推荐用 `--create`**(免手写嵌套 JSON, 见上)。**仍可用 Write 从 template 写骨架, 但弱模型易漏逗号崩 JSON, 不推荐**。 改字段 / 删字段 / 改 source 现在都走 wf-patch (`directive` 也是普通标量字段, 直接 `--set`), **不要再 Read→Edit 改 workflow.json** (那会反复重读、烧 token).
+
+**退出码**: `0` 全通过并写入 (或 `--dry-run` 通过) / `1` 有校验失败 (整批未写) / `2` CLI 错 / 文件不存在 / JSON 损坏.
+
+---
+
+## 1.5 `spec/tools/quote-source.py` — 原文片段捞取 (填 value / directive 用)
+
+**做什么**: value / directive 要填原文真实内容(那段 JSON 风格分析、那句完整提示词)时, 用它按字符匹配从原文 case json 把那段**逐字捞出来**再粘进 workflow.json。别凭记忆缩写、别写 `<JSON 数据>` 这种空壳。
+
+**匹配空白无关**: 原文 body_text 常有乱换行 / 前导空格, 工具匹配时忽略所有空白, 但返回的是**原文逐字片段**。
+
+**用法**:
+```bash
+# 普通: 按关键词捞周边 (默认前后各 300 字)
+python spec/tools/quote-source.py --source input/case-{N}.json --query "视觉风格分析"
+# 捞整段 JSON: 命中点落在 {...} 内时返回整块 (适合捞结构化分析)
+python spec/tools/quote-source.py --source input/case-{N}.json --query "视觉风格分析" --json-block
+# 捞一句完整提示词当 directive
+python spec/tools/quote-source.py --source input/case-{N}.json --query "结构化数据的形式"
+# 范围引用: 用首尾两个短锚点引出之间的整段长原文 (比 --window 猜长度精确, 适合整段 JSON / 多段提示词)
+python spec/tools/quote-source.py --source input/case-{N}.json --from "请以 JSON" --to "500 字以内"
+python spec/tools/quote-source.py --source input/case-{N}.json --from "视觉风格分析" --to "电影感人像"
+# 放宽窗口 (关键词模式)
+python spec/tools/quote-source.py --source input/case-{N}.json --query "主色调" --window 800
+# 连配图 OCR 文本一起搜 (prompt/JSON/参数常只在截图里, 不在 body_text)
+python spec/tools/quote-source.py --source input/case-{N}.json --query "主色调" --ocr outputs/case-{N}/_scratch/ocr.txt
+```
+
+> **关于 `--ocr`**: runner 执行前会把每张配图 OCR 成文本落到 `outputs/case-{N}/_scratch/ocr.txt`。带上 `--ocr <该文件>` 就能在原文 body_text **和图片文字**里一起搜(命中分别标 `[原文]` / `[配图OCR]`)。
+
+**小贴士**: query 用**一段独特的短语**(4-10 字)最稳; 整句带标点时可能因全/半角差异落到 `~approx`(仍返回最接近的一段, 核对即可)。
+
+**退出码**: `0` 有命中(含 approx) / `1` 找不到 / `2` CLI/IO 错。
+
+---
+
+## 2. `spec/tools/render-case.py` — 阶段三 workflow.json → HTML 渲染 + schema 校验
+
+**用途**: 阶段三. 接收 `workflow.json` (多工序 `procedures:[]`), 在内存组装成 case_data (merge `--source-input` 原帖 + `--page-title` + `--case-id`), 跑 schema 校验, 渲染输出 HTML. **不落盘 case_data.json**.
+
+**Agent 用法** (推荐): workflow.json 已含全部 procedures + 标注后:
+
+```bash
+# 1. 只校验, 看 schema 错 (建议带 --source-input, 让校验看到的就是 merge 后的最终版)
+python spec/tools/render-case.py \
+    --workflow outputs/case-{N}/workflow.json \
+    --source-input input/case-{N}-raw.json \
+    --page-title "Case {N} · <主题>" \
+    --case-id {N} \
+    --validate
+
+# 2. 校验通过后渲染输出 (--source-input 必带, 否则 HTML 折叠原文区只剩 60 字 excerpt)
+python spec/tools/render-case.py \
+    --workflow outputs/case-{N}/workflow.json \
+    --source-input input/case-{N}-raw.json \
+    --page-title "Case {N} · <主题>" \
+    --case-id {N} \
+    --out outputs/case-{N}/case-{N}-<slug>.html
+```
+
+**关于 `--source-input`** (2026-05-22 新增): renderer 直接从原帖 raw json 抽 `body_text` + 封面 + 图集兜底, in-place 填到 `case_data.source` —— Agent 不必手工复制原文内容. 行为:
+- `body_text` / `cover_image` 直接覆盖 case_data 同字段
+- `title` / `url` 仅在 case_data 缺时填
+- `excerpt` / `author` / `date` / `platform` **不动** (那些是 Agent 推断的友好版本)
+- **图集兜底**: 检查 raw.body_text 已 inline 的 `[image:URL]` 标记, 把 raw.image_url_list 里没 inline 也不是封面的图 append 到 body 末尾 (加 `--- 附图 ---` 分隔符). 适配"小红书短文 + 多图独立列" 和 "微信公众号长文 + inline 图" 两种平台
+
+**输入契约**: 见 [`spec/format/case-data.schema.json`](format/case-data.schema.json) (canonical JSON Schema Draft 2020-12, 受控字段 + enum + 条件约束 `if/then`).
+
+**模板**: 见 [`format/workflow-format.md`](format/workflow-format.md) 末尾「附:骨架模板」, 复制后替换 `<填:...>` 占位符.
+
+⚠ **绝对不要参考其他 `outputs/case-*/` 下的产物文件当模板** — 那些是 case-specific 产物. 唯一 canonical 骨架在 `format/workflow-format.md` 末尾.
+
+**输出码**:
+- `0` — 成功 (渲染或校验通过)
+- `1` — IO / schema / 渲染异常
+- `2` — CLI 参数错误
+
+**依赖**:
+- 必需: `spec/tools/renderer.py` (跟本脚本同目录, 自动 import)
+- 可选: `jsonschema` (装了用 Draft 2020-12 完整校验; 没装走 minimal check fallback)
+
+---
+
+## 3. `spec/tools/lint-case.py` — 轻量 lint + 自动 record 新 type
+
+**用途**: 阶段三. 跑完 `render-case.py` 后跑一次. 干六件事:
+1. **type 完整性 hint**: 扫 workflow.json 各 procedure 的 IO type 字段, 找出"用了 case-specific type 但该 procedure 的 `type_registry` 漏注册 / 缺 extends / 缺 desc"的情况, 打 stdout 给 Agent / 用户看
+2. **value 自包含 hint**: 扫每个 IO 的 value + 每个 directive, 揪出「(同 sN 输出)」「见 sN」「← sN」这类**引用占位** —— spec 要求 value 逐字回填数据本身 (引用归 anchor), 这种占位 schema/type 检查抓不到. 命中会提示跑 `wf-patch.py --resolve-passthrough` 自动回填
+3. **value/directive 真实性 hint**: 揪出 `<…>` 占位 value、以及工具步骤(`via` 是具体工具)**缺失的 directive**, 提示用 `quote-source.py` 从原文/配图 OCR 捞真内容回填(弱模型常把 value 全填 `<…>` 占位、漏 directive)。
+4. **章节覆盖 hint (结构强制, 需 `--source`)**: 按原文 `0N` 章节标号切段, 逐章节算骨架覆盖率, 把整段漏抽的章节(<40%)报出来 —— 弱模型"按成品图扫"常漏掉**无独立成品图的框架/案例章节**. 缺 `--source` 则跳过.
+5. **value 逐字 hint (值强制, 需 `--source`)**: 文本类 value 应是原文里**一整段连续文本**; 算"最长连续命中原文"比例, <80% 判**缩写/改写/截断**(典型: 抄了开头第一句、后面用原文小标题拼盘)并报出, 提示用 `@quote` 重填. 缺 `--source` 则跳过.
+6. **副作用: auto-record**: 把各 procedure.type_registry 里的 case-specific entry 自动 append 到 `spec/taxonomy/type_suggestions.md` 累积条目段 (Agent **不必手工 Write** suggestions, 工具代劳)
+
+**幂等**: dedup key = `(type_name, case_id)` 二元组. 同 case 重跑不重复; 不同 case 同名允许 (跨 case 频次是升级信号).
+
+**用法**:
+```bash
+# 推荐: 带 --source 原文(+ --ocr 配图文本)才会跑「章节覆盖」+「value 逐字」两条结构/值强制
+python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N} \
+    --source input/case-{N}.json --ocr outputs/case-{N}/_scratch/ocr.txt
+
+# 不带 --source: 只跑前 3 项 + record (结构/值强制跳过)
+python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N}
+
+# 只检测不写
+python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N} --no-record
+```
+
+**输出范例** (case 含 silent gap 的情况):
+```
+[lint] case-1 (workflow.json)
+  · type 完整性: 2 个提示
+      - [p1] step[1].outputs[0].type='主角图' 是 case-specific 但该 procedure 的 type_registry 没注册
+      - [p1] step[8].inputs[1].type='主角图' 是 case-specific 但该 procedure 的 type_registry 没注册
+  · 无新 type 可 record (type_registry 为空 — 全部 type 命中字典叶子)
+```
+
+**退出码**: 始终 0 (不阻塞流程). 解析 2 = CLI 参数错 / 文件不存在.
+
+**设计哲学**: 不严格. 检测项是 hint 不是 fail; record 是副作用不是核心契约. Agent 看 stdout 决定回不回去补 case_data.type_registry.
+
+---
+
+## 4. `spec/tools/renderer.py` — HTML 渲染主模板 (Python 模块)
+
+**做什么**:把数据渲染成网页的主程序,被 `render-case.py` 在内部调用。
+
+**你不用读它**——通过 `render-case.py` 调用即可。要了解格式,看 [`format/case-data.schema.json`](format/case-data.schema.json)(机器清单)和 [`format/workflow-format.md`](format/workflow-format.md)(格式说明)就够了。
+
+---
+
+## 5. `run_procedure_dsl.py` — runner 本身 (不在 skill 内)
+
+跑 Agent 的入口脚本. **Agent 不读它** — 它在跑 Agent. 但 Agent 可能想知道**自己被怎么起的**:
+
+- OAuth Max 模式 (走 ~/.claude 凭证, 不计 API 费)
+- `allowed_tools = ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]`  # 单 Agent 全程, 不开 Task/Agent (第二阶段不再用子助手, 见 §7)
+- `permission_mode = "bypassPermissions"` (Agent 全自动, 不停下来问)
+- `cwd = procedure-dsl/` (Agent 的工作目录)
+- `--resume` 支持 (中断后续跑)
+- runner 实时把每 turn 写到 `outputs/case-{N}/_trace.md`
+
+---
+
+## 6. 文件路径约定 (per case)
+
+| 路径 | 内容 | 谁负责 |
+|---|---|---|
+| `input/case-{N}-raw.json` | 原 case 素材 (title / link / body_text / image_url_list / ...) | 人 (Agent 读, 不写) |
+| `outputs/case-{N}/` | Agent 工作目录 (一 case 一目录) | Agent (写产物) |
+| `outputs/case-{N}/.session_id` | SDK session UUID, 用于 `--resume` | runner 写, Agent 不动 |
+| `outputs/case-{N}/_trace.md` | runner 写的实时执行流水 | runner (Agent 别 Read) |
+| `outputs/case-{N}/_scratch/` | **sanctioned scratch 区** — 只用于 dump 大 Bash 输出 (taxonomy --subtree 长结果 / find 结果) 之后 Read 切片, 或一次性 smoke test. runner 预创建. **不要用项目根的 `scratch/`**. ⚠ **不是写 build/normalize 脚本生成 workflow.json 的地方** —— 那个用 §2 `wf-patch.py` | Agent (随便 dump, 跟着 case 一起清理) |
+| `outputs/case-{N}/understanding.md` | 阶段一 1.1 心智模型 (含多工序判断) | Agent |
+| `outputs/case-{N}/workflow.json` | **唯一中间产物** — Phase 1.2 用 `wf-patch.py --create` 建骨架 (扁平清单, 免手写嵌套 JSON; 也可 Write 模板但易错), 之后**逐字段演化**: 批量结构化字段 (1.3 anchor / 2 effect·action·type + step 级 substance·form) 用 `wf-patch.py`, 单处零星用 Edit. **绝不写 Python 脚本生成 / 批改它**. 符合 case-data.schema.json (`procedures:[]`) | Agent |
+| `outputs/case-{N}/case-{N}-<slug>.html` | 阶段三 .html 输出 (跑 render-case.py 生成; **唯一产物, .md 已取消**) | render-case.py |
+
+---
+
+## 7. 第二阶段不再用子助手
+
+第二阶段(作用/动作/类型 归类 + 实质/形式 提炼)现在**由主流程自己一趟做完**——不再切任务、不再召唤子助手、不再查"实质/形式"大词表(实质/形式 改为自由提炼元素点)。具体见 [phase2-normalize.md](extraction/phase2-normalize.md)。
+

+ 588 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/lint-case.py

@@ -0,0 +1,588 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+lint-case.py — workflow.json 轻量 lint + 自动 record 新 type 到 type_suggestions.md.
+
+设计哲学: **不严格**.
+  - 不分 error/warning 等级, 不卡 exit code (都返 0)
+  - 主要副作用是 record 新 type 到 spec/taxonomy/type_suggestions.md
+  - 检测项打 stdout 给 Agent / 用户看, 决定要不要回去修
+
+用法:
+    python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N}
+    python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N} --no-record   # 只校验不写
+
+退出码:
+    0  始终 (不阻塞流程)
+    2  CLI 参数错误 / 文件不存在
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+# spec/tools/lint-case.py → procedure-dsl/
+DSL_ROOT = Path(__file__).resolve().parent.parent.parent
+TYPE_JSON = DSL_ROOT / 'spec' / 'taxonomy' / 'type.json'
+SUGGESTIONS = DSL_ROOT / 'spec' / 'taxonomy' / 'type_suggestions.md'
+
+
+# Windows 控制台 UTF-8
+for _s in (sys.stdout, sys.stderr):
+    if hasattr(_s, 'reconfigure'):
+        try:
+            _s.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+
+
+def load_type_leaves() -> set[str]:
+    """读 spec/taxonomy/type.json 的 $leaves 集合."""
+    if not TYPE_JSON.exists():
+        return set()
+    return set(json.loads(TYPE_JSON.read_text(encoding='utf-8')).get('$leaves', []))
+
+
+# ===========================================================================
+# Check 1: type 完整性提示
+# ===========================================================================
+
+def _iter_procedures(case_data: dict):
+    """遍历 workflow.json 的 procedures.
+
+    Yields: (procedure_label, procedure_dict) — 含 steps + type_registry.
+    """
+    for p in case_data.get('procedures') or []:
+        label = p.get('id') or p.get('name') or '?'
+        yield (label, p)
+
+
+def check_type_completeness(case_data: dict) -> list[str]:
+    """IO 用了 case-specific type 但 type_registry 漏写 entry → 提示.
+
+    只 hint 不 fail. Agent 看输出回去修.
+    多工序时, hint 前缀加 [proc_id] 让用户知道是哪个工序的问题.
+    """
+    leaves = load_type_leaves()
+    hints: list[str] = []
+
+    for proc_label, proc in _iter_procedures(case_data):
+        type_reg = proc.get('type_registry') or {}
+
+        for i, step in enumerate(proc.get('steps') or []):
+            if not isinstance(step, dict):
+                continue
+            for kind in ('inputs', 'outputs'):
+                for j, item in enumerate(step.get(kind) or []):
+                    if not isinstance(item, dict):
+                        continue
+                    t = item.get('type', '') or ''
+                    if not t:
+                        continue
+                    if t in leaves:
+                        continue   # 字典叶子, OK
+                    if t not in type_reg:
+                        hints.append(
+                            f"[{proc_label}] step[{i}].{kind}[{j}].type={t!r} 是 case-specific "
+                            f"但 type_registry 没注册"
+                        )
+                    else:
+                        entry = type_reg[t]
+                        if isinstance(entry, dict):
+                            if not entry.get('extends'):
+                                hints.append(f"[{proc_label}] type_registry[{t!r}] 缺 extends 字段")
+                            if not entry.get('desc'):
+                                hints.append(f"[{proc_label}] type_registry[{t!r}] 缺 desc 字段 (renderer drawer 显示需要)")
+    return hints
+
+
+# ===========================================================================
+# Check 2: value / directive 自包含性 (禁止引用占位)
+# ===========================================================================
+
+# value/directive 应填数据本身, 不是 anchor 的引用. 命中即「没真正回填」.
+META_REF = re.compile(r'[((]?\s*同\s*s[\d]|见\s*s[\d]|←\s*s[\d]|同上')
+
+
+def check_value_selfcontained(case_data: dict) -> list[str]:
+    """扫每个 IO 的 value + 每个 directive, 找「引用占位」文案 (同 sX / 见 sX / ← sX ...).
+
+    spec: value 逐字回填数据本身, 引用归 anchor (workflow-format.md §2 数据流).
+    这种占位 schema/type 检查抓不到, 专门一条. 只 hint. 可用
+    `wf-patch.py --resolve-passthrough` 自动从源回填.
+    """
+    hints: list[str] = []
+    for proc_label, proc in _iter_procedures(case_data):
+        for i, step in enumerate(proc.get('steps') or []):
+            if not isinstance(step, dict):
+                continue
+            for kind in ('inputs', 'outputs'):
+                for j, item in enumerate(step.get(kind) or []):
+                    if not isinstance(item, dict):
+                        continue
+                    v = item.get('value')
+                    if isinstance(v, str) and META_REF.search(v):
+                        hints.append(f"[{proc_label}] step[{i}].{kind}[{j}].value 是引用占位 {v[:24]!r} — 应逐字回填数据本身")
+            t = step.get('directive')
+            if isinstance(t, str) and META_REF.search(t):
+                hints.append(f"[{proc_label}] step[{i}].directive 是引用占位 {t[:24]!r} — 应填实际 prompt 原文")
+    return hints
+
+
+# ===========================================================================
+# Check 3: value 占位 / directive 缺失 (提示用 quote-source 回填真内容)
+# ===========================================================================
+
+# 纯 <...> 占位 (value 该填真实内容, <...> 仅限无文字的图/视频)
+PLACEHOLDER_RE = re.compile(r'^\s*<[^>]*>\s*$')
+
+# 模态分类关键词 (TEXT 优先于 MEDIA, 因 "配音文案" 这类既含媒体词又是文本)
+_TEXT_KW = ('提示词', '描述', '参数', '评', '大纲', '脚本', '文案', '歌词', '字幕',
+            '标题', '正文', '词', '知识', '工作流', '对标', '规格', '批处理', '模板', '版式',
+            '数据', '分析', '报告', '记录', '方案', '思路', '设定', '依据', '标准', '清单', '列表', '文本', '文字')
+_MEDIA_KW = ('图', '视频', '音频', '帧', '片段', '截图', '蒙版', '音效', '配音', 'BGM',
+             '数字人', '滤镜', '海报', '封面')
+
+
+def _type_modality(type_name: str, type_reg: dict) -> str:
+    """按类型名(case-specific 类型先经 type_registry.extends 解析到 stdlib 叶子)判模态.
+
+    返回 'media' (图/视频/音频 — 可 <描述>) / 'text' (提示词/数据/报告 — 必须真实文本) / 'unknown'.
+    media 用关键词可靠识别; 非 media 一律按"需真实文本"对待 (data/text 占多数, 宁严勿漏).
+    """
+    base, seen = type_name, set()
+    while base in (type_reg or {}) and base not in seen:
+        seen.add(base)
+        ent = type_reg[base]
+        ext = ent.get('extends') if isinstance(ent, dict) else None
+        if not ext:
+            break
+        base = ext
+    nm = base or type_name or ''
+    if any(k in nm for k in _TEXT_KW):
+        return 'text'
+    if any(k in nm for k in _MEDIA_KW):
+        return 'media'
+    return 'unknown'
+
+
+def check_placeholder_content(case_data: dict) -> list[str]:
+    """逐 IO 按模态审计 value + 工具步骤 directive → 提示用 quote-source 回填真内容.
+
+    规则 (phase1 §value): 文本类 IO(提示词/数据/报告)的 value 必须是从原文匹配到的真实内容,
+    不能写 <…> 占位; 原文确实没有 → 标 inferred:true + inferred_reason 显式说明 (本检查放行).
+    媒体类 IO(图/视频/音频)允许 <具体描述>. 工具步骤(via 是具体工具)必须带原文那段 prompt 当 directive.
+    弱模型常把所有 value 写成 <…> 占位、整个漏 directive (实测 test-7 全踩), 这条逐 IO 抓, 只 hint.
+    """
+    hints: list[str] = []
+    for proc_label, proc in _iter_procedures(case_data):
+        type_reg = proc.get('type_registry') or {}
+        for i, step in enumerate(proc.get('steps') or []):
+            if not isinstance(step, dict):
+                continue
+            for kind in ('inputs', 'outputs'):
+                for j, item in enumerate(step.get(kind) or []):
+                    if not isinstance(item, dict):
+                        continue
+                    if item.get('inferred'):       # 已显式标 inferred 说明 → 放行
+                        continue
+                    v = item.get('value')
+                    if not isinstance(v, str):
+                        continue
+                    if re.search(r'原文(未提供|未给出|没有|无)', v):
+                        continue                   # 显式标「原文未提供」→ 放行 (LLM 确认原文确无)
+                    if not PLACEHOLDER_RE.match(v):
+                        continue                   # value 不是 <…> 占位 (已填真内容)
+                    t = item.get('type', '') or ''
+                    mod = _type_modality(t, type_reg)
+                    if mod == 'media':
+                        continue                   # 图/视频/音频 用 <描述> 合理
+                    label = '文本类' if mod == 'text' else '非媒体(疑似数据/文本)'
+                    # 输出占位 = 步骤产出物没回填; 原文/OCR 里通常紧跟在 prompt 后展示了它
+                    extra = (';这是步骤**产出物**, 原文/配图 OCR 里常紧跟 prompt 展示了它, '
+                             '用 quote-source --from/--to 把那段产出也捞进 value') if kind == 'outputs' else ''
+                    hints.append(
+                        f"[{proc_label}] step[{i}].{kind}[{j}] type={t!r}({label}) value={v.strip()!r} 仍是占位 "
+                        f"—— 你即便已 quote 到原文也**必须把真实内容替换进 value**(别只填 directive){extra}; "
+                        f"原文确无则标 inferred:true + inferred_reason; 若其实是无文字图/视频, 让类型/描述体现"
+                    )
+            via = (step.get('via') or '').strip()
+            directive = (step.get('directive') or '').strip()
+            if step.get('kind', 'step') == 'step' and via and via not in ('human', '-') and not directive:
+                hints.append(
+                    f"[{proc_label}] step[{i}](via={via!r}) directive 空 — 若原文有给工具的提示词/指令, "
+                    f"用 quote-source 捞原文那段填进 directive"
+                )
+            # substance/form 缺失 (Phase 2 该提炼实质/形式; 纯技术步可显式设 null, 但别整个漏掉 key)
+            if step.get('kind', 'step') in ('step', 'nested'):
+                miss = [f for f in ('substance', 'form') if f not in step]
+                if miss:
+                    hints.append(
+                        f"[{proc_label}] step[{i}] 缺 {'/'.join(miss)} — Phase 2 漏做了实质/形式提炼; "
+                        f"读懂这步内容提炼元素点填上(纯技术步可显式设 null, 但别漏掉字段)"
+                    )
+            # intent 缺失 (Phase 2 每步都要填目的列, 一句话概括)
+            if step.get('kind', 'step') in ('step', 'block', 'nested') and not (step.get('intent') or '').strip():
+                hints.append(
+                    f"[{proc_label}] step[{i}] 缺 intent — Phase 2 每步都要填目的列(一句话概括这步在做什么, ≤25 字)"
+                )
+    return hints
+
+
+# ===========================================================================
+# Check 4: 章节覆盖 (结构强制 — 需 --source) + value 逐字 (值强制 — 需 --source)
+# ===========================================================================
+#
+# 弱模型在 Phase 1 骨架阶段走两条最省力的路, 都靠"看原文"才抓得到:
+#   (结构) 只挑两个最显眼的工序就收工, 整段章节(框架/附加案例/总结)漏抽
+#   (值)   挑中的 value 也打字缩写成标题纲要, 不是逐字原文 (能过 render 门禁因为不是 <占位>)
+# 这两条 check 都需要原文 (--source input/case-N.json [--ocr ocr.txt]) 才能比对.
+
+# 比对噪声: 空白 + 各式引号 (原文 “”、骨架常写成 「」/"",内容一致只是引号风格不同, 不该算缩写)
+_QUOTE_NOISE = dict.fromkeys(map(ord, '「」『』“”‘’"\'"''), None)
+
+
+def _norm(s: str) -> str:
+    """归一化用于子串比对: 去所有空白 (原文常把一个词拆到两行) + 抹掉引号风格差异."""
+    return re.sub(r'\s+', '', s or '').translate(_QUOTE_NOISE)
+
+
+def _load_source_corpus(source_path: Path | None, ocr_path: Path | None) -> tuple[str, str]:
+    """读原文语料: 返回 (raw_text, normed). raw 用来切章节, normed 用来子串比对.
+
+    source = input/case-N.json 的 title + body_text; ocr = 配图 OCR 文本 (可选).
+    """
+    parts: list[str] = []
+    if source_path and source_path.exists():
+        try:
+            sd = json.loads(source_path.read_text(encoding='utf-8'))
+            parts.append(sd.get('title', '') or '')
+            parts.append(sd.get('body_text', '') or sd.get('content', '') or '')
+        except Exception:
+            parts.append(source_path.read_text(encoding='utf-8'))
+    if ocr_path and ocr_path.exists():
+        parts.append(ocr_path.read_text(encoding='utf-8'))
+    raw = '\n'.join(parts)
+    return raw, _norm(raw)
+
+
+def _sections(body: str) -> list[tuple[str, str, str]]:
+    """切原文章节: 按行首 `0N |` 标号 (排除 `图 0N |` 配图说明). 返回 [(号, 标题, 正文段)]."""
+    marks = [(m.start(), m.group(1)) for m in re.finditer(r'(?m)^\s*(0\d)\s*[||]', body)]
+    out: list[tuple[str, str, str]] = []
+    for idx, (pos, num) in enumerate(marks):
+        end = marks[idx + 1][0] if idx + 1 < len(marks) else len(body)
+        seg = body[pos:end]
+        after = re.split(r'[||]', seg, 1)
+        tail = after[-1] if len(after) > 1 else seg
+        title = ''
+        for line in tail.splitlines():
+            line = line.strip()
+            if line:
+                title = line[:24]
+                break
+        out.append((num, title, seg))
+    return out
+
+
+# 章节正文里的"要点标记": 思路X / 第X层 / 第X步 / 案例X / 冒号短标签 (人物特征:…)
+_POINT_MARKER = re.compile(
+    r'(?m)^\s*(思路[一二三四五]|第[一二三四五六七八九十]+[层步]|案例[一二三四五六七八九十]+)')
+_POINT_COLON = re.compile(r'(?m)^\s*([^\n::((]{2,12})\s*[::]')
+
+
+def _section_points(seg: str) -> list[str]:
+    """抽一节正文的要点短语 (用来量化它被骨架覆盖了多少)."""
+    pts: list[str] = []
+    for m in _POINT_MARKER.finditer(seg):
+        line = seg[m.start():].splitlines()[0].strip()
+        pts.append(line[:16])
+    for m in _POINT_COLON.finditer(seg):
+        lab = m.group(1).strip()
+        # 纯序号标记 (第X步/第X层/思路X/案例X) 是结构序号不是内容要点, 骨架改写成动作后必然对不上 → 跳过
+        if re.fullmatch(r'(思路[一二三四五]|第[一二三四五六七八九十]+[层步]|案例[一二三四五六七八九十]+)', lab):
+            continue
+        if re.search(r'[一-龥]', lab):
+            pts.append(lab)
+    seen: set[str] = set()
+    out: list[str] = []
+    for p in pts:
+        if p and p not in seen:
+            seen.add(p)
+            out.append(p)
+    return out
+
+
+def _point_covered(point: str, wf_norm: str) -> bool:
+    """要点是否被骨架覆盖: 去掉结构前缀后, 任一 4-gram 命中 workflow 文本即算覆盖 (从宽)."""
+    core = re.sub(r'^(思路[一二三四五]|第[一二三四五六七八九十]+[层步]|案例[一二三四五六七八九十]+)', '', point)
+    core = _norm(core) or _norm(point)
+    if len(core) < 4:
+        return core in wf_norm
+    return any(core[k:k + 4] in wf_norm for k in range(len(core) - 3))
+
+
+def check_section_coverage(case_data: dict, source_raw: str, wf_norm: str) -> list[str]:
+    """结构强制: 逐章节算骨架覆盖率, 整段漏抽的章节 (<40%) 报出来 + 给缺失要点样例."""
+    hints: list[str] = []
+    secs = _sections(source_raw)
+    if not secs:
+        return hints
+    for num, title, seg in secs:
+        pts = _section_points(seg)
+        if len(pts) < 2:
+            continue   # 没足够要点 (纯过渡/口号段), 不评判
+        missed = [p for p in pts if not _point_covered(p, wf_norm)]
+        ratio = 1 - len(missed) / len(pts)
+        if ratio < 0.40:
+            sample = '、'.join(missed[:5])
+            hints.append(
+                f"章节『{num} {title}』覆盖率 {ratio:.0%} ({len(pts) - len(missed)}/{len(pts)} 要点) "
+                f"—— 疑似整段漏抽; 缺: {sample}{' …' if len(missed) > 5 else ''}; "
+                f"回去为它补 procedure/step (每个 0N 章节至少对应一个工序或子步骤)"
+            )
+    return hints
+
+
+def _longest_run(v_norm: str, source_norm: str) -> int:
+    """value 在原文里能连续命中的最长子串长度. 逐字原文应是原文一整段连续文本;
+    拼接/缩写出来的(把分散的小标题用、串起来)最长连续命中会很短."""
+    n = len(v_norm)
+    best = 0
+    for i in range(n):
+        if n - i <= best:
+            break                       # 剩余长度已不可能超过 best
+        lo, hi = 0, n - i
+        while lo < hi:                  # 二分该起点能命中的最长长度
+            mid = (lo + hi + 1) // 2
+            if v_norm[i:i + mid] in source_norm:
+                lo = mid
+            else:
+                hi = mid - 1
+        if lo > best:
+            best = lo
+    return best
+
+
+def check_value_verbatim(case_data: dict, source_norm: str) -> list[str]:
+    """值强制: 文本类 value 必须是原文里的「一整段连续文本」. 最长连续命中 <80% 判缩写/改写/截断.
+
+    跳过: 占位<…>(归 check3)、inferred、原文未提供、未 resolve 的 @quote、媒体类、短值(<12字).
+    用最长连续命中而非逐子句覆盖: 后者会被「人物、产品、环境」这种"原文小标题拼盘"骗过
+    (每个词单独在原文里, 但整体不是任何一段原文 — 真正的逐字细节全被丢了).
+    """
+    hints: list[str] = []
+    if not source_norm:
+        return hints
+    for proc_label, proc in _iter_procedures(case_data):
+        type_reg = proc.get('type_registry') or {}
+        for i, step in enumerate(proc.get('steps') or []):
+            if not isinstance(step, dict):
+                continue
+            for kind in ('inputs', 'outputs'):
+                for j, item in enumerate(step.get(kind) or []):
+                    if not isinstance(item, dict):
+                        continue
+                    if item.get('inferred'):
+                        continue
+                    v = item.get('value')
+                    if not isinstance(v, str) or not v.strip():
+                        continue
+                    if PLACEHOLDER_RE.match(v):
+                        continue                          # 占位 → check3 管
+                    if v.startswith('@quote'):
+                        continue                          # 未回填的 quote, resolve 后才比
+                    if re.search(r'原文(未提供|未给出|没有|无)', v):
+                        continue
+                    if _type_modality(item.get('type', '') or '', type_reg) == 'media':
+                        continue                          # 媒体描述不要求逐字
+                    vn = _norm(v)
+                    if len(vn) < 12:
+                        continue                          # 短标签不查 (无所谓缩写)
+                    run = _longest_run(vn, source_norm)
+                    ratio = run / len(vn)
+                    # 绝对护栏: 连续命中 ≥80 字 = 铁证级真引用 (没人会"凑巧"逐字打 80 字),
+                    # 哪怕整体比例因中途一处微小偏差掉到 80% 也放行, 只抓"短值缩写"。
+                    if run >= 80:
+                        continue
+                    if ratio < 0.80:
+                        hints.append(
+                            f"[{proc_label}] step[{i}].{kind}[{j}] value 最长连续命中原文仅 {run}/{len(vn)} 字"
+                            f"({ratio:.0%}) —— 整体不是一整段原文(疑似开头逐字后就缩写/改写); value={v[:40]!r}…; "
+                            f"用 @quote|起锚|止锚 + wf-patch --resolve-quotes 把整段原文逐字拽进来(原文那段提示词约 350 字)"
+                        )
+    return hints
+
+
+# ===========================================================================
+# Side effect: record 新 type 到 type_suggestions.md
+# ===========================================================================
+
+def record_new_types(case_data: dict, suggestions_path: Path = SUGGESTIONS) -> list[str]:
+    """把 case_data.type_registry 里的 case-specific type append 到 suggestions.
+
+    幂等: 同一 (type_name, case_id) 二元组只 append 一次. Dedup 靠 grep 现有文件,
+    抽 `(来自 case-{N})` + 类型名 二元组.
+
+    Returns:
+        本次新写入的条目 list (空 list = 没新东西要 record).
+    """
+    # 合并所有 procedures.type_registry
+    type_reg: dict = {}
+    for p in case_data.get('procedures', []):
+        type_reg.update(p.get('type_registry') or {})
+    if not type_reg:
+        return []
+
+    leaves = load_type_leaves()
+    case_id = case_data.get('case_id') or '?'
+    text = suggestions_path.read_text(encoding='utf-8') if suggestions_path.exists() else ''
+
+    # 已 record 过的 (type_name, case_id) — 用 regex 抓 markdown list entry
+    existing = set(re.findall(
+        r'^- `([^`]+)`:.*?\(来自 case-([^,)\s]+)', text, re.M
+    ))
+
+    new_lines: list[str] = []
+    for tname, entry in type_reg.items():
+        if not isinstance(entry, dict):
+            continue
+        if tname in leaves:
+            continue   # 已是字典叶子, 不是新 type (Agent 误把 stdlib type 加进 case_data.type_registry)
+        if (tname, str(case_id)) in existing:
+            continue
+        ext = entry.get('extends', '?')
+        desc = entry.get('desc') or '(无 desc)'
+        new_lines.append(f'- `{tname}`: {desc}  (来自 case-{case_id}, extends `{ext}`)')
+
+    if new_lines:
+        # 确保 suggestions 文件存在 (没有就建个空骨架)
+        if not suggestions_path.exists():
+            suggestions_path.write_text(
+                '# Type 字典扩展建议\n\n## 累积条目\n\n', encoding='utf-8'
+            )
+        # append 末尾
+        with suggestions_path.open('a', encoding='utf-8') as f:
+            f.write('\n' + '\n'.join(new_lines) + '\n')
+
+    return new_lines
+
+
+# ===========================================================================
+# main
+# ===========================================================================
+
+def main() -> None:
+    ap = argparse.ArgumentParser(
+        prog='lint-case.py',
+        description='workflow 轻量 lint + 自动 record 新 type 到 type_suggestions.md',
+    )
+    ap.add_argument('--workflow', type=Path, required=True,
+                    help='workflow.json (含 procedures 数组). lint 内部读 procedures + type_registry')
+    ap.add_argument('--case-id', type=str, default=None,
+                    help='record suggestions 用的 case_id. 不传就 fallback workflow.case_id 或 ?')
+    ap.add_argument('--source', type=Path, default=None,
+                    help='原文 input/case-N.json. 传了才启用「章节覆盖」+「value 逐字」两条结构/值强制校验')
+    ap.add_argument('--ocr', type=Path, default=None,
+                    help='配图 OCR 文本 (可选). 并入原文语料, 让逐字校验也认配图里的文字')
+    ap.add_argument('--no-record', action='store_true',
+                    help='只校验, 不写 suggestions')
+    args = ap.parse_args()
+
+    target_path = args.workflow
+    if not target_path.exists():
+        print(f'lint-case: 文件不存在 {target_path}', file=sys.stderr)
+        sys.exit(2)
+
+    try:
+        case_data = json.loads(target_path.read_text(encoding='utf-8'))
+    except json.JSONDecodeError as e:
+        print(f'lint-case: {target_path} 不是合法 JSON: {e}', file=sys.stderr)
+        sys.exit(2)
+
+    # workflow 模式: 注入 case_id (suggestions record 需要)
+    if args.case_id is not None and 'case_id' not in case_data:
+        try:
+            case_data['case_id'] = int(args.case_id)
+        except ValueError:
+            case_data['case_id'] = args.case_id
+
+    case_id = case_data.get('case_id', '?')
+    print(f'[lint] case-{case_id} ({target_path.name})')
+
+    # check: type 完整性提示
+    hints = check_type_completeness(case_data)
+    if hints:
+        print(f'  · type 完整性: {len(hints)} 个提示')
+        for h in hints:
+            print(f'      - {h}')
+    else:
+        print('  · type 完整性: OK')
+
+    # check: value / directive 自包含性 (引用占位)
+    vhints = check_value_selfcontained(case_data)
+    if vhints:
+        print(f'  · value 自包含: {len(vhints)} 个引用占位 (跑 wf-patch.py --resolve-passthrough 自动回填)')
+        for h in vhints:
+            print(f'      - {h}')
+    else:
+        print('  · value 自包含: OK')
+
+    # check: value 占位 / directive 缺失 (用 quote-source 回填真内容)
+    chints = check_placeholder_content(case_data)
+    if chints:
+        print(f'  · value/directive 真实性: {len(chints)} 处占位/缺失 (用 quote-source.py 从原文/配图 OCR 捞真内容回填)')
+        for h in chints:
+            print(f'      - {h}')
+    else:
+        print('  · value/directive 真实性: OK')
+
+    # check: 章节覆盖 + value 逐字 (结构/值强制 — 需 --source)
+    if args.source is not None:
+        source_raw, source_norm = _load_source_corpus(args.source, args.ocr)
+        wf_norm = _norm(json.dumps(case_data, ensure_ascii=False))
+
+        shints = check_section_coverage(case_data, source_raw, wf_norm)
+        if shints:
+            print(f'  · 章节覆盖(结构强制): {len(shints)} 个章节疑似漏抽 —— 骨架要覆盖原文每个章节')
+            for h in shints:
+                print(f'      - {h}')
+        else:
+            print('  · 章节覆盖(结构强制): OK (原文各章节都有对应工序)')
+
+        bhints = check_value_verbatim(case_data, source_norm)
+        if bhints:
+            print(f'  · value 逐字(值强制): {len(bhints)} 处疑似缩写/改写 —— 文本类 value 要逐字搬原文(用 @quote)')
+            for h in bhints:
+                print(f'      - {h}')
+        else:
+            print('  · value 逐字(值强制): OK (文本类 value 都逐字命中原文)')
+    else:
+        print('  · 章节覆盖 + value 逐字: skipped (传 --source input/case-N.json [--ocr ocr.txt] 启用结构/值强制)')
+
+    # side effect: record 新 type
+    if not args.no_record:
+        new_lines = record_new_types(case_data)
+        if new_lines:
+            print(f'  · 已 record {len(new_lines)} 条新 type 到 {SUGGESTIONS.name}:')
+            for ln in new_lines:
+                print(f'      {ln}')
+        else:
+            # 合并所有 procedure type_registry
+            merged_reg: dict = {}
+            for p in case_data.get('procedures', []):
+                merged_reg.update(p.get('type_registry') or {})
+            if not merged_reg:
+                print('  · 无新 type 可 record (type_registry 为空 — 全部 type 命中字典叶子)')
+            else:
+                print('  · 无新 type 可 record (type_registry 里的项已全部 record 过)')
+
+    # 不卡 exit code
+    sys.exit(0)
+
+
+if __name__ == '__main__':
+    main()

+ 239 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/quote-source.py

@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+"""quote-source.py — 从原文 case json 按字符匹配查找并**原样返回**片段, 供填 value / directive。
+
+为什么需要: value / directive 要填原文里的真实内容(那段 JSON 风格分析、那句完整提示词),
+不能写 "<JSON 风格分析数据>" 这种空壳, 也不能凭记忆缩写。用本工具把原文那段逐字捞出来,
+再粘进 workflow.json 的 value / directive。
+
+匹配是**空白无关**的: 原文 body_text 常有乱换行 / 前导空格("交给\\n AI"), 直接子串匹配
+多词查询会落空。本工具匹配时忽略所有空白, 但返回的是**原文逐字片段**(连原排版一起)。
+
+用法 (Agent 走 bash_command 调):
+    # 按几个关键词捞出周边片段 (默认前后各 300 字)
+    python spec/tools/quote-source.py --source input/case-N.json --query "视觉风格分析"
+    # 捞大块时放宽窗口
+    python spec/tools/quote-source.py --source input/case-N.json --query "主色调" --window 800
+    # 命中点落在 {...} 里时, 返回整个 JSON 块 (适合捞那段结构化分析)
+    python spec/tools/quote-source.py --source input/case-N.json --query "视觉风格分析" --json-block
+    # 捞一句完整提示词当 directive
+    python spec/tools/quote-source.py --source input/case-N.json --query "请以 JSON 结构化"
+    # 范围引用: 用首尾两个短锚点引出之间的整段长原文 (比 --window 猜长度精确)
+    python spec/tools/quote-source.py --source input/case-N.json --from "请以 JSON" --to "500 字以内"
+    python spec/tools/quote-source.py --source input/case-N.json --from "视觉风格分析" --to "电影感人像"
+
+输出: 命中的原文逐字片段 (多处命中各自分隔)。无精确命中时给出最接近的一段 (标 ~approx)。
+退出码: 0 有命中(含 approx) / 1 完全找不到 / 2 CLI/IO 错。
+"""
+import argparse
+import json
+import sys
+from pathlib import Path
+
+for _s in (sys.stdout, sys.stderr):
+    if hasattr(_s, "reconfigure"):
+        try:
+            _s.reconfigure(encoding="utf-8", errors="replace")
+        except Exception:
+            pass
+
+
+def _source_text(path: Path) -> str:
+    """从 case json 抽可搜索文本: title + body_text (两套 schema 都有 body_text)。"""
+    data = json.loads(path.read_text(encoding="utf-8"))
+    if not isinstance(data, dict):
+        return str(data)
+    parts = []
+    for k in ("title", "body_text"):
+        v = data.get(k)
+        if isinstance(v, str) and v:
+            parts.append(v)
+    return "\n".join(parts)
+
+
+def _build_norm(text: str):
+    """去掉所有空白, 返回 (norm_str, idxmap)。idxmap[i] = norm 第 i 个字符在原文里的下标。"""
+    chars, idxmap = [], []
+    for i, ch in enumerate(text):
+        if ch.isspace():
+            continue
+        chars.append(ch)
+        idxmap.append(i)
+    return "".join(chars), idxmap
+
+
+def _find_all(ns: str, nq: str, max_hits: int):
+    hits, start = [], 0
+    while len(hits) < max_hits:
+        p = ns.find(nq, start)
+        if p < 0:
+            break
+        hits.append(p)
+        start = p + max(1, len(nq))
+    return hits
+
+
+def _enclosing_braces(text: str, lo: int, hi: int):
+    """找包住 [lo,hi] 的最小 {...} (按花括号配平)。找不到返回 None。"""
+    # 向左找候选 '{'
+    depth = 0
+    open_pos = None
+    i = lo
+    while i >= 0:
+        if text[i] == "}":
+            depth += 1
+        elif text[i] == "{":
+            if depth == 0:
+                open_pos = i
+                break
+            depth -= 1
+        i -= 1
+    if open_pos is None:
+        return None
+    # 从 open_pos 向右配平
+    depth = 0
+    j = open_pos
+    while j < len(text):
+        if text[j] == "{":
+            depth += 1
+        elif text[j] == "}":
+            depth -= 1
+            if depth == 0:
+                if j >= hi:
+                    return (open_pos, j)
+                return None
+        j += 1
+    return None
+
+
+def _approx(ns: str, nq: str, idxmap, text: str, window: int):
+    """无精确命中时, 用最长公共子串给最接近的一段 (标 approx)。"""
+    from difflib import SequenceMatcher
+    if not nq:
+        return None
+    m = SequenceMatcher(None, ns, nq, autojunk=False).find_longest_match(0, len(ns), 0, len(nq))
+    if m.size == 0:
+        return None
+    lo_n, hi_n = m.a, m.a + m.size - 1
+    return _slice(idxmap, text, lo_n, hi_n, window, False)
+
+
+def _slice(idxmap, text: str, lo_n: int, hi_n: int, window: int, json_block: bool):
+    o_lo = idxmap[lo_n]
+    o_hi = idxmap[hi_n]
+    if json_block:
+        br = _enclosing_braces(text, o_lo, o_hi)
+        if br:
+            return text[br[0]: br[1] + 1], br[0], br[1]
+    lo = max(0, o_lo - window)
+    hi = min(len(text), o_hi + 1 + window)
+    return text[lo:hi], lo, hi
+
+
+def _range_between(text: str, frm: str, to: str):
+    """范围引用: 返回 frm 首次出现 → 其后首次 to 结束 之间(含两端)的原文逐字片段。
+
+    匹配空白无关; to 必须落在 frm 之后(取最近的一个), 给出最小包裹区间。找不到返回 None。
+    """
+    ns, idxmap = _build_norm(text)
+    nf, _ = _build_norm(frm)
+    nt, _ = _build_norm(to)
+    if not nf or not nt:
+        return None
+    p = ns.find(nf)
+    if p < 0:
+        return None
+    q = ns.find(nt, p + len(nf))      # to 必须在 from 之后
+    if q < 0:
+        return None
+    o_lo = idxmap[p]
+    o_hi = idxmap[q + len(nt) - 1]
+    return text[o_lo:o_hi + 1], o_lo, o_hi
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description="从原文 case json 按字符匹配原样捞片段 (供填 value/directive)")
+    ap.add_argument("--source", required=True, help="原文 case json 路径")
+    ap.add_argument("--query", default=None, help="关键词模式: 要查找的文字 (空白无关匹配; 配 --window / --json-block)")
+    ap.add_argument("--from", dest="frm", default=None, help="范围模式: 起始锚点 (短串)")
+    ap.add_argument("--to", dest="to", default=None, help="范围模式: 结束锚点 (短串); 与 --from 同用, 用两个短参数引出之间(含)的整段长原文")
+    ap.add_argument("--ocr", default=None, help="配图 OCR 文本文件 (如 <case_dir>/_scratch/ocr.txt); 一并搜图片里的文字")
+    ap.add_argument("--window", type=int, default=300, help="命中点前后各保留多少字 (default 300)")
+    ap.add_argument("--max", type=int, default=3, help="每个语料最多返回几处命中 (default 3)")
+    ap.add_argument("--json-block", action="store_true", help="命中落在 {...} 内时返回整个 JSON 块")
+    args = ap.parse_args()
+
+    src = Path(args.source)
+    if not src.exists():
+        print(f"source not found: {src}", file=sys.stderr)
+        return 2
+    try:
+        corpora = [("原文", _source_text(src))]
+    except (json.JSONDecodeError, OSError) as e:
+        print(f"read source failed: {e}", file=sys.stderr)
+        return 2
+    # 第二语料: 配图 OCR 文本
+    if args.ocr:
+        ocr_p = Path(args.ocr)
+        if ocr_p.exists():
+            corpora.append(("配图OCR", ocr_p.read_text(encoding="utf-8")))
+        else:
+            print(f"# (--ocr {ocr_p} 不存在, 仅搜原文)", file=sys.stderr)
+
+    # ── 范围模式: --from / --to 两个短锚点 → 返回之间(含)的整段原文 ──
+    if args.frm is not None or args.to is not None:
+        if not (args.frm and args.to):
+            print("范围模式需同时给 --from 和 --to", file=sys.stderr)
+            return 2
+        out = []
+        for label, text in corpora:
+            rng = _range_between(text, args.frm, args.to)
+            if rng:
+                frag, lo, hi = rng
+                warn = "  ⚠ 跨度很大, 确认锚点是否选窄了" if (hi - lo) > 4000 else ""
+                out.append(f"# [{label}] 范围命中 (第 {lo}–{hi} 字, 共 {hi - lo + 1} 字{warn}; 下为两锚点间逐字原文)\n{frag}")
+        if out:
+            print("\n\n".join(out))
+            return 0
+        print(f"# 找不到从 {args.frm!r} 到 {args.to!r} 的范围 (检查: 两锚点是否各自存在、to 是否在 from 之后)", file=sys.stderr)
+        return 1
+
+    # ── 关键词模式: --query ──
+    if not args.query:
+        print("需要 --query, 或 (--from + --to) 范围模式", file=sys.stderr)
+        return 2
+    nq, _ = _build_norm(args.query)
+    if not nq:
+        print("query 去空白后为空", file=sys.stderr)
+        return 2
+
+    out, found = [], False
+    for label, text in corpora:
+        ns, idxmap = _build_norm(text)
+        hits = _find_all(ns, nq, args.max)
+        if hits:
+            found = True
+            out.append(f"# [{label}] {len(hits)} 处命中 (query={args.query!r}, 空白无关匹配; 下为逐字片段)")
+            for k, p in enumerate(hits, 1):
+                frag, lo, hi = _slice(idxmap, text, p, p + len(nq) - 1, args.window, args.json_block)
+                out.append(f"\n--- [{label}] 命中 {k} (第 {lo}–{hi} 字) ---\n{frag}")
+    if found:
+        print("\n".join(out))
+        return 0
+
+    # 无精确命中 → 各语料给最接近的一段
+    for label, text in corpora:
+        ns, idxmap = _build_norm(text)
+        ap_res = _approx(ns, nq, idxmap, text, args.window)
+        if ap_res:
+            frag, lo, hi = ap_res
+            out.append(f"# [{label}] 无精确命中, 最接近的一段 (~approx, 第 {lo}–{hi} 字; 请核对):\n\n{frag}")
+    if out:
+        print("\n".join(out))
+        return 0
+
+    print(f"# 原文/OCR 里都找不到与 {args.query!r} 相关的内容", file=sys.stderr)
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 346 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/render-case.py

@@ -0,0 +1,346 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+render-case.py — Procedure DSL · 阶段三 3.3 落盘&渲染脚本.
+
+把 case_data (符合 spec/format/case-data.schema.json) 通过 spec/tools/renderer.build_html
+渲染成单文件 HTML.
+
+用法:
+
+  渲染:
+       render-case.py --workflow outputs/case-N/workflow.json \
+                      --source-input input/case-N-raw.json \
+                      --page-title "Case N · 主题" --case-id N \
+                      --out outputs/case-N/case-N-<slug>.html
+
+  只校验, 不渲染:
+       render-case.py --workflow outputs/case-N/workflow.json --source-input input/case-N-raw.json --validate
+
+退出码:
+  0  成功 (渲染或校验通过)
+  1  IO / schema / 渲染异常
+  2  CLI 参数错误
+
+依赖:
+  必需: spec/tools/renderer.py (跟本脚本同目录, 自动加入 sys.path)
+  可选: jsonschema (装了就用 Draft 2020-12 校验; 没装则只做最小结构检查)
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+# Windows cp1252 console 不能直接打中文/勾选号; 把 stdout/stderr 强转 UTF-8.
+for _stream in (sys.stdout, sys.stderr):
+    if hasattr(_stream, 'reconfigure'):
+        try:
+            _stream.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+
+# DSL_ROOT = procedure-dsl/  (本脚本位于 procedure-dsl/spec/tools/)
+DSL_ROOT = Path(__file__).resolve().parent.parent.parent
+SCHEMA_PATH = DSL_ROOT / 'spec' / 'format' / 'case-data.schema.json'
+# renderer.py 跟本脚本同目录 (spec/tools/), 直接用 parent 最 robust.
+TEMPLATE_DIR = Path(__file__).resolve().parent
+
+
+def _import_build_html():
+    """延迟 import build_html, 把 examples/_build 接到 sys.path. 失败时给出明确错误."""
+    sys.path.insert(0, str(TEMPLATE_DIR))
+    try:
+        from renderer import build_html  # type: ignore
+    except ImportError as e:
+        die(f'无法导入 renderer.build_html (期望路径: {TEMPLATE_DIR}/renderer.py): {e}')
+    return build_html
+
+
+def die(msg: str, code: int = 1):
+    print(f'render-case: {msg}', file=sys.stderr)
+    sys.exit(code)
+
+
+def load_json(path: Path) -> dict:
+    if not path.exists():
+        die(f'文件不存在: {path}')
+    try:
+        return json.loads(path.read_text(encoding='utf-8'))
+    except json.JSONDecodeError as e:
+        die(f'{path} 不是合法 JSON: {e}')
+
+
+def die_short(msg: str):
+    die(msg)
+
+
+def merge_raw_source(case_data: dict, raw_path: Path) -> None:
+    """从 input/case-{N}-raw.json 抽 body_text + 封面 + 图集兜底, in-place 填到 case_data.source.
+
+    设计: raw 是事实源 (原帖正文 / 封面 / 标题 / URL), case_data.source.body_text 和 cover_image
+    被 raw 覆盖. 其他字段 (excerpt / author / date / platform) **保留 case_data 原值** ——
+    那些是 Agent 推断/总结的友好版本, 不应被 raw 覆盖.
+
+    图集兜底逻辑: 微信公众号正文里有 `[image:URL]` inline markup → renderer 自动渲染就好.
+    小红书等"短文 + 独立图集" 平台 body_text 不含 inline markup → 把 image_url_list 里
+    既没 inline 也不是封面的图, 以 `[image: URL]` 形式 append 到 body_text 末尾 (附 "附图" 分隔符).
+    这样 renderer.render_source_body 一视同仁地把它们也渲染成 <img>, 不重复封面.
+    """
+    import re
+
+    raw = load_json(raw_path)
+    src = case_data.setdefault('source', {})
+
+    body = raw.get('body_text', '') or ''
+
+    # cover_image: image_url_list 里 image_type=2 (封面) 的第一张; 没有就 fallback 整列第一张
+    images = raw.get('image_url_list') or []
+    covers = [it for it in images if isinstance(it, dict) and it.get('image_type') == 2]
+    pick = covers[0] if covers else (images[0] if images and isinstance(images[0], dict) else None)
+    cover_url = ''
+    if pick and pick.get('image_url'):
+        cover_url = pick['image_url']
+        src['cover_image'] = cover_url
+
+    # 图集兜底: 检查哪些图既没在 body inline, 也不是封面 → append 到 body 末尾
+    inlined_urls = set(re.findall(r'\[image:\s*(\S+?)\]', body))
+    missing = []
+    for it in images:
+        if not isinstance(it, dict):
+            continue
+        u = it.get('image_url', '') or ''
+        if not u:
+            continue
+        if u in inlined_urls:
+            continue   # 已 inline 不重复
+        if u == cover_url:
+            continue   # 封面已 src-cover 单独渲染
+        missing.append(u)
+
+    if missing:
+        suffix = '\n\n--- 附图 ---\n' + '\n'.join(f'[image: {u}]' for u in missing)
+        body = body + suffix
+
+    if body:
+        src['body_text'] = body
+
+    # title / url 兜底: case_data 漏填时从 raw 补 (Agent 一般会填)
+    if not src.get('title') and raw.get('title'):
+        src['title'] = raw['title']
+    if not src.get('url') and raw.get('content_link'):
+        src['url'] = raw['content_link']
+
+
+# =============================================================================
+# 校验
+# =============================================================================
+def validate(case_data: dict, strict: bool = True) -> list[str]:
+    """返回错误信息列表 (空 list = 通过).
+
+    优先使用 jsonschema (Draft 2020-12); 不可用时退化到最小结构检查 (必填 key 存在 + 类型对).
+    strict=True 时, 任何错误抛 SystemExit(1); strict=False 仅返回错误列表.
+    """
+    errors: list[str] = []
+
+    try:
+        from jsonschema import Draft202012Validator  # type: ignore
+        schema = load_json(SCHEMA_PATH)
+        validator = Draft202012Validator(schema)
+        for err in sorted(validator.iter_errors(case_data), key=lambda e: list(e.absolute_path)):
+            path = '$' + ''.join(f'.{p}' if isinstance(p, str) else f'[{p}]' for p in err.absolute_path)
+            errors.append(f'{path}: {err.message}')
+    except ImportError:
+        # 退化: 最小检查
+        errors.extend(_minimal_check(case_data))
+
+    if errors and strict:
+        for e in errors:
+            print(f'  ✗ {e}', file=sys.stderr)
+        print(f'  ↳ schema: {SCHEMA_PATH}  (要看字段约束直接 Read 这个文件, 不要猜路径)', file=sys.stderr)
+        die(f'schema 校验失败, 共 {len(errors)} 项 (装 `pip install jsonschema` 可看完整 Draft 2020-12 报告)')
+    return errors
+
+
+def _minimal_check(d: dict) -> list[str]:
+    """无 jsonschema 时的兜底: 仅检 顶层必填 + steps 元素必填 + IO 元素必填."""
+    errs: list[str] = []
+    top_required = ['page_title', 'procedure', 'declarations', 'source', 'steps']
+    for k in top_required:
+        if k not in d:
+            errs.append(f'$.{k}: 缺失必填字段')
+    if not isinstance(d.get('steps'), list):
+        errs.append('$.steps: 必须是 array')
+        return errs
+    # 所有 step 都要的基础字段; effect/action 仅非 block 步要 (block 是控制容器)
+    step_required = ['id', 'kind', 'via', 'inputs', 'outputs', 'intent']
+    exec_only = ['effect', 'action']               # kind != block 才要
+    io_required = ['type', 'value', 'anchor']
+    for i, step in enumerate(d['steps']):
+        if not isinstance(step, dict):
+            errs.append(f'$.steps[{i}]: 必须是 object'); continue
+        need = step_required + ([] if step.get('kind') == 'block' else exec_only)
+        for k in need:
+            if k not in step:
+                errs.append(f'$.steps[{i}].{k}: 缺失必填字段')
+        for io_kind in ('inputs', 'outputs'):
+            for j, item in enumerate(step.get(io_kind, []) or []):
+                if not isinstance(item, dict):
+                    errs.append(f'$.steps[{i}].{io_kind}[{j}]: 必须是 object'); continue
+                for k in io_required:
+                    if k not in item:
+                        errs.append(f'$.steps[{i}].{io_kind}[{j}].{k}: 缺失必填字段')
+    return errs
+
+
+# =============================================================================
+# 占位门禁: 文本类 value 是 <占位> → 拒绝渲染 (强制回填真实内容)
+# 模态判定与 lint-case.py Check 3 一致: 文本类必须真内容, 媒体类可 <描述>, inferred 放行.
+# =============================================================================
+_PH_RE = re.compile(r'^\s*<[^>]*>\s*$')
+# 显式「原文未提供」标记 → 放行 (LLM 判断原文确无该信息时的轻量逃生, 等同 inferred)。
+# 写法如: <占位>(原文未提供) / <某分析>(原文未给出)
+_NOSRC_RE = re.compile(r'原文(未提供|未给出|没有|无)')
+_TEXT_KW = ('提示词', '描述', '参数', '评', '大纲', '脚本', '文案', '歌词', '字幕',
+            '标题', '正文', '词', '知识', '工作流', '对标', '规格', '批处理', '模板', '版式',
+            '数据', '分析', '报告', '记录', '方案', '思路', '设定', '依据', '标准', '清单', '列表', '文本', '文字')
+_MEDIA_KW = ('图', '视频', '音频', '帧', '片段', '截图', '蒙版', '音效', '配音', 'BGM',
+             '数字人', '滤镜', '海报', '封面')
+
+
+def _modality(type_name: str, type_reg: dict) -> str:
+    base, seen = type_name, set()
+    while base in (type_reg or {}) and base not in seen:
+        seen.add(base)
+        ent = type_reg[base]
+        ext = ent.get('extends') if isinstance(ent, dict) else None
+        if not ext:
+            break
+        base = ext
+    nm = base or type_name or ''
+    if any(k in nm for k in _TEXT_KW):
+        return 'text'
+    if any(k in nm for k in _MEDIA_KW):
+        return 'media'
+    return 'unknown'
+
+
+def check_placeholder_values(case_data: dict) -> list[str]:
+    """揪出文本类(非媒体、非 inferred)IO value 仍是 <占位> 的——这些必须回填真内容才放行渲染."""
+    bad: list[str] = []
+    for p in case_data.get('procedures') or []:
+        pid = p.get('id') or '?'
+        type_reg = p.get('type_registry') or {}
+        for i, step in enumerate(p.get('steps') or []):
+            if not isinstance(step, dict):
+                continue
+            for kind in ('inputs', 'outputs'):
+                for j, io in enumerate(step.get(kind) or []):
+                    if not isinstance(io, dict) or io.get('inferred'):
+                        continue
+                    v = io.get('value')
+                    if not isinstance(v, str):
+                        continue
+                    if _NOSRC_RE.search(v):
+                        continue   # 显式标了「原文未提供」→ 放行 (LLM 确认原文确无)
+                    if not _PH_RE.match(v):
+                        continue
+                    if _modality(io.get('type', '') or '', type_reg) == 'media':
+                        continue   # 图/视频/音频 用 <描述> 合理
+                    bad.append(f"[{pid}] {step.get('id')}.{kind}[{j}] type={io.get('type','')!r} value={v.strip()!r}")
+    return bad
+
+
+# =============================================================================
+# main
+# =============================================================================
+def main() -> None:
+    ap = argparse.ArgumentParser(
+        prog='render-case.py',
+        description='Procedure DSL · case_data → HTML 渲染',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=__doc__.split('用法:')[1] if __doc__ else '',
+    )
+    ap.add_argument('--workflow', type=Path, required=True,
+                    help='workflow.json (含 procedures 数组). 内部组装 case_data = '
+                         'workflow + source-input merge + --page-title + --case-id, 不落盘 case_data.json. '
+                         '配合 --source-input / --page-title / --case-id 一起用.')
+    ap.add_argument('--page-title', type=str, default=None,
+                    help='页面标题, 仅与 --workflow 配对. e.g. "Case 5 · 产品宣传图 AI 工作流可视化"')
+    ap.add_argument('--case-id', type=str, default=None,
+                    help='case 编号, 仅与 --workflow 配对')
+    ap.add_argument('--source-input', type=Path, dest='source_input',
+                    help='可选: 原帖 raw json 路径 (e.g. input/case-N-raw.json). 给了的话, 自动把 '
+                         'raw.body_text + 封面图 / 标题 / URL 填到 case_data.source 对应字段 '
+                         '(body_text/cover_image 直接覆盖 case_data 同字段, title/url 仅在 case_data 缺时填), '
+                         '让 HTML 折叠原文区显示完整原帖正文 + 内嵌图. excerpt/author/date 等 Agent 推断字段不动.')
+    ap.add_argument('--out', type=Path,
+                    help='输出 HTML 路径. --validate 时可省略')
+    ap.add_argument('--validate', action='store_true',
+                    help='只做 schema 校验, 不渲染. 退出码 0=通过, 1=失败')
+    ap.add_argument('--no-validate', action='store_true',
+                    help='跳过校验直接渲染 (不推荐)')
+    ap.add_argument('--allow-placeholders', action='store_true',
+                    help='跳过「文本类 value 占位」门禁 (调试用; 默认: 有文本类 <占位> value 就拒绝出 HTML)')
+    args = ap.parse_args()
+
+    # workflow.json 装 procedures 主体 + cli 套 case-level meta
+    case_data = load_json(args.workflow)
+    if args.page_title:
+        case_data['page_title'] = args.page_title
+    if args.case_id is not None:
+        try:
+            case_data['case_id'] = int(args.case_id)
+        except ValueError:
+            case_data['case_id'] = args.case_id
+    # source 由 --source-input 提供 (workflow.json 不含 source); 没给就让 schema validate 报错
+
+    # raw 原帖 → case_data.source merge (在 validate 前, 让 schema 校验看到的就是完整版)
+    if args.source_input:
+        merge_raw_source(case_data, args.source_input)
+
+    def _count_steps(cd):
+        return sum(len(p.get('steps') or []) for p in cd.get('procedures', []))
+
+    if not args.no_validate:
+        validate(case_data, strict=not args.validate)
+
+    # 占位硬门禁: 文本类 value 仍是 <占位> → 拒绝出 HTML, 逼回填真内容 (媒体类/inferred/原文未提供 放行)。
+    # 放在这里 (validate 块外), 所以 --no-validate 也绕不过; --validate 模式也会查。
+    if not args.allow_placeholders:
+        ph = check_placeholder_values(case_data)
+        if ph:
+            print(f'render-case: ✗ {len(ph)} 处文本类 value 仍是 <占位>, 拒绝出 HTML —— 必须回填真实内容:', file=sys.stderr)
+            for h in ph:
+                print(f'    - {h}', file=sys.stderr)
+            print('  → 用 wf-patch 的 `@quote|<起锚>|<止锚>` + `--resolve-quotes --source <原文> --ocr <ocr.txt>` '
+                  '从原文/OCR 回填真内容;\n'
+                  '    原文确无该信息 → value 写成 `<占位>(原文未提供)`(或给该 IO 标 `inferred:true`+reason)即可放行;\n'
+                  '    媒体类(图/视频/音频)用 `<描述>` 本就不受此限。', file=sys.stderr)
+            print('  (确需带占位渲染才加 --allow-placeholders)', file=sys.stderr)
+            die(f'{len(ph)} 处文本类 value 占位未回填')
+
+    if not args.no_validate and args.validate:
+        n_procs = len(case_data.get('procedures', []))
+        print(f'render-case: ✓ {SCHEMA_PATH.name} 校验通过 '
+              f'({n_procs} procedure{"s" if n_procs > 1 else ""}, {_count_steps(case_data)} steps)')
+        if not args.out:
+            return
+
+    if not args.out:
+        ap.error('--out 必填 (除非用 --validate 只校验)')
+
+    build_html = _import_build_html()
+    html = build_html(case_data)
+    args.out.parent.mkdir(parents=True, exist_ok=True)
+    # newline='' 保留 build_html 返回串里的换行原貌, 防 Windows 把 \n 翻译成 \r\n
+    args.out.write_text(html, encoding='utf-8', newline='')
+    n_procs = len(case_data.get('procedures', []))
+    print(f'render-case: ✓ wrote {args.out} ({len(html):,} chars, '
+          f'{n_procs} procedure{"s" if n_procs > 1 else ""}, {_count_steps(case_data)} steps)')
+
+
+if __name__ == '__main__':
+    main()

+ 661 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/renderer.py

@@ -0,0 +1,661 @@
+#!/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 把 `"` 转成 `&quot;`.
+    这点关键: 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'<span class="intent-tok {kc}">{he(val)}</span>'
+    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'<span class="chip" data-type="{he(type_name)}">{he(disp)}</span>'
+
+
+def render_path(prefix, value):
+    if not value:
+        return ''
+    if isinstance(value, list):
+        spans = []
+        for val in value:
+            if val:
+                spans.append(f'<span data-prefix="{prefix}" data-value="{he(val)}">{he(val)}</span>')
+        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'<span data-prefix="{prefix}" data-value="{he(val)}">{he(val)}</span>')
+            return '\n'.join(spans)
+        return f'<span data-prefix="{prefix}" data-value="{he(value)}">{he(value)}</span>'
+    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'<span class="value-desc">&lt;{he(inner)}&gt;</span>'
+    return f'<span class="natural">{he(s)}</span>'
+
+
+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'<span class="io-id" data-io-id="{he(iid)}">{he(iid)}</span> ' 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 = '<span class="arrow">▼</span> ' if is_block else ''
+            indent = '  └ ' if is_nested else ''
+            cells.append(f'<td class="idx{render_focus_class("idx", focus)}"{rs}>{arrow}{indent}{he(idx_label)}</td>')
+            intent_html = render_intent(step.get('intent', ''))
+            c, a = cell_attrs('intent', focus)
+            cells.append(f'<td class="intent{c}"{a}{rs}><div class="intent-text">{intent_html}</div></td>')
+            c, a = cell_attrs('effect', focus)
+            cells.append(f'<td class="effect{c}"{a}{rs}>{render_path("作用", step.get("effect", ""))}</td>')
+            c, a = cell_attrs('substance', focus)
+            cells.append(f'<td class="substance{c}"{a}{rs}>{render_path("实质", step.get("substance")) if step.get("substance") else ""}</td>')
+            c, a = cell_attrs('form', focus)
+            cells.append(f'<td class="form{c}"{a}{rs}>{render_path("形式", step.get("form")) if step.get("form") else ""}</td>')
+
+        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'<td class="in-type{c}"{a}>{render_chip(tp)}</td>')
+            c, a = cell_attrs(f'in-value-{k}', focus, io_inf, is_empty=not vl)
+            cells.append(f'<td class="in-value{c}"{a}>{render_io_value(in_item)}</td>')
+            c, a = cell_attrs(f'in-anchor-{k}', focus, io_inf, is_empty=not ac)
+            cells.append(f'<td class="in-anchor{c}"{a}><span class="flow">{he(ac)}</span></td>')
+        else:
+            cells += ['<td class="in-type"></td>', '<td class="in-value"></td>', '<td class="in-anchor"></td>']
+
+        if k == 0:
+            rs = f' rowspan="{N}"' if N > 1 else ''
+            c, a = cell_attrs('via', focus)
+            cells.append(f'<td class="via{c}"{a}{rs}>{he(step.get("via", ""))}</td>')
+            c, a = cell_attrs('action', focus)
+            cells.append(f'<td class="action{c}"{a}{rs}>{render_path("动作", step.get("action", ""))}</td>')
+            c, a = cell_attrs('directive', focus)
+            directive = step.get('directive', '') or ''
+            inner = f'<span class="natural">{he(directive)}</span>' if directive else ''
+            cells.append(f'<td class="directive{c}"{a}{rs}>{inner}</td>')
+
+        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'<td class="out-type{c}"{a}>{render_chip(tp)}</td>')
+            c, a = cell_attrs(f'out-value-{k}', focus, io_inf, is_empty=not vl)
+            cells.append(f'<td class="out-value{c}"{a}>{render_io_value(out_item)}</td>')
+            c, a = cell_attrs(f'out-anchor-{k}', focus, io_inf, is_empty=not ac)
+            cells.append(f'<td class="out-anchor{c}"{a}><span class="flow">{he(ac)}</span></td>')
+        else:
+            cells += ['<td class="out-type"></td>', '<td class="out-value"></td>', '<td class="out-anchor"></td>']
+
+        rows.append(f'<tr class="{tr_cls}"{attrs}>{"".join(cells)}</tr>')
+    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('<details class="declarations" open>')
+    parts.append('<summary class="decl-summary">')
+    parts.append(f'  <span class="kw">工序</span> <b class="proc-name">{he(proc.get("name", ""))}</b>')
+    if proc.get('purpose'):
+        parts.append(f'  <span class="decl-purpose">#目的: {he(proc["purpose"])}</span>')
+    if proc.get('category'):
+        parts.append(f'  <span class="tag-mini">类别: {he(proc["category"])}</span>')
+    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'  <span class="decl-meta">{" · ".join(meta_bits)}</span>')
+    parts.append('</summary>')
+    parts.append('<div class="decl-body">')
+
+    def section(label, items, renderer):
+        out = [f'<div class="decl-section"><div class="decl-label">{label}</div>']
+        for it in items:
+            out.append(renderer(it))
+        out.append('</div>')
+        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'<div class="decl-row">{chip} <span class="name" data-var="{name}">{name}</span>'
+        if default:
+            line += f'  <span class="decl-default">= {he(default)}</span>'
+        if it.get('desc'):
+            line += f'  <span class="decl-desc">— {he(it["desc"])}</span>'
+        return line + '</div>'
+
+    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'<div class="decl-row">{chip}'
+        if ret.get('note'):
+            line += f'  <span class="decl-desc">— {he(ret["note"])}</span>'
+        line += '</div>'
+        parts.append(f'<div class="decl-section"><div class="decl-label">返回</div>{line}</div>')
+
+    parts.append('</div>')
+    parts.append('</details>')
+    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] → <img>, [video:URL] → <a>, 其他按 \\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'<p class="src-p">{he(p)}</p>')
+        kind, url = m.group(1), m.group(2)
+        if kind == 'image':
+            pieces.append(f'<div class="src-img-wrap"><img class="src-img" src="{he(url)}" loading="lazy" referrerpolicy="no-referrer" alt=""></div>')
+        else:
+            pieces.append(f'<div class="src-video"><a href="{he(url)}" target="_blank" rel="noopener">▶ 视频</a></div>')
+        last = m.end()
+    tail = body_text[last:]
+    if tail.strip():
+        for para in tail.split('\n'):
+            p = para.strip()
+            if p:
+                pieces.append(f'<p class="src-p">{he(p)}</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 = ['<details class="source-block">']
+    parts.append(f'<summary>原文: <b>{he(title or "(无标题)")}</b></summary>')
+    parts.append('<div class="source-body">')
+    if url:
+        parts.append(f'<div class="source-url"><a href="{he(url)}" target="_blank" rel="noopener">{he(url)}</a></div>')
+    if meta_bits:
+        parts.append(f'<div class="source-meta">{" · ".join(he(b) for b in meta_bits)}</div>')
+    if excerpt:
+        parts.append(f'<p class="source-excerpt"><b>摘要</b>: {he(excerpt)}</p>')
+    if cover:
+        parts.append(f'<div class="src-cover"><img class="src-img" src="{he(cover)}" loading="lazy" referrerpolicy="no-referrer" alt=""></div>')
+    if body_text:
+        parts.append('<hr class="src-divider">')
+        parts.append('<div class="source-full">')
+        parts.append(render_source_body(body_text))
+        parts.append('</div>')
+    parts.append('</div></details>')
+    return '\n'.join(parts)
+
+
+def render_thead():
+    return '''<thead>
+  <tr>
+    <th colspan="5" class="col-group-demand">需求</th>
+    <th colspan="3" class="col-group-input">输入</th>
+    <th colspan="3" class="col-group-impl">实现</th>
+    <th colspan="3" class="col-group-output">输出</th>
+  </tr>
+  <tr>
+    <th class="col-idx">#</th>
+    <th class="col-intent">目的</th>
+    <th class="col-effect">作用</th>
+    <th class="col-substance">实质</th>
+    <th class="col-form">形式</th>
+    <th class="col-in-type">类型</th>
+    <th class="col-in-value">值</th>
+    <th class="col-in-anchor">来源</th>
+    <th class="col-via">外部工具</th>
+    <th class="col-action">动作</th>
+    <th class="col-directive">指令</th>
+    <th class="col-out-type">类型</th>
+    <th class="col-out-value">值</th>
+    <th class="col-out-anchor">去处</th>
+  </tr>
+</thead>'''
+
+
+def render_legend():
+    return '''<div class="legend">
+    <div class="group gray">
+      <span class="gh">需求</span>
+      <span class="col-toggle" data-col="idx">#</span>
+      <span class="col-toggle" data-col="intent">目的</span>
+      <span class="col-toggle" data-col="effect">作用</span>
+      <span class="col-toggle" data-col="substance">实质</span>
+      <span class="col-toggle" data-col="form">形式</span>
+    </div>
+    <div class="group yellow">
+      <span class="gh">输入</span>
+      <span class="col-toggle" data-col="in-type">类型</span>
+      <span class="col-toggle" data-col="in-value">值</span>
+      <span class="col-toggle" data-col="in-anchor">来源</span>
+    </div>
+    <div class="group green">
+      <span class="gh">实现</span>
+      <span class="col-toggle" data-col="via">外部工具</span>
+      <span class="col-toggle" data-col="action">动作</span>
+      <span class="col-toggle" data-col="directive">指令</span>
+    </div>
+    <div class="group blue">
+      <span class="gh">输出</span>
+      <span class="col-toggle" data-col="out-type">类型</span>
+      <span class="col-toggle" data-col="out-value">值</span>
+      <span class="col-toggle" data-col="out-anchor">去处</span>
+    </div>
+    <span class="inferred-toggle" id="inferred-toggle" title="高亮所有标为推断补全的 cell">高亮推断</span>
+    <span class="legend-hint">点击列名 ↔ 显示/隐藏 · 点击组名 ↔ 整组切换 · 「推」角标 hover 看推断理由</span>
+  </div>'''
+
+
+# =============================================================================
+# 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'<tr class="return-row"><td class="idx">{rr.get("arrow", "↩")}</td><td colspan="13">{rr.get("text", "")}</td></tr>')
+        proc_tbody = '<tbody>\n      ' + '\n      '.join(body_rows) + '\n    </tbody>'
+
+        proc_id_attr = he(proc.get('id', ''))
+        procedure_blocks.append(f'''<section class="procedure" id="proc-{proc_id_attr}">
+    {proc_decl_html}
+    <div class="table-wrap">
+      <table class="proc">
+        {thead}
+        {proc_tbody}
+      </table>
+    </div>
+  </section>''')
+
+    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'''<!doctype html>
+<html lang="zh">
+<head>
+  <meta charset="utf-8">
+  <title>{he(page_title)}</title>
+  <style>{CSS}</style>
+</head>
+<body>
+  <div class="scroll-area">
+    <header class="page-header">
+      <h1>{he(page_title)}</h1>
+    </header>
+    {source_html}
+    {legend_html}
+    {procedures_html}
+  </div>
+  <div class="drawer-overlay" id="drawer-overlay"></div>
+  <aside class="drawer" id="drawer" aria-hidden="true">
+    <header>
+      <h2>详情</h2>
+      <button class="close" aria-label="关闭">×</button>
+    </header>
+    <div class="content"></div>
+  </aside>
+  <script>{js}</script>
+</body>
+</html>
+'''

+ 194 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/script.js

@@ -0,0 +1,194 @@
+const typeRegistry = __TYPE_REGISTRY__;
+const taxonomy = __TAXONOMY__;
+
+function isInTypeTree(name) {
+  const t = typeRegistry[name];
+  if (!t) return false;
+  if (t.in_tree) return true;
+  if (t.extends) return isInTypeTree(t.extends);
+  return false;
+}
+
+const drawer = document.getElementById('drawer');
+const overlay = document.getElementById('drawer-overlay');
+const drawerTitle = drawer.querySelector('h2');
+const drawerContent = drawer.querySelector('.content');
+
+function openDrawer(title, htmlContent) {
+  drawerTitle.textContent = title;
+  drawerContent.innerHTML = htmlContent;
+  drawer.classList.add('open');
+  overlay.classList.add('open');
+}
+function closeDrawer() {
+  drawer.classList.remove('open');
+  overlay.classList.remove('open');
+}
+drawer.querySelector('.close').addEventListener('click', closeDrawer);
+overlay.addEventListener('click', closeDrawer);
+
+function escapeHtml(s) {
+  return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
+}
+function renderTree(tree, highlight, depth = 0) {
+  if (typeof tree === 'string') {
+    return `<div class="node" style="margin-left:${depth*16}px; color:#6b7280;">${escapeHtml(tree)}</div>`;
+  }
+  let out = '';
+  for (const k of Object.keys(tree)) {
+    const v = tree[k];
+    const isHL = (highlight && (k === highlight || k.endsWith('/'+highlight)));
+    const cls = isHL ? 'node highlight' : 'node';
+    out += `<div class="${cls}" style="margin-left:${depth*16}px;">${escapeHtml(k)}${(typeof v === 'string') ? ' — ' + escapeHtml(v) : ''}</div>`;
+    if (typeof v === 'object') {
+      out += renderTree(v, highlight, depth + 1);
+    }
+  }
+  return out;
+}
+
+// type chip click
+document.querySelectorAll('.chip[data-type]').forEach(c => {
+  c.addEventListener('click', () => {
+    const tp = c.getAttribute('data-type');
+    const t = typeRegistry[tp] || {};
+    let parts = [];
+    parts.push(`<div class="row"><b>类型名</b>: ${escapeHtml(tp)}</div>`);
+    if (t.extends) parts.push(`<div class="row"><b>extends</b>: ${escapeHtml(t.extends)}</div>`);
+    if (t.desc) parts.push(`<div class="row"><b>描述</b>: ${escapeHtml(t.desc)}</div>`);
+    parts.push(`<h3>字典树 (§A.3 类型词表)</h3>`);
+    if (t && t.in_tree) {
+      parts.push(`<div class="row" style="color:#047857;">✓ <b>${escapeHtml(tp)}</b> 是字典树叶子</div>`);
+    } else if (t && t.extends && isInTypeTree(t.extends)) {
+      parts.push(`<div class="row" style="color:#0369a1;">✓ <b>${escapeHtml(tp)}</b> 是自定义拓展节点 (继承自: <b>${escapeHtml(t.extends)}</b>)</div>`);
+    } else {
+      parts.push(`<div class="warning">${escapeHtml(tp)} 不在字典树叶子, 也无合法的 extends 桥接.</div>`);
+    }
+    parts.push(`<div class="tree">${renderTree(taxonomy['类型'].tree, tp)}</div>`);
+    openDrawer(`类型 · ${tp}`, parts.join(''));
+  });
+});
+
+// 作用 / 动作 / 特性 / 实质 / 形式 click
+document.querySelectorAll('[data-prefix]').forEach(el => {
+  el.addEventListener('click', (e) => {
+    e.stopPropagation();
+    const prefix = el.getAttribute('data-prefix');
+    const value = el.getAttribute('data-value');
+    const tx = taxonomy[prefix];
+    if (!tx) {
+      openDrawer(`${prefix}`, `<div class="warning">无字典树定义</div>`);
+      return;
+    }
+    let body = '';
+    body += `<div class="row"><b>${escapeHtml(prefix)}</b>: ${escapeHtml(tx.title || '')}</div>`;
+    if (tx.desc) body += `<div class="row" style="color:#6b7280;">${escapeHtml(tx.desc)}</div>`;
+    if (tx.source === 'external') {
+      body += `<div class="warning"><b>${escapeHtml(prefix)}</b> 维度词表过大, 维护在外部 JSON: <code>${escapeHtml(tx.file || '')}</code>. 查询: <code>spec/tools/taxonomy-lookup.py --dim ${escapeHtml(prefix)} --subtree &lt;path&gt;</code>.</div>`;
+      body += `<h3>当前值</h3><div class="row" style="font-family:ui-monospace,monospace;">${escapeHtml(value)}</div>`;
+    } else if (tx.tree) {
+      body += `<h3>字典树 (当前值: <span class="highlight">${escapeHtml(value)}</span> 已高亮)</h3>`;
+      body += `<div class="tree">${renderTree(tx.tree, value)}</div>`;
+    }
+    openDrawer(`${prefix} · ${value}`, body);
+  });
+});
+
+// variable hover
+document.querySelectorAll('.name[data-var]').forEach(n => {
+  n.addEventListener('mouseenter', () => {
+    const v = n.getAttribute('data-var');
+    document.querySelectorAll(`.name[data-var="${v}"]`).forEach(x => x.classList.add('var-highlight'));
+  });
+  n.addEventListener('mouseleave', () => {
+    document.querySelectorAll('.name.var-highlight').forEach(x => x.classList.remove('var-highlight'));
+  });
+});
+
+// block toggle (含 nested step-sub: 用 data-group 选)
+document.querySelectorAll('tr.block-header').forEach(b => {
+  b.addEventListener('click', (e) => {
+    if (e.target.closest('.chip, [data-prefix], .name')) return;
+    const group = b.getAttribute('data-step');
+    const arrow = b.querySelector('.arrow');
+    const nested = document.querySelectorAll(`tr[data-group="${group}"]`);
+    const collapsed = arrow.textContent.trim() === '▶';
+    nested.forEach(n => n.style.display = collapsed ? '' : 'none');
+    arrow.textContent = collapsed ? '▼' : '▶';
+  });
+});
+
+// ───────── column visibility (legend toggle) ─────────
+const COL_GROUPS = {
+  'demand': { headerSel: 'th.col-group-demand', cols: ['idx','intent','effect','substance','form'] },
+  'input':  { headerSel: 'th.col-group-input',  cols: ['in-type','in-value','in-anchor'] },
+  'impl':   { headerSel: 'th.col-group-impl',   cols: ['via','action','directive'] },
+  'output': { headerSel: 'th.col-group-output', cols: ['out-type','out-value','out-anchor'] },
+};
+
+function setColVisible(col, visible) {
+  document.querySelectorAll(`th.col-${col}, td.${col}`).forEach(el => {
+    el.classList.toggle('col-hidden', !visible);
+  });
+  document.querySelectorAll(`.col-toggle[data-col="${col}"]`).forEach(b => {
+    b.classList.toggle('off', !visible);
+  });
+  recomputeGroupColspans();
+  if (typeof updateGroupHeadState === 'function') updateGroupHeadState();
+}
+
+function recomputeGroupColspans() {
+  for (const [, g] of Object.entries(COL_GROUPS)) {
+    const visibleCount = g.cols.filter(c => {
+      const th = document.querySelector(`th.col-${c}`);
+      return th && !th.classList.contains('col-hidden');
+    }).length;
+    const groupTh = document.querySelector(g.headerSel);
+    if (!groupTh) continue;
+    if (visibleCount === 0) {
+      groupTh.classList.add('col-hidden');
+    } else {
+      groupTh.classList.remove('col-hidden');
+      groupTh.setAttribute('colspan', String(visibleCount));
+    }
+  }
+}
+
+document.querySelectorAll('.col-toggle').forEach(btn => {
+  btn.addEventListener('click', () => {
+    const col = btn.getAttribute('data-col');
+    const isOff = btn.classList.contains('off');
+    setColVisible(col, isOff);
+  });
+});
+
+document.querySelectorAll('.legend .group .gh').forEach(gh => {
+  gh.addEventListener('click', () => {
+    const grpEl = gh.closest('.group');
+    const toggles = Array.from(grpEl.querySelectorAll('.col-toggle'));
+    const anyVisible = toggles.some(t => !t.classList.contains('off'));
+    toggles.forEach(t => setColVisible(t.getAttribute('data-col'), !anyVisible));
+    updateGroupHeadState();
+  });
+});
+
+function updateGroupHeadState() {
+  document.querySelectorAll('.legend .group').forEach(grpEl => {
+    const toggles = Array.from(grpEl.querySelectorAll('.col-toggle'));
+    const allOff = toggles.every(t => t.classList.contains('off'));
+    grpEl.querySelector('.gh').classList.toggle('all-off', allOff);
+  });
+}
+
+recomputeGroupColspans();
+updateGroupHeadState();
+
+// 推断补全 toggle (legend 上的"高亮推断")
+const infBtn = document.getElementById('inferred-toggle');
+if (infBtn) {
+  infBtn.addEventListener('click', () => {
+    document.body.classList.toggle('show-inferred');
+    infBtn.classList.toggle('on');
+  });
+}
+

+ 306 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/styles.css

@@ -0,0 +1,306 @@
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; font-family: -apple-system, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif; font-size: 13px; color: #111827; background: #f5f5f7; }
+/* case 页布局: body 固定 viewport 高 + overflow hidden, 唯一的滚动容器是 .scroll-area; 页头 / declarations / source / legend / 表格全装在里面.
+   滚动时它们一起向上滚走, 只有 thead 因 position: sticky 留在顶 — 这是用户要求的"只冻结表头两行". */
+body { height: 100vh; overflow: hidden; }
+header.page-header { padding: 14px 20px 8px; background: #fff; border-bottom: 1px solid #e5e7eb; }
+header.page-header h1 { margin: 0 0 6px; font-size: 16px; font-weight: 600; }
+header.page-header .meta { color: #6b7280; font-size: 12px; line-height: 1.5; }
+
+/* 工序头部 + declare 块 */
+details.declarations { margin: 12px 20px 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; }
+details.declarations[open] summary { border-bottom: 1px solid #e2e8f0; }
+details.declarations summary { padding: 10px 14px; cursor: pointer; font-size: 13px; line-height: 1.65; list-style: none; }
+details.declarations summary::-webkit-details-marker { display: none; }
+details.declarations summary::before { content: '▼ '; color: #94a3b8; font-size: 10px; margin-right: 4px; }
+details.declarations:not([open]) summary::before { content: '▶ '; }
+details.declarations .proc-name { color: #1f2937; font-size: 14px; }
+details.declarations .decl-purpose { color: #475569; margin-left: 12px; font-size: 12px; }
+details.declarations .decl-meta { color: #6b7280; font-size: 11.5px; margin-left: 16px; }
+details.declarations .tag-mini { display: inline-block; padding: 1px 7px; font-size: 11px; background: #fef3c7; border-radius: 3px; color: #92400e; margin-left: 8px; }
+.decl-body { padding: 12px 18px 14px; display: flex; flex-wrap: wrap; gap: 14px 32px; }
+.decl-section { min-width: 260px; flex: 1 1 280px; }
+.decl-label { color: #475569; font-size: 11.5px; font-weight: 600; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px dashed #e2e8f0; }
+.decl-row { padding: 3px 0; font-size: 12.5px; line-height: 1.65; }
+.decl-row .name { font-weight: 500; color: #1f2937; margin-left: 4px; }
+.decl-row .decl-default { color: #6b7280; font-size: 11.5px; margin-left: 6px; }
+.decl-row .decl-desc { color: #6b7280; font-size: 11.5px; margin-left: 6px; }
+
+/* 原文 */
+details.source-block { margin: 8px 20px 12px; background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; }
+details.source-block summary { padding: 8px 14px; cursor: pointer; font-size: 12px; color: #475569; list-style: none; }
+details.source-block summary::-webkit-details-marker { display: none; }
+details.source-block summary::before { content: '▶ '; color: #94a3b8; font-size: 10px; margin-right: 4px; }
+details.source-block[open] summary::before { content: '▼ '; }
+.source-body { padding: 6px 18px 12px; font-size: 12.5px; line-height: 1.65; }
+.source-body a { color: #2563eb; word-break: break-all; }
+.source-body .source-meta { color: #94a3b8; font-size: 11px; margin-top: 4px; }
+.source-body .source-excerpt { color: #1f2937; margin: 8px 0; padding: 8px 10px; background: #f8fafc; border-left: 3px solid #94a3b8; font-size: 12px; }
+.source-body hr.src-divider { border: 0; border-top: 1px dashed #cbd5e1; margin: 10px 0; }
+.source-body .src-p { margin: 6px 0; color: #1f2937; }
+.source-body .src-img-wrap, .source-body .src-cover { text-align: center; margin: 8px 0; }
+.source-body .src-img { max-width: 100%; max-height: 480px; border-radius: 4px; border: 1px solid #e2e8f0; }
+.source-body .src-video { margin: 8px 0; }
+.source-body .src-video a { display: inline-block; padding: 4px 10px; background: #fef3c7; color: #78350f; text-decoration: none; border-radius: 3px; font-size: 11.5px; }
+.source-body .source-full { max-height: 720px; overflow-y: auto; padding: 6px 12px; background: #fff; border: 1px solid #e2e8f0; border-radius: 4px; }
+
+/* Legend */
+.legend { padding: 10px 20px; background: #fff; border-bottom: 1px solid #e5e7eb; font-size: 11.5px; color: #6b7280; display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; }
+.legend .group { display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; }
+.legend .group .gh { display: inline-block; padding: 2px 8px; border-radius: 3px; color: #1f2937; font-weight: 600; cursor: pointer; user-select: none; }
+.legend .group .gh:hover { filter: brightness(0.92); }
+.legend .group .gh.all-off { opacity: 0.45; text-decoration: line-through; }
+.legend .group.gray  .gh { background: #e2e8f0; }
+.legend .group.yellow .gh { background: #fde68a; }
+.legend .group.green .gh { background: #bbf7d0; }
+.legend .group.blue  .gh { background: #bfdbfe; }
+.legend .col-toggle { display: inline-block; padding: 2px 7px; border: 1px solid #cbd5e1; border-radius: 3px; background: #fff; cursor: pointer; font-size: 11.5px; color: #334155; user-select: none; }
+.legend .col-toggle:hover { background: #f1f5f9; }
+.legend .col-toggle.off { color: #94a3b8; background: #f1f5f9; text-decoration: line-through; border-color: #e2e8f0; }
+.legend .inferred-toggle { display: inline-block; padding: 2px 8px; border: 1px solid #d97706; border-radius: 3px;
+                           background: #fffbeb; color: #92400e; cursor: pointer; font-size: 11.5px;
+                           font-weight: 600; user-select: none; margin-left: 8px; }
+.legend .inferred-toggle.on { background: #fef3c7; box-shadow: inset 0 0 0 1px #d97706; }
+.legend .legend-hint { color: #94a3b8; font-size: 11px; margin-left: auto; }
+
+/* 表格容器 */
+.table-wrap { background: #fff; }
+/* case 页 .scroll-area: 占满 body, 自身承担两轴滚动. 内部不嵌套 overflow 容器, 否则 sticky 锚点会被抢走.
+   (browser 页不会走这条 — 它在 .browser-main 内, 不直接挂 body 下) */
+body > .scroll-area { height: 100%; overflow: auto; background: #f5f5f7; }
+table.proc { width: max-content; min-width: 100%; border-collapse: collapse; background: #fff; table-layout: auto; font-size: 12px; }
+table.proc th, table.proc td { border: 1px solid #e5e7eb; padding: 6px 8px; vertical-align: top; line-height: 1.5; }
+table.proc thead th { font-weight: 600; text-align: center; font-size: 12.5px; padding: 8px 6px; }
+table.proc tbody td { font-size: 12px; }
+
+/* 列宽 (需求 3 列固定 width, 其他 min-width) */
+table.proc th.col-idx,    table.proc td.idx    { width: 38px;  min-width: 38px;  max-width: 38px;  text-align: center; }
+table.proc th.col-intent, table.proc td.intent { width: 240px; min-width: 240px; max-width: 240px; }
+table.proc th.col-effect, table.proc td.effect { width: 80px;  min-width: 80px;  max-width: 80px; }
+table.proc th.col-via       { min-width: 110px; }
+table.proc th.col-action    { min-width: 130px; }
+table.proc th.col-directive { min-width: 200px; }
+table.proc th.col-config    { min-width: 150px; }
+table.proc th.col-decorator { min-width: 130px; }
+table.proc th.col-memo      { min-width: 220px; }
+table.proc th.col-control   { min-width: 80px; }
+table.proc th.col-feature   { min-width: 80px; }
+
+/* 值列整段 <...> — 表示该值是对内容的描述, 不是内容本身. 整段 inline italic gray bg. */
+.value-desc { display: inline; padding: 1px 4px; border-radius: 3px;
+              background: rgba(241, 245, 249, 0.7); color: #475569; font-style: italic; }
+
+/* 值列不允许截断 */
+table.proc td.in-value, table.proc td.out-value {
+  white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;
+  max-width: 320px;
+}
+table.proc td.directive, table.proc td.config,
+table.proc td.decorator, table.proc td.memo {
+  white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;
+  max-width: 280px;
+}
+/* 实质/形式 列 (step 级) — 内容是短路径, 收窄列宽强制换行 (避免空旷低信息密度) */
+table.proc th.col-substance, table.proc td.substance,
+table.proc th.col-form, table.proc td.form { width: 90px; min-width: 90px; max-width: 90px;
+  word-break: break-all; overflow-wrap: anywhere; }
+table.proc th.col-in-type, table.proc th.col-out-type           { min-width: 90px;  }
+table.proc th.col-in-name, table.proc th.col-out-name           { min-width: 110px; }
+table.proc th.col-in-value, table.proc th.col-out-value         { min-width: 180px; }
+table.proc th.col-in-anchor, table.proc th.col-out-anchor       { min-width: 110px; }
+
+/* 冻结 需求 组 (idx + 目的 + 作用) — left 累计 = 0 / 38 / 278 */
+table.proc th.col-idx,    table.proc td.idx    { position: sticky; left: 0;     z-index: 2; }
+table.proc th.col-intent, table.proc td.intent { position: sticky; left: 38px;  z-index: 2; }
+table.proc th.col-effect, table.proc td.effect { position: sticky; left: 278px; z-index: 2; }
+table.proc thead th.col-group-demand          { position: sticky; left: 0;     z-index: 3; }
+table.proc thead th.col-idx, table.proc thead th.col-intent, table.proc thead th.col-effect { z-index: 3; }
+table.proc td.effect, table.proc thead th.col-effect, table.proc thead th.col-group-demand {
+  box-shadow: 6px 0 8px -4px rgba(0,0,0,0.18);
+}
+
+/* 冻结 表头 — 滚动时组名/列名行 stick to top */
+table.proc thead th { position: sticky; top: 0; z-index: 3; }
+table.proc thead tr:nth-child(2) th { top: 33px; }
+/* 4 个 corner cell (sticky-left + sticky-top 同时生效) z-index 加高, 避免被 tbody sticky-left 列覆盖 */
+table.proc thead th.col-group-demand,
+table.proc thead th.col-idx,
+table.proc thead th.col-intent,
+table.proc thead th.col-effect { z-index: 5; }
+
+/* 颜色分组背景 */
+table.proc td.idx, table.proc td.intent, table.proc td.effect, table.proc td.substance, table.proc td.form { background: #f8fafc; }
+table.proc thead th.col-group-demand { background: #1e293b; color: #fff; }
+table.proc thead tr:nth-child(2) th.col-idx,
+table.proc thead tr:nth-child(2) th.col-intent,
+table.proc thead tr:nth-child(2) th.col-effect,
+table.proc thead tr:nth-child(2) th.col-substance,
+table.proc thead tr:nth-child(2) th.col-form { background: #475569; color: #fff; }
+table.proc td.in-substance, table.proc td.in-form, table.proc td.in-type, table.proc td.in-name, table.proc td.in-value, table.proc td.in-anchor { background: #fefce8; }
+table.proc thead th.col-group-input { background: #b45309; color: #fff; }
+table.proc thead tr:nth-child(2) th.col-in-substance,
+table.proc thead tr:nth-child(2) th.col-in-form,
+table.proc thead tr:nth-child(2) th.col-in-type,
+table.proc thead tr:nth-child(2) th.col-in-name,
+table.proc thead tr:nth-child(2) th.col-in-value,
+table.proc thead tr:nth-child(2) th.col-in-anchor { background: #d97706; color: #fff; }
+table.proc td.via, table.proc td.action, table.proc td.directive, table.proc td.config,
+table.proc td.decorator, table.proc td.memo, table.proc td.control, table.proc td.feature { background: #f0fdf4; }
+table.proc thead th.col-group-impl { background: #065f46; color: #fff; }
+table.proc thead tr:nth-child(2) th.col-via,
+table.proc thead tr:nth-child(2) th.col-action,
+table.proc thead tr:nth-child(2) th.col-directive,
+table.proc thead tr:nth-child(2) th.col-config,
+table.proc thead tr:nth-child(2) th.col-decorator,
+table.proc thead tr:nth-child(2) th.col-memo,
+table.proc thead tr:nth-child(2) th.col-control,
+table.proc thead tr:nth-child(2) th.col-feature { background: #047857; color: #fff; }
+table.proc td.out-substance, table.proc td.out-form, table.proc td.out-type, table.proc td.out-name, table.proc td.out-value, table.proc td.out-anchor { background: #eff6ff; }
+table.proc thead th.col-group-output { background: #1e40af; color: #fff; }
+table.proc thead tr:nth-child(2) th.col-out-substance,
+table.proc thead tr:nth-child(2) th.col-out-form,
+table.proc thead tr:nth-child(2) th.col-out-type,
+table.proc thead tr:nth-child(2) th.col-out-name,
+table.proc thead tr:nth-child(2) th.col-out-value,
+table.proc thead tr:nth-child(2) th.col-out-anchor { background: #2563eb; color: #fff; }
+
+/* 组间分隔粗线 (需求|输入 在 form 后; 输入|实现 在 来源后; 实现|输出 在 指令后) */
+table.proc th.col-form, table.proc td.form,
+table.proc th.col-in-anchor, table.proc td.in-anchor,
+table.proc th.col-directive, table.proc td.directive { border-right: 3px solid #94a3b8; }
+
+/* 行边框 / 块头 / nested */
+table.proc tr.step-main > td { border-top: 1.5px solid #94a3b8; }
+table.proc tr.step-sub > td { border-top: 1px dashed #cbd5e1; }
+table.proc tr.block-header { font-weight: 600; }
+table.proc tr.block-header > td.intent { background: #e2e8f0; }
+table.proc tr.step-nested > td.idx { color: #6b7280; }
+
+/* :target 高亮 — 从其他页跳转过来时让对应 step 行闪烁定位 */
+table.proc tr:target > td {
+  animation: rowTargetFlash 1.6s ease-out;
+  box-shadow: inset 4px 0 0 #6d28d9;
+}
+@keyframes rowTargetFlash {
+  0%   { background-color: #fef3c7 !important; }
+  100% { background-color: inherit; }
+}
+
+/* output 的 id 小标签 (贴在值前, 供 anchor 1:1 引用对照) */
+.io-id {
+  display: inline-block; margin-right: 4px; padding: 0 4px;
+  font-size: 10px; font-family: ui-monospace, "SF Mono", monospace;
+  color: #475569; background: #e2e8f0; border-radius: 3px; vertical-align: top;
+}
+
+/* 返回行 */
+tr.return-row > td { background: #fde68a !important; font-weight: 600; padding: 8px; }
+
+/* chip — 中性灰底 (供 declaration / drawer / 返回行); 表格内 type chip 由下方列色覆盖 */
+.chip { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11.5px; color: #1f2937; background: #d1d5db; cursor: pointer; user-select: none; }
+.chip:hover { filter: brightness(0.95); box-shadow: 0 0 0 1px #1e293b; }
+
+/* 表格 in-type / out-type 列内 chip 统一列色 (黄/蓝区分输入输出) */
+table.proc td.in-type .chip  { background: #fde68a !important; color: #78350f; }
+table.proc td.out-type .chip { background: #bfdbfe !important; color: #1e3a8a; }
+
+/* keyword / name / flow */
+.kw { color: #6d28d9; font-weight: 600; }
+.name { color: #0f172a; }
+.name:hover, .name.var-highlight { background: #fef3c7; cursor: pointer; }
+.flow { color: #6b7280; font-size: 11.5px; font-style: italic; }
+
+/* intent tokens */
+.intent-text { color: #1f2937; line-height: 1.55; }
+.intent-tok { display: inline-block; padding: 1px 5px; border-radius: 3px; margin: 0 1px; font-size: 11.5px; }
+/* intent token: 底色对应其引用的列. 输入列黄, 输出列蓝, 实现列绿, 需求列灰. */
+/* 需求 (灰) */
+.intent-tok.ik-effect    { background: #cbd5e1; color: #1f2937; }
+/* 实现 (绿系) — 3 个不同饱和度区分 */
+.intent-tok.ik-via       { background: #d1fae5; color: #064e3b; font-family: ui-monospace, "SF Mono", monospace; font-size: 11px; }
+.intent-tok.ik-act       { background: #86efac; color: #14532d; }
+.intent-tok.ik-control   { background: #99f6e4; color: #134e4a; }
+/* feature 禁止 intent 引用; 用 ik-other 警告色 */
+.intent-tok.ik-other     { background: #fee2e2; color: #991b1b; text-decoration: line-through; }
+/* 输入·类型 (黄圆胶囊) / 输出·类型 (蓝圆胶囊) */
+.intent-tok.ik-in-type   { background: #fde68a; color: #78350f; border-radius: 10px; padding: 1px 8px; font-weight: 500; }
+.intent-tok.ik-out-type  { background: #bfdbfe; color: #1e3a8a; border-radius: 10px; padding: 1px 8px; font-weight: 500; }
+/* 输入·实质 / 输出·实质 (矩形 tag) */
+.intent-tok.ik-in-sub    { background: #fef3c7; color: #92400e; }
+.intent-tok.ik-out-sub   { background: #dbeafe; color: #1e40af; }
+/* 输入·形式 / 输出·形式 (矩形 tag 斜体) */
+.intent-tok.ik-in-form   { background: #fef3c7; color: #92400e; font-style: italic; }
+.intent-tok.ik-out-form  { background: #dbeafe; color: #1e40af; font-style: italic; }
+
+/* substance/form 路径文本 */
+td.substance > span, td.form > span {
+  display: inline-block; padding: 1px 4px; border-radius: 2px; font-size: 11px;
+  color: #1f2937; background: rgba(255,255,255,0.6); border: 1px solid rgba(0,0,0,0.06);
+  word-break: break-all; line-height: 1.4; cursor: pointer;
+}
+td.effect > span, td.action > span {
+  display: inline-block; padding: 1px 5px; border-radius: 2px; font-size: 11.5px;
+  color: #0f172a; background: rgba(255,255,255,0.7); cursor: pointer; border: 1px solid rgba(0,0,0,0.06);
+}
+
+/* 指令/配置/运行/备注 列 — 多条目 vertical 堆叠 */
+td.directive .instr-item, td.config .instr-item, td.decorator .instr-item, td.memo .instr-item {
+  display: block; font-size: 11.5px; color: #1f2937; margin: 2px 0; line-height: 1.5;
+}
+
+/* row-focus 加深底色 */
+td.row-focus { font-weight: 600; color: #0f172a; }
+td.idx.row-focus, td.intent.row-focus, td.effect.row-focus, td.substance.row-focus, td.form.row-focus { background: #e2e8f0 !important; }
+td.in-substance.row-focus, td.in-form.row-focus, td.in-type.row-focus, td.in-name.row-focus, td.in-value.row-focus, td.in-anchor.row-focus { background: #fde68a !important; }
+td.via.row-focus, td.action.row-focus, td.directive.row-focus, td.config.row-focus,
+td.decorator.row-focus, td.memo.row-focus, td.control.row-focus, td.feature.row-focus { background: #bbf7d0 !important; }
+td.out-substance.row-focus, td.out-form.row-focus, td.out-type.row-focus, td.out-name.row-focus, td.out-value.row-focus, td.out-anchor.row-focus { background: #bfdbfe !important; }
+
+/* inferred 角标 */
+sup.inferred { color: #b45309; font-size: 10px; font-weight: bold; padding: 0 2px; cursor: help; }
+
+/* 任意 cell 推断补全标记 — 「推」角标 + hover 显示 reason (不再加虚线下划线, 由角标承担识别) */
+td.is-inferred {
+  cursor: help;
+}
+td.is-inferred::after {
+  content: '推';
+  display: inline-block;
+  font-size: 9px;
+  color: #92400e;
+  background: rgba(254, 243, 199, 0.9);
+  padding: 0 3px;
+  border-radius: 2px;
+  margin-left: 4px;
+  vertical-align: top;
+  font-weight: 600;
+  border: 1px solid #fbbf24;
+}
+/* 低置信留空变体: AI 想过但拿不准, 故意留空; 角标变 推? 与高置信 推 区分 (spec §推断补全标记 C) */
+td.is-inferred.is-low-confidence::after { content: '推?'; }
+/* 高亮模式: legend 点击"高亮推断" 时, body 加 show-inferred 类, 所有推断 cell 加重底色 */
+body.show-inferred td.is-inferred {
+  background: #fef3c7 !important;
+  box-shadow: inset 0 0 0 1px #d97706;
+}
+
+/* 列隐藏 */
+.col-hidden { display: none !important; }
+
+/* drawer */
+.drawer-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); display: none; z-index: 100; }
+.drawer-overlay.open { display: block; }
+.drawer { position: fixed; right: 0; top: 0; bottom: 0; width: 520px; max-width: 90vw; background: #fff; box-shadow: -4px 0 16px rgba(0,0,0,0.15); z-index: 101; overflow-y: auto; transform: translateX(100%); transition: transform 0.2s ease-out; }
+.drawer.open { transform: translateX(0); }
+.drawer header { padding: 14px 18px; background: #1e293b; color: #fff; border-bottom: 0; position: sticky; top: 0; z-index: 2; }
+.drawer header h2 { margin: 0; font-size: 14px; }
+.drawer .close { position: absolute; right: 12px; top: 12px; background: transparent; color: #fff; border: 0; cursor: pointer; font-size: 18px; }
+.drawer .content { padding: 14px 18px; font-size: 12.5px; line-height: 1.6; color: #1f2937; }
+.drawer .content h3 { margin: 14px 0 6px; font-size: 12.5px; color: #047857; }
+.drawer .content pre { background: #f3f4f6; padding: 8px; border-radius: 3px; font-size: 11.5px; overflow-x: auto; line-height: 1.5; white-space: pre-wrap; }
+.drawer .content .row { margin: 4px 0; }
+.drawer .content .row b { color: #374151; }
+.drawer .tree { font-family: ui-monospace, "SF Mono", monospace; font-size: 11.5px; }
+.drawer .tree .node { padding: 1px 0; }
+.drawer .tree .highlight { background: #fde68a; padding: 1px 4px; border-radius: 2px; font-weight: 600; }
+.drawer .warning { background: #fef3c7; border-left: 3px solid #f59e0b; padding: 8px 10px; margin: 8px 0; font-size: 12px; }

+ 242 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/verify-io.py

@@ -0,0 +1,242 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""verify-io.py — Phase 1 收口的专用「IO 校验」步骤 (填完 value 后、进 Phase 2 前必跑)。
+
+专门校验三件事, 报问题就回去补 (修完重跑直到通过):
+  1. **文本 IO 逐字**: 每个**文本类** input/output 的 value 是不是在原文(正文+配图 OCR)里有
+     连续对应 —— 媒体类(图/视频/音频)IO **不要求**对应, 直接跳过。
+  2. **提示词提取**: 每个**工具生成步**(via 是具体生成工具)是不是把原文给这步的整段 prompt
+     提取进了 directive 或某个文本输入 —— 弱模型常把生成步建成「媒体输入→媒体输出」, 把原文
+     那段 prompt(最该提取的内容, 如「半剖面视图…」「视觉层:…技术层:…」)整段漏掉。
+  3. **declarations 收口**: 每个工序的 declarations.returns 补了没(必), inputs/resources 提醒。
+
+退出码: 0 通过 / 1 有阻塞项要修 / 2 CLI/文件错。
+
+用法:
+    python spec/tools/verify-io.py --workflow outputs/case-{N}/workflow.json \
+        --source input/case-{N}.json --ocr outputs/case-{N}/_scratch/ocr.txt
+"""
+import argparse
+import importlib.util
+import re
+import sys
+from pathlib import Path
+
+import json
+
+# 媒体类 value 应整体用 <…> 包成 <具体描述> (强模型参考一致): <极简写实背景图:深钢蓝-松绿-冷白渐层…>
+_MEDIA_BRACKET_OK = re.compile(r'^\s*<.+>\s*$', re.S)
+
+DSL_ROOT = Path(__file__).resolve().parent.parent.parent
+
+for _s in (sys.stdout, sys.stderr):
+    if hasattr(_s, 'reconfigure'):
+        try:
+            _s.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+
+
+def _load_lint():
+    """复用 lint-case.py 的 _norm / check_value_verbatim / _type_modality (文件名带连字符, 按路径载)."""
+    p = Path(__file__).resolve().parent / 'lint-case.py'
+    spec = importlib.util.spec_from_file_location('_lint_case_mod', p)
+    mod = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(mod)
+    return mod
+
+
+# 装配/排版/人工类工具不要求 prompt (它们不是"按提示词生成"). 用较长的判别词, 避免 'na' 误伤 nanobanana
+_NON_PROMPT_VIA = ('human', 'ppt', 'adobe', 'illustrat', 'inkscape', 'photoshop',
+                   '剪辑', '剪映', '拼接', '排版', '拼版', '合成', '后期', '裁剪', '修图',
+                   '人工', 'excel', 'word')
+
+
+def _is_generation_via(via: str) -> bool:
+    v = (via or '').strip().lower()
+    if not v or v in ('-', 'n/a', 'na', '人工', '人'):
+        return False
+    return not any(k in v for k in _NON_PROMPT_VIA)
+
+
+# 原文 prompt 的**独特**分层/要素标记: 命中后原文紧跟这些 = 只提了一部分 (截断)。
+# 只放区分度高的 (视觉层/技术层/核心要素…); 不放 产品/人物/环境 这种常见词 (满篇都是, 会误判)。
+_LAYER_MARKERS = ('视觉层', '技术层', '氛围层', '核心要素', '视觉风格', '输出要求', '技术规格')
+
+
+def _has_prompt_type(t: str) -> bool:
+    return '提示词' in (t or '') or 'prompt' in (t or '').lower()
+
+
+def check_prompt_extraction(data: dict, lint, source_norm: str) -> list:
+    """提示词建模 + 完整性 (对齐强模型参考: 提示词是**数据**, 建成 type=提示词 的 IO value, directive 不装它):
+      1. 生成步必须有 type=提示词 的输入 (原文整段 prompt 当数据流入), 没有=漏建;
+      2. directive 里疑似装了提示词原文 = 放错地方, 该挪到 IO;
+      3. type=提示词 的 value 逐字但截断 (原文后面还有分层/要素) = 只提了一部分。
+    (value 是否逐字由「文本 IO 逐字」那条统一管, 这里只管"建对地方"和"提全".)
+    """
+    issues = []
+    for p in data.get('procedures', []):
+        for s in p.get('steps', []) or []:
+            if not isinstance(s, dict) or s.get('kind') == 'block':
+                continue
+            sid = f"{p.get('id')}.{s.get('id')}"
+            via = (s.get('via') or '').strip()
+            directive = (s.get('directive') or '').strip()
+
+            # 2. directive 不该装提示词 (长且像在引原文) —— 不限生成步
+            if directive:
+                dn = lint._norm(directive)
+                if len(dn) >= 12 and lint._longest_run(dn, source_norm) >= 12:
+                    issues.append(
+                        f"[{sid}] directive 里疑似装了提示词原文 —— directive 只放「给工具的指示」"
+                        f"(如严格反推/比例 2:3); **提示词原文是数据, 挪到 type=提示词 的输入/输出 value**。")
+
+            # 1. 生成步必须有 type=提示词 的输入
+            if _is_generation_via(via):
+                has_prompt_in = any(_has_prompt_type(io.get('type', '')) for io in (s.get('inputs') or []))
+                if not has_prompt_in:
+                    issues.append(
+                        f"[{sid}] via={via!r} 是生成步但**没有 type=提示词 的输入** —— 原文的整段 prompt 是数据, "
+                        f"建成 type=提示词 的输入(value 逐字完整; 通常由一个 human「写提示词」步 OUT=提示词、本步 anchor 引入), "
+                        f"别塞 directive、也别只写「XX需求」标签。原文确实没给详细 prompt 才可省。")
+
+            # 3. type=提示词 的 value 逐字但截断 (原文紧跟还有分层/要素)
+            for kind in ('inputs', 'outputs'):
+                for io in (s.get(kind) or []):
+                    if not _has_prompt_type(io.get('type', '')):
+                        continue
+                    v = (io.get('value') or '').strip()
+                    if not v or v.startswith('<'):
+                        continue
+                    vn = lint._norm(v)
+                    if len(vn) < 8:
+                        continue
+                    pos = source_norm.find(vn)
+                    if pos >= 0:
+                        tail = source_norm[pos + len(vn): pos + len(vn) + 40]
+                        cont = [m for m in _LAYER_MARKERS if m in tail and m not in vn]
+                        if cont:
+                            issues.append(
+                                f"[{sid}].{kind[:3]} type=提示词 的 value 可能**只提了一部分** —— 原文后面紧跟 "
+                                f"{cont[:3]} 等, @quote 止锚要延到这段 prompt 真正结束(把所有分层/要素提全)。")
+    return issues
+
+
+def check_media_brackets(data: dict, lint) -> list:
+    """媒体类(图/视频/音频)IO 的 value 必须整体用 <…> 包成 <具体描述>, 别写裸标签."""
+    issues = []
+    for p in data.get('procedures', []):
+        reg = p.get('type_registry') or {}
+        for s in p.get('steps', []) or []:
+            if not isinstance(s, dict) or s.get('kind') == 'block':
+                continue
+            for kind in ('inputs', 'outputs'):
+                for io in (s.get(kind) or []):
+                    if io.get('inferred'):
+                        continue
+                    if lint._type_modality(io.get('type', ''), reg) != 'media':
+                        continue
+                    v = (io.get('value') or '').strip()
+                    if not v:
+                        continue
+                    if not _MEDIA_BRACKET_OK.match(v):
+                        issues.append(
+                            f"[{p.get('id')}.{s.get('id')}.{kind[:3]}] type={io.get('type', '')!r}(媒体) "
+                            f"value={v[:24]!r} 没用 <…> 包 —— 媒体类 value 写成 <具体描述>"
+                            f"(如 <一张冲锋衣登山者暴雨场景图,水珠滚落,冷色调>)。")
+    return issues
+
+
+def check_declarations(data: dict) -> tuple:
+    """returns 空 = 必补; inputs 空 = 提醒. 返回 (block_list, warn_list)."""
+    block, warn = [], []
+    for p in data.get('procedures', []):
+        decl = p.get('declarations') or {}
+        rets = decl.get('returns')
+        if not rets or (isinstance(rets, dict) and not any(rets.values())):
+            block.append(f"[{p.get('id')}] declarations.returns 空 — 补这个工序最终返回什么(type)")
+        if not decl.get('inputs'):
+            warn.append(f"[{p.get('id')}] declarations.inputs 空 — 确认外部输入(没有可不填)")
+    return block, warn
+
+
+def main() -> None:
+    ap = argparse.ArgumentParser(prog='verify-io.py', description='Phase 1 收口 IO 校验')
+    ap.add_argument('--workflow', type=Path, required=True)
+    ap.add_argument('--source', type=Path, required=True, help='原文 input/case-N.json (逐字比对用)')
+    ap.add_argument('--ocr', type=Path, default=None, help='配图 OCR 文本 (可选, 并入比对语料)')
+    args = ap.parse_args()
+
+    if not args.workflow.exists():
+        print(f'verify-io: workflow 不存在 {args.workflow}', file=sys.stderr)
+        sys.exit(2)
+    try:
+        data = json.loads(args.workflow.read_text(encoding='utf-8'))
+    except json.JSONDecodeError as e:
+        print(f'verify-io: workflow 不是合法 JSON: {e}', file=sys.stderr)
+        sys.exit(2)
+
+    lint = _load_lint()
+    _raw, source_norm = lint._load_source_corpus(args.source, args.ocr)
+    if not source_norm:
+        print(f'verify-io: 读不到原文 {args.source} (逐字校验需要它)', file=sys.stderr)
+        sys.exit(2)
+
+    case_id = data.get('case_id', '?')
+    print(f'[verify-io] case-{case_id} ({args.workflow.name})')
+
+    # 1. 文本 IO 逐字 (媒体跳过)
+    io_hints = lint.check_value_verbatim(data, source_norm)
+    if io_hints:
+        print(f'  · 文本 IO 逐字: {len(io_hints)} 处未对上原文 (媒体类已跳过)')
+        for h in io_hints:
+            print(f'      ✗ {h}')
+        print('     → 此时可重读原文; 用 @quote|起锚|止锚 + wf-patch --resolve-quotes 拽完整原文; '
+              '原文确无则标 inferred:true+reason 或 value 写「原文未提供」。')
+    else:
+        print('  · 文本 IO 逐字: OK (文本类 value 都对上原文; 媒体类不要求)')
+
+    # 2. 提示词提取
+    prompt_issues = check_prompt_extraction(data, lint, source_norm)
+    if prompt_issues:
+        print(f'  · 提示词提取: {len(prompt_issues)} 个生成步没提取到提示词')
+        for h in prompt_issues:
+            print(f'      ✗ {h}')
+    else:
+        print('  · 提示词提取: OK (生成步都有提示词)')
+
+    # 3. 媒体 value 的 <…> 包裹
+    media_issues = check_media_brackets(data, lint)
+    if media_issues:
+        print(f'  · 媒体 value <…>: {len(media_issues)} 处没包 (媒体类 value 要写 <具体描述>)')
+        for h in media_issues:
+            print(f'      ✗ {h}')
+    else:
+        print('  · 媒体 value <…>: OK')
+
+    # 4. declarations 收口
+    decl_block, decl_warn = check_declarations(data)
+    if decl_block or decl_warn:
+        print(f'  · declarations 收口: {len(decl_block)} 必补 / {len(decl_warn)} 待确认')
+        for d in decl_block:
+            print(f'      ✗ {d}')
+        for d in decl_warn:
+            print(f'      ⚠ {d}')
+        print('     → 用 wf-patch 补 declarations.inputs/resources/returns, '
+              '例: `--set p1.declarations.returns.type=产品概念图`。')
+    else:
+        print('  · declarations 收口: OK')
+
+    n_block = len(io_hints) + len(prompt_issues) + len(media_issues) + len(decl_block)
+    if n_block:
+        print(f'\n有 {n_block} 个阻塞项要修 (文本IO {len(io_hints)} + 提示词 {len(prompt_issues)} '
+              f'+ 媒体<…> {len(media_issues)} + returns {len(decl_block)})。修完**重跑本脚本**确认通过, 才进 Phase 2。',
+              file=sys.stderr)
+        sys.exit(1)
+    print('\n✓ IO 校验通过, 可进 Phase 2 归类标注。')
+    sys.exit(0)
+
+
+if __name__ == '__main__':
+    main()

+ 909 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back-1/tools/wf-patch.py

@@ -0,0 +1,909 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+wf-patch.py — workflow.json 的安全批量字段设置器.
+
+为什么有这个工具:
+  workflow.json 由各 phase **直接 Write 骨架 + 逐字段填充** 演化. 但「给几十个 IO
+  逐个加 anchor」「给每个 step 填 effect/action/type」这类批量字段赋值, 用 Edit
+  一处一处改太碎, 手写整段 JSON 又极易踩转义 / 控制字符坑 (把文件搞坏).
+  wf-patch 卡在中间: **你只负责语义决策 (path=value), 工具负责安全落盘 + 合法性校验**.
+
+  - 安全 IO: 工具自己 json.load -> 改 -> json.dump(ensure_ascii=False), 你永远不手写 JSON.
+  - 写入即校验 (fail-fast): 每条赋值立刻对照字典树 / type_registry / anchor 格式校验,
+    **任何一条非法 -> 报具体哪条错, 整批不写** (不产出悄悄错的文件). lint 仍做全局兜底.
+
+用法:
+    # 单条 / 多条 --set (path=value, 只在第一个 '=' 处切, value 可含 '=' 和空格)
+    python spec/tools/wf-patch.py --workflow outputs/case-N/workflow.json \
+        --set 'p1.s1.inputs[0].anchor=← s0.主角图' \
+        --set 'p1.s2.effect=主体生成' \
+        --set 'p1.s2.action=生成/图像生成/文生图'
+
+    # 或一次性喂一份 patch 清单 (适合 1.3 加 anchor / 2A 填字段这种几十处批量)
+    python spec/tools/wf-patch.py --workflow outputs/case-N/workflow.json --patch _scratch/anchors.json
+    # anchors.json = [{"path": "p1.s1.inputs[0].anchor", "value": "← s0.x"}, ...]
+
+    # 只校验不写
+    python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
+
+    # 删字段 (取代手 Edit 删; 字段不存在则幂等跳过)
+    python spec/tools/wf-patch.py --workflow ... --unset 'p1.declarations.inputs[0].inferred'
+
+    # 只校验不写
+    python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
+
+路径语法 (proc/step 主用 id (p1/s1), 下标 procedures[N].steps[M] 也接受; inputs/outputs 用 [i]; 嵌套步 id 带点 s2.1 也支持):
+    p1.s2.effect                      step 标量字段 (effect/substance/form/via/action/directive/kind/intent/group)
+    p1.s1.inputs[0].anchor            IO 字段 (anchor/type/value/id)
+    p1.s2.focus                       step 的 focus 数组 (逗号分隔: focus=via,action,out-type-0)
+    p1.purpose                        procedure 头部字段 (name/purpose/category/platform/author)
+    p1.declarations.inputs[0].desc    declarations 内任意字段 (通用下钻)
+    source.url                        case-level 原帖信息 (platform/author/date/url/title/excerpt)
+    p1.type_registry.场景图.extends    注册 case-specific 类型 (会自动建 type_registry 段)
+
+value 特殊取值:
+    __null__   -> JSON null (用于 substance/form/url 可空)
+
+仍用 Write / Edit 的只剩 (尽量别碰生 JSON):
+    - workflow.json 骨架的首次创建 (Phase 1.2 从 template Write)
+    - instruction (列表套列表, 手动 Edit; 透传 directive 用 --resolve-passthrough)
+    改字段/删字段/改 source 现在都走本工具, 不要再 Read→Edit 改 workflow.json (会反复重读、烧 token).
+
+自动修引号 (load 时兜底):
+    workflow.json 由模型直 Write, 偶尔把中文引号写成未转义的 ASCII " → JSON 崩.
+    本工具 load 失败时会自动把这类误引号修成「」再 parse; 修成功则继续 patch, 并把
+    修复随本次写回落盘 (--dry-run 不写). 修不回才按 exit 2 报错. 不用再手写 _scratch 修复脚本.
+
+退出码:
+    0  全部校验通过并写入 (--dry-run 时为校验通过)
+    1  有校验失败 (整批未写) / 路径解析失败
+    2  CLI 参数错误 / 文件不存在 / JSON 损坏 (且自动修引号也救不回)
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+# spec/tools/wf-patch.py -> procedure-dsl/
+DSL_ROOT = Path(__file__).resolve().parent.parent.parent
+TAX_DIR = DSL_ROOT / 'spec' / 'taxonomy'
+
+# Windows 控制台 UTF-8
+for _s in (sys.stdout, sys.stderr):
+    if hasattr(_s, 'reconfigure'):
+        try:
+            _s.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+
+KIND_VOCAB = {'step', 'block', 'nested'}
+
+# value/directive 里的「引用占位」文案 — 这些是 anchor 的活, value 应填数据本身.
+# 命中即视为「未真正回填」(--resolve-passthrough 会尝试填, lint 会报警).
+META_REF = re.compile(r'[((]?\s*同\s*s[\d]|见\s*s[\d]|←\s*s[\d]|同上')
+
+
+# ===========================================================================
+# 自动修引号: 模型直 Write workflow.json 时常把中文引号写成 ASCII " (未转义) → JSON 崩.
+# 仅在 json.loads 失败时兜底调用 (合法文件零开销). 判别: 串内一个 ASCII " 之后第一个
+# 非空白字符 ∈ {,:}]} 或 EOF → 真·字符串定界符 (保留); 否则是误写的内容引号 → 换直角
+# 引号「」(串内交替 开「/闭」). 逻辑独立内置于本文件 (不 import 任何外部模块);
+# scratch/repair_workflow_quotes.py 是同款独立实现, 二者无依赖关系. 改完必须能 parse 才用.
+# ===========================================================================
+
+_STRUCT_AFTER = set(',:}]')
+
+
+def repair_ascii_quotes(raw: str):
+    """→ (修后文本, 改动的内容引号数). 纯走字符, 不依赖能否 parse."""
+    out, i, n = [], 0, len(raw)
+    in_str = esc = False
+    open_q = True
+    changes = 0
+    while i < n:
+        c = raw[i]
+        if not in_str:
+            out.append(c)
+            if c == '"':
+                in_str, esc, open_q = True, False, True
+            i += 1
+            continue
+        if esc:
+            out.append(c); esc = False; i += 1; continue
+        if c == '\\':
+            out.append(c); esc = True; i += 1; continue
+        if c == '"':
+            j = i + 1
+            while j < n and raw[j] in ' \t\r\n':
+                j += 1
+            nxt = raw[j] if j < n else ''
+            if nxt == '' or nxt in _STRUCT_AFTER:
+                out.append(c); in_str = False          # 真·结束符
+            else:
+                out.append('「' if open_q else '」')     # 误写的内容引号
+                open_q = not open_q
+                changes += 1
+            i += 1
+            continue
+        out.append(c); i += 1
+    return ''.join(out), changes
+
+
+class PathError(Exception):
+    """路径无法解析到 workflow.json 里的目标位置."""
+
+
+# ===========================================================================
+# 字典树加载: leaf 集 + {leaf: 全路径} + 全叶路径集 (与 lint 同款叶子派生)
+# ===========================================================================
+
+def _load_tree(name: str):
+    """读 spec/taxonomy/{name}.json. 返回 (leaves:set, leaf2path:dict, control:list)."""
+    f = TAX_DIR / f'{name}.json'
+    if not f.exists():
+        return set(), {}, []
+    d = json.loads(f.read_text(encoding='utf-8'))
+    leaf2path: dict[str, str] = {}
+
+    def walk(node: dict, prefix: list[str]):
+        nm = node.get('分类名称')
+        if not nm:
+            return
+        p = prefix + [nm]
+        kids = node.get('子分类') or []
+        if not kids:                       # 无子分类 = 叶子
+            leaf2path[nm] = '/'.join(p)
+        for c in kids:
+            walk(c, p)
+
+    for top in d.get('最终分类树') or []:
+        walk(top, [])
+    leaves = set(d.get('$leaves') or leaf2path.keys())
+    return leaves, leaf2path, (d.get('$control') or [])
+
+
+EFFECT_LEAVES, EFFECT_PATHS, _ = _load_tree('effect')
+ACTION_LEAVES, ACTION_PATHS, ACTION_CONTROL = _load_tree('action')
+TYPE_LEAVES, TYPE_PATHS, _ = _load_tree('type')
+
+def _closest(name: str, leaves) -> str:
+    """给个最接近的叶子名做提示 (子串/前缀朴素匹配, 仅供报错文案)."""
+    cands = [lf for lf in leaves if name and (name in lf or lf in name)]
+    return (' 最接近: ' + '/'.join(cands[:3])) if cands else ''
+
+
+# ===========================================================================
+# 字段校验 -> (ok, normalized_value, err_msg)
+# ===========================================================================
+
+def validate_field(field: str, value, proc: dict, pending_types: set[str] = None):
+    # null 哨兵 (substance/form/url 可空)
+    if value == '__null__':
+        if field in ('substance', 'form', 'url'):
+            return True, None, ''
+        return False, value, f'__null__ 只对 substance/form/url 有意义, {field} 不可为 null'
+
+    # focus 是数组: 逗号分隔 → list ('via,action,out-type-0'); 空串 → []
+    if field == 'focus':
+        items = [t.strip() for t in str(value).split(',') if t.strip()]
+        return True, items, ''
+
+    if field == 'effect':
+        if value in EFFECT_LEAVES:
+            return True, value, ''
+        # 给了全路径 -> 归一到叶名 (schema 存叶名)
+        for leaf, path in EFFECT_PATHS.items():
+            if value == path:
+                return True, leaf, ''
+        # 容错: 路径里有某段是合法叶名 → 取最末的那个
+        segs = [s.strip() for s in str(value).split('/') if s.strip()]
+        for seg in reversed(segs):
+            if seg in EFFECT_LEAVES:
+                return True, seg, ''
+        return False, value, f'effect={value!r} 不是 effect.json 叶子(存叶名).{_closest(segs[-1] if segs else str(value), EFFECT_LEAVES)}'
+
+    if field == 'action':
+        # action 存全路径; 给叶名自动展开, 给全叶路径原样接受
+        if value in ACTION_PATHS:                 # 是叶名
+            return True, ACTION_PATHS[value], ''
+        if value in ACTION_PATHS.values():        # 是合法叶路径
+            return True, value, ''
+        # 容错: 路径里有某段是合法叶名(多写了段 / 前缀错, 如 生成/元素生成/文生图)→ 取最末的合法叶, 纠正到其全路径
+        segs = [s.strip() for s in str(value).split('/') if s.strip()]
+        for seg in reversed(segs):
+            if seg in ACTION_PATHS:
+                return True, ACTION_PATHS[seg], ''
+        return False, value, (f'action={value!r} 不是合法动作叶子/叶路径 '
+                              f'(对到 action.json 的叶子, 如 元素生成、提取/化学提取/反推).{_closest(segs[-1] if segs else str(value), ACTION_LEAVES)}')
+
+    if field == 'type':
+        # type 是自由标签: Phase 1 随便起个描述性词即可, 不校验是否叶子/注册。
+        # 「对到 type.json 标准叶子 / 注册 type_registry 挂靠」是 Phase 2 归类的活;
+        # 最终是否合法由 lint-case (Check 1: 揪未注册的 case-specific type) + render schema 兜底。
+        if isinstance(value, str) and value.strip():
+            return True, value.strip(), ''
+        return False, value, 'type 不能为空'
+
+    if field == 'extends':                        # type_registry entry 的 extends 必须桥到 stdlib 叶子
+        if value in TYPE_LEAVES:
+            return True, value, ''
+        return False, value, f'type_registry extends={value!r} 必须是 type.json 叶子.{_closest(value, TYPE_LEAVES)}'
+
+    if field in ('substance', 'form'):
+        # 自由提炼的元素点 — 不查词表、不校验 (spec/tools.md §1; phase2-normalize.md §4).
+        # 字符串原样存 (如 "人物、卧室场景"), 数组逐项 strip 后存. 旧的 taxonomy-lookup 校验已废弃.
+        if isinstance(value, str):
+            return True, value.strip(), ''
+        if isinstance(value, list):
+            return True, [str(p).strip() for p in value if str(p).strip()], ''
+        return False, value, f'{field} 必须是字符串或数组'
+
+    if field == 'anchor':
+        if re.match(r'^\s*(←|→)', str(value)):
+            return True, value, ''
+        return False, value, f'anchor={value!r} 须以 ← (输入引用) 或 → (输出去向) 开头'
+
+    if field == 'kind':
+        if value in KIND_VOCAB:
+            return True, value, ''
+        return False, value, f'kind={value!r} 不在 {sorted(KIND_VOCAB)}'
+
+    # 自由文本字段 (name/value/intent/via/purpose/category/platform/author/desc/group...)
+    return True, value, ''
+
+
+# ===========================================================================
+# 路径解析 -> (parent_container, key, proc, field_name)
+# ===========================================================================
+
+_SEG = re.compile(r'^([^\[]+)(?:\[(\d+)\])?$')
+
+
+def _split_seg(seg: str):
+    m = _SEG.match(seg)
+    if not m:
+        raise PathError(f'非法路径段 {seg!r}')
+    return m.group(1), (int(m.group(2)) if m.group(2) is not None else None)
+
+
+def _descend(container, segs, create=False):
+    """沿 segs 走进 container, 返回 (parent, last_key). create=True 时自动建中间节点.
+
+    segs 每段可带 [i] 下标. last_key 是 dict 键 (str) 或列表下标 (int);
+    设置即 parent[last_key]=value, 删除即 del parent[last_key].
+    用于 source.* / declarations.* 等通用路径 (proc/step 的 id 寻址不走这里).
+    """
+    cur = container
+    for i, seg in enumerate(segs):
+        name, idx = _split_seg(seg)
+        last = (i == len(segs) - 1)
+
+        if idx is None:
+            if last:
+                if not isinstance(cur, dict):
+                    raise PathError(f'{name!r} 的父级不是对象')
+                return cur, name
+            if not isinstance(cur, dict):
+                raise PathError(f'路径段 {name!r} 的父级不是对象')
+            if name not in cur:
+                if not create:
+                    raise PathError(f'路径段 {name!r} 不存在, 无法下钻')
+                cur[name] = {}
+            cur = cur[name]
+        else:
+            if not isinstance(cur, dict):
+                raise PathError(f'路径段 {name!r} 的父级不是对象')
+            if name not in cur:
+                if not create:
+                    raise PathError(f'路径段 {name!r} 不存在, 无法下钻')
+                cur[name] = []
+            lst = cur[name]
+            if not isinstance(lst, list):
+                raise PathError(f'{name} 不是列表')
+            if idx >= len(lst):
+                if not create:
+                    raise PathError(f'{name}[{idx}] 越界或非列表')
+                while idx >= len(lst):
+                    lst.append({})
+            if last:
+                return lst, idx
+            cur = lst[idx]
+    raise PathError('路径为空')
+
+
+# ── --create 自动建结构用的骨架 ─────────────────────────────────────────────
+_STEP_SCALARS = {'effect', 'substance', 'form', 'via', 'action', 'directive', 'kind', 'intent', 'group'}
+
+
+def _new_procedure(pid: str) -> dict:
+    return {
+        'id': pid, 'name': '', 'purpose': '', 'category': '', 'platform': '', 'author': '',
+        'declarations': {'inputs': [], 'resources': [], 'returns': {}},
+        'steps': [],
+    }
+
+
+def _new_step(sid: str) -> dict:
+    return {'id': sid, 'kind': 'step', 'via': '', 'inputs': [], 'outputs': []}
+
+
+def _new_io(is_output: bool, sid: str, idx: int) -> dict:
+    if is_output:
+        return {'id': f'{sid}o{idx + 1}', 'type': '', 'value': '', 'anchor': ''}
+    return {'type': '', 'value': '', 'anchor': ''}
+
+
+def _split_step_path(remainder: str):
+    """create 模式下 step 不存在、无法前缀匹配时, 从路径切出 (sid, fsegs).
+
+    规则: 出现 inputs[i]/outputs[i] 段 → 其前为 sid; 否则末段须是已知 step 标量字段, 其前为 sid.
+    sid 可含点 (嵌套步 s2.1)。切不出返回 (None, None) — 不创建, 避免误建。
+    """
+    segs = remainder.split('.')
+    for j, s in enumerate(segs):
+        nm, _ = _split_seg(s)
+        if nm in ('inputs', 'outputs'):
+            return ('.'.join(segs[:j]) or None), segs[j:]
+    if segs[-1] in _STEP_SCALARS:
+        return ('.'.join(segs[:-1]) or None), [segs[-1]]
+    return None, None
+
+
+def locate(data: dict, path: str, create: bool = False):
+    """把 path 解析到目标. 返回 (parent, key, proc, field_name).
+
+    设置即 parent[key] = value. proc 给校验提供 type_registry 上下文.
+    proc / step 主用 id 寻址 (p1/s1), 也接受下标 procedures[N]/steps[M]; inputs/outputs 用 [i] 下标.
+    step id 可能带点 (嵌套步 s2.1) — 用最长前缀匹配消歧 (s2.1 优先于 s2).
+    create=True (构建模式): 缺失的 procedure / step / IO 元素 / 中间结构自动创建, 新建打印到 stderr.
+    """
+    if '.' not in path:
+        raise PathError(f'路径太短 {path!r}, 至少 <proc>.<字段> 或 source.<字段>')
+
+    proc_id, remainder = path.split('.', 1)
+
+    # --- source.* 分支 (case-level 原帖信息, 无 proc 上下文) ---
+    if proc_id == 'source':
+        src = data.setdefault('source', {})
+        parent, key = _descend(src, remainder.split('.'), create=create)
+        return parent, key, None, (key if isinstance(key, str) else '')
+
+    procs = data.setdefault('procedures', [])
+    # 接受两种 proc 寻址: id (p1) 或下标别名 procedures[N] (映射到第 N 个工序; 弱模型爱用这种)
+    m_idx = re.match(r'^procedures\[(\d+)\]$', proc_id)
+    if m_idx:
+        idx = int(m_idx.group(1))
+        if idx < len(procs):
+            proc = procs[idx]
+        elif create:
+            proc = _new_procedure(f'p{idx + 1}')
+            procs.append(proc)
+            print(f'[wf-patch] + 新建 procedure p{idx + 1} (来自 procedures[{idx}])', file=sys.stderr)
+        else:
+            raise PathError(f'procedures[{idx}] 越界 (现有 {len(procs)} 个工序)')
+    else:
+        proc = next((p for p in procs if p.get('id') == proc_id), None)
+        if proc is None:
+            if not create:
+                ids = [p.get('id') for p in procs]
+                raise PathError(f'找不到 procedure id={proc_id!r} (现有: {ids})')
+            proc = _new_procedure(proc_id)
+            procs.append(proc)
+            print(f'[wf-patch] + 新建 procedure {proc_id}', file=sys.stderr)
+
+    # --- type_registry 分支 (允许自动建段/条目) ---
+    if remainder == 'type_registry' or remainder.startswith('type_registry.'):
+        parts = remainder.split('.')
+        if len(parts) == 3:
+            reg = proc.setdefault('type_registry', {})
+            entry = reg.setdefault(parts[1], {})
+            return entry, parts[2], proc, parts[2]
+        raise PathError('type_registry 路径形如 p1.type_registry.<类型名>.<extends|desc>')
+
+    # --- step 分支: 支持 id 寻址 (p1.s1.effect) 和下标寻址 (procedures[0].steps[0].effect) ---
+    matched, field_part = None, None
+    m_step = re.match(r'^steps\[(\d+)\]\.(.+)$', remainder)   # 下标寻址 steps[N].<字段>
+    if m_step:
+        sidx = int(m_step.group(1))
+        steps = proc.setdefault('steps', [])
+        if sidx < len(steps):
+            matched = steps[sidx]
+        elif create:
+            matched = _new_step(f's{sidx + 1}')
+            steps.append(matched)
+            print(f'[wf-patch] + 新建 step (procedures.steps[{sidx}] → id s{sidx + 1})', file=sys.stderr)
+        else:
+            raise PathError(f'steps[{sidx}] 越界 (该工序现有 {len(steps)} 步)')
+        field_part = m_step.group(2)
+    else:                                                     # id 寻址: 最长前缀匹配现有 step id
+        for s in (proc.get('steps') or []):
+            sid = s.get('id')
+            if not sid:
+                continue
+            if remainder == sid:
+                raise PathError(f'step 路径要带字段, 形如 {proc_id}.{sid}.effect')
+            if remainder.startswith(sid + '.') and (matched is None or len(sid) > len(matched['id'])):
+                matched = s
+        if matched is None and create:
+            sid_new, _ = _split_step_path(remainder)
+            if sid_new:
+                matched = _new_step(sid_new)
+                proc.setdefault('steps', []).append(matched)
+                print(f'[wf-patch] + 新建 step {proc_id}.{sid_new}', file=sys.stderr)
+        if matched is not None:
+            field_part = remainder[len(matched['id']) + 1:]   # 'sid.' 之后
+    if matched is not None:
+        sid = matched.get('id', '?')
+        fsegs = field_part.split('.')
+        name2, idx2 = _split_seg(fsegs[0])
+        if name2 in ('inputs', 'outputs'):
+            if idx2 is None:
+                raise PathError(f'{name2} 要带下标, 形如 {name2}[0]')
+            lst = matched.get(name2)
+            if not isinstance(lst, list):
+                if create:
+                    matched[name2] = lst = []
+                else:
+                    raise PathError(f'{proc_id}.{sid}.{name2}[{idx2}] 越界 (该 step 有 0 个 {name2})')
+            if idx2 >= len(lst):
+                if not create:
+                    raise PathError(f'{proc_id}.{sid}.{name2}[{idx2}] 越界 (该 step 有 {len(lst)} 个 {name2})')
+                while idx2 >= len(lst):
+                    lst.append(_new_io(name2 == 'outputs', sid, len(lst)))
+            if len(fsegs) != 2:
+                raise PathError(f'IO 路径形如 {proc_id}.{sid}.{name2}[{idx2}].anchor')
+            return lst[idx2], fsegs[1], proc, fsegs[1]
+        else:
+            if len(fsegs) != 1:
+                raise PathError(f'step 标量字段形如 {proc_id}.{sid}.{name2}')
+            return matched, name2, proc, name2
+
+    # --- proc 内其余路径: 头部字段 / declarations.* / return_row.* 等, 走通用下钻 ---
+    parent, key = _descend(proc, remainder.split('.'), create=create)
+    return parent, key, proc, (key if isinstance(key, str) else '')
+
+
+# ===========================================================================
+# 透传回填: anchor 为纯 ← sN.varname 的 IO, 从源 output 抄 value (逐字回填)
+# ===========================================================================
+
+def _is_fillable(value) -> bool:
+    """该 value 算「还没真正回填」吗 — 空 / 占位符 / 引用文案."""
+    if value in (None, '', '-'):
+        return True
+    return bool(META_REF.search(str(value)))
+
+
+def _passthrough_id(anchor):
+    """anchor 为 `← <output-id>` (可带 [i] 容器索引) → 返回 output id; 否则 None.
+
+    `← 工序输入` / `← s6 (链)` 等非纯 id 引用返回 None (无法确定唯一源 value).
+    """
+    m = re.match(r'^\s*←\s*([^\s\[((]+)', str(anchor or ''))
+    if not m:
+        return None
+    return m.group(1) or None
+
+
+def resolve_passthrough(data: dict):
+    """把 anchor 为纯透传 (← <output-id>)、value 仍空或占位的 input, 用源 output 的 value 逐字填上.
+
+    迭代到不动点 (处理链式透传). 返回 (filled_msgs, warn_msgs).
+    """
+    out_index = {}          # output id -> output item (读 value)
+    for p in data.get('procedures') or []:
+        for s in p.get('steps') or []:
+            for o in s.get('outputs') or []:
+                if isinstance(o, dict) and o.get('id'):
+                    out_index[o['id']] = o
+
+    def _src_value(rid):
+        src = out_index.get(rid)
+        if src is None or _is_fillable(src.get('value')):
+            return None
+        return src['value']
+
+    filled: list[str] = []
+    changed, rounds = True, 0
+    while changed and rounds < 20:
+        changed, rounds = False, rounds + 1
+        for p in data.get('procedures') or []:
+            for s in p.get('steps') or []:
+                for idx, io in enumerate(s.get('inputs') or []):
+                    if not isinstance(io, dict) or not _is_fillable(io.get('value')):
+                        continue
+                    rid = _passthrough_id(io.get('anchor'))
+                    val = _src_value(rid) if rid else None
+                    if val is None:
+                        continue
+                    io['value'] = val
+                    filled.append(
+                        f"{p.get('id')}.{s.get('id')}.inputs[{idx}].value "
+                        f"← 复制自 {rid} ({len(str(val))} 字)"
+                    )
+                    changed = True
+
+    # 仍填不动的透传 (源 id 找不到) → warn
+    warns: list[str] = []
+    for p in data.get('procedures') or []:
+        for s in p.get('steps') or []:
+            for idx, io in enumerate(s.get('inputs') or []):
+                if not isinstance(io, dict) or not _is_fillable(io.get('value')):
+                    continue
+                rid = _passthrough_id(io.get('anchor'))
+                if rid and out_index.get(rid) is None:
+                    warns.append(
+                        f"{p.get('id')}.{s.get('id')}.inputs[{idx}] anchor 指向 "
+                        f"{rid} 但找不到该 output id (检查 anchor / output id)"
+                    )
+    return filled, warns
+
+
+# ===========================================================================
+# 应用
+# ===========================================================================
+
+def load_patches(args) -> list[tuple[str, str]]:
+    """汇总 --set、--patch 与 --set-file 成 [(path, value), ...]."""
+    def _norm(v):
+        if isinstance(v, str):
+            # 将中文全角双角/单引号自动归一化为标准半角引号,更利于 AI 生图引擎和 Prompt 语法识别
+            v = v.replace('“', '"').replace('”', '"').replace('‘', "'").replace('’', "'")
+        return v
+
+    def _dq(x: str) -> str:
+        # 剥掉成对外层引号. cmd.exe 不剥单引号 → agent 写的 --set 'p.f=v' / p.f='v'
+        # 会把单引号原样传进来, 导致 path 变成 'p1 这种 (找不到 procedure). 这里兜底剥掉.
+        x = x.strip()
+        if len(x) >= 2 and x[0] == x[-1] and x[0] in ('"', "'"):
+            x = x[1:-1].strip()
+        return x
+
+    out: list[tuple[str, str]] = []
+    for s in args.set or []:
+        s = _dq(s)                           # 整体外层引号 (cmd 没剥的 'path=value')
+        if '=' not in s:
+            raise SystemExit(f'wf-patch: --set 缺 "=" : {s!r} (形如 path=value)')
+        path, value = s.split('=', 1)        # 只切第一个 '='
+        out.append((_dq(path), _norm(_dq(value))))
+
+    # 🟢 新增:从外部文件读取值注入
+    for sf in getattr(args, 'set_file', None) or []:
+        if '=' not in sf:
+            raise SystemExit(f'wf-patch: --set-file 缺 "=" : {sf!r} (形如 path=file_path)')
+        path, fpath_str = sf.split('=', 1)
+        fpath = Path(fpath_str.strip())
+        if not fpath.exists():
+            raise SystemExit(f'wf-patch: --set-file 指定的文件不存在: {fpath_str}')
+        try:
+            value = fpath.read_text(encoding='utf-8')
+        except Exception as e:
+            raise SystemExit(f'wf-patch: 无法读取 --set-file 指定的文件 {fpath_str}: {e}')
+        out.append((path.strip(), _norm(value)))
+
+    if args.patch:
+        if not args.patch.exists():
+            raise SystemExit(
+                f'wf-patch: --patch 文件不存在 {args.patch}\n'
+                f'  → --patch 的清单文件是**你要先写的输入**(扁平 `[{{"path":..,"value":..}}]` JSON)。\n'
+                f'    先 write_file 写出它, 再 `--patch` 跑它;\n'
+                f'    或字段不多时直接内联: `--set \'p1.s1.inputs[0].type=参考图\' --set ...`(不用文件)。')
+        try:
+            items = json.loads(args.patch.read_text(encoding='utf-8'))
+        except json.JSONDecodeError as e:
+            raise SystemExit(f'wf-patch: --patch 不是合法 JSON: {e}')
+        for it in items:
+            out.append((it['path'], _norm(it['value'])))
+    return out
+
+
+# ===========================================================================
+# @quote 标记回填: value/directive 写 `@quote|起锚|止锚` 或 `@quote|关键词`,
+# 由 --resolve-quotes 顺标记从原文/配图 OCR 匹配真实内容, 批量替换 (空白无关匹配)
+# ===========================================================================
+
+def _q_source_text(path: Path) -> str:
+    d = json.loads(path.read_text(encoding='utf-8'))
+    if not isinstance(d, dict):
+        return str(d)
+    return '\n'.join(str(d.get(k, '')) for k in ('title', 'body_text') if d.get(k))
+
+
+def _q_norm(text: str):
+    chars, idx = [], []
+    for i, ch in enumerate(text):
+        if ch.isspace():
+            continue
+        chars.append(ch)
+        idx.append(i)
+    return ''.join(chars), idx
+
+
+def _q_range_between(text: str, frm: str, to: str):
+    ns, idx = _q_norm(text)
+    nf, _ = _q_norm(frm)
+    nt, _ = _q_norm(to)
+    if not nf or not nt:
+        return None
+    p = ns.find(nf)
+    if p < 0:
+        return None
+    q = ns.find(nt, p + len(nf))
+    if q < 0:
+        return None
+    return text[idx[p]: idx[q + len(nt) - 1] + 1]
+
+
+def _q_braces(text: str, lo: int, hi: int):
+    depth, op, i = 0, None, lo
+    while i >= 0:
+        if text[i] == '}':
+            depth += 1
+        elif text[i] == '{':
+            if depth == 0:
+                op = i
+                break
+            depth -= 1
+        i -= 1
+    if op is None:
+        return None
+    depth, j = 0, op
+    while j < len(text):
+        if text[j] == '{':
+            depth += 1
+        elif text[j] == '}':
+            depth -= 1
+            if depth == 0:
+                return (op, j) if j >= hi else None
+        j += 1
+    return None
+
+
+def _q_query_block(text: str, query: str):
+    ns, idx = _q_norm(text)
+    nq, _ = _q_norm(query)
+    if not nq:
+        return None
+    p = ns.find(nq)
+    if p < 0:
+        return None
+    o_lo, o_hi = idx[p], idx[p + len(nq) - 1]
+    br = _q_braces(text, o_lo, o_hi)           # 命中落在 {...} 内 → 返回整块
+    if br:
+        return text[br[0]: br[1] + 1]
+    lo = text.rfind('\n', 0, o_lo) + 1          # 否则返回所在行/段
+    hi = text.find('\n', o_hi)
+    return text[lo: (hi if hi >= 0 else len(text))].strip()
+
+
+def resolve_quotes(data: dict, corpora: list):
+    """扫 value/directive 里的 `@quote|...` 标记, 顺标记从 corpora 匹配真实内容批量替换.
+
+    标记: `@quote|起锚|止锚` (范围, 推荐长段) 或 `@quote|关键词` (命中落 JSON 块返回整块, 否则所在行/段).
+    corpora: [(label, text), ...] 依次尝试 (原文优先, 再 OCR). 返回 (filled, warns).
+    """
+    filled, warns = [], []
+
+    def _resolve(v: str):
+        parts = v[len('@quote|'):].split('|')
+        for label, text in corpora:
+            r = _q_range_between(text, parts[0], parts[1]) if len(parts) >= 2 else _q_query_block(text, parts[0])
+            if r:
+                return r, label
+        return None, None
+
+    for p in data.get('procedures') or []:
+        pid = p.get('id')
+        for s in p.get('steps') or []:
+            sid = s.get('id')
+            d = s.get('directive')
+            if isinstance(d, str) and d.startswith('@quote|'):
+                r, label = _resolve(d)
+                if r:
+                    s['directive'] = r
+                    filled.append(f'{pid}.{sid}.directive ← [{label}] {len(r)} 字')
+                else:
+                    warns.append(f'{pid}.{sid}.directive: @quote 未匹配 {d[:40]!r}')
+            for kind in ('inputs', 'outputs'):
+                for k, item in enumerate(s.get(kind) or []):
+                    if not isinstance(item, dict):
+                        continue
+                    v = item.get('value')
+                    if isinstance(v, str) and v.startswith('@quote|'):
+                        r, label = _resolve(v)
+                        if r:
+                            item['value'] = r
+                            filled.append(f'{pid}.{sid}.{kind}[{k}].value ← [{label}] {len(r)} 字')
+                        else:
+                            warns.append(f'{pid}.{sid}.{kind}[{k}].value: @quote 未匹配 {v[:40]!r}')
+    return filled, warns
+
+
+def main() -> None:
+    ap = argparse.ArgumentParser(
+        prog='wf-patch.py',
+        description='workflow.json 安全批量字段设置器 (写入即校验, 任何一条非法整批不写)',
+    )
+    ap.add_argument('--workflow', type=Path, required=True, help='目标 workflow.json')
+    ap.add_argument('--set', action='append', metavar='PATH=VALUE',
+                    help='单条赋值, 可重复. 只在第一个 = 处切; value 可含 = 和空格 (记得整体加引号)')
+    ap.add_argument('--patch', type=Path, default=None,
+                    help='批量赋值清单 .json: [{"path":..,"value":..}, ...]')
+    ap.add_argument('--set-file', action='append', metavar='PATH=FILE_PATH', default=None,
+                    help='从外部文件读取内容注入指定字段. e.g. p1.s1.outputs[0].value=_scratch/prompt.txt')
+    ap.add_argument('--unset', action='append', metavar='PATH', default=None,
+                    help='删字段, 可重复. e.g. p1.declarations.inputs[0].inferred (字段不存在则跳过). 取代手 Edit 删字段')
+    ap.add_argument('--resolve-passthrough', action='store_true',
+                    help='把 anchor 为纯透传 (← sN.varname)、value 仍空/占位的 IO, 顺 anchor 从源 output 逐字抄 value. 可单独跑, 也可跟在 --set/--patch 后 (先赋值再解析). 迭代处理链式透传')
+    ap.add_argument('--dry-run', action='store_true', help='只校验/预演, 不写')
+    ap.add_argument('--create', action='store_true',
+                    help='(已默认开启, 保留兼容) 缺失的 procedure / step / IO 元素自动创建、文件不存在从空建')
+    ap.add_argument('--no-create', action='store_true',
+                    help='关闭自动建: 路径不存在就报错 (严格防 typo; 仅在「纯填充已存在结构、想抓打错的路径」时用)')
+    ap.add_argument('--resolve-quotes', action='store_true',
+                    help='把 value/directive 里的 @quote|起锚|止锚 (或 @quote|关键词) 标记, 顺标记从 --source 原文 / --ocr 配图文本匹配真实内容批量替换. 跟 anchor patch 一起跑')
+    ap.add_argument('--source', type=Path, default=None, help='--resolve-quotes 的原文 case json (匹配语料)')
+    ap.add_argument('--ocr', type=Path, default=None, help='--resolve-quotes 的配图 OCR 文本文件 (第二语料)')
+    args = ap.parse_args()
+    # 默认 upsert (缺路径自动建); 弱模型增量 patch 总需要它, gated 反而一直撞「越界/不存在」死循环。
+    # --no-create 才关闭 (回到严格寻址、防 typo)。--create 保留为 no-op 兼容旧命令/文档。
+    args.create = not args.no_create
+
+    wf = args.workflow
+    repaired = 0
+    if args.create and not wf.exists():
+        data = {}
+        print(f'[wf-patch] + 新建 workflow 文件 {wf.name} (从空开始构建)', file=sys.stderr)
+    elif not wf.exists():
+        print(f'wf-patch: 文件不存在 {wf} (默认会从空新建; 你传了 --no-create 才不建)', file=sys.stderr)
+        sys.exit(2)
+    else:
+        raw = wf.read_text(encoding='utf-8')
+        try:
+            data = json.loads(raw)
+        except json.JSONDecodeError as e:
+            # 兜底: 试着把误写成 ASCII 的中文引号修成「」再 parse (模型直 Write 常见崩因)
+            fixed, repaired = repair_ascii_quotes(raw)
+            try:
+                data = json.loads(fixed)
+            except json.JSONDecodeError:
+                print(f'wf-patch: {wf} 不是合法 JSON, 无法处理: {e}\n'
+                      f'  → 去 workflow.json 第 {e.lineno} 行附近修语法 '
+                      f'(最常见: 数组 ] 或 对象 }} 结束后、下一个 "key" 前缺逗号), 修好再重跑。'
+                      f'别盲目重试本命令 (JSON 没修, 每次都同样报错)。', file=sys.stderr)
+                sys.exit(2)
+            print(f'[wf-patch] ⚠️ 原文件 JSON 非法 ({e.msg} @ line {e.lineno}); 已自动把 '
+                  f'{repaired} 处误写的 ASCII 引号修成「」→ 解析成功, 修复将随本次写回落盘',
+                  file=sys.stderr)
+
+    patches = load_patches(args)
+    unsets = args.unset or []
+    if not patches and not unsets and not args.resolve_passthrough and not args.resolve_quotes:
+        print('wf-patch: 没有 --set / --patch / --unset / --resolve-passthrough / --resolve-quotes, 啥也没干', file=sys.stderr)
+        sys.exit(2)
+
+    # 解析 + 校验; 任何一条失败 -> 整批不写
+    pending_types = set()
+    for path, _ in patches:
+        m = re.match(r'^p\d+\.type_registry\.([^.]+)\.(extends|desc)$', path)
+        if m:
+            pending_types.add(m.group(1))
+
+    plan = []          # set: (parent, key, normalized_value, path, display)
+    del_plan = []      # unset: (parent, key, path)
+    skipped = []       # unset 跳过 (字段本就不在)
+    # 错误分两类, 决定原子 vs 部分应用:
+    #   fatal  = 路径/结构错 (locate 失败: 路径对不上、缺 --create 等) → 整批不写, 保护结构
+    #   value  = 字段值校验失败 (effect/action/type 等值非法) → **跳过这条、其余照写** (部分应用),
+    #            末尾以 exit 1 提示回去补。这样一批里几个值错不再连累其余正确字段一起丢。
+    fatal_errors = []  # (path, msg)
+    value_errors = []  # (path, msg)
+    for path, value in patches:
+        try:
+            parent, key, proc, field = locate(data, path, create=args.create)
+        except PathError as e:
+            fatal_errors.append((path, str(e)))
+            continue
+        ok, norm, msg = validate_field(field, value, proc, pending_types)
+        if not ok:
+            value_errors.append((path, msg))
+            continue
+        plan.append((parent, key, norm, path, norm if norm is not None else 'null'))
+    for path in unsets:
+        try:
+            parent, key, _proc, _field = locate(data, path)
+        except PathError as e:
+            fatal_errors.append((path, str(e)))
+            continue
+        present = (isinstance(parent, dict) and key in parent) or \
+                  (isinstance(parent, list) and isinstance(key, int) and key < len(parent))
+        (del_plan if present else skipped).append((parent, key, path) if present else path)
+
+    if patches or unsets:
+        n_fail = len(fatal_errors) + len(value_errors)
+        print(f'[wf-patch] {wf.name} — set {len(plan)}/{len(patches)} 通过, '
+              f'unset {len(del_plan)} 删/{len(skipped)} 跳过, {n_fail} 失败')
+        for _p, _k, _n, path, disp in plan:
+            print(f'  ✓ set   {path} = {disp}')
+        for _p, _k, path in del_plan:
+            print(f'  ✓ unset {path}')
+        for path in skipped:
+            print(f'  · skip  {path} (字段本就不存在)')
+        for path, msg in value_errors:
+            print(f'  ✗ {path}  — {msg}')
+        for path, msg in fatal_errors:
+            print(f'  ✗✗ {path}  — {msg}  [路径/结构错]')
+        # 结构错 = patch 清单跟文件对不上 → 整批不写 (原子, 别半建坏骨架)
+        if fatal_errors:
+            print(f'\n有 {len(fatal_errors)} 条路径/结构错误, 整批未写入 (先修: 路径写对 / 该建的加 --create).',
+                  file=sys.stderr)
+            sys.exit(1)
+        # 只有字段值非法 → 跳过这几条、其余照常写, 进度不丢; 末尾 exit 1 提示去补
+        if value_errors:
+            print(f'\n{len(value_errors)} 处字段值非法**已跳过未写**, 其余 {len(plan)} 处照常应用; '
+                  f'只需修正这几条后重跑补上 (进度不丢, 别整批重来).', file=sys.stderr)
+
+    # 应用到内存 data (set 先 unset 后; resolve 要看到这些改动). 是否持久化由 dry-run 决定.
+    for parent, key, norm, _, _ in plan:
+        parent[key] = norm
+    for parent, key, _path in sorted(del_plan, key=lambda d: -d[1] if isinstance(d[1], int) else 0):
+        if isinstance(parent, list):
+            parent.pop(key)
+        else:
+            del parent[key]
+
+    # 透传回填
+    filled, warns = [], []
+    if args.resolve_passthrough:
+        filled, warns = resolve_passthrough(data)
+        print(f'[resolve-passthrough] 回填 {len(filled)} 处透传 value, {len(warns)} 处填不动')
+        for m in filled:
+            print(f'  ✓ {m}')
+        for w in warns:
+            print(f'  ⚠ {w}')
+
+    # @quote 标记回填 (顺 @quote|起锚|止锚 从原文/OCR 匹配真实内容批量替换)
+    quoted, qwarns = [], []
+    if args.resolve_quotes:
+        corpora = []
+        if args.source and args.source.exists():
+            corpora.append(('原文', _q_source_text(args.source)))
+        if args.ocr and args.ocr.exists():
+            corpora.append(('配图OCR', args.ocr.read_text(encoding='utf-8')))
+        if not corpora:
+            print('wf-patch: --resolve-quotes 需要 --source (或 --ocr) 指向匹配语料', file=sys.stderr)
+        else:
+            quoted, qwarns = resolve_quotes(data, corpora)
+            print(f'[resolve-quotes] 回填 {len(quoted)} 处 @quote 标记, {len(qwarns)} 处未匹配')
+            for m in quoted:
+                print(f'  ✓ {m}')
+            for w in qwarns:
+                print(f'  ⚠ {w}')
+
+    # 有字段值非法被跳过 → 退出码 1 (告诉 agent 还有几条要补), 但有效改动已照常落盘
+    final_exit = 1 if value_errors else 0
+
+    n_changes = len(plan) + len(del_plan) + len(filled) + len(quoted)
+    if args.dry_run:
+        extra = f' (+ 自动修复 {repaired} 处引号, dry-run 同样不写)' if repaired else ''
+        print(f'\n--dry-run: 预演 {n_changes} 处改动{extra}, 未写入.')
+        sys.exit(final_exit)
+
+    # repaired>0 时即便无字段改动也要落盘 (否则修好的引号没存下来, 文件还是坏的)
+    if n_changes == 0 and not repaired:
+        print('\n没有改动 (透传 value 都已填好 / 无可赋值), 未写文件.')
+        sys.exit(final_exit)
+
+    # 落盘 (安全序列化, 你从不手写 JSON)
+    wf.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    tail = f' (含自动修复 {repaired} 处引号→「」)' if repaired else ''
+    print(f'\n已写入 {n_changes} 处到 {wf.name}{tail}.')
+    sys.exit(final_exit)
+
+
+if __name__ == '__main__':
+    main()

+ 235 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/README.md

@@ -0,0 +1,235 @@
+# Procedure DSL · 语法 + 提取 SKILL
+
+> Procedure DSL 的语法 reference 和从原始 case 提取的操作 SKILL。
+>
+> 两部分: **语法定义** (顶层 [`syntax.md`](syntax.md), 干货不论证) + **提取流程** (子目录 [`extraction/`](extraction/), 三阶段操作步骤)。
+>
+> 本目录各文件完整自洽; 需要 .md 样板时参考 `outputs/` 下已产出的 case-*.md。
+>
+> ⚠ **路径提示**: 语法在 `spec/syntax.md` 顶层文件 (不在 `spec/part1-syntax/`!), 提取流程在 `spec/extraction/` 子目录. 没有 `part1-` / `part2-` 这种命名 — 不要脑补对称目录.
+
+---
+
+**本目录 = 一个自包含 skill**: Agent 跑提取流程时, **所有 spec 信息都在本目录内**. 外部 case 数据 (`input/case-N-raw.json`) 和工作产物 (`outputs/case-N/`) 由 Agent 各自读写, 不算 spec.
+
+## 输入 / 输出
+
+**输入** — 任一原始 case 素材: 公众号 / 小红书 / 推文 / 博客 (正文 + 配图)、视频教程 (含转写)、自己的工作回顾。
+
+**输出** (两份, 路径 `outputs/case-{N}/case-{N}-<slug>.{md,html}`):
+- `.md` — DSL 文本版 (按 [format/md-structure.md](format/md-structure.md) §11 结构)
+- `.html` — 可视化 (按 [format/procedure-table.md](format/procedure-table.md) 工序表规范)
+
+## 章节地图
+
+### 概念模型 ([syntax.md](syntax.md))
+
+DSL 的**概念模型 + 字段语义速览** (Phase 1 一次性加载). 不含逐构造的文本语法 —— 产物是 workflow.json + HTML, 不手写 DSL 文本.
+
+| 内容 | § |
+|---|---|
+| 5 层架构 (L0 类型库 / L1 外部函数 / L2 抽象动作 / L3 工序模板 / L4 工序实例) + workflow.json 字段映射 | §1 |
+| 类型系统 (字典树叶子命名 + case-specific extends; 实质/形式 挂 IO item) | §2 |
+| 动作模型 (`via / action / feature / control / effect` 五字段) | §3 |
+| 工序结构 (L3 模板 = procedure; L4 实例 = 实际值回填) | §4 |
+| 命名规范 | §5 |
+| 标注速览 (step + IO 字段, 详见 [extraction/fields.md](extraction/fields.md)) | §6 |
+
+### 提取 SKILL ([extraction/](extraction/))
+
+从 case 原文到 DSL + 可视化的提取流程. 按 phase 加载.
+
+| 文件 | 内容 | 加载时机 |
+|---|---|---|
+| [extraction/fields.md](extraction/fields.md) | 23 字段定义 + 推断补全标记 (三阶段共享) | Phase 1 起手 |
+| [extraction/control-flow.md](extraction/control-flow.md) | 控制流 → block/nested 结构建模 (循环/并行/分支怎么不拍平) | Phase 1 |
+| [extraction/phase1-skeleton.md](extraction/phase1-skeleton.md) | 阶段一执行 (1.1/1.2/1.3 骨架提取 + 多工序判断) | Phase 1 |
+| [extraction/phase2-normalize.md](extraction/phase2-normalize.md) | 阶段二归一化与标注 (fan-out 2A / 2B 子 Agent) | Phase 2 |
+| [extraction/phase3-finalize.md](extraction/phase3-finalize.md) | 阶段三检查收尾 (lint + 目的列 + 落盘) | Phase 3 |
+
+### Output · 产物规约 ([format/](format/))
+
+`.md` 和 `.html` 两种产物的固定结构 + 数据契约.
+
+| 文件 | 内容 |
+|---|---|
+| [md-structure.md](format/md-structure.md) | .md 输出固定章节 (工序梗概 / 引用类型 / L1-L4 / 关键启发) |
+| [procedure-table.md](format/procedure-table.md) | 工序表创作规范 (23 列字段语义 + intent token 规则 + chip 视觉 + 审美约束) |
+| [case-data.schema.json](format/case-data.schema.json) | `render-case.py` 的输入 JSON Schema (Draft 2020-12) |
+
+### Taxonomy · 字典树受控词表 ([taxonomy/](taxonomy/))
+
+5 维标注体系 (作用 / 动作 / 类型 / 实质 / 形式) 全部 bundled 在本目录, skill 自包含.
+
+| 文件 | 维度 | 规模 | Agent 加载方式 |
+|---|---|---:|---|
+| [taxonomy/README.md](taxonomy/README.md) | — | — | **仅子 Agent 读取**(主 Agent 绝对禁止加载) |
+| [effect.json](taxonomy/effect.json) | 作用 | 9 叶子 | **仅 `phase-2a-normalizer` 自动加载并读取**(主 Agent 绝不加载,以维持极简 Context) |
+| [action.json](taxonomy/action.json) | 动作 | 30 叶子 + 5 control | 同上 |
+| [type.json](taxonomy/type.json) | 类型 | 50 叶子 | 同上。每节点含 `分类说明`;case-specific 类型自动写入 `workflow.json` 的 `type_registry` |
+| [type_suggestions.md](taxonomy/type_suggestions.md) | 类型升级提案 | append-only | 阶段三跑 `lint-case.py` 时**由工具自动同步 append**,Agent 无需手工编辑 |
+| `taxonomy/分类库导出_实质_*.json` | 实质 | 911 路径 | **仅由 `phase-2b-matcher` 内部通过脚本查询**(主 Agent 绝不读取和查询) |
+| `taxonomy/分类库导出_形式_*.json` | 形式 | 565 路径 | 同上 |
+
+> ⚠ **主 Agent 绝对隔离原则**:上述所有分类树文件和查询脚本均**只对子 Agent 开放**。主 Agent 在整个流程中**绝对不要直接 Read 它们,也绝对不要直接调用 `taxonomy-lookup.py`**!主 Agent 唯一的职责是运行物理切片脚本 `prepare-subtask.py` 并分发 `Agent` 任务,以此彻底杜绝主 Agent 发生 Context 膨胀或违背隔离架构手动通关。
+
+### Templates · 渲染模板 ([templates/](templates/))
+
+| 文件 | 用途 |
+|---|---|
+| [templates/workflow.template.json](templates/workflow.template.json) | `workflow.json` 填充骨架 (多工序 `procedures:[]`). 复制到 `outputs/case-{N}/workflow.json` 后填 `<填:...>` 占位符, 各 phase in-place Edit, Phase 3 跑 `spec/tools/render-case.py --workflow` 校验+渲染 |
+
+### Tools · 工具脚本 ([tools/](tools/))
+
+Agent 跑提取时**通过 Bash 调用**的脚本. **Agent 不需要 Read 这些脚本源代码**, 接口手册见 [tools.md](tools.md).
+
+| 工具 | 用途 |
+|---|---|
+| [tools/taxonomy-lookup.py](tools/taxonomy-lookup.py) | 实质/形式 词表查询 (阶段二 2B 用) |
+| [tools/render-case.py](tools/render-case.py) | case_data → HTML 渲染 + JSON Schema 校验 (阶段三用) |
+| [tools/renderer.py](tools/renderer.py) | render-case.py 内部 import 的渲染主模板 (**Agent 不要 Read 源码**, 通过 render-case.py 间接调用) |
+| [tools/styles.css](tools/styles.css) + [tools/script.js](tools/script.js) | renderer 注入到 HTML 的样式 + 交互. 视觉规范见 [format/procedure-table.md](format/procedure-table.md), 实施以这两文件为准 |
+
+---
+
+## 三语言协作原则
+
+本目录内不同类型内容按读者类型分语言:
+
+| 语言 | 负责 | 文件示例 | 谁读 |
+|---|---|---|---|
+| **JSON** | 结构化定义 (字段 / 受控词表 / 树结构) | `taxonomy/*.json`, `format/case-data.schema.json` | 程序 + LLM (直接 import 当 context) |
+| **Python** | 程序性校验 + 规则定义 (lint, 路径合法性) | `taxonomy/validate.py` (TBD), `tools/taxonomy-lookup.py` | 程序 + 程序员 |
+| **Markdown** | 内容定义 (设计动机 / 边界判断 / 跨模块关系 / narrative) | 本目录绝大多数文件 | 人 + LLM (吸收 narrative context) |
+
+数据流单向: JSON (ground truth) → Python (import + 加规则) → MD (人写, 解释 why). 不三方互改。
+
+> 当前只有 `taxonomy/` 完全实施了三语言分工; `syntax.md` / `extraction/` / `format/` 是纯 markdown 规范, 内部尚未做 JSON/Python 抽离 — 这些内容主要是 prose, 没有需要程序消费的结构.
+
+---
+
+## Agent 自驱加载指南 (累积式)
+
+> **本节是给 Agent 看的执行手册**. Agent 是 self-driven — 没有 Driver 显式切 phase, 按下面顺序自行加载 + 自行判断转移. 关键原则: **context 累积, 已读不要再读**.
+
+**总览** (单一中间产物 `workflow.json`, Phase 1.2 起 in-place Edit 演化, 不写多个快照):
+
+```
+[阶段一] 骨架提取 ── 主 Agent 全程在场
+           1.1 心智模型 (多工序判断)  → understanding.md
+           1.2 步骤切片 + 单步骨架     → Write workflow.json
+           1.3 跨步引用闭合 (anchor)   → Edit workflow.json
+[阶段二] 归一化标注 ── fan-out 2 子 Agent, 都 Edit workflow.json
+           2A 作用/动作/类型归一化 (字典树整进 context)
+           2B 实质/形式 匹配 (走 taxonomy-lookup.py)
+           2C case-specific type 注册 → procedures[i].type_registry
+[阶段三] 检查收尾 ── 主 Agent 调脚本
+           3.1 render-case.py --workflow → HTML
+           3.2 lint-case.py --workflow   → type 完整性
+           3.3 写 case-N-<slug>.md (md-structure §11)
+```
+
+### 起手 (只做一次)
+
+读本 README **一遍**, 之后**永远不要再 Read 本文件**. 你需要 phase 细节时直接读 phase 对应的 spec 模块, 不要回来翻 README.
+
+也请把 [`tools.md`](tools.md) 读一遍 — 你后续要调的外部脚本接口都在那, 之后**不要重读**。
+
+### 🛑 绝对禁止重复读取 (ZERO REPEATED READS RULE)
+由于 Context 是持续累积的,**你读取过的所有文件(README.md, tools.md, phase2-normalize.md 等)内容会永远保留在你的 Context 记忆中**。
+请**绝对不要**再次调用 `Read` 读取它们!重复读取会导致巨大的 Token 浪费与回合预算超限。
+- **严禁**:因为看到 spec 里写了 `详见 [tools.md §2]` 就去重复 `Read(file_path="spec/tools.md")`。
+- **严禁**:因为进入了 Phase 2B 就去重复 `Read(file_path="spec/extraction/phase2-normalize.md")`。
+- 检索你的前置 Turn 历史,直接从记忆中查找对应规格!
+
+### Phase 1 · 骨架提取 (新增加载)
+
+工作: 通读 case 原文 → 建立工序心智模型 → 切片 → 抽骨架 → 闭合引用.
+
+| 必读 (尚未读过) | 用途 |
+|---|---|
+| [`syntax.md`](syntax.md) | DSL 概念模型 + 字段语义 |
+| [`extraction/fields.md`](extraction/fields.md) | 23 字段定义 + 推断补全标记 (三阶段共享) |
+| [`extraction/control-flow.md`](extraction/control-flow.md) | 控制流 → block/nested 建模 (循环/并行/分支怎么表达, **必读** — 切片时就要会) |
+| [`extraction/phase1-skeleton.md`](extraction/phase1-skeleton.md) | 阶段一执行 (1.1/1.2/1.3 骨架提取 + 多工序判断) |
+| [`format/md-structure.md`](format/md-structure.md) | .md 产物结构 (后面要按这个写) |
+| `input/case-{N}-raw.json` (case 原文) | 你的工作素材 |
+
+Phase 1 结束累积: **~30-50k context**
+
+产物 (写到 `outputs/case-{N}/`):
+- `understanding.md` (1.1 心智模型, 含多 procedures 判断)
+- `workflow.json` (1.2 **Write** 骨架: 顶层 `procedures: [{id, name, purpose, declarations, steps}]`, 之后 1.3 **Edit** 加 anchor)
+
+转移条件: workflow.json 自检通过 (引用闭合 / 命名归一 / 类型一致) → 进 Phase 2.
+
+⚠ **新工作流**: 中间产物从原来"5 个 JSON 快照" → **1 个 workflow.json + understanding.md**. 各 phase 都 in-place Edit workflow.json (不写新文件). 减少冗余 + 降低 LLM 长 JSON 输出错误率.
+
+### Phase 2 · 归一化 + 标注 (新增加载)
+
+工作: 归一化 作用 / 动作 / 类型 (查字典树) + 实质 / 形式 (走 `spec/tools/taxonomy-lookup.py` 查外部词表).
+
+| 新增 Read | 用途 |
+|---|---|
+| [`extraction/phase2-normalize.md`](extraction/phase2-normalize.md) | 阶段二 2A + 2B 执行细节 |
+| [`taxonomy/effect.json`](taxonomy/effect.json) | 作用树 + 边界判断 (节点 `分类说明`) |
+| [`taxonomy/action.json`](taxonomy/action.json) | 动作树 + 边界判断 (节点 `分类说明`) |
+| [`taxonomy/type.json`](taxonomy/type.json) | 类型树 + 边界判断 (节点 `分类说明`) |
+| [`taxonomy/type_suggestions.md`](taxonomy/type_suggestions.md) | 跨 case 累积的类型升级提案 (Read 一遍知道格式; 走 §2A 候选→extends 桥接时 **append** 一条) |
+
+**不要 Read**:
+- 已读: 全部 Phase 1 list (`syntax.md`, `extraction/fields.md`, `extraction/control-flow.md`, `extraction/phase1-skeleton.md`, `format/md-structure.md`, README, tools.md)
+- 永远不要: `分类库导出_*.json` (词表太大, 走 `spec/tools/taxonomy-lookup.py` query — 接口见 tools.md)
+
+Phase 2 结束累积: **~40-60k** (并行子 Agent 各自: 2A ~8-15k, 2B ~5-10k)
+
+产物:
+- `outputs/case-{N}/workflow.json` (**Edit** 加 effect/action/type/feature/control + substance/form + procedures[i].type_registry)
+
+⚠ **不写新文件** — Phase 2 是 Read workflow.json + Edit 加字段 in-place. 不要 Write 重写整个 JSON.
+
+转移条件: 每个 step 的 effect/action 命中字典树叶子 + 每个 IO item 的 substance/form 走 `spec/tools/taxonomy-lookup.py --validate` 通过 → 进 Phase 3.
+
+### Phase 3 · 收尾 + 渲染 (新增加载)
+
+工作: 跑 lint + 渲染 HTML (脚本自动) + 写 .md (Agent). renderer 内存组装 case_data 直接渲染, 不落盘中间 JSON.
+
+| 新增 Read | 用途 |
+|---|---|
+| [`extraction/phase3-finalize.md`](extraction/phase3-finalize.md) | lint 清单 + 目的列填法 |
+| [`format/procedure-table.md`](format/procedure-table.md) | 工序表创作规范 (字段语义 + intent + chip 视觉 + 审美) |
+| [`format/case-data.schema.json`](format/case-data.schema.json) | workflow.json 的契约 (顶层 procedures 数组, 每 procedure 含 declarations/steps/type_registry) |
+
+Phase 3 结束累积: **~50-78k**
+
+工具调用:
+```bash
+# 1. lint + 自动 record 新 type (轻量, 不卡 exit)
+python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N}
+
+# 2. 渲染 HTML (renderer 内存组装 case_data, 不落盘)
+python spec/tools/render-case.py \
+    --workflow outputs/case-{N}/workflow.json \
+    --source-input input/case-{N}-raw.json \
+    --page-title "Case {N} · <主题>" \
+    --case-id {N} \
+    --out outputs/case-{N}/case-{N}-<slug>.html
+```
+
+产物:
+- `outputs/case-{N}/case-{N}-<slug>.md` (DSL 文本版, 你 Write)
+- `outputs/case-{N}/case-{N}-<slug>.html` (跑 render-case.py --workflow 自动生成)
+
+⚠ **Phase 3 不落盘中间 JSON** — renderer 在内存组装 case_data = workflow.json + source-input merge + page_title + case_id, 直接渲染.
+
+### 自查清单 (每次 Read 前)
+
+- [ ] 这个文件**之前读过吗**? 如果读过, 不要再 Read (context 累积, 重读浪费 5-15% budget)
+- [ ] 我只是想确认某概念**有没有提过**? 用 Grep, 不要 Read 完整文件
+- [ ] 我只是想看**目录里有啥**? 用 Glob, 不要 ls 后再 Read
+- [ ] resume 后: 用户**改过**的产物 (understanding.md / workflow.json) **要重读**; spec 不变, 不要重读
+- [ ] 想知道某外部脚本接口? 看 [`tools.md`](tools.md) (已读), 不要去 Read 脚本源码
+
+### 转移失败处理
+
+任一 phase 的转移条件不过 → **不要硬进下一 phase**. 回当前 phase 修产物再自查. 重做 2 次还过不去 → 在产物里挂 `inferred: true, inferred_reason: "lint 反复不过, 需人工审"` 标记, 再向下推进.

+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec/extraction/control-flow.md → examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/control-flow.md


+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec/extraction/fields.md → examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/fields.md


+ 50 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/phase1-skeleton.md

@@ -0,0 +1,50 @@
+## 阶段一 · 骨架提取 (Phase 1)
+
+> 整体三阶段流程 + 文件清单见 [README](../README.md) 的"加载指南 · 总览";字段定义 + 推断补全标记见 [fields.md](fields.md)。本文只讲阶段一执行。
+
+主 Agent 不切换的理由: 切片决策、抽取的逻辑补全、跨步引用闭合三件事**认知耦合**, 同一份"工序心智模型"贯穿三步; 换 Agent 要重读源材料 + 重建模型, 浪费 context, 且易在切片/补全决策上不一致。
+
+| 子步 | 任务 | 关键产物 |
+|------|------|---------|
+| **1.1** | **思考分析 + 多工序判断** — 通读原文 (含图片) 建立心智模型: (a) **判断本 case 含几个独立工序** (单/多),  多工序时列出每个 procedure 名字 + 终态产物 + 大致步骤数 + 工艺类型; (b) 识别终态产物、外部工具依赖、隐含的工艺规约/预准备需求 | `understanding.md` (按 procedure 分章节) |
+| **1.2** | **步骤切片 + 单步骨架** — 一次 Write `workflow.json` 骨架. 顶层 `{procedures: [...]}`, 每个 procedure 含 `{id (e.g. p1-simple/p1), name, purpose, category, platform, author, declarations, steps}`. 每个 step 抽: id / kind / action (自然语言) / via / inputs[] / outputs[] (含 type/name/value, anchor 暂留空) / instruction / feature / control / intent / focus. 推断隐含 IO 标 `inferred: True, inferred_reason: "..."`. **effect/类型归一/substance/form 留到 Phase 2**, 不要在 Phase 1.2 就填. | `workflow.json` (Write 骨架版, 不含 anchor/effect/type/sub/form) |
+| **1.3** | **跨步引用闭合** (quality gate) — 给每个 IO.anchor 字段加引用: input.anchor=`← sN.var` / `← 工序输入` / `← 容器[i]`; output.anchor=`→ sN.var` / `→ 容器.追加`. 命名归一, 失败回 1.2 改 step. **几十处 anchor 用 `wf-patch.py --patch` 一次过** (见下), 不要写 Python 脚本批量塞. | `workflow.json` (wf-patch 加 anchor) |
+
+**1.1 多工序的判断标准**
+
+⚠️ 判断与原文段落角色无关:不论原文把它定位为"案例示范"、"方法论说明"、"案例欣赏"还是"进阶玩法",只要满足下列三条,即为一个独立工序。**工序的识别单元是一条完整的输入→终态产出链,不是原文的章节结构。**
+
+**扫描单元**:以原文中**每一张出现的成品图**(或明确描述的终态产物)为起点,逐一检查它是否有对应的独立输入→做法链条。有则记为一个候选工序,再用下列三条标准过滤。**不要以章节/段落为扫描单元。**
+
+1. 有明确产出物:该步骤能产出一个可见结果(图片、素材、成品等)
+2. 有具体做法:该输入→输出链条中存在可操作的方法(Prompt、框架、流程步骤等)
+3. 产出物或做法有差异:与其他工序相比,产出物不同,或做法(框架/方法论)不同,两者满足其一即可;即便产出物类型相同(同为场景图、同为宣传图),只要 Prompt 文本不同,即视为不同工序,不得合并。
+
+判断边界:
+- 同一输入→输出链条内部的中间步骤,归入该工序,不单独拆分(如:同一张海报的生成过程中,背景图生成是中间步骤而非终态产物)
+- 有终态产物但完全没有输入描述或做法描述(仅展示结果图)不算工序
+- 同类产出物但方法不同,算两个工序
+- 相似产出物 ≠ 同一工序:两张同类型的图(如都是冲锋衣场景图),只要生成它们的 Prompt 或方法有任何实质差异,必须拆为两个独立工序。不得以"产出物类型相同"或"方法论相似"为由合并。
+
+**1.2 的逻辑补全**: 原文常省略隐含输入/输出, 抽取者要主动补:
+- 工具领域知识 (生成模型必带 model checkpoint + LoRA + 采样参数; 召回必带索引依据; 视频生成必带主角图/分镜图作 reference)
+- 跨步 contract 推理 (下游用 X 但上游没产 → 漏抽?; 当前输出后续没消费 → 是否漏抽 inferred consumer?)
+- 工艺范式知识 (短剧 = 分镜图 → 视频片段 → 拼接; 建库 = 收集 + 入库 + 索引)
+
+补全的 IO item 标 `inferred: True, inferred_reason: "原文方法 3 只说..., 但工艺需要..."`, 让 review 阶段确认; 不要默默插入. 标记方式详见 [fields.md](fields.md) "推断补全标记"。
+
+**1.2 控制流建模**: 源里有循环 / 并行 / 分支 / 抽样时, 切片要展开成 `block + nested` 结构 (不要拍平成单个标记步), 容器流走 anchor `[i]` / `.追加`。详见 [control-flow.md](control-flow.md)。
+
+**1.3 闭合检查项**:
+- 每个 input 能 trace 到上游 output / declare / 字面量
+- 同名变量在不同 step 的 type 一致
+- 容器索引在合理范围 (`[i]` 在循环内, `[-1]` 指最近一项)
+- 锚/链关系正确
+
+**1.3 怎么批量加 anchor (重要)**: workflow.json 由你**直接演化, 绝不写 Python 脚本生成 / 批改它** (脚本拼 JSON 易踩转义坑把文件搞坏). 几十个 anchor 用 `wf-patch.py` 一次过:
+1. 写一份 patch 清单到 `_scratch/anchors.json` —— `[{"path":"p1.s1.inputs[0].anchor","value":"← s0.x"}, ...]`, **每条都是你的显式决策** (anchor 指向谁是语义判断, 不是机械映射);
+2. `python spec/tools/wf-patch.py --workflow workflow.json --patch _scratch/anchors.json`。
+
+工具负责安全写 JSON + 校验 anchor 格式 (非法整批不写), 你从不碰 JSON 序列化。零星单处改用 Edit 即可。完整路径语法见 [tools.md §2](../tools.md)。
+
+**1.3 透传 value 自动回填**: anchor 闭合后, 跑 `python spec/tools/wf-patch.py --workflow workflow.json --resolve-passthrough`。原样透传的输入/directive (anchor `← sN.x`) 你**不用手抄上游 prompt 全文** —— 只在源头 step 的 output 填一次真值, 工具顺 anchor 把它逐字抄到所有透传位置 (`value` 留空或先写引用占位都行, 工具会替换)。**禁止把「(同 sN 输出)」这类引用占位当最终 value 留着** (lint 会报, 见 [fields.md](fields.md) value 行)。

+ 125 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/phase2-normalize.md

@@ -0,0 +1,125 @@
+#### 阶段二 · 归一化与标注 (主 Agent fan-out 2 个子 Agent)
+
+**工作模式 (并行子 Agent 架构)**:
+为了彻底隔离重负载上下文(含 Base64 图片和 Phase 1 漫长推理历史)并最大化提取效率,**主 Agent 必须使用 `Task` 工具将 Phase 2 任务分流给 2 个并行的子 Agent**。
+* 主 Agent **绝对不要**自己去跑这几十次 `taxonomy-lookup` 查询和 `wf-patch` 校验。
+* **物理切片与图片分流**:主 Agent 在启动 Phase 2 时,必须先运行物理切片脚本:
+  ```bash
+  python spec/tools/prepare-subtask.py --workflow outputs/case-N/workflow.json --source input/case-N.json --out-dir outputs/case-N/_scratch
+  ```
+  该脚本会自动在 `outputs/case-N/_scratch` 目录下生成最小化的子任务定义文件 `task_2a.json` 与 `task_2b.json`。
+* **图片及数据无缝对齐**:为了让子 Agent 具备完美的上下文感知能力,`task_2a.json` 和 `task_2b.json` 不仅包含了精简的 IO 变量与步骤信息,还在根节点内置了当前 Case 的完整图集 `image_url_list`,并为每一个具体的 IO 变量对象级关联了其对应的 `related_images` 引用数组(如识别 `图05` 等关联图片)。
+* 子 Agent 采用极简 Context(2k tokens,不载入大图),只读取当前 `{case_dir}/_scratch/task_2a.json` 或 `task_2b.json`,跑完查询后只返回标准的 `patch_2a.json` 或 `patch_2b.json` 补丁内容。
+* 主 Agent 只负责运行切片脚本、唤醒子 Agent 并行作业,最后收集子 Agent 返回的补丁文件(或 patch 文本段),通过 `wf-patch.py --patch` 一体化应用落盘。
+
+**子 Agent 召唤机制与指令示范 (主 Agent 必看)**:
+在 `run_procedure_dsl.py` 环境中,我们已通过编程式(`agents` 字段)在 Claude SDK 中**预先注册**了两个常驻子 Agent 角色:
+1. `phase-2a-normalizer` (作用/动作/类型归一化专家)
+2. `phase-2b-matcher` (实质/形式词表精确匹配专家)
+
+主 Agent 在运行中可通过以下两种极其优雅的方式激活并分流任务:
+
+### 🟢 方式一:显式指定调用(推荐,指向性极强)
+直接在 prompt 里命令子 Agent 工作,或在 Tool call 中指定其名称调用:
+```json
+// 1. P2A 子 Agent 显式召唤示例:
+Agent(
+  subagent_type="Explore",
+  description="召唤 phase-2a-normalizer 专家处理 2A 任务",
+  prompt="请 phase-2a-normalizer 子 agent 立即读取 outputs/case-N/_scratch/task_2a.json 任务文件,结合 spec/ 里的 effect.json、action.json、type.json,为各步骤和 IO 变量完成作用/动作/类型归一化。完成后请在 outputs/case-N/_scratch/ 下写入标准的 patch_2a.json 并向我汇报具体修改项。"
+)
+
+// 2. P2B 子 Agent 显式召唤示例:
+Agent(
+  subagent_type="Explore",
+  description="召唤 phase-2b-matcher 专家处理 2B 任务",
+  prompt="请 phase-2b-matcher 子 agent 立即读取 outputs/case-N/_scratch/task_2b.json 任务文件,使用 spec/tools/taxonomy-lookup.py 工具查询词表,查出各 IO 变量最精准的 substance/form 路径。完成后请在 outputs/case-N/_scratch/ 下写入标准的 patch_2b.json 并向我汇报具体修改项。"
+)
+```
+
+### 🔵 方式二:自动触发(编程式 description 自动匹配)
+由于 SDK 在后台配置了 `AgentDefinition`,主 Agent 在发起一般的 prompt 对话时如果提到相关职责,Claude SDK 也会基于 descriptions 自动路由调用:
+```
+Use the phase-2b-matcher agent to run taxonomy lookup for task_2b.json and generate patch_2b.json
+```
+
+这样:
+- 单一真理源, 没有 phase 间复制 → 零冗余
+- 主 Agent 只出并行分发与合并决策,极速降本 90%+,速度提升 10x
+- Resume 中断时看 workflow.json 里某 step 是否有 effect 字段就知道 Phase 2A 做没做
+
+##### 2A 子 Agent — 作用 / 动作 / 类型 归一化
+
+**context**: `workflow.json` + spec §A.1 (作用树, 9 叶) + §A.2 (动作树, 5 L1 + 控制) + §A.3 (类型树, 4 大类) + 现有 type_registry — 全部小, 整体进 context。
+
+**任务**:
+- **作用归一**: 每步的"在哪个工艺位置"映射到 §A.1 叶子之一 (工艺规约 / 预准备 / 预处理 / 主体生成 / 装配 / 后期 / 配套伴生 / 检验 / 交付)。必须命中, 不命中 = 抽错回 1.3
+- **动作归一**: 每步的 action 自然语言映射到 §A.2 树路径 (e.g. `提取/化学提取/反推`、`生成/元素生成`); 控制类 `控制/并行` `控制/遍历` 自动转到 `特性` 字段
+- **类型归一** (对每个 input/output 的 type, 走以下 funnel 不要省步骤):
+
+  1. **候选扩展**: 基于 IO 的 `name` + `value` 描述, 显式列出 **3-5 个候选 type 词** (不是只猜一个).
+     - e.g. value=`<图: 苏晚 25 岁年轻女性肖像, 卧室床上>` →
+       候选: `[参考图, 主角图, 人物肖像, 人物参考, 分镜图]`
+     - 候选要覆盖**不同抽象层**: 通用 (参考图) + 具体 (主角图) + 邻近 (分镜图), 让匹配有挑选余地
+
+  2. **字典匹配** (按优先级, 命中即停):
+     - **a. 命中 §A.3 字典树叶子**: 候选里有哪个直接命中叶子 (type.json `$leaves`)? 用它 (e.g. 候选含"参考图" → 命中)
+     - **b. extends 桥接**: 都没命中, 但有候选语义贴近某叶子 → 选最近 leaf 做 extends. **Edit workflow.json**, 在该 procedure 顶层加 `type_registry` 段 (每个 procedure 独立 type_registry, 单 case 多工序时不冲突). 格式:
+       ```json
+       // workflow.json (部分)
+       {
+         "procedures": [
+           {
+             "id": "p1-simple",
+             "name": "...",
+             "type_registry": {
+               "主角图": {"extends": "参考图", "desc": "case-specific 主角肖像"}
+             },
+             "steps": [ ... ]
+           }
+         ]
+       }
+       ```
+       ⚠ **不要只在 IO item 内 inline 写 extends 子字段** — renderer 走 procedure 顶层 `type_registry` 找, IO inline 字段它不读.
+
+  3. **不必手工写 type_suggestions.md**:
+     Phase 3 跑 `bin/lint-case.py --workflow` 时, 会自动扫每个 procedure 的 `type_registry` 把所有 case-specific entry record 到 `spec/taxonomy/type_suggestions.md` (幂等, 同 (type_name, case_id) 只写一次). 你**只要**保证 `procedure.type_registry` 每个 entry 含 extends + desc 就行, suggestions 文件让工具维护.
+
+  4. **lint 自查** (轻量自检, 真正校验由 Phase 3 lint-case.py 跑):
+     - [ ] 每个 IO 的 type 要么命中 §A.3 叶子 (type.json `$leaves`), 要么所在 procedure 的 `type_registry` 段里有该 type 的 extends + desc 项
+     - [ ] 走 2b 的所有新 type 都在对应 procedure 的 `type_registry` 段出现
+     - [ ] **不允许** type 字段写"自由名"但所在 procedure 的 `type_registry` 缺对应 entry — 这种 silent gap 会让 Phase 3 渲染 HTML 时 drawer metadata 全丢, Phase 3 lint-case.py 会捕捉到
+
+**输出**: **写回 workflow.json** (几十个字段批量用 `wf-patch.py --patch`, 见开头工作模式; type_registry 也能 `--set p1.type_registry.X.extends=...` 注册), 给每个 step 加 `effect / action / feature / control` 字段 + 给每个 IO 加 `type` 字段 (Phase 1.2 已有则归一, 没有则填). 不写新文件, 不写生成脚本.
+
+##### 2B 子 Agent — 实质 / 形式 匹配
+
+**context**: `workflow.json` 中所有 input/output 的 name + value + 上下文 + 调 `spec/tools/taxonomy-lookup.py` tool. 实质·形式 JSON 词表本身**不进 context**, 完全通过 tool 查询。
+
+**tool 接口**:
+```
+spec/tools/taxonomy-lookup.py --dim {实质|形式} --list-l2                  # 列二级路径
+spec/tools/taxonomy-lookup.py --dim {实质|形式} --subtree <path>           # 返回子树 (叶子 + alias)
+spec/tools/taxonomy-lookup.py --dim {实质|形式} --match "<tok1 tok2 ...>"  # 多 token 拆词聚合 (推荐用法)
+spec/tools/taxonomy-lookup.py --dim {实质|形式} --narrow "<tok1 tok2 ...>" # 层级下钻 (--match miss 兜底)
+spec/tools/taxonomy-lookup.py --dim {实质|形式} --validate <path>          # 校验路径存在
+```
+
+**任务** (对每个 input/output value):
+1. **从 value 抽 2-5 个 ≥2 字描述性 token**, 一次调用 `--match "tok1 tok2 tok3"` (空格分隔, tool 自动拆词聚合)
+   - 好例: value=`<图: 苏晚 25 岁年轻女性肖像, 卧室床上>` → `--match "年轻女性 卧室 床上 肖像"`
+   - 不要再一个个单 token 试错 — tool 内部已经做了拆词聚合 + coverage bonus
+2. **若 `--match` 返 (无匹配)** → 同 query 切 `--narrow` 走层级下钻 (按子树整体语义打分)
+3. 上面拿到 top 候选后, 用 `--subtree <候选>` 列叶子细节, 选最贴的
+4. `--validate <chosen_leaf>` 确认前再写回; 完全无法匹配 → `unmatched`
+5. 抽象容器/纯工具参数 → null (不标)
+   - ⚠ **警惕过度标注 null**:诸如 `产品需求`、`结构化提示词`、`分层提示词` 等文本,虽然物理形式上是“文本”,但它们在**实质(Substance)上承载了具体的业务内容**(例如:提示词若描述冲锋衣,实质即为 `/理念/知识/商业/产品服务/产品特征`;若描述登山场景,则为 `/理念/知识/商业/产品服务/使用场景`)。**它们决非纯工具参数**,必须为其匹配对应的实质路径。
+   - **仅有完全不含具体业务实体、纯粹的技术参数**(如逻辑判断布尔值、循环索引 `i`、纯控制流指令等)才允许设为 `null`。
+
+> ⚠ **避坑**: 不要把整段 value 描述塞进 `--match` (e.g. `--match "苏晚 25 岁年轻女性, 卧室床上, 湿发素颜"`),
+> 标点会被当 token 一部分干扰拆词. 提炼 2-5 个干净的描述性词组就够.
+
+**输出**: **写回 workflow.json** (几十个 IO 批量用 `wf-patch.py --patch`, 见开头工作模式; substance/form 会自动走 taxonomy-lookup 校验, 设 null 传 `__null__`), 给每个 IO 加 `substance: /xxx/yyy` 或数组 `["/xxx/yyy", "/zzz"]` + `form: /xxx/yyy` 或数组 (或 null). 不写新文件, 不写生成脚本。对于复杂内容,推荐在 CLI 传入时用 `+` 连接多条路径 (如 `/表象/视觉/人物 + /表象/视觉/空间`),`wf-patch.py` 会自动校验并将其作为 JSON 数组保存至 `workflow.json` 中。
+
+---
+

+ 30 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/extraction/phase3-finalize.md

@@ -0,0 +1,30 @@
+#### 阶段三 · 检查收尾 (主 Agent 调脚本)
+
+| 子步 | 任务 | 备注 |
+|------|------|------|
+| **3.1** | **lint + 渲染** — 跑两个命令: (a) `python spec/tools/lint-case.py --workflow outputs/case-N/workflow.json --case-id N` 校 type 完整性 + 自动 record 新 type 到 `spec/taxonomy/type_suggestions.md`; (b) `python spec/tools/render-case.py --workflow outputs/case-N/workflow.json --source-input input/case-N.json --page-title "Case N · 主题" --case-id N --out outputs/case-N/case-N-<slug>.html` 渲染 HTML (内存组装 case_data, **不落盘 case_data.json**) | lint 失败 → 主 Agent 看 stdout 决定回阶段二哪步重 Edit workflow.json |
+| **3.2** | **目的列填写** — **Edit workflow.json**, per step 加 `intent` 字段 (≤ 20 字自然语言). 跨 step 一次性填 (差异化需要跨步视图). 以已有结构化元素 (其他列) 作 `{kind:value}` token, 允许调整语序 + 少量胶水. 合法 kind: `{effect/via/act/control/in-type/out-type/in-sub/out-sub/in-form/out-form}`. **严禁变量名** (no `{in:X}` / `{out:X}`). **严禁引用特性列** (`{feature:X}` 不允许). **严禁 "X: Y → Z" 这种伪代码结构** | 不可子 Agent 化, 因为差异化判断需要跨步视图 |
+| **3.3** | **.md 落盘** — Write `outputs/case-N/case-N-<slug>.md` (DSL 文本版, 按 md-structure §11 结构) | Agent 写, 不脚本化 |
+
+##### 阶段三 · lint 检查清单
+
+最终产物必须满足以下规则, 任一失败 → 回阶段一对应步骤修:
+
+| 检查项 | 规则 | 失败示例 |
+|------|------|------|
+| **命名归一** | 同一概念在不同 step 用同一变量名 | "正向提示词" vs "提示词.正向" 混用 |
+| **类型一致** | 同名变量在不同 step 的 type 一致 | s1 的 `主角图: 参考图` 但 s5 写成 `主角图: 分镜图` |
+| **引用闭合** | 每个 input 能 trace 到上游 output / declare / 字面量 | 引用了 `s2.角色图` 但 s2 实际 output 是 `s2.主角图` |
+| **容器索引合法** | `[i]/[i+1]/[-1]` 在循环或合理位置内 | 在非 loop 内引用 `[i]` |
+| **值 vs 来源分离** | `值` 列**不能**只放 `← sN.x` 这种来源引用 (来源信息归 `来源` 列). `值` 必须是数据本身的描述/字面量 (允许 "(同上游) ...short excerpt..." 形式) | 值=`← s1.正向提示词` ❌ → 值=`(同上游) "中景特写..."` + 来源=`← s1.正向提示词` ✓ |
+| **值不允许截断** | `值` 列即使内容长, 也要 CSS 多行换行完整展示, 不允许 `text-overflow: ellipsis` / `overflow: hidden` 截断. 引文带省略号 ("...") 必须是源材料本身就截断, 不是 UI 截断 | UI 强制裁掉 prompt 文本结尾 ❌ |
+| **值是内容本身, 非元描述** | `值` 列写**数据本身的内容**, 不写 "[图 N 显示]" / "原文: ..." 等溯源/元数据. **文本数据** (提示词/分镜脚本/描述 等) 直接写完整 prose 内容 (生动详尽, 不加任何包裹标记); **非文本数据** (视频/图像/音频) 无法在 cell 中直接嵌入, 用 `<整段描述用尖括号括起>` 标识 (注意: 是**整段**包在 `<...>` 内, 不是 `<描述>` 前缀). **值列写数据内容本身, 不写 `[视频]/[图像]` 这种载体标签** | "`[图 2 显示] 含 ...`" ❌; "`<描述> AI 生成 10s 视频`" ❌ (是 `<描述>` 前缀格式); "提示词内容: `一个中景特写的年轻亚洲女性, 坐在低光的卧室床上, 湿长发素颜雀斑, 缓慢举手机...`" ✓ (文本直接写); "视频内容: `<AI 生成的 10s 视频, 床上女性举手机场景, 含 AI generated 水印>`" ✓ (整段括起) |
+| **目的列以结构化元素为骨架** | 简短自然语言 (≤ 20 字). **尽量直接使用已有的结构化元素** (其他列的值) 作为 token; 允许少量胶水文字 (动词 / 连接词) 串联. 多个同列值要独立 token, 不用胶水词合并. 合法 kind: `{effect/via/act/control/in-type/out-type/in-sub/out-sub/in-form/out-form}` (in-/out- 区分输入输出, 自动染色). 严禁变量名 token; 严禁引用特性 (`{feature:X}` ❌); 严禁 dataflow 公式 | "`{act:反推}: {form:景别} → {form:纪实}`" ❌ (公式); "得到正负 `{out-type:提示词}`" ❌ (胶水词合并); "得到 `{out-type:提示词}` 和 `{out-type:负向提示词}`" ✓ (2 个独立 token) |
+| **作用 / 动作 命中字典树** | `作用` 必须是 §A.1 L3 叶子; `动作` 必须是 §A.2 树路径 | `作用:开端` ❌ (不在树里) |
+| **类型命中字典树或 extends** | `类型` chip 必须是 §A.3 叶子, 或 case 自定义但 `extends` 一个叶子 | `类型:小品` ❌ → 需补 `extends 视频成品` |
+| **case-specific type 完整性** (调 `spec/tools/lint-case.py` 自动校验) | 跑 `python spec/tools/lint-case.py --workflow outputs/case-N/workflow.json --case-id N` — 输出"type 完整性: N 个提示" 时, 表示有 IO 用了 case-specific type 但该 procedure 的 type_registry 漏注册 / 缺 extends / 缺 desc. 工具同时会把所有 procedure.type_registry 里的 case-specific entry 自动 record 到 `spec/taxonomy/type_suggestions.md`. Agent 看 stdout 决定是否回 Phase 2 Edit workflow.json 补 type_registry | lint-case 输出 `[p1-simple] step[1].outputs[0].type='主角图' 是 case-specific 但 type_registry 没注册` → 回 Phase 2 Edit workflow.json 在 p1-simple.type_registry 加 `主角图: {extends:"参考图", desc:"..."}`, 重跑 lint |
+| **实质·形式 路径合法** | 调 `spec/tools/taxonomy-lookup.py --validate <path>` 校验; 不命中标 `unmatched` 待人工审 | `/视觉/人物` ❌ (缺 L1 顶层) |
+| **指令归类正确** | 工具组的 4 个子列各司其职: `指令`=directive 文本片段; `配置`=工具运行参数 (采样/cfg/aspect/...); `运行`=caller-side decorator (`@采样` 等); `备注`=**其他结构化字段没能包含的实现方法信息** (经验性招法 / 替代 variant / 选型理由 / 适用条件 等). data 端用单一 kind `('memo', txt)`. 不混 | 把采样参数放到 `指令` 列 ❌; 备注里写"原文: ..."这种溯源 ❌ (备注是工艺信息, 非引文) |
+
+---
+

+ 327 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/format/case-data.schema.json

@@ -0,0 +1,327 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://procedure-dsl/spec/format/case-data.schema.json",
+  "title": "Procedure DSL · case_data",
+  "description": "Canonical structure consumed by spec/tools/renderer.build_html(case_data) -> str. 一篇 case 可含 1 ~ N 个独立工序 (procedures), 共享同一份 source 原帖. 单工序 case 也用 procedures 数组长度 1 的形式 — schema 不区分.",
+  "type": "object",
+  "required": [
+    "page_title",
+    "source",
+    "procedures"
+  ],
+  "additionalProperties": false,
+  "properties": {
+    "page_title": {
+      "type": "string",
+      "description": "HTML <title> + <h1>. e.g. 'Case 5 · 产品宣传图 AI 工作流可视化'"
+    },
+    "case_id": {
+      "type": ["integer", "string", "null"],
+      "description": "Optional case identifier shown in UI badge."
+    },
+    "source": {
+      "$ref": "#/$defs/Source",
+      "description": "case-level 原帖信息, 跨所有 procedures 共享 (一篇文章对应一份 source)."
+    },
+    "procedures": {
+      "type": "array",
+      "minItems": 1,
+      "description": "一篇 case 包含的工序数组. 多工序场景 (e.g. case-5 '简单工作流 + 进阶工作流 + 附加案例'). 单工序 case 也用长度 1 数组. 每个 procedure 独立含 declarations / steps / type_registry / return_row.",
+      "items": { "$ref": "#/$defs/Procedure" }
+    }
+  },
+
+  "$defs": {
+
+    "Procedure": {
+      "type": "object",
+      "description": "一个独立工序: 工序头部 (name/purpose/...) + declare 块 + 步骤 + 返回行. 一个 procedure 内的 type_registry 是该工序的 case-specific 类型扩展.",
+      "required": ["id", "name", "purpose", "category", "platform", "author", "declarations", "steps"],
+      "additionalProperties": false,
+      "properties": {
+        "id":       { "type": "string", "description": "procedure 局部 id (单 case 内唯一). e.g. 'p1' / 'p1-simple' / 'p2-advanced'." },
+        "name":     { "type": "string", "description": "工序名称, e.g. '简单工作流: 一步生成'." },
+        "purpose":  { "type": "string", "description": "One-sentence intent (该工序在做什么)." },
+        "category": { "type": "string", "description": "Top-level taxonomy, e.g. '产物创造'." },
+        "platform": { "type": "string", "description": "原帖来源 platform; 通常跟 case-level source.platform 一致." },
+        "author":   { "type": "string", "description": "原作者; 通常跟 case-level source.author 一致." },
+        "declarations": { "$ref": "#/$defs/Declarations" },
+        "type_registry": {
+          "type": "object",
+          "description": "Per-procedure case-specific type entries; 渲染期跟 spec/taxonomy/type.json 的 stdlib 叶子合并 — case 项覆盖 stdlib 项. 跨工序共享的 case-specific type 可在多个 procedure 各自声明 (允许冗余).",
+          "additionalProperties": { "$ref": "#/$defs/TypeRegistryEntry" }
+        },
+        "steps": {
+          "type": "array",
+          "description": "工序的步骤数组, 按执行顺序排. block/step 在顶层; nested 子项 kind:nested + group 指向父 block id.",
+          "items": { "$ref": "#/$defs/Step" }
+        },
+        "return_row": { "$ref": "#/$defs/ReturnRow" }
+      }
+    },
+
+    "Declarations": {
+      "type": "object",
+      "description": "工序的声明: declared inputs / resources / return type. 渲染成 declare 折叠块.",
+      "required": ["inputs", "resources", "returns"],
+      "additionalProperties": false,
+      "properties": {
+        "inputs": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/DeclareItem" },
+          "description": "Caller-provided inputs to the procedure."
+        },
+        "resources": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/DeclareItem" },
+          "description": "Long-lived resources (libraries, knowledge bases) read or written across runs."
+        },
+        "returns": { "$ref": "#/$defs/DeclareReturn" }
+      }
+    },
+
+    "DeclareItem": {
+      "type": "object",
+      "required": ["type", "name"],
+      "additionalProperties": false,
+      "properties": {
+        "type":    { "type": "string", "description": "Type leaf (§A.3) or case-extended type." },
+        "name":    { "type": "string" },
+        "default": { "type": "string", "description": "Optional default value/template." },
+        "desc":    { "type": "string", "description": "Optional human description." }
+      }
+    },
+
+    "DeclareReturn": {
+      "type": "object",
+      "required": ["type"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "note": { "type": "string", "description": "e.g. 'extends 视频成品 (序列关系)'." }
+      }
+    },
+
+    "Source": {
+      "type": "object",
+      "description": "case-level 原帖信息. 渲染成 foldable 折叠块 (default closed). 跨所有 procedures 共享.",
+      "required": ["platform", "author", "date", "title", "excerpt"],
+      "additionalProperties": false,
+      "properties": {
+        "platform":    { "type": "string" },
+        "author":      { "type": "string" },
+        "date":        { "type": "string", "description": "Free-text date, e.g. '2026 上半年'." },
+        "url":         { "type": ["string", "null"], "description": "原帖 URL; 没有干净 URL 时填 null 或省略 (不强制 uri 格式)." },
+        "title":       { "type": "string" },
+        "excerpt":     { "type": "string", "description": "1-2 sentence pipeline summary." },
+        "body_text":   { "type": "string", "description": "Optional full body of the source article." },
+        "cover_image": { "type": "string", "description": "Optional cover image URL." }
+      }
+    },
+
+    "TypeRegistryEntry": {
+      "type": "object",
+      "description": "Case-specific 类型定义 (procedures[*].type_registry). 桥接到一个 stdlib 叶子 + 人类可读描述.",
+      "required": ["extends", "desc"],
+      "additionalProperties": false,
+      "properties": {
+        "extends": { "type": "string", "description": "桥接到的 stdlib 叶子名 (spec/taxonomy/type.json $leaves 之一)." },
+        "desc":    { "type": "string", "description": "case-specific 类型的人类可读说明 (renderer drawer + type_suggestions 用)." }
+      }
+    },
+
+    "Step": {
+      "type": "object",
+      "description": "One row group in the 25-column procedure table. Renders to N <tr> where N = max(len(inputs), len(outputs), 1). The dictionary-tree fields (effect/via/action/feature/control) MUST hit canonical leaves per spec §A. NOTE: kind=block 是控制容器, 只带 control + intent + via='-' + feature='-' + io; effect/action/focus 由其下 kind=nested 执行步承担 (见下方 allOf 条件: 仅 step/nested/atom 要求 effect/action/focus).",
+      "required": ["id", "kind", "via", "feature", "inputs", "outputs", "intent"],
+      "additionalProperties": false,
+      "properties": {
+        "id": {
+          "type": "string",
+          "pattern": "^(s\\d+(\\.\\d+)*|a\\d+)$",
+          "description": "Step id. Plain step / block / nested use 's' prefix (block children dotted, e.g. 's3.1'); atoms use 'a' prefix (e.g. 'a1') scoped under their parent step."
+        },
+        "kind": {
+          "enum": ["step", "block", "nested", "atom"],
+          "description": "step=plain; block=control-flow parent; nested=child of a block; atom=experimental dimensional projection (only used in case1)."
+        },
+        "group": {
+          "type": "string",
+          "description": "When kind=nested, the parent block's id (e.g. 's3')."
+        },
+        "parent_step": {
+          "type": "string",
+          "description": "When kind=atom, the host step's id."
+        },
+        "name": {
+          "type": "string",
+          "description": "Only meaningful for kind=atom (displayed above the intent text)."
+        },
+        "key_dim": {
+          "type": "string",
+          "description": "atom-only: the dimension this atom projects (e.g. '实质' / '形式')."
+        },
+        "key_value": {
+          "type": "string",
+          "description": "atom-only: the specific path within the key_dim (e.g. '/表象/视觉/.../形象呈现')."
+        },
+
+        "effect": {
+          "enum": [
+            "预处理", "主体生成", "装配", "后期",
+            "工艺规约", "预准备", "配套伴生", "检验", "交付"
+          ],
+          "description": "§A.1 L3 leaf. Required to hit a leaf — non-leaf = extraction error."
+        },
+        "via": {
+          "type": "string",
+          "description": "Canonical L1 tool name (manus / nano_banana_pro / human / <llm-agent>) or generic placeholder for unspecified tools ('(AI 生图工具)'). Use '-' for control-flow blocks."
+        },
+        "action": {
+          "type": "string",
+          "pattern": "^[^/]+(/[^/]+)*$",
+          "description": "§A.2 path, e.g. '生成/元素生成', '提取/化学提取/反推'. Control-flow already routed to `control` field."
+        },
+        "feature": {
+          "type": "string",
+          "enum": ["随机", "幂等", "人工", "本地", "写外部", "读外部", "-"],
+          "description": "Execution attribute (internal). Block-level steps use '-'. NEVER referenced in intent column."
+        },
+        "control": {
+          "type": "string",
+          "enum": ["并行", "遍历", "分支", "请求", "等待", "", "-"],
+          "description": "Control-flow kind. Typically on kind=block; may appear on a regular step that itself is a control. Empty string or '-' = no control flow (kept for atom rows and plain steps that explicitly set the field)."
+        },
+
+        "instruction": {
+          "type": "array",
+          "description": "Heterogeneous list of (kind, text) tuples. Order is preserved; any subset of kinds is allowed; control-flow blocks usually only carry memo entries.",
+          "items": {
+            "type": "array",
+            "prefixItems": [
+              {
+                "enum": ["directive", "config", "decorator", "memo"],
+                "description": "directive=literal prompt text; config=tool runtime params; decorator=caller-side @decorator; memo=engineering wisdom not captured elsewhere."
+              },
+              { "type": "string" }
+            ],
+            "items": false,
+            "minItems": 2,
+            "maxItems": 2
+          }
+        },
+
+        "inputs":  {
+          "type": "array",
+          "items": { "$ref": "#/$defs/IOItem" }
+        },
+        "outputs": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/IOItem" }
+        },
+
+        "intent": {
+          "type": "string",
+          "description": "≤ ~20 chars natural-language sentence. Tokens `{kind:value}` with kind ∈ {effect, via, act, control, in-type, out-type, in-sub, out-sub, in-form, out-form}. NEVER reference {feature:X}, NEVER use variable-name tokens, NEVER use 'X: Y → Z' pseudo-code."
+        },
+        "focus": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "Column-cell keys to highlight on this row. Format: bare key for step-level cells ('via', 'action', 'effect', 'idx', 'intent'), or '<col>-<io_index>' for IO cells ('in-type-0', 'out-substance-1', etc.)."
+        },
+
+        "inferred_marks": {
+          "type": "object",
+          "description": "Step-level field-by-field inference annotations (single cells, not whole rows). Maps column key → reason string. Used when only a specific field was filled by 工艺补全, not the whole row.",
+          "additionalProperties": { "type": "string" }
+        },
+
+        "atoms": {
+          "type": "array",
+          "description": "EXPERIMENTAL extension (case1-only). Dimensional projections of this step; each atom mirrors a Step shape with kind='atom' + parent_step + name + key_dim + key_value.",
+          "items": { "$ref": "#/$defs/Step" }
+        }
+      },
+
+      "allOf": [
+        {
+          "if": { "properties": { "kind": { "enum": ["step", "nested", "atom"] } } },
+          "then": { "required": ["effect", "action", "focus"] }
+        },
+        {
+          "if": { "properties": { "kind": { "const": "nested" } } },
+          "then": { "required": ["group"] }
+        },
+        {
+          "if": { "properties": { "kind": { "const": "atom" } } },
+          "then": { "required": ["parent_step", "name", "key_dim", "key_value"] }
+        }
+      ]
+    },
+
+    "IOItem": {
+      "type": "object",
+      "description": "One row in inputs[] or outputs[]. Input and output items share the same shape (anchor points up for inputs, down for outputs).",
+      "required": ["type", "name", "value", "anchor"],
+      "additionalProperties": false,
+      "properties": {
+        "substance": {
+          "type": ["string", "array", "null"],
+          "items": { "type": "string" },
+          "description": "What the content IS (理念 vs 表象 layer). Path from `分类库导出_实质_*.json`. Can be a single path string, an array of path strings, or null for abstract containers / pure tool params."
+        },
+        "form": {
+          "type": ["string", "array", "null"],
+          "items": { "type": "string" },
+          "description": "How the content is PRESENTED (呈现 vs 架构 layer). Path from `分类库导出_形式_*.json`. Can be a single path string, an array of path strings, or null when not applicable."
+        },
+        "type": {
+          "type": "string",
+          "description": "§A.3 leaf or case-extended type name."
+        },
+        "name": {
+          "type": "string",
+          "description": "Variable identifier within this step. Same concept across steps MUST use the same name (lint enforces this)."
+        },
+        "value": {
+          "type": "string",
+          "description": "Content itself. Text data: full prose, no wrappers. Non-text data: '<整段描述>' (the whole description angle-bracketed). NEVER metadata ('[图 N 显示]', '原文: ...') or upstream refs ('← sN.x' — those go in anchor)."
+        },
+        "anchor": {
+          "type": "string",
+          "description": "Inputs: upstream ref ('← 工序输入', '← sN.varname', '← 容器[i]'). Outputs: downstream destination ('→ sN', '→ 视频片段列表.追加', '→ 返回 X')."
+        },
+        "inferred": {
+          "type": "boolean",
+          "description": "True when this whole IO item was filled by 工艺补全 (not in source material). Renderer dyes the entire row's 6 data-flow cells."
+        },
+        "inferred_reason": {
+          "type": "string",
+          "description": "REQUIRED when inferred=true. Shown on hover."
+        }
+      },
+      "if": {
+        "required": ["inferred"],
+        "properties": { "inferred": { "const": true } }
+      },
+      "then": { "required": ["inferred_reason"] }
+    },
+
+    "ReturnRow": {
+      "type": "object",
+      "description": "Bottom row of the table announcing what the procedure returns.",
+      "required": ["arrow", "text"],
+      "additionalProperties": false,
+      "properties": {
+        "arrow": {
+          "type": "string",
+          "description": "Usually '↩'."
+        },
+        "text": {
+          "type": "string",
+          "description": "May contain inline HTML fragments (chip / name / em spans) — emitted verbatim into a colspan=24 cell."
+        }
+      }
+    }
+  }
+}

+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec/format/md-structure.md → examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/format/md-structure.md


+ 0 - 0
examples/process_pipeline/script/search_eval/procedure-dsl/spec/format/procedure-table.md → examples/process_pipeline/script/search_eval/procedure-dsl/spec-back/format/procedure-table.md


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio