浏览代码

chore(visualization): archive the legacy workbench

SamLee 1 天之前
父节点
当前提交
d812ee9bcc
共有 100 个文件被更改,包括 29216 次插入0 次删除
  1. 1 0
      visualization-old/.cache/.gitkeep
  2. 27 0
      visualization-old/.gitignore
  3. 115 0
      visualization-old/DATA_SOURCE_CONTRACT.md
  4. 51 0
      visualization-old/DESIGN.md
  5. 43 0
      visualization-old/EVENT_AUDIT_AND_GAPS.md
  6. 52 0
      visualization-old/PRODUCT.md
  7. 143 0
      visualization-old/README.md
  8. 2 0
      visualization-old/backend/app/__init__.py
  9. 570 0
      visualization-old/backend/app/business_detail_text.py
  10. 3 0
      visualization-old/backend/app/card_details/__init__.py
  11. 273 0
      visualization-old/backend/app/card_details/common.py
  12. 396 0
      visualization-old/backend/app/card_details/implementation.py
  13. 221 0
      visualization-old/backend/app/card_details/planning.py
  14. 130 0
      visualization-old/backend/app/card_details/projector.py
  15. 124 0
      visualization-old/backend/app/card_details/results.py
  16. 288 0
      visualization-old/backend/app/card_details/retrieval.py
  17. 148 0
      visualization-old/backend/app/card_details/schema.py
  18. 399 0
      visualization-old/backend/app/card_details/summaries.py
  19. 337 0
      visualization-old/backend/app/creative_event_projection.py
  20. 896 0
      visualization-old/backend/app/decision_projection.py
  21. 156 0
      visualization-old/backend/app/evaluation_event_projection.py
  22. 593 0
      visualization-old/backend/app/evaluation_report_parser.py
  23. 1975 0
      visualization-old/backend/app/execution_builder.py
  24. 66 0
      visualization-old/backend/app/execution_view_payload.py
  25. 1646 0
      visualization-old/backend/app/inspector_projection.py
  26. 468 0
      visualization-old/backend/app/main.py
  27. 712 0
      visualization-old/backend/app/main_agent_decision_projection.py
  28. 274 0
      visualization-old/backend/app/main_decision_text.py
  29. 5 0
      visualization-old/backend/app/module_audit/__init__.py
  30. 55 0
      visualization-old/backend/app/module_audit/log_context.py
  31. 136 0
      visualization-old/backend/app/module_audit/repository.py
  32. 121 0
      visualization-old/backend/app/module_audit/router.py
  33. 1165 0
      visualization-old/backend/app/module_audit/service.py
  34. 805 0
      visualization-old/backend/app/module_audit/static/app.js
  35. 50 0
      visualization-old/backend/app/module_audit/static/index.html
  36. 277 0
      visualization-old/backend/app/module_audit/static/styles.css
  37. 72 0
      visualization-old/backend/app/prompt_projection.py
  38. 64 0
      visualization-old/backend/app/providers.py
  39. 797 0
      visualization-old/backend/app/repositories.py
  40. 426 0
      visualization-old/backend/app/retrieval_detail_projection.py
  41. 567 0
      visualization-old/backend/app/retrieval_event_projection.py
  42. 83 0
      visualization-old/backend/app/runtime_bridge.py
  43. 362 0
      visualization-old/backend/app/runtime_event_index.py
  44. 85 0
      visualization-old/backend/app/runtime_event_projection.py
  45. 69 0
      visualization-old/backend/app/runtime_tool_catalog.py
  46. 51 0
      visualization-old/backend/app/sanitizer.py
  47. 5 0
      visualization-old/backend/app/source_lineage/__init__.py
  48. 1052 0
      visualization-old/backend/app/source_lineage/comparison.py
  49. 2056 0
      visualization-old/backend/app/source_lineage/projector.py
  50. 774 0
      visualization-old/backend/app/source_lineage/resolver.py
  51. 85 0
      visualization-old/backend/app/source_lineage/schema.py
  52. 5 0
      visualization-old/backend/pytest.ini
  53. 8 0
      visualization-old/backend/requirements.txt
  54. 411 0
      visualization-old/backend/scripts/validate_source_inspector.py
  55. 259 0
      visualization-old/backend/tests/conftest.py
  56. 154 0
      visualization-old/backend/tests/test_api.py
  57. 176 0
      visualization-old/backend/tests/test_business_detail_text.py
  58. 101 0
      visualization-old/backend/tests/test_card_data.py
  59. 265 0
      visualization-old/backend/tests/test_decision_projection_v8.py
  60. 219 0
      visualization-old/backend/tests/test_evaluation_report_parser_v8.py
  61. 79 0
      visualization-old/backend/tests/test_execution_view_payload_v8.py
  62. 690 0
      visualization-old/backend/tests/test_main_agent_decision_projection.py
  63. 88 0
      visualization-old/backend/tests/test_main_decision_text.py
  64. 261 0
      visualization-old/backend/tests/test_module_audit.py
  65. 126 0
      visualization-old/backend/tests/test_repository_rules.py
  66. 159 0
      visualization-old/backend/tests/test_retrieval_detail_projection.py
  67. 75 0
      visualization-old/backend/tests/test_runtime_bridge.py
  68. 401 0
      visualization-old/backend/tests/test_runtime_event_projection.py
  69. 27 0
      visualization-old/backend/tests/test_sanitizer.py
  70. 134 0
      visualization-old/backend/tests/test_script_table_projection.py
  71. 687 0
      visualization-old/backend/tests/test_source_lineage.py
  72. 687 0
      visualization-old/backend/tests/test_v8_builder.py
  73. 1 0
      visualization-old/exports/.gitkeep
  74. 57 0
      visualization-old/frontend/DESIGN.md
  75. 3273 0
      visualization-old/frontend/app/globals.css
  76. 13 0
      visualization-old/frontend/app/layout.tsx
  77. 5 0
      visualization-old/frontend/app/page.tsx
  78. 119 0
      visualization-old/frontend/components/artifact/ScriptArtifactViewer.tsx
  79. 26 0
      visualization-old/frontend/components/artifact/ScriptElementTable.tsx
  80. 210 0
      visualization-old/frontend/components/artifact/ScriptTable.tsx
  81. 60 0
      visualization-old/frontend/components/decision/AgentDecisionCard.tsx
  82. 172 0
      visualization-old/frontend/components/decision/DecisionDetailRenderer.tsx
  83. 31 0
      visualization-old/frontend/components/decision/PromptDrawer.tsx
  84. 10 0
      visualization-old/frontend/components/flow/AgentRegion.tsx
  85. 22 0
      visualization-old/frontend/components/flow/BranchFrame.tsx
  86. 31 0
      visualization-old/frontend/components/flow/CandidateCard.tsx
  87. 26 0
      visualization-old/frontend/components/flow/CardFields.tsx
  88. 27 0
      visualization-old/frontend/components/flow/DirectToolGroupCard.tsx
  89. 13 0
      visualization-old/frontend/components/flow/QueryAttemptList.tsx
  90. 38 0
      visualization-old/frontend/components/flow/RetrievalAgentCard.tsx
  91. 209 0
      visualization-old/frontend/components/flow/RetrievalHoverPreview.tsx
  92. 26 0
      visualization-old/frontend/components/flow/RetrievalStageFrame.tsx
  93. 26 0
      visualization-old/frontend/components/flow/RoundFrame.tsx
  94. 43 0
      visualization-old/frontend/components/flow/RoundSummaryCard.tsx
  95. 40 0
      visualization-old/frontend/components/flow/RunResultCard.tsx
  96. 23 0
      visualization-old/frontend/components/flow/SourceComparisonButton.tsx
  97. 37 0
      visualization-old/frontend/components/flow/StoryCard.tsx
  98. 54 0
      visualization-old/frontend/components/inspector/BindingBadge.tsx
  99. 163 0
      visualization-old/frontend/components/inspector/BusinessValueRenderer.tsx
  100. 178 0
      visualization-old/frontend/components/inspector/ComparisonRowCells.tsx

+ 1 - 0
visualization-old/.cache/.gitkeep

@@ -0,0 +1 @@
+

+ 27 - 0
visualization-old/.gitignore

@@ -0,0 +1,27 @@
+# Python
+backend/.venv/
+backend/**/__pycache__/
+backend/.pytest_cache/
+backend/.coverage
+
+# Node / Next.js
+frontend/node_modules/
+frontend/.next/
+frontend/coverage/
+frontend/playwright-report/
+frontend/test-results/
+frontend/*.tsbuildinfo
+
+# Local environment
+.env
+.env.local
+backend/.env
+frontend/.env.local
+
+# Generated artifacts must stay under visualization, but are not committed.
+.cache/*
+!.cache/.gitkeep
+exports/*
+!exports/.gitkeep
+test-artifacts/*
+!test-artifacts/.gitkeep

+ 115 - 0
visualization-old/DATA_SOURCE_CONTRACT.md

@@ -0,0 +1,115 @@
+# V8 数据源与决策真实性契约
+
+## 业务事实表
+
+| 表 | V8 中的唯一业务含义 |
+|---|---|
+| `script_build_record` | Run 状态、当前创作方向、最终总结和模型配置 |
+| `script_build_round` | 轮次目标和当前保存的多路规划 |
+| `script_build_branch` | 实现任务、方案类型、当前处置状态和理由 |
+| `script_build_data_decision` | 实现 Agent 对取数结果的采用、组合和排除 |
+| `script_build_multipath_decision` | 主 Agent 对 `branch_ids` 指向的一批候选结果作出的综合决策 |
+| `script_build_domain_info` | 已核实并独立保存的领域事实 |
+| 段落、元素、关联三表 | 当前主脚本和内容方案候选产物 |
+
+## 运行记录表
+
+`script_build_event` 和 `script_build_event_body` 只用于补充:
+
+- `script_multipath_evaluator` 对候选方案的逐支评审、跨方案比较和建议;
+- `script_evaluator` 对主 Agent 决策后的主脚本进行整体评审;
+- 实现 Agent 直接调用的取数 Tool;
+- 取数 Agent 实例、Agent 内多次查询和 Agent 输出的初筛整理;
+- 规划工具在运行时留下的修订记录;
+- Inspector 中的原始输入输出。
+
+## 主 Agent 前三阶段决策链
+
+- 创作目标的当前业务事实是 `script_build_record.script_direction`;成功的 `save_script_direction` 只用来还原它何时保存及保存前主 Agent 直接读取了什么。
+- 本轮目标的当前业务事实是 `script_build_round.goal`。`begin_round` 调用前携带的 `round_index` 仍是旧轮次,新轮次优先取成功返回中的 `round_index`,其次才取紧随的 `round_begin` marker。
+- 实现规划的当前业务事实是 `multipath_plan / race_or_divide / plan_note`。成功的 `record_multipath_plan` 保留为同轮修订版本,失败调用只进技术记录。
+- “主 Agent 直接读取”只认 `agent_role=main + agent_depth=0 + 真实主 scope`;评审 Agent 内部的工具调用不得并入。
+- “主 Agent 收到评审”要求评审 Agent 明确返回主 scope;只有结构化轮次和时间顺序时,只能写“形成前可见”,不得写成已采用依据。
+- 评审报告正文里的轮次文字与结构化事件冲突时,以事件的 `round_index` 为准。
+
+## 统一 Agent 决策契约
+
+- `direction`:创作目标、本轮目标、实现规划,由主 Agent 最终决定。
+- `tradeoff`:实现 Agent 数据取舍仅在当前方案内有效;主 Agent 多路决策是候选方案的最终取舍。
+- `evaluation`:多方案评审与主脚本整体评审只提供判断、问题和建议,不直接决定 Branch 状态或 Run 状态。
+- `creative`:只有安全关联的实现 Agent `think_and_plan` 与写入事件才能形成创作处理;候选脚本本身不能反推创作过程。
+- 所有输入同时标记“观察到的关系”和“是否明确作为依据”。读取过的数据默认只能说明形成前可见,不能自动升级为已采用依据。
+
+评审正文只由带版本号的 `EvaluationReportParser` 解析。业务区只展示结构化的评审对象、标准、逐项结论、达成项、问题、总结论和建议;原始 Markdown、表格和正文中的内部 ID 只进入技术记录。报告正文中的构建 ID 和轮次不参与关联。
+
+## Prompt 准确性
+
+- Agent 派发事件中保存的任务可显示为“本次真实任务”。
+- 当前 system prompt 从当前数据库配置或当前文件读取,只能显示为“当前版本规则”。
+- 系统没有保存每次 Run 的完整 Prompt 快照,因此当前规则不得描述成“当次运行完整 Prompt”。
+- Prompt 只通过懒加载接口读取,统一脱敏、限制长度,不进入 execution-view 主响应。
+
+实现 Agent 与取数 Agent 的归属仅使用 `parent_event_id` / `scope_event_id` 及明确的 `round_index + branch_id`。缺少父子关系时进入未归属技术记录,不通过中文任务文本猜测。
+
+## 取数三层事实
+
+1. **取到什么**:来自 Tool 调用状态和轻量结果摘要。0 条结果是“无结果”,不是失败。
+2. **初筛了什么**:只来自取数 Agent 的 `output_content.summary`,不从工具结果自动总结。
+3. **正式采用什么**:只来自 `script_build_data_decision`,由实现 Agent 做决定。
+
+`script_build_data_decision.sources` 没有 Agent 事件 ID,因此只做数据类型级对照,不声称某条证据来自同类 Agent 的某一次具体调用。取过但没有进入正式来源的数据,也不自动标记为“被排除”。
+
+## 取数分类
+
+- 工具取数:实现 Agent 直接读取选题、创作总目标、当前脚本、账号段落模式、账号人设或领域信息。
+- Agent 取数:解构 Case、知识、外部搜索、Pattern / Relation Agent。每次 `agent_invoke` 是一个独立实例,即使 Agent 类型相同也不合并。
+- Agent 内的 `think_and_plan` 和上下文 `get_script_snapshot` 不计入源数据查询次数。
+
+## 禁止解释
+
+1. 不读取或展示 `branch_id <= 0` 的旧语义数据决策。
+2. 不用 Branch 的 merged、parked、discarded 状态生成一条不存在的多路决策。
+3. 不用事件内容覆盖业务表中的决定、Branch 状态或领域事实。
+4. 不把领域事实已经入库解释成其来源内容方案已经进入主脚本。
+5. 不把当前规划解释成完整的规划修订历史。
+6. 不把候选处置前快照解释成本轮合并后的完整主脚本。
+7. 不用模型生成缺失的理由、评审结论或业务影响。
+8. 不把取数 Agent 的初筛整理解释成实现 Agent 的正式数据取舍。
+9. 不因为同一实现 Agent 调用多个取数 Agent 就显示为并行;必须存在真实时间重叠。
+10. 不用模糊的 `evaluator` 名称匹配评审类型;两类评审只认精确事件名。
+11. 不把“决策形成前发生过读取”直接解释为“这些数据导致了决策”。
+12. 不把评审 Agent 内部读取的数据解释为主 Agent 直接读取。
+13. 不把多方案评审或整体评审的建议解释为主 Agent 最终决定。
+14. 不从候选脚本或最终主脚本反推实现 Agent 的创作处理过程。
+15. 不把当前 Prompt 配置解释成历史 Run 实际使用的完整 Prompt 快照。
+
+## 多批次决策
+
+- 同一轮的多路决策按 `created_at, id` 排序。
+- 每条记录独立生成一个收敛节点。
+- `branch_ids` 决定进入该节点的连接线。
+- 同一 Branch 多次出现时保留全部决策记录,不声明后者覆盖前者。
+- 引用不存在的 Branch 时保留决策,但标记为部分完整。
+- 有 Branch、无多路决策时显示“未记录主 Agent 多路决策”。
+
+## 多方案评审与主决策关联
+
+- 同一轮允许存在多个收敛批次。
+- 多方案评审先用报告中明确的 Branch ID 确认候选范围。
+- 只有 Branch 集合相同、且决策时间不早于评审结束时间时,评审和数据库决策才会关联。
+- 无法安全关联的评审进入未归属运行记录,不强行挂到某个方案。
+- Branch 的当前状态和处置理由只作为主 Agent 决策的逐方案结果展示,不再生成实现 Agent 内部的“方案处理”节点。
+
+## 领域信息方案
+
+- `path_type=领域信息` 的产出来自 `script_build_domain_info`。
+- 领域事实按 `script_build_id` 全局累加,`round_index/branch_id` 只用于复盘来源。
+- 领域事实数量与 Branch 状态分别展示。
+- 领域信息方案不得显示段落、元素或“合入主脚本”的候选统计。
+
+## 只读边界
+
+- 数据库连接使用 visualization 自有连接池。
+- 物理连接创建时设置 MySQL Session 为只读。
+- Repository 仅允许 Query,不允许 flush、commit 或写操作。
+- 活动正文和候选快照按需读取,所有技术输出统一脱敏。

+ 51 - 0
visualization-old/DESIGN.md

@@ -0,0 +1,51 @@
+# V8 决策可视化设计规范
+
+## 阅读顺序
+
+画布从上到下按轮次讲述一次构建;每轮内部仍从左到右展示业务流程:
+
+```text
+确定方向 → 实现规划 → 多路实现 → 多方案评审 → 主 Agent 最终取舍 → 整体评审 → 本轮产出
+```
+
+实现方案内部使用“发散后汇聚”的结构:工具取数和 Agent 取数平级展开,取数 Agent 内只展示查询与初筛;正式数据取舍始终放在实现 Agent 范围。
+
+## 卡片信息预算
+
+- 每张决策卡只展示一条主结论、默认两条摘要,最多三条摘要。
+- Direction 展示最终方向、形成前信息与真实约束。
+- Tradeoff 展示取舍范围、明确处理结果与记录中的理由。
+- Evaluation 展示评审结论、关键达成项与真实问题或建议。
+- Creative 以“产生候选创作表”展示真实记录的创作处理与候选产出;完整创作任务只在前置“实现任务”节点展示,不在产出节点重复。
+- Implementation Plan 使用专用“本轮实施路线”结构,逐路展示动作、类型、作用范围、实施方法与路线侧重;规划理由紧随路线,完整形成依据折叠展示。
+- 输出、取数与普通执行节点不使用决策标签,也不显示 Prompt 入口。
+
+主画布不展示数据库表名、字段名、事件 ID、Tool 名、JSON 或原始 Markdown。
+
+## 决定权视觉
+
+- 主 Agent 最终决定:珊瑚色主轮廓,标签为“最终决定”。
+- 评审 Agent:紫色辅助轮廓,标签为“评审建议”,不使用最终态语气。
+- 实现 Agent 内部决策:蓝色轮廓,标签为“本方案内有效”。
+- 成功、暂存、未采用分别使用绿、黄、红,只标注真实状态。
+
+## Inspector
+
+Inspector 只保留三层:
+
+1. **业务详情**:按决策类型展示问题、输入关系、结论、理由、评审建议与最终处理。
+2. **产出与改动**:仅在存在可确认的候选产物或主脚本变化时出现。
+3. **技术记录**:按数据来源、关联方式、原始输入、原始输出、耗时成本与原始记录分组。
+
+Agent 的“本次真实任务 / 当前版本规则”只从 Inspector 内的规则按钮进入;主画布的候选产出卡优先保留“查看完整候选脚本”。
+
+实现规划的画布卡只承担摘要职责;Inspector 不得复用画布 preview,形成依据保留完整业务文本并默认折叠。
+
+评审建议与主 Agent 最终决定并排对照;只有主 Agent 理由明确记录接受、部分接受、延后转化或拒绝时,才显示“对建议的处理”。
+
+## 交互与响应式
+
+- 画布支持 Mac 双指平移和捏合缩放,不显示 Minimap。
+- 节点可拖动;轮询刷新不重置视口、用户位置、展开状态或 Inspector 页签。
+- Prompt Drawer 和 Inspector 遵守顶层弹层优先级;`Escape` 只关闭最上层,关闭后恢复到原触发按钮。
+- 桌面端按轮次纵向分行,每轮内部保持横向流程;移动端使用同一业务投影的轮次折叠列表,不在浏览器里另行解释业务。

+ 43 - 0
visualization-old/EVENT_AUDIT_AND_GAPS.md

@@ -0,0 +1,43 @@
+# V8 事件记录能力与剩余缺口
+
+## 当前已经具备
+
+数据库已经提供结构化运行事件及独立正文表,可记录工具调用、模型轮次、子 Agent 派发、返回状态、输入输出和执行时间。V8 用这些记录补充取数树、Agent 初筛、创作处理、多方案评审和主脚本整体评审,但业务主线仍以业务表为准。
+
+## 仍然存在的缺口
+
+1. `script_build_event.round_index` 在部分运行中为空,无法直接关联轮次。
+2. 事件表没有 `branch_id`,取数活动经常无法直接关联实现方案。
+3. `parent_event_id` 尚未稳定建立“主 Agent → 实现 Agent → 取数 Agent”的完整父子树。
+4. 评审结果仍是报告正文,没有独立的轮次评审和 Branch 评审业务表。
+5. `script_build_round.multipath_plan` 保存当前版本,同轮早期规划仍需从运行事件补充。
+6. parked 方案没有处置时历史快照,后续只能查看基于当前主脚本计算的 overlay。
+7. 主脚本没有逐轮版本,无法精确还原每轮合并完成后的完整状态。
+
+## V8 的保守策略
+
+- 只有明确评审结论才能显示通过、部分通过或需要继续。
+- `script_multipath_evaluator` 只表示候选方案评审,`script_evaluator` 只表示主脚本整体评审。
+- 多方案评审与主 Agent 决策只有在 Branch 集合和时间顺序一致时才关联。
+- 事件明确携带轮次或 Branch 时使用直接关联。
+- 文本中只有一个合法轮次或 Branch 时,允许标记为“运行记录关联”。
+- 多个候选同时匹配时不猜测,记录进入 `unassigned.runtimeEvents`。
+- 事件中的规划记录只进入轮次详情,不覆盖当前规划。
+- 当前主脚本只描述为“当前”,候选快照只描述为“处置前快照”或“当前 overlay”。
+- 评审报告统一解析为结构化业务字段,原始 Markdown 只留在技术记录。
+- 实现 Agent 没有安全关联的创作处理事件时,只显示候选产出,不补空决策节点。
+- 当前 Prompt 配置只称为“当前版本规则”,不伪装成历史运行快照。
+
+## 后续建议
+
+无需再新建一套事件表,优先完善现有事件字段:
+
+- 所有事件写入真实 `round_index`;
+- 增加可空 `branch_id`;
+- 实现 Agent 派发事件写入 Branch 创建后的稳定 ID;
+- 取数 Agent 和工具事件写入 `parent_event_id`;
+- 评审结果增加结构化正文:结论、问题列表、下一轮目标、维度结果;
+- 主脚本每次合并后保存内容寻址的版本引用;
+- parked、merged、discarded 都保存统一的处置前候选快照。
+
+这些改进需要修改父业务服务和数据库,本次 V8 可视化不执行迁移。

+ 52 - 0
visualization-old/PRODUCT.md

@@ -0,0 +1,52 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+主要面向需要理解脚本构建过程与最终脚本的业务人员。他们不需要理解数据库、事件或 Agent 内部实现,但需要快速看懂每轮做了什么、为什么这样决定,以及最终产出了什么。
+
+## Product Purpose
+
+以横向 n8n 式流程还原真实的脚本构建过程,并提供与原业务页一致的最终脚本表,让非技术用户可以从流程故事下钻到完整业务数据。
+
+## Brand Personality
+
+清晰、克制、可信。
+
+## Anti-references
+
+- 不做将数据库字段直接堆到界面上的技术审计台。
+- 不用大量套话、重复标签和缺失提示干扰主流程。
+- 不为某个 Run、Case 或 Branch 编写展示特例。
+
+## Design Principles
+
+- 主画布先讲清业务顺序和决策,技术记录只在下钻时出现。
+- 数据库业务事实优先,运行事件只做补充,不把推测写成事实。
+- 每张卡片只回答该阶段最重要的问题,完整原文放入 Inspector。
+- 主 Agent 的前三阶段按“当前结论、形成前可见信息、明确理由”分层;运行中看过的信息不能自动写成已采用依据。
+- 严格区分“主 Agent 直接读取”“评审 Agent 返回报告”和“仅按运行顺序关联”,评审 Agent 内部读取不得归到主 Agent。
+- 创作目标显示当前数据库保存值并保留运行版本记录;实现规划显示当前规划,历史修订只在详情中展开。
+- 所有 Agent 决策卡使用一个主结论、默认两个摘要、最多三个摘要;不同决策保留各自的业务问题,不套用同一组固定句式。
+- 业务详情只解释问题、输入关系、决定、理由和结果;表名、字段名、内部 ID、Tool、原始 Markdown 和 JSON 统一进入技术记录。
+- 评审建议与最终决定必须视觉和语义分离;多方案评审、整体评审都没有替主 Agent 做最终取舍的权限。
+- “本次真实任务”来自运行事件;“当前版本规则”来自当前数据库或文件配置,不得称为当次运行时的完整 Prompt。
+- 最终脚本表尊重已有业务表达,不为了“简化”丢失字段或改变语义。
+- 相同交互在不同 Run 与不同屏幕上保持一致。
+
+## Decision Relationship Vocabulary
+
+- `主 Agent 直接读取`:同一主 Agent scope、`agent_role=main`、`agent_depth=0` 的真实读取。
+- `主 Agent 收到评审`:评审 Agent 已完成并把报告返回到主流程。
+- `形成前可见`:记录在决定前出现,但没有证据证明被采用,只能描述时间关系。
+- `明确作为依据`:只有业务记录或运行记录明确表达采用关系时才能使用。
+- `评审建议`:评审 Agent 返回的判断和建议,不等于主 Agent 的最终决定。
+- `最终决定`:主 Agent 或当前实现方案内有明确决定权的 Agent 实际保存的取舍。
+- `当前保存值`:数据库现在能够确认的结果,不等同于不可覆盖的永久版本。
+
+## Accessibility & Inclusion
+
+保证键盘可操作、明确焦点、减少动效支持、语义化表格表头,以及文字与背景的可读对比度。

+ 143 - 0
visualization-old/README.md

@@ -0,0 +1,143 @@
+# 脚本构建真实运行可视化 V8
+
+独立、只读的 FastAPI + Next.js 运行台。V8 只消费最新业务表和结构化运行事件,把真实脚本构建过程整理成非技术人员能够顺着读完的横向故事,并统一四类 Agent 决策的权限、摘要和详情表达。
+
+```text
+创作目标 → 本轮规划 → 实现 Agent 多路方案 → 多方案评审 Agent → 主 Agent 多路决策 → 主脚本整体评审 → 本轮产出
+```
+
+每轮先发散为多个实现方案。展开方案后,取数阶段将“实现 Agent 直接调用工具”和“委派取数 Agent”放在同一层级;取数 Agent 内部展示多次查询和自己的初筛整理。实现 Agent 的正式数据取舍位于取数阶段之后,不与初筛混合。
+
+```text
+实现任务
+  → 取数阶段
+      ├─ 工具取数
+      └─ Agent 取数 → 多次查询 → 初筛整理
+  → 实现 Agent 数据取舍
+  → 候选产出
+```
+
+所有候选产出在实现 Agent 区结束,随后按真实批次汇入 `script_multipath_evaluator`。评审只提供候选比较和建议;最终采用、暂存或未采用由 `script_build_multipath_decision` 与 Branch 状态共同展示。合并后的主脚本再由 `script_evaluator` 做整体评审。
+
+取数操作按运行时间投影为单项、依次、并行、混合或先后未确认。没有时间重叠时不会仅因为存在多个 Agent 就显示为并行。
+
+## 数据真实性
+
+- `script_build_data_decision` 只表示实现 Agent 对数据的采用和排除。
+- `script_build_multipath_decision` 只表示主 Agent 对一批候选结果的综合决策。
+- `script_build_domain_info` 是独立累加的领域事实;即使来源方案后来未采用,已核实事实仍然存在。
+- `script_build_event/body` 只补充评审和运行活动,不覆盖业务表事实。
+- 当前 `script_build_round.multipath_plan` 是当前保存值,不描述成完整规划历史。
+- 当前主脚本是最终 `branch_id=0` 数据,不描述成任何一轮结束时的精确快照。
+- 旧语义 `data_decision` 记录不会被解释成主 Agent 决策。
+- 没有多路决策记录时明确显示缺失,不用 Branch 状态反推决定。
+
+完整数据优先级和禁止解释见 [DATA_SOURCE_CONTRACT.md](./DATA_SOURCE_CONTRACT.md)。
+
+## 架构
+
+```text
+LocalDatabaseProvider
+        ↓
+ScriptBuildRepository(MySQL 会话级只读)
+        ↓
+Business Bundle
+        ├─ RuntimeEventProjector(只负责编排)
+        │   └─ RuntimeEventIndex(一次建立顺序、父子、scope、actor 和轮次索引)
+        │       ├─ RetrievalEventProjector
+        │       ├─ EvaluationEventProjector + EvaluationReportParser
+        │       └─ MainAgentDecisionProjector
+        │           ├─ ObjectiveDecisionProjector
+        │           ├─ RoundGoalDecisionProjector
+        │           └─ ImplementationPlanDecisionProjector
+        ├─ CreativeEventProjector
+        ├─ AgentDecisionProjector
+        └─ Artifact Projection
+        ↓
+ExecutionViewV8
+        ↓
+React Flow 横向发散—收敛画布
+```
+
+主接口只返回业务摘要和详情引用。完整事件正文、候选快照、数据库行和技术字段在点击卡片后按需读取。最终主脚本使用单独的只读三表查询,不会为打开脚本表重复加载轮次、分支、决策和事件。
+
+主 Agent 前三阶段分别由专用来源投影器还原事实,再统一投影为 `AgentDecisionProjection`。Direction、Tradeoff、Evaluation、Creative 共用一个卡片与详情协议:画布只展示一个主结论和最多三个摘要,完整输入关系、理由、评审对照和技术证据进入 Inspector。
+
+评审报告只通过一个带版本号的解析器进入业务区,解析失败不会把原始 Markdown 倾倒给业务用户。Agent 派发任务和当前版本规则通过独立懒加载接口查看,并明确标注当前规则不是历史运行 Prompt 快照。
+
+产品信息架构、视觉与交互规范、数据真实性边界分别见 [`PRODUCT.md`](./PRODUCT.md)、[`DESIGN.md`](./DESIGN.md) 和 [`DATA_SOURCE_CONTRACT.md`](./DATA_SOURCE_CONTRACT.md)。
+
+## 启动
+
+```bash
+cd aiddit/pattern/patter_from_global_and_build/visualization/backend
+python3 -m venv .venv
+source .venv/bin/activate
+PIP_CACHE_DIR=../.cache/pip pip install -r requirements.txt
+PATTERN_DB_HOST_OVERRIDE=192.168.202.204 uvicorn app.main:app --host 127.0.0.1 --port 8788
+```
+
+```bash
+cd aiddit/pattern/patter_from_global_and_build/visualization/frontend
+npm_config_cache=../.cache/npm npm install
+NEXT_PUBLIC_API_BASE=http://127.0.0.1:8788 npm run dev
+```
+
+打开 `http://127.0.0.1:3008`。
+
+### 环境变量
+
+| 变量 | 默认值 | 作用 |
+|---|---|---|
+| `PATTERN_RUNTIME_DIR` | visualization 的父业务目录 | 定位父项目的 `db_manager.py` 和 `models.py` |
+| `PATTERN_DB_HOST_OVERRIDE` | 空 | 只在 visualization 的独立连接池中替换 DB host |
+| `VISUALIZATION_CORS_ORIGINS` | `localhost/127.0.0.1:3008` | CORS 白名单 |
+| `NEXT_PUBLIC_API_BASE` | `http://127.0.0.1:8788` | 前端访问的可视化后端 |
+
+无论是否配置 Host Override,后端都创建独立连接池,并在每个物理连接上执行 `SET SESSION TRANSACTION READ ONLY`。Repository 不包含新增、更新、删除、flush 或 commit。
+
+## API
+
+```text
+GET /api/health
+GET /api/capabilities
+GET /api/script-builds?limit=30&status=
+GET /api/script-builds/{id}/execution-view
+GET /api/script-builds/{id}/rounds/{round_index}
+GET /api/script-builds/{id}/activities/{activity_id}
+GET /api/script-builds/{id}/card-data/{detail_ref}
+GET /api/script-builds/{id}/inspector-view/{detail_ref}
+GET /api/script-builds/{id}/artifacts/{snapshot_ref}
+GET /api/script-builds/{id}/decision-prompts/{prompt_ref}
+```
+
+真实 ID 读取失败返回 404/503,不切换为示例数据。生产后端没有示例数据接口、远程旧接口适配器或原始执行日志接口。
+
+## 前端交互
+
+- 横向 n8n 式画布,轮次从左向右推进。
+- 当前轮默认展开,终态 Run 默认折叠历史轮。
+- 内容方案和领域信息方案使用不同图标与产出标签。
+- 取数 Agent 默认只展示查询统计和初筛结论;用户可在原地展开每次查询,单次查询结果按需读取。
+- Mac 双指自由平移、捏合缩放,普通滚轮不缩放;不显示 MiniMap。
+- 所有业务卡片可拖动,最小缩放为 60%,操作按钮始终保留。
+- 面向业务阅读的 Inspector 保持原有单列展示;独立的“数据来源”Inspector 用于调试对照。
+- “数据来源”按“业务详情中的一项|这一项的数据依据|对应的原始字段或原文”逐行对齐,来源明确区分数据库、运行事件、产物、确定性计算和日志锚点。
+- 最终结果的“查看完整主脚本”会打开近全屏文档表,支持完整表/精简表、脚本元素关联、表头与前两列固定;关闭后回到原画布位置。
+
+真实 Run 的数据来源回归可在后端目录执行:
+
+```bash
+.venv/bin/python scripts/validate_source_inspector.py --runs 422 443 444 446 --workers 8
+```
+- 轮询不会重置画布位置、节点选择、轮次展开或 Inspector 页签。
+
+## 验证
+
+```bash
+cd backend && .venv/bin/pytest -q
+cd ../frontend && npm test && npm run typecheck && npm run build
+PLAYWRIGHT_USE_SYSTEM_CHROME=1 npm run test:e2e
+```
+
+所有缓存、截图和测试产物必须留在 `visualization/.cache`、`visualization/exports` 或 `visualization/test-artifacts`。实现不得修改父业务代码或旧 Demo。

+ 2 - 0
visualization-old/backend/app/__init__.py

@@ -0,0 +1,2 @@
+"""Read-only script-build visualization backend."""
+

+ 570 - 0
visualization-old/backend/app/business_detail_text.py

@@ -0,0 +1,570 @@
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+from .evaluation_report_parser import EvaluationReportParser
+from .runtime_tool_catalog import TOOL_CATALOG
+
+
+_STATUS_LABELS = {
+    "merged": "已采用",
+    "parked": "暂存",
+    "discarded": "未采用",
+    "open": "等待处理",
+    "running": "进行中",
+    "success": "成功",
+    "failure": "失败",
+    "failed": "失败",
+    "ok": "完成",
+    "completed": "完成",
+}
+
+_FIELD_LABELS = {
+    "full_description": "详细执行描述",
+    "content_range": "内容范围",
+    "theme_elements": "主题信息",
+    "form_elements": "形式信息",
+    "function_elements": "作用信息",
+    "feeling_elements": "感受信息",
+    "element_count": "元素数量",
+    "theme": "主题",
+    "form": "形式",
+    "function": "作用",
+    "feeling": "感受",
+}
+
+
+def business_text(value: Any) -> str:
+    """Translate stored business prose without inventing a new conclusion."""
+    text = _string(value)
+    if not text:
+        return ""
+    text = _decode_visible_escapes(text)
+    for field, label in _FIELD_LABELS.items():
+        text = re.sub(
+            rf"(?<![a-z0-9_]){re.escape(field)}(?![a-z0-9_])",
+            label,
+            text,
+            flags=re.I,
+        )
+    text = re.sub(r"(?i)(?<![a-z0-9_])branch_id\s*[=::]\s*(\d+)", r"方案 \1", text)
+    text = re.sub(r"(?i)(?<![a-z0-9_])branch\s*#?\s*(\d+)", r"方案 \1", text)
+    text = re.sub(r"(?i)(?<![a-z0-9_])base(?![a-z0-9_])", "主脚本", text)
+    text = re.sub(
+        r"选\s+方案\s*(\d+)\s*合入(?:\s*主脚本)?\s*[,,、]\s*弃\s+方案\s*(\d+)",
+        r"采用方案 \1 并合入主脚本,方案 \2 未采用",
+        text,
+    )
+    text = re.sub(
+        r"组\s+方案\s*(\d+)\s*与\s*方案\s*(\d+)\s*合入",
+        r"组合方案 \1 与方案 \2,并合入主脚本",
+        text,
+    )
+    text = re.sub(r"(?i)(?<![a-z0-9_])target\s*[=::]", "任务目标:", text)
+    text = re.sub(r"(?i)(?<![a-z0-9_])target(?![a-z0-9_])", "任务目标", text)
+    text = re.sub(r"(?i)(?<![a-z0-9_])scope\s*[=::]", "账号:", text)
+    text = re.sub(r"(?i)(?<![a-z0-9_])null(?![a-z0-9_])", "未填写", text)
+    text = (
+        text.replace("主脚本 脚本表", "主脚本")
+        .replace("账号 账号:", "账号:")
+        .replace("同一 任务目标", "同一个任务")
+    )
+    text = re.sub(r"(?i)patch\s*分支", "候选方案", text)
+    text = re.sub(r"[ \t]+([,。;!?、:)】,.])", r"\1", text)
+    return _tidy(text)
+
+
+def branch_task_text(value: Any) -> str:
+    """Remove implementation headers while preserving the complete business brief."""
+    text = business_text(value)
+    if not text:
+        return ""
+    kept: list[str] = []
+    for line in text.splitlines():
+        stripped = line.strip()
+        if re.match(r"^(?:路序号|action|path_type)\s*[::]", stripped, re.I):
+            continue
+        if re.match(r"^任务目标\s*[::]", stripped):
+            stripped = re.sub(r"^任务目标\s*[::]\s*", "", stripped)
+            if stripped:
+                kept.extend(["### 实现范围", stripped])
+            continue
+        stripped = re.sub(r"^取证方向与约束\s*[::]?", "### 参考依据与约束", stripped)
+        stripped = re.sub(r"^总目标方向(?:([^)]*))?\s*[::]?", "### 整体方向", stripped)
+        stripped = re.sub(r"^本轮当前目标\s*[::]?", "**本轮重点:**", stripped)
+        stripped = re.sub(r"^禁止事项\s*[::]?\s*", "### 需要避免\n", stripped)
+        kept.append(stripped)
+    return _tidy("\n".join(kept))
+
+
+def branch_task_sections(value: Any) -> list[dict[str, str]]:
+    """Split a delegated task into optional display sections without data loss.
+
+    Real runs mostly follow the dispatch contract, but some repair tasks are
+    free-form.  No field is therefore required: recognized labels become
+    sections and every remaining line stays in a generic task note.
+    """
+    text = branch_task_text(value)
+    if not text:
+        return []
+
+    sections: list[dict[str, str]] = []
+    current: dict[str, Any] | None = None
+    recognized = False
+
+    def flush() -> None:
+        nonlocal current
+        if not current:
+            return
+        content = _tidy("\n".join(current.pop("lines", [])))
+        if content:
+            sections.append({**current, "content": content})
+        current = None
+
+    def begin(kind: str, title: str, inline: str = "") -> None:
+        nonlocal current, recognized
+        flush()
+        recognized = True
+        current = {"kind": kind, "title": title, "lines": []}
+        if inline.strip():
+            current["lines"].append(inline.strip())
+
+    for line in text.splitlines():
+        stripped = line.strip()
+        heading = re.match(r"^#{1,6}\s+(.+?)\s*$", stripped)
+        if heading:
+            label = heading.group(1).strip()
+            if label == "实现范围":
+                begin("scope", label)
+                continue
+            if label in {"参考依据与约束", "整体方向"}:
+                begin("constraints", label)
+                continue
+            if label == "需要避免":
+                begin("avoid", label)
+                continue
+
+        labelled = _task_section_label(stripped)
+        if labelled:
+            kind, title, inline = labelled
+            begin(kind, title, inline)
+            continue
+
+        if current is None:
+            current = {"kind": "notes", "title": "任务说明", "lines": []}
+        current["lines"].append(line)
+
+    flush()
+    if not recognized:
+        return [{"kind": "notes", "title": "任务说明", "content": text}]
+    return sections
+
+
+def _task_section_label(value: str) -> tuple[str, str, str] | None:
+    plain = re.sub(r"^\*\*(.+?)\*\*\s*([::]?.*)$", r"\1\2", value).strip()
+    patterns = (
+        ("method", "实施方法", r"(?i)^method\s*[::]\s*(.*)$"),
+        ("goal", "目标", r"^目标\s*[::]\s*(.*)$"),
+        ("materials", None, r"^(必用素材(?:([^\n]*))?)\s*[::]\s*(.*)$"),
+        ("context", None, r"^(前情提要(?:([^\n]*))?)\s*[::]\s*(.*)$"),
+        (
+            "constraints",
+            None,
+            r"^((?:极其重要的|统一基调|严格|重要)?约束(?:([^\n]*))?)\s*[::]\s*(.*)$",
+        ),
+    )
+    for kind, fixed_title, pattern in patterns:
+        match = re.match(pattern, plain)
+        if not match:
+            continue
+        if fixed_title is not None:
+            return kind, fixed_title, match.group(1)
+        return kind, match.group(1), match.group(2)
+    return None
+
+
+def assessment_text(value: Any) -> str:
+    """Keep the implementer's declaration; remove thought/process and cost telemetry."""
+    text = clean_report(value)
+    if not text:
+        return ""
+    marker = re.search(r"(?i)#{1,6}\s*完成度申报", text)
+    if marker:
+        text = text[marker.end() :]
+    else:
+        text = re.sub(
+            r"(?is)^.*?(?=^\s*[-*]\s*\*{0,2}(?:完成|未尽|存疑)\*{0,2}\s*[::])",
+            "",
+            text,
+        )
+    return _tidy(text)
+
+
+def clean_report(value: Any) -> str:
+    """Turn evaluator/agent Markdown into business prose; raw input remains technical."""
+    text = business_text(value)
+    if not text:
+        return ""
+    text = re.sub(r"(?ms)^\s*```[^\n]*\n.*?^\s*```\s*$", "", text)
+    text = re.sub(r"(?ms)^\s*```[^\n]*\n.*\Z", "", text)
+    lines: list[str] = []
+    skip_stats = False
+    for line in text.splitlines():
+        stripped = line.strip()
+        if re.match(r"^#{1,6}\s*(?:委托任务完成|评估报告)\b", stripped):
+            continue
+        if re.match(r"^(?:\*\*)?执行统计(?:\*\*)?\s*[::]?", stripped):
+            skip_stats = True
+            continue
+        if skip_stats and (
+            not stripped
+            or re.match(r"^[-*]\s*(?:消息数|Tokens?|成本)\s*[::]", stripped, re.I)
+        ):
+            continue
+        if skip_stats and stripped:
+            skip_stats = False
+        if re.match(r"^[-*]?\s*(?:消息数|Tokens?|Token\s*usage|成本)\s*[::]", stripped, re.I):
+            continue
+        if "分支元信息" in stripped:
+            continue
+        line = re.sub(
+            r"[((]\s*段落\s*(?:ID|id)\s*[::]\s*[\d,,、\s]+[))]",
+            "",
+            line,
+        )
+        line = re.sub(r"(?i)script_build_id\s*[=:]\s*\d+\s*[·|,,]?", "", line)
+        line = re.sub(r"(?i)\bpost_id\s*[=::]\s*[^\s,,;;))]+", "来源记录", line)
+        line = re.sub(r"(?i)\bround(?:_index)?\s*[=:]\s*(\d+)\b", r"第 \1 轮", line)
+        line = re.sub(r"(?i)\b(?:ID|id)\s*[=::]\s*\d+\b", "", line)
+        line = re.sub(r"[((]\s*[,,·|\s-]*[))]", "", line)
+        line = re.sub(r"(?<=\d)中", " 中", line)
+        line = re.sub(r"`?\[元素:ID\]`?\s*格式", "规范格式", line, flags=re.I)
+        lines.append(line.rstrip())
+    return _tidy("\n".join(lines))
+
+
+def event_business_sections(event: dict[str, Any]) -> list[dict[str, str]]:
+    name = str(event.get("event_name") or "")
+    task = _event_value(event, "input", "task")
+    result = _event_value(event, "output", "summary")
+    if name == "script_multipath_evaluator":
+        report = EvaluationReportParser().parse(result, kind="multipath")
+        scope = "、".join(report["subjects"]) or branch_task_text(task)
+        return _compact_sections(
+            [
+                ("scope", "评审了哪些方案", scope, "summary"),
+                (
+                    "conclusion",
+                    "各方案结论",
+                    _evaluation_item_text(report["itemConclusions"]),
+                    "default",
+                ),
+                ("recommendation", "评审建议", report["recommendation"], "report"),
+            ]
+        )
+
+    if name == "script_evaluator":
+        report = EvaluationReportParser().parse(result, kind="overall")
+        return _compact_sections(
+            [
+                ("conclusion", "主脚本是否达标", report["conclusion"], "summary"),
+                ("scope", "评审范围", branch_task_text(task), "default"),
+                ("achievements", "已达成", "\n".join(report["achievements"]), "report"),
+                (
+                    "problems",
+                    "仍需解决",
+                    _evaluation_problem_text(report["problems"]),
+                    "report",
+                ),
+                ("next", "下一步建议", report["nextGoal"], "report"),
+            ]
+        )
+
+    action = friendly_event_name(event)
+    request = branch_task_text(task)
+    outcome = event_result_text(event)
+    return _compact_sections(
+        [
+            ("action", "做了什么", action, "summary"),
+            ("request", "处理内容", request, "default"),
+            ("result", "得到什么", outcome, "report"),
+        ]
+    )
+
+
+def _evaluation_item_text(items: list[Any]) -> str:
+    lines: list[str] = []
+    for item in items:
+        if not isinstance(item, dict):
+            continue
+        subject = business_text(item.get("subject") or item.get("label"))
+        conclusion = business_text(item.get("conclusion") or item.get("summary"))
+        if subject and conclusion:
+            lines.append(f"{subject}:{conclusion}")
+    return "\n".join(lines)
+
+
+def _evaluation_problem_text(items: list[Any]) -> str:
+    values = []
+    for item in items:
+        if isinstance(item, dict):
+            value = business_text(item.get("summary") or item.get("label"))
+        else:
+            value = business_text(item)
+        if value:
+            values.append(value)
+    return "\n".join(values)
+
+
+def source_markdown(sources: list[Any]) -> str:
+    items: list[str] = []
+    for source in sources:
+        if not isinstance(source, dict):
+            continue
+        source_type = business_text(source.get("data_type") or source.get("type")) or "参考信息"
+        content = business_text(
+            source.get("data_content")
+            or source.get("content")
+            or source.get("summary")
+        )
+        if content:
+            items.append(f"- **{source_type}:** {content}")
+    return "\n".join(items)
+
+
+def decisions_markdown(values: list[Any]) -> str:
+    items = [business_text(value) for value in values]
+    return "\n\n".join(f"{index}. {value}" for index, value in enumerate(items, 1) if value)
+
+
+def branches_markdown(branches: list[dict[str, Any]], *, raw: bool = False) -> str:
+    parts: list[str] = []
+    for index, branch in enumerate(branches, 1):
+        branch_id = branch.get("branch_id") if raw else branch.get("branchId")
+        title = (
+            branch.get("target") or branch.get("impl_task")
+            if raw
+            else branch.get("title") or (branch.get("summary") or {}).get("task")
+        )
+        status = branch.get("status")
+        output = None if raw else (branch.get("summary") or {}).get("output")
+        disposition = (
+            branch.get("decision_reasoning")
+            if raw
+            else (branch.get("outcome") or {}).get("reasoning")
+        )
+        heading = f"### 方案 {branch_id or index}"
+        if status:
+            heading += f" · {status_label(status)}"
+        rows = [heading, business_text(title)]
+        if output:
+            rows.append(f"**候选产出:** {business_text(output)}")
+        if disposition:
+            label = "选择理由" if raw else "最终选择"
+            rows.append(f"**{label}:** {business_text(disposition)}")
+        parts.append("\n\n".join(item for item in rows if item))
+    return "\n\n".join(parts)
+
+
+def batches_markdown(batches: list[dict[str, Any]]) -> str:
+    if not batches:
+        return "未记录主 Agent 多路决策。"
+    parts = []
+    for index, batch in enumerate(batches, 1):
+        branch_ids = "、".join(str(value) for value in batch.get("branchIds") or [])
+        lines = [f"### 决策 {index}"]
+        if branch_ids:
+            lines.append(f"**比较方案:** {branch_ids}")
+        if batch.get("decision"):
+            lines.append(f"**最终决定:** {business_text(batch['decision'])}")
+        if batch.get("reasoning"):
+            lines.append(f"**原因:** {business_text(batch['reasoning'])}")
+        parts.append("\n\n".join(lines))
+    return "\n\n".join(parts)
+
+
+def status_label(value: Any) -> str:
+    raw = str(value or "").strip()
+    return _STATUS_LABELS.get(raw.lower(), business_text(raw) or "状态未记录")
+
+
+def friendly_event_name(event: dict[str, Any]) -> str:
+    name = str(event.get("event_name") or "")
+    title = str(event.get("title") or "")
+    labels = {
+        "script_evaluator": "对本轮主脚本进行整体评审",
+        "script_multipath_evaluator": "比较候选方案并给出独立评审",
+        "record_data_decision": "记录实现方案的数据取舍",
+        "get_branch_data_decisions": "读取方案的数据取舍记录",
+        "get_script_build_context": "读取本次构建的业务背景",
+    }
+    if name in labels:
+        return labels[name]
+    definition = TOOL_CATALOG.get(name)
+    if definition:
+        if definition.category == "decision-input":
+            return f"读取{definition.business_label}"
+        return definition.business_label
+    cleaned = re.sub(r"\s*\([^)]*\)\s*$", "", title).strip()
+    if cleaned and not re.search(r"[a-z]+_[a-z_]", cleaned, re.I):
+        return business_text(cleaned)
+    lowered = name.lower()
+    if any(token in lowered for token in ("search", "find", "query", "retrieve")):
+        return "查找相关参考信息"
+    if lowered.startswith("get_"):
+        return "读取相关业务资料"
+    if lowered.startswith(("record_", "save_")):
+        return "保存本步骤的业务结果"
+    return "完成一次运行活动"
+
+
+def event_result_text(event: dict[str, Any]) -> str:
+    name = str(event.get("event_name") or "")
+    status = str(event.get("status") or "").lower()
+    output = _event_object(event, "output")
+    if status in {"error", "failed", "failure"} or output.get("success") is False:
+        message = business_text(output.get("message") or output.get("error"))
+        return f"执行失败。{message}" if message else "执行失败,具体原因请查看技术详情。"
+
+    if name == "get_script_direction":
+        direction = _event_value(event, "output", "script_direction")
+        return clean_report(direction) or "已读取当前创作方向。"
+    if name == "get_account_script_section_patterns":
+        summary = _event_value(event, "output", "分段规律摘要")
+        return clean_report(summary) or "已读取账号常用的段落结构。"
+    if name == "get_domain_info":
+        count = _list_count(output.get("domain_info"))
+        return f"已读取 {count} 条已核实领域信息。" if count is not None else "已读取已核实的领域信息。"
+    if name == "get_script_snapshot":
+        paragraphs = _list_count(output.get("paragraphs"))
+        elements = _list_count(output.get("elements"))
+        if paragraphs is not None and elements is not None:
+            return f"已读取当前主脚本,共 {paragraphs} 个段落、{elements} 个元素。"
+        return "已读取当前主脚本内容。"
+    if name == "get_branch_data_decisions":
+        count = _list_count(output.get("decisions"))
+        return f"找到 {count} 条数据取舍记录。" if count is not None else "已读取方案的数据取舍记录。"
+
+    summary = output.get("summary") or output.get("message")
+    if isinstance(summary, str):
+        readable = clean_report(summary)
+        if readable:
+            return readable
+    count = _first_count(output)
+    if count is not None:
+        return f"已完成,共返回 {count} 条结果。"
+    return "已完成。具体输入输出保留在技术详情中。"
+
+
+def _event_value(event: dict[str, Any], side: str, key: str | None = None) -> Any:
+    body = event.get(side)
+    content = body.get("content") if isinstance(body, dict) else body
+    value = _nested_value(content, key)
+    if value not in (None, ""):
+        return value
+    if key and isinstance(content, str) and not content.lstrip().startswith(("{", "[")):
+        return content
+    preview = event.get(f"{side}_preview")
+    if key:
+        extracted = _json_string_preview(preview, key)
+        if extracted not in (None, ""):
+            return extracted
+    return _nested_value(preview, key)
+
+
+def _event_object(event: dict[str, Any], side: str) -> dict[str, Any]:
+    body = event.get(side)
+    content = body.get("content") if isinstance(body, dict) else body
+    for value in (content, event.get(f"{side}_preview")):
+        if isinstance(value, dict):
+            return value
+        if isinstance(value, str):
+            try:
+                parsed = json.loads(value)
+            except json.JSONDecodeError:
+                continue
+            if isinstance(parsed, dict):
+                return parsed
+    return {}
+
+
+def _nested_value(value: Any, key: str | None) -> Any:
+    if key is None:
+        return value
+    if isinstance(value, dict):
+        return value.get(key)
+    if isinstance(value, str):
+        try:
+            parsed = json.loads(value)
+        except (TypeError, json.JSONDecodeError):
+            return None
+        return parsed.get(key) if isinstance(parsed, dict) else None
+    return None
+
+
+def _json_string_preview(value: Any, key: str) -> str | None:
+    text = _string(value)
+    if not text:
+        return None
+    match = re.search(rf'"{re.escape(key)}"\s*:\s*"', text)
+    if not match:
+        return None
+    start = match.end()
+    escaped = False
+    end = None
+    for index in range(start, len(text)):
+        char = text[index]
+        if char == '"' and not escaped:
+            end = index
+            break
+        if char == "\\" and not escaped:
+            escaped = True
+        else:
+            escaped = False
+    fragment = text[start:end] if end is not None else text[start:].rstrip("…")
+    try:
+        return json.loads(f'"{fragment}"')
+    except json.JSONDecodeError:
+        return _decode_visible_escapes(fragment)
+
+
+def _compact_sections(
+    values: list[tuple[str, str, Any, str]],
+) -> list[dict[str, str]]:
+    return [
+        {"id": section_id, "title": title, "content": content, "variant": variant}
+        for section_id, title, raw, variant in values
+        if (content := business_text(raw))
+    ]
+
+
+def _list_count(value: Any) -> int | None:
+    return len(value) if isinstance(value, list) else None
+
+
+def _first_count(value: dict[str, Any]) -> int | None:
+    for key in ("count", "result_count", "path_count", "source_count", "total"):
+        count = value.get(key)
+        if isinstance(count, int) and not isinstance(count, bool):
+            return count
+    return None
+
+
+def _decode_visible_escapes(value: str) -> str:
+    return value.replace("\\r\\n", "\n").replace("\\n", "\n").replace('\\"', '"')
+
+
+def _tidy(value: str) -> str:
+    value = re.sub(r"[ \t]+\n", "\n", value)
+    value = re.sub(r"\n{3,}", "\n\n", value)
+    return value.strip()
+
+
+def _unique(values: list[str]) -> list[str]:
+    return list(dict.fromkeys(values))
+
+
+def _string(value: Any) -> str:
+    return value.strip() if isinstance(value, str) else ""

+ 3 - 0
visualization-old/backend/app/card_details/__init__.py

@@ -0,0 +1,3 @@
+from .projector import CardBusinessDataProjector, CardDataNotFound
+
+__all__ = ["CardBusinessDataProjector", "CardDataNotFound"]

+ 273 - 0
visualization-old/backend/app/card_details/common.py

@@ -0,0 +1,273 @@
+from __future__ import annotations
+
+import re
+from collections.abc import Callable, Iterable
+from typing import Any
+
+from .schema import RuntimeUnit, field
+
+
+EventLoader = Callable[[int], dict[str, Any]]
+
+
+def integer(value: Any) -> int | None:
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def suffix_int(ref: str) -> int | None:
+    return integer(str(ref).rsplit(":", 1)[-1])
+
+
+def event_content(side: Any) -> Any:
+    return side.get("content") if isinstance(side, dict) else None
+
+
+def event_completeness(event: dict[str, Any]) -> str:
+    has_input = event_content(event.get("input")) not in (None, "", [], {})
+    has_output = event_content(event.get("output")) not in (None, "", [], {})
+    if has_input and has_output:
+        return "complete"
+    if has_input or has_output or event:
+        return "partial"
+    return "missing"
+
+
+def event_unit(event: dict[str, Any], *, label: str | None = None) -> RuntimeUnit:
+    event_id = integer(event.get("id")) or 0
+    status = str(event.get("status") or "unknown")
+    event_label = label or str(event.get("businessLabel") or event.get("title") or event.get("event_name") or f"运行事件 {event_id}")
+    return {
+        "id": f"runtime:event:{event_id}",
+        "label": event_label,
+        "status": status,
+        "summary": event_summary(event, event_label),
+        "input": event.get("input"),
+        "output": event.get("output"),
+        "durationMs": integer(event.get("duration_ms")),
+        "eventRef": f"event:{event_id}",
+        "completeness": event_completeness(event),  # type: ignore[typeddict-item]
+        "startedAt": event.get("started_at"),
+        "endedAt": event.get("ended_at"),
+    }
+
+
+def event_summary(event: dict[str, Any], fallback: str) -> str:
+    if event.get("error_message"):
+        return f"技术失败:{event['error_message']}"
+    output = event_content(event.get("output"))
+    if isinstance(output, dict):
+        for key in ("summary", "message", "conclusion"):
+            if output.get(key):
+                return str(output[key])
+    if event.get("output_preview"):
+        return str(event["output_preview"])
+    return fallback
+
+
+def load_events(loader: EventLoader, refs: Iterable[Any]) -> list[dict[str, Any]]:
+    values: list[dict[str, Any]] = []
+    seen: set[int] = set()
+    for ref in refs:
+        event_id = suffix_int(str(ref)) if isinstance(ref, str) else integer(ref)
+        if event_id is None or event_id in seen:
+            continue
+        seen.add(event_id)
+        try:
+            values.append(loader(event_id))
+        except LookupError:
+            continue
+    return values
+
+
+def event_notices(events: Iterable[dict[str, Any]]) -> list[dict[str, str]]:
+    notices: list[dict[str, str]] = []
+    omitted = 0
+    missing_body = 0
+    for event in events:
+        for side_name in ("input", "output"):
+            side = event.get(side_name)
+            if isinstance(side, dict) and side.get("truncated"):
+                omitted += int(side.get("omittedCharacters") or 0)
+        if event.get("input") is None and event.get("output") is None:
+            missing_body += 1
+    if omitted:
+        notices.append({"level": "warning", "message": f"Event Body 超过读取上限,共省略 {omitted} 个字符。"})
+    if missing_body:
+        notices.append({"level": "warning", "message": f"{missing_body} 个运行事件没有保存可用的 Event Body。"})
+    return notices
+
+
+def find_by_id(items: Iterable[dict[str, Any]], record_id: int | None) -> dict[str, Any] | None:
+    return next((item for item in items if integer(item.get("id")) == record_id), None)
+
+
+def find_round(view: dict[str, Any], round_index: int) -> dict[str, Any] | None:
+    return next((item for item in view.get("rounds") or [] if integer(item.get("roundIndex")) == round_index), None)
+
+
+def find_raw_round(bundle: dict[str, Any], round_index: int) -> dict[str, Any] | None:
+    return next((item for item in bundle.get("rounds") or [] if integer(item.get("round_index")) == round_index), None)
+
+
+def find_branch(view: dict[str, Any], round_index: int, branch_id: int) -> dict[str, Any] | None:
+    round_ = find_round(view, round_index)
+    return next((item for item in (round_ or {}).get("branches") or [] if integer(item.get("branchId")) == branch_id), None)
+
+
+def find_raw_branch(bundle: dict[str, Any], branch_id: int) -> dict[str, Any] | None:
+    return next((item for item in bundle.get("branches") or [] if integer(item.get("branch_id")) == branch_id), None)
+
+
+def find_detail(value: Any, detail_ref: str) -> dict[str, Any] | None:
+    if isinstance(value, dict):
+        if value.get("detailRef") == detail_ref:
+            return value
+        for child in value.values():
+            found = find_detail(child, detail_ref)
+            if found is not None:
+                return found
+    elif isinstance(value, list):
+        for child in value:
+            found = find_detail(child, detail_ref)
+            if found is not None:
+                return found
+    return None
+
+
+def decision_context(node: dict[str, Any] | None) -> dict[str, Any]:
+    if not isinstance(node, dict):
+        return {}
+    decision = node.get("decision")
+    if isinstance(decision, dict):
+        body = decision.get("body")
+        if isinstance(body, dict):
+            return {**decision, **body}
+        return decision
+    return node
+
+
+def context_input_fields(
+    context: dict[str, Any],
+    prefix: str,
+    events: list[dict[str, Any]] | None = None,
+) -> list[dict[str, Any]]:
+    relation_map = {
+        "direct-read": "direct-input",
+        "standing-constraint": "standing-constraint",
+        "persisted-record": "previous-result",
+        "explicit-basis": "explicit-basis",
+    }
+    fields: list[dict[str, Any]] = []
+    events_by_ref = {
+        f"event:{integer(event.get('id'))}": event
+        for event in events or []
+        if integer(event.get("id"))
+    }
+    for index, item in enumerate(context.get("inputs") or [], 1):
+        if not isinstance(item, dict):
+            continue
+        raw_relation = str(item.get("relation") or "")
+        relation = relation_map.get(raw_relation, "available-upstream")
+        detail_ref = str(item.get("detailRef") or "")
+        label = str(item.get("label") or f"上游输入 {index}")
+        if not detail_ref:
+            detail_ref = _implicit_context_ref(prefix, label)
+        event = events_by_ref.get(detail_ref) or {}
+        event_name = str(event.get("event_name") or "")
+        source_kind, source_label, field_path = _context_source(
+            detail_ref, raw_relation, event_name
+        )
+        fields.append(field(
+            f"{prefix}:input:{index}",
+            label,
+            item.get("summary"),
+            source_kind=source_kind,
+            source_label=source_label,
+            source_ref=detail_ref or None,
+            field_path=field_path,
+            relation=relation,
+        ))
+    return fields
+
+
+def _context_source(
+    detail_ref: str,
+    raw_relation: str,
+    event_name: str,
+) -> tuple[str, str, str]:
+    """Classify context sources from their real reference contract."""
+    event_paths = {
+        "save_script_direction": "input.content.script_direction",
+        "begin_round": "input.content.goal",
+        "record_multipath_plan": "input.content",
+    }
+    if event_name or detail_ref.startswith("event:"):
+        return (
+            "runtime-event",
+            "明确读取的运行事件" if raw_relation == "direct-read" else "上游运行事件",
+            event_paths.get(event_name, ""),
+        )
+    database_refs = (
+        (r"^run:objective$", "script_build_record", "script_direction"),
+        (r"^round:\d+:goal$", "script_build_round", "goal"),
+        (r"^multipath-decision:\d+$", "script_build_multipath_decision", "decision"),
+        (r"^data-decision:\d+$", "script_build_data_decision", "decision"),
+        (r"^domain-info:\d+$", "script_build_domain_info", "content"),
+    )
+    for pattern, table, field_path in database_refs:
+        if re.match(pattern, detail_ref):
+            return "database", table, field_path
+    if detail_ref.startswith("artifact:"):
+        return "artifact", "脚本或候选产物", ""
+    return "database", "上游业务结果", ""
+
+
+def _implicit_context_ref(prefix: str, label: str) -> str:
+    """Recover only deterministic upstream refs omitted by historical views."""
+    if prefix == "objective" and label in {"选题", "账号人设"}:
+        return ""
+    if re.match(r"^round:\d+:goal$", prefix) and label == "当前保存的创作目标":
+        return "run:objective"
+    match = re.match(r"^(round:\d+):plan$", prefix)
+    if match and label == "本轮目标":
+        return f"{match.group(1)}:goal"
+    return ""
+
+
+def decision_event_refs(context: dict[str, Any]) -> list[str]:
+    refs: list[str] = []
+    for key in ("revisionRefs", "technicalRefs", "eventRefs"):
+        for ref in context.get(key) or []:
+            if isinstance(ref, str) and ref.startswith("event:") and ref not in refs:
+                refs.append(ref)
+    for key in ("currentRevisionRef", "eventRef"):
+        ref = context.get(key)
+        if isinstance(ref, str) and ref.startswith("event:") and ref not in refs:
+            refs.append(ref)
+    for item in context.get("inputs") or []:
+        ref = item.get("detailRef") if isinstance(item, dict) else None
+        if isinstance(ref, str) and ref.startswith("event:") and ref not in refs:
+            refs.append(ref)
+    for item in context.get("revisions") or []:
+        ref = item.get("detailRef") if isinstance(item, dict) else None
+        if isinstance(ref, str) and ref.startswith("event:") and ref not in refs:
+            refs.append(ref)
+    return refs
+
+
+def output_field_from_event(event: dict[str, Any], prefix: str, label: str, relation: str = "persisted-output") -> dict[str, Any]:
+    event_id = integer(event.get("id")) or 0
+    return field(
+        f"{prefix}:event:{event_id}:output",
+        label,
+        event_content(event.get("output")),
+        source_kind="runtime-event",
+        source_label="Event Body 输出",
+        source_ref=f"event:{event_id}",
+        field_path="output.content",
+        relation=relation,
+        completeness=event_completeness(event),
+    )

+ 396 - 0
visualization-old/backend/app/card_details/implementation.py

@@ -0,0 +1,396 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .common import (
+    EventLoader,
+    decision_context,
+    decision_event_refs,
+    event_content,
+    event_notices,
+    event_unit,
+    find_branch,
+    find_by_id,
+    find_detail,
+    find_raw_branch,
+    integer,
+    load_events,
+    suffix_int,
+)
+from .schema import field, payload
+
+
+def project_implementation_task(
+    detail_ref: str,
+    round_index: int,
+    branch_id: int,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    raw = find_raw_branch(bundle, branch_id) or {}
+    branch = find_branch(view, round_index, branch_id) or {}
+    stage = branch.get("retrievalStage") or {}
+    implementer_id = integer(stage.get("implementerEventId"))
+    events = load_events(load_event, [implementer_id] if implementer_id else [])
+    full_task = _agent_task(events[0]) if events else None
+    task_value = full_task or raw.get("impl_task") or raw.get("target")
+    task_source = "runtime-event" if full_task else "database"
+    task_source_label = "实现 Agent Event Body" if full_task else "script_build_branch(历史回退)"
+    task_source_ref = f"event:{implementer_id}" if full_task and implementer_id else detail_ref
+    inputs = [
+        field(
+            f"branch:{branch_id}:task",
+            "完整实现任务",
+            task_value,
+            source_kind=task_source,
+            source_label=task_source_label,
+            source_ref=task_source_ref,
+            field_path="input.content.task" if full_task else "impl_task",
+            relation="direct-input",
+            completeness="complete" if full_task else "partial" if task_value else "missing",
+        ),
+        field(
+            f"branch:{branch_id}:path-type",
+            "实现范围",
+            raw.get("path_type"),
+            source_kind="database",
+            source_label="script_build_branch",
+            source_ref=detail_ref,
+            field_path="path_type",
+            relation="standing-constraint",
+        ),
+        field(
+            f"branch:{branch_id}:target",
+            "实现目标",
+            raw.get("target"),
+            source_kind="database",
+            source_label="script_build_branch",
+            source_ref=detail_ref,
+            field_path="target",
+            relation="standing-constraint",
+        ),
+    ]
+    notices = event_notices(events)
+    if task_value and not full_task:
+        notices.append({"level": "warning", "message": "未找到完整实现 Agent 输入,当前使用 Branch 字段作历史回退。"})
+    return payload(
+        "implementation-task",
+        "single-event" if events else "business-record",
+        inputs=inputs,
+        units=[event_unit(item, label="实现 Agent") for item in events],
+        outputs=[],
+        runtime_summary="任务作为实现 Agent 的完整输入执行。" if events else "该历史记录没有可用的实现 Agent Event Body。",
+        notices=notices,
+        completeness="complete" if full_task else "partial" if task_value else "missing",
+        business_modules=[
+            # The readable Inspector sections are parsed from the complete
+            # implementer task.  Keep all six source modules on that exact
+            # Event input so the source Inspector can bind every displayed
+            # section to a text span.  Branch.target remains available to the
+            # canvas summary below, but must not masquerade as the section's
+            # original source.
+            {"id": "scope", "title": "实现范围", "sourceIds": [inputs[0]["id"]]},
+            {"id": "method", "title": "实施方法", "sourceIds": [inputs[0]["id"]]},
+            {"id": "goal", "title": "完整目标", "sourceIds": [inputs[0]["id"]]},
+            {"id": "constraints", "title": "参考依据与约束", "sourceIds": [inputs[0]["id"]]},
+            {"id": "avoid", "title": "需要避免", "sourceIds": [inputs[0]["id"]]},
+            {"id": "deliverable", "title": "预期交付", "sourceIds": [inputs[0]["id"]]},
+        ],
+        card_fields=[
+            {"key": "scope", "label": "范围", "sourceIds": [inputs[2]["id"]], "transform": "直接使用结构化 target"},
+            {"key": "method", "label": "方法", "sourceIds": [inputs[0]["id"]], "transform": "使用任务中的实施方法段"},
+            {"key": "target", "label": "目标", "sourceIds": [inputs[2]["id"], inputs[0]["id"]], "transform": "优先结构化 target"},
+        ],
+    )
+
+
+def project_data_decision(
+    detail_ref: str,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    record_id = suffix_int(detail_ref)
+    row = find_by_id(bundle.get("dataDecisions") or [], record_id)
+    if row is None:
+        raise KeyError(detail_ref)
+    node = find_detail(view, detail_ref)
+    context = decision_context(node)
+    events = load_events(load_event, decision_event_refs(context))
+    sources = row.get("sources") if isinstance(row.get("sources"), list) else []
+    inputs = [
+        field(
+            f"data-decision:{record_id}:source:{index}",
+            f"参与判断的数据 {index}",
+            source,
+            source_kind="database",
+            source_label="script_build_data_decision.sources",
+            source_ref=detail_ref,
+            field_path=f"sources[{index - 1}]",
+            relation="explicit-basis",
+        )
+        for index, source in enumerate(sources, 1)
+    ]
+    outputs = [
+        field(f"data-decision:{record_id}:decision", "明确取舍", row.get("decision"), source_kind="database", source_label="script_build_data_decision", source_ref=detail_ref, field_path="decision", relation="persisted-output"),
+        field(f"data-decision:{record_id}:reasoning", "取舍理由", row.get("reasoning"), source_kind="database", source_label="script_build_data_decision", source_ref=detail_ref, field_path="reasoning", relation="persisted-output"),
+    ]
+    return payload(
+        "data-decision",
+        "business-record",
+        inputs=inputs,
+        units=[event_unit(item) for item in events],
+        outputs=outputs,
+        runtime_summary="这是独立落库的业务取舍记录;不伪造一次不存在的 Agent I/O。",
+        notices=event_notices(events) + ([] if events else [{"level": "info", "message": "未找到能与该取舍记录一对一安全关联的运行事件。"}]),
+        completeness="complete" if row.get("decision") and sources else "partial",
+        business_modules=[
+            {"id": "sources", "title": "参与判断的数据", "sourceIds": [item["id"] for item in inputs]},
+            {"id": "tradeoff", "title": "采用 / 组合 / 舍弃", "sourceIds": [outputs[0]["id"]]},
+            {"id": "decision", "title": "明确取舍", "sourceIds": [outputs[0]["id"]]},
+            {"id": "reason", "title": "理由", "sourceIds": [outputs[1]["id"]]},
+            {"id": "source-completeness", "title": "来源完整性", "sourceIds": [item["id"] for item in inputs]},
+        ],
+        card_fields=[
+            {"key": "conclusion", "label": "取舍结论", "sourceIds": [outputs[0]["id"]], "transform": "直接使用业务记录"},
+            {"key": "sourceCount", "label": "来源数", "sourceIds": [item["id"] for item in inputs], "transform": "明确来源数量"},
+            {"key": "reason", "label": "核心理由", "sourceIds": [outputs[1]["id"]], "transform": "使用结构化理由"},
+        ],
+    )
+
+
+def project_creative(
+    detail_ref: str,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    node = find_detail(view, detail_ref)
+    if node is None:
+        raise KeyError(detail_ref)
+    context = decision_context(node)
+    seed_id = suffix_int(detail_ref)
+    seed = find_by_id(bundle.get("events") or [], seed_id)
+    round_index = integer((seed or {}).get("round_index"))
+    branch_id = integer((seed or {}).get("branch_id"))
+    refs = decision_event_refs(context)
+    if seed_id is not None:
+        refs.append(f"event:{seed_id}")
+    # Explicit round/branch/scope metadata is safe lineage.  It also captures
+    # script mutation calls omitted by the old think_and_plan-only refs.
+    implementer_ids = {
+        integer(item.get("id"))
+        for item in bundle.get("events") or []
+        if item.get("event_name") == "script_implementer"
+        and integer(item.get("round_index")) == round_index
+        and integer(item.get("branch_id")) == branch_id
+    }
+    for item in bundle.get("events") or []:
+        if integer(item.get("round_index")) != round_index or integer(item.get("branch_id")) != branch_id:
+            continue
+        if integer(item.get("scope_event_id")) in implementer_ids or integer(item.get("id")) in implementer_ids:
+            refs.append(f"event:{item.get('id')}")
+    events = load_events(load_event, refs)
+    raw_branch = find_raw_branch(bundle, branch_id or -1) or {}
+    candidate = raw_branch.get("candidate_snapshot") or {}
+    snapshot = candidate.get("snapshot") if isinstance(candidate, dict) else None
+    visible_inputs = []
+    for index, source in enumerate(context.get("evidence") or context.get("inputs") or [], 1):
+        if not isinstance(source, dict):
+            continue
+        observed = str(source.get("observedRelation") or "")
+        relation = "direct-input" if observed == "read-by-actor" else "previous-result" if observed == "returned-to-actor" else "available-upstream"
+        visible_inputs.append(field(f"creative:{seed_id}:input:{index}", str(source.get("label") or f"创作依据 {index}"), source.get("summary") or source.get("value"), source_kind="runtime-event" if source.get("detailRef") else "database", source_label="实现过程中可确认的数据", source_ref=source.get("detailRef"), field_path="output.content" if source.get("detailRef") else "", relation=relation))
+    explicit_basis = [
+        field(
+            f"creative:{seed_id}:explicit-basis:{index}",
+            str(item.get("label") or f"明确采用的数据取舍 {index}"),
+            {"decision": item.get("label"), "reasoning": item.get("value")},
+            source_kind="database",
+            source_label="script_build_data_decision",
+            source_ref=item.get("detailRef"),
+            field_path="",
+            relation="explicit-basis",
+        )
+        for index, item in enumerate(context.get("explicitBasis") or [], 1)
+        if isinstance(item, dict) and str(item.get("detailRef") or "").startswith("data-decision:")
+    ]
+    change_fields = [
+        field(
+            f"creative:{seed_id}:change:{index}",
+            str(item.get("label") or f"脚本改动 {index}"),
+            {"status": item.get("value"), "note": item.get("note")},
+            source_kind="runtime-event",
+            source_label="脚本修改运行事件",
+            source_ref=item.get("detailRef"),
+            field_path="",
+            relation="run-output",
+        )
+        for index, item in enumerate(context.get("runtimeActions") or [], 1)
+        if isinstance(item, dict) and str(item.get("detailRef") or "").startswith("event:")
+    ]
+    outputs = [
+        field(f"creative:{seed_id}:snapshot", "产出的候选表", snapshot, source_kind="artifact", source_label="候选分支快照", source_ref=f"artifact:branch:{branch_id}" if branch_id else None, field_path="candidate_snapshot.snapshot", relation="persisted-output", completeness="complete" if snapshot else "missing"),
+        field(f"creative:{seed_id}:assessment", "实现者自评", raw_branch.get("self_assessment"), source_kind="database", source_label="script_build_branch", source_ref=f"round:{round_index}:branch:{branch_id}:output", field_path="self_assessment", relation="persisted-output"),
+    ]
+    notices = event_notices(events)
+    if visible_inputs:
+        notices.append({"level": "info", "message": "可确认是上游信息,但无法确认是否被本次决策采用。"})
+    if not events:
+        notices.append({"level": "warning", "message": "候选产出存在,但未保存可安全关联的完整创作过程。"})
+    card_kind = "creative" if events else "candidate-output-fallback"
+    return payload(
+        card_kind,
+        "event-sequence" if events else "business-record",
+        inputs=[*visible_inputs, *explicit_basis],
+        units=[event_unit(item) for item in events],
+        outputs=[*outputs, *change_fields],
+        runtime_summary=(f"按明确的轮次、分支和实现 Agent scope 串起 {len(events)} 个创作/修改事件。" if events else "只能展示候选快照和实现者自评。"),
+        notices=notices,
+        completeness="complete" if events and snapshot else "partial" if snapshot or raw_branch.get("self_assessment") else "missing",
+        business_modules=[
+            {"id": "process", "title": "创作处理", "sourceIds": []},
+            {"id": "basis", "title": "明确采用的数据取舍", "sourceIds": [item["id"] for item in explicit_basis]},
+            {"id": "available", "title": "可确认的上游信息", "sourceIds": [item["id"] for item in visible_inputs]},
+            {"id": "changes", "title": "实际脚本改动", "sourceIds": [item["id"] for item in change_fields]},
+            {"id": "candidate", "title": "产出的候选表", "sourceIds": [outputs[0]["id"]]},
+            {"id": "uncertainty", "title": "存疑项 / 完整性", "sourceIds": [outputs[1]["id"]]},
+        ],
+    )
+
+
+def project_multipath_decision(
+    detail_ref: str,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    record_id = suffix_int(detail_ref)
+    row = find_by_id(bundle.get("multipathDecisions") or [], record_id)
+    if row is None:
+        raise KeyError(detail_ref)
+    node = find_detail(view, detail_ref)
+    context = decision_context(node)
+    events = load_events(load_event, decision_event_refs(context))
+    branch_ids = row.get("branch_ids") if isinstance(row.get("branch_ids"), list) else []
+    inputs = [field(f"multipath:{record_id}:branch:{branch_id}", f"候选方案 {branch_id}", find_raw_branch(bundle, integer(branch_id) or -1), source_kind="database", source_label="script_build_branch", source_ref=f"round:{row.get('round_index')}:branch:{branch_id}", field_path="", relation="available-upstream") for branch_id in branch_ids]
+    review_events = [
+        item for item in bundle.get("events") or []
+        if item.get("event_name") == "script_multipath_evaluator"
+        and integer(item.get("round_index")) == integer(row.get("round_index"))
+    ]
+    attached_ids = {integer(item.get("id")) for item in events}
+    unscoped_reviews = [item for item in review_events if integer(item.get("id")) not in attached_ids]
+    for item in unscoped_reviews:
+        inputs.append(field(f"multipath:{record_id}:review:{item.get('id')}", "同轮多方案评审", item.get("agentOutputData") or item.get("output_preview"), source_kind="runtime-event", source_label="同轮评审事件(候选范围未安全关联)", source_ref=f"event:{item.get('id')}", field_path="output", relation="available-upstream", completeness="partial"))
+    outputs = [
+        field(f"multipath:{record_id}:decision", "最终决定", row.get("decision"), source_kind="database", source_label="script_build_multipath_decision", source_ref=detail_ref, field_path="decision", relation="persisted-output"),
+        field(f"multipath:{record_id}:reasoning", "决策理由", row.get("reasoning"), source_kind="database", source_label="script_build_multipath_decision", source_ref=detail_ref, field_path="reasoning", relation="persisted-output"),
+    ]
+    notices = event_notices(events)
+    notices.append({"level": "info", "message": "候选分支可确认是本次取舍对象;除非有明确评审关联,不宣称其内容已被采用。"})
+    if unscoped_reviews:
+        notices.append({"level": "warning", "message": "发现同轮评审,但无法安全关联到具体候选。"})
+    return payload(
+        "multipath-decision",
+        "business-record",
+        inputs=inputs,
+        units=[event_unit(item) for item in events],
+        outputs=outputs,
+        runtime_summary="主 Agent 多路决策以业务表为最终事实,评审 Event 只在有可靠关联时作为明确依据。",
+        notices=notices,
+        completeness="complete" if row.get("decision") and branch_ids else "partial",
+        business_modules=[
+            {"id": "scope", "title": "取舍对象", "sourceIds": [item["id"] for item in inputs if ":branch:" in item["id"]]},
+            {"id": "comparison", "title": "评审建议与最终决定", "sourceIds": [item["id"] for item in inputs if ":review:" in item["id"]] + [outputs[0]["id"]]},
+            {"id": "outcomes", "title": "逐方案处置", "sourceIds": [item["id"] for item in inputs if ":branch:" in item["id"]]},
+            {"id": "reason", "title": "决策理由", "sourceIds": [outputs[1]["id"]]},
+            {"id": "strength", "title": "关联强度", "sourceIds": [item["id"] for item in inputs]},
+        ],
+    )
+
+
+def project_branch_output(
+    detail_ref: str,
+    round_index: int,
+    branch_id: int,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+) -> dict[str, Any]:
+    raw = find_raw_branch(bundle, branch_id)
+    branch = find_branch(view, round_index, branch_id)
+    if raw is None or branch is None:
+        raise KeyError(detail_ref)
+    output = branch.get("output") or {}
+    if output.get("type") == "domain-info":
+        facts = [
+            item for item in bundle.get("domainInfo") or []
+            if integer(item.get("branch_id")) == branch_id
+        ]
+        outputs = [field(f"branch:{branch_id}:facts", "已核实领域事实", facts, source_kind="database", source_label="script_build_domain_info", source_ref=detail_ref, field_path="domainInfo", relation="persisted-output")]
+        return payload(
+            "domain-facts",
+            "aggregate",
+            outputs=outputs,
+            runtime_summary="按 branch_id 聚合该领域信息方案写入的事实。",
+            calculation="只按显式 branch_id 关联,不使用文本相似度。",
+            # A successful branch-scoped lookup returning zero rows is a
+            # complete empty result, not a missing source record.
+            completeness="complete",
+            business_modules=[
+                {"id": "facts", "title": "已核实事实", "sourceIds": [outputs[0]["id"]]},
+                {"id": "verification", "title": "核实说明", "sourceIds": [outputs[0]["id"]]},
+                {"id": "sources", "title": "出处", "sourceIds": [outputs[0]["id"]]},
+                {"id": "usage", "title": "用途与存疑状态", "sourceIds": [outputs[0]["id"]]},
+            ],
+        )
+    candidate = raw.get("candidate_snapshot") or {}
+    snapshot = candidate.get("snapshot") if isinstance(candidate, dict) else None
+    outputs = [
+        field(f"branch:{branch_id}:candidate", "形成了什么", snapshot, source_kind="artifact", source_label="候选分支快照", source_ref=f"artifact:branch:{branch_id}", field_path="candidate_snapshot.snapshot", relation="persisted-output", completeness="complete" if snapshot else "missing"),
+        field(f"branch:{branch_id}:assessment", "实现者自评", raw.get("self_assessment"), source_kind="database", source_label="script_build_branch", source_ref=detail_ref, field_path="self_assessment", relation="persisted-output"),
+    ]
+    return payload(
+        "candidate-output-fallback",
+        "business-record",
+        outputs=outputs,
+        runtime_summary="候选产出回退详情只展示候选快照和自评,不伪造未保存的创作过程。",
+        notices=[{"level": "warning", "message": "未保存可安全关联的完整创作过程。"}],
+        completeness="partial" if snapshot or raw.get("self_assessment") else "missing",
+        business_modules=[
+            {"id": "output", "title": "形成了什么", "sourceIds": [outputs[0]["id"]]},
+            {"id": "assessment", "title": "实现者自评", "sourceIds": [outputs[1]["id"]]},
+            {"id": "missing", "title": "创作过程缺失说明", "sourceIds": [item["id"] for item in outputs]},
+        ],
+    )
+
+
+def project_domain_info(detail_ref: str, bundle: dict[str, Any]) -> dict[str, Any]:
+    record_id = suffix_int(detail_ref)
+    row = find_by_id(bundle.get("domainInfo") or [], record_id)
+    if row is None:
+        raise KeyError(detail_ref)
+    outputs = [
+        field(f"domain:{record_id}:fact", "已核实事实", row.get("content"), source_kind="database", source_label="script_build_domain_info", source_ref=detail_ref, field_path="content", relation="persisted-output"),
+        field(f"domain:{record_id}:note", "核实说明", row.get("note"), source_kind="database", source_label="script_build_domain_info", source_ref=detail_ref, field_path="note", relation="persisted-output"),
+        field(f"domain:{record_id}:source", "信息出处", row.get("source"), source_kind="database", source_label="script_build_domain_info", source_ref=detail_ref, field_path="source", relation="persisted-output"),
+    ]
+    return payload(
+        "domain-fact",
+        "business-record",
+        outputs=outputs,
+        runtime_summary="这是独立落库的已核实领域事实。",
+        completeness="complete" if row.get("content") and row.get("source") else "partial",
+        business_modules=[
+            {"id": "fact", "title": "已核实事实", "sourceIds": [outputs[0]["id"]]},
+            {"id": "verification", "title": "核实说明", "sourceIds": [outputs[1]["id"]]},
+            {"id": "source", "title": "出处", "sourceIds": [outputs[2]["id"]]},
+            {"id": "usage", "title": "用途与存疑状态", "sourceIds": [item["id"] for item in outputs]},
+        ],
+    )
+
+
+def _agent_task(event: dict[str, Any]) -> Any:
+    content = event_content(event.get("input"))
+    return content.get("task") if isinstance(content, dict) else content

+ 221 - 0
visualization-old/backend/app/card_details/planning.py

@@ -0,0 +1,221 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .common import (
+    EventLoader,
+    context_input_fields,
+    decision_context,
+    decision_event_refs,
+    event_notices,
+    event_unit,
+    find_raw_round,
+    find_round,
+    load_events,
+)
+from .schema import field, payload
+
+
+def project_objective(
+    bundle: dict[str, Any], view: dict[str, Any], load_event: EventLoader
+) -> dict[str, Any]:
+    record = bundle.get("record") or {}
+    node = view.get("objective") or {}
+    context = decision_context(node)
+    events = load_events(load_event, decision_event_refs(context))
+    inputs = context_input_fields(context, "objective", events)
+    output = field(
+        "objective:current-value",
+        "完整创作方向",
+        record.get("script_direction"),
+        source_kind="database",
+        source_label="script_build_record",
+        source_ref="run:objective",
+        field_path="script_direction",
+        relation="persisted-output",
+    )
+    outputs = [output]
+    if context.get("decisionItems"):
+        outputs.append(field(
+            "objective:goals",
+            "创作目标",
+            context.get("decisionItems"),
+            source_kind="calculation",
+            source_label="创作方向结构化解析",
+            source_ref="run:objective",
+            field_path="decision.decisionItems",
+            relation="persisted-output",
+        ))
+    if context.get("constraints"):
+        outputs.append(field(
+            "objective:dimensions",
+            "评估维度与约束",
+            context.get("constraints"),
+            source_kind="calculation",
+            source_label="创作方向结构化解析",
+            source_ref="run:objective",
+            field_path="decision.constraints",
+            relation="persisted-output",
+        ))
+    notices = event_notices(events)
+    if inputs and any(item["relation"] == "available-upstream" for item in inputs):
+        notices.append({"level": "info", "message": "可确认是上游信息,但无法确认是否被本次决策采用。"})
+    return payload(
+        "objective",
+        "business-record",
+        inputs=inputs,
+        units=[event_unit(item) for item in events],
+        outputs=outputs,
+        runtime_summary=(f"找到 {len(events)} 个保存/修订事件。" if events else "只能确认当前保存的业务值,未找到完整形成过程。"),
+        notices=notices,
+        completeness=context.get("completeness") if context.get("completeness") in {"complete", "partial", "missing"} else None,
+        business_modules=[
+            {"id": "direction", "title": "完整创作方向原文", "sourceIds": [output["id"]]},
+            {"id": "inputs", "title": "形成前读取", "sourceIds": [item["id"] for item in inputs]},
+            {"id": "structure", "title": "目标与评估维度", "sourceIds": [item["id"] for item in outputs[1:]]},
+            {"id": "revisions", "title": "修订记录", "sourceIds": [output["id"]]},
+        ],
+        card_fields=[
+            {"key": "headline", "label": "核心目标", "sourceIds": [output["id"]], "transform": "使用结构化解析的 headline"},
+            {"key": "targetCount", "label": "目标数", "sourceIds": ["objective:goals"], "transform": "列表计数"},
+            {"key": "dimensionCount", "label": "评估维度数", "sourceIds": ["objective:dimensions"], "transform": "列表计数"},
+        ],
+    )
+
+
+def project_round_goal(
+    detail_ref: str,
+    round_index: int,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    raw = find_raw_round(bundle, round_index) or {}
+    round_ = find_round(view, round_index) or {}
+    node = round_.get("goal") or {}
+    context = decision_context(node)
+    refs = decision_event_refs(context)
+    refs.extend(
+        f"event:{item.get('id')}"
+        for item in bundle.get("events") or []
+        if item.get("event_name") == "begin_round"
+    )
+    events = load_events(load_event, refs)
+    inputs = context_input_fields(context, f"round:{round_index}:goal", events)
+    begin_round_fields = [
+        field(
+            f"round:{round_index}:goal:begin:{event.get('id')}",
+            "进入本轮时提交的目标",
+            ((event.get("input") or {}).get("content") or {}).get("goal")
+            if isinstance((event.get("input") or {}).get("content"), dict)
+            else (event.get("input") or {}).get("content"),
+            source_kind="runtime-event",
+            source_label="begin_round 运行事件",
+            source_ref=f"event:{event.get('id')}",
+            field_path="input.content.goal",
+            relation="run-output",
+        )
+        for event in events
+        if event.get("event_name") == "begin_round"
+        and (
+            (
+                isinstance((event.get("output") or {}).get("content"), dict)
+                and int(((event.get("output") or {}).get("content") or {}).get("round_index") or 0) == round_index
+            )
+            or (
+                isinstance((event.get("input") or {}).get("content"), dict)
+                and ((event.get("input") or {}).get("content") or {}).get("goal") == raw.get("goal")
+            )
+        )
+    ]
+    outputs = [field(
+        f"round:{round_index}:goal:value",
+        "Goal 原文",
+        raw.get("goal"),
+        source_kind="database",
+        source_label="script_build_round",
+        source_ref=detail_ref,
+        field_path="goal",
+        relation="persisted-output",
+    )]
+    return payload(
+        "round-goal",
+        "business-record",
+        inputs=[*inputs, *begin_round_fields],
+        units=[event_unit(item) for item in events],
+        outputs=outputs,
+        runtime_summary=(f"通过 {len(events)} 个 begin_round/读取事件形成本轮目标。" if events else "本轮目标有业务记录,但形成过程未完整保存。"),
+        notices=event_notices(events) + ([{"level": "info", "message": "可确认是上游信息,但无法确认是否被本次决策采用。"}] if any(item["relation"] == "available-upstream" for item in inputs) else []),
+        completeness=context.get("completeness") if context.get("completeness") in {"complete", "partial", "missing"} else None,
+        business_modules=[
+            {"id": "goal", "title": "Goal 原文", "sourceIds": [outputs[0]["id"]]},
+            {"id": "why", "title": "进入本轮时提交的目标", "sourceIds": [item["id"] for item in begin_round_fields]},
+            {"id": "dependencies", "title": "本轮形成前可见的上游", "sourceIds": [item["id"] for item in inputs]},
+        ],
+        card_fields=[
+            {"key": "headline", "label": "本轮目标", "sourceIds": [outputs[0]["id"]], "transform": "使用结构化目标首项"},
+            {"key": "basis", "label": "形成前依据", "sourceIds": [item["id"] for item in inputs[:1]], "transform": "取第一个明确上游依据"},
+        ],
+    )
+
+
+def project_implementation_plan(
+    detail_ref: str,
+    round_index: int,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    raw = find_raw_round(bundle, round_index) or {}
+    round_ = find_round(view, round_index) or {}
+    node = ((round_.get("plan") or {}).get("node") or {})
+    context = decision_context(node)
+    refs = decision_event_refs(context)
+    refs.extend(
+        f"event:{item.get('id')}"
+        for item in bundle.get("events") or []
+        if item.get("event_name") == "record_multipath_plan"
+        and int(item.get("round_index") or 0) == round_index
+    )
+    events = load_events(load_event, refs)
+    inputs = context_input_fields(context, f"round:{round_index}:plan", events)
+    outputs = [
+        field(f"round:{round_index}:plan:mode", "规划方式", raw.get("race_or_divide"), source_kind="database", source_label="script_build_round", source_ref=detail_ref, field_path="race_or_divide", relation="persisted-output"),
+        field(f"round:{round_index}:plan:routes", "实现路线", raw.get("multipath_plan") or [], source_kind="database", source_label="script_build_round", source_ref=detail_ref, field_path="multipath_plan", relation="persisted-output"),
+        field(f"round:{round_index}:plan:reason", "规划理由", raw.get("plan_note"), source_kind="database", source_label="script_build_round", source_ref=detail_ref, field_path="plan_note", relation="persisted-output"),
+    ]
+    revision_fields = [
+        field(
+            f"round:{round_index}:plan:revision:{event.get('id')}",
+            f"规划保存 Event {event.get('id')}",
+            (event.get("input") or {}).get("content"),
+            source_kind="runtime-event",
+            source_label="record_multipath_plan 运行事件",
+            source_ref=f"event:{event.get('id')}",
+            field_path="input.content",
+            relation="run-output",
+        )
+        for event in events
+        if event.get("event_name") == "record_multipath_plan"
+    ]
+    return payload(
+        "implementation-plan",
+        "business-record",
+        inputs=inputs,
+        units=[event_unit(item) for item in events],
+        outputs=[*outputs, *revision_fields],
+        runtime_summary=(f"找到 {len(events)} 个规划保存/修订事件。" if events else "展示数据库当前保存的实现规划。"),
+        notices=event_notices(events),
+        completeness=context.get("completeness") if context.get("completeness") in {"complete", "partial", "missing"} else None,
+        business_modules=[
+            {"id": "mode", "title": "规划模式与理由", "sourceIds": [outputs[0]["id"], outputs[2]["id"]]},
+            {"id": "routes", "title": "逐条实现路线", "sourceIds": [outputs[1]["id"]]},
+            {"id": "basis", "title": "规划依据", "sourceIds": [item["id"] for item in inputs]},
+            {"id": "revisions", "title": "规划保存与修订记录", "sourceIds": [item["id"] for item in revision_fields]},
+        ],
+        card_fields=[
+            {"key": "mode", "label": "模式", "sourceIds": [outputs[0]["id"]], "transform": "规范化为单路/赛马/分工"},
+            {"key": "routeCount", "label": "路线数", "sourceIds": [outputs[1]["id"]], "transform": "列表计数"},
+            {"key": "routeScope", "label": "路线作用范围", "sourceIds": [outputs[1]["id"]], "transform": "取每路结构化 target"},
+        ],
+    )

+ 130 - 0
visualization-old/backend/app/card_details/projector.py

@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import re
+from collections.abc import Callable
+from typing import Any
+
+from .implementation import (
+    project_branch_output,
+    project_creative,
+    project_data_decision,
+    project_domain_info,
+    project_implementation_task,
+    project_multipath_decision,
+)
+from .planning import (
+    project_implementation_plan,
+    project_objective,
+    project_round_goal,
+)
+from .results import project_final_result, project_round_result
+from .summaries import (
+    project_branch_summary,
+    project_missing_convergence,
+    project_missing_round_evaluation,
+    project_round_summary,
+)
+from .retrieval import (
+    project_direct_group,
+    project_event,
+    project_retrieval_agent,
+    project_retrieval_stage,
+)
+
+
+class CardDataNotFound(LookupError):
+    pass
+
+
+class CardBusinessDataProjector:
+    """Read-only application projector for one card's complete data flow.
+
+    The HTTP adapter supplies plain bundle/view dictionaries and a lazy event
+    loader.  The projector has no database or FastAPI dependency.
+    """
+
+    def project(
+        self,
+        detail_ref: str,
+        *,
+        bundle: dict[str, Any],
+        view: dict[str, Any],
+        load_event: Callable[[int], dict[str, Any]],
+    ) -> dict[str, Any]:
+        ref = str(detail_ref or "").strip()
+        try:
+            if ref == "run:objective":
+                return project_objective(bundle, view, load_event)
+            if ref in {"run:final-result", "artifact:base:current", "base:current"}:
+                return project_final_result("run:final-result", bundle, view)
+            if ref.startswith("event:"):
+                return project_event(ref, load_event)
+            if ref.startswith("retrieval-direct:"):
+                return project_direct_group(ref, view, load_event)
+            if ref.startswith("retrieval-agent:"):
+                return project_retrieval_agent(ref, view, load_event)
+            if ref.startswith("data-decision:"):
+                return project_data_decision(ref, bundle, view, load_event)
+            if ref.startswith("creative:"):
+                return project_creative(ref, bundle, view, load_event)
+            if ref.startswith("multipath-decision:"):
+                return project_multipath_decision(ref, bundle, view, load_event)
+            if ref.startswith("domain-info:"):
+                return project_domain_info(ref, bundle)
+
+            match = re.fullmatch(r"round:(\d+)", ref)
+            if match:
+                return project_round_summary(
+                    ref, int(match.group(1)), bundle, view, load_event
+                )
+
+            match = re.fullmatch(r"round:(\d+):branch:(\d+)", ref)
+            if match:
+                return project_branch_summary(
+                    ref,
+                    int(match.group(1)),
+                    int(match.group(2)),
+                    bundle,
+                    view,
+                    load_event,
+                )
+
+            match = re.fullmatch(r"round:(\d+):(goal|plan|result)", ref)
+            if match:
+                round_index = int(match.group(1))
+                role = match.group(2)
+                if role == "goal":
+                    return project_round_goal(ref, round_index, bundle, view, load_event)
+                if role == "plan":
+                    return project_implementation_plan(ref, round_index, bundle, view, load_event)
+                return project_round_result(ref, round_index, bundle, view, load_event)
+
+            match = re.fullmatch(r"round:(\d+):evaluation", ref)
+            if match:
+                return project_missing_round_evaluation(
+                    ref, int(match.group(1)), bundle, view
+                )
+
+            match = re.fullmatch(
+                r"round:(\d+):[^:]+:(review|decision)-missing", ref
+            )
+            if match:
+                return project_missing_convergence(
+                    ref,
+                    int(match.group(1)),
+                    match.group(2),
+                    view,
+                )
+
+            match = re.fullmatch(r"round:(\d+):branch:(\d+):(task|retrieval|output)", ref)
+            if match:
+                round_index, branch_id = int(match.group(1)), int(match.group(2))
+                role = match.group(3)
+                if role == "task":
+                    return project_implementation_task(ref, round_index, branch_id, bundle, view, load_event)
+                if role == "retrieval":
+                    return project_retrieval_stage(ref, round_index, branch_id, view, load_event)
+                return project_branch_output(ref, round_index, branch_id, bundle, view)
+        except KeyError as exc:
+            raise CardDataNotFound(f"未找到卡片数据 {ref}") from exc
+        raise CardDataNotFound(f"未找到卡片数据 {ref}")

+ 124 - 0
visualization-old/backend/app/card_details/results.py

@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+from collections import Counter
+from typing import Any
+
+from .common import (
+    EventLoader,
+    decision_context,
+    decision_event_refs,
+    event_notices,
+    event_unit,
+    find_round,
+    load_events,
+)
+from .schema import field, payload
+
+
+def project_round_result(
+    detail_ref: str,
+    round_index: int,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    round_ = find_round(view, round_index)
+    if round_ is None:
+        raise KeyError(detail_ref)
+    branches = round_.get("branches") or []
+    result = round_.get("result") or {}
+    evaluation = round_.get("overallEvaluation") or {}
+    events = load_events(load_event, decision_event_refs(decision_context(evaluation)))
+    domain_rows = [
+        item for item in bundle.get("domainInfo") or []
+        if int(item.get("round_index") or 0) == round_index
+    ]
+    statuses = Counter(str(item.get("status") or "unknown") for item in branches)
+    inputs = [
+        field(f"round:{round_index}:result:goal", "本轮目标", ((round_.get("goal") or {}).get("card") or {}).get("primary", {}).get("value"), source_kind="database", source_label="script_build_round", source_ref=f"round:{round_index}:goal", field_path="goal", relation="standing-constraint"),
+        field(f"round:{round_index}:result:review", "整体评审", evaluation, source_kind="runtime-event", source_label="整体评审运行事件", source_ref=evaluation.get("detailRef"), relation="explicit-basis", completeness="complete" if evaluation.get("detailRef") else "partial"),
+    ]
+    outputs = [
+        field(f"round:{round_index}:result:summary", "本轮目标完成情况", (result.get("card") or {}).get("primary", {}).get("value"), source_kind="calculation", source_label="本轮业务记录聚合", source_ref=detail_ref, field_path="result.card.primary", relation="persisted-output"),
+        field(f"round:{round_index}:result:dispositions", "方案处置", dict(statuses), source_kind="calculation", source_label="分支状态统计", source_ref=detail_ref, field_path="branches.status", relation="persisted-output"),
+        field(f"round:{round_index}:result:domain", "领域事实", domain_rows, source_kind="calculation", source_label="按 round_index 筛选 script_build_domain_info", source_ref=detail_ref, field_path="", relation="persisted-output", completeness="complete"),
+        field(f"round:{round_index}:result:changes", "主脚本变化", [item.get("output") for item in branches if item.get("output")], source_kind="calculation", source_label="候选处置与当前产物聚合", source_ref=f"artifact:round:{round_index}", field_path="branches[].output", relation="persisted-output"),
+        field(f"round:{round_index}:result:next", "下一步", next((item.get("value") for item in (result.get("card") or {}).get("secondary") or [] if item.get("key") == "next"), None), source_kind="calculation", source_label="本轮状态与下一轮记录", source_ref=detail_ref, field_path="result.card.secondary.next", relation="persisted-output"),
+    ]
+    same_round_reviews = [
+        item for item in bundle.get("events") or []
+        if item.get("event_name") == "script_evaluator"
+        and int(item.get("round_index") or 0) == round_index
+    ]
+    notices = event_notices(events)
+    if not events and same_round_reviews:
+        notices.append({"level": "warning", "message": "发现同轮评审,但无法安全关联到具体候选。"})
+    return payload(
+        "round-result",
+        "calculated",
+        inputs=inputs,
+        units=[event_unit(item) for item in events],
+        outputs=outputs,
+        runtime_summary="本轮产出由候选分支处置、领域事实、整体评审和下一轮状态计算聚合。",
+        calculation="按 round_index 聚合 Branch、DomainInfo、Evaluation 和下一轮记录;不伪造独立 Agent I/O。",
+        notices=notices,
+        completeness="complete" if outputs[0]["value"] else "partial",
+        business_modules=[
+            {"id": "completion", "title": "本轮目标完成情况", "sourceIds": [inputs[0]["id"], outputs[0]["id"]]},
+            {"id": "dispositions", "title": "方案处置", "sourceIds": [outputs[1]["id"]]},
+            {"id": "domain", "title": "领域事实", "sourceIds": [outputs[2]["id"]]},
+            {"id": "review", "title": "整体评审", "sourceIds": [inputs[1]["id"]]},
+            {"id": "changes", "title": "主脚本变化", "sourceIds": [outputs[3]["id"]]},
+            {"id": "next", "title": "下一步", "sourceIds": [outputs[4]["id"]]},
+        ],
+        card_fields=[
+            {"key": "status", "label": "状态", "sourceIds": [outputs[0]["id"]], "transform": "使用轮次业务状态"},
+            {"key": "accepted", "label": "采用数", "sourceIds": [outputs[1]["id"]], "transform": "merged 计数"},
+            {"key": "next", "label": "下一步", "sourceIds": [outputs[4]["id"]], "transform": "直接使用结构化下一步"},
+        ],
+    )
+
+
+def project_final_result(
+    detail_ref: str, bundle: dict[str, Any], view: dict[str, Any]
+) -> dict[str, Any]:
+    record = bundle.get("record") or {}
+    final = view.get("finalResult") or {}
+    artifact = bundle.get("currentArtifact") or {}
+    paragraphs = artifact.get("paragraphs") or []
+    elements = artifact.get("elements") or []
+    links = artifact.get("paragraphElements") or artifact.get("paragraph_elements") or []
+    branches = bundle.get("branches") or []
+    status_counts = Counter(str(item.get("status") or "unknown") for item in branches)
+    outputs = [
+        field("final:status", "构建结论", record.get("status"), source_kind="database", source_label="script_build_record", source_ref=detail_ref, field_path="status", relation="persisted-output"),
+        field("final:summary", "摘要", record.get("summary") or record.get("error_message"), source_kind="database", source_label="script_build_record", source_ref=detail_ref, field_path="summary" if record.get("summary") else "error_message", relation="persisted-output"),
+        field("final:rounds", "轮次与方案统计", {"roundCount": len(bundle.get("rounds") or []), "branchCounts": dict(status_counts)}, source_kind="calculation", source_label="Round / Branch 记录计数", source_ref=detail_ref, field_path="rounds / branches", relation="persisted-output"),
+        field("final:artifact-scale", "最终脚本规模", {"paragraphs": len(paragraphs), "elements": len(elements), "links": len(links)}, source_kind="artifact", source_label="当前主脚本", source_ref="artifact:base:current", field_path="recordCounts", relation="persisted-output"),
+        field("final:artifact", "主脚本入口", "artifact:base:current", source_kind="artifact", source_label="当前主脚本", source_ref="artifact:base:current", field_path="snapshotRef", relation="persisted-output"),
+        field("final:version", "版本说明", "当前保存的最终主脚本,不是历史轮次快照。", source_kind="calculation", source_label="产物精度规则", source_ref="artifact:base:current", field_path="historicalVersion", relation="persisted-output"),
+    ]
+    return payload(
+        "final-result",
+        "calculated",
+        inputs=[],
+        units=[],
+        outputs=outputs,
+        runtime_summary="最终结果是 Run 记录、全部轮次/分支和当前主脚本的计算聚合。",
+        calculation="直接读取 Run 状态与摘要,对 Round / Branch / Artifact 做确定性计数。",
+        notices=[] if artifact else [{"level": "warning", "message": "未找到当前主脚本产物。"}],
+        completeness="complete" if record and artifact else "partial" if record else "missing",
+        business_modules=[
+            {"id": "conclusion", "title": "构建结论", "sourceIds": [outputs[0]["id"]]},
+            {"id": "summary", "title": "摘要或失败原因", "sourceIds": [outputs[1]["id"]]},
+            {"id": "stats", "title": "轮次与方案统计", "sourceIds": [outputs[2]["id"]]},
+            {"id": "scale", "title": "最终脚本规模", "sourceIds": [outputs[3]["id"]]},
+            {"id": "artifact", "title": "主脚本入口", "sourceIds": [outputs[4]["id"]]},
+            {"id": "version", "title": "版本说明", "sourceIds": [outputs[5]["id"]]},
+        ],
+        card_fields=[
+            {"key": "status", "label": "状态", "sourceIds": [outputs[0]["id"]], "transform": "直接使用 Run 状态"},
+            {"key": "summary", "label": "摘要", "sourceIds": [outputs[1]["id"]], "transform": "优先 summary,失败时使用 error_message"},
+            {"key": "scriptScale", "label": "脚本规模", "sourceIds": [outputs[3]["id"]], "transform": "段落/元素/关联计数"},
+        ],
+    )

+ 288 - 0
visualization-old/backend/app/card_details/retrieval.py

@@ -0,0 +1,288 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .common import (
+    EventLoader,
+    event_completeness,
+    event_content,
+    event_notices,
+    event_unit,
+    find_branch,
+    find_detail,
+    integer,
+    load_events,
+    suffix_int,
+)
+from .schema import field, payload
+
+
+def project_event(detail_ref: str, load_event: EventLoader) -> dict[str, Any]:
+    event_id = suffix_int(detail_ref)
+    if event_id is None:
+        raise KeyError(detail_ref)
+    event = load_event(event_id)
+    event_name = str(event.get("event_name") or "")
+    input_value = event_content(event.get("input"))
+    output_value = event_content(event.get("output"))
+    inputs = [field(
+        f"event:{event_id}:input",
+        "运行输入",
+        input_value,
+        source_kind="runtime-event",
+        source_label="Event Body 输入",
+        source_ref=detail_ref,
+        field_path="input.content",
+        relation="direct-input",
+        completeness="complete" if input_value not in (None, "", [], {}) else "missing",
+    )]
+    outputs = [field(
+        f"event:{event_id}:output",
+        "运行输出",
+        output_value,
+        source_kind="runtime-event",
+        source_label="Event Body 输出",
+        source_ref=detail_ref,
+        field_path="output.content",
+        relation="persisted-output",
+        completeness="complete" if output_value not in (None, "", [], {}) else "missing",
+    )]
+    card_kind = _event_card_kind(event)
+    modules = _event_modules(event)
+    return payload(
+        card_kind,
+        "single-event",
+        inputs=inputs,
+        units=[event_unit(event)],
+        outputs=outputs,
+        runtime_summary=f"{event_name or '运行事件'} 是一次可独立下钻的运行 I/O。",
+        notices=event_notices([event]),
+        completeness=event_completeness(event),  # type: ignore[arg-type]
+        business_modules=modules(inputs[0]["id"], outputs[0]["id"]),
+    )
+
+
+def project_retrieval_stage(
+    detail_ref: str,
+    round_index: int,
+    branch_id: int,
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    branch = find_branch(view, round_index, branch_id)
+    if branch is None:
+        raise KeyError(detail_ref)
+    stage = branch.get("retrievalStage") or {}
+    refs: list[Any] = [stage.get("implementerEventId")]
+    for group in stage.get("directToolGroups") or []:
+        refs.extend(item.get("eventId") for item in group.get("calls") or [])
+    for run in stage.get("agentRuns") or []:
+        refs.append(run.get("eventId"))
+        refs.extend(item.get("eventId") for item in run.get("attempts") or [])
+    events = load_events(load_event, refs)
+    inputs = [field(
+        f"round:{round_index}:branch:{branch_id}:retrieval:purpose",
+        "取数目的",
+        ((branch.get("task") or {}).get("card") or {}).get("primary", {}).get("value"),
+        source_kind="database",
+        source_label="实现任务",
+        source_ref=f"round:{round_index}:branch:{branch_id}:task",
+        field_path="impl_task",
+        relation="standing-constraint",
+    )]
+    outputs = [
+        field(f"round:{round_index}:branch:{branch_id}:retrieval:mode", "执行方式", stage.get("observedMode") or "unknown", source_kind="calculation", source_label="按执行时间和依赖关系确定", source_ref=detail_ref, field_path="observedMode", relation="persisted-output", completeness="complete"),
+        field(f"round:{round_index}:branch:{branch_id}:retrieval:waves", "执行波次", stage.get("waves") or [], source_kind="calculation", source_label="按明确 Event 时间聚合", source_ref=detail_ref, field_path="waves", relation="persisted-output", completeness="complete"),
+        field(f"round:{round_index}:branch:{branch_id}:retrieval:groups", "工具取数", stage.get("directToolGroups") or [], source_kind="calculation", source_label="按明确 Event scope 分组", source_ref=detail_ref, field_path="directToolGroups", relation="persisted-output", completeness="complete"),
+        field(f"round:{round_index}:branch:{branch_id}:retrieval:agents", "Agent 取数", stage.get("agentRuns") or [], source_kind="calculation", source_label="按 parent_event_id 聚合", source_ref=detail_ref, field_path="agentRuns", relation="persisted-output", completeness="complete"),
+        field(f"round:{round_index}:branch:{branch_id}:retrieval:result", "阶段结果与失败", {"status": stage.get("status"), "directToolGroups": stage.get("directToolGroups") or [], "agentRuns": stage.get("agentRuns") or []}, source_kind="calculation", source_label="取数阶段状态确定性聚合", source_ref=detail_ref, field_path="status", relation="persisted-output", completeness="complete"),
+    ]
+    return payload(
+        "retrieval-stage",
+        "aggregate",
+        inputs=inputs,
+        units=[event_unit(item) for item in events],
+        outputs=outputs,
+        runtime_summary=f"按实现 Agent 的明确 scope 聚合 {len(events)} 个运行事件,观测模式为 {stage.get('observedMode') or 'unknown'}。",
+        notices=event_notices(events),
+        completeness="complete" if events and stage.get("status") not in {"missing", "unknown"} else "partial" if events else "missing",
+        business_modules=[
+            {"id": "purpose", "title": "取数目的", "sourceIds": [inputs[0]["id"]]},
+            {"id": "mode", "title": "执行方式", "sourceIds": [outputs[0]["id"]]},
+            {"id": "waves", "title": "执行波次", "sourceIds": [outputs[1]["id"]]},
+            {"id": "direct", "title": "工具取数", "sourceIds": [outputs[2]["id"]]},
+            {"id": "agents", "title": "Agent 取数", "sourceIds": [outputs[3]["id"]]},
+            {"id": "result", "title": "阶段结果与失败", "sourceIds": [outputs[4]["id"]]},
+        ],
+    )
+
+
+def project_direct_group(
+    detail_ref: str, view: dict[str, Any], load_event: EventLoader
+) -> dict[str, Any]:
+    group = find_detail(view, detail_ref)
+    if group is None:
+        raise KeyError(detail_ref)
+    calls = group.get("calls") or []
+    events = load_events(load_event, [item.get("eventId") for item in calls])
+    inputs = [field(
+        f"{detail_ref}:purpose",
+        "读取目的",
+        [item.get("businessLabel") for item in calls if item.get("businessLabel")],
+        source_kind="calculation",
+        source_label="工具类型业务标签",
+        source_ref=detail_ref,
+        field_path="calls.businessLabel",
+        relation="direct-input",
+    )]
+    outputs = [field(
+        f"{detail_ref}:results",
+        "实际返回的信息",
+        [event_content(item.get("output")) for item in events],
+        source_kind="runtime-event",
+        source_label="逐次工具 Event Body",
+        source_ref=detail_ref,
+        field_path="events[].output.content",
+        relation="persisted-output",
+        completeness="complete" if events and all(event_content(item.get("output")) is not None for item in events) else "partial",
+    )]
+    return payload(
+        "retrieval-direct-group",
+        "event-sequence",
+        inputs=inputs,
+        units=[event_unit(item) for item in events],
+        outputs=outputs,
+        runtime_summary=f"该工具取数组包含 {len(events)} 次直接读取。",
+        notices=event_notices(events),
+        completeness="complete" if events and len(events) == len(calls) else "partial" if events else "missing",
+        business_modules=[
+            {"id": "purpose", "title": "读取目的", "sourceIds": [inputs[0]["id"]]},
+            {"id": "calls", "title": "逐次工具调用", "sourceIds": [outputs[0]["id"]]},
+            {"id": "results", "title": "实际返回的信息", "sourceIds": [outputs[0]["id"]]},
+            {"id": "failures", "title": "失败与缺失", "sourceIds": [outputs[0]["id"]]},
+        ],
+    )
+
+
+def project_retrieval_agent(
+    detail_ref: str, view: dict[str, Any], load_event: EventLoader
+) -> dict[str, Any]:
+    run = find_detail(view, detail_ref)
+    if run is None:
+        raise KeyError(detail_ref)
+    agent_id = integer(run.get("eventId")) or suffix_int(detail_ref)
+    refs = [agent_id, *[item.get("eventId") for item in run.get("attempts") or []]]
+    events = load_events(load_event, refs)
+    agent = next((item for item in events if integer(item.get("id")) == agent_id), {})
+    queries = [item for item in events if integer(item.get("id")) != agent_id]
+    task = _agent_task(agent) or run.get("taskPreview")
+    screening = _agent_screening(agent) or (run.get("screening") or {}).get("preview")
+    inputs = [field(
+        f"{detail_ref}:task",
+        "完整取数任务",
+        task,
+        source_kind="runtime-event",
+        source_label="取数 Agent Event Body",
+        source_ref=f"event:{agent_id}",
+        field_path="input.content.task",
+        relation="direct-input",
+        completeness="complete" if _agent_task(agent) else "partial" if task else "missing",
+    )]
+    outputs = [
+        field(f"{detail_ref}:raw-results", "原始命中", [event_content(item.get("output")) for item in queries], source_kind="runtime-event", source_label="查询 Event Body", source_ref=detail_ref, field_path="queryEvents[].output.content", relation="persisted-output", completeness="complete" if queries and all(event_content(item.get("output")) is not None for item in queries) else "partial" if queries else "missing"),
+        field(f"{detail_ref}:screening", "初筛整理", screening, source_kind="runtime-event", source_label="取数 Agent Event Body", source_ref=f"event:{agent_id}", field_path="output.content", relation="persisted-output", completeness="complete" if _agent_screening(agent) else "partial" if screening else "missing"),
+        field(f"{detail_ref}:query-summary", "查询统计", run.get("querySummary"), source_kind="calculation", source_label="查询 Event 状态聚合", source_ref=detail_ref, field_path="querySummary", relation="persisted-output"),
+    ]
+    return payload(
+        "retrieval-agent",
+        "aggregate",
+        inputs=inputs,
+        units=[event_unit(item, label=(run.get("businessLabel") if integer(item.get("id")) == agent_id else None)) for item in events],
+        outputs=outputs,
+        runtime_summary=f"一个取数 Agent 和 {len(queries)} 次查询组成该运行过程。",
+        notices=event_notices(events),
+        completeness="complete" if _agent_task(agent) and _agent_screening(agent) else "partial" if events else "missing",
+        business_modules=[
+            {"id": "task", "title": "完整取数任务", "sourceIds": [inputs[0]["id"]]},
+            {"id": "queries", "title": "查询过程", "sourceIds": [outputs[2]["id"]]},
+            {"id": "raw", "title": "原始命中", "sourceIds": [outputs[0]["id"]]},
+            {"id": "screening", "title": "初筛整理", "sourceIds": [outputs[1]["id"]]},
+            {"id": "failures", "title": "失败与空结果", "sourceIds": [outputs[2]["id"]]},
+        ],
+        card_fields=[
+            {"key": "target", "label": "取数目标", "sourceIds": [inputs[0]["id"]], "transform": "使用完整任务的结构化目标"},
+            {"key": "queryStats", "label": "查询统计", "sourceIds": [outputs[2]["id"]], "transform": "命中/空/失败数量"},
+            {"key": "screening", "label": "初筛结论", "sourceIds": [outputs[1]["id"]], "transform": "使用 Agent 完整结果中的 summary"},
+        ],
+    )
+
+
+def _agent_task(event: dict[str, Any]) -> Any:
+    content = event_content(event.get("input"))
+    return content.get("task") if isinstance(content, dict) else content
+
+
+def _agent_screening(event: dict[str, Any]) -> Any:
+    content = event_content(event.get("output"))
+    return content.get("summary") if isinstance(content, dict) and content.get("summary") else content
+
+
+def _event_card_kind(event: dict[str, Any]) -> str:
+    name = str(event.get("event_name") or "")
+    if name == "think_and_plan" and str(event.get("agent_role") or "") == "main":
+        return "planning-analysis"
+    if name == "script_multipath_evaluator":
+        return "multipath-review"
+    if name == "script_evaluator":
+        return "overall-review"
+    if name == "script_implementer":
+        return "implementation-task"
+    if str(event.get("event_type") or "") == "tool_call":
+        return "query" if name.startswith(("search_", "query_", "get_")) else "tool-call"
+    return "runtime-event"
+
+
+def _event_modules(event: dict[str, Any]):
+    kind = _event_card_kind(event)
+    def modules(input_id: str, output_id: str) -> list[dict[str, Any]]:
+        if kind == "planning-analysis":
+            return [
+                {"id": "summary", "title": "规划概要", "sourceIds": [input_id]},
+                {"id": "thought", "title": "完整思考", "sourceIds": [input_id]},
+                {"id": "plan", "title": "执行计划", "sourceIds": [input_id]},
+                {"id": "action", "title": "下一步", "sourceIds": [input_id]},
+                {"id": "completeness", "title": "记录完整性", "sourceIds": [input_id, output_id]},
+            ]
+        if kind == "multipath-review":
+            return [
+                {"id": "scope", "title": "评审范围", "sourceIds": [input_id]},
+                {"id": "criteria", "title": "评审标准", "sourceIds": [input_id]},
+                {"id": "per-branch", "title": "逐方案评审", "sourceIds": [output_id]},
+                {"id": "comparison", "title": "方案对比", "sourceIds": [output_id]},
+                {"id": "recommendation", "title": "评审建议", "sourceIds": [output_id]},
+            ]
+        if kind == "overall-review":
+            return [
+                {"id": "subject", "title": "评审对象", "sourceIds": [input_id]},
+                {"id": "criteria", "title": "标准", "sourceIds": [input_id]},
+                {"id": "achieved", "title": "已达成", "sourceIds": [output_id]},
+                {"id": "issues", "title": "问题", "sourceIds": [output_id]},
+                {"id": "conclusion", "title": "总结论", "sourceIds": [output_id]},
+                {"id": "next", "title": "下一轮建议", "sourceIds": [output_id]},
+            ]
+        if kind == "implementation-task":
+            return [
+                {"id": "task", "title": "完整实现任务", "sourceIds": [input_id]},
+                {"id": "result", "title": "实现 Agent 返回", "sourceIds": [output_id]},
+            ]
+        if kind in {"query", "tool-call"}:
+            return [
+                {"id": "purpose", "title": "目的 / 查询条件", "sourceIds": [input_id]},
+                {"id": "result", "title": "返回内容 / 结果列表", "sourceIds": [output_id]},
+                {"id": "status", "title": "状态、耗时与完整性", "sourceIds": [input_id, output_id]},
+            ]
+        return [
+            {"id": "input", "title": "运行输入", "sourceIds": [input_id]},
+            {"id": "output", "title": "运行输出", "sourceIds": [output_id]},
+        ]
+    return modules

+ 148 - 0
visualization-old/backend/app/card_details/schema.py

@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from typing import Any, Literal, TypedDict
+
+
+RelationKind = Literal[
+    "single-event",
+    "event-sequence",
+    "business-record",
+    "aggregate",
+    "calculated",
+    "missing",
+]
+Completeness = Literal["complete", "partial", "missing"]
+
+
+class CardDataSource(TypedDict, total=False):
+    kind: Literal["database", "runtime-event", "artifact", "calculation", "log-anchor"]
+    label: str
+    ref: str
+    fieldPath: str
+
+
+class CardDataField(TypedDict):
+    id: str
+    label: str
+    value: Any
+    source: CardDataSource
+    relation: Literal[
+        "direct-input",
+        "previous-result",
+        "standing-constraint",
+        "explicit-basis",
+        "available-upstream",
+        "persisted-output",
+        "run-output",
+    ]
+    completeness: Completeness
+
+
+class RuntimeUnit(TypedDict, total=False):
+    id: str
+    label: str
+    status: str
+    summary: str
+    input: Any
+    output: Any
+    durationMs: int | None
+    eventRef: str
+    completeness: Completeness
+    startedAt: str | None
+    endedAt: str | None
+
+
+def field(
+    field_id: str,
+    label: str,
+    value: Any,
+    *,
+    source_kind: str,
+    source_label: str,
+    source_ref: str | None = None,
+    field_path: str | None = None,
+    relation: str,
+    completeness: Completeness | None = None,
+) -> CardDataField:
+    missing = value is None or value == "" or value == [] or value == {}
+    source: CardDataSource = {"kind": source_kind, "label": source_label}  # type: ignore[typeddict-item]
+    if source_ref:
+        source["ref"] = source_ref
+    if field_path:
+        source["fieldPath"] = field_path
+    return {
+        "id": field_id,
+        "label": label,
+        "value": value,
+        "source": source,
+        "relation": relation,  # type: ignore[typeddict-item]
+        "completeness": completeness or ("missing" if missing else "complete"),
+    }
+
+
+def payload(
+    card_kind: str,
+    relation_kind: RelationKind,
+    *,
+    inputs: list[CardDataField] | None = None,
+    units: list[RuntimeUnit] | None = None,
+    outputs: list[CardDataField] | None = None,
+    runtime_summary: str = "",
+    calculation: str | None = None,
+    notices: list[dict[str, str]] | None = None,
+    business_modules: list[dict[str, Any]] | None = None,
+    card_fields: list[dict[str, Any]] | None = None,
+    completeness: Completeness | None = None,
+) -> dict[str, Any]:
+    inputs = inputs or []
+    units = units or []
+    outputs = outputs or []
+    all_fields = [*inputs, *outputs]
+    available = [item for item in all_fields if item["completeness"] != "missing"]
+    if completeness is None:
+        if not available and not units:
+            completeness = "missing"
+        elif any(item["completeness"] != "complete" for item in all_fields) or any(
+            item.get("completeness") != "complete" for item in units
+        ):
+            completeness = "partial"
+        else:
+            completeness = "complete"
+    if business_modules is None:
+        business_modules = [
+            {"id": "business-inputs", "title": "业务输入", "sourceIds": [item["id"] for item in inputs]},
+            {"id": "runtime", "title": "运行过程 / I/O", "sourceIds": [item["id"] for item in all_fields]},
+            {"id": "business-outputs", "title": "业务输出", "sourceIds": [item["id"] for item in outputs]},
+        ]
+    if card_fields is None:
+        headline = next((item for item in outputs if item["completeness"] != "missing"), None)
+        if headline is None:
+            headline = next((item for item in inputs if item["completeness"] != "missing"), None)
+        card_fields = [] if headline is None else [{
+            "key": "headline",
+            "label": headline["label"],
+            "sourceIds": [headline["id"]],
+            "transform": "使用结构化业务字段;卡片仅做视觉截行",
+        }]
+    used = {
+        source_id
+        for item in [*business_modules, *card_fields]
+        for source_id in item.get("sourceIds", [])
+    }
+    runtime: dict[str, Any] = {"summary": runtime_summary, "units": units}
+    if calculation:
+        runtime["calculation"] = calculation
+    return {
+        "cardKind": card_kind,
+        "relationKind": relation_kind,
+        "completeness": completeness,
+        "businessInputs": inputs,
+        "runtime": runtime,
+        "businessOutputs": outputs,
+        "displayUse": {
+            "businessModules": business_modules,
+            "cardFields": card_fields,
+            "unusedSourceIds": [item["id"] for item in all_fields if item["id"] not in used],
+        },
+        "notices": notices or [],
+    }

+ 399 - 0
visualization-old/backend/app/card_details/summaries.py

@@ -0,0 +1,399 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .common import (
+    EventLoader,
+    event_notices,
+    event_unit,
+    find_branch,
+    find_raw_branch,
+    find_raw_round,
+    find_round,
+    integer,
+    load_events,
+)
+from .schema import field, payload
+
+
+def project_round_summary(
+    detail_ref: str,
+    round_index: int,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    round_ = find_round(view, round_index)
+    raw_round = find_raw_round(bundle, round_index)
+    if round_ is None or raw_round is None:
+        raise KeyError(detail_ref)
+
+    raw_branches = [
+        row
+        for row in bundle.get("branches") or []
+        if integer(row.get("round_index")) == round_index
+    ]
+    decisions = [
+        row
+        for row in bundle.get("multipathDecisions") or []
+        if integer(row.get("round_index")) == round_index
+    ]
+    evaluation = round_.get("overallEvaluation") or {}
+    evaluation_ref = str(evaluation.get("detailRef") or "")
+    evaluation_events = load_events(
+        load_event,
+        [evaluation_ref] if evaluation_ref.startswith("event:") else [],
+    )
+
+    goal = field(
+        f"round:{round_index}:summary:goal",
+        "本轮目标",
+        raw_round.get("goal"),
+        source_kind="database",
+        source_label="script_build_round",
+        source_ref=detail_ref,
+        field_path="goal",
+        relation="standing-constraint",
+    )
+    plan = field(
+        f"round:{round_index}:summary:plan",
+        "当前保存的规划",
+        {
+            "mode": raw_round.get("race_or_divide"),
+            "paths": raw_round.get("multipath_plan"),
+            "note": raw_round.get("plan_note"),
+        },
+        source_kind="database",
+        source_label="script_build_round",
+        source_ref=detail_ref,
+        field_path="multipath_plan",
+        relation="persisted-output",
+    )
+    branch_fields = [
+        field(
+            f"round:{round_index}:summary:branch:{row.get('branch_id')}",
+            f"候选方案 {row.get('branch_id')}",
+            row,
+            source_kind="database",
+            source_label="script_build_branch",
+            source_ref=f"round:{round_index}:branch:{row.get('branch_id')}",
+            relation="persisted-output",
+        )
+        for row in raw_branches
+    ]
+    decision_fields = [
+        field(
+            f"round:{round_index}:summary:decision:{row.get('id')}",
+            f"主 Agent 决策 {row.get('id')}",
+            row,
+            source_kind="database",
+            source_label="script_build_multipath_decision",
+            source_ref=f"multipath-decision:{row.get('id')}",
+            relation="persisted-output",
+        )
+        for row in decisions
+    ]
+    convergence = field(
+        f"round:{round_index}:summary:convergence",
+        "候选评审与主 Agent 决策",
+        round_.get("convergenceBatches") or [],
+        source_kind="calculation",
+        source_label="按当前轮次的候选范围与明确关联记录汇总",
+        source_ref=detail_ref,
+        field_path="convergenceBatches",
+        relation="persisted-output",
+        completeness="complete",
+    )
+    evaluation_field = field(
+        f"round:{round_index}:summary:evaluation",
+        "主脚本整体评审",
+        evaluation_events[0] if evaluation_events else None,
+        source_kind="runtime-event",
+        source_label="主脚本整体评审运行事件",
+        source_ref=evaluation_ref if evaluation_ref.startswith("event:") else None,
+        relation="persisted-output",
+        completeness="complete" if evaluation_events else "missing",
+    )
+    result = field(
+        f"round:{round_index}:summary:result",
+        "本轮产出",
+        round_.get("result"),
+        source_kind="calculation",
+        source_label="本轮业务记录确定性聚合",
+        source_ref=detail_ref,
+        field_path="result",
+        relation="persisted-output",
+        completeness="complete" if round_.get("result") else "missing",
+    )
+    outputs = [
+        plan,
+        *branch_fields,
+        *decision_fields,
+        convergence,
+        evaluation_field,
+        result,
+    ]
+    notices = event_notices(evaluation_events)
+    if not evaluation_events:
+        notices.append(
+            {
+                "level": "warning",
+                "message": "本轮没有执行或保存主脚本整体评审运行事件。",
+            }
+        )
+    return payload(
+        "round-summary",
+        "aggregate",
+        inputs=[goal],
+        units=[event_unit(item) for item in evaluation_events],
+        outputs=outputs,
+        runtime_summary="按 round_index 聚合本轮目标、规划、候选分支、决策、整体评审和本轮产出。",
+        calculation="只聚合当前轮次的结构化业务记录;缺失的整体评审保持缺失。",
+        notices=notices,
+        completeness="partial" if not evaluation_events else "complete",
+        business_modules=[
+            {"id": "goal", "title": "本轮目标", "sourceIds": [goal["id"]]},
+            {"id": "plan", "title": "当前保存的规划", "sourceIds": [plan["id"]]},
+            {"id": "branches", "title": "候选方案", "sourceIds": [item["id"] for item in branch_fields]},
+            {
+                "id": "decisions",
+                "title": "候选评审与主 Agent 决策",
+                "sourceIds": [
+                    *[item["id"] for item in decision_fields],
+                    convergence["id"],
+                ],
+            },
+            {"id": "evaluation", "title": "主脚本整体评审", "sourceIds": [evaluation_field["id"]]},
+            {"id": "result", "title": "本轮产出", "sourceIds": [result["id"]]},
+        ],
+    )
+
+
+def project_branch_summary(
+    detail_ref: str,
+    round_index: int,
+    branch_id: int,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+    load_event: EventLoader,
+) -> dict[str, Any]:
+    branch = find_branch(view, round_index, branch_id)
+    raw_branch = find_raw_branch(bundle, branch_id)
+    if branch is None or raw_branch is None:
+        raise KeyError(detail_ref)
+
+    decisions = [
+        row
+        for row in bundle.get("dataDecisions") or []
+        if integer(row.get("branch_id")) == branch_id
+        and integer(row.get("round_index")) in {None, round_index}
+    ]
+    stage = branch.get("retrievalStage") or {}
+    retrieval_refs: list[Any] = []
+    for group in stage.get("directToolGroups") or []:
+        retrieval_refs.extend(
+            item.get("eventId") for item in group.get("calls") or []
+        )
+    for agent_run in stage.get("agentRuns") or []:
+        retrieval_refs.append(agent_run.get("eventId"))
+        retrieval_refs.extend(
+            item.get("eventId") for item in agent_run.get("attempts") or []
+        )
+    retrieval_events = load_events(load_event, retrieval_refs)
+    task = field(
+        f"branch:{branch_id}:summary:task",
+        "实现任务",
+        raw_branch.get("impl_task"),
+        source_kind="database",
+        source_label="script_build_branch",
+        source_ref=detail_ref,
+        field_path="impl_task",
+        relation="direct-input",
+    )
+    decision_fields = [
+        field(
+            f"branch:{branch_id}:summary:decision:{row.get('id')}",
+            f"数据取舍 {row.get('id')}",
+            row,
+            source_kind="database",
+            source_label="script_build_data_decision",
+            source_ref=f"data-decision:{row.get('id')}",
+            relation="persisted-output",
+        )
+        for row in decisions
+    ]
+    retrieval_fields = [
+        field(
+            f"branch:{branch_id}:summary:retrieval:{event.get('id')}",
+            str(event.get("event_name") or f"取数 Event {event.get('id')}"),
+            event,
+            source_kind="runtime-event",
+            source_label=str(event.get("event_name") or "取数运行事件"),
+            source_ref=f"event:{event.get('id')}",
+            field_path="",
+            relation="available-upstream",
+        )
+        for event in retrieval_events
+    ]
+    output = field(
+        f"branch:{branch_id}:summary:output",
+        "候选产出",
+        raw_branch,
+        source_kind="database",
+        source_label="script_build_branch",
+        source_ref=detail_ref,
+        relation="persisted-output",
+    )
+    return payload(
+        "branch-summary",
+        "aggregate",
+        inputs=[task],
+        units=[event_unit(item) for item in retrieval_events],
+        outputs=[*retrieval_fields, *decision_fields, output],
+        runtime_summary="按 branch_id 聚合实现任务、取数、数据取舍和候选产出。",
+        calculation="只使用当前方案的 Branch、明确 scope 取数 Event 与 DataDecision 记录。",
+        completeness="complete",
+        business_modules=[
+            {"id": "task", "title": "实现任务", "sourceIds": [task["id"]]},
+            {
+                "id": "data",
+                "title": "取数与数据取舍",
+                "sourceIds": [
+                    *[item["id"] for item in retrieval_fields],
+                    *[item["id"] for item in decision_fields],
+                ],
+            },
+            {"id": "output", "title": "候选产出", "sourceIds": [output["id"]]},
+        ],
+    )
+
+
+def project_missing_round_evaluation(
+    detail_ref: str,
+    round_index: int,
+    bundle: dict[str, Any],
+    view: dict[str, Any],
+) -> dict[str, Any]:
+    round_ = find_round(view, round_index)
+    raw_round = find_raw_round(bundle, round_index)
+    if round_ is None or raw_round is None:
+        raise KeyError(detail_ref)
+    evaluation = round_.get("overallEvaluation") or {}
+    if str(evaluation.get("detailRef") or "").startswith("event:"):
+        raise KeyError(detail_ref)
+    missing = field(
+        f"round:{round_index}:evaluation:missing",
+        "记录状态",
+        None,
+        source_kind="runtime-event",
+        source_label="script_evaluator Event",
+        source_ref=None,
+        relation="persisted-output",
+        completeness="missing",
+    )
+    return payload(
+        "evaluation-missing",
+        "missing",
+        outputs=[missing],
+        runtime_summary="本轮没有执行或保存主脚本整体评审运行事件。",
+        notices=[
+            {
+                "level": "warning",
+                "message": "本轮没有执行或保存主脚本整体评审运行事件。",
+            }
+        ],
+        completeness="missing",
+        business_modules=[
+            {"id": "primary", "title": "记录状态", "sourceIds": [missing["id"]]}
+        ],
+    )
+
+
+def project_missing_convergence(
+    detail_ref: str,
+    round_index: int,
+    record_type: str,
+    view: dict[str, Any],
+) -> dict[str, Any]:
+    round_ = find_round(view, round_index)
+    if round_ is None:
+        raise KeyError(detail_ref)
+    found_node: dict[str, Any] | None = None
+    found_batch: dict[str, Any] | None = None
+    key = "missingReview" if record_type == "review" else "missingDecision"
+    for batch in round_.get("convergenceBatches") or []:
+        node = batch.get(key)
+        if isinstance(node, dict) and detail_ref in {
+            node.get("id"),
+            node.get("detailRef"),
+        }:
+            found_node, found_batch = node, batch
+            break
+    if found_node is None or found_batch is None:
+        raise KeyError(detail_ref)
+
+    branch_ids = found_batch.get("branchIds") or []
+    unscoped_refs = found_node.get("unscopedReviewRefs") or []
+    status_value = ((found_node.get("card") or {}).get("primary") or {}).get("value")
+    if unscoped_refs:
+        source_kind = "calculation"
+        source_label = "同轮评审未安全关联到具体候选"
+        source_ref = detail_ref
+        value: Any = {
+            "roundIndex": round_index,
+            "branchIds": branch_ids,
+            "unscopedReviewRefs": unscoped_refs,
+        }
+        completeness = "partial"
+    else:
+        source_kind = "database"
+        source_label = (
+            "script_multipath_evaluator Event 查询结果"
+            if record_type == "review"
+            else "script_build_multipath_decision 查询结果"
+        )
+        source_ref = (
+            f"missing-convergence:{record_type}:round:{round_index}:"
+            + ",".join(str(item) for item in branch_ids)
+        )
+        value = []
+        completeness = "complete"
+    status = field(
+        f"{detail_ref}:status",
+        "记录状态",
+        status_value,
+        source_kind=source_kind,
+        source_label=source_label,
+        source_ref=source_ref,
+        relation="persisted-output",
+        completeness=completeness,
+    )
+    scope = field(
+        f"{detail_ref}:scope",
+        "实际查询范围",
+        {
+            "roundIndex": round_index,
+            "branchIds": branch_ids,
+            "resultCount": len(unscoped_refs),
+        },
+        source_kind="calculation",
+        source_label="可视化按当前轮次和候选范围确定",
+        source_ref=detail_ref,
+        field_path="value",
+        relation="persisted-output",
+    )
+    return payload(
+        f"multipath-{record_type}-missing",
+        "aggregate",
+        outputs=[status, scope],
+        runtime_summary=str(status_value or "未找到对应记录"),
+        notices=(
+            [{"level": "warning", "message": "存在同轮记录,但无法安全关联到具体候选。"}]
+            if unscoped_refs
+            else [{"level": "info", "message": "按当前轮次与候选范围查询完成,结果为空。"}]
+        ),
+        completeness=completeness,
+        business_modules=[
+            {"id": "status", "title": "记录状态", "sourceIds": [status["id"], scope["id"]]}
+        ],
+    )

+ 337 - 0
visualization-old/backend/app/creative_event_projection.py

@@ -0,0 +1,337 @@
+from __future__ import annotations
+
+import re
+from collections import defaultdict
+from typing import Any
+
+from .business_detail_text import branch_task_text, business_text
+from .decision_projection import AgentDecisionProjector
+from .runtime_event_index import RuntimeEventIndex, event_succeeded, integer
+from .runtime_tool_catalog import (
+    RETRIEVAL_AGENT_LABELS,
+    TOOL_CATALOG,
+    business_tool_label,
+    tools_in_category,
+)
+
+
+_CREATIVE_ACTION_TOOLS = tools_in_category("creative-action")
+_DECISION_INPUT_TOOLS = tools_in_category("decision-input")
+
+
+class CreativeEventProjector:
+    """Project only safely owned implementer ``think_and_plan`` records."""
+
+    def __init__(self, decisions: AgentDecisionProjector | None = None):
+        self._decisions = decisions or AgentDecisionProjector()
+
+    def project(
+        self,
+        index: RuntimeEventIndex,
+        *,
+        valid_rounds: set[int],
+        valid_branches: set[int],
+    ) -> dict[str, Any]:
+        implementers: dict[int, dict[str, Any]] = {}
+        for event in index.ordered:
+            if (
+                str(event.get("event_type") or "") == "agent_invoke"
+                and str(event.get("event_name") or "") == "script_implementer"
+                and (branch_id := integer(event.get("branch_id"))) in valid_branches
+            ):
+                implementers[branch_id] = event
+
+        events_by_branch: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        for event in index.ordered:
+            if (
+                str(event.get("event_type") or "") != "tool_call"
+                or str(event.get("event_name") or "") != "think_and_plan"
+                or not event_succeeded(event)
+            ):
+                continue
+            branch_id = integer(event.get("branch_id"))
+            round_index = integer(event.get("round_index"))
+            implementer = implementers.get(branch_id or -1)
+            if (
+                branch_id not in valid_branches
+                or round_index not in valid_rounds
+                or implementer is None
+                or integer(event.get("scope_event_id"))
+                not in {integer(implementer.get("id")), integer(implementer.get("scope_event_id"))}
+                or str(event.get("agent_role") or "") not in {"script_implementer", "implementer"}
+            ):
+                continue
+            events_by_branch[branch_id].append(event)
+
+        projected: dict[int, dict[str, Any]] = {}
+        for branch_id, records in events_by_branch.items():
+            implementer = implementers[branch_id]
+            latest = records[-1]
+            task = _task(implementer)
+            uncertainty = _uncertainties(records)
+            steps = _steps(records)
+            reasoning = _thought(latest)
+            runtime_actions = _runtime_actions(index, implementer)
+            available_upstream = _available_upstream(index, implementer)
+            decision = self._decisions.creative(
+                {
+                    "id": f"creative:{integer(latest.get('id')) or 0}",
+                    "detailRef": f"creative:{integer(latest.get('id')) or 0}",
+                    "promptRef": f"prompt:event:{integer(implementer.get('id')) or 0}",
+                    "safeLink": True,
+                    "event": latest,
+                    "task": task,
+                    "steps": steps,
+                    "reasoning": reasoning,
+                    "uncertainties": uncertainty,
+                    "inputs": available_upstream,
+                    "completeness": "partial",
+                    "notices": [
+                        {
+                            "code": "creative-record-partial",
+                            "message": "当前运行只保存了通用思考与计划,没有稳定的完整创作四元组。",
+                        }
+                    ],
+                }
+            )
+            if decision is None:
+                continue
+            decision["body"] = {
+                **(decision.get("body") or {}),
+                "availableUpstream": available_upstream,
+                "runtimeActions": runtime_actions,
+            }
+            blocks = list((decision.get("detail") or {}).get("blocks") or [])
+            insert_at = next(
+                (
+                    index_ + 1
+                    for index_, block in enumerate(blocks)
+                    if isinstance(block, dict)
+                    and block.get("type") == "creative-process"
+                ),
+                0,
+            )
+            additions = []
+            if available_upstream:
+                additions.append(
+                    {
+                        "type": "items",
+                        "title": "可确认的上游信息",
+                        "items": [
+                            {
+                                "label": item["label"],
+                                "value": item.get("summary"),
+                                "note": "可确认在上游存在或已返回,但无法确认是否被本次创作采用。",
+                                "detailRef": item.get("detailRef"),
+                            }
+                            for item in available_upstream
+                        ],
+                        "visualRole": "evidence",
+                        "presentation": "list",
+                        "collapsible": True,
+                    }
+                )
+            if runtime_actions:
+                additions.append(
+                    {
+                        "type": "items",
+                        "title": "实际脚本改动",
+                        "items": runtime_actions,
+                        "visualRole": "section",
+                        "presentation": "list",
+                        "collapsible": True,
+                    }
+                )
+            blocks[insert_at:insert_at] = additions
+            decision["detail"] = {
+                **(decision.get("detail") or {}),
+                "blocks": blocks,
+            }
+            ref_set = {
+                *(f"event:{integer(item.get('id')) or 0}" for item in records),
+                *(str(item.get("detailRef")) for item in available_upstream if item.get("detailRef")),
+                *(str(item.get("detailRef")) for item in runtime_actions if item.get("detailRef")),
+            }
+            decision["eventRefs"] = [
+                ref
+                for item in index.ordered
+                if (ref := f"event:{integer(item.get('id')) or 0}") in ref_set
+            ]
+            decision["technicalRefs"] = decision["eventRefs"]
+            projected[branch_id] = decision
+        return {"creativeByBranch": projected, "unassigned": []}
+
+
+def _task(event: dict[str, Any]) -> str | None:
+    value = event.get("inputData")
+    if isinstance(value, dict) and isinstance(value.get("task"), str):
+        text = value["task"].strip()
+        if match := re.search(r"(?:^|\s)目标\s*[::]\s*(.+)$", text, re.S):
+            text = match.group(1).strip()
+        return branch_task_text(text) or None
+    return None
+
+
+def _steps(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    values: list[dict[str, Any]] = []
+    for fallback_index, event in enumerate(events, start=1):
+        payload = event.get("inputData")
+        if not isinstance(payload, dict):
+            continue
+        summary = _creative_action_text(payload.get("thought_summary"))
+        action = _creative_action_text(payload.get("action")) or summary
+        plan = _creative_action_text(payload.get("plan"))
+        reasoning = _creative_action_text(payload.get("thought"))
+        if not any((action, plan, summary, reasoning)):
+            continue
+        values.append(
+            {
+                "stepIndex": integer(payload.get("thought_number")) or fallback_index,
+                "action": action or f"步骤 {fallback_index}",
+                "summary": summary,
+                "plan": plan,
+                "reasoning": reasoning,
+                "eventRef": f"event:{integer(event.get('id')) or 0}",
+            }
+        )
+    return values
+
+
+def _creative_action_text(value: Any) -> str | None:
+    if not isinstance(value, str) or not value.strip():
+        return None
+    text = value.strip()
+    exact = text.lower()
+    if exact in RETRIEVAL_AGENT_LABELS:
+        return f"委派{RETRIEVAL_AGENT_LABELS[exact]}取数"
+    if exact in TOOL_CATALOG:
+        definition = TOOL_CATALOG[exact]
+        if definition.category == "decision-input":
+            return f"读取{definition.business_label}"
+        return definition.business_label
+    if re.fullmatch(r"[a-z][a-z0-9_]{2,}", exact):
+        return None
+    replacements = {
+        **{
+            name: f"委派{label}取数"
+            for name, label in RETRIEVAL_AGENT_LABELS.items()
+        },
+        **{
+            name: (
+                f"读取{definition.business_label}"
+                if definition.category == "decision-input"
+                else definition.business_label
+            )
+            for name, definition in TOOL_CATALOG.items()
+        },
+    }
+    for name in sorted(replacements, key=len, reverse=True):
+        text = re.sub(
+            rf"(?<![A-Za-z0-9_]){re.escape(name)}(?![A-Za-z0-9_])",
+            replacements[name],
+            text,
+            flags=re.I,
+        )
+    text = business_text(text)
+    text = re.sub(r"调用\s*(读取|记录|新增|更新|补充|批量)", r"\1", text)
+    text = re.sub(r"委派\s*委派", "委派", text)
+    return text.strip() or None
+
+
+def _thought(event: dict[str, Any]) -> str | None:
+    payload = event.get("inputData")
+    if not isinstance(payload, dict):
+        return None
+    value = payload.get("thought_summary") or payload.get("thought")
+    return value.strip() if isinstance(value, str) and value.strip() else None
+
+
+def _uncertainties(events: list[dict[str, Any]]) -> list[str]:
+    values: list[str] = []
+    for event in events:
+        payload = event.get("inputData")
+        if not isinstance(payload, dict):
+            continue
+        text = "\n".join(
+            str(payload.get(key) or "") for key in ("thought", "thought_summary", "plan")
+        )
+        for sentence in re.split(r"[。!?\n]+", text):
+            if any(token in sentence for token in ("缺少直接证据", "未验证", "常识", "推导", "无法确认")):
+                cleaned = sentence.strip()
+                if cleaned and cleaned not in values:
+                    values.append(cleaned)
+    return values[:3]
+
+
+def _runtime_actions(
+    index: RuntimeEventIndex, implementer: dict[str, Any]
+) -> list[dict[str, Any]]:
+    scope_id = integer(implementer.get("id"))
+    if scope_id is None:
+        return []
+    values: list[dict[str, Any]] = []
+    for event in index.events_in_scope(scope_id):
+        name = str(event.get("event_name") or "")
+        if str(event.get("event_type") or "") != "tool_call" or name not in _CREATIVE_ACTION_TOOLS:
+            continue
+        event_id = integer(event.get("id")) or 0
+        status = str(event.get("status") or "unknown")
+        values.append(
+            {
+                "label": business_tool_label(name),
+                "value": "已完成" if event_succeeded(event) else "未完成",
+                "note": f"运行状态:{status}",
+                "detailRef": f"event:{event_id}",
+            }
+        )
+    return values
+
+
+def _available_upstream(
+    index: RuntimeEventIndex, implementer: dict[str, Any]
+) -> list[dict[str, Any]]:
+    implementer_id = integer(implementer.get("id"))
+    branch_id = integer(implementer.get("branch_id"))
+    round_index = integer(implementer.get("round_index"))
+    if implementer_id is None:
+        return []
+    values: list[dict[str, Any]] = []
+    for event in index.events_in_scope(implementer_id):
+        name = str(event.get("event_name") or "")
+        if str(event.get("event_type") or "") != "tool_call" or name not in _DECISION_INPUT_TOOLS:
+            continue
+        if not event_succeeded(event):
+            continue
+        values.append(
+            {
+                "label": business_tool_label(name),
+                "summary": "实现 Agent 在创作过程中直接读取",
+                "observedRelation": "read-by-actor",
+                "decisionUse": "not-recorded",
+                "detailRef": f"event:{integer(event.get('id')) or 0}",
+            }
+        )
+    for event in index.ordered:
+        name = str(event.get("event_name") or "")
+        if (
+            str(event.get("event_type") or "") != "agent_invoke"
+            or name not in RETRIEVAL_AGENT_LABELS
+            or integer(event.get("parent_event_id")) != implementer_id
+            or integer(event.get("branch_id")) != branch_id
+            or integer(event.get("round_index")) != round_index
+            or not event_succeeded(event)
+        ):
+            continue
+        values.append(
+            {
+                "label": RETRIEVAL_AGENT_LABELS[name],
+                "summary": "取数结果已返回实现 Agent",
+                "observedRelation": "returned-to-actor",
+                "decisionUse": "not-recorded",
+                "detailRef": f"event:{integer(event.get('id')) or 0}",
+            }
+        )
+    unique: dict[tuple[Any, Any], dict[str, Any]] = {}
+    for item in values:
+        unique[(item.get("label"), item.get("detailRef"))] = item
+    return list(unique.values())

+ 896 - 0
visualization-old/backend/app/decision_projection.py

@@ -0,0 +1,896 @@
+from __future__ import annotations
+
+import re
+from typing import Any, Iterable, Mapping
+
+from .business_detail_text import business_text
+
+
+_SUCCESS_STATUSES = {"ok", "success", "succeeded", "completed", "complete"}
+_DECISION_BASIS_TITLE = "根据以下信息做出的决策"
+_RELATIONS = {
+    "direct-read": "read-by-actor",
+    "read-by-actor": "read-by-actor",
+    "returned-report": "returned-to-actor",
+    "returned-to-actor": "returned-to-actor",
+    "persisted-record": "persisted-before-decision",
+    "persisted-before-decision": "persisted-before-decision",
+    "standing-constraint": "standing-constraint",
+}
+
+
+class AgentDecisionProjector:
+    """Create the V8 decision union from already-associated, plain records.
+
+    The projector deliberately does not scan event timelines or infer causal
+    links.  Callers must first establish ownership/association, then pass those
+    facts here.  In particular an observed read remains a read unless its input
+    explicitly records ``decisionUse=explicit-basis``.
+    """
+
+    def direction(self, payload: Mapping[str, Any]) -> dict[str, Any]:
+        subtype = _machine(payload.get("subtype")) or "direction"
+        actor = _actor(payload.get("actor"), default_role="main", default_label="主 Agent")
+        decision_items = _strings(
+            payload.get("decisionItems") or payload.get("decisions")
+        )
+        reasoning = _text(payload.get("reasoning") or payload.get("explicitReasoning"))
+        constraints = _strings(payload.get("constraints"))
+        inputs = _inputs(payload.get("inputs"))
+        primary = decision_items[0] if decision_items else _text(payload.get("decision"))
+        secondary = []
+        if inputs:
+            secondary.append(_card_line("inputs", _DECISION_BASIS_TITLE, _input_labels(inputs)))
+        if constraints:
+            secondary.append(_card_line("constraints", "关键约束", _join(constraints, 2)))
+        if reasoning:
+            secondary.append(_card_line("reasoning", "决策理由", reasoning))
+        body = {
+            "type": "direction",
+            "decisionItems": decision_items,
+            "reasoning": reasoning,
+            "constraints": constraints,
+        }
+        if subtype == "implementation-plan":
+            implementation_plan = _implementation_plan(payload.get("implementationPlan"), reasoning)
+            body["implementationPlan"] = implementation_plan
+            blocks = [
+                _implementation_plan_block(implementation_plan),
+                _items_block(_DECISION_BASIS_TITLE, [_input_business_item(item) for item in inputs], visual_role="evidence"),
+                _items_block("关键约束", constraints),
+            ]
+        elif subtype == "objective" and _source_text(payload.get("sourceDocument")):
+            source_document = _source_text(payload.get("sourceDocument"))
+            body["sourceDocument"] = source_document
+            blocks = [
+                _source_document_block(
+                    "当前保存的创作方向",
+                    source_document,
+                ),
+                _items_block(
+                    _DECISION_BASIS_TITLE,
+                    [_input_business_item(item) for item in inputs],
+                    visual_role="evidence",
+                ),
+            ]
+        elif subtype == "round-goal" and _source_text(payload.get("sourceDocument")):
+            source_document = _source_text(payload.get("sourceDocument"))
+            body["sourceDocument"] = source_document
+            blocks = [
+                _source_document_block(
+                    "本轮目标构成",
+                    source_document,
+                ),
+                _items_block(
+                    _DECISION_BASIS_TITLE,
+                    [_input_business_item(item) for item in inputs],
+                    visual_role="evidence",
+                ),
+                _summary_block("目标来源", reasoning, visual_role="reason"),
+            ]
+        else:
+            blocks = [
+                _decision_items_block("最终方向", decision_items or ([primary] if primary else []), visual_role="key-result"),
+                _items_block(_DECISION_BASIS_TITLE, [_input_business_item(item) for item in inputs], visual_role="evidence"),
+                _items_block("关键约束", constraints),
+                _summary_block("明确理由", reasoning, visual_role="reason"),
+            ]
+        return _decision(
+            payload,
+            decision_type="direction",
+            subtype=subtype,
+            actor=actor,
+            authority="final",
+            question=_text(payload.get("question")) or _direction_question(subtype),
+            inputs=inputs,
+            body=body,
+            primary_label=_text(payload.get("primaryLabel")) or "最终方向",
+            primary=primary,
+            secondary=secondary,
+            blocks=blocks,
+        )
+
+    def data_tradeoff(self, row: Mapping[str, Any]) -> dict[str, Any]:
+        inputs = _inputs(row.get("inputs") or _source_inputs(row.get("sources")))
+        decision_text = _text(row.get("decision"))
+        reasoning = _text(row.get("reasoning"))
+        scope_items = _strings(row.get("scopeItems"))
+        body = {
+            "type": "tradeoff",
+            "scopeItems": scope_items,
+            "decision": decision_text,
+            "selected": _strings(row.get("selected")),
+            "combined": _strings(row.get("combined")),
+            "parked": _strings(row.get("parked")),
+            "rejected": _strings(row.get("rejected")),
+            "reasoning": reasoning,
+        }
+        secondary = []
+        if inputs:
+            secondary.append(_card_line("scope", "参与判断", f"{len(inputs)} 类信息"))
+        if reasoning:
+            secondary.append(_card_line("reasoning", "理由", reasoning))
+        return _decision(
+            row,
+            decision_type="tradeoff",
+            subtype="data-tradeoff",
+            actor=_actor(
+                row.get("actor"),
+                default_role="implementer",
+                default_label="实现 Agent",
+            ),
+            authority="implementation-scope",
+            question=_text(row.get("question")) or "哪些信息用于当前实现方案?",
+            inputs=inputs,
+            body=body,
+            primary_label="取舍结论",
+            primary=decision_text,
+            secondary=secondary,
+            blocks=[
+                _items_block("取舍对象", scope_items),
+                _items_block("参与判断", [_input_business_item(item) for item in inputs], visual_role="evidence", collapsible=True),
+                _summary_block("明确取舍", decision_text, visual_role="key-result"),
+                _summary_block("理由", reasoning, visual_role="reason"),
+            ],
+        )
+
+    def multipath_tradeoff(self, row: Mapping[str, Any]) -> dict[str, Any]:
+        branch_ids = _unique_values(row.get("branchIds") or row.get("branch_ids"))
+        inputs = _inputs(row.get("inputs"))
+        decision_text = _text(row.get("decision"))
+        reasoning = _text(row.get("reasoning"))
+        selected = _strings(row.get("selected"))
+        rejected = _strings(row.get("rejected"))
+        body = {
+            "type": "tradeoff",
+            "scopeItems": _strings(row.get("scopeItems")),
+            "decision": decision_text,
+            "selected": selected,
+            "combined": _strings(row.get("combined")),
+            "parked": _strings(row.get("parked")),
+            "rejected": rejected,
+            "reasoning": reasoning,
+            # Identity is useful to flow wiring but is not rendered as business copy.
+            "branchIds": branch_ids,
+            "reviewComparison": row.get("reviewComparison"),
+        }
+        secondary = []
+        if branch_ids:
+            secondary.append(
+                _card_line("scope", "取舍范围", f"比较 {len(branch_ids)} 个候选方案")
+            )
+        if reasoning:
+            secondary.append(_card_line("reasoning", "决策理由", reasoning))
+        if row.get("reviewComparison"):
+            secondary.append(_card_line("review", "评审对照", "可在详情中查看"))
+        return _decision(
+            row,
+            decision_type="tradeoff",
+            subtype="multipath-tradeoff",
+            actor=_actor(row.get("actor"), default_role="main", default_label="主 Agent"),
+            authority="final",
+            question=_text(row.get("question")) or "多个候选方案最终如何处理?",
+            inputs=inputs,
+            body=body,
+            primary_label="最终决定",
+            primary=decision_text,
+            secondary=secondary,
+            blocks=[
+                _summary_block(
+                    "取舍对象",
+                    f"{len(branch_ids)} 个候选方案" if branch_ids else None,
+                ),
+                _items_block("参与判断", [_input_business_item(item) for item in inputs], visual_role="evidence", collapsible=True),
+                _summary_block("最终决定", decision_text, visual_role="key-result"),
+                _summary_block("决策理由", reasoning, visual_role="reason"),
+                _comparison_block(row.get("reviewComparison")),
+            ],
+        )
+
+    def evaluation(self, payload: Mapping[str, Any]) -> dict[str, Any]:
+        subtype = _machine(payload.get("subtype")) or "overall-evaluation"
+        is_multipath = subtype == "multipath-evaluation"
+        inputs = _inputs(payload.get("inputs"))
+        conclusion = _text(payload.get("conclusion"))
+        recommendation = _text(payload.get("recommendation"))
+        subjects = _strings(payload.get("subjects"))
+        criteria = _strings(payload.get("criteria"))
+        item_conclusions = _business_dicts(payload.get("itemConclusions"))
+        achievements = _strings(payload.get("achievements"))
+        problems = _business_dicts(payload.get("problems"))
+        branch_evaluations = _mapping_list(payload.get("branchEvaluations"))
+        comparison_rows = _mapping_list(payload.get("comparisonRows"))
+        body = {
+            "type": "evaluation",
+            "subjects": subjects,
+            "criteria": criteria,
+            "itemConclusions": item_conclusions,
+            "achievements": achievements,
+            "problems": problems,
+            "branchEvaluations": branch_evaluations,
+            "comparisonRows": comparison_rows,
+            "conclusion": conclusion,
+            "recommendation": recommendation,
+            "nextGoal": _text(payload.get("nextGoal")),
+        }
+        secondary = []
+        if achievements:
+            secondary.append(_card_line("achievement", "已达成", _join(achievements, 2)))
+        if problems:
+            secondary.append(
+                _card_line("problems", "需要关注", _problem_summary(problems), "warning")
+            )
+        if recommendation:
+            secondary.append(_card_line("recommendation", "评审建议", recommendation))
+        primary = conclusion or _item_conclusion_summary(item_conclusions) or recommendation
+        if is_multipath and branch_evaluations:
+            detail_blocks = [
+                _evaluation_branches_block(branch_evaluations),
+                _evaluation_matrix_block(comparison_rows) or _items_block("评审标准", criteria),
+                _summary_block("评审建议", recommendation),
+            ]
+        else:
+            detail_blocks = [
+                _items_block("评审对象", subjects),
+                _items_block("评审标准", criteria),
+                _items_block("逐项结论", item_conclusions),
+                _items_block("已达成", achievements),
+                _items_block("问题", problems),
+                _summary_block("总结论", conclusion, visual_role="key-result"),
+                _summary_block("评审建议", recommendation),
+            ]
+        return _decision(
+            payload,
+            decision_type="evaluation",
+            subtype=subtype,
+            actor=_actor(
+                payload.get("actor"),
+                default_role=("multipath-evaluator" if is_multipath else "overall-evaluator"),
+                default_label=("多方案评审 Agent" if is_multipath else "整体评审 Agent"),
+            ),
+            authority="recommendation",
+            question=_text(payload.get("question"))
+            or ("哪些候选方案更值得采用?" if is_multipath else "当前主脚本是否达到目标?"),
+            inputs=inputs,
+            body=body,
+            primary_label="评审结论",
+            primary=primary,
+            secondary=secondary,
+            blocks=detail_blocks,
+        )
+
+    def creative(self, payload: Mapping[str, Any]) -> dict[str, Any] | None:
+        """Project an implementer creative decision only from a safe event link.
+
+        An artifact reference may be attached to a real creative record, but it
+        is never sufficient to create that record.  This avoids inferring the
+        implementer's plan from the eventual candidate script.
+        """
+
+        if payload.get("safeLink") is not True:
+            return None
+        event = payload.get("event")
+        event = event if isinstance(event, Mapping) else payload
+        if not _is_successful_think_and_plan(event):
+            return None
+        raw_input = event.get("inputData") or event.get("input")
+        if not isinstance(raw_input, Mapping):
+            return None
+        thought = _text(
+            payload.get("reasoning")
+            or raw_input.get("thought")
+            or raw_input.get("thought_summary")
+        )
+        steps = _creative_steps(payload.get("steps"))
+        actions = _strings(payload.get("actions"))
+        if not steps:
+            fallback_action = _text(raw_input.get("action"))
+            fallback_plan = _source_text(raw_input.get("plan"))
+            if fallback_action or fallback_plan or thought:
+                steps = [{
+                    "stepIndex": 1,
+                    "action": fallback_action or "创作处理",
+                    "summary": _text(raw_input.get("thought_summary")),
+                    "plan": fallback_plan,
+                    "reasoning": _source_text(raw_input.get("thought")),
+                }]
+        if not actions:
+            actions = [str(step["action"]) for step in steps if step.get("action")]
+        if not any((thought, actions, steps)):
+            return None
+        task = _text(payload.get("task"))
+        explicit_basis = _strings(payload.get("basis"))
+        uncertainties = _strings(payload.get("uncertainties"))
+        body = {
+            "type": "creative",
+            "task": task,
+            "basis": explicit_basis,
+            "actions": actions,
+            "steps": steps,
+            "reasoning": thought,
+            "output": _text(payload.get("output")),
+            "uncertainties": uncertainties,
+        }
+        secondary = []
+        projected_payload = dict(payload)
+        projected_payload.setdefault("id", f"creative-event:{event.get('id')}")
+        return _decision(
+            projected_payload,
+            decision_type="creative",
+            subtype="creative-handling",
+            actor=_actor(
+                payload.get("actor"),
+                default_role="implementer",
+                default_label="实现 Agent",
+            ),
+            authority="implementation-scope",
+            question=_text(payload.get("question")) or "当前实现方案如何形成候选产出?",
+            inputs=_inputs(payload.get("inputs")),
+            body=body,
+            primary_label="创作处理",
+            primary=actions[0] if actions else thought,
+            secondary=secondary,
+            blocks=[
+                _items_block("明确依据", explicit_basis, visual_role="evidence", collapsible=True),
+                _creative_process_block(steps) or _items_block("创作处理", actions),
+                _summary_block("产出的候选表", body["output"], visual_role="key-result"),
+                _items_block("存疑项", uncertainties, visual_role="notice"),
+            ],
+        )
+
+
+def _decision(
+    payload: Mapping[str, Any],
+    *,
+    decision_type: str,
+    subtype: str,
+    actor: dict[str, str],
+    authority: str,
+    question: str,
+    inputs: list[dict[str, Any]],
+    body: dict[str, Any],
+    primary_label: str,
+    primary: str | None,
+    secondary: list[dict[str, Any]],
+    blocks: list[dict[str, Any] | None],
+) -> dict[str, Any]:
+    completeness = _completeness(payload.get("completeness"), primary)
+    notices = _notices(payload.get("notices"))
+    if not primary and not any(item.get("code") == "record-missing" for item in notices):
+        notices.append({"code": "record-missing", "message": "没有找到结构化决策结论。"})
+    decision_id = _machine(payload.get("id") or payload.get("detailRef")) or f"{subtype}:unknown"
+    detail_ref = _machine(payload.get("detailRef")) or decision_id
+    result = {
+        "id": decision_id,
+        "semanticKind": "decision",
+        "decisionType": decision_type,
+        "subtype": subtype,
+        "actor": actor,
+        "authority": authority,
+        "question": question,
+        "inputs": inputs,
+        "body": body,
+        "card": {
+            "primary": {
+                "label": primary_label,
+                "value": primary or "决策结论未记录",
+            },
+            "secondary": [item for item in secondary if item.get("value")][:3],
+        },
+        "detailRef": detail_ref,
+        "promptRef": _machine(payload.get("promptRef")),
+        "artifactRef": _machine(payload.get("artifactRef")),
+        "completeness": completeness,
+        "notices": notices,
+        "detail": {
+            "detailKind": "agent-decision",
+            "decisionType": decision_type,
+            "actor": actor,
+            "authority": authority,
+            "question": question,
+            "blocks": [block for block in blocks if block],
+        },
+    }
+    return result
+
+
+def _inputs(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, Iterable) or isinstance(value, (str, bytes, Mapping)):
+        return []
+    result: list[dict[str, Any]] = []
+    for raw in value:
+        if not isinstance(raw, Mapping):
+            continue
+        observed = _RELATIONS.get(
+            _text(raw.get("observedRelation") or raw.get("relation")) or "",
+            "persisted-before-decision",
+        )
+        explicit_use = _machine(raw.get("decisionUse"))
+        decision_use = explicit_use if explicit_use in {"explicit-basis", "not-recorded"} else "not-recorded"
+        if not (raw.get("observedRelation") or raw.get("relation")):
+            continue
+        label = _text(raw.get("label") or raw.get("name") or raw.get("type"))
+        if not label:
+            continue
+        result.append(
+            {
+                "label": label,
+                "summary": _text(raw.get("summary")),
+                "observedRelation": observed,
+                "decisionUse": decision_use,
+                "detailRef": _text(raw.get("detailRef")),
+            }
+        )
+    return result
+
+
+def _source_inputs(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, list):
+        return []
+    result = []
+    for index, item in enumerate(value, 1):
+        if isinstance(item, Mapping):
+            label = _text(
+                item.get("label")
+                or item.get("data_type")
+                or item.get("source_type")
+                or item.get("type")
+            )
+            summary = _text(
+                item.get("summary")
+                or item.get("data_content")
+                or item.get("title")
+                or item.get("name")
+            )
+            explicit_use = item.get("decisionUse")
+        else:
+            label, summary, explicit_use = _text(item), None, None
+        result.append(
+            {
+                "label": label or f"信息来源 {index}",
+                "summary": summary,
+                "observedRelation": "persisted-before-decision",
+                "decisionUse": explicit_use or "not-recorded",
+            }
+        )
+    return result
+
+
+def _actor(value: Any, *, default_role: str, default_label: str) -> dict[str, str]:
+    if isinstance(value, Mapping):
+        return {
+            "role": _machine(value.get("role")) or default_role,
+            "label": _text(value.get("label")) or default_label,
+        }
+    return {"role": default_role, "label": default_label}
+
+
+def _summary_block(
+    title: str,
+    value: Any,
+    *,
+    visual_role: str = "section",
+    presentation: str = "prose",
+) -> dict[str, Any] | None:
+    text = _text(value)
+    return {
+        "type": "summary",
+        "title": title,
+        "value": text,
+        "visualRole": visual_role,
+        "presentation": presentation,
+    } if text else None
+
+
+def _source_document_block(title: str, value: Any) -> dict[str, Any] | None:
+    text = _source_text(value)
+    return {
+        "type": "summary",
+        "title": title,
+        "value": text,
+        "visualRole": "key-result",
+        "presentation": "document",
+    } if text else None
+
+
+def _items_block(
+    title: str,
+    items: Any,
+    *,
+    visual_role: str = "section",
+    presentation: str = "list",
+    collapsible: bool = False,
+    default_open: bool = False,
+) -> dict[str, Any] | None:
+    if not isinstance(items, list) or not items:
+        return None
+    return {
+        "type": "items",
+        "title": title,
+        "items": items,
+        "visualRole": visual_role,
+        "presentation": presentation,
+        "collapsible": collapsible,
+        "defaultOpen": default_open,
+    }
+
+
+def _creative_process_block(steps: list[dict[str, Any]]) -> dict[str, Any] | None:
+    if not steps:
+        return None
+    return {
+        "type": "creative-process",
+        "title": "创作处理",
+        "steps": steps,
+        "visualRole": "section",
+        "presentation": "process",
+    }
+
+
+def _evaluation_branches_block(branches: list[dict[str, Any]]) -> dict[str, Any] | None:
+    if not branches:
+        return None
+    return {
+        "type": "evaluation-branches",
+        "title": "逐方案评审",
+        "branches": branches,
+        "visualRole": "section",
+        "presentation": "grouped-review",
+    }
+
+
+def _evaluation_matrix_block(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
+    if not rows:
+        return None
+    return {
+        "type": "evaluation-matrix",
+        "title": "评审标准与方案对比",
+        "rows": rows,
+        "visualRole": "section",
+        "presentation": "matrix",
+    }
+
+
+def _decision_items_block(
+    title: str,
+    items: list[str],
+    *,
+    visual_role: str = "section",
+) -> dict[str, Any] | None:
+    if not items:
+        return None
+    if len(items) == 1:
+        return _summary_block(title, items[0], visual_role=visual_role)
+    return _items_block(title, items, visual_role=visual_role)
+
+
+def _implementation_plan(value: Any, reasoning: str | None) -> dict[str, Any]:
+    plan = value if isinstance(value, Mapping) else {}
+    routes = plan.get("routes") if isinstance(plan.get("routes"), list) else []
+    return {
+        "mode": _text(plan.get("mode")) or _text(plan.get("rawMode")),
+        "reasoning": _text(plan.get("reasoning")) or reasoning,
+        "routes": [
+            {
+                "pathIndex": route.get("pathIndex") or index,
+                "pathType": _business_text(route.get("pathType")),
+                "action": _business_text(route.get("action")),
+                "target": _business_text(route.get("target")),
+                "method": _business_text(route.get("method")),
+                "emphasis": _business_text(route.get("emphasis")),
+            }
+            for index, route in enumerate(routes, 1)
+            if isinstance(route, Mapping)
+        ],
+    }
+
+
+def _implementation_plan_block(plan: Mapping[str, Any]) -> dict[str, Any] | None:
+    routes = plan.get("routes") if isinstance(plan.get("routes"), list) else []
+    if not routes and not plan.get("reasoning"):
+        return None
+    return {
+        "type": "implementation-plan",
+        "title": "本轮实施路线",
+        "visualRole": "section",
+        "presentation": "plan",
+        "mode": plan.get("mode"),
+        "routes": routes,
+        "reasoning": plan.get("reasoning"),
+    }
+
+
+def _item_conclusion_summary(items: list[dict[str, Any]]) -> str | None:
+    summaries = []
+    for item in items[:3]:
+        subject = _text(item.get("subject") or item.get("label"))
+        conclusion = _text(item.get("conclusion") or item.get("value"))
+        if subject and conclusion:
+            summaries.append(f"{subject}:{conclusion}")
+    if not summaries:
+        return None
+    suffix = f";另有 {len(items) - 3} 项" if len(items) > 3 else ""
+    return ";".join(summaries) + suffix
+
+
+def _comparison_block(value: Any) -> dict[str, Any] | None:
+    if not isinstance(value, list) or not value:
+        return None
+    return {
+        "type": "comparison",
+        "title": "评审建议与最终决定",
+        "visualRole": "section",
+        "presentation": "comparison",
+        "rows": _business_value(value),
+    }
+
+
+def _input_business_item(item: Mapping[str, Any]) -> dict[str, str]:
+    result = {"label": str(item.get("label") or "信息")}
+    summary = _business_text(item.get("summary"))
+    if summary:
+        result["value"] = summary
+    relation = {
+        "read-by-actor": "决策前直接读取",
+        "returned-to-actor": "评审返回给决策者",
+        "persisted-before-decision": "此前已保存",
+        "standing-constraint": "持续约束",
+    }.get(str(item.get("observedRelation") or ""))
+    use = (
+        "本轮必须遵守"
+        if item.get("observedRelation") == "standing-constraint"
+        else "明确作为决策依据"
+        if item.get("decisionUse") == "explicit-basis"
+        else "是否采用未记录"
+    )
+    result["note"] = ";".join(part for part in (relation, use) if part)
+    return result
+
+
+def _business_text(value: Any) -> str | None:
+    text = business_text(value)
+    return text or None
+
+
+def _card_line(key: str, label: str, value: Any, tone: str | None = None) -> dict[str, Any]:
+    line = {"key": key, "label": label, "value": _preview(value, 160)}
+    if tone:
+        line["tone"] = tone
+    return line
+
+
+def _notices(value: Any) -> list[dict[str, str]]:
+    if not isinstance(value, list):
+        return []
+    result = []
+    for item in value:
+        if isinstance(item, Mapping):
+            code = _machine(item.get("code"))
+            message = _text(item.get("message"))
+        else:
+            code, message = "notice", _text(item)
+        if code and message:
+            result.append({"code": code, "message": message})
+    return result
+
+
+def _business_dicts(value: Any) -> list[Any]:
+    if not isinstance(value, list):
+        return []
+    result: list[Any] = []
+    for item in value:
+        if isinstance(item, Mapping):
+            cleaned = {
+                key: _business_value(item[key])
+                for key in (
+                    "label",
+                    "subject",
+                    "conclusion",
+                    "summary",
+                    "severity",
+                    "recommendation",
+                    "achievements",
+                    "problems",
+                )
+                if item.get(key) not in (None, "")
+            }
+            if cleaned:
+                result.append(cleaned)
+        elif text := _text(item):
+            result.append(text)
+    return result
+
+
+def _mapping_list(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, list):
+        return []
+    return [
+        _business_value(dict(item))
+        for item in value
+        if isinstance(item, Mapping)
+    ]
+
+
+def _creative_steps(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, list):
+        return []
+    result: list[dict[str, Any]] = []
+    for fallback_index, item in enumerate(value, start=1):
+        if not isinstance(item, Mapping):
+            continue
+        action = _text(item.get("action"))
+        plan = _source_text(item.get("plan"))
+        summary = _text(item.get("summary"))
+        reasoning = _source_text(item.get("reasoning"))
+        if not any((action, plan, summary, reasoning)):
+            continue
+        result.append(
+            {
+                "stepIndex": int(item.get("stepIndex") or fallback_index),
+                "action": action or f"步骤 {fallback_index}",
+                "summary": summary,
+                "plan": plan,
+                "reasoning": reasoning,
+                "eventRef": _text(item.get("eventRef")),
+            }
+        )
+    return result
+
+
+def _problem_summary(problems: list[Any]) -> str:
+    values = []
+    for item in problems[:2]:
+        if isinstance(item, Mapping):
+            values.append(_text(item.get("summary") or item.get("label")) or "待处理问题")
+        else:
+            values.append(str(item))
+    return _join(values, 2)
+
+
+def _input_labels(inputs: list[dict[str, Any]]) -> str:
+    return _join([str(item["label"]) for item in inputs], 3)
+
+
+def _direction_question(subtype: str) -> str:
+    return {
+        "objective": "这次构建要形成什么创作方向?",
+        "round-goal": "本轮优先解决什么问题?",
+        "implementation-plan": "本轮应如何组织实现方案?",
+    }.get(subtype, "这一步确定了什么方向?")
+
+
+def _is_successful_think_and_plan(event: Mapping[str, Any]) -> bool:
+    return (
+        str(event.get("event_type") or "") == "tool_call"
+        and str(event.get("event_name") or "") == "think_and_plan"
+        and str(event.get("agent_role") or "")
+        in {"script_implementer", "implementer"}
+        and str(event.get("status") or "").lower() in _SUCCESS_STATUSES
+    )
+
+
+def _completeness(value: Any, primary: str | None) -> str:
+    explicit = _machine(value)
+    if explicit in {"complete", "partial", "missing"}:
+        return explicit
+    return "complete" if primary else "missing"
+
+
+def _strings(value: Any) -> list[str]:
+    if not isinstance(value, list):
+        return []
+    return [text for item in value if (text := _text(item))]
+
+
+def _unique_values(value: Any) -> list[Any]:
+    if not isinstance(value, list):
+        return []
+    result = []
+    for item in value:
+        if item not in result:
+            result.append(item)
+    return result
+
+
+def _join(values: list[str], limit: int) -> str:
+    kept = [value for value in values if value][:limit]
+    suffix = f" · 另有 {len(values) - limit} 项" if len(values) > limit else ""
+    return " · ".join(kept) + suffix
+
+
+def _preview(value: Any, limit: int) -> str:
+    text = _text(value) or ""
+    if len(text) <= limit:
+        return text
+    cut = text[:limit]
+    boundary = max(cut.rfind(mark) for mark in "。!?;")
+    if boundary >= max(24, limit // 2):
+        return cut[: boundary + 1]
+    return cut.rstrip() + "…"
+
+
+def _text(value: Any) -> str | None:
+    if value is None:
+        return None
+    if isinstance(value, (dict, list, tuple, set)):
+        return None
+    text = str(value).replace("\\n", "\n")
+    text = re.sub(r"[`*_#]+", "", text)
+    text = re.sub(r"\s+", " ", text).strip(" ::")
+    return _business_copy(text) or None
+
+
+def _source_text(value: Any) -> str | None:
+    """Keep authoritative saved Markdown intact for source-document rendering."""
+
+    if value is None or isinstance(value, (dict, list, tuple, set)):
+        return None
+    text = str(value).replace("\\n", "\n").strip()
+    return text or None
+
+
+def _business_value(value: Any) -> Any:
+    if isinstance(value, str):
+        return _business_copy(value)
+    if isinstance(value, list):
+        return [_business_value(item) for item in value]
+    if isinstance(value, Mapping):
+        return {key: _business_value(item) for key, item in value.items()}
+    return value
+
+
+def _business_copy(text: str) -> str:
+    """Translate machine-facing tokens without deleting ordinary numbers."""
+
+    replacements = (
+        (r"\bretrieve_data_decode_case\b|\bretrievedatadecodecase\b", "解构案例取数"),
+        (r"\bretrieve_data_knowledge\b|\bretrievedataknowledge\b", "知识取数"),
+        (r"\bget_account_script_persona_points\b|\bgetaccountscriptpersonapoints\b", "账号人设"),
+        (r"\bget_account_script_section_patterns\b", "账号段落模式"),
+        (r"\bget_topic_detail\b", "选题内容"),
+        (r"\bget_script_snapshot\b", "当前主脚本"),
+        (r"\brecord_data_decision\b", "记录数据取舍"),
+        (r"\bthink_and_plan\b", "实现思考与计划"),
+    )
+    for pattern, label in replacements:
+        text = re.sub(pattern, label, text, flags=re.IGNORECASE)
+    text = re.sub(
+        r"\bpost_?id\s*[:=]?(?:\s*[A-Za-z0-9-]+)?",
+        "账号内容样本",
+        text,
+        flags=re.IGNORECASE,
+    )
+    text = re.sub(r"\bscript_build_id\s*[:=]\s*\d+\b", "", text, flags=re.IGNORECASE)
+    text = re.sub(r"\bround_index\s*[:=]\s*(\d+)\b", r"第 \1 轮", text, flags=re.IGNORECASE)
+    text = re.sub(r"\bbranch[_\s-]*(\d+)\b", r"方案 \1", text, flags=re.IGNORECASE)
+    text = re.sub(r"\bP(\d+)\b", r"段落 \1", text)
+    text = re.sub(r"\bD(\d+)\b", r"评估维度 \1", text)
+    text = re.sub(r"\bbase\b", "主脚本", text, flags=re.IGNORECASE)
+    return re.sub(r"\s+", " ", text).strip(" ::")
+
+
+def _machine(value: Any) -> str | None:
+    if value is None or isinstance(value, (dict, list, tuple, set)):
+        return None
+    text = str(value).strip()
+    return text or None

+ 156 - 0
visualization-old/backend/app/evaluation_event_projection.py

@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+import re
+from collections import defaultdict
+from typing import Any
+
+from .evaluation_report_parser import EvaluationReportParser
+from .runtime_event_index import (
+    RuntimeEventIndex,
+    event_input,
+    event_output,
+    event_summary,
+    integer,
+)
+
+
+class EvaluationEventProjector:
+    """Project evaluator calls using event metadata for identity and one parser.
+
+    The report body never decides build/round association.  It only supplies
+    business-facing evaluation content after the event has been placed by its
+    structured metadata.
+    """
+
+    def __init__(self, parser: EvaluationReportParser | None = None):
+        self._parser = parser or EvaluationReportParser()
+
+    def project(
+        self,
+        index: RuntimeEventIndex,
+        *,
+        valid_rounds: set[int],
+        valid_branches: set[int],
+    ) -> dict[str, Any]:
+        multipath: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        overall: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        unassigned: list[dict[str, Any]] = []
+
+        for event in index.ordered:
+            if str(event.get("event_type") or "") != "agent_invoke":
+                continue
+            name = str(event.get("event_name") or "")
+            if name not in {"script_multipath_evaluator", "script_evaluator"}:
+                continue
+            round_index = integer(event.get("round_index"))
+            if round_index not in valid_rounds:
+                unassigned.append(event_summary(event, "missing-business-context"))
+                continue
+
+            report = _agent_summary(event) or str(event.get("output_preview") or "")
+            parsed = self._parser.parse(
+                report,
+                kind="multipath" if name == "script_multipath_evaluator" else "overall",
+            )
+            if name == "script_evaluator":
+                overall[round_index].append(
+                    {
+                        **event_summary(event, "structured-round"),
+                        **parsed,
+                        "id": f"overall-evaluation:{integer(event.get('id')) or 0}",
+                        "roundIndex": round_index,
+                        "detailRef": f"event:{integer(event.get('id')) or 0}",
+                        "promptRef": f"prompt:event:{integer(event.get('id')) or 0}",
+                    }
+                )
+                continue
+
+            task = _agent_task(event) or str(event.get("input_preview") or "")
+            branch_ids = [value for value in _branch_ids(task) if value in valid_branches]
+            review = {
+                **event_summary(event, "explicit-task-branch-ids"),
+                **parsed,
+                "id": f"multipath-review:{integer(event.get('id')) or 0}",
+                "roundIndex": round_index,
+                "branchIds": branch_ids,
+                "branchConclusions": _branch_conclusions(parsed, branch_ids),
+                "comparison": parsed.get("recommendation"),
+                "detailRef": f"event:{integer(event.get('id')) or 0}",
+                "promptRef": f"prompt:event:{integer(event.get('id')) or 0}",
+            }
+            if branch_ids:
+                multipath[round_index].append(review)
+            else:
+                unassigned.append({**review, "association": "missing-branch-scope"})
+
+        return {
+            "multipathReviewsByRound": dict(multipath),
+            "overallEvaluationsByRound": dict(overall),
+            "unassigned": unassigned,
+        }
+
+
+def _branch_ids(text: str) -> list[int]:
+    values: list[int] = []
+    source = text or ""
+
+    # Agent tasks commonly express one batch as ``branch_id = 4, 5, 6, 7``.
+    # Capture the complete labelled list instead of keeping only its first id.
+    list_pattern = (
+        r"\bbranch[_\s-]*ids?\s*[:=]\s*\[?\s*"
+        r"(\d+(?:\s*[,,、/]\s*\d+)*)\s*\]?"
+    )
+    for match in re.finditer(list_pattern, source, flags=re.IGNORECASE):
+        for raw_value in re.findall(r"\d+", match.group(1)):
+            value = int(raw_value)
+            if value not in values:
+                values.append(value)
+
+    # Keep supporting prose that names branches individually.
+    for match in re.finditer(r"(?:方案|分支)\s*(\d+)", source, flags=re.IGNORECASE):
+        value = int(match.group(1))
+        if value not in values:
+            values.append(value)
+    return values
+
+
+def _branch_conclusions(
+    parsed: dict[str, Any], branch_ids: list[int]
+) -> list[dict[str, Any]]:
+    by_subject = {
+        str(item.get("subject") or ""): item
+        for item in parsed.get("itemConclusions") or []
+        if isinstance(item, dict)
+    }
+    return [
+        {
+            "branchId": branch_id,
+            "conclusion": (by_subject.get(f"方案 {branch_id}") or {}).get("conclusion"),
+            "summary": _item_summary(by_subject.get(f"方案 {branch_id}")),
+        }
+        for branch_id in branch_ids
+    ]
+
+
+def _item_summary(item: Any) -> str | None:
+    if not isinstance(item, dict):
+        return None
+    values = [
+        *(str(value) for value in item.get("achievements") or [] if value),
+        *(str(value) for value in item.get("problems") or [] if value),
+    ]
+    return ";".join(values[:2]) or None
+
+
+def _agent_task(event: dict[str, Any]) -> str | None:
+    value = event_input(event)
+    if isinstance(value, dict) and isinstance(value.get("task"), str):
+        return value["task"]
+    return None
+
+
+def _agent_summary(event: dict[str, Any]) -> str | None:
+    value = event_output(event)
+    if isinstance(value, dict) and isinstance(value.get("summary"), str):
+        return value["summary"]
+    return None

+ 593 - 0
visualization-old/backend/app/evaluation_report_parser.py

@@ -0,0 +1,593 @@
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from typing import Any, Literal
+
+
+EvaluationKind = Literal["multipath", "overall", "unknown"]
+
+
+@dataclass(frozen=True)
+class _Section:
+    title: str
+    body: str
+
+
+class EvaluationReportParser:
+    """Parse evaluator Markdown into business-safe, deterministic fields.
+
+    The parser deliberately does not accept a build or round identifier. Runtime
+    metadata remains the only source for association; identifiers written inside
+    a report are treated as technical prose and ignored.
+    """
+
+    VERSION = 1
+
+    def parse(
+        self,
+        value: Any,
+        *,
+        kind: EvaluationKind = "unknown",
+    ) -> dict[str, Any]:
+        report = _report_text(value)
+        visible = _remove_code_fences(report)
+        sections = _sections(visible)
+        tables = _tables(visible)
+        branch_evaluations = _branch_evaluations(sections)
+        comparison_rows = _comparison_rows(tables)
+
+        subjects = _subjects(sections)
+        criteria = _collect_sections(
+            sections,
+            ("评审标准", "评估标准", "评价标准", "评审维度", "评估维度", "评价维度", "维度评估", "领域评估维度"),
+        )
+        achievements = _collect_positive_sections(
+            sections,
+            ("已达成", "达成情况", "目标达成", "目标推进", "完成情况", "通过项", "达成项", "主要优点"),
+        )
+        problems = _problem_sections(sections)
+        if not problems:
+            inline_problem = _labeled_value(
+                visible,
+                ("问题详情", "主要问题", "未通过项", "遗留问题", "存疑项", "问题"),
+            )
+            if inline_problem:
+                problems.append({"summary": inline_problem})
+        item_conclusions = _item_conclusions(sections)
+        if branch_evaluations:
+            item_conclusions = [
+                {
+                    "subject": item["subject"],
+                    "conclusion": item.get("conclusion"),
+                    "achievements": item.get("achievements") or [],
+                    "problems": item.get("problems") or [],
+                }
+                for item in branch_evaluations
+            ]
+
+        for table in tables:
+            _merge_table(
+                table,
+                criteria=criteria,
+                item_conclusions=item_conclusions,
+                achievements=achievements,
+                problems=problems,
+            )
+
+        problems = [
+            item
+            for item in problems
+            if not _means_no_problem(str(item.get("summary") or ""))
+        ]
+        for item in item_conclusions:
+            item["problems"] = [
+                problem
+                for problem in item.get("problems") or []
+                if not _means_no_problem(str(problem))
+            ]
+
+        conclusion = _labeled_value(
+            visible,
+            ("整体结论", "总结论", "评审结论", "评估结论"),
+        )
+        recommendation = _section_value(
+            sections,
+            ("评审建议", "对比与建议", "跨方案对比", "方案对比", "采纳建议", "组合建议", "建议采用", "建议合入", "推荐"),
+        )
+        next_goal = _section_value(
+            sections,
+            ("下一步引领目标", "下一轮引领目标", "下一轮目标", "下一步目标", "引领目标"),
+        )
+
+        # Some reports use bold inline labels without a Markdown heading.
+        if not recommendation:
+            recommendation = _labeled_value(
+                visible,
+                ("采纳建议", "组合建议", "评审建议", "建议采用", "建议合入", "推荐"),
+            )
+        if not next_goal:
+            next_goal = _labeled_value(
+                visible,
+                ("下一步引领目标", "下一轮引领目标", "下一轮目标", "下一步目标", "引领目标"),
+            )
+
+        result = {
+            "parserVersion": self.VERSION,
+            "kind": kind,
+            "subjects": _unique(subjects),
+            "criteria": _unique(criteria),
+            "itemConclusions": _unique_dicts(item_conclusions),
+            "achievements": _unique(achievements),
+            "problems": _unique_dicts(problems),
+            "conclusion": _business_value(conclusion),
+            "recommendation": _business_value(recommendation),
+            "nextGoal": _business_value(next_goal),
+            "notices": [],
+        }
+        if branch_evaluations:
+            result["branchEvaluations"] = branch_evaluations
+        if comparison_rows:
+            result["comparisonRows"] = comparison_rows
+        if not any(
+            result[key]
+            for key in (
+                "subjects",
+                "criteria",
+                "itemConclusions",
+                "achievements",
+                "problems",
+                "conclusion",
+                "recommendation",
+                "nextGoal",
+            )
+        ):
+            result["notices"].append("unparsed-report")
+        return result
+
+
+def _means_no_problem(value: str) -> bool:
+    sentences = [
+        re.sub(r"\s+", "", item)
+        for item in re.split(r"[。!?!?;;]+", value)
+        if item.strip()
+    ]
+    if not sentences:
+        return False
+    marker = re.fullmatch(
+        r"(?:无|没有|无(?:显著|明显)?(?:质量)?问题|无硬伤|无未通过项|未发现问题|未发现硬伤)",
+        sentences[0],
+    )
+    if not marker:
+        return False
+    remainder = "".join(sentences[1:])
+    return not re.search(r"(?:但|不过|仍|需|建议|风险|问题|不足|缺失|待|未)", remainder)
+
+
+def _report_text(value: Any) -> str:
+    if value is None:
+        return ""
+    if isinstance(value, dict):
+        summary = value.get("summary")
+        return str(summary) if isinstance(summary, str) else ""
+    text = str(value).replace("\r\n", "\n").replace("\r", "\n").strip()
+    if text.startswith("{"):
+        try:
+            payload = json.loads(text)
+        except (TypeError, ValueError):
+            return text
+        if isinstance(payload, dict) and isinstance(payload.get("summary"), str):
+            return payload["summary"]
+    return text.replace("\\n", "\n")
+
+
+def _remove_code_fences(text: str) -> str:
+    return re.sub(r"(?ms)^\s*```[^\n]*\n.*?^\s*```\s*$", "", text)
+
+
+def _sections(text: str) -> list[_Section]:
+    lines = text.splitlines()
+    sections: list[_Section] = []
+    current_title = ""
+    body: list[str] = []
+
+    def flush() -> None:
+        nonlocal body
+        if current_title or any(line.strip() for line in body):
+            sections.append(_Section(_clean_heading(current_title), "\n".join(body).strip()))
+        body = []
+
+    for line in lines:
+        heading = re.match(r"^\s*#{1,6}\s+(.+?)\s*#*\s*$", line)
+        if heading:
+            flush()
+            current_title = heading.group(1)
+            continue
+        bold_heading = re.match(
+            r"^\s*(?:[-*+]\s*)?\*\*([^*\n]{2,40})\*\*\s*(?:[((][^))\n]{0,40}[))])?\s*[::]?\s*$",
+            line,
+        )
+        if bold_heading:
+            flush()
+            current_title = bold_heading.group(1)
+            continue
+        body.append(line)
+    flush()
+    return sections
+
+
+def _tables(text: str) -> list[list[dict[str, str]]]:
+    lines = text.splitlines()
+    tables: list[list[dict[str, str]]] = []
+    index = 0
+    while index + 1 < len(lines):
+        headers = _table_cells(lines[index])
+        separator = _table_cells(lines[index + 1])
+        if not headers or not separator or not all(re.fullmatch(r":?-{3,}:?", cell) for cell in separator):
+            index += 1
+            continue
+        rows: list[dict[str, str]] = []
+        index += 2
+        while index < len(lines):
+            cells = _table_cells(lines[index])
+            if not cells:
+                break
+            cells += [""] * max(0, len(headers) - len(cells))
+            rows.append(dict(zip((_plain(cell) for cell in headers), cells)))
+            index += 1
+        if rows:
+            tables.append(rows)
+    return tables
+
+
+def _table_cells(line: str) -> list[str]:
+    stripped = line.strip()
+    if "|" not in stripped:
+        return []
+    return [cell.strip() for cell in stripped.strip("|").split("|")]
+
+
+def _subjects(sections: list[_Section]) -> list[str]:
+    values: list[str] = []
+    for section in sections:
+        title = _business_value(section.title)
+        if not title:
+            continue
+        match = re.search(r"(?:分支评估|方案评估|候选评审)\s*(?:方案\s*)?(\d+)", title)
+        if not match:
+            match = re.search(r"方案\s*(\d+)\b", title)
+        if match:
+            values.append(f"方案 {match.group(1)}")
+        elif re.search(r"主脚本|整体评审|整体评估", title):
+            values.append("当前主脚本")
+    return values
+
+
+def _collect_sections(sections: list[_Section], labels: tuple[str, ...]) -> list[str]:
+    values: list[str] = []
+    for section in sections:
+        if not _matches_label(section.title, labels):
+            continue
+        values.extend(_business_items(section.body))
+    return values
+
+
+def _collect_positive_sections(sections: list[_Section], labels: tuple[str, ...]) -> list[str]:
+    values: list[str] = []
+    for section in sections:
+        if _is_problem_title(section.title) or not _matches_label(section.title, labels):
+            continue
+        values.extend(_business_items(section.body))
+    return values
+
+
+def _branch_evaluations(sections: list[_Section]) -> list[dict[str, Any]]:
+    values: list[dict[str, Any]] = []
+    current: dict[str, Any] | None = None
+
+    for section in sections:
+        subject = _branch_section_subject(section.title)
+        if subject:
+            current = {
+                "subject": subject,
+                "conclusion": _business_value(
+                    _labeled_value(section.body, ("该支结论", "评审结论", "结论"))
+                ),
+                "achievements": [],
+                "problems": [],
+                "evidence": _business_value(_labeled_value(section.body, ("有据扎实度",))),
+            }
+            inline_achievement = _labeled_value(section.body, ("目标推进", "已达成", "主要优点"))
+            inline_problem = _labeled_value(section.body, ("未通过项", "部分通过项", "主要问题", "问题", "存疑项"))
+            if inline_achievement:
+                current["achievements"].append(inline_achievement)
+            if inline_problem and not _means_no_problem(inline_problem):
+                current["problems"].append(inline_problem)
+            values.append(current)
+            continue
+
+        if current is None:
+            continue
+        if _is_comparison_section(section.title):
+            current = None
+            continue
+
+        evidence = _labeled_value(section.body, ("有据扎实度",))
+        if evidence:
+            current["evidence"] = _business_value(evidence)
+        body = _without_labeled_lines(section.body, ("有据扎实度",))
+        if _is_problem_title(section.title):
+            current["problems"].extend(
+                item for item in _business_items(body) if not _means_no_problem(item)
+            )
+        elif _is_positive_section_title(section.title):
+            current["achievements"].extend(_business_items(body))
+
+    for item in values:
+        item["achievements"] = _unique(item.get("achievements") or [])
+        item["problems"] = _unique(item.get("problems") or [])
+    return values
+
+
+def _comparison_rows(tables: list[list[dict[str, str]]]) -> list[dict[str, Any]]:
+    values: list[dict[str, Any]] = []
+    for rows in tables:
+        if not rows:
+            continue
+        headers = list(rows[0])
+        criterion_key = _first_header(headers, ("维度", "标准"))
+        candidate_keys = [
+            header for header in headers
+            if re.search(r"(?:branch|方案|分支)\s*#?\s*\d+", header, re.I)
+        ]
+        basis_key = _first_header(headers, ("差异依据", "比较依据", "依据"))
+        if not criterion_key or not candidate_keys:
+            continue
+        for row in rows:
+            criterion = _business_value(row.get(criterion_key))
+            if not criterion:
+                continue
+            values.append(
+                {
+                    "criterion": criterion,
+                    "candidates": [
+                        {
+                            "subject": _business_value(header),
+                            "value": _business_value(row.get(header)),
+                        }
+                        for header in candidate_keys
+                        if _business_value(row.get(header))
+                    ],
+                    "basis": _business_value(row.get(basis_key)) if basis_key else None,
+                }
+            )
+    return values
+
+
+def _is_positive_section_title(value: str) -> bool:
+    return not _is_problem_title(value) and _matches_label(
+        value,
+        ("已达成", "达成情况", "目标达成", "目标推进", "完成情况", "通过项", "达成项", "主要优点"),
+    )
+
+
+def _is_problem_title(value: str) -> bool:
+    normalized = _clean_heading(value)
+    return any(label in normalized for label in ("未通过", "部分通过项", "问题", "存疑", "遗留", "风险"))
+
+
+def _is_comparison_section(value: str) -> bool:
+    normalized = _clean_heading(value)
+    return any(label in normalized for label in ("对比与建议", "跨方案对比", "方案对比", "综合建议", "执行统计"))
+
+
+def _without_labeled_lines(text: str, labels: tuple[str, ...]) -> str:
+    pattern = "|".join(re.escape(label) for label in labels)
+    return "\n".join(
+        line for line in text.splitlines()
+        if not re.match(rf"^\s*(?:[-*+]\s*)?(?:\*\*)?(?:{pattern})(?:\*\*)?\s*[::]", line)
+    )
+
+
+def _problem_sections(sections: list[_Section]) -> list[dict[str, str]]:
+    values: list[dict[str, str]] = []
+    for section in sections:
+        if not _is_problem_title(section.title):
+            continue
+        body = _without_labeled_lines(section.body, ("有据扎实度",))
+        for item in _business_items(body):
+            problem: dict[str, str] = {"summary": item}
+            severity = _severity(item)
+            if severity:
+                problem["severity"] = severity
+            values.append(problem)
+    return values
+
+
+def _item_conclusions(sections: list[_Section]) -> list[dict[str, Any]]:
+    values: list[dict[str, Any]] = []
+    for section in sections:
+        subject = _subject_from_title(section.title)
+        if not subject:
+            continue
+        conclusion = _labeled_value(section.body, ("该支结论", "评审结论", "结论"))
+        achievements = _inline_or_section_items(section.body, ("目标推进", "已达成", "主要优点"))
+        problems = _inline_or_section_items(section.body, ("未通过项", "主要问题", "问题"))
+        if conclusion or achievements or problems:
+            values.append(
+                {
+                    "subject": subject,
+                    "conclusion": _business_value(conclusion),
+                    "achievements": _unique(achievements),
+                    "problems": _unique(problems),
+                }
+            )
+    return values
+
+
+def _merge_table(
+    rows: list[dict[str, str]],
+    *,
+    criteria: list[str],
+    item_conclusions: list[dict[str, Any]],
+    achievements: list[str],
+    problems: list[dict[str, str]],
+) -> None:
+    if not rows:
+        return
+    headers = list(rows[0])
+    subject_key = _first_header(headers, ("方案", "分支", "对象", "候选", "维度", "标准"))
+    conclusion_key = _first_header(headers, ("结论", "结果", "判断"))
+    achievement_key = _first_header(headers, ("已达成", "达成", "优点", "通过项"))
+    problem_key = _first_header(headers, ("问题", "未通过", "遗留", "风险"))
+
+    for row in rows:
+        subject = _business_value(row.get(subject_key)) if subject_key else None
+        conclusion = _business_value(row.get(conclusion_key)) if conclusion_key else None
+        row_achievements = _business_items(row.get(achievement_key, "")) if achievement_key else []
+        row_problems = _business_items(row.get(problem_key, "")) if problem_key else []
+
+        if subject_key and any(term in subject_key for term in ("维度", "标准")) and subject:
+            criteria.append(subject)
+        if subject and conclusion:
+            item_conclusions.append(
+                {
+                    "subject": subject,
+                    "conclusion": conclusion,
+                    "achievements": row_achievements,
+                    "problems": row_problems,
+                }
+            )
+        achievements.extend(row_achievements)
+        for item in row_problems:
+            problem: dict[str, str] = {"summary": item}
+            severity = _severity(item)
+            if severity:
+                problem["severity"] = severity
+            problems.append(problem)
+
+
+def _section_value(sections: list[_Section], labels: tuple[str, ...]) -> str | None:
+    for section in sections:
+        if _matches_label(section.title, labels):
+            items = _business_items(section.body)
+            return ";".join(items) if items else None
+    return None
+
+
+def _labeled_value(text: str, labels: tuple[str, ...]) -> str | None:
+    if not text:
+        return None
+    label_pattern = "|".join(re.escape(label) for label in labels)
+    match = re.search(
+        rf"(?im)^\s*(?:#{{1,6}}\s+)?(?:[-*+]\s*)?(?:\*\*)?(?:{label_pattern})(?:\*\*)?\s*[::]\s*(.+?)\s*$",
+        text,
+    )
+    return _business_value(match.group(1)) if match else None
+
+
+def _inline_or_section_items(text: str, labels: tuple[str, ...]) -> list[str]:
+    value = _labeled_value(text, labels)
+    return [value] if value else []
+
+
+def _business_items(text: str) -> list[str]:
+    values: list[str] = []
+    for paragraph in re.split(r"\n\s*\n", text or ""):
+        lines = paragraph.splitlines()
+        for line in lines:
+            if re.match(r"^\s*\|.*\|\s*$", line):
+                continue
+            value = re.sub(r"^\s*(?:[-*+]\s+|\d+[.)、]\s*)", "", line).strip()
+            value = _business_value(value)
+            if value:
+                values.append(value)
+    return values
+
+
+def _business_value(value: Any) -> str | None:
+    text = _plain(str(value or ""))
+    if not text:
+        return None
+    # Remove only named technical assignments. Never remove bare numbers or
+    # generic "ID" tokens: business content such as "领域信息 31/32" must survive.
+    text = re.sub(r"(?i)\bscript_build_id\s*[=::]\s*\d+\s*[·|,,;;]?", "", text)
+    text = re.sub(r"(?i)\bround(?:_index)?\s*[=::]\s*\d+\s*[·|,,;;]?", "", text)
+    text = re.sub(r"(?i)\b(?:paragraph|element|post)_id\s*[=::]\s*[^\s::,,;;))]+\s*[·|,,;;]?", "", text)
+    text = re.sub(r"(?i)\bbranch_id\s*[=::]\s*(\d+)", r"方案 \1", text)
+    text = re.sub(r"(?i)\bbranch\s*#?\s*(\d+)\b", r"方案 \1", text)
+    text = re.sub(r"^(?:D|P)\d+\s*[::|·-]?\s*(?=\D)", "", text, flags=re.I)
+    text = re.sub(r"(?i)\b(?:action|target|path_type|dimension_pass)\s*[=::]\s*[^,,;;]+", "", text)
+    text = re.sub(r"^\s*[::·|,,;;-]+\s*", "", text)
+    text = re.sub(r"\s+([,。;!?、:,.])", r"\1", text)
+    text = re.sub(r"\s{2,}", " ", text).strip(" ::,,;;|-_")
+    return text or None
+
+
+def _plain(text: str) -> str:
+    text = text.replace("\\n", "\n")
+    text = re.sub(r"`([^`]*)`", r"\1", text)
+    text = re.sub(r"[*#]", "", text)
+    return text.strip()
+
+
+def _clean_heading(value: str) -> str:
+    return _plain(value).strip(" ::")
+
+
+def _matches_label(value: str, labels: tuple[str, ...]) -> bool:
+    normalized = _clean_heading(value)
+    return any(label in normalized for label in labels)
+
+
+def _subject_from_title(title: str) -> str | None:
+    cleaned = _business_value(title)
+    if not cleaned:
+        return None
+    match = re.search(r"(?:分支评估|方案评估|候选评审)?\s*方案\s*(\d+)\b", cleaned)
+    if match:
+        return f"方案 {match.group(1)}"
+    return None
+
+
+def _branch_section_subject(title: str) -> str | None:
+    cleaned = _business_value(title)
+    if not cleaned or not re.search(r"分支评估|方案评估|候选评审", cleaned):
+        return None
+    return _subject_from_title(title)
+
+
+def _first_header(headers: list[str], labels: tuple[str, ...]) -> str | None:
+    return next((header for header in headers if any(label in header for label in labels)), None)
+
+
+def _severity(value: str) -> str | None:
+    match = re.search(r"(?:严重程度|级别|等级)\s*[::]\s*(严重|高|中|低)", value)
+    if match:
+        return match.group(1)
+    for label in ("严重", "高", "中", "低"):
+        if re.match(rf"^(?:[【\[]{label}[】\]]|{label}\s*[::])", value):
+            return label
+    return None
+
+
+def _unique(values: list[str]) -> list[str]:
+    seen: set[str] = set()
+    result: list[str] = []
+    for value in values:
+        if value and value not in seen:
+            seen.add(value)
+            result.append(value)
+    return result
+
+
+def _unique_dicts(values: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    seen: set[str] = set()
+    result: list[dict[str, Any]] = []
+    for value in values:
+        marker = json.dumps(value, ensure_ascii=False, sort_keys=True)
+        if marker not in seen:
+            seen.add(marker)
+            result.append(value)
+    return result

+ 1975 - 0
visualization-old/backend/app/execution_builder.py

@@ -0,0 +1,1975 @@
+from __future__ import annotations
+
+import re
+from collections import Counter, defaultdict
+from datetime import datetime, timezone
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from .business_detail_text import branch_task_sections, branch_task_text, business_text
+from .decision_projection import AgentDecisionProjector
+from .main_decision_text import (
+    parse_goal_text,
+    parse_objective_text,
+    plan_mode_label,
+    plan_path_summary,
+    preview as decision_preview,
+)
+from .runtime_event_projection import RuntimeEventProjector
+
+
+_DECISIONS = AgentDecisionProjector()
+
+
+TERMINAL_STATUSES = {
+    "success",
+    "partial",
+    "failed",
+    "stopped",
+    "completed",
+    "cancelled",
+    "cancel",
+    "cancle",
+}
+
+
+class ExecutionViewBuilder:
+    """Deterministic V8 business narrative projector.
+
+    The projector consumes plain dictionaries and has no database, HTTP or
+    framework dependencies.  Business rows win over runtime events; events may
+    only fill clearly labelled evaluation/activity detail.
+    """
+
+    def __init__(self, event_projector: RuntimeEventProjector | None = None):
+        self._event_projector = event_projector or RuntimeEventProjector()
+
+    def from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]:
+        record = bundle.get("record") or {}
+        raw_rounds = sorted(
+            bundle.get("rounds") or [],
+            key=lambda item: (_integer(item.get("round_index")) or 0, _integer(item.get("id")) or 0),
+        )
+        raw_branches = sorted(
+            bundle.get("branches") or [],
+            key=lambda item: (
+                _integer(item.get("round_index")) or 0,
+                _integer(item.get("branch_id")) or 0,
+            ),
+        )
+        data_decisions = [
+            item
+            for item in bundle.get("dataDecisions") or []
+            if (_integer(item.get("branch_id")) or 0) > 0
+        ]
+        multipath_decisions = bundle.get("multipathDecisions") or []
+        domain_info = bundle.get("domainInfo") or []
+        events = bundle.get("events") or []
+
+        valid_rounds = {
+            value
+            for value in (_integer(item.get("round_index")) for item in raw_rounds)
+            if value is not None
+        }
+        valid_branches = {
+            value
+            for value in (_integer(item.get("branch_id")) for item in raw_branches)
+            if value is not None
+        }
+        captured_at = datetime.now(timezone.utc)
+        runtime = self._event_projector.project(
+            events,
+            valid_rounds=valid_rounds,
+            valid_branches=valid_branches,
+            captured_at=captured_at,
+            run_status=str(record.get("status") or "unknown"),
+            script_direction=record.get("script_direction"),
+            rounds=raw_rounds,
+            multipath_decisions=multipath_decisions,
+        )
+        implementer_tasks = _implementer_tasks_by_branch(events)
+
+        decisions_by_branch: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        for item in data_decisions:
+            branch_id = _integer(item.get("branch_id"))
+            if branch_id is not None:
+                decisions_by_branch[branch_id].append(item)
+        domain_by_branch: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        domain_by_round: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        for item in domain_info:
+            branch_id = _integer(item.get("branch_id"))
+            round_index = _integer(item.get("round_index"))
+            if branch_id is not None:
+                domain_by_branch[branch_id].append(item)
+            if round_index is not None:
+                domain_by_round[round_index].append(item)
+        multipath_by_round: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        unassigned_business: list[dict[str, Any]] = []
+        for item in multipath_decisions:
+            round_index = _integer(item.get("round_index"))
+            if round_index in valid_rounds:
+                multipath_by_round[round_index].append(item)
+            else:
+                unassigned_business.append(
+                    {
+                        "type": "multipath-decision",
+                        "id": item.get("id"),
+                        "reason": "没有可确认的所属轮次",
+                    }
+                )
+
+        projected_rounds: list[dict[str, Any]] = []
+        for index, raw_round in enumerate(raw_rounds):
+            round_index = _integer(raw_round.get("round_index")) or index + 1
+            branch_rows = [
+                item
+                for item in raw_branches
+                if _integer(item.get("round_index")) == round_index
+            ]
+            next_round = raw_rounds[index + 1] if index + 1 < len(raw_rounds) else None
+            projected_rounds.append(
+                self._round_story(
+                    record,
+                    raw_round,
+                    branch_rows,
+                    decisions_by_branch,
+                    domain_by_branch,
+                    domain_by_round.get(round_index, []),
+                    multipath_by_round.get(round_index, []),
+                    runtime,
+                    implementer_tasks,
+                    next_round,
+                )
+            )
+
+        data_shape = _data_shape(
+            raw_branches, multipath_decisions, domain_info, events
+        )
+        warnings = _warnings(
+            data_shape=data_shape,
+            ignored_legacy_count=_integer(
+                bundle.get("ignoredLegacyDataDecisionCount")
+            )
+            or 0,
+            rounds=projected_rounds,
+            runtime_unassigned=runtime.get("unassigned") or [],
+            business_unassigned=unassigned_business,
+        )
+        artifact = bundle.get("currentArtifact") or {}
+        current_round = projected_rounds[-1]["roundIndex"] if projected_rounds else None
+        view = {
+            "schemaVersion": "8",
+            "capturedAt": captured_at.isoformat(),
+            "dataShape": data_shape,
+            "header": _header(record, current_round),
+            "planning": _planning_analysis(
+                (runtime.get("mainAgentDecisions") or {}).get("planningAnalysis"),
+            ),
+            "objective": _objective(
+                record,
+                (runtime.get("mainAgentDecisions") or {}).get("objective"),
+            ),
+            "rounds": projected_rounds,
+            "finalResult": _final_result(record, projected_rounds, artifact),
+            "unassigned": {
+                "runtimeEvents": runtime.get("unassigned") or [],
+                "businessRecords": unassigned_business,
+            },
+            "warnings": warnings,
+        }
+        if view["planning"] is None:
+            view.pop("planning")
+        return view
+
+    def _round_story(
+        self,
+        record: dict[str, Any],
+        raw_round: dict[str, Any],
+        branch_rows: list[dict[str, Any]],
+        decisions_by_branch: dict[int, list[dict[str, Any]]],
+        domain_by_branch: dict[int, list[dict[str, Any]]],
+        round_domain_info: list[dict[str, Any]],
+        multipath_rows: list[dict[str, Any]],
+        runtime: dict[str, Any],
+        implementer_tasks: dict[int, str],
+        next_round: dict[str, Any] | None,
+    ) -> dict[str, Any]:
+        round_index = _integer(raw_round.get("round_index")) or 0
+        main_decisions = runtime.get("mainAgentDecisions") or {}
+        goal_context = (main_decisions.get("roundGoalsByRound") or {}).get(round_index)
+        plan_context = (main_decisions.get("implementationPlansByRound") or {}).get(
+            round_index
+        )
+        plan = _plan_projection(
+            raw_round,
+            plan_context,
+        )
+        branches = [
+            _branch_story(
+                round_index,
+                row,
+                decisions_by_branch.get(_integer(row.get("branch_id")) or 0, []),
+                domain_by_branch.get(_integer(row.get("branch_id")) or 0, []),
+                runtime.get("retrievalStagesByBranch", {}).get(
+                    _integer(row.get("branch_id")) or 0
+                ),
+                runtime.get("creativeByBranch", {}).get(
+                    _integer(row.get("branch_id")) or 0
+                ),
+                implementer_tasks.get(_integer(row.get("branch_id")) or 0),
+                str(record.get("status") or "unknown"),
+            )
+            for row in branch_rows
+        ]
+        branch_ids = {branch["branchId"] for branch in branches}
+        decision_batches = [
+            _decision_batch(round_index, row, branch_ids, branches)
+            for row in sorted(multipath_rows, key=_record_order)
+        ]
+        convergence_batches, unassigned_reviews = _convergence_batches(
+            round_index,
+            runtime.get("multipathReviewsByRound", {}).get(round_index, []),
+            decision_batches,
+            str(record.get("status") or "unknown"),
+        )
+        if not convergence_batches and branches:
+            convergence_batches = [
+                _convergence_batch(
+                    round_index,
+                    None,
+                    None,
+                    "records-missing",
+                    str(record.get("status") or "unknown"),
+                    branch_ids=sorted(branch_ids),
+                )
+            ]
+        _annotate_unscoped_round_reviews(
+            convergence_batches,
+            runtime.get("unassigned") or [],
+            round_index,
+        )
+        if unassigned_reviews:
+            runtime.setdefault("unassigned", []).extend(unassigned_reviews)
+        overall_evaluation = _round_evaluation(
+            round_index,
+            runtime.get("overallEvaluationsByRound", {}).get(round_index, []),
+            str(record.get("status") or "unknown"),
+        )
+        state = _round_state(record, raw_round, overall_evaluation)
+        result = _round_result(
+            record,
+            round_index,
+            branches,
+            round_domain_info,
+            state,
+            next_round,
+        )
+        goal_summary = _goal_summary(raw_round.get("goal"))
+        round_summary = _round_summary(
+            round_index=round_index,
+            goal=goal_summary,
+            plan=plan,
+            branches=branches,
+            batches=decision_batches,
+            domain_info=round_domain_info,
+            run_status=str(record.get("status") or "unknown"),
+        )
+        goal_parsed = parse_goal_text(raw_round.get("goal"))
+        goal_lines: list[dict[str, Any]] = []
+        focus_items = goal_parsed.get("focusItems") or []
+        if focus_items:
+            focus_summary = ";".join(str(item) for item in focus_items[:2])
+            if len(focus_items) > 2:
+                focus_summary += f";另有 {len(focus_items) - 2} 项,详情查看"
+            goal_lines.append(_line("focus", "本轮聚焦", focus_summary))
+        visible_inputs = _context_input_labels(goal_context)
+        if visible_inputs:
+            goal_lines.append(_line("visible-before", "形成前可见", " · ".join(visible_inputs)))
+        goal_decision = _direction_decision(
+            id=f"round:{round_index}:goal",
+            subtype="round-goal",
+            context=goal_context,
+            decision_items=(goal_context or {}).get("decisionItems")
+            or [goal_parsed["headline"]],
+            primary_label="要解决",
+            primary_value=goal_parsed["headline"],
+            secondary=goal_lines,
+            prompt_ref="prompt:main",
+        )
+        return {
+            "id": f"round:{round_index}",
+            "roundIndex": round_index,
+            "state": state,
+            "summary": round_summary,
+            "goal": _story(
+                id=f"round:{round_index}:goal",
+                role="goal",
+                kind="decision",
+                title="本轮目标",
+                label="要解决",
+                value=goal_parsed["headline"],
+                secondary=goal_lines,
+                detail_ref=f"round:{round_index}:goal",
+                source_notice=None if raw_round.get("goal") else "record-missing",
+                decision=goal_decision,
+            ),
+            "plan": plan,
+            "branches": branches,
+            "convergenceBatches": convergence_batches,
+            "overallEvaluation": overall_evaluation,
+            "result": result,
+            "detailRef": f"round:{round_index}",
+        }
+
+
+def _header(record: dict[str, Any], current_round: int | None) -> dict[str, Any]:
+    agent_config = record.get("agent_config")
+    model = None
+    if isinstance(agent_config, dict):
+        model = agent_config.get("model_name") or agent_config.get("model")
+    return {
+        "id": str(record.get("id") or ""),
+        "title": f"脚本构建 #{record.get('id')}",
+        "status": str(record.get("status") or "unknown"),
+        "summary": _plain(record.get("summary")),
+        "errorMessage": _plain(record.get("error_message")),
+        "model": model,
+        "totalCost": record.get("cost_usd"),
+        "currentRound": current_round,
+        "createdAt": record.get("start_time"),
+        "updatedAt": record.get("end_time"),
+        "direction": {
+            "currentValue": _plain(record.get("script_direction")),
+            "note": "这是数据库当前保存的创作方向。",
+        },
+    }
+
+
+def _objective(
+    record: dict[str, Any], decision_context: dict[str, Any] | None
+) -> dict[str, Any]:
+    raw_direction = record.get("script_direction")
+    direction = _plain(raw_direction)
+    # Section parsing needs the persisted Markdown line breaks. ``direction``
+    # is only the compact truthy/display fallback used by the surrounding view.
+    parsed = parse_objective_text(raw_direction)
+    secondary: list[dict[str, Any]] = []
+    input_labels = _context_input_labels(decision_context, relation="direct-read")
+    if input_labels:
+        secondary.append(
+            _line("inputs", "决策前读取", " · ".join(input_labels))
+        )
+    structure: list[str] = []
+    target_count = int(parsed.get("targetCount") or 0)
+    dimension_count = int(parsed.get("domainDimensionCount") or 0)
+    if target_count:
+        structure.append(f"{target_count} 个创作目标")
+    if dimension_count:
+        structure.append(f"{dimension_count} 个领域评估维度")
+    if structure:
+        secondary.append(_line("structure", "目标结构", " · ".join(structure)))
+    decision = _direction_decision(
+        id="run:objective",
+        subtype="objective",
+        context=decision_context,
+        decision_items=(decision_context or {}).get("decisionItems")
+        or ([parsed.get("headline")] if parsed.get("headline") else []),
+        primary_label="核心目标",
+        primary_value=parsed.get("headline"),
+        secondary=secondary,
+        prompt_ref="prompt:main",
+    )
+    return _story(
+        id="run:objective",
+        role="objective",
+        kind="decision" if direction else "missing",
+        title="创作目标",
+        label="核心目标",
+        value=parsed.get("headline") or "创作方向未记录",
+        secondary=secondary,
+        detail_ref="run:objective",
+        source_notice=None if direction else "record-missing",
+        decision=decision,
+    )
+
+
+def _planning_analysis(context: dict[str, Any] | None) -> dict[str, Any] | None:
+    if not context or context.get("completeness") != "complete" or not context.get("eventRef"):
+        return None
+    secondary = [
+        _line("plan", "执行计划", context.get("plan")),
+        _line("next", "下一步", context.get("action")),
+    ]
+    return _story(
+        id="run:planning-analysis",
+        role="planning-analysis",
+        kind="execution",
+        title="创作规划解析",
+        label="规划概要",
+        value=context.get("summary") or "主 Agent 已完成首次创作规划",
+        secondary=secondary,
+        detail_ref=context.get("eventRef"),
+        status="completed",
+    )
+
+
+def _plan_projection(
+    raw_round: dict[str, Any], decision_context: dict[str, Any] | None
+) -> dict[str, Any]:
+    round_index = _integer(raw_round.get("round_index")) or 0
+    paths = raw_round.get("multipath_plan")
+    if not isinstance(paths, list):
+        paths = []
+    raw_mode = _plain(raw_round.get("race_or_divide"))
+    mode = plan_mode_label(raw_mode, len(paths))
+    summary = (
+        f"{mode or '方式未记录'} · {len(paths)} 个方案"
+        if paths
+        else "多路规划未记录"
+    )
+    decision = _direction_decision(
+        id=f"round:{round_index}:plan",
+        subtype="implementation-plan",
+        context=decision_context,
+        decision_items=(decision_context or {}).get("decisionItems")
+        or [summary],
+        primary_label="实现方式",
+        primary_value=summary,
+        secondary=_plan_card_lines(paths, raw_round.get("plan_note")),
+        prompt_ref="prompt:main",
+    )
+    node = _story(
+        id=f"round:{round_index}:plan",
+        role="plan",
+        kind="decision" if paths else "missing",
+        title="实现规划",
+        label="实现方式",
+        value=summary,
+        secondary=_plan_card_lines(paths, raw_round.get("plan_note")),
+        detail_ref=f"round:{round_index}:plan",
+        source_notice=None if paths else "record-missing",
+        decision=decision,
+    )
+    return {
+        "mode": mode,
+        "rawMode": raw_mode,
+        "paths": paths,
+        "note": _plain(raw_round.get("plan_note")),
+        "currentOnly": True,
+        "runtimeRevisionCount": len((decision_context or {}).get("revisionRefs") or []),
+        "runtimeRevisionRefs": list((decision_context or {}).get("revisionRefs") or []),
+        "node": node,
+    }
+
+
+def _branch_story(
+    round_index: int,
+    row: dict[str, Any],
+    decisions: list[dict[str, Any]],
+    domain_info: list[dict[str, Any]],
+    retrieval_stage: dict[str, Any] | None,
+    creative_decision: dict[str, Any] | None,
+    implementer_task: str | None,
+    run_status: str,
+) -> dict[str, Any]:
+    branch_id = _integer(row.get("branch_id")) or 0
+    path_type = str(row.get("path_type") or "unknown")
+    if path_type not in {"内容", "领域信息"}:
+        path_type = "unknown"
+    task = (
+        branch_task_text(implementer_task)
+        or branch_task_text(row.get("impl_task"))
+        or business_text(row.get("target"))
+    )
+    retrieval_stage = retrieval_stage or _missing_retrieval_stage(
+        round_index, branch_id, run_status
+    )
+    projected_data_decisions = [
+        _DECISIONS.data_tradeoff(
+            {
+                **item,
+                "decision": business_text(item.get("decision")),
+                "reasoning": business_text(item.get("reasoning")),
+                "id": f"data-decision:{item.get('id')}",
+                "detailRef": f"data-decision:{item.get('id')}",
+                "promptRef": _implementer_prompt_ref(retrieval_stage),
+            }
+        )
+        for item in decisions
+    ]
+    for item, projection in zip(decisions, projected_data_decisions):
+        full_conclusion = business_text(item.get("decision"))
+        if full_conclusion:
+            projection.setdefault("card", {}).setdefault("primary", {})[
+                "value"
+            ] = full_conclusion
+    decision_nodes = [
+        _story(
+            id=f"data-decision:{item.get('id')}",
+            role="data-decision",
+            kind="decision",
+            title="数据取舍",
+            label="取舍结论",
+            value=projection["card"]["primary"]["value"],
+            secondary=projection["card"]["secondary"],
+            detail_ref=f"data-decision:{item.get('id')}",
+            decision=projection,
+        )
+        for item, projection in zip(decisions, projected_data_decisions)
+    ]
+    output = _branch_output(row, path_type, domain_info)
+    if creative_decision and output.get("type") == "script-artifact":
+        creative_decision = dict(creative_decision)
+        explicit_basis = [
+            {
+                "label": business_text(item.get("decision")) or "已记录一项数据取舍",
+                "value": business_text(item.get("reasoning")) or None,
+                "detailRef": f"data-decision:{item.get('id')}",
+            }
+            for item in decisions
+            if item.get("decision")
+        ]
+        creative_decision["artifactRef"] = output.get("snapshotRef")
+        creative_decision["body"] = {
+            **(creative_decision.get("body") or {}),
+            "output": output.get("summary"),
+            "explicitBasis": explicit_basis,
+            "candidateSnapshot": {
+                "snapshotRef": output.get("snapshotRef"),
+                "paragraphCount": output.get("paragraphCount"),
+                "elementCount": output.get("elementCount"),
+                "linkCount": output.get("linkCount"),
+                "exactness": _candidate_output_exactness(output),
+            },
+        }
+        detail = creative_decision.get("detail")
+        if isinstance(detail, dict):
+            blocks = [
+                block
+                for block in detail.get("blocks") or []
+                if not (
+                    isinstance(block, dict)
+                    and block.get("title") in {"实际产出", "产出的候选表"}
+                )
+            ]
+            if explicit_basis:
+                blocks.insert(
+                    0,
+                    {
+                        "type": "items",
+                        "title": "明确采用的数据取舍",
+                        "items": explicit_basis,
+                        "visualRole": "evidence",
+                        "presentation": "list",
+                        "collapsible": True,
+                    },
+                )
+            blocks.append(
+                {
+                    "type": "summary",
+                    "title": "产出的候选表",
+                    "value": output.get("summary"),
+                    "visualRole": "key-result",
+                    "presentation": "prose",
+                }
+            )
+            creative_decision["detail"] = {**detail, "blocks": blocks}
+        secondary = list((creative_decision.get("card") or {}).get("secondary") or [])
+        if explicit_basis:
+            secondary.append(_line("basis", "采用数据", f"{len(explicit_basis)} 项明确取舍"))
+        secondary.append(_line("output", "产出的候选表", output.get("summary")))
+        creative_decision["card"] = {
+            **(creative_decision.get("card") or {}),
+            "secondary": secondary[:3],
+        }
+        output["node"] = _story(
+            id=creative_decision["id"],
+            role="candidate-output",
+            kind="decision",
+            title="产生候选创作表",
+            label="创作处理",
+            value=creative_decision["card"]["primary"]["value"],
+            secondary=creative_decision["card"]["secondary"],
+            detail_ref=creative_decision["detailRef"],
+            artifact_ref=output.get("snapshotRef"),
+            has_changes=bool(output.get("snapshotRef")),
+            decision=creative_decision,
+        )
+    status = str(row.get("status") or "open")
+    disposition_context = _branch_disposition_context(status, run_status)
+    title = _plain(row.get("target")) or f"方案 {branch_id}"
+    return {
+        "id": f"round:{round_index}:branch:{branch_id}",
+        "branchId": branch_id,
+        "pathType": path_type,
+        "status": status,
+        "title": title,
+        "summary": {
+            "task": task or "实现任务未记录",
+            "output": output["summary"],
+        },
+        "task": _branch_task_story(
+            id=f"round:{round_index}:branch:{branch_id}:task",
+            task=task,
+            detail_ref=f"round:{round_index}:branch:{branch_id}:task",
+        ),
+        "retrievalStage": retrieval_stage,
+        "dataDecisions": [
+            {
+                "id": f"data-decision:{item.get('id')}",
+                "recordId": item.get("id"),
+                "decision": _plain(item.get("decision")),
+                "reasoning": _plain(item.get("reasoning")),
+                "sources": item.get("sources") or [],
+                "createdAt": item.get("created_at"),
+                "node": node,
+                "decisionProjection": projection,
+            }
+            for item, node, projection in zip(
+                decisions, decision_nodes, projected_data_decisions
+            )
+        ],
+        "output": output,
+        "outcome": {
+            "status": status,
+            "label": disposition_context["label"],
+            "reasoning": _plain(row.get("decision_reasoning")),
+            "sourceNotice": disposition_context["sourceNotice"],
+        },
+        "detailRef": f"round:{round_index}:branch:{branch_id}",
+    }
+
+
+def _branch_output(
+    row: dict[str, Any], path_type: str, domain_info: list[dict[str, Any]]
+) -> dict[str, Any]:
+    branch_id = _integer(row.get("branch_id")) or 0
+    round_index = _integer(row.get("round_index")) or 0
+    if path_type == "领域信息":
+        facts = [
+            item
+            for item in domain_info
+            if _integer(item.get("branch_id")) == branch_id
+            and _integer(item.get("round_index")) in {None, round_index}
+        ]
+        summary = (
+            f"新增 {len(facts)} 条已核实领域事实"
+            if facts
+            else "已查询完成,本方案没有新增领域事实"
+        )
+        node = _story(
+            id=f"round:{round_index}:branch:{branch_id}:output",
+            role="domain-output",
+            kind="result",
+            title="领域事实",
+            label="产出",
+            value=summary,
+            detail_ref=f"round:{round_index}:branch:{branch_id}:output",
+            has_changes=bool(facts),
+            source_notice=None,
+        )
+        return {
+            "type": "domain-info",
+            "summary": summary,
+            "factCount": len(facts),
+            "factRefs": [f"domain-info:{item.get('id')}" for item in facts],
+            "node": node,
+        }
+
+    candidate = row.get("candidate_snapshot") or {}
+    snapshot = candidate.get("snapshot") if isinstance(candidate, dict) else None
+    counts = _snapshot_counts(snapshot)
+    if any(counts.values()):
+        summary = (
+            f"候选脚本包含 {counts['paragraphs']} 个段落、"
+            f"{counts['elements']} 个元素"
+        )
+    elif row.get("self_assessment"):
+        assessment = business_text(row.get("self_assessment"))
+        first_item = next(
+            (line.strip(" -*") for line in assessment.splitlines() if line.strip()),
+            assessment,
+        )
+        summary = f"实现 Agent 自评:{first_item}"
+    else:
+        summary = "候选脚本内容未记录"
+    has_output = bool(snapshot) or bool(row.get("self_assessment"))
+    node = _story(
+        id=f"round:{round_index}:branch:{branch_id}:output",
+        role="candidate-output",
+        kind="result" if has_output else "missing",
+        title="候选产出",
+        label="产出",
+        value=summary,
+        detail_ref=f"round:{round_index}:branch:{branch_id}:output",
+        has_changes=bool(snapshot),
+        source_notice=None if has_output else "record-missing",
+        artifact_ref=f"artifact:branch:{branch_id}" if snapshot else None,
+    )
+    return {
+        "type": "script-artifact" if has_output else "missing",
+        "summary": summary,
+        "snapshotRef": f"artifact:branch:{branch_id}" if snapshot else None,
+        "paragraphCount": counts["paragraphs"],
+        "elementCount": counts["elements"],
+        "linkCount": counts["links"],
+        "accuracy": {
+            "historical": candidate.get("historicalAccuracy")
+            if isinstance(candidate, dict)
+            else "unknown",
+            "currentProjection": candidate.get("currentProjectionAccuracy")
+            if isinstance(candidate, dict)
+            else "unknown",
+        },
+        "node": node,
+    }
+
+
+def _branch_task_story(
+    *, id: str, task: str | None, detail_ref: str
+) -> dict[str, Any]:
+    if not task:
+        return _story(
+            id=id,
+            role="branch-task",
+            kind="missing",
+            title="实现任务",
+            label="实现任务",
+            value="实现任务未记录",
+            detail_ref=detail_ref,
+            source_notice="record-missing",
+        )
+    sections = branch_task_sections(task)
+    preferred = next(
+        (
+            item
+            for kind in ("scope", "method", "goal", "notes")
+            for item in sections
+            if item.get("kind") == kind and item.get("content")
+        ),
+        None,
+    )
+    primary = (preferred or {}).get("content") or task
+    secondary = [
+        _line(str(item.get("kind") or index), item.get("title") or "任务说明", item.get("content"))
+        for index, item in enumerate(sections, 1)
+        if item is not preferred and item.get("content")
+    ][:3]
+    node = _story(
+        id=id,
+        role="branch-task",
+        kind="execution",
+        title="实现任务",
+        label=(preferred or {}).get("title") or "实现任务",
+        value=primary,
+        secondary=secondary,
+        detail_ref=detail_ref,
+    )
+    node["taskStructure"] = {
+        "sectionCount": len(sections),
+        "sectionKinds": [item.get("kind") for item in sections],
+    }
+    return node
+
+
+def _candidate_output_exactness(output: dict[str, Any]) -> str:
+    historical = (output.get("accuracy") or {}).get("historical")
+    if historical == "exact":
+        return "historical-snapshot"
+    return "current-only" if output.get("snapshotRef") else "unknown"
+
+
+def _missing_retrieval_stage(
+    round_index: int, branch_id: int, run_status: str
+) -> dict[str, Any]:
+    running = str(run_status or "").lower() not in TERMINAL_STATUSES
+    return {
+        "id": f"round:{round_index}:branch:{branch_id}:retrieval",
+        "roundIndex": round_index,
+        "branchId": branch_id,
+        "implementerEventId": None,
+        "observedMode": "unknown",
+        "waves": [],
+        "directToolGroups": [],
+        "agentRuns": [],
+        "status": "running" if running else "missing",
+        "startedAt": None,
+        "endedAt": None,
+        "detailRef": f"round:{round_index}:branch:{branch_id}:retrieval",
+        "sourceNotice": None if running else "record-missing",
+    }
+
+
+def _decision_batch(
+    round_index: int,
+    row: dict[str, Any],
+    valid_branch_ids: set[int],
+    branches: list[dict[str, Any]],
+) -> dict[str, Any]:
+    branch_ids = _ids(row.get("branch_ids"))
+    missing = [branch_id for branch_id in branch_ids if branch_id not in valid_branch_ids]
+    record_id = row.get("id")
+    decision = _plain(row.get("decision")) or "决策内容未记录"
+    display_decision = _business_terms(decision)
+    secondary = [
+        _line(
+            "branches",
+            "比较方案",
+            "、".join(f"方案 {branch_id}" for branch_id in branch_ids)
+            or "涉及方案未记录",
+        )
+    ]
+    if row.get("reasoning"):
+        secondary.append(
+            _line("reason", "理由", _preview(row.get("reasoning"), 120))
+        )
+    branch_by_id = {branch["branchId"]: branch for branch in branches}
+    outcomes = [
+        {
+            "branchId": branch_id,
+            "status": branch_by_id.get(branch_id, {}).get("outcome", {}).get("status", "unknown"),
+            "label": branch_by_id.get(branch_id, {}).get("outcome", {}).get("label", "状态未记录"),
+            "reasoning": branch_by_id.get(branch_id, {}).get("outcome", {}).get("reasoning"),
+        }
+        for branch_id in branch_ids
+    ]
+    if outcomes:
+        secondary.insert(
+            1,
+            _line(
+                "outcomes",
+                "各方案结果",
+                ";".join(
+                    f"方案 {item['branchId']} · {item['label']}" for item in outcomes
+                ),
+            ),
+        )
+    projection = _DECISIONS.multipath_tradeoff(
+        {
+            "id": f"multipath-decision:{record_id}",
+            "detailRef": f"multipath-decision:{record_id}",
+            "promptRef": "prompt:main",
+            "branchIds": branch_ids,
+            "scopeItems": [f"方案 {branch_id}" for branch_id in branch_ids],
+            "decision": display_decision,
+            "reasoning": business_text(row.get("reasoning")),
+            "selected": [
+                f"方案 {item['branchId']}"
+                for item in outcomes
+                if item["status"] == "merged"
+            ],
+            "parked": [
+                f"方案 {item['branchId']}"
+                for item in outcomes
+                if item["status"] == "parked"
+            ],
+            "rejected": [
+                f"方案 {item['branchId']}"
+                for item in outcomes
+                if item["status"] == "discarded"
+            ],
+            "completeness": "partial" if missing or not branch_ids else "complete",
+        }
+    )
+    projection.setdefault("card", {}).setdefault("primary", {})[
+        "value"
+    ] = display_decision
+    return {
+        "id": f"multipath-decision:{record_id}",
+        "recordId": record_id,
+        "roundIndex": round_index,
+        "branchIds": branch_ids,
+        "decision": decision,
+        "reasoning": _plain(row.get("reasoning")),
+        "createdAt": row.get("created_at"),
+        "completeness": "partial" if missing or not branch_ids else "complete",
+        "missingBranchIds": missing,
+        "branchOutcomes": outcomes,
+        "decisionProjection": projection,
+        "node": _story(
+            id=f"multipath-decision:{record_id}",
+            role="multipath-decision",
+            kind="decision",
+            title="主 Agent 多路决策",
+            label="决定",
+            value=projection["card"]["primary"]["value"],
+            secondary=projection["card"]["secondary"],
+            detail_ref=f"multipath-decision:{record_id}",
+            source_notice="record-missing" if missing else None,
+            decision=projection,
+        ),
+    }
+
+
+def _convergence_batches(
+    round_index: int,
+    reviews: list[dict[str, Any]],
+    decisions: list[dict[str, Any]],
+    run_status: str,
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+    ordered_reviews = sorted(reviews, key=_runtime_order)
+    ordered_decisions = sorted(decisions, key=_decision_order)
+    unmatched_reviews = set(range(len(ordered_reviews)))
+    batches: list[dict[str, Any]] = []
+
+    for decision in ordered_decisions:
+        decision_set = set(decision.get("branchIds") or [])
+        decision_time = _date_value(decision.get("createdAt"))
+        candidates = [
+            index
+            for index in unmatched_reviews
+            if set(ordered_reviews[index].get("branchIds") or []) == decision_set
+            and _not_after(ordered_reviews[index].get("endedAt"), decision_time)
+        ]
+        review_index = max(
+            candidates,
+            key=lambda index: _runtime_order(ordered_reviews[index]),
+            default=None,
+        )
+        review = ordered_reviews[review_index] if review_index is not None else None
+        if review_index is not None:
+            unmatched_reviews.remove(review_index)
+        batches.append(
+            _convergence_batch(
+                round_index,
+                review,
+                decision,
+                "exact-branch-set-and-time" if review else "decision-only",
+                run_status,
+            )
+        )
+
+    for index in sorted(unmatched_reviews, key=lambda value: _runtime_order(ordered_reviews[value])):
+        review = ordered_reviews[index]
+        batches.append(
+            _convergence_batch(
+                round_index, review, None, "review-only", run_status
+            )
+        )
+
+    batches.sort(key=_convergence_order)
+    return batches, []
+
+
+def _annotate_unscoped_round_reviews(
+    batches: list[dict[str, Any]],
+    unassigned: list[dict[str, Any]],
+    round_index: int,
+) -> None:
+    """Keep unscoped reviews visible without fabricating a candidate link."""
+
+    reviews = [
+        item
+        for item in unassigned
+        if str(item.get("name") or "") == "script_multipath_evaluator"
+        and _integer(item.get("roundIndex")) == round_index
+        and str(item.get("association") or "") == "missing-branch-scope"
+    ]
+    if not reviews:
+        return
+    refs = [item.get("detailRef") for item in reviews if item.get("detailRef")]
+    for batch in batches:
+        missing = batch.get("missingReview")
+        if not isinstance(missing, dict):
+            continue
+        missing["card"] = {
+            "primary": {
+                "label": "评审关联",
+                "value": "发现同轮评审,但无法安全关联到具体候选",
+            },
+            "secondary": [
+                _line("records", "未关联记录", f"{len(refs)} 条评审记录")
+            ],
+        }
+        missing["sourceNotice"] = "association-incomplete"
+        missing["unscopedReviewRefs"] = refs
+
+
+def _convergence_batch(
+    round_index: int,
+    review: dict[str, Any] | None,
+    decision: dict[str, Any] | None,
+    association: str,
+    run_status: str,
+    branch_ids: list[int] | None = None,
+) -> dict[str, Any]:
+    branch_ids = branch_ids or list(
+        dict.fromkeys(
+            (review or {}).get("branchIds")
+            or (decision or {}).get("branchIds")
+            or []
+        )
+    )
+    suffix = (
+        f"review-{review.get('eventId')}"
+        if review
+        else f"decision-{decision.get('recordId')}"
+        if decision
+        else "records-missing"
+    )
+    missing_context = _missing_multipath_context(run_status)
+    if review is not None and not review.get("decisionProjection"):
+        review = dict(review)
+        review["decisionProjection"] = _evaluation_decision(
+            review,
+            subtype="multipath-evaluation",
+            detail_ref=review.get("detailRef"),
+            prompt_ref=review.get("promptRef"),
+        )
+    missing_review = None
+    if review is None:
+        missing_review_id = f"round:{round_index}:{suffix}:review-missing"
+        missing_review = _story(
+            id=missing_review_id,
+            role="multipath-review",
+            kind=missing_context["kind"],
+            title="多方案评审",
+            label="评审记录",
+            value="未找到可与本次决策安全关联的多方案评审记录",
+            detail_ref=missing_review_id,
+            source_notice=missing_context["sourceNotice"],
+            status=missing_context.get("status"),
+        )
+    missing_decision = None
+    if decision is None:
+        missing_decision_id = f"round:{round_index}:{suffix}:decision-missing"
+        missing_decision = _story(
+            id=missing_decision_id,
+            role="multipath-decision",
+            kind=missing_context["kind"],
+            title="主 Agent 多路决策",
+            label=missing_context["label"],
+            value=missing_context["value"],
+            detail_ref=missing_decision_id,
+            source_notice=missing_context["sourceNotice"],
+            status=missing_context.get("status"),
+        )
+    if review is not None and decision is not None:
+        _attach_review_comparison(review, decision, association)
+    return {
+        "id": f"round:{round_index}:convergence:{suffix}",
+        "roundIndex": round_index,
+        "branchIds": branch_ids,
+        "review": review,
+        "decision": decision,
+        "missingReview": missing_review,
+        "missingDecision": missing_decision,
+        "association": association,
+    }
+
+
+def _round_evaluation(
+    round_index: int, evaluations: list[dict[str, Any]], run_status: str
+) -> dict[str, Any]:
+    if not evaluations:
+        context = _missing_evaluation_context(run_status)
+        status = context.get("status")
+        if not status:
+            status = (
+                "not-evaluated"
+                if context["sourceNotice"] == "record-missing"
+                else "unknown"
+            )
+        return _story(
+            id=f"round:{round_index}:evaluation",
+            role="round-evaluation",
+            kind=context["kind"],
+            title="主脚本整体评审",
+            label=context["label"],
+            value=context["value"],
+            detail_ref=f"round:{round_index}:evaluation",
+            source_notice=context["sourceNotice"],
+            status=status,
+        )
+    latest = evaluations[-1]
+    conclusion = _plain(latest.get("conclusion"))
+    projection = _evaluation_decision(
+        latest,
+        subtype="overall-evaluation",
+        detail_ref=latest.get("detailRef"),
+        prompt_ref=latest.get("promptRef"),
+    )
+    return _story(
+        id=f"round:{round_index}:evaluation",
+        role="round-evaluation",
+        kind="decision" if conclusion else "missing",
+        title="主脚本整体评审",
+        label="评审结论",
+        value=projection["card"]["primary"]["value"],
+        secondary=projection["card"]["secondary"],
+        detail_ref=latest.get("detailRef"),
+        source_notice="runtime-associated",
+        status=_evaluation_state(conclusion),
+        decision=projection,
+    )
+
+
+def _evaluation_decision(
+    evaluation: dict[str, Any],
+    *,
+    subtype: str,
+    detail_ref: Any,
+    prompt_ref: Any,
+) -> dict[str, Any]:
+    projection = _DECISIONS.evaluation(
+        {
+            "id": evaluation.get("id") or detail_ref,
+            "detailRef": detail_ref,
+            "promptRef": prompt_ref,
+            "subtype": subtype,
+            "subjects": evaluation.get("subjects") or [
+                f"方案 {value}" for value in evaluation.get("branchIds") or []
+            ],
+            "criteria": evaluation.get("criteria") or [],
+            "itemConclusions": evaluation.get("itemConclusions")
+            or evaluation.get("branchConclusions")
+            or [],
+            "branchEvaluations": evaluation.get("branchEvaluations") or [],
+            "comparisonRows": evaluation.get("comparisonRows") or [],
+            "achievements": evaluation.get("achievements") or [],
+            "problems": evaluation.get("problems") or [],
+            "conclusion": evaluation.get("conclusion"),
+            "recommendation": evaluation.get("recommendation")
+            or evaluation.get("comparison"),
+            "nextGoal": evaluation.get("nextGoal"),
+            "completeness": "partial"
+            if evaluation.get("notices")
+            else "complete",
+        }
+    )
+    full_conclusion = business_text(
+        evaluation.get("conclusion")
+        or evaluation.get("recommendation")
+        or evaluation.get("comparison")
+    )
+    if full_conclusion:
+        projection.setdefault("card", {}).setdefault("primary", {})[
+            "value"
+        ] = full_conclusion
+    return projection
+
+
+def _attach_review_comparison(
+    review: dict[str, Any], decision: dict[str, Any], association: str
+) -> None:
+    projection = decision.get("decisionProjection")
+    if not isinstance(projection, dict):
+        return
+    conclusions = {
+        int(item.get("branchId") or 0): item
+        for item in review.get("branchConclusions") or []
+        if isinstance(item, dict)
+    }
+    outcomes = {
+        int(item.get("branchId") or 0): item
+        for item in decision.get("branchOutcomes") or []
+        if isinstance(item, dict)
+    }
+    reasoning = business_text(decision.get("reasoning"))
+    rows: list[dict[str, Any]] = []
+    for branch_id in decision.get("branchIds") or []:
+        row = {
+            "candidate": f"方案 {branch_id}",
+            "recommendation": (conclusions.get(branch_id) or {}).get("conclusion")
+            or review.get("recommendation")
+            or "评审未给出单独结论",
+            "finalDecision": (outcomes.get(branch_id) or {}).get("label")
+            or "最终状态未记录",
+        }
+        handling = _explicit_recommendation_handling(reasoning, branch_id)
+        if handling:
+            row["handling"] = handling
+        rows.append(row)
+    projection.setdefault("body", {})["reviewComparison"] = rows
+    projection["body"]["reviewAssociation"] = association
+    review_input = {
+        "label": "同批候选方案的评审建议",
+        "summary": business_text(
+            review.get("recommendation") or review.get("comparison")
+        ),
+        "observedRelation": "returned-to-actor",
+        "decisionUse": "not-recorded",
+        "detailRef": review.get("detailRef"),
+    }
+    projection.setdefault("inputs", []).append(review_input)
+    projection.setdefault("notices", []).append(
+        {
+            "code": "same-batch-association",
+            "message": "评审与最终决定按同一批候选方案和发生顺序关联;系统没有保存直接的一对一关系。",
+        }
+    )
+    if rows:
+        blocks = projection.setdefault("detail", {}).setdefault("blocks", [])
+        blocks.insert(
+            0,
+            {
+                "type": "items",
+                "title": "参与判断",
+                "items": [
+                    {
+                        "label": review_input["label"],
+                        "value": review_input["summary"],
+                        "note": "可确认评审在最终决定前返回;是否采用应以下方的逐方案对照为准。",
+                        "detailRef": review_input["detailRef"],
+                    }
+                ],
+                "visualRole": "evidence",
+                "presentation": "list",
+                "collapsible": True,
+            },
+        )
+        blocks.insert(
+            3,
+            {
+                "type": "comparison",
+                "title": "评审建议与最终决定",
+                "rows": rows,
+                "visualRole": "section",
+                "presentation": "comparison",
+            },
+        )
+
+
+def _explicit_recommendation_handling(reasoning: str, branch_id: int) -> str | None:
+    if not reasoning:
+        return None
+    for sentence in re.split(r"[。;;\n]+", reasoning):
+        if not re.search(rf"方案\s*{branch_id}\b", sentence):
+            continue
+        if any(
+            token in sentence
+            for token in (
+                "采纳",
+                "吸收",
+                "保留",
+                "延后",
+                "转入",
+                "下一轮",
+                "转译",
+                "不直接合并",
+                "未采用",
+                "拒绝",
+                "不采纳",
+            )
+        ):
+            return sentence.strip()
+    return None
+
+
+_DATABASE_TIMEZONE = ZoneInfo("Asia/Shanghai")
+
+
+def _date_value(value: Any) -> datetime | None:
+    if isinstance(value, datetime):
+        parsed = value
+    elif isinstance(value, str) and value:
+        try:
+            parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+        except ValueError:
+            return None
+    else:
+        return None
+    if parsed.tzinfo is None:
+        parsed = parsed.replace(tzinfo=_DATABASE_TIMEZONE)
+    return parsed.astimezone(timezone.utc)
+
+
+def _not_after(value: Any, boundary: datetime | None) -> bool:
+    current = _date_value(value)
+    return boundary is None or current is None or current <= boundary
+
+
+def _record_order(row: dict[str, Any]) -> tuple[str, int]:
+    return (str(row.get("created_at") or ""), _integer(row.get("id")) or 0)
+
+
+def _runtime_order(row: dict[str, Any]) -> tuple[str, int]:
+    return (
+        str(row.get("startedAt") or row.get("endedAt") or ""),
+        _integer(row.get("eventId")) or 0,
+    )
+
+
+def _decision_order(row: dict[str, Any]) -> tuple[str, int]:
+    return (str(row.get("createdAt") or ""), _integer(row.get("recordId")) or 0)
+
+
+def _convergence_order(row: dict[str, Any]) -> tuple[str, int]:
+    review = row.get("review") or {}
+    decision = row.get("decision") or {}
+    return (
+        str(review.get("startedAt") or decision.get("createdAt") or ""),
+        _integer(review.get("eventId") or decision.get("recordId")) or 0,
+    )
+
+
+def _round_result(
+    record: dict[str, Any],
+    round_index: int,
+    branches: list[dict[str, Any]],
+    domain_info: list[dict[str, Any]],
+    state: str,
+    next_round: dict[str, Any] | None,
+) -> dict[str, Any]:
+    merged_content = [
+        branch
+        for branch in branches
+        if branch["pathType"] != "领域信息" and branch["status"] == "merged"
+    ]
+    script_summary = (
+        f"确认采用 {len(merged_content)} 个内容方案"
+        if merged_content
+        else "没有可确认的内容方案合入记录"
+    )
+    domain_summary = (
+        f"新增 {len(domain_info)} 条领域事实"
+        if domain_info
+        else "本轮没有新增领域事实记录"
+    )
+    if next_round:
+        next_step = f"数据库已记录第 {next_round.get('round_index')} 轮"
+    elif str(record.get("status") or "").lower() in TERMINAL_STATUSES:
+        next_step = "构建已经结束"
+    else:
+        next_step = "等待本轮收口"
+    primary = f"{script_summary};{domain_summary}"
+    return _story(
+        id=f"round:{round_index}:result",
+        role="round-result",
+        kind="result",
+        title="本轮产出",
+        label="产出",
+        value=primary,
+        secondary=[_line("next", "下一步", next_step)],
+        detail_ref=f"round:{round_index}:result",
+        artifact_ref=f"artifact:round:{round_index}",
+        has_changes=bool(merged_content or domain_info),
+        status=state,
+    )
+
+
+def _round_state(
+    record: dict[str, Any],
+    raw_round: dict[str, Any],
+    evaluation: dict[str, Any] | None,
+) -> str:
+    if evaluation and evaluation.get("status") not in {None, "unknown"}:
+        return str(evaluation["status"])
+    run_status = str(record.get("status") or "").lower()
+    if str(raw_round.get("status") or "").lower() == "open" and run_status not in TERMINAL_STATUSES:
+        return "running"
+    return "unknown"
+
+
+def _evaluation_state(conclusion: str | None) -> str:
+    text = str(conclusion or "")
+    if any(token in text for token in ("未通过", "不通过", "回退", "重试")):
+        return "needs-retry"
+    if "部分通过" in text:
+        return "partially-passed"
+    if "通过" in text:
+        return "passed"
+    return "unknown"
+
+
+def _final_result(
+    record: dict[str, Any], rounds: list[dict[str, Any]], artifact: dict[str, Any]
+) -> dict[str, Any]:
+    branches = [branch for round_ in rounds for branch in round_["branches"]]
+    statuses = Counter(branch["status"] for branch in branches)
+    counts = _snapshot_counts(artifact)
+    status = str(record.get("status") or "unknown")
+    summary = _plain(record.get("summary"))
+    error = _plain(record.get("error_message"))
+    primary = summary or error or _final_status_text(status)
+    return {
+        "id": "run:final-result",
+        "role": "final-result",
+        "kind": "result",
+        "title": "最终结果",
+        "status": status,
+        "terminal": status.lower() in TERMINAL_STATUSES,
+        "roundCount": len(rounds),
+        "branchCounts": {
+            "total": len(branches),
+            "merged": statuses.get("merged", 0),
+            "parked": statuses.get("parked", 0),
+            "discarded": statuses.get("discarded", 0),
+            "open": statuses.get("open", 0),
+        },
+        "summary": _plain(record.get("summary")),
+        "errorMessage": _plain(record.get("error_message")),
+        "artifact": {
+            "snapshotRef": "artifact:base:current",
+            "paragraphCount": counts["paragraphs"],
+            "elementCount": counts["elements"],
+            "linkCount": counts["links"],
+            "historicalVersion": "current-only",
+        },
+        "card": {
+            "primary": {"label": "构建结论", "value": primary},
+            "secondary": [
+                _line("rounds", "轮次", f"{len(rounds)} 轮"),
+                _line(
+                    "branches",
+                    "方案处置",
+                    f"采用 {statuses.get('merged', 0)} · 暂存 {statuses.get('parked', 0)} · 未采用 {statuses.get('discarded', 0)}",
+                ),
+                _line(
+                    "artifact",
+                    "主脚本规模",
+                    f"{counts['paragraphs']} 段落 · {counts['elements']} 元素 · {counts['links']} 关联",
+                ),
+            ],
+        },
+        "artifactRef": "artifact:base:current",
+        "detailRef": "run:final-result",
+    }
+
+
+def _final_status_text(status: str) -> str:
+    return {
+        "success": "构建已完成",
+        "completed": "构建已完成",
+        "partial": "构建部分完成",
+        "failed": "构建失败",
+        "stopped": "构建已停止",
+        "cancelled": "构建已取消",
+        "running": "构建进行中",
+    }.get(str(status or "").lower(), "构建状态已记录")
+
+
+def _story(
+    *,
+    id: str,
+    role: str,
+    kind: str,
+    title: str,
+    label: str,
+    value: Any,
+    secondary: list[dict[str, Any]] | None = None,
+    detail_ref: str | None = None,
+    has_changes: bool = False,
+    source_notice: str | None = None,
+    status: str | None = None,
+    artifact_ref: str | None = None,
+    decision: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    node = {
+        "id": id,
+        "role": role,
+        "kind": kind,
+        "title": title,
+        "card": (decision or {}).get("card")
+        or {
+            # Callers pass a deliberately selected card field. Keep that field
+            # intact; line clamping is a view concern, not a data projection.
+            "primary": {"label": label, "value": _plain(value)},
+            "secondary": [item for item in (secondary or []) if item.get("value")],
+        },
+        "hasChanges": bool(has_changes),
+    }
+    if detail_ref:
+        node["detailRef"] = detail_ref
+    if source_notice:
+        node["sourceNotice"] = source_notice
+    if status:
+        node["status"] = status
+    if artifact_ref:
+        node["artifactRef"] = artifact_ref
+    if decision:
+        node["decision"] = decision
+    return node
+
+
+def _line(key: str, label: str, value: Any, tone: str = "normal") -> dict[str, Any]:
+    return {"key": key, "label": label, "value": _plain(value), "tone": tone}
+
+
+def _plan_card_lines(paths: list[Any], note: Any) -> list[dict[str, Any]]:
+    lines: list[dict[str, Any]] = []
+    if paths:
+        route_values = [plan_path_summary(path, index) for index, path in enumerate(paths[:2], 1)]
+        route_summary = ";".join(route_values)
+        if len(paths) > 2:
+            route_summary += f";另有 {len(paths) - 2} 路,详情查看"
+        lines.append(_line("routes", "各路任务", route_summary))
+    reason = _plain(note)
+    if reason:
+        lines.append(_line("reason", "规划理由", reason))
+    return lines[:2]
+
+
+def _context_input_labels(
+    context: dict[str, Any] | None, *, relation: str | None = None
+) -> list[str]:
+    result: list[str] = []
+    for item in (context or {}).get("inputs") or []:
+        if not isinstance(item, dict):
+            continue
+        if relation and item.get("relation") != relation:
+            continue
+        label = _plain(item.get("label"))
+        if label and label not in result:
+            result.append(label)
+    return result[:3]
+
+
+def _direction_decision(
+    *,
+    id: str,
+    subtype: str,
+    context: dict[str, Any] | None,
+    decision_items: list[Any],
+    primary_label: str,
+    primary_value: Any,
+    secondary: list[dict[str, Any]],
+    prompt_ref: str,
+) -> dict[str, Any]:
+    context = context if isinstance(context, dict) else {}
+    decision = _DECISIONS.direction(
+        {
+            "id": id,
+            "detailRef": id,
+            "promptRef": prompt_ref,
+            "subtype": subtype,
+            "decisionItems": [str(item) for item in decision_items if item],
+            "explicitReasoning": context.get("explicitReasoning"),
+            "constraints": context.get("constraints") or [],
+            "inputs": context.get("inputs") or [],
+            "sourceDocument": context.get("sourceDocument"),
+            "implementationPlan": {
+                "mode": context.get("displayMode"),
+                "rawMode": context.get("rawMode"),
+                "routes": context.get("routeItems") or [],
+                "reasoning": context.get("explicitReasoning"),
+            }
+            if subtype == "implementation-plan"
+            else None,
+            "completeness": context.get("completeness"),
+        }
+    )
+    decision["card"] = {
+        "primary": {
+            "label": primary_label,
+            "value": _plain(primary_value) or "决策结论未记录",
+        },
+        "secondary": [item for item in secondary if item.get("value")][:3],
+    }
+    revisions = []
+    for revision in context.get("revisions") or []:
+        if not isinstance(revision, dict):
+            continue
+        paths = revision.get("paths") if isinstance(revision.get("paths"), list) else []
+        parts = [str(revision.get("mode"))] if revision.get("mode") else []
+        if paths:
+            parts.append(f"{len(paths)} 路方案")
+        revisions.append(
+            {
+                "current": bool(revision.get("current")),
+                "detailRef": revision.get("detailRef"),
+                "summary": " · ".join(parts) or "已保存一次版本",
+            }
+        )
+    if revisions:
+        decision["body"]["revisions"] = revisions
+    decision["technicalRefs"] = list(context.get("technicalRefs") or [])
+    return decision
+
+
+def _implementer_prompt_ref(retrieval_stage: dict[str, Any] | None) -> str | None:
+    event_id = _integer((retrieval_stage or {}).get("implementerEventId"))
+    return f"prompt:event:{event_id}" if event_id is not None else None
+
+
+def _snapshot_counts(value: Any) -> dict[str, int]:
+    if isinstance(value, dict) and isinstance(value.get("snapshot"), dict):
+        value = value["snapshot"]
+    if not isinstance(value, dict):
+        return {"paragraphs": 0, "elements": 0, "links": 0}
+    stats = value.get("stats") if isinstance(value.get("stats"), dict) else {}
+    if stats:
+        return {
+            "paragraphs": int(stats.get("paragraph_count") or 0),
+            "elements": int(stats.get("element_count") or 0),
+            "links": int(stats.get("link_count") or 0),
+        }
+    return {
+        "paragraphs": len(value.get("paragraphs") or []),
+        "elements": len(value.get("elements") or []),
+        "links": len(value.get("paragraphElements") or value.get("paragraph_elements") or []),
+    }
+
+
+def _ids(value: Any) -> list[int]:
+    if not isinstance(value, list):
+        return []
+    result: list[int] = []
+    for item in value:
+        number = _integer(item)
+        if number is not None and number not in result:
+            result.append(number)
+    return result
+
+
+def _implementer_tasks_by_branch(
+    events: list[dict[str, Any]],
+) -> dict[int, str]:
+    """Return complete dispatch tasks when the lightweight event carries them.
+
+    Historical bundles may only retain previews; callers deliberately keep the
+    branch business record as a labelled fallback in that case.
+    """
+
+    result: dict[int, str] = {}
+    for event in events:
+        if (
+            str(event.get("event_type") or "") != "agent_invoke"
+            or str(event.get("event_name") or "") != "script_implementer"
+        ):
+            continue
+        branch_id = _integer(event.get("branch_id"))
+        if branch_id is None:
+            continue
+        data = event.get("inputData")
+        task = data.get("task") if isinstance(data, dict) else None
+        if not task:
+            wrapped = event.get("input")
+            content = wrapped.get("content") if isinstance(wrapped, dict) else None
+            task = content.get("task") if isinstance(content, dict) else None
+        text = branch_task_text(task)
+        if text:
+            result[branch_id] = text
+    return result
+
+
+def _round_summary(
+    *,
+    round_index: int,
+    goal: dict[str, Any],
+    plan: dict[str, Any],
+    branches: list[dict[str, Any]],
+    batches: list[dict[str, Any]],
+    domain_info: list[dict[str, Any]],
+    run_status: str,
+) -> dict[str, Any]:
+    paths = plan.get("paths") or []
+    branch_ids = sorted(
+        {
+            branch_id
+            for batch in batches
+            for branch_id in batch.get("branchIds") or []
+        }
+    )
+    decision_context = _missing_multipath_context(run_status)
+    decision_summary = (
+        ";".join(_preview(_business_terms(batch.get("decision")), 64) for batch in batches[:2])
+        if batches
+        else decision_context["value"]
+    )
+    if len(batches) > 2:
+        decision_summary += f";另有 {len(batches) - 2} 个决策批次"
+    merged_content_count = sum(
+        1
+        for branch in branches
+        if branch.get("pathType") != "领域信息" and branch.get("status") == "merged"
+    )
+    script_summary = (
+        f"{merged_content_count} 个内容方案进入主脚本"
+        if merged_content_count
+        else "没有可确认的内容方案合入记录"
+    )
+    domain_summary = (
+        f"新增 {len(domain_info)} 条领域事实"
+        if domain_info
+        else "没有新增领域事实记录"
+    )
+    return {
+        "title": f"第 {round_index} 轮",
+        "goal": goal,
+        "approach": {
+            "mode": plan.get("mode"),
+            "candidateCount": len(branches) or len(paths),
+            "plannedPathCount": len(paths),
+            "contentBranchCount": sum(
+                1 for branch in branches if branch.get("pathType") == "内容"
+            ),
+            "domainBranchCount": sum(
+                1 for branch in branches if branch.get("pathType") == "领域信息"
+            ),
+            "summary": _plan_summary(plan),
+        },
+        "decision": {
+            "batchCount": len(batches),
+            "branchIds": branch_ids,
+            "summary": decision_summary,
+        },
+        "output": {
+            "mergedContentCount": merged_content_count,
+            "domainFactCount": len(domain_info),
+            "scriptSummary": script_summary,
+            "domainSummary": domain_summary,
+            "summary": f"{script_summary};{domain_summary}",
+        },
+    }
+
+
+def _goal_summary(value: Any) -> dict[str, Any]:
+    parsed = parse_goal_text(value)
+    if not parsed.get("raw"):
+        return {
+            "headline": "本轮目标未记录",
+            "itemCount": 0,
+            "previewItems": [],
+            "truncated": False,
+        }
+    items = list(parsed.get("focusItems") or [])
+    headline_source = str(parsed.get("headline") or parsed.get("raw"))
+    headline = _preview(headline_source, 96)
+    preview_items = [_preview(item, 72) for item in items[:3]]
+    return {
+        "headline": headline,
+        "itemCount": len(items),
+        "previewItems": preview_items,
+        "truncated": bool(
+            items
+            or len(headline_source) > len(headline)
+            or len(items) > len(preview_items)
+        ),
+    }
+
+
+def _business_terms(value: Any) -> str:
+    text = _plain(value) or "未记录"
+    text = re.sub(r"(?i)\s*\bbranch\s*#?(\d+)\b\s*", r"方案 \1 ", text)
+    text = re.sub(r"(?i)\s*\bbase\b", "主脚本", text)
+    text = re.sub(r"\s+([,。;!?、:)】,.])", r"\1", text)
+    return text
+
+
+def _missing_multipath_context(run_status: str) -> dict[str, Any]:
+    status = str(run_status or "unknown").lower()
+    if status == "running":
+        return {
+            "kind": "execution",
+            "label": "当前进度",
+            "value": "等待主 Agent 比较候选方案",
+            "sourceNotice": None,
+            "status": "running",
+        }
+    interrupted = {
+        "stopped": "构建停止前尚未形成多路决策",
+        "cancelled": "构建取消前尚未形成多路决策",
+        "cancel": "构建取消前尚未形成多路决策",
+        "cancle": "构建取消前尚未形成多路决策",
+        "failed": "构建失败前尚未形成多路决策",
+    }
+    if status in interrupted:
+        return {
+            "kind": "missing",
+            "label": "流程进度",
+            "value": interrupted[status],
+            "sourceNotice": "process-incomplete",
+        }
+    return {
+        "kind": "missing",
+        "label": "记录状态",
+        "value": "构建已经结束,但未找到主 Agent 多路决策记录",
+        "sourceNotice": "record-missing",
+    }
+
+
+def _missing_evaluation_context(run_status: str) -> dict[str, Any]:
+    status = str(run_status or "unknown").lower()
+    if status == "running":
+        return {
+            "kind": "execution",
+            "label": "当前进度",
+            "value": "等待整体评审",
+            "sourceNotice": None,
+            "status": "running",
+        }
+    interrupted = {
+        "stopped": "构建停止前尚未进行整体评审",
+        "cancelled": "构建取消前尚未进行整体评审",
+        "cancel": "构建取消前尚未进行整体评审",
+        "cancle": "构建取消前尚未进行整体评审",
+        "failed": "构建失败前尚未形成整体评审结论",
+    }
+    if status in interrupted:
+        return {
+            "kind": "missing",
+            "label": "流程进度",
+            "value": interrupted[status],
+            "sourceNotice": "process-incomplete",
+        }
+    return {
+        "kind": "missing",
+        "label": "记录状态",
+        "value": "构建已经结束,但未找到明确的整体评审结论",
+        "sourceNotice": "record-missing",
+    }
+
+
+def _branch_disposition_context(branch_status: str, run_status: str) -> dict[str, Any]:
+    if branch_status != "open":
+        return {
+            "kind": "decision",
+            "label": _status_label(branch_status),
+            "sourceNotice": None,
+        }
+    status = str(run_status or "unknown").lower()
+    if status == "running":
+        return {
+            "kind": "execution",
+            "label": "等待主 Agent 处置",
+            "sourceNotice": None,
+        }
+    interrupted = {
+        "stopped": "构建停止时尚未处置",
+        "cancelled": "构建取消时尚未处置",
+        "cancel": "构建取消时尚未处置",
+        "cancle": "构建取消时尚未处置",
+        "failed": "构建失败时尚未处置",
+    }
+    if status in interrupted:
+        return {
+            "kind": "missing",
+            "label": interrupted[status],
+            "sourceNotice": "process-incomplete",
+        }
+    return {
+        "kind": "missing",
+        "label": "构建结束时仍未处置",
+        "sourceNotice": "record-missing",
+    }
+
+
+def _plan_summary(plan: dict[str, Any]) -> str:
+    paths = plan.get("paths") or []
+    if not paths:
+        return "多路规划未记录"
+    return f"{plan.get('mode') or '方式未记录'}:{len(paths)} 个候选方案"
+
+
+def _status_label(status: str) -> str:
+    return {
+        "merged": "已采用",
+        "parked": "暂存",
+        "discarded": "未采用",
+        "open": "尚未处置",
+    }.get(status, status or "状态未记录")
+
+
+def _data_shape(
+    branches: list[dict[str, Any]],
+    multipath_decisions: list[dict[str, Any]],
+    domain_info: list[dict[str, Any]],
+    events: list[dict[str, Any]],
+) -> str:
+    if (
+        multipath_decisions
+        or domain_info
+        or events
+        or any(branch.get("path_type") for branch in branches)
+    ):
+        return "current"
+    return "legacy-incomplete"
+
+
+def _warnings(
+    *,
+    data_shape: str,
+    ignored_legacy_count: int,
+    rounds: list[dict[str, Any]],
+    runtime_unassigned: list[dict[str, Any]],
+    business_unassigned: list[dict[str, Any]],
+) -> list[dict[str, str]]:
+    warnings: list[dict[str, str]] = []
+    if data_shape == "legacy-incomplete":
+        warnings.append(
+            {
+                "id": "legacy-shape",
+                "level": "warning",
+                "message": "这是旧结构记录,只展示新数据契约能够直接确认的内容。",
+            }
+        )
+    if ignored_legacy_count:
+        warnings.append(
+            {
+                "id": "ignored-legacy-data-decisions",
+                "level": "info",
+                "message": f"已忽略 {ignored_legacy_count} 条旧语义主决策记录。",
+            }
+        )
+    missing_rounds = [
+        round_["roundIndex"]
+        for round_ in rounds
+        if round_["branches"]
+        and not any(
+            batch.get("decision")
+            for batch in round_.get("convergenceBatches") or []
+        )
+        and any(
+            (batch.get("missingDecision") or {}).get("sourceNotice")
+            == "record-missing"
+            for batch in round_.get("convergenceBatches") or []
+        )
+    ]
+    if missing_rounds:
+        warnings.append(
+            {
+                "id": "missing-multipath-decisions",
+                "level": "warning",
+                "message": "第 " + "、".join(map(str, missing_rounds)) + " 轮未记录主 Agent 多路决策。",
+            }
+        )
+    if runtime_unassigned:
+        warnings.append(
+            {
+                "id": "unassigned-runtime-events",
+                "level": "info",
+                "message": f"有 {len(runtime_unassigned)} 条运行记录无法安全归属,已留在技术详情中。",
+            }
+        )
+    if business_unassigned:
+        warnings.append(
+            {
+                "id": "unassigned-business-records",
+                "level": "warning",
+                "message": f"有 {len(business_unassigned)} 条多路决策缺少可确认的轮次归属。",
+            }
+        )
+    return warnings
+
+
+def _plain(value: Any) -> str | None:
+    if value is None:
+        return None
+    text = str(value).replace("\r\n", "\n").strip()
+    if not text:
+        return None
+    text = re.sub(r"```[a-zA-Z0-9_-]*", "", text).replace("```", "")
+    text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M)
+    text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
+    text = re.sub(r"(?m)^\s*[-*]\s+", "• ", text)
+    return text.strip()
+
+
+def _preview(value: Any, limit: int = 140) -> str:
+    text = _plain(value) or "未记录"
+    text = re.sub(r"\s+", " ", text)
+    if len(text) <= limit:
+        return text
+    window = text[:limit]
+    cut = max(window.rfind(mark) for mark in "。!?;")
+    if cut >= max(30, limit // 2):
+        return window[: cut + 1]
+    return window.rstrip(",、;:,. ") + "…"
+
+
+def _integer(value: Any) -> int | None:
+    try:
+        return int(value) if value is not None else None
+    except (TypeError, ValueError):
+        return None

+ 66 - 0
visualization-old/backend/app/execution_view_payload.py

@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+
+_REVIEW_KEYS = {
+    "id",
+    "eventId",
+    "roundIndex",
+    "branchIds",
+    "branchConclusions",
+    "comparison",
+    "recommendation",
+    "conclusion",
+    "status",
+    "startedAt",
+    "endedAt",
+    "detailRef",
+    "promptRef",
+    "decisionProjection",
+}
+
+
+def compact_execution_view(view: dict[str, Any]) -> dict[str, Any]:
+    """Return the card-level V8 payload used by the main canvas.
+
+    Inspector blocks, evaluator bodies and raw runtime previews remain available
+    through the round/activity endpoints.  Keeping this boundary here prevents
+    the browser from downloading the same large report once per card.
+    """
+
+    compact = deepcopy(view)
+    _strip_decision_details(compact)
+    for round_ in compact.get("rounds") or []:
+        for branch in round_.get("branches") or []:
+            branch["dataDecisions"] = [
+                {
+                    "id": item.get("id"),
+                    "node": item.get("node"),
+                }
+                for item in branch.get("dataDecisions") or []
+                if isinstance(item, dict)
+            ]
+        for batch in round_.get("convergenceBatches") or []:
+            review = batch.get("review")
+            if isinstance(review, dict):
+                batch["review"] = {
+                    key: value for key, value in review.items() if key in _REVIEW_KEYS
+                }
+    return compact
+
+
+def _strip_decision_details(value: Any) -> None:
+    if isinstance(value, dict):
+        if value.get("semanticKind") == "decision":
+            value.pop("detail", None)
+        value.pop("inputPreview", None)
+        value.pop("outputPreview", None)
+        value.pop("agentType", None)
+        for child in value.values():
+            _strip_decision_details(child)
+        return
+    if isinstance(value, list):
+        for child in value:
+            _strip_decision_details(child)

+ 1646 - 0
visualization-old/backend/app/inspector_projection.py

@@ -0,0 +1,1646 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from .business_detail_text import (
+    assessment_text,
+    batches_markdown,
+    branch_task_sections,
+    branch_task_text,
+    branches_markdown,
+    business_text,
+    decisions_markdown,
+    event_business_sections,
+    source_markdown,
+    clean_report,
+)
+from .sanitizer import sanitize
+from .retrieval_detail_projection import (
+    is_retrieval_detail_event,
+    project_retrieval_event,
+)
+
+
+def project_round_detail(
+    script_build_id: int,
+    round_: dict[str, Any],
+    bundle: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    raw_round = next(
+        (
+            item
+            for item in (bundle or {}).get("rounds", [])
+            if int(item.get("round_index") or 0) == int(round_.get("roundIndex") or 0)
+        ),
+        None,
+    )
+    sections = [
+        _section(
+            "goal",
+            "本轮目标",
+            (raw_round or {}).get("goal")
+            or (round_.get("summary", {}).get("goal") or {}).get("headline"),
+        ),
+        _section("plan", "当前保存的规划", _plan_text(round_.get("plan") or {})),
+        _section("branches", "候选方案", branches_markdown(round_.get("branches") or [])),
+        _section(
+            "decisions",
+            "候选评审与主 Agent 决策",
+            _convergence_markdown(round_.get("convergenceBatches") or []),
+        ),
+        _section(
+            "evaluation",
+            "主脚本整体评审",
+            _node_value(round_.get("overallEvaluation")) or "未找到明确整轮评审结论",
+        ),
+        _section("result", "本轮产出", _node_value(round_.get("result"))),
+    ]
+    result = round_.get("result") or {}
+    changes = None
+    if result.get("hasChanges"):
+        changes = {
+            "summary": _node_value(result),
+            "sections": [
+                item
+                for item in [
+                    _section(
+                        "content",
+                        "内容方案",
+                        _round_content_changes(round_.get("branches") or []),
+                    ),
+                    _section(
+                        "domain",
+                        "领域信息",
+                        _round_domain_changes(round_.get("branches") or []),
+                    ),
+                ]
+                if item
+            ],
+            "artifactRef": "artifact:base:current",
+            "exactness": "current-only",
+        }
+    return _payload(
+        sections,
+        {
+            "scriptBuildId": script_build_id,
+            "round": raw_round,
+            "multipathDecisions": [
+                item
+                for item in (bundle or {}).get("multipathDecisions", [])
+                if int(item.get("round_index") or 0) == int(round_.get("roundIndex") or 0)
+            ],
+        },
+        changes,
+    )
+
+
+def project_activity_detail(
+    script_build_id: int,
+    activity_id: str,
+    view: dict[str, Any],
+    bundle: dict[str, Any],
+    event_detail: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    if activity_id == "run:final-result":
+        return _final_result_detail(script_build_id, view, bundle)
+
+    missing_convergence = _find_missing_convergence(view, activity_id)
+    if missing_convergence is not None:
+        node, batch = missing_convergence
+        return _payload(
+            [
+                _section(
+                    "status",
+                    str(node.get("title") or "记录状态"),
+                    _node_value(node),
+                    "summary",
+                )
+            ],
+            {
+                "scriptBuildId": script_build_id,
+                "roundIndex": batch.get("roundIndex"),
+                "branchIds": batch.get("branchIds") or [],
+                "association": batch.get("association"),
+                "unscopedReviewRefs": node.get("unscopedReviewRefs") or [],
+            },
+        )
+
+    decision = _find_decision_projection(view, activity_id)
+    if decision is not None:
+        return _decision_detail_payload(
+            script_build_id, decision, bundle, activity_id, event_detail
+        )
+
+    if activity_id.startswith("domain-info:"):
+        record_id = _suffix_int(activity_id)
+        row = _find_by_id(bundle.get("domainInfo") or [], record_id)
+        if not row:
+            raise KeyError(activity_id)
+        return _payload(
+            [
+                _section("fact", "已核实领域事实", row.get("content")),
+                _section("verification", "核实说明", row.get("note")),
+                _section("source", "信息出处", _source_value(row.get("source"))),
+                _section(
+                    "use",
+                    "用途与存疑状态",
+                    "已作为领域事实独立保存;是否进入具体候选脚本文案未记录。",
+                ),
+            ],
+            {"scriptBuildId": script_build_id, "table": "script_build_domain_info", "record": row},
+            {
+                "summary": "这条领域事实已经独立写入领域信息库。",
+                "sections": [],
+                "exactness": "exact",
+            },
+        )
+
+    if activity_id.startswith("retrieval-direct:"):
+        group = _find_retrieval_item(view, "directToolGroups", activity_id)
+        if not group:
+            raise KeyError(activity_id)
+        calls = group.get("calls") or []
+        payload = _payload(
+            [
+                _section("purpose", "读取方式", "实现 Agent 直接调用只读工具,补齐创作所需的上下文。"),
+                _section("sources", "读取了什么", _direct_sources_markdown(group)),
+                _section("result", "得到什么", _direct_result_markdown(calls), "summary"),
+            ],
+            {
+                "scriptBuildId": script_build_id,
+                "retrievalGroup": group,
+                "events": _events_for_ids(bundle, [item.get("eventId") for item in calls]),
+            },
+        )
+        payload.update(
+            {
+                "detailKind": "retrieval-group",
+                "retrievalKind": "direct-tools",
+                "activities": [
+                    {
+                        "id": item.get("id") or item.get("detailRef"),
+                        "kind": "tool",
+                        "label": item.get("businessLabel") or "读取相关信息",
+                        "status": item.get("status") or "unknown",
+                        "summary": item.get("resultSummary") or "",
+                        "detailRef": item.get("detailRef"),
+                    }
+                    for item in calls
+                ],
+            }
+        )
+        return payload
+
+    if activity_id.startswith("retrieval-agent:"):
+        run = _find_retrieval_item(view, "agentRuns", activity_id)
+        if not run:
+            raise KeyError(activity_id)
+        event = event_detail or _event_by_id(bundle, run.get("eventId"))
+        screening = run.get("screening") or {}
+        full_task = _event_nested_value(event, "input", "task")
+        raw_screening_text = _event_nested_value(event, "output", "summary")
+        if not raw_screening_text:
+            raw_screening_text = screening.get("preview")
+        screening_text = clean_report(raw_screening_text)
+        payload = _payload(
+            [
+                _section("task", "完整取数任务", full_task or run.get("taskPreview") or "取数目标未记录"),
+                _section("queries", "查询过程", _agent_query_markdown(run)),
+                _section("hits", "原始命中", _agent_hit_markdown(run)),
+                _section(
+                    "screening",
+                    "初筛整理",
+                    screening_text or _screening_fallback(screening.get("state")),
+                    "report",
+                ),
+            ],
+            {
+                "scriptBuildId": script_build_id,
+                "agentRun": run,
+                "agentEvent": event,
+                "queryEvents": _events_for_ids(bundle, [item.get("eventId") for item in run.get("attempts") or []]),
+            },
+        )
+        activities = [
+            {
+                "id": item.get("id") or item.get("detailRef"),
+                "kind": "query",
+                "label": item.get("queryLabel") or "查询内容详见详情",
+                "status": item.get("status") or "unknown",
+                "resultCount": item.get("resultCount"),
+                "detailRef": item.get("detailRef"),
+            }
+            for item in run.get("attempts") or []
+        ]
+        payload.update(
+            {
+                "detailKind": "retrieval-agent",
+                "retrievalKind": "agent",
+                "activities": activities,
+            }
+        )
+        if not activities:
+            if script_build_id <= 433:
+                payload["missingActivityNotice"] = "该历史运行没有结构化查询明细。"
+            elif run.get("agentType") == "retrieve_data_pattern_relation":
+                payload["missingActivityNotice"] = (
+                    "本次运行未保存可下钻的结构化查询记录。"
+                )
+        return payload
+
+    round_index, branch_id, role = _activity_parts(activity_id)
+    round_ = _find_round(view, round_index) if round_index is not None else None
+    if round_ is None:
+        raise KeyError(activity_id)
+    if branch_id is None:
+        if role is None:
+            return project_round_detail(script_build_id, round_, bundle)
+        node = _round_node(round_, role)
+        if not node:
+            raise KeyError(activity_id)
+        raw_round = next(
+            (
+                item
+                for item in bundle.get("rounds") or []
+                if int(item.get("round_index") or 0) == round_index
+            ),
+            None,
+        )
+        if role == "result":
+            return _round_result_detail(script_build_id, round_, raw_round, bundle)
+        return _node_detail(script_build_id, node, raw_round)
+
+    branch = next(
+        (
+            item
+            for item in round_.get("branches") or []
+            if int(item.get("branchId") or 0) == branch_id
+        ),
+        None,
+    )
+    raw_branch = next(
+        (
+            item
+            for item in bundle.get("branches") or []
+            if int(item.get("branch_id") or 0) == branch_id
+        ),
+        None,
+    )
+    if not branch:
+        raise KeyError(activity_id)
+    if role is None:
+        return _branch_detail(script_build_id, branch, raw_branch, bundle)
+    if role == "retrieval":
+        return _retrieval_stage_detail(script_build_id, branch, raw_branch, bundle)
+    node = _branch_node(branch, role)
+    if not node:
+        raise KeyError(activity_id)
+    if role == "output":
+        return _branch_output_detail(script_build_id, branch, raw_branch, bundle)
+    if role == "task":
+        return _branch_task_detail(
+            script_build_id,
+            branch,
+            raw_branch,
+            bundle,
+            round_index=round_index,
+            branch_id=branch_id,
+        )
+    return _node_detail(script_build_id, node, raw_branch)
+
+
+def project_event_detail(
+    script_build_id: int,
+    event: dict[str, Any],
+    *,
+    run_status: str | None = None,
+) -> dict[str, Any]:
+    if is_retrieval_detail_event(event):
+        payload = project_retrieval_event(event, run_status=run_status)
+        payload["technical"] = sanitize(
+            {
+                "source": {
+                    "scriptBuildId": script_build_id,
+                    "table": "script_build_event / script_build_event_body",
+                    "detailRef": f"event:{event.get('id')}",
+                },
+                **_event_technical_groups(event),
+            },
+            max_text=None,
+        )
+        return sanitize(payload, max_text=None)
+    if (
+        event.get("event_name") == "think_and_plan"
+        and event.get("agent_role") == "main"
+        and int(event.get("agent_depth") or 0) == 0
+    ):
+        raw_input = event.get("input")
+        content = raw_input.get("content") if isinstance(raw_input, dict) else None
+        data = content if isinstance(content, dict) else event.get("inputData")
+        data = data if isinstance(data, dict) else {}
+        return _payload(
+            [
+                _section(
+                    "summary",
+                    "规划概要",
+                    data.get("thought_summary"),
+                    "summary",
+                    visual_role="key-result",
+                ),
+                _section(
+                    "thought",
+                    "完整思考",
+                    data.get("thought"),
+                    "report",
+                    visual_role="section",
+                ),
+                _section(
+                    "plan",
+                    "执行计划",
+                    data.get("plan"),
+                    visual_role="section",
+                ),
+                _section(
+                    "action",
+                    "下一步",
+                    data.get("action"),
+                    visual_role="reason",
+                ),
+            ],
+            {
+                "scriptBuildId": script_build_id,
+                "table": "script_build_event / script_build_event_body",
+                "event": event,
+            },
+        )
+    return _payload(
+        event_business_sections(event),
+        {"scriptBuildId": script_build_id, "table": "script_build_event / script_build_event_body", "event": event},
+    )
+
+
+def project_artifact_detail(
+    script_build_id: int, snapshot_ref: str, artifact: dict[str, Any]
+) -> dict[str, Any]:
+    paragraphs, elements, links = _normalized_artifact_records(artifact)
+    exactness = "current-only" if "base:current" in snapshot_ref else _artifact_exactness(artifact)
+    context = artifact.get("artifactContext") if isinstance(artifact.get("artifactContext"), dict) else {}
+    context = {
+        "type": context.get("type") or ("final" if "base:current" in snapshot_ref else "candidate"),
+        **context,
+        "exactness": exactness,
+    }
+    candidate = context.get("type") == "candidate"
+    scale_title = "候选脚本规模" if candidate else "主脚本规模"
+    content_summary = "候选脚本内容" if candidate else "当前主脚本内容"
+    payload = _payload(
+        [
+            _section("summary", scale_title, f"{len(paragraphs)} 个段落、{len(elements)} 个元素、{len(links)} 条关联"),
+        ],
+        {
+            "scriptBuildId": script_build_id,
+            "snapshotRef": snapshot_ref,
+            "sourceOrigin": artifact.get("sourceOrigin") or "database",
+            "exactness": exactness,
+            "artifactContext": context,
+            "recordCounts": {
+                "paragraphs": len(paragraphs),
+                "elements": len(elements),
+                "links": len(links),
+            },
+        },
+        {
+            "summary": content_summary,
+            "sections": [
+                _section(
+                    "counts",
+                    "内容规模",
+                    f"{len(paragraphs)} 个段落、{len(elements)} 个元素、{len(links)} 条关联",
+                ),
+            ],
+            "artifactRef": snapshot_ref,
+            "exactness": exactness,
+        },
+    )
+    payload["scriptTable"] = sanitize(
+        _script_table_projection(snapshot_ref, artifact, exactness),
+        max_text=None,
+    )
+    payload["artifactContext"] = sanitize(context, max_text=None)
+    return payload
+
+
+_ELEMENT_REFERENCE = re.compile(r"\[([^\]:]+):(\d+)\]")
+_SCRIPT_SECTIONS = (
+    ("theme", "主题"),
+    ("form", "形式"),
+    ("function", "作用"),
+    ("feeling", "感受"),
+)
+
+
+def _script_table_projection(
+    snapshot_ref: str, artifact: dict[str, Any], exactness: str
+) -> dict[str, Any]:
+    snapshot = artifact.get("snapshot") if isinstance(artifact.get("snapshot"), dict) else artifact
+    snapshot = snapshot if isinstance(snapshot, dict) else {}
+    paragraphs, elements, links = _normalized_artifact_records(artifact)
+
+    element_names: dict[int, str] = {}
+    for element in elements:
+        element_id = _as_int(element.get("effectiveId") or element.get("id"))
+        row_id = _as_int(element.get("rowId") or element.get("id"))
+        name = str(element.get("name") or "").strip()
+        if element_id is not None:
+            element_names[element_id] = name
+        if row_id is not None:
+            element_names[row_id] = name
+
+    paragraph_rows = _ordered_paragraphs(
+        [_script_paragraph(row, element_names) for row in paragraphs]
+    )
+
+    paragraph_lookup: dict[int, dict[str, Any]] = {}
+    for paragraph in paragraph_rows:
+        for key in ("id", "rowId"):
+            value = _as_int(paragraph.get(key))
+            if value is not None:
+                paragraph_lookup[value] = paragraph
+
+    linked_paragraphs: dict[int, list[dict[str, Any]]] = {}
+    for link in links:
+        element_id = _as_int(link.get("element_id") or link.get("elementId"))
+        paragraph_id = _as_int(link.get("paragraph_id") or link.get("paragraphId"))
+        paragraph = paragraph_lookup.get(paragraph_id) if paragraph_id is not None else None
+        if element_id is None or paragraph is None:
+            continue
+        linked_paragraphs.setdefault(element_id, []).append(
+            {
+                "id": paragraph.get("id"),
+                "index": paragraph.get("index"),
+                "name": paragraph.get("name"),
+            }
+        )
+
+    element_rows = []
+    for element in elements:
+        element_id = element.get("effectiveId") or element.get("id")
+        row_id = element.get("rowId") or element.get("id")
+        link_keys = {
+            value
+            for value in (_as_int(row_id), _as_int(element_id))
+            if value is not None
+        }
+        linked = [
+            paragraph
+            for key in link_keys
+            for paragraph in linked_paragraphs.get(key, [])
+        ]
+        element_rows.append(
+            {
+                "id": element_id,
+                "rowId": row_id,
+                "name": element.get("name") or "未命名元素",
+                "primaryDimension": element.get("dimension_primary"),
+                "secondaryDimension": element.get("dimension_secondary"),
+                "paragraphs": _unique_records(linked, "id"),
+            }
+        )
+    element_rows.sort(key=lambda row: (str(row.get("name") or ""), _sort_number(row.get("id"))))
+
+    return {
+        "snapshotRef": snapshot_ref,
+        "exactness": exactness,
+        "sourceOrigin": artifact.get("sourceOrigin") or snapshot.get("sourceOrigin") or "database",
+        "counts": {
+            "paragraphs": len(paragraph_rows),
+            "elements": len(element_rows),
+            "links": len(links),
+        },
+        "maxDepth": (
+            max((int(row.get("depth") or 0) for row in paragraph_rows), default=0) + 1
+            if paragraph_rows
+            else 0
+        ),
+        "paragraphs": paragraph_rows,
+        "elements": element_rows,
+    }
+
+
+def _normalized_artifact_records(
+    artifact: dict[str, Any],
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
+    """Normalize current overlays and frozen branch snapshots to one table shape.
+
+    Current overlays store a flat ``paragraphs`` list plus top-level
+    ``paragraphElements``.  Frozen ``content_snapshot`` rows store second-level
+    paragraphs under ``sub_paragraphs`` and links under each paragraph's
+    ``linked_elements``.  The visualizer must preserve both without pretending
+    that a truncated top-level list is the complete candidate script.
+    """
+    snapshot = artifact.get("snapshot") if isinstance(artifact.get("snapshot"), dict) else artifact
+    snapshot = snapshot if isinstance(snapshot, dict) else {}
+    paragraphs: list[dict[str, Any]] = []
+    seen_paragraphs: set[tuple[str, str]] = set()
+
+    def visit(row: Any, parent_id: Any = None) -> None:
+        if not isinstance(row, dict):
+            return
+        current = dict(row)
+        children = current.pop("sub_paragraphs", None) or current.pop("subParagraphs", None) or []
+        if parent_id is not None and current.get("parent_id") is None and current.get("parentId") is None:
+            current["parent_id"] = parent_id
+        row_id = current.get("rowId") or current.get("id")
+        key = (str(row_id), str(current.get("baseRefId") or current.get("base_ref_id") or ""))
+        if key not in seen_paragraphs:
+            seen_paragraphs.add(key)
+            paragraphs.append(current)
+        effective_parent = current.get("effectiveId") or current.get("id") or row_id
+        for child in children if isinstance(children, list) else []:
+            visit(child, effective_parent)
+
+    for paragraph in snapshot.get("paragraphs") or []:
+        visit(paragraph)
+
+    elements = [item for item in snapshot.get("elements") or [] if isinstance(item, dict)]
+    links: list[dict[str, Any]] = []
+    seen_links: set[tuple[str, str]] = set()
+
+    def append_link(link: Any, paragraph_id: Any = None) -> None:
+        if not isinstance(link, dict):
+            return
+        current = dict(link)
+        if paragraph_id is not None and current.get("paragraph_id") is None and current.get("paragraphId") is None:
+            current["paragraph_id"] = paragraph_id
+        element_id = current.get("element_id") or current.get("elementId")
+        linked_paragraph = current.get("paragraph_id") or current.get("paragraphId")
+        if element_id is None or linked_paragraph is None:
+            return
+        key = (str(linked_paragraph), str(element_id))
+        if key in seen_links:
+            return
+        seen_links.add(key)
+        links.append(current)
+
+    for link in snapshot.get("paragraphElements") or snapshot.get("paragraph_elements") or []:
+        append_link(link)
+    for paragraph in paragraphs:
+        paragraph_id = paragraph.get("effectiveId") or paragraph.get("id") or paragraph.get("rowId")
+        nested_links = paragraph.get("linked_elements") or paragraph.get("linkedElements") or []
+        if isinstance(nested_links, list) and nested_links:
+            for link in nested_links:
+                append_link(link, paragraph_id)
+        else:
+            for element_id in paragraph.get("linked_element_ids") or paragraph.get("linkedElementIds") or []:
+                append_link({"element_id": element_id}, paragraph_id)
+
+    return paragraphs, elements, links
+
+
+def _script_paragraph(row: dict[str, Any], element_names: dict[int, str]) -> dict[str, Any]:
+    full_description = str(row.get("full_description") or "")
+    return {
+        "id": row.get("effectiveId") or row.get("id"),
+        "rowId": row.get("rowId") or row.get("id"),
+        "parentId": row.get("parent_id"),
+        "depth": 0,
+        "index": row.get("paragraph_index"),
+        "level": row.get("level"),
+        "name": row.get("name") or "未命名段落",
+        "contentRange": _content_range(row.get("content_range")),
+        "sections": {
+            key: {
+                "label": label,
+                "description": row.get(key),
+                "descriptionParts": _description_parts(
+                    str(row.get(key) or ""), element_names
+                ),
+                "dimensions": _dimensions(row.get(f"{key}_elements")),
+            }
+            for key, label in _SCRIPT_SECTIONS
+        },
+        "description": row.get("description"),
+        "fullDescription": full_description,
+        "fullDescriptionParts": _description_parts(full_description, element_names),
+    }
+
+
+def _ordered_paragraphs(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    """Keep every child next to its real parent without guessing from level labels."""
+    by_reference: dict[int, dict[str, Any]] = {}
+    for row in rows:
+        for value in (row.get("rowId"), row.get("id")):
+            reference = _as_int(value)
+            if reference is not None:
+                by_reference[reference] = row
+
+    children: dict[int, list[dict[str, Any]]] = {}
+    roots: list[dict[str, Any]] = []
+    for row in rows:
+        parent = by_reference.get(_as_int(row.get("parentId")))
+        if parent is None or parent is row:
+            row["parentId"] = None
+            roots.append(row)
+            continue
+        row["parentId"] = parent.get("id")
+        children.setdefault(id(parent), []).append(row)
+
+    ordered: list[dict[str, Any]] = []
+    visited: set[int] = set()
+
+    def visit(row: dict[str, Any], depth: int, ancestry: set[int]) -> None:
+        marker = id(row)
+        if marker in visited:
+            return
+        if marker in ancestry:
+            row["parentId"] = None
+            depth = 0
+        visited.add(marker)
+        row["depth"] = depth
+        ordered.append(row)
+        next_ancestry = {*ancestry, marker}
+        for child in sorted(children.get(marker, []), key=_paragraph_sort_key):
+            visit(child, depth + 1, next_ancestry)
+
+    for root in sorted(roots, key=_paragraph_sort_key):
+        visit(root, 0, set())
+    for row in sorted(rows, key=_paragraph_sort_key):
+        if id(row) not in visited:
+            row["parentId"] = None
+            visit(row, 0, set())
+    return ordered
+
+
+def _paragraph_sort_key(row: dict[str, Any]) -> tuple[Any, ...]:
+    return (_sort_number(row.get("index")), _sort_number(row.get("id")))
+
+
+def _content_range(value: Any) -> list[str]:
+    if not isinstance(value, dict):
+        return [_display_value(value)] if value not in (None, "") else []
+
+    tags: list[str] = []
+    for key, item in value.items():
+        if key == "标题":
+            tags.append("标题")
+        elif key == "图片" and isinstance(item, list):
+            if not item:
+                continue
+            values = [str(entry) for entry in item]
+            tags.append(f"图{values[0]}" if len(values) == 1 else f"图{values[0]}-{values[-1]}")
+        elif key == "视频":
+            tags.append(_display_value(item))
+        elif key == "正文":
+            body = "\n".join(str(entry) for entry in item) if isinstance(item, list) else _display_value(item)
+            tags.append(f"正文: {body}")
+        else:
+            rendered = ",".join(str(entry) for entry in item) if isinstance(item, list) else _display_value(item)
+            tags.append(f"{key}: {rendered}")
+    return tags
+
+
+def _dimensions(value: Any) -> list[dict[str, Any]]:
+    rows = value if isinstance(value, list) else [value] if isinstance(value, dict) else []
+    return [
+        {
+            "type": row.get("维度类型") or row.get("dimension_type"),
+            "name": row.get("维度") or row.get("dimension"),
+            "value": row.get("原子点") or row.get("value"),
+            **({"id": row.get("id")} if row.get("id") is not None else {}),
+        }
+        for row in rows
+        if isinstance(row, dict)
+    ]
+
+
+def _description_parts(value: str, element_names: dict[int, str]) -> list[dict[str, Any]]:
+    parts: list[dict[str, Any]] = []
+    cursor = 0
+    for match in _ELEMENT_REFERENCE.finditer(value):
+        if match.start() > cursor:
+            parts.append({"type": "text", "text": value[cursor : match.start()]})
+        element_id = int(match.group(2))
+        name = element_names.get(element_id)
+        parts.append(
+            {
+                "type": "element",
+                "id": element_id,
+                "name": name,
+                "resolved": name is not None,
+                "text": match.group(0),
+            }
+        )
+        cursor = match.end()
+        if name:
+            duplicate = re.match(r"\s*" + re.escape(name), value[cursor:])
+            if duplicate:
+                cursor += duplicate.end()
+    if cursor < len(value):
+        parts.append({"type": "text", "text": value[cursor:]})
+    return parts
+
+
+def _display_value(value: Any) -> str:
+    if value is True:
+        return "包含"
+    if value is False:
+        return "不包含"
+    if value is None:
+        return ""
+    return str(value)
+
+
+def _as_int(value: Any) -> int | None:
+    try:
+        return int(value) if value is not None else None
+    except (TypeError, ValueError):
+        return None
+
+
+def _sort_number(value: Any) -> tuple[int, str]:
+    number = _as_int(value)
+    return (0, str(number).zfill(12)) if number is not None else (1, str(value or ""))
+
+
+def _unique_records(rows: list[dict[str, Any]], key: str) -> list[dict[str, Any]]:
+    seen: set[Any] = set()
+    result = []
+    for row in rows:
+        value = row.get(key)
+        if value in seen:
+            continue
+        seen.add(value)
+        result.append(row)
+    return result
+
+
+def _branch_detail(script_build_id: int, branch: dict[str, Any], raw: Any, bundle: dict[str, Any]) -> dict[str, Any]:
+    facts = _branch_facts(branch, bundle)
+    return _payload(
+        [
+            _section("task", "实现任务", branch_task_text(branch.get("summary", {}).get("task"))),
+            _section("data", "数据取舍", decisions_markdown([item.get("decision") for item in branch.get("dataDecisions") or []])),
+            _section("output", "候选产出", branch.get("summary", {}).get("output")),
+        ],
+        {"scriptBuildId": script_build_id, "branch": raw},
+        {
+            "summary": branch.get("summary", {}).get("output"),
+            "sections": [_section("facts", "领域事实", _domain_facts_markdown(facts))] if facts else [],
+            "artifactRef": branch.get("output", {}).get("snapshotRef"),
+            "exactness": _output_exactness(branch.get("output") or {}),
+        }
+        if branch.get("output", {}).get("node", {}).get("hasChanges")
+        else None,
+    )
+
+
+def _branch_task_detail(
+    script_build_id: int,
+    branch: dict[str, Any],
+    raw: Any,
+    bundle: dict[str, Any],
+    *,
+    round_index: int,
+    branch_id: int,
+) -> dict[str, Any]:
+    task_event = next(
+        (
+            event
+            for event in reversed(bundle.get("events") or [])
+            if str(event.get("event_type") or "") == "agent_invoke"
+            and str(event.get("event_name") or "") == "script_implementer"
+            and int(event.get("round_index") or 0) == round_index
+            and int(event.get("branch_id") or 0) == branch_id
+        ),
+        None,
+    )
+    event_input = (task_event or {}).get("inputData")
+    event_task = event_input.get("task") if isinstance(event_input, dict) else None
+    raw_task = (raw or {}).get("impl_task") if isinstance(raw, dict) else None
+    complete_task = branch_task_text(
+        event_task
+        or raw_task
+        or branch.get("summary", {}).get("task")
+    )
+    task_sections = branch_task_sections(
+        event_task
+        or raw_task
+        or branch.get("summary", {}).get("task")
+    )
+    visual_roles = {
+        "goal": ("summary", "key-result"),
+        "materials": ("report", "evidence"),
+        "avoid": ("report", "notice"),
+        "context": ("report", "evidence"),
+    }
+    return _payload(
+        [
+            _section(
+                f"task-{section['kind']}-{index}",
+                section["title"],
+                section["content"],
+                visual_roles.get(section["kind"], ("default", "section"))[0],
+                visual_role=visual_roles.get(section["kind"], ("default", "section"))[1],
+                presentation="document" if section["kind"] in {"materials", "constraints", "avoid", "context"} else "prose",
+            )
+            for index, section in enumerate(task_sections, 1)
+        ] or [_section("task-notes-1", "任务说明", complete_task)],
+        {
+            "scriptBuildId": script_build_id,
+            "branch": raw,
+            "taskEvent": task_event,
+            "taskSource": (
+                "script_build_event.inputData.task"
+                if event_task
+                else "script_build_branch.impl_task"
+                if raw_task
+                else "execution-view summary"
+            ),
+        },
+    )
+
+
+def _branch_output_detail(script_build_id: int, branch: dict[str, Any], raw: Any, bundle: dict[str, Any]) -> dict[str, Any]:
+    output = branch.get("output") or {}
+    facts = _branch_facts(branch, bundle)
+    changes = {
+        "summary": output.get("summary"),
+        "sections": [_section("facts", "已核实领域事实", _domain_facts_markdown(facts))] if facts else [],
+        "artifactRef": output.get("snapshotRef"),
+        "exactness": _output_exactness(output),
+    }
+    return _payload(
+        [
+            _section("output", "形成了什么", output.get("summary")),
+            _section("assessment", "实现者说明", assessment_text((raw or {}).get("self_assessment")), "report"),
+        ],
+        {"scriptBuildId": script_build_id, "branch": raw, "output": output},
+        changes if output.get("node", {}).get("hasChanges") else None,
+    )
+
+
+def _retrieval_stage_detail(script_build_id: int, branch: dict[str, Any], raw: Any, bundle: dict[str, Any]) -> dict[str, Any]:
+    stage = branch.get("retrievalStage") or {}
+    direct = stage.get("directToolGroups") or []
+    agents = stage.get("agentRuns") or []
+    return _payload(
+        [
+            _section(
+                "purpose",
+                "取数目的",
+                branch_task_text(branch.get("summary", {}).get("task"))
+                or "为当前实现任务补齐可用的上游信息。",
+            ),
+            _section("mode", "执行方式", _retrieval_mode_text(stage.get("observedMode"))),
+            _section("waves", "执行波次", _retrieval_waves_markdown(stage.get("waves") or [])),
+            _section("direct", "工具取数", _direct_groups_markdown(direct)),
+            _section("agents", "Agent 取数", _agent_runs_markdown(agents)),
+            _section("result", "阶段结果与失败", _retrieval_stage_result(stage)),
+        ],
+        {
+            "scriptBuildId": script_build_id,
+            "branch": raw,
+            "retrievalStage": stage,
+            "note": "取数和初筛来自结构化运行事件;正式数据取舍来自独立业务表,不在本详情内反推。",
+        },
+    )
+
+
+def _node_detail(script_build_id: int, node: dict[str, Any], technical: Any) -> dict[str, Any]:
+    fields = []
+    primary = node.get("card", {}).get("primary")
+    if primary:
+        fields.append(_section("primary", primary.get("label") or node.get("title"), primary.get("value")))
+    for line in node.get("card", {}).get("secondary") or []:
+        fields.append(_section(str(line.get("key")), line.get("label") or "补充说明", line.get("value")))
+    return _payload(fields, {"scriptBuildId": script_build_id, "record": technical})
+
+
+def _round_result_detail(
+    script_build_id: int,
+    round_: dict[str, Any],
+    raw_round: dict[str, Any] | None,
+    bundle: dict[str, Any],
+) -> dict[str, Any]:
+    result = round_.get("result") or {}
+    branches = round_.get("branches") or []
+    round_index = int(round_.get("roundIndex") or 0)
+    facts = [
+        item
+        for item in bundle.get("domainInfo") or []
+        if int(item.get("round_index") or 0) == round_index
+    ]
+    evaluation = round_.get("overallEvaluation") or {}
+    next_step = next(
+        (
+            line.get("value")
+            for line in (result.get("card") or {}).get("secondary") or []
+            if line.get("key") == "next"
+        ),
+        None,
+    )
+    sections = [
+        _section(
+            "goal",
+            "本轮目标完成情况",
+            "\n".join(
+                part
+                for part in (
+                    business_text((raw_round or {}).get("goal")),
+                    f"收口状态:{result.get('status') or round_.get('state') or '未记录'}",
+                )
+                if part
+            ),
+        ),
+        _section("outcomes", "方案处置", _branch_outcomes_markdown(branches)),
+        _section("facts", "领域事实", _domain_facts_markdown(facts) or "本轮没有新增领域事实记录。"),
+        _section(
+            "evaluation",
+            "整体评审",
+            _node_value(evaluation) or "未找到明确的本轮整体评审结论。",
+        ),
+        _section(
+            "changes",
+            "主脚本变化",
+            _round_content_changes(branches),
+        ),
+        _section("next", "下一步", next_step or "下一步未记录。"),
+    ]
+    changes = None
+    if result.get("hasChanges"):
+        changes = {
+            "summary": _node_value(result),
+            "sections": [
+                _section("content", "内容方案", _round_content_changes(branches)),
+                _section("domain", "领域信息", f"本轮独立保存 {len(facts)} 条领域事实。"),
+            ],
+            "artifactRef": "artifact:base:current",
+            "exactness": "current-only",
+        }
+    return _payload(
+        sections,
+        {
+            "scriptBuildId": script_build_id,
+            "round": raw_round,
+            "resultProjection": result,
+            "note": "本轮主脚本历史快照无法完整还原,产物入口指向当前主脚本。",
+        },
+        changes,
+    )
+
+
+def _final_result_detail(
+    script_build_id: int, view: dict[str, Any], bundle: dict[str, Any]
+) -> dict[str, Any]:
+    final = view.get("finalResult") or {}
+    record = bundle.get("record") or {}
+    branches = final.get("branchCounts") or {}
+    artifact = final.get("artifact") or {}
+    status = str(final.get("status") or record.get("status") or "unknown")
+    conclusion = {
+        "success": "构建已完成。",
+        "completed": "构建已完成。",
+        "partial": "构建部分完成。",
+        "failed": "构建失败。",
+        "stopped": "构建已停止。",
+        "running": "构建仍在进行。",
+    }.get(status.lower(), f"构建状态:{status}。")
+    summary_or_error = final.get("summary")
+    if final.get("errorMessage"):
+        summary_or_error = f"失败原因:{final['errorMessage']}"
+    sections = [
+        _section("conclusion", "构建结论", conclusion, "summary"),
+        _section("summary", "构建摘要或失败原因", summary_or_error or "未另外记录构建摘要。"),
+        _section(
+            "statistics",
+            "轮次与方案统计",
+            (
+                f"共 {int(final.get('roundCount') or 0)} 轮,"
+                f"{int(branches.get('total') or 0)} 个方案;"
+                f"采用 {int(branches.get('merged') or 0)},"
+                f"暂存 {int(branches.get('parked') or 0)},"
+                f"未采用 {int(branches.get('discarded') or 0)},"
+                f"待处理 {int(branches.get('open') or 0)}。"
+            ),
+        ),
+        _section(
+            "artifact",
+            "最终主脚本规模",
+            (
+                f"{int(artifact.get('paragraphCount') or 0)} 个段落、"
+                f"{int(artifact.get('elementCount') or 0)} 个元素、"
+                f"{int(artifact.get('linkCount') or 0)} 条关联。"
+            ),
+        ),
+        _section(
+            "version",
+            "版本说明",
+            "当前只能确认数据库保存的最新主脚本,不将它伪装成某一轮结束时的历史快照。",
+        ),
+    ]
+    return _payload(
+        sections,
+        {
+            "scriptBuildId": script_build_id,
+            "table": "script_build_record",
+            "record": record,
+            "finalResultProjection": final,
+        },
+        {
+            "summary": "打开当前主脚本。",
+            "sections": [],
+            "artifactRef": artifact.get("snapshotRef") or "artifact:base:current",
+            "exactness": "current-only",
+        },
+    )
+
+
+def _find_decision_projection(
+    view: dict[str, Any], detail_ref: str
+) -> dict[str, Any] | None:
+    candidates: list[dict[str, Any]] = []
+    objective = view.get("objective") or {}
+    if isinstance(objective.get("decision"), dict):
+        candidates.append(objective["decision"])
+    for round_ in view.get("rounds") or []:
+        for node in (
+            round_.get("goal"),
+            (round_.get("plan") or {}).get("node"),
+            round_.get("overallEvaluation"),
+        ):
+            if isinstance(node, dict) and isinstance(node.get("decision"), dict):
+                candidates.append(node["decision"])
+        for branch in round_.get("branches") or []:
+            for item in branch.get("dataDecisions") or []:
+                projection = item.get("decisionProjection")
+                if isinstance(projection, dict):
+                    candidates.append(projection)
+            output_node = (branch.get("output") or {}).get("node") or {}
+            if isinstance(output_node.get("decision"), dict):
+                candidates.append(output_node["decision"])
+        for batch in round_.get("convergenceBatches") or []:
+            review = batch.get("review") or {}
+            decision = batch.get("decision") or {}
+            if isinstance(review.get("decisionProjection"), dict):
+                candidates.append(review["decisionProjection"])
+            if isinstance(decision.get("decisionProjection"), dict):
+                candidates.append(decision["decisionProjection"])
+    return next(
+        (
+            item
+            for item in candidates
+            if detail_ref in {item.get("id"), item.get("detailRef")}
+        ),
+        None,
+    )
+
+
+def _find_missing_convergence(
+    view: dict[str, Any], detail_ref: str
+) -> tuple[dict[str, Any], dict[str, Any]] | None:
+    for round_ in view.get("rounds") or []:
+        for batch in round_.get("convergenceBatches") or []:
+            for key in ("missingReview", "missingDecision"):
+                node = batch.get(key)
+                if isinstance(node, dict) and detail_ref in {
+                    node.get("id"),
+                    node.get("detailRef"),
+                }:
+                    return node, batch
+    return None
+
+
+def _decision_detail_payload(
+    script_build_id: int,
+    decision: dict[str, Any],
+    bundle: dict[str, Any],
+    activity_id: str,
+    event_detail: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    detail = decision.get("detail") if isinstance(decision.get("detail"), dict) else {}
+    blocks = [
+        block
+        for block in detail.get("blocks") or []
+        if isinstance(block, dict)
+    ]
+    for notice in decision.get("notices") or []:
+        if not isinstance(notice, dict) or not notice.get("message"):
+            continue
+        blocks.append(
+            {
+                "type": "notice",
+                "level": "warning" if "missing" in str(notice.get("code")) else "info",
+                "title": "记录说明",
+                "value": notice["message"],
+                "visualRole": "notice",
+                "presentation": "prose",
+            }
+        )
+    event_refs = [
+        ref
+        for ref in [
+            *(decision.get("eventRefs") or []),
+            *(decision.get("technicalRefs") or []),
+        ]
+        if isinstance(ref, str) and ref.startswith("event:")
+    ]
+    event_refs = list(dict.fromkeys(event_refs))
+    technical: dict[str, Any] = {
+        "source": {
+            "scriptBuildId": script_build_id,
+            "detailRef": activity_id,
+            "technicalRefs": event_refs,
+        },
+        "association": {
+            "completeness": decision.get("completeness"),
+            "notices": decision.get("notices") or [],
+        },
+        "rawDecisionProjection": decision,
+        "rawBusinessRecord": _decision_raw_record(bundle, activity_id),
+    }
+    if event_refs:
+        technical["runtimeEvents"] = _events_for_ids(
+            bundle, [_suffix_int(ref) for ref in event_refs]
+        )
+    if event_detail:
+        technical.update(_event_technical_groups(event_detail))
+    payload: dict[str, Any] = {
+        "detailKind": "agent-decision",
+        "decisionType": decision.get("decisionType"),
+        "actor": decision.get("actor"),
+        "authority": decision.get("authority"),
+        "question": decision.get("question"),
+        "promptRef": decision.get("promptRef"),
+        "blocks": sanitize(blocks, max_text=None),
+        "technical": sanitize(technical, max_text=None),
+    }
+    artifact_ref = decision.get("artifactRef")
+    if artifact_ref:
+        payload["changes"] = {
+            "summary": (decision.get("body") or {}).get("output")
+            or "已形成可查看的候选产出。",
+            "sections": [],
+            "artifactRef": artifact_ref,
+            "exactness": "historical-snapshot",
+        }
+    return payload
+
+
+def _event_technical_groups(event: dict[str, Any]) -> dict[str, Any]:
+    """Split a lazily loaded event body into the Inspector's technical groups.
+
+    The execution bundle only carries bounded previews.  Once a user opens an
+    event-backed decision, the full (sanitized) body belongs here rather than
+    being silently replaced by those previews.
+    """
+
+    metadata = {
+        key: value
+        for key, value in event.items()
+        if key not in {"input", "output", "duration_ms"}
+    }
+    groups: dict[str, Any] = {
+        "input": event.get("input"),
+        "output": event.get("output"),
+        "rawEvent": metadata,
+    }
+    if event.get("duration_ms") is not None:
+        groups["cost"] = {"durationMs": event.get("duration_ms")}
+    return groups
+
+
+def _decision_raw_record(bundle: dict[str, Any], activity_id: str) -> Any:
+    if activity_id.startswith("data-decision:"):
+        return _find_by_id(bundle.get("dataDecisions") or [], _suffix_int(activity_id))
+    if activity_id.startswith("multipath-decision:"):
+        return _find_by_id(
+            bundle.get("multipathDecisions") or [], _suffix_int(activity_id)
+        )
+    if activity_id.startswith("event:"):
+        return _event_by_id(bundle, _suffix_int(activity_id))
+    if activity_id.startswith("creative:"):
+        event_id = _suffix_int(activity_id)
+        event = _event_by_id(bundle, event_id)
+        branch_id = int((event or {}).get("branch_id") or 0)
+        branch = next(
+            (
+                item
+                for item in bundle.get("branches") or []
+                if int(item.get("branch_id") or 0) == branch_id
+            ),
+            None,
+        )
+        return {
+            "anchorEvent": event,
+            "candidateSnapshot": (branch or {}).get("candidate_snapshot"),
+        }
+    if activity_id == "run:objective":
+        return bundle.get("record")
+    round_index, _, role = _activity_parts(activity_id)
+    if role in {"goal", "plan"}:
+        return next(
+            (
+                item
+                for item in bundle.get("rounds") or []
+                if int(item.get("round_index") or 0) == int(round_index or 0)
+            ),
+            None,
+        )
+    return None
+
+
+def _payload(sections: list[dict[str, Any] | None], technical: Any, changes: dict[str, Any] | None = None) -> dict[str, Any]:
+    payload: dict[str, Any] = {
+        "businessSections": [item for item in sections if item],
+        "technical": sanitize(technical, max_text=None),
+    }
+    if changes:
+        changes["sections"] = [item for item in changes.get("sections", []) if item]
+        payload["changes"] = sanitize(changes, max_text=None)
+    return payload
+
+
+def _section(
+    section_id: str,
+    title: str,
+    content: Any,
+    variant: str = "default",
+    *,
+    visual_role: str | None = None,
+    presentation: str = "prose",
+    collapsible: bool = False,
+    default_open: bool = False,
+) -> dict[str, Any] | None:
+    if content is None or content == "" or content == []:
+        return None
+    if not isinstance(content, str):
+        return None
+    content = business_text(content)
+    if not content:
+        return None
+    return {
+        "id": section_id,
+        "title": title,
+        "content": sanitize(content, max_text=None),
+        "variant": variant,
+        "visualRole": visual_role or ("key-result" if variant == "summary" else "section"),
+        "presentation": presentation,
+        "collapsible": collapsible,
+        "defaultOpen": default_open,
+    }
+
+
+def _items_section(
+    section_id: str,
+    title: str,
+    items: list[dict[str, Any] | None],
+    *,
+    fallback: Any = None,
+    variant: str = "default",
+    visual_role: str | None = None,
+    collapsible: bool = False,
+    default_open: bool = False,
+) -> dict[str, Any] | None:
+    values = [item for item in items if item]
+    if not values and fallback in (None, "", []):
+        return None
+    content = "\n".join(
+        f"{item.get('label')}:{item.get('value')}" for item in values
+    ) or str(fallback)
+    section = _section(
+        section_id,
+        title,
+        content,
+        variant,
+        visual_role=visual_role,
+        presentation="list",
+        collapsible=collapsible,
+        default_open=default_open,
+    )
+    if section is not None and values:
+        section["items"] = sanitize(values, max_text=None)
+    return section
+
+
+def _detail_item(label: Any, value: Any) -> dict[str, Any] | None:
+    if value in (None, "", []):
+        return None
+    return {
+        "label": sanitize(business_text(str(label)), max_text=None),
+        "value": sanitize(business_text(str(value)), max_text=None),
+    }
+
+
+def _activity_parts(activity_id: str) -> tuple[int | None, int | None, str | None]:
+    parts = activity_id.split(":")
+    if len(parts) < 2 or parts[0] != "round":
+        return None, None, None
+    try:
+        round_index = int(parts[1])
+    except ValueError:
+        return None, None, None
+    if len(parts) >= 4 and parts[2] == "branch":
+        try:
+            branch_id = int(parts[3])
+        except ValueError:
+            return round_index, None, None
+        return round_index, branch_id, parts[4] if len(parts) > 4 else None
+    return round_index, None, parts[2] if len(parts) > 2 else None
+
+
+def _round_node(round_: dict[str, Any], role: str) -> dict[str, Any] | None:
+    if role == "goal":
+        return round_.get("goal")
+    if role == "plan":
+        return (round_.get("plan") or {}).get("node")
+    if role == "result":
+        return round_.get("result")
+    if role == "evaluation":
+        return round_.get("overallEvaluation")
+    return None
+
+
+def _branch_node(branch: dict[str, Any], role: str) -> dict[str, Any] | None:
+    if role == "task":
+        return branch.get("task")
+    if role == "output":
+        return branch.get("output", {}).get("node")
+    return None
+
+
+def _convergence_markdown(batches: list[dict[str, Any]]) -> str:
+    if not batches:
+        return "未找到候选评审或主 Agent 多路决策记录。"
+    rows: list[str] = []
+    for index, batch in enumerate(batches, 1):
+        review = batch.get("review") or {}
+        decision = batch.get("decision") or {}
+        branch_ids = batch.get("branchIds") or []
+        rows.append(
+            f"### 第 {index} 批 · "
+            + ("、".join(f"方案 {value}" for value in branch_ids) or "方案范围未记录")
+        )
+        rows.append(
+            f"**多方案评审:** {review.get('recommendation') or review.get('comparison') or review.get('conclusion') or '评审结论未记录'}"
+        )
+        rows.append(
+            f"**主 Agent 决策:** {business_text(decision.get('decision')) or '决策尚未记录'}"
+        )
+    return "\n\n".join(rows)
+
+
+def _find_retrieval_item(view: dict[str, Any], collection: str, item_id: str) -> dict[str, Any] | None:
+    for round_ in view.get("rounds") or []:
+        for branch in round_.get("branches") or []:
+            for item in (branch.get("retrievalStage") or {}).get(collection) or []:
+                if item.get("id") == item_id:
+                    return item
+    return None
+
+
+def _event_by_id(bundle: dict[str, Any], event_id: Any) -> dict[str, Any] | None:
+    return next(
+        (item for item in bundle.get("events") or [] if int(item.get("id") or 0) == int(event_id or 0)),
+        None,
+    )
+
+
+def _events_for_ids(bundle: dict[str, Any], event_ids: list[Any]) -> list[dict[str, Any]]:
+    wanted = {int(value) for value in event_ids if value is not None}
+    return [item for item in bundle.get("events") or [] if int(item.get("id") or 0) in wanted]
+
+
+def _event_nested_value(
+    event: dict[str, Any] | None, side: str, key: str
+) -> Any:
+    if not isinstance(event, dict):
+        return None
+    aliases = (
+        ("inputData", "input_data")
+        if side == "input"
+        else ("agentOutputData", "outputData", "output_data")
+    )
+    for alias in aliases:
+        value = event.get(alias)
+        if isinstance(value, dict) and value.get(key) not in (None, ""):
+            return value.get(key)
+    wrapped = event.get(side)
+    if isinstance(wrapped, dict):
+        content = wrapped.get("content")
+        if isinstance(content, dict):
+            return content.get(key)
+        if key == "summary" and isinstance(content, str):
+            return content
+    return None
+
+
+def _direct_sources_markdown(group: dict[str, Any]) -> str:
+    rows = group.get("sources") or []
+    return "\n".join(
+        f"- {item.get('businessLabel') or '相关信息'}:{int(item.get('callCount') or 0)} 次"
+        for item in rows
+    ) or "未记录具体读取项"
+
+
+def _direct_result_markdown(calls: list[dict[str, Any]]) -> str:
+    success = sum(1 for item in calls if item.get("status") == "success")
+    failure = sum(1 for item in calls if item.get("status") == "failure")
+    running = sum(1 for item in calls if item.get("status") == "running")
+    interrupted = sum(1 for item in calls if item.get("status") == "interrupted")
+    return f"共 {len(calls)} 次读取:{success} 次完成,{failure} 次失败,{running} 次进行中,{interrupted} 次因构建中断。"
+
+
+def _agent_query_markdown(run: dict[str, Any]) -> str:
+    summary = run.get("querySummary") or {}
+    total = int(summary.get("total") or 0)
+    if total == 0 and not (run.get("attempts") or []):
+        return "Agent 已完成,没有发起查询。"
+    header = (
+        f"共 {total} 次查询:"
+        f"{int(summary.get('hit') or 0)} 次命中,"
+        f"{int(summary.get('empty') or 0)} 次无结果,"
+        f"{int(summary.get('failure') or 0)} 次失败,"
+        f"{int(summary.get('interrupted') or 0)} 次因构建中断。"
+    )
+    rows = []
+    for index, attempt in enumerate(run.get("attempts") or [], 1):
+        query = attempt.get("queryLabel") or attempt.get("queryPreview") or attempt.get("businessLabel") or "查询"
+        label = {"hit": "命中", "empty": "无结果", "failure": "失败", "running": "进行中", "interrupted": "构建中断", "unknown": "结果数不明"}.get(attempt.get("status"), "结果数不明")
+        count = attempt.get("resultCount")
+        suffix = f"· {count} 条" if isinstance(count, int) and count > 0 else f"· {label}"
+        rows.append(f"{index:02d}. {query} {suffix}")
+    return "\n".join([header, *rows])
+
+
+def _agent_hit_markdown(run: dict[str, Any]) -> str:
+    attempts = run.get("attempts") or []
+    hits = [item for item in attempts if item.get("status") == "hit"]
+    empty = [item for item in attempts if item.get("status") == "empty"]
+    failures = [
+        item
+        for item in attempts
+        if item.get("status") in {"failure", "interrupted"}
+    ]
+    rows = [
+        f"- {item.get('queryLabel') or '查询'}:{int(item.get('resultCount') or 0)} 条"
+        for item in hits
+    ]
+    rows.append(f"成功命中 {len(hits)} 次,成功为空 {len(empty)} 次,技术失败或中断 {len(failures)} 次。")
+    return "\n".join(rows)
+
+
+def _screening_fallback(state: Any) -> str:
+    return {"running": "Agent 仍在整理查询结果。", "missing": "未找到 Agent 的初筛整理记录。"}.get(str(state), "初筛整理未记录。")
+
+
+def _retrieval_mode_text(mode: Any) -> str:
+    return {
+        "single": "本次只有一个取数操作。",
+        "sequential": "各项取数依次执行。",
+        "parallel": "各项取数在同一时间段并行执行。",
+        "mixed": "本次同时存在并行取数和后续取数。",
+        "unknown": "运行时间记录不足,无法确认执行先后。",
+    }.get(str(mode), "执行方式未记录。")
+
+
+def _retrieval_waves_markdown(waves: list[dict[str, Any]]) -> str:
+    if not waves:
+        return "运行时间或并发信息不足,无法安全还原执行波次。"
+    rows = []
+    for index, wave in enumerate(waves, 1):
+        operations = wave.get("operations") or wave.get("operationIds") or []
+        rows.append(
+            f"- 第 {index} 波:{len(operations)} 个取数操作"
+        )
+    return "\n".join(rows)
+
+
+def _retrieval_stage_result(stage: dict[str, Any]) -> str:
+    direct_calls = [
+        call
+        for group in stage.get("directToolGroups") or []
+        for call in group.get("calls") or []
+    ]
+    attempts = [
+        attempt
+        for run in stage.get("agentRuns") or []
+        for attempt in run.get("attempts") or []
+    ]
+    failures = sum(
+        1
+        for item in [*direct_calls, *attempts]
+        if item.get("status") in {"failure", "interrupted"}
+    )
+    hits = sum(1 for item in attempts if item.get("status") == "hit")
+    empty = sum(1 for item in attempts if item.get("status") == "empty")
+    return (
+        f"阶段状态:{stage.get('status') or '未记录'}。"
+        f"工具读取 {len(direct_calls)} 次,Agent 查询 {len(attempts)} 次;"
+        f"命中 {hits} 次,成功为空 {empty} 次,失败或中断 {failures} 次。"
+    )
+
+
+def _direct_groups_markdown(groups: list[dict[str, Any]]) -> str:
+    if not groups:
+        return "本次未记录实现 Agent 直接读取工具。"
+    return "\n".join(
+        f"- 第 {index} 组:{'、'.join(item.get('businessLabel') or '相关信息' for item in group.get('sources') or [])}({int(group.get('callCount') or 0)} 次)"
+        for index, group in enumerate(groups, 1)
+    )
+
+
+def _agent_runs_markdown(runs: list[dict[str, Any]]) -> str:
+    if not runs:
+        return "本次未记录委派取数 Agent。"
+    return "\n".join(
+        f"- {item.get('businessLabel') or '取数 Agent'}:{int((item.get('querySummary') or {}).get('total') or 0)} 次查询,初筛{'已记录' if (item.get('screening') or {}).get('state') == 'available' else '未完整记录'}"
+        for item in runs
+    )
+
+
+def _find_round(view: dict[str, Any], round_index: int | None) -> dict[str, Any] | None:
+    return next((item for item in view.get("rounds") or [] if int(item.get("roundIndex") or 0) == int(round_index or 0)), None)
+
+
+def _find_by_id(rows: list[dict[str, Any]], record_id: int) -> dict[str, Any] | None:
+    return next((item for item in rows if int(item.get("id") or 0) == record_id), None)
+
+
+def _suffix_int(value: str) -> int:
+    try:
+        return int(value.rsplit(":", 1)[1])
+    except (IndexError, ValueError) as exc:
+        raise KeyError(value) from exc
+
+
+def _node_value(node: dict[str, Any] | None) -> str | None:
+    return ((node or {}).get("card") or {}).get("primary", {}).get("value")
+
+
+def _plan_text(plan: dict[str, Any]) -> str:
+    paths = plan.get("paths") or []
+    parts = [f"方式:{plan.get('mode') or '未记录'}", f"当前保存 {len(paths)} 个方案"]
+    if plan.get("note"):
+        parts.append(f"说明:{plan['note']}")
+    revisions = int(plan.get("runtimeRevisionCount") or 0)
+    if revisions > 1:
+        parts.append(f"运行事件中找到 {revisions} 次规划记录;历史版本只在技术详情中查看。")
+    return "\n".join(parts)
+
+
+def _round_content_changes(branches: list[dict[str, Any]]) -> str:
+    merged = [item for item in branches if item.get("pathType") != "领域信息" and item.get("status") == "merged"]
+    return f"确认采用 {len(merged)} 个内容方案;无法据此还原本轮结束时的完整主脚本快照。"
+
+
+def _branch_outcomes_markdown(branches: list[dict[str, Any]]) -> str:
+    if not branches:
+        return "本轮没有可展示的方案处置记录。"
+    rows = []
+    for item in branches:
+        outcome = item.get("outcome") or {}
+        line = (
+            f"- 方案 {item.get('branchId')}:"
+            f"{outcome.get('label') or item.get('status') or '状态未记录'}"
+        )
+        if outcome.get("reasoning"):
+            line += f"。{business_text(outcome.get('reasoning'))}"
+        rows.append(line)
+    return "\n".join(rows)
+
+
+def _round_domain_changes(branches: list[dict[str, Any]]) -> str:
+    count = sum(int(item.get("output", {}).get("factCount") or 0) for item in branches if item.get("pathType") == "领域信息")
+    return f"新增 {count} 条独立保存的领域事实。"
+
+
+def _branch_facts(branch: dict[str, Any], bundle: dict[str, Any]) -> list[dict[str, Any]]:
+    refs = set(branch.get("output", {}).get("factRefs") or [])
+    return [item for item in bundle.get("domainInfo") or [] if f"domain-info:{item.get('id')}" in refs]
+
+
+def _output_exactness(output: dict[str, Any]) -> str:
+    if output.get("type") == "domain-info":
+        return "exact"
+    historical = output.get("accuracy", {}).get("historical")
+    return "historical-snapshot" if historical == "exact" else "current-only" if output.get("snapshotRef") else "unknown"
+
+
+def _artifact_exactness(artifact: dict[str, Any]) -> str:
+    return "historical-snapshot" if artifact.get("historicalAccuracy") == "exact" else "current-only"
+
+
+def _domain_facts_markdown(rows: list[dict[str, Any]]) -> str:
+    values = [business_text(row.get("content")) for row in rows]
+    return "\n".join(f"- {value}" for value in values if value)
+
+
+def _source_value(value: Any) -> str:
+    if isinstance(value, str):
+        return value
+    if isinstance(value, list):
+        return source_markdown(value)
+    if isinstance(value, dict):
+        return source_markdown([value]) or business_text(value.get("url") or value.get("name"))
+    return ""

+ 468 - 0
visualization-old/backend/app/main.py

@@ -0,0 +1,468 @@
+from __future__ import annotations
+
+import os
+import time
+from threading import Lock
+from typing import Optional
+
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.middleware.gzip import GZipMiddleware
+
+from .execution_builder import ExecutionViewBuilder
+from .execution_view_payload import compact_execution_view
+from .inspector_projection import (
+    project_activity_detail,
+    project_artifact_detail,
+    project_event_detail,
+    project_round_detail,
+)
+from .providers import LocalDatabaseProvider
+from .repositories import ActivityNotFound, BuildNotFound
+from .runtime_bridge import database_host_override, runtime_dir
+from .prompt_projection import project_prompt
+from .sanitizer import sanitize
+from .module_audit import router as module_audit_router
+from .module_audit.repository import AuditRepository
+from .card_details import CardBusinessDataProjector, CardDataNotFound
+from .card_details.common import find_detail
+from .source_lineage import InspectorWorkbenchProjector, InspectorViewNotFound
+
+
+app = FastAPI(
+    title="Script Build Runtime Visualization",
+    description="Read-only V8 projection of script-build decisions and structured runtime events.",
+    version="8.0.0",
+)
+
+origins = [
+    value.strip()
+    for value in os.getenv(
+        "VISUALIZATION_CORS_ORIGINS",
+        "http://127.0.0.1:3008,http://localhost:3008",
+    ).split(",")
+    if value.strip()
+]
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=origins,
+    allow_credentials=False,
+    allow_methods=["GET"],
+    allow_headers=["Accept", "Content-Type"],
+)
+app.add_middleware(GZipMiddleware, minimum_size=1_000, compresslevel=5)
+app.include_router(module_audit_router)
+
+provider = LocalDatabaseProvider()
+builder = ExecutionViewBuilder()
+card_data_projector = CardBusinessDataProjector()
+inspector_workbench_projector = InspectorWorkbenchProjector()
+audit_repository = AuditRepository()
+_inspector_cache: dict[tuple[int, str], tuple[float, dict]] = {}
+_inspector_cache_lock = Lock()
+_build_view_cache: dict[int, tuple[float, dict, dict]] = {}
+_build_view_cache_lock = Lock()
+
+
+def _load_bundle_and_view(script_build_id: int) -> tuple[dict, dict]:
+    """Share one read-only run snapshot across Inspector source requests."""
+    now = time.monotonic()
+    with _build_view_cache_lock:
+        cached = _build_view_cache.get(script_build_id)
+        if cached and cached[0] > now:
+            return cached[1], cached[2]
+        # The first parallel Inspector burst for a run must not issue the same
+        # ORM bundle query from several worker threads.  Build the immutable
+        # snapshot once while holding the short per-cache critical section.
+        bundle = provider.load_bundle(script_build_id)
+        view = builder.from_bundle(bundle)
+        terminal = str((bundle.get("record") or {}).get("status") or "").lower() in {
+            "success", "completed", "partial", "failed", "stopped", "cancelled"
+        }
+        ttl = 300.0 if terminal else 3.0
+        _build_view_cache[script_build_id] = (now + ttl, bundle, view)
+        if len(_build_view_cache) > 16:
+            expired = [key for key, value in _build_view_cache.items() if value[0] <= now]
+            for key in expired or list(_build_view_cache)[:4]:
+                _build_view_cache.pop(key, None)
+        return bundle, view
+
+
+def execution_view(script_build_id: int) -> dict:
+    _bundle, view = _load_bundle_and_view(script_build_id)
+    return compact_execution_view(view)
+
+
+def _safe_load_log_windows(
+    script_build_id: int,
+    event_details: dict[int, dict],
+) -> dict[int, str]:
+    try:
+        anchors = [
+            (
+                event_id,
+                str(event.get("msg_id") or "").strip(),
+                int(event.get("event_seq")) if event.get("event_seq") is not None else None,
+            )
+            for event_id, event in event_details.items()
+            if str(event.get("msg_id") or "").strip()
+        ]
+        return audit_repository.load_event_log_windows(script_build_id, anchors)
+    except Exception:
+        # Log context is supplemental; DB/Event lineage must remain usable
+        # when the historical log table is absent or temporarily unavailable.
+        return {}
+
+
+@app.get("/api/health")
+def health():
+    source_ok = False
+    source_error = None
+    try:
+        provider.list_builds(limit=1)
+        source_ok = True
+    except Exception as exc:  # Health remains inspectable when DB is unavailable.
+        source_error = f"{type(exc).__name__}: {exc}"
+    return {
+        "ok": True,
+        "source": {
+            "mode": provider.mode,
+            "available": source_ok,
+            "error": sanitize(source_error),
+        },
+        "runtimeDir": str(runtime_dir()),
+        "readOnly": True,
+        "databaseHostOverrideEnabled": bool(database_host_override()),
+        "databaseSessionReadOnly": True,
+    }
+
+
+@app.get("/api/capabilities")
+def capabilities():
+    return {
+        "schemaVersion": "8",
+        "sourceMode": provider.mode,
+        "businessSources": [
+            "script_build_record",
+            "script_build_round",
+            "script_build_branch",
+            "script_build_data_decision",
+            "script_build_multipath_decision",
+            "script_build_domain_info",
+            "script_build_paragraph",
+            "script_build_element",
+            "script_build_paragraph_element",
+        ],
+        "runtimeSupplement": ["script_build_event", "script_build_event_body"],
+        "readOnly": True,
+        "databaseHostOverrideEnabled": bool(database_host_override()),
+        "databaseSessionReadOnly": True,
+    }
+
+
+@app.get("/api/script-builds")
+def list_script_builds(
+    limit: int = Query(30, ge=1, le=100), status: Optional[str] = None
+):
+    try:
+        return {
+            "items": provider.list_builds(limit=limit, status=status),
+            "sourceMode": provider.mode,
+            "warnings": [],
+        }
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取构建列表失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+
+@app.get("/api/script-builds/{script_build_id}/execution-view")
+def get_execution_view(script_build_id: int):
+    try:
+        return execution_view(script_build_id)
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取构建失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+
+@app.get("/api/script-builds/{script_build_id}/card-data/{detail_ref:path}")
+def get_card_data(script_build_id: int, detail_ref: str):
+    try:
+        bundle, view = _load_bundle_and_view(script_build_id)
+        return sanitize(card_data_projector.project(
+            detail_ref,
+            bundle=bundle,
+            view=view,
+            load_event=lambda event_id: provider.load_event_detail(
+                script_build_id, event_id
+            ),
+        ))
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except (ActivityNotFound, CardDataNotFound) as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取卡片数据流失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+
+@app.get("/api/script-builds/{script_build_id}/inspector-view/{detail_ref:path}")
+def get_inspector_view(script_build_id: int, detail_ref: str):
+    """Return business, evidence and raw runtime columns in one response."""
+    try:
+        key = (script_build_id, detail_ref)
+        now = time.monotonic()
+        with _inspector_cache_lock:
+            cached = _inspector_cache.get(key)
+            if cached and cached[0] > now:
+                return cached[1]
+
+        bundle, view = _load_bundle_and_view(script_build_id)
+        record = bundle.get("record") or {}
+        event_ids = inspector_workbench_projector.related_event_ids(
+            detail_ref,
+            bundle=bundle,
+            view=view,
+        )
+        event_details = provider.load_event_details(script_build_id, event_ids)
+        # Event bodies have already been redacted and bounded by the
+        # repository.  Do not impose a second lower text limit here, which
+        # would silently invalidate selectors without updating completeness.
+        result = sanitize(
+            inspector_workbench_projector.project(
+                detail_ref,
+                script_build_id=script_build_id,
+                bundle=bundle,
+                view=view,
+                event_details=event_details,
+                log_contents=_safe_load_log_windows(script_build_id, event_details),
+            ),
+            max_text=None,
+        )
+        terminal = str(record.get("status") or "").lower() in {
+            "success", "completed", "partial", "failed", "stopped", "cancelled"
+        }
+        ttl = 300.0 if terminal else 3.0
+        with _inspector_cache_lock:
+            _inspector_cache[key] = (now + ttl, result)
+            if len(_inspector_cache) > 256:
+                expired = [item for item, value in _inspector_cache.items() if value[0] <= now]
+                for item in expired or list(_inspector_cache)[:64]:
+                    _inspector_cache.pop(item, None)
+        return result
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except (ActivityNotFound, InspectorViewNotFound) as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取 Inspector 数据来源失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+
+@app.get("/api/script-builds/{script_build_id}/rounds/{round_index}")
+def get_round_detail(script_build_id: int, round_index: int):
+    try:
+        bundle = provider.load_bundle(script_build_id)
+        view = builder.from_bundle(bundle)
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取轮次失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+    round_ = next(
+        (
+            item
+            for item in view.get("rounds", [])
+            if int(item.get("roundIndex") or 0) == round_index
+        ),
+        None,
+    )
+    if round_ is None:
+        raise HTTPException(status_code=404, detail=f"未找到第 {round_index} 轮")
+    return project_round_detail(script_build_id, round_, bundle)
+
+
+@app.get("/api/script-builds/{script_build_id}/activities/{activity_id:path}")
+def get_activity_detail(script_build_id: int, activity_id: str):
+    if activity_id.startswith("event:"):
+        try:
+            bundle, view = _load_bundle_and_view(script_build_id)
+            event_id = int(activity_id.rsplit(":", 1)[1])
+            event_detail = provider.load_event_detail(script_build_id, event_id)
+            try:
+                return project_activity_detail(
+                    script_build_id,
+                    activity_id,
+                    view,
+                    bundle,
+                    event_detail=event_detail,
+                )
+            except KeyError:
+                pass
+            return project_event_detail(
+                script_build_id,
+                event_detail,
+                run_status=(view.get("header") or {}).get("status"),
+            )
+        except (ValueError, ActivityNotFound) as exc:
+            raise HTTPException(status_code=404, detail=f"未找到活动 {activity_id}") from exc
+        except Exception as exc:
+            raise HTTPException(
+                status_code=503,
+                detail=f"读取运行事件失败:{type(exc).__name__}: {sanitize(str(exc))}",
+            ) from exc
+    try:
+        bundle, view = _load_bundle_and_view(script_build_id)
+        event_detail = None
+        if activity_id.startswith("retrieval-agent:"):
+            run = find_detail(view, activity_id)
+            event_id = int((run or {}).get("eventId") or 0)
+            if event_id:
+                event_detail = provider.load_event_detail(script_build_id, event_id)
+        return project_activity_detail(
+            script_build_id,
+            activity_id,
+            view,
+            bundle,
+            event_detail=event_detail,
+        )
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except KeyError as exc:
+        raise HTTPException(status_code=404, detail=f"未找到活动 {activity_id}") from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取活动详情失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+
+@app.get("/api/script-builds/{script_build_id}/decision-prompts/{prompt_ref:path}")
+def get_decision_prompt(script_build_id: int, prompt_ref: str):
+    try:
+        return project_prompt(
+            provider.load_prompt_context(script_build_id, prompt_ref)
+        )
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except ActivityNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取 Agent 提示词失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+
+@app.get("/api/script-builds/{script_build_id}/artifacts/{snapshot_ref:path}")
+def get_artifact(script_build_id: int, snapshot_ref: str):
+    if snapshot_ref in {"base:current", "artifact:base:current"}:
+        try:
+            artifact = provider.load_current_artifact(script_build_id)
+        except BuildNotFound as exc:
+            raise HTTPException(status_code=404, detail=str(exc)) from exc
+        except Exception as exc:
+            raise HTTPException(
+                status_code=503,
+                detail=f"读取创作表失败:{type(exc).__name__}: {sanitize(str(exc))}",
+            ) from exc
+        return project_artifact_detail(
+            script_build_id,
+            "artifact:base:current",
+            {
+                **artifact,
+                "artifactContext": {
+                    "type": "final",
+                    "versionKind": "current-base",
+                    "note": "当前保存的最终主脚本",
+                },
+            },
+        )
+    round_ref = snapshot_ref.removeprefix("artifact:").removeprefix("round:")
+    if round_ref.isdigit() and "round:" in snapshot_ref:
+        round_index = int(round_ref)
+        try:
+            bundle = provider.load_bundle(script_build_id)
+        except BuildNotFound as exc:
+            raise HTTPException(status_code=404, detail=str(exc)) from exc
+        except Exception as exc:
+            raise HTTPException(
+                status_code=503,
+                detail=f"读取本轮产出脚本表失败:{type(exc).__name__}: {sanitize(str(exc))}",
+            ) from exc
+        if not any(int(item.get("round_index") or 0) == round_index for item in bundle.get("rounds", [])):
+            raise HTTPException(status_code=404, detail=f"未找到第 {round_index} 轮")
+        return project_artifact_detail(
+            script_build_id,
+            f"artifact:round:{round_index}",
+            {
+                **(bundle.get("currentArtifact") or {}),
+                "artifactContext": {
+                    "type": "round",
+                    "roundIndex": round_index,
+                    "versionKind": "current-base",
+                    "note": f"当前数据库未保存第 {round_index} 轮结束时的独立快照;这里展示当前保存的主脚本。",
+                },
+            },
+        )
+    try:
+        bundle = provider.load_bundle(script_build_id)
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取创作表失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+    match = snapshot_ref.removeprefix("artifact:").removeprefix("branch:")
+    if match.isdigit():
+        branch = next(
+            (
+                item
+                for item in bundle.get("branches", [])
+                if int(item.get("branch_id") or 0) == int(match)
+            ),
+            None,
+        )
+        if branch:
+            if str(branch.get("path_type") or "").strip() == "领域信息":
+                raise HTTPException(
+                    status_code=404,
+                    detail="领域信息方案不产出候选脚本表",
+                )
+            candidate = branch.get("candidate_snapshot") or {}
+            if not isinstance(candidate, dict) or not isinstance(candidate.get("snapshot"), dict):
+                raise HTTPException(
+                    status_code=404,
+                    detail="该实现方案没有可用的候选脚本快照",
+                )
+            return project_artifact_detail(
+                script_build_id,
+                f"artifact:branch:{match}",
+                {
+                    **candidate,
+                    "artifactContext": {
+                        "type": "candidate",
+                        "roundIndex": branch.get("round_index"),
+                        "branchId": branch.get("branch_id"),
+                        "branchStatus": branch.get("status"),
+                        "pathType": branch.get("path_type"),
+                        "versionKind": candidate.get("snapshotKind"),
+                        "historicalAccuracy": candidate.get("historicalAccuracy"),
+                        "currentProjectionAccuracy": candidate.get("currentProjectionAccuracy"),
+                        "note": candidate.get("note"),
+                    },
+                },
+            )
+    raise HTTPException(status_code=404, detail=f"未找到创作表快照 {snapshot_ref}")

+ 712 - 0
visualization-old/backend/app/main_agent_decision_projection.py

@@ -0,0 +1,712 @@
+from __future__ import annotations
+
+import json
+import re
+from datetime import datetime, timezone
+from typing import Any, Iterable
+
+from .evaluation_report_parser import EvaluationReportParser
+from .main_decision_text import parse_goal_text, parse_objective_text
+from .runtime_event_index import (
+    EventBounds,
+    RuntimeEventIndex,
+    detail_ref,
+    event_input,
+    event_output,
+    event_sequence,
+    event_summary,
+    event_text_output,
+    integer,
+)
+from .runtime_tool_catalog import business_tool_label
+
+
+class MainAgentDecisionProjector:
+    """Build honest decision contexts for the first three main-Agent stages.
+
+    Database rows are passed in by the caller and remain the current business
+    truth.  Runtime events only describe how that persisted result was reached.
+    """
+
+    def project(
+        self,
+        index: RuntimeEventIndex,
+        *,
+        script_direction: str | None,
+        rounds: Iterable[dict[str, Any]],
+        multipath_decisions: Iterable[dict[str, Any]] = (),
+    ) -> dict[str, Any]:
+        round_rows = sorted(
+            (dict(row) for row in rounds),
+            key=lambda row: integer(row.get("round_index")) or 0,
+        )
+        decisions = [dict(item) for item in multipath_decisions]
+        planning_analysis = PlanningAnalysisProjector().project(index)
+        objective = ObjectiveDecisionProjector().project(
+            index, script_direction=script_direction
+        )
+        goals: dict[int, dict[str, Any]] = {}
+        plans: dict[int, dict[str, Any]] = {}
+        for position, row in enumerate(round_rows):
+            round_index = integer(row.get("round_index"))
+            if round_index is None:
+                continue
+            goals[round_index] = RoundGoalDecisionProjector().project(
+                index,
+                round_row=row,
+                objective_context=objective,
+                previous_round=(round_rows[position - 1] if position else None),
+                multipath_decisions=decisions,
+            )
+            plans[round_index] = ImplementationPlanDecisionProjector().project(
+                index, round_row=row, goal_context=goals[round_index]
+            )
+        projection = {
+            "planningAnalysis": planning_analysis,
+            "objective": objective,
+            "roundGoalsByRound": goals,
+            "implementationPlansByRound": plans,
+        }
+        assigned_technical_refs = {
+            ref
+            for context in [planning_analysis, objective, *goals.values(), *plans.values()]
+            for ref in context.get("technicalRefs") or []
+        }
+        projection["unassigned"] = [
+            event_summary(event, "failed-main-decision-call-unassigned")
+            for name in ("save_script_direction", "begin_round", "record_multipath_plan")
+            for event in index.anchors(name, successful=False)
+            if detail_ref(event) not in assigned_technical_refs
+        ]
+        return projection
+
+
+class PlanningAnalysisProjector:
+    """Project the last real main-Agent planning note before direction is saved."""
+
+    def project(self, index: RuntimeEventIndex) -> dict[str, Any]:
+        direction_saves = index.anchors("save_script_direction", successful=True)
+        first_save_seq = event_sequence(direction_saves[0]) if direction_saves else None
+        candidates = index.anchors(
+            "think_and_plan",
+            successful=True,
+            bounds=EventBounds(before_seq=first_save_seq),
+        )
+        event = candidates[-1] if candidates else None
+        raw_input = event_input(event) if event else None
+        data = raw_input if isinstance(raw_input, dict) else {}
+        event_ref = detail_ref(event) if event else None
+        return {
+            "stage": "planning-analysis",
+            "summary": _detail_text(data.get("thought_summary")),
+            "thought": _detail_text(data.get("thought")),
+            "plan": _detail_text(data.get("plan")),
+            "action": _detail_text(data.get("action")),
+            "eventRef": event_ref,
+            "technicalRefs": [event_ref] if event_ref else [],
+            "completeness": "complete" if event_ref and data else "missing",
+        }
+
+
+class ObjectiveDecisionProjector:
+    def project(
+        self, index: RuntimeEventIndex, *, script_direction: str | None
+    ) -> dict[str, Any]:
+        successful = index.anchors("save_script_direction", successful=True)
+        failed = index.anchors("save_script_direction", successful=False)
+        current_anchor = successful[-1] if successful else None
+        previous_seq = event_sequence(successful[-2]) if len(successful) > 1 else None
+        current_seq = event_sequence(current_anchor) if current_anchor else None
+        scope_id = integer(current_anchor.get("scope_event_id")) if current_anchor else None
+        bounds = EventBounds(after_seq=previous_seq, before_seq=current_seq)
+        reads = index.main_direct_reads(
+            scope_id=scope_id,
+            bounds=bounds,
+        )
+        failed_reads = index.main_direct_reads(
+            scope_id=scope_id, bounds=bounds, successful=False
+        )
+        parsed = parse_script_direction(script_direction)
+        revisions = [_direction_revision(event, script_direction) for event in successful]
+        return {
+            "stage": "objective",
+            "inputs": [_decision_read(event) for event in reads],
+            "decisionItems": parsed["goals"],
+            "explicitReasoning": parsed["valueReasoning"],
+            "constraints": parsed["constraints"],
+            "revisionRefs": [ref for event in successful if (ref := detail_ref(event))],
+            "completeness": _completeness(bool(script_direction), bool(current_anchor)),
+            "revisions": revisions,
+            "currentRevisionRef": _current_revision_ref(revisions),
+            "technicalRefs": [
+                ref
+                for event in [*failed_reads, *failed]
+                if (ref := detail_ref(event))
+            ],
+            "currentSavedValue": script_direction,
+            "sourceDocument": parsed["raw"],
+            "valueJudgements": parsed["valueJudgements"],
+        }
+
+
+class RoundGoalDecisionProjector:
+    def project(
+        self,
+        index: RuntimeEventIndex,
+        *,
+        round_row: dict[str, Any],
+        objective_context: dict[str, Any],
+        previous_round: dict[str, Any] | None,
+        multipath_decisions: Iterable[dict[str, Any]],
+    ) -> dict[str, Any]:
+        round_index = integer(round_row.get("round_index"))
+        goal = _text(round_row.get("goal"))
+        begin_events = index.successful_begin_rounds().get(round_index or -1, [])
+        current_anchor = begin_events[-1] if begin_events else None
+        current_seq = event_sequence(current_anchor) if current_anchor else None
+        inputs: list[dict[str, Any]] = []
+        constraints: list[str] = []
+        late_decision_refs: list[str] = []
+
+        if round_index == 1:
+            if objective_context.get("decisionItems"):
+                inputs.append(
+                    {
+                        "label": "当前保存的创作目标",
+                        "summary": _detail_text(
+                            "\n\n".join(
+                                str(item)
+                                for item in objective_context["decisionItems"]
+                            )
+                        ),
+                        "actor": "main",
+                        "relation": "standing-constraint",
+                        "detailRef": objective_context.get("currentRevisionRef"),
+                    }
+                )
+            lower_seq = _last_objective_save_seq(index)
+        else:
+            previous_index = integer((previous_round or {}).get("round_index"))
+            lower_seq = None
+            reports = index.agent_returns(
+                "script_evaluator",
+                round_index=previous_index,
+                before_seq=current_seq,
+            )
+            if reports:
+                evaluator, relation = reports[-1]
+                parsed_report = EvaluationReportParser().parse(
+                    event_text_output(evaluator) or "", kind="overall"
+                )
+                inputs.append(
+                    {
+                        "label": "上一轮整体评审",
+                        "summary": _parsed_report_summary(parsed_report),
+                        "actor": "overall-evaluator",
+                        "relation": relation,
+                        "detailRef": detail_ref(evaluator),
+                    }
+                )
+                constraints.extend(_parsed_report_guidance(parsed_report))
+                lower_seq = event_sequence(evaluator)
+
+            previous_decisions = sorted(
+                [
+                item
+                for item in multipath_decisions
+                if integer(item.get("round_index")) == previous_index
+                ],
+                key=lambda item: (str(item.get("created_at") or ""), integer(item.get("id")) or 0),
+            )
+            for decision in previous_decisions:
+                decision_ref = (
+                    f"multipath-decision:{decision.get('id')}"
+                    if decision.get("id") is not None
+                    else None
+                )
+                if not _record_precedes_event(decision, current_anchor):
+                    if decision_ref:
+                        late_decision_refs.append(decision_ref)
+                    continue
+                inputs.append(
+                    {
+                        "label": "上一轮主 Agent 多路决策",
+                        "summary": _detail_text(decision.get("decision")),
+                        "actor": "main",
+                        "relation": "persisted-record",
+                        "detailRef": decision_ref,
+                    }
+                )
+
+        if current_anchor is not None:
+            scope_id = integer(current_anchor.get("scope_event_id"))
+            read_bounds = EventBounds(after_seq=lower_seq, before_seq=current_seq)
+            reads = index.main_direct_reads(
+                scope_id=scope_id,
+                bounds=read_bounds,
+            )
+            inputs.extend(_decision_read(event) for event in reads)
+            failed_reads = index.main_direct_reads(
+                scope_id=scope_id, bounds=read_bounds, successful=False
+            )
+        else:
+            failed_reads = []
+
+        failed = [
+            event
+            for event in index.anchors("begin_round", successful=False)
+            if _explicit_output_round_hint(event) == round_index
+        ]
+        return {
+            "stage": "round-goal",
+            "inputs": _dedupe_inputs(inputs),
+            "decisionItems": [goal] if goal else [],
+            "sourceDocument": goal,
+            "explicitReasoning": _labelled_reasoning(goal),
+            "constraints": _unique(constraints),
+            "revisionRefs": [ref for event in begin_events if (ref := detail_ref(event))],
+            "completeness": _completeness(bool(goal), bool(current_anchor)),
+            "technicalRefs": [
+                ref
+                for event in [*failed_reads, *failed]
+                if (ref := detail_ref(event))
+            ] + late_decision_refs,
+            "currentSavedValue": goal,
+            "roundIndex": round_index,
+        }
+
+
+class ImplementationPlanDecisionProjector:
+    def project(
+        self,
+        index: RuntimeEventIndex,
+        *,
+        round_row: dict[str, Any],
+        goal_context: dict[str, Any],
+    ) -> dict[str, Any]:
+        round_index = integer(round_row.get("round_index"))
+        paths = round_row.get("multipath_plan")
+        paths = paths if isinstance(paths, list) else []
+        mode = _text(round_row.get("race_or_divide"))
+        note = _text(round_row.get("plan_note"))
+        successful: list[dict[str, Any]] = []
+        failed: list[dict[str, Any]] = []
+        for event in index.anchors("record_multipath_plan", successful=None):
+            resolved_round = _round_hint(event)
+            if resolved_round != round_index:
+                continue
+            (successful if _anchor_success(event) else failed).append(event)
+
+        current_anchor = successful[-1] if successful else None
+        begin_events = index.successful_begin_rounds().get(round_index or -1, [])
+        lower_seq = event_sequence(begin_events[-1]) if begin_events else None
+        current_seq = event_sequence(current_anchor) if current_anchor else None
+        scope_id = integer(current_anchor.get("scope_event_id")) if current_anchor else None
+        inputs: list[dict[str, Any]] = []
+        goal_items = goal_context.get("decisionItems") or []
+        if goal_items:
+            inputs.append(
+                {
+                    "label": "本轮目标",
+                    "summary": _detail_text("\n".join(str(item) for item in goal_items)),
+                    "actor": "main",
+                    "relation": "standing-constraint",
+                    "detailRef": (goal_context.get("revisionRefs") or [None])[-1],
+                }
+            )
+        if current_anchor is not None:
+            read_bounds = EventBounds(after_seq=lower_seq, before_seq=current_seq)
+            inputs.extend(
+                _decision_read(event)
+                for event in index.main_direct_reads(
+                    scope_id=scope_id,
+                    bounds=read_bounds,
+                )
+            )
+            failed_reads = index.main_direct_reads(
+                scope_id=scope_id, bounds=read_bounds, successful=False
+            )
+        else:
+            failed_reads = []
+
+        revisions = [_plan_revision(event, paths, mode, note) for event in successful]
+        display_mode = "单路" if len(paths) == 1 else mode
+        return {
+            "stage": "implementation-plan",
+            "inputs": _dedupe_inputs(inputs),
+            "decisionItems": [_path_summary(path, index + 1) for index, path in enumerate(paths)],
+            "explicitReasoning": note,
+            "constraints": [],
+            "revisionRefs": [ref for event in successful if (ref := detail_ref(event))],
+            "completeness": _completeness(bool(paths), bool(current_anchor)),
+            "revisions": revisions,
+            "currentRevisionRef": _current_revision_ref(revisions),
+            "technicalRefs": [
+                ref
+                for event in [*failed_reads, *failed]
+                if (ref := detail_ref(event))
+            ],
+            "roundIndex": round_index,
+            "displayMode": display_mode,
+            "rawMode": mode,
+            "pathCount": len(paths),
+            "routeItems": [_path_item(path, index + 1) for index, path in enumerate(paths)],
+        }
+
+
+def parse_script_direction(value: str | None) -> dict[str, Any]:
+    parsed = parse_objective_text(value)
+    goals = list(parsed.get("targets") or [])
+    if not goals and parsed.get("raw"):
+        goals = [str(parsed.get("headline"))]
+    constraints = _unique(
+        [
+            *(str(item) for item in parsed.get("domainDimensions") or []),
+            *(str(item) for item in parsed.get("constraints") or []),
+        ]
+    )
+    value_judgements = [
+        {"label": label, "value": content}
+        for label, content in (
+            ("选题价值主张", parsed.get("topicValue")),
+            ("账号价值主张", parsed.get("accountValue")),
+        )
+        if content
+    ]
+    reasoning = "\n".join(
+        f"{item['label']}:{item['value']}" for item in value_judgements
+    ) or None
+    return {
+        "goals": _unique(goals),
+        "constraints": _unique(constraints),
+        "valueJudgements": value_judgements,
+        "valueReasoning": reasoning,
+        "raw": str(parsed.get("raw") or ""),
+    }
+
+
+def parse_goal_items(value: str | None) -> list[str]:
+    parsed = parse_goal_text(value)
+    if not parsed.get("raw"):
+        return []
+    return [str(parsed["raw"])]
+
+
+def _decision_read(event: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "label": business_tool_label(str(event.get("event_name") or "")),
+        "summary": _read_result_summary(event),
+        "actor": "main",
+        "relation": "direct-read",
+        "detailRef": detail_ref(event),
+    }
+
+
+def _read_result_summary(event: dict[str, Any]) -> str:
+    name = str(event.get("event_name") or "")
+    output = event_output(event)
+    if isinstance(output, dict):
+        if name == "get_topic_detail":
+            topic = output.get("topic") if isinstance(output.get("topic"), dict) else {}
+            points = output.get("points") if isinstance(output.get("points"), list) else []
+            point_name = next(
+                (
+                    _text(point.get("point_result"))
+                    for point in points
+                    if isinstance(point, dict) and _text(point.get("point_result"))
+                ),
+                None,
+            )
+            topic_result = _text(topic.get("result"))
+            summary = ":".join(value for value in (point_name, topic_result) if value)
+            if summary:
+                return _detail_text(summary) or "已读取选题"
+        if name == "get_account_script_section_patterns":
+            summary = _detail_text(output.get("分段规律摘要"))
+            if summary:
+                return summary
+        if name == "get_account_script_persona_points":
+            persona = _persona_summary(output)
+            if persona:
+                return persona
+        for key in ("count", "returned_count", "result_count", "total"):
+            count = integer(output.get(key))
+            if count is not None:
+                return f"返回 {count} 条记录"
+        for key in ("summary", "message"):
+            value = _detail_text(output.get(key))
+            if value:
+                return value
+    raw_preview = str(event.get("output_preview") or "")
+    if name == "get_topic_detail":
+        topic_result = _json_string_field(raw_preview, "result")
+        point_name = _json_string_field(raw_preview, "point_result")
+        summary = ":".join(value for value in (point_name, topic_result) if value)
+        if summary:
+            return _detail_text(summary) or "已读取选题"
+    if name == "get_account_script_section_patterns":
+        summary = _json_string_field(raw_preview, "分段规律摘要")
+        if summary:
+            return _detail_text(summary) or "已读取账号段落模式"
+    if name == "get_account_script_persona_points":
+        count_match = re.search(r'"returned_count"\s*:\s*(\d+)', raw_preview)
+        columns = _json_string_fields(raw_preview, "列")
+        if count_match:
+            suffix = f",主要涉及{'、'.join(columns[:3])}" if columns else ""
+            return f"返回 {count_match.group(1)} 个账号表达特征{suffix}"
+    return "已读取"
+
+
+def _persona_summary(output: dict[str, Any]) -> str | None:
+    count = integer(output.get("returned_count"))
+    records = output.get("records") if isinstance(output.get("records"), list) else []
+    columns = _unique(
+        str(item.get("列"))
+        for item in records
+        if isinstance(item, dict) and item.get("列")
+    )
+    if count is None:
+        count = len(records) if records else None
+    if count is None:
+        return None
+    suffix = f",主要涉及{'、'.join(columns[:3])}" if columns else ""
+    return f"返回 {count} 个账号表达特征{suffix}"
+
+
+def _json_string_field(value: str, key: str) -> str | None:
+    values = _json_string_fields(value, key)
+    return values[0] if values else None
+
+
+def _json_string_fields(value: str, key: str) -> list[str]:
+    results: list[str] = []
+    pattern = re.compile(
+        rf'"{re.escape(key)}"\s*:\s*"((?:\\.|[^"\\])*)"'
+    )
+    for match in pattern.finditer(value):
+        try:
+            decoded = json.loads(f'"{match.group(1)}"')
+        except json.JSONDecodeError:
+            decoded = match.group(1)
+        text = _text(decoded)
+        if text and text not in results:
+            results.append(text)
+    return results
+
+
+def _direction_revision(
+    event: dict[str, Any], current_direction: str | None
+) -> dict[str, Any]:
+    payload = event_input(event)
+    saved = payload.get("script_direction") if isinstance(payload, dict) else None
+    return {
+        "eventId": integer(event.get("id")),
+        "eventSeq": event_sequence(event),
+        "savedValue": saved,
+        "current": _normalized_compare(saved) == _normalized_compare(current_direction),
+        "detailRef": detail_ref(event),
+    }
+
+
+def _plan_revision(
+    event: dict[str, Any], current_paths: list[Any], current_mode: str | None, current_note: str | None
+) -> dict[str, Any]:
+    payload = event_input(event)
+    payload = payload if isinstance(payload, dict) else {}
+    paths = payload.get("paths") if isinstance(payload.get("paths"), list) else []
+    mode = _normalized_plan_mode(payload.get("mode"))
+    normalized_current_mode = _normalized_plan_mode(current_mode)
+    note = _text(payload.get("note"))
+    current = (
+        _canonical_json(paths) == _canonical_json(current_paths)
+        and (mode or normalized_current_mode) == normalized_current_mode
+        and (note if note is not None else current_note) == current_note
+    )
+    return {
+        "eventId": integer(event.get("id")),
+        "eventSeq": event_sequence(event),
+        "paths": paths,
+        "mode": mode,
+        "note": note,
+        "current": current,
+        "detailRef": detail_ref(event),
+    }
+
+
+def _normalized_plan_mode(value: Any) -> str | None:
+    mode = _text(value)
+    aliases = {"race": "竞争", "赛马": "竞争", "divide": "分工"}
+    return aliases.get((mode or "").lower(), mode)
+
+
+def _path_item(path: Any, fallback_index: int) -> dict[str, Any]:
+    data = path if isinstance(path, dict) else {}
+    return {
+        "pathIndex": integer(data.get("path_index")) or fallback_index,
+        "pathType": _text(data.get("path_type")),
+        "action": _text(data.get("action")),
+        "target": _text(data.get("target")),
+        "method": _text(data.get("method")),
+        "emphasis": _text(data.get("emphasis")),
+    }
+
+
+def _path_summary(path: Any, fallback_index: int) -> str:
+    item = _path_item(path, fallback_index)
+    parts = [part for part in (item["action"], item["target"]) if part]
+    headline = ":".join(parts) if parts else f"方案 {item['pathIndex']}"
+    return f"方案 {item['pathIndex']} · {headline}"
+
+
+def _parsed_report_summary(report: dict[str, Any]) -> str | None:
+    if report.get("conclusion"):
+        return _detail_text(report["conclusion"])
+    if report.get("recommendation"):
+        return _detail_text(report["recommendation"])
+    problems = report.get("problems") or []
+    if problems and isinstance(problems[0], dict):
+        return _detail_text(problems[0].get("summary"))
+    return None
+
+
+def _parsed_report_guidance(report: dict[str, Any]) -> list[str]:
+    values: list[str] = []
+    for item in report.get("problems") or []:
+        if isinstance(item, dict) and (summary := _text(item.get("summary"))):
+            values.append(f"问题:{summary}")
+    if next_goal := _text(report.get("nextGoal")):
+        values.append(f"下一步:{next_goal}")
+    return _unique(values)
+
+
+def _record_precedes_event(
+    record: dict[str, Any], event: dict[str, Any] | None
+) -> bool:
+    if event is None:
+        return False
+    record_time = _date_value(record.get("created_at"))
+    event_time = _date_value(event.get("started_at") or event.get("ended_at"))
+    return record_time is not None and event_time is not None and record_time <= event_time
+
+
+def _date_value(value: Any) -> datetime | None:
+    if isinstance(value, datetime):
+        parsed = value
+    elif isinstance(value, str) and value.strip():
+        try:
+            parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
+        except ValueError:
+            return None
+    else:
+        return None
+    if parsed.tzinfo is None:
+        parsed = parsed.replace(tzinfo=timezone.utc)
+    return parsed.astimezone(timezone.utc)
+
+
+def _labelled_reasoning(text: str | None) -> str | None:
+    value = _normalize_markdown(text)
+    match = re.search(r"(?im)^\s*(?:\u7406\u7531|\u76ee\u6807\u6765\u6e90)\s*[\uff1a:]\s*(.+)$", value)
+    return _clean_line(match.group(1)) if match else None
+
+
+def _last_objective_save_seq(index: RuntimeEventIndex) -> int | None:
+    saves = index.anchors("save_script_direction", successful=True)
+    return event_sequence(saves[-1]) if saves else None
+
+
+def _round_hint(event: dict[str, Any]) -> int | None:
+    output = event_output(event)
+    if isinstance(output, dict):
+        value = integer(output.get("round_index"))
+        if value is not None:
+            return value
+    return integer(event.get("round_index"))
+
+
+def _explicit_output_round_hint(event: dict[str, Any]) -> int | None:
+    output = event_output(event)
+    return integer(output.get("round_index")) if isinstance(output, dict) else None
+
+
+def _anchor_success(event: dict[str, Any]) -> bool:
+    # Imported lazily to keep the public API above concise and avoid duplicating
+    # success/error interpretation.
+    from .runtime_event_index import event_succeeded
+
+    return event_succeeded(event)
+
+
+def _current_revision_ref(revisions: list[dict[str, Any]]) -> str | None:
+    for revision in reversed(revisions):
+        if revision.get("current"):
+            return revision.get("detailRef")
+    return None
+
+
+def _normalize_markdown(value: Any) -> str:
+    text = str(value or "").strip()
+    if "\\n" in text:
+        text = text.replace("\\n", "\n")
+    return text.replace("\r\n", "\n").replace("\r", "\n")
+
+
+def _clean_line(value: Any) -> str:
+    text = str(value or "")
+    text = re.sub(r"^\s*(?:[-*+]\s+|\d+[.、]\s+|[\u2460-\u2473]\s*)", "", text)
+    text = re.sub(r"[`*#]", "", text)
+    return re.sub(r"\s+", " ", text).strip(" ::")
+
+
+def _plain_text(value: Any) -> str:
+    lines = [_clean_line(line) for line in _normalize_markdown(value).splitlines()]
+    return " ".join(line for line in lines if line)
+
+
+def _detail_text(value: Any) -> str:
+    text = _normalize_markdown(value)
+    text = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", text)
+    text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
+    text = re.sub(r"`([^`]*)`", r"\1", text)
+    return text.strip()
+
+
+def _text(value: Any) -> str | None:
+    text = str(value).strip() if value is not None else ""
+    return text or None
+
+
+def _canonical_json(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+
+
+def _normalized_compare(value: Any) -> str:
+    return re.sub(r"\s+", "", str(value or ""))
+
+
+def _completeness(has_fact: bool, has_runtime_anchor: bool) -> str:
+    if has_fact and has_runtime_anchor:
+        return "complete"
+    if has_fact or has_runtime_anchor:
+        return "partial"
+    return "missing"
+
+
+def _unique(values: Iterable[str]) -> list[str]:
+    result: list[str] = []
+    for value in values:
+        if value and value not in result:
+            result.append(value)
+    return result
+
+
+def _dedupe_inputs(values: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
+    result: list[dict[str, Any]] = []
+    seen: set[tuple[Any, ...]] = set()
+    for value in values:
+        key = (value.get("label"), value.get("detailRef"), value.get("relation"))
+        if key in seen:
+            continue
+        seen.add(key)
+        result.append(value)
+    return result

+ 274 - 0
visualization-old/backend/app/main_decision_text.py

@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+
+_HEADING = re.compile(r"(?m)^\s*#{1,6}\s+(.+?)\s*$")
+_TARGET_SENTENCE = re.compile(r"(?im)^\s*[-*]?\s*目标语句\s*[::]\s*(.+?)\s*$")
+_FIELD_LINE = re.compile(r"(?im)^\s*[-*]?\s*([^::\n]{1,24})\s*[::]\s*(.+?)\s*$")
+_BULLET = re.compile(r"^\s*(?:[-*+]\s+|[①②③④⑤⑥⑦⑧⑨⑩]\s*|\d{1,2}[.、)]\s*)")
+
+
+def parse_objective_text(value: Any) -> dict[str, Any]:
+    """Extract business sections from the persisted direction without inventing text."""
+
+    text = _text(value)
+    if not text:
+        return {
+            "headline": "创作方向未记录",
+            "targets": [],
+            "topicValue": None,
+            "accountValue": None,
+            "domainDimensions": [],
+            "constraints": [],
+            "constraintItems": [],
+            "constraintGroups": [],
+            "targetCount": 0,
+            "domainDimensionCount": 0,
+            "structured": False,
+            "raw": "",
+        }
+
+    sections = _markdown_sections(text)
+    targets = [_clean(match.group(1)) for match in _TARGET_SENTENCE.finditer(text)]
+    if not targets:
+        target_section = _first_section(sections, "创作总目标", "创作目标", "总目标")
+        targets = _meaningful_lines(target_section)[:3]
+
+    constraint_groups = _objective_constraint_groups(text)
+    domain_dimensions = [
+        str(group.get("dimensionName"))
+        for group in constraint_groups
+        if group.get("kind") == "domain" and group.get("dimensionName")
+    ]
+    if not domain_dimensions:
+        domain_section = _first_section(sections, "领域评估维度", "领域维度")
+        domain_dimensions = [
+            line
+            for line in _meaningful_lines(domain_section)
+            if not re.match(
+                r"^(?:评估内容|通过标准|强制性|前置条件)\s*[::]", line
+            )
+        ]
+    constraint_items = [
+        {"group": group["title"], **item}
+        for group in constraint_groups
+        for item in group.get("items") or []
+        if item.get("label") in {"通过标准", "强制性", "前置条件", "约束条件"}
+    ]
+    constraints = [item["value"] for item in constraint_items]
+    topic_value = _section_preview(_first_section(sections, "选题价值主张"))
+    account_value = _section_preview(_first_section(sections, "账号价值主张"))
+    headline = targets[0] if targets else _first_business_sentence(text)
+    return {
+        "headline": headline or _clean(text),
+        "targets": _unique(targets),
+        "topicValue": topic_value,
+        "accountValue": account_value,
+        "domainDimensions": _unique(domain_dimensions),
+        "constraints": _unique(constraints),
+        "constraintItems": _unique_records(constraint_items),
+        "constraintGroups": constraint_groups,
+        "targetCount": len(_unique(targets)),
+        "domainDimensionCount": len(_unique(domain_dimensions)),
+        "structured": bool(sections or targets),
+        "raw": text,
+    }
+
+
+def parse_goal_text(value: Any) -> dict[str, Any]:
+    """Return the persisted round goal as one authoritative business statement."""
+
+    text = _text(value)
+    if not text:
+        return {
+            "headline": "本轮目标未记录",
+            "focusItems": [],
+            "raw": "",
+        }
+    return {
+        "headline": text,
+        "focusItems": [],
+        "raw": text,
+    }
+
+
+def plan_path_summary(path: Any, index: int) -> str:
+    if not isinstance(path, dict):
+        return _clean(path) or f"方案 {index}"
+    path_index = path.get("path_index") or index
+    target = _clean(path.get("target"))
+    action = _business_plan_term(path.get("action"))
+    method = _business_plan_term(path.get("method"))
+    detail = plan_path_detail(path)
+    return f"方案 {path_index}:{detail}" if detail else f"方案 {path_index}"
+
+
+def plan_path_detail(path: Any) -> str:
+    if not isinstance(path, dict):
+        return _clean(path)
+    target = _clean(path.get("target"))
+    action = _business_plan_term(path.get("action"))
+    method = _business_plan_term(path.get("method"))
+    return " · ".join(value for value in (action, target, method) if value)
+
+
+def plan_mode_label(mode: Any, path_count: int) -> str:
+    if path_count == 1:
+        return "单路"
+    value = _clean(mode)
+    aliases = {"race": "竞争", "赛马": "竞争", "divide": "分工"}
+    return aliases.get(value.lower(), value or "方式未记录")
+
+
+def preview(value: Any, limit: int = 140) -> str | None:
+    text = _clean(value)
+    if not text:
+        return None
+    if len(text) <= limit:
+        return text
+    cut = text[: limit + 1]
+    punctuation = max(cut.rfind(token) for token in "。!?;")
+    if punctuation >= max(24, int(limit * 0.55)):
+        return cut[: punctuation + 1]
+    return text[: limit - 1].rstrip() + "…"
+
+
+def _markdown_sections(text: str) -> dict[str, str]:
+    matches = list(_HEADING.finditer(text))
+    sections: dict[str, str] = {}
+    for index, match in enumerate(matches):
+        title = _clean(match.group(1)).replace("**", "")
+        end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
+        body = text[match.end() : end].strip()
+        if title and body:
+            sections[title] = body
+    return sections
+
+
+def _first_section(sections: dict[str, str], *labels: str) -> str | None:
+    for label in labels:
+        for title, body in sections.items():
+            if label in title:
+                return body
+    return None
+
+
+def _objective_constraint_groups(text: str) -> list[dict[str, Any]]:
+    headings = list(_HEADING.finditer(text))
+    groups: list[dict[str, Any]] = []
+    for index, heading in enumerate(headings):
+        title = _clean(heading.group(1)).replace("**", "")
+        if not re.match(r"^(?:目标\s*\d+|领域维度\s*\d+)", title):
+            continue
+        end = headings[index + 1].start() if index + 1 < len(headings) else len(text)
+        body = text[heading.end() : end]
+        fields = [
+            {"label": label.strip(), "value": _clean(value)}
+            for label, value in _FIELD_LINE.findall(body)
+            if label.strip() in {
+                "name",
+                "维度名",
+                "名称",
+                "评估内容",
+                "通过标准",
+                "强制性",
+                "前置条件",
+                "约束条件",
+            }
+            and _clean(value)
+        ]
+        dimension_name = next(
+            (
+                item["value"]
+                for item in fields
+                if item["label"].lower() in {"name", "维度名", "名称"}
+            ),
+            None,
+        )
+        business_items = [
+            item
+            for item in fields
+            if item["label"].lower() not in {"name", "维度名", "名称"}
+        ]
+        if not business_items and not dimension_name:
+            continue
+        groups.append(
+            {
+                "title": title,
+                "kind": "domain" if title.startswith("领域维度") else "target",
+                "dimensionName": dimension_name,
+                "items": business_items,
+            }
+        )
+    return groups
+
+
+def _section_preview(value: str | None) -> str | None:
+    lines = _meaningful_lines(value)
+    return lines[0] if lines else None
+
+
+def _meaningful_lines(value: Any) -> list[str]:
+    result: list[str] = []
+    for raw in _text(value).splitlines():
+        line = _clean(_BULLET.sub("", raw))
+        if not line or line.startswith("#"):
+            continue
+        if re.match(r"^(?:评估维度|评估内容|通过标准|强制性|前置条件|name)\s*[::]", line, re.I):
+            continue
+        result.append(line)
+    return _unique(result)
+
+
+def _first_business_sentence(text: str) -> str:
+    without_headings = _HEADING.sub("", text)
+    lines = _meaningful_lines(without_headings)
+    return lines[0] if lines else _clean(text)
+
+
+def _clean(value: Any) -> str:
+    text = str(value or "").replace("\\n", "\n")
+    text = re.sub(r"[`*_]", "", text)
+    text = re.sub(r"\s+", " ", text).strip(" #::")
+    return text
+
+
+def _text(value: Any) -> str:
+    return str(value or "").replace("\r\n", "\n").replace("\\n", "\n").strip()
+
+
+def _unique(values: list[str]) -> list[str]:
+    result: list[str] = []
+    for value in values:
+        if value and value not in result:
+            result.append(value)
+    return result
+
+
+def _unique_records(values: list[dict[str, str]]) -> list[dict[str, str]]:
+    result: list[dict[str, str]] = []
+    seen: set[tuple[str, str, str]] = set()
+    for value in values:
+        key = (
+            value.get("group") or "",
+            value.get("label") or "",
+            value.get("value") or "",
+        )
+        if key not in seen:
+            seen.add(key)
+            result.append(value)
+    return result
+
+
+def _business_plan_term(value: Any) -> str:
+    text = _clean(value)
+    return {
+        "增": "新增",
+        "改": "修改",
+        "删": "删除",
+        "组": "组合",
+        "产生脉络": "形成段落结构",
+        "产生元素": "补充脚本元素",
+    }.get(text, text)

+ 5 - 0
visualization-old/backend/app/module_audit/__init__.py

@@ -0,0 +1,5 @@
+"""Read-only card and Inspector lineage audit."""
+
+from .router import router
+
+__all__ = ["router"]

+ 55 - 0
visualization-old/backend/app/module_audit/log_context.py

@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+
+_ANCHOR_RE = re.compile(
+    r"^\[(?:MSG|TOOL)_ANCHOR:msg_id=([^:\]]+):seq=(\d+)(?::[^\]]+)?\]\s*$",
+    re.MULTILINE,
+)
+_BLOCK_START_RE = re.compile(r"^\[(?:AGENT_TASK|FOLD):[^\]]+\]\s*$", re.MULTILINE)
+
+
+def locate_log_module(log_content: str | None, event: dict[str, Any]) -> dict[str, Any] | None:
+    """Return only the anchored log module associated with an event."""
+
+    if not log_content:
+        return None
+    msg_id = str(event.get("msg_id") or "").strip()
+    if not msg_id:
+        return None
+    anchors = list(_ANCHOR_RE.finditer(log_content))
+    matched = next(
+        (
+            match
+            for match in anchors
+            if match.group(1) == msg_id
+        ),
+        None,
+    )
+    if matched is None:
+        return None
+
+    previous_starts = [
+        item
+        for item in _BLOCK_START_RE.finditer(log_content, max(0, matched.start() - 2_000), matched.start())
+    ]
+    start = previous_starts[-1].start() if previous_starts else matched.start()
+    next_anchor = next((item for item in anchors if item.start() > matched.start()), None)
+    end = next_anchor.start() if next_anchor else len(log_content)
+    content = log_content[start:end].strip()
+    return {
+        "anchor": matched.group(0),
+        "msgId": matched.group(1),
+        "sequence": int(matched.group(2)),
+        "content": content,
+        "characters": len(content),
+    }
+
+
+def _integer(value: Any) -> int | None:
+    try:
+        return int(value) if value is not None else None
+    except (TypeError, ValueError):
+        return None

+ 136 - 0
visualization-old/backend/app/module_audit/repository.py

@@ -0,0 +1,136 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+from typing import Any
+
+from sqlalchemy import case, func
+
+from ..runtime_bridge import load_runtime_modules, new_session
+
+
+def _value(value: Any) -> Any:
+    if isinstance(value, (datetime, date)):
+        return value.isoformat()
+    if isinstance(value, dict):
+        return {str(key): _value(item) for key, item in value.items()}
+    if isinstance(value, (list, tuple)):
+        return [_value(item) for item in value]
+    return value
+
+
+def _row(row: Any) -> dict[str, Any]:
+    if row is None:
+        return {}
+    mapping = getattr(row, "_mapping", None)
+    if mapping is not None:
+        return {str(key): _value(value) for key, value in mapping.items()}
+    return {
+        column.name: _value(getattr(row, column.name))
+        for column in row.__table__.columns
+    }
+
+
+class AuditRepository:
+    """Loads only audit-only full records; every query is read-only."""
+
+    def load_event(self, script_build_id: int, event_id: int) -> dict[str, Any] | None:
+        _, models = load_runtime_modules()
+        session = new_session()
+        try:
+            event = (
+                session.query(models.ScriptBuildEvent)
+                .filter(
+                    models.ScriptBuildEvent.script_build_id == script_build_id,
+                    models.ScriptBuildEvent.id == event_id,
+                )
+                .first()
+            )
+            if event is None:
+                return None
+            body = (
+                session.query(models.ScriptBuildEventBody)
+                .filter(
+                    models.ScriptBuildEventBody.script_build_id == script_build_id,
+                    models.ScriptBuildEventBody.event_id == event_id,
+                )
+                .first()
+            )
+            payload = _row(event)
+            if body is not None:
+                body_row = _row(body)
+                payload["eventBody"] = {
+                    "id": body_row.get("id"),
+                    "input_content_type": body_row.get("input_content_type"),
+                    "input_content": body_row.get("input_content"),
+                    "output_content_type": body_row.get("output_content_type"),
+                    "output_content": body_row.get("output_content"),
+                    "created_at": body_row.get("created_at"),
+                    "updated_at": body_row.get("updated_at"),
+                }
+            return payload
+        finally:
+            session.close()
+
+    def load_log(self, script_build_id: int) -> str | None:
+        _, models = load_runtime_modules()
+        session = new_session()
+        try:
+            row = (
+                session.query(models.ScriptBuildLog)
+                .filter(models.ScriptBuildLog.script_build_id == script_build_id)
+                .first()
+            )
+            return str(row.log_content) if row and row.log_content is not None else None
+        finally:
+            session.close()
+
+    def load_event_log_windows(
+        self,
+        script_build_id: int,
+        event_anchors: list[tuple[int, str, int | None]],
+        *,
+        window_size: int = 50_000,
+    ) -> dict[int, str]:
+        """Read only anchored log windows in one query, never the whole Run log."""
+        # `script_build_event.event_seq` is the structured event stream order,
+        # while the seq stored in script_build_log anchors is the model-message
+        # stream order.  They are not interchangeable.  Only an exact msg_id is
+        # safe enough for the source Inspector.
+        unique = [
+            item
+            for item in dict.fromkeys(event_anchors)
+            if str(item[1] or "").strip()
+        ]
+        if not unique:
+            return {}
+        _, models = load_runtime_modules()
+        session = new_session()
+        try:
+            content = models.ScriptBuildLog.log_content
+            expressions = []
+            for _event_id, msg_id, _event_seq in unique:
+                position = func.greatest(
+                    func.locate(f"[MSG_ANCHOR:msg_id={msg_id}", content),
+                    func.locate(f"[TOOL_ANCHOR:msg_id={msg_id}", content),
+                )
+                start = func.greatest(position - 2_000, 1)
+                expressions.append(
+                    case(
+                        (position > 0, func.substr(content, start, window_size)),
+                        else_=None,
+                    )
+                )
+            row = (
+                session.query(*expressions)
+                .filter(models.ScriptBuildLog.script_build_id == script_build_id)
+                .first()
+            )
+            if row is None:
+                return {}
+            return {
+                event_id: str(row[index])
+                for index, (event_id, _msg_id, _event_seq) in enumerate(unique)
+                if row[index] is not None
+            }
+        finally:
+            session.close()

+ 121 - 0
visualization-old/backend/app/module_audit/router.py

@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import json
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from pathlib import Path
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
+
+from ..repositories import BuildNotFound
+from ..sanitizer import sanitize
+from .service import ModuleAuditService, ModuleNotFound
+
+
+router = APIRouter(tags=["module-audit"])
+service = ModuleAuditService()
+_STATIC = Path(__file__).resolve().parent / "static"
+_ASSETS = {
+    "app.js": ("application/javascript; charset=utf-8", "app.js"),
+    "styles.css": ("text/css; charset=utf-8", "styles.css"),
+}
+
+
+@router.get("/audit/{script_build_id}", response_class=HTMLResponse)
+def audit_page(script_build_id: int):
+    template = (_STATIC / "index.html").read_text(encoding="utf-8")
+    return HTMLResponse(template.replace("__RUN_ID__", str(script_build_id)))
+
+
+@router.get("/audit-assets/{asset_name}")
+def audit_asset(asset_name: str):
+    asset = _ASSETS.get(asset_name)
+    if asset is None:
+        raise HTTPException(status_code=404, detail="未找到审计页面资源")
+    media_type, filename = asset
+    return FileResponse(_STATIC / filename, media_type=media_type)
+
+
+@router.get("/api/script-builds/{script_build_id}/module-audit")
+def module_audit_index(script_build_id: int):
+    try:
+        return service.index(script_build_id)
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取模块数据对照失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+
+@router.get("/api/script-builds/{script_build_id}/module-audit-stream")
+def module_audit_stream(script_build_id: int):
+    """Stream every module automatically; this is full loading, not click-to-load."""
+    try:
+        index = service.index(script_build_id)
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取模块数据对照失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+    def rows():
+        yield _ndjson({"type": "index", "data": index})
+        modules = index.get("modules") or []
+        with ThreadPoolExecutor(max_workers=4, thread_name_prefix="module-audit") as pool:
+            futures = {
+                pool.submit(service.detail, script_build_id, str(module["id"])): str(module["id"])
+                for module in modules
+            }
+            for future in as_completed(futures):
+                module_id = futures[future]
+                try:
+                    yield _ndjson(
+                        {
+                            "type": "detail",
+                            "moduleId": module_id,
+                            "data": future.result(),
+                        }
+                    )
+                except Exception as exc:  # One bad module must not block the full audit.
+                    yield _ndjson(
+                        {
+                            "type": "error",
+                            "moduleId": module_id,
+                            "message": f"{type(exc).__name__}: {sanitize(str(exc))}",
+                        }
+                    )
+        yield _ndjson({"type": "complete", "moduleCount": len(modules)})
+
+    return StreamingResponse(
+        rows(),
+        media_type="application/x-ndjson; charset=utf-8",
+        headers={
+            "Cache-Control": "no-store",
+            "X-Content-Type-Options": "nosniff",
+        },
+    )
+
+
+@router.get("/api/script-builds/{script_build_id}/module-audit/{module_id:path}")
+def module_audit_detail(script_build_id: int, module_id: str):
+    try:
+        return service.detail(script_build_id, module_id)
+    except BuildNotFound as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except ModuleNotFound as exc:
+        raise HTTPException(status_code=404, detail=f"未找到模块 {module_id}") from exc
+    except Exception as exc:
+        raise HTTPException(
+            status_code=503,
+            detail=f"读取模块完整数据失败:{type(exc).__name__}: {sanitize(str(exc))}",
+        ) from exc
+
+
+def _ndjson(value: dict) -> bytes:
+    return (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode(
+        "utf-8"
+    )

+ 1165 - 0
visualization-old/backend/app/module_audit/service.py

@@ -0,0 +1,1165 @@
+from __future__ import annotations
+
+import json
+from hashlib import sha1
+from dataclasses import dataclass
+from threading import RLock
+from time import monotonic
+from typing import Any, Iterable
+
+from ..execution_builder import ExecutionViewBuilder
+from ..inspector_projection import (
+    project_activity_detail,
+    project_event_detail,
+    project_round_detail,
+)
+from ..providers import LocalDatabaseProvider
+from ..sanitizer import sanitize
+from .log_context import locate_log_module
+from .repository import AuditRepository
+
+
+class ModuleNotFound(LookupError):
+    pass
+
+
+@dataclass(frozen=True)
+class _RunSnapshot:
+    bundle: dict[str, Any]
+    view: dict[str, Any]
+    modules: list[dict[str, Any]]
+    expires_at: float
+
+
+class ModuleAuditService:
+    def __init__(
+        self,
+        provider: LocalDatabaseProvider | None = None,
+        builder: ExecutionViewBuilder | None = None,
+        repository: AuditRepository | None = None,
+    ):
+        self.provider = provider or LocalDatabaseProvider()
+        self.builder = builder or ExecutionViewBuilder()
+        self.repository = repository or AuditRepository()
+        self._snapshot_cache: dict[int, _RunSnapshot] = {}
+        self._detail_cache: dict[tuple[int, str], tuple[float, dict[str, Any]]] = {}
+        self._event_cache: dict[tuple[int, int], tuple[float, dict[str, Any] | None]] = {}
+        self._log_cache: dict[int, tuple[float, str | None]] = {}
+        self._snapshot_lock = RLock()
+
+    def _snapshot(self, script_build_id: int) -> _RunSnapshot:
+        now = monotonic()
+        with self._snapshot_lock:
+            cached = self._snapshot_cache.get(script_build_id)
+            if cached is not None and cached.expires_at > now:
+                return cached
+
+        bundle = self.provider.load_bundle(script_build_id)
+        view = self.builder.from_bundle(bundle)
+        modules = _collect_modules(view)
+        status = str((view.get("header") or {}).get("status") or "").lower()
+        ttl = 300.0 if status in {"completed", "success", "failed", "interrupted", "cancelled"} else 3.0
+        snapshot = _RunSnapshot(
+            bundle=bundle,
+            view=view,
+            modules=modules,
+            expires_at=monotonic() + ttl,
+        )
+        with self._snapshot_lock:
+            self._snapshot_cache[script_build_id] = snapshot
+            if len(self._snapshot_cache) > 12:
+                stale_ids = sorted(
+                    self._snapshot_cache,
+                    key=lambda item: self._snapshot_cache[item].expires_at,
+                )[:-12]
+                for stale_id in stale_ids:
+                    self._snapshot_cache.pop(stale_id, None)
+        return snapshot
+
+    def index(self, script_build_id: int) -> dict[str, Any]:
+        snapshot = self._snapshot(script_build_id)
+        view = snapshot.view
+        modules = snapshot.modules
+        return sanitize(
+            {
+                "schemaVersion": "1",
+                "run": {
+                    "id": script_build_id,
+                    "status": (view.get("header") or {}).get("status"),
+                    "capturedAt": view.get("capturedAt"),
+                    "dataShape": view.get("dataShape"),
+                },
+                "columns": ["模块实际使用", "模块实际读取", "原始记录与上下文"],
+                "modules": [
+                    {
+                        "id": item["id"],
+                        "title": item["title"],
+                        "group": item["group"],
+                        "path": item["path"],
+                        "kind": item["kind"],
+                        "role": item.get("role"),
+                        "roundIndex": item.get("roundIndex"),
+                        "branchId": item.get("branchId"),
+                        "detailRef": item.get("detailRef"),
+                        "displayVariant": item.get("displayVariant", "expanded"),
+                        "cardCharacters": _characters(_card_use(item)),
+                        "completeness": "available",
+                    }
+                    for item in modules
+                ],
+                "summary": {
+                    "moduleCount": len(modules),
+                    "groupCount": len({item["group"] for item in modules}),
+                    "cardCharacters": sum(_characters(_card_use(item)) for item in modules),
+                },
+            },
+            max_text=None,
+        )
+
+    def detail(self, script_build_id: int, module_id: str) -> dict[str, Any]:
+        snapshot = self._snapshot(script_build_id)
+        cache_key = (script_build_id, module_id)
+        now = monotonic()
+        with self._snapshot_lock:
+            cached_detail = self._detail_cache.get(cache_key)
+            if cached_detail is not None and cached_detail[0] > now:
+                return cached_detail[1]
+        bundle = snapshot.bundle
+        view = snapshot.view
+        module = next((item for item in snapshot.modules if item["id"] == module_id), None)
+        if module is None:
+            raise ModuleNotFound(module_id)
+
+        inspector, inspector_notice = self._inspector(script_build_id, module, view, bundle)
+        card = _card_use(module)
+        reads = self._reads(script_build_id, module, inspector, bundle)
+        reads = _annotate_usage_areas(reads, inspector)
+        use_by_consumer = {
+            "card": _strings(card),
+            "inspector": _strings(inspector),
+        }
+        use_snippets = sorted(
+            set(use_by_consumer["card"] + use_by_consumer["inspector"]),
+            key=len,
+            reverse=True,
+        )
+        contexts = self._contexts(
+            script_build_id,
+            reads,
+            use_snippets,
+            expires_at=snapshot.expires_at,
+        )
+        reads, contexts = _annotate_lineage(module["id"], reads, contexts)
+        completeness = _completeness(inspector_notice, reads, contexts)
+        payload = sanitize(
+            {
+                "schemaVersion": "1",
+                "id": module["id"],
+                "title": module["title"],
+                "group": module["group"],
+                "path": module["path"],
+                "actualUse": {
+                    "card": card,
+                    "inspector": inspector,
+                    "inspectorNotice": inspector_notice,
+                    "characters": {
+                        "card": _characters(card),
+                        "inspectorBusiness": _characters((inspector or {}).get("businessSections")),
+                        "inspectorDecision": _characters((inspector or {}).get("blocks")),
+                        "inspectorChanges": _characters((inspector or {}).get("changes")),
+                        "inspectorTechnical": _characters((inspector or {}).get("technical")),
+                    },
+                },
+                "actualReads": [
+                    _public_read(item, use_by_consumer) for item in reads
+                ],
+                "contexts": contexts,
+                "completeness": completeness,
+            },
+            max_text=None,
+        )
+        with self._snapshot_lock:
+            self._detail_cache[cache_key] = (snapshot.expires_at, payload)
+            if len(self._detail_cache) > 512:
+                stale_keys = sorted(
+                    self._detail_cache,
+                    key=lambda item: self._detail_cache[item][0],
+                )[:-512]
+                for stale_key in stale_keys:
+                    self._detail_cache.pop(stale_key, None)
+        return payload
+
+    def _inspector(
+        self,
+        script_build_id: int,
+        module: dict[str, Any],
+        view: dict[str, Any],
+        bundle: dict[str, Any],
+    ) -> tuple[dict[str, Any] | None, str | None]:
+        detail_ref = str(module.get("detailRef") or "")
+        if not detail_ref:
+            return None, "该模块没有 Inspector 入口。"
+        if module["kind"] == "final-result" or detail_ref.startswith("artifact:"):
+            return None, "该按钮进入脚本表查看器,不属于 Inspector。"
+        try:
+            if module["kind"] == "round-summary":
+                return project_round_detail(
+                    script_build_id,
+                    module["payload"],
+                    bundle,
+                ), None
+            if detail_ref.startswith("event:"):
+                event_id = int(detail_ref.rsplit(":", 1)[1])
+                event_detail = self.provider.load_event_detail(script_build_id, event_id)
+                try:
+                    return project_activity_detail(
+                        script_build_id,
+                        detail_ref,
+                        view,
+                        bundle,
+                        event_detail=event_detail,
+                    ), None
+                except KeyError:
+                    return project_event_detail(
+                        script_build_id,
+                        event_detail,
+                        run_status=(view.get("header") or {}).get("status"),
+                    ), None
+            return project_activity_detail(
+                script_build_id,
+                detail_ref,
+                view,
+                bundle,
+            ), None
+        except (KeyError, ValueError, LookupError) as exc:
+            return None, f"Inspector 记录不可用:{type(exc).__name__}"
+
+    def _reads(
+        self,
+        script_build_id: int,
+        module: dict[str, Any],
+        inspector: dict[str, Any] | None,
+        bundle: dict[str, Any],
+    ) -> list[dict[str, Any]]:
+        reads = _explicit_reads(module, bundle)
+        technical = (inspector or {}).get("technical")
+        if isinstance(technical, dict):
+            for key, value in technical.items():
+                if key in {"scriptBuildId", "table"} or value in (None, "", [], {}):
+                    continue
+                source = _source_from_technical(key, value)
+                reads.append(
+                    {
+                        "consumer": "inspector",
+                        "label": _technical_label(key),
+                        "disposition": "selected",
+                        "relation": "exact-record" if isinstance(value, (dict, list)) else "projected",
+                        "source": source,
+                        "fieldPath": f"technical.{key}",
+                        "value": value,
+                        "characters": _characters(value),
+                        "context": value,
+                    }
+                )
+
+        event_ids = _event_ids(module, reads)
+        known = {
+            int(item["source"]["eventId"])
+            for item in reads
+            if isinstance(item.get("source"), dict)
+            and item["source"].get("eventId") is not None
+        }
+        for event_id in event_ids:
+            if event_id in known:
+                continue
+            event = self.repository.load_event(script_build_id, event_id)
+            if event:
+                reads.append(_event_read(event, "module", "关联运行事件", "calculation-input"))
+        return _dedupe_reads(reads)
+
+    def _contexts(
+        self,
+        script_build_id: int,
+        reads: list[dict[str, Any]],
+        use_snippets: list[str],
+        *,
+        expires_at: float,
+    ) -> list[dict[str, Any]]:
+        contexts: list[dict[str, Any]] = []
+        event_ids: set[int] = set()
+        for read in reads:
+            source = read.get("source") or {}
+            event_id = _integer(source.get("eventId"))
+            if event_id is not None:
+                event_ids.add(event_id)
+                if source.get("sourceKind") == "database":
+                    continue
+            content = read.get("context")
+            if content in (None, "", [], {}):
+                continue
+            function_result = source.get("sourceKind") == "function"
+            contexts.append(
+                {
+                    "id": _context_id(source, read.get("fieldPath")),
+                    "kind": "function-result" if function_result else "database-record",
+                    "title": (
+                        f"函数处理结果 · {read.get('label') or '数据'}"
+                        if function_result
+                        else read.get("label") or "原始数据库记录"
+                    ),
+                    "source": source,
+                    "sourceKey": _source_key(source),
+                    "relatedSourceKey": (
+                        f"database:event:{source.get('eventId')}"
+                        if function_result and source.get("eventId") is not None
+                        else None
+                    ),
+                    "content": content,
+                    "characters": _characters(content),
+                    "readPaths": ["$"] if function_result else [read.get("fieldPath")],
+                    "usedFragments": _matching_snippets(content, use_snippets),
+                    "match": read.get("relation"),
+                    "completeness": "complete",
+                }
+            )
+
+        log_content = self._load_log(script_build_id, expires_at) if event_ids else None
+        for event_id in sorted(event_ids):
+            event = self._load_event(script_build_id, event_id, expires_at)
+            if not event:
+                contexts.append(
+                    {
+                        "id": f"event:{event_id}:missing",
+                        "kind": "event",
+                        "title": f"Event {event_id}",
+                        "source": {
+                            "sourceKind": "database",
+                            "table": "script_build_event + script_build_event_body",
+                            "eventId": event_id,
+                        },
+                        "sourceKey": f"database:event:{event_id}",
+                        "content": "事件记录不存在。",
+                        "characters": 8,
+                        "readPaths": [],
+                        "usedFragments": [],
+                        "match": "missing",
+                        "completeness": "missing",
+                    }
+                )
+                continue
+            contexts.append(
+                {
+                    "id": f"event:{event_id}",
+                    "kind": "event-record",
+                    "title": f"script_build_event #{event_id}",
+                    "source": {
+                        "sourceKind": "database",
+                        "table": "script_build_event + script_build_event_body",
+                        "eventId": event_id,
+                    },
+                    "sourceKey": f"database:event:{event_id}",
+                    "content": event,
+                    "characters": _characters(event),
+                    "readPaths": ["eventBody.input_content", "eventBody.output_content", "inputData", "outputPreview"],
+                    "usedFragments": _matching_snippets(event, use_snippets),
+                    "match": "exact-record",
+                    "completeness": "complete" if event.get("eventBody") else "body-missing",
+                }
+            )
+            log_module = locate_log_module(log_content, event)
+            if log_module:
+                contexts.append(
+                    {
+                        "id": f"event:{event_id}:log",
+                        "kind": "log-module",
+                        "title": f"锚定日志模块 · Event {event_id}",
+                        "source": {
+                            "sourceKind": "log",
+                            "table": "script_build_log",
+                            "eventId": event_id,
+                            "anchor": log_module["anchor"],
+                        },
+                        "sourceKey": f"log:event:{event_id}",
+                        "relatedSourceKey": f"database:event:{event_id}",
+                        "content": log_module["content"],
+                        "characters": log_module["characters"],
+                        "readPaths": [],
+                        "usedFragments": _matching_snippets(log_module["content"], use_snippets),
+                        "match": "anchored",
+                        "completeness": "complete",
+                    }
+                )
+        return _dedupe_contexts(contexts)
+
+    def _load_event(
+        self,
+        script_build_id: int,
+        event_id: int,
+        expires_at: float,
+    ) -> dict[str, Any] | None:
+        key = (script_build_id, event_id)
+        now = monotonic()
+        with self._snapshot_lock:
+            cached = self._event_cache.get(key)
+            if cached is not None and cached[0] > now:
+                return cached[1]
+            event = self.repository.load_event(script_build_id, event_id)
+            self._event_cache[key] = (expires_at, event)
+            if len(self._event_cache) > 512:
+                stale_keys = sorted(
+                    self._event_cache,
+                    key=lambda item: self._event_cache[item][0],
+                )[:-512]
+                for stale_key in stale_keys:
+                    self._event_cache.pop(stale_key, None)
+            return event
+
+    def _load_log(self, script_build_id: int, expires_at: float) -> str | None:
+        now = monotonic()
+        with self._snapshot_lock:
+            cached = self._log_cache.get(script_build_id)
+            if cached is not None and cached[0] > now:
+                return cached[1]
+            log_content = self.repository.load_log(script_build_id)
+            self._log_cache[script_build_id] = (expires_at, log_content)
+            if len(self._log_cache) > 12:
+                stale_ids = sorted(
+                    self._log_cache,
+                    key=lambda item: self._log_cache[item][0],
+                )[:-12]
+                for stale_id in stale_ids:
+                    self._log_cache.pop(stale_id, None)
+            return log_content
+
+
+def _collect_modules(view: dict[str, Any]) -> list[dict[str, Any]]:
+    modules: list[dict[str, Any]] = []
+
+    def add(
+        payload: Any,
+        *,
+        group: str,
+        path: list[str],
+        kind: str,
+        round_index: int | None = None,
+        branch_id: int | None = None,
+        display_variant: str = "expanded",
+        forced_id: str | None = None,
+        fallback_id: str | None = None,
+        fallback_title: str | None = None,
+        fallback_detail_ref: str | None = None,
+    ) -> None:
+        if not isinstance(payload, dict):
+            return
+        module_id = str(forced_id or payload.get("id") or fallback_id or "").strip()
+        if not module_id:
+            return
+        modules.append(
+            {
+                "id": module_id,
+                "title": str(payload.get("title") or fallback_title or module_id),
+                "group": group,
+                "path": path,
+                "kind": kind,
+                "role": payload.get("role"),
+                "roundIndex": round_index,
+                "branchId": branch_id,
+                "detailRef": payload.get("detailRef") or fallback_detail_ref,
+                "displayVariant": display_variant,
+                "payload": payload,
+            }
+        )
+
+    add(view.get("planning"), group="构建前", path=["构建前"], kind="story")
+    add(view.get("objective"), group="构建前", path=["构建前"], kind="story")
+    for round_ in view.get("rounds") or []:
+        round_index = _integer(round_.get("roundIndex")) or 0
+        group = f"第 {round_index} 轮"
+        add(round_.get("goal"), group=group, path=[group, "主 Agent"], kind="story", round_index=round_index)
+        add((round_.get("plan") or {}).get("node"), group=group, path=[group, "主 Agent"], kind="story", round_index=round_index)
+        for branch in round_.get("branches") or []:
+            branch_id = _integer(branch.get("branchId")) or 0
+            branch_label = f"方案 {branch_id}"
+            add(branch.get("task"), group=group, path=[group, branch_label], kind="story", round_index=round_index, branch_id=branch_id)
+            stage = branch.get("retrievalStage") or {}
+            add(stage, group=group, path=[group, branch_label, "取数"], kind="retrieval-stage", round_index=round_index, branch_id=branch_id, fallback_title="取数阶段")
+            for direct in stage.get("directToolGroups") or []:
+                add(direct, group=group, path=[group, branch_label, "取数", "工具取数"], kind="retrieval-direct", round_index=round_index, branch_id=branch_id)
+                for call in direct.get("calls") or []:
+                    add(call, group=group, path=[group, branch_label, "取数", "工具调用"], kind="retrieval-call", round_index=round_index, branch_id=branch_id, fallback_title=call.get("businessLabel"))
+            for agent in stage.get("agentRuns") or []:
+                add(agent, group=group, path=[group, branch_label, "取数", "Agent 取数"], kind="retrieval-agent", round_index=round_index, branch_id=branch_id, fallback_title=agent.get("businessLabel"))
+                for attempt in agent.get("attempts") or []:
+                    add(attempt, group=group, path=[group, branch_label, "取数", "单次查询"], kind="query-attempt", round_index=round_index, branch_id=branch_id, fallback_title=attempt.get("queryLabel"))
+            for decision in branch.get("dataDecisions") or []:
+                add(decision.get("node") or decision, group=group, path=[group, branch_label], kind="story", round_index=round_index, branch_id=branch_id)
+            add((branch.get("output") or {}).get("node"), group=group, path=[group, branch_label], kind="story", round_index=round_index, branch_id=branch_id)
+        for batch in round_.get("convergenceBatches") or []:
+            add(batch.get("review") or batch.get("missingReview"), group=group, path=[group, "收敛"], kind="review", round_index=round_index)
+            add((batch.get("decision") or {}).get("node") or batch.get("decision") or batch.get("missingDecision"), group=group, path=[group, "收敛"], kind="decision", round_index=round_index)
+        add(round_.get("overallEvaluation"), group=group, path=[group, "整体评审"], kind="story", round_index=round_index)
+        add(round_.get("result"), group=group, path=[group, "本轮产出"], kind="story", round_index=round_index)
+    add(view.get("finalResult"), group="最终结果", path=["最终结果"], kind="final-result")
+    return _unique_modules(modules)
+
+
+def _card_use(module: dict[str, Any]) -> dict[str, Any]:
+    payload = module["payload"]
+    card = payload.get("card")
+    if not isinstance(card, dict):
+        card = ((payload.get("decisionProjection") or {}).get("card") if isinstance(payload.get("decisionProjection"), dict) else None)
+    if isinstance(card, dict):
+        fields = []
+        primary = card.get("primary")
+        if isinstance(primary, dict):
+            fields.append({"label": primary.get("label"), "value": primary.get("value"), "kind": "primary"})
+        for item in card.get("secondary") or []:
+            if isinstance(item, dict):
+                fields.append({"label": item.get("label"), "value": item.get("value"), "kind": "secondary"})
+        return {"title": module["title"], "fields": fields}
+
+    kind = module["kind"]
+    if kind == "round-summary":
+        summary = payload.get("summary") or {}
+        return {
+            "title": f"第 {module.get('roundIndex')} 轮摘要",
+            "fields": [
+                {"label": "本轮目标", "value": (summary.get("goal") or {}).get("headline")},
+                {"label": "尝试方案", "value": summary.get("approach")},
+                {"label": "主 Agent 决策", "value": summary.get("decision")},
+                {"label": "本轮产出", "value": summary.get("output")},
+            ],
+        }
+    if kind == "branch-summary":
+        summary = payload.get("summary") or {}
+        return {"title": module["title"], "fields": [{"label": "任务", "value": summary.get("task")}, {"label": "产出", "value": summary.get("output")}, {"label": "状态", "value": payload.get("status")}]}
+    if kind == "retrieval-stage":
+        return {"title": "取数阶段", "fields": [{"label": "模式", "value": payload.get("observedMode")}, {"label": "状态", "value": payload.get("status")}, {"label": "取数模块", "value": f"{len(payload.get('directToolGroups') or [])} 个工具组、{len(payload.get('agentRuns') or [])} 个 Agent"}]}
+    if kind == "retrieval-direct":
+        sources = "、".join(str(item.get("businessLabel") or "") for item in payload.get("sources") or [] if isinstance(item, dict))
+        result = f"{payload.get('failureCount')} 次失败" if payload.get("failureCount") else f"{payload.get('interruptedCount')} 次中断" if payload.get("interruptedCount") else "进行中" if payload.get("runningCount") else "已完成"
+        return {"title": sources or "工具取数", "fields": [{"label": "读取", "value": f"{payload.get('callCount') or 0} 次"}, {"label": "结果", "value": result}]}
+    if kind == "retrieval-agent":
+        query = payload.get("querySummary") or {}
+        return {"title": payload.get("businessLabel") or "Agent 取数", "fields": [{"label": "取数目标", "value": payload.get("taskPreview")}, {"label": "查询过程", "value": query}, {"label": "初筛整理", "value": (payload.get("screening") or {}).get("preview")}]}
+    if kind in {"query-attempt", "retrieval-call"}:
+        return {"title": module["title"], "fields": [{"label": "状态", "value": payload.get("status")}, {"label": "结果数量", "value": payload.get("resultCount")}, {"label": "结果摘要", "value": payload.get("resultSummary")}]}
+    if kind == "final-result":
+        return {"title": module["title"], "fields": [{"label": "执行结果", "value": payload.get("summary")}, {"label": "状态", "value": payload.get("status")}, {"label": "轮次", "value": payload.get("roundCount")}, {"label": "方案", "value": payload.get("branchCounts")}]}
+    return {"title": module["title"], "fields": [{"label": "模块数据", "value": payload}]}
+
+
+def _explicit_reads(module: dict[str, Any], bundle: dict[str, Any]) -> list[dict[str, Any]]:
+    reads: list[dict[str, Any]] = []
+    role = str(module.get("role") or "")
+    round_index = module.get("roundIndex")
+    branch_id = module.get("branchId")
+    raw_round = _find(bundle.get("rounds"), "round_index", round_index)
+    raw_branch = _find(bundle.get("branches"), "branch_id", branch_id)
+
+    if role == "planning-analysis":
+        _append_related_events(reads, module, bundle, "card", "规划事件")
+    elif role == "objective":
+        record = bundle.get("record") or {}
+        reads.append(_record_read("card", "创作目标", "script_build_record", record, "script_direction"))
+    elif role == "goal" and raw_round:
+        reads.append(_record_read("card", "本轮目标", "script_build_round", raw_round, "goal"))
+    elif role == "plan" and raw_round:
+        for field, label in (("multipath_plan", "实现路线"), ("race_or_divide", "组织方式"), ("plan_note", "规划理由")):
+            if raw_round.get(field) not in (None, "", [], {}):
+                reads.append(_record_read("card", label, "script_build_round", raw_round, field))
+    elif role == "branch-task" and raw_branch:
+        reads.append(_record_read("card", "实现任务", "script_build_branch", raw_branch, "impl_task"))
+        task_event = next(
+            (
+                event
+                for event in reversed(bundle.get("events") or [])
+                if event.get("event_name") == "script_implementer"
+                and _integer(event.get("round_index")) == round_index
+                and _integer(event.get("branch_id")) == branch_id
+            ),
+            None,
+        )
+        if task_event:
+            event_value = (task_event.get("inputData") or {}).get("task") if isinstance(task_event.get("inputData"), dict) else None
+            reads.append(_event_summary_read(task_event, "inspector", "实现 Agent 派发任务", "inputData.task", event_value, "selected" if event_value else "missing"))
+            reads.append({**_record_read("inspector", "历史回退任务", "script_build_branch", raw_branch, "impl_task"), "disposition": "fallback" if event_value else "selected"})
+        else:
+            reads.append({**_record_read("inspector", "实现任务", "script_build_branch", raw_branch, "impl_task"), "disposition": "selected"})
+    elif role == "data-decision":
+        row = _find(bundle.get("dataDecisions"), "id", _suffix_int(module["id"]))
+        if row:
+            reads.append(_whole_record_read("card", "取数取舍记录", "script_build_data_decision", row))
+    elif role in {"domain-output", "candidate-output"} and raw_branch:
+        reads.append(_whole_record_read("card", "实现方案记录", "script_build_branch", raw_branch))
+        if role == "domain-output":
+            for row in bundle.get("domainInfo") or []:
+                if _integer(row.get("branch_id")) == branch_id:
+                    reads.append(_whole_record_read("card", "领域事实", "script_build_domain_info", row))
+    elif role == "multipath-decision":
+        row = _find(bundle.get("multipathDecisions"), "id", _suffix_int(module["id"]))
+        if row:
+            reads.append(_whole_record_read("card", "多方案决定", "script_build_multipath_decision", row))
+    elif role in {"round-evaluation", "round-result"} and raw_round:
+        reads.append(_whole_record_read("card", "轮次记录", "script_build_round", raw_round))
+        _append_related_events(reads, module, bundle, "card", "轮次运行事件")
+
+    kind = module["kind"]
+    payload = module["payload"]
+    if kind == "round-summary" and raw_round:
+        reads.append(_whole_record_read("card", "折叠态轮次记录", "script_build_round", raw_round))
+    elif kind == "branch-summary" and raw_branch:
+        reads.append(_whole_record_read("card", "折叠态方案记录", "script_build_branch", raw_branch))
+    elif kind.startswith("retrieval") or kind == "query-attempt":
+        event_ids = _event_ids_from_payload(payload)
+        for event_id in event_ids:
+            event = next((item for item in bundle.get("events") or [] if _integer(item.get("id")) == event_id), None)
+            if event:
+                reads.append(_event_summary_read(event, "card", "取数运行事件", "event", event, "calculation-input"))
+    elif kind == "review":
+        _append_related_events(reads, module, bundle, "card", "评审事件")
+    elif kind == "final-result":
+        reads.append(_whole_record_read("card", "构建记录", "script_build_record", bundle.get("record") or {}))
+        reads.append(_whole_record_read("card", "当前主脚本快照", "script_build_artifact", bundle.get("currentArtifact") or {}))
+
+    if not reads:
+        reads.append(
+            {
+                "consumer": "module",
+                "label": "来源未确认",
+                "disposition": "unconfirmed",
+                "relation": "unconfirmed",
+                "source": {"type": "unknown", "sourceKind": "unknown"},
+                "fieldPath": None,
+                "value": "当前审计规则无法可靠确认这个模块的字段来源。",
+                "characters": 24,
+                "context": None,
+            }
+        )
+    return reads
+
+
+def _record_read(consumer: str, label: str, table: str, row: dict[str, Any], field: str) -> dict[str, Any]:
+    value = row.get(field)
+    return {
+        "consumer": consumer,
+        "label": label,
+        "disposition": "selected",
+        "relation": (
+            "projected"
+            if consumer == "card"
+            else "exact" if isinstance(value, str) else "structured"
+        ),
+        "source": {
+            "type": "business-record",
+            "sourceKind": "database",
+            "table": table,
+            "recordId": row.get("id"),
+        },
+        "fieldPath": field,
+        "value": value,
+        "characters": _characters(value),
+        "context": row,
+    }
+
+
+def _whole_record_read(consumer: str, label: str, table: str, row: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "consumer": consumer,
+        "label": label,
+        "disposition": "selected",
+        "relation": (
+            "combined"
+            if table == "script_build_artifact"
+            else "projected" if consumer == "card" else "exact-record"
+        ),
+        "source": {
+            "type": "business-record",
+            "sourceKind": "database",
+            "table": table,
+            "recordId": row.get("id"),
+        },
+        "fieldPath": "$",
+        "value": row,
+        "characters": _characters(row),
+        "context": row,
+    }
+
+
+def _event_summary_read(event: dict[str, Any], consumer: str, label: str, field_path: str, value: Any, disposition: str) -> dict[str, Any]:
+    return {
+        "consumer": consumer,
+        "label": label,
+        "disposition": disposition,
+        "relation": (
+            "calculation-input"
+            if disposition == "calculation-input"
+            else "exact" if isinstance(value, str) else "structured"
+        ),
+        "source": {
+            "type": "runtime-event",
+            "sourceKind": "database",
+            "table": "script_build_event + script_build_event_body",
+            "eventId": event.get("id"),
+            "eventName": event.get("event_name"),
+        },
+        "fieldPath": field_path,
+        "value": value,
+        "characters": _characters(value),
+        "context": None,
+    }
+
+
+def _event_read(event: dict[str, Any], consumer: str, label: str, disposition: str) -> dict[str, Any]:
+    return {
+        "consumer": consumer,
+        "label": label,
+        "disposition": disposition,
+        "relation": "exact-record",
+        "source": {
+            "type": "runtime-event",
+            "sourceKind": "database",
+            "table": "script_build_event + script_build_event_body",
+            "eventId": event.get("id"),
+            "eventName": event.get("event_name"),
+        },
+        "fieldPath": "$",
+        "value": event,
+        "characters": _characters(event),
+        "context": None,
+    }
+
+
+def _append_related_events(reads: list[dict[str, Any]], module: dict[str, Any], bundle: dict[str, Any], consumer: str, label: str) -> None:
+    for event_id in _event_ids_from_payload(module.get("payload") or {}):
+        event = next((item for item in bundle.get("events") or [] if _integer(item.get("id")) == event_id), None)
+        if event:
+            reads.append(_event_summary_read(event, consumer, label, "event", event, "calculation-input"))
+
+
+def _source_from_technical(key: str, value: Any) -> dict[str, Any]:
+    event_id = None
+    table = None
+    if isinstance(value, dict):
+        event_id = _integer(value.get("eventId") or value.get("id")) if "event" in key.lower() else None
+        table = value.get("table")
+    return {
+        "type": "inspector-projection",
+        "sourceKind": "function",
+        "functionName": "Inspector 详情组装",
+        "table": table,
+        "eventId": event_id,
+        "technicalGroup": key,
+    }
+
+
+def _event_ids(module: dict[str, Any], reads: list[dict[str, Any]]) -> list[int]:
+    values = _event_ids_from_payload(module.get("payload") or {})
+    values.extend(
+        _integer((item.get("source") or {}).get("eventId"))
+        for item in reads
+    )
+    return sorted({value for value in values if value is not None})
+
+
+def _event_ids_from_payload(value: Any) -> list[int]:
+    result: list[int] = []
+    if isinstance(value, dict):
+        for key, nested in value.items():
+            if key in {"eventId", "implementerEventId", "eventRef"}:
+                parsed = _integer(nested)
+                if parsed is None:
+                    parsed = _suffix_int(nested)
+                if parsed is not None:
+                    result.append(parsed)
+            elif key == "detailRef" and str(nested or "").startswith("event:"):
+                parsed = _suffix_int(nested)
+                if parsed is not None:
+                    result.append(parsed)
+            elif key in {"technicalRefs", "runtimeRevisionRefs"} and isinstance(nested, list):
+                result.extend(_suffix_int(item) for item in nested if _suffix_int(item) is not None)
+            elif key not in {"card", "summary", "body", "content_snapshot", "candidate_snapshot"}:
+                result.extend(_event_ids_from_payload(nested))
+    elif isinstance(value, list):
+        for item in value:
+            result.extend(_event_ids_from_payload(item))
+    return sorted({item for item in result if item is not None})
+
+
+def _public_read(
+    value: dict[str, Any], use_by_consumer: dict[str, list[str]]
+) -> dict[str, Any]:
+    public = {key: item for key, item in value.items() if key != "context"}
+    source = value.get("source") or {}
+    public["sourceKey"] = _source_key(source)
+    disposition = str(value.get("disposition") or "")
+    relation = str(value.get("relation") or "")
+    public["sourcePriority"] = {
+        "selected": "已采用来源",
+        "fallback": "备用来源",
+        "calculation-input": "计算输入",
+        "unconfirmed": "来源未确认",
+        "missing": "来源缺失",
+    }.get(disposition, "已读取来源")
+    public["transformation"] = {
+        "exact": "直接摘取",
+        "exact-record": "完整记录读取",
+        "structured": "结构化字段读取",
+        "projected": "展示处理",
+        "combined": "多来源重组",
+        "calculation-input": "参与计算",
+        "unconfirmed": "无法确认",
+    }.get(relation, relation or "未标记")
+    ratio = _usage_ratio(
+        value.get("value"),
+        use_by_consumer.get(str(value.get("consumer") or ""), []),
+        relation,
+        disposition,
+    )
+    public["usageRatio"] = (
+        {
+            "calculable": True,
+            "value": ratio,
+            "label": f"{ratio * 100:.1f}%",
+        }
+        if ratio is not None
+        else {
+            "calculable": False,
+            "value": None,
+            "label": "不可直接计算",
+        }
+    )
+    return public
+
+
+def _annotate_lineage(
+    module_id: str,
+    reads: list[dict[str, Any]],
+    contexts: list[dict[str, Any]],
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+    """Attach field-level pointers without inventing lineage for derived values."""
+    contexts_by_source: dict[str, list[dict[str, Any]]] = {}
+    for context in contexts:
+        contexts_by_source.setdefault(str(context.get("sourceKey") or ""), []).append(context)
+
+    for index, read in enumerate(reads):
+        source_key = _source_key(read.get("source") or {})
+        identity = "|".join(
+            (
+                module_id,
+                str(read.get("consumer") or "module"),
+                source_key,
+                str(read.get("fieldPath") or "$"),
+                str(read.get("label") or "data"),
+                str(index),
+            )
+        )
+        read_id = f"read:{sha1(identity.encode('utf-8')).hexdigest()[:14]}"
+        read["readId"] = read_id
+        refs: list[dict[str, Any]] = []
+        for context in contexts_by_source.get(source_key, []):
+            context_path, relation = _lineage_path(read, context)
+            refs.append(
+                {
+                    "contextId": context.get("id"),
+                    "sourceKey": source_key,
+                    "contextFieldPath": context_path,
+                    "relation": relation,
+                    "exact": relation in {"direct", "normalized-alias", "whole-record"},
+                }
+            )
+        read["contextRefs"] = refs
+
+    read_ids_by_context: dict[str, list[str]] = {}
+    paths_by_context: dict[str, list[str]] = {}
+    for read in reads:
+        for ref in read.get("contextRefs") or []:
+            context_id = str(ref.get("contextId") or "")
+            if not context_id:
+                continue
+            read_ids_by_context.setdefault(context_id, []).append(str(read["readId"]))
+            field_path = str(ref.get("contextFieldPath") or "$")
+            paths_by_context.setdefault(context_id, []).append(field_path)
+
+    for context in contexts:
+        context_id = str(context.get("id") or "")
+        context["readIds"] = list(dict.fromkeys(read_ids_by_context.get(context_id, [])))
+        mapped_paths = list(dict.fromkeys(paths_by_context.get(context_id, [])))
+        if mapped_paths:
+            context["readPaths"] = mapped_paths
+    return reads, contexts
+
+
+def _lineage_path(
+    read: dict[str, Any], context: dict[str, Any]
+) -> tuple[str, str]:
+    field_path = str(read.get("fieldPath") or "$")
+    content = context.get("content")
+    source_kind = str((read.get("source") or {}).get("sourceKind") or "")
+
+    if source_kind == "function":
+        return "$", "transformed"
+    if field_path in {"$", "event"}:
+        return "$", "whole-record"
+
+    candidates = [field_path]
+    if field_path.startswith("inputData."):
+        suffix = field_path.removeprefix("inputData.")
+        candidates.append(f"eventBody.input_content.{suffix}")
+    elif field_path == "inputData":
+        candidates.append("eventBody.input_content")
+    if field_path.startswith("agentOutputData."):
+        suffix = field_path.removeprefix("agentOutputData.")
+        candidates.append(f"eventBody.output_content.{suffix}")
+    elif field_path in {"agentOutputData", "outputPreview", "output_preview"}:
+        candidates.append("eventBody.output_content")
+
+    for candidate in candidates:
+        if _value_at_path(content, candidate) is not _MISSING:
+            relation = "direct" if candidate == field_path else "normalized-alias"
+            return candidate, relation
+    return "$", "record-only"
+
+
+_MISSING = object()
+
+
+def _value_at_path(value: Any, field_path: str) -> Any:
+    if not field_path or field_path == "$":
+        return value
+    current = value
+    for part in field_path.removeprefix("$.").split("."):
+        if isinstance(current, str):
+            text = current.strip()
+            if text[:1] in {"{", "["}:
+                try:
+                    current = json.loads(text)
+                except (TypeError, ValueError, json.JSONDecodeError):
+                    return _MISSING
+        if not isinstance(current, dict) or part not in current:
+            return _MISSING
+        current = current[part]
+    return current
+
+
+def _annotate_usage_areas(
+    reads: list[dict[str, Any]], inspector: dict[str, Any] | None
+) -> list[dict[str, Any]]:
+    has_business = bool(
+        (inspector or {}).get("businessSections") or (inspector or {}).get("blocks")
+    )
+    has_changes = bool((inspector or {}).get("changes"))
+    has_technical = isinstance((inspector or {}).get("technical"), dict)
+
+    for read in reads:
+        consumer = read.get("consumer")
+        field_path = str(read.get("fieldPath") or "")
+        disposition = read.get("disposition")
+        areas: list[str] = []
+        if disposition == "fallback":
+            read["usageAreas"] = areas
+            continue
+        if consumer == "card":
+            areas.append("卡片")
+            if has_business:
+                areas.append("Inspector 业务详情")
+            if has_changes:
+                areas.append("Inspector 产出与改动")
+        elif consumer == "inspector":
+            areas.append(
+                "Inspector 技术详情"
+                if field_path.startswith("technical.")
+                else "Inspector 业务详情"
+            )
+        elif consumer == "module":
+            areas.append("模块处理")
+        read["usageAreas"] = areas
+
+    if has_technical:
+        technical_refs = {
+            _underlying_record_key(read.get("source") or {})
+            for read in reads
+            if str(read.get("fieldPath") or "").startswith("technical.")
+        }
+        technical_refs.discard(None)
+        for read in reads:
+            source = read.get("source") or {}
+            if (
+                source.get("sourceKind") == "database"
+                and _underlying_record_key(source) in technical_refs
+                and read.get("disposition") != "fallback"
+                and "Inspector 技术详情" not in read["usageAreas"]
+            ):
+                read["usageAreas"].append("Inspector 技术详情")
+    return reads
+
+
+def _underlying_record_key(source: dict[str, Any]) -> str | None:
+    event_id = _integer(source.get("eventId"))
+    if event_id is not None:
+        return f"event:{event_id}"
+    table = source.get("table")
+    record_id = source.get("recordId")
+    if table and record_id is not None:
+        return f"record:{table}:{record_id}"
+    return None
+
+
+def _source_key(source: dict[str, Any]) -> str:
+    kind = str(source.get("sourceKind") or "unknown")
+    underlying = _underlying_record_key(source)
+    if kind == "database" and underlying:
+        return f"database:{underlying}"
+    if kind == "log":
+        return f"log:event:{source.get('eventId') or 'unknown'}"
+    if kind == "function":
+        group = source.get("technicalGroup") or source.get("functionName") or "detail"
+        event = source.get("eventId")
+        return f"function:{group}:{event or 'module'}"
+    return f"unknown:{source.get('type') or 'source'}"
+
+
+def _usage_ratio(
+    value: Any, snippets: list[str], relation: str, disposition: str
+) -> float | None:
+    if (
+        disposition != "selected"
+        or relation != "exact"
+        or not isinstance(value, str)
+        or not value
+    ):
+        return None
+    normalized_source = value.strip()
+    if not normalized_source:
+        return None
+    candidates = []
+    for snippet in snippets:
+        normalized = snippet.strip().removesuffix("…").rstrip()
+        if len(normalized) >= 8 and normalized in normalized_source:
+            candidates.append(normalized)
+    if not candidates:
+        return None
+    return min(1.0, len(max(candidates, key=len)) / len(normalized_source))
+
+
+def _dedupe_reads(reads: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    seen: set[tuple[str, str, str, str]] = set()
+    result = []
+    for item in reads:
+        source = item.get("source") or {}
+        key = (str(item.get("consumer")), str(source.get("table")), str(source.get("eventId") or source.get("recordId")), str(item.get("fieldPath")))
+        if key in seen:
+            continue
+        seen.add(key)
+        result.append(item)
+    return result
+
+
+def _dedupe_contexts(contexts: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    result = []
+    seen = set()
+    for item in contexts:
+        key = item.get("id")
+        if key in seen:
+            continue
+        seen.add(key)
+        result.append(item)
+    return result
+
+
+def _unique_modules(modules: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    seen = set()
+    result = []
+    for item in modules:
+        if item["id"] in seen:
+            continue
+        seen.add(item["id"])
+        result.append(item)
+    return result
+
+
+def _find(values: Any, key: str, expected: Any) -> dict[str, Any] | None:
+    if expected is None:
+        return None
+    return next((item for item in values or [] if _integer(item.get(key)) == _integer(expected)), None)
+
+
+def _characters(value: Any) -> int:
+    if value is None:
+        return 0
+    if isinstance(value, str):
+        return len(value)
+    try:
+        return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")))
+    except (TypeError, ValueError):
+        return len(str(value))
+
+
+def _strings(value: Any) -> list[str]:
+    result: list[str] = []
+    if isinstance(value, str):
+        if len(value.strip()) >= 8:
+            result.append(value.strip())
+    elif isinstance(value, dict):
+        for nested in value.values():
+            result.extend(_strings(nested))
+    elif isinstance(value, list):
+        for nested in value:
+            result.extend(_strings(nested))
+    return sorted(set(result), key=len, reverse=True)
+
+
+def _matching_snippets(value: Any, snippets: list[str]) -> list[str]:
+    text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
+    return [snippet for snippet in snippets if snippet in text][:20]
+
+
+def _technical_label(key: str) -> str:
+    return {
+        "source": "数据来源与定位",
+        "association": "关联方式与完整性",
+        "input": "原始输入",
+        "output": "原始输出",
+        "cost": "耗时与成本",
+        "record": "原始业务记录",
+        "event": "原始运行事件",
+        "taskEvent": "实现任务事件",
+        "taskSource": "任务来源判定",
+        "rawEvent": "运行事件摘要",
+        "branch": "实现方案记录",
+        "round": "轮次记录",
+    }.get(key, key)
+
+
+def _context_id(source: dict[str, Any], field_path: Any) -> str:
+    return ":".join(str(item) for item in (source.get("table") or source.get("type"), source.get("recordId") or source.get("eventId") or "record", field_path or "$") )
+
+
+def _completeness(notice: str | None, reads: list[dict[str, Any]], contexts: list[dict[str, Any]]) -> dict[str, Any]:
+    missing = [item for item in contexts if item.get("completeness") in {"missing", "body-missing"}]
+    unconfirmed = [item for item in reads if item.get("disposition") == "unconfirmed"]
+    return {
+        "state": "partial" if notice or missing or unconfirmed else "complete",
+        "inspectorAvailable": notice is None,
+        "sourceCount": len(reads),
+        "contextCount": len(contexts),
+        "missingContextCount": len(missing),
+        "unconfirmedSourceCount": len(unconfirmed),
+        "notice": notice,
+        "redacted": True,
+    }
+
+
+def _suffix_int(value: Any) -> int | None:
+    text = str(value or "")
+    try:
+        return int(text.rsplit(":", 1)[-1])
+    except (TypeError, ValueError):
+        return None
+
+
+def _integer(value: Any) -> int | None:
+    try:
+        return int(value) if value is not None else None
+    except (TypeError, ValueError):
+        return None

+ 805 - 0
visualization-old/backend/app/module_audit/static/app.js

@@ -0,0 +1,805 @@
+(() => {
+  const runId = document.body.dataset.runId;
+  const root = document.getElementById("audit-root");
+  const runSummary = document.getElementById("run-summary");
+  const moduleJump = document.getElementById("module-jump");
+  const onlyUsedFields = document.getElementById("only-used-fields");
+  const sourceFilter = document.getElementById("source-filter");
+  const readColumnTitle = document.getElementById("read-column-title");
+  const contextColumnTitle = document.getElementById("context-column-title");
+  const detailCache = new Map();
+  const renderedModules = new Set();
+  const renderQueue = [];
+  let renderScheduled = false;
+  let runMeta = null;
+  let moduleTotal = 0;
+  let streamComplete = false;
+
+  const jsonText = (value) => {
+    if (value === null || value === undefined) return "";
+    if (typeof value === "string") return value;
+    try { return JSON.stringify(value, null, 2); } catch { return String(value); }
+  };
+  const chars = (value) => Array.from(jsonText(value)).length;
+  const escapeHtml = (value) => String(value ?? "")
+    .replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
+    .replaceAll('"', "&quot;").replaceAll("'", "&#039;");
+  const truncate = (value, length = 240) => {
+    const text = jsonText(value).replace(/\s+/g, " ").trim();
+    return Array.from(text).length > length ? `${Array.from(text).slice(0, length).join("")}…` : text;
+  };
+  const excerptAround = (value, needle, length = 300) => {
+    const text = jsonText(value).replace(/\s+/g, " ").trim();
+    if (!needle || !text.includes(needle)) return truncate(text, length);
+    const charsBefore = 80;
+    const index = text.indexOf(needle);
+    const start = Math.max(0, index - charsBefore);
+    const end = Math.min(text.length, start + Math.max(length, needle.length + charsBefore));
+    return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
+  };
+  const correlationText = (value) => {
+    if (value === null || value === undefined) return "";
+    if (typeof value === "string") return value.trim();
+    if (typeof value === "number" || typeof value === "boolean") return String(value);
+    return jsonText(value).trim();
+  };
+  const makeSegments = (scope, items) => items
+    .filter((item) => item && correlationText(item.value))
+    .map((item, index) => ({ ...item, id: `${scope}-${index + 1}`, number: index + 1, color: index % 8, text: correlationText(item.value) }));
+  const matchingSegments = (value, segments) => {
+    const text = correlationText(value);
+    if (!text) return [];
+    return segments.filter((segment) => segment.text.length >= 2 && text.includes(segment.text));
+  };
+  const labelForDisposition = (value) => ({ selected: "已采用", fallback: "备用来源", "calculation-input": "参与计算", unconfirmed: "来源未确认", missing: "记录缺失" }[value] || value || "已读取");
+  const sourceKind = (source = {}) => source.sourceKind || "unknown";
+  const sourceKindLabel = (kind) => ({ database: "数据库", function: "函数", log: "日志", unknown: "未确认" }[kind] || "未确认");
+  const relationLabel = (value) => ({ exact: "直接摘取", "exact-record": "完整记录", structured: "结构化字段", projected: "展示处理", combined: "多来源重组", "calculation-input": "参与计算", anchored: "日志定位", missing: "记录缺失" }[value] || value || "未标记");
+  const completenessLabel = (value) => ({ complete: "完整", "body-missing": "Event Body 缺失", missing: "记录缺失", partial: "部分可用" }[value] || value || "未知");
+
+  function sourceName(source = {}) {
+    const kind = sourceKind(source);
+    if (kind === "database") return `数据库:${source.table || "表名未记录"}`;
+    if (kind === "function") return `函数:${source.functionName || "Inspector 详情组装"}`;
+    if (kind === "log") return `日志:${source.table || "script_build_log"}`;
+    return "来源未确认";
+  }
+
+  function sourceDetail(source = {}) {
+    return [
+      source.recordId !== null && source.recordId !== undefined ? `记录 #${source.recordId}` : null,
+      source.eventId !== null && source.eventId !== undefined ? `Event ${source.eventId}` : null,
+      source.eventName || null,
+      source.technicalGroup ? `处理组 ${source.technicalGroup}` : null,
+    ].filter(Boolean).join(" · ");
+  }
+
+  function buildSourceRegistry(reads, contexts) {
+    const registry = new Map();
+    const counters = { database: 0, function: 0, log: 0, unknown: 0 };
+    [...reads, ...contexts].forEach((item) => {
+      const key = item.sourceKey || `unknown:${registry.size + 1}`;
+      if (registry.has(key)) return;
+      const kind = sourceKind(item.source || {});
+      counters[kind] = (counters[kind] || 0) + 1;
+      registry.set(key, { key, kind, label: `${sourceKindLabel(kind)} ${counters[kind]}`, source: item.source || {} });
+    });
+    return registry;
+  }
+
+  function groupReads(reads) {
+    const groups = new Map();
+    reads.forEach((read, index) => {
+      const key = read.sourceKey || `unknown:${index}`;
+      if (!groups.has(key)) groups.set(key, { key, source: read.source || {}, items: [], usageAreas: new Set() });
+      const group = groups.get(key);
+      group.items.push(read);
+      (Array.isArray(read.usageAreas) ? read.usageAreas : []).forEach((area) => group.usageAreas.add(area));
+    });
+    return [...groups.values()];
+  }
+
+  function groupsForAreas(groups, areas) {
+    const wanted = new Set(areas);
+    return groups.filter((group) => [...group.usageAreas].some((area) => wanted.has(area)));
+  }
+
+  function referencesForGroups(groups, registry) {
+    return groups.map((group) => registry.get(group.key)).filter(Boolean);
+  }
+
+  function contextsForGroups(contexts, groups) {
+    const keys = new Set(groups.map((group) => group.key));
+    return contexts.filter((context) => keys.has(context.sourceKey) || (context.kind === "log-module" && context.relatedSourceKey && keys.has(context.relatedSourceKey)));
+  }
+
+  function activeSourceKind() {
+    return sourceFilter?.value || "all";
+  }
+
+  function filterGroups(groups) {
+    const wanted = activeSourceKind();
+    return wanted === "all" ? groups : groups.filter((group) => sourceKind(group.source) === wanted);
+  }
+
+  function filterContexts(contexts) {
+    const wanted = activeSourceKind();
+    return wanted === "all" ? contexts : contexts.filter((context) => sourceKind(context.source) === wanted);
+  }
+
+  function parseStructuredText(value) {
+    if (typeof value !== "string") return value;
+    const text = value.trim();
+    if (!text || !["{", "["].includes(text[0])) return value;
+    try { return JSON.parse(text); } catch { return value; }
+  }
+
+  function sourceLinks(links = [], usageAreas = []) {
+    if (!links.length && !usageAreas.length) return "";
+    const refs = links.map((item) => `<span class="sourceTag source-${escapeHtml(item.kind)}" role="button" tabindex="0" aria-pressed="false" data-source-key="${escapeHtml(item.key)}" title="突出显示三列中的对应内容">${escapeHtml(item.label)}</span>`).join("");
+    const usages = usageAreas.map((area) => `<span class="usageTag">${escapeHtml(area)}</span>`).join("");
+    return `<span class="sourceLinks">${refs}${usages}</span>`;
+  }
+
+  function correlationLinks(segments = []) {
+    if (!segments.length) return "";
+    return `<span class="correlationLinks" aria-label="字段对应关系">${segments.map((segment) => `<span class="correlationChip correlation-${segment.color}" role="button" tabindex="0" aria-pressed="false" data-correlation-id="${escapeHtml(segment.id)}" title="突出显示三列中的对应字段"><b>${String(segment.number).padStart(2, "0")}</b>${escapeHtml(segment.label)}</span>`).join("")}</span>`;
+  }
+
+  function contentBox(title, value, options = {}) {
+    const count = options.characters ?? chars(value);
+    const long = count > 800;
+    const body = options.html ?? `<pre>${escapeHtml(jsonText(value))}</pre>`;
+    const meta = [options.meta, `${count.toLocaleString("zh-CN")} 字`].filter(Boolean).join(" · ");
+    const sourceKeys = [...new Set([...(options.links || []).map((item) => item.key), ...(options.sourceKeys || [])].filter(Boolean))].join(" ");
+    return `<details class="contentBox ${options.tone ? `tone-${options.tone}` : ""}" data-source-keys="${escapeHtml(sourceKeys)}" ${long ? "" : "open"}>
+      <summary><span class="summaryLine"><b>${escapeHtml(title)}</b><small>${escapeHtml(meta)}</small></span>${sourceLinks(options.links, options.usageAreas)}${correlationLinks(options.correlationSegments)}${long && options.showPreview ? `<em>${escapeHtml(truncate(value))}</em>` : ""}</summary>
+      <div class="contentBody">${body}</div>
+    </details>`;
+  }
+
+  function cardSegments(card) {
+    return makeSegments("card", (card?.fields || []).map((field) => ({ label: field?.label || "内容", value: field?.value })));
+  }
+
+  function businessSegments(inspector) {
+    const items = [];
+    (inspector?.businessSections || []).forEach((section) => items.push({ label: section.title || "内容", value: section.content ?? section.items ?? section }));
+    (inspector?.blocks || []).forEach((block) => items.push({ label: block.title || block.type || "决策内容", value: block.value ?? block.items ?? block }));
+    if (inspector?.changes) items.push({ label: "产出与改动", value: inspector.changes });
+    return makeSegments("business", items);
+  }
+
+  function technicalSegments(inspector) {
+    const technical = inspector?.technical;
+    if (!technical || typeof technical !== "object") return [];
+    return makeSegments("technical", Object.entries(technical).map(([label, value]) => ({ label, value })));
+  }
+
+  function targetAttrs(segment) {
+    if (!segment) return "";
+    return ` role="button" tabindex="0" data-correlation-id="${escapeHtml(segment.id)}"`;
+  }
+
+  function renderCard(card, links, segments) {
+    if (!card) return contentBox("卡片", "该模块没有卡片展示数据。", { tone: "muted" });
+    const fields = (card.fields || []).filter((field) => field && field.value !== null && field.value !== undefined && field.value !== "");
+    const html = `<div class="displayCard"><h4>${escapeHtml(card.title || "卡片")}</h4>${fields.map((field, index) => {
+      const segment = segments[index];
+      return `<div class="displayField${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><b>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}${escapeHtml(field.label || "内容")}</b><div>${renderPlainValue(field.value)}</div></div>`;
+    }).join("") || `<p class="empty">卡片没有可显示字段。</p>`}</div>`;
+    return contentBox("卡片", card, { html, characters: chars(card), meta: "最终显示内容", tone: "used", links, correlationSegments: segments });
+  }
+
+  function businessDetailPayload(inspector) {
+    return {
+      businessSections: inspector?.businessSections || [],
+      blocks: inspector?.blocks || [],
+      changes: inspector?.changes ?? null,
+    };
+  }
+
+  function renderBusinessDetail(inspector, notice, links, segments) {
+    if (!inspector) return contentBox("业务详情", notice || "该模块没有业务详情。", { tone: "muted", links });
+    const parts = [];
+    let segmentIndex = 0;
+    (inspector.businessSections || []).forEach((section) => {
+      const segment = segments[segmentIndex++];
+      parts.push(`<section class="businessPart${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><h4>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}${escapeHtml(section.title || "内容")}</h4>${renderPlainValue(section.content ?? section.items ?? section)}</section>`);
+    });
+    (inspector.blocks || []).forEach((block) => {
+      const segment = segments[segmentIndex++];
+      parts.push(`<section class="businessPart${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><h4>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}${escapeHtml(block.title || block.type || "决策内容")}</h4>${renderPlainValue(block.value ?? block.items ?? block)}</section>`);
+    });
+    if (inspector.changes) {
+      const segment = segments[segmentIndex];
+      parts.push(`<section class="businessPart${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><h4>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}产出与改动</h4>${renderPlainValue(inspector.changes)}</section>`);
+    }
+    const payload = businessDetailPayload(inspector);
+    return contentBox("业务详情", payload, {
+      html: parts.length ? `<div class="businessDetailBody">${parts.join("")}</div>` : `<p class="empty">本次运行没有记录业务详情。</p>`,
+      characters: chars(payload),
+      meta: "Inspector 最终显示内容",
+      tone: "used",
+      links,
+      correlationSegments: segments,
+    });
+  }
+
+  function renderTechnicalDetail(inspector, notice, links, segments) {
+    if (!inspector || !Object.prototype.hasOwnProperty.call(inspector, "technical")) {
+      return contentBox("技术详情", notice || "该模块没有技术详情。", { tone: "muted", links });
+    }
+    const technical = inspector.technical;
+    const html = technical && typeof technical === "object"
+      ? `<div class="technicalDetailBody">${Object.entries(technical).map(([label, value], index) => {
+        const segment = segments[index];
+        return `<section class="technicalPart${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><h4>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}${escapeHtml(label)}</h4>${renderPlainValue(value)}</section>`;
+      }).join("")}</div>`
+      : renderPlainValue(technical);
+    return contentBox("技术详情", technical, { html, meta: "Inspector 最终显示内容", tone: "read", links, correlationSegments: segments });
+  }
+
+  function renderReadGroup(group, registry, segments) {
+    const entry = registry.get(group.key);
+    const usageAreas = [...group.usageAreas];
+    const count = group.items.reduce((sum, item) => sum + Number(item.characters || 0), 0);
+    const correlated = segments.filter((segment) => group.items.some((item) => matchingSegments(item.value, [segment]).length));
+    const html = group.items.map((read) => {
+      const status = `<span class="readStatus status-${escapeHtml(read.disposition)}">${escapeHtml(labelForDisposition(read.disposition))}</span>`;
+      const ratio = read.usageRatio?.label || "不可直接计算";
+      const field = read.fieldPath && read.fieldPath !== "$" ? `读取字段:${read.fieldPath}` : "读取完整记录";
+      const exactRef = (read.contextRefs || []).find((ref) => ref.exact);
+      const locateLabel = exactRef ? "定位原始字段" : "定位原始记录";
+      return `<div class="readItem" data-read-id="${escapeHtml(read.readId || "")}"><div class="readItemHeader"><b>${escapeHtml(read.label || "数据")}</b><code>${escapeHtml(field)}</code></div><div class="readMeta">${status}<span>${escapeHtml(read.transformation || relationLabel(read.relation))}</span><span>使用比例 ${escapeHtml(ratio)}</span><button type="button" class="locateContextButton" data-locate-read-id="${escapeHtml(read.readId || "")}">${locateLabel}</button></div><div class="contextTree readValueTree">${renderContextValue(read.value, "", [], [], correlated)}</div></div>`;
+    }).join("");
+    const detail = sourceDetail(group.source);
+    return contentBox(sourceName(group.source), group.items.map((item) => item.value), { html, characters: count, meta: [detail, `${group.items.length} 项读取`].filter(Boolean).join(" · "), tone: sourceKind(group.source), links: entry ? [entry] : [], usageAreas: usageAreas.length ? usageAreas : ["未进入界面"], correlationSegments: correlated });
+  }
+
+  function refsForContext(groups, context) {
+    return groups.flatMap((group) => group.items).flatMap((read) =>
+      (read.contextRefs || [])
+        .filter((ref) => ref.contextId === context.id)
+        .map((ref) => ({ read, ref }))
+    );
+  }
+
+  function renderContext(context, groups, registry, segments) {
+    const source = context.source || {};
+    const entry = registry.get(context.sourceKey);
+    const meta = [context.title, sourceDetail(source), relationLabel(context.match), completenessLabel(context.completeness)].filter(Boolean).join(" · ");
+    const readPaths = Array.isArray(context.readPaths) ? context.readPaths : [];
+    const usedFragments = Array.isArray(context.usedFragments) ? context.usedFragments : [];
+    const correlated = matchingSegments(context.content, segments);
+    const exactFields = refsForContext(groups, context)
+      .filter(({ ref }) => ref.contextFieldPath)
+      .map(({ read, ref }) => ({ read, ref, value: resolveFieldPath(context.content, ref.contextFieldPath) }))
+      .filter(({ value }) => value !== undefined);
+    const raw = exactFields.length
+      ? `<section class="exactFieldMap"><h4>第二列对应的原始字段</h4>${exactFields.map(({ read, ref, value }) => renderRawField(read, value, segments, [], ref)).join("")}</section>`
+      : "";
+    const html = renderContextValue(context.content, "", readPaths, usedFragments, correlated);
+    return contentBox(sourceName(source), context.content, { html: `${raw}<div class="contextTree">${html}</div>`, characters: context.characters, meta, tone: context.completeness === "complete" ? sourceKind(source) : "warning", links: entry ? [entry] : [], sourceKeys: [context.relatedSourceKey], correlationSegments: correlated });
+  }
+
+  function renderReads(groups, registry, segments) {
+    return groups.map((group) => renderReadGroup(group, registry, segments)).join("") || contentBox("读取来源", "这一部分没有可确认的读取数据。", { tone: "muted" });
+  }
+
+  function renderContexts(contexts, groups, registry, segments) {
+    const matched = filterContexts(contextsForGroups(contexts, groups));
+    return matched.map((context) => renderContext(context, groups, registry, segments)).join("") || contentBox("原始记录与上下文", "这一部分没有可定位的原始记录或日志上下文。", { tone: "muted" });
+  }
+
+  function compactSourceLabel(group) {
+    const source = group.source || {};
+    const event = group.items.map((item) => item.value).find((value) => value && typeof value === "object" && value.event_name);
+    const eventName = source.eventName || event?.event_name;
+    const record = source.eventId !== null && source.eventId !== undefined ? `Event ${source.eventId}` : source.recordId !== null && source.recordId !== undefined ? `记录 ${source.recordId}` : "";
+    return [sourceKindLabel(sourceKind(source)), eventName || source.functionName || source.table, record].filter(Boolean).join(" · ");
+  }
+
+  function previewWithMatch(value, needle) {
+    const preview = excerptAround(value, needle, 300);
+    if (!needle || !preview.includes(needle)) return escapeHtml(preview);
+    const index = preview.indexOf(needle);
+    return `${escapeHtml(preview.slice(0, index))}<mark class="valueMatch">${escapeHtml(needle)}</mark>${escapeHtml(preview.slice(index + needle.length))}`;
+  }
+
+  function renderValueDisclosure(value, label = "实际读取值", needle = "") {
+    const count = chars(value);
+    if (count <= 260 && typeof parseStructuredText(value) !== "object") {
+      return `<div class="fieldActualValue"><b>${escapeHtml(label)}</b><p>${previewWithMatch(value, needle)}</p></div>`;
+    }
+    return `<details class="valueDisclosure"><summary><span>${escapeHtml(label)}</span><small>${count.toLocaleString("zh-CN")} 字 · 展开全文</small><em>${previewWithMatch(value, needle)}</em></summary><div class="valueDisclosureBody">${renderPlainValue(value)}</div></details>`;
+  }
+
+  function renderCompactSource(group, segments) {
+    const unique = new Map();
+    group.items.forEach((read) => {
+      if (read.value === null || read.value === undefined || read.value === "") return;
+      const key = read.readId || `${read.fieldPath || "$"}::${jsonText(read.value)}`;
+      if (!unique.has(key)) unique.set(key, read);
+    });
+    const items = [...unique.values()];
+    const visible = items.slice(0, 8);
+    const hidden = items.length - visible.length;
+    return `<section class="lineageSource" data-source-key="${escapeHtml(group.key)}">
+      <header class="lineageSourceHeader"><span class="sourceKindPill source-${escapeHtml(sourceKind(group.source))}">${escapeHtml(compactSourceLabel(group))}</span></header>
+      ${visible.map((read) => {
+        const related = matchingSegments(read.value, segments);
+        const ids = related.map((segment) => segment.id).join(" ");
+        const tone = related[0] ? ` correspondenceTarget correlation-${related[0].color}` : "";
+        const exactRef = (read.contextRefs || []).find((ref) => ref.exact);
+        return `<div class="readValueEntry${tone}" data-read-id="${escapeHtml(read.readId || "")}"${ids ? ` data-correlation-ids="${escapeHtml(ids)}"` : ""}><div class="readValueMeta"><span>${escapeHtml(read.label || "读取字段")}</span><code>${escapeHtml(read.fieldPath || "$" )}</code>${correlationLinks(related)}<button type="button" class="locateContextButton" data-locate-read-id="${escapeHtml(read.readId || "")}" data-locate-correlation="${escapeHtml(related[0]?.id || "")}">${exactRef ? "定位原始字段" : "定位原始记录"}</button></div>${renderValueDisclosure(read.value, "实际读取值", related[0]?.text || "")}${related.length ? "" : `<small class="lineageUnproven">参与展示处理,但无法逐字定位到左侧单一字段</small>`}</div>`;
+      }).join("")}
+      ${hidden > 0 ? `<small class="moreFieldsNotice">另有 ${hidden} 个读取字段,可在完整记录模式查看。</small>` : ""}
+    </section>`;
+  }
+
+  function renderCompactReads(groups, contexts, segments) {
+    if (!groups.length) return `<div class="compactEmpty">当前来源筛选下没有业务读取字段。</div>`;
+    const items = groups.map((group) => renderCompactSource(group, segments)).join("");
+    return `<section class="compactPanel"><header><h4>字段来源</h4><span>${groups.reduce((sum, group) => sum + group.items.length, 0)} 个实际读取字段</span></header><div class="lineageSources">${items}</div></section>`;
+  }
+
+  function contextDigest(context) {
+    const content = context.content && typeof context.content === "object" ? context.content : {};
+    const eventName = content.event_name || context.source?.eventName || context.source?.functionName || context.title;
+    const eventId = context.source?.eventId;
+    return { context, eventName, eventId };
+  }
+
+  function resolveFieldPath(value, fieldPath) {
+    if (!fieldPath || fieldPath === "$" || fieldPath === "event") return value;
+    const parts = String(fieldPath).replace(/^\$\.?/, "").replaceAll("[]", "").split(".").filter(Boolean);
+    let current = value;
+    for (const part of parts) {
+      current = parseStructuredText(current);
+      if (!current || typeof current !== "object" || !(part in current)) return undefined;
+      current = current[part];
+    }
+    return parseStructuredText(current);
+  }
+
+  function renderRawField(read, value, segments, derivedSegments, ref = {}) {
+    const exact = matchingSegments(value, segments);
+    const related = exact.length ? exact : derivedSegments;
+    const ids = related.map((segment) => segment.id).join(" ");
+    const tone = related[0] ? ` correlation-${related[0].color}` : "";
+    const relation = ref.relation === "record-only" ? "只能定位到记录" : ref.relation === "transformed" ? "函数处理结果" : "精确字段对应";
+    return `<div class="rawField${tone}" tabindex="-1" data-read-ids="${escapeHtml(read.readId || "")}"${ids ? ` data-correlation-ids="${escapeHtml(ids)}"` : ""}><header><div><span>${escapeHtml(read.label || "读取字段")}</span><code>${escapeHtml(ref.contextFieldPath || read.fieldPath || "完整记录")}</code></div><em class="lineageRelation">${escapeHtml(relation)}</em>${correlationLinks(related)}</header>${renderValueDisclosure(value, "原始字段值", exact[0]?.text || "")}</div>`;
+  }
+
+  function renderCompactContexts(contexts, groups, segments) {
+    const matched = filterContexts(contextsForGroups(contexts, groups));
+    if (!matched.length) return `<div class="compactEmpty">当前来源筛选下没有相关原始记录。</div>`;
+    const grouped = new Map();
+    matched.forEach((context) => {
+      const kind = sourceKind(context.source);
+      const key = `${kind}::${context.sourceKey || context.id}`;
+      if (!grouped.has(key)) grouped.set(key, { context, contexts: [], eventIds: [] });
+      const bucket = grouped.get(key);
+      bucket.contexts.push(context);
+      if (context.source?.eventId !== null && context.source?.eventId !== undefined) bucket.eventIds.push(context.source.eventId);
+    });
+    const html = [...grouped.values()].map((bucket) => {
+      const context = bucket.context;
+      const kind = sourceKind(context.source);
+      const digest = contextDigest(context);
+      const records = bucket.eventIds.length ? `Event ${[...new Set(bucket.eventIds)].join("、")}` : sourceDetail(context.source);
+      const sourceKey = context.sourceKey || "";
+      const relatedGroups = groups.filter((group) => group.key === sourceKey || (context.relatedSourceKey && group.key === context.relatedSourceKey));
+      const reads = relatedGroups.flatMap((group) => group.items);
+      const uniqueReads = new Map();
+      reads.forEach((read) => {
+        (read.contextRefs || []).forEach((ref) => {
+          const targetContext = bucket.contexts.find((candidate) => candidate.id === ref.contextId);
+          if (!targetContext) return;
+          const value = resolveFieldPath(targetContext.content, ref.contextFieldPath);
+          if (value === undefined) return;
+          const key = `${read.readId || "read"}::${ref.contextId}::${ref.contextFieldPath}`;
+          if (!uniqueReads.has(key)) uniqueReads.set(key, { read, value, ref });
+        });
+      });
+      if (kind === "log") return `<article class="sourceDigest tone-log" tabindex="-1" data-source-key="${escapeHtml(context.relatedSourceKey || sourceKey)}"><header><span class="sourceKindPill source-log">日志</span><strong>${escapeHtml(digest.eventName)}</strong><em>${escapeHtml(records || `${context.characters || 0} 字`)}</em></header>${renderValueDisclosure(context.content, "锚定日志上下文")}</article>`;
+      const fields = [...uniqueReads.values()].slice(0, 6);
+      const hidden = uniqueReads.size - fields.length;
+      return `<article class="sourceDigest tone-${escapeHtml(kind)}" tabindex="-1" data-source-key="${escapeHtml(sourceKey)}"><header><span class="sourceKindPill source-${escapeHtml(kind)}">${escapeHtml(sourceKindLabel(kind))}</span><strong>${escapeHtml(digest.eventName)}</strong><em>${escapeHtml(records)}</em></header><div class="recordLocation"><span>原始记录位置</span><code>${escapeHtml(context.source?.table || sourceName(context.source))}</code>${context.source?.recordId !== null && context.source?.recordId !== undefined ? `<code>#${escapeHtml(context.source.recordId)}</code>` : ""}${context.source?.eventId !== null && context.source?.eventId !== undefined ? `<code>Event ${escapeHtml(context.source.eventId)}</code>` : ""}</div>${fields.length ? `<div class="rawFields">${fields.map(({ read, value, ref }) => renderRawField(read, value, segments, [], ref)).join("")}</div>` : `<p>这条记录参与了处理,但没有可确认的独立读取字段。</p>`}${hidden > 0 ? `<small class="moreFieldsNotice">另有 ${hidden} 个字段,可在完整记录模式查看。</small>` : ""}</article>`;
+    }).join("");
+    return `<section class="compactPanel"><header><h4>原始字段与记录</h4><span>${grouped.size} 组</span></header>${html}</section>`;
+  }
+
+  function correspondenceRow(kind, actualUse, groups, contextGroups, contexts, registry, segments, compact) {
+    return `<section class="correspondenceRow" data-area="${escapeHtml(kind)}">
+      <div class="correspondenceCell" data-column="use"><div class="columnLabel">模块实际使用</div>${actualUse}</div>
+      <div class="correspondenceCell" data-column="read"><div class="columnLabel">${compact ? "字段来源与读取" : "模块实际读取"}</div>${compact ? renderCompactReads(groups, contexts, segments) : renderReads(groups, registry, segments)}</div>
+      <div class="correspondenceCell" data-column="context"><div class="columnLabel">${compact ? "原始字段与记录" : "原始记录与上下文"}</div>${compact ? renderCompactContexts(contexts, contextGroups, segments) : renderContexts(contexts, contextGroups, registry, segments)}</div>
+    </section>`;
+  }
+
+  function renderPlainValue(value) {
+    if (value === null || value === undefined || value === "") return `<span class="empty">空</span>`;
+    if (typeof value === "object") return `<pre>${escapeHtml(jsonText(value))}</pre>`;
+    return `<p>${escapeHtml(String(value)).replaceAll("\n", "<br>")}</p>`;
+  }
+
+  function renderContextValue(value, path, readPaths, snippets, segments = []) {
+    if (Array.isArray(value)) return `<ol>${value.map((item, index) => `<li>${renderContextValue(item, `${path}[${index}]`, readPaths, snippets, segments)}</li>`).join("")}</ol>`;
+    if (value && typeof value === "object") return `<dl>${Object.entries(value).map(([key, nested]) => {
+      const childPath = path ? `${path}.${key}` : key;
+      const read = readPaths.some((item) => item === "$" || childPath === item || childPath.endsWith(`.${item}`) || item.startsWith(`${childPath}.`));
+      const correlated = matchingSegments(nested, segments).length > 0;
+      return `<div class="contextEntry ${read ? "isRead" : "isDim"}${correlated ? " hasCorrelationData" : ""}"><dt>${escapeHtml(key)}</dt><dd>${renderContextValue(nested, childPath, readPaths, snippets, segments)}</dd></div>`;
+    }).join("")}</dl>`;
+    const text = String(value ?? "");
+    return `<span>${highlightCorrelations(text, segments) || highlightUsed(text, snippets)}</span>`;
+  }
+
+  function highlightCorrelations(text, segments) {
+    const matches = [];
+    segments.forEach((segment) => {
+      if (!segment.text || segment.text.length < 2) return;
+      let from = 0;
+      let found = 0;
+      while (from <= text.length && found < 24) {
+        const index = text.indexOf(segment.text, from);
+        if (index < 0) break;
+        matches.push({ start: index, end: index + segment.text.length, segment });
+        from = index + Math.max(segment.text.length, 1);
+        found += 1;
+      }
+    });
+    if (!matches.length) return "";
+    matches.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start));
+    const accepted = [];
+    let cursor = -1;
+    matches.forEach((match) => {
+      if (match.start < cursor) return;
+      accepted.push(match);
+      cursor = match.end;
+    });
+    let output = "";
+    cursor = 0;
+    accepted.forEach((match) => {
+      output += escapeHtml(text.slice(cursor, match.start));
+      output += `<mark class="correlationMark correlation-${match.segment.color}" role="button" tabindex="0" data-correlation-id="${escapeHtml(match.segment.id)}" title="对应 ${escapeHtml(match.segment.label)}">${escapeHtml(text.slice(match.start, match.end))}</mark>`;
+      cursor = match.end;
+    });
+    output += escapeHtml(text.slice(cursor));
+    return output.replaceAll("\n", "<br>");
+  }
+
+  function highlightUsed(text, snippets) {
+    const candidates = (Array.isArray(snippets) ? snippets : []).filter((snippet) => snippet && snippet.length >= 8 && text.includes(snippet)).sort((a, b) => b.length - a.length);
+    if (!candidates.length) return escapeHtml(text).replaceAll("\n", "<br>");
+    const snippet = candidates[0];
+    const index = text.indexOf(snippet);
+    return `${escapeHtml(text.slice(0, index))}<mark>${escapeHtml(snippet)}</mark>${escapeHtml(text.slice(index + snippet.length))}`.replaceAll("\n", "<br>");
+  }
+
+  function moduleRow(module) {
+    const path = (module.path || []).join(" / ");
+    const variant = module.displayVariant === "collapsed-summary" ? `<span class="variant">折叠态摘要</span>` : "";
+    return `<article class="moduleRow" data-module-id="${escapeHtml(module.id)}" tabindex="-1">
+      <header><div><span>${escapeHtml(path)}</span><h3>${escapeHtml(module.title)}</h3></div><div class="moduleMeta">${variant}<code>${escapeHtml(module.id)}</code><span>${Number(module.cardCharacters || 0).toLocaleString("zh-CN")} 字卡片内容</span><span class="moduleStreamState">正在自动加载</span></div></header>
+      <div class="moduleDeferred"><div class="cellLoading"><i></i><i></i></div></div>
+    </article>`;
+  }
+
+  function populateModuleJump(modules) {
+    if (!moduleJump) return;
+    const groups = new Map();
+    modules.forEach((module) => {
+      const group = module.group || "其他";
+      if (!groups.has(group)) groups.set(group, []);
+      groups.get(group).push(module);
+    });
+    const options = [...groups.entries()].map(([group, items]) => `<optgroup label="${escapeHtml(group)}">${items.map((module) => {
+      const path = (module.path || []).filter(Boolean).join(" / ");
+      const label = path ? `${module.title} · ${path}` : module.title;
+      return `<option value="${escapeHtml(module.id)}">${escapeHtml(label)}</option>`;
+    }).join("")}</optgroup>`).join("");
+    moduleJump.innerHTML = `<option value="">选择一张卡片…</option>${options}`;
+    moduleJump.disabled = modules.length === 0;
+  }
+
+  function jumpToModule(moduleId, options = {}) {
+    if (!moduleId) return;
+    const row = [...document.querySelectorAll(".moduleRow")].find((item) => item.dataset.moduleId === moduleId);
+    if (!row) return;
+    const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
+    row.scrollIntoView({ behavior: reduceMotion || options.instant ? "auto" : "smooth", block: "start" });
+    row.focus({ preventScroll: true });
+    row.classList.remove("isJumpTarget");
+    void row.offsetWidth;
+    row.classList.add("isJumpTarget");
+    window.setTimeout(() => row.classList.remove("isJumpTarget"), 1600);
+    if (options.updateHash !== false) history.replaceState(null, "", `#module=${encodeURIComponent(moduleId)}`);
+  }
+
+  function renderModuleDetail(row, detail) {
+    const reads = detail.actualReads || [];
+    const contexts = detail.contexts || [];
+    const registry = buildSourceRegistry(reads, contexts);
+    const sourceGroups = groupReads(reads);
+    const cardContextGroups = groupsForAreas(sourceGroups, ["卡片"]);
+    const businessContextGroups = groupsForAreas(sourceGroups, ["Inspector 业务详情", "Inspector 产出与改动"]);
+    const assigned = new Set([...cardContextGroups, ...businessContextGroups].map((group) => group.key));
+    const technicalContextGroups = [
+      ...groupsForAreas(sourceGroups, ["Inspector 技术详情", "模块处理"]),
+      ...sourceGroups.filter((group) => !assigned.has(group.key) && group.usageAreas.size === 0),
+    ].filter((group, index, all) => all.findIndex((candidate) => candidate.key === group.key) === index);
+    const cardGroups = filterGroups(cardContextGroups);
+    const businessGroups = filterGroups(businessContextGroups);
+    const technicalGroups = filterGroups(technicalContextGroups);
+    const cardLinks = referencesForGroups(cardGroups, registry);
+    const businessLinks = referencesForGroups(businessGroups, registry);
+    const technicalLinks = referencesForGroups(technicalGroups, registry);
+    const cardFieldSegments = cardSegments(detail.actualUse?.card);
+    const businessFieldSegments = businessSegments(detail.actualUse?.inspector);
+    const technicalFieldSegments = technicalSegments(detail.actualUse?.inspector);
+    const compact = onlyUsedFields?.checked !== false;
+    const rows = [
+      correspondenceRow("卡片", renderCard(detail.actualUse?.card, cardLinks, cardFieldSegments), cardGroups, cardContextGroups, contexts, registry, cardFieldSegments, compact),
+      correspondenceRow("业务详情", renderBusinessDetail(detail.actualUse?.inspector, detail.actualUse?.inspectorNotice, businessLinks, businessFieldSegments), businessGroups, businessContextGroups, contexts, registry, businessFieldSegments, compact),
+      correspondenceRow("技术详情", renderTechnicalDetail(detail.actualUse?.inspector, detail.actualUse?.inspectorNotice, technicalLinks, technicalFieldSegments), technicalGroups, technicalContextGroups, contexts, registry, technicalFieldSegments, compact),
+    ];
+    const target = row.querySelector(".moduleDeferred, .moduleColumns, .correspondenceRows");
+    if (target) target.outerHTML = `<div class="correspondenceRows ${compact ? "isCompact" : "isComplete"}">${rows.join("")}</div>`;
+    row.classList.remove("hasSourceFocus", "hasCorrelationFocus");
+    delete row.dataset.activeSource;
+    delete row.dataset.activeCorrelation;
+    row.classList.add("isLoaded");
+    const state = row.querySelector(".moduleStreamState");
+    if (state) state.textContent = "已加载";
+  }
+
+  function updateLoadProgress() {
+    if (!runMeta) return;
+    const loadedCount = detailCache.size;
+    const renderedCount = renderedModules.size;
+    const progress = moduleTotal ? `${loadedCount}/${moduleTotal} 数据` : "正在建立模块目录";
+    const dataDone = streamComplete && loadedCount >= moduleTotal;
+    const renderDone = renderedCount >= moduleTotal;
+    const state = dataDone && renderDone ? "全部加载完成" : dataDone ? `正在展示 ${renderedCount}/${moduleTotal}` : "正在加载全部数据";
+    runSummary.innerHTML = `<strong>${progress}</strong><span>${Number(runMeta.summary?.groupCount || 0)} 个流程分组</span><span>卡片内容 ${Number(runMeta.summary?.cardCharacters || 0).toLocaleString("zh-CN")} 字</span><em>${state}</em>`;
+  }
+
+  function drainRenderQueue() {
+    renderScheduled = false;
+    const started = performance.now();
+    while (renderQueue.length && performance.now() - started < 10) {
+      const { moduleId, detail } = renderQueue.shift();
+      const row = document.querySelector(`.moduleRow[data-module-id="${CSS.escape(moduleId)}"]`);
+      if (row) {
+        renderModuleDetail(row, detail);
+        renderedModules.add(moduleId);
+      }
+    }
+    updateLoadProgress();
+    if (renderQueue.length) scheduleRenderQueue();
+  }
+
+  function scheduleRenderQueue() {
+    if (renderScheduled) return;
+    renderScheduled = true;
+    requestAnimationFrame(drainRenderQueue);
+  }
+
+  function enqueueDetail(moduleId, detail) {
+    detailCache.set(moduleId, detail);
+    renderQueue.push({ moduleId, detail });
+    updateLoadProgress();
+    scheduleRenderQueue();
+  }
+
+  function refreshLoadedModules() {
+    const compact = onlyUsedFields?.checked !== false;
+    if (readColumnTitle) readColumnTitle.textContent = compact ? "字段来源与读取" : "模块实际读取";
+    if (contextColumnTitle) contextColumnTitle.textContent = compact ? "原始字段与记录" : "原始记录与上下文";
+    const entries = [...detailCache.entries()];
+    let cursor = 0;
+    const renderBatch = () => {
+      const limit = Math.min(cursor + 3, entries.length);
+      for (; cursor < limit; cursor += 1) {
+        const [moduleId, detail] = entries[cursor];
+        const row = document.querySelector(`.moduleRow[data-module-id="${CSS.escape(moduleId)}"]`);
+        if (row) renderModuleDetail(row, detail);
+      }
+      if (cursor < entries.length) requestAnimationFrame(renderBatch);
+    };
+    requestAnimationFrame(renderBatch);
+  }
+
+  function locateOriginalField(target) {
+    const row = target.closest(".moduleRow");
+    if (!row) return;
+    const readId = target.dataset.locateReadId;
+    const match = readId
+      ? row.querySelector(`[data-column="context"] [data-read-ids~="${CSS.escape(readId)}"]`)
+      : null;
+    if (!match) return;
+    const correlationId = target.dataset.locateCorrelation;
+    if (correlationId) activateCorrelation(row, correlationId, target);
+    match.classList.remove("isLocated");
+    void match.offsetWidth;
+    match.classList.add("isLocated");
+    match.scrollIntoView({ behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "center", inline: "nearest" });
+    match.focus({ preventScroll: true });
+    window.setTimeout(() => match.classList.remove("isLocated"), 1800);
+  }
+
+  function setSourceFocus(target) {
+    const row = target.closest(".moduleRow");
+    if (!row) return;
+    const key = target.dataset.sourceKey;
+    const alreadyActive = row.dataset.activeSource === key;
+    row.querySelectorAll(".sourceTag").forEach((tag) => tag.setAttribute("aria-pressed", "false"));
+    row.querySelectorAll(".contentBox").forEach((box) => box.classList.remove("isSourceMatch"));
+    row.classList.remove("hasSourceFocus");
+    delete row.dataset.activeSource;
+    if (alreadyActive || !key) return;
+    row.dataset.activeSource = key;
+    row.classList.add("hasSourceFocus");
+    row.querySelectorAll(".sourceTag").forEach((tag) => {
+      if (tag.dataset.sourceKey === key) tag.setAttribute("aria-pressed", "true");
+    });
+    row.querySelectorAll(".contentBox").forEach((box) => {
+      const keys = (box.dataset.sourceKeys || "").split(" ");
+      if (keys.includes(key)) box.classList.add("isSourceMatch");
+    });
+  }
+
+  function activateCorrelation(row, id, colorTarget) {
+    const alreadyActive = row.dataset.activeCorrelation === id;
+    row.querySelectorAll("[data-correlation-id], [data-correlation-ids]").forEach((item) => {
+      item.classList.remove("isCorrelationMatch");
+      item.setAttribute("aria-pressed", "false");
+    });
+    row.classList.remove("hasCorrelationFocus");
+    delete row.dataset.activeCorrelation;
+    row.style.removeProperty("--active-correlation-color");
+    if (alreadyActive || !id) return;
+    row.dataset.activeCorrelation = id;
+    row.classList.add("hasCorrelationFocus");
+    const activeColor = getComputedStyle(colorTarget).getPropertyValue("--correlation-color").trim();
+    if (activeColor) row.style.setProperty("--active-correlation-color", activeColor);
+    row.querySelectorAll(`[data-correlation-id="${CSS.escape(id)}"], [data-correlation-ids~="${CSS.escape(id)}"]`).forEach((item) => {
+      item.classList.add("isCorrelationMatch");
+      item.setAttribute("aria-pressed", "true");
+      const details = item.closest("details");
+      if (details) details.open = true;
+    });
+  }
+
+  function setCorrelationFocus(target) {
+    const row = target.closest(".moduleRow");
+    if (!row) return;
+    activateCorrelation(row, target.dataset.correlationId, target);
+  }
+
+  root.addEventListener("click", (event) => {
+    const locateTarget = event.target.closest("[data-locate-read-id]");
+    if (locateTarget) {
+      event.preventDefault();
+      event.stopPropagation();
+      locateOriginalField(locateTarget);
+      return;
+    }
+    const correlationTarget = event.target.closest("[data-correlation-id]");
+    if (correlationTarget) {
+      event.preventDefault();
+      event.stopPropagation();
+      setCorrelationFocus(correlationTarget);
+      return;
+    }
+    const target = event.target.closest(".sourceTag");
+    if (!target) return;
+    event.preventDefault();
+    event.stopPropagation();
+    setSourceFocus(target);
+  });
+  root.addEventListener("keydown", (event) => {
+    if (event.key !== "Enter" && event.key !== " ") return;
+    const locateTarget = event.target.closest("[data-locate-read-id]");
+    if (locateTarget) {
+      event.preventDefault();
+      event.stopPropagation();
+      locateOriginalField(locateTarget);
+      return;
+    }
+    const correlationTarget = event.target.closest("[data-correlation-id]");
+    if (correlationTarget) {
+      event.preventDefault();
+      event.stopPropagation();
+      setCorrelationFocus(correlationTarget);
+      return;
+    }
+    const target = event.target.closest(".sourceTag");
+    if (!target) return;
+    event.preventDefault();
+    event.stopPropagation();
+    setSourceFocus(target);
+  });
+
+  moduleJump?.addEventListener("change", () => jumpToModule(moduleJump.value));
+  onlyUsedFields?.addEventListener("change", refreshLoadedModules);
+  sourceFilter?.addEventListener("change", refreshLoadedModules);
+
+  function initializeIndex(data) {
+    runMeta = data;
+    const modules = data.modules || [];
+    moduleTotal = modules.length;
+    updateLoadProgress();
+    const groups = new Map();
+    modules.forEach((module) => { if (!groups.has(module.group)) groups.set(module.group, []); groups.get(module.group).push(module); });
+    root.innerHTML = [...groups.entries()].map(([group, items]) => `<section class="auditGroup"><header class="groupHeader"><h2>${escapeHtml(group)}</h2><span>${items.length} 个模块</span></header>${items.map(moduleRow).join("")}</section>`).join("") || `<p class="fatal">这个 Run 没有可展示的模块。</p>`;
+    populateModuleJump(modules);
+    const initialModule = new URLSearchParams(location.hash.slice(1)).get("module");
+    if (initialModule) {
+      moduleJump.value = initialModule;
+      jumpToModule(initialModule, { instant: true, updateHash: false });
+    }
+  }
+
+  function handleStreamMessage(message) {
+    if (message.type === "index") {
+      initializeIndex(message.data || {});
+      return;
+    }
+    if (message.type === "detail") {
+      enqueueDetail(String(message.moduleId || message.data?.id || ""), message.data || {});
+      return;
+    }
+    if (message.type === "error") {
+      const row = document.querySelector(`.moduleRow[data-module-id="${CSS.escape(String(message.moduleId || ""))}"]`);
+      const target = row?.querySelector(".moduleDeferred");
+      if (target) target.innerHTML = `<p class="loadError">读取失败:${escapeHtml(message.message || "未知错误")}</p>`;
+      const state = row?.querySelector(".moduleStreamState");
+      if (state) state.textContent = "读取失败";
+      return;
+    }
+    if (message.type === "complete") {
+      streamComplete = true;
+      updateLoadProgress();
+    }
+  }
+
+  function consumeLines(text, carry = "") {
+    const lines = `${carry}${text}`.split("\n");
+    const nextCarry = lines.pop() || "";
+    lines.forEach((line) => {
+      if (!line.trim()) return;
+      handleStreamMessage(JSON.parse(line));
+    });
+    return nextCarry;
+  }
+
+  async function start() {
+    try {
+      const response = await fetch(`/api/script-builds/${encodeURIComponent(runId)}/module-audit-stream`, { headers: { Accept: "application/x-ndjson" }, cache: "no-store" });
+      if (!response.ok) throw new Error(`API ${response.status}`);
+      if (!response.body) {
+        consumeLines(await response.text());
+        return;
+      }
+      const reader = response.body.getReader();
+      const decoder = new TextDecoder();
+      let carry = "";
+      while (true) {
+        const { value, done } = await reader.read();
+        if (done) break;
+        carry = consumeLines(decoder.decode(value, { stream: true }), carry);
+      }
+      carry = consumeLines(decoder.decode(), carry);
+      if (carry.trim()) handleStreamMessage(JSON.parse(carry));
+    } catch (error) {
+      runSummary.textContent = "读取失败";
+      root.innerHTML = `<div class="fatal"><h2>无法读取模块数据对照</h2><p>${escapeHtml(error instanceof Error ? error.message : String(error))}</p></div>`;
+    }
+  }
+
+  start();
+})();

+ 50 - 0
visualization-old/backend/app/module_audit/static/index.html

@@ -0,0 +1,50 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Run #__RUN_ID__ · 模块数据对照</title>
+  <link rel="stylesheet" href="/audit-assets/styles.css" />
+</head>
+<body data-run-id="__RUN_ID__">
+  <header class="auditHeader">
+    <div class="headerIntro">
+      <span class="eyeline">脚本构建 #__RUN_ID__</span>
+      <h1>模块数据对照</h1>
+      <p>对齐模块实际使用、模块实际读取和原始记录上下文。</p>
+    </div>
+    <div class="runSummary" id="run-summary" aria-live="polite">正在读取真实运行数据…</div>
+  </header>
+  <nav class="auditToolbar" aria-label="审计页面工具栏">
+    <strong class="toolbarRun">Run #__RUN_ID__</strong>
+    <label class="moduleJump" for="module-jump">
+      <span>卡片</span>
+      <select id="module-jump" disabled>
+        <option value="">正在读取卡片列表…</option>
+      </select>
+    </label>
+    <label class="compactToggle" for="only-used-fields">
+      <input id="only-used-fields" type="checkbox" checked />
+      <span>只看使用字段</span>
+    </label>
+    <label class="sourceFilter" for="source-filter">
+      <span>来源</span>
+      <select id="source-filter">
+        <option value="all">全部</option>
+        <option value="database">数据库</option>
+        <option value="function">函数</option>
+        <option value="log">日志</option>
+      </select>
+    </label>
+  </nav>
+  <main>
+    <div class="columnHeader" aria-hidden="true">
+      <strong>模块实际使用</strong>
+      <strong id="read-column-title">字段来源与读取</strong>
+      <strong id="context-column-title">原始字段与记录</strong>
+    </div>
+    <div id="audit-root"><div class="loadingBlock"><i></i><i></i><i></i></div></div>
+  </main>
+  <script src="/audit-assets/app.js" defer></script>
+</body>
+</html>

+ 277 - 0
visualization-old/backend/app/module_audit/static/styles.css

@@ -0,0 +1,277 @@
+:root {
+  color-scheme: dark;
+  --bg: #101114;
+  --canvas: #15171b;
+  --surface: #202228;
+  --raised: #292c33;
+  --border: #383c45;
+  --text: #f2f3f5;
+  --muted: #adb2bd;
+  --subtle: #777e8c;
+  --accent: #ff6d5a;
+  --read: #62a8ff;
+  --success: #63c883;
+  --warning: #f4c152;
+  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  background: var(--bg);
+  color: var(--text);
+}
+* { box-sizing: border-box; }
+body { margin: 0; min-height: 100vh; background-color: var(--bg); background-image: radial-gradient(circle at 1px 1px, #2b2e35 1px, transparent 0); background-size: 22px 22px; }
+button, summary { font: inherit; }
+.auditHeader { min-height: 116px; padding: 24px 30px; display: flex; align-items: flex-end; justify-content: space-between; gap: 28px; background: rgba(21, 23, 27, .97); border-bottom: 1px solid var(--border); }
+.headerIntro { min-width: 0; }
+.auditHeader h1 { margin: 4px 0 6px; font-size: 24px; line-height: 1.25; }
+.auditHeader p { margin: 0; color: var(--muted); font-size: 14px; }
+.eyeline { color: var(--accent); font-size: 13px; font-weight: 700; }
+.auditToolbar { position: sticky; top: 0; z-index: 30; min-height: 52px; padding: 8px 18px; display: flex; align-items: center; gap: 12px 18px; background: rgba(25, 27, 32, .98); border-bottom: 1px solid var(--border); box-shadow: 0 8px 20px rgba(0,0,0,.20); }
+.toolbarRun { flex: none; color: #dfe2e7; font-size: 13px; }
+.moduleJump, .sourceFilter { display: flex; align-items: center; gap: 8px; color: #c7cbd3; font-size: 12px; font-weight: 700; }
+.moduleJump > span, .sourceFilter > span { flex: none; white-space: nowrap; }
+.moduleJump { min-width: 300px; flex: 1 1 520px; max-width: 620px; }
+.moduleJump select { width: 100%; }
+.moduleJump select, .sourceFilter select { height: 34px; padding: 0 9px; color: var(--text); background: var(--surface); border: 1px solid #4a4f5b; border-radius: 7px; font: 12px/1.2 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+.sourceFilter select { min-width: 94px; }
+.moduleJump select:hover:not(:disabled), .sourceFilter select:hover { border-color: #666d7b; background: #252830; }
+.moduleJump select:focus-visible, .sourceFilter select:focus-visible { outline: 2px solid var(--read); outline-offset: 2px; border-color: var(--read); }
+.moduleJump select:disabled { cursor: wait; color: var(--subtle); opacity: .72; }
+.compactToggle { flex: none; min-height: 34px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; color: #e3e6eb; background: #24272d; border: 1px solid #484d58; border-radius: 7px; cursor: pointer; font-size: 12px; font-weight: 700; }
+.compactToggle:hover { background: #2a2e36; border-color: #626977; }
+.compactToggle:focus-within { outline: 2px solid var(--read); outline-offset: 2px; }
+.compactToggle input { width: 15px; height: 15px; margin: 0; accent-color: var(--read); }
+.runSummary { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px 14px; color: var(--muted); font-size: 13px; }
+.runSummary strong { color: var(--text); font-size: 15px; }
+.runSummary em { color: var(--success); font-style: normal; }
+main { width: 100%; min-width: 1080px; }
+.columnHeader { position: sticky; top: 52px; z-index: 20; display: grid; grid-template-columns: minmax(320px, .92fr) minmax(360px, 1fr) minmax(430px, 1.18fr); background: #191b20; border-bottom: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,.22); }
+.columnHeader strong { padding: 14px 18px; color: #d8dbe1; font-size: 13px; }
+.columnHeader strong + strong { border-left: 1px solid var(--border); }
+.auditGroup { padding-bottom: 24px; }
+.groupHeader { display: flex; align-items: center; gap: 12px; padding: 22px 30px 10px; }
+.groupHeader h2 { margin: 0; font-size: 18px; }
+.groupHeader span { color: var(--subtle); font-size: 12px; }
+.moduleRow { scroll-margin-top: 108px; margin: 0 18px 16px; background: var(--canvas); border: 1px solid var(--border); border-radius: 10px; overflow: clip; contain: layout paint style; box-shadow: 0 10px 30px rgba(0,0,0,.14); transition: border-color 180ms ease-out, box-shadow 180ms ease-out; }
+.moduleRow:focus { outline: none; }
+.moduleRow.isJumpTarget { border-color: var(--read); box-shadow: 0 0 0 2px rgba(98, 168, 255, .28), 0 14px 36px rgba(0,0,0,.24); }
+.moduleRow > header { min-height: 66px; padding: 13px 18px; display: flex; justify-content: space-between; align-items: center; gap: 20px; background: #1c1e23; border-bottom: 1px solid var(--border); }
+.moduleRow > header span { display: block; color: var(--subtle); font-size: 12px; }
+.moduleRow > header h3 { margin: 3px 0 0; font-size: 16px; }
+.moduleMeta { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
+.moduleMeta code, .readMeta code { color: #c7cbd3; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
+.moduleStreamState { min-height: 23px; padding: 3px 7px; display: inline-flex !important; align-items: center; border: 1px solid #48505d; border-radius: 999px; color: #cbd5e2 !important; background: #252932; font-size: 10px !important; }
+.moduleRow.isLoaded .moduleStreamState { color: #9be2b1 !important; border-color: #315d3f; background: #1e3527; }
+.moduleLoadButton, .locateContextButton { border: 1px solid #555b67; border-radius: 6px; color: #dfe3e9; background: #272a31; cursor: pointer; font-weight: 700; }
+.moduleLoadButton { min-height: 30px; padding: 4px 9px; font-size: 11px; }
+.moduleLoadButton:hover:not(:disabled), .locateContextButton:hover { color: #fff; border-color: #748093; background: #30343d; }
+.moduleLoadButton:focus-visible, .locateContextButton:focus-visible { outline: 2px solid var(--read); outline-offset: 2px; }
+.moduleLoadButton:disabled { cursor: wait; opacity: .64; }
+.moduleRow.isLoaded .moduleLoadButton { display: none; }
+.moduleDeferred { min-height: 48px; padding: 13px 18px; display: flex; align-items: center; color: var(--subtle); font-size: 12px; }
+.moduleDeferred .cellLoading { width: min(520px, 100%); padding: 0; }
+.variant { padding: 3px 7px; border: 1px solid #555b67; border-radius: 999px; color: #cbd0d9 !important; }
+.moduleColumns { display: grid; grid-template-columns: minmax(320px, .92fr) minmax(360px, 1fr) minmax(430px, 1.18fr); align-items: start; }
+.moduleColumns > section { min-width: 0; padding: 14px; }
+.moduleColumns > section + section { border-left: 1px solid var(--border); }
+.correspondenceRows { display: block; }
+.correspondenceRow { display: grid; grid-template-columns: minmax(320px, .92fr) minmax(360px, 1fr) minmax(430px, 1.18fr); align-items: start; }
+.correspondenceRow + .correspondenceRow { border-top: 1px solid #464b56; }
+.correspondenceCell { min-width: 0; padding: 14px; }
+.correspondenceCell + .correspondenceCell { border-left: 1px solid var(--border); }
+.columnLabel { display: none; margin: 0 0 10px; color: var(--muted); font-size: 12px; font-weight: 700; }
+.contentBox { margin-bottom: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
+.contentBox:last-child { margin-bottom: 0; }
+.contentBox > summary { cursor: pointer; list-style: none; padding: 11px 12px; display: flex; flex-direction: column; gap: 7px; color: var(--text); }
+.contentBox > summary::-webkit-details-marker { display: none; }
+.contentBox > summary > .summaryLine { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
+.contentBox > summary b { font-size: 13px; }
+.contentBox > summary b::before { content: "›"; display: inline-block; width: 16px; color: var(--muted); font-size: 19px; line-height: 10px; transform: rotate(0); transform-origin: center; transition: transform 160ms ease-out; }
+.contentBox[open] > summary b::before { transform: rotate(90deg); }
+.contentBox > summary small { flex: none; color: var(--subtle); font-size: 11px; font-weight: 500; }
+.contentBox > summary em { color: #c2c6ce; font-size: 12px; font-style: normal; line-height: 1.55; }
+.contentBody { padding: 0 12px 13px; border-top: 1px solid #30333b; }
+.contentBody p, .contentBody pre { margin: 10px 0 0; color: #dfe1e5; font-size: 13px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
+.contentBody pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+.tone-used { border-color: #66433e; }
+.tone-used > summary b { color: #ff8d7e; }
+.tone-read { border-color: #344f70; }
+.tone-read > summary b { color: #8fc0ff; }
+.tone-database { border-color: #3a5272; }
+.tone-database > summary b { color: #9bc8ff; }
+.tone-function { border-color: #38605d; }
+.tone-function > summary b { color: #8fd8cf; }
+.tone-log { border-color: #655735; }
+.tone-log > summary b { color: #f1ce7a; }
+.tone-context { background: #1d1f24; }
+.tone-muted { opacity: .82; }
+.tone-warning { border-color: #695a30; }
+.displayCard { padding: 12px 0 2px; }
+.displayCard h4 { margin: 0 0 12px; font-size: 16px; }
+.displayField { display: grid; grid-template-columns: 92px minmax(0,1fr); gap: 10px; padding: 9px 0; border-top: 1px solid #343740; }
+.displayField b { color: var(--muted); font-size: 12px; }
+.displayField p, .displayField pre { margin: 0; }
+.businessDetailBody { padding-top: 2px; }
+.businessPart { padding: 13px 0; border-top: 1px solid #343740; }
+.businessPart:first-child { border-top: 0; }
+.businessPart h4 { margin: 0 0 8px; color: #f1a196; font-size: 14px; line-height: 1.4; }
+.businessPart p, .businessPart pre { margin: 0; }
+.technicalDetailBody { padding-top: 2px; }
+.technicalPart { padding: 12px 0; border-top: 1px solid #343740; }
+.technicalPart:first-child { border-top: 0; }
+.technicalPart h4 { margin: 0 0 7px; color: #9bc8ff; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
+.technicalPart p, .technicalPart pre { margin: 0; }
+.correspondenceTarget { margin-inline: -8px; padding-inline: 9px; border-left: 3px solid var(--correlation-color); border-radius: 5px; background: var(--correlation-bg); cursor: pointer; transition: opacity 160ms ease-out, box-shadow 160ms ease-out, background 160ms ease-out; }
+.correspondenceTarget:focus-visible, .correlationChip:focus-visible, .correlationMark:focus-visible { outline: 2px solid var(--correlation-color); outline-offset: 2px; }
+.correlationNumber { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 18px; margin-right: 7px; border-radius: 4px; color: #101114; background: var(--correlation-color); font-size: 10px; font-weight: 800; vertical-align: 1px; }
+.correlationLinks { display: flex; flex-wrap: wrap; gap: 6px; }
+.correlationChip { display: inline-flex; align-items: center; gap: 5px; min-height: 26px; padding: 3px 8px; border: 1px solid var(--correlation-color); border-radius: 999px; color: var(--correlation-color); background: var(--correlation-bg); cursor: pointer; font-size: 10px; font-weight: 650; }
+.correlationChip b { color: inherit !important; font-size: 10px !important; }
+.correlationChip b::before { content: none !important; }
+.correlationMark { padding: 1px 3px; color: #fff; background: var(--correlation-bg-strong); border-bottom: 2px solid var(--correlation-color); border-radius: 3px; cursor: pointer; }
+.correlation-0 { --correlation-color: #ff8c7c; --correlation-bg: rgba(255, 140, 124, .10); --correlation-bg-strong: rgba(255, 140, 124, .34); }
+.correlation-1 { --correlation-color: #78b8ff; --correlation-bg: rgba(120, 184, 255, .10); --correlation-bg-strong: rgba(120, 184, 255, .32); }
+.correlation-2 { --correlation-color: #70d7aa; --correlation-bg: rgba(112, 215, 170, .10); --correlation-bg-strong: rgba(112, 215, 170, .30); }
+.correlation-3 { --correlation-color: #d5a2ff; --correlation-bg: rgba(213, 162, 255, .10); --correlation-bg-strong: rgba(213, 162, 255, .30); }
+.correlation-4 { --correlation-color: #f0ca67; --correlation-bg: rgba(240, 202, 103, .10); --correlation-bg-strong: rgba(240, 202, 103, .30); }
+.correlation-5 { --correlation-color: #75d8e8; --correlation-bg: rgba(117, 216, 232, .10); --correlation-bg-strong: rgba(117, 216, 232, .30); }
+.correlation-6 { --correlation-color: #ff9dcb; --correlation-bg: rgba(255, 157, 203, .10); --correlation-bg-strong: rgba(255, 157, 203, .30); }
+.correlation-7 { --correlation-color: #add574; --correlation-bg: rgba(173, 213, 116, .10); --correlation-bg-strong: rgba(173, 213, 116, .30); }
+.isCompact [data-column="use"] .sourceLinks { display: none; }
+.compactPanel { overflow: hidden; background: #1c1e23; border: 1px solid var(--border); border-radius: 8px; }
+.compactPanel > header { min-height: 42px; padding: 9px 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #343740; }
+.compactPanel > header h4 { margin: 0; color: #e8eaee; font-size: 13px; }
+.compactPanel > header span { color: var(--subtle); font-size: 11px; }
+.lineageItem { margin: 0; padding: 11px 12px; background: transparent; border: 0; border-top: 1px solid #31343c; border-radius: 0; }
+.lineageItem:first-of-type { border-top: 0; }
+.lineageItem.correspondenceTarget { margin: 0; padding: 11px 12px; border-left: 0; border-radius: 0; background: var(--correlation-bg); }
+.lineageItem > header { display: flex; align-items: center; gap: 7px; }
+.lineageItem > header strong { color: #eef0f3; font-size: 12px; }
+.lineageItem > header em { margin-left: auto; color: var(--correlation-color, var(--subtle)); font-size: 10px; font-style: normal; }
+.lineageSources { padding-top: 8px; display: grid; gap: 7px; }
+.lineageSource { min-width: 0; display: grid; gap: 5px; }
+.lineageSource small { color: var(--subtle); font-size: 11px; line-height: 1.45; }
+.lineageSourceSummary { grid-template-columns: max-content minmax(0, 1fr); align-items: start; column-gap: 8px; }
+.lineageSource + .lineageSource { padding-top: 9px; border-top: 1px solid #333741; }
+.lineageSourceHeader { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.locateContextButton { flex: none; min-height: 27px; padding: 3px 8px; font-size: 10px; }
+.sourceKindPill { display: inline-flex; width: fit-content; align-items: center; min-height: 22px; padding: 2px 7px; border: 1px solid currentColor; border-radius: 999px; font-size: 10px; font-weight: 700; }
+.pathList { display: flex; flex-wrap: wrap; gap: 5px; }
+.pathList code { max-width: 100%; padding: 3px 6px; color: #dce7f7; background: #2a3039; border: 1px solid #3b4655; border-radius: 4px; font: 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
+.sourceDigest { padding: 11px 12px; border-top: 1px solid #31343c; }
+.sourceDigest:first-of-type { border-top: 0; }
+.sourceDigest > header { display: flex; align-items: center; gap: 7px; }
+.sourceDigest > header strong { min-width: 0; color: #eef0f3; font-size: 12px; overflow-wrap: anywhere; }
+.sourceDigest > header em { margin-left: auto; flex: none; max-width: 44%; color: var(--subtle); font-size: 10px; font-style: normal; text-align: right; overflow-wrap: anywhere; }
+.sourceDigest > p { margin: 8px 0 0; color: #c5c9d1; font-size: 11px; line-height: 1.5; }
+.sourceDigest.isLocated { outline: 2px solid var(--active-correlation-color, var(--read)); outline-offset: -2px; animation: locate-pulse 680ms ease-out 2; }
+.rawField.isLocated { outline: 2px solid var(--active-correlation-color, var(--read)); outline-offset: 2px; border-radius: 5px; animation: locate-pulse 680ms ease-out 2; }
+.readValueEntry { padding: 8px 0 2px; }
+.readValueEntry + .readValueEntry { margin-top: 5px; border-top: 1px solid #343842; }
+.readValueMeta { padding-bottom: 6px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 5px 9px; }
+.readValueMeta > span { color: #cdd2da; font-size: 11px; font-weight: 700; }
+.readValueMeta code, .recordLocation code, .rawField code { color: #dce7f7; font: 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
+.fieldActualValue { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 8px; padding: 7px 9px; background: rgba(255,255,255,.025); border-radius: 5px; }
+.fieldActualValue > b { color: var(--muted); font-size: 10px; }
+.fieldActualValue p, .fieldActualValue pre { margin: 0; color: #eef0f3; font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
+.valueDisclosure { background: rgba(255,255,255,.025); border-radius: 5px; }
+.valueDisclosure > summary { min-height: 34px; padding: 7px 9px; display: grid; grid-template-columns: max-content 1fr; gap: 3px 9px; cursor: pointer; list-style: none; }
+.valueDisclosure > summary::-webkit-details-marker { display: none; }
+.valueDisclosure > summary > span { color: var(--muted); font-size: 10px; font-weight: 700; }
+.valueDisclosure > summary > span::before { content: "›"; display: inline-block; width: 13px; font-size: 16px; line-height: 9px; transform-origin: center; transition: transform 160ms ease-out; }
+.valueDisclosure[open] > summary > span::before { transform: rotate(90deg); }
+.valueDisclosure > summary > small { justify-self: end; color: var(--subtle); font-size: 10px; }
+.valueDisclosure > summary > em { grid-column: 1 / -1; color: #cfd3db; font-size: 11px; font-style: normal; line-height: 1.5; }
+.valueDisclosure .valueMatch, .fieldActualValue .valueMatch { padding: 1px 2px; color: #fff; background: rgba(255, 109, 90, .42); border-radius: 2px; }
+.valueDisclosure[open] > summary > em { display: none; }
+.valueDisclosureBody { padding: 8px 9px 10px; border-top: 1px solid #363a43; }
+.valueDisclosureBody p, .valueDisclosureBody pre { margin: 0; color: #eef0f3; font-size: 11px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
+.moreFieldsNotice { display: block; padding-top: 7px; }
+.lineageUnproven { display: block; padding: 7px 9px 2px; color: var(--subtle); font-size: 10px; line-height: 1.45; }
+.recordLocation { margin: 9px 0 2px; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
+.recordLocation > span { margin-right: 3px; color: var(--muted); font-size: 10px; font-weight: 700; }
+.recordLocation code { padding: 3px 5px; background: #292d35; border-radius: 4px; }
+.rawFields { padding-top: 7px; }
+.rawField { padding: 9px 0; border-top: 1px solid #343842; }
+.rawField > header { padding-bottom: 6px; display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
+.rawField > header > div { min-width: 0; display: grid; gap: 3px; }
+.rawField > header span { color: #dfe2e7; font-size: 11px; font-weight: 700; }
+.rawField .correlationLinks { justify-content: flex-end; }
+.lineageRelation { flex: none; margin-left: auto; color: #8fc0ff; font-size: 10px; font-style: normal; }
+.exactFieldMap { margin: 10px 0 14px; padding: 10px 11px; border: 1px solid #3a526d; border-radius: 7px; background: rgba(98,168,255,.045); }
+.exactFieldMap > h4 { margin: 0 0 7px; color: #9bc8ff; font-size: 12px; }
+.digestFields { padding-top: 8px; display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 7px; align-items: start; }
+.digestFields > b { color: var(--muted); font-size: 10px; line-height: 1.5; }
+.digestFields > span { color: var(--subtle); font-size: 11px; line-height: 1.45; }
+.compactEmpty { min-height: 78px; padding: 18px; display: flex; align-items: center; justify-content: center; color: var(--subtle); background: #1c1e23; border: 1px dashed #454a55; border-radius: 8px; font-size: 12px; text-align: center; }
+.sourceLinks { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: 6px; }
+.sourceTag, .usageTag { display: inline-flex; align-items: center; min-height: 28px; padding: 4px 9px; border-radius: 999px; font-size: 11px; line-height: 1.2; }
+.sourceTag { cursor: pointer; border: 1px solid currentColor; font-weight: 700; transition: background 160ms ease-out, color 160ms ease-out, opacity 160ms ease-out; }
+.sourceTag:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+.source-database { color: #9bc8ff; background: rgba(98, 168, 255, .09); }
+.source-function { color: #8fd8cf; background: rgba(82, 190, 176, .09); }
+.source-log { color: #f1ce7a; background: rgba(241, 206, 122, .09); }
+.source-unknown { color: #c6cad2; background: rgba(198, 202, 210, .08); }
+.source-database[aria-pressed="true"] { color: #101114; background: #9bc8ff; }
+.source-function[aria-pressed="true"] { color: #101114; background: #8fd8cf; }
+.source-log[aria-pressed="true"] { color: #101114; background: #f1ce7a; }
+.source-unknown[aria-pressed="true"] { color: #101114; background: #c6cad2; }
+.usageTag { color: #c7cbd3; background: #30333a; }
+.readItem { padding: 11px 0; border-top: 1px solid #343740; }
+.readItem:first-child { border-top: 0; }
+.readItemHeader { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 12px; }
+.readItemHeader b { color: #e7e9ed; font-size: 13px; }
+.readItemHeader code { color: var(--muted); font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
+.readMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px 10px; padding-top: 8px; }
+.readMeta > span:not(.readStatus) { color: var(--subtle); font-size: 11px; }
+.readStatus { padding: 3px 7px; border-radius: 999px; color: #dbeeff; background: #29445f; font-size: 11px; }
+.status-fallback { color: #d9dce2; background: #424751; }
+.status-calculation-input { color: #fff0bc; background: #5e4e20; }
+.status-unconfirmed, .status-missing { color: #ffe1d4; background: #603528; }
+.contextTree { padding-top: 10px; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
+.contextTree dl { margin: 0; }
+.contextTree ol { margin: 4px 0; padding-left: 22px; }
+.contextEntry { display: grid; grid-template-columns: minmax(128px, .32fr) minmax(0, 1fr); gap: 10px; padding: 4px 0; }
+.contextEntry dt { color: #aeb4c0; overflow-wrap: anywhere; }
+.contextEntry dd { margin: 0; color: #d9dce3; overflow-wrap: anywhere; }
+.contextEntry.isDim { opacity: .34; }
+.contextEntry.isRead { padding: 5px 7px; margin: 2px -7px; border-radius: 5px; background: rgba(98, 168, 255, .09); opacity: 1; }
+.contextEntry.hasCorrelationData { opacity: 1; }
+mark { padding: 1px 2px; color: #fff; background: rgba(255, 109, 90, .48); border-radius: 2px; }
+.moduleRow .contentBox { transition: opacity 160ms ease-out, border-color 160ms ease-out, box-shadow 160ms ease-out; }
+.moduleRow.hasSourceFocus .contentBox { opacity: .3; }
+.moduleRow.hasSourceFocus .contentBox.isSourceMatch { opacity: 1; box-shadow: 0 0 0 1px rgba(242, 243, 245, .2); }
+.moduleRow.hasCorrelationFocus [data-correlation-id], .moduleRow.hasCorrelationFocus [data-correlation-ids] { opacity: .18; }
+.moduleRow.hasCorrelationFocus [data-correlation-id].isCorrelationMatch, .moduleRow.hasCorrelationFocus [data-correlation-ids].isCorrelationMatch { opacity: 1; box-shadow: 0 0 0 2px var(--active-correlation-color, var(--correlation-color)); }
+.empty { color: var(--subtle) !important; }
+.loadError, .fatal { margin: 18px; padding: 18px; color: #ffd8d2; background: #35211f; border: 1px solid #643a34; border-radius: 8px; }
+.loadingBlock, .cellLoading { padding: 18px; }
+.loadingBlock i, .cellLoading i { display: block; height: 12px; margin: 9px 0; border-radius: 4px; background: linear-gradient(90deg, #25282e, #333740, #25282e); background-size: 200% 100%; animation: loading 1.3s linear infinite; }
+.loadingBlock i:nth-child(2), .cellLoading i:nth-child(2) { width: 72%; }
+@keyframes loading { to { background-position: -200% 0; } }
+@keyframes locate-pulse { 50% { box-shadow: inset 0 0 0 2px var(--active-correlation-color, var(--read)); } }
+@media (prefers-reduced-motion: reduce) { *, *::before { animation: none !important; transition: none !important; } }
+@media (max-width: 900px) {
+  main { min-width: 0; }
+  .auditHeader { align-items: flex-start; flex-direction: column; padding: 20px; }
+  .headerIntro { width: 100%; }
+  .auditToolbar { padding: 8px 10px; flex-wrap: wrap; }
+  .toolbarRun { display: none; }
+  .moduleJump { min-width: 0; max-width: none; flex: 1 1 100%; }
+  .moduleJump select { width: 100%; }
+  .compactToggle { flex: 1 1 auto; justify-content: center; }
+  .sourceFilter { flex: 1 1 auto; }
+  .sourceFilter select { flex: 1; }
+  .runSummary { justify-content: flex-start; }
+  .columnHeader { display: none; }
+  .groupHeader { padding-inline: 14px; }
+  .moduleRow { scroll-margin-top: 104px; margin-inline: 10px; }
+  .moduleRow > header { align-items: flex-start; flex-direction: column; }
+  .moduleMeta { justify-content: flex-start; }
+  .lineageSourceHeader { align-items: flex-start; flex-direction: column; }
+  .locateContextButton { min-height: 34px; }
+  .fieldActualValue { grid-template-columns: 1fr; }
+  .moduleColumns { display: block; }
+  .moduleColumns > section + section { border-left: 0; border-top: 1px solid var(--border); }
+  .correspondenceRow { display: block; }
+  .correspondenceCell + .correspondenceCell { border-left: 0; border-top: 1px solid var(--border); }
+  .columnLabel { display: block; }
+}

+ 72 - 0
visualization-old/backend/app/prompt_projection.py

@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+
+from .sanitizer import sanitize
+
+
+_MAX_PROMPT_CHARS = 60_000
+
+
+def project_prompt(payload: dict[str, Any]) -> dict[str, Any]:
+    """Build a truthful, lazy prompt view.
+
+    Runtime events preserve the exact delegated task, but the application does
+    not persist a system-prompt snapshot for each invocation.  The current
+    prompt is therefore deliberately labelled as current configuration.
+    """
+
+    task = _text(payload.get("task"))
+    prompt = _text(payload.get("promptContent"))
+    truncated = bool(prompt and len(prompt) > _MAX_PROMPT_CHARS)
+    if truncated:
+        prompt = prompt[:_MAX_PROMPT_CHARS].rstrip() + "\n\n…(当前规则过长,已截断)"
+
+    result: dict[str, Any] = {
+        "promptRef": str(payload.get("promptRef") or ""),
+        "actor": payload.get("actor") or {"role": "main", "label": "主 Agent"},
+        "notices": [
+            {
+                "code": "PROMPT_NOT_SNAPSHOTTED",
+                "message": "系统没有保存这次运行所用提示词的历史快照;下方展示当前提示词。",
+            }
+        ],
+    }
+    if mode_notice := _text(payload.get("modeNotice")):
+        result["notices"].append(
+            {"code": "CURRENT_MODE_CONTEXT", "message": mode_notice}
+        )
+    if task:
+        result["exactTask"] = {
+            "content": sanitize(task, max_text=_MAX_PROMPT_CHARS),
+            "accuracy": "run-exact",
+        }
+    if prompt:
+        result["systemPrompt"] = {
+            "content": sanitize(prompt, max_text=_MAX_PROMPT_CHARS + 200),
+            "source": payload.get("promptSource") or "current-file",
+            "version": payload.get("promptVersion"),
+            "accuracy": "current-not-run-snapshot",
+            "capturedAt": datetime.now(timezone.utc).isoformat(),
+            "truncated": truncated,
+        }
+    else:
+        result["notices"].append(
+            {
+                "code": "PROMPT_UNAVAILABLE",
+                "message": "当前提示词暂时不可读取。",
+            }
+        )
+    if truncated:
+        result["notices"].append(
+            {"code": "PROMPT_TRUNCATED", "message": "当前提示词已按安全长度截断。"}
+        )
+    return sanitize(result, max_text=_MAX_PROMPT_CHARS + 400)
+
+
+def _text(value: Any) -> str | None:
+    if not isinstance(value, str):
+        return None
+    value = value.strip()
+    return value or None

+ 64 - 0
visualization-old/backend/app/providers.py

@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from .repositories import ScriptBuildRepository
+
+
+class RuntimeDataProvider(Protocol):
+    mode: str
+
+    def list_builds(
+        self, *, limit: int = 30, status: str | None = None
+    ) -> list[dict[str, Any]]: ...
+
+    def load_bundle(self, script_build_id: int) -> dict[str, Any]: ...
+
+    def load_current_artifact(self, script_build_id: int) -> dict[str, Any]: ...
+
+    def load_event_detail(
+        self, script_build_id: int, event_id: int
+    ) -> dict[str, Any]: ...
+
+    def load_event_details(
+        self, script_build_id: int, event_ids: list[int]
+    ) -> dict[int, dict[str, Any]]: ...
+
+    def load_prompt_context(
+        self, script_build_id: int, prompt_ref: str
+    ) -> dict[str, Any]: ...
+
+
+class LocalDatabaseProvider:
+    """V8 production provider: direct, visualization-only, read-only DB access."""
+
+    mode = "local-db-read-only"
+
+    def __init__(self, repository: ScriptBuildRepository | None = None):
+        self.repository = repository or ScriptBuildRepository()
+
+    def list_builds(
+        self, *, limit: int = 30, status: str | None = None
+    ) -> list[dict[str, Any]]:
+        return self.repository.list_builds(limit=limit, status=status)
+
+    def load_bundle(self, script_build_id: int) -> dict[str, Any]:
+        return self.repository.load_bundle(script_build_id)
+
+    def load_current_artifact(self, script_build_id: int) -> dict[str, Any]:
+        return self.repository.load_current_artifact(script_build_id)
+
+    def load_event_detail(
+        self, script_build_id: int, event_id: int
+    ) -> dict[str, Any]:
+        return self.repository.load_event_detail(script_build_id, event_id)
+
+    def load_event_details(
+        self, script_build_id: int, event_ids: list[int]
+    ) -> dict[int, dict[str, Any]]:
+        return self.repository.load_event_details(script_build_id, event_ids)
+
+    def load_prompt_context(
+        self, script_build_id: int, prompt_ref: str
+    ) -> dict[str, Any]:
+        return self.repository.load_prompt_context(script_build_id, prompt_ref)

+ 797 - 0
visualization-old/backend/app/repositories.py

@@ -0,0 +1,797 @@
+from __future__ import annotations
+
+import json
+from datetime import date, datetime
+from collections.abc import Mapping
+from threading import Lock
+from typing import Any
+
+from sqlalchemy import MetaData, Table
+
+from .runtime_bridge import load_runtime_modules, new_session, runtime_dir
+from .runtime_tool_catalog import RETRIEVAL_AGENT_LABELS
+from .retrieval_detail_projection import (
+    QUERY_TOOL_NAMES,
+    summarize_retrieval_output,
+)
+from .sanitizer import sanitize
+
+
+_DETAIL_BODY_MAX = 240_000
+
+
+class BuildNotFound(LookupError):
+    pass
+
+
+class ActivityNotFound(LookupError):
+    pass
+
+
+def _json_value(value: Any) -> Any:
+    if isinstance(value, (datetime, date)):
+        return value.isoformat()
+    return sanitize(value)
+
+
+def row_dict(row: Any) -> dict[str, Any]:
+    if row is None:
+        return {}
+    if isinstance(row, Mapping):
+        return {str(key): _json_value(value) for key, value in row.items()}
+    mapping = getattr(row, "_mapping", None)
+    if mapping is not None:
+        return {str(key): _json_value(value) for key, value in mapping.items()}
+    return {
+        column.name: _json_value(getattr(row, column.name))
+        for column in row.__table__.columns
+    }
+
+
+class ScriptBuildRepository:
+    """The only database adapter used by V8.
+
+    The adapter is deliberately query-only.  Sessions come from a dedicated
+    MySQL read-only pool and this class never flushes, commits or mutates ORM
+    objects.
+    """
+
+    def __init__(self, session_factory=new_session):
+        self._session_factory = session_factory
+        self._metadata = MetaData()
+        self._reflected_tables: dict[str, Table] = {}
+        self._table_lock = Lock()
+
+    def list_builds(
+        self, *, limit: int = 30, status: str | None = None
+    ) -> list[dict[str, Any]]:
+        _, models = load_runtime_modules()
+        session = self._session_factory()
+        try:
+            query = session.query(models.ScriptBuildRecord).filter(
+                models.ScriptBuildRecord.is_deleted.is_(False)
+            )
+            if status:
+                query = query.filter(models.ScriptBuildRecord.status == status)
+            rows = (
+                query.order_by(models.ScriptBuildRecord.id.desc())
+                .limit(max(1, min(limit, 100)))
+                .all()
+            )
+            return [self._build_summary(row) for row in rows]
+        finally:
+            session.close()
+
+    def load_bundle(self, script_build_id: int) -> dict[str, Any]:
+        _, models = load_runtime_modules()
+        session = self._session_factory()
+        try:
+            record = (
+                session.query(models.ScriptBuildRecord)
+                .filter(models.ScriptBuildRecord.id == script_build_id)
+                .first()
+            )
+            if record is None:
+                raise BuildNotFound(f"未找到脚本构建 #{script_build_id}")
+
+            rounds = (
+                session.query(models.ScriptBuildRound)
+                .filter(models.ScriptBuildRound.script_build_id == script_build_id)
+                .order_by(models.ScriptBuildRound.round_index.asc())
+                .all()
+            )
+            branches = (
+                session.query(models.ScriptBuildBranch)
+                .filter(models.ScriptBuildBranch.script_build_id == script_build_id)
+                .order_by(
+                    models.ScriptBuildBranch.round_index.asc(),
+                    models.ScriptBuildBranch.branch_id.asc(),
+                )
+                .all()
+            )
+            data_decisions = (
+                session.query(models.ScriptBuildDataDecision)
+                .filter(
+                    models.ScriptBuildDataDecision.script_build_id == script_build_id,
+                    models.ScriptBuildDataDecision.branch_id > 0,
+                )
+                .order_by(
+                    models.ScriptBuildDataDecision.created_at.asc(),
+                    models.ScriptBuildDataDecision.id.asc(),
+                )
+                .all()
+            )
+            ignored_legacy_decisions = (
+                session.query(models.ScriptBuildDataDecision)
+                .filter(
+                    models.ScriptBuildDataDecision.script_build_id == script_build_id,
+                    models.ScriptBuildDataDecision.branch_id <= 0,
+                )
+                .count()
+            )
+            multipath_decisions = (
+                session.query(models.ScriptBuildMultipathDecision)
+                .filter(
+                    models.ScriptBuildMultipathDecision.script_build_id
+                    == script_build_id
+                )
+                .order_by(
+                    models.ScriptBuildMultipathDecision.created_at.asc(),
+                    models.ScriptBuildMultipathDecision.id.asc(),
+                )
+                .all()
+            )
+            domain_info = (
+                session.query(models.ScriptBuildDomainInfo)
+                .filter(models.ScriptBuildDomainInfo.script_build_id == script_build_id)
+                .order_by(
+                    models.ScriptBuildDomainInfo.created_at.asc(),
+                    models.ScriptBuildDomainInfo.id.asc(),
+                )
+                .all()
+            )
+            event_table = self._table(session, "script_build_event")
+            events = (
+                session.query(event_table)
+                .filter(event_table.c.script_build_id == script_build_id)
+                .order_by(
+                    event_table.c.event_seq.asc(),
+                    event_table.c.id.asc(),
+                )
+                .all()
+            )
+            event_payloads = [self._event_summary(row) for row in events]
+            self._attach_light_event_bodies(session, event_payloads)
+
+            paragraphs = (
+                session.query(models.ScriptBuildParagraph)
+                .filter(models.ScriptBuildParagraph.script_build_id == script_build_id)
+                .order_by(
+                    models.ScriptBuildParagraph.branch_id.asc(),
+                    models.ScriptBuildParagraph.id.asc(),
+                )
+                .all()
+            )
+            elements = (
+                session.query(models.ScriptBuildElement)
+                .filter(models.ScriptBuildElement.script_build_id == script_build_id)
+                .order_by(
+                    models.ScriptBuildElement.branch_id.asc(),
+                    models.ScriptBuildElement.id.asc(),
+                )
+                .all()
+            )
+            links = (
+                session.query(models.ScriptBuildParagraphElement)
+                .filter(
+                    models.ScriptBuildParagraphElement.script_build_id
+                    == script_build_id
+                )
+                .order_by(
+                    models.ScriptBuildParagraphElement.branch_id.asc(),
+                    models.ScriptBuildParagraphElement.id.asc(),
+                )
+                .all()
+            )
+
+            branch_payloads: list[dict[str, Any]] = []
+            for branch in branches:
+                payload = row_dict(branch)
+                payload["candidate_snapshot"] = self._candidate_snapshot(
+                    branch, paragraphs=paragraphs, elements=elements, links=links
+                )
+                branch_payloads.append(payload)
+
+            return {
+                "record": row_dict(record),
+                "rounds": [row_dict(row) for row in rounds],
+                "branches": branch_payloads,
+                "dataDecisions": [row_dict(row) for row in data_decisions],
+                "multipathDecisions": [
+                    row_dict(row) for row in multipath_decisions
+                ],
+                "domainInfo": [row_dict(row) for row in domain_info],
+                "events": event_payloads,
+                "ignoredLegacyDataDecisionCount": int(
+                    ignored_legacy_decisions or 0
+                ),
+                "currentArtifact": self._snapshot_for_branch(
+                    0, paragraphs, elements, links
+                ),
+            }
+        finally:
+            session.close()
+
+    def load_current_artifact(self, script_build_id: int) -> dict[str, Any]:
+        """Read only the active Base rows needed by the full script viewer."""
+        _, models = load_runtime_modules()
+        session = self._session_factory()
+        try:
+            exists = (
+                session.query(models.ScriptBuildRecord.id)
+                .filter(models.ScriptBuildRecord.id == script_build_id)
+                .first()
+            )
+            if exists is None:
+                raise BuildNotFound(f"未找到脚本构建 #{script_build_id}")
+
+            paragraphs = (
+                session.query(models.ScriptBuildParagraph)
+                .filter(
+                    models.ScriptBuildParagraph.script_build_id == script_build_id,
+                    models.ScriptBuildParagraph.branch_id == 0,
+                    models.ScriptBuildParagraph.is_active.is_not(False),
+                )
+                .order_by(
+                    models.ScriptBuildParagraph.paragraph_index.asc(),
+                    models.ScriptBuildParagraph.id.asc(),
+                )
+                .all()
+            )
+            elements = (
+                session.query(models.ScriptBuildElement)
+                .filter(
+                    models.ScriptBuildElement.script_build_id == script_build_id,
+                    models.ScriptBuildElement.branch_id == 0,
+                    models.ScriptBuildElement.is_active.is_not(False),
+                )
+                .order_by(models.ScriptBuildElement.id.asc())
+                .all()
+            )
+            paragraph_ids = [row.id for row in paragraphs]
+            element_ids = [row.id for row in elements]
+            links = []
+            if paragraph_ids and element_ids:
+                links = (
+                    session.query(models.ScriptBuildParagraphElement)
+                    .filter(
+                        models.ScriptBuildParagraphElement.script_build_id
+                        == script_build_id,
+                        models.ScriptBuildParagraphElement.branch_id == 0,
+                        models.ScriptBuildParagraphElement.paragraph_id.in_(paragraph_ids),
+                        models.ScriptBuildParagraphElement.element_id.in_(element_ids),
+                    )
+                    .order_by(models.ScriptBuildParagraphElement.id.asc())
+                    .all()
+                )
+            return self._snapshot_for_branch(0, paragraphs, elements, links)
+        finally:
+            session.close()
+
+    def load_event_detail(
+        self, script_build_id: int, event_id: int
+    ) -> dict[str, Any]:
+        values = self.load_event_details(script_build_id, [event_id])
+        event = values.get(int(event_id))
+        if event is None:
+            raise ActivityNotFound(f"未找到运行事件 {event_id}")
+        return event
+
+    def load_event_details(
+        self, script_build_id: int, event_ids: list[int]
+    ) -> dict[int, dict[str, Any]]:
+        """Batch-load full Event + Event Body records for one Inspector.
+
+        A creative card can reference dozens of events.  Loading the rows and
+        bodies in two queries keeps the three-column endpoint independent of
+        that cardinality.
+        """
+        wanted: list[int] = []
+        for value in event_ids:
+            try:
+                event_id = int(value)
+            except (TypeError, ValueError):
+                continue
+            if event_id > 0 and event_id not in wanted:
+                wanted.append(event_id)
+        wanted.sort()
+        if not wanted:
+            return {}
+        session = self._session_factory()
+        try:
+            event_table = self._table(session, "script_build_event")
+            events = (
+                session.query(event_table)
+                .filter(
+                    event_table.c.script_build_id == script_build_id,
+                    event_table.c.id.in_(wanted),
+                )
+                .order_by(event_table.c.event_seq.asc(), event_table.c.id.asc())
+                .all()
+            )
+            body_table = self._table(session, "script_build_event_body")
+            bodies = (
+                session.query(body_table)
+                .filter(
+                    body_table.c.script_build_id == script_build_id,
+                    body_table.c.event_id.in_(wanted),
+                )
+                .all()
+            )
+            body_by_event = {
+                int(parsed["eventId"]): parsed
+                for row in bodies
+                for parsed in [self._event_body(row)]
+                if parsed.get("eventId") is not None
+            }
+            result: dict[int, dict[str, Any]] = {}
+            for row in events:
+                payload = self._event_summary(row)
+                event_id = int(payload["id"])
+                parsed = body_by_event.get(event_id)
+                payload["input"] = parsed.get("input") if parsed else None
+                payload["output"] = parsed.get("output") if parsed else None
+                result[event_id] = payload
+            return result
+        finally:
+            session.close()
+
+    def load_prompt_context(
+        self, script_build_id: int, prompt_ref: str
+    ) -> dict[str, Any]:
+        """Read the exact delegated task plus the *current* prompt config.
+
+        Prompt history is intentionally not guessed from event time: runtime
+        events do not store a prompt snapshot or version reference.
+        """
+
+        ref = str(prompt_ref or "").strip()
+        event: dict[str, Any] | None = None
+        if ref.startswith("prompt:event:") or ref.startswith("event:"):
+            try:
+                event_id = int(ref.rsplit(":", 1)[1])
+            except ValueError as exc:
+                raise ActivityNotFound(f"无效的规则引用 {prompt_ref}") from exc
+            event = self.load_event_detail(script_build_id, event_id)
+            event_name = str(event.get("event_name") or "")
+        elif ref in {"prompt:main", "main"}:
+            event_name = "main"
+        else:
+            raise ActivityNotFound(f"未找到规则引用 {prompt_ref}")
+
+        config = _prompt_config(event_name)
+        task = None
+        if event:
+            side = event.get("input")
+            content = side.get("content") if isinstance(side, dict) else None
+            if isinstance(content, dict):
+                task = content.get("task")
+            elif isinstance(content, str):
+                task = content
+
+        session = self._session_factory()
+        try:
+            _, models = load_runtime_modules()
+            exists = (
+                session.query(models.ScriptBuildRecord.id)
+                .filter(models.ScriptBuildRecord.id == script_build_id)
+                .first()
+            )
+            if exists is None:
+                raise BuildNotFound(f"未找到脚本构建 #{script_build_id}")
+            prompt_row = None
+            for biz_type in config["bizTypes"]:
+                prompt_row = (
+                    session.query(models.Prompt)
+                    .filter(models.Prompt.biz_type == biz_type)
+                    .order_by(models.Prompt.id.asc())
+                    .first()
+                )
+                if prompt_row is not None:
+                    break
+            if prompt_row is not None:
+                content = prompt_row.prompt_content
+                source = "current-db"
+                version = prompt_row.current_version
+            else:
+                path = runtime_dir() / "prompts" / "script" / config["file"]
+                content = path.read_text(encoding="utf-8") if path.exists() else None
+                source = "current-file"
+                version = None
+            return {
+                "promptRef": ref,
+                "actor": config["actor"],
+                "task": task,
+                "promptContent": content,
+                "promptSource": source,
+                "promptVersion": version,
+                "modeNotice": config.get("modeNotice"),
+            }
+        finally:
+            session.close()
+
+    def _table(self, session: Any, name: str) -> Table:
+        table = self._reflected_tables.get(name)
+        if table is not None:
+            return table
+        # SQLAlchemy registers a Table in MetaData before reflection has
+        # populated all columns. Parallel Inspector requests must not observe
+        # that half-built object.
+        with self._table_lock:
+            table = self._reflected_tables.get(name)
+            if table is None:
+                table = Table(name, self._metadata, autoload_with=session.get_bind())
+                self._reflected_tables[name] = table
+            return table
+
+    @staticmethod
+    def _build_summary(row: Any) -> dict[str, Any]:
+        return {
+            "id": str(row.id),
+            "status": row.status,
+            "title": f"脚本构建 #{row.id}",
+            "scriptDirection": row.script_direction,
+            "createdAt": _json_value(row.start_time),
+            "updatedAt": _json_value(row.end_time),
+        }
+
+    @staticmethod
+    def _event_summary(row: Any) -> dict[str, Any]:
+        payload = row_dict(row)
+        payload["detailRef"] = f"event:{payload.get('id')}"
+        return payload
+
+    @staticmethod
+    def _event_body(row: Any) -> dict[str, Any]:
+        mapping = getattr(row, "_mapping", None)
+        data = dict(mapping) if mapping is not None else {
+            column.name: getattr(row, column.name)
+            for column in row.__table__.columns
+        }
+        return {
+            "id": data.get("id"),
+            "eventId": data.get("event_id"),
+            "input": ScriptBuildRepository._body_side(
+                data.get("input_content_type"), data.get("input_content")
+            ),
+            "output": ScriptBuildRepository._body_side(
+                data.get("output_content_type"), data.get("output_content")
+            ),
+        }
+
+    @staticmethod
+    def _body_side(content_type: Any, raw: Any) -> dict[str, Any] | None:
+        if raw is None:
+            return None
+        raw_text = str(raw)
+        content: Any = raw
+        parsed = False
+        if content_type == "json" or raw_text.lstrip().startswith(("{", "[")):
+            try:
+                content = json.loads(raw_text)
+                parsed = True
+            except (TypeError, json.JSONDecodeError):
+                content = raw
+        truncated = len(raw_text) > _DETAIL_BODY_MAX
+        safe_content = (
+            sanitize(raw_text, max_text=_DETAIL_BODY_MAX)
+            if truncated
+            # A valid JSON body below the declared detail limit must not have
+            # an individual string silently clipped at a second, lower limit.
+            else sanitize(content, max_text=_DETAIL_BODY_MAX)
+        )
+        return {
+            "contentType": content_type,
+            "content": safe_content,
+            "truncated": truncated,
+            **(
+                {"omittedCharacters": len(raw_text) - _DETAIL_BODY_MAX}
+                if truncated
+                else {}
+            ),
+        }
+
+    def _attach_light_event_bodies(
+        self, session: Any, events: list[dict[str, Any]]
+    ) -> None:
+        """Attach only the small event fields needed by the business projector.
+
+        Tool outputs can be very large.  The execution view intentionally reads
+        only tool inputs and the event's bounded output preview; full output is
+        loaded by ``load_event_detail`` after the user opens one query.
+        """
+        agent_ids = [
+            int(item["id"])
+            for item in events
+            if item.get("id") is not None and self._needs_light_agent_body(item)
+        ]
+        tool_ids = [
+            int(item["id"])
+            for item in events
+            if item.get("id") is not None and item.get("event_type") == "tool_call"
+        ]
+        query_tool_ids = [
+            int(item["id"])
+            for item in events
+            if item.get("id") is not None
+            and item.get("event_type") == "tool_call"
+            and str(item.get("event_name") or "") in QUERY_TOOL_NAMES
+        ]
+        if not agent_ids and not tool_ids:
+            return
+        body_table = self._table(session, "script_build_event_body")
+        build_ids = {
+            int(item["script_build_id"])
+            for item in events
+            if item.get("script_build_id") is not None
+        }
+        body_by_event: dict[int, dict[str, Any]] = {}
+        if agent_ids:
+            rows = (
+                session.query(
+                    body_table.c.event_id,
+                    body_table.c.input_content_type,
+                    body_table.c.input_content,
+                    body_table.c.output_content_type,
+                    body_table.c.output_content,
+                )
+                .filter(
+                    body_table.c.event_id.in_(agent_ids),
+                    body_table.c.script_build_id.in_(build_ids),
+                )
+                .all()
+            )
+            for row in rows:
+                data = row_dict(row)
+                body_by_event[int(data["event_id"])] = {
+                    "inputData": self._light_content(
+                        data.get("input_content_type"), data.get("input_content")
+                    ),
+                    "agentOutputData": self._light_content(
+                        data.get("output_content_type"), data.get("output_content")
+                    ),
+                }
+        if tool_ids:
+            rows = (
+                session.query(
+                    body_table.c.event_id,
+                    body_table.c.input_content_type,
+                    body_table.c.input_content,
+                )
+                .filter(
+                    body_table.c.event_id.in_(tool_ids),
+                    body_table.c.script_build_id.in_(build_ids),
+                )
+                .all()
+            )
+            for row in rows:
+                data = row_dict(row)
+                body_by_event.setdefault(int(data["event_id"]), {})["inputData"] = self._light_content(
+                    data.get("input_content_type"), data.get("input_content")
+                )
+        if query_tool_ids:
+            rows = (
+                session.query(
+                    body_table.c.event_id,
+                    body_table.c.output_content,
+                )
+                .filter(
+                    body_table.c.event_id.in_(query_tool_ids),
+                    body_table.c.script_build_id.in_(build_ids),
+                )
+                .all()
+            )
+            event_by_id = {
+                int(item["id"]): item
+                for item in events
+                if item.get("id") is not None
+            }
+            for row in rows:
+                mapping = getattr(row, "_mapping", {})
+                event_id = int(mapping.get("event_id"))
+                event = event_by_id.get(event_id) or {}
+                body_by_event.setdefault(event_id, {})["retrievalOutcome"] = (
+                    summarize_retrieval_output(
+                        str(event.get("event_name") or ""),
+                        raw_status=event.get("status"),
+                        ended_at=event.get("ended_at"),
+                        output=mapping.get("output_content"),
+                    )
+                )
+        for event in events:
+            event.update(body_by_event.get(int(event.get("id") or 0), {}))
+
+    @staticmethod
+    def _needs_light_agent_body(event: dict[str, Any]) -> bool:
+        if event.get("event_type") != "agent_invoke":
+            return False
+        name = str(event.get("event_name") or "")
+        return name in RETRIEVAL_AGENT_LABELS or name in {
+            "script_implementer",
+            "script_multipath_evaluator",
+            "script_evaluator",
+        }
+
+    @staticmethod
+    def _light_content(content_type: Any, raw: Any) -> Any:
+        if raw is None:
+            return None
+        if content_type == "json":
+            try:
+                return sanitize(json.loads(str(raw)), max_text=20_000)
+            except (TypeError, json.JSONDecodeError):
+                return sanitize(raw, max_text=20_000)
+        return sanitize(raw, max_text=20_000)
+
+    def _candidate_snapshot(
+        self,
+        branch: Any,
+        *,
+        paragraphs: list[Any],
+        elements: list[Any],
+        links: list[Any],
+    ) -> dict[str, Any]:
+        if branch.status in {"merged", "discarded"}:
+            if branch.content_snapshot:
+                return {
+                    "snapshot": sanitize(branch.content_snapshot),
+                    "sourceOrigin": "database",
+                    "snapshotKind": "saved-at-disposition",
+                    "historicalAccuracy": "exact",
+                    "currentProjectionAccuracy": "not-applicable",
+                    "note": "分支处置前保存的候选快照",
+                }
+            return {
+                "snapshot": None,
+                "sourceOrigin": "missing",
+                "snapshotKind": "missing-disposition-snapshot",
+                "historicalAccuracy": "missing",
+                "currentProjectionAccuracy": "not-applicable",
+                "note": "终态候选未保存处置前快照,不能用当前主脚本反推当时候选。",
+            }
+        return {
+            "snapshot": self._snapshot_for_branch(
+                int(branch.branch_id), paragraphs, elements, links
+            ),
+            "sourceOrigin": "database",
+            "snapshotKind": "current-overlay",
+            "historicalAccuracy": "unknown",
+            "currentProjectionAccuracy": "exact",
+            "note": (
+                "基于当前主脚本计算的 overlay;不是暂存时的历史快照"
+                if branch.status == "parked"
+                else "基于当前主脚本计算的分支 overlay"
+            ),
+        }
+
+    @staticmethod
+    def _snapshot_for_branch(
+        branch_id: int,
+        paragraphs: list[Any],
+        elements: list[Any],
+        links: list[Any],
+    ) -> dict[str, Any]:
+        base_paras = [
+            row
+            for row in paragraphs
+            if int(row.branch_id or 0) == 0 and row.is_active is not False
+        ]
+        base_elems = [
+            row
+            for row in elements
+            if int(row.branch_id or 0) == 0 and row.is_active is not False
+        ]
+        active_base_para_ids = {row.id for row in base_paras}
+        active_base_elem_ids = {row.id for row in base_elems}
+        base_links = [
+            row
+            for row in links
+            if int(row.branch_id or 0) == 0
+            and row.paragraph_id in active_base_para_ids
+            and row.element_id in active_base_elem_ids
+        ]
+        if branch_id == 0:
+            para_rows, elem_rows, link_rows = base_paras, base_elems, base_links
+            origin = "database"
+        else:
+            branch_paras = [
+                row
+                for row in paragraphs
+                if int(row.branch_id or 0) == branch_id
+                and row.is_active is not False
+            ]
+            branch_elems = [
+                row
+                for row in elements
+                if int(row.branch_id or 0) == branch_id
+                and row.is_active is not False
+            ]
+            overridden_para_ids = {
+                row.base_ref_id
+                for row in branch_paras
+                if row.base_ref_id is not None
+            }
+            overridden_elem_ids = {
+                row.base_ref_id
+                for row in branch_elems
+                if row.base_ref_id is not None
+            }
+            para_rows = [
+                row for row in base_paras if row.id not in overridden_para_ids
+            ] + branch_paras
+            elem_rows = [
+                row for row in base_elems if row.id not in overridden_elem_ids
+            ] + branch_elems
+            visible_para_ids = {row.id for row in para_rows} | overridden_para_ids
+            visible_elem_ids = {row.id for row in elem_rows} | overridden_elem_ids
+            branch_links = [
+                row
+                for row in links
+                if int(row.branch_id or 0) == branch_id
+                and row.paragraph_id in visible_para_ids
+                and row.element_id in visible_elem_ids
+            ]
+            seen = {(row.paragraph_id, row.element_id) for row in base_links}
+            link_rows = base_links + [
+                row
+                for row in branch_links
+                if (row.paragraph_id, row.element_id) not in seen
+            ]
+            origin = "database-overlay"
+        return {
+            "paragraphs": [_artifact_row(row) for row in para_rows],
+            "elements": [_artifact_row(row) for row in elem_rows],
+            "paragraphElements": [row_dict(row) for row in link_rows],
+            "sourceOrigin": origin,
+        }
+
+
+def _artifact_row(row: Any) -> dict[str, Any]:
+    payload = row_dict(row)
+    row_id = payload.get("id")
+    base_ref_id = payload.get("base_ref_id")
+    payload["rowId"] = row_id
+    payload["baseRefId"] = base_ref_id
+    payload["effectiveId"] = base_ref_id if base_ref_id is not None else row_id
+    return payload
+
+
+def _prompt_config(event_name: str) -> dict[str, Any]:
+    configs = {
+        "main": {
+            "bizTypes": ("script_build_system",),
+            "file": "script_build_system_v2.md",
+            "actor": {"role": "main", "label": "主 Agent"},
+        },
+        "script_implementer": {
+            "bizTypes": ("script_implementer",),
+            "file": "script_implementer.md",
+            "actor": {"role": "implementer", "label": "实现 Agent"},
+        },
+        "script_multipath_evaluator": {
+            "bizTypes": ("script_build_eval_agent", "script_build_evaluator"),
+            "file": "script_build_eval_agent.md",
+            "actor": {"role": "multipath-evaluator", "label": "多方案评审 Agent"},
+            "modeNotice": "当前调用使用多方案对比评审模式。",
+        },
+        "script_evaluator": {
+            "bizTypes": ("script_build_eval_agent", "script_build_evaluator"),
+            "file": "script_build_eval_agent.md",
+            "actor": {"role": "overall-evaluator", "label": "整体评审 Agent"},
+            "modeNotice": "当前调用使用主脚本整体评审模式。",
+        },
+    }
+    config = configs.get(str(event_name))
+    if config is None:
+        raise ActivityNotFound(f"该活动没有可展示的 Agent 规则:{event_name}")
+    return config

+ 426 - 0
visualization-old/backend/app/retrieval_detail_projection.py

@@ -0,0 +1,426 @@
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+from .runtime_tool_catalog import (
+    MAIN_DECISION_READ_TOOLS,
+    RETRIEVAL_AGENT_LABELS,
+    business_tool_label,
+)
+
+
+QUERY_TOOL_LABELS = {
+    "search_script_decode_case": "解构 Case 查询",
+    "external_search_case": "外部内容查询",
+    "search_knowledge": "创作知识查询",
+}
+QUERY_TOOL_NAMES = frozenset(QUERY_TOOL_LABELS)
+RETRIEVAL_TOOL_NAMES = frozenset({*QUERY_TOOL_NAMES, *MAIN_DECISION_READ_TOOLS})
+RETRIEVAL_RESULT_COLLECTION_KEYS: dict[str, str | None] = {
+    "search_script_decode_case": "results",
+    "external_search_case": "data",
+    "search_knowledge": None,
+    "get_domain_info": "domain_info",
+}
+TERMINAL_RUN_STATUSES = frozenset(
+    {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
+)
+
+_CONDITION_LABELS = {
+    "keyword": "关键词",
+    "account_name": "账号",
+    "match_fields": "匹配范围",
+    "top_k": "最多返回",
+    "return_field": "返回内容",
+    "platform_channel": "平台",
+    "max_count": "最多返回",
+    "message": "查询需求",
+    "script_build_id": "构建记录",
+}
+_HIDDEN_CONDITIONS = {"execution_id", "account_name_guard"}
+
+
+def is_retrieval_detail_event(event: dict[str, Any]) -> bool:
+    return (
+        str(event.get("event_type") or "") == "tool_call"
+        and (
+            str(event.get("event_name") or "") in RETRIEVAL_TOOL_NAMES
+            or str(event.get("agent_role") or "") in RETRIEVAL_AGENT_LABELS
+        )
+    )
+
+
+def summarize_retrieval_output(
+    event_name: str,
+    *,
+    raw_status: Any,
+    ended_at: Any,
+    output: Any,
+    run_status: Any = None,
+) -> dict[str, Any]:
+    """Return only the bounded status/count needed by the execution view."""
+
+    parsed = _parse_jsonish(output)
+    state, count, error = _outcome(
+        event_name,
+        raw_status=raw_status,
+        ended_at=ended_at,
+        output=parsed,
+        run_status=run_status,
+    )
+    return {
+        "state": state,
+        "count": count,
+        "error": error,
+    }
+
+
+def project_retrieval_event(
+    event: dict[str, Any], *, run_status: Any = None
+) -> dict[str, Any]:
+    name = str(event.get("event_name") or "")
+    source_kind = "direct-tool" if name in MAIN_DECISION_READ_TOOLS else "agent-query"
+    input_value = _event_side(event, "input")
+    output_value = _event_side(event, "output")
+    output_side = event.get("output")
+    output_truncated = isinstance(output_side, dict) and bool(output_side.get("truncated"))
+    summary_output = (
+        _parse_jsonish(event.get("output_preview"))
+        if output_truncated
+        else output_value
+    )
+    summary = summarize_retrieval_output(
+        name,
+        raw_status=event.get("status"),
+        ended_at=event.get("ended_at"),
+        output=summary_output,
+        run_status=run_status,
+    )
+    state = summary["state"]
+    count = summary.get("count")
+    error = summary.get("error")
+    items = _result_items(name, output_value) if state == "hit" and not output_truncated else []
+    completeness = _completeness(event.get("output"), output_value)
+    return {
+        "detailKind": "retrieval-event",
+        "eventId": _integer(event.get("id")) or 0,
+        "sourceKind": source_kind,
+        "toolName": name,
+        "businessLabel": QUERY_TOOL_LABELS.get(name)
+        or business_tool_label(name),
+        "queryConditions": _query_conditions(input_value),
+        "outcome": {
+            "state": state,
+            **({"count": count} if count is not None else {}),
+            "message": _outcome_message(state, count, error, source_kind),
+            **({"errorKind": _error_kind(error)} if error else {}),
+        },
+        "items": items,
+        "completeness": completeness,
+    }
+
+
+def _outcome(
+    name: str,
+    *,
+    raw_status: Any,
+    ended_at: Any,
+    output: Any,
+    run_status: Any,
+) -> tuple[str, int | None, str | None]:
+    status = str(raw_status or "").lower()
+    terminal_run = str(run_status or "").lower() in TERMINAL_RUN_STATUSES
+    if terminal_run and (status == "running" or not ended_at):
+        return "interrupted", None, None
+
+    error = _error_text(output)
+    if status in {"error", "failed", "failure"} or error:
+        return "failure", None, error
+
+    count = _result_count(name, output)
+    if count is not None:
+        return ("hit" if count > 0 else "empty"), count, None
+
+    if status == "running" or not ended_at:
+        return "running", None, None
+
+    if name in MAIN_DECISION_READ_TOOLS and output not in (None, "", [], {}):
+        return "hit", None, None
+    return "unknown", None, None
+
+
+def _error_text(value: Any) -> str | None:
+    if isinstance(value, dict):
+        success = value.get("success")
+        if success is False or (isinstance(success, str) and success.lower() == "false"):
+            candidate = value.get("error") or value.get("message") or "返回结果标记为失败"
+            return _clean_error(candidate)
+        error = value.get("error")
+        if error not in (None, "", False, [], {}):
+            return _clean_error(error)
+        status = str(value.get("status") or "").lower()
+        if status in {"error", "failed", "failure"}:
+            return _clean_error(value.get("message") or value.get("output") or status)
+    if isinstance(value, str):
+        text = value.strip()
+        if re.match(r"^(?:错误|失败)\s*[::]", text) or text.startswith("❌"):
+            return _clean_error(text)
+    return None
+
+
+def _clean_error(value: Any) -> str:
+    text = str(value or "").strip()
+    text = re.sub(r"^(?:错误|失败)\s*[::]\s*", "", text)
+    text = re.sub(r"^❌\s*", "", text)
+    return text or "执行失败,未保存更多错误说明"
+
+
+def _result_count(name: str, value: Any) -> int | None:
+    if name == "search_knowledge" and isinstance(value, list):
+        return len(value)
+    if isinstance(value, list):
+        return len(value)
+    if not isinstance(value, dict):
+        return None
+    for key in ("count", "returned_count", "result_count"):
+        count = _integer(value.get(key))
+        if count is not None:
+            return count
+    for key in ("results", "data", "items", "domain_info"):
+        if isinstance(value.get(key), list):
+            return len(value[key])
+    return None
+
+
+def _result_items(name: str, output: Any) -> list[dict[str, Any]]:
+    if name == "search_script_decode_case":
+        values = _result_collection(name, output)
+        return [_case_item(item, index) for index, item in enumerate(values or [], 1)]
+    if name == "external_search_case":
+        values = _result_collection(name, output)
+        return [_external_item(item, index, output) for index, item in enumerate(values or [], 1)]
+    if name == "search_knowledge":
+        return [
+            _knowledge_item(item, index)
+            for index, item in enumerate(_result_collection(name, output) or [], 1)
+        ]
+    return _direct_items(name, output)
+
+
+def _result_collection(name: str, output: Any) -> Any:
+    key = RETRIEVAL_RESULT_COLLECTION_KEYS.get(name)
+    if key is None:
+        return output
+    return output.get(key) if isinstance(output, dict) else None
+
+
+def _case_item(item: Any, index: int) -> dict[str, Any]:
+    data = item if isinstance(item, dict) else {"data": item}
+    account = str(data.get("account") or "未知账号")
+    return {
+        "id": str(data.get("post_id") or index),
+        "title": f"{account} · Case {index}",
+        "subtitle": str(data.get("post_id") or "来源标识未记录"),
+        "meta": _meta(
+            ("匹配分数", data.get("score")),
+            ("返回内容", data.get("return_field")),
+        ),
+        "sections": [{"title": "完整解构内容", "value": data.get("data")}],
+    }
+
+
+def _external_item(item: Any, index: int, output: dict[str, Any]) -> dict[str, Any]:
+    data = item if isinstance(item, dict) else {"body_text": item}
+    return {
+        "id": str(data.get("channel_content_id") or index),
+        "title": str(data.get("title") or f"外部内容 {index}"),
+        "subtitle": str(data.get("channel") or output.get("platform_channel") or "外部平台"),
+        "href": data.get("link"),
+        "meta": _meta(
+            ("内容类型", data.get("content_type")),
+            ("点赞", data.get("like_count")),
+            ("发布时间", data.get("publish_timestamp")),
+        ),
+        "sections": [
+            value
+            for value in (
+                _value_section("正文", data.get("body_text")),
+                _value_section("图片内容理解", data.get("images_text_escape")),
+                _value_section("图片", data.get("images")),
+                _value_section("视频", data.get("videos")),
+            )
+            if value
+        ],
+    }
+
+
+def _knowledge_item(item: Any, index: int) -> dict[str, Any]:
+    data = item if isinstance(item, dict) else {"content": item}
+    return {
+        "id": str(data.get("id") or index),
+        "title": str(data.get("title") or f"知识条目 {index}"),
+        "subtitle": str(data.get("purpose") or ""),
+        "meta": [],
+        "sections": [section for section in [_value_section("完整内容", data.get("content"))] if section],
+    }
+
+
+def _direct_items(name: str, output: Any) -> list[dict[str, Any]]:
+    if name == "get_domain_info" and isinstance(output, dict):
+        values = _result_collection(name, output)
+        return [
+            {
+                "id": str(item.get("id") or index),
+                "title": f"领域事实 {index}",
+                "subtitle": str(item.get("note") or ""),
+                "meta": _meta(("轮次", item.get("round_index"))),
+                "sections": [
+                    section
+                    for section in (
+                        _value_section("事实内容", item.get("content")),
+                        _value_section("核实说明", item.get("note")),
+                        _value_section("来源", item.get("source")),
+                    )
+                    if section
+                ],
+            }
+            for index, item in enumerate(values or [], 1)
+            if isinstance(item, dict)
+        ]
+
+    if output in (None, "", [], {}):
+        return []
+    data = output if isinstance(output, dict) else {"完整返回": output}
+    title = {
+        "get_topic_detail": "选题完整信息",
+        "get_script_direction": "当前创作方向",
+        "get_script_snapshot": "当前主脚本快照",
+        "get_account_script_section_patterns": "账号段落模式",
+        "get_account_script_persona_points": "账号人设",
+    }.get(name, business_tool_label(name))
+    preferred = {
+        "get_topic_detail": [("选题", "topic"), ("创作点", "points"), ("内容关系", "item_relations")],
+        "get_script_snapshot": [("脚本概况", "script_build"), ("段落", "paragraphs"), ("元素", "elements"), ("段落元素关联", "paragraph_element_links")],
+        "get_account_script_section_patterns": [("分段规律摘要", "分段规律摘要"), ("典型分段依据", "典型分段依据"), ("段落结构模式", "段落结构模式列表"), ("分析规模", "分析帖子数")],
+    }.get(name)
+    if preferred:
+        sections = [_value_section(label, data.get(key)) for label, key in preferred]
+    else:
+        sections = [_value_section(_condition_label(key), value) for key, value in data.items()]
+    return [{
+        "id": name,
+        "title": title,
+        "subtitle": "",
+        "meta": [],
+        "sections": [section for section in sections if section],
+    }]
+
+
+def _query_conditions(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, dict):
+        if value in (None, ""):
+            return []
+        return [{"key": "input", "label": "调用输入", "value": value}]
+    return [
+        {"key": str(key), "label": _condition_label(str(key)), "value": item}
+        for key, item in value.items()
+        if key not in _HIDDEN_CONDITIONS and item not in (None, "", [], {})
+    ]
+
+
+def _condition_label(key: str) -> str:
+    return _CONDITION_LABELS.get(key, key.replace("_", " "))
+
+
+def _outcome_message(
+    state: str, count: int | None, error: str | None, source_kind: str
+) -> str:
+    if state == "failure":
+        return f"查询执行失败:{error}" if error else "查询执行失败。"
+    if state == "empty":
+        return "查询执行成功,没有匹配数据。"
+    if state == "hit":
+        if source_kind == "direct-tool":
+            return "读取成功。" if count is None else f"读取成功,共返回 {count} 条记录。"
+        return f"查询成功,返回 {count} 条结果。" if count is not None else "查询成功。"
+    if state == "interrupted":
+        return "构建已经结束,但这次活动没有完成。"
+    if state == "running":
+        return "查询仍在执行。"
+    return "现有记录不足以可靠判断查询结果。"
+
+
+def _error_kind(error: str | None) -> str:
+    text = str(error or "").lower()
+    if any(token in text for token in ("参数", "仅支持", "必须", "不能为空", "return_field")):
+        return "parameter"
+    if any(token in text for token in ("http", "timeout", "timed out", "connection", "请求失败")):
+        return "transport"
+    if any(token in text for token in ("upstream", "openrouter", "403", "502", "503")):
+        return "upstream"
+    return "unknown"
+
+
+def _completeness(side: Any, output: Any) -> dict[str, Any]:
+    meta = side if isinstance(side, dict) else {}
+    truncated = bool(meta.get("truncated"))
+    omitted = _integer(meta.get("omittedCharacters"))
+    if isinstance(output, str):
+        match = re.search(r"…\[truncated\s+(\d+)\s+chars\]", output)
+        if match:
+            truncated = True
+            omitted = int(match.group(1))
+    return {
+        "bodyAvailable": side is not None and output is not None,
+        "truncated": truncated,
+        **({"omittedCharacters": omitted} if omitted is not None else {}),
+    }
+
+
+def _event_side(event: dict[str, Any], side: str) -> Any:
+    wrapped = event.get(side)
+    if isinstance(wrapped, dict) and "content" in wrapped:
+        return _parse_jsonish(wrapped.get("content"))
+    if wrapped is not None:
+        return _parse_jsonish(wrapped)
+    fallback = event.get("inputData" if side == "input" else "agentOutputData")
+    if fallback is not None:
+        return _parse_jsonish(fallback)
+    return _parse_jsonish(event.get(f"{side}_preview"))
+
+
+def _parse_jsonish(value: Any) -> Any:
+    if not isinstance(value, str):
+        return value
+    text = value.strip()
+    if not text or text[0] not in "[{":
+        return value
+    try:
+        return json.loads(text)
+    except json.JSONDecodeError:
+        return value
+
+
+def _value_section(title: str, value: Any) -> dict[str, Any] | None:
+    if value in (None, "", [], {}):
+        return None
+    return {"title": title, "value": value}
+
+
+def _meta(*items: tuple[str, Any]) -> list[dict[str, Any]]:
+    return [
+        {"label": label, "value": value}
+        for label, value in items
+        if value not in (None, "", [], {})
+    ]
+
+
+def _integer(value: Any) -> int | None:
+    if isinstance(value, bool):
+        return None
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None

+ 567 - 0
visualization-old/backend/app/retrieval_event_projection.py

@@ -0,0 +1,567 @@
+from __future__ import annotations
+
+import json
+import re
+from collections import Counter
+from datetime import datetime, timezone
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from .business_detail_text import clean_report
+from .runtime_event_index import (
+    RuntimeEventIndex,
+    event_input,
+    event_order,
+    event_output,
+    event_summary,
+    integer,
+)
+from .runtime_tool_catalog import (
+    MAIN_DECISION_READ_TOOLS,
+    RETRIEVAL_AGENT_LABELS,
+    business_tool_label,
+)
+
+_AGENT_CONTEXT_TOOLS = {"think_and_plan", "get_script_snapshot"}
+_DATABASE_TIMEZONE = ZoneInfo("Asia/Shanghai")
+
+
+class RetrievalEventProjector:
+    """Project implementer retrieval activity from an explicit event tree."""
+
+    def project(
+        self,
+        index: RuntimeEventIndex,
+        *,
+        valid_rounds: set[int],
+        valid_branches: set[int],
+        captured_at: datetime,
+        run_status: str | None = None,
+    ) -> dict[str, Any]:
+        ordered = index.ordered
+        stages_by_branch: dict[int, dict[str, Any]] = {}
+        unassigned: list[dict[str, Any]] = []
+
+        implementers: list[dict[str, Any]] = []
+        for event in ordered:
+            if (
+                str(event.get("event_type") or "") != "agent_invoke"
+                or str(event.get("event_name") or "") != "script_implementer"
+            ):
+                continue
+            round_index = integer(event.get("round_index"))
+            branch_id = integer(event.get("branch_id"))
+            if round_index in valid_rounds and branch_id in valid_branches:
+                implementers.append(event)
+            else:
+                unassigned.append(event_summary(event, "missing-business-context"))
+
+        retrieval_agents = [
+            event
+            for event in ordered
+            if str(event.get("event_type") or "") == "agent_invoke"
+            and str(event.get("event_name") or "") in RETRIEVAL_AGENT_LABELS
+        ]
+        attached_retrieval_ids: set[int] = set()
+
+        for implementer in implementers:
+            implementer_id = integer(implementer.get("id"))
+            branch_id = integer(implementer.get("branch_id"))
+            round_index = integer(implementer.get("round_index"))
+            if implementer_id is None or branch_id is None or round_index is None:
+                continue
+
+            child_agents = [
+                event
+                for event in retrieval_agents
+                if integer(event.get("parent_event_id")) == implementer_id
+                and integer(event.get("branch_id")) == branch_id
+                and integer(event.get("round_index")) == round_index
+            ]
+            attached_retrieval_ids.update(
+                value
+                for event in child_agents
+                if (value := integer(event.get("id"))) is not None
+            )
+            direct_groups = _direct_tool_groups(index, implementer, run_status)
+            agent_runs = [
+                _retrieval_agent_run(event, index, run_status)
+                for event in sorted(child_agents, key=event_order)
+            ]
+            operations: list[dict[str, Any]] = [*direct_groups, *agent_runs]
+            waves, observed_mode = _assign_waves(operations, captured_at)
+            status = _stage_status(operations, implementer, run_status)
+            stage = {
+                "id": f"round:{round_index}:branch:{branch_id}:retrieval",
+                "roundIndex": round_index,
+                "branchId": branch_id,
+                "implementerEventId": implementer_id,
+                "observedMode": observed_mode,
+                "waves": waves,
+                "directToolGroups": direct_groups,
+                "agentRuns": agent_runs,
+                "status": status,
+                "startedAt": _iso_value(
+                    min(
+                        (_start(item) for item in operations if _start(item)),
+                        default=None,
+                    )
+                ),
+                "endedAt": _iso_value(
+                    max(
+                        (
+                            _end(item, captured_at)
+                            for item in operations
+                            if _end(item, captured_at)
+                        ),
+                        default=None,
+                    )
+                ),
+                "detailRef": f"round:{round_index}:branch:{branch_id}:retrieval",
+            }
+            previous = stages_by_branch.get(branch_id)
+            if previous is None:
+                stages_by_branch[branch_id] = stage
+                continue
+
+            # Multiple implementer invocations for one branch are rare but
+            # valid. Preserve every operation instead of overwriting history.
+            combined = [
+                *previous.get("directToolGroups", []),
+                *previous.get("agentRuns", []),
+                *direct_groups,
+                *agent_runs,
+            ]
+            waves, mode = _assign_waves(combined, captured_at)
+            previous["directToolGroups"].extend(direct_groups)
+            previous["agentRuns"].extend(agent_runs)
+            previous["waves"] = waves
+            previous["observedMode"] = mode
+            previous["status"] = _combined_stage_status(previous["status"], status)
+
+        for event in retrieval_agents:
+            event_id = integer(event.get("id"))
+            if event_id not in attached_retrieval_ids:
+                unassigned.append(event_summary(event, "missing-parent-implementer"))
+
+        return {
+            "retrievalStagesByBranch": stages_by_branch,
+            "unassigned": unassigned,
+        }
+
+
+def _direct_tool_groups(
+    index: RuntimeEventIndex,
+    implementer: dict[str, Any],
+    run_status: str | None,
+) -> list[dict[str, Any]]:
+    implementer_id = integer(implementer.get("id"))
+    if implementer_id is None:
+        return []
+    scoped = [
+        event
+        for event in index.events_in_scope(implementer_id)
+        if integer(event.get("id")) != implementer_id
+    ]
+    groups: list[list[dict[str, Any]]] = []
+    pending: list[dict[str, Any]] = []
+    for event in sorted(scoped, key=event_order):
+        is_direct = (
+            str(event.get("event_type") or "") == "tool_call"
+            and str(event.get("agent_role") or "") == "script_implementer"
+            and str(event.get("event_name") or "") in MAIN_DECISION_READ_TOOLS
+        )
+        if is_direct:
+            pending.append(event)
+            continue
+        if pending:
+            groups.append(pending)
+            pending = []
+    if pending:
+        groups.append(pending)
+    return [_direct_group(group, run_status) for group in groups]
+
+
+def _direct_group(
+    events: list[dict[str, Any]], run_status: str | None
+) -> dict[str, Any]:
+    first_id = integer(events[0].get("id")) or 0
+    calls = [_direct_call(event, run_status) for event in events]
+    counts = Counter(call["status"] for call in calls)
+    labels = Counter(call["businessLabel"] for call in calls)
+    result = {
+        "id": f"retrieval-direct:{first_id}",
+        "kind": "direct-tools",
+        "owner": "script_implementer",
+        "callCount": len(calls),
+        "successCount": counts["success"],
+        "failureCount": counts["failure"],
+        "runningCount": counts["running"],
+        "sources": [
+            {"businessLabel": label, "callCount": count}
+            for label, count in labels.items()
+        ],
+        "calls": calls,
+        "startedAt": _iso_value(
+            min((_date_value(item.get("started_at")) for item in events), default=None)
+        ),
+        "endedAt": _iso_value(
+            max((_date_value(item.get("ended_at")) for item in events), default=None)
+        ),
+        "detailRef": f"retrieval-direct:{first_id}",
+    }
+    if counts["interrupted"]:
+        result["interruptedCount"] = counts["interrupted"]
+    return result
+
+
+def _direct_call(event: dict[str, Any], run_status: str | None) -> dict[str, Any]:
+    event_id = integer(event.get("id")) or 0
+    status = _operation_status(event, run_status)
+    return {
+        "id": f"event:{event_id}",
+        "eventId": event_id,
+        "businessLabel": business_tool_label(
+            str(event.get("event_name") or "")
+        ),
+        "status": status,
+        "resultSummary": (
+            "读取失败"
+            if status == "failure"
+            else "构建中断"
+            if status == "interrupted"
+            else "正在读取"
+            if status == "running"
+            else "已读取"
+        ),
+        "detailRef": f"event:{event_id}",
+    }
+
+
+def _retrieval_agent_run(
+    agent: dict[str, Any], index: RuntimeEventIndex, run_status: str | None
+) -> dict[str, Any]:
+    event_id = integer(agent.get("id")) or 0
+    agent_type = str(agent.get("event_name") or "")
+    child_tools = [
+        event
+        for event in index.ordered
+        if str(event.get("event_type") or "") == "tool_call"
+        and (
+            integer(event.get("scope_event_id")) == event_id
+            or integer(event.get("parent_event_id")) == event_id
+        )
+    ]
+    attempts = [
+        _query_attempt(event, run_status)
+        for event in sorted(child_tools, key=event_order)
+        if str(event.get("event_name") or "") not in _AGENT_CONTEXT_TOOLS
+    ]
+    counts = Counter(attempt["status"] for attempt in attempts)
+    screening = clean_report(_agent_summary(agent))
+    status = _agent_status(agent, run_status)
+    result = {
+        "id": f"retrieval-agent:{event_id}",
+        "kind": "retrieval-agent",
+        "eventId": event_id,
+        "agentType": agent_type,
+        "businessLabel": RETRIEVAL_AGENT_LABELS.get(agent_type, "取数 Agent"),
+        "taskPreview": _preview(_agent_task(agent), 180),
+        "status": status,
+        "querySummary": {
+            "total": len(attempts),
+            "hit": counts["hit"],
+            "empty": counts["empty"],
+            "failure": counts["failure"],
+            "unknown": counts["unknown"] + counts["running"],
+        },
+        "attempts": attempts,
+        "screening": {
+            "state": (
+                "available"
+                if screening
+                else "running"
+                if status == "running"
+                else "missing"
+            ),
+            "preview": _preview(screening, 220),
+        },
+        "startedAt": _iso_value(_date_value(agent.get("started_at"))),
+        "endedAt": _iso_value(_date_value(agent.get("ended_at"))),
+        "durationMs": integer(agent.get("duration_ms")),
+        "detailRef": f"retrieval-agent:{event_id}",
+    }
+    if counts["interrupted"]:
+        result["querySummary"]["interrupted"] = counts["interrupted"]
+    return result
+
+
+def _query_attempt(event: dict[str, Any], run_status: str | None) -> dict[str, Any]:
+    event_id = integer(event.get("id")) or 0
+    status, count = _query_status(event, run_status)
+    return {
+        "id": f"event:{event_id}",
+        "eventId": event_id,
+        "sequence": integer(event.get("event_seq")) or 0,
+        "queryLabel": _query_label(event_input(event)),
+        "status": status,
+        "resultCount": count,
+        "detailRef": f"event:{event_id}",
+    }
+
+
+def _query_status(
+    event: dict[str, Any], run_status: str | None = None
+) -> tuple[str, int | None]:
+    summary = event.get("retrievalOutcome")
+    if isinstance(summary, dict) and summary.get("state"):
+        state = str(summary.get("state"))
+        if (
+            str(run_status or "").lower()
+            in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
+            and (str(event.get("status") or "").lower() == "running" or not event.get("ended_at"))
+        ):
+            state = "interrupted"
+        return state, integer(summary.get("count"))
+    raw_status = str(event.get("status") or "").lower()
+    if (
+        str(run_status or "").lower()
+        in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
+        and (raw_status == "running" or not event.get("ended_at"))
+    ):
+        return "interrupted", None
+    if raw_status in {"error", "failed", "failure"}:
+        return "failure", None
+    if raw_status == "running" or not event.get("ended_at"):
+        return "running", None
+    output = event_output(event)
+    if isinstance(output, dict) and _output_reports_failure(output):
+        return "failure", None
+    count = _result_count(output)
+    if count is not None:
+        return ("hit" if count > 0 else "empty"), count
+    text = str(event.get("output_preview") or "").strip()
+    if text == "[]":
+        return "empty", 0
+    return "unknown", None
+
+
+def _output_reports_failure(value: dict[str, Any]) -> bool:
+    success = value.get("success")
+    if success is False or (isinstance(success, str) and success.lower() == "false"):
+        return True
+    error = value.get("error")
+    return error not in (None, "", False, [], {})
+
+
+def _result_count(value: Any) -> int | None:
+    parsed = value
+    if isinstance(value, str):
+        try:
+            parsed = json.loads(value)
+        except json.JSONDecodeError:
+            match = re.search(r'"count"\s*:\s*(\d+)', value)
+            return int(match.group(1)) if match else None
+    if isinstance(parsed, list):
+        return len(parsed)
+    if not isinstance(parsed, dict):
+        return None
+    for key in ("count", "returned_count", "result_count"):
+        number = integer(parsed.get(key))
+        if number is not None:
+            return number
+    for key in ("results", "data", "items"):
+        items = parsed.get(key)
+        if isinstance(items, list):
+            return len(items)
+    return None
+
+
+def _query_label(value: Any) -> str:
+    if not isinstance(value, dict):
+        return "查询内容详见详情"
+    parts: list[str] = []
+    keyword = value.get("keyword") or value.get("query") or value.get("topic_name")
+    if isinstance(keyword, str) and keyword.strip():
+        parts.append(keyword.strip())
+    if not parts and isinstance(value.get("account_name"), str):
+        parts.append(f"账号 {value['account_name'].strip()}")
+    for key in ("names", "category_names"):
+        names = value.get(key)
+        if isinstance(names, list) and names:
+            parts.append("、".join(str(item) for item in names[:4]))
+    return_field = value.get("return_field")
+    if isinstance(return_field, str) and return_field.strip():
+        parts.append(f"返回{return_field.strip()}")
+    platform = value.get("platform_channel")
+    if isinstance(platform, str) and platform.strip():
+        parts.append(platform.strip())
+    return " · ".join(parts) or "查询内容详见详情"
+
+
+def _assign_waves(
+    operations: list[dict[str, Any]], captured_at: datetime
+) -> tuple[list[dict[str, Any]], str]:
+    if not operations:
+        return [], "unknown"
+    if any(_start(item) is None for item in operations):
+        for item in operations:
+            item["waveIndex"] = None
+        return [], "unknown"
+    ordered = sorted(operations, key=lambda item: (_start(item), item.get("id")))
+    waves: list[dict[str, Any]] = []
+    current: list[dict[str, Any]] = []
+    current_end: datetime | None = None
+    for operation in ordered:
+        start = _start(operation)
+        end = _end(operation, captured_at) or start
+        if current and current_end is not None and start is not None and start >= current_end:
+            waves.append(_wave(len(waves), current, current_end))
+            current = []
+            current_end = None
+        current.append(operation)
+        current_end = max(filter(None, (current_end, end)), default=None)
+    if current:
+        waves.append(_wave(len(waves), current, current_end))
+    by_id = {item.get("id"): item for item in operations}
+    for wave in waves:
+        for operation_id in wave["operationIds"]:
+            by_id[operation_id]["waveIndex"] = wave["index"]
+    if len(operations) == 1:
+        mode = "single"
+    elif len(waves) == 1:
+        mode = "parallel"
+    elif any(len(wave["operationIds"]) > 1 for wave in waves):
+        mode = "mixed"
+    else:
+        mode = "sequential"
+    return waves, mode
+
+
+def _wave(
+    index: int, operations: list[dict[str, Any]], end: datetime | None
+) -> dict[str, Any]:
+    return {
+        "index": index,
+        "operationIds": [item.get("id") for item in operations],
+        "startedAt": _iso_value(
+            min((_start(item) for item in operations), default=None)
+        ),
+        "endedAt": _iso_value(end),
+    }
+
+
+def _stage_status(
+    operations: list[dict[str, Any]],
+    implementer: dict[str, Any],
+    run_status: str | None = None,
+) -> str:
+    implementer_status = _operation_status(implementer, run_status)
+    if not operations:
+        return "partial" if implementer_status == "interrupted" else implementer_status if implementer_status == "running" else "missing"
+    statuses = {_operation_status(item, run_status) for item in operations}
+    if "running" in statuses or implementer_status == "running":
+        return "running"
+    if "failure" in statuses or "interrupted" in statuses or implementer_status == "interrupted":
+        return "partial"
+    return "completed"
+
+
+def _combined_stage_status(left: str, right: str) -> str:
+    if "running" in {left, right}:
+        return "running"
+    if "partial" in {left, right}:
+        return "partial"
+    if "completed" in {left, right}:
+        return "completed"
+    return "missing"
+
+
+def _operation_status(
+    value: dict[str, Any], run_status: str | None = None
+) -> str:
+    if int(value.get("failureCount") or 0) > 0:
+        return "failure"
+    if int(value.get("runningCount") or 0) > 0:
+        return "running"
+    status = str(value.get("status") or "").lower()
+    if status == "interrupted":
+        return "interrupted"
+    if (
+        str(run_status or "").lower()
+        in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
+        and (status == "running" or not value.get("ended_at") and not value.get("endedAt"))
+    ):
+        return "interrupted"
+    if status in {"error", "failed", "failure"}:
+        return "failure"
+    if status == "running" or not value.get("ended_at") and not value.get("endedAt"):
+        return "running"
+    return "success"
+
+
+def _agent_status(value: dict[str, Any], run_status: str | None = None) -> str:
+    status = _operation_status(value, run_status)
+    return (
+        "failed"
+        if status == "failure"
+        else "interrupted"
+        if status == "interrupted"
+        else "running"
+        if status == "running"
+        else "completed"
+    )
+
+
+def _agent_task(event: dict[str, Any]) -> str | None:
+    value = event_input(event)
+    if isinstance(value, dict) and isinstance(value.get("task"), str):
+        return value["task"]
+    return None
+
+
+def _agent_summary(event: dict[str, Any]) -> str | None:
+    value = event_output(event)
+    if isinstance(value, dict) and isinstance(value.get("summary"), str):
+        return value["summary"]
+    return None
+
+
+def _start(item: dict[str, Any]) -> datetime | None:
+    return _date_value(item.get("startedAt") or item.get("started_at"))
+
+
+def _end(item: dict[str, Any], captured_at: datetime) -> datetime | None:
+    value = _date_value(item.get("endedAt") or item.get("ended_at"))
+    if value is None and _operation_status(item) == "running":
+        return captured_at
+    return value
+
+
+def _date_value(value: Any) -> datetime | None:
+    if isinstance(value, datetime):
+        parsed = value
+        if parsed.tzinfo is None:
+            parsed = parsed.replace(tzinfo=_DATABASE_TIMEZONE)
+        return parsed.astimezone(timezone.utc)
+    if isinstance(value, str) and value:
+        try:
+            parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+            if parsed.tzinfo is None:
+                parsed = parsed.replace(tzinfo=_DATABASE_TIMEZONE)
+            return parsed.astimezone(timezone.utc)
+        except ValueError:
+            return None
+    return None
+
+
+def _iso_value(value: datetime | None) -> str | None:
+    return value.isoformat() if value is not None else None
+
+
+def _preview(value: Any, limit: int) -> str | None:
+    text = str(value or "").strip()
+    if not text:
+        return None
+    text = re.sub(r"\s+", " ", text)
+    return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"

+ 83 - 0
visualization-old/backend/app/runtime_bridge.py

@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import importlib
+import os
+import sys
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+from sqlalchemy import create_engine, event
+from sqlalchemy.orm import sessionmaker
+
+
+def runtime_dir() -> Path:
+    configured = os.getenv("PATTERN_RUNTIME_DIR")
+    if configured:
+        return Path(configured).expanduser().resolve()
+    return Path(__file__).resolve().parents[3]
+
+
+def database_host_override() -> str | None:
+    """Return a visualization-only DB host override.
+
+    The value is deliberately host-only: credentials, database name and driver
+    continue to come from the parent runtime's existing SQLAlchemy URL.
+    """
+    value = os.getenv("PATTERN_DB_HOST_OVERRIDE", "").strip()
+    if not value:
+        return None
+    if any(token in value for token in ("/", "@", "?", "#", ":")) or any(char.isspace() for char in value):
+        raise ValueError("PATTERN_DB_HOST_OVERRIDE 只能是不含端口的主机名或 IPv4 地址")
+    return value
+
+
+def load_runtime_modules() -> tuple[Any, Any]:
+    """Load only the parent's DB manager and ORM models.
+
+    Importing pattern_service would initialize unrelated mining/config modules, so
+    the visualization intentionally avoids it.
+    """
+    root = str(runtime_dir())
+    if root not in sys.path:
+        sys.path.insert(0, root)
+    return importlib.import_module("db_manager"), importlib.import_module("models")
+
+
+def new_session():
+    """Return a session backed by a pool that is read-only at MySQL level.
+
+    V8 does not borrow the parent application's ordinary session because that
+    pool is intentionally write-capable.  The visualization always owns a
+    separate pool, even when no host override is configured.
+    """
+    return _read_only_session_factory(
+        str(runtime_dir()), database_host_override() or ""
+    )()
+
+
+@lru_cache(maxsize=4)
+def _read_only_session_factory(runtime_path: str, host: str):
+    """Build one cached visualization-only read-only connection pool."""
+    del runtime_path  # Included in the cache key so runtime switches cannot reuse a stale pool.
+    db_manager, _ = load_runtime_modules()
+    parent_manager = db_manager.DatabaseManager()
+    parent_engine = parent_manager.engine
+    try:
+        override_url = parent_engine.url.set(host=host) if host else parent_engine.url
+    finally:
+        # The parent engine is lazy and has not connected; do not retain a second pool.
+        parent_engine.dispose()
+
+    engine = create_engine(override_url, pool_pre_ping=True, pool_recycle=3600)
+    event.listen(engine, "connect", _set_mysql_session_read_only)
+    return sessionmaker(bind=engine, autoflush=False, autocommit=False)
+
+
+def _set_mysql_session_read_only(dbapi_connection, _connection_record) -> None:
+    """Make every transaction on the visualization pool read-only at MySQL level."""
+    cursor = dbapi_connection.cursor()
+    try:
+        cursor.execute("SET SESSION TRANSACTION READ ONLY")
+    finally:
+        cursor.close()

+ 362 - 0
visualization-old/backend/app/runtime_event_index.py

@@ -0,0 +1,362 @@
+from __future__ import annotations
+
+import json
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import Any, Iterable, Iterator
+
+from .runtime_tool_catalog import MAIN_DECISION_READ_TOOLS
+
+
+_SUCCESS_STATUSES = {"ok", "success", "succeeded", "completed", "complete"}
+_FAILURE_STATUSES = {"error", "failed", "failure"}
+
+
+@dataclass(frozen=True)
+class EventBounds:
+    """Sequence bounds used by projectors; both ends are exclusive by default."""
+
+    after_seq: int | None = None
+    before_seq: int | None = None
+
+
+class RuntimeEventIndex:
+    """Read-only structural index over ``script_build_event`` projections.
+
+    This class intentionally knows nothing about card wording.  It establishes
+    event identity, order, hierarchy, scope and actor once, then exposes strict
+    queries used by the three main-Agent decision projectors.
+    """
+
+    def __init__(self, events: Iterable[dict[str, Any]]):
+        ordered = sorted((dict(item) for item in events), key=event_order)
+        self._ordered: tuple[dict[str, Any], ...] = tuple(ordered)
+        self._by_id: dict[int, dict[str, Any]] = {}
+        self._by_name: dict[str, list[dict[str, Any]]] = defaultdict(list)
+        self._by_scope: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        self._children: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        self._by_actor: dict[str, list[dict[str, Any]]] = defaultdict(list)
+        self._by_round: dict[int, list[dict[str, Any]]] = defaultdict(list)
+
+        for event in ordered:
+            event_id = integer(event.get("id"))
+            if event_id is not None:
+                self._by_id[event_id] = event
+            self._by_name[str(event.get("event_name") or "")].append(event)
+            scope_id = integer(event.get("scope_event_id"))
+            if scope_id is not None:
+                self._by_scope[scope_id].append(event)
+            parent_id = integer(event.get("parent_event_id"))
+            if parent_id is not None:
+                self._children[parent_id].append(event)
+            self._by_actor[event_actor(event)].append(event)
+            round_index = integer(event.get("round_index"))
+            if round_index is not None:
+                self._by_round[round_index].append(event)
+
+        self._main_scope_ids = frozenset(
+            event_id
+            for event in ordered
+            if is_main_scope(event)
+            and (event_id := integer(event.get("scope_event_id")) or integer(event.get("id")))
+            is not None
+        )
+
+    @property
+    def ordered(self) -> tuple[dict[str, Any], ...]:
+        return self._ordered
+
+    @property
+    def main_scope_ids(self) -> frozenset[int]:
+        return self._main_scope_ids
+
+    def by_id(self, event_id: int | None) -> dict[str, Any] | None:
+        return self._by_id.get(int(event_id)) if event_id is not None else None
+
+    def children_of(self, event_id: int) -> tuple[dict[str, Any], ...]:
+        return tuple(self._children.get(int(event_id), ()))
+
+    def events_in_scope(self, scope_id: int) -> tuple[dict[str, Any], ...]:
+        return tuple(self._by_scope.get(int(scope_id), ()))
+
+    def events_for_actor(self, actor: str) -> tuple[dict[str, Any], ...]:
+        return tuple(self._by_actor.get(str(actor), ()))
+
+    def events_for_round(self, round_index: int) -> tuple[dict[str, Any], ...]:
+        return tuple(self._by_round.get(int(round_index), ()))
+
+    def tools_in_scope(
+        self, scope_id: int, *, names: set[str] | frozenset[str] | None = None
+    ) -> list[dict[str, Any]]:
+        return [
+            event
+            for event in self.events_in_scope(scope_id)
+            if str(event.get("event_type") or "") == "tool_call"
+            and (names is None or str(event.get("event_name") or "") in names)
+        ]
+
+    def named(
+        self,
+        name: str,
+        *,
+        event_type: str | None = None,
+        successful: bool | None = None,
+        bounds: EventBounds | None = None,
+    ) -> list[dict[str, Any]]:
+        events = self._by_name.get(str(name), ())
+        return [
+            event
+            for event in events
+            if (event_type is None or str(event.get("event_type") or "") == event_type)
+            and (successful is None or event_succeeded(event) is successful)
+            and _within_bounds(event, bounds)
+        ]
+
+    def anchors(
+        self,
+        name: str,
+        *,
+        successful: bool | None = None,
+        bounds: EventBounds | None = None,
+    ) -> list[dict[str, Any]]:
+        """Return only anchor calls made directly by a real main scope."""
+
+        return [
+            event
+            for event in self.named(
+                name,
+                event_type="tool_call",
+                successful=successful,
+                bounds=bounds,
+            )
+            if self.is_main_direct_event(event)
+        ]
+
+    def is_main_direct_event(self, event: dict[str, Any]) -> bool:
+        return (
+            str(event.get("agent_role") or "") == "main"
+            and integer(event.get("agent_depth")) == 0
+            and integer(event.get("scope_event_id")) in self._main_scope_ids
+        )
+
+    def main_direct_reads(
+        self,
+        *,
+        scope_id: int | None = None,
+        names: set[str] | frozenset[str] | None = None,
+        bounds: EventBounds | None = None,
+        successful: bool | None = True,
+    ) -> list[dict[str, Any]]:
+        allowed = names or MAIN_DECISION_READ_TOOLS
+        return [
+            event
+            for event in self._ordered
+            if str(event.get("event_type") or "") == "tool_call"
+            and str(event.get("event_name") or "") in allowed
+            and self.is_main_direct_event(event)
+            and (scope_id is None or integer(event.get("scope_event_id")) == scope_id)
+            and _within_bounds(event, bounds)
+            and (successful is None or event_succeeded(event) is successful)
+        ]
+
+    def agent_returns(
+        self,
+        name: str,
+        *,
+        round_index: int | None = None,
+        before_seq: int | None = None,
+    ) -> list[tuple[dict[str, Any], str]]:
+        """Return evaluator calls and their honest association strength.
+
+        ``returned-report`` requires an explicit parent main scope.  A matching
+        structured round without that hierarchy is only ``runtime-associated``.
+        Report text is never inspected to infer its round.
+        """
+
+        results: list[tuple[dict[str, Any], str]] = []
+        for event in self.named(name, event_type="agent_invoke", successful=True):
+            if before_seq is not None and event_sequence(event) >= before_seq:
+                continue
+            if round_index is not None and integer(event.get("round_index")) != round_index:
+                continue
+            parent_id = integer(event.get("parent_event_id"))
+            relation = (
+                "returned-report"
+                if parent_id in self._main_scope_ids
+                else "runtime-associated"
+            )
+            results.append((event, relation))
+        return results
+
+    def resolved_begin_round(self, event: dict[str, Any]) -> int | None:
+        """Resolve the newly opened round, never trusting the pre-call context first."""
+
+        output = event_output(event)
+        if isinstance(output, dict):
+            round_index = integer(output.get("round_index"))
+            if output.get("success") is not False and round_index is not None:
+                return round_index
+
+        seq = event_sequence(event)
+        scope_id = integer(event.get("scope_event_id"))
+        for marker in self._ordered:
+            marker_seq = event_sequence(marker)
+            if marker_seq <= seq:
+                continue
+            if str(marker.get("event_type") or "") == "tool_call" and str(
+                marker.get("event_name") or ""
+            ) == "begin_round":
+                break
+            if str(marker.get("event_type") or "") != "round_begin":
+                continue
+            marker_scope = integer(marker.get("scope_event_id"))
+            if scope_id is not None and marker_scope not in {None, scope_id}:
+                continue
+            payload = marker.get("payload")
+            round_index = integer(payload.get("round_index")) if isinstance(payload, dict) else None
+            return round_index or integer(marker.get("event_name"))
+        return None
+
+    def successful_begin_rounds(self) -> dict[int, list[dict[str, Any]]]:
+        result: dict[int, list[dict[str, Any]]] = defaultdict(list)
+        for event in self.anchors("begin_round", successful=True):
+            round_index = self.resolved_begin_round(event)
+            if round_index is not None:
+                result[round_index].append(event)
+        return dict(result)
+
+
+def is_main_scope(event: dict[str, Any]) -> bool:
+    return (
+        str(event.get("event_type") or "") == "agent_scope"
+        and str(event.get("event_name") or "") == "main"
+        and str(event.get("agent_role") or "") == "main"
+        and integer(event.get("agent_depth")) == 0
+    )
+
+
+def event_actor(event: dict[str, Any]) -> str:
+    role = str(event.get("agent_role") or "")
+    name = str(event.get("event_name") or "")
+    if role == "main" and integer(event.get("agent_depth")) == 0:
+        return "main"
+    if role == "script_evaluator" or name == "script_evaluator":
+        return "overall-evaluator"
+    if role == "script_multipath_evaluator" or name == "script_multipath_evaluator":
+        return "multipath-evaluator"
+    return role or "unknown"
+
+
+def event_order(event: dict[str, Any]) -> tuple[int, int]:
+    return (event_sequence(event), integer(event.get("id")) or 0)
+
+
+def event_sequence(event: dict[str, Any]) -> int:
+    return integer(event.get("event_seq")) or 0
+
+
+def event_succeeded(event: dict[str, Any]) -> bool:
+    status = str(event.get("status") or "").lower()
+    if status in _FAILURE_STATUSES or status == "running":
+        return False
+    output = event_output(event)
+    if isinstance(output, dict):
+        if output.get("success") is False:
+            return False
+        if output.get("error") not in (None, "", False, [], {}):
+            return False
+    return status in _SUCCESS_STATUSES or bool(event.get("ended_at"))
+
+
+def event_input(event: dict[str, Any]) -> Any:
+    if "inputData" in event:
+        return event.get("inputData")
+    return _json_value(event.get("input_preview"))
+
+
+def event_output(event: dict[str, Any]) -> Any:
+    for key in ("agentOutputData", "outputData"):
+        if key in event and event.get(key) is not None:
+            return _unwrap_output(event.get(key))
+    return _unwrap_output(_json_value(event.get("output_preview")))
+
+
+def event_text_output(event: dict[str, Any]) -> str | None:
+    output = event_output(event)
+    if isinstance(output, dict):
+        for key in ("summary", "report", "content", "message"):
+            value = output.get(key)
+            if isinstance(value, str) and value.strip():
+                return value.strip()
+        return None
+    return output.strip() if isinstance(output, str) and output.strip() else None
+
+
+def detail_ref(event: dict[str, Any]) -> str | None:
+    event_id = integer(event.get("id"))
+    return f"event:{event_id}" if event_id is not None else None
+
+
+def event_summary(
+    event: dict[str, Any], association: str | None
+) -> dict[str, Any]:
+    """Return a lightweight shared event reference without full body data."""
+
+    event_id = integer(event.get("id"))
+    return {
+        "id": f"event:{event_id}" if event_id is not None else "event:unknown",
+        "eventId": event_id,
+        "eventSeq": event.get("event_seq"),
+        "type": event.get("event_type"),
+        "name": event.get("event_name"),
+        "status": event.get("status"),
+        "title": event.get("title"),
+        "inputPreview": event.get("input_preview"),
+        "outputPreview": event.get("output_preview"),
+        "startedAt": event.get("started_at"),
+        "endedAt": event.get("ended_at"),
+        "association": association,
+        "detailRef": f"event:{event_id}" if event_id is not None else None,
+    }
+
+
+def integer(value: Any) -> int | None:
+    try:
+        return int(value) if value is not None else None
+    except (TypeError, ValueError):
+        return None
+
+
+def _within_bounds(event: dict[str, Any], bounds: EventBounds | None) -> bool:
+    if bounds is None:
+        return True
+    seq = event_sequence(event)
+    if bounds.after_seq is not None and seq <= bounds.after_seq:
+        return False
+    if bounds.before_seq is not None and seq >= bounds.before_seq:
+        return False
+    return True
+
+
+def _json_value(value: Any) -> Any:
+    if not isinstance(value, str):
+        return value
+    text = value.strip()
+    if not text:
+        return None
+    try:
+        return json.loads(text)
+    except json.JSONDecodeError:
+        return value
+
+
+def _unwrap_output(value: Any) -> Any:
+    # Tool bodies currently store many JSON results as a JSON-looking string.
+    # Unwrap at most twice so malformed or ordinary prose remains untouched.
+    result = value
+    for _ in range(2):
+        parsed = _json_value(result)
+        if parsed is result or parsed == result:
+            break
+        result = parsed
+    return result

+ 85 - 0
visualization-old/backend/app/runtime_event_projection.py

@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Iterable
+
+from .evaluation_event_projection import EvaluationEventProjector
+from .creative_event_projection import CreativeEventProjector
+from .main_agent_decision_projection import MainAgentDecisionProjector
+from .retrieval_event_projection import RetrievalEventProjector
+from .runtime_event_index import RuntimeEventIndex
+
+
+class RuntimeEventProjector:
+    """Facade that builds one event index and composes business projections."""
+
+    def __init__(
+        self,
+        *,
+        retrieval_projector: RetrievalEventProjector | None = None,
+        evaluation_projector: EvaluationEventProjector | None = None,
+        main_agent_projector: MainAgentDecisionProjector | None = None,
+        creative_projector: CreativeEventProjector | None = None,
+    ):
+        self._retrieval_projector = retrieval_projector or RetrievalEventProjector()
+        self._evaluation_projector = evaluation_projector or EvaluationEventProjector()
+        self._main_agent_projector = main_agent_projector or MainAgentDecisionProjector()
+        self._creative_projector = creative_projector or CreativeEventProjector()
+
+    def project(
+        self,
+        events: list[dict[str, Any]],
+        *,
+        valid_rounds: set[int],
+        valid_branches: set[int],
+        captured_at: datetime | None = None,
+        run_status: str | None = None,
+        script_direction: str | None = None,
+        rounds: Iterable[dict[str, Any]] = (),
+        multipath_decisions: Iterable[dict[str, Any]] = (),
+    ) -> dict[str, Any]:
+        captured_at = captured_at or datetime.now(timezone.utc)
+        index = RuntimeEventIndex(events)
+        retrieval = self._retrieval_projector.project(
+            index,
+            valid_rounds=valid_rounds,
+            valid_branches=valid_branches,
+            captured_at=captured_at,
+            run_status=run_status,
+        )
+        evaluations = self._evaluation_projector.project(
+            index,
+            valid_rounds=valid_rounds,
+            valid_branches=valid_branches,
+        )
+        main_agent = self._main_agent_projector.project(
+            index,
+            script_direction=script_direction,
+            rounds=rounds,
+            multipath_decisions=multipath_decisions,
+        )
+        creative = self._creative_projector.project(
+            index,
+            valid_rounds=valid_rounds,
+            valid_branches=valid_branches,
+        )
+        unassigned = [
+            *retrieval.get("unassigned", []),
+            *evaluations.get("unassigned", []),
+            *main_agent.get("unassigned", []),
+            *creative.get("unassigned", []),
+        ]
+        return {
+            "multipathReviewsByRound": evaluations.get(
+                "multipathReviewsByRound", {}
+            ),
+            "overallEvaluationsByRound": evaluations.get(
+                "overallEvaluationsByRound", {}
+            ),
+            "retrievalStagesByBranch": retrieval.get(
+                "retrievalStagesByBranch", {}
+            ),
+            "mainAgentDecisions": main_agent,
+            "creativeByBranch": creative.get("creativeByBranch", {}),
+            "unassigned": unassigned,
+        }

+ 69 - 0
visualization-old/backend/app/runtime_tool_catalog.py

@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class ToolDefinition:
+    """Stable business metadata for one runtime tool.
+
+    The event projection layer owns this translation.  Business projectors and
+    UI adapters should never maintain their own copies of tool-name mappings.
+    """
+
+    name: str
+    business_label: str
+    category: str
+
+
+_TOOLS: tuple[ToolDefinition, ...] = (
+    ToolDefinition("get_topic_detail", "选题", "decision-input"),
+    ToolDefinition("get_script_direction", "创作总目标", "decision-input"),
+    ToolDefinition("get_script_snapshot", "当前主脚本", "decision-input"),
+    ToolDefinition("get_account_script_section_patterns", "账号段落模式", "decision-input"),
+    ToolDefinition("get_account_script_persona_points", "账号人设", "decision-input"),
+    ToolDefinition("get_domain_info", "领域信息", "decision-input"),
+    ToolDefinition("save_script_direction", "保存创作目标", "decision-anchor"),
+    ToolDefinition("begin_round", "开启新一轮", "decision-anchor"),
+    ToolDefinition("record_multipath_plan", "记录实现规划", "decision-anchor"),
+    ToolDefinition("implement_paths", "并发派发多路实现", "orchestration"),
+    ToolDefinition("record_multipath_decision", "记录多路决策", "decision-anchor"),
+    ToolDefinition("record_data_decision", "记录数据取舍", "creative-action"),
+    ToolDefinition("record_task_plan", "记录任务规划", "creative-action"),
+    ToolDefinition("update_task_plan_step", "更新规划步骤", "creative-action"),
+    ToolDefinition("get_task_plan", "查看任务规划", "creative-action"),
+    ToolDefinition("create_script_paragraph", "新增脚本段落", "creative-action"),
+    ToolDefinition("batch_update_script_paragraphs", "更新候选脚本内容", "creative-action"),
+    ToolDefinition("append_paragraph_atoms", "补充段落原子点", "creative-action"),
+    ToolDefinition("create_script_element", "新增脚本元素", "creative-action"),
+    ToolDefinition("batch_create_script_elements", "批量新增脚本元素", "creative-action"),
+    ToolDefinition("load_images", "查看图片", "creative-action"),
+)
+
+TOOL_CATALOG: dict[str, ToolDefinition] = {item.name: item for item in _TOOLS}
+
+MAIN_DECISION_READ_TOOLS: frozenset[str] = frozenset(
+    item.name for item in _TOOLS if item.category == "decision-input"
+)
+
+MAIN_DECISION_ANCHORS: frozenset[str] = frozenset(
+    item.name for item in _TOOLS if item.category == "decision-anchor"
+)
+
+RETRIEVAL_AGENT_LABELS: dict[str, str] = {
+    "retrieve_data_decode_case": "解构 Case Agent",
+    "retrieve_data_external_search": "外部搜索 Agent",
+    "retrieve_data_knowledge": "知识 Agent",
+    "retrieve_data_pattern_relation": "Pattern / Relation Agent",
+}
+
+
+def business_tool_label(name: str) -> str:
+    """Return a non-technical label without inventing an unknown translation."""
+
+    definition = TOOL_CATALOG.get(str(name or ""))
+    return definition.business_label if definition else "运行记录"
+
+
+def tools_in_category(category: str) -> frozenset[str]:
+    return frozenset(item.name for item in _TOOLS if item.category == category)

+ 51 - 0
visualization-old/backend/app/sanitizer.py

@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+
+_SECRET_PATTERNS = (
+    re.compile(
+        r"(?i)([\"']?authorization[\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic)\s+)?([^\s,\"'\]}]+)"
+    ),
+    re.compile(
+        r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|secret)[\"']?\s*[:=]\s*[\"']?)([^\s,\"'\]}]+)"
+    ),
+    re.compile(r"(?i)(mysql(?:\+pymysql)?://[^:/\s]+:)([^@\s]+)(@)"),
+    re.compile(r"(?i)((?:postgres(?:ql)?|redis|mongodb(?:\+srv)?|amqp)://[^:/\s]+:)([^@\s]+)(@)"),
+)
+
+
+def redact_text(value: str) -> str:
+    result = value
+    for pattern in _SECRET_PATTERNS:
+        if pattern.groups == 3:
+            result = pattern.sub(r"\1***\3", result)
+        else:
+            result = pattern.sub(r"\1***", result)
+    return result
+
+
+def sanitize(value: Any, *, max_text: int | None = 120_000) -> Any:
+    if isinstance(value, str):
+        redacted = redact_text(value)
+        if max_text is not None and len(redacted) > max_text:
+            return redacted[:max_text] + f"\n…[truncated {len(redacted) - max_text} chars]"
+        return redacted
+    if isinstance(value, dict):
+        result = {}
+        for key, nested in value.items():
+            key_text = str(key)
+            if re.search(r"(?i)(authorization|api[_-]?key|access[_-]?token|secret|password|credential|dsn|database[_-]?url|connection[_-]?string|data[_-]?source[_-]?url)", key_text):
+                result[key_text] = "***"
+            else:
+                result[key_text] = sanitize(nested, max_text=max_text)
+        return result
+    if isinstance(value, (list, tuple)):
+        return [sanitize(v, max_text=max_text) for v in value]
+    try:
+        json.dumps(value)
+        return value
+    except TypeError:
+        return str(value)

+ 5 - 0
visualization-old/backend/app/source_lineage/__init__.py

@@ -0,0 +1,5 @@
+"""Unified business/source/runtime projection for the three-column Inspector."""
+
+from .projector import InspectorWorkbenchProjector, InspectorViewNotFound
+
+__all__ = ["InspectorWorkbenchProjector", "InspectorViewNotFound"]

+ 1052 - 0
visualization-old/backend/app/source_lineage/comparison.py

@@ -0,0 +1,1052 @@
+from __future__ import annotations
+
+import re
+from copy import deepcopy
+from typing import Any
+
+from .resolver import SourceResolver, pointer_lookup
+from .schema import SourceBinding, unresolved_binding
+
+
+# These mappings are keyed by the stable business projection shape, never by
+# fuzzy title matching.  Values point at the richer source modules built by the
+# card-specific projectors.
+_MODULE_MAP: dict[str, dict[str, list[str]]] = {
+    "objective": {
+        "block:direction": ["direction"],
+        "block:inputs": ["inputs"],
+    },
+    "round-goal": {
+        "block:goal": ["goal", "why"],
+        "block:dependencies": ["dependencies"],
+    },
+    "implementation-plan": {
+        "block:plan": ["mode", "routes", "revisions"],
+        "block:basis": ["basis", "revisions"],
+    },
+    "implementation-task": {
+        "section:scope": ["scope"],
+        "section:method": ["method"],
+        "section:goal": ["goal"],
+        "section:constraints": ["constraints"],
+        "section:avoid": ["avoid"],
+        "section:deliverable": ["deliverable"],
+        "section:action": ["action"],
+        "section:request": ["request"],
+        "section:result": ["result"],
+    },
+    "creative": {
+        "block:basis": ["basis"],
+        "block:process": ["process"],
+        "block:available": ["available"],
+        "block:changes": ["changes"],
+        "block:candidate": ["candidate"],
+        "block:uncertainty": ["uncertainty"],
+        "block:record-note": ["record-note"],
+    },
+    "data-decision": {
+        "block:0": ["sources"],
+        "block:1": ["decision", "tradeoff"],
+        "block:2": ["reason"],
+    },
+    "multipath-decision": {
+        "block:0": ["strength"],
+        "block:1": ["scope"],
+        "block:2": ["comparison"],
+        "block:3": ["comparison"],
+        "block:4": ["reason"],
+        "block:5": ["strength"],
+    },
+    "domain-facts": {
+        "section:output": ["facts", "verification", "sources", "usage"],
+        "section:assessment": [],
+    },
+    "candidate-output-fallback": {
+        "section:output": ["output"],
+        "section:assessment": ["assessment"],
+    },
+    "round-result": {
+        "section:goal": ["completion"],
+        "section:outcomes": ["dispositions"],
+        "section:facts": ["domain"],
+        "section:evaluation": ["review"],
+        "section:changes": ["changes"],
+        "section:next": ["next"],
+    },
+    "final-result": {
+        "section:conclusion": ["conclusion"],
+        "section:summary": ["summary"],
+        "section:statistics": ["stats"],
+        "section:artifact": ["scale", "artifact"],
+        "section:version": ["version"],
+    },
+    "retrieval-agent": {
+        "section:task": ["task"],
+        "section:queries": ["queries"],
+        "section:hits": ["raw"],
+        "section:screening": ["screening"],
+        "special:activities": ["activities"],
+    },
+    "retrieval-direct-group": {
+        "section:purpose": ["purpose"],
+        "section:sources": ["calls"],
+        "section:result": ["results", "failures"],
+        "special:activities": ["calls", "results", "failures"],
+    },
+    "retrieval-event": {
+        "special:conditions": ["purpose"],
+        "special:outcome": ["status"],
+        "special:results": ["result", "results"],
+        "special:completeness": ["status"],
+    },
+}
+
+
+def business_only_projection(detail: dict[str, Any]) -> dict[str, Any]:
+    """Keep exactly what the readable Inspector uses, without duplicating raw JSON."""
+    hidden = {"technical", "changes"}
+    return deepcopy({key: value for key, value in detail.items() if key not in hidden})
+
+
+def align_business_projection(
+    *,
+    detail_ref: str,
+    card_kind: str,
+    business: dict[str, Any],
+    source_modules: list[dict[str, Any]],
+    resolver: SourceResolver,
+    bundle: dict[str, Any],
+) -> list[dict[str, Any]]:
+    """Turn the readable Inspector projection into row-aligned source modules."""
+    projection = business_only_projection(business)
+    normalized = _normalize_business(projection, card_kind=card_kind)
+    source_by_id = {str(module.get("id")): module for module in source_modules}
+    aligned: list[dict[str, Any]] = []
+    for module_index, module in enumerate(normalized, 1):
+        semantic_key = str(module.pop("_semanticKey"))
+        source_ids = _source_module_ids(card_kind, semantic_key, module_index)
+        candidates = [source_by_id[source_id] for source_id in source_ids if source_id in source_by_id]
+        if not candidates and card_kind == "implementation-task" and "historical-task" in source_by_id:
+            candidates = [source_by_id["historical-task"]]
+        rows: list[dict[str, Any]] = []
+        module_bindings: list[SourceBinding] = []
+        for row_index, row in enumerate(module.get("rows") or [], 1):
+            found, business_value = pointer_lookup(
+                projection, str(row.get("businessSelector") or "")
+            )
+            if card_kind == "domain-facts" and semantic_key == "section:assessment":
+                branch_id = _branch_id(detail_ref)
+                branch = next((item for item in bundle.get("branches") or [] if _integer(item.get("branch_id")) == branch_id), None)
+                bindings = [resolver.binding_for_field({
+                    "id": f"branch:{branch_id}:assessment",
+                    "label": "实现者说明",
+                    "value": (branch or {}).get("self_assessment"),
+                    "source": {
+                        "kind": "database",
+                        "label": "script_build_branch",
+                        "ref": detail_ref,
+                        "fieldPath": "self_assessment",
+                    },
+                    "relation": "persisted-output",
+                    "completeness": "complete" if branch and branch.get("self_assessment") else "partial",
+                }, binding_id=f"{row['id']}:source:1", role="output")]
+            elif semantic_key in {"special:question", "block:record-note"}:
+                bindings = [resolver.binding_for_field({
+                    "id": f"{module['id']}:display-text",
+                    "label": "业务详情确定性文案",
+                    "value": business_value if found else None,
+                    "source": {
+                        "kind": "calculation",
+                        "label": "业务详情确定性说明",
+                        "ref": f"calculation:{module['id']}:display-text",
+                        "fieldPath": "value",
+                    },
+                    "relation": "persisted-output",
+                    "completeness": "complete",
+                }, binding_id=f"{row['id']}:source:1", role="status")]
+            elif card_kind == "retrieval-event" and semantic_key in {
+                "special:outcome",
+                "special:completeness",
+            }:
+                event_id = _event_id(detail_ref)
+                output_key = str(row.get("id") or "").rsplit(":", 1)[-1]
+                path = "output.content" if semantic_key == "special:outcome" else ""
+                binding = resolver.binding_for_event(
+                    event_id or 0,
+                    binding_id=f"{row['id']}:source:1",
+                    path=path,
+                    role="output" if semantic_key == "special:outcome" else "status",
+                    availability=(
+                        "returned-to-agent"
+                        if semantic_key == "special:outcome"
+                        else "produced-by-run"
+                    ),
+                )
+                binding["transform"] = {
+                    "kind": "parsed",
+                    "operation": (
+                        "retrieval-outcome-v1"
+                        if semantic_key == "special:outcome"
+                        else "event-body-completeness-v1"
+                    ),
+                    "outputKey": output_key,
+                }
+                binding["evidence"]["confidence"] = "deterministic"
+                bindings = [binding]
+            elif semantic_key == "special:activities":
+                ref = ""
+                if found and isinstance(business_value, dict):
+                    ref = str(
+                        business_value.get("detailRef")
+                        or business_value.get("id")
+                        or ""
+                    )
+                event_id = _event_id(ref)
+                if event_id is not None:
+                    binding = resolver.binding_for_event(
+                        event_id,
+                        binding_id=f"{row['id']}:source:1",
+                        role="process",
+                        availability="produced-by-run",
+                    )
+                    binding["transform"] = {
+                        "kind": "parsed",
+                        "operation": "activity-summary-v1",
+                        "outputKey": str(row.get("id") or "activity"),
+                    }
+                    binding["evidence"]["confidence"] = "deterministic"
+                    bindings = [binding]
+                elif found and (
+                    business_value == []
+                    or str(row.get("businessSelector") or "")
+                    == "/missingActivityNotice"
+                ):
+                    empty_binding = resolver.binding_for_field(
+                        {
+                            "id": f"{row['id']}:empty",
+                            "label": "可见查询运行记录数量",
+                            "value": business_value,
+                            "source": {
+                                "kind": "calculation",
+                                "label": "取数 Agent 结构化查询记录确定性检查",
+                                "ref": f"calculation:{row['id']}:empty",
+                                "fieldPath": "value",
+                            },
+                            "relation": "persisted-output",
+                            "completeness": "complete",
+                        },
+                        binding_id=f"{row['id']}:source:1",
+                        role="status",
+                    )
+                    empty_source = resolver.sources.get(empty_binding["sourceId"])
+                    if empty_source is not None:
+                        empty_source["resultState"] = "empty"
+                        empty_source.setdefault("locator", {})["resultCount"] = 0
+                    bindings = [empty_binding]
+                else:
+                    bindings = []
+            else:
+                bindings = _bindings_for_row(
+                    module_id=str(module["id"]),
+                    row=row,
+                    row_index=row_index,
+                    candidates=candidates,
+                    resolver=resolver,
+                    business_value=business_value if found else None,
+                    narrow_event_text=card_kind == "implementation-task",
+                )
+                if card_kind == "implementation-plan" and semantic_key == "block:plan":
+                    revision = _implementation_plan_revision_binding(
+                        row=row,
+                        business_value=business_value if found else None,
+                        candidates=candidates,
+                        resolver=resolver,
+                    )
+                    if revision is not None:
+                        bindings.append(revision)
+            if not bindings:
+                binding = unresolved_binding(
+                    f"{module['id']}:row:{row_index}:unresolved",
+                    reason=f"业务详情「{module['title']}」的这一项没有保存可安全定位的原始记录",
+                    code="binding-map-missing",
+                )
+                resolver.add_unresolved_source(binding)
+                bindings = [binding]
+            _mark_row_transform(
+                bindings,
+                business_value if found else None,
+                resolver,
+                str(module["id"]),
+            )
+            row = {key: value for key, value in row.items() if not key.startswith("_")}
+            row["bindingIds"] = [binding["id"] for binding in bindings]
+            rows.append(row)
+            module_bindings.extend(bindings)
+        module["rows"] = rows
+        module["bindings"] = _unique_bindings(module_bindings)
+        module["runtimeRefs"] = list(dict.fromkeys(
+            binding["sourceId"]
+            for binding in module["bindings"]
+            if binding["sourceId"].startswith("event:")
+        ))
+        aligned.append(module)
+    return aligned
+
+
+def _source_module_ids(card_kind: str, semantic_key: str, index: int) -> list[str]:
+    explicit = _MODULE_MAP.get(card_kind, {}).get(semantic_key)
+    if explicit is not None:
+        return explicit
+    if semantic_key.startswith("section:"):
+        return [semantic_key.split(":", 1)[1]]
+    if semantic_key.startswith("block:"):
+        try:
+            return [f"block-{int(semantic_key.split(':', 1)[1]) + 1}"]
+        except ValueError:
+            return [f"block-{index}"]
+    return [semantic_key.split(":", 1)[-1]]
+
+
+def _normalize_business(
+    projection: dict[str, Any], *, card_kind: str = ""
+) -> list[dict[str, Any]]:
+    modules: list[dict[str, Any]] = []
+    question = projection.get("question")
+    if question not in (None, ""):
+        modules.append(_module(
+            "question",
+            "这一步要回答什么",
+            "/question",
+            "special:question",
+            [_row("question:value", None, "/question")],
+            default_open=True,
+        ))
+
+    for index, section in enumerate(projection.get("businessSections") or []):
+        if not isinstance(section, dict):
+            continue
+        section_id = str(section.get("id") or f"section-{index + 1}")
+        path = f"/businessSections/{index}"
+        rows = []
+        if isinstance(section.get("items"), list):
+            for item_index, item in enumerate(section.get("items") or []):
+                label = str((item or {}).get("label") or f"第 {item_index + 1} 项") if isinstance(item, dict) else f"第 {item_index + 1} 项"
+                item_path = f"{path}/items/{item_index}"
+                rows.append(_row(f"{section_id}:item:{item_index + 1}", label, item_path, item_index=item_index))
+        elif section.get("content") is not None:
+            rows.append(_row(f"{section_id}:content", None, f"{path}/content"))
+        semantic_id = _section_semantic_id(section_id)
+        modules.append(_module(
+            section_id,
+            str(section.get("title") or f"业务模块 {index + 1}"),
+            path,
+            f"section:{semantic_id}",
+            rows,
+            presentation=str(section.get("presentation") or "text"),
+            default_open=not bool(section.get("collapsible")) or bool(section.get("defaultOpen")),
+        ))
+
+    for index, block in enumerate(projection.get("blocks") or []):
+        if not isinstance(block, dict):
+            continue
+        path = f"/blocks/{index}"
+        semantic_id = _block_semantic_id(card_kind, block, index)
+        module_id = semantic_id or f"block-{index + 1}"
+        modules.append(_module(
+            module_id,
+            str(block.get("title") or "记录说明"),
+            path,
+            f"block:{semantic_id}" if semantic_id else f"block:{index}",
+            _block_rows(module_id, path, block),
+            presentation=str(block.get("presentation") or block.get("type") or "text"),
+            default_open=not bool(block.get("collapsible")) or bool(block.get("defaultOpen")),
+        ))
+
+    conditions = projection.get("queryConditions")
+    if projection.get("detailKind") == "retrieval-event" and isinstance(conditions, list):
+        rows = [
+            _row(
+                f"conditions:item:{index + 1}",
+                str(item.get("label") or item.get("key") or f"条件 {index + 1}"),
+                f"/queryConditions/{index}/value",
+                item_index=index,
+            )
+            for index, item in enumerate(conditions)
+            if isinstance(item, dict)
+        ]
+        if not rows:
+            rows = [_row("conditions:empty", None, "/queryConditions")]
+        modules.append(_module("conditions", "查询条件", "/queryConditions", "special:conditions", rows))
+
+    outcome = projection.get("outcome")
+    if isinstance(outcome, dict):
+        rows = []
+        for key, label in (("message", "执行结果"), ("count", "返回数量"), ("errorKind", "失败类型")):
+            if outcome.get(key) is not None:
+                rows.append(_row(f"outcome:{key}", label, f"/outcome/{key}"))
+        modules.append(_module("outcome", "查询结果", "/outcome", "special:outcome", rows))
+
+    result_items = projection.get("items")
+    if isinstance(result_items, list) and result_items:
+        rows = [
+            _row(
+                f"results:item:{index + 1}",
+                str((item or {}).get("title") or f"结果 {index + 1}") if isinstance(item, dict) else f"结果 {index + 1}",
+                f"/items/{index}",
+                item_index=index,
+            )
+            for index, item in enumerate(result_items)
+        ]
+        modules.append(_module("results", "返回结果", "/items", "special:results", rows))
+
+    activities = projection.get("activities")
+    if isinstance(activities, list):
+        rows = [
+            _row(
+                f"activities:item:{index + 1}",
+                str((item or {}).get("label") or f"运行记录 {index + 1}") if isinstance(item, dict) else f"运行记录 {index + 1}",
+                f"/activities/{index}",
+                item_index=index,
+            )
+            for index, item in enumerate(activities)
+        ]
+        if not rows:
+            notice_path = "/missingActivityNotice" if projection.get("missingActivityNotice") else "/activities"
+            rows = [_row("activities:empty", None, notice_path)]
+        modules.append(_module("activities", "运行记录", "/activities", "special:activities", rows, presentation="process"))
+
+    completeness = projection.get("completeness")
+    if projection.get("detailKind") == "retrieval-event" and isinstance(completeness, dict):
+        rows = []
+        if completeness.get("bodyAvailable") is False:
+            rows.append(_row("completeness:body", "完整返回", "/completeness/bodyAvailable"))
+        if completeness.get("truncated"):
+            rows.append(_row(
+                "completeness:truncated",
+                "截断说明",
+                "/completeness/omittedCharacters" if completeness.get("omittedCharacters") is not None else "/completeness/truncated",
+            ))
+        if rows:
+            modules.append(_module("completeness", "记录完整性", "/completeness", "special:completeness", rows, presentation="notice"))
+    return modules
+
+
+def _block_rows(module_id: str, path: str, block: dict[str, Any]) -> list[dict[str, Any]]:
+    block_type = str(block.get("type") or "summary")
+    if block_type in {"summary", "notice"}:
+        return [_row(f"{module_id}:value", None, f"{path}/value")]
+    if block_type == "items":
+        return [
+            _row(
+                f"{module_id}:item:{index + 1}",
+                str(
+                    item.get("label")
+                    or item.get("title")
+                    or item.get("subject")
+                    or item.get("summary")
+                    or f"第 {index + 1} 项"
+                ) if isinstance(item, dict) else f"第 {index + 1} 项",
+                f"{path}/items/{index}",
+                item_index=index,
+            )
+            for index, item in enumerate(block.get("items") or [])
+        ]
+    if block_type == "implementation-plan":
+        rows = []
+        if block.get("mode") is not None:
+            rows.append(_row(f"{module_id}:mode", "规划方式", f"{path}/mode", binding_slot="mode:0"))
+        for route_index, route in enumerate(block.get("routes") or []):
+            if not isinstance(route, dict):
+                continue
+            route_number = route.get("pathIndex") or route_index + 1
+            for key, label in (("target", "作用范围"), ("method", "实施方法"), ("action", "具体动作"), ("emphasis", "路线侧重"), ("pathType", "路线类型")):
+                if route.get(key) is None:
+                    continue
+                source_key = {"pathIndex": "path_index", "pathType": "path_type"}.get(key, key)
+                rows.append(_row(
+                    f"{module_id}:route:{route_index + 1}:{key}",
+                    f"路线 {route_number} · {label}",
+                    f"{path}/routes/{route_index}/{key}",
+                    binding_slot="routes:0",
+                    source_suffix=f"/{route_index}/{source_key}",
+                ))
+        if block.get("reasoning") is not None:
+            rows.append(_row(f"{module_id}:reasoning", "为什么这样规划", f"{path}/reasoning", binding_slot="mode:1"))
+        return rows
+    if block_type == "creative-process":
+        rows = []
+        for step_index, step in enumerate(block.get("steps") or []):
+            if not isinstance(step, dict):
+                continue
+            number = step.get("stepIndex") or step_index + 1
+            for key, label in (("action", "动作"), ("summary", "结果"), ("plan", "执行计划"), ("reasoning", "思考依据")):
+                if step.get(key) is not None:
+                    row = _row(f"{module_id}:step:{step_index + 1}:{key}", f"步骤 {number} · {label}", f"{path}/steps/{step_index}/{key}", item_index=step_index)
+                    row["_fieldKey"] = key
+                    rows.append(row)
+        return rows
+    if block_type == "evaluation-branches":
+        rows = []
+        for branch_index, branch in enumerate(block.get("branches") or []):
+            if not isinstance(branch, dict):
+                continue
+            subject = str(branch.get("subject") or f"方案 {branch_index + 1}")
+            for key, label in (("conclusion", "结论"), ("achievements", "已达成"), ("problems", "需关注"), ("evidence", "依据")):
+                if branch.get(key) not in (None, [], ""):
+                    rows.append(_row(f"{module_id}:branch:{branch_index + 1}:{key}", f"{subject} · {label}", f"{path}/branches/{branch_index}/{key}", item_index=branch_index))
+        return rows
+    if block_type == "evaluation-matrix":
+        return [
+            _row(f"{module_id}:row:{index + 1}", str((row or {}).get("criterion") or f"标准 {index + 1}"), f"{path}/rows/{index}", item_index=index)
+            for index, row in enumerate(block.get("rows") or [])
+        ]
+    if block_type == "comparison":
+        return [
+            _row(f"{module_id}:row:{index + 1}", str((row or {}).get("candidate") or f"方案 {index + 1}"), f"{path}/rows/{index}", item_index=index)
+            for index, row in enumerate(block.get("rows") or [])
+        ]
+    if block_type == "artifact":
+        return [
+            _row(f"{module_id}:artifact:{index + 1}", str((ref or {}).get("label") or f"产物 {index + 1}"), f"{path}/refs/{index}", item_index=index)
+            for index, ref in enumerate(block.get("refs") or [])
+        ]
+    return [_row(f"{module_id}:record", None, path)]
+
+
+def _bindings_for_row(
+    *,
+    module_id: str,
+    row: dict[str, Any],
+    row_index: int,
+    candidates: list[dict[str, Any]],
+    resolver: SourceResolver,
+    business_value: Any,
+    narrow_event_text: bool = False,
+) -> list[SourceBinding]:
+    slot = str(row.get("_bindingSlot") or "")
+    bindings: list[SourceBinding] = []
+    if slot:
+        source_id, _, raw_index = slot.partition(":")
+        source_module = next((module for module in candidates if str(module.get("id")) == source_id), None)
+        source_bindings = list((source_module or {}).get("bindings") or [])
+        try:
+            binding = source_bindings[int(raw_index or 0)]
+        except (IndexError, ValueError):
+            binding = None
+        if binding is not None:
+            cloned = _clone_binding(
+                binding,
+                binding_id=f"{row['id']}:source:1",
+                source_suffix=str(row.get("_sourceSuffix") or ""),
+                resolver=resolver,
+            )
+            bindings = [
+                _narrow_event_text_binding(cloned, business_value, resolver, module_id)
+                if narrow_event_text
+                else cloned
+            ]
+    if not bindings:
+        item_index = row.get("_itemIndex")
+        for source_module in candidates:
+            items = ((source_module.get("business") or {}).get("items") or [])
+            source_bindings = list(source_module.get("bindings") or [])
+            if isinstance(item_index, int) and item_index < len(items):
+                item = items[item_index] or {}
+                item_bindings = list(item.get("bindings") or [])
+                field_key = str(row.get("_fieldKey") or "")
+                if field_key and item_bindings:
+                    event_id = _event_id(item_bindings[0].get("sourceId"))
+                    event_paths = {
+                        "action": "input.content.action",
+                        "summary": "input.content.thought_summary",
+                        "plan": "input.content.plan",
+                        "reasoning": "input.content.thought",
+                    }
+                    if event_id and field_key in event_paths:
+                        binding = resolver.binding_for_event(
+                            event_id,
+                            binding_id=f"{row['id']}:source:1",
+                            path=event_paths[field_key],
+                            role="process",
+                        )
+                        found, selected = pointer_lookup(
+                            (resolver.sources.get(binding["sourceId"]) or {}).get("rawRecord"),
+                            str((binding.get("selector") or {}).get("path") or ""),
+                        )
+                        if not found or selected != business_value:
+                            binding["transform"] = {
+                                "kind": "parsed",
+                                "operation": "creative-step-business-label-v1",
+                                "outputKey": field_key,
+                            }
+                            binding["evidence"]["confidence"] = "deterministic"
+                        return [binding]
+                matched = _bindings_matching_value(
+                    item.get("value"), item_bindings, business_value
+                )
+                bindings.extend(matched or item_bindings)
+            elif (
+                isinstance(item_index, int)
+                and isinstance(((source_module.get("business") or {}).get("value")), list)
+                and item_index < len(source_bindings)
+            ):
+                # The readable Inspector may split one source list into one row
+                # per item while CardData keeps it as one aggregate module.
+                # The persisted source order is explicit, so bind the same
+                # index instead of repeating every source on every row.
+                bindings.append(source_bindings[item_index])
+            elif isinstance(item_index, int) and item_index < len(items):
+                bindings.extend((items[item_index] or {}).get("bindings") or [])
+        if not bindings:
+            bindings = [
+                binding
+                for source_module in candidates
+                for binding in source_module.get("bindings") or []
+            ]
+        physical_bindings = _unique_physical_bindings(bindings)
+        usable_bindings = [
+            binding
+            for binding in physical_bindings
+            if binding.get("resolution") != "missing"
+        ]
+        if usable_bindings:
+            physical_bindings = usable_bindings
+        bindings = [
+            _narrow_event_text_binding(cloned, business_value, resolver, module_id)
+            if narrow_event_text
+            else cloned
+            for index, binding in enumerate(physical_bindings, 1)
+            for cloned in [
+                _clone_binding(
+                binding,
+                binding_id=f"{row['id']}:source:{index}",
+                source_suffix="",
+                resolver=resolver,
+                )
+            ]
+        ]
+    return bindings
+
+
+def _mark_row_transform(
+    bindings: list[SourceBinding],
+    business_value: Any,
+    resolver: SourceResolver,
+    module_id: str,
+) -> None:
+    """Never call a normalized/combined business row a direct projection."""
+    mismatched: list[SourceBinding] = []
+    for binding in bindings:
+        if (binding.get("transform") or {}).get("kind") != "direct":
+            continue
+        source = resolver.sources.get(str(binding.get("sourceId") or "")) or {}
+        selector = binding.get("selector") or {}
+        selected: Any = None
+        found = False
+        if selector.get("kind") == "json-pointer":
+            found, selected = pointer_lookup(
+                source.get("rawRecord"), str(selector.get("path") or "")
+            )
+        elif selector.get("kind") == "whole-record":
+            found, selected = True, source.get("rawRecord")
+        elif selector.get("kind") == "text-span":
+            selected = selector.get("exactText")
+            found = True
+        if found and selected != business_value:
+            mismatched.append(binding)
+    for binding in mismatched:
+        binding["transform"] = (
+            {
+                "kind": "combined",
+                "operation": f"business-row:{module_id}",
+            }
+            if len(bindings) > 1
+            else {
+                "kind": "parsed",
+                "operation": "business-row-projection-v1",
+                "outputKey": module_id,
+            }
+        )
+        binding["evidence"]["confidence"] = "deterministic"
+
+
+def _section_semantic_id(section_id: str) -> str:
+    """Map task section instance ids to the stable business module ids."""
+    if not section_id.startswith("task-"):
+        return section_id
+    match = re.match(r"^task-([a-z-]+)-\d+$", section_id)
+    kind = match.group(1) if match else "notes"
+    return {
+        "scope": "scope",
+        "method": "method",
+        "goal": "goal",
+        "materials": "constraints",
+        "context": "constraints",
+        "constraints": "constraints",
+        "avoid": "avoid",
+        "notes": "deliverable",
+    }.get(kind, "deliverable")
+
+
+def _block_semantic_id(
+    card_kind: str, block: dict[str, Any], index: int
+) -> str | None:
+    """Return a stable semantic id for optional/reordered decision blocks."""
+    explicit = str(block.get("semanticId") or "").strip()
+    if explicit:
+        return explicit
+    block_type = str(block.get("type") or "").strip()
+    title = str(block.get("title") or "").strip()
+    by_title = ({
+        "当前保存的创作方向": "direction",
+        "最终方向": "direction",
+        "根据以下信息做出的决策": "inputs",
+    } if card_kind == "objective" else {
+        "明确依据": "basis",
+        "明确采用的数据取舍": "basis",
+        "创作处理": "process",
+        "可确认的上游信息": "available",
+        "实际脚本改动": "changes",
+        "实际产出": "candidate",
+        "产出的候选表": "candidate",
+        "存疑项": "uncertainty",
+        "记录说明": "record-note",
+    } if card_kind == "creative" else {
+        "本轮目标构成": "goal",
+        "根据以下信息做出的决策": "dependencies",
+    } if card_kind == "round-goal" else {
+        "本轮实施路线": "plan",
+        "根据以下信息做出的决策": "basis",
+    } if card_kind == "implementation-plan" else {})
+    if title in by_title:
+        return by_title[title]
+    if card_kind == "creative" and block_type == "creative-process":
+        return "process"
+    if card_kind == "creative" and block_type == "notice":
+        return "record-note"
+    return None
+
+
+def _narrow_event_text_binding(
+    binding: SourceBinding,
+    business_value: Any,
+    resolver: SourceResolver,
+    module_id: str = "",
+) -> SourceBinding:
+    """Narrow a complete implementation task binding to its exact visible section."""
+    if not isinstance(business_value, str) or not business_value.strip():
+        return binding
+    event_id = _event_id(binding.get("sourceId"))
+    selector = binding.get("selector") or {}
+    if event_id is None or selector.get("kind") != "json-pointer":
+        return binding
+    path = str(selector.get("path") or "")
+    source = resolver.sources.get(str(binding.get("sourceId") or "")) or {}
+    found, raw_text = pointer_lookup(source.get("rawRecord"), path)
+    if not found or not isinstance(raw_text, str):
+        return binding
+    exact_text = business_value if business_value in raw_text else _task_source_section(raw_text, module_id)
+    if not exact_text:
+        return binding
+    narrowed = resolver.text_span_binding(
+        event_id,
+        binding_id=str(binding.get("id") or "implementation-task:span"),
+        path=path.lstrip("/").replace("/", "."),
+        exact_text=exact_text,
+        role=str(binding.get("role") or "input"),
+    )
+    if exact_text != business_value:
+        narrowed["transform"] = {
+            "kind": "parsed",
+            "operation": "implementation-task-section-normalization-v1",
+            "outputKey": module_id,
+        }
+        narrowed["evidence"]["confidence"] = "deterministic"
+    return narrowed
+
+
+def _task_source_section(task: str, module_id: str) -> str | None:
+    """Return the exact raw task section behind a normalized business block."""
+    module_id = _section_semantic_id(module_id)
+    line_keys = {
+        "scope": ("target", "实现范围"),
+        "method": ("method", "实施方法"),
+    }
+    if module_id in line_keys:
+        keys = "|".join(re.escape(key) for key in line_keys[module_id])
+        match = re.search(rf"(?im)^(?:{keys})\s*[::]\s*(.+)$", task)
+        return match.group(1).strip() if match else None
+    headings = {
+        "goal": (r"目标",),
+        "constraints": (
+            r"取证方向与约束",
+            r"参考依据与约束",
+            r"严格约束",
+            r"约束(?:([^)]*))?",
+        ),
+        "avoid": (r"禁止事项", r"需要避免"),
+        "deliverable": (r"预期交付", r"交付"),
+    }
+    constraint_pattern = "|".join(headings["constraints"])
+    if module_id == "deliverable" and not re.search(
+        rf"(?im)^(?:{'|'.join(headings['deliverable'])})\s*[::]", task
+    ):
+        # Unlabelled task notes are the exact prefix before a later explicit
+        # constraint section.  The readable Inspector may normalize terms
+        # inside it, so retain the raw prefix as a parsed text span.
+        match = re.search(
+            rf"(?ims)^(.*?)(?=\n\s*\n(?:{constraint_pattern})\s*[::]|\Z)",
+            task,
+        )
+        return match.group(1).strip() if match else task.strip()
+    names = headings.get(module_id)
+    if not names:
+        return None
+    all_names = [name for values in headings.values() for name in values]
+    start_pattern = "|".join(names)
+    next_pattern = "|".join(all_names)
+    match = re.search(
+        rf"(?ims)^(?:{start_pattern})\s*[::]\s*(.*?)(?=\n\s*\n(?:{next_pattern})\s*[::]|\Z)",
+        task,
+    )
+    return match.group(1).strip() if match else None
+
+
+def _implementation_plan_revision_binding(
+    *,
+    row: dict[str, Any],
+    business_value: Any,
+    candidates: list[dict[str, Any]],
+    resolver: SourceResolver,
+) -> SourceBinding | None:
+    """Bind the displayed final plan row to the revision that formed it.
+
+    A round may call ``record_multipath_plan`` several times.  The database
+    value is authoritative, while the latest revision containing the exact
+    displayed value is the formation record.  This prevents round 4 from
+    stopping at Events 4301/4369 and omitting the final Event 4434.
+    """
+    revision = next(
+        (module for module in candidates if str(module.get("id")) == "revisions"),
+        None,
+    )
+    event_ids = sorted(
+        {
+            event_id
+            for binding in (revision or {}).get("bindings") or []
+            for event_id in [_event_id(binding.get("sourceId"))]
+            if event_id is not None
+        },
+        reverse=True,
+    )
+    row_id = str(row.get("id") or "")
+    path = ""
+    if row_id.endswith(":mode"):
+        path = "input.content.mode"
+    elif row_id.endswith(":reasoning"):
+        path = "input.content.note"
+    else:
+        match = re.search(r":route:(\d+):([A-Za-z]+)$", row_id)
+        if match:
+            route_index = int(match.group(1)) - 1
+            key = {"pathType": "path_type"}.get(match.group(2), match.group(2))
+            path = f"input.content.paths.{route_index}.{key}"
+    if not path:
+        return None
+    fallback_event: int | None = None
+    for event_id in event_ids:
+        event = resolver.event_details.get(event_id) or {}
+        found, selected = pointer_lookup(event, "/" + path.replace(".", "/"))
+        if not found:
+            continue
+        fallback_event = fallback_event or event_id
+        if selected != business_value:
+            continue
+        return resolver.binding_for_event(
+            event_id,
+            binding_id=f"{row_id}:revision",
+            path=path,
+            role="process",
+            availability="produced-by-run",
+        )
+    # The readable mode can normalize a one-route saved plan to "单路".
+    # Retain the latest concrete revision but mark that normalization openly.
+    if row_id.endswith(":mode") and fallback_event:
+        binding = resolver.binding_for_event(
+            fallback_event,
+            binding_id=f"{row_id}:revision",
+            path=path,
+            role="process",
+            availability="produced-by-run",
+        )
+        binding["transform"] = {
+            "kind": "parsed",
+            "operation": "single-route-mode-normalization-v1",
+            "outputKey": "mode",
+        }
+        binding["evidence"]["confidence"] = "deterministic"
+        return binding
+    return None
+
+
+def _bindings_matching_value(
+    source_value: Any,
+    bindings: list[SourceBinding],
+    business_value: Any,
+) -> list[SourceBinding]:
+    source_leaves = _leaf_texts(source_value)
+    wanted = set(_leaf_texts(business_value))
+    if not wanted or len(source_leaves) != len(bindings):
+        return []
+    return [
+        binding
+        for leaf, binding in zip(source_leaves, bindings)
+        if leaf in wanted
+    ]
+
+
+def _leaf_texts(value: Any) -> list[str]:
+    if isinstance(value, str):
+        return [value.strip()] if value.strip() else []
+    if isinstance(value, dict):
+        result: list[str] = []
+        for child in value.values():
+            result.extend(_leaf_texts(child))
+        return result
+    if isinstance(value, list):
+        result = []
+        for child in value:
+            result.extend(_leaf_texts(child))
+        return result
+    return [str(value)] if value is not None else []
+
+
+def _event_id(source_id: Any) -> int | None:
+    value = str(source_id or "")
+    if not value.startswith("event:"):
+        return None
+    try:
+        return int(value.split(":", 1)[1])
+    except ValueError:
+        return None
+
+
+def _clone_binding(
+    binding: SourceBinding,
+    *,
+    binding_id: str,
+    source_suffix: str,
+    resolver: SourceResolver,
+) -> SourceBinding:
+    cloned: SourceBinding = deepcopy(binding)
+    cloned["id"] = binding_id
+    selector = cloned.get("selector") or {}
+    if source_suffix and selector.get("kind") == "json-pointer":
+        pointer = f"{str(selector.get('path') or '').rstrip('/')}{source_suffix}"
+        source = resolver.sources.get(cloned["sourceId"]) or {}
+        found, selected = pointer_lookup(source.get("rawRecord"), pointer)
+        if found:
+            cloned["selector"] = {"kind": "json-pointer", "path": pointer}
+            source.setdefault("selectedValues", []).append({
+                "bindingId": binding_id,
+                "fieldId": binding_id,
+                "label": binding_id,
+                "path": pointer,
+                "value": selected,
+            })
+        else:
+            cloned["selector"] = {
+                "kind": "unresolved",
+                "reason": f"原始记录中无法定位字段 {pointer}",
+                "code": "field-path-mismatch",
+            }
+            cloned["resolution"] = "partial"
+            cloned["evidence"]["confidence"] = "unconfirmed"
+    return cloned
+
+
+def _module(
+    module_id: str,
+    title: str,
+    business_path: str,
+    semantic_key: str,
+    rows: list[dict[str, Any]],
+    *,
+    presentation: str = "text",
+    default_open: bool = True,
+) -> dict[str, Any]:
+    return {
+        "id": module_id,
+        "title": title,
+        "businessPath": business_path,
+        "presentation": presentation,
+        "rows": rows,
+        "defaultOpen": default_open,
+        "_semanticKey": semantic_key,
+    }
+
+
+def _row(
+    row_id: str,
+    label: str | None,
+    business_selector: str,
+    *,
+    item_index: int | None = None,
+    binding_slot: str | None = None,
+    source_suffix: str | None = None,
+) -> dict[str, Any]:
+    row: dict[str, Any] = {
+        "id": row_id,
+        "label": label,
+        "businessSelector": business_selector,
+    }
+    if item_index is not None:
+        row["_itemIndex"] = item_index
+    if binding_slot is not None:
+        row["_bindingSlot"] = binding_slot
+    if source_suffix is not None:
+        row["_sourceSuffix"] = source_suffix
+    return row
+
+
+def _unique_bindings(bindings: list[SourceBinding]) -> list[SourceBinding]:
+    result: list[SourceBinding] = []
+    seen: set[str] = set()
+    for binding in bindings:
+        key = str(binding.get("id") or "")
+        if not key or key in seen:
+            continue
+        seen.add(key)
+        result.append(binding)
+    return result
+
+
+def _unique_physical_bindings(bindings: list[SourceBinding]) -> list[SourceBinding]:
+    result: list[SourceBinding] = []
+    seen: set[str] = set()
+    for binding in bindings:
+        selector = binding.get("selector") or {}
+        key = "|".join((
+            str(binding.get("sourceId") or ""),
+            str(selector.get("kind") or ""),
+            str(selector.get("path") or selector.get("exactText") or selector.get("reason") or selector),
+            str(binding.get("role") or ""),
+        ))
+        if not key or key in seen:
+            continue
+        seen.add(key)
+        result.append(binding)
+    return result
+
+
+def _branch_id(value: str) -> int | None:
+    import re
+    match = re.search(r":branch:(\d+)", value)
+    return _integer(match.group(1)) if match else None
+
+
+def _integer(value: Any) -> int | None:
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None

+ 2056 - 0
visualization-old/backend/app/source_lineage/projector.py

@@ -0,0 +1,2056 @@
+from __future__ import annotations
+
+import re
+from copy import deepcopy
+from typing import Any
+
+from ..card_details import CardBusinessDataProjector, CardDataNotFound
+from ..card_details.common import find_detail
+from ..inspector_projection import project_activity_detail, project_event_detail
+from ..module_audit.log_context import locate_log_module
+from ..retrieval_detail_projection import (
+    QUERY_TOOL_NAMES,
+    RETRIEVAL_RESULT_COLLECTION_KEYS,
+)
+from .comparison import align_business_projection, business_only_projection
+from .resolver import SourceResolver, pointer_value
+from .schema import SourceBinding, unresolved_binding
+
+
+class InspectorViewNotFound(LookupError):
+    pass
+
+
+class InspectorWorkbenchProjector:
+    """Application service that makes all three Inspector columns together."""
+
+    def __init__(self) -> None:
+        self._card_projector = CardBusinessDataProjector()
+
+    def related_event_ids(
+        self, detail_ref: str, *, bundle: dict[str, Any], view: dict[str, Any]
+    ) -> list[int]:
+        ref = str(detail_ref or "")
+        ids: set[int] = set()
+        if ref.startswith("event:"):
+            value = _suffix_int(ref)
+            if value:
+                ids.add(value)
+
+        node = find_detail(view, ref)
+        _collect_event_ids(node, ids)
+
+        if ref == "run:objective":
+            _collect_event_ids(view.get("objective"), ids)
+        round_match = re.fullmatch(r"round:(\d+):(goal|plan|result|evaluation)", ref)
+        if round_match:
+            round_index = int(round_match.group(1))
+            round_ = next(
+                (
+                    item
+                    for item in view.get("rounds") or []
+                    if _integer(item.get("roundIndex")) == round_index
+                ),
+                {},
+            )
+            role = round_match.group(2)
+            target = {
+                "goal": round_.get("goal"),
+                "plan": (round_.get("plan") or {}).get("node"),
+                "result": round_.get("overallEvaluation"),
+                "evaluation": round_.get("overallEvaluation"),
+            }.get(role)
+            _collect_event_ids(target, ids)
+            event_names = {
+                "goal": {"begin_round"},
+                "plan": {"record_multipath_plan"},
+            }.get(role, set())
+            for event in bundle.get("events") or []:
+                if (
+                    event.get("event_name") in event_names
+                    and (
+                        role == "goal"
+                        or _integer(event.get("round_index")) == round_index
+                    )
+                ):
+                    ids.add(_integer(event.get("id")) or 0)
+
+        branch_match = re.fullmatch(
+            r"round:(\d+):branch:(\d+):(task|retrieval|output)", ref
+        )
+        if branch_match:
+            round_index, branch_id = int(branch_match.group(1)), int(branch_match.group(2))
+            branch = _view_branch(view, round_index, branch_id)
+            _collect_event_ids(branch, ids)
+
+        if ref.startswith("creative:"):
+            seed_id = _suffix_int(ref)
+            seed = _bundle_event(bundle, seed_id)
+            round_index = _integer((seed or {}).get("round_index"))
+            branch_id = _integer((seed or {}).get("branch_id"))
+            implementers = {
+                _integer(event.get("id"))
+                for event in bundle.get("events") or []
+                if event.get("event_name") == "script_implementer"
+                and _integer(event.get("round_index")) == round_index
+                and _integer(event.get("branch_id")) == branch_id
+            }
+            for event in bundle.get("events") or []:
+                if _integer(event.get("round_index")) != round_index or _integer(event.get("branch_id")) != branch_id:
+                    continue
+                event_id = _integer(event.get("id"))
+                if event_id in implementers or _integer(event.get("scope_event_id")) in implementers:
+                    ids.add(event_id or 0)
+
+        if ref.startswith("multipath-decision:"):
+            decision_id = _suffix_int(ref)
+            decision = next(
+                (
+                    item
+                    for item in bundle.get("multipathDecisions") or []
+                    if _integer(item.get("id")) == decision_id
+                ),
+                {},
+            )
+            round_index = _integer(decision.get("round_index"))
+            for event in bundle.get("events") or []:
+                if (
+                    event.get("event_name") == "script_multipath_evaluator"
+                    and _integer(event.get("round_index")) == round_index
+                ):
+                    ids.add(_integer(event.get("id")) or 0)
+
+        if ref.startswith("retrieval-agent:"):
+            agent_id = _suffix_int(ref)
+            for event in bundle.get("events") or []:
+                if (
+                    _integer(event.get("id")) == agent_id
+                    or _integer(event.get("parent_event_id")) == agent_id
+                    or _integer(event.get("scope_event_id")) == agent_id
+                ):
+                    ids.add(_integer(event.get("id")) or 0)
+
+        return sorted(value for value in ids if value > 0)
+
+    def project(
+        self,
+        detail_ref: str,
+        *,
+        script_build_id: int,
+        bundle: dict[str, Any],
+        view: dict[str, Any],
+        event_details: dict[int, dict[str, Any]],
+        log_contents: dict[int, str] | None = None,
+    ) -> dict[str, Any]:
+        ref = str(detail_ref or "").strip()
+
+        def event_loader(event_id: int) -> dict[str, Any]:
+            event = event_details.get(int(event_id))
+            if event is None:
+                raise KeyError(event_id)
+            return event
+
+        business = self._business_detail(
+            ref,
+            script_build_id=script_build_id,
+            bundle=bundle,
+            view=view,
+            event_details=event_details,
+        )
+        try:
+            card_data = self._card_projector.project(
+                ref,
+                bundle=bundle,
+                view=view,
+                load_event=event_loader,
+            )
+        except (CardDataNotFound, KeyError):
+            card_data = _fallback_card_data(ref, business)
+
+        resolver = SourceResolver(bundle=bundle, event_details=event_details)
+        source_modules = self._modules(ref, business, card_data, resolver, event_details, bundle)
+        if not source_modules:
+            raise InspectorViewNotFound(f"未找到活动 {ref}")
+        alignment_kind = (
+            "retrieval-event"
+            if business.get("detailKind") == "retrieval-event"
+            else str(card_data.get("cardKind") or _fallback_card_kind(ref))
+        )
+        modules = align_business_projection(
+            detail_ref=ref,
+            card_kind=alignment_kind,
+            business=business,
+            source_modules=source_modules,
+            resolver=resolver,
+            bundle=bundle,
+        )
+        if not modules:
+            raise InspectorViewNotFound(f"活动 {ref} 没有可展示的业务详情")
+        if log_contents:
+            log_refs: dict[int, str] = {}
+            for event_id, event in event_details.items():
+                log_module = locate_log_module(log_contents.get(event_id) or "", event)
+                if log_module:
+                    log_refs[event_id] = resolver.add_log_anchor(
+                        event_id,
+                        log_module,
+                        event_msg_id=str(event.get("msg_id") or ""),
+                    )
+            for module in modules:
+                runtime_refs = list(module.get("runtimeRefs") or [])
+                for event_id, log_ref in log_refs.items():
+                    if f"event:{event_id}" in runtime_refs and log_ref not in runtime_refs:
+                        runtime_refs.append(log_ref)
+                module["runtimeRefs"] = runtime_refs
+
+        completeness = str(card_data.get("completeness") or "partial")
+        if any(
+            binding.get("resolution") != "resolved"
+            for binding in _all_bindings(modules)
+        ):
+            completeness = "partial" if completeness != "missing" else "missing"
+        notices = [
+            *[item for item in card_data.get("notices") or [] if isinstance(item, dict)],
+            *_business_notices(business),
+        ]
+        used_source_ids = _used_source_ids(modules)
+        return {
+            "schemaVersion": "inspector-source-v2",
+            "detailRef": ref,
+            "cardKind": card_data.get("cardKind") or _fallback_card_kind(ref),
+            "completeness": completeness,
+            "businessProjection": business_only_projection(business),
+            "modules": modules,
+            "sources": {
+                source_id: source
+                for source_id, source in resolver.sources.items()
+                if source_id in used_source_ids
+            },
+            "notices": _unique_notices(notices),
+        }
+
+    def _business_detail(
+        self,
+        detail_ref: str,
+        *,
+        script_build_id: int,
+        bundle: dict[str, Any],
+        view: dict[str, Any],
+        event_details: dict[int, dict[str, Any]],
+    ) -> dict[str, Any]:
+        if detail_ref.startswith("event:"):
+            event_id = _suffix_int(detail_ref)
+            event = event_details.get(event_id or -1)
+            if event is None:
+                raise InspectorViewNotFound(f"未找到运行事件 {event_id}")
+            try:
+                return project_activity_detail(
+                    script_build_id,
+                    detail_ref,
+                    view,
+                    bundle,
+                    event_detail=event,
+                )
+            except KeyError:
+                return project_event_detail(
+                    script_build_id,
+                    event,
+                    run_status=((view.get("header") or {}).get("status")),
+                )
+        event_detail = None
+        if detail_ref.startswith("retrieval-agent:"):
+            run = find_detail(view, detail_ref) or {}
+            event_detail = event_details.get(_integer(run.get("eventId")) or -1)
+        try:
+            return project_activity_detail(
+                script_build_id,
+                detail_ref,
+                view,
+                bundle,
+                event_detail=event_detail,
+            )
+        except KeyError as exc:
+            raise InspectorViewNotFound(f"未找到活动 {detail_ref}") from exc
+
+    def _modules(
+        self,
+        detail_ref: str,
+        business: dict[str, Any],
+        card_data: dict[str, Any],
+        resolver: SourceResolver,
+        event_details: dict[int, dict[str, Any]],
+        bundle: dict[str, Any],
+    ) -> list[dict[str, Any]]:
+        if detail_ref.startswith("retrieval-direct:"):
+            return _direct_group_modules(card_data, resolver, event_details)
+        if detail_ref.startswith("retrieval-agent:"):
+            return _retrieval_agent_modules(card_data, resolver, event_details)
+        special = self._event_modules(detail_ref, business, resolver, event_details)
+        if special is not None:
+            return special
+
+        business_values = _business_modules(business)
+        planned = [
+            item
+            for item in (card_data.get("displayUse") or {}).get("businessModules") or []
+            if isinstance(item, dict)
+        ]
+        fields = {
+            str(item.get("id")): item
+            for item in [
+                *(card_data.get("businessInputs") or []),
+                *(card_data.get("businessOutputs") or []),
+            ]
+            if isinstance(item, dict) and item.get("id")
+        }
+        if card_data.get("cardKind") == "implementation-task" and not any(
+            str((field.get("source") or {}).get("kind") or "") == "runtime-event"
+            for field in fields.values()
+        ):
+            return _historical_implementation_task_modules(fields, resolver)
+        modules: list[dict[str, Any]] = []
+        consumed: set[str] = set()
+        for index, plan in enumerate(planned, 1):
+            module_id = str(plan.get("id") or f"module-{index}")
+            title = str(plan.get("title") or f"业务模块 {index}")
+            business_module = _match_business_module(business_values, module_id, title)
+            if business_module:
+                consumed.add(str(business_module.get("id")))
+            source_ids = [str(value) for value in plan.get("sourceIds") or []]
+            bindings = [
+                resolver.binding_for_field(
+                    fields[source_id],
+                    binding_id=f"{module_id}:source:{position}",
+                )
+                for position, source_id in enumerate(source_ids, 1)
+                if source_id in fields
+            ]
+            if card_data.get("cardKind") == "implementation-task" and business_module:
+                task_field = next(
+                    (
+                        fields[source_id]
+                        for source_id in source_ids
+                        if source_id in fields
+                        and str((fields[source_id].get("source") or {}).get("kind")) == "runtime-event"
+                        and str((fields[source_id].get("source") or {}).get("fieldPath")) == "input.content.task"
+                    ),
+                    None,
+                )
+                task_event_id = _suffix_int(str((task_field or {}).get("source", {}).get("ref") or ""))
+                if task_event_id and _module_value(business_module):
+                    bindings = [
+                        resolver.text_span_binding(
+                            task_event_id,
+                            binding_id=f"{module_id}:task-span",
+                            path="input.content.task",
+                            exact_text=_module_value(business_module),
+                            role="input",
+                        ),
+                        *[
+                            binding
+                            for binding in bindings
+                            if binding.get("sourceId") != f"event:{task_event_id}"
+                        ],
+                    ]
+            aggregate = _aggregate_binding(
+                str(card_data.get("cardKind") or ""),
+                module_id,
+                source_ids,
+                fields,
+                resolver,
+                bundle,
+            )
+            if aggregate is not None:
+                bindings = [aggregate]
+            if module_id == "process" and not bindings:
+                bindings = [
+                    resolver.binding_for_event(
+                        event_id,
+                        binding_id=f"{module_id}:event:{event_id}",
+                        role="process",
+                    )
+                    for event_id in sorted(event_details)
+                ]
+            if card_data.get("cardKind") == "multipath-decision" and module_id == "outcomes":
+                for binding in bindings:
+                    source_record = resolver.sources.get(str(binding.get("sourceId") or "")) or {}
+                    raw_record = source_record.get("rawRecord")
+                    branch_status = str((raw_record or {}).get("status") or "").lower() if isinstance(raw_record, dict) else ""
+                    if branch_status in {"merged", "adopted", "accepted", "selected"}:
+                        binding["evidence"]["adoption"] = "explicitly-adopted"
+                        binding["evidence"]["confidence"] = "exact"
+                    elif branch_status in {"discarded", "rejected", "not_adopted"}:
+                        binding["evidence"]["adoption"] = "explicitly-rejected"
+                        binding["evidence"]["confidence"] = "exact"
+            if not bindings:
+                binding = unresolved_binding(
+                    f"{module_id}:unresolved",
+                    reason=f"模块「{title}」没有保存可安全定位的原始记录",
+                    code="binding-map-missing",
+                )
+                resolver.add_unresolved_source(binding)
+                bindings = [binding]
+            value = _module_value(business_module)
+            if value in (None, "", [], {}) and source_ids:
+                values = [fields[source_id].get("value") for source_id in source_ids if source_id in fields]
+                value = values[0] if len(values) == 1 else values
+            _align_transforms_with_business(value, bindings, resolver, module_id)
+            module = {
+                "id": module_id,
+                "title": title,
+                "presentation": _presentation(business_module, title),
+                "business": {"value": value},
+                "bindings": bindings,
+                "runtimeRefs": _runtime_refs(bindings),
+                "defaultOpen": index <= 3,
+            }
+            if business_module and business_module.get("items"):
+                business_items = _normalize_items(business_module.get("items") or [])
+                item_rows = []
+                child_bindings: list[SourceBinding] = []
+                for item_index, item in enumerate(business_items, 1):
+                    item_bindings = (
+                        [_multipath_comparison_binding(
+                            item,
+                            item_index,
+                            bindings,
+                            resolver,
+                            bundle,
+                        )]
+                        if card_data.get("cardKind") == "multipath-decision" and module_id == "comparison"
+                        else _item_bindings(
+                            module_id,
+                            item_index,
+                            item,
+                            len(business_items),
+                            bindings,
+                            resolver,
+                        )
+                    )
+                    child_bindings.extend(item_bindings)
+                    item_rows.append(
+                        {
+                            "id": f"{module_id}:item:{item_index}",
+                            "label": item.get("label"),
+                            "value": item.get("value"),
+                            "bindings": item_bindings,
+                        }
+                    )
+                module["business"] = {"items": item_rows}
+                module["bindings"] = _unique_bindings(child_bindings)
+                module["runtimeRefs"] = _runtime_refs(module["bindings"])
+            elif business_module and business_module.get("steps"):
+                step_rows = []
+                step_bindings: list[SourceBinding] = []
+                for step_index, step in enumerate(business_module.get("steps") or [], 1):
+                    if not isinstance(step, dict):
+                        continue
+                    step_event_id = _suffix_int(str(step.get("eventRef") or ""))
+                    if step_event_id and step_event_id in event_details:
+                        step_binding = resolver.binding_for_event(
+                            step_event_id,
+                            binding_id=f"{module_id}:step:{step_index}",
+                            path="",
+                            role="process",
+                        )
+                        step_binding["transform"] = {
+                            "kind": "parsed",
+                            "operation": "creative-step-v1",
+                            "outputKey": str(step_index),
+                        }
+                        step_binding["evidence"]["confidence"] = "deterministic"
+                    else:
+                        step_binding = unresolved_binding(
+                            f"{module_id}:step:{step_index}:unresolved",
+                            reason=f"创作步骤 {step_index} 没有保存可安全关联的 Event",
+                            source_id=(f"event:{step_event_id}" if step_event_id else None),
+                            resolution="unsafe",
+                            code="unsafe-association",
+                        )
+                        if step_binding["sourceId"] not in resolver.sources:
+                            resolver.add_unresolved_source(step_binding)
+                    step_bindings.append(step_binding)
+                    step_rows.append({
+                        "id": f"{module_id}:step:{step_index}",
+                        "label": step.get("action") or f"步骤 {step_index}",
+                        "value": step,
+                        "bindings": [step_binding],
+                    })
+                module["business"] = {"items": step_rows}
+                module["bindings"] = step_bindings
+                module["runtimeRefs"] = _runtime_refs(step_bindings)
+            modules.append(module)
+
+        # The formal Inspector exposes only modules declared by the unified
+        # card contract.  Legacy business blocks are an input adapter, not a
+        # second set of UI modules; appending unmatched blocks here created
+        # duplicates such as Goal + “本轮目标构成” with no trustworthy binding.
+        return modules
+
+    def _event_modules(
+        self,
+        detail_ref: str,
+        business: dict[str, Any],
+        resolver: SourceResolver,
+        event_details: dict[int, dict[str, Any]],
+    ) -> list[dict[str, Any]] | None:
+        if not detail_ref.startswith("event:"):
+            return None
+        event_id = _suffix_int(detail_ref)
+        event = event_details.get(event_id or -1)
+        if event is None:
+            return None
+        if business.get("detailKind") == "retrieval-event":
+            return _retrieval_event_modules(business, event, resolver)
+
+        if event.get("event_name") == "script_implementer":
+            input_side = event.get("input") if isinstance(event.get("input"), dict) else {}
+            content = input_side.get("content") if isinstance(input_side, dict) else None
+            task = content.get("task") if isinstance(content, dict) else content
+            sections = {
+                str(item.get("id")): item
+                for item in business.get("businessSections") or []
+                if isinstance(item, dict)
+            }
+            action_binding = resolver.binding_for_event(
+                event_id or 0,
+                binding_id="action:event",
+                path="event_name",
+                role="process",
+            )
+            action_binding["transform"] = {
+                "kind": "parsed",
+                "operation": "implementation-activity-label-v1",
+                "outputKey": "action",
+            }
+            action_binding["evidence"]["confidence"] = "deterministic"
+            request_binding = resolver.binding_for_event(
+                event_id or 0,
+                binding_id="request:event",
+                path="input.content.task",
+                role="input",
+                availability="direct-read",
+            )
+            request_binding["transform"] = {
+                "kind": "parsed",
+                "operation": "implementation-task-readable-sections-v1",
+                "outputKey": "request",
+            }
+            request_binding["evidence"]["confidence"] = "deterministic"
+            result_value = (sections.get("result") or {}).get("content")
+            result_binding = resolver.text_span_binding(
+                event_id or 0,
+                binding_id="result:event",
+                path="output.content.summary",
+                exact_text=result_value,
+                role="output",
+            )
+            return [
+                {
+                    "id": "action",
+                    "title": "做了什么",
+                    "presentation": "text",
+                    "business": {"value": (sections.get("action") or {}).get("content")},
+                    "bindings": [action_binding],
+                    "runtimeRefs": [detail_ref],
+                    "defaultOpen": True,
+                },
+                {
+                    "id": "request",
+                    "title": "处理内容",
+                    "presentation": "text",
+                    "business": {"value": (sections.get("request") or {}).get("content")},
+                    "bindings": [request_binding],
+                    "runtimeRefs": [detail_ref],
+                    "defaultOpen": True,
+                },
+                {
+                    "id": "result",
+                    "title": "得到什么",
+                    "presentation": "text",
+                    "business": {"value": result_value},
+                    "bindings": [result_binding],
+                    "runtimeRefs": [detail_ref],
+                    "defaultOpen": True,
+                },
+            ]
+
+        values = _business_modules(business)
+        planning_paths = {
+            "summary": "input.content.thought_summary",
+            "thought": "input.content.thought",
+            "plan": "input.content.plan",
+            "action": "input.content.action",
+        }
+        modules: list[dict[str, Any]] = []
+        is_planning = (
+            event.get("event_name") == "think_and_plan"
+            and event.get("agent_role") == "main"
+            and _integer(event.get("agent_depth")) == 0
+        )
+        is_review = event.get("event_name") in {"script_multipath_evaluator", "script_evaluator"}
+        if not is_planning and not is_review:
+            return _generic_event_modules(
+                detail_ref, business, event, resolver, event_id or 0
+            )
+        for index, item in enumerate(values, 1):
+            module_id = str(item.get("id") or f"event-module-{index}")
+            title = str(item.get("title") or f"运行模块 {index}")
+            value = _module_value(item)
+            input_title = title in {"评审范围", "评审标准", "评审对象", "标准"}
+            if is_planning and module_id in planning_paths:
+                binding = resolver.binding_for_event(
+                    event_id or 0,
+                    binding_id=f"{module_id}:event:{event_id}",
+                    path=planning_paths[module_id],
+                    role="input",
+                    availability="direct-read",
+                )
+            elif title == "记录说明" and not event.get("output"):
+                binding = resolver.binding_for_event(
+                    event_id or 0,
+                    binding_id=f"{module_id}:event:{event_id}",
+                    role="status",
+                    availability="produced-by-run",
+                )
+                binding["transform"] = {
+                    "kind": "calculated",
+                    "operation": "runtime-missing-decision-notice-v1",
+                }
+                binding["evidence"]["confidence"] = "deterministic"
+            else:
+                side = "input" if input_title else "output"
+                binding = resolver.text_span_binding(
+                    event_id or 0,
+                    binding_id=f"{module_id}:event:{event_id}",
+                    path=_event_text_path(event, side),
+                    exact_text=value,
+                    role="input" if input_title else "output",
+                )
+            business_payload: dict[str, Any] = {"value": value}
+            bindings = [binding]
+            if item.get("items"):
+                rows = []
+                bindings = []
+                for item_index, child in enumerate(_normalize_items(item.get("items") or []), 1):
+                    child_bindings = []
+                    for leaf_index, leaf in enumerate(_leaf_texts(child.get("value")), 1):
+                        child_bindings.append(resolver.text_span_binding(
+                            event_id or 0,
+                            binding_id=f"{module_id}:item:{item_index}:leaf:{leaf_index}",
+                            path=_event_text_path(event, "input" if input_title else "output"),
+                            exact_text=leaf,
+                            role="input" if input_title else "output",
+                        ))
+                    if not child_bindings:
+                        fallback = resolver.binding_for_event(
+                            event_id or 0,
+                            binding_id=f"{module_id}:item:{item_index}:record",
+                            path=_event_text_path(event, "input" if input_title else "output"),
+                            role="input" if input_title else "output",
+                            availability="direct-read" if input_title else "returned-to-agent",
+                        )
+                        fallback["transform"] = {
+                            "kind": "parsed",
+                            "operation": "review-item-v1",
+                            "outputKey": str(item_index),
+                        }
+                        fallback["evidence"]["confidence"] = "deterministic"
+                        child_bindings = [fallback]
+                    bindings.extend(child_bindings)
+                    rows.append(
+                        {
+                            "id": f"{module_id}:item:{item_index}",
+                            "label": child.get("label"),
+                            "value": child.get("value"),
+                            "bindings": child_bindings,
+                        }
+                    )
+                business_payload = {"items": rows}
+            modules.append(
+                {
+                    "id": module_id,
+                    "title": title,
+                    "presentation": _presentation(item, title),
+                    "business": business_payload,
+                    "bindings": bindings,
+                    "runtimeRefs": [detail_ref],
+                    "defaultOpen": index <= 3,
+                }
+            )
+        return modules
+
+
+def _generic_event_modules(
+    detail_ref: str,
+    business: dict[str, Any],
+    event: dict[str, Any],
+    resolver: SourceResolver,
+    event_id: int,
+) -> list[dict[str, Any]] | None:
+    """Bind the readable action/request/result projection to the real Event Body."""
+    sections = [
+        item
+        for item in business.get("businessSections") or []
+        if isinstance(item, dict)
+    ]
+    if not sections:
+        return None
+    modules: list[dict[str, Any]] = []
+    for index, section in enumerate(sections, 1):
+        module_id = str(section.get("id") or f"section-{index}")
+        value = section.get("content")
+        if module_id == "action":
+            path, role, availability = "event_name", "process", "produced-by-run"
+            operation = "runtime-activity-label-v1"
+        elif module_id == "request":
+            path, role, availability = _event_text_path(event, "input"), "input", "direct-read"
+            operation = "runtime-request-readable-projection-v1"
+        elif module_id == "result":
+            path, role, availability = _event_text_path(event, "output"), "output", "returned-to-agent"
+            operation = "runtime-result-readable-projection-v1"
+        else:
+            # The section is visibly derived from this Event, but there is no
+            # card-specific field contract. Bind the complete Event instead of
+            # falsely claiming that its original record is missing.
+            path, role, availability = "", "process", "produced-by-run"
+            operation = "runtime-business-section-v1"
+        binding = resolver.binding_for_event(
+            event_id,
+            binding_id=f"{module_id}:event:{event_id}",
+            path=path,
+            role=role,
+            availability=availability,
+        )
+        binding["transform"] = {
+            "kind": "parsed",
+            "operation": operation,
+            "outputKey": module_id,
+        }
+        binding["evidence"]["confidence"] = "deterministic"
+        modules.append({
+            "id": module_id,
+            "title": str(section.get("title") or f"业务模块 {index}"),
+            "presentation": "text",
+            "business": {"value": value},
+            "bindings": [binding],
+            "runtimeRefs": [detail_ref],
+            "defaultOpen": True,
+        })
+    return modules
+
+
+def _retrieval_event_modules(
+    business: dict[str, Any], event: dict[str, Any], resolver: SourceResolver
+) -> list[dict[str, Any]]:
+    event_id = _integer(event.get("id")) or 0
+    conditions = business.get("queryConditions") or []
+    condition_items = []
+    condition_bindings: list[SourceBinding] = []
+    for index, item in enumerate(conditions, 1):
+        key = str(item.get("key") or index)
+        binding = resolver.binding_for_event(
+            event_id,
+            binding_id=f"purpose:{key}",
+            path=f"input.content.{key}",
+            role="input",
+            availability="direct-read",
+        )
+        condition_bindings.append(binding)
+        condition_items.append(
+            {
+                "id": f"purpose:item:{key}",
+                "label": item.get("label"),
+                "value": item.get("value"),
+                "bindings": [binding],
+            }
+        )
+    if not condition_bindings:
+        binding = resolver.binding_for_event(
+            event_id,
+            binding_id="purpose:event-input",
+            path="input.content",
+            role="input",
+            availability="direct-read",
+        )
+        condition_bindings = [binding]
+
+    name = str(event.get("event_name") or "")
+    result_key = RETRIEVAL_RESULT_COLLECTION_KEYS.get(name)
+    root = "output.content" + (f".{result_key}" if result_key else "")
+    root_value = pointer_value(event, f"/{root.replace('.', '/')}")
+    result_items = []
+    result_bindings: list[SourceBinding] = []
+    for index, item in enumerate(business.get("items") or [], 1):
+        item_path = (
+            f"{root}.{index - 1}"
+            if isinstance(root_value, list)
+            else root
+        )
+        binding = resolver.binding_for_event(
+            event_id,
+            binding_id=f"result:item:{index}",
+            path=item_path,
+            role="output",
+            availability="returned-to-agent",
+        )
+        binding["transform"] = {
+            "kind": "parsed",
+            "operation": "retrieval-result-item-v1",
+            "outputKey": str(index - 1),
+        }
+        binding["evidence"]["confidence"] = "deterministic"
+        result_bindings.append(binding)
+        result_items.append(
+            {
+                "id": f"result:item:{index}",
+                "label": item.get("title"),
+                "value": item,
+                "bindings": [binding],
+            }
+        )
+    if not result_bindings:
+        empty_result = resolver.binding_for_event(
+                event_id,
+                binding_id="result:event-output",
+                path="output.content",
+                role="output",
+                availability="returned-to-agent",
+            )
+        empty_result["transform"] = {
+            "kind": "parsed",
+            "operation": "retrieval-empty-or-failure-message-v1",
+            "outputKey": "message",
+        }
+        empty_result["evidence"]["confidence"] = "deterministic"
+        result_bindings = [empty_result]
+    status_bindings = _event_status_bindings(
+        resolver, event_id, prefix="status", include_output=True
+    )
+    event_source = resolver.sources.get(f"event:{event_id}")
+    if event_source is not None and str((business.get("outcome") or {}).get("state")) == "empty":
+        event_source["resultState"] = "empty"
+    return [
+        {
+            "id": "purpose",
+            "title": "目的 / 查询条件",
+            "presentation": "items",
+            "business": {"items": condition_items} if condition_items else {"value": "未记录调用输入"},
+            "bindings": condition_bindings,
+            "runtimeRefs": [f"event:{event_id}"],
+            "defaultOpen": True,
+        },
+        {
+            "id": "result",
+            "title": "返回内容 / 结果列表",
+            "presentation": "items",
+            "business": {"items": result_items} if result_items else {"value": (business.get("outcome") or {}).get("message")},
+            "bindings": result_bindings,
+            "runtimeRefs": [f"event:{event_id}"],
+            "defaultOpen": True,
+        },
+        {
+            "id": "status",
+            "title": "状态、耗时与完整性",
+            "presentation": "notice",
+            "business": {"value": {"outcome": business.get("outcome"), "completeness": business.get("completeness")}},
+            "bindings": status_bindings,
+            "runtimeRefs": [f"event:{event_id}"],
+            "defaultOpen": True,
+        },
+    ]
+
+
+def _direct_group_modules(
+    card_data: dict[str, Any],
+    resolver: SourceResolver,
+    event_details: dict[int, dict[str, Any]],
+) -> list[dict[str, Any]]:
+    events = [event_details[event_id] for event_id in sorted(event_details)]
+
+    def rows(kind: str) -> tuple[list[dict[str, Any]], list[SourceBinding]]:
+        values: list[dict[str, Any]] = []
+        bindings: list[SourceBinding] = []
+        for index, event in enumerate(events, 1):
+            event_id = _integer(event.get("id")) or 0
+            if kind == "input":
+                path, role, availability = "input.content", "input", "direct-read"
+                label = str(event.get("event_name") or f"调用 {index}")
+                value = ((event.get("input") or {}).get("content") if isinstance(event.get("input"), dict) else None)
+            elif kind == "output":
+                path, role, availability = "output.content", "output", "returned-to-agent"
+                label = str(event.get("event_name") or f"调用 {index}")
+                value = ((event.get("output") or {}).get("content") if isinstance(event.get("output"), dict) else None)
+            else:
+                path, role, availability = "status", "status", "produced-by-run"
+                label = str(event.get("event_name") or f"调用 {index}")
+                value = {
+                    "status": event.get("status"),
+                    "durationMs": event.get("duration_ms"),
+                    "error": event.get("error_message"),
+                }
+            item_bindings = (
+                _event_status_bindings(resolver, event_id, prefix=kind)
+                if kind == "status"
+                else [resolver.binding_for_event(
+                    event_id,
+                    binding_id=f"{kind}:event:{event_id}",
+                    path=path,
+                    role=role,
+                    availability=availability,
+                )]
+            )
+            bindings.extend(item_bindings)
+            values.append(
+                {
+                    "id": f"{kind}:item:{event_id}",
+                    "label": label,
+                    "value": value,
+                    "bindings": item_bindings,
+                }
+            )
+        return values, bindings
+
+    input_rows, input_bindings = rows("input")
+    output_rows, output_bindings = rows("output")
+    status_rows, status_bindings = rows("status")
+    failed_rows = [
+        row
+        for row in status_rows
+        if str((row.get("value") or {}).get("status") or "").lower()
+        in {"failed", "failure", "error", "interrupted"}
+    ]
+    failed_ids = {row["id"].rsplit(":", 1)[-1] for row in failed_rows}
+    failed_bindings = [
+        binding
+        for binding in status_bindings
+        if binding["id"].rsplit(":", 1)[-1] in failed_ids
+    ]
+    if not failed_rows:
+        failed_bindings = [resolver.members_binding(
+            {
+                "id": "failures:none",
+                "label": "失败调用数量",
+                "value": 0,
+                "relation": "persisted-output",
+            },
+            binding_id="failures:none-calculated",
+            members=[{
+                "id": f"failures:status:{event.get('id')}",
+                "label": str(event.get("event_name") or event.get("id")),
+                "value": event.get("status"),
+                "source": {
+                    "kind": "runtime-event",
+                    "label": str(event.get("event_name") or f"Event {event.get('id')}"),
+                    "ref": f"event:{event.get('id')}",
+                    "fieldPath": "status",
+                },
+                "relation": "persisted-output",
+                "completeness": "complete",
+            } for event in events],
+            reducer="no-failed-tool-status-v1",
+            role="status",
+        )]
+    return [
+        {
+            "id": "purpose",
+            "title": "读取目的",
+            "presentation": "items",
+            "business": {"items": input_rows},
+            "bindings": input_bindings,
+            "runtimeRefs": _runtime_refs(input_bindings),
+            "defaultOpen": True,
+        },
+        {
+            "id": "calls",
+            "title": "逐次工具调用",
+            "presentation": "items",
+            "business": {"items": status_rows},
+            "bindings": status_bindings,
+            "runtimeRefs": _runtime_refs(status_bindings),
+            "defaultOpen": True,
+        },
+        {
+            "id": "results",
+            "title": "实际返回的信息",
+            "presentation": "items",
+            "business": {"items": output_rows},
+            "bindings": output_bindings,
+            "runtimeRefs": _runtime_refs(output_bindings),
+            "defaultOpen": True,
+        },
+        {
+            "id": "failures",
+            "title": "失败与缺失",
+            "presentation": "notice",
+            "business": {"items": failed_rows} if failed_rows else {"value": "未发现失败或中断的工具调用。"},
+            "bindings": failed_bindings,
+            "runtimeRefs": _runtime_refs(failed_bindings),
+            "defaultOpen": bool(failed_rows),
+        },
+    ]
+
+
+def _retrieval_agent_modules(
+    card_data: dict[str, Any],
+    resolver: SourceResolver,
+    event_details: dict[int, dict[str, Any]],
+) -> list[dict[str, Any]]:
+    events = [event_details[event_id] for event_id in sorted(event_details)]
+    agent = next(
+        (event for event in events if str(event.get("event_type") or "") == "agent_invoke"),
+        events[0] if events else {},
+    )
+    agent_id = _integer(agent.get("id")) or 0
+    children = [event for event in events if _integer(event.get("id")) != agent_id]
+    queries = [
+        event
+        for event in children
+        if str(event.get("event_name") or "") in QUERY_TOOL_NAMES
+    ]
+    task_binding = resolver.binding_for_event(
+        agent_id,
+        binding_id="task:agent-input",
+        path="input.content.task",
+        role="input",
+        availability="direct-read",
+    )
+    screening_binding = resolver.binding_for_event(
+        agent_id,
+        binding_id="screening:agent-output",
+        path="output.content",
+        role="output",
+        availability="produced-by-run",
+    )
+    task = ((agent.get("input") or {}).get("content") if isinstance(agent.get("input"), dict) else None)
+    task = task.get("task") if isinstance(task, dict) else task
+    screening = ((agent.get("output") or {}).get("content") if isinstance(agent.get("output"), dict) else None)
+    query_rows: list[dict[str, Any]] = []
+    result_rows: list[dict[str, Any]] = []
+    status_rows: list[dict[str, Any]] = []
+    query_bindings: list[SourceBinding] = []
+    result_bindings: list[SourceBinding] = []
+    status_bindings: list[SourceBinding] = []
+    for index, event in enumerate(queries, 1):
+        event_id = _integer(event.get("id")) or 0
+        label = str(event.get("event_name") or f"查询 {index}")
+        input_binding = resolver.binding_for_event(
+            event_id,
+            binding_id=f"queries:event:{event_id}",
+            path="input.content",
+            role="input",
+            availability="direct-read",
+        )
+        output_binding = resolver.binding_for_event(
+            event_id,
+            binding_id=f"raw:event:{event_id}",
+            path="output.content",
+            role="output",
+            availability="returned-to-agent",
+        )
+        status_item_bindings = _event_status_bindings(
+            resolver, event_id, prefix="failures"
+        )
+        query_bindings.append(input_binding)
+        result_bindings.append(output_binding)
+        status_bindings.extend(status_item_bindings)
+        query_rows.append({
+            "id": f"queries:item:{event_id}", "label": label,
+            "value": ((event.get("input") or {}).get("content") if isinstance(event.get("input"), dict) else None),
+            "bindings": [input_binding],
+        })
+        result_rows.append({
+            "id": f"raw:item:{event_id}", "label": label,
+            "value": ((event.get("output") or {}).get("content") if isinstance(event.get("output"), dict) else None),
+            "bindings": [output_binding],
+        })
+        status_rows.append({
+            "id": f"failures:item:{event_id}", "label": label,
+            "value": {"status": event.get("status"), "error": event.get("error_message")},
+            "bindings": status_item_bindings,
+        })
+    if not queries:
+        query_bindings = [
+            _calculation_binding(
+                resolver,
+                binding_id="queries:empty",
+                label="取数 Agent 查询事件计数",
+                value=[],
+                role="process",
+            )
+        ]
+        result_bindings = [
+            _calculation_binding(
+                resolver,
+                binding_id="raw:empty",
+                label="取数 Agent 原始命中计数",
+                value=[],
+                role="output",
+            )
+        ]
+        status_bindings = [
+            _calculation_binding(
+                resolver,
+                binding_id="failures:empty",
+                label="取数 Agent 失败查询计数",
+                value=[],
+                role="status",
+            )
+        ]
+    activity_rows: list[dict[str, Any]] = []
+    activity_bindings: list[SourceBinding] = []
+    for event in events:
+        event_id = _integer(event.get("id")) or 0
+        binding = resolver.binding_for_event(
+            event_id,
+            binding_id=f"activities:event:{event_id}",
+            role="process",
+        )
+        activity_bindings.append(binding)
+        activity_rows.append(
+            {
+                "id": f"activities:item:{event_id}",
+                "label": str(event.get("event_name") or f"Event {event_id}"),
+                "value": {
+                    "eventName": event.get("event_name"),
+                    "eventType": event.get("event_type"),
+                    "status": event.get("status"),
+                },
+                "bindings": [binding],
+            }
+        )
+    return [
+        {
+            "id": "task", "title": "完整取数任务", "presentation": "text",
+            "business": {"value": task}, "bindings": [task_binding],
+            "runtimeRefs": [f"event:{agent_id}"], "defaultOpen": True,
+        },
+        {
+            "id": "queries", "title": "查询过程", "presentation": "items",
+            "business": {"items": query_rows}, "bindings": query_bindings,
+            "runtimeRefs": _runtime_refs(query_bindings), "defaultOpen": True,
+        },
+        {
+            "id": "raw", "title": "原始命中", "presentation": "items",
+            "business": {"items": result_rows}, "bindings": result_bindings,
+            "runtimeRefs": _runtime_refs(result_bindings), "defaultOpen": True,
+        },
+        {
+            "id": "screening", "title": "初筛整理", "presentation": "text",
+            "business": {"value": screening}, "bindings": [screening_binding],
+            "runtimeRefs": [f"event:{agent_id}"], "defaultOpen": True,
+        },
+        {
+            "id": "failures", "title": "失败与空结果", "presentation": "items",
+            "business": {"items": status_rows}, "bindings": status_bindings,
+            "runtimeRefs": _runtime_refs(status_bindings), "defaultOpen": False,
+        },
+        {
+            "id": "activities", "title": "运行记录", "presentation": "process",
+            "business": {"items": activity_rows}, "bindings": activity_bindings,
+            "runtimeRefs": _runtime_refs(activity_bindings), "defaultOpen": False,
+        },
+    ]
+
+
+def _calculation_binding(
+    resolver: SourceResolver,
+    *,
+    binding_id: str,
+    label: str,
+    value: Any,
+    role: str,
+) -> SourceBinding:
+    binding = resolver.binding_for_field(
+        {
+            "id": binding_id,
+            "label": label,
+            "value": value,
+            "source": {
+                "kind": "calculation",
+                "label": label,
+                "ref": f"calculation:{binding_id}",
+                "fieldPath": "value",
+            },
+            "relation": "persisted-output",
+            "completeness": "complete",
+        },
+        binding_id=binding_id,
+        role=role,
+    )
+    binding["evidence"]["confidence"] = "deterministic"
+    return binding
+
+
+def _event_status_bindings(
+    resolver: SourceResolver,
+    event_id: int,
+    *,
+    prefix: str,
+    include_output: bool = False,
+) -> list[SourceBinding]:
+    """Bind every Event value rendered by a status/diagnostic module."""
+    bindings = [
+        resolver.binding_for_event(
+            event_id,
+            binding_id=f"{prefix}:status:event:{event_id}",
+            path="status",
+            role="status",
+        ),
+    ]
+    event = resolver.event_details.get(int(event_id)) or {}
+    if "duration_ms" in event:
+        bindings.append(resolver.binding_for_event(
+            event_id,
+            binding_id=f"{prefix}:duration:event:{event_id}",
+            path="duration_ms",
+            role="status",
+        ))
+    if pointer_value(event, "/error_message") is not None or "error_message" in event:
+        bindings.append(resolver.binding_for_event(
+            event_id,
+            binding_id=f"{prefix}:error:event:{event_id}",
+            path="error_message",
+            role="status",
+        ))
+    if include_output:
+        outcome = resolver.binding_for_event(
+            event_id,
+            binding_id=f"{prefix}:outcome:event:{event_id}",
+            path="output.content",
+            role="output",
+            availability="returned-to-agent",
+        )
+        outcome["transform"] = {
+            "kind": "parsed",
+            "operation": "retrieval-outcome-v1",
+            "outputKey": "outcome",
+        }
+        outcome["evidence"]["confidence"] = "deterministic"
+        bindings.append(outcome)
+        completeness = resolver.binding_for_event(
+            event_id,
+            binding_id=f"{prefix}:completeness:event:{event_id}",
+            path="",
+            role="status",
+        )
+        completeness["transform"] = {
+            "kind": "parsed",
+            "operation": "event-body-completeness-v1",
+            "outputKey": "completeness",
+        }
+        completeness["evidence"]["confidence"] = "deterministic"
+        bindings.append(completeness)
+    return bindings
+
+
+def _business_modules(detail: dict[str, Any]) -> list[dict[str, Any]]:
+    if isinstance(detail.get("blocks"), list):
+        modules: list[dict[str, Any]] = []
+        for index, item in enumerate(detail.get("blocks") or [], 1):
+            if not isinstance(item, dict):
+                continue
+            # Preserve the complete semantic block.  Specialized business
+            # blocks carry their real payload in routes/steps/branches/rows,
+            # not in the generic value/items pair.
+            module = dict(item)
+            module["id"] = str(item.get("id") or f"block-{index}")
+            module["title"] = item.get("title") or f"业务模块 {index}"
+            if not module.get("items"):
+                module["items"] = item.get("branches") or item.get("rows")
+            modules.append(module)
+        return modules
+    return [
+        dict(item)
+        for item in detail.get("businessSections") or []
+        if isinstance(item, dict)
+    ]
+
+
+def _match_business_module(
+    modules: list[dict[str, Any]], module_id: str, title: str
+) -> dict[str, Any] | None:
+    exact = next((item for item in modules if str(item.get("id")) == module_id), None)
+    if exact:
+        return exact
+    normalized = _normalized_title(title)
+    exact_title = next(
+        (item for item in modules if _normalized_title(str(item.get("title") or "")) == normalized),
+        None,
+    )
+    if exact_title:
+        return exact_title
+    # Do not use substring similarity here. A nearby title is not proof that
+    # two independently shaped modules contain the same business value.
+    return None
+
+
+def _module_value(module: dict[str, Any] | None) -> Any:
+    if not module:
+        return None
+    if module.get("content") is not None:
+        return module.get("content")
+    if module.get("value") is not None:
+        return module.get("value")
+    block_type = str(module.get("type") or "")
+    if block_type == "implementation-plan":
+        return {
+            "mode": module.get("mode"),
+            "routes": module.get("routes") or [],
+            "reasoning": module.get("reasoning"),
+        }
+    if block_type == "creative-process":
+        return module.get("steps") or []
+    if block_type == "evaluation-branches":
+        return module.get("branches") or []
+    if module.get("rows") is not None:
+        return module.get("rows")
+    if module.get("routes") is not None:
+        return module.get("routes")
+    if module.get("steps") is not None:
+        return module.get("steps")
+    return None
+
+
+def _presentation(module: dict[str, Any] | None, title: str) -> str:
+    raw = str((module or {}).get("presentation") or (module or {}).get("type") or "")
+    if raw in {"items", "list"}:
+        return "items"
+    if any(token in title for token in ("规划", "路线", "计划")):
+        return "plan"
+    if any(token in title for token in ("过程", "处理", "调用")):
+        return "process"
+    if "评审" in title:
+        return "review"
+    if any(token in title for token in ("候选表", "主脚本", "产物")):
+        return "artifact"
+    if any(token in title for token in ("说明", "缺失", "状态", "完整性")):
+        return "notice"
+    return "text"
+
+
+def _runtime_refs(bindings: list[SourceBinding]) -> list[str]:
+    refs: list[str] = []
+    for binding in bindings:
+        source_id = str(binding.get("sourceId") or "")
+        if source_id.startswith("event:"):
+            refs.append(source_id)
+        selector = binding.get("selector") or {}
+        if selector.get("kind") == "members":
+            refs.extend(
+                str(member.get("sourceId") or "")
+                for member in selector.get("members") or []
+                if str(member.get("sourceId") or "").startswith("event:")
+            )
+    return list(dict.fromkeys(refs))
+
+
+def _historical_implementation_task_modules(
+    fields: dict[str, dict[str, Any]],
+    resolver: SourceResolver,
+) -> list[dict[str, Any]]:
+    """Keep old Branch tasks readable without copying one blob into six modules."""
+    task_field = next(
+        (field for field in fields.values() if str(field.get("label") or "") == "完整实现任务"),
+        None,
+    )
+    target_field = next(
+        (field for field in fields.values() if str(field.get("label") or "") == "实现目标"),
+        None,
+    )
+    modules: list[dict[str, Any]] = []
+    if target_field and target_field.get("value") not in (None, ""):
+        binding = resolver.binding_for_field(target_field, binding_id="scope:historical-target")
+        modules.append({
+            "id": "scope",
+            "title": "实现范围",
+            "presentation": "text",
+            "business": {"value": target_field.get("value")},
+            "bindings": [binding],
+            "runtimeRefs": [],
+            "defaultOpen": True,
+        })
+    if task_field and task_field.get("value") not in (None, ""):
+        binding = resolver.binding_for_field(task_field, binding_id="task:historical-full")
+        modules.append({
+            "id": "historical-task",
+            "title": "完整实现任务(历史回退)",
+            "presentation": "text",
+            "business": {"value": task_field.get("value")},
+            "bindings": [binding],
+            "runtimeRefs": [],
+            "defaultOpen": True,
+        })
+    if modules:
+        return modules
+    binding = unresolved_binding(
+        "historical-task:missing",
+        reason="该历史方案没有保存实现 Agent 输入,也没有可用的 Branch 实现任务字段",
+        code="source-record-not-saved",
+    )
+    resolver.add_unresolved_source(binding)
+    return [{
+        "id": "historical-task",
+        "title": "实现任务记录缺失",
+        "presentation": "notice",
+        "business": {"value": "该历史方案未保存可恢复的完整实现任务。"},
+        "bindings": [binding],
+        "runtimeRefs": [],
+        "defaultOpen": True,
+    }]
+
+
+def _multipath_comparison_binding(
+    item: dict[str, Any],
+    item_index: int,
+    module_bindings: list[SourceBinding],
+    resolver: SourceResolver,
+    bundle: dict[str, Any],
+) -> SourceBinding:
+    """Bind one comparison row to its review, Branch status and final decision."""
+    text = str(item.get("value") or item.get("label") or "")
+    match = re.search(r"方案\s*(\d+)", text)
+    branch_id = int(match.group(1)) if match else None
+    decision_id = next(
+        (
+            _suffix_int(str(binding.get("sourceId") or ""))
+            for binding in module_bindings
+            if "script_build_multipath_decision" in str(binding.get("sourceId") or "")
+        ),
+        None,
+    )
+    members: list[dict[str, Any]] = []
+    if branch_id is not None:
+        branch = next(
+            (row for row in bundle.get("branches") or [] if _integer(row.get("branch_id")) == branch_id),
+            None,
+        )
+        if branch is not None:
+            members.append({
+                "id": f"comparison:{item_index}:branch",
+                "label": f"方案 {branch_id} 处置状态",
+                "value": branch.get("status"),
+                "source": {
+                    "kind": "database",
+                    "label": "script_build_branch",
+                    "ref": f"round:{branch.get('round_index')}:branch:{branch_id}:output",
+                    "fieldPath": "status",
+                },
+                "relation": "persisted-output",
+            })
+    if decision_id is not None:
+        decision = next(
+            (row for row in bundle.get("multipathDecisions") or [] if _integer(row.get("id")) == decision_id),
+            None,
+        )
+        if decision is not None:
+            members.append({
+                "id": f"comparison:{item_index}:decision",
+                "label": "主 Agent 最终决定",
+                "value": decision.get("decision"),
+                "source": {
+                    "kind": "database",
+                    "label": "script_build_multipath_decision",
+                    "ref": f"multipath-decision:{decision_id}",
+                    "fieldPath": "decision",
+                },
+                "relation": "persisted-output",
+            })
+    for binding in module_bindings:
+        source_id = str(binding.get("sourceId") or "")
+        event_id = _suffix_int(source_id) if source_id.startswith("event:") else None
+        if event_id is None:
+            continue
+        event = resolver.event_details.get(event_id)
+        if event is None:
+            continue
+        members.append({
+            "id": f"comparison:{item_index}:review:{event_id}",
+            "label": "多方案评审",
+            "value": (event.get("output") or {}).get("content") if isinstance(event.get("output"), dict) else event.get("output"),
+            "source": {
+                "kind": "runtime-event",
+                "label": str(event.get("event_name") or f"Event {event_id}"),
+                "ref": f"event:{event_id}",
+                "fieldPath": "output.content",
+            },
+            "relation": "available-upstream",
+        })
+    return resolver.members_binding(
+        {
+            "id": f"comparison:item:{item_index}",
+            "label": str(item.get("label") or f"方案对照 {item_index}"),
+            "value": item.get("value"),
+        },
+        binding_id=f"comparison:item:{item_index}",
+        members=members,
+        reducer="multipath-review-and-decision-row-v1",
+        role="basis",
+    )
+
+
+def _align_transforms_with_business(
+    value: Any,
+    bindings: list[SourceBinding],
+    resolver: SourceResolver,
+    module_id: str,
+) -> None:
+    """Make the transform describe what the first column actually renders."""
+    if value is None or not bindings:
+        return
+    if len(bindings) > 1:
+        for binding in bindings:
+            if binding.get("transform", {}).get("kind") == "direct":
+                binding["transform"] = {
+                    "kind": "combined",
+                    "operation": f"business-module:{module_id}",
+                }
+                binding["evidence"]["confidence"] = "deterministic"
+        return
+    binding = bindings[0]
+    if binding.get("transform", {}).get("kind") != "direct":
+        return
+    source = resolver.sources.get(str(binding.get("sourceId") or "")) or {}
+    selector = binding.get("selector") or {}
+    if selector.get("kind") == "json-pointer":
+        selected = pointer_value(source.get("rawRecord"), str(selector.get("path") or ""))
+    elif selector.get("kind") == "whole-record":
+        selected = source.get("rawRecord")
+    else:
+        return
+    if not _same_value(value, selected):
+        binding["transform"] = {
+            "kind": "parsed",
+            "operation": "business-module-projection-v1",
+            "outputKey": module_id,
+        }
+        binding["evidence"]["confidence"] = "deterministic"
+
+
+def _normalize_items(items: list[Any]) -> list[dict[str, Any]]:
+    values: list[dict[str, Any]] = []
+    for index, item in enumerate(items, 1):
+        if isinstance(item, dict):
+            label = (
+                item.get("label")
+                or item.get("title")
+                or item.get("subject")
+                or item.get("summary")
+                or f"第 {index} 项"
+            )
+            value = item.get("value") if "value" in item else item
+            values.append({"label": label, "value": value})
+        else:
+            values.append({"label": f"第 {index} 项", "value": item})
+    return values
+
+
+def _item_bindings(
+    module_id: str,
+    item_index: int,
+    item: dict[str, Any],
+    item_count: int,
+    module_bindings: list[SourceBinding],
+    resolver: SourceResolver,
+) -> list[SourceBinding]:
+    if not module_bindings:
+        binding = unresolved_binding(
+            f"{module_id}:item:{item_index}:unresolved",
+            reason=f"子项「{item.get('label')}」没有可定位的来源绑定",
+            code="binding-map-missing",
+        )
+        resolver.add_unresolved_source(binding)
+        return [binding]
+
+    if len(module_bindings) == item_count and item_index <= len(module_bindings):
+        binding = deepcopy(module_bindings[item_index - 1])
+        binding["id"] = f"{module_id}:item:{item_index}"
+        source = resolver.sources.get(str(binding.get("sourceId") or "")) or {}
+        selector = binding.get("selector") or {}
+        selected = pointer_value(source.get("rawRecord"), str(selector.get("path") or ""))
+        if not _same_value(item.get("value"), selected):
+            binding["transform"] = {
+                "kind": "parsed",
+                "operation": "business-item-v1",
+                "outputKey": str(item_index),
+            }
+            binding["evidence"]["confidence"] = "deterministic"
+        return [binding]
+
+    parent = module_bindings[0]
+    selector = parent.get("selector") or {}
+    source_id = str(parent.get("sourceId") or "")
+    source_record = resolver.sources.get(source_id) or {}
+    selected_parent = pointer_value(
+        source_record.get("rawRecord"), str(selector.get("path") or "")
+    )
+    if selector.get("kind") == "json-pointer" and isinstance(selected_parent, list):
+        binding = deepcopy(parent)
+        binding["id"] = f"{module_id}:item:{item_index}"
+        binding["selector"] = {
+            "kind": "json-pointer",
+            "path": f"{str(selector.get('path') or '').rstrip('/')}/{item_index - 1}",
+        }
+        return [binding]
+
+    event_id = _suffix_int(source_id) if source_id.startswith("event:") else None
+    if event_id:
+        role = str(parent.get("role") or "basis")
+        path = str(selector.get("path") or "")
+        if not path:
+            path = "input.content.task" if role in {"input", "constraint", "basis"} else "output.content.summary"
+        return [
+            resolver.text_span_binding(
+                event_id,
+                binding_id=f"{module_id}:item:{item_index}",
+                path=path.lstrip("/").replace("/", "."),
+                exact_text=_item_text(item.get("value")),
+                role=role,
+            )
+        ]
+
+    binding = unresolved_binding(
+        f"{module_id}:item:{item_index}:unresolved",
+        reason=f"子项「{item.get('label')}」是结构化重组结果,无法安全定位到单一原始区间",
+        source_id=source_id or None,
+        resolution="unsafe",
+        code="unsafe-association",
+    )
+    if source_id and source_id in resolver.sources:
+        return [binding]
+    resolver.add_unresolved_source(binding)
+    return [binding]
+
+
+def _item_text(value: Any) -> str:
+    if isinstance(value, str):
+        return value
+    if not isinstance(value, dict):
+        return str(value or "")
+    for key in ("summary", "conclusion", "subject", "title", "evidence"):
+        if value.get(key):
+            return str(value[key])
+    return ""
+
+
+def _leaf_texts(value: Any) -> list[str]:
+    """Return every visible textual leaf so a structured review item is fully bound."""
+    result: list[str] = []
+    if isinstance(value, str):
+        cleaned = value.strip()
+        if cleaned:
+            result.append(cleaned)
+    elif isinstance(value, dict):
+        for child in value.values():
+            result.extend(_leaf_texts(child))
+    elif isinstance(value, list):
+        for child in value:
+            result.extend(_leaf_texts(child))
+    elif value is not None:
+        result.append(str(value))
+    return list(dict.fromkeys(result))
+
+
+def _same_value(left: Any, right: Any) -> bool:
+    try:
+        import json
+        return json.dumps(left, ensure_ascii=False, sort_keys=True, default=str) == json.dumps(
+            right, ensure_ascii=False, sort_keys=True, default=str
+        )
+    except (TypeError, ValueError):
+        return str(left) == str(right)
+
+
+def _unique_bindings(bindings: list[SourceBinding]) -> list[SourceBinding]:
+    result: list[SourceBinding] = []
+    seen: set[str] = set()
+    for binding in bindings:
+        key = str(binding.get("id") or "")
+        if key in seen:
+            continue
+        seen.add(key)
+        result.append(binding)
+    return result
+
+
+def _all_bindings(modules: list[dict[str, Any]]) -> list[SourceBinding]:
+    result: list[SourceBinding] = []
+    for module in modules:
+        result.extend(module.get("bindings") or [])
+        business = module.get("business") or {}
+        for item in business.get("items") or []:
+            if isinstance(item, dict):
+                result.extend(item.get("bindings") or [])
+    return result
+
+
+def _used_source_ids(modules: list[dict[str, Any]]) -> set[str]:
+    used: set[str] = set()
+
+    def add_selector_members(selector: dict[str, Any]) -> None:
+        if selector.get("kind") != "members":
+            return
+        for member in selector.get("members") or []:
+            source_id = str(member.get("sourceId") or "")
+            if source_id:
+                used.add(source_id)
+            child = member.get("selector")
+            if isinstance(child, dict):
+                add_selector_members(child)
+
+    for module in modules:
+        for binding in module.get("bindings") or []:
+            source_id = str(binding.get("sourceId") or "")
+            if source_id:
+                used.add(source_id)
+            selector = binding.get("selector")
+            if isinstance(selector, dict):
+                add_selector_members(selector)
+        for source_id in module.get("runtimeRefs") or []:
+            if isinstance(source_id, str) and source_id:
+                used.add(source_id)
+    return used
+
+
+def _aggregate_binding(
+    card_kind: str,
+    module_id: str,
+    source_ids: list[str],
+    fields: dict[str, dict[str, Any]],
+    resolver: SourceResolver,
+    bundle: dict[str, Any],
+) -> SourceBinding | None:
+    if not source_ids:
+        return None
+    target = next((fields[source_id] for source_id in source_ids if source_id in fields), None)
+    if target is None:
+        return None
+    eligible = (
+        (card_kind == "round-result" and module_id in {"completion", "dispositions", "domain", "changes"})
+        or (card_kind == "round-summary" and module_id == "plan")
+        or (card_kind == "final-result" and module_id in {"stats", "scale"})
+        or (card_kind == "domain-facts" and module_id in {"facts", "verification", "sources", "usage"})
+        or (card_kind == "retrieval-stage" and module_id in {"mode", "waves", "direct", "agents", "result"})
+    )
+    if not eligible:
+        return None
+    members: list[dict[str, Any]] = []
+    reducer = str((target.get("source") or {}).get("label") or "确定性聚合")
+    if card_kind == "round-summary" and module_id == "plan":
+        round_index = _round_index_from_field_id(str(target.get("id") or ""))
+        row = next(
+            (
+                item
+                for item in bundle.get("rounds") or []
+                if _integer(item.get("round_index")) == round_index
+            ),
+            None,
+        )
+        if row:
+            for path in ("race_or_divide", "multipath_plan", "plan_note"):
+                members.append(
+                    _member_field(
+                        "script_build_round",
+                        f"round:{round_index}",
+                        row,
+                        path,
+                    )
+                )
+    elif card_kind == "retrieval-stage":
+        value = target.get("value")
+        event_ids: list[int] = []
+
+        def collect_event_ids(node: Any) -> None:
+            if isinstance(node, dict):
+                event_id = _integer(node.get("eventId"))
+                if event_id:
+                    event_ids.append(event_id)
+                for child in node.values():
+                    collect_event_ids(child)
+            elif isinstance(node, list):
+                for child in node:
+                    collect_event_ids(child)
+
+        collect_event_ids(value)
+        # Mode/wave/result are deterministic summaries of the whole scoped
+        # retrieval stage, so their members are every Event loaded for this
+        # card. Direct/Agent modules only retain the Events named by their
+        # own structured projection.
+        if module_id in {"mode", "waves", "result"}:
+            event_ids.extend(resolver.event_details)
+        for event_id in dict.fromkeys(event_ids):
+            event = resolver.event_details.get(event_id)
+            if not event:
+                continue
+            members.append(
+                {
+                    "id": f"aggregate:retrieval:{module_id}:event:{event_id}",
+                    "label": str(event.get("event_name") or f"Event {event_id}"),
+                    "value": event,
+                    "source": {
+                        "kind": "runtime-event",
+                        "label": str(event.get("event_name") or f"Event {event_id}"),
+                        "ref": f"event:{event_id}",
+                        "fieldPath": "",
+                    },
+                    "relation": "persisted-output",
+                    "completeness": "complete",
+                }
+            )
+    elif card_kind == "round-result":
+        round_index = _round_index_from_field_id(str(target.get("id") or ""))
+        if module_id in {"dispositions", "changes", "completion"}:
+            for row in bundle.get("branches") or []:
+                if _integer(row.get("round_index")) != round_index:
+                    continue
+                branch_id = _integer(row.get("branch_id"))
+                path = "status" if module_id == "dispositions" else "candidate_snapshot"
+                members.append(
+                    {
+                        "id": f"aggregate:{module_id}:branch:{branch_id}",
+                        "label": f"方案 {branch_id}",
+                        "value": row.get(path),
+                        "source": {
+                            "kind": "database",
+                            "label": "script_build_branch",
+                            "ref": f"round:{round_index}:branch:{branch_id}:output",
+                            "fieldPath": path,
+                        },
+                        "relation": "persisted-output",
+                        "completeness": "complete",
+                    }
+                )
+        elif module_id == "domain":
+            for row in bundle.get("domainInfo") or []:
+                if _integer(row.get("round_index")) != round_index:
+                    continue
+                members.append(
+                    {
+                        "id": f"aggregate:domain:{row.get('id')}",
+                        "label": "领域事实",
+                        "value": row,
+                        "source": {
+                            "kind": "database",
+                            "label": "script_build_domain_info",
+                            "ref": f"domain-info:{row.get('id')}",
+                            "fieldPath": "",
+                        },
+                        "relation": "persisted-output",
+                        "completeness": "complete",
+                    }
+                )
+    elif card_kind == "final-result" and module_id == "stats":
+        for row in bundle.get("rounds") or []:
+            members.append(_member_field("script_build_round", f"round:{row.get('round_index')}:result", row, "status"))
+        for row in bundle.get("branches") or []:
+            members.append(_member_field("script_build_branch", f"round:{row.get('round_index')}:branch:{row.get('branch_id')}:output", row, "status"))
+    elif card_kind == "final-result" and module_id == "scale":
+        artifact = bundle.get("currentArtifact") or {}
+        for collection in ("paragraphs", "elements", "paragraphElements"):
+            members.append(
+                {
+                    "id": f"aggregate:artifact:{collection}",
+                    "label": collection,
+                    "value": artifact.get(collection) or [],
+                    "source": {
+                        "kind": "artifact",
+                        "label": "当前主脚本",
+                        "ref": "artifact:base:current",
+                        "fieldPath": collection,
+                    },
+                    "relation": "persisted-output",
+                    "completeness": "complete",
+                }
+            )
+    elif card_kind == "domain-facts":
+        match = re.search(r"branch:(\d+)", str(target.get("id") or ""))
+        branch_id = _integer(match.group(1)) if match else None
+        path = {
+            "facts": "content",
+            "verification": "note",
+            "sources": "source",
+            "usage": "content",
+        }.get(module_id, "content")
+        for row in bundle.get("domainInfo") or []:
+            if _integer(row.get("branch_id")) != branch_id:
+                continue
+            members.append(
+                {
+                    "id": f"aggregate:domain:{row.get('id')}:{path}",
+                    "label": f"领域事实 {row.get('id')}",
+                    "value": row.get(path),
+                    "source": {
+                        "kind": "database",
+                        "label": "script_build_domain_info",
+                        "ref": f"domain-info:{row.get('id')}",
+                        "fieldPath": path,
+                    },
+                    "relation": "persisted-output",
+                    "completeness": "complete" if row.get(path) not in (None, "", [], {}) else "partial",
+                }
+            )
+    if not members and card_kind == "domain-facts":
+        match = re.search(r"branch:(\d+)", str(target.get("id") or ""))
+        branch_id = _integer(match.group(1)) if match else None
+        return resolver.empty_database_result_binding(
+            binding_id=f"{module_id}:empty-result",
+            table="script_build_domain_info",
+            filters={"branch_id": branch_id},
+            label="领域事实查询结果",
+        )
+    if not members:
+        return None
+    return resolver.members_binding(
+        target,
+        binding_id=f"{module_id}:aggregate",
+        members=members,
+        reducer=reducer,
+    )
+
+
+def _member_field(table: str, ref: str, row: dict[str, Any], path: str) -> dict[str, Any]:
+    return {
+        "id": f"aggregate:{table}:{row.get('id')}:{path}",
+        "label": f"{table}.{path}",
+        "value": row.get(path),
+        "source": {
+            "kind": "database",
+            "label": table,
+            "ref": ref,
+            "fieldPath": path,
+        },
+        "relation": "persisted-output",
+        "completeness": "complete",
+    }
+
+
+def _round_index_from_field_id(value: str) -> int | None:
+    match = re.search(r"round:(\d+)", value)
+    return _integer(match.group(1)) if match else None
+
+
+def _business_notices(detail: dict[str, Any]) -> list[dict[str, Any]]:
+    notices: list[dict[str, Any]] = []
+    for item in detail.get("notices") or []:
+        if isinstance(item, dict) and item.get("message"):
+            notices.append(item)
+    if detail.get("missingActivityNotice"):
+        notices.append({"level": "warning", "message": detail["missingActivityNotice"]})
+    return notices
+
+
+def _unique_notices(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    result: list[dict[str, Any]] = []
+    seen: set[str] = set()
+    for item in items:
+        message = str(item.get("message") or "")
+        if not message or message in seen:
+            continue
+        seen.add(message)
+        result.append({"level": item.get("level") or "info", "message": message})
+    return result
+
+
+def _fallback_card_data(detail_ref: str, business: dict[str, Any]) -> dict[str, Any]:
+    modules = _business_modules(business)
+    return {
+        "cardKind": _fallback_card_kind(detail_ref),
+        "completeness": "partial",
+        "businessInputs": [],
+        "businessOutputs": [],
+        "runtime": {"summary": "", "units": []},
+        "displayUse": {
+            "businessModules": [
+                {"id": item.get("id") or f"module-{index}", "title": item.get("title") or f"业务模块 {index}", "sourceIds": []}
+                for index, item in enumerate(modules, 1)
+            ],
+            "cardFields": [],
+            "unusedSourceIds": [],
+        },
+        "notices": [
+            {
+                "level": "warning",
+                "message": "该历史卡片没有完整结构化来源投影,已保留业务详情并显式标记断链。",
+            }
+        ],
+    }
+
+
+def _fallback_card_kind(detail_ref: str) -> str:
+    if detail_ref.startswith("event:"):
+        return "runtime-event"
+    if detail_ref.startswith("round:"):
+        return detail_ref.rsplit(":", 1)[-1]
+    return detail_ref.split(":", 1)[0] or "unknown"
+
+
+def _event_text_path(event: dict[str, Any], side: str) -> str:
+    wrapped = event.get(side)
+    content = wrapped.get("content") if isinstance(wrapped, dict) else None
+    if isinstance(content, dict):
+        preferred = "task" if side == "input" else "summary"
+        if isinstance(content.get(preferred), str):
+            return f"{side}.content.{preferred}"
+    return f"{side}.content"
+
+
+def _normalized_title(value: str) -> str:
+    return re.sub(r"[\s/\u3001,,()()]+", "", value).replace("完整", "")
+
+
+def _collect_event_ids(value: Any, ids: set[int], key: str = "") -> None:
+    if isinstance(value, dict):
+        for child_key, child in value.items():
+            lowered = str(child_key).lower()
+            if lowered in {
+                "eventid",
+                "event_id",
+                "implementereventid",
+                "scopeeventid",
+                "parenteventid",
+            }:
+                event_id = _integer(child)
+                if event_id:
+                    ids.add(event_id)
+            elif lowered in {"eventref", "currentrevisionref", "detailref"}:
+                event_id = _suffix_int(str(child))
+                if event_id:
+                    ids.add(event_id)
+            elif lowered in {"eventrefs", "technicalrefs", "revisionrefs"}:
+                for ref in child or []:
+                    event_id = _suffix_int(str(ref))
+                    if event_id:
+                        ids.add(event_id)
+            _collect_event_ids(child, ids, lowered)
+    elif isinstance(value, list):
+        for child in value:
+            _collect_event_ids(child, ids, key)
+
+
+def _view_branch(view: dict[str, Any], round_index: int, branch_id: int) -> dict[str, Any]:
+    round_ = next(
+        (item for item in view.get("rounds") or [] if _integer(item.get("roundIndex")) == round_index),
+        {},
+    )
+    return next(
+        (item for item in round_.get("branches") or [] if _integer(item.get("branchId")) == branch_id),
+        {},
+    )
+
+
+def _bundle_event(bundle: dict[str, Any], event_id: int | None) -> dict[str, Any] | None:
+    return next(
+        (item for item in bundle.get("events") or [] if _integer(item.get("id")) == event_id),
+        None,
+    )
+
+
+def _integer(value: Any) -> int | None:
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _suffix_int(value: str) -> int | None:
+    return _integer(str(value or "").rsplit(":", 1)[-1])

+ 774 - 0
visualization-old/backend/app/source_lineage/resolver.py

@@ -0,0 +1,774 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from typing import Any
+
+from .schema import EvidenceSpec, SourceBinding, SourceRecord, unresolved_binding
+
+
+_INDEX = re.compile(r"\[([0-9]+)\]")
+
+
+def json_pointer(path: str | None) -> str:
+    """Convert the visualization's legacy dotted paths to RFC 6901 pointers."""
+    value = str(path or "").strip()
+    if not value:
+        return ""
+    if " / " in value:
+        return ""
+    value = _INDEX.sub(r".\1", value)
+    parts = [part for part in value.split(".") if part]
+    return "/" + "/".join(
+        part.replace("~", "~0").replace("/", "~1") for part in parts
+    )
+
+
+def pointer_lookup(value: Any, pointer: str) -> tuple[bool, Any]:
+    if not pointer:
+        return True, value
+    current = value
+    for raw in pointer.lstrip("/").split("/"):
+        part = raw.replace("~1", "/").replace("~0", "~")
+        if isinstance(current, list):
+            try:
+                current = current[int(part)]
+            except (ValueError, IndexError):
+                return False, None
+        elif isinstance(current, dict):
+            if part not in current:
+                return False, None
+            current = current[part]
+        else:
+            return False, None
+    return True, current
+
+
+def pointer_value(value: Any, pointer: str) -> Any:
+    return pointer_lookup(value, pointer)[1]
+
+
+def source_digest(value: Any) -> str:
+    serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
+    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+
+
+class SourceResolver:
+    """Maps semantic CardData fields to deduplicated physical source records.
+
+    It deliberately does not infer adoption from matching text.  The only
+    evidence inputs are the field's declared relation and physical origin.
+    """
+
+    def __init__(
+        self,
+        *,
+        bundle: dict[str, Any],
+        event_details: dict[int, dict[str, Any]],
+    ) -> None:
+        self.bundle = bundle
+        self.event_details = event_details
+        self.sources: dict[str, SourceRecord] = {}
+        self._span_offsets: dict[tuple[int, str, str], int] = {}
+
+    def binding_for_field(
+        self,
+        field: dict[str, Any],
+        *,
+        binding_id: str,
+        role: str | None = None,
+    ) -> SourceBinding:
+        source = field.get("source") if isinstance(field.get("source"), dict) else {}
+        source_kind = str(source.get("kind") or "")
+        source_ref = str(source.get("ref") or "")
+        raw, physical = self._raw_source(source_kind, source_ref, source)
+        if source_kind == "calculation":
+            raw = {
+                "value": field.get("value"),
+                "calculation": source.get("label"),
+            }
+        source_id = self._source_id(source_kind, source_ref, source, physical)
+        field_path = str(source.get("fieldPath") or "")
+        pointer = json_pointer(field_path)
+        resolution = "resolved"
+
+        if raw is None:
+            missing = self._ensure_missing_source(
+                source_id,
+                source,
+                reason=f"未找到 {source.get('label') or source_ref or '原始记录'}",
+            )
+            return unresolved_binding(
+                binding_id,
+                reason=missing["locator"]["reason"],
+                role=role or self._role(field),
+                source_id=source_id,
+                code="source-record-not-saved",
+            )
+
+        selector: dict[str, Any]
+        found, selected = pointer_lookup(raw, pointer) if pointer else (True, raw)
+        if source_kind == "calculation":
+            selector = {"kind": "json-pointer", "path": "/value"}
+            selected = field.get("value")
+        elif source_kind == "artifact" and field_path in {"snapshotRef", "recordCounts", "historicalVersion"}:
+            selector = {"kind": "whole-record"}
+            selected = field.get("value")
+        elif pointer and found:
+            selector = {"kind": "json-pointer", "path": pointer}
+        elif pointer:
+            # The legacy CardData path may refer to a derived projection rather
+            # than a persisted record.  Keep the source visible, but do not
+            # pretend that the pointer resolves against it.
+            selector = {
+                "kind": "unresolved",
+                "reason": f"原始记录中无法安全定位字段 {field_path}",
+                "code": "field-path-mismatch",
+            }
+            selected = field.get("value")
+            resolution = "partial"
+        else:
+            selector = {"kind": "whole-record"}
+            selected = field.get("value")
+
+        record = self._ensure_source(source_id, source_kind, source, raw, physical)
+        record.setdefault("selectedValues", []).append(
+            {
+                "bindingId": binding_id,
+                "fieldId": field.get("id"),
+                "label": field.get("label"),
+                "path": pointer or field_path or None,
+                "value": selected,
+            }
+        )
+        transform = self._transform(source_kind, source)
+        if (
+            transform.get("kind") == "direct"
+            and found
+            and not _equivalent_value(field.get("value"), selected)
+        ):
+            transform = {
+                "kind": "parsed",
+                "operation": "business-projection-v1",
+                "outputKey": str(field.get("id") or binding_id),
+            }
+        binding: SourceBinding = {
+            "id": binding_id,
+            "sourceId": source_id,
+            "role": role or self._role(field),  # type: ignore[typeddict-item]
+            "selector": selector,
+            "transform": transform,
+            "evidence": self._evidence(field, source_kind),
+            "resolution": resolution,  # type: ignore[typeddict-item]
+        }
+        if transform.get("kind") == "parsed":
+            binding["evidence"]["confidence"] = "deterministic"
+        if binding["resolution"] != "resolved" and transform.get("kind") == "direct":
+            binding["transform"] = {
+                "kind": "parsed",
+                "operation": "unresolved-source-projection-v1",
+                "outputKey": str(field.get("id") or binding_id),
+            }
+            binding["evidence"]["confidence"] = "unconfirmed"
+        if "未安全关联" in str(source.get("label") or ""):
+            binding["resolution"] = "unsafe"
+            binding["evidence"]["confidence"] = "unconfirmed"
+        return binding
+
+    def binding_for_event(
+        self,
+        event_id: int,
+        *,
+        binding_id: str,
+        path: str = "",
+        role: str = "process",
+        availability: str = "produced-by-run",
+    ) -> SourceBinding:
+        event = self.event_details.get(int(event_id))
+        source_id = f"event:{event_id}"
+        if event is None:
+            self._ensure_missing_source(
+                source_id,
+                {"label": f"Event {event_id}", "ref": source_id},
+                reason=f"Event {event_id} 或 Event Body 未保存",
+            )
+            return unresolved_binding(
+                binding_id,
+                reason=f"Event {event_id} 或 Event Body 未保存",
+                role=role,
+                source_id=source_id,
+                code="event-body-not-saved",
+            )
+        source = {
+            "kind": "runtime-event",
+            "label": str(event.get("event_name") or f"Event {event_id}"),
+            "ref": source_id,
+            "fieldPath": path,
+        }
+        pointer = json_pointer(path)
+        found, selected = pointer_lookup(event, pointer) if pointer else (True, event)
+        resolution = "resolved" if found else "partial"
+        if not pointer:
+            selector = {"kind": "whole-record"}
+        elif found:
+            selector = {"kind": "json-pointer", "path": pointer}
+        else:
+            selector = {
+                "kind": "unresolved",
+                "reason": f"Event {event_id} 中不存在字段 {path}",
+                "code": "field-path-mismatch",
+            }
+        record = self._ensure_source(source_id, "runtime-event", source, event, event)
+        record.setdefault("selectedValues", []).append(
+            {"bindingId": binding_id, "fieldId": binding_id, "label": source["label"], "path": pointer or None, "value": selected}
+        )
+        return {
+            "id": binding_id,
+            "sourceId": source_id,
+            "role": role,  # type: ignore[typeddict-item]
+            "selector": selector,
+            "transform": {"kind": "direct"},
+            "evidence": {
+                "availability": availability,
+                "adoption": "not-applicable" if availability == "produced-by-run" else "not-recorded",
+                "confidence": "exact",
+            },  # type: ignore[typeddict-item]
+            "resolution": resolution,  # type: ignore[typeddict-item]
+        }
+
+    def text_span_binding(
+        self,
+        event_id: int,
+        *,
+        binding_id: str,
+        path: str,
+        exact_text: Any,
+        role: str,
+    ) -> SourceBinding:
+        event = self.event_details.get(int(event_id))
+        source_id = f"event:{event_id}"
+        if event is None:
+            self._ensure_missing_source(
+                source_id,
+                {"label": f"Event {event_id}", "ref": source_id},
+                reason=f"Event {event_id} 或 Event Body 未保存",
+            )
+            return unresolved_binding(
+                binding_id,
+                reason=f"Event {event_id} 或 Event Body 未保存",
+                role=role,
+                source_id=source_id,
+                code="event-body-not-saved",
+            )
+        pointer = json_pointer(path)
+        raw_text = pointer_value(event, pointer)
+        needle = str(exact_text or "")
+        if not isinstance(raw_text, str) or not needle:
+            binding = self.binding_for_event(
+                event_id,
+                binding_id=binding_id,
+                path=path,
+                role=role,
+                availability="returned-to-agent" if role == "output" else "direct-read",
+            )
+            binding["transform"] = {
+                "kind": "parsed",
+                "operation": "structured-text-section-v1",
+                "outputKey": binding_id,
+            }
+            binding["evidence"]["confidence"] = "deterministic"
+            return binding
+        span_key = (int(event_id), pointer, needle)
+        search_from = self._span_offsets.get(span_key, 0)
+        start = raw_text.find(needle, search_from)
+        if start < 0 and search_from:
+            start = raw_text.find(needle)
+        if start < 0:
+            binding = self.binding_for_event(
+                event_id,
+                binding_id=binding_id,
+                path=path,
+                role=role,
+                availability="returned-to-agent" if role == "output" else "direct-read",
+            )
+            binding["transform"] = {
+                "kind": "parsed",
+                "operation": "normalized-text-section-v1",
+                "outputKey": binding_id,
+            }
+            binding["evidence"]["confidence"] = "deterministic"
+            return binding
+        record = self._ensure_source(
+            source_id,
+            "runtime-event",
+            {"label": str(event.get("event_name") or f"Event {event_id}"), "ref": source_id},
+            event,
+            event,
+        )
+        record.setdefault("selectedValues", []).append(
+            {"bindingId": binding_id, "fieldId": binding_id, "label": binding_id, "path": pointer, "value": needle}
+        )
+        self._span_offsets[span_key] = start + len(needle)
+        return {
+            "id": binding_id,
+            "sourceId": source_id,
+            "role": role,  # type: ignore[typeddict-item]
+            "selector": {
+                "kind": "text-span",
+                "path": pointer,
+                "startCodePoint": start,
+                "endCodePoint": start + len(needle),
+                "exactText": needle,
+                "sourceDigest": source_digest(raw_text),
+            },
+            "transform": {"kind": "direct"},
+            "evidence": {
+                "availability": "returned-to-agent" if role == "output" else "direct-read",
+                "adoption": "not-recorded",
+                "confidence": "exact",
+            },
+            "resolution": "resolved",
+        }
+
+    def add_unresolved_source(self, binding: SourceBinding) -> None:
+        source_id = binding["sourceId"]
+        reason = str(binding.get("selector", {}).get("reason") or "原始记录无法定位")
+        self._ensure_missing_source(
+            source_id,
+            {"label": "记录缺失"},
+            reason=reason,
+        )
+
+    def add_log_anchor(
+        self, event_id: int, module: dict[str, Any], *, event_msg_id: str
+    ) -> str:
+        source_id = f"log:event:{event_id}"
+        self.sources[source_id] = {
+            "id": source_id,
+            "kind": "log-anchor",
+            "label": f"锚定日志模块 · Event {event_id}",
+            "locator": {
+                "table": "script_build_log",
+                "eventId": event_id,
+                "msgId": event_msg_id,
+                "logAnchor": module.get("anchor"),
+            },
+            "completeness": "complete",
+            "resultState": "present",
+            "truncated": False,
+            "selectedValues": [],
+            "rawRecord": module.get("content"),
+        }
+        return source_id
+
+    def empty_database_result_binding(
+        self,
+        *,
+        binding_id: str,
+        table: str,
+        filters: dict[str, Any],
+        label: str,
+        role: str = "output",
+    ) -> SourceBinding:
+        """Represent a successful zero-row lookup without calling it missing."""
+        filter_key = ",".join(f"{key}={filters[key]}" for key in sorted(filters))
+        source_id = f"db-result:{table}:{filter_key or 'all'}"
+        self.sources[source_id] = {
+            "id": source_id,
+            "kind": "database",
+            "label": label,
+            "locator": {
+                "table": table,
+                "filters": filters,
+                "resultCount": 0,
+            },
+            "completeness": "complete",
+            "resultState": "empty",
+            "truncated": False,
+            "selectedValues": [{
+                "bindingId": binding_id,
+                "fieldId": binding_id,
+                "label": "查询结果",
+                "path": "",
+                "value": [],
+            }],
+            "rawRecord": [],
+        }
+        return {
+            "id": binding_id,
+            "sourceId": source_id,
+            "role": role,  # type: ignore[typeddict-item]
+            "selector": {"kind": "whole-record"},
+            "transform": {
+                "kind": "calculated",
+                "operation": "数据库查询结果计数为 0",
+            },
+            "evidence": {
+                "availability": "visualization-derived",
+                "adoption": "not-applicable",
+                "confidence": "deterministic",
+            },
+            "resolution": "resolved",
+        }
+
+    def members_binding(
+        self,
+        field: dict[str, Any],
+        *,
+        binding_id: str,
+        members: list[dict[str, Any]],
+        reducer: str,
+        role: str = "output",
+    ) -> SourceBinding:
+        """Create a deterministic aggregate while retaining every member ref."""
+        base_field = {
+            **field,
+            "source": {
+                "kind": "calculation",
+                "label": reducer,
+                "ref": f"calculation:{binding_id}",
+                "fieldPath": "value",
+            },
+            "relation": "persisted-output",
+        }
+        base = self.binding_for_field(base_field, binding_id=binding_id, role=role)
+        member_selectors: list[dict[str, Any]] = []
+        member_resolutions: list[str] = []
+        for index, member in enumerate(members, 1):
+            member_binding = self.binding_for_field(
+                member,
+                binding_id=f"{binding_id}:member:{index}",
+                role=role,
+            )
+            member_selectors.append(
+                {
+                    "sourceId": member_binding["sourceId"],
+                    "selector": member_binding["selector"],
+                }
+            )
+            member_resolutions.append(str(member_binding.get("resolution") or "missing"))
+            member_source = self.sources.get(member_binding["sourceId"])
+            if member_source and member_source.get("selectedValues"):
+                member_source["selectedValues"][-1]["aggregateBindingId"] = binding_id
+        if not member_selectors:
+            base["selector"] = {
+                "kind": "unresolved",
+                "reason": "确定性聚合没有找到可定位的参与记录",
+                "code": "binding-map-missing",
+            }
+            base["resolution"] = "missing"
+            base["evidence"]["confidence"] = "unconfirmed"
+            return base
+        base["selector"] = {
+            "kind": "members",
+            "members": member_selectors,
+            "reducer": reducer,
+        }
+        base["transform"] = {"kind": "calculated", "operation": reducer}
+        base["evidence"] = {
+            "availability": "visualization-derived",
+            "adoption": "not-applicable",
+            "confidence": "deterministic",
+        }
+        if any(value in {"missing", "unsafe"} for value in member_resolutions):
+            base["resolution"] = "partial"
+            base["evidence"]["confidence"] = "unconfirmed"
+        elif any(value == "partial" for value in member_resolutions):
+            base["resolution"] = "partial"
+        return base
+
+    def _raw_source(
+        self, source_kind: str, source_ref: str, source: dict[str, Any]
+    ) -> tuple[Any, dict[str, Any]]:
+        if source_kind == "runtime-event":
+            event_id = _suffix_int(source_ref)
+            if event_id is not None:
+                event = self.event_details.get(event_id)
+                return event, event or {}
+            # A runtime source is only trustworthy when it names one concrete
+            # Event.  Treating an empty ref as "all related Events" previously
+            # let a multipath evaluator impersonate a missing script_evaluator
+            # in round summaries.
+            return None, {"detailRef": source_ref, "reason": "未保存可定位的 Event ref"}
+        if source_kind == "database":
+            return self._database_record(source_ref, str(source.get("label") or ""))
+        if source_kind == "artifact":
+            if "branch:" in source_ref:
+                branch_id = _suffix_int(source_ref)
+                row = _by_int(self.bundle.get("branches"), "branch_id", branch_id)
+                # CardData paths are rooted at the branch record
+                # (`candidate_snapshot.snapshot`), so retain that root rather
+                # than pre-extracting candidate_snapshot and breaking the
+                # declared JSON Pointer.
+                return row, {"table": "script_build_branch", "recordId": (row or {}).get("id")}
+            artifact = self.bundle.get("currentArtifact")
+            return artifact, {"artifactRef": source_ref or "artifact:base:current"}
+        if source_kind == "calculation":
+            return {"value": None, "calculation": source.get("label")}, {"calculation": source.get("label")}
+        return None, {"ref": source_ref}
+
+    def _database_record(self, source_ref: str, label: str) -> tuple[Any, dict[str, Any]]:
+        normalized = label.lower()
+        if source_ref.startswith("missing-convergence:"):
+            parts = source_ref.split(":", 4)
+            record_type = parts[1] if len(parts) > 1 else "unknown"
+            round_index = _integer(parts[3]) if len(parts) > 3 else None
+            branch_ids = [
+                _integer(value)
+                for value in (parts[4].split(",") if len(parts) > 4 else [])
+                if _integer(value) is not None
+            ]
+            return [], {
+                "tables": (
+                    ["script_build_event", "script_build_event_body"]
+                    if record_type == "review"
+                    else ["script_build_multipath_decision"]
+                ),
+                "filters": {
+                    "round_index": round_index,
+                    "branch_ids": branch_ids,
+                },
+                "resultCount": 0,
+            }
+        if "script_build_record" in normalized or source_ref == "run:objective" or source_ref == "run:final-result":
+            row = self.bundle.get("record")
+            return row, {"table": "script_build_record", "recordId": (row or {}).get("id")}
+        if "data_decision" in normalized or source_ref.startswith("data-decision:"):
+            row = _by_int(self.bundle.get("dataDecisions"), "id", _suffix_int(source_ref))
+            return row, {"table": "script_build_data_decision", "recordId": (row or {}).get("id")}
+        if "multipath" in normalized or source_ref.startswith("multipath-decision:"):
+            row = _by_int(self.bundle.get("multipathDecisions"), "id", _suffix_int(source_ref))
+            return row, {"table": "script_build_multipath_decision", "recordId": (row or {}).get("id")}
+        if "domain_info" in normalized or source_ref.startswith("domain-info:"):
+            row = _by_int(self.bundle.get("domainInfo"), "id", _suffix_int(source_ref))
+            return row, {"table": "script_build_domain_info", "recordId": (row or {}).get("id")}
+        if "script_build_round" in normalized or source_ref.startswith("round:") and ":branch:" not in source_ref:
+            round_index = _round_index(source_ref)
+            row = _by_int(self.bundle.get("rounds"), "round_index", round_index)
+            return row, {"table": "script_build_round", "recordId": (row or {}).get("id")}
+        if "script_build_branch" in normalized or ":branch:" in source_ref:
+            branch_id = _branch_id(source_ref)
+            row = _by_int(self.bundle.get("branches"), "branch_id", branch_id)
+            return row, {"table": "script_build_branch", "recordId": (row or {}).get("id")}
+        return None, {"table": label or "unknown", "ref": source_ref}
+
+    def _ensure_source(
+        self,
+        source_id: str,
+        source_kind: str,
+        source: dict[str, Any],
+        raw: Any,
+        physical: dict[str, Any],
+    ) -> SourceRecord:
+        existing = self.sources.get(source_id)
+        if existing is not None:
+            return existing
+        kind = {
+            "database": "database",
+            "runtime-event": "runtime-event",
+            "artifact": "artifact",
+            "calculation": "calculation",
+            "log-anchor": "log-anchor",
+        }.get(source_kind, "missing")
+        if "历史回退" in str(source.get("label") or ""):
+            kind = "historical-fallback"
+        completeness = "complete"
+        truncated = False
+        omitted = 0
+        if kind == "runtime-event" and isinstance(raw, dict):
+            sides = [raw.get("input"), raw.get("output")]
+            if raw.get("input") is None and raw.get("output") is None:
+                completeness = "partial"
+            for side in sides:
+                if isinstance(side, dict) and side.get("truncated"):
+                    truncated = True
+                    omitted += int(side.get("omittedCharacters") or 0)
+        record: SourceRecord = {
+            "id": source_id,
+            "kind": kind,  # type: ignore[typeddict-item]
+            "label": str(source.get("label") or source.get("ref") or source_id),
+            "locator": physical,
+            "completeness": "partial" if truncated else completeness,  # type: ignore[typeddict-item]
+            "resultState": (
+                "empty"
+                if raw == []
+                or (
+                    kind == "calculation"
+                    and isinstance(raw, dict)
+                    and raw.get("value") == []
+                )
+                else "present"
+            ),
+            "truncated": truncated,
+            "selectedValues": [],
+            "rawRecord": raw,
+        }
+        if omitted:
+            record["omittedCharacters"] = omitted
+        if isinstance(raw, dict) and kind == "runtime-event":
+            record["status"] = str(raw.get("status") or "unknown")
+            record["durationMs"] = _integer(raw.get("duration_ms"))
+            record["locator"] = {
+                "eventId": raw.get("id"),
+                "eventName": raw.get("event_name"),
+                "eventType": raw.get("event_type"),
+                "table": "script_build_event + script_build_event_body",
+            }
+        self.sources[source_id] = record
+        return record
+
+    def _ensure_missing_source(
+        self, source_id: str, source: dict[str, Any], *, reason: str
+    ) -> SourceRecord:
+        record = self.sources.get(source_id)
+        if record is None:
+            record = {
+                "id": source_id,
+                "kind": "missing",
+                "label": str(source.get("label") or source_id),
+                "locator": {"ref": source.get("ref"), "reason": reason},
+                "completeness": "missing",
+                "resultState": "missing",
+                "truncated": False,
+                "selectedValues": [],
+                "rawRecord": None,
+            }
+            self.sources[source_id] = record
+        return record
+
+    @staticmethod
+    def _source_id(
+        source_kind: str,
+        source_ref: str,
+        source: dict[str, Any],
+        physical: dict[str, Any],
+    ) -> str:
+        if source_kind == "runtime-event" and _suffix_int(source_ref) is not None:
+            return f"event:{_suffix_int(source_ref)}"
+        if source_kind == "database" and physical.get("table"):
+            return f"db:{physical['table']}:{physical.get('recordId') or source_ref or 'unknown'}"
+        if source_kind == "artifact":
+            return f"artifact:{source_ref or physical.get('artifactRef') or 'unknown'}"
+        key = f"{source_kind}:{source_ref}:{source.get('label')}"
+        return f"source:{hashlib.sha1(key.encode('utf-8')).hexdigest()[:12]}"
+
+    @staticmethod
+    def _role(field: dict[str, Any]) -> str:
+        relation = str(field.get("relation") or "")
+        return {
+            "direct-input": "input",
+            "previous-result": "basis",
+            "standing-constraint": "constraint",
+            "explicit-basis": "basis",
+            "available-upstream": "basis",
+            "persisted-output": "output",
+            "run-output": "output",
+        }.get(relation, "basis")
+
+    @staticmethod
+    def _transform(source_kind: str, source: dict[str, Any]) -> dict[str, Any]:
+        if source_kind == "calculation":
+            return {"kind": "calculated", "operation": str(source.get("label") or "确定性计算")}
+        if "历史回退" in str(source.get("label") or ""):
+            return {
+                "kind": "fallback",
+                "selectedSourceId": str(source.get("ref") or ""),
+                "candidateSourceIds": [str(source.get("ref") or "")],
+            }
+        return {"kind": "direct"}
+
+    @staticmethod
+    def _evidence(field: dict[str, Any], source_kind: str) -> EvidenceSpec:
+        relation = str(field.get("relation") or "")
+        if source_kind == "calculation":
+            return {
+                "availability": "visualization-derived",
+                "adoption": "not-applicable",
+                "confidence": "deterministic",
+            }
+        if relation == "explicit-basis":
+            return {
+                "availability": "direct-read",
+                "adoption": "explicitly-adopted",
+                "confidence": "exact",
+            }
+        if relation in {"direct-input", "standing-constraint"}:
+            return {
+                "availability": "direct-read",
+                "adoption": "not-recorded",
+                "confidence": "exact",
+            }
+        if relation == "previous-result":
+            return {
+                "availability": "returned-to-agent" if source_kind == "runtime-event" else "available-upstream",
+                "adoption": "not-recorded",
+                "confidence": "exact" if source_kind == "runtime-event" else "safe-association",
+            }
+        if relation == "available-upstream":
+            return {
+                "availability": "available-upstream",
+                "adoption": "not-recorded",
+                "confidence": "safe-association",
+            }
+        if relation == "persisted-output":
+            availability = "returned-to-agent" if source_kind == "runtime-event" else "produced-by-run"
+            return {
+                "availability": availability,
+                "adoption": "not-recorded" if availability == "returned-to-agent" else "not-applicable",
+                "confidence": "exact",
+            }
+        if relation == "run-output":
+            return {
+                "availability": "produced-by-run",
+                "adoption": "not-applicable",
+                "confidence": "exact",
+            }
+        return {
+            "availability": "available-upstream",
+            "adoption": "not-recorded",
+            "confidence": "unconfirmed",
+        }
+
+
+def _integer(value: Any) -> int | None:
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _suffix_int(value: str) -> int | None:
+    return _integer(str(value or "").rsplit(":", 1)[-1])
+
+
+def _round_index(ref: str) -> int | None:
+    match = re.search(r"(?:^|:)round:(\d+)", f":{ref}")
+    return _integer(match.group(1)) if match else None
+
+
+def _branch_id(ref: str) -> int | None:
+    match = re.search(r":branch:(\d+)", ref)
+    return _integer(match.group(1)) if match else None
+
+
+def _by_int(items: Any, key: str, wanted: int | None) -> dict[str, Any] | None:
+    if wanted is None:
+        return None
+    return next(
+        (
+            item
+            for item in items or []
+            if isinstance(item, dict) and _integer(item.get(key)) == wanted
+        ),
+        None,
+    )
+
+
+def _equivalent_value(left: Any, right: Any) -> bool:
+    try:
+        return json.dumps(left, ensure_ascii=False, sort_keys=True, default=str) == json.dumps(
+            right, ensure_ascii=False, sort_keys=True, default=str
+        )
+    except (TypeError, ValueError):
+        return str(left) == str(right)

+ 85 - 0
visualization-old/backend/app/source_lineage/schema.py

@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from typing import Any, Literal, TypedDict
+
+
+Completeness = Literal["complete", "partial", "missing"]
+Resolution = Literal["resolved", "partial", "missing", "unsafe"]
+
+
+class EvidenceSpec(TypedDict):
+    availability: Literal[
+        "direct-read",
+        "returned-to-agent",
+        "available-upstream",
+        "produced-by-run",
+        "visualization-derived",
+        "unavailable",
+    ]
+    adoption: Literal[
+        "explicitly-adopted",
+        "explicitly-rejected",
+        "not-recorded",
+        "not-applicable",
+    ]
+    confidence: Literal["exact", "safe-association", "deterministic", "unconfirmed"]
+
+
+class SourceBinding(TypedDict):
+    id: str
+    sourceId: str
+    role: Literal["input", "basis", "constraint", "process", "output", "status"]
+    selector: dict[str, Any]
+    transform: dict[str, Any]
+    evidence: EvidenceSpec
+    resolution: Resolution
+
+
+class SourceRecord(TypedDict, total=False):
+    id: str
+    kind: Literal[
+        "database",
+        "runtime-event",
+        "artifact",
+        "log-anchor",
+        "calculation",
+        "historical-fallback",
+        "missing",
+    ]
+    label: str
+    locator: dict[str, Any]
+    status: str
+    durationMs: int | None
+    completeness: Completeness
+    resultState: Literal["present", "empty", "missing", "unavailable"]
+    truncated: bool
+    omittedCharacters: int
+    selectedValues: list[dict[str, Any]]
+    rawRecord: Any
+
+
+def unresolved_binding(
+    binding_id: str,
+    *,
+    reason: str,
+    role: str = "basis",
+    source_id: str | None = None,
+    resolution: Resolution = "missing",
+    code: str | None = None,
+) -> SourceBinding:
+    selector = {"kind": "unresolved", "reason": reason}
+    if code:
+        selector["code"] = code
+    return {
+        "id": binding_id,
+        "sourceId": source_id or f"missing:{binding_id}",
+        "role": role,  # type: ignore[typeddict-item]
+        "selector": selector,
+        "transform": {"kind": "direct"},
+        "evidence": {
+            "availability": "unavailable",
+            "adoption": "not-recorded",
+            "confidence": "unconfirmed",
+        },
+        "resolution": resolution,
+    }

+ 5 - 0
visualization-old/backend/pytest.ini

@@ -0,0 +1,5 @@
+[pytest]
+testpaths = tests
+pythonpath = .
+cache_dir = ../.cache/pytest
+

+ 8 - 0
visualization-old/backend/requirements.txt

@@ -0,0 +1,8 @@
+fastapi==0.128.1
+uvicorn==0.39.0
+pydantic==2.12.5
+sqlalchemy>=2.0,<3
+pymysql>=1.1,<2
+pytest==8.4.2
+httpx>=0.27,<1
+

+ 411 - 0
visualization-old/backend/scripts/validate_source_inspector.py

@@ -0,0 +1,411 @@
+#!/usr/bin/env python3
+"""Read-only validator for every visible data-source Inspector in real runs."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from collections import Counter
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Any
+
+
+HIDDEN_BUSINESS_KEYS = {"technical", "changes"}
+
+
+def request_json(base_url: str, path: str) -> tuple[int, Any]:
+    url = f"{base_url.rstrip('/')}{path}"
+    try:
+        with urllib.request.urlopen(url, timeout=60) as response:
+            return response.status, json.load(response)
+    except urllib.error.HTTPError as exc:
+        try:
+            payload = json.load(exc)
+        except Exception:
+            payload = {"detail": str(exc)}
+        return exc.code, payload
+
+
+def quote_ref(value: str) -> str:
+    return urllib.parse.quote(value, safe="")
+
+
+def collect_detail_refs(value: Any) -> list[str]:
+    found: list[str] = []
+
+    def visit(node: Any) -> None:
+        if isinstance(node, dict):
+            for key, child in node.items():
+                if key == "detailRef" and isinstance(child, str) and child:
+                    found.append(child)
+                visit(child)
+        elif isinstance(node, list):
+            for child in node:
+                visit(child)
+
+    visit(value)
+    return list(dict.fromkeys(found))
+
+
+def pointer_lookup(value: Any, pointer: str) -> tuple[bool, Any]:
+    if not pointer:
+        return True, value
+    current = value
+    for raw in pointer.lstrip("/").split("/"):
+        part = raw.replace("~1", "/").replace("~0", "~")
+        if isinstance(current, list):
+            try:
+                current = current[int(part)]
+            except (ValueError, IndexError):
+                return False, None
+        elif isinstance(current, dict) and part in current:
+            current = current[part]
+        else:
+            return False, None
+    return True, current
+
+
+def digest(value: Any) -> str:
+    serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
+    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+
+
+def validate_binding(binding: dict[str, Any], sources: dict[str, Any]) -> list[str]:
+    errors: list[str] = []
+    source_id = str(binding.get("sourceId") or "")
+    selector = binding.get("selector") or {}
+    resolution = binding.get("resolution")
+    source = sources.get(source_id)
+    if source is None:
+        return [f"binding {binding.get('id')} 的 sourceId {source_id} 未返回"]
+    if resolution == "resolved" and source.get("resultState") in {"missing", "unavailable"}:
+        errors.append(
+            f"binding {binding.get('id')} 标记 resolved,但来源状态为 {source.get('resultState')}"
+        )
+    if selector.get("kind") == "unresolved":
+        code = str(selector.get("code") or "")
+        reason = str(selector.get("reason") or "")
+        if not reason:
+            errors.append(f"binding {binding.get('id')} 的 unresolved 原因为空")
+        if not code:
+            errors.append(f"binding {binding.get('id')} 的 unresolved 缺少原因码")
+        if code in {"binding-map-missing", "field-path-mismatch"}:
+            errors.append(f"binding {binding.get('id')} 存在可视化映射错误 {code}")
+        elif code not in {
+            "source-record-not-saved",
+            "event-body-not-saved",
+            "unsafe-association",
+            "reliable-log-anchor-unavailable",
+        }:
+            errors.append(f"binding {binding.get('id')} 使用了未允许的缺失原因码 {code}")
+        if code in {"source-record-not-saved", "event-body-not-saved"} and source.get("resultState") not in {"missing", "unavailable"}:
+            errors.append(
+                f"binding {binding.get('id')} 声称原始记录未保存,"
+                f"但来源状态为 {source.get('resultState')}"
+            )
+        return errors
+    if selector.get("kind") == "json-pointer":
+        found, _selected = pointer_lookup(source.get("rawRecord"), selector.get("path") or "")
+        if not found:
+            errors.append(f"binding {binding.get('id')} 无法反向定位 {selector.get('path')}")
+    elif selector.get("kind") == "text-span":
+        found, text = pointer_lookup(source.get("rawRecord"), selector.get("path") or "")
+        if not found or not isinstance(text, str):
+            errors.append(f"binding {binding.get('id')} 的原文字段不存在")
+        else:
+            characters = list(text)
+            start = int(selector.get("startCodePoint") or 0)
+            end = int(selector.get("endCodePoint") or 0)
+            if "".join(characters[start:end]) != selector.get("exactText"):
+                errors.append(f"binding {binding.get('id')} 的原文区间不匹配")
+            if digest(text) != selector.get("sourceDigest"):
+                errors.append(f"binding {binding.get('id')} 的 sourceDigest 不匹配")
+    elif selector.get("kind") == "members":
+        members = selector.get("members") or []
+        if not members:
+            errors.append(f"binding {binding.get('id')} 的聚合成员为空")
+        for member in members:
+            errors.extend(validate_binding({
+                "id": f"{binding.get('id')}:member",
+                "sourceId": member.get("sourceId"),
+                "selector": member.get("selector"),
+                "resolution": "resolved",
+            }, sources))
+    return errors
+
+
+def selected_value(binding: dict[str, Any], source: dict[str, Any]) -> tuple[bool, Any]:
+    selector = binding.get("selector") or {}
+    kind = selector.get("kind")
+    raw = source.get("rawRecord")
+    if kind == "json-pointer":
+        return pointer_lookup(raw, str(selector.get("path") or ""))
+    if kind == "whole-record":
+        return True, raw
+    if kind == "text-span":
+        found, text = pointer_lookup(raw, str(selector.get("path") or ""))
+        if not found or not isinstance(text, str):
+            return False, None
+        start = int(selector.get("startCodePoint") or 0)
+        end = int(selector.get("endCodePoint") or 0)
+        return True, "".join(list(text)[start:end])
+    return False, None
+
+
+def equivalent(left: Any, right: Any) -> bool:
+    return json.dumps(left, ensure_ascii=False, sort_keys=True, default=str) == json.dumps(
+        right, ensure_ascii=False, sort_keys=True, default=str
+    )
+
+
+def validate_business_value(
+    binding: dict[str, Any], source: dict[str, Any], business_value: Any
+) -> list[str]:
+    """Direct bindings must select the exact value shown in column one."""
+    if (binding.get("transform") or {}).get("kind") != "direct":
+        return []
+    found, selected = selected_value(binding, source)
+    if found and not equivalent(selected, business_value):
+        return [
+            f"binding {binding.get('id')} 声明直接展示,但 selector 值与业务项不一致"
+        ]
+    return []
+
+
+def validate_empty_sources(sources: dict[str, Any]) -> list[str]:
+    errors: list[str] = []
+    for source_id, source in sources.items():
+        if source.get("resultState") != "empty":
+            continue
+        kind = source.get("kind")
+        locator = source.get("locator") or {}
+        raw = source.get("rawRecord")
+        if kind == "database" and not (
+            raw == [] and int(locator.get("resultCount") or 0) == 0
+        ):
+            errors.append(f"空结果来源 {source_id} 无法证明数据库查询返回 0 条")
+        if kind == "runtime-event" and not isinstance(raw, dict):
+            errors.append(f"空结果来源 {source_id} 没有保存运行 Event")
+    return errors
+
+
+def validate_view(payload: dict[str, Any]) -> tuple[Counter, list[str]]:
+    counts: Counter = Counter()
+    errors: list[str] = []
+    if payload.get("schemaVersion") != "inspector-source-v2":
+        return counts, ["schemaVersion 不是 inspector-source-v2"]
+    business = payload.get("businessProjection") or {}
+    sources = payload.get("sources") or {}
+    for source in sources.values():
+        counts[f"source:{source.get('resultState') or 'present'}"] += 1
+    actual_selectors: list[str] = []
+    for module in payload.get("modules") or []:
+        bindings = {item.get("id"): item for item in module.get("bindings") or []}
+        for row in module.get("rows") or []:
+            counts["rows"] += 1
+            business_selector = str(row.get("businessSelector") or "")
+            actual_selectors.append(business_selector)
+            found, business_value = pointer_lookup(business, business_selector)
+            if not found:
+                errors.append(f"业务行 {row.get('id')} 无法定位 {row.get('businessSelector')}")
+            ids = row.get("bindingIds") or []
+            if not ids:
+                errors.append(f"业务行 {row.get('id')} 没有 binding")
+            for binding_id in ids:
+                binding = bindings.get(binding_id)
+                if binding is None:
+                    errors.append(f"业务行 {row.get('id')} 引用了不存在的 binding {binding_id}")
+                    continue
+                counts[str(binding.get("resolution") or "unknown")] += 1
+                errors.extend(validate_binding(binding, sources))
+                source = sources.get(str(binding.get("sourceId") or "")) or {}
+                errors.extend(validate_business_value(binding, source, business_value))
+    errors.extend(validate_log_sources(sources))
+    errors.extend(validate_empty_sources(sources))
+    expected_selectors = visible_business_selectors(business)
+    missing_selectors = [value for value in expected_selectors if value not in actual_selectors]
+    if missing_selectors:
+        errors.append(f"旧业务 Inspector 的可见项没有生成对照行: {missing_selectors}")
+    return counts, errors
+
+
+def validate_log_sources(sources: dict[str, Any]) -> list[str]:
+    errors: list[str] = []
+    for source_id, source in sources.items():
+        if source.get("kind") != "log-anchor":
+            continue
+        locator = source.get("locator") or {}
+        event_id = locator.get("eventId")
+        if event_id is None:
+            errors.append(f"日志来源 {source_id} 没有 eventId")
+            continue
+        event = sources.get(f"event:{event_id}") or {}
+        event_raw = event.get("rawRecord") or {}
+        expected_msg_id = str(event_raw.get("msg_id") or locator.get("msgId") or "")
+        if not expected_msg_id:
+            errors.append(f"Event {event_id} 没有 msg_id,不应附带日志来源")
+            continue
+        raw_log = source.get("rawRecord")
+        rendered = raw_log if isinstance(raw_log, str) else json.dumps(raw_log, ensure_ascii=False)
+        if f"msg_id={expected_msg_id}" not in rendered and f"msg_id:{expected_msg_id}" not in rendered:
+            errors.append(f"日志来源 {source_id} 与 Event {event_id} 的 msg_id 不一致")
+    return errors
+
+
+def visible_business_selectors(projection: dict[str, Any]) -> list[str]:
+    """Mirror the readable Inspector's visible leaves, including fixed empty notices."""
+    selectors: list[str] = []
+    if projection.get("question") not in (None, ""):
+        selectors.append("/question")
+    for section_index, section in enumerate(projection.get("businessSections") or []):
+        if not isinstance(section, dict):
+            continue
+        root = f"/businessSections/{section_index}"
+        if isinstance(section.get("items"), list):
+            selectors.extend(f"{root}/items/{index}" for index, _ in enumerate(section["items"]))
+        elif section.get("content") is not None:
+            selectors.append(f"{root}/content")
+    for block_index, block in enumerate(projection.get("blocks") or []):
+        if not isinstance(block, dict):
+            continue
+        root = f"/blocks/{block_index}"
+        kind = str(block.get("type") or "summary")
+        if kind in {"summary", "notice"}:
+            selectors.append(f"{root}/value")
+        elif kind == "items":
+            selectors.extend(f"{root}/items/{index}" for index, _ in enumerate(block.get("items") or []))
+        elif kind == "implementation-plan":
+            if block.get("mode") is not None:
+                selectors.append(f"{root}/mode")
+            for route_index, route in enumerate(block.get("routes") or []):
+                if not isinstance(route, dict):
+                    continue
+                for key in ("target", "method", "action", "emphasis", "pathType"):
+                    if route.get(key) is not None:
+                        selectors.append(f"{root}/routes/{route_index}/{key}")
+            if block.get("reasoning") is not None:
+                selectors.append(f"{root}/reasoning")
+        elif kind == "creative-process":
+            for step_index, step in enumerate(block.get("steps") or []):
+                if not isinstance(step, dict):
+                    continue
+                for key in ("action", "summary", "plan", "reasoning"):
+                    if step.get(key) is not None:
+                        selectors.append(f"{root}/steps/{step_index}/{key}")
+        elif kind == "evaluation-branches":
+            for branch_index, branch in enumerate(block.get("branches") or []):
+                if not isinstance(branch, dict):
+                    continue
+                for key in ("conclusion", "achievements", "problems", "evidence"):
+                    if branch.get(key) not in (None, [], ""):
+                        selectors.append(f"{root}/branches/{branch_index}/{key}")
+        elif kind in {"evaluation-matrix", "comparison"}:
+            selectors.extend(f"{root}/rows/{index}" for index, _ in enumerate(block.get("rows") or []))
+        elif kind == "artifact":
+            selectors.extend(f"{root}/refs/{index}" for index, _ in enumerate(block.get("refs") or []))
+        else:
+            selectors.append(root)
+    if projection.get("detailKind") == "retrieval-event":
+        conditions = projection.get("queryConditions") or []
+        selectors.extend(
+            [f"/queryConditions/{index}/value" for index, _ in enumerate(conditions)]
+            or ["/queryConditions"]
+        )
+        outcome = projection.get("outcome") or {}
+        selectors.extend(
+            f"/outcome/{key}"
+            for key in ("message", "count", "errorKind")
+            if outcome.get(key) is not None
+        )
+        selectors.extend(f"/items/{index}" for index, _ in enumerate(projection.get("items") or []))
+        completeness = projection.get("completeness") or {}
+        if completeness.get("bodyAvailable") is False:
+            selectors.append("/completeness/bodyAvailable")
+        if completeness.get("truncated"):
+            selectors.append(
+                "/completeness/omittedCharacters"
+                if completeness.get("omittedCharacters") is not None
+                else "/completeness/truncated"
+            )
+    if isinstance(projection.get("activities"), list):
+        activities = projection.get("activities") or []
+        selectors.extend(
+            [f"/activities/{index}" for index, _ in enumerate(activities)]
+            or ["/missingActivityNotice" if projection.get("missingActivityNotice") else "/activities"]
+        )
+    return selectors
+
+
+def validate_detail(base_url: str, run_id: int, detail_ref: str) -> tuple[str, Counter, list[str], bool]:
+    encoded = quote_ref(detail_ref)
+    status, payload = request_json(
+        base_url, f"/api/script-builds/{run_id}/inspector-view/{encoded}"
+    )
+    if status == 404:
+        return detail_ref, Counter(), [], False
+    if status != 200:
+        return detail_ref, Counter(), [f"inspector-view HTTP {status}: {payload}"], True
+    counts, errors = validate_view(payload)
+    old_status, old = request_json(
+        base_url, f"/api/script-builds/{run_id}/activities/{encoded}"
+    )
+    if old_status == 200:
+        expected = {key: value for key, value in old.items() if key not in HIDDEN_BUSINESS_KEYS}
+        if payload.get("businessProjection") != expected:
+            errors.append("第一列与旧业务 Inspector 投影不一致")
+    return detail_ref, counts, errors, True
+
+
+def validate_run(base_url: str, run_id: int, *, workers: int) -> dict[str, Any]:
+    status, execution = request_json(base_url, f"/api/script-builds/{run_id}/execution-view")
+    if status != 200:
+        return {"runId": run_id, "fatal": f"execution-view HTTP {status}: {execution}"}
+    refs = collect_detail_refs(execution)
+    totals: Counter = Counter()
+    failures: list[dict[str, Any]] = []
+    checked = 0
+    with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
+        futures = {
+            executor.submit(validate_detail, base_url, run_id, detail_ref): detail_ref
+            for detail_ref in refs
+        }
+        for future in as_completed(futures):
+            detail_ref, counts, errors, did_check = future.result()
+            checked += int(did_check)
+            totals.update(counts)
+            if errors:
+                failures.append({"detailRef": detail_ref, "errors": errors})
+    failures.sort(key=lambda item: refs.index(item["detailRef"]))
+    return {
+        "runId": run_id,
+        "visibleRefs": len(refs),
+        "checkedInspectors": checked,
+        "counts": dict(totals),
+        "failures": failures,
+    }
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--base-url", default="http://127.0.0.1:8788")
+    parser.add_argument("--runs", type=int, nargs="+", default=[422, 443, 444, 446])
+    parser.add_argument("--output")
+    parser.add_argument("--workers", type=int, default=8)
+    args = parser.parse_args()
+    results = [validate_run(args.base_url, run_id, workers=args.workers) for run_id in args.runs]
+    report = {"baseUrl": args.base_url, "runs": results}
+    rendered = json.dumps(report, ensure_ascii=False, indent=2)
+    print(rendered)
+    if args.output:
+        with open(args.output, "w", encoding="utf-8") as handle:
+            handle.write(rendered + "\n")
+    return 1 if any(result.get("fatal") or result.get("failures") for result in results) else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 259 - 0
visualization-old/backend/tests/conftest.py

@@ -0,0 +1,259 @@
+from __future__ import annotations
+
+from copy import deepcopy
+
+import pytest
+
+
+@pytest.fixture
+def current_bundle():
+    bundle = {
+        "record": {
+            "id": 88001,
+            "status": "running",
+            "script_direction": "解释一家工厂怎样通过柔性排产减少浪费。",
+            "summary": None,
+            "error_message": None,
+            "agent_config": {"model_name": "test-model"},
+            "cost_usd": 1.2,
+            "start_time": "2026-07-13T10:00:00",
+            "end_time": None,
+        },
+        "rounds": [
+            {
+                "id": 1,
+                "round_index": 1,
+                "goal": "确定文章结构并补齐可靠行业事实",
+                "multipath_plan": [
+                    {"path_index": 1, "target": "文章结构"},
+                    {"path_index": 2, "target": "行业事实"},
+                    {"path_index": 3, "target": "结尾表达"},
+                ],
+                "race_or_divide": "分工",
+                "plan_note": "内容方案和领域信息方案并行推进。",
+                "status": "done",
+            },
+            {
+                "id": 2,
+                "round_index": 2,
+                "goal": "补齐正文细节",
+                "multipath_plan": [{"path_index": 1, "target": "正文"}],
+                "race_or_divide": "赛马",
+                "plan_note": None,
+                "status": "open",
+            },
+        ],
+        "branches": [
+            _branch(1, 1, "内容", "形成三段式文章结构", "merged"),
+            _branch(1, 2, "领域信息", "核实柔性排产数据", "discarded"),
+            _branch(1, 3, "内容", "形成行动建议结尾", "parked"),
+            _branch(2, 4, "内容", "补齐正文案例", "open"),
+        ],
+        "dataDecisions": [
+            _data_decision(11, 1, 1, "采用案例中的三段结构", "结构能解释因果关系"),
+            _data_decision(12, 1, 2, "采用两条交叉印证的数据", "两类来源结论一致"),
+            _data_decision(13, 1, 3, "采用行动建议写法", None),
+        ],
+        "multipathDecisions": [
+            {
+                "id": 21,
+                "script_build_id": 88001,
+                "round_index": 1,
+                "branch_ids": [1, 2],
+                "decision": "采用方案 1;方案 2 的领域事实保留,但内容分支不采用。",
+                "reasoning": "结构可直接进入主脚本,领域事实独立沉淀。",
+                "created_at": "2026-07-13T10:03:00",
+            },
+            {
+                "id": 22,
+                "script_build_id": 88001,
+                "round_index": 1,
+                "branch_ids": [3],
+                "decision": "方案 3 暂存。",
+                "reasoning": "方向可用,但与正文衔接仍需调整。",
+                "created_at": "2026-07-13T10:04:00",
+            },
+        ],
+        "domainInfo": [
+            {
+                "id": 31,
+                "script_build_id": 88001,
+                "round_index": 1,
+                "branch_id": 2,
+                "content": "柔性排产可降低小批量切换损耗。",
+                "source": [{"type": "report", "id": "R-1", "loc": "p.4"}],
+                "note": "两份报告交叉印证",
+                "created_at": "2026-07-13T10:02:30",
+            }
+        ],
+        "events": [
+            {
+                "id": 39,
+                "event_seq": 6,
+                "event_type": "agent_invoke",
+                "event_name": "script_multipath_evaluator",
+                "round_index": 1,
+                "status": "ok",
+                "inputData": {"task": "比较 branch_id=1, branch_id=2"},
+                "agentOutputData": {"summary": "### 分支评估 branch_id=1\n**该支结论**:通过\n**目标推进**:结构清晰。\n\n### 分支评估 branch_id=2\n**该支结论**:部分通过\n**目标推进**:事实可靠。\n\n### 对比与建议\n**采纳建议**:优先采用 branch_id=1。"},
+                "started_at": "2026-07-13T10:02:40",
+                "ended_at": "2026-07-13T10:02:50",
+            },
+            {
+                "id": 40,
+                "event_seq": 7,
+                "event_type": "agent_invoke",
+                "event_name": "script_multipath_evaluator",
+                "round_index": 1,
+                "status": "ok",
+                "inputData": {"task": "评审 branch_id=3"},
+                "agentOutputData": {"summary": "### 分支评估 branch_id=3\n**该支结论**:通过\n**目标推进**:结尾方向可用。\n\n### 对比与建议\n**采纳建议**:可以暂存后调整。"},
+                "started_at": "2026-07-13T10:03:30",
+                "ended_at": "2026-07-13T10:03:40",
+            },
+            {
+                "id": 41,
+                "event_seq": 8,
+                "event_type": "agent_invoke",
+                "event_name": "script_evaluator",
+                "round_index": 1,
+                "status": "ok",
+                "title": "派发 script_evaluator (ok)",
+                "input_preview": "请评估第1轮",
+                "output_preview": "### 整体结论:部分通过\n仍需完善正文细节。",
+                "agentOutputData": {"summary": "### 整体结论:部分通过\n仍需完善正文细节。"},
+                "started_at": "2026-07-13T10:05:00",
+                "ended_at": "2026-07-13T10:05:10",
+            },
+            {
+                "id": 42,
+                "event_seq": 9,
+                "event_type": "agent_invoke",
+                "event_name": "retrieve_data_knowledge",
+                "status": "ok",
+                "title": "无法归属的取数 Agent",
+                "started_at": "2026-07-13T10:06:00",
+                "ended_at": "2026-07-13T10:06:10",
+            },
+            {
+                "id": 50,
+                "event_seq": 10,
+                "event_type": "agent_invoke",
+                "event_name": "script_implementer",
+                "agent_role": "script_implementer",
+                "round_index": 1,
+                "branch_id": 1,
+                "status": "ok",
+                "started_at": "2026-07-13T10:00:00",
+                "ended_at": "2026-07-13T10:02:00",
+            },
+            {
+                "id": 51,
+                "event_seq": 11,
+                "event_type": "tool_call",
+                "event_name": "get_script_snapshot",
+                "agent_role": "script_implementer",
+                "scope_event_id": 50,
+                "round_index": 1,
+                "branch_id": 1,
+                "status": "ok",
+                "inputData": {},
+                "output_preview": "{\"count\": 1}",
+                "started_at": "2026-07-13T10:00:05",
+                "ended_at": "2026-07-13T10:00:06",
+            },
+            {
+                "id": 52,
+                "event_seq": 12,
+                "event_type": "agent_invoke",
+                "event_name": "retrieve_data_decode_case",
+                "parent_event_id": 50,
+                "scope_event_id": 50,
+                "round_index": 1,
+                "branch_id": 1,
+                "status": "ok",
+                "inputData": {"task": "查找可说明因果结构的解构 Case"},
+                "agentOutputData": {"summary": "初筛出两个可用的三段式案例。"},
+                "started_at": "2026-07-13T10:00:10",
+                "ended_at": "2026-07-13T10:00:40",
+            },
+            {
+                "id": 53,
+                "event_seq": 13,
+                "event_type": "tool_call",
+                "event_name": "search_script_decode_case",
+                "scope_event_id": 52,
+                "parent_event_id": 52,
+                "round_index": 1,
+                "branch_id": 1,
+                "status": "ok",
+                "inputData": {"keyword": "因果结构"},
+                "output_preview": "{\"count\": 2}",
+                "started_at": "2026-07-13T10:00:15",
+                "ended_at": "2026-07-13T10:00:20",
+            },
+            {
+                "id": 54,
+                "event_seq": 14,
+                "event_type": "tool_call",
+                "event_name": "search_script_decode_case",
+                "scope_event_id": 52,
+                "parent_event_id": 52,
+                "round_index": 1,
+                "branch_id": 1,
+                "status": "ok",
+                "inputData": {"keyword": "柔性排产"},
+                "output_preview": "{\"count\": 0}",
+                "started_at": "2026-07-13T10:00:25",
+                "ended_at": "2026-07-13T10:00:30",
+            },
+        ],
+        "ignoredLegacyDataDecisionCount": 2,
+        "currentArtifact": {
+            "paragraphs": [{"id": 1}, {"id": 2}, {"id": 3}],
+            "elements": [{"id": 10}, {"id": 11}],
+            "paragraphElements": [{"id": 100, "paragraph_id": 1, "element_id": 10}],
+            "sourceOrigin": "database",
+        },
+    }
+    return deepcopy(bundle)
+
+
+def _branch(round_index: int, branch_id: int, path_type: str, task: str, status: str):
+    snapshot = {
+        "paragraphs": [{"id": branch_id * 100, "name": task}],
+        "elements": [{"id": branch_id * 1000}],
+        "paragraphElements": [],
+    }
+    return {
+        "id": branch_id,
+        "script_build_id": 88001,
+        "branch_id": branch_id,
+        "round_index": round_index,
+        "path_type": path_type,
+        "target": task,
+        "impl_task": task,
+        "self_assessment": f"已经完成:{task}",
+        "status": status,
+        "decision_reasoning": f"方案 {branch_id} 的当前处理理由",
+        "candidate_snapshot": {
+            "snapshot": snapshot if path_type == "内容" else None,
+            "snapshotKind": "saved-at-disposition" if status in {"merged", "discarded"} else "current-overlay",
+            "historicalAccuracy": "exact" if status in {"merged", "discarded"} else "unknown",
+            "currentProjectionAccuracy": "exact" if status in {"open", "parked"} else "not-applicable",
+            "note": "测试候选版本",
+        },
+    }
+
+
+def _data_decision(record_id: int, round_index: int, branch_id: int, decision: str, reasoning: str | None):
+    return {
+        "id": record_id,
+        "script_build_id": 88001,
+        "round_index": round_index,
+        "branch_id": branch_id,
+        "sources": [{"data_type": "领域信息", "data_id": f"S-{record_id}", "data_content": "已核实材料"}],
+        "decision": decision,
+        "reasoning": reasoning,
+        "created_at": "2026-07-13T10:02:00",
+    }

+ 154 - 0
visualization-old/backend/tests/test_api.py

@@ -0,0 +1,154 @@
+from copy import deepcopy
+
+from fastapi.testclient import TestClient
+
+from app import main
+
+
+client = TestClient(main.app)
+
+
+def test_v8_execution_round_activity_and_artifact_endpoints(monkeypatch, current_bundle):
+    bundle = deepcopy(current_bundle)
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_current_artifact", lambda _build_id: deepcopy(bundle["currentArtifact"]))
+    monkeypatch.setattr(main.provider, "list_builds", lambda **_kwargs: [{"id": "88001", "status": "running"}])
+
+    execution = client.get("/api/script-builds/88001/execution-view")
+    assert execution.status_code == 200
+    assert execution.json()["schemaVersion"] == "8"
+
+    round_detail = client.get("/api/script-builds/88001/rounds/1")
+    assert round_detail.status_code == 200
+    assert round_detail.json()["businessSections"]
+
+    data_detail = client.get("/api/script-builds/88001/activities/data-decision:11")
+    assert data_detail.status_code == 200
+    assert data_detail.json()["detailKind"] == "agent-decision"
+    assert data_detail.json()["decisionType"] == "tradeoff"
+    assert "明确取舍" in [block["title"] for block in data_detail.json()["blocks"]]
+
+    artifact = client.get("/api/script-builds/88001/artifacts/base:current")
+    assert artifact.status_code == 200
+    assert artifact.json()["changes"]["exactness"] == "current-only"
+    assert artifact.json()["scriptTable"]["counts"] == {"paragraphs": 3, "elements": 2, "links": 1}
+
+    round_artifact = client.get("/api/script-builds/88001/artifacts/artifact:round:1")
+    assert round_artifact.status_code == 200
+    assert round_artifact.json()["artifactContext"] == {
+        "type": "round",
+        "roundIndex": 1,
+        "versionKind": "current-base",
+        "note": "当前数据库未保存第 1 轮结束时的独立快照;这里展示当前保存的主脚本。",
+        "exactness": "current-only",
+    }
+
+    candidate = client.get("/api/script-builds/88001/artifacts/artifact:branch:1")
+    assert candidate.status_code == 200
+    assert candidate.json()["artifactContext"] == {
+        "type": "candidate",
+        "roundIndex": 1,
+        "branchId": 1,
+        "branchStatus": "merged",
+        "pathType": "内容",
+        "versionKind": "saved-at-disposition",
+        "historicalAccuracy": "exact",
+        "currentProjectionAccuracy": "not-applicable",
+        "note": "测试候选版本",
+        "exactness": "historical-snapshot",
+    }
+
+    domain = client.get("/api/script-builds/88001/artifacts/artifact:branch:2")
+    assert domain.status_code == 404
+    assert "领域信息方案" in domain.json()["detail"]
+
+
+def test_terminal_branch_without_snapshot_does_not_fake_a_candidate(monkeypatch, current_bundle):
+    bundle = deepcopy(current_bundle)
+    branch = bundle["branches"][0]
+    branch["candidate_snapshot"] = {
+        "snapshot": None,
+        "snapshotKind": "missing-disposition-snapshot",
+        "historicalAccuracy": "missing",
+        "currentProjectionAccuracy": "not-applicable",
+    }
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+
+    response = client.get("/api/script-builds/88001/artifacts/artifact:branch:1")
+    assert response.status_code == 404
+    assert "没有可用的候选脚本快照" in response.json()["detail"]
+
+
+def test_event_body_is_loaded_only_from_activity_endpoint(monkeypatch, current_bundle):
+    full_report = "评审原文" * 8_000
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(current_bundle))
+    monkeypatch.setattr(main.provider, "load_event_detail", lambda _build_id, _event_id: {
+        "id": 41,
+        "event_name": "script_evaluator",
+        "title": "整体评审",
+        "input": {"content": "评审第1轮"},
+        "output": {"content": f"整体结论:部分通过\n{full_report}"},
+    })
+
+    execution = client.get("/api/script-builds/88001/execution-view").json()
+    assert "评审第1轮" not in str(execution)
+
+    detail = client.get("/api/script-builds/88001/activities/event:41")
+    assert detail.status_code == 200
+    payload = detail.json()
+    assert payload["detailKind"] == "agent-decision"
+    assert payload["decisionType"] == "evaluation"
+    assert any(
+        block.get("title") == "总结论" and block.get("value") == "部分通过"
+        for block in payload["blocks"]
+    )
+    assert "script_evaluator" not in str(payload["blocks"])
+    assert full_report in payload["technical"]["output"]["content"]
+    assert "[truncated" not in payload["technical"]["output"]["content"]
+
+
+def test_removed_fixture_and_raw_log_routes_are_not_exposed():
+    assert client.get("/api/fixtures/current-schema/execution-view").status_code == 404
+    assert client.get("/api/script-builds/1/raw-log").status_code == 404
+
+
+def test_capabilities_only_advertise_new_db_contract():
+    response = client.get("/api/capabilities")
+    assert response.status_code == 200
+    payload = response.json()
+    assert payload["schemaVersion"] == "8"
+    assert payload["readOnly"] is True
+    assert "script_build_multipath_decision" in payload["businessSources"]
+    assert payload["runtimeSupplement"] == ["script_build_event", "script_build_event_body"]
+
+
+def test_prompt_endpoint_marks_current_rules_as_not_a_run_snapshot(monkeypatch):
+    monkeypatch.setattr(
+        main.provider,
+        "load_prompt_context",
+        lambda _build_id, _prompt_ref: {
+            "promptRef": "prompt:event:50",
+            "actor": {"role": "implementer", "label": "实现 Agent"},
+            "task": "补齐烹饪步骤",
+            "promptContent": "当前规则\nAuthorization: Bearer secret-token",
+            "promptSource": "current-db",
+            "promptVersion": 8,
+        },
+    )
+
+    response = client.get(
+        "/api/script-builds/88001/decision-prompts/prompt:event:50"
+    )
+    assert response.status_code == 200
+    payload = response.json()
+    assert payload["exactTask"] == {
+        "content": "补齐烹饪步骤",
+        "accuracy": "run-exact",
+    }
+    assert payload["systemPrompt"]["source"] == "current-db"
+    assert payload["systemPrompt"]["accuracy"] == "current-not-run-snapshot"
+    assert "secret-token" not in str(payload)
+    assert any(
+        item["code"] == "PROMPT_NOT_SNAPSHOTTED"
+        for item in payload["notices"]
+    )

+ 176 - 0
visualization-old/backend/tests/test_business_detail_text.py

@@ -0,0 +1,176 @@
+from app.business_detail_text import (
+    assessment_text,
+    branch_task_sections,
+    branch_task_text,
+    business_text,
+    clean_report,
+    event_business_sections,
+)
+
+
+def test_business_items_translate_branch_and_base_decision_language():
+    assert business_text("选 Branch 1 合入 Base,弃 Branch 2。") == (
+        "采用方案 1 并合入主脚本,方案 2 未采用。"
+    )
+    assert business_text("组 Branch 3 与 Branch 4 合入。") == (
+        "组合方案 3 与方案 4,并合入主脚本。"
+    )
+
+
+def test_evaluator_preview_becomes_business_markdown_not_json():
+    event = {
+        "event_type": "subagent_dispatch",
+        "event_name": "script_multipath_evaluator",
+        "title": "派发 script_multipath_evaluator (ok)",
+        "status": "ok",
+        "input_preview": '{"agent_type":"script_multipath_evaluator","task":"本轮为赛马:branch 1 与 branch 2。\\n\\n当前目标:选出更清晰的骨架。"}',
+        "output_preview": '{"status":"completed","summary":"## 委托任务完成\\n\\n## 评估报告 script_build_id=99\\n\\n#### 1. 分支评估 branch_id=1\\n* **分支元信息**:round_index=1,action=增\\n* **该支结论**:**通过**\\n* **目标推进**:结构清晰。"}',
+    }
+
+    sections = event_business_sections(event)
+    serialized = str(sections)
+
+    assert sections[0]["title"] == "评审了哪些方案"
+    assert sections[0]["content"] == "方案 1"
+    assert sections[1]["title"] == "各方案结论"
+    assert sections[1]["content"] == "方案 1:通过"
+    assert "script_multipath_evaluator" not in serialized
+    assert "script_build_id" not in serialized
+    assert "分支元信息" not in serialized
+    assert all("\\n" not in section["content"] for section in sections)
+    assert not any(section["content"].lstrip().startswith("{") for section in sections)
+
+
+def test_branch_task_and_assessment_remove_technical_headers_and_telemetry():
+    task = """路序号:1
+action:增
+target:整篇骨架
+path_type:内容
+
+目标:完成五段结构
+
+取证方向与约束:
+- scope=创业邦
+
+禁止事项:不得写成已上市
+"""
+    assessment = """## 委托任务完成
+
+思维过程:这是内部执行过程。
+
+### 完成度申报
+- **完成**:形成五段骨架。
+- **未尽**:无。
+
+**执行统计**:
+- Tokens: 739446
+- 成本: $0.24
+"""
+
+    readable_task = branch_task_text(task)
+    readable_assessment = assessment_text(assessment)
+
+    assert "路序号" not in readable_task
+    assert "action" not in readable_task
+    assert "path_type" not in readable_task
+    assert "实现范围" in readable_task
+    assert "账号:创业邦" in readable_task
+    assert "思维过程" not in readable_assessment
+    assert "Tokens" not in readable_assessment
+    assert "成本" not in readable_assessment
+    assert "形成五段骨架" in readable_assessment
+
+
+def test_branch_task_sections_are_optional_and_preserve_recognized_content():
+    task = """路序号:1
+action:改
+target:五段骨架
+path_type:内容
+method:产生脉络
+
+目标:补齐每段的完整文字。
+
+必用素材(已核实):
+- 领域事实 A
+
+取证方向与约束:
+- 保持概念准确
+
+禁止事项:不得改动原文"""
+
+    sections = branch_task_sections(task)
+
+    assert [item["kind"] for item in sections] == [
+        "scope", "method", "goal", "materials", "constraints", "avoid"
+    ]
+    assert sections[0]["content"] == "五段骨架"
+    assert sections[3]["title"] == "必用素材(已核实)"
+    assert sections[3]["content"] == "- 领域事实 A"
+    assert sections[-1]["content"] == "不得改动原文"
+
+
+def test_branch_task_sections_keep_free_form_task_as_one_complete_note():
+    task = "这是一个单一数据修复任务。\n\n任务:删除一条悬空边。\n\n执行方式:精确删除。"
+
+    assert branch_task_sections(task) == [
+        {"kind": "notes", "title": "任务说明", "content": task}
+    ]
+
+
+def test_tool_event_business_view_never_prints_raw_json_fields():
+    event = {
+        "event_type": "tool_call",
+        "event_name": "get_script_snapshot",
+        "title": "🔧 get_script_snapshot",
+        "status": "ok",
+        "input_preview": '{"script_build_id":431}',
+        "output_preview": '{"script_build":{"id":431},"paragraphs":[{"id":1}],"elements":[{"id":2}]}',
+    }
+
+    sections = event_business_sections(event)
+    serialized = str(sections)
+
+    assert sections[0]["content"] == "读取当前主脚本"
+    assert sections[1]["content"] == "已读取当前主脚本,共 1 个段落、1 个元素。"
+    assert "script_build_id" not in serialized
+    assert "get_script_snapshot" not in serialized
+    assert not any(section["content"].lstrip().startswith("{") for section in sections)
+
+
+def test_retrieval_screening_removes_record_ids_and_runtime_telemetry():
+    report = """### 初筛结果
+- 找到可用的构图案例,post_id=abc123
+- 保留粉白配色的业务结论。
+
+**执行统计**:
+- 消息数: 16
+- Tokens: 739446
+- 成本: $0.24
+"""
+    readable = clean_report(report)
+    assert "可用的构图案例" in readable
+    assert "粉白配色" in readable
+    assert "post_id" not in readable
+    assert "abc123" not in readable
+    assert "Tokens" not in readable
+    assert "成本" not in readable
+
+
+def test_retrieval_screening_keeps_business_summary_but_removes_json_fence():
+    readable = clean_report(
+        """
+已找到 3 条与烹饪步骤相关的候选证据。
+
+```json
+[{"post_id": "abc", "raw": "large payload"}]
+```
+
+初筛后保留防碎处理和火候控制两类信息。
+"""
+    )
+
+    assert "已找到 3 条" in readable
+    assert "保留防碎处理" in readable
+    assert "```" not in readable
+    assert "post_id" not in readable
+    assert "large payload" not in readable

+ 101 - 0
visualization-old/backend/tests/test_card_data.py

@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+from copy import deepcopy
+
+from fastapi.testclient import TestClient
+
+from app import main
+
+
+client = TestClient(main.app)
+
+
+def _event_detail(bundle, event_id: int):
+    event = next(item for item in bundle["events"] if int(item["id"]) == event_id)
+    input_content = deepcopy(event.get("inputData"))
+    output_content = deepcopy(event.get("agentOutputData"))
+    if event_id == 50:
+        input_content = {
+            "task": "范围:文章结构\n方法:补齐因果链\n目标:形成三段式文章结构\n避免:不要虚构事实"
+        }
+        output_content = {"summary": "实现完成"}
+    if output_content is None and event.get("output_preview") is not None:
+        output_content = event.get("output_preview")
+    return {
+        **deepcopy(event),
+        "input": {"contentType": "json", "content": input_content, "truncated": False}
+        if input_content is not None else None,
+        "output": {"contentType": "json", "content": output_content, "truncated": False}
+        if output_content is not None else None,
+    }
+
+
+def test_card_data_protocol_and_primary_kinds(monkeypatch, current_bundle):
+    bundle = deepcopy(current_bundle)
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_detail", lambda _build_id, event_id: _event_detail(bundle, event_id))
+
+    objective = client.get("/api/script-builds/88001/card-data/run%3Aobjective")
+    assert objective.status_code == 200
+    assert objective.json()["relationKind"] == "business-record"
+    assert objective.json()["businessOutputs"][0]["source"] == {
+        "kind": "database",
+        "label": "script_build_record",
+        "ref": "run:objective",
+        "fieldPath": "script_direction",
+    }
+
+    task = client.get(
+        "/api/script-builds/88001/card-data/round%3A1%3Abranch%3A1%3Atask"
+    )
+    assert task.status_code == 200
+    assert task.json()["relationKind"] == "single-event"
+    assert "不要虚构事实" in task.json()["businessInputs"][0]["value"]
+    assert task.json()["runtime"]["units"][0]["eventRef"] == "event:50"
+
+    tradeoff = client.get(
+        "/api/script-builds/88001/card-data/data-decision%3A11"
+    )
+    assert tradeoff.status_code == 200
+    assert tradeoff.json()["relationKind"] == "business-record"
+    assert tradeoff.json()["businessInputs"][0]["relation"] == "explicit-basis"
+    assert tradeoff.json()["runtime"]["units"] == []
+
+    final = client.get(
+        "/api/script-builds/88001/card-data/run%3Afinal-result"
+    )
+    assert final.status_code == 200
+    assert final.json()["cardKind"] == "final-result"
+    assert final.json()["relationKind"] == "calculated"
+    assert next(item for item in final.json()["businessOutputs"] if item["id"] == "final:artifact-scale")["value"] == {
+        "paragraphs": 3,
+        "elements": 2,
+        "links": 1,
+    }
+
+
+def test_retrieval_agent_loads_full_parent_and_query_event_bodies(monkeypatch, current_bundle):
+    bundle = deepcopy(current_bundle)
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_detail", lambda _build_id, event_id: _event_detail(bundle, event_id))
+
+    response = client.get(
+        "/api/script-builds/88001/card-data/retrieval-agent%3A52"
+    )
+    assert response.status_code == 200
+    payload = response.json()
+    assert payload["cardKind"] == "retrieval-agent"
+    assert payload["relationKind"] == "aggregate"
+    assert payload["businessInputs"][0]["value"] == "查找可说明因果结构的解构 Case"
+    assert [item["eventRef"] for item in payload["runtime"]["units"]] == [
+        "event:52",
+        "event:53",
+        "event:54",
+    ]
+    assert payload["businessOutputs"][1]["value"] == "初筛出两个可用的三段式案例。"
+
+
+def test_unknown_card_data_ref_is_404(monkeypatch, current_bundle):
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(current_bundle))
+    response = client.get("/api/script-builds/88001/card-data/not-a-card")
+    assert response.status_code == 404

+ 265 - 0
visualization-old/backend/tests/test_decision_projection_v8.py

@@ -0,0 +1,265 @@
+from __future__ import annotations
+
+from app.decision_projection import AgentDecisionProjector
+
+
+def test_direction_keeps_observed_reads_separate_from_explicit_basis():
+    decision = AgentDecisionProjector().direction(
+        {
+            "id": "run:objective",
+            "subtype": "objective",
+            "decisionItems": ["讲清柔性排产为什么能减少浪费"],
+            "constraints": ["数据可信", "结构清楚"],
+            "inputs": [
+                {"label": "选题", "relation": "direct-read"},
+                {
+                    "label": "账号段落模式",
+                    "relation": "direct-read",
+                    "decisionUse": "explicit-basis",
+                },
+            ],
+            "promptRef": "main:direction",
+        }
+    )
+
+    assert decision["semanticKind"] == "decision"
+    assert decision["decisionType"] == "direction"
+    assert decision["authority"] == "final"
+    assert decision["inputs"][0] == {
+        "label": "选题",
+        "summary": None,
+        "observedRelation": "read-by-actor",
+        "decisionUse": "not-recorded",
+        "detailRef": None,
+    }
+    assert decision["inputs"][1]["decisionUse"] == "explicit-basis"
+    input_block = next(
+        block for block in decision["detail"]["blocks"] if block["title"] == "根据以下信息做出的决策"
+    )
+    assert input_block["items"][0]["note"] == "决策前直接读取;是否采用未记录"
+    assert input_block["items"][1]["note"] == "决策前直接读取;明确作为决策依据"
+    assert input_block["visualRole"] == "evidence"
+    assert input_block["presentation"] == "list"
+    assert input_block["collapsible"] is False
+    final_block = next(
+        block for block in decision["detail"]["blocks"] if block["title"] == "最终方向"
+    )
+    assert final_block["visualRole"] == "key-result"
+    assert len(decision["card"]["secondary"]) <= 3
+    assert decision["detail"]["detailKind"] == "agent-decision"
+
+
+def test_implementation_plan_uses_dedicated_routes_and_full_evidence():
+    full_goal = "修复结构完整性。" * 40
+    decision = AgentDecisionProjector().direction(
+        {
+            "id": "round:4:plan",
+            "subtype": "implementation-plan",
+            "decisionItems": ["方案 1 · 改:段落 2207、2208"],
+            "explicitReasoning": "这是收敛阶段的定点修复,不需要竞争。",
+            "inputs": [
+                {"label": "本轮目标", "summary": full_goal, "relation": "standing-constraint"},
+            ],
+            "implementationPlan": {
+                "mode": "单路",
+                "routes": [{
+                    "pathIndex": 1,
+                    "pathType": "内容",
+                    "action": "改",
+                    "target": "段落 2207、2208 的 form_elements",
+                    "method": "产生元素",
+                    "emphasis": "结构完整性",
+                }],
+            },
+        }
+    )
+
+    route_block = decision["detail"]["blocks"][0]
+    evidence_block = decision["detail"]["blocks"][1]
+    assert route_block["type"] == "implementation-plan"
+    assert route_block["routes"][0]["target"] == "段落 2207、2208 的 形式信息"
+    assert route_block["routes"][0]["emphasis"] == "结构完整性"
+    assert evidence_block["title"] == "根据以下信息做出的决策"
+    assert evidence_block["visualRole"] == "evidence"
+    assert evidence_block["collapsible"] is False
+    assert evidence_block["items"][0]["value"] == full_goal
+    assert evidence_block["items"][0]["note"] == "持续约束;本轮必须遵守"
+
+
+def test_data_tradeoff_is_implementer_scope_and_sources_are_not_auto_adopted():
+    decision = AgentDecisionProjector().data_tradeoff(
+        {
+            "id": "data-decision:12",
+            "decision": "采用两条交叉印证的数据,暂不采用单一来源估算。",
+            "reasoning": "两类来源结论一致。",
+            "sources": [
+                {"type": "行业报告", "title": "报告 A"},
+                {"type": "搜索结果", "title": "结果 B"},
+            ],
+        }
+    )
+
+    assert decision["decisionType"] == "tradeoff"
+    assert decision["subtype"] == "data-tradeoff"
+    assert decision["actor"]["role"] == "implementer"
+    assert decision["authority"] == "implementation-scope"
+    assert {item["decisionUse"] for item in decision["inputs"]} == {"not-recorded"}
+    assert decision["body"]["selected"] == []
+    assert len(decision["card"]["secondary"]) == 2
+    blocks = {block["title"]: block for block in decision["detail"]["blocks"]}
+    assert blocks["参与判断"]["visualRole"] == "evidence"
+    assert blocks["明确取舍"]["visualRole"] == "key-result"
+    assert blocks["理由"]["visualRole"] == "reason"
+
+
+def test_data_tradeoff_uses_real_source_type_and_content_fields():
+    decision = AgentDecisionProjector().data_tradeoff(
+        {
+            "id": "data-decision:13",
+            "decision": "组合领域信息与账号模式。",
+            "sources": [
+                {
+                    "data_type": "领域信息",
+                    "data_content": "米豆腐热量约 50-60kcal/100g",
+                }
+            ],
+        }
+    )
+
+    source = decision["detail"]["blocks"][0]["items"][0]
+    assert source["label"] == "领域信息"
+    assert source["value"] == "米豆腐热量约 50-60kcal/100g"
+
+
+def test_multipath_tradeoff_is_main_final_and_does_not_render_internal_ids():
+    decision = AgentDecisionProjector().multipath_tradeoff(
+        {
+            "id": "multipath-decision:21",
+            "branch_ids": [3, 4],
+            "decision": "组合两个候选方案后合入主脚本。",
+            "reasoning": "两路内容互补。",
+            "reviewComparison": [
+                {
+                    "recommendation": "建议组合",
+                    "finalDecision": "组合采用",
+                    "handling": None,
+                }
+            ],
+        }
+    )
+
+    assert decision["authority"] == "final"
+    assert decision["actor"] == {"role": "main", "label": "主 Agent"}
+    assert decision["body"]["branchIds"] == [3, 4]
+    business_text = str(decision["card"]) + str(decision["detail"]["blocks"])
+    assert "branch_id" not in business_text
+    assert "比较 2 个候选方案" in business_text
+
+
+def test_evaluator_is_recommendation_with_structured_business_blocks():
+    decision = AgentDecisionProjector().evaluation(
+        {
+            "id": "multipath-review:40",
+            "subtype": "multipath-evaluation",
+            "subjects": ["候选方案 A", "候选方案 B"],
+            "criteria": ["结构完整", "证据可信"],
+            "itemConclusions": [
+                {"subject": "候选方案 A", "conclusion": "通过"},
+                {"subject": "候选方案 B", "conclusion": "部分通过"},
+            ],
+            "achievements": ["核心结构已经形成"],
+            "problems": [{"severity": "一般", "summary": "结尾仍需收束"}],
+            "conclusion": "部分通过",
+            "recommendation": "优先采用候选方案 A。",
+        }
+    )
+
+    assert decision["decisionType"] == "evaluation"
+    assert decision["authority"] == "recommendation"
+    assert decision["actor"]["role"] == "multipath-evaluator"
+    assert [block["title"] for block in decision["detail"]["blocks"]] == [
+        "评审对象",
+        "评审标准",
+        "逐项结论",
+        "已达成",
+        "问题",
+        "总结论",
+        "评审建议",
+    ]
+    conclusion = next(block for block in decision["detail"]["blocks"] if block["title"] == "总结论")
+    assert conclusion["visualRole"] == "key-result"
+
+
+def test_creative_requires_safe_think_and_plan_and_never_infers_from_artifact():
+    projector = AgentDecisionProjector()
+    event = {
+        "id": 70,
+        "event_type": "tool_call",
+        "event_name": "think_and_plan",
+        "agent_role": "script_implementer",
+        "status": "ok",
+        "inputData": {
+            "thought": "现有证据不足,抗性淀粉只作为待验证推导。",
+            "action": "补充烹饪贴士段",
+            "plan": "先写可确认内容,再标注待核验项。",
+        },
+    }
+
+    assert projector.creative({"safeLink": False, "event": event}) is None
+    assert projector.creative({"safeLink": True, "artifactRef": "artifact:9"}) is None
+
+    decision = projector.creative(
+        {
+            "safeLink": True,
+            "event": event,
+            "task": "补齐烹饪贴士",
+            "output": "形成候选脚本段落",
+            "uncertainties": ["抗性淀粉结论需要进一步核验"],
+            "artifactRef": "artifact:9",
+            "promptRef": "implementer:70",
+        }
+    )
+
+    assert decision is not None
+    assert decision["decisionType"] == "creative"
+    assert decision["authority"] == "implementation-scope"
+    assert decision["artifactRef"] == "artifact:9"
+    assert "待验证推导" in decision["body"]["reasoning"]
+    assert decision["body"]["uncertainties"] == ["抗性淀粉结论需要进一步核验"]
+    assert all(item["key"] != "task" for item in decision["card"]["secondary"])
+    assert "创作任务" not in [block["title"] for block in decision["detail"]["blocks"]]
+
+
+def test_missing_decision_is_explicit_without_fabricated_reasoning():
+    decision = AgentDecisionProjector().evaluation(
+        {"id": "overall-review:missing", "subtype": "overall-evaluation"}
+    )
+
+    assert decision["completeness"] == "missing"
+    assert decision["card"]["primary"]["value"] == "决策结论未记录"
+    assert decision["body"]["recommendation"] is None
+    assert decision["notices"] == [
+        {"code": "record-missing", "message": "没有找到结构化决策结论。"}
+    ]
+
+
+def test_business_copy_translates_internal_ids_without_deleting_business_numbers():
+    projection = AgentDecisionProjector().evaluation(
+        {
+            "id": "event:9",
+            "subtype": "multipath-evaluation",
+            "achievements": [
+                "branch3 完成 P2,参考 postid: 67c6c723,保留领域信息 31/32。"
+            ],
+            "recommendation": "建议合入 base。",
+        }
+    )
+
+    business = str(projection["detail"]["blocks"])
+    assert "方案 3" in business
+    assert "段落 2" in business
+    assert "账号内容样本" in business
+    assert "主脚本" in business
+    assert "领域信息 31/32" in business
+    assert "branch3" not in business
+    assert "postid" not in business

+ 219 - 0
visualization-old/backend/tests/test_evaluation_report_parser_v8.py

@@ -0,0 +1,219 @@
+from app.evaluation_report_parser import EvaluationReportParser
+
+
+def test_multipath_report_becomes_structured_business_data():
+    report = """## 评估报告 script_build_id=436
+
+### 分支评估 branch_id=3
+**该支结论**:通过
+**目标推进**:完成核心教学段落。
+
+### 分支评估 branch_id=4
+**该支结论**:部分通过
+**未通过项**:工具属性说明还需补充。
+
+### 对比与建议
+**采纳建议**:组合方案 3 与方案 4。
+"""
+    parsed = EvaluationReportParser().parse(report, kind="multipath")
+
+    assert parsed["subjects"] == ["方案 3", "方案 4"]
+    assert [item["conclusion"] for item in parsed["itemConclusions"]] == ["通过", "部分通过"]
+    assert parsed["itemConclusions"][0]["achievements"] == ["完成核心教学段落。"]
+    assert parsed["recommendation"] == "采纳建议:组合方案 3 与方案 4。"
+    assert "script_build_id" not in str(parsed)
+    assert "branch_id" not in str(parsed)
+
+
+def test_overall_report_extracts_criteria_achievements_problems_and_next_goal():
+    report = """### 评审标准
+- 内容完整性
+- 证据可靠性
+
+### 已达成
+- 三个核心段落已完成。
+
+### 问题详情
+- **paragraph_id=9**:收尾段落缺失。
+- 【中】数据说明仍需补充。
+
+### 整体结论:部分通过
+
+### 下一步引领目标
+补齐收尾并形成闭环。
+"""
+    parsed = EvaluationReportParser().parse(report, kind="overall")
+
+    assert parsed["criteria"] == ["内容完整性", "证据可靠性"]
+    assert parsed["achievements"] == ["三个核心段落已完成。"]
+    assert [item["summary"] for item in parsed["problems"]] == ["收尾段落缺失。", "【中】数据说明仍需补充。"]
+    assert parsed["problems"][1]["severity"] == "中"
+    assert parsed["conclusion"] == "部分通过"
+    assert parsed["nextGoal"] == "补齐收尾并形成闭环。"
+
+
+def test_markdown_table_is_parsed_without_exposing_internal_dimension_codes():
+    report = """### 维度评估
+
+| 维度 | 结论 | 已达成 | 问题 |
+| --- | --- | --- | --- |
+| D1 维度体系一致性 | 部分通过 | 主线统一 | 段 3 命名重复 |
+| P1 元素完整性 | 通过 | 元素齐全 |  |
+"""
+    parsed = EvaluationReportParser().parse(report, kind="overall")
+
+    assert parsed["criteria"] == ["维度体系一致性", "元素完整性"]
+    assert [item["subject"] for item in parsed["itemConclusions"]] == [
+        "维度体系一致性",
+        "元素完整性",
+    ]
+    assert parsed["problems"][0]["summary"] == "段 3 命名重复"
+    assert "D1" not in str(parsed)
+    assert "P1" not in str(parsed)
+
+
+def test_json_fence_and_wrong_build_id_never_become_business_content_or_association():
+    report = """## 评估报告 script_build_id=1393
+
+```json
+{"script_build_id": 434, "branch_id": 99, "dimension_pass": false}
+```
+
+### 整体结论:通过
+
+### 评审建议
+保留当前主线。
+"""
+    parsed = EvaluationReportParser().parse(report, kind="overall")
+
+    assert parsed["conclusion"] == "通过"
+    assert parsed["recommendation"] == "保留当前主线。"
+    serialized = str(parsed)
+    assert "1393" not in serialized
+    assert "434" not in serialized
+    assert "99" not in serialized
+    assert "dimension_pass" not in serialized
+
+
+def test_missing_headings_and_broken_markdown_still_extract_explicit_labels():
+    report = """**整体结论**:需要重试
+遗留问题:开头和结尾未呼应
+**下一轮目标**:重写收尾
+*** 未闭合的 Markdown
+"""
+    parsed = EvaluationReportParser().parse(report, kind="overall")
+
+    assert parsed["conclusion"] == "需要重试"
+    assert parsed["problems"] == [{"summary": "开头和结尾未呼应"}]
+    assert parsed["nextGoal"] == "重写收尾"
+    assert parsed["notices"] == []
+
+
+def test_business_numbers_are_not_removed_by_identifier_sanitizing():
+    report = """### 已达成
+- 领域信息 31/32 已确认。
+- 完成 5 个段落、12 个元素和 4:3 画面说明。
+
+### 整体结论:通过
+"""
+    parsed = EvaluationReportParser().parse(report)
+
+    assert parsed["achievements"] == [
+        "领域信息 31/32 已确认。",
+        "完成 5 个段落、12 个元素和 4:3 画面说明。",
+    ]
+
+
+def test_empty_or_unparseable_report_returns_stable_empty_shape():
+    parsed = EvaluationReportParser().parse("```json\n{broken}\n```", kind="overall")
+
+    assert parsed == {
+        "parserVersion": 1,
+        "kind": "overall",
+        "subjects": [],
+        "criteria": [],
+        "itemConclusions": [],
+        "achievements": [],
+        "problems": [],
+        "conclusion": None,
+        "recommendation": None,
+        "nextGoal": None,
+        "notices": ["unparsed-report"],
+    }
+
+
+def test_no_problem_markers_are_not_presented_as_warnings_and_suffix_heading_splits():
+    parsed = EvaluationReportParser().parse(
+        """
+### 分支评估 branch_id=5
+**该支结论**:通过
+**目标推进**:已完成收尾。
+**未通过项**:无显著质量问题。
+
+**领域评估维度**(已激活并判定):
+- 烹饪常识:通过
+""",
+        kind="multipath",
+    )
+
+    assert parsed["problems"] == []
+    assert parsed["itemConclusions"][0]["problems"] == []
+    assert "烹饪常识:通过" in parsed["criteria"]
+
+
+def test_positive_sentence_after_no_problem_marker_is_not_a_problem():
+    parsed = EvaluationReportParser().parse(
+        "## 问题\n无显著质量问题。脚本已达到发布标准。\n\n## 整体结论\n通过",
+        kind="overall",
+    )
+    assert parsed["problems"] == []
+
+
+def test_multipath_report_keeps_branch_groups_and_does_not_treat_not_passed_as_achievement():
+    report = """### 分支评估 branch_id=1
+**该支结论**:部分通过
+
+**目标推进**:
+- 领域信息库新增 5 条事实(id 54-58)。
+
+**未通过/存疑项**:
+- 元素创建尚未完整落地。
+
+**有据扎实度**:来源可指位。
+
+### 分支评估 branch_id=2
+**该支结论**:部分通过
+
+**目标推进**:
+- 已搭建五段骨架。
+
+**未通过/部分通过项**:
+- 数量红线违规。
+- function_elements 全部为空。
+
+### 对比与建议
+| 维度 | branch1 | branch2 | 差异依据 |
+| --- | --- | --- | --- |
+| 与本轮目标的契合度 | 高 | 高 | 各自完成主任务 |
+| 结构完备性 | 不适用 | 部分通过 | 仅方案二涉及 |
+"""
+    parsed = EvaluationReportParser().parse(report, kind="multipath")
+
+    assert parsed["branchEvaluations"][0] == {
+        "subject": "方案 1",
+        "conclusion": "部分通过",
+        "achievements": ["领域信息库新增 5 条事实(id 54-58)。"],
+        "problems": ["元素创建尚未完整落地。"],
+        "evidence": "来源可指位。",
+    }
+    assert parsed["branchEvaluations"][1]["achievements"] == ["已搭建五段骨架。"]
+    assert parsed["branchEvaluations"][1]["problems"] == ["数量红线违规。", "function_elements 全部为空。"]
+    assert "数量红线违规。" not in parsed["achievements"]
+    assert parsed["comparisonRows"][0] == {
+        "criterion": "与本轮目标的契合度",
+        "candidates": [
+            {"subject": "方案 1", "value": "高"},
+            {"subject": "方案 2", "value": "高"},
+        ],
+        "basis": "各自完成主任务",
+    }

+ 79 - 0
visualization-old/backend/tests/test_execution_view_payload_v8.py

@@ -0,0 +1,79 @@
+from app.execution_view_payload import compact_execution_view
+
+
+def test_main_payload_removes_decision_details_and_runtime_previews():
+    source = {
+        "schemaVersion": "8",
+        "objective": {
+            "decision": {
+                "semanticKind": "decision",
+                "card": {"primary": {"value": "目标"}},
+                "detail": {"blocks": [{"value": "完整业务详情"}]},
+            }
+        },
+        "rounds": [
+            {
+                "branches": [
+                    {
+                        "dataDecisions": [
+                            {
+                                "id": "data-decision:1",
+                                "recordId": 1,
+                                "reasoning": "原始理由",
+                                "sources": [{"post_id": "secret"}],
+                                "node": {"id": "data-decision:1", "card": {}},
+                            },
+                        ]
+                    },
+                    {
+                        "retrievalStage": {
+                            "agentRuns": [
+                                {
+                                    "businessLabel": "知识 Agent",
+                                    "agentType": "retrieve_data_knowledge",
+                                }
+                            ]
+                        }
+                    },
+                ],
+                "convergenceBatches": [
+                    {
+                        "review": {
+                            "id": "multipath-review:7",
+                            "eventId": 7,
+                            "roundIndex": 1,
+                            "branchIds": [1, 2],
+                            "status": "ok",
+                            "inputPreview": "完整任务",
+                            "outputPreview": "完整评审报告",
+                            "achievements": ["很长的报告内容"],
+                            "detailRef": "event:7",
+                            "decisionProjection": {
+                                "semanticKind": "decision",
+                                "card": {"primary": {"value": "通过"}},
+                                "detail": {"blocks": [{"value": "完整评审"}]},
+                            },
+                        }
+                    }
+                ]
+            }
+        ],
+    }
+
+    result = compact_execution_view(source)
+
+    assert "detail" not in result["objective"]["decision"]
+    review = result["rounds"][0]["convergenceBatches"][0]["review"]
+    assert "inputPreview" not in review
+    assert "outputPreview" not in review
+    assert "achievements" not in review
+    assert "detail" not in review["decisionProjection"]
+    data_decision = result["rounds"][0]["branches"][0]["dataDecisions"][0]
+    assert data_decision == {
+        "id": "data-decision:1",
+        "node": {"id": "data-decision:1", "card": {}},
+    }
+    agent_run = result["rounds"][0]["branches"][1]["retrievalStage"]["agentRuns"][0]
+    assert agent_run == {"businessLabel": "知识 Agent"}
+    assert "retrieve_data_" not in str(result)
+    assert source["objective"]["decision"]["detail"]

+ 690 - 0
visualization-old/backend/tests/test_main_agent_decision_projection.py

@@ -0,0 +1,690 @@
+from __future__ import annotations
+
+from app.main_agent_decision_projection import (
+    MainAgentDecisionProjector,
+    parse_goal_items,
+    parse_script_direction,
+)
+from app.runtime_event_index import RuntimeEventIndex
+from app.runtime_tool_catalog import business_tool_label
+
+
+DIRECTION = """## 选题价值主张
+用真实事件解释行业变化。
+
+## 账号价值主张
+保持专业深度与信息密度。
+
+## 创作总目标
+### 目标 1
+- 目标语句:拆解机制并让读者看懂因果链。
+- 通过标准:结构清楚。
+### 目标 2
+- 目标语句:用可核验数据支撑关键结论。
+
+## 领域评估维度
+1. 数据可信度
+2. 适用边界
+"""
+
+
+def _event(
+    event_id: int,
+    event_seq: int,
+    event_type: str,
+    event_name: str,
+    **extra,
+):
+    return {
+        "id": event_id,
+        "event_seq": event_seq,
+        "event_type": event_type,
+        "event_name": event_name,
+        "status": "ok",
+        "ended_at": f"2026-07-14T10:00:{event_seq:02d}",
+        **extra,
+    }
+
+
+def _main_scope(event_id: int = 1):
+    return _event(
+        event_id,
+        1,
+        "agent_scope",
+        "main",
+        agent_role="main",
+        agent_depth=0,
+        scope_event_id=event_id,
+        parent_event_id=None,
+    )
+
+
+def _main_tool(event_id: int, event_seq: int, name: str, **extra):
+    return _event(
+        event_id,
+        event_seq,
+        "tool_call",
+        name,
+        agent_role="main",
+        agent_depth=0,
+        scope_event_id=1,
+        parent_event_id=1,
+        **extra,
+    )
+
+
+def test_runtime_index_requires_real_main_scope_for_direct_reads():
+    events = [
+        _main_scope(),
+        _main_tool(2, 2, "get_topic_detail"),
+        _event(
+            3,
+            3,
+            "tool_call",
+            "get_topic_detail",
+            agent_role="script_evaluator",
+            agent_depth=1,
+            scope_event_id=30,
+            parent_event_id=30,
+        ),
+        _event(
+            4,
+            4,
+            "tool_call",
+            "get_topic_detail",
+            agent_role="main",
+            agent_depth=0,
+            scope_event_id=999,
+            parent_event_id=999,
+        ),
+        _main_tool(5, 5, "get_domain_info", status="error"),
+    ]
+    index = RuntimeEventIndex(events)
+
+    assert index.main_scope_ids == {1}
+    assert [event["id"] for event in index.main_direct_reads()] == [2]
+    assert [event["id"] for event in index.main_direct_reads(successful=False)] == [5]
+    assert [event["id"] for event in index.events_for_actor("main")] == [1, 2, 4, 5]
+    assert [event["id"] for event in index.events_for_actor("overall-evaluator")] == [3]
+    assert business_tool_label("get_topic_detail") == "选题"
+
+
+def test_planning_analysis_uses_last_real_main_thought_before_direction_save():
+    events = [
+        _main_scope(),
+        _main_tool(2, 2, "think_and_plan", inputData={
+            "thought_number": 1,
+            "thought_summary": "先形成旧规划",
+            "thought": "旧思考",
+            "plan": "旧计划",
+            "action": "旧下一步",
+        }),
+        _main_tool(3, 3, "think_and_plan", inputData={
+            "thought_number": 2,
+            "thought_summary": "锁定创作目标与首轮骨架",
+            "thought": "完整分析账号结构与领域下限。",
+            "plan": "保存目标,然后开启首轮。",
+            "action": "写入创作总目标",
+        }),
+        _main_tool(4, 4, "save_script_direction", inputData={"script_direction": DIRECTION}),
+        _main_tool(5, 5, "think_and_plan", inputData={
+            "thought_summary": "保存后的其他规划",
+            "thought": "不应成为开场规划",
+            "plan": "后续计划",
+            "action": "继续",
+        }),
+    ]
+
+    planning = MainAgentDecisionProjector().project(
+        RuntimeEventIndex(events),
+        script_direction=DIRECTION,
+        rounds=[],
+    )["planningAnalysis"]
+
+    assert planning == {
+        "stage": "planning-analysis",
+        "summary": "锁定创作目标与首轮骨架",
+        "thought": "完整分析账号结构与领域下限。",
+        "plan": "保存目标,然后开启首轮。",
+        "action": "写入创作总目标",
+        "eventRef": "event:3",
+        "technicalRefs": ["event:3"],
+        "completeness": "complete",
+    }
+
+
+def test_begin_round_prefers_success_output_then_falls_back_to_round_marker():
+    events = [
+        _main_scope(),
+        _main_tool(
+            2,
+            2,
+            "begin_round",
+            round_index=0,
+            output_preview='{"success": true, "round_index": 1}',
+        ),
+        _main_tool(
+            3,
+            3,
+            "begin_round",
+            round_index=1,
+            output_preview='{"success": true}',
+        ),
+        _event(
+            4,
+            4,
+            "round_begin",
+            "2",
+            agent_role="main",
+            agent_depth=0,
+            scope_event_id=1,
+            parent_event_id=1,
+            payload={"round_index": 2, "goal": "第二轮"},
+        ),
+    ]
+    index = RuntimeEventIndex(events)
+
+    assert index.resolved_begin_round(events[1]) == 1
+    assert index.resolved_begin_round(events[2]) == 2
+    assert {key: [event["id"] for event in value] for key, value in index.successful_begin_rounds().items()} == {1: [2], 2: [3]}
+
+
+def test_evaluator_association_uses_hierarchy_and_structured_round_not_report_text():
+    events = [
+        _main_scope(),
+        _event(
+            10,
+            2,
+            "agent_invoke",
+            "script_evaluator",
+            agent_role="script_evaluator",
+            agent_depth=1,
+            scope_event_id=10,
+            parent_event_id=1,
+            round_index=1,
+            agentOutputData={"summary": "## 评估报告 round=99\n### 整体结论:部分通过"},
+        ),
+        _event(
+            11,
+            3,
+            "agent_invoke",
+            "script_evaluator",
+            agent_role="script_evaluator",
+            agent_depth=1,
+            scope_event_id=11,
+            parent_event_id=None,
+            round_index=1,
+            agentOutputData={"summary": "### 整体结论:通过"},
+        ),
+    ]
+    associations = RuntimeEventIndex(events).agent_returns(
+        "script_evaluator", round_index=1
+    )
+
+    assert [(event["id"], relation) for event, relation in associations] == [
+        (10, "returned-report"),
+        (11, "runtime-associated"),
+    ]
+    assert RuntimeEventIndex(events).agent_returns("script_evaluator", round_index=99) == []
+
+
+def test_direction_parser_selects_real_goals_instead_of_value_proposition():
+    parsed = parse_script_direction(DIRECTION)
+
+    assert parsed["goals"] == [
+        "拆解机制并让读者看懂因果链。",
+        "用可核验数据支撑关键结论。",
+    ]
+    assert parsed["constraints"] == ["数据可信度", "适用边界", "结构清楚。"]
+    assert parsed["valueJudgements"][0]["label"] == "选题价值主张"
+    assert parsed["goals"][0] != parsed["valueJudgements"][0]["value"]
+    assert parsed["raw"] == DIRECTION.strip()
+
+
+def test_full_projection_preserves_db_truth_and_three_honest_decision_chains():
+    first_paths = [
+        {
+            "path_index": 1,
+            "path_type": "内容",
+            "action": "新增",
+            "target": "机制拆解段",
+            "method": "案例对照",
+            "emphasis": "结构可读性",
+        },
+        {
+            "path_index": 2,
+            "path_type": "领域信息",
+            "action": "核验",
+            "target": "行业数据",
+            "method": "交叉验证",
+        },
+    ]
+    second_paths = [
+        {"path_index": 1, "path_type": "内容", "action": "补齐", "target": "结尾"}
+    ]
+    events = [
+        _main_scope(),
+        _main_tool(2, 2, "get_topic_detail", output_preview='{"topic":{"result":"解释真实机制"},"points":[{"point_result":"测试选题"}]}'),
+        _main_tool(3, 3, "get_account_script_section_patterns", output_preview='{"\u5206\u6bb5\u89c4\u5f8b\u6458\u8981":"成品展示到机制拆解的四段结构"}'),
+        _main_tool(4, 4, "get_account_script_persona_points", output_preview='{"returned_count":82,"records":[{"\u5217":"形式"},{"\u5217":"作用"}]}'),
+        _event(
+            40,
+            5,
+            "tool_call",
+            "get_topic_detail",
+            agent_role="script_evaluator",
+            agent_depth=1,
+            scope_event_id=39,
+            parent_event_id=39,
+            output_preview='{"count":1}',
+        ),
+        _main_tool(
+            5,
+            6,
+            "save_script_direction",
+            inputData={"script_direction": DIRECTION},
+            output_preview='{"success": true}',
+        ),
+        _main_tool(
+            6,
+            7,
+            "begin_round",
+            round_index=0,
+            inputData={"goal": "搭建机制拆解主结构"},
+            output_preview='{"success": true, "round_index": 1}',
+        ),
+        _event(
+            7,
+            8,
+            "round_begin",
+            "1",
+            agent_role="main",
+            agent_depth=0,
+            scope_event_id=1,
+            parent_event_id=1,
+            payload={"round_index": 1},
+        ),
+        _main_tool(8, 9, "get_script_snapshot", round_index=1, output_preview='{"count":0}'),
+        _main_tool(
+            9,
+            10,
+            "record_multipath_plan",
+            round_index=1,
+            inputData={"paths": first_paths, "mode": "竞争", "note": "一路搭结构,一路核验数据。"},
+            output_preview='{"success": true, "round_index": 1, "path_count": 2}',
+        ),
+        _main_tool(
+            90,
+            11,
+            "record_multipath_plan",
+            round_index=1,
+            inputData={"paths": [], "mode": "竞争"},
+            status="error",
+            output_preview='{"success": false, "error": "paths required"}',
+        ),
+        _event(
+            10,
+            12,
+            "agent_invoke",
+            "script_evaluator",
+            agent_role="script_evaluator",
+            agent_depth=1,
+            scope_event_id=10,
+            parent_event_id=1,
+            round_index=1,
+            agentOutputData={
+                "summary": "## 评估报告 round=7\n### 整体结论:部分通过\n引领目标:补齐结尾\n遗留问题:缺少行动建议"
+            },
+        ),
+        _main_tool(
+            11,
+            13,
+            "begin_round",
+            round_index=1,
+            inputData={"goal": "补齐结尾。\n具体任务:增加行动建议"},
+            output_preview='{"success": true, "round_index": 2}',
+        ),
+        _event(
+            12,
+            14,
+            "round_begin",
+            "2",
+            agent_role="main",
+            agent_depth=0,
+            scope_event_id=1,
+            parent_event_id=1,
+            payload={"round_index": 2},
+        ),
+        _main_tool(
+            13,
+            15,
+            "record_multipath_plan",
+            round_index=2,
+            inputData={"paths": second_paths, "mode": "分工", "note": "任务已收敛,不需要重复探索。"},
+            output_preview='{"success": true, "round_index": 2, "path_count": 1}',
+        ),
+    ]
+    rounds = [
+        {
+            "round_index": 1,
+            "goal": "搭建机制拆解主结构",
+            "multipath_plan": first_paths,
+            "race_or_divide": "竞争",
+            "plan_note": "一路搭结构,一路核验数据。",
+        },
+        {
+            "round_index": 2,
+            "goal": "补齐结尾。\n具体任务:增加行动建议",
+            "multipath_plan": second_paths,
+            "race_or_divide": "分工",
+            "plan_note": "任务已收敛,不需要重复探索。",
+        },
+    ]
+    projection = MainAgentDecisionProjector().project(
+        RuntimeEventIndex(events),
+        script_direction=DIRECTION,
+        rounds=rounds,
+        multipath_decisions=[
+            {
+                "id": 501,
+                "round_index": 1,
+                "decision": "采用结构方案,保留数据核验结果。",
+                "reasoning": "两路结果可组合。",
+                "created_at": "2026-07-14T10:00:11",
+            }
+        ],
+    )
+
+    objective = projection["objective"]
+    assert objective["decisionItems"][0] == "拆解机制并让读者看懂因果链。"
+    assert [item["label"] for item in objective["inputs"]] == [
+        "选题",
+        "账号段落模式",
+        "账号人设",
+    ]
+    assert [item["summary"] for item in objective["inputs"]] == [
+        "测试选题:解释真实机制",
+        "成品展示到机制拆解的四段结构",
+        "返回 82 个账号表达特征,主要涉及形式、作用",
+    ]
+    assert 40 not in [int(item["detailRef"].split(":")[1]) for item in objective["inputs"]]
+
+    round_two = projection["roundGoalsByRound"][2]
+    assert [item["relation"] for item in round_two["inputs"][:2]] == [
+        "returned-report",
+        "persisted-record",
+    ]
+    assert round_two["inputs"][0]["summary"] == "部分通过"
+    assert "下一步:补齐结尾" in round_two["constraints"]
+    assert round_two["revisionRefs"] == ["event:11"]
+
+    first_plan = projection["implementationPlansByRound"][1]
+    assert first_plan["decisionItems"] == [
+        "方案 1 · 新增:机制拆解段",
+        "方案 2 · 核验:行业数据",
+    ]
+    assert first_plan["inputs"][1]["label"] == "当前主脚本"
+    assert first_plan["currentRevisionRef"] == "event:9"
+    assert first_plan["technicalRefs"] == ["event:90"]
+    assert first_plan["routeItems"][0] == {
+        "pathIndex": 1,
+        "pathType": "内容",
+        "action": "新增",
+        "target": "机制拆解段",
+        "method": "案例对照",
+        "emphasis": "结构可读性",
+    }
+
+    second_plan = projection["implementationPlansByRound"][2]
+    assert second_plan["displayMode"] == "单路"
+    assert second_plan["rawMode"] == "分工"
+
+    assert projection["objective"]["sourceDocument"] == DIRECTION.strip()
+    first_goal = projection["roundGoalsByRound"][1]
+    assert first_goal["decisionItems"] == ["搭建机制拆解主结构"]
+    assert first_goal["sourceDocument"] == "搭建机制拆解主结构"
+    assert "goalSections" not in first_goal
+
+
+def test_round_goal_inputs_keep_complete_business_text():
+    long_goal = "完整创作目标需要保留全部约束和判断依据。" * 24
+    long_report = "上一轮整体评审的完整结论需要原样传给下一轮。" * 20
+    long_decision = "上一轮主 Agent 的完整多路决策需要原样保留。" * 20
+    direction = f"## 创作总目标\n- 目标语句:{long_goal}"
+    events = [
+        _main_scope(),
+        _main_tool(
+            2,
+            2,
+            "save_script_direction",
+            inputData={"script_direction": direction},
+            output_preview='{"success": true}',
+        ),
+        _main_tool(
+            3,
+            3,
+            "begin_round",
+            round_index=0,
+            output_preview='{"success": true, "round_index": 1}',
+        ),
+        _event(
+            4,
+            4,
+            "agent_invoke",
+            "script_evaluator",
+            agent_role="script_evaluator",
+            agent_depth=1,
+            scope_event_id=4,
+            parent_event_id=1,
+            round_index=1,
+            agentOutputData={"summary": f"### 整体结论:{long_report}"},
+        ),
+        _main_tool(
+            5,
+            5,
+            "begin_round",
+            round_index=1,
+            output_preview='{"success": true, "round_index": 2}',
+        ),
+    ]
+    projection = MainAgentDecisionProjector().project(
+        RuntimeEventIndex(events),
+        script_direction=direction,
+        rounds=[
+            {"round_index": 1, "goal": "第一轮", "multipath_plan": []},
+            {"round_index": 2, "goal": "第二轮", "multipath_plan": []},
+        ],
+        multipath_decisions=[
+            {
+                "id": 88,
+                "round_index": 1,
+                "decision": long_decision,
+                "created_at": "2026-07-14T10:00:04",
+            }
+        ],
+    )
+
+    first_round_input = projection["roundGoalsByRound"][1]["inputs"][0]["summary"]
+    second_round_inputs = projection["roundGoalsByRound"][2]["inputs"]
+    assert first_round_input == "\n\n".join(projection["objective"]["decisionItems"])
+    assert len(first_round_input) > 220
+    assert second_round_inputs[0]["summary"] == long_report
+    assert second_round_inputs[1]["summary"] == long_decision
+    assert all(not str(item["summary"]).endswith("…") for item in second_round_inputs[:2])
+
+
+def test_db_only_projection_degrades_without_inventing_runtime_evidence():
+    projection = MainAgentDecisionProjector().project(
+        RuntimeEventIndex([]),
+        script_direction="做一个清晰的行业解释。",
+        rounds=[
+            {
+                "round_index": 1,
+                "goal": "搭建结构",
+                "multipath_plan": [{"path_index": 1, "target": "开场"}],
+                "race_or_divide": "分工",
+                "plan_note": None,
+            }
+        ],
+    )
+
+    assert projection["objective"]["completeness"] == "partial"
+    assert projection["objective"]["inputs"] == []
+    assert projection["roundGoalsByRound"][1]["completeness"] == "partial"
+    assert projection["implementationPlansByRound"][1]["completeness"] == "partial"
+    assert projection["implementationPlansByRound"][1]["explicitReasoning"] is None
+
+
+def test_multiple_saves_and_plan_versions_preserve_history_and_separate_failures():
+    old_direction = "## 创作总目标\n- 目标语句:旧目标"
+    current_direction = "## 创作总目标\n- 目标语句:新目标"
+    old_paths = [{"path_index": 1, "action": "新增", "target": "开场"}]
+    current_paths = [{"path_index": 1, "action": "改写", "target": "开场"}]
+    events = [
+        _main_scope(),
+        _main_tool(2, 2, "save_script_direction", inputData={"script_direction": old_direction}, output_preview='{"success":true}'),
+        _main_tool(3, 3, "save_script_direction", inputData={"script_direction": current_direction}, output_preview='{"success":true}'),
+        _main_tool(4, 4, "save_script_direction", status="error", inputData={"script_direction": "失败版"}, output_preview='{"success":false,"error":"write failed"}'),
+        _main_tool(5, 5, "begin_round", round_index=0, output_preview='{"success":true,"round_index":1}'),
+        _main_tool(6, 6, "record_multipath_plan", round_index=1, inputData={"paths": old_paths, "mode": "竞争", "note": "初版"}, output_preview='{"success":true,"round_index":1}'),
+        _main_tool(7, 7, "record_multipath_plan", round_index=1, inputData={"paths": current_paths, "mode": "分工", "note": "修订版"}, output_preview='{"success":true,"round_index":1}'),
+    ]
+    projection = MainAgentDecisionProjector().project(
+        RuntimeEventIndex(events),
+        script_direction=current_direction,
+        rounds=[{
+            "round_index": 1,
+            "goal": "修正开场",
+            "multipath_plan": current_paths,
+            "race_or_divide": "分工",
+            "plan_note": "修订版",
+        }],
+    )
+
+    assert projection["objective"]["revisionRefs"] == ["event:2", "event:3"]
+    assert projection["objective"]["currentRevisionRef"] == "event:3"
+    assert projection["objective"]["technicalRefs"] == ["event:4"]
+    plan = projection["implementationPlansByRound"][1]
+    assert plan["revisionRefs"] == ["event:6", "event:7"]
+    assert [item["current"] for item in plan["revisions"]] == [False, True]
+    assert plan["currentRevisionRef"] == "event:7"
+
+
+def test_goal_parser_keeps_the_saved_goal_as_one_item():
+    goal = "本轮要补齐结构\n① 增加开场\n② 补齐数据"
+    assert parse_goal_items(goal) == [goal]
+
+
+def test_plan_revision_matches_business_alias_without_changing_raw_db_mode():
+    paths = [{"path_index": 1, "action": "改写", "target": "开场"}]
+    events = [
+        _main_scope(),
+        _main_tool(2, 2, "begin_round", round_index=0, output_preview='{"success":true,"round_index":1}'),
+        _main_tool(
+            3,
+            3,
+            "record_multipath_plan",
+            round_index=1,
+            inputData={"paths": paths, "mode": "竞争", "note": "比较两路表达"},
+            output_preview='{"success":true,"round_index":1}',
+        ),
+    ]
+    plan = MainAgentDecisionProjector().project(
+        RuntimeEventIndex(events),
+        script_direction=None,
+        rounds=[{
+            "round_index": 1,
+            "goal": "修正开场",
+            "multipath_plan": paths,
+            "race_or_divide": "赛马",
+            "plan_note": "比较两路表达",
+        }],
+    )["implementationPlansByRound"][1]
+
+    assert plan["rawMode"] == "赛马"
+    assert plan["currentRevisionRef"] == "event:3"
+
+
+def test_failed_begin_round_does_not_reuse_its_old_round_context():
+    events = [
+        _main_scope(),
+        _main_tool(
+            2,
+            2,
+            "begin_round",
+            round_index=1,
+            status="error",
+            output_preview='{"success":false,"error":"write failed"}',
+        ),
+    ]
+    projection = MainAgentDecisionProjector().project(
+        RuntimeEventIndex(events),
+        script_direction="创作目标",
+        rounds=[
+            {"round_index": 1, "goal": "第一轮", "multipath_plan": []},
+            {"round_index": 2, "goal": "第二轮", "multipath_plan": []},
+        ],
+    )
+
+    assert projection["roundGoalsByRound"][1]["technicalRefs"] == []
+    assert projection["roundGoalsByRound"][2]["technicalRefs"] == []
+    assert projection["unassigned"][0]["eventId"] == 2
+    assert projection["unassigned"][0]["association"] == "failed-main-decision-call-unassigned"
+
+
+def test_round_goal_keeps_report_problems_and_rejects_late_db_decisions():
+    events = [
+        _main_scope(),
+        _event(
+            10,
+            2,
+            "agent_invoke",
+            "script_evaluator",
+            agent_role="script_evaluator",
+            agent_depth=1,
+            scope_event_id=10,
+            parent_event_id=1,
+            round_index=1,
+            agentOutputData={
+                "summary": """### 整体结论:部分通过
+
+**问题详情**:
+- **paragraph_id=9**:收尾段落缺失。
+
+**下一步引领目标**:
+**补齐收尾并形成闭环。**
+"""
+            },
+        ),
+        _main_tool(
+            11,
+            5,
+            "begin_round",
+            round_index=1,
+            started_at="2026-07-14T10:00:05",
+            output_preview='{"success":true,"round_index":2}',
+        ),
+    ]
+    projection = MainAgentDecisionProjector().project(
+        RuntimeEventIndex(events),
+        script_direction="创作目标",
+        rounds=[
+            {"round_index": 1, "goal": "第一轮", "multipath_plan": []},
+            {"round_index": 2, "goal": "补齐收尾", "multipath_plan": []},
+        ],
+        multipath_decisions=[
+            {
+                "id": 71,
+                "round_index": 1,
+                "decision": "采用方案 1",
+                "created_at": "2026-07-14T10:00:06",
+            }
+        ],
+    )
+    goal = projection["roundGoalsByRound"][2]
+
+    assert "问题:收尾段落缺失。" in goal["constraints"]
+    assert "下一步:补齐收尾并形成闭环。" in goal["constraints"]
+    assert not any(value.endswith("---") for value in goal["constraints"])
+    assert not any(item["label"] == "上一轮主 Agent 多路决策" for item in goal["inputs"])
+    assert "multipath-decision:71" in goal["technicalRefs"]

+ 88 - 0
visualization-old/backend/tests/test_main_decision_text.py

@@ -0,0 +1,88 @@
+from app.main_decision_text import (
+    parse_goal_text,
+    parse_objective_text,
+    plan_mode_label,
+    plan_path_summary,
+)
+
+
+def test_objective_parser_prefers_real_target_statement_over_value_proposition():
+    value = """## 选题价值主张
+这是选题价值,不是目标。
+
+## 账号价值主张
+这是账号价值。
+
+## 创作总目标
+### 目标 1
+- 目标语句:形成清晰可执行的内容主线。
+- 通过标准:必须包含完整结构。
+### 目标 2
+- 目标语句:补齐可靠的数据说明。
+
+## 领域评估维度
+### 领域维度 1
+- name:行业事实准确性
+"""
+    parsed = parse_objective_text(value)
+
+    assert parsed["headline"] == "形成清晰可执行的内容主线。"
+    assert parsed["targets"] == ["形成清晰可执行的内容主线。", "补齐可靠的数据说明。"]
+    assert parsed["topicValue"] == "这是选题价值,不是目标。"
+    assert parsed["accountValue"] == "这是账号价值。"
+    assert parsed["targetCount"] == 2
+    assert parsed["domainDimensionCount"] == 1
+    assert parsed["constraintItems"] == [
+        {"label": "通过标准", "value": "必须包含完整结构。", "group": "目标 1"}
+    ]
+    assert parsed["constraintGroups"] == [
+        {
+            "title": "目标 1",
+            "kind": "target",
+            "dimensionName": None,
+            "items": [{"label": "通过标准", "value": "必须包含完整结构。"}],
+        },
+        {
+            "title": "领域维度 1",
+            "kind": "domain",
+            "dimensionName": "行业事实准确性",
+            "items": [],
+        },
+    ]
+
+
+def test_objective_parser_falls_back_without_inventing_a_summary():
+    parsed = parse_objective_text("把产品机制解释清楚,并给出真实案例。")
+    assert parsed["headline"] == "把产品机制解释清楚,并给出真实案例。"
+    assert parsed["targets"] == []
+    assert parsed["structured"] is False
+
+
+def test_goal_parser_keeps_the_complete_saved_goal_as_one_value():
+    value = "补齐烹饪步骤。\n① 写清关键动作\n② 补充火候说明\n③ 加入避坑提示"
+    parsed = parse_goal_text(value)
+    assert parsed["headline"] == value
+    assert parsed["focusItems"] == []
+    assert parsed["raw"] == value
+    assert "sections" not in parsed
+
+
+def test_goal_parser_does_not_infer_roles_from_sentence_keywords():
+    value = (
+        "立起脚本的段落骨架。"
+        "竞争不同的框架切分与编排策略,选出整体最佳方案。"
+        "本轮只做段落结构,不填充具体文案。"
+    )
+
+    parsed = parse_goal_text(value)
+
+    assert parsed == {"headline": value, "focusItems": [], "raw": value}
+
+
+def test_single_path_is_business_single_even_when_raw_mode_is_divide():
+    assert plan_mode_label("分工", 1) == "单路"
+    assert plan_mode_label("竞争", 2) == "竞争"
+    assert plan_path_summary(
+        {"path_index": 2, "action": "增", "target": "烹饪步骤", "method": "补充细节"},
+        2,
+    ) == "方案 2:新增 · 烹饪步骤 · 补充细节"

+ 261 - 0
visualization-old/backend/tests/test_module_audit.py

@@ -0,0 +1,261 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from importlib import import_module
+
+from fastapi.testclient import TestClient
+
+from app import main
+from app.module_audit.service import ModuleAuditService
+
+
+class _Provider:
+    mode = "test"
+
+    def __init__(self, bundle):
+        self.bundle = deepcopy(bundle)
+        self.load_bundle_calls = 0
+
+    def load_bundle(self, _script_build_id: int):
+        self.load_bundle_calls += 1
+        return deepcopy(self.bundle)
+
+    def load_event_detail(self, _script_build_id: int, event_id: int):
+        event = next(item for item in self.bundle["events"] if item["id"] == event_id)
+        return {
+            **deepcopy(event),
+            "input": {"content": deepcopy(event.get("inputData"))},
+            "output": {
+                "content": deepcopy(
+                    event.get("agentOutputData") or event.get("output_preview")
+                )
+            },
+        }
+
+
+class _Repository:
+    def __init__(self, bundle):
+        self.events = {item["id"]: deepcopy(item) for item in bundle["events"]}
+        self.load_event_calls = 0
+        self.load_log_calls = 0
+
+    def load_event(self, _script_build_id: int, event_id: int):
+        self.load_event_calls += 1
+        event = deepcopy(self.events.get(event_id))
+        if event is None:
+            return None
+        event["eventBody"] = {
+            "input_content_type": "application/json",
+            "input_content": '{"task":"完整派发任务"}',
+            "output_content_type": "text/plain",
+            "output_content": "完整运行返回",
+        }
+        return event
+
+    def load_log(self, _script_build_id: int):
+        self.load_log_calls += 1
+        return (
+            "[AGENT_TASK:script_implementer]\n"
+            "[MSG_ANCHOR:msg_id=task-50:seq=10]\n"
+            "完整派发任务\n"
+            "[TOOL_ANCHOR:msg_id=tool-51:seq=11]\n"
+            "后续工具调用"
+        )
+
+
+def _service(current_bundle):
+    return ModuleAuditService(
+        provider=_Provider(current_bundle), repository=_Repository(current_bundle)
+    )
+
+
+def test_audit_index_is_lightweight_and_collects_full_flow_modules(current_bundle):
+    payload = _service(current_bundle).index(88001)
+
+    module_ids = {item["id"] for item in payload["modules"]}
+    assert "run:objective" in module_ids
+    assert "round:1:branch:1:task" in module_ids
+    assert "event:53" in module_ids
+    assert "round:1:result" in module_ids
+    assert not any(item["displayVariant"] == "collapsed-summary" for item in payload["modules"])
+    assert "round:1:summary" not in module_ids
+    assert "round:1:branch:1:summary" not in module_ids
+    assert "eventBody" not in str(payload)
+    assert "contexts" not in payload
+
+
+def test_audit_reuses_completed_run_snapshot_between_index_and_detail(current_bundle):
+    service = _service(current_bundle)
+
+    service.index(88001)
+    service.detail(88001, "round:1:branch:1:task")
+
+    assert service.provider.load_bundle_calls == 1
+
+
+def test_audit_reuses_completed_module_detail(current_bundle):
+    service = _service(current_bundle)
+
+    first = service.detail(88001, "round:1:branch:1:task")
+    event_calls = service.repository.load_event_calls
+    log_calls = service.repository.load_log_calls
+    second = service.detail(88001, "round:1:branch:1:task")
+
+    assert second is first
+    assert service.repository.load_event_calls == event_calls
+    assert service.repository.load_log_calls == log_calls
+
+
+def test_branch_task_audit_separates_card_event_and_fallback_sources(current_bundle):
+    current_bundle["events"][4]["msg_id"] = "task-50"
+    current_bundle["events"][4]["inputData"] = {
+        "task": "完整的实现 Agent 派发任务"
+    }
+    payload = _service(current_bundle).detail(88001, "round:1:branch:1:task")
+
+    assert payload["actualUse"]["card"]["title"] == "实现任务"
+    reads = payload["actualReads"]
+    assert any(
+        item["consumer"] == "card"
+        and item["fieldPath"] == "impl_task"
+        and item["disposition"] == "selected"
+        for item in reads
+    )
+    event_read = next(
+        item
+        for item in reads
+        if item["consumer"] == "inspector"
+        and item["fieldPath"] == "inputData.task"
+    )
+    assert event_read["value"] == "完整的实现 Agent 派发任务"
+    assert event_read["disposition"] == "selected"
+    assert event_read["transformation"] == "直接摘取"
+    assert event_read["source"]["sourceKind"] == "database"
+    assert event_read["sourceKey"] == "database:event:50"
+    assert event_read["usageAreas"] == [
+        "Inspector 业务详情",
+        "Inspector 技术详情",
+    ]
+    assert event_read["usageRatio"] == {
+        "calculable": True,
+        "value": 1.0,
+        "label": "100.0%",
+    }
+    assert event_read["readId"].startswith("read:")
+    assert event_read["contextRefs"] == [
+        {
+            "contextId": "event:50",
+            "sourceKey": "database:event:50",
+            "contextFieldPath": "inputData.task",
+            "relation": "direct",
+            "exact": True,
+        }
+    ]
+    fallback_read = next(
+        item
+        for item in reads
+        if item["consumer"] == "inspector"
+        and item["fieldPath"] == "impl_task"
+    )
+    assert fallback_read["disposition"] == "fallback"
+    assert fallback_read["sourcePriority"] == "备用来源"
+    assert fallback_read["usageRatio"]["calculable"] is False
+    assert any(
+        item["consumer"] == "inspector"
+        and item["fieldPath"] == "technical.branch"
+        and item["transformation"] == "完整记录读取"
+        for item in reads
+    )
+    assert any(
+        context["kind"] == "event-record"
+        and context["content"]["eventBody"]["output_content"] == "完整运行返回"
+        and isinstance(context["usedFragments"], list)
+        for context in payload["contexts"]
+    )
+    assert not any(item["label"] == "table" for item in reads)
+    assert any(
+        context["kind"] == "function-result"
+        and context["source"]["sourceKind"] == "function"
+        for context in payload["contexts"]
+    )
+    assert any(
+        context["kind"] == "log-module"
+        and context["source"]["sourceKind"] == "log"
+        and context["relatedSourceKey"] == "database:event:50"
+        for context in payload["contexts"]
+    )
+    assert any(context["kind"] == "log-module" for context in payload["contexts"])
+
+
+def test_audit_routes_serve_page_assets_and_full_stream(monkeypatch, current_bundle):
+    module_audit_router = import_module("app.module_audit.router")
+    audit_service = _service(current_bundle)
+    monkeypatch.setattr(module_audit_router, "service", audit_service)
+    client = TestClient(main.app)
+
+    page = client.get("/audit/88001")
+    assert page.status_code == 200
+    assert 'data-run-id="88001"' in page.text
+    assert 'id="module-jump"' in page.text
+    assert "只看使用字段" in page.text
+    assert 'id="source-filter"' in page.text
+    assert client.get("/audit-assets/styles.css").status_code == 200
+    assert client.get("/audit-assets/app.js").status_code == 200
+
+    index = client.get("/api/script-builds/88001/module-audit")
+    assert index.status_code == 200
+    assert "eventBody" not in index.text
+
+    detail = client.get(
+        "/api/script-builds/88001/module-audit/round%3A1%3Abranch%3A1%3Atask"
+    )
+    assert detail.status_code == 200
+    assert detail.json()["id"] == "round:1:branch:1:task"
+
+    stream = client.get("/api/script-builds/88001/module-audit-stream")
+    assert stream.status_code == 200
+    assert stream.headers["content-type"].startswith("application/x-ndjson")
+    assert '"type":"index"' in stream.text
+    assert '"type":"detail"' in stream.text
+    assert '"type":"complete"' in stream.text
+
+
+def test_audit_page_collapses_large_blocks_without_truncating_source():
+    script = (
+        main.__file__.replace("main.py", "module_audit/static/app.js")
+    )
+    with open(script, encoding="utf-8") as handle:
+        source = handle.read()
+
+    assert "count > 800" in source
+    assert "slice(0, length)" in source
+    assert "jsonText(value)" in source
+    assert 'contentBox("技术详情"' in source
+    assert 'contentBox("业务详情"' in source
+    assert 'class="businessPart${' in source
+    assert 'class="correspondenceRow"' in source
+    assert "contextsForGroups" in source
+    assert "correlationMark" in source
+    assert "setCorrelationFocus" in source
+    assert "businessSegments" in source
+    assert "cardSegments" in source
+    assert "populateModuleJump" in source
+    assert "jumpToModule" in source
+    assert "scrollIntoView" in source
+    assert "renderCompactReads" in source
+    assert "renderCompactContexts" in source
+    assert "实际读取值" in source
+    assert "原始字段值" in source
+    assert "原始记录位置" in source
+    assert "定位原始字段" in source
+    assert "data-locate-read-id" in source
+    assert "data-read-ids" in source
+    assert "module-audit-stream" in source
+    assert "requestAnimationFrame" in source
+    assert 'get("module")' in source
+    assert "data-load-module" not in source
+    assert "IntersectionObserver" not in source
+    assert "Inspector 业务详情 ·" not in source
+    assert "Inspector 读取 · table" not in source
+    assert "来源投影" not in source
+    assert "数据库:" in source and "函数:" in source and "日志:" in source

+ 126 - 0
visualization-old/backend/tests/test_repository_rules.py

@@ -0,0 +1,126 @@
+import inspect
+from types import SimpleNamespace
+
+from app.repositories import ScriptBuildRepository
+
+
+class Row:
+    def __init__(self, **values):
+        self.__dict__.update(values)
+        self.__table__ = SimpleNamespace(
+            columns=[SimpleNamespace(name=name) for name in values]
+        )
+
+
+def test_final_snapshot_filters_branch_zero_and_overlay_replaces_base_rows():
+    repository = ScriptBuildRepository()
+    paragraphs = [
+        Row(id=1, branch_id=0, is_active=True, base_ref_id=None, content="base"),
+        Row(id=2, branch_id=7, is_active=True, base_ref_id=1, content="overlay"),
+        Row(id=3, branch_id=9, is_active=True, base_ref_id=None, content="other"),
+    ]
+    elements = [
+        Row(id=10, branch_id=0, is_active=True, base_ref_id=None, content="base-element"),
+        Row(id=11, branch_id=7, is_active=True, base_ref_id=10, content="overlay-element"),
+    ]
+    links = [
+        Row(id=20, branch_id=0, paragraph_id=1, element_id=10),
+        Row(id=21, branch_id=7, paragraph_id=2, element_id=11),
+    ]
+
+    final_snapshot = repository._snapshot_for_branch(0, paragraphs, elements, links)
+    assert {row["branch_id"] for row in final_snapshot["paragraphs"]} == {0}
+    assert {row["branch_id"] for row in final_snapshot["elements"]} == {0}
+    assert {row["branch_id"] for row in final_snapshot["paragraphElements"]} == {0}
+
+    overlay = repository._snapshot_for_branch(7, paragraphs, elements, links)
+    assert [row["content"] for row in overlay["paragraphs"]] == ["overlay"]
+    assert [row["content"] for row in overlay["elements"]] == ["overlay-element"]
+
+
+def test_candidate_snapshot_uses_saved_content_for_terminal_branch_and_overlay_for_park():
+    repository = ScriptBuildRepository()
+    merged = Row(status="merged", branch_id=1, content_snapshot={"paragraphs": [{"id": "saved"}]})
+    parked = Row(status="parked", branch_id=2, content_snapshot=None)
+
+    saved = repository._candidate_snapshot(merged, paragraphs=[], elements=[], links=[])
+    assert saved["snapshot"]["paragraphs"][0]["id"] == "saved"
+    assert saved["historicalAccuracy"] == "exact"
+    assert saved["snapshotKind"] == "saved-at-disposition"
+
+    overlay = repository._candidate_snapshot(parked, paragraphs=[], elements=[], links=[])
+    assert overlay["sourceOrigin"] == "database"
+    assert overlay["historicalAccuracy"] == "unknown"
+    assert overlay["currentProjectionAccuracy"] == "exact"
+    assert "不是暂存时的历史快照" in overlay["note"]
+
+
+def test_terminal_branch_without_snapshot_is_not_reconstructed_from_current_base():
+    repository = ScriptBuildRepository()
+    merged = Row(status="merged", branch_id=7, content_snapshot=None)
+    result = repository._candidate_snapshot(merged, paragraphs=[], elements=[], links=[])
+    assert result["snapshot"] is None
+    assert result["historicalAccuracy"] == "missing"
+    assert result["currentProjectionAccuracy"] == "not-applicable"
+    assert result["snapshotKind"] == "missing-disposition-snapshot"
+
+
+def test_base_links_require_both_active_endpoints_and_override_has_effective_id():
+    repository = ScriptBuildRepository()
+    paragraphs = [
+        Row(id=1, branch_id=0, is_active=True, base_ref_id=None),
+        Row(id=2, branch_id=0, is_active=False, base_ref_id=None),
+        Row(id=3, branch_id=7, is_active=True, base_ref_id=1),
+    ]
+    elements = [Row(id=10, branch_id=0, is_active=True, base_ref_id=None)]
+    links = [
+        Row(id=20, branch_id=0, paragraph_id=1, element_id=10),
+        Row(id=21, branch_id=0, paragraph_id=2, element_id=10),
+    ]
+    base = repository._snapshot_for_branch(0, paragraphs, elements, links)
+    assert [row["id"] for row in base["paragraphElements"]] == [20]
+    overlay = repository._snapshot_for_branch(7, paragraphs, elements, links)
+    assert overlay["paragraphs"][0]["rowId"] == 3
+    assert overlay["paragraphs"][0]["effectiveId"] == 1
+
+
+def test_repository_implementation_contains_no_write_or_commit_calls():
+    source = inspect.getsource(ScriptBuildRepository)
+    for forbidden in (".commit(", ".flush(", ".add(", ".delete(", ".execute("):
+        assert forbidden not in source
+
+
+def test_latest_split_event_body_fields_are_parsed_without_old_schema():
+    repository = ScriptBuildRepository()
+    body = Row(
+        id=1,
+        event_id=9,
+        input_content_type="json",
+        input_content='{"task": "查找案例"}',
+        output_content_type="json",
+        output_content='{"summary": "初筛完成"}',
+    )
+    parsed = repository._event_body(body)
+    assert parsed["input"]["content"] == {"task": "查找案例"}
+    assert parsed["output"]["content"] == {"summary": "初筛完成"}
+    source = inspect.getsource(ScriptBuildRepository._event_body)
+    assert "input_body_id" not in source
+    assert "output_body_id" not in source
+
+
+def test_oversized_event_body_is_safely_bounded_and_reports_omitted_characters():
+    raw = '{"content":"' + ("x" * 250_000) + '"}'
+    parsed = ScriptBuildRepository._body_side("json", raw)
+
+    assert parsed["truncated"] is True
+    assert parsed["omittedCharacters"] == len(raw) - 240_000
+    assert "[truncated" in parsed["content"]
+    assert len(parsed["content"]) < len(raw)
+
+
+def test_main_view_reads_bodies_only_for_retrieval_evaluation_and_creative_agents():
+    repository = ScriptBuildRepository()
+    assert repository._needs_light_agent_body({"event_type": "agent_invoke", "event_name": "retrieve_data_knowledge"})
+    assert repository._needs_light_agent_body({"event_type": "agent_invoke", "event_name": "script_multipath_evaluator"})
+    assert repository._needs_light_agent_body({"event_type": "agent_invoke", "event_name": "script_implementer"})
+    assert not repository._needs_light_agent_body({"event_type": "tool_call", "event_name": "retrieve_data_knowledge"})

+ 159 - 0
visualization-old/backend/tests/test_retrieval_detail_projection.py

@@ -0,0 +1,159 @@
+from app.retrieval_detail_projection import (
+    project_retrieval_event,
+    summarize_retrieval_output,
+)
+
+
+def _event(event_id, name, input_value, output_value, **overrides):
+    event = {
+        "id": event_id,
+        "event_type": "tool_call",
+        "event_name": name,
+        "status": "ok",
+        "ended_at": "2026-07-14T20:00:00",
+        "input": {"contentType": "json", "content": input_value},
+        "output": {"contentType": "json", "content": output_value},
+    }
+    event.update(overrides)
+    return event
+
+
+def test_case_hit_preserves_conditions_and_complete_case_content():
+    detail = project_retrieval_event(
+        _event(
+            4607,
+            "search_script_decode_case",
+            {"keyword": "毛驴效应", "top_k": 3, "return_field": "主脉络"},
+            {
+                "success": True,
+                "count": 2,
+                "results": [
+                    {
+                        "post_id": "case-a",
+                        "account": "每天心理学",
+                        "score": 0.7032,
+                        "return_field": "主脉络",
+                        "data": [{"id": "段落1", "详细描述": "完整解构内容"}],
+                    },
+                    {
+                        "post_id": "case-b",
+                        "account": "每天心理学",
+                        "score": 0.68,
+                        "return_field": "主脉络",
+                        "data": [{"id": "段落1", "详细描述": "第二条"}],
+                    },
+                ],
+            },
+        )
+    )
+
+    assert detail["detailKind"] == "retrieval-event"
+    assert detail["sourceKind"] == "agent-query"
+    assert detail["outcome"] == {
+        "state": "hit",
+        "count": 2,
+        "message": "查询成功,返回 2 条结果。",
+    }
+    assert [item["title"] for item in detail["items"]] == [
+        "每天心理学 · Case 1",
+        "每天心理学 · Case 2",
+    ]
+    assert detail["items"][0]["sections"][0]["value"][0]["详细描述"] == "完整解构内容"
+    assert [field["label"] for field in detail["queryConditions"]] == ["关键词", "最多返回", "返回内容"]
+
+
+def test_successful_empty_is_not_a_failure_and_keeps_query_conditions():
+    detail = project_retrieval_event(
+        _event(
+            4603,
+            "search_script_decode_case",
+            {"keyword": "毛驴效应", "account_name": "每天一点心理学", "top_k": 3},
+            {"success": True, "count": 0, "results": []},
+        )
+    )
+
+    assert detail["outcome"] == {
+        "state": "empty",
+        "count": 0,
+        "message": "查询执行成功,没有匹配数据。",
+    }
+    assert detail["items"] == []
+    assert {item["label"] for item in detail["queryConditions"]} == {"关键词", "账号", "最多返回"}
+
+
+def test_text_failure_exposes_readable_parameter_error():
+    detail = project_retrieval_event(
+        _event(
+            4066,
+            "search_script_decode_case",
+            {"return_field": "写法"},
+            "错误: return_field 仅支持 ['主脉络', '主题']",
+            status="error",
+        )
+    )
+
+    assert detail["outcome"]["state"] == "failure"
+    assert detail["outcome"]["errorKind"] == "parameter"
+    assert "return_field 仅支持" in detail["outcome"]["message"]
+
+
+def test_knowledge_list_count_and_content_are_projected():
+    detail = project_retrieval_event(
+        _event(
+            4574,
+            "search_knowledge",
+            {"keyword": "决策瘫痪", "max_count": 3},
+            [
+                {"id": 1, "title": "知识 A", "purpose": "用于核对", "content": "正文 A"},
+                {"id": 2, "title": "知识 B", "purpose": "用于行动建议", "content": "正文 B"},
+            ],
+        )
+    )
+
+    assert detail["outcome"]["count"] == 2
+    assert detail["outcome"]["state"] == "hit"
+    assert detail["items"][1]["sections"] == [{"title": "完整内容", "value": "正文 B"}]
+
+
+def test_direct_tool_has_direct_source_and_structured_output():
+    detail = project_retrieval_event(
+        _event(
+            4650,
+            "get_script_snapshot",
+            {"script_build_id": 444},
+            {"script_build": {"id": 444}, "paragraphs": [{"id": 2284}], "elements": []},
+        )
+    )
+
+    assert detail["sourceKind"] == "direct-tool"
+    assert detail["outcome"]["state"] == "hit"
+    assert [section["title"] for section in detail["items"][0]["sections"]] == ["脚本概况", "段落"]
+
+
+def test_terminal_run_marks_unfinished_event_interrupted_before_other_rules():
+    detail = project_retrieval_event(
+        _event(
+            5000,
+            "search_knowledge",
+            {"keyword": "决策"},
+            [{"title": "还未完成的结果"}],
+            status="running",
+            ended_at=None,
+        ),
+        run_status="failed",
+    )
+
+    assert detail["outcome"]["state"] == "interrupted"
+    assert detail["items"] == []
+
+
+def test_light_summary_does_not_include_full_results():
+    summary = summarize_retrieval_output(
+        "search_script_decode_case",
+        raw_status="ok",
+        ended_at="2026-07-14T20:00:00",
+        output={"success": True, "count": 1, "results": [{"data": "x" * 1000}]},
+    )
+
+    assert summary == {"state": "hit", "count": 1, "error": None}
+    assert "results" not in summary

+ 75 - 0
visualization-old/backend/tests/test_runtime_bridge.py

@@ -0,0 +1,75 @@
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy import create_engine
+
+from app import runtime_bridge
+
+
+def test_database_host_override_is_host_only(monkeypatch):
+    monkeypatch.setenv("PATTERN_DB_HOST_OVERRIDE", "192.168.202.204")
+    assert runtime_bridge.database_host_override() == "192.168.202.204"
+
+    for invalid in ("mysql://host", "host:3306", "user@host", "host name"):
+        monkeypatch.setenv("PATTERN_DB_HOST_OVERRIDE", invalid)
+        with pytest.raises(ValueError):
+            runtime_bridge.database_host_override()
+
+
+def test_override_factory_replaces_only_host_and_keeps_parent_database(monkeypatch):
+    class FakeDatabaseManager:
+        def __init__(self):
+            self.engine = create_engine("mysql+pymysql://reader:secret@old-host:3306/open_aigc_pattern")
+
+    monkeypatch.setattr(
+        runtime_bridge,
+        "load_runtime_modules",
+        lambda: (SimpleNamespace(DatabaseManager=FakeDatabaseManager), SimpleNamespace()),
+    )
+    runtime_bridge._read_only_session_factory.cache_clear()
+    factory = runtime_bridge._read_only_session_factory("/runtime", "192.168.202.204")
+    engine = factory.kw["bind"]
+    try:
+        assert engine.url.host == "192.168.202.204"
+        assert engine.url.port == 3306
+        assert engine.url.database == "open_aigc_pattern"
+        assert engine.url.username == "reader"
+        assert engine.url.password == "secret"
+    finally:
+        engine.dispose()
+        runtime_bridge._read_only_session_factory.cache_clear()
+
+
+def test_read_only_factory_keeps_original_host_without_override(monkeypatch):
+    class FakeDatabaseManager:
+        def __init__(self):
+            self.engine = create_engine("mysql+pymysql://reader:secret@db-host:3306/open_aigc_pattern")
+
+    monkeypatch.setattr(
+        runtime_bridge,
+        "load_runtime_modules",
+        lambda: (SimpleNamespace(DatabaseManager=FakeDatabaseManager), SimpleNamespace()),
+    )
+    runtime_bridge._read_only_session_factory.cache_clear()
+    factory = runtime_bridge._read_only_session_factory("/runtime", "")
+    engine = factory.kw["bind"]
+    try:
+        assert engine.url.host == "db-host"
+        assert engine.url.database == "open_aigc_pattern"
+    finally:
+        engine.dispose()
+        runtime_bridge._read_only_session_factory.cache_clear()
+
+
+def test_read_only_hook_executes_only_session_guard():
+    statements = []
+
+    class Cursor:
+        def execute(self, statement):
+            statements.append(statement)
+
+        def close(self):
+            statements.append("closed")
+
+    runtime_bridge._set_mysql_session_read_only(SimpleNamespace(cursor=lambda: Cursor()), None)
+    assert statements == ["SET SESSION TRANSACTION READ ONLY", "closed"]

+ 401 - 0
visualization-old/backend/tests/test_runtime_event_projection.py

@@ -0,0 +1,401 @@
+from copy import deepcopy
+from datetime import datetime, timezone
+
+from app.runtime_event_projection import RuntimeEventProjector
+
+
+def _implementer_events():
+    return [
+        {"id": 10, "event_seq": 1, "event_type": "agent_invoke", "event_name": "script_implementer", "round_index": 1, "branch_id": 7, "status": "ok", "started_at": "2026-07-13T10:00:00", "ended_at": "2026-07-13T10:02:00"},
+        {"id": 11, "event_seq": 2, "event_type": "tool_call", "event_name": "get_topic_detail", "agent_role": "script_implementer", "scope_event_id": 10, "round_index": 1, "branch_id": 7, "status": "ok", "started_at": "2026-07-13T10:00:01", "ended_at": "2026-07-13T10:00:02"},
+        {"id": 20, "event_seq": 3, "event_type": "agent_invoke", "event_name": "retrieve_data_decode_case", "parent_event_id": 10, "scope_event_id": 10, "round_index": 1, "branch_id": 7, "status": "ok", "inputData": {"task": "查找叙事案例"}, "agentOutputData": {"summary": "初筛出两个案例"}, "started_at": "2026-07-13T10:00:10", "ended_at": "2026-07-13T10:00:30"},
+        {"id": 21, "event_seq": 4, "event_type": "tool_call", "event_name": "think_and_plan", "scope_event_id": 20, "parent_event_id": 20, "status": "ok", "started_at": "2026-07-13T10:00:11", "ended_at": "2026-07-13T10:00:12"},
+        {"id": 22, "event_seq": 5, "event_type": "tool_call", "event_name": "search_script_decode_case", "scope_event_id": 20, "parent_event_id": 20, "status": "ok", "inputData": {"keyword": "叙事"}, "output_preview": '{"count": 2}', "started_at": "2026-07-13T10:00:13", "ended_at": "2026-07-13T10:00:17"},
+        {"id": 23, "event_seq": 6, "event_type": "tool_call", "event_name": "search_script_decode_case", "scope_event_id": 20, "parent_event_id": 20, "status": "ok", "inputData": {"keyword": "构图"}, "output_preview": '{"count": 0}', "started_at": "2026-07-13T10:00:18", "ended_at": "2026-07-13T10:00:22"},
+    ]
+
+
+def test_explicit_event_tree_separates_direct_tools_agent_queries_and_screening():
+    projection = RuntimeEventProjector().project(_implementer_events(), valid_rounds={1}, valid_branches={7})
+    stage = projection["retrievalStagesByBranch"][7]
+
+    assert stage["observedMode"] == "sequential"
+    assert stage["directToolGroups"][0]["sources"] == [{"businessLabel": "选题", "callCount": 1}]
+    assert len(stage["agentRuns"]) == 1
+    run = stage["agentRuns"][0]
+    assert run["id"] == "retrieval-agent:20"
+    assert run["taskPreview"] == "查找叙事案例"
+    assert run["screening"]["preview"] == "初筛出两个案例"
+    assert run["querySummary"] == {"total": 2, "hit": 1, "empty": 1, "failure": 0, "unknown": 0}
+    assert [item["eventId"] for item in run["attempts"]] == [22, 23]
+
+
+def test_same_agent_type_invoked_twice_stays_two_instances():
+    events = _implementer_events()
+    second = deepcopy(events[2])
+    second.update({"id": 30, "event_seq": 7, "started_at": "2026-07-13T10:00:40", "ended_at": "2026-07-13T10:00:50"})
+    events.append(second)
+    projection = RuntimeEventProjector().project(events, valid_rounds={1}, valid_branches={7})
+    assert [item["id"] for item in projection["retrievalStagesByBranch"][7]["agentRuns"]] == ["retrieval-agent:20", "retrieval-agent:30"]
+
+
+def test_missing_parent_is_unassigned_instead_of_text_matched():
+    event = {"id": 3, "event_type": "agent_invoke", "event_name": "retrieve_data_knowledge", "round_index": 1, "branch_id": 7, "inputData": {"task": "文字里写了方案 7"}}
+    projection = RuntimeEventProjector().project([event], valid_rounds={1}, valid_branches={7})
+    assert projection["retrievalStagesByBranch"] == {}
+    assert projection["unassigned"][0]["eventId"] == 3
+    assert projection["unassigned"][0]["association"] == "missing-parent-implementer"
+
+
+def test_parallel_and_mixed_modes_come_from_time_overlap():
+    events = _implementer_events()
+    first_agent = events[2]
+    direct = events[1]
+    direct["ended_at"] = "2026-07-13T10:00:20"
+    first_agent["started_at"] = "2026-07-13T10:00:10"
+    projection = RuntimeEventProjector().project(events, valid_rounds={1}, valid_branches={7})
+    assert projection["retrievalStagesByBranch"][7]["observedMode"] == "parallel"
+
+    later = deepcopy(first_agent)
+    later.update({"id": 40, "event_seq": 9, "started_at": "2026-07-13T10:00:40", "ended_at": "2026-07-13T10:00:50"})
+    events.append(later)
+    projection = RuntimeEventProjector().project(events, valid_rounds={1}, valid_branches={7})
+    assert projection["retrievalStagesByBranch"][7]["observedMode"] == "mixed"
+
+
+def test_unknown_time_does_not_claim_parallelism():
+    events = _implementer_events()
+    events[2]["started_at"] = None
+    projection = RuntimeEventProjector().project(events, valid_rounds={1}, valid_branches={7})
+    stage = projection["retrievalStagesByBranch"][7]
+    assert stage["observedMode"] == "unknown"
+    assert stage["waves"] == []
+
+
+def test_failed_direct_read_makes_stage_partial():
+    events = _implementer_events()
+    events[1]["status"] = "error"
+    projection = RuntimeEventProjector().project(events, valid_rounds={1}, valid_branches={7})
+    stage = projection["retrievalStagesByBranch"][7]
+    assert stage["directToolGroups"][0]["failureCount"] == 1
+    assert stage["status"] == "partial"
+
+
+def test_running_implementer_without_retrieval_calls_is_preparing_not_missing():
+    events = [{
+        "id": 10, "event_seq": 1, "event_type": "agent_invoke",
+        "event_name": "script_implementer", "round_index": 1,
+        "branch_id": 7, "status": "running",
+        "started_at": "2026-07-14T12:00:00", "ended_at": None,
+    }]
+    projection = RuntimeEventProjector().project(
+        events,
+        valid_rounds={1},
+        valid_branches={7},
+        captured_at=datetime(2026, 7, 14, 4, 0, 5, tzinfo=timezone.utc),
+    )
+    stage = projection["retrievalStagesByBranch"][7]
+    assert stage["status"] == "running"
+    assert stage["observedMode"] == "unknown"
+
+
+def test_terminal_run_reclassifies_unfinished_retrieval_as_interrupted():
+    events = _implementer_events()
+    events[0].update({"status": "running", "ended_at": None})
+    events[1].update({"status": "running", "ended_at": None})
+    events[2].update({"status": "running", "ended_at": None})
+    events[4].update({"status": "running", "ended_at": None})
+    projection = RuntimeEventProjector().project(
+        events,
+        valid_rounds={1},
+        valid_branches={7},
+        run_status="failed",
+    )
+    stage = projection["retrievalStagesByBranch"][7]
+
+    assert stage["status"] == "partial"
+    assert stage["directToolGroups"][0]["calls"][0]["status"] == "interrupted"
+    assert stage["agentRuns"][0]["status"] == "interrupted"
+    assert stage["agentRuns"][0]["attempts"][0]["status"] == "interrupted"
+
+
+def test_running_naive_database_time_uses_asia_shanghai_before_wave_projection():
+    events = _implementer_events()
+    events[0].update({"status": "running", "ended_at": None})
+    events[1].update({"status": "running", "started_at": "2026-07-14T12:00:00", "ended_at": None})
+    events[2].update({"status": "running", "started_at": "2026-07-14T12:00:02", "ended_at": None})
+    projection = RuntimeEventProjector().project(
+        events,
+        valid_rounds={1},
+        valid_branches={7},
+        captured_at=datetime(2026, 7, 14, 4, 0, 5, tzinfo=timezone.utc),
+    )
+    stage = projection["retrievalStagesByBranch"][7]
+    assert stage["observedMode"] == "parallel"
+    assert stage["startedAt"] == "2026-07-14T04:00:00+00:00"
+    assert stage["endedAt"] == "2026-07-14T04:00:05+00:00"
+
+
+def test_query_output_error_precedes_empty_count_and_unstructured_object_is_unknown():
+    events = _implementer_events()
+    events[4]["output_preview"] = '{"error":"knowledge unavailable","items":[]}'
+    events[5]["output_preview"] = '{"message":"query accepted"}'
+    stage = RuntimeEventProjector().project(
+        events, valid_rounds={1}, valid_branches={7}
+    )["retrievalStagesByBranch"][7]
+    assert [attempt["status"] for attempt in stage["agentRuns"][0]["attempts"]] == ["failure", "unknown"]
+    assert stage["agentRuns"][0]["querySummary"] == {
+        "total": 2, "hit": 0, "empty": 0, "failure": 1, "unknown": 1,
+    }
+
+
+def test_agent_card_screening_preview_uses_business_cleaning():
+    events = _implementer_events()
+    events[2]["agentOutputData"] = {
+        "summary": "## 委托任务完成\n\n### 初筛结果\n- 找到可用案例 post_id=abc123\n\n**执行统计**:\n- Tokens: 1200\n- 成本: $0.1"
+    }
+    stage = RuntimeEventProjector().project(
+        events, valid_rounds={1}, valid_branches={7}
+    )["retrievalStagesByBranch"][7]
+    preview = stage["agentRuns"][0]["screening"]["preview"]
+    assert "可用案例" in preview
+    assert "post_id" not in preview
+    assert "abc123" not in preview
+    assert "Tokens" not in preview
+    assert "成本" not in preview
+
+
+def test_creative_business_projection_translates_runtime_tool_names():
+    events = [
+        {
+            "id": 10,
+            "event_seq": 1,
+            "event_type": "agent_invoke",
+            "event_name": "script_implementer",
+            "round_index": 1,
+            "branch_id": 7,
+            "status": "ok",
+            "inputData": {
+                "task": "路序号:1 action:增 target:烹饪贴士 path_type:内容 目标:补齐烹饪贴士与总结。"
+            },
+        },
+        {
+            "id": 11,
+            "event_seq": 2,
+            "event_type": "tool_call",
+            "event_name": "think_and_plan",
+            "agent_role": "script_implementer",
+            "scope_event_id": 10,
+            "round_index": 1,
+            "branch_id": 7,
+            "status": "ok",
+            "inputData": {
+                "thought": "缺少直接证据,先保留为待验证推导。",
+                "action": "record_data_decision",
+                "plan": "1. 调用 get_topic_detail。\n2. 委派 retrieve_data_knowledge。\n3. 调用 batch_update_script_paragraphs。",
+            },
+        },
+    ]
+    decision = RuntimeEventProjector().project(
+        events, valid_rounds={1}, valid_branches={7}
+    )["creativeByBranch"][7]
+
+    assert decision["body"]["task"] == "补齐烹饪贴士与总结。"
+    assert decision["body"]["actions"] == ["记录数据取舍"]
+    assert decision["body"]["steps"] == [
+        {
+            "stepIndex": 1,
+            "action": "记录数据取舍",
+            "summary": None,
+            "plan": "1. 读取选题。\n2. 委派知识 Agent取数。\n3. 更新候选脚本内容。",
+            "reasoning": "缺少直接证据,先保留为待验证推导。",
+            "eventRef": "event:11",
+        }
+    ]
+    process_block = decision["detail"]["blocks"][0]
+    assert process_block["type"] == "creative-process"
+    assert process_block["steps"][0]["action"] == "记录数据取舍"
+    business = str(decision["detail"]["blocks"])
+    assert "record_data_decision" not in business
+    assert "get_topic_detail" not in business
+    assert "batch_update_script_paragraphs" not in business
+    assert "path_type" not in business
+
+
+def test_creative_projection_keeps_upstream_and_actual_mutation_refs_separate():
+    events = [
+        {
+            "id": 100,
+            "event_seq": 1,
+            "event_type": "agent_invoke",
+            "event_name": "script_implementer",
+            "round_index": 1,
+            "branch_id": 7,
+            "status": "ok",
+            "inputData": {"task": "目标:补齐正文。"},
+        },
+        {
+            "id": 101,
+            "event_seq": 2,
+            "event_type": "tool_call",
+            "event_name": "get_script_snapshot",
+            "agent_role": "script_implementer",
+            "scope_event_id": 100,
+            "round_index": 1,
+            "branch_id": 7,
+            "status": "ok",
+        },
+        {
+            "id": 102,
+            "event_seq": 3,
+            "event_type": "tool_call",
+            "event_name": "think_and_plan",
+            "agent_role": "script_implementer",
+            "scope_event_id": 100,
+            "round_index": 1,
+            "branch_id": 7,
+            "status": "ok",
+            "inputData": {"action": "更新候选正文", "thought": "先根据已有结构补写。"},
+        },
+        {
+            "id": 103,
+            "event_seq": 4,
+            "event_type": "tool_call",
+            "event_name": "batch_update_script_paragraphs",
+            "agent_role": "script_implementer",
+            "scope_event_id": 100,
+            "round_index": 1,
+            "branch_id": 7,
+            "status": "ok",
+        },
+    ]
+
+    decision = RuntimeEventProjector().project(
+        events, valid_rounds={1}, valid_branches={7}
+    )["creativeByBranch"][7]
+
+    assert decision["eventRefs"] == ["event:101", "event:102", "event:103"]
+    assert decision["body"]["availableUpstream"][0]["decisionUse"] == "not-recorded"
+    assert decision["body"]["runtimeActions"][0]["label"] == "更新候选脚本内容"
+    blocks = {item["title"]: item for item in decision["detail"]["blocks"]}
+    upstream_item = blocks["可确认的上游信息"]["items"][0]
+    assert "无法确认是否被本次创作采用" in upstream_item["note"]
+    assert blocks["实际脚本改动"]["items"][0]["detailRef"] == "event:103"
+
+
+def test_exact_evaluator_names_create_two_different_business_stages():
+    events = [
+        {
+            "id": 80, "event_seq": 1, "event_type": "agent_invoke",
+            "event_name": "script_multipath_evaluator", "round_index": 1,
+            "status": "ok", "inputData": {"task": "比较 branch_id=7, branch_id=8"},
+            "agentOutputData": {"summary": "### 分支评估 branch_id=7\n**该支结论**:通过\n**目标推进**:结构完整。\n\n### 分支评估 branch_id=8\n**该支结论**:部分通过\n**目标推进**:仍需补充。\n\n### 对比与建议\n**采纳建议**:优先采用 branch_id=7。"},
+            "started_at": "2026-07-13T10:03:00", "ended_at": "2026-07-13T10:03:10",
+        },
+        {
+            "id": 81, "event_seq": 2, "event_type": "agent_invoke",
+            "event_name": "script_evaluator", "round_index": 1,
+            "status": "ok", "agentOutputData": {"summary": "### 整体结论:部分通过"},
+            "started_at": "2026-07-13T10:04:00", "ended_at": "2026-07-13T10:04:10",
+        },
+        {
+            "id": 82, "event_seq": 3, "event_type": "agent_invoke",
+            "event_name": "some_future_evaluator", "round_index": 1,
+            "status": "ok", "started_at": "2026-07-13T10:05:00", "ended_at": "2026-07-13T10:05:10",
+        },
+    ]
+    projection = RuntimeEventProjector().project(events, valid_rounds={1}, valid_branches={7, 8})
+
+    review = projection["multipathReviewsByRound"][1][0]
+    assert review["branchIds"] == [7, 8]
+    assert [item["conclusion"] for item in review["branchConclusions"]] == ["通过", "部分通过"]
+    assert "优先采用" in review["recommendation"]
+    assert projection["overallEvaluationsByRound"][1][0]["eventId"] == 81
+    assert "evaluationsByBranch" not in projection
+    assert "evaluationsByRound" not in projection
+
+
+def test_multipath_evaluator_parses_one_labelled_comma_separated_branch_list():
+    event = {
+        "id": 3687,
+        "event_seq": 1,
+        "event_type": "agent_invoke",
+        "event_name": "script_multipath_evaluator",
+        "round_index": 2,
+        "status": "ok",
+        "inputData": {
+            "task": "评估对象:branch_id = 4, 5, 6, 7(分工,各填充一段)"
+        },
+        "agentOutputData": {
+            "summary": "\n".join(
+                f"### 分支评估 branch_id={branch_id}\n**该支结论**:通过"
+                for branch_id in (4, 5, 6, 7)
+            )
+        },
+        "started_at": "2026-07-14T17:17:09",
+        "ended_at": "2026-07-14T17:18:06",
+    }
+
+    projection = RuntimeEventProjector().project(
+        [event], valid_rounds={2}, valid_branches={4, 5, 6, 7}
+    )
+
+    assert projection["multipathReviewsByRound"][2][0]["branchIds"] == [4, 5, 6, 7]
+
+
+def test_facade_builds_one_index_and_merges_projector_outputs():
+    received = []
+
+    class RetrievalSpy:
+        def project(self, index, **kwargs):
+            received.append(index)
+            return {
+                "retrievalStagesByBranch": {7: {"id": "retrieval"}},
+                "unassigned": [{"id": "retrieval-unassigned"}],
+            }
+
+    class EvaluationSpy:
+        def project(self, index, **kwargs):
+            received.append(index)
+            return {
+                "multipathReviewsByRound": {1: [{"id": "review"}]},
+                "overallEvaluationsByRound": {1: [{"id": "evaluation"}]},
+                "unassigned": [{"id": "evaluation-unassigned"}],
+            }
+
+    class MainAgentSpy:
+        def project(self, index, **kwargs):
+            received.append(index)
+            return {
+                "objective": {"stage": "objective"},
+                "roundGoalsByRound": {},
+                "implementationPlansByRound": {},
+            }
+
+    projection = RuntimeEventProjector(
+        retrieval_projector=RetrievalSpy(),
+        evaluation_projector=EvaluationSpy(),
+        main_agent_projector=MainAgentSpy(),
+    ).project(
+        [{
+            "id": 9,
+            "event_seq": 1,
+            "event_type": "tool_call",
+            "event_name": "record_multipath_plan",
+            "round_index": 1,
+            "status": "ok",
+        }],
+        valid_rounds={1},
+        valid_branches={7},
+    )
+
+    assert len({id(index) for index in received}) == 1
+    assert projection["retrievalStagesByBranch"][7]["id"] == "retrieval"
+    assert projection["multipathReviewsByRound"][1][0]["id"] == "review"
+    assert projection["overallEvaluationsByRound"][1][0]["id"] == "evaluation"
+    assert projection["mainAgentDecisions"]["objective"]["stage"] == "objective"
+    assert "planRevisionsByRound" not in projection
+    assert projection["unassigned"] == [
+        {"id": "retrieval-unassigned"},
+        {"id": "evaluation-unassigned"},
+    ]

+ 27 - 0
visualization-old/backend/tests/test_sanitizer.py

@@ -0,0 +1,27 @@
+from app.sanitizer import sanitize
+
+
+def test_sensitive_mapping_values_are_redacted():
+    payload = sanitize({
+        "api_key": "sk-secret",
+        "nested": {"Authorization": "Bearer token"},
+        "data_source_url": "postgresql://user:pass@db/name",
+        "safe": "ok",
+    })
+    assert payload == {
+        "api_key": "***",
+        "nested": {"Authorization": "***"},
+        "data_source_url": "***",
+        "safe": "ok",
+    }
+
+
+def test_connection_strings_are_redacted_inside_text():
+    assert sanitize("connect postgresql://user:pass@db/name") == "connect postgresql://user:***@db/name"
+
+
+def test_json_strings_and_authorization_schemes_are_fully_redacted():
+    assert sanitize('{"api_key":"SECRET","safe":"ok"}') == '{"api_key":"***","safe":"ok"}'
+    assert sanitize("Authorization: Bearer SECRET") == "Authorization: ***"
+    assert sanitize("Authorization=Basic dXNlcjpwYXNz") == "Authorization=***"
+    assert sanitize('{"access_token":"TOKEN"}') == '{"access_token":"***"}'

+ 134 - 0
visualization-old/backend/tests/test_script_table_projection.py

@@ -0,0 +1,134 @@
+from app.inspector_projection import project_artifact_detail
+
+
+def test_script_table_projection_normalizes_facets_links_and_element_references():
+    artifact = {
+        "sourceOrigin": "database",
+        "paragraphs": [
+            {
+                "id": 1,
+                "rowId": 1,
+                "effectiveId": 1,
+                "paragraph_index": 2,
+                "level": 1,
+                "name": "机制拆解",
+                "content_range": {"图片": ["1", "2"], "标题": True, "正文": "第二段"},
+                "theme": "通过[元素:10]解释机制",
+                "theme_elements": [{"维度类型": "主", "维度": "核心竞争力", "原子点": "份额换订单"}],
+                "form": "案例拆解",
+                "form_elements": [],
+                "function": "承接上文",
+                "function_elements": [],
+                "feeling": "建立可信度",
+                "feeling_elements": [],
+                "full_description": "通过[元素:10]解释机制,并保留[元素:999]。",
+            }
+        ],
+        "elements": [
+            {"id": 10, "rowId": 10, "effectiveId": 10, "name": "份额换订单", "dimension_primary": "核心竞争力", "dimension_secondary": "商业模式"}
+        ],
+        "paragraphElements": [{"id": 100, "paragraph_id": 1, "element_id": 10}],
+    }
+
+    payload = project_artifact_detail(431, "artifact:base:current", artifact)
+    table = payload["scriptTable"]
+
+    assert table["counts"] == {"paragraphs": 1, "elements": 1, "links": 1}
+    assert table["paragraphs"][0]["contentRange"] == ["图1-2", "标题", "正文: 第二段"]
+    assert table["paragraphs"][0]["sections"]["theme"]["dimensions"][0] == {
+        "type": "主",
+        "name": "核心竞争力",
+        "value": "份额换订单",
+    }
+    parts = table["paragraphs"][0]["fullDescriptionParts"]
+    assert parts[1] == {"type": "element", "id": 10, "name": "份额换订单", "resolved": True, "text": "[元素:10]"}
+    assert parts[3]["resolved"] is False
+    assert table["paragraphs"][0]["sections"]["theme"]["descriptionParts"][1] == {
+        "type": "element",
+        "id": 10,
+        "name": "份额换订单",
+        "resolved": True,
+        "text": "[元素:10]",
+    }
+    assert table["elements"][0]["paragraphs"] == [{"id": 1, "index": 2, "name": "机制拆解"}]
+    assert payload["changes"]["sections"][0]["content"] == "1 个段落、1 个元素、1 条关联"
+    assert "artifact" not in payload["technical"]
+
+
+def test_script_table_keeps_children_beside_their_real_parent():
+    def paragraph(row_id, index, name, parent_id=None, level=1):
+        return {
+            "id": row_id,
+            "rowId": row_id,
+            "effectiveId": row_id,
+            "paragraph_index": index,
+            "parent_id": parent_id,
+            "level": level,
+            "name": name,
+        }
+
+    artifact = {
+        "sourceOrigin": "database",
+        "paragraphs": [
+            paragraph(1, 1, "第一章"),
+            paragraph(2, 1, "第一章子段", parent_id=1, level=2),
+            paragraph(3, 2, "第二章"),
+            paragraph(4, 1, "第二章子段", parent_id=3, level=2),
+        ],
+        "elements": [],
+        "paragraphElements": [],
+    }
+
+    table = project_artifact_detail(1, "artifact:base:current", artifact)["scriptTable"]
+    assert [row["name"] for row in table["paragraphs"]] == [
+        "第一章",
+        "第一章子段",
+        "第二章",
+        "第二章子段",
+    ]
+    assert [row["depth"] for row in table["paragraphs"]] == [0, 1, 0, 1]
+    assert table["maxDepth"] == 2
+
+
+def test_frozen_candidate_snapshot_restores_nested_paragraphs_and_embedded_links():
+    artifact = {
+        "sourceOrigin": "database",
+        "historicalAccuracy": "exact",
+        "artifactContext": {"type": "candidate", "branchStatus": "merged"},
+        "snapshot": {
+            "stats": {"paragraph_count": 2, "element_count": 1, "link_count": 2},
+            "paragraphs": [
+                {
+                    "id": 1,
+                    "paragraph_index": 1,
+                    "level": 1,
+                    "name": "主段落",
+                    "linked_elements": [{"link_id": 10, "element_id": 100}],
+                    "sub_paragraphs": [
+                        {
+                            "id": 2,
+                            "paragraph_index": 1,
+                            "level": 2,
+                            "name": "子段落",
+                            "linked_elements": [],
+                            "linked_element_ids": [100],
+                        }
+                    ],
+                }
+            ],
+            "elements": [{"id": 100, "name": "候选元素"}],
+        },
+    }
+
+    payload = project_artifact_detail(1, "artifact:branch:7", artifact)
+    table = payload["scriptTable"]
+
+    assert table["counts"] == {"paragraphs": 2, "elements": 1, "links": 2}
+    assert [row["name"] for row in table["paragraphs"]] == ["主段落", "子段落"]
+    assert [row["depth"] for row in table["paragraphs"]] == [0, 1]
+    assert table["elements"][0]["paragraphs"] == [
+        {"id": 1, "index": 1, "name": "主段落"},
+        {"id": 2, "index": 1, "name": "子段落"},
+    ]
+    assert payload["businessSections"][0]["title"] == "候选脚本规模"
+    assert payload["changes"]["summary"] == "候选脚本内容"

+ 687 - 0
visualization-old/backend/tests/test_source_lineage.py

@@ -0,0 +1,687 @@
+from __future__ import annotations
+
+from copy import deepcopy
+
+from fastapi.testclient import TestClient
+import pytest
+
+from app import main
+from app.card_details.common import context_input_fields
+from app.module_audit.log_context import locate_log_module
+from app.source_lineage.comparison import _normalize_business, _source_module_ids
+from app.source_lineage.projector import _all_bindings
+from app.source_lineage.resolver import SourceResolver
+
+
+client = TestClient(main.app)
+
+
+@pytest.fixture(autouse=True)
+def clear_source_inspector_caches():
+    main._inspector_cache.clear()
+    main._build_view_cache.clear()
+    yield
+    main._inspector_cache.clear()
+    main._build_view_cache.clear()
+
+
+def _module(payload: dict, module_id: str) -> dict:
+    return next(item for item in payload["modules"] if item["id"] == module_id)
+
+
+def _row_bindings(module: dict, row_index: int = 0) -> list[dict]:
+    ids = set(module["rows"][row_index]["bindingIds"])
+    return [binding for binding in module["bindings"] if binding["id"] in ids]
+
+
+def _event_detail(summary: dict, *, input_content=None, output_content=None):
+    return {
+        **summary,
+        "input": {"contentType": "json", "content": input_content, "truncated": False},
+        "output": {"contentType": "json", "content": output_content, "truncated": False},
+    }
+
+
+def test_inspector_view_planning_fields_have_exact_independent_bindings(
+    monkeypatch, current_bundle
+):
+    bundle = deepcopy(current_bundle)
+    event = {
+        "id": 60,
+        "event_seq": 1,
+        "event_type": "tool_call",
+        "event_name": "think_and_plan",
+        "agent_role": "main",
+        "agent_depth": 0,
+        "status": "completed",
+        "started_at": "2026-07-13T09:59:00",
+        "ended_at": "2026-07-13T09:59:01",
+    }
+    bundle["events"].insert(0, event)
+    detail = _event_detail(
+        event,
+        input_content={
+            "thought_summary": "先确定承重结构",
+            "thought": "完整思考原文",
+            "plan": "1. 写入方向\n2. 开始本轮",
+            "action": "保存创作目标",
+        },
+        output_content={"ok": True},
+    )
+    calls = []
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(
+        main.provider,
+        "load_event_details",
+        lambda _build_id, ids: calls.append(list(ids)) or {60: deepcopy(detail)},
+    )
+    monkeypatch.setattr(main.provider, "load_event_detail", lambda _build_id, _event_id: deepcopy(detail))
+    main._inspector_cache.clear()
+
+    response = client.get("/api/script-builds/88001/inspector-view/event:60")
+    assert response.status_code == 200
+    payload = response.json()
+    assert payload["schemaVersion"] == "inspector-source-v2"
+    assert payload["businessProjection"]["businessSections"][0]["content"] == "先确定承重结构"
+    assert calls == [[60]]
+    paths = {
+        module["id"]: _row_bindings(module)[0]["selector"].get("path")
+        for module in payload["modules"]
+    }
+    assert paths == {
+        "summary": "/input/content/thought_summary",
+        "thought": "/input/content/thought",
+        "plan": "/input/content/plan",
+        "action": "/input/content/action",
+    }
+    assert list(payload["sources"]) == ["event:60"]
+
+
+def test_optional_objective_and_creative_blocks_use_stable_semantic_ids():
+    objective = _normalize_business({
+        "blocks": [
+            {"type": "items", "title": "根据以下信息做出的决策", "items": [{"label": "账号", "value": "A"}]},
+            {"type": "summary", "title": "当前保存的创作方向", "value": "方向原文"},
+        ]
+    }, card_kind="objective")
+    assert [item["_semanticKey"] for item in objective] == [
+        "block:inputs",
+        "block:direction",
+    ]
+    assert _source_module_ids("objective", "block:inputs", 1) == ["inputs"]
+    assert _source_module_ids("objective", "block:direction", 2) == ["direction"]
+
+    creative = _normalize_business({
+        "blocks": [
+            {"type": "items", "title": "可确认的上游信息", "items": [{"label": "案例", "value": "A"}]},
+            {"type": "creative-process", "title": "创作处理", "steps": [{"action": "改写"}]},
+            {"type": "summary", "title": "产出的候选表", "value": "候选 A"},
+            {"type": "notice", "title": "记录说明", "value": "过程只保存了一部分"},
+        ]
+    }, card_kind="creative")
+    assert [item["_semanticKey"] for item in creative] == [
+        "block:available",
+        "block:process",
+        "block:candidate",
+        "block:record-note",
+    ]
+    assert [
+        _source_module_ids("creative", item["_semanticKey"], index)
+        for index, item in enumerate(creative, 1)
+        ] == [["available"], ["process"], ["candidate"], ["record-note"]]
+
+
+def test_context_input_fields_classifies_business_refs_as_database_records():
+    context = {
+        "inputs": [
+            {"label": "多路决策", "summary": "采用方案 1", "detailRef": "multipath-decision:21"},
+            {"label": "数据取舍", "summary": "采用两条证据", "detailRef": "data-decision:11"},
+            {"label": "领域事实", "summary": "事实 A", "detailRef": "domain-info:31"},
+            {"label": "开启本轮", "summary": "目标 A", "detailRef": "event:60", "relation": "direct-read"},
+        ]
+    }
+    event = {"id": 60, "event_name": "begin_round"}
+    fields = context_input_fields(context, "test", [event])
+    assert [item["source"]["kind"] for item in fields] == [
+        "database", "database", "database", "runtime-event"
+    ]
+    assert [item["source"]["label"] for item in fields[:3]] == [
+        "script_build_multipath_decision",
+        "script_build_data_decision",
+        "script_build_domain_info",
+    ]
+    assert [item["source"]["fieldPath"] for item in fields] == [
+        "decision", "decision", "content", "input.content.goal"
+    ]
+
+
+def test_implementation_task_sections_bind_to_exact_task_spans(
+    monkeypatch, current_bundle
+):
+    bundle = deepcopy(current_bundle)
+    task = """交付说明:输出一份完整候选表
+任务目标:形成三段式文章结构
+method: 先搭建段落骨架
+目标:让读者理解因果关系
+必用素材:柔性排产案例
+前情提要:本轮已核实领域事实
+重要约束:不改动事实数字
+禁止事项:不得伪造引用"""
+    event = next(item for item in bundle["events"] if item["id"] == 50)
+    event["inputData"] = {"task": task}
+    detail = _event_detail(event, input_content={"task": task}, output_content={"summary": "完成"})
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(
+        main.provider,
+        "load_event_details",
+        lambda _build_id, ids: {50: deepcopy(detail)} if 50 in ids else {},
+    )
+    main._inspector_cache.clear()
+
+    response = client.get(
+        "/api/script-builds/88001/inspector-view/round:1:branch:1:task"
+    )
+    assert response.status_code == 200
+    payload = response.json()
+    expected_modules = {
+        "task-notes-1": "deliverable",
+        "task-scope-2": "scope",
+        "task-method-3": "method",
+        "task-goal-4": "goal",
+        "task-materials-5": "constraints",
+        "task-context-6": "constraints",
+        "task-constraints-7": "constraints",
+        "task-avoid-8": "avoid",
+    }
+    for module_id, source_module_id in expected_modules.items():
+        module = _module(payload, module_id)
+        bindings = _row_bindings(module)
+        assert bindings, module_id
+        assert all(binding["resolution"] == "resolved" for binding in bindings)
+        assert all(
+            binding["selector"]["kind"] == "text-span"
+            for binding in bindings
+            if binding["sourceId"] == "event:50"
+        )
+        if source_module_id != "scope":
+            event_binding = next(binding for binding in bindings if binding["sourceId"] == "event:50")
+            assert event_binding["selector"]["kind"] == "text-span"
+            assert event_binding["selector"]["path"] == "/input/content/task"
+            section = next(
+                item
+                for item in payload["businessProjection"]["businessSections"]
+                if item["id"] == module_id
+            )
+            assert event_binding["selector"]["exactText"] == section["content"]
+
+
+def test_inspector_view_query_binds_each_condition_and_case(monkeypatch, current_bundle):
+    bundle = deepcopy(current_bundle)
+    event = next(item for item in bundle["events"] if item["id"] == 53)
+    detail = _event_detail(
+        event,
+        input_content={"keyword": "因果结构", "top_k": 2},
+        output_content={
+            "count": 2,
+            "results": [
+                {"post_id": "A", "account": "账号A", "data": "Case A"},
+                {"post_id": "B", "account": "账号B", "data": "Case B"},
+            ],
+        },
+    )
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, _ids: {53: deepcopy(detail)})
+    monkeypatch.setattr(main.provider, "load_event_detail", lambda _build_id, _event_id: deepcopy(detail))
+    main._inspector_cache.clear()
+
+    payload = client.get("/api/script-builds/88001/inspector-view/event:53").json()
+    purpose = _module(payload, "conditions")
+    result = _module(payload, "results")
+    status = _module(payload, "outcome")
+    assert [_row_bindings(purpose, index)[0]["selector"]["path"] for index in range(2)] == [
+        "/input/content/keyword",
+        "/input/content/top_k",
+    ]
+    assert [_row_bindings(result, index)[0]["selector"]["path"] for index in range(2)] == [
+        "/output/content/results/0",
+        "/output/content/results/1",
+    ]
+    assert len({binding["id"] for binding in result["bindings"]}) == 2
+    assert all(
+        binding["selector"].get("path") == "/output/content"
+        and binding["transform"]["operation"] == "retrieval-outcome-v1"
+        for binding in _row_bindings(status)
+    )
+    assert result["bindings"][0]["evidence"] == {
+        "availability": "returned-to-agent",
+        "adoption": "not-recorded",
+        "confidence": "deterministic",
+    }
+    assert result["bindings"][0]["transform"]["operation"] == "retrieval-result-item-v1"
+    assert all(binding["resolution"] == "resolved" for binding in status["bindings"])
+    assert not any(":error:" in binding["id"] for binding in status["bindings"])
+    status_binding_ids = {binding["id"] for binding in status["bindings"]}
+    assert all(
+        set(row["bindingIds"]).issubset(status_binding_ids)
+        for row in status["rows"]
+    )
+
+
+@pytest.mark.parametrize(
+    ("event_name", "output_content", "expected_paths"),
+    [
+        (
+            "get_domain_info",
+            {"domain_info": [{"id": 1, "content": "事实 A"}, {"id": 2, "content": "事实 B"}]},
+            [
+                "/output/content/domain_info/0",
+                "/output/content/domain_info/1",
+            ],
+        ),
+        (
+            "get_script_snapshot",
+            {"script_build": {"status": "running"}, "paragraphs": []},
+            ["/output/content"],
+        ),
+    ],
+)
+def test_direct_tool_result_paths_follow_the_shared_business_contract(
+    monkeypatch, current_bundle, event_name, output_content, expected_paths
+):
+    bundle = deepcopy(current_bundle)
+    event = {
+        "id": 60,
+        "event_seq": 60,
+        "event_type": "tool_call",
+        "event_name": event_name,
+        "agent_role": "main",
+        "agent_depth": 0,
+        "status": "completed",
+        "ended_at": "2026-07-13T10:00:01",
+    }
+    bundle["events"].append(event)
+    detail = _event_detail(event, input_content={}, output_content=output_content)
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(
+        main.provider,
+        "load_event_details",
+        lambda _build_id, ids: {60: deepcopy(detail)} if 60 in ids else {},
+    )
+    main._inspector_cache.clear()
+
+    payload = client.get("/api/script-builds/88001/inspector-view/event:60").json()
+    results = _module(payload, "results")
+    paths = [
+        _row_bindings(results, index)[0]["selector"].get("path")
+        for index in range(len(results["rows"]))
+    ]
+    assert paths == expected_paths
+
+
+def test_generic_event_with_saved_body_never_falls_back_to_false_missing(
+    monkeypatch, current_bundle
+):
+    bundle = deepcopy(current_bundle)
+    event = {
+        "id": 60,
+        "event_seq": 60,
+        "event_type": "tool_call",
+        "event_name": "save_script_direction",
+        "agent_role": "main",
+        "agent_depth": 0,
+        "status": "completed",
+    }
+    bundle["events"].insert(0, event)
+    detail = _event_detail(
+        event,
+        input_content={"direction": "完整创作方向"},
+        output_content="创作脚本方向已保存",
+    )
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(
+        main.provider,
+        "load_event_details",
+        lambda _build_id, ids: {60: deepcopy(detail)} if 60 in ids else {},
+    )
+    main._inspector_cache.clear()
+
+    payload = client.get("/api/script-builds/88001/inspector-view/event:60").json()
+    assert payload["sources"]["event:60"]["resultState"] == "present"
+    bindings = [
+        binding
+        for module in payload["modules"]
+        for binding in module["bindings"]
+    ]
+    assert bindings
+    assert all(binding["sourceId"] == "event:60" for binding in bindings)
+    assert all(binding["resolution"] == "resolved" for binding in bindings)
+
+
+def test_data_decision_and_final_result_bind_to_real_database_fields(
+    monkeypatch, current_bundle
+):
+    bundle = deepcopy(current_bundle)
+    bundle["record"].update({"status": "success", "summary": "构建摘要"})
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, _ids: {})
+    main._inspector_cache.clear()
+
+    decision = client.get(
+        "/api/script-builds/88001/inspector-view/data-decision:11"
+    ).json()
+    source_module = _module(decision, "block-1")
+    for index, row in enumerate(source_module["rows"]):
+        row_bindings = _row_bindings(source_module, index)
+        assert len(row_bindings) == 1
+        assert row_bindings[0]["selector"]["path"] == f"/sources/{index}"
+        assert row_bindings[0]["evidence"]["adoption"] == "explicitly-adopted"
+        assert decision["sources"][row_bindings[0]["sourceId"]]["locator"]["table"] == "script_build_data_decision"
+    assert len(_row_bindings(_module(decision, "block-2"))) == 1
+
+    final = client.get(
+        "/api/script-builds/88001/inspector-view/run:final-result"
+    ).json()
+    conclusion = _module(final, "conclusion")
+    summary = _module(final, "summary")
+    assert final["businessProjection"]["businessSections"][0]["content"] == "构建已完成。"
+    assert _row_bindings(conclusion)[0]["selector"]["path"] == "/status"
+    assert _row_bindings(conclusion)[0]["transform"]["kind"] == "parsed"
+    assert _row_bindings(summary)[0]["selector"]["path"] == "/summary"
+    stats = _module(final, "statistics")
+    assert _row_bindings(stats)[0]["selector"]["kind"] == "members"
+    assert _row_bindings(stats)[0]["selector"]["members"]
+    assert _row_bindings(stats)[0]["selector"]["reducer"] == "Round / Branch 记录计数"
+
+
+def test_every_visible_module_has_a_resolved_or_explicit_unresolved_binding(
+    monkeypatch, current_bundle
+):
+    bundle = deepcopy(current_bundle)
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, _ids: {})
+    main._inspector_cache.clear()
+
+    payload = client.get(
+        "/api/script-builds/88001/inspector-view/round:1:goal"
+    ).json()
+    assert payload["modules"]
+    for module in payload["modules"]:
+        assert module["rows"]
+        for row in module["rows"]:
+            assert row["businessSelector"]
+            bindings = _row_bindings(module, module["rows"].index(row))
+            assert bindings
+            for binding in bindings:
+                assert binding["resolution"] in {"resolved", "partial", "missing", "unsafe"}
+                if binding["resolution"] == "missing":
+                    assert binding["selector"]["kind"] == "unresolved"
+                    assert binding["selector"]["reason"]
+                if binding["resolution"] == "unsafe":
+                    assert binding["evidence"]["confidence"] == "unconfirmed"
+                assert binding["sourceId"] in payload["sources"]
+
+
+def test_business_projection_is_the_same_projection_as_readable_inspector(
+    monkeypatch, current_bundle
+):
+    bundle = deepcopy(current_bundle)
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, _ids: {})
+    main._inspector_cache.clear()
+
+    for detail_ref in (
+        "run:objective",
+        "round:1:goal",
+        "round:1:plan",
+        "data-decision:11",
+        "round:1:branch:1:task",
+        "round:1:result",
+        "run:final-result",
+    ):
+        old = client.get(f"/api/script-builds/88001/activities/{detail_ref}")
+        source = client.get(f"/api/script-builds/88001/inspector-view/{detail_ref}")
+        assert old.status_code == 200, detail_ref
+        assert source.status_code == 200, detail_ref
+        expected = {
+            key: value
+            for key, value in old.json().items()
+            if key not in {"technical", "changes"}
+        }
+        assert source.json()["businessProjection"] == expected, detail_ref
+
+
+def test_zero_domain_facts_are_a_complete_empty_query_not_missing(
+    monkeypatch, current_bundle
+):
+    bundle = deepcopy(current_bundle)
+    bundle["domainInfo"] = []
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, _ids: {})
+    main._inspector_cache.clear()
+
+    payload = client.get(
+        "/api/script-builds/88001/inspector-view/round:1:branch:2:output"
+    ).json()
+    assert payload["businessProjection"]["businessSections"][0]["content"] == (
+        "已查询完成,本方案没有新增领域事实"
+    )
+    output = _module(payload, "output")
+    binding = _row_bindings(output)[0]
+    source = payload["sources"][binding["sourceId"]]
+    assert binding["resolution"] == "resolved"
+    assert source["completeness"] == "complete"
+    assert source["resultState"] == "empty"
+    assert source["locator"] == {
+        "table": "script_build_domain_info",
+        "filters": {"branch_id": 2},
+        "resultCount": 0,
+    }
+    assert source["rawRecord"] == []
+
+    round_result = client.get(
+        "/api/script-builds/88001/inspector-view/round:1:result"
+    ).json()
+    domain = _module(round_result, "facts")
+    domain_source = round_result["sources"][_row_bindings(domain)[0]["sourceId"]]
+    assert domain_source["resultState"] == "empty"
+    assert domain_source["rawRecord"]["value"] == []
+
+
+def test_completeness_scan_includes_item_level_bindings():
+    modules = [
+        {
+            "bindings": [{"id": "module", "resolution": "resolved"}],
+            "business": {
+                "items": [
+                    {
+                        "bindings": [
+                            {
+                                "id": "child",
+                                "resolution": "missing",
+                                "selector": {"kind": "unresolved", "reason": "missing"},
+                            }
+                        ]
+                    }
+                ]
+            },
+        }
+    ]
+    assert {item["resolution"] for item in _all_bindings(modules)} == {
+        "resolved",
+        "missing",
+    }
+
+
+def test_direct_tool_group_has_one_item_binding_per_call(monkeypatch, current_bundle):
+    bundle = deepcopy(current_bundle)
+    summary = next(item for item in bundle["events"] if item["id"] == 51)
+    detail = _event_detail(summary, input_content={}, output_content={"paragraphs": [1, 2]})
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, _ids: {51: deepcopy(detail)})
+    monkeypatch.setattr(main.provider, "load_event_detail", lambda _build_id, _event_id: deepcopy(detail))
+    main._inspector_cache.clear()
+
+    payload = client.get(
+        "/api/script-builds/88001/inspector-view/retrieval-direct:51"
+    ).json()
+    activities = _module(payload, "activities")
+    assert len(activities["rows"]) == 1
+    activity_binding = _row_bindings(activities)[0]
+    assert activity_binding["selector"]["kind"] == "whole-record"
+    assert activity_binding["transform"]["operation"] == "activity-summary-v1"
+
+
+def test_whole_event_and_parsed_text_fallback_are_resolved():
+    event = _event_detail(
+        {
+            "id": 91,
+            "event_name": "script_evaluator",
+            "status": "completed",
+            "duration_ms": 20,
+        },
+        input_content={"task": "评审两个方案"},
+        output_content={"summary": "原始 Markdown **结论**"},
+    )
+    resolver = SourceResolver(bundle={}, event_details={91: event})
+    whole = resolver.binding_for_event(91, binding_id="whole", path="")
+    assert whole["selector"] == {"kind": "whole-record"}
+    assert whole["resolution"] == "resolved"
+
+    parsed = resolver.text_span_binding(
+        91,
+        binding_id="parsed",
+        path="output.content.summary",
+        exact_text="清理后的结论",
+        role="output",
+    )
+    assert parsed["selector"] == {
+        "kind": "json-pointer",
+        "path": "/output/content/summary",
+    }
+    assert parsed["transform"]["kind"] == "parsed"
+    assert parsed["resolution"] == "resolved"
+
+
+def test_round_goal_and_plan_bind_their_real_save_events(monkeypatch, current_bundle):
+    bundle = deepcopy(current_bundle)
+    begin = {
+        "id": 60,
+        "event_seq": 60,
+        "event_type": "tool_call",
+        "event_name": "begin_round",
+        "round_index": 1,
+        "status": "completed",
+    }
+    revision = {
+        "id": 61,
+        "event_seq": 61,
+        "event_type": "tool_call",
+        "event_name": "record_multipath_plan",
+        "round_index": 1,
+        "status": "completed",
+    }
+    bundle["events"].extend([begin, revision])
+    details = {
+        60: _event_detail(begin, input_content={"goal": bundle["rounds"][0]["goal"]}, output_content={"round_index": 1}),
+        61: _event_detail(revision, input_content={"paths": bundle["rounds"][0]["multipath_plan"], "mode": "分工", "note": "保存规划"}, output_content={"saved": True}),
+    }
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, ids: {event_id: deepcopy(details[event_id]) for event_id in ids if event_id in details})
+    main._inspector_cache.clear()
+
+    goal = client.get("/api/script-builds/88001/inspector-view/round:1:goal").json()
+    goal_block = _module(goal, "goal")
+    entered = next(binding for binding in _row_bindings(goal_block) if binding["sourceId"] == "event:60")
+    assert entered["selector"]["path"] == "/input/content/goal"
+    assert entered["evidence"]["availability"] == "produced-by-run"
+
+    plan = client.get("/api/script-builds/88001/inspector-view/round:1:plan").json()
+    formation = _module(plan, "basis")
+    revisions = next(binding for binding in _row_bindings(formation) if binding["sourceId"] == "event:61")
+    assert revisions["selector"]["path"] == "/input/content"
+    assert revisions["evidence"]["availability"] == "produced-by-run"
+
+
+def test_round_result_review_uses_the_real_event_record(monkeypatch, current_bundle):
+    bundle = deepcopy(current_bundle)
+    summary = next(item for item in bundle["events"] if item["id"] == 41)
+    detail = _event_detail(
+        summary,
+        input_content={"task": "评审第 1 轮主脚本"},
+        output_content={"summary": summary["agentOutputData"]["summary"]},
+    )
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, ids: {41: deepcopy(detail)} if 41 in ids else {})
+    main._inspector_cache.clear()
+
+    payload = client.get("/api/script-builds/88001/inspector-view/round:1:result").json()
+    review_bindings = [binding for binding in _all_bindings(payload["modules"]) if binding["sourceId"] == "event:41"]
+    assert review_bindings
+    assert all(binding["selector"]["kind"] == "whole-record" for binding in review_bindings)
+    assert all(binding["resolution"] == "resolved" for binding in review_bindings)
+
+
+def test_log_window_loader_ignores_events_without_msg_id(monkeypatch):
+    captured = []
+    monkeypatch.setattr(
+        main.audit_repository,
+        "load_event_log_windows",
+        lambda build_id, anchors: captured.extend(anchors) or {},
+    )
+    result = main._safe_load_log_windows(443, {9: {"msg_id": None, "event_seq": 77}})
+    assert captured == []
+    assert result == {}
+
+
+def test_log_context_requires_the_exact_event_msg_id():
+    content = """[FOLD:第一段]
+[MSG_ANCHOR:msg_id=message-a:seq=7]
+内容 A
+[FOLD:第二段]
+[TOOL_ANCHOR:msg_id=message-b:seq=7:tool=test]
+内容 B
+"""
+    matched = locate_log_module(content, {"msg_id": "message-b", "event_seq": 7})
+    assert matched is not None
+    assert matched["msgId"] == "message-b"
+    assert "内容 B" in matched["content"]
+    assert "内容 A" not in matched["content"]
+    assert locate_log_module(content, {"msg_id": None, "event_seq": 7}) is None
+    assert locate_log_module(content, {"msg_id": "message-c", "event_seq": 7}) is None
+
+
+def test_missing_review_and_decision_cards_have_resolved_empty_sources(
+    monkeypatch, current_bundle
+):
+    bundle = deepcopy(current_bundle)
+    bundle["events"] = [
+        event
+        for event in bundle["events"]
+        if event.get("event_name") != "script_multipath_evaluator"
+    ]
+    bundle["multipathDecisions"] = []
+    monkeypatch.setattr(main.provider, "load_bundle", lambda _build_id: deepcopy(bundle))
+    monkeypatch.setattr(main.provider, "load_event_details", lambda _build_id, _ids: {})
+    main._inspector_cache.clear()
+    main._build_view_cache.clear()
+
+    execution = client.get("/api/script-builds/88001/execution-view").json()
+    batch = execution["rounds"][0]["convergenceBatches"][0]
+    refs = [
+        batch["missingReview"]["detailRef"],
+        batch["missingDecision"]["detailRef"],
+    ]
+    for detail_ref in refs:
+        response = client.get(
+            f"/api/script-builds/88001/inspector-view/{detail_ref}"
+        )
+        assert response.status_code == 200
+        payload = response.json()
+        assert payload["businessProjection"]["businessSections"][0]["id"] == "status"
+        bindings = _row_bindings(_module(payload, "status"))
+        assert bindings
+        assert all(binding["resolution"] == "resolved" for binding in bindings)
+        assert any(
+            source["resultState"] == "empty"
+            for source in payload["sources"].values()
+        )

+ 687 - 0
visualization-old/backend/tests/test_v8_builder.py

@@ -0,0 +1,687 @@
+from copy import deepcopy
+
+from app.execution_builder import ExecutionViewBuilder
+from app.inspector_projection import project_activity_detail, project_event_detail, project_round_detail
+
+
+def test_v8_contract_separates_multipath_review_main_decision_and_overall_review(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+
+    assert view["schemaVersion"] == "8"
+    assert view["dataShape"] == "current"
+    first = view["rounds"][0]
+    assert len(first["convergenceBatches"]) == 2
+    assert [batch["branchIds"] for batch in first["convergenceBatches"]] == [[1, 2], [3]]
+    assert [batch["review"]["eventId"] for batch in first["convergenceBatches"]] == [39, 40]
+    assert [batch["decision"]["recordId"] for batch in first["convergenceBatches"]] == [21, 22]
+    assert first["convergenceBatches"][0]["decision"]["branchOutcomes"] == [
+        {"branchId": 1, "status": "merged", "label": "已采用", "reasoning": "方案 1 的当前处理理由"},
+        {"branchId": 2, "status": "discarded", "label": "未采用", "reasoning": "方案 2 的当前处理理由"},
+    ]
+    assert first["overallEvaluation"]["detailRef"] == "event:41"
+    assert first["branches"][0]["dataDecisions"][0]["decision"] == "采用案例中的三段结构"
+    assert all(decision["recordId"] > 0 for branch in first["branches"] for decision in branch["dataDecisions"])
+    assert "dataDecisions" not in first["convergenceBatches"][0]
+    assert "evaluation" not in first["branches"][0]
+    assert "disposition" not in first["branches"][0]
+    retrieval = first["branches"][0]["retrievalStage"]
+    assert retrieval["directToolGroups"][0]["calls"][0]["businessLabel"] == "当前主脚本"
+    assert retrieval["agentRuns"][0]["businessLabel"] == "解构 Case Agent"
+    assert retrieval["agentRuns"][0]["querySummary"]["empty"] == 1
+    review_projection = first["convergenceBatches"][0]["review"]["decisionProjection"]
+    main_projection = first["convergenceBatches"][0]["decision"]["decisionProjection"]
+    data_projection = first["branches"][0]["dataDecisions"][0]["decisionProjection"]
+    overall_projection = first["overallEvaluation"]["decision"]
+    assert (review_projection["decisionType"], review_projection["authority"]) == (
+        "evaluation",
+        "recommendation",
+    )
+    assert (main_projection["decisionType"], main_projection["authority"]) == (
+        "tradeoff",
+        "final",
+    )
+    assert (data_projection["decisionType"], data_projection["authority"]) == (
+        "tradeoff",
+        "implementation-scope",
+    )
+    assert (overall_projection["decisionType"], overall_projection["authority"]) == (
+        "evaluation",
+        "recommendation",
+    )
+
+
+def test_multipath_review_keeps_grouped_branches_and_comparison_matrix(current_bundle):
+    review_event = next(item for item in current_bundle["events"] if item["id"] == 39)
+    review_event["agentOutputData"]["summary"] = """### 分支评估 branch_id=1(领域信息路)
+**该支结论**:部分通过
+
+**目标推进**:
+- 事实链完整。
+
+**未通过/存疑项**:
+- 元素尚未全部落库。
+
+**有据扎实度**:来源可指位。
+
+### 分支评估 branch_id=2(内容路)
+**该支结论**:部分通过
+
+**目标推进**:
+- 骨架已形成。
+
+**未通过/部分通过项**:
+- 数量红线违规。
+
+**有据扎实度**:与快照一致。
+
+### 对比与建议
+
+| 维度 | branch1 | branch2 | 差异依据 |
+|------|---------|---------|----------|
+| 目标契合度 | 高 | 高 | 各自完成主任务 |
+
+**采纳建议**:组合两个方案。"""
+
+    first = ExecutionViewBuilder().from_bundle(current_bundle)["rounds"][0]
+    projection = first["convergenceBatches"][0]["review"]["decisionProjection"]
+    blocks = projection["detail"]["blocks"]
+
+    assert [block["type"] for block in blocks[:2]] == [
+        "evaluation-branches",
+        "evaluation-matrix",
+    ]
+    branches = blocks[0]["branches"]
+    assert [item["subject"] for item in branches] == ["方案 1", "方案 2"]
+    assert branches[1]["achievements"] == ["骨架已形成。"]
+    assert branches[1]["problems"] == ["数量红线违规。"]
+    assert blocks[1]["rows"][0]["criterion"] == "目标契合度"
+
+
+def test_same_round_multipath_decisions_remain_separate_and_ordered(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    batches = [item["decision"] for item in view["rounds"][0]["convergenceBatches"]]
+
+    assert [item["recordId"] for item in batches] == [21, 22]
+    assert batches[0]["decision"] != batches[1]["decision"]
+    assert batches[0]["node"]["detailRef"] == "multipath-decision:21"
+
+
+def test_comma_separated_review_scope_joins_matching_main_decision(current_bundle):
+    review_event = next(item for item in current_bundle["events"] if item["id"] == 39)
+    review_event["inputData"]["task"] = "评估对象:branch_id = 1, 2(分工)"
+
+    first = ExecutionViewBuilder().from_bundle(current_bundle)["rounds"][0]
+    batch = next(
+        item
+        for item in first["convergenceBatches"]
+        if item.get("decision") and item["decision"]["recordId"] == 21
+    )
+
+    assert batch["association"] == "exact-branch-set-and-time"
+    assert batch["review"]["eventId"] == 39
+    assert batch["branchIds"] == [1, 2]
+    assert batch["missingReview"] is None
+    assert batch["missingDecision"] is None
+
+
+def test_review_without_main_decision_remains_a_review_only_batch(current_bundle):
+    current_bundle["multipathDecisions"] = []
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    batches = view["rounds"][0]["convergenceBatches"]
+
+    assert [item["association"] for item in batches] == ["review-only", "review-only"]
+    assert [item["review"]["eventId"] for item in batches] == [39, 40]
+    assert all(item["decision"] is None for item in batches)
+    assert all(item["missingDecision"] for item in batches)
+
+
+def test_unscoped_same_round_review_is_reported_as_unlinked_not_missing(current_bundle):
+    current_bundle["events"] = [
+        item
+        for item in current_bundle["events"]
+        if item.get("event_name") != "script_multipath_evaluator"
+    ]
+    current_bundle["events"].append(
+        {
+            "id": 4995,
+            "event_seq": 99,
+            "event_type": "agent_invoke",
+            "event_name": "script_multipath_evaluator",
+            "round_index": 1,
+            "status": "ok",
+            "inputData": {"task": "请评审本轮候选方案"},
+            "agentOutputData": {"summary": "评审内容完整,但任务未保存方案范围。"},
+        }
+    )
+
+    first = ExecutionViewBuilder().from_bundle(current_bundle)["rounds"][0]
+    missing = first["convergenceBatches"][0]["missingReview"]
+
+    assert missing["card"]["primary"]["value"] == (
+        "发现同轮评审,但无法安全关联到具体候选"
+    )
+    assert missing["sourceNotice"] == "association-incomplete"
+    assert missing["unscopedReviewRefs"] == ["event:4995"]
+
+
+def test_main_canvas_translates_agent_terms_without_changing_recorded_decision(current_bundle):
+    current_bundle["multipathDecisions"][0]["decision"] = "选择 Branch 1 合入 Base。"
+    first = ExecutionViewBuilder().from_bundle(current_bundle)["rounds"][0]
+    batch = first["convergenceBatches"][0]["decision"]
+
+    assert batch["decision"] == "选择 Branch 1 合入 Base。"
+    assert batch["node"]["card"]["primary"]["value"] == "选择方案 1 合入主脚本。"
+    assert "方案 1 合入主脚本" in first["summary"]["decision"]["summary"]
+
+
+def test_round_summary_is_structured_and_keeps_multiple_decision_batches(current_bundle):
+    current_bundle["rounds"][0]["goal"] = (
+        "将三段骨架扩展到可模拟完整观感的程度\n"
+        "① 为各段补充具体形式和脚本元素\n"
+        "② 将详细执行描述改成可执行画面\n"
+        "③ 拆分核心呈现段的二级子段\n"
+        "④ 保持三个方案互不依赖"
+    )
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    summary = view["rounds"][0]["summary"]
+
+    assert summary["goal"]["headline"].startswith("将三段骨架扩展到可模拟完整观感的程度")
+    assert summary["goal"]["itemCount"] == 0
+    assert summary["goal"]["previewItems"] == []
+    assert summary["goal"]["truncated"] is False
+    assert summary["approach"]["mode"] == "分工"
+    assert summary["approach"]["candidateCount"] == 3
+    assert summary["decision"]["batchCount"] == 2
+    assert summary["decision"]["branchIds"] == [1, 2, 3]
+    assert summary["output"]["mergedContentCount"] == 1
+    assert summary["output"]["domainFactCount"] == 1
+    assert view["rounds"][0]["result"]["artifactRef"] == "artifact:round:1"
+
+
+def test_round_detail_keeps_complete_goal_out_of_compact_summary(current_bundle):
+    full_goal = "核心目标\n① 第一项很长的具体任务\n② 第二项很长的具体任务\n③ 第三项很长的具体任务\n④ 第四项只在详情中完整保留"
+    current_bundle["rounds"][0]["goal"] = full_goal
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    first = view["rounds"][0]
+    detail = project_round_detail(88001, first, current_bundle)
+
+    assert first["summary"]["goal"]["previewItems"] == []
+    assert detail["businessSections"][0]["content"] == full_goal
+
+
+def test_branch_task_detail_uses_complete_dispatch_task_instead_of_card_preview(current_bundle):
+    full_task = "目标:" + "完整创作任务。" * 80
+    current_bundle["events"].append({
+        "id": 99,
+        "event_seq": 99,
+        "event_type": "agent_invoke",
+        "event_name": "script_implementer",
+        "agent_role": "script_implementer",
+        "round_index": 1,
+        "branch_id": 1,
+        "status": "ok",
+        "inputData": {"task": full_task},
+    })
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    task_card = view["rounds"][0]["branches"][0]["task"]["card"]["primary"]
+    detail = project_activity_detail(
+        88001,
+        "round:1:branch:1:task",
+        view,
+        current_bundle,
+    )
+
+    assert detail["businessSections"][0]["title"] == "目标"
+    assert detail["businessSections"][0]["content"] == "完整创作任务。" * 80
+    assert len(detail["businessSections"][0]["content"]) > 140
+    assert task_card["label"] == "目标"
+    assert task_card["value"] == "完整创作任务。" * 80
+    assert "…" not in task_card["value"]
+
+
+def test_final_result_has_independent_detail_and_current_artifact_entry(current_bundle):
+    current_bundle["record"]["status"] = "success"
+    current_bundle["record"]["summary"] = "已形成可交付的主脚本。"
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    final = view["finalResult"]
+    detail = project_activity_detail(
+        88001, "run:final-result", view, current_bundle
+    )
+
+    assert final["detailRef"] == "run:final-result"
+    assert final["artifactRef"] == "artifact:base:current"
+    assert final["card"]["primary"]["value"] == "已形成可交付的主脚本。"
+    assert [item["title"] for item in detail["businessSections"]] == [
+        "构建结论",
+        "构建摘要或失败原因",
+        "轮次与方案统计",
+        "最终主脚本规模",
+        "版本说明",
+    ]
+    assert detail["changes"]["artifactRef"] == "artifact:base:current"
+    assert detail["changes"]["exactness"] == "current-only"
+
+
+def test_round_result_uses_dedicated_aggregate_detail(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    detail = project_activity_detail(
+        88001, "round:1:result", view, current_bundle
+    )
+
+    assert [item["title"] for item in detail["businessSections"]] == [
+        "本轮目标完成情况",
+        "方案处置",
+        "领域事实",
+        "整体评审",
+        "主脚本变化",
+        "下一步",
+    ]
+    assert "方案 1:已采用" in detail["businessSections"][1]["content"]
+    assert "柔性排产可降低" in detail["businessSections"][2]["content"]
+    assert detail["changes"]["artifactRef"] == "artifact:base:current"
+
+
+def test_retrieval_agent_detail_prefers_lazy_loaded_complete_event(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    full_task = "查找并对比可说明因果结构的解构 Case,保留完整限制条件。"
+    full_screening = "### 初筛整理\n- 两个案例可用。\n- 一个案例因结构不符而舍弃。"
+    event_detail = {
+        "id": 52,
+        "event_name": "retrieve_data_decode_case",
+        "input": {"content": {"task": full_task}},
+        "output": {"content": {"summary": full_screening}},
+    }
+    detail = project_activity_detail(
+        88001,
+        "retrieval-agent:52",
+        view,
+        current_bundle,
+        event_detail=event_detail,
+    )
+
+    sections = {item["title"]: item["content"] for item in detail["businessSections"]}
+    assert sections["完整取数任务"] == full_task
+    assert "一个案例因结构不符而舍弃" in sections["初筛整理"]
+    assert "成功为空 1 次" in sections["原始命中"]
+
+
+def test_first_three_main_agent_cards_and_details_use_real_decision_context(current_bundle):
+    direction = """## 选题价值主张
+用真实工厂解释柔性排产的价值。
+
+## 账号价值主张
+保持专业、可核验的表达。
+
+## 创作总目标
+### 目标 1
+- 目标语句:让读者看懂柔性排产如何减少浪费。
+### 目标 2
+- 目标语句:用可核验的行业事实支撑结论。
+
+## 领域评估维度
+- 维度名:数据可信度
+"""
+    current_bundle["record"]["script_direction"] = direction
+    first_round = current_bundle["rounds"][0]
+    first_round["goal"] = "完成主结构。\n① 补齐机制拆解\n② 核实行业数据"
+    paths = first_round["multipath_plan"]
+    current_bundle["events"] = [
+        {
+            "id": 100,
+            "event_seq": 0,
+            "event_type": "agent_scope",
+            "event_name": "main",
+            "agent_role": "main",
+            "agent_depth": 0,
+            "scope_event_id": 100,
+            "status": "ok",
+        },
+        *[
+            {
+                "id": event_id,
+                "event_seq": 1,
+                "event_type": "tool_call",
+                "event_name": name,
+                "agent_role": "main",
+                "agent_depth": 0,
+                "scope_event_id": 100,
+                "parent_event_id": 100,
+                "status": "ok",
+                "ended_at": "2026-07-13T09:59:01",
+            }
+            for event_id, name in (
+                (101, "get_topic_detail"),
+                (102, "get_account_script_section_patterns"),
+                (103, "get_account_script_persona_points"),
+            )
+        ],
+        {
+            "id": 108,
+            "event_seq": 2,
+            "event_type": "tool_call",
+            "event_name": "think_and_plan",
+            "agent_role": "main",
+            "agent_depth": 0,
+            "scope_event_id": 100,
+            "parent_event_id": 100,
+            "status": "ok",
+            "inputData": {
+                "thought_number": 1,
+                "thought_summary": "先锁定创作目标,再竞争首轮结构。",
+                "thought": "完整分析账号结构、选题价值与领域下限。",
+                "plan": "保存创作目标,然后开启首轮。",
+                "action": "写入创作总目标",
+            },
+            "ended_at": "2026-07-13T09:59:02",
+        },
+        {
+            "id": 104,
+            "event_seq": 3,
+            "event_type": "tool_call",
+            "event_name": "save_script_direction",
+            "agent_role": "main",
+            "agent_depth": 0,
+            "scope_event_id": 100,
+            "parent_event_id": 100,
+            "status": "ok",
+            "inputData": {"script_direction": direction},
+            "outputData": {"success": True},
+            "ended_at": "2026-07-13T09:59:02",
+        },
+        {
+            "id": 105,
+            "event_seq": 4,
+            "event_type": "tool_call",
+            "event_name": "begin_round",
+            "agent_role": "main",
+            "agent_depth": 0,
+            "scope_event_id": 100,
+            "parent_event_id": 100,
+            "round_index": 0,
+            "status": "ok",
+            "outputData": {"success": True, "round_index": 1},
+            "ended_at": "2026-07-13T09:59:03",
+        },
+        {
+            "id": 106,
+            "event_seq": 5,
+            "event_type": "tool_call",
+            "event_name": "get_script_snapshot",
+            "agent_role": "main",
+            "agent_depth": 0,
+            "scope_event_id": 100,
+            "parent_event_id": 100,
+            "round_index": 1,
+            "status": "ok",
+            "outputData": {"count": 0},
+            "ended_at": "2026-07-13T09:59:04",
+        },
+        {
+            "id": 107,
+            "event_seq": 6,
+            "event_type": "tool_call",
+            "event_name": "record_multipath_plan",
+            "agent_role": "main",
+            "agent_depth": 0,
+            "scope_event_id": 100,
+            "parent_event_id": 100,
+            "round_index": 1,
+            "status": "ok",
+            "inputData": {
+                "paths": paths,
+                "mode": "分工",
+                "note": first_round["plan_note"],
+            },
+            "outputData": {"success": True, "round_index": 1},
+            "ended_at": "2026-07-13T09:59:05",
+        },
+        *current_bundle["events"],
+    ]
+
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    planning = view["planning"]
+    objective = view["objective"]
+    first = view["rounds"][0]
+
+    assert planning["title"] == "创作规划解析"
+    assert planning["detailRef"] == "event:108"
+    assert planning["card"]["primary"]["value"] == "先锁定创作目标,再竞争首轮结构。"
+    planning_event = next(item for item in current_bundle["events"] if item["id"] == 108)
+    planning_detail = project_event_detail(88001, planning_event)
+    assert [section["title"] for section in planning_detail["businessSections"]] == [
+        "规划概要",
+        "完整思考",
+        "执行计划",
+        "下一步",
+    ]
+    assert planning_detail["businessSections"][1]["content"] == "完整分析账号结构、选题价值与领域下限。"
+    assert objective["card"]["primary"]["value"] == "让读者看懂柔性排产如何减少浪费。"
+    assert [line["label"] for line in objective["card"]["secondary"]] == [
+        "决策前读取",
+        "目标结构",
+    ]
+    assert objective["card"]["secondary"][0]["value"] == "选题 · 账号段落模式 · 账号人设"
+    assert "2 个创作目标" in objective["card"]["secondary"][1]["value"]
+    assert "完成主结构" in first["goal"]["card"]["primary"]["value"]
+    assert "补齐机制拆解" in first["goal"]["card"]["primary"]["value"]
+    assert first["goal"]["card"]["secondary"][0]["label"] == "形成前可见"
+    assert first["plan"]["node"]["card"]["secondary"][0]["label"] == "各路任务"
+    assert first["plan"]["runtimeRevisionRefs"] == ["event:107"]
+    assert "decisionContext" not in str(view)
+
+    objective_detail = project_activity_detail(88001, "run:objective", view, current_bundle)
+    goal_detail = project_activity_detail(88001, "round:1:goal", view, current_bundle)
+    plan_detail = project_activity_detail(88001, "round:1:plan", view, current_bundle)
+    objective_titles = [block["title"] for block in objective_detail["blocks"]]
+    assert objective_titles == ["当前保存的创作方向", "根据以下信息做出的决策"]
+    assert objective_detail["blocks"][0]["value"] == direction.strip()
+    assert objective_detail["blocks"][0]["presentation"] == "document"
+    assert "关键约束" not in objective_titles
+    assert "构建总结" not in str(objective_detail["blocks"])
+    goal_titles = [block["title"] for block in goal_detail["blocks"]]
+    assert goal_titles[0] == "本轮目标构成"
+    assert goal_detail["blocks"][0]["presentation"] == "document"
+    assert goal_detail["blocks"][0]["value"] == current_bundle["rounds"][0]["goal"]
+    assert "核心目标" not in str(goal_detail["blocks"][0])
+    plan_block = plan_detail["blocks"][0]
+    assert plan_block["type"] == "implementation-plan"
+    assert plan_block["title"] == "本轮实施路线"
+    assert plan_block["routes"][0]["target"] == "文章结构"
+    assert any(block["title"] == "根据以下信息做出的决策" for block in plan_detail["blocks"])
+    assert "人设详细原文" not in str(view)
+
+
+def test_running_round_waits_for_main_agent_without_claiming_record_loss(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    second = view["rounds"][1]
+
+    batch = second["convergenceBatches"][0]
+    assert batch["missingDecision"]["kind"] == "execution"
+    assert batch["missingDecision"]["card"]["primary"]["value"] == "等待主 Agent 比较候选方案"
+    assert "sourceNotice" not in batch["missingDecision"]
+    assert second["branches"][0]["outcome"]["label"] == "等待主 Agent 处置"
+    assert not any(item["id"] == "missing-multipath-decisions" for item in view["warnings"])
+
+
+def test_stopped_round_is_process_incomplete_not_record_missing(current_bundle):
+    stopped = deepcopy(current_bundle)
+    stopped["record"]["status"] = "stopped"
+    stopped["multipathDecisions"] = []
+    stopped["events"] = []
+    view = ExecutionViewBuilder().from_bundle(stopped)
+    first = view["rounds"][0]
+
+    batch = first["convergenceBatches"][0]
+    assert batch["missingDecision"]["card"]["primary"]["value"] == "构建停止前尚未形成多路决策"
+    assert batch["missingDecision"]["sourceNotice"] == "process-incomplete"
+    assert first["overallEvaluation"]["card"]["primary"]["value"] == "构建停止前尚未进行整体评审"
+    assert first["overallEvaluation"]["sourceNotice"] == "process-incomplete"
+    open_branch = view["rounds"][1]["branches"][0]
+    assert open_branch["outcome"]["label"] == "构建停止时尚未处置"
+    assert open_branch["outcome"]["sourceNotice"] == "process-incomplete"
+    assert not any(item["id"] == "missing-multipath-decisions" for item in view["warnings"])
+
+
+def test_completed_round_without_main_decision_is_a_real_record_gap(current_bundle):
+    completed = deepcopy(current_bundle)
+    completed["record"]["status"] = "success"
+    completed["multipathDecisions"] = []
+    completed["events"] = []
+    view = ExecutionViewBuilder().from_bundle(completed)
+    first = view["rounds"][0]
+
+    batch = first["convergenceBatches"][0]
+    assert batch["missingDecision"]["card"]["primary"]["value"] == "构建已经结束,但未找到主 Agent 多路决策记录"
+    assert batch["missingDecision"]["sourceNotice"] == "record-missing"
+    assert first["overallEvaluation"]["card"]["primary"]["value"] == "构建已经结束,但未找到明确的整体评审结论"
+    assert first["overallEvaluation"]["sourceNotice"] == "record-missing"
+    assert first["overallEvaluation"]["status"] == "not-evaluated"
+    assert first["state"] == "not-evaluated"
+    assert first["result"]["status"] == "not-evaluated"
+    assert any(item["id"] == "missing-multipath-decisions" for item in view["warnings"])
+
+
+def test_domain_information_is_an_independent_output(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    branch = next(item for item in view["rounds"][0]["branches"] if item["branchId"] == 2)
+
+    assert branch["pathType"] == "领域信息"
+    assert branch["output"]["type"] == "domain-info"
+    assert branch["output"]["factCount"] == 1
+    assert branch["output"]["summary"] == "新增 1 条已核实领域事实"
+    assert branch["status"] == "discarded"
+
+
+def test_runtime_event_only_supplies_explicit_evaluation_and_keeps_orphan_agent_unassigned(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+
+    first = view["rounds"][0]
+    assert first["overallEvaluation"]["sourceNotice"] == "runtime-associated"
+    assert first["state"] == "partially-passed"
+    assert view["rounds"][1]["state"] == "running"
+    assert [item["eventId"] for item in view["unassigned"]["runtimeEvents"]] == [42]
+
+
+def test_branch_zero_rows_cannot_enter_new_projection(current_bundle):
+    current_bundle["dataDecisions"].append({
+        "id": 999,
+        "round_index": 1,
+        "branch_id": 0,
+        "decision": "危险旧决策内容",
+        "sources": [],
+    })
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    serialized = str(view)
+
+    assert "危险旧决策内容" not in serialized
+    assert [item["decision"]["recordId"] for item in view["rounds"][0]["convergenceBatches"]] == [21, 22]
+
+
+def test_old_record_is_visible_but_not_reinterpreted(current_bundle):
+    legacy = deepcopy(current_bundle)
+    legacy["events"] = []
+    legacy["multipathDecisions"] = []
+    legacy["domainInfo"] = []
+    for branch in legacy["branches"]:
+        branch["path_type"] = None
+
+    view = ExecutionViewBuilder().from_bundle(legacy)
+    assert view["dataShape"] == "legacy-incomplete"
+    assert all(not any(batch.get("decision") for batch in round_["convergenceBatches"]) for round_ in view["rounds"])
+    assert any(item["id"] == "legacy-shape" for item in view["warnings"])
+
+
+def test_main_payload_does_not_embed_event_bodies_or_candidate_snapshot(current_bundle):
+    current_bundle["events"][0]["output_body"] = "secret complete report"
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    serialized = str(view)
+
+    assert "secret complete report" not in serialized
+    assert "candidate_snapshot" not in serialized
+    assert "script_build_event_body" not in serialized
+
+
+def test_inspector_exposes_business_decisions_and_only_real_changes(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    first = view["rounds"][0]
+    round_detail = project_round_detail(88001, first, current_bundle)
+    data_detail = project_activity_detail(88001, "data-decision:11", view, current_bundle)
+    main_detail = project_activity_detail(88001, "multipath-decision:21", view, current_bundle)
+    goal_detail = project_activity_detail(88001, "round:1:goal", view, current_bundle)
+
+    assert round_detail["changes"]["exactness"] == "current-only"
+    assert data_detail["detailKind"] == "agent-decision"
+    assert [block["title"] for block in data_detail["blocks"]] == [
+        "参与判断",
+        "明确取舍",
+        "理由",
+    ]
+    assert main_detail["detailKind"] == "agent-decision"
+    assert "最终决定" in [block["title"] for block in main_detail["blocks"]]
+    assert "评审建议与最终决定" in [
+        block["title"] for block in main_detail["blocks"]
+    ]
+    assert "changes" not in goal_detail
+
+
+def test_explicit_next_round_translation_is_shown_as_recommendation_handling(current_bundle):
+    changed = deepcopy(current_bundle)
+    changed["multipathDecisions"][0]["reasoning"] = (
+        "方案 1 直接采用。方案 2 的数据优势很有价值,"
+        "但不直接合并,决定在下一轮转译为增强目标。"
+    )
+
+    view = ExecutionViewBuilder().from_bundle(changed)
+    rows = view["rounds"][0]["convergenceBatches"][0]["decision"][
+        "decisionProjection"
+    ]["body"]["reviewComparison"]
+
+    assert "handling" in rows[1]
+    assert "下一轮转译" in rows[1]["handling"]
+
+
+def test_every_projected_business_section_is_readable_text(current_bundle):
+    view = ExecutionViewBuilder().from_bundle(current_bundle)
+    refs = [
+        "run:objective",
+        "data-decision:11",
+        "multipath-decision:21",
+        "domain-info:31",
+        "round:1:goal",
+        "round:1:plan",
+        "round:1:result",
+        "round:1:branch:1",
+        "round:1:branch:1:task",
+        "round:1:branch:1:retrieval",
+        "round:1:branch:1:output",
+    ]
+    details = [project_round_detail(88001, view["rounds"][0], current_bundle)]
+    details.extend(project_activity_detail(88001, ref, view, current_bundle) for ref in refs)
+
+    for detail in details:
+        if detail.get("detailKind") == "agent-decision":
+            assert detail["blocks"]
+            _assert_business_blocks_hide_technical_fields(detail["blocks"])
+        else:
+            assert detail["businessSections"]
+            assert all(isinstance(section["content"], str) for section in detail["businessSections"])
+        assert all(
+            isinstance(section["content"], str)
+            for section in (detail.get("changes") or {}).get("sections", [])
+        )
+
+
+def _assert_business_blocks_hide_technical_fields(blocks):
+    serialized = str(blocks)
+    for forbidden in (
+        "script_build_id",
+        "branch_id",
+        "paragraph_id",
+        "post_id",
+        "event ID",
+        "Tool 名",
+        "path_type",
+        "Base",
+        "patch",
+    ):
+        assert forbidden not in serialized

+ 1 - 0
visualization-old/exports/.gitkeep

@@ -0,0 +1 @@
+

+ 57 - 0
visualization-old/frontend/DESIGN.md

@@ -0,0 +1,57 @@
+# Design System
+
+## Theme
+
+深色运行画布,保留旧 Demo 的 n8n 式点阵背景与橙红当前态。颜色只服务于选中和“已采用/暂存/未采用”等关键业务状态。
+
+## Palette
+
+- Background `#101114`
+- Canvas `#15171b`
+- Surface `#202228`
+- Raised surface `#292c33`
+- Accent `#ff6d5a`
+- Success `#63c883`
+- Warning `#f4c152`
+- Failure `#ff806e`
+
+## Typography
+
+Inter/system-ui 单字体产品界面;数据、ID 与技术引用使用 ui-monospace。层级依靠字重与间距,不使用装饰性大标题。
+
+## Signature
+
+横向的“先发散、再收敛”是界面的视觉签名:主 Agent 目标与规划在左侧,多个实现 Agent 泳道向中间展开;每条泳道内部展示取数、数据决策和候选产出。所有候选先汇入多方案评审 Agent,再由主 Agent 按真实批次收敛,随后进入主脚本整体评审与本轮产出。
+
+## Layout
+
+画布内悬浮运行工具条 + 顶部轮次导航 + 中央 React Flow 横向画布 + 右侧覆盖式 Inspector。面向非技术人员的业务 Inspector 保持单列阅读;独立的“数据来源”Inspector 不挤压画布,桌面端以“业务详情中的一项|这一项的数据依据|对应的原始字段或原文”逐项并行展示,窄屏则在每个比较项内按三段纵向排列。小屏画布本身仍切换为按轮次排列的 accordion。
+
+初始视口以文字可读为先,不把十轮流程强行压缩到一屏。历史轮默认折叠为摘要,当前轮原位展开;展开时保持视口锚点。候选分支纵向并行,高度随真实内容计算。
+
+## Agent Decision Cards
+
+- Direction、Tradeoff、Evaluation、Creative 共用统一卡片外壳:Agent 身份、决策类型、决定权、一个主结论和最多三个补充摘要。
+- Direction 说明最终方向;Tradeoff 说明取舍结果;Evaluation 明确标成建议;Creative 以“产生候选创作表”展示有真实运行记录的创作处理和候选产物,不重复前置“实现任务”的完整创作任务。
+- Implementation Plan 不复用普通 Direction 的长文本布局:Inspector 逐路展示动作、类型、作用范围、实施方法、路线侧重和规划理由,完整形成依据默认折叠。
+- 主卡不重复显示“系统记录”、默认完整性徽标、数据库来源或“为什么 · 0”。
+- 来源仅在“运行记录关联、记录缺失、旧版本记录”会影响理解时显示。
+- 决策卡的摘要预算是一个主结论、默认两个摘要、最多三个摘要;完整内容进入 Inspector,不硬编码句数。
+- 执行节点只回答“做了什么、得到什么”;结果节点回答“是否达标、形成什么、下一步”。
+- 候选卡默认只显示任务、候选产出和状态徽标;取数、依据与自评通过展开和 Inspector 查看。
+- 多方案评审显示“建议”,主 Agent 多路决策显示“最终决定”,不能用颜色或文案将二者混为一体。
+- 主脚本影响并入本轮产出,不设置独立 Base 节点。
+
+## Inspector
+
+- 业务 Inspector 与“数据来源”Inspector 是两个独立入口;业务 Inspector 继续只服务业务阅读,数据来源 Inspector 专门服务调试对照。
+- 数据来源 Inspector 的第一列直接复用业务 Inspector 的投影、顺序和完整内容。每个可见叶子项占一整行,三列共享稳定的 `moduleId`、`rowId` 和 `bindingId`,不再让用户跨模块寻找来源。
+- `这一项的数据依据` 展示来源类型、转换方式与采用证据。“内容来自哪里”和“Agent 是否采用”必须分开;没有引用不等于明确未采用。
+- `对应的原始字段或原文` 默认直接展示精确字段值或带上下文的原文片段,并显示数据表与记录 ID、Event ID 与名称、状态、耗时和完整性。JSON Pointer 与字符范围进入“定位详情”,完整 Event、数据库记录和 JSON 进入二级折叠。
+- 无法安全定位的内容必须显示带原因的 `unresolved`,禁止静默空绑定,也不用文本相似度补链。
+- 业务模块可整体折叠;超过 800 字的内容默认显示 240 字预览,数据在数据来源 Inspector 打开时一次读取,大段 DOM 仅在展开时挂载。
+- Prompt 抽屉把“本次真实任务”和“当前版本规则”分开,并持续说明当前规则不是历史运行快照。
+
+## Interaction
+
+Mac 双指自由平移,捏合缩放,普通滚轮不缩放;不显示 MiniMap。所有主要按钮和触控目标至少 44px。节点支持 Tab、Enter、Space;数据来源 Inspector 中点击或聚焦来源编号会同步突出当前比较行,Escape 先清除突出状态,再关闭 Inspector,关闭后恢复原卡片按钮焦点。

+ 3273 - 0
visualization-old/frontend/app/globals.css

@@ -0,0 +1,3273 @@
+:root {
+  --font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
+  --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+
+  --type-caption: .75rem;
+  --type-label: .8125rem;
+  --type-ui: .875rem;
+  --type-body: 1rem;
+  --type-title: 1.125rem;
+  --type-heading: 1.25rem;
+  --inspector-title-size: var(--type-heading);
+  --inspector-section-title-size: var(--type-title);
+  --inspector-field-label-size: var(--type-label);
+  --inspector-copy-size: var(--type-body);
+  --weight-regular: 400;
+  --weight-medium: 500;
+  --weight-semibold: 600;
+  --weight-bold: 700;
+
+  --bg: #101114;
+  --canvas: #15171b;
+  --surface: #202228;
+  --surface-raised: #25282f;
+  --surface-control: #2b2e36;
+  --surface-subtle: #1b1d22;
+  --text-primary: #f5f5f6;
+  --text-secondary: #d7d9df;
+  --text-muted: #aeb2bb;
+  --text-subtle: #9298a3;
+  --border-subtle: #30343d;
+  --border: #3c414c;
+  --border-strong: #5b6270;
+  --accent: #ff6d5a;
+  --accent-hover: #ff806e;
+  --accent-soft: rgba(255, 109, 90, .12);
+  --success: #63c883;
+  --warning: #f4c152;
+  --danger: #ff806e;
+  --info: #7bb8ff;
+  --evaluator: #b7a0eb;
+  --retrieval: #e6a86f;
+
+  --space-1: .25rem;
+  --space-2: .5rem;
+  --space-3: .75rem;
+  --space-4: 1rem;
+  --space-5: 1.5rem;
+  --space-6: 2rem;
+  --radius-sm: .5rem;
+  --radius-md: .75rem;
+  --radius-lg: 1rem;
+  --shadow-sm: 0 5px 14px rgba(0, 0, 0, .22);
+  --shadow-md: 0 16px 38px rgba(0, 0, 0, .32), 0 2px 8px rgba(0, 0, 0, .24);
+  --ease-out: cubic-bezier(.16, 1, .3, 1);
+  --z-sticky: 20;
+  --z-popover: 30;
+  --z-drawer: 40;
+  --z-toast: 50;
+  --z-modal: 60;
+}
+
+* { box-sizing: border-box; }
+
+html,
+body {
+  width: 100%;
+  height: 100%;
+  margin: 0;
+}
+
+body {
+  overflow: hidden;
+  background: var(--bg);
+  color: var(--text-primary);
+  font-family: var(--font-ui);
+  font-size: 100%;
+  font-weight: var(--weight-regular);
+  line-height: 1.5;
+  letter-spacing: .008em;
+  font-kerning: normal;
+  font-variant-numeric: tabular-nums;
+  text-rendering: optimizeLegibility;
+}
+
+button,
+input,
+select { font: inherit; }
+
+button,
+select { cursor: pointer; }
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+[tabindex]:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: 3px;
+}
+
+.srOnly {
+  position: absolute !important;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+}
+
+/* App shell and floating run controls */
+
+.appFrame {
+  position: relative;
+  height: 100vh;
+  background: var(--bg);
+}
+
+.runHeader {
+  position: absolute;
+  z-index: var(--z-sticky);
+  top: var(--space-4);
+  left: var(--space-4);
+  right: var(--space-4);
+  pointer-events: none;
+}
+
+.withInspector .runHeader { right: 29.75rem; }
+
+.topBar {
+  min-height: 4rem;
+  display: flex;
+  align-items: center;
+  gap: var(--space-2);
+  padding: var(--space-2);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface);
+  box-shadow: var(--shadow-md);
+  pointer-events: auto;
+}
+
+.productMark {
+  min-width: max-content;
+  display: flex;
+  align-items: center;
+  gap: var(--space-2);
+  padding: 0 var(--space-3) 0 var(--space-1);
+}
+
+.productIcon {
+  width: 2.5rem;
+  height: 2.5rem;
+  display: grid;
+  place-items: center;
+  flex: none;
+  color: var(--accent);
+  border: 1px solid rgba(255, 109, 90, .42);
+  border-radius: var(--radius-sm);
+  background: var(--accent-soft);
+}
+
+.productMark h1 {
+  margin: 0;
+  font-size: var(--type-body);
+  font-weight: var(--weight-semibold);
+  line-height: 1.25;
+}
+
+.buildPicker {
+  min-width: 19.5rem;
+  display: flex;
+  align-items: center;
+  gap: var(--space-2);
+}
+
+.buildPicker select,
+.buildPicker input {
+  height: 2.75rem;
+  min-width: 0;
+  padding: 0 var(--space-3);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--canvas);
+  color: var(--text-primary);
+  font-size: var(--type-ui);
+  transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out);
+}
+
+.buildPicker select:hover,
+.buildPicker input:hover { border-color: var(--border-strong); }
+.buildPicker select { width: 12rem; }
+.buildPicker input { width: 7rem; }
+.buildPicker input::placeholder { color: var(--text-muted); opacity: 1; }
+
+.quietButton,
+.iconButton {
+  min-height: 2.75rem;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: .375rem;
+  border-radius: var(--radius-sm);
+  white-space: nowrap;
+  font-size: var(--type-ui);
+  font-weight: var(--weight-semibold);
+  transition: color 160ms var(--ease-out), border-color 160ms var(--ease-out), background 160ms var(--ease-out), transform 160ms var(--ease-out);
+}
+
+.quietButton:active,
+.iconButton:active { transform: translateY(1px); }
+
+.quietButton,
+.iconButton {
+  border: 1px solid var(--border);
+  background: var(--surface-control);
+  color: var(--text-secondary);
+}
+
+.quietButton { padding: 0 var(--space-3); }
+.quietButton:hover,
+.iconButton:hover { border-color: var(--border-strong); color: var(--text-primary); background: #31353e; }
+.iconButton { width: 2.75rem; padding: 0; }
+
+.roundNav {
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  gap: .375rem;
+  overflow-x: auto;
+  scrollbar-width: none;
+}
+.roundNav::-webkit-scrollbar { display: none; }
+.roundNav button {
+  min-width: max-content;
+  min-height: 2.75rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: .375rem;
+  padding: 0 var(--space-3);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--canvas);
+  color: var(--text-secondary);
+  font-size: var(--type-label);
+  font-weight: var(--weight-medium);
+}
+.roundNav button:hover { border-color: var(--border-strong); }
+.roundNav button.active {
+  border-color: var(--accent);
+  background: var(--accent-soft);
+  color: var(--text-primary);
+}
+
+.runMeta {
+  display: flex;
+  align-items: center;
+  gap: .375rem;
+  margin-left: auto;
+}
+
+.refreshing { width: 1.75rem; display: grid; place-items: center; color: var(--text-muted); }
+.refreshing svg { animation: spin 1s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.dataNotes { position: relative; }
+.dataNotes summary {
+  min-height: 2.75rem;
+  display: flex;
+  align-items: center;
+  gap: .375rem;
+  padding: 0 var(--space-3);
+  border-radius: var(--radius-sm);
+  color: var(--warning);
+  font-size: var(--type-label);
+  font-weight: var(--weight-medium);
+  white-space: nowrap;
+  cursor: pointer;
+  list-style: none;
+}
+.dataNotes summary::-webkit-details-marker { display: none; }
+.dataNotes summary:hover { background: var(--surface-control); }
+.dataNotes summary b {
+  min-width: 1.25rem;
+  height: 1.25rem;
+  display: grid;
+  place-items: center;
+  border-radius: 999px;
+  background: rgba(244, 193, 82, .13);
+  font-size: var(--type-caption);
+}
+.dataNotes > div {
+  position: absolute;
+  top: 3.25rem;
+  right: 0;
+  width: min(28rem, 80vw);
+  padding: var(--space-4);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface-raised);
+  box-shadow: var(--shadow-md);
+}
+.dataNotes ul {
+  margin: 0 0 var(--space-3);
+  padding-left: 1.25rem;
+  color: var(--text-secondary);
+  font-size: var(--type-ui);
+  line-height: 1.6;
+}
+.dataNotes li + li { margin-top: var(--space-2); }
+.dataNotes p,
+.dataNotes small { color: var(--text-muted); font-size: var(--type-label); }
+
+/* Canvas and React Flow */
+
+.workspace {
+  position: relative;
+  height: 100vh;
+  min-height: 0;
+  display: grid;
+  grid-template-columns: minmax(0, 1fr);
+  overflow: hidden;
+}
+.withInspector .workspace { grid-template-columns: minmax(0, 1fr) 28.75rem; }
+
+.flowShell {
+  position: relative;
+  min-width: 0;
+  min-height: 0;
+  overflow: hidden;
+  background: var(--canvas);
+}
+.react-flow { background: var(--canvas); }
+.react-flow__edge-path { stroke: #858b95; stroke-width: 1.5; }
+
+.react-flow__controls {
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface);
+  box-shadow: var(--shadow-sm);
+}
+.react-flow__controls-button {
+  width: 2.75rem !important;
+  height: 2.75rem !important;
+  border-bottom-color: var(--border) !important;
+  background: var(--surface) !important;
+  fill: var(--text-secondary) !important;
+}
+.react-flow__controls-button:hover { background: var(--surface-control) !important; }
+.react-flow__node.selected .flowCard {
+  border-color: var(--accent);
+  box-shadow: 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-soft), var(--shadow-md);
+}
+
+/* Business cards */
+
+.flowCard {
+  width: 20rem;
+  min-height: 12.25rem;
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-4);
+  padding: 1.125rem;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface);
+  color: var(--text-primary);
+  box-shadow: var(--shadow-sm);
+  user-select: none;
+  transition: border-color 180ms var(--ease-out), box-shadow 180ms var(--ease-out), background 180ms var(--ease-out);
+}
+.flowCard:hover { border-color: var(--border-strong); background: #22252b; }
+.flowHandle {
+  width: .625rem !important;
+  height: .625rem !important;
+  border: 2px solid var(--canvas) !important;
+  background: #858b95 !important;
+}
+.cardHeader {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-2);
+}
+.cardIdentity { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
+.actorBadge {
+  display: inline-flex;
+  align-items: center;
+  min-height: 1.5rem;
+  padding: .125rem .5rem;
+  border: 1px solid currentColor;
+  border-radius: 999px;
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+  line-height: 1.2;
+  white-space: nowrap;
+}
+.actor-main { color: var(--accent); background: rgba(255, 109, 90, .06); }
+.actor-implementer { color: var(--info); background: rgba(123, 184, 255, .06); }
+.actor-retrieval { color: var(--retrieval); background: rgba(230, 168, 111, .065); }
+.actor-evaluator { color: var(--evaluator); background: rgba(183, 160, 235, .065); }
+.flowCard h3 {
+  margin: 0;
+  color: var(--text-primary);
+  font-size: var(--type-title);
+  font-weight: var(--weight-semibold);
+  line-height: 1.35;
+  letter-spacing: -.01em;
+  text-wrap: balance;
+}
+.storyRole,
+.roundNumber,
+.candidateLabel,
+.finalLabel {
+  display: inline-flex;
+  align-items: center;
+  gap: .375rem;
+  color: var(--text-muted);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+  line-height: 1.35;
+}
+.role-objective,
+.story-decision { border-color: rgba(255, 109, 90, .5); }
+.role-branch-task,
+.role-candidate-output { border-color: rgba(123, 184, 255, .42); }
+.role-data-decision { border-color: rgba(230, 168, 111, .42); }
+.role-multipath-decision { border-color: rgba(255, 109, 90, .52); }
+.role-domain-output { border-color: rgba(99, 200, 131, .44); }
+.role-multipath-review,
+.role-round-evaluation { border-color: rgba(183, 160, 235, .48); }
+
+.story-result,
+.runResultCard { border-color: rgba(99, 200, 131, .48); }
+.story-missing { border-color: rgba(244, 193, 82, .5); border-style: dashed; }
+
+.agentDecisionCard { min-height: 18.5rem; }
+.agentDecisionCard.decision-direction { border-color: rgba(255, 109, 90, .55); }
+.agentDecisionCard.decision-tradeoff { border-color: rgba(230, 168, 111, .52); }
+.agentDecisionCard.decision-evaluation { border-color: rgba(183, 160, 235, .54); }
+.agentDecisionCard.decision-creative { border-color: rgba(123, 184, 255, .5); }
+.authorityBadge {
+  display: inline-flex;
+  align-items: center;
+  min-height: 1.5rem;
+  padding: .125rem .5rem;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  color: var(--text-muted);
+  background: rgba(255, 255, 255, .025);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+  white-space: nowrap;
+}
+.authority-final { color: var(--accent-hover); border-color: rgba(255, 109, 90, .42); }
+.authority-recommendation { color: var(--evaluator); border-color: rgba(183, 160, 235, .42); }
+.authority-implementation-scope { color: var(--info); border-color: rgba(123, 184, 255, .38); }
+.promptAction { color: var(--text-muted); }
+.inspectorDecisionActions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
+
+.cardFields,
+.candidateSummary { display: grid; gap: var(--space-3); }
+.cardFields > div,
+.candidateSummary > div {
+  display: grid;
+  grid-template-columns: 3.25rem minmax(0, 1fr);
+  gap: var(--space-2);
+  align-items: start;
+}
+.cardFields > div,
+.candidateSummary > div { grid-template-columns: 4.5rem minmax(0, 1fr); }
+.cardFields b,
+.candidateSummary b {
+  padding-top: .125rem;
+  color: var(--text-muted);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+  line-height: 1.5;
+  white-space: nowrap;
+}
+.cardFields p,
+.candidateSummary p {
+  margin: 0;
+  color: var(--text-secondary);
+  font-size: var(--type-ui);
+  font-weight: var(--weight-regular);
+  line-height: 1.58;
+  letter-spacing: .012em;
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+  text-wrap: pretty;
+}
+.cardFields .cardFieldPrimary p {
+  color: var(--text-primary);
+  font-size: var(--type-body);
+  font-weight: var(--weight-semibold);
+  line-height: 1.58;
+}
+
+.cardFooter {
+  display: flex;
+  align-items: center;
+  gap: var(--space-2);
+  margin-top: auto;
+  flex-wrap: wrap;
+}
+.nodeAction {
+  min-height: 2.75rem;
+  display: inline-flex;
+  align-items: center;
+  gap: .375rem;
+  padding: 0 var(--space-3);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--canvas);
+  color: var(--text-secondary);
+  font-size: var(--type-label);
+  font-weight: var(--weight-semibold);
+  transition: color 160ms var(--ease-out), border-color 160ms var(--ease-out), background 160ms var(--ease-out);
+}
+.nodeAction:hover { border-color: var(--accent); background: var(--accent-soft); color: var(--text-primary); }
+.nodeAction.artifactAction {
+  border-color: rgba(255, 109, 90, .42);
+  color: var(--accent-hover);
+}
+.nodeAction.lineageAction,
+.frameDetail.lineageAction {
+  border-color: rgba(123, 184, 255, .34);
+  color: #a9cfff;
+  background: rgba(123, 184, 255, .045);
+}
+.nodeAction.lineageAction:hover,
+.frameDetail.lineageAction:hover { border-color: var(--info); background: rgba(123, 184, 255, .11); color: #d6e9ff; }
+
+.roundSummaryCard { width: 22.5rem; min-height: 25rem; }
+.roundSummaryContent { display: grid; gap: 1.125rem; }
+.roundGoalSummary {
+  display: grid;
+  gap: .5rem;
+  padding-bottom: 1rem;
+  border-bottom: 1px solid var(--border-subtle);
+}
+.roundGoalSummary > b,
+.roundSummaryBlock > b {
+  color: var(--text-muted);
+  font-size: var(--type-label);
+  font-weight: var(--weight-semibold);
+  line-height: 1.4;
+}
+.roundGoalSummary > p {
+  display: -webkit-box;
+  margin: 0;
+  overflow: hidden;
+  color: var(--text-primary);
+  font-size: var(--type-body);
+  font-weight: var(--weight-semibold);
+  line-height: 1.55;
+  letter-spacing: 0;
+  overflow-wrap: anywhere;
+  text-wrap: pretty;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 3;
+}
+.roundGoalSummary > small {
+  color: var(--text-subtle);
+  font-size: var(--type-caption);
+  line-height: 1.45;
+}
+.roundSummaryFacts { display: grid; gap: .9375rem; }
+.roundSummaryBlock {
+  display: grid;
+  grid-template-columns: 5.75rem minmax(0, 1fr);
+  gap: var(--space-3);
+  align-items: start;
+}
+.roundSummaryBlock > div { min-width: 0; display: grid; gap: .25rem; }
+.roundSummaryBlock p,
+.roundSummaryBlock small {
+  margin: 0;
+  overflow-wrap: anywhere;
+  text-wrap: pretty;
+}
+.roundSummaryBlock p {
+  color: var(--text-secondary);
+  font-size: var(--type-ui);
+  font-weight: var(--weight-medium);
+  line-height: 1.55;
+}
+.roundSummaryBlock small {
+  display: -webkit-box;
+  overflow: hidden;
+  color: var(--text-subtle);
+  font-size: var(--type-caption);
+  line-height: 1.5;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+.summaryTone-decision p:first-child { color: var(--accent-hover); }
+.summaryTone-success p { color: var(--success); }
+.summaryTone-warning p { color: var(--warning); }
+.candidateCard { width: 22.5rem; min-height: 13rem; border-color: rgba(123, 184, 255, .38); }
+.runResultCard {
+  width: 22.5rem;
+  min-height: 23rem;
+  border-color: rgba(99, 200, 131, .52);
+  background: #1d2422;
+}
+.runResultCard:hover { border-color: rgba(99, 200, 131, .72); background: #202824; }
+.runResultCard .finalLabel { color: var(--success); }
+.finalOutcome {
+  display: flex;
+  align-items: flex-start;
+  gap: var(--space-3);
+}
+.finalOutcome > span {
+  width: 2rem;
+  height: 2rem;
+  flex: 0 0 auto;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background: rgba(99, 200, 131, .12);
+  color: var(--success);
+}
+.finalOutcome > div { min-width: 0; display: grid; gap: var(--space-1); }
+.finalOutcome b { color: var(--text-primary); font-size: var(--type-body); font-weight: var(--weight-semibold); }
+.finalOutcome p,
+.finalSummaryPreview p { margin: 0; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.58; text-wrap: pretty; }
+.finalOutcome p { color: #b8c4bf; font-size: var(--type-label); }
+.finalSummaryPreview {
+  display: grid;
+  gap: var(--space-2);
+  padding-block: var(--space-3);
+  border-top: 1px solid rgba(99, 200, 131, .15);
+  border-bottom: 1px solid rgba(99, 200, 131, .15);
+}
+.finalSummaryPreview > b { color: #a9d8b7; font-size: var(--type-caption); font-weight: var(--weight-semibold); }
+.finalSummaryPreview p {
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 3;
+}
+.finalRunFacts { display: flex; align-items: center; gap: var(--space-4); color: #aebbb6; font-size: var(--type-caption); }
+.finalRunFacts b { color: var(--text-primary); font-weight: var(--weight-semibold); }
+
+.artifactStats,
+.metricGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-2); }
+.artifactStats span,
+.metricGrid span {
+  padding: var(--space-3) var(--space-2);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  background: var(--surface-subtle);
+  color: var(--text-muted);
+  font-size: var(--type-caption);
+  text-align: center;
+}
+.runResultCard .artifactStats span {
+  border-color: rgba(99, 200, 131, .18);
+  background: rgba(99, 200, 131, .055);
+  color: #aebbb6;
+}
+.runResultCard .artifactStats b { color: var(--success); }
+.artifactStats b,
+.metricGrid b {
+  display: block;
+  margin-bottom: var(--space-1);
+  color: var(--text-primary);
+  font-size: var(--type-title);
+  font-weight: var(--weight-semibold);
+}
+
+.sourceBadge,
+.statusBadge {
+  width: max-content;
+  justify-self: start;
+  display: inline-flex;
+  align-items: center;
+  min-height: 1.5rem;
+  padding: .125rem .5rem;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  background: rgba(255, 255, 255, .025);
+  color: var(--text-secondary);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+  line-height: 1.25;
+  white-space: nowrap;
+}
+.source-runtime-associated,
+.source-process-incomplete,
+.source-record-missing,
+.source-legacy-record { align-self: flex-start; color: var(--warning); }
+.status-merged,
+.status-success,
+.status-completed,
+.status-passed,
+.status-done { color: var(--success); }
+.status-parked,
+.status-partial,
+.status-partially-passed,
+.status-open,
+.status-not-evaluated,
+.status-unknown { color: var(--warning); }
+.status-discarded,
+.status-failed,
+.status-needs-retry,
+.status-cancel,
+.status-cancle,
+.status-cancelled { color: var(--danger); }
+.status-running { color: var(--accent); }
+.status-interrupted { color: var(--warning); }
+.status-legacy { color: var(--warning); border-color: rgba(244, 193, 82, .42); }
+
+/* Round group */
+
+.roundFrame {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  background: rgba(25, 27, 32, .55);
+}
+.roundFrame > header {
+  height: 5rem;
+  display: flex;
+  align-items: center;
+  gap: var(--space-4);
+  padding: var(--space-3) var(--space-4);
+  border-bottom: 1px solid var(--border-subtle);
+  background: var(--surface);
+}
+.roundFrame > header > div { min-width: 0; display: grid; gap: var(--space-1); }
+.roundFrame > header strong { font-size: var(--type-body); font-weight: var(--weight-semibold); }
+.roundFrame > header div span {
+  max-width: 44rem;
+  color: var(--text-muted);
+  font-size: var(--type-label);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.roundCollapse {
+  min-height: 2.75rem;
+  display: inline-flex;
+  align-items: center;
+  gap: .375rem;
+  padding: 0 var(--space-3);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface-control);
+  color: var(--text-secondary);
+  font-size: var(--type-label);
+  font-weight: var(--weight-semibold);
+}
+.roundCollapse:hover { border-color: var(--border-strong); color: var(--text-primary); }
+.branchCount {
+  display: inline-flex;
+  align-items: center;
+  gap: .375rem;
+  margin-left: auto;
+  color: var(--text-muted);
+  font-size: var(--type-label);
+  white-space: nowrap;
+}
+
+/* Agent ownership and convergence regions */
+
+.agentRegion {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow: hidden;
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-md);
+  background: rgba(20, 22, 26, .34);
+  pointer-events: none;
+}
+.agentRegion > header {
+  min-height: 3.375rem;
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-3);
+  padding: var(--space-3) var(--space-4);
+  border-bottom: 1px solid currentColor;
+  color: var(--text-muted);
+}
+.agentRegion > header strong {
+  color: currentColor;
+  font-size: var(--type-ui);
+  font-weight: var(--weight-semibold);
+  white-space: nowrap;
+}
+.agentRegion > header span {
+  min-width: 0;
+  color: var(--text-subtle);
+  font-size: var(--type-caption);
+  line-height: 1.4;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.region-main { border-color: rgba(255, 109, 90, .22); background: rgba(255, 109, 90, .025); }
+.region-main > header { color: #d9897e; border-bottom-color: rgba(255, 109, 90, .16); }
+.region-implementer { border-color: rgba(123, 184, 255, .24); background: rgba(123, 184, 255, .025); }
+.region-implementer > header { color: #82b5e9; border-bottom-color: rgba(123, 184, 255, .17); }
+.region-evaluator { border-color: rgba(183, 160, 235, .24); background: rgba(183, 160, 235, .025); }
+.region-evaluator > header { color: #ab99d5; border-bottom-color: rgba(183, 160, 235, .17); }
+.region-outcome { border-color: rgba(99, 200, 131, .24); background: rgba(99, 200, 131, .025); }
+.region-outcome > header { color: #71bc88; border-bottom-color: rgba(99, 200, 131, .17); }
+
+.branchFrame {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow: hidden;
+  border: 1px solid rgba(123, 184, 255, .38);
+  border-radius: var(--radius-md);
+  background: rgba(29, 35, 43, .78);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .025);
+}
+.branchFrame > header {
+  min-height: 4.125rem;
+  display: flex;
+  align-items: center;
+  gap: var(--space-3);
+  padding: var(--space-2) var(--space-3);
+  border-bottom: 1px dashed rgba(123, 184, 255, .26);
+  background: rgba(123, 184, 255, .035);
+}
+.branchFrameTitle { display: flex; align-items: center; gap: var(--space-3); }
+.branchFrameTitle span {
+  display: inline-flex;
+  align-items: center;
+  gap: .375rem;
+  color: var(--info);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+}
+.branchFrameTitle strong { font-size: var(--type-ui); font-weight: var(--weight-semibold); }
+.branchFrameActions { display: flex; gap: var(--space-2); margin-left: auto; }
+.branchFrame .nodeAction { min-height: 2.5rem; }
+
+/* Retrieval is a real nested stage: direct tools and delegated agents share one level. */
+
+.retrievalStageFrame {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow: hidden;
+  border: 1px dashed rgba(230, 168, 111, .45);
+  border-radius: var(--radius-md);
+  background: rgba(230, 168, 111, .035);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .02);
+}
+.retrievalStageFrame > header {
+  min-height: 4.5rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-3);
+  padding: var(--space-3) var(--space-4);
+  border-bottom: 1px dashed rgba(230, 168, 111, .28);
+  background: rgba(230, 168, 111, .035);
+}
+.retrievalStageFrame > header > div { display: flex; align-items: center; gap: var(--space-2); }
+.retrievalStageFrame > header > .retrievalStageActions { flex: none; }
+.retrievalStageIcon {
+  width: 1.75rem;
+  height: 1.75rem;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background: rgba(230, 168, 111, .12);
+  color: var(--retrieval);
+}
+.retrievalStageFrame > header strong { color: var(--retrieval); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
+.retrievalStageFrame > header em {
+  padding: .125rem .5rem;
+  border: 1px solid rgba(230, 168, 111, .28);
+  border-radius: 999px;
+  color: var(--text-muted);
+  font-size: var(--type-caption);
+  font-style: normal;
+  white-space: nowrap;
+}
+.frameDetail {
+  min-height: 2.75rem;
+  display: inline-flex;
+  align-items: center;
+  gap: .375rem;
+  padding: 0 var(--space-3);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--canvas);
+  color: var(--text-secondary);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+}
+.retrievalEmpty {
+  height: calc(100% - 4.5rem);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: var(--space-2);
+  color: var(--text-muted);
+  font-size: var(--type-label);
+}
+
+.retrievalCard {
+  width: 21.25rem;
+  min-height: 14rem;
+  border-color: rgba(230, 168, 111, .34);
+  background: rgba(31, 30, 30, .97);
+}
+.retrievalCard > header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
+.retrievalCard h3 { font-size: var(--type-body); }
+.retrievalKind {
+  display: inline-flex;
+  align-items: center;
+  gap: .375rem;
+  color: var(--retrieval);
+  font-size: var(--type-label);
+  font-weight: var(--weight-semibold);
+}
+.retrievalOwner,
+.agentRunStatus { color: var(--text-subtle); font-size: var(--type-caption); white-space: nowrap; }
+.run-completed { color: var(--success); }
+.run-failed { color: var(--danger); }
+.run-running { color: var(--accent); }
+.retrievalStats { display: grid; gap: var(--space-2); margin: 0; }
+.retrievalStats > div { display: grid; grid-template-columns: 4rem minmax(0, 1fr); gap: var(--space-3); }
+.retrievalStats dt,
+.agentTask b,
+.agentQuerySummary b,
+.agentScreening b { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-semibold); }
+.retrievalStats dd { margin: 0; color: var(--text-secondary); font-size: var(--type-ui); }
+.retrievalAgentCard { min-height: 18.25rem; }
+.agentTask,
+.agentQuerySummary,
+.agentScreening { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--space-3); align-items: start; }
+.agentTask p,
+.agentQuerySummary p,
+.agentScreening p {
+  display: -webkit-box;
+  margin: 0;
+  overflow: hidden;
+  color: var(--text-secondary);
+  font-size: var(--type-label);
+  line-height: 1.55;
+  overflow-wrap: anywhere;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+.query-hit { color: var(--success); }
+.query-empty { color: var(--text-muted); }
+.query-failure { color: var(--danger); }
+.query-unknown { color: var(--warning); }
+.query-interrupted { color: var(--warning); }
+.retrievalAgentCard > footer { display: flex; gap: var(--space-2); margin-top: auto; flex-wrap: wrap; }
+.queryAttemptList { display: grid; gap: .25rem; margin: var(--space-1) 0 0; padding: var(--space-2) 0 0; border-top: 1px solid var(--border-subtle); list-style: none; }
+.queryAttemptList button {
+  width: 100%;
+  min-height: 2.75rem;
+  display: grid;
+  grid-template-columns: 1.8rem minmax(0, 1fr) auto;
+  align-items: center;
+  gap: var(--space-2);
+  padding: .25rem .375rem;
+  border: 0;
+  border-radius: .375rem;
+  background: transparent;
+  text-align: left;
+}
+.queryAttemptList button:hover { background: rgba(255, 255, 255, .035); }
+.queryIndex { color: var(--text-subtle); font-size: var(--type-caption); font-variant-numeric: tabular-nums; }
+.queryText { overflow: hidden; color: var(--text-secondary); font-size: var(--type-caption); text-overflow: ellipsis; white-space: nowrap; }
+.queryStatus { font-size: var(--type-caption); font-weight: var(--weight-semibold); white-space: nowrap; }
+.queryStatus.query-hit { color: var(--success); }
+.queryStatus.query-empty,
+.queryStatus.query-unknown { color: var(--text-muted); }
+.queryStatus.query-failure { color: var(--danger); }
+.queryStatus.query-running { color: var(--accent); }
+.queryStatus.query-interrupted { color: var(--warning); }
+
+.retrievalHoverPreview {
+  position: fixed;
+  z-index: var(--z-popover);
+  width: min(23.75rem, calc(100vw - 1.5rem));
+  max-height: min(31rem, calc(100vh - 1.5rem));
+  overflow: auto;
+  display: grid;
+  gap: var(--space-4);
+  padding: var(--space-4);
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius-md);
+  background: #202228;
+  box-shadow: var(--shadow-md);
+  color: var(--text-secondary);
+  animation: retrievalPreviewIn 160ms var(--ease-out);
+}
+.retrievalPreviewHeader { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); }
+.retrievalPreviewHeader > div { min-width: 0; }
+.retrievalPreviewHeader small { display: block; color: var(--text-muted); font-size: var(--type-caption); }
+.retrievalPreviewHeader h3 { margin: .1rem 0 0; color: var(--text-primary); font-size: var(--type-body); line-height: 1.4; text-wrap: balance; }
+.retrievalPreviewHeader strong { align-self: start; padding: .2rem .5rem; border-radius: 999px; background: rgba(99, 200, 131, .1); color: #91dca8; font-size: var(--type-caption); white-space: nowrap; }
+.retrievalPreviewIcon { width: 2.25rem; height: 2.25rem; display: grid; place-items: center; border-radius: .45rem; }
+.retrievalPreviewIcon.is-agent { background: rgba(230, 168, 111, .12); color: var(--retrieval); }
+.retrievalPreviewIcon.is-tool { background: rgba(123, 184, 255, .11); color: #afd2ff; }
+.retrievalHoverPreview section { display: grid; gap: var(--space-2); }
+.retrievalHoverPreview section + section { padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
+.retrievalHoverPreview h4 { margin: 0; color: var(--text-primary); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+.retrievalPreviewSummary,
+.retrievalPreviewText,
+.retrievalPreviewError { margin: 0; font-size: var(--type-ui); line-height: 1.65; overflow-wrap: anywhere; text-wrap: pretty; }
+.retrievalPreviewText { color: var(--text-secondary); }
+.retrievalPreviewError { color: #ffb0a2; }
+.retrievalHoverPreview ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
+.retrievalHoverPreview li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: baseline; padding: .45rem .55rem; border-radius: .4rem; background: rgba(255, 255, 255, .035); }
+.retrievalHoverPreview li span { min-width: 0; color: var(--text-secondary); font-size: var(--type-label); line-height: 1.5; overflow-wrap: anywhere; }
+.retrievalHoverPreview li em { color: var(--success); font-size: var(--type-caption); font-style: normal; white-space: nowrap; }
+.retrievalHoverPreview li small { grid-column: 1 / -1; color: var(--text-muted); font-size: var(--type-caption); line-height: 1.55; }
+.retrievalPreviewCalls { display: grid; gap: var(--space-3); }
+.retrievalPreviewLoading { min-height: 5rem; display: flex; align-items: center; justify-content: center; gap: var(--space-2); color: var(--text-muted); font-size: var(--type-ui); }
+.retrievalPreviewLoading svg { animation: retrievalPreviewSpin 900ms linear infinite; }
+.retrievalPreviewMore { color: var(--text-muted); font-size: var(--type-caption); }
+.retrievalPreviewAction { min-height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; gap: .4rem; padding: 0 var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-control); color: var(--text-primary); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+.retrievalPreviewAction:hover { border-color: var(--info); background: rgba(123, 184, 255, .1); }
+.retrievalPreviewAction:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; }
+@keyframes retrievalPreviewIn { from { opacity: 0; transform: translateY(.35rem); } to { opacity: 1; transform: translateY(0); } }
+@keyframes retrievalPreviewSpin { to { transform: rotate(360deg); } }
+
+/* Semantic zoom: reduce content density instead of shrinking unreadable prose. */
+
+.detail-compact .flowCard {
+  min-height: 8.75rem;
+  gap: var(--space-2);
+  padding: .875rem;
+}
+.detail-compact .flowCard h3 { font-size: var(--type-body); }
+.detail-compact .cardFields > div:nth-child(n + 2),
+.detail-compact .candidateSummary > div:nth-child(n + 2) { display: none; }
+.detail-compact .cardFields > div,
+.detail-compact .candidateSummary > div { grid-template-columns: 3.5rem minmax(0, 1fr); }
+.detail-compact .cardFields .cardField:first-child p { -webkit-line-clamp: 2; }
+.detail-compact .roundGoalSummary > small,
+.detail-compact .roundSummaryBlock small { display: none; }
+.detail-compact .finalSummaryPreview,
+.detail-compact .sourceBadge,
+.detail-compact .agentTask,
+.detail-compact .agentScreening { display: none; }
+.detail-compact .nodeAction {
+  min-height: 2.25rem;
+  padding-inline: .625rem;
+}
+.detail-compact .retrievalAgentCard { min-height: 13.75rem; }
+.detail-compact .queryAttemptList button { min-height: 2.25rem; }
+
+.detail-overview .cardFields,
+.detail-overview .roundSummaryContent,
+.detail-overview .candidateSummary,
+.detail-overview .finalSummaryPreview,
+.detail-overview .artifactStats,
+.detail-overview .finalRunFacts,
+.detail-overview .sourceBadge { display: none; }
+.detail-overview .flowCard {
+  min-height: 6.5rem;
+  justify-content: center;
+  gap: var(--space-2);
+  padding: var(--space-3);
+}
+.detail-overview .roundSummaryCard,
+.detail-overview .candidateCard,
+.detail-overview .runResultCard { min-height: 8.5rem; }
+.detail-overview .finalOutcome p { display: none; }
+.detail-overview .finalOutcome > span { width: 1.75rem; height: 1.75rem; }
+.detail-overview .retrievalCard { min-height: 7.5rem; }
+.detail-overview .retrievalCard .agentTask,
+.detail-overview .retrievalCard .agentQuerySummary,
+.detail-overview .retrievalCard .agentScreening,
+.detail-overview .retrievalCard .retrievalStats { display: none; }
+.detail-overview .queryAttemptList,
+.detail-overview .flowCard > footer,
+.detail-overview .flowCard > .cardFooter { display: none; }
+.detail-overview .roundFrame > header div span,
+.detail-overview .branchCount { display: none; }
+.detail-compact .agentRegion > header span,
+.detail-overview .agentRegion > header span { display: none; }
+.detail-overview .agentRegion > header { padding-inline: var(--space-3); }
+
+.cardFields .cardField p,
+.candidateSummary p {
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-box-orient: vertical;
+  text-overflow: ellipsis;
+  -webkit-line-clamp: 2;
+}
+.cardFields .cardField:first-child p { -webkit-line-clamp: 4; }
+.cardFields .tone-warning p { color: var(--warning); }
+.cardFields .tone-success p { color: var(--success); }
+.cardFields .tone-failure p { color: var(--danger); }
+
+/* Inspector and long-form reading */
+
+.inspector {
+  min-width: 0;
+  min-height: 0;
+  height: 100%;
+  max-height: 100%;
+  display: grid;
+  grid-template-rows: auto auto minmax(0, 1fr);
+  border-left: 1px solid var(--border);
+  background: var(--surface-subtle);
+  box-shadow: -14px 0 35px rgba(0, 0, 0, .22);
+  z-index: var(--z-drawer);
+}
+.inspectorHeader {
+  min-height: 5.5rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-4);
+  padding: var(--space-4) var(--space-5);
+  border-bottom: 1px solid var(--border-subtle);
+}
+.inspectorHeader > div { min-width: 0; flex: 1; }
+.inspectorHeader > .inspectorHeaderActions { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); }
+.inspectorHeader > .iconButton { flex: none; }
+.inspectorBack { color: var(--text-secondary); }
+.inspectorHeader span {
+  color: var(--accent);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+}
+.inspectorHeader h2 {
+  max-width: 22rem;
+  margin: var(--space-1) 0 0;
+  font-size: var(--inspector-title-size);
+  font-weight: var(--weight-semibold);
+  line-height: 1.35;
+  letter-spacing: -.015em;
+  text-wrap: balance;
+}
+.inspectorTabs {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
+  border-bottom: 1px solid var(--border-subtle);
+}
+.changeExactness {
+  margin: 0;
+  padding: var(--space-3) var(--space-4);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  color: var(--text-muted);
+  font-size: var(--type-caption);
+  line-height: 1.55;
+}
+.inspectorTabs button {
+  min-height: 3rem;
+  padding: 0 var(--space-2);
+  border: 0;
+  border-bottom: 2px solid transparent;
+  background: transparent;
+  color: var(--text-muted);
+  font-size: var(--type-ui);
+  font-weight: var(--weight-medium);
+}
+.inspectorTabs button:hover { color: var(--text-secondary); background: rgba(255, 255, 255, .025); }
+.inspectorTabs button.active { border-bottom-color: var(--accent); color: var(--text-primary); font-weight: var(--weight-semibold); }
+
+.inspectorBody {
+  min-height: 0;
+  display: grid;
+  align-content: start;
+  gap: var(--space-5);
+  padding: var(--space-5);
+  overflow-x: hidden;
+  overflow-y: auto;
+  overscroll-behavior: contain;
+  scrollbar-gutter: stable;
+  touch-action: pan-y;
+}
+.inspectorBadgeRow { display: flex; gap: var(--space-2); flex-wrap: wrap; }
+.decisionMetaRow {
+  display: flex;
+  align-items: center;
+  gap: var(--space-2);
+  flex-wrap: wrap;
+}
+.decisionMetaRow span,
+.decisionMetaRow strong {
+  display: inline-flex;
+  min-height: 1.75rem;
+  align-items: center;
+  padding: .125rem .625rem;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  color: var(--text-secondary);
+  background: var(--surface);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+}
+.decisionMetaRow strong { color: var(--accent-hover); border-color: rgba(255, 109, 90, .38); }
+.decisionDetailBlocks { display: grid; gap: var(--space-5); }
+.decisionDetailBlocks .inspectorPanel { margin: 0; }
+.decisionSummaryPanel[data-presentation="document"] {
+  gap: var(--space-4);
+  padding: 0 0 var(--space-5);
+  border: 0;
+  border-bottom: 1px solid var(--border-subtle);
+  border-radius: 0;
+  background: transparent;
+}
+.decisionSummaryPanel[data-presentation="document"] > h3 {
+  color: var(--accent-hover);
+}
+.decisionSummaryPanel[data-presentation="document"] > .richText {
+  max-width: 72ch;
+  color: var(--text-secondary);
+  font-size: var(--inspector-copy-size);
+  line-height: 1.75;
+}
+.decisionSummaryPanel[data-presentation="document"] .richText h2 {
+  margin: var(--space-6) 0 var(--space-3);
+  padding-bottom: var(--space-2);
+  border-bottom: 1px solid var(--border-subtle);
+  color: var(--text-primary);
+  font-size: var(--inspector-section-title-size);
+  line-height: 1.4;
+}
+.decisionSummaryPanel[data-presentation="document"] .richText h2:first-child { margin-top: 0; }
+.decisionSummaryPanel[data-presentation="document"] .richText h3 {
+  margin: var(--space-5) 0 var(--space-3);
+  color: var(--accent-hover);
+  font-size: var(--type-body);
+  line-height: 1.45;
+}
+.decisionSummaryPanel[data-presentation="document"] .richText > ul {
+  margin: 0;
+  padding-left: 1.25rem;
+}
+.decisionSummaryPanel[data-presentation="document"] .richText ul ul {
+  margin-top: var(--space-2);
+  padding-left: 1.1rem;
+}
+.decisionItemsPanel[data-presentation="labelled-list"] .inspectorDetailItem {
+  grid-template-columns: minmax(5.75rem, 7rem) minmax(0, 1fr);
+  gap: var(--space-4);
+}
+.decisionItemsPanel[data-presentation="labelled-list"] .inspectorDetailItem dt {
+  min-width: 0;
+  padding: 0;
+  border: 0;
+  border-radius: 0;
+  color: var(--accent-hover);
+  font-size: var(--inspector-field-label-size);
+  text-align: left;
+}
+.decisionItemsPanel[data-presentation="labelled-list"] .inspectorDetailItem .richText {
+  color: var(--text-primary);
+  line-height: 1.75;
+}
+.implementationPlanPanel {
+  gap: var(--space-4);
+  padding: 0 0 var(--space-5);
+  border: 0;
+  border-bottom: 1px solid var(--border-subtle);
+  border-radius: 0;
+  background: transparent;
+}
+.implementationPlanHeader,
+.implementationRouteHeader {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-3);
+}
+.implementationPlanHeader > div { min-width: 0; display: grid; gap: .25rem; }
+.implementationPlanHeader h3 { color: var(--accent-hover); }
+.implementationPlanHeader p { margin: 0; color: var(--text-muted); font-size: var(--type-caption); }
+.implementationPlanHeader > span {
+  flex: none;
+  padding: .25rem .625rem;
+  color: var(--accent-hover);
+  border: 1px solid rgba(255, 109, 90, .38);
+  border-radius: 999px;
+  background: rgba(255, 109, 90, .08);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+}
+.implementationRoutes { display: grid; gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
+.implementationRoutes > li {
+  min-width: 0;
+  padding: var(--space-4);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface);
+}
+.implementationRouteHeader { padding-bottom: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
+.implementationRouteHeader > strong { color: var(--text-primary); font-size: var(--type-body); }
+.implementationRouteTags { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
+.implementationRouteTags b {
+  padding: .1875rem .5rem;
+  color: var(--text-secondary);
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  background: var(--surface-control);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+}
+.implementationRouteBody {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: var(--space-4);
+  padding-top: var(--space-4);
+}
+.implementationPlanField { min-width: 0; display: grid; align-content: start; gap: var(--space-2); }
+.implementationPlanField.is-wide { grid-column: 1 / -1; }
+.implementationPlanField > small { color: var(--text-muted); font-size: var(--inspector-field-label-size); font-weight: var(--weight-semibold); }
+.implementationPlanField .richText { color: var(--text-primary); font-size: var(--type-body); line-height: 1.7; overflow-wrap: break-word; word-break: normal; }
+.implementationReasoning { display: grid; gap: var(--space-2); padding-top: var(--space-4); border-top: 1px solid rgba(255, 109, 90, .2); }
+.implementationReasoning h4 { margin: 0; color: var(--text-muted); font-size: var(--inspector-field-label-size); }
+.implementationReasoning .richText { color: var(--text-primary); line-height: 1.75; }
+.creativeProcessPanel,
+.evaluationBranchesPanel,
+.evaluationMatrixPanel { gap: var(--space-4); }
+.creativeProcessHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
+.creativeProcessHeader h3,
+.evaluationBranchesPanel > h3,
+.evaluationMatrixPanel > h3 { margin: 0; color: var(--info); font-size: var(--inspector-section-title-size); }
+.creativeProcessHeader p { margin: var(--space-1) 0 0; color: var(--text-muted); font-size: var(--type-caption); }
+.creativeProcessSteps { margin: 0; padding: 0; list-style: none; }
+.creativeProcessSteps > li { display: grid; gap: var(--space-3); padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
+.creativeProcessSteps > li:first-child { padding-top: 0; border-top: 0; }
+.creativeProcessSteps > li > header { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); align-items: baseline; gap: var(--space-3); }
+.creativeProcessSteps > li > header span { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-semibold); }
+.creativeProcessSteps > li > header strong { color: var(--text-primary); font-size: var(--type-body); line-height: 1.5; }
+.creativeStepSummary { margin: 0 0 0 5.25rem; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.65; }
+.creativeStepPlan { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--space-3); }
+.creativeStepPlan > small { color: var(--text-muted); font-size: var(--inspector-field-label-size); font-weight: var(--weight-semibold); }
+.creativeStepPlan .decisionReadableText { color: var(--text-primary); font-weight: var(--weight-regular); }
+.creativeStepReasoning { margin-left: 5.25rem; }
+.creativeStepReasoning > summary { width: fit-content; display: inline-flex; align-items: center; gap: var(--space-1); color: var(--text-muted); font-size: var(--type-caption); cursor: pointer; list-style: none; }
+.creativeStepReasoning > summary::-webkit-details-marker { display: none; }
+.creativeStepReasoning > summary svg { transition: transform 180ms var(--ease-out); }
+.creativeStepReasoning[open] > summary svg { transform: rotate(90deg); }
+.creativeStepReasoning > .richText { margin-top: var(--space-3); color: var(--text-secondary); font-size: var(--type-ui); }
+.evaluationBranchList { display: grid; }
+.evaluationBranchList > article { display: grid; gap: var(--space-4); padding: var(--space-5) 0; border-top: 1px solid var(--border-subtle); }
+.evaluationBranchList > article:first-child { padding-top: 0; border-top: 0; }
+.evaluationBranchList > article > header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
+.evaluationBranchList h4 { margin: 0; color: var(--text-primary); font-size: var(--type-title); }
+.evaluationBranchList > article > header span { padding: .25rem .55rem; border: 1px solid var(--border); border-radius: 999px; color: var(--text-secondary); font-size: var(--type-caption); }
+.evaluationList { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--space-3); }
+.evaluationList > strong,
+.evaluationEvidence > strong { font-size: var(--inspector-field-label-size); font-weight: var(--weight-semibold); }
+.evaluationList.is-success > strong { color: var(--success); }
+.evaluationList.is-warning > strong { color: var(--warning); }
+.evaluationList ul { display: grid; gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
+.evaluationList li { padding-bottom: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
+.evaluationList li:last-child { padding-bottom: 0; border-bottom: 0; }
+.evaluationList .richText { color: var(--text-primary); font-size: var(--inspector-copy-size); line-height: 1.7; }
+.evaluationEvidence { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
+.evaluationEvidence > strong { color: var(--info); }
+.evaluationEvidence .richText { color: var(--text-secondary); font-size: var(--type-ui); }
+.evaluationMatrixScroll { overflow-x: auto; }
+.evaluationMatrixPanel table { width: 100%; min-width: 46rem; border-collapse: collapse; font-size: var(--type-ui); line-height: 1.55; }
+.evaluationMatrixPanel th,
+.evaluationMatrixPanel td { padding: var(--space-3); border: 1px solid var(--border); text-align: left; vertical-align: top; }
+.evaluationMatrixPanel thead th { color: var(--text-primary); background: var(--surface-control); font-weight: var(--weight-semibold); }
+.evaluationMatrixPanel tbody th { width: 10rem; color: var(--info); background: transparent; font-weight: var(--weight-semibold); }
+.evaluationMatrixPanel td { color: var(--text-secondary); }
+.decisionDisclosurePanel {
+  padding: 0;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: rgba(255, 255, 255, .018);
+  overflow: hidden;
+}
+.decisionDisclosure > summary {
+  min-height: 3.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-3);
+  padding: var(--space-3) var(--space-4);
+  color: var(--text-secondary);
+  cursor: pointer;
+  list-style: none;
+  transition: color 160ms var(--ease-out), background 160ms var(--ease-out);
+}
+.decisionDisclosure > summary::-webkit-details-marker { display: none; }
+.decisionDisclosure > summary:hover { color: var(--text-primary); background: rgba(255, 255, 255, .025); }
+.decisionDisclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.decisionDisclosureLabel { min-width: 0; display: inline-flex; align-items: center; gap: var(--space-2); }
+.decisionDisclosureLabel svg { flex: none; color: var(--text-muted); transition: transform 180ms var(--ease-out); }
+.decisionDisclosure[open] .decisionDisclosureLabel svg { transform: rotate(90deg); }
+.decisionDisclosureLabel strong { color: var(--text-primary); font-size: var(--inspector-section-title-size); font-weight: var(--weight-semibold); line-height: 1.4; }
+.decisionDisclosureCount { flex: none; color: var(--text-muted); font-size: var(--type-caption); }
+.decisionDisclosureBody { padding: var(--space-1) var(--space-4) var(--space-4); border-top: 1px solid var(--border-subtle); }
+.decisionDisclosureBody .inspectorDetailItems { padding-top: var(--space-4); }
+.decisionSummaryPanel {
+  gap: var(--space-3);
+  padding: 0 0 var(--space-5);
+  border: 0;
+  border-bottom: 1px solid var(--border-subtle);
+  border-radius: 0;
+  background: transparent;
+}
+.decisionSummaryPanel.is-role-key-result {
+  border-color: var(--border-subtle);
+  background: transparent;
+}
+.inspectorPanel.is-role-key-result > h3 { color: var(--accent-hover); }
+.inspectorPanel.is-role-section > h3,
+.inspectorPanel.is-role-reason > h3,
+.inspectorPanel.is-role-evidence > h3 { color: var(--info); }
+.decisionItemsPanel.is-role-evidence > h3 {
+  color: var(--accent-hover);
+  font-size: var(--type-title);
+}
+.inspectorPanel.is-role-notice > h3 { color: var(--warning); }
+.decisionSummaryPanel .decisionReadableText {
+  width: 100%;
+  min-width: 0;
+  max-width: 68ch;
+  color: var(--text-primary);
+  font-size: var(--inspector-copy-size);
+  font-weight: var(--weight-medium);
+  line-height: 1.78;
+  letter-spacing: 0;
+  text-wrap: pretty;
+  overflow-wrap: break-word;
+  word-break: normal;
+}
+.decisionReadableText > :first-child { margin-top: 0; }
+.decisionReadableText > :last-child { margin-bottom: 0; }
+.decisionReadableText p { margin-block: 0 var(--space-3); }
+.decisionReadableText ol { padding-left: 1.65rem; }
+.decisionReadableText ol > li { padding-left: .25rem; }
+.decisionReadableText ol > li + li { margin-top: var(--space-3); }
+.decisionNotice {
+  padding: var(--space-4);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface);
+}
+.decisionNotice.is-warning { border-color: rgba(244, 193, 82, .38); background: rgba(244, 193, 82, .05); }
+.decisionNotice.is-info { border-color: rgba(123, 184, 255, .32); background: rgba(123, 184, 255, .04); }
+.decisionComparison { display: grid; min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-sm); }
+.comparisonHeader,
+.comparisonRow {
+  display: grid;
+  grid-template-columns: minmax(6.5rem, .75fr) repeat(2, minmax(7.5rem, 1fr));
+}
+.decisionComparison.hasHandling .comparisonHeader,
+.decisionComparison.hasHandling .comparisonRow { grid-template-columns: minmax(6.5rem, .75fr) repeat(3, minmax(7.5rem, 1fr)); }
+.comparisonHeader { background: var(--surface-control); }
+.comparisonHeader b,
+.comparisonRow > * { padding: var(--space-3); border-right: 1px solid var(--border-subtle); }
+.comparisonHeader b:last-child,
+.comparisonRow > *:last-child { border-right: 0; }
+.comparisonHeader b { color: var(--text-muted); font-size: var(--type-caption); }
+.comparisonRow { border-top: 1px solid var(--border-subtle); background: var(--surface); }
+.comparisonRow strong { color: var(--text-primary); font-size: var(--type-label); }
+.comparisonRow span { color: var(--text-secondary); font-size: var(--type-label); line-height: 1.55; }
+.decisionNestedList { margin-top: var(--space-3); }
+.decisionNestedList > strong { display: block; margin-bottom: var(--space-2); color: var(--text-muted); font-size: var(--type-caption); }
+.decisionNestedList ul { margin: 0; padding-left: 1.15rem; color: var(--text-secondary); }
+.decisionNestedList li + li { margin-top: var(--space-2); }
+
+.promptDrawer {
+  position: fixed;
+  top: var(--space-4);
+  right: var(--space-4);
+  bottom: var(--space-4);
+  z-index: var(--z-modal);
+  width: min(40rem, calc(100vw - 2rem));
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr);
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  background: var(--surface-subtle);
+  box-shadow: 0 28px 90px rgba(0, 0, 0, .58);
+}
+.promptDrawer > header {
+  min-height: 5rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-4);
+  padding: var(--space-4) var(--space-5);
+  border-bottom: 1px solid var(--border-subtle);
+}
+.promptDrawer > header span { color: var(--accent); font-size: var(--type-caption); font-weight: var(--weight-semibold); }
+.promptDrawer > header h2 { margin: var(--space-1) 0 0; font-size: var(--type-heading); }
+.promptDrawerBody {
+  min-height: 0;
+  display: grid;
+  align-content: start;
+  gap: var(--space-5);
+  padding: var(--space-5);
+  overflow-y: auto;
+  overscroll-behavior: contain;
+  scrollbar-gutter: stable;
+}
+.promptAccuracy { padding: var(--space-4); border: 1px solid rgba(244, 193, 82, .38); border-radius: var(--radius-sm); background: rgba(244, 193, 82, .05); }
+.promptMeta { color: var(--warning) !important; font-size: var(--type-label) !important; }
+.inspectorPanel {
+  display: grid;
+  gap: var(--space-3);
+  padding: 0 0 var(--space-5);
+  border-bottom: 1px solid var(--border-subtle);
+}
+.inspectorPanel:last-child { border-bottom: 0; }
+.inspectorPanel.is-summary {
+  gap: var(--space-2);
+  padding: 0 0 var(--space-5);
+  border: 0;
+  border-bottom: 1px solid var(--border-subtle);
+  border-radius: 0;
+  background: transparent;
+}
+.inspectorPanel.is-summary h3 { color: var(--accent-hover); }
+.inspectorPanel.is-summary .richText {
+  color: var(--text-primary);
+  font-size: var(--inspector-copy-size);
+  font-weight: var(--weight-semibold);
+  line-height: 1.52;
+  letter-spacing: -.008em;
+}
+.inspectorPanel.is-report { gap: var(--space-4); }
+.inspectorPanel.is-report > .richText { max-width: 68ch; }
+.inspectorPanel.isMissing {
+  padding: var(--space-4);
+  border: 1px solid rgba(244, 193, 82, .38);
+  border-radius: var(--radius-md);
+  background: rgba(244, 193, 82, .055);
+}
+.inspectorPanel h3 {
+  margin: 0;
+  color: var(--info);
+  font-size: var(--inspector-section-title-size);
+  font-weight: var(--weight-semibold);
+  line-height: 1.45;
+  letter-spacing: -.006em;
+}
+.inspectorPanel > p {
+  margin: 0;
+  color: var(--text-secondary);
+  font-size: var(--inspector-copy-size);
+  line-height: 1.7;
+  white-space: pre-wrap;
+}
+.inspectorDetailItems {
+  display: grid;
+  gap: var(--space-4);
+  margin: 0;
+}
+.inspectorDetailItem {
+  display: grid;
+  grid-template-columns: minmax(6.5rem, 8rem) minmax(0, 1fr);
+  gap: var(--space-3);
+  align-items: start;
+}
+.inspectorDetailItem + .inspectorDetailItem {
+  padding-top: var(--space-4);
+  border-top: 1px solid var(--border-subtle);
+}
+.inspectorDetailItem dt {
+  color: var(--text-muted);
+  font-size: var(--inspector-field-label-size);
+  font-weight: var(--weight-semibold);
+  line-height: 1.55;
+}
+.inspectorDetailItem dd {
+  min-width: 0;
+  display: grid;
+  gap: var(--space-2);
+  margin: 0;
+}
+.decisionItemsPanel .inspectorDetailItem,
+.decisionDisclosureBody .inspectorDetailItem {
+  grid-template-columns: 1fr;
+}
+.decisionItemsPanel .inspectorDetailItem dt,
+.decisionDisclosureBody .inspectorDetailItem dt {
+  width: fit-content;
+  min-width: 2rem;
+  padding: .125rem .5rem;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  font-size: var(--inspector-field-label-size);
+  text-align: center;
+}
+.inspectorDetailItem .richText {
+  color: var(--text-secondary);
+  font-size: var(--inspector-copy-size);
+  line-height: 1.68;
+  overflow-wrap: break-word;
+  word-break: normal;
+}
+.inspectorDetailItem .decisionReadableText { max-width: 68ch; line-height: 1.75; text-wrap: pretty; }
+.inspectorDetailItem dd > small { color: var(--text-muted); font-size: var(--type-caption); line-height: 1.55; }
+.inspectorDetailItem .richText > :first-child { margin-top: 0; }
+.inspectorDetailItem .richText > :last-child { margin-bottom: 0; }
+.inspectorDetailItem .sourceBadge { justify-self: start; }
+
+/* Retrieval details: a compact index first, complete records on demand. */
+
+.retrievalInspector { min-width: 0; display: grid; gap: var(--space-5); }
+.retrievalActivityPanel,
+.retrievalConditionPanel,
+.retrievalResultsPanel { gap: var(--space-4); }
+.retrievalSectionHeader {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: var(--space-3);
+}
+.retrievalSectionHeader h3 { margin: 0; color: var(--info); font-size: var(--inspector-section-title-size); }
+.retrievalSectionHeader > span { flex: none; color: var(--text-muted); font-size: var(--type-caption); }
+.retrievalActivityList { margin: 0; padding: 0; border-top: 1px solid var(--border-subtle); list-style: none; }
+.retrievalActivityList > li { border-bottom: 1px solid var(--border-subtle); }
+.retrievalActivityList button {
+  width: 100%;
+  min-height: 4rem;
+  display: grid;
+  grid-template-columns: 2rem minmax(0, 1fr) auto 1rem;
+  align-items: center;
+  gap: var(--space-3);
+  padding: var(--space-3) 0;
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  color: var(--text-primary);
+  text-align: left;
+}
+.retrievalActivityList button:hover:not(:disabled) { background: rgba(255, 255, 255, .025); }
+.retrievalActivityList button:disabled { cursor: default; opacity: .72; }
+.retrievalActivityList button > svg { color: var(--text-subtle); }
+.retrievalActivityIndex { color: var(--text-subtle); font-size: var(--type-caption); font-variant-numeric: tabular-nums; }
+.retrievalActivityCopy { min-width: 0; display: grid; gap: .25rem; }
+.retrievalActivityCopy strong { overflow: hidden; color: var(--text-primary); font-size: var(--type-ui); text-overflow: ellipsis; white-space: nowrap; }
+.retrievalActivityCopy small { overflow: hidden; color: var(--text-muted); font-size: var(--type-caption); line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
+.retrievalActivityStatus { font-size: var(--type-caption); font-weight: var(--weight-semibold); white-space: nowrap; }
+.retrievalActivityStatus.is-hit,
+.retrievalActivityStatus.is-success { color: var(--success); }
+.retrievalActivityStatus.is-empty,
+.retrievalActivityStatus.is-unknown { color: var(--text-muted); }
+.retrievalActivityStatus.is-failure { color: var(--danger); }
+.retrievalActivityStatus.is-running { color: var(--accent); }
+.retrievalActivityStatus.is-interrupted { color: var(--warning); }
+.retrievalMissingNotice,
+.retrievalCompletenessNotice { margin: 0; color: var(--text-muted); font-size: var(--type-ui); line-height: 1.65; }
+.retrievalCompletenessNotice {
+  padding: var(--space-3) var(--space-4);
+  border: 1px solid rgba(244, 193, 82, .28);
+  border-radius: var(--radius-sm);
+  background: rgba(244, 193, 82, .045);
+  color: var(--warning);
+}
+.retrievalOutcome {
+  display: grid;
+  gap: var(--space-2);
+  padding: var(--space-4);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface);
+}
+.retrievalOutcome > span { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-semibold); }
+.retrievalOutcome > strong { color: var(--text-primary); font-size: var(--type-body); line-height: 1.55; }
+.retrievalOutcome.is-hit { border-color: rgba(99, 200, 131, .34); }
+.retrievalOutcome.is-empty { border-color: var(--border-strong); }
+.retrievalOutcome.is-failure { border-color: rgba(255, 109, 90, .4); }
+.retrievalOutcome.is-running { border-color: rgba(255, 109, 90, .26); }
+.retrievalOutcome.is-interrupted { border-color: rgba(244, 193, 82, .36); }
+.retrievalOutcome.is-hit > span { color: var(--success); }
+.retrievalOutcome.is-failure > span { color: var(--danger); }
+.retrievalOutcome.is-running > span { color: var(--accent); }
+.retrievalOutcome.is-interrupted > span { color: var(--warning); }
+.retrievalConditions { display: grid; gap: 0; margin: 0; }
+.retrievalConditions > div {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: minmax(6rem, 8rem) minmax(0, 1fr);
+  gap: var(--space-3);
+  padding: var(--space-3) 0;
+  border-top: 1px solid var(--border-subtle);
+}
+.retrievalConditions > div:first-child { padding-top: 0; border-top: 0; }
+.retrievalConditions dt { color: var(--text-muted); font-size: var(--inspector-field-label-size); font-weight: var(--weight-semibold); }
+.retrievalConditions dd { min-width: 0; margin: 0; color: var(--text-primary); font-size: var(--type-ui); line-height: 1.65; overflow-wrap: anywhere; }
+.retrievalResultList { display: grid; gap: var(--space-3); }
+.retrievalResult {
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface);
+  overflow: hidden;
+}
+.retrievalResult > summary {
+  min-height: 4.25rem;
+  display: grid;
+  grid-template-columns: 1.25rem minmax(0, 1fr);
+  align-items: center;
+  gap: var(--space-3);
+  padding: var(--space-3) var(--space-4);
+  cursor: pointer;
+  list-style: none;
+}
+.retrievalResult > summary::-webkit-details-marker { display: none; }
+.retrievalResult > summary:hover { background: rgba(255, 255, 255, .025); }
+.retrievalResult > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.retrievalResultChevron { grid-row: 1 / span 2; align-self: center; display: inline-flex; color: var(--text-muted); transition: transform 180ms var(--ease-out); }
+.retrievalResult[open] .retrievalResultChevron { transform: rotate(90deg); }
+.retrievalResultHeading { min-width: 0; display: grid; gap: .2rem; }
+.retrievalResultHeading strong { color: var(--text-primary); font-size: var(--type-ui); line-height: 1.45; overflow-wrap: break-word; word-break: normal; text-wrap: pretty; }
+.retrievalResultHeading small { color: var(--text-muted); font-size: var(--type-caption); overflow-wrap: anywhere; }
+.retrievalResultMeta { grid-column: 2; min-width: 0; display: flex; justify-content: flex-start; gap: var(--space-2) var(--space-3); flex-wrap: wrap; }
+.retrievalResultMeta b { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-medium); line-height: 1.45; overflow-wrap: anywhere; }
+.retrievalResultBody { display: grid; gap: var(--space-4); padding: var(--space-4); border-top: 1px solid var(--border-subtle); }
+.retrievalResultLink { width: fit-content; display: inline-flex; align-items: center; gap: var(--space-1); color: var(--info); font-size: var(--type-ui); }
+.retrievalResultSection { min-width: 0; display: grid; gap: var(--space-2); }
+.retrievalResultSection + .retrievalResultSection { padding-top: var(--space-4); border-top: 1px solid var(--border-subtle); }
+.retrievalResultSection h4 { margin: 0; color: var(--info); font-size: var(--type-ui); }
+.retrievalResultSection .richText { color: var(--text-secondary); font-size: var(--inspector-copy-size); line-height: 1.72; overflow-wrap: anywhere; }
+.retrievalEmptyValue { color: var(--text-muted); font-size: var(--type-ui); }
+
+/* Card data lineage: one readable spine from input to output and UI projection. */
+
+.dataFlow {
+  min-width: 0;
+  display: grid;
+  align-content: start;
+  gap: 0;
+  color: var(--text-secondary);
+}
+.dataFlowOverview {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: 2.25rem minmax(0, 1fr);
+  gap: var(--space-3);
+  padding: var(--space-4);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface);
+}
+.dataFlowOverviewIcon {
+  width: 2.25rem;
+  height: 2.25rem;
+  display: grid;
+  place-items: center;
+  border-radius: 50%;
+  background: rgba(123, 184, 255, .11);
+  color: var(--info);
+}
+.dataFlowOverview > div:nth-child(2) { min-width: 0; }
+.dataFlowOverview > div:nth-child(2) > span { color: var(--text-muted); font-size: var(--type-caption); }
+.dataFlowOverview h3 { margin: .125rem 0 0; color: var(--text-primary); font-size: var(--inspector-section-title-size); font-weight: var(--weight-semibold); }
+.dataFlowOverviewBadges { grid-column: 1 / -1; display: flex; gap: var(--space-2); flex-wrap: wrap; }
+.dataFlowOverviewBadges span,
+.dataCompleteness,
+.runtimeStatus {
+  display: inline-flex;
+  min-height: 1.65rem;
+  align-items: center;
+  padding: .125rem .55rem;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  color: var(--text-secondary);
+  background: var(--surface-control);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+  line-height: 1.3;
+}
+.dataFlowOverviewBadges .is-complete,
+.dataCompleteness.is-complete { border-color: rgba(99, 200, 131, .36); color: var(--success); background: rgba(99, 200, 131, .07); }
+.dataFlowOverviewBadges .is-partial,
+.dataCompleteness.is-partial { border-color: rgba(244, 193, 82, .36); color: var(--warning); background: rgba(244, 193, 82, .07); }
+.dataFlowOverviewBadges .is-missing,
+.dataCompleteness.is-missing { border-color: rgba(255, 128, 110, .36); color: var(--danger); background: rgba(255, 128, 110, .07); }
+.dataFlowNotices { display: grid; gap: var(--space-2); margin-top: var(--space-3); }
+.dataFlowNotices p {
+  display: grid;
+  grid-template-columns: 1rem minmax(0, 1fr);
+  gap: var(--space-2);
+  margin: 0;
+  padding: var(--space-3);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  color: var(--text-secondary);
+  background: rgba(123, 184, 255, .045);
+  font-size: var(--type-ui);
+  line-height: 1.6;
+  overflow-wrap: anywhere;
+}
+.dataFlowNotices p.is-warning { border-color: rgba(244, 193, 82, .3); background: rgba(244, 193, 82, .045); color: #efd486; }
+.dataFlowNotices svg { margin-top: .25rem; }
+.dataFlowStage,
+.displayUse { --stage-color: var(--info); min-width: 0; display: grid; gap: var(--space-4); }
+.dataFlowStage[data-stage="runtime"] { --stage-color: var(--warning); }
+.dataFlowStage[data-stage="output"] { --stage-color: var(--success); }
+.dataFlowStage > header,
+.displayUse > header {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: 2rem minmax(0, 1fr);
+  align-items: start;
+  gap: var(--space-3);
+}
+.dataFlowStage > header > div,
+.displayUse > header > div { min-width: 0; }
+.dataFlowStage h3,
+.displayUse h3 { margin: 0; color: var(--text-primary); font-size: .9375rem; font-weight: var(--weight-semibold); line-height: 1.45; }
+.dataFlowStage > header p,
+.displayUse > header p { max-width: 70ch; margin: .2rem 0 0; color: var(--text-muted); font-size: var(--type-label); line-height: 1.6; overflow-wrap: anywhere; text-wrap: pretty; }
+.dataFlowStageIcon {
+  width: 2rem;
+  height: 2rem;
+  display: grid;
+  place-items: center;
+  border: 1px solid color-mix(in srgb, var(--stage-color) 34%, transparent);
+  border-radius: 50%;
+  background: color-mix(in srgb, var(--stage-color) 8%, transparent);
+  color: var(--stage-color);
+}
+.dataFlowConnector {
+  position: relative;
+  width: 2rem;
+  height: 2.5rem;
+  display: grid;
+  place-items: center;
+  margin-left: 0;
+  color: var(--text-subtle);
+}
+.dataFlowConnector::before {
+  position: absolute;
+  width: 1px;
+  height: 2.5rem;
+  background: var(--border-strong);
+  content: "";
+}
+.dataFlowConnector svg { position: relative; padding: 2px; border-radius: 50%; background: var(--surface-subtle); }
+.dataFlowConnector.is-muted { color: var(--text-muted); }
+.dataFieldList,
+.runtimeUnitList { min-width: 0; display: grid; gap: var(--space-3); }
+.dataField {
+  min-width: 0;
+  display: grid;
+  gap: var(--space-3);
+  padding: var(--space-4);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  background: rgba(32, 34, 40, .58);
+}
+.dataField > header { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
+.dataField h4 { min-width: 0; flex: 1 1 10rem; margin: 0; color: var(--text-primary); font-size: var(--type-ui); font-weight: var(--weight-semibold); line-height: 1.45; overflow-wrap: anywhere; }
+.sourceMarker {
+  flex: none;
+  min-width: 2rem;
+  min-height: 1.55rem;
+  display: inline-grid;
+  place-items: center;
+  padding: 0 .35rem;
+  border: 1px solid rgba(123, 184, 255, .3);
+  border-radius: .375rem;
+  background: rgba(123, 184, 255, .07);
+  color: #9fcaff;
+  font: var(--type-caption)/1 var(--font-mono);
+}
+.dataField > footer { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; color: var(--text-muted); font-size: var(--type-caption); line-height: 1.5; }
+.dataField > footer code,
+.runtimeEventRef code { min-width: 0; color: var(--text-secondary); font: var(--type-caption)/1.5 var(--font-mono); overflow-wrap: anywhere; }
+.dataSourceBadge {
+  display: inline-flex;
+  min-height: 1.65rem;
+  align-items: center;
+  gap: .3rem;
+  padding: .125rem .5rem;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  background: var(--surface-control);
+  color: var(--text-secondary);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+  white-space: nowrap;
+}
+.dataSourceBadge.is-runtime-event { border-color: rgba(244, 193, 82, .3); color: #efd486; }
+.dataSourceBadge.is-artifact { border-color: rgba(99, 200, 131, .3); color: #8edca6; }
+.dataSourceBadge.is-calculation { border-color: rgba(183, 160, 235, .34); color: #c7b5ee; }
+.dataRelation { flex-basis: 100%; color: var(--text-muted); }
+.dataFlowText { max-width: 70ch; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.65; overflow-wrap: anywhere; word-break: normal; text-wrap: pretty; }
+.dataFlowText > :first-child { margin-top: 0; }
+.dataFlowText > :last-child { margin-bottom: 0; }
+.runtimeUnit {
+  min-width: 0;
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  background: rgba(32, 34, 40, .58);
+  overflow: hidden;
+}
+.runtimeUnit > summary {
+  min-height: 3.25rem;
+  display: grid;
+  grid-template-columns: auto auto minmax(0, 1fr) auto auto;
+  align-items: center;
+  gap: var(--space-2);
+  padding: var(--space-3);
+  cursor: pointer;
+  list-style: none;
+}
+.runtimeUnit > summary::-webkit-details-marker,
+.longDataValue > summary::-webkit-details-marker { display: none; }
+.runtimeUnit > summary:hover,
+.longDataValue > summary:hover { background: rgba(255, 255, 255, .025); }
+.runtimeUnit > summary:focus-visible,
+.longDataValue > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.runtimeUnit > summary strong { min-width: 0; color: var(--text-primary); font-size: var(--type-ui); line-height: 1.45; overflow-wrap: anywhere; }
+.runtimeUnit > summary small { color: var(--text-muted); font: var(--type-caption)/1.4 var(--font-mono); white-space: nowrap; }
+.dataDisclosureChevron { flex: none; color: var(--text-muted); transition: transform 180ms var(--ease-out); }
+.runtimeUnit[open] > summary .dataDisclosureChevron,
+.longDataValue[open] > summary .dataDisclosureChevron { transform: rotate(90deg); }
+.runtimeStatus.is-success { border-color: rgba(99, 200, 131, .3); color: var(--success); }
+.runtimeStatus.is-failure { border-color: rgba(255, 128, 110, .3); color: var(--danger); }
+.runtimeStatus.is-running { border-color: rgba(244, 193, 82, .3); color: var(--warning); }
+.runtimeUnitBody { min-width: 0; display: grid; gap: var(--space-4); padding: var(--space-4); border-top: 1px solid var(--border-subtle); }
+.runtimeValue { min-width: 0; display: grid; gap: var(--space-2); }
+.runtimeValue h5 { margin: 0; color: var(--warning); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+.runtimeEventRef,
+.runtimeCompleteness { display: flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--text-muted); font-size: var(--type-caption); }
+.runtimeCompleteness { color: var(--success); }
+.dataFlowCalculation { display: grid; gap: var(--space-2); padding: var(--space-3) 0 0; border-top: 1px solid var(--border-subtle); }
+.dataFlowCalculation > strong { color: var(--warning); font-size: var(--type-label); }
+.longDataValue { min-width: 0; display: grid; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
+.longDataValue > summary { min-height: 2.75rem; display: grid; gap: var(--space-2); padding: var(--space-3); cursor: pointer; list-style: none; color: var(--text-secondary); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+.longDataSummaryLine { min-width: 0; display: flex; align-items: center; gap: var(--space-2); }
+.longDataSummaryLine small { margin-left: auto; color: var(--text-muted); font: var(--type-caption)/1.4 var(--font-mono); }
+.longDataPreview { display: block; color: var(--text-secondary); font-size: var(--type-ui); font-weight: var(--weight-regular); line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
+.longDataValue > .richText,
+.longDataValue > .valueView { margin: 0 var(--space-3) var(--space-3); }
+.dataFlowEmpty { margin: 0; padding: var(--space-3); border: 1px dashed var(--border); border-radius: var(--radius-sm); color: var(--text-muted); font-size: var(--type-ui); line-height: 1.6; }
+.displayUse { --stage-color: var(--evaluator); }
+.displayUseGroups { display: grid; gap: var(--space-4); }
+.mappingGroup { min-width: 0; display: grid; gap: var(--space-2); }
+.mappingGroup h4,
+.unusedSources > strong { margin: 0; color: var(--text-primary); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+.mappingGroup ul { display: grid; gap: 0; margin: 0; padding: 0; border-top: 1px solid var(--border-subtle); list-style: none; }
+.mappingGroup li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: var(--space-3) 0; border-bottom: 1px solid var(--border-subtle); }
+.mappingGroup li > div { min-width: 0; display: grid; gap: .2rem; }
+.mappingGroup li strong { color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.5; overflow-wrap: anywhere; }
+.mappingGroup li small { color: var(--text-muted); font-size: var(--type-caption); line-height: 1.5; overflow-wrap: anywhere; }
+.sourceReferences { display: inline-flex; justify-content: flex-end; gap: .25rem; flex-wrap: wrap; }
+.sourceReferences > span { min-width: 2rem; min-height: 1.45rem; display: inline-grid; place-items: center; padding: 0 .3rem; border-radius: .325rem; background: rgba(123, 184, 255, .08); color: #9fcaff; font: var(--type-caption)/1 var(--font-mono); }
+.sourceReferenceEmpty { color: var(--text-subtle); font-size: var(--type-caption); white-space: nowrap; }
+.unusedSources { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
+.dataFlowState { min-height: 16rem; display: grid; place-content: center; justify-items: center; gap: var(--space-2); padding: var(--space-5); color: var(--text-muted); text-align: center; }
+.dataFlowState svg { color: var(--info); }
+.dataFlowState h3 { margin: var(--space-1) 0 0; color: var(--text-primary); font-size: var(--type-title); }
+.dataFlowState p { max-width: 34rem; margin: 0; font-size: var(--type-ui); line-height: 1.65; }
+.dataFlowSkeleton { display: grid; gap: var(--space-3); }
+.dataFlowSkeleton div { height: 5.5rem; border-radius: var(--radius-md); background: var(--surface); }
+.dataFlowSkeleton span { width: 100%; height: 2.75rem; border-radius: var(--radius-sm); background: var(--surface-control); }
+.dataFlowSkeleton span:nth-child(3),
+.dataFlowSkeleton span:nth-child(6),
+.dataFlowSkeleton span:nth-child(8) { width: 72%; }
+.dataFlowSkeleton > * { animation: dataFlowPulse 1.5s ease-in-out infinite alternate; }
+@keyframes dataFlowPulse { from { opacity: .45; } to { opacity: .9; } }
+
+/* Current final script: a document surface, not another canvas card. */
+
+.scriptViewer {
+  width: min(96vw, 112rem);
+  height: 92dvh;
+  max-width: none;
+  max-height: none;
+  margin: auto;
+  padding: 0;
+  overflow: hidden;
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius-lg);
+  background: var(--surface-subtle);
+  color: var(--text-primary);
+  box-shadow: 0 28px 90px rgba(0, 0, 0, .58);
+  z-index: var(--z-modal);
+}
+.scriptViewer[open] { display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
+.scriptViewer::backdrop { background: rgba(7, 8, 10, .76); }
+.scriptViewerHeader {
+  min-width: 0;
+  min-height: 5.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-5);
+  padding: var(--space-4) var(--space-5);
+  border-bottom: 1px solid var(--border-subtle);
+  background: var(--surface);
+}
+.scriptViewerHeader > .iconButton { flex: none; }
+.scriptViewerHeaderActions { display: flex; align-items: center; gap: var(--space-2); flex: none; }
+.scriptViewerTitle { min-width: 0; display: grid; gap: .15rem; }
+.scriptViewerTitle > span {
+  display: flex;
+  align-items: center;
+  gap: var(--space-2);
+  color: var(--success);
+  font-size: var(--type-label);
+  font-weight: var(--weight-semibold);
+}
+.scriptViewer.isCandidate .scriptViewerTitle > span { color: var(--accent-hover); }
+.scriptViewerTitle h2 { margin: 0; font-size: var(--type-heading); font-weight: var(--weight-semibold); }
+.scriptViewerTitle p { margin: 0; color: var(--text-muted); font-size: var(--type-label); }
+.scriptViewerToolbar {
+  min-width: 0;
+  min-height: 3.5rem;
+  display: flex;
+  align-items: stretch;
+  justify-content: space-between;
+  gap: var(--space-4);
+  padding: 0 var(--space-5);
+  border-bottom: 1px solid var(--border-subtle);
+  background: var(--surface-subtle);
+}
+.scriptViewerTabs { display: flex; align-items: stretch; }
+.scriptViewerTabs button {
+  min-width: 7.5rem;
+  border: 0;
+  border-bottom: 2px solid transparent;
+  background: transparent;
+  color: var(--text-muted);
+  font-size: var(--type-ui);
+  font-weight: var(--weight-medium);
+}
+.scriptViewerTabs button:hover { color: var(--text-primary); }
+.scriptViewerTabs button.active { border-bottom-color: var(--accent); color: var(--text-primary); font-weight: var(--weight-semibold); }
+.scriptViewerVersion {
+  align-self: center;
+  max-width: 52rem;
+  margin: 0;
+  color: var(--text-secondary);
+  font-size: var(--type-label);
+  line-height: 1.5;
+  text-align: right;
+  text-wrap: pretty;
+}
+.scriptViewerVersion.isHistorical { color: var(--success); }
+.scriptViewerVersion.isCurrent { color: var(--warning); }
+.scriptViewerBody { min-width: 0; min-height: 0; overflow: hidden; padding: var(--space-4); background: #e9ecf2; }
+.scriptViewerState {
+  width: min(34rem, 100%);
+  min-height: 14rem;
+  display: grid;
+  align-content: center;
+  gap: var(--space-3);
+  margin: 10vh auto 0;
+  padding: var(--space-5);
+  border: 1px solid #d5dae3;
+  border-radius: var(--radius-md);
+  background: #fff;
+  color: #323944;
+  text-align: center;
+}
+.scriptViewerState p { margin: 0; color: #667080; }
+.scriptViewerState.isError { border-color: #efb5ad; }
+
+.originalScriptTable {
+  height: 100%;
+  min-height: 0;
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr);
+  color: #1e293b;
+}
+.scriptTableTitle {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  min-height: 48px;
+  padding: 12px 16px;
+  border: 1px solid rgba(0, 0, 0, .04);
+  border-bottom: 0;
+  border-radius: 10px 10px 0 0;
+  background: #fff;
+  color: #1e293b;
+  font-size: 14px;
+}
+.script-table-wrapper {
+  min-width: 0;
+  min-height: 0;
+  overflow: auto;
+  overscroll-behavior: contain;
+  scrollbar-gutter: stable;
+  padding: 0 0 16px;
+  background: #fff;
+  border-radius: 0 0 10px 10px;
+  border: 1px solid rgba(0, 0, 0, .04);
+  border-top: none;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, .06);
+  isolation: isolate;
+}
+.script-table-wrapper::-webkit-scrollbar { width: 8px; height: 8px; }
+.script-table-wrapper::-webkit-scrollbar-thumb { background: #c1c9d2; border-radius: 4px; }
+.script-table-wrapper::-webkit-scrollbar-track { background: #f1f5f9; }
+.script-table {
+  border-collapse: separate;
+  border-spacing: 0;
+  table-layout: fixed;
+  overflow: visible;
+  border-radius: 0;
+  box-shadow: none;
+  border: none;
+  color: #1e293b;
+  font-size: 12px;
+  line-height: 1.5;
+}
+.script-table th,
+.script-table td {
+  max-width: 0;
+  padding: 6px 8px;
+  border-right: 1px solid #d0d5dd;
+  border-bottom: 1px solid #d0d5dd;
+  text-align: left;
+  vertical-align: top;
+  word-break: break-word;
+  overflow-wrap: break-word;
+}
+.script-table th:first-child,
+.script-table td:first-child { border-left: 1px solid #d0d5dd; }
+.script-table thead tr:first-child th { border-top: 1px solid #d0d5dd; }
+.script-table thead th {
+  position: sticky;
+  z-index: 5;
+  color: #1e293b;
+  font-size: 11px;
+  font-weight: 600;
+  text-align: center;
+  vertical-align: middle;
+  white-space: nowrap;
+}
+.script-table thead tr.st-header-group th { top: 0; z-index: 12; }
+.script-table thead tr.st-header-subgroup th { top: 34px; z-index: 11; }
+.script-table thead tr.st-header-col th { top: 68px; z-index: 10; }
+.st-header-group th { font-size: 12px; font-weight: 700; }
+.st-header-subgroup th { font-size: 11px; font-weight: 600; }
+.st-header-col th { color: #475569; font-size: 11px; font-weight: 500; }
+.col-paragraph { background: #ffe4e1; }
+.col-range { background: #ffdab9; }
+.col-outline { background: #d6eaf8; }
+.script-table thead th.col-outline-theme { background: #c8e6c9; color: #2e7d32; }
+.script-table thead th.col-outline-form { background: #ffe0b2; color: #e65100; }
+.script-table thead th.col-outline-action { background: #bbdefb; color: #1565c0; }
+.script-table thead th.col-outline-feeling { background: #f8bbd0; color: #c2185b; }
+.col-full-desc { background: #eceff1; color: #37474f; }
+.script-table tbody td { position: relative; z-index: 0; }
+.script-table th.col-paragraph,
+.script-table td.col-paragraph {
+  position: sticky;
+  padding: 8px 14px;
+  background-color: #fff5f3;
+}
+.script-table thead tr.st-header-group th.col-paragraph,
+.script-table thead tr.st-header-subgroup th.col-paragraph,
+.script-table thead tr.st-header-col th.col-paragraph { z-index: 40; background-color: #ffe4e1; }
+.script-table thead tr.st-header-group th.col-paragraph,
+.script-table thead tr.st-header-subgroup th.col-paragraph:first-child { box-shadow: -14px 0 0 #ffe4e1; }
+.script-table tbody td.col-paragraph { z-index: 15; box-shadow: inset 0 -1px #d0d5dd; }
+.script-table th.col-paragraph-last,
+.script-table td.col-paragraph-last { box-shadow: inset 0 -1px #d0d5dd, 3px 0 6px -2px rgba(0, 0, 0, .07); }
+.script-table tbody tr > td.col-paragraph:first-child { box-shadow: -14px 0 0 #fff5f3, inset 0 -1px #d0d5dd; }
+.script-table tbody td.col-paragraph { background: #fff5f3; }
+.script-table tbody td.col-range { background: #fff8f0; }
+.script-table tbody td.col-outline,
+.script-table tbody td.col-outline-theme,
+.script-table tbody td.col-outline-form,
+.script-table tbody td.col-outline-action,
+.script-table tbody td.col-outline-feeling { background: #fff; }
+.script-table tbody td.col-full-desc { background: #f5f5f5; }
+.script-table thead th.col-atom-sub { font-size: 11px; font-weight: 600; }
+.script-table tbody td.col-atom-group { padding: 0; background: #fff; font-size: 12px; vertical-align: top; }
+.script-table tbody td.col-atom-group .atom-rows-table { width: 100%; margin: 0; border-collapse: collapse; table-layout: fixed; }
+.script-table tbody td.col-atom-group .atom-rows-table td {
+  padding: 6px 8px;
+  border-right: 1px solid #d0d5dd;
+  border-bottom: 1px solid #d0d5dd;
+  line-height: 1.35;
+  vertical-align: top;
+  white-space: normal;
+}
+.script-table tbody td.col-atom-group .atom-rows-table tr:last-child td { border-bottom: none; }
+.script-table tbody td.col-atom-group .atom-rows-table td:last-child { border-right: none; }
+.script-table tbody td.col-atom-group .atom-rows-table .col-atom-type { color: #64748b; }
+.script-table tbody td.col-atom-group-theme .atom-rows-table .atom-dim-main { background: #66bb6a; color: #fff; font-weight: 600; }
+.script-table tbody td.col-atom-group-theme .atom-rows-table .atom-dim-sub { background: #c8e6c9; color: #2e7d32; font-weight: 500; }
+.script-table tbody td.col-atom-group-form .atom-rows-table .atom-dim-main { background: #ffb74d; color: #4e342e; font-weight: 600; }
+.script-table tbody td.col-atom-group-form .atom-rows-table .atom-dim-sub { background: #ffe0b2; color: #e65100; font-weight: 500; }
+.script-table tbody td.col-atom-group-action .atom-rows-table .atom-dim-main { background: #64b5f6; color: #fff; font-weight: 600; }
+.script-table tbody td.col-atom-group-action .atom-rows-table .atom-dim-sub { background: #bbdefb; color: #1565c0; font-weight: 500; }
+.script-table tbody td.col-atom-group .atom-val-with-id { cursor: help; border-bottom: 1px dotted #99f6e4; }
+.script-row-l1 td { font-weight: 500; }
+.script-row-l2 td { color: #475569; font-size: 11.5px; }
+.script-row-l3 td,
+tr[class^="script-row-l"]:not(.script-row-l1):not(.script-row-l2):not(.script-row-l3) td { color: #64748b; font-size: 11px; }
+tr[class^="script-row-l"]:hover td:not(.col-paragraph) { background: rgba(91, 141, 239, .04); }
+.collapse-toggle {
+  width: 16px;
+  min-height: 16px;
+  display: inline-block;
+  margin: 0 4px 0 0;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: #64748b;
+  cursor: pointer;
+  font-size: 10px;
+  text-align: center;
+  vertical-align: middle;
+}
+.collapse-toggle:hover { color: #1e293b; }
+.collapse-toggle.collapsed { color: #94a3b8; }
+.content-range-tags { display: flex; flex-wrap: wrap; align-items: flex-start; }
+.cr-tag {
+  display: inline-block;
+  margin: 1px 2px;
+  padding: 1px 6px;
+  border: 1px solid #e2e8f0;
+  border-radius: 3px;
+  background: #f1f5f9;
+  color: #475569;
+  font-size: 11px;
+  word-break: break-word;
+}
+.elem-hl-extended {
+  display: inline;
+  margin: 0;
+  padding: 0 1px 0 2px;
+  border: 0;
+  border-radius: 2px;
+  background: #fff;
+  color: #0288d1;
+  box-shadow: 0 0 0 1px #81d4fa;
+  font: inherit;
+  font-weight: 600;
+  line-height: inherit;
+  cursor: pointer;
+}
+span.elem-hl-extended { cursor: default; }
+.cell-expandable {
+  max-height: 100px;
+  overflow: hidden;
+  position: relative;
+  color: #4a5568;
+  cursor: pointer;
+  font-size: 11px;
+  line-height: 1.5;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+.cell-expandable::after {
+  content: '... 点击展开';
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  height: 24px;
+  background: linear-gradient(transparent, #f5f5f5 60%);
+  color: #5b8def;
+  font-size: 10px;
+  line-height: 28px;
+  text-align: center;
+}
+.cell-expandable.expanded { max-height: none; }
+.cell-expandable.expanded::after { content: none; }
+.script-legend { display: flex; flex-wrap: wrap; gap: 10px; color: #64748b; font-size: 11px; font-weight: 400; }
+.legend-item { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
+.legend-swatch { width: 12px; height: 12px; display: inline-block; border: 1px solid #81d4fa; border-radius: 2px; background: #fff; }
+.elementParagraphs { margin: 0; padding: 0; list-style: none; }
+.elementTableViewport {
+  width: 100%;
+  height: 100%;
+  min-width: 0;
+  min-height: 0;
+  overflow: auto;
+  overscroll-behavior: contain;
+  scrollbar-gutter: stable;
+  border: 1px solid #cfd5df;
+  border-radius: var(--radius-sm);
+  background: #f8f9fc;
+  color: #2e3540;
+  box-shadow: 0 4px 18px rgba(30, 40, 55, .1);
+}
+.elementTable {
+  width: 100%;
+  min-width: 72rem;
+  border-collapse: separate;
+  border-spacing: 0;
+  table-layout: fixed;
+  color: #303744;
+  font-size: .875rem;
+  line-height: 1.55;
+}
+.elementTable th,
+.elementTable td {
+  min-width: 9.5rem;
+  padding: .875rem;
+  border-right: 1px solid #d7dce5;
+  border-bottom: 1px solid #d7dce5;
+  background: #fff;
+  overflow-wrap: anywhere;
+  text-align: left;
+  vertical-align: top;
+}
+.elementTable thead th { top: 0 !important; min-width: 12rem; background: #e8edf4; }
+.elementTable thead th { position: sticky; z-index: 2; color: #374151; font-size: .8125rem; font-weight: 700; }
+.elementTable thead th:first-child { width: 18rem; }
+.elementTable tbody th { background: #f5f7fa; color: #2d3541; font-weight: 700; }
+.elementTable tr.isSelected th,
+.elementTable tr.isSelected td { background: #fff2d7; box-shadow: inset 0 2px #e6a83f, inset 0 -2px #e6a83f; }
+.elementParagraphs { margin: 0; display: grid; gap: .4rem; }
+.elementParagraphs li { display: grid; grid-template-columns: 4rem minmax(0, 1fr); gap: var(--space-2); }
+.elementParagraphs span { color: #788291; font-size: .75rem; font-weight: 600; }
+
+.richText {
+  max-width: 72ch;
+  color: var(--text-secondary);
+  font-size: var(--type-body);
+  line-height: 1.72;
+  letter-spacing: .012em;
+  overflow-wrap: anywhere;
+}
+.richText > :first-child { margin-top: 0; }
+.richText > :last-child { margin-bottom: 0; }
+.richText h1,
+.richText h2,
+.richText h3,
+.richText h4 {
+  margin: 1.5em 0 .55em;
+  color: var(--text-primary);
+  font-weight: var(--weight-semibold);
+  line-height: 1.35;
+  letter-spacing: -.01em;
+  text-wrap: balance;
+}
+.richText h1 { font-size: var(--type-heading); }
+.richText h2 { font-size: var(--type-title); }
+.richText h3,
+.richText h4 { font-size: var(--type-body); }
+.richText p { margin: 0 0 1em; text-wrap: pretty; }
+.richText strong { color: var(--text-primary); font-weight: var(--weight-semibold); }
+.richText ul,
+.richText ol { margin: .5em 0 1em; padding-left: 1.4em; }
+.richText li + li { margin-top: .35em; }
+.richText blockquote {
+  margin: 1em 0;
+  padding: var(--space-3) var(--space-4);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface);
+  color: var(--text-muted);
+}
+.richText code {
+  padding: .12em .35em;
+  border-radius: .25rem;
+  background: var(--canvas);
+  color: #e4e6ea;
+  font-family: var(--font-mono);
+  font-size: .875em;
+  font-variant-ligatures: none;
+}
+.richText pre {
+  max-width: 100%;
+  padding: var(--space-3);
+  overflow: auto;
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  background: var(--canvas);
+}
+.richText pre code { padding: 0; background: transparent; }
+.richText table {
+  display: block;
+  max-width: 100%;
+  overflow-x: auto;
+  border-collapse: collapse;
+  font-size: var(--type-ui);
+}
+.richText th,
+.richText td { padding: var(--space-2); border: 1px solid var(--border); text-align: left; vertical-align: top; }
+.richText th { color: var(--text-primary); background: var(--surface); font-weight: var(--weight-semibold); }
+.richText hr { height: 1px; margin: var(--space-5) 0; border: 0; background: var(--border-subtle); }
+.richText a { color: var(--info); text-underline-offset: .18em; }
+
+.valueView {
+  max-width: 100%;
+  max-height: 62vh;
+  margin: 0;
+  padding: var(--space-4);
+  overflow: auto;
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  background: var(--canvas);
+  color: #d8dae0;
+  font: var(--type-label)/1.65 var(--font-mono);
+  font-variant-ligatures: none;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+.diffWarning,
+.emptyState { color: var(--warning) !important; }
+.planHistorySummary { color: var(--text-muted) !important; }
+.planHistoryList { display: grid; gap: var(--space-3); margin: var(--space-1) 0 0; padding: 0; list-style: none; }
+.planHistoryItem {
+  padding: var(--space-3);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface);
+}
+.planHistoryItem.is-success { border-color: rgba(99, 200, 131, .48); }
+.planHistoryItem.is-failure { border-color: rgba(255, 128, 110, .48); }
+.planHistoryItem p { margin-top: var(--space-2); }
+.planCurrentTag {
+  display: inline-flex;
+  margin-left: var(--space-2);
+  padding: .125rem .5rem;
+  border-radius: 999px;
+  background: rgba(99, 200, 131, .14);
+  color: var(--success);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+}
+
+/* Feedback and loading */
+
+.errorBanner {
+  position: fixed;
+  left: 1.125rem;
+  right: 1.125rem;
+  bottom: 1.125rem;
+  z-index: var(--z-toast);
+  display: flex;
+  align-items: center;
+  gap: var(--space-2);
+  padding: var(--space-3) var(--space-4);
+  border: 1px solid rgba(255, 128, 110, .45);
+  border-radius: var(--radius-sm);
+  background: #3a201d;
+  color: #ffb7aa;
+  font-size: var(--type-ui);
+  box-shadow: var(--shadow-md);
+}
+.loadingScreen,
+.fatalState {
+  min-height: 100vh;
+  display: grid;
+  place-content: center;
+  justify-items: center;
+  gap: var(--space-4);
+  padding: var(--space-6);
+  background: var(--canvas);
+  text-align: center;
+}
+.loadingScreen h1,
+.fatalState h1 { margin: 0; font-size: var(--type-heading); font-weight: var(--weight-semibold); }
+.fatalState p { max-width: 65ch; margin: 0; color: var(--text-muted); font-size: var(--type-body); line-height: 1.65; }
+.skeletonWide,
+.skeletonLines { width: min(26rem, 80vw); display: grid; gap: var(--space-2); }
+.skeletonWide i,
+.skeletonLines i { height: .625rem; border-radius: .25rem; background: var(--surface-control); }
+.skeletonWide i:nth-child(2),
+.skeletonLines i:nth-child(2) { width: 82%; }
+.skeletonWide i:nth-child(3),
+.skeletonLines i:nth-child(3) { width: 64%; }
+
+.mobileTimeline { display: none; }
+.exitFullscreen {
+  position: absolute;
+  z-index: var(--z-toast);
+  top: var(--space-3);
+  left: 50%;
+  transform: translateX(-50%);
+  min-height: 2.75rem;
+  padding: 0 var(--space-4);
+  border: 1px solid var(--accent);
+  border-radius: var(--radius-sm);
+  background: var(--surface);
+  color: var(--text-primary);
+  font-size: var(--type-ui);
+  font-weight: var(--weight-semibold);
+}
+
+@media (max-width: 1199px) {
+  .runHeader,
+  .withInspector .runHeader { right: var(--space-3); left: var(--space-3); }
+  .topBar { flex-wrap: wrap; }
+  .runMeta { order: 3; }
+  .roundNav { order: 5; flex: 1 1 20rem; width: auto; }
+  .withInspector .workspace { grid-template-columns: 1fr; }
+  .inspector {
+    position: absolute;
+    top: var(--space-3);
+    right: var(--space-3);
+    bottom: var(--space-3);
+    width: min(28.75rem, calc(100vw - 1.5rem));
+    height: auto;
+    border: 1px solid var(--border);
+    border-radius: var(--radius-md);
+    overflow: hidden;
+  }
+}
+
+@media (max-width: 767px) {
+  body { overflow: auto; }
+  .appFrame { height: auto; min-height: 100vh; }
+  .runHeader,
+  .withInspector .runHeader { position: relative; top: auto; left: auto; right: auto; margin: var(--space-2); }
+  .topBar { gap: var(--space-2); }
+  .productMark { flex: 1; }
+  .buildPicker { order: 3; width: 100%; min-width: 0; }
+  .buildPicker select { flex: 1; width: auto; }
+  .buildPicker input { flex: 1; width: auto; }
+  .roundNav { order: 5; width: 100%; }
+  .runMeta { order: 2; margin-left: auto; }
+  .compactAction span,
+  .dataNotes summary span,
+  .refreshing { display: none; }
+  .dataNotes > div { position: fixed; top: auto; left: .625rem; right: .625rem; bottom: .625rem; width: auto; }
+
+  .workspace { height: auto; min-height: calc(100vh - 11.25rem); display: block; overflow: visible; }
+  .flowShell { display: none; }
+  .mobileTimeline { display: block; padding: var(--space-4); background: var(--canvas); }
+  .mobileTimeline > header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); }
+  .mobileTimeline h2 { margin: 0; font-size: var(--type-title); font-weight: var(--weight-semibold); }
+  .mobileTimeline header p { margin: var(--space-1) 0 0; color: var(--text-muted); font-size: var(--type-label); }
+
+  .mobileStory {
+    width: 100%;
+    display: grid;
+    grid-template-columns: auto 1fr;
+    gap: var(--space-2) var(--space-3);
+    min-height: 6rem;
+    margin-bottom: var(--space-3);
+    padding: var(--space-4);
+    border: 1px solid var(--border);
+    border-radius: var(--radius-md);
+    background: var(--surface);
+    color: var(--text-primary);
+    text-align: left;
+  }
+  .mobileStory > span:first-child { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+  .mobileStory strong,
+  .mobileStory p { grid-column: 1 / -1; margin: 0; }
+  .mobileStory strong { font-size: var(--type-title); font-weight: var(--weight-semibold); }
+  .mobileStory p { color: var(--text-secondary); font-size: var(--type-body); line-height: 1.65; }
+  .mobileStory > .cardFields,
+  .mobileSteps .cardFields {
+    grid-column: 1 / -1;
+    width: 100%;
+    gap: var(--space-3);
+  }
+  .mobileStory .cardField,
+  .mobileSteps .cardField {
+    grid-template-columns: minmax(4.75rem, 5.5rem) minmax(0, 1fr);
+    gap: var(--space-3);
+  }
+  .mobileStory .cardField b,
+  .mobileSteps .cardField b {
+    color: var(--text-muted);
+    font-size: var(--type-label);
+    line-height: 1.55;
+  }
+  .mobileStory .cardField p,
+  .mobileSteps .cardField p {
+    grid-column: auto;
+    color: var(--text-secondary);
+    font-size: var(--type-ui);
+    font-weight: var(--weight-regular);
+    line-height: 1.58;
+    -webkit-line-clamp: unset;
+  }
+  .mobileStory .cardFieldPrimary p,
+  .mobileSteps .cardFieldPrimary p {
+    color: var(--text-primary);
+    font-size: var(--type-body);
+    font-weight: var(--weight-semibold);
+  }
+  .mobileFinal { display: block; padding: 0; }
+  .mobileFinalSummary {
+    width: 100%;
+    display: grid;
+    grid-template-columns: auto 1fr;
+    gap: var(--space-2) var(--space-3);
+    padding: var(--space-4);
+    border: 0;
+    background: transparent;
+    color: var(--text-primary);
+    text-align: left;
+  }
+  .mobileFinalSummary > span:first-child { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+  .mobileFinalSummary strong,
+  .mobileFinalSummary p { grid-column: 1 / -1; margin: 0; }
+  .mobileFinal > .nodeAction { margin: 0 var(--space-4) var(--space-4); }
+
+  .mobileTimeline > .mobileDecision {
+    margin: 0 0 var(--space-3);
+    padding: 0;
+    list-style: none;
+  }
+  .mobileDecision > button:first-child {
+    width: 100%;
+    min-height: 5rem;
+    display: grid;
+    grid-template-columns: 1fr auto;
+    gap: var(--space-2) var(--space-3);
+    padding: var(--space-4);
+    border: 1px solid rgba(255, 109, 90, .42);
+    border-radius: var(--radius-md);
+    background: var(--surface);
+    color: var(--text-primary);
+    text-align: left;
+  }
+  .mobileDecision > button:first-child > span { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+  .mobileDecision > button:first-child > em { color: var(--text-muted); font-size: var(--type-caption); font-style: normal; }
+  .mobileDecision > button:first-child > strong,
+  .mobileDecision > button:first-child > .cardFields { grid-column: 1 / -1; }
+  .mobileDecision > button:first-child > strong { font-size: var(--type-title); }
+  .mobilePromptAction { width: 100%; min-height: 2.75rem; margin-bottom: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--canvas); color: var(--text-secondary); }
+
+  .roundList,
+  .mobileSteps { margin: 0; padding: 0; list-style: none; }
+  .roundList > li { margin-bottom: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
+  .mobileRoundSummary { display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); }
+  .roundExpand { border: 0; border-right: 1px solid var(--border-subtle); background: var(--surface-control); color: var(--text-primary); }
+  .roundSummaryOpen {
+    min-width: 0;
+    display: grid;
+    grid-template-columns: auto 1fr;
+    gap: var(--space-2) var(--space-3);
+    padding: var(--space-4);
+    border: 0;
+    background: transparent;
+    color: var(--text-primary);
+    text-align: left;
+  }
+  .roundSummaryOpen > span { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+  .roundSummaryOpen > strong,
+  .mobileSummarySequence { grid-column: 1 / -1; }
+  .roundSummaryOpen > strong { font-size: var(--type-title); font-weight: var(--weight-semibold); }
+  .mobileSummarySequence { display: grid; gap: var(--space-2); }
+  .mobileSummarySequence p {
+    display: grid;
+    grid-template-columns: 2.75rem 1fr;
+    gap: var(--space-2);
+    margin: 0;
+    color: var(--text-secondary);
+    font-size: var(--type-ui);
+    line-height: 1.55;
+  }
+  .mobileSummarySequence b { color: var(--text-muted); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+
+  .mobileSteps { padding: var(--space-2); border-top: 1px solid var(--border-subtle); background: var(--canvas); }
+  .mobileSteps li > button:first-child {
+    width: 100%;
+    min-height: 5rem;
+    display: grid;
+    grid-template-columns: auto 1fr;
+    gap: var(--space-2) var(--space-3);
+    margin-bottom: var(--space-2);
+    padding: var(--space-3);
+    border: 1px solid var(--border);
+    border-radius: var(--radius-sm);
+    background: var(--surface);
+    color: var(--text-primary);
+    text-align: left;
+  }
+  .mobileSteps span { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
+  .mobileSteps strong,
+  .mobileSteps p { grid-column: 1 / -1; }
+  .mobileSteps strong { font-size: var(--type-body); font-weight: var(--weight-semibold); }
+  .mobileSteps p { margin: 0; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.55; }
+  .mobileConvergenceSteps {
+    display: grid;
+    gap: var(--space-2);
+    margin: 0;
+    padding: 0;
+    list-style: none;
+  }
+  .mobileConvergenceSteps > li { margin: 0; padding: 0; }
+  .inspectorDetailItem {
+    grid-template-columns: 1fr;
+    gap: var(--space-2);
+  }
+  .implementationRouteBody { grid-template-columns: 1fr; }
+  .implementationPlanField.is-wide { grid-column: auto; }
+  .mobileCandidate { padding: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-control); }
+  .mobileCandidate .candidateExpand {
+    width: 100%;
+    min-height: 2.75rem;
+    border: 1px solid var(--border);
+    border-radius: var(--radius-sm);
+    background: var(--canvas);
+    color: var(--accent);
+    font-size: var(--type-ui);
+    font-weight: var(--weight-semibold);
+  }
+  .mobileCandidateActions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
+  .mobileCandidateActions .candidateExpand {
+    width: auto;
+    flex: 1 1 12rem;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    gap: .375rem;
+    padding: 0 var(--space-3);
+  }
+  .mobileCandidate ol { margin: 0; padding: var(--space-2) 0 0; list-style: none; }
+  .mobileBranchProcess { display: grid; gap: var(--space-2); }
+  .mobileRetrievalStage {
+    padding: var(--space-2);
+    border: 1px dashed rgba(230, 168, 111, .42);
+    border-radius: var(--radius-sm);
+    background: rgba(230, 168, 111, .045);
+  }
+  .mobileRetrievalStage > button:first-child { border-color: rgba(230, 168, 111, .38); background: rgba(37, 35, 31, .96); }
+  .mobileRetrievalStage > button:first-child span,
+  .mobileRetrievalStage > ol > li > button:first-child span { color: var(--retrieval); }
+  .mobileRetrievalStage > ol { display: grid; gap: var(--space-2); padding: 0; }
+  .mobileRetrievalStage > ol > li { padding-left: var(--space-2); border-left: 2px solid rgba(230, 168, 111, .28); }
+  .mobileRetrievalAgent > button:first-child { background: rgba(31, 30, 30, .97); }
+  .mobileRetrievalAgent > ol { display: grid; gap: .25rem; padding-left: var(--space-3); }
+  .mobileRetrievalAgent > ol > li > button:first-child { min-height: 2.75rem; }
+
+  .flowShell.mobileFullscreen { position: fixed; inset: 0; z-index: var(--z-drawer); display: block; }
+  .inspector { position: fixed; inset: 0; width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }
+  .inspectorHeader { padding: var(--space-4); }
+  .inspectorTabs {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    overflow-x: visible;
+  }
+  .inspectorTabs button { min-width: 0; }
+  .inspectorTabs button:last-child:nth-child(odd) { grid-column: 1 / -1; }
+  .inspectorBody { gap: var(--space-4); padding: var(--space-4); }
+  .retrievalActivityList button { grid-template-columns: 1.75rem minmax(0, 1fr) auto; gap: var(--space-2); }
+  .retrievalActivityList button > svg { display: none; }
+  .retrievalConditions > div { grid-template-columns: 1fr; gap: var(--space-1); }
+  .retrievalResult > summary { grid-template-columns: 1.25rem minmax(0, 1fr); }
+  .retrievalResultMeta { grid-column: 2; justify-content: flex-start; }
+  .decisionComparison { overflow-x: auto; }
+  .comparisonHeader,
+  .comparisonRow { min-width: 30rem; }
+  .decisionComparison.hasHandling .comparisonHeader,
+  .decisionComparison.hasHandling .comparisonRow { min-width: 38rem; }
+  .runtimeUnit > summary { grid-template-columns: auto auto minmax(0, 1fr) auto; }
+  .runtimeUnit > summary small { display: none; }
+  .dataField { padding: var(--space-3); }
+  .mappingGroup li { align-items: start; grid-template-columns: 1fr; }
+  .sourceReferences { justify-content: flex-start; }
+  .unusedSources { align-items: flex-start; flex-direction: column; }
+  .promptDrawer { inset: 0; width: 100vw; border: 0; border-radius: 0; }
+  .promptDrawerBody { padding: var(--space-4); }
+  .errorBanner { left: .625rem; right: .625rem; bottom: .625rem; }
+
+  .scriptViewer { inset: 0; width: 100vw; height: 100dvh; border: 0; border-radius: 0; }
+  .scriptViewerHeader { min-height: 4.75rem; padding: var(--space-3) var(--space-4); }
+  .scriptViewerTitle h2 { font-size: var(--type-title); }
+  .scriptViewerHeaderActions .quietButton span { display: none; }
+  .scriptViewerHeaderActions .quietButton { width: 2.75rem; padding: 0; justify-content: center; }
+  .scriptViewerToolbar { min-height: 6.5rem; align-items: center; flex-wrap: wrap; padding: var(--space-2) var(--space-3); }
+  .scriptViewerTabs { height: 3rem; flex: 1 1 100%; }
+  .scriptViewerTabs button { flex: 1; min-width: 0; }
+  .scriptViewerVersion { flex: 1 1 100%; max-width: none; text-align: left; }
+  .scriptViewerBody { padding: var(--space-2); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *,
+  *::before,
+  *::after {
+    scroll-behavior: auto !important;
+    animation-duration: .01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: .01ms !important;
+  }
+  .react-flow__edge.animated .react-flow__edge-path { animation: none !important; }
+}
+
+/* Inspector three-column source comparison workbench. */
+
+.withLineageInspector .runHeader { right: var(--space-4); }
+.withLineageInspector .workspace { grid-template-columns: minmax(0, 1fr); }
+
+.inspectorWorkbenchBackdrop {
+  position: fixed;
+  z-index: calc(var(--z-drawer) - 1);
+  inset: 0;
+  background: rgba(7, 8, 10, .48);
+  backdrop-filter: blur(1px);
+}
+
+.inspectorWorkbench {
+  position: fixed;
+  inset: var(--space-3) var(--space-3) var(--space-3) auto;
+  width: min(96vw, 112rem);
+  height: auto;
+  max-height: none;
+  grid-template-rows: auto minmax(0, 1fr);
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  background: #17191e;
+  box-shadow: 0 28px 90px rgba(0, 0, 0, .62);
+}
+.comparisonInspectorHeader {
+  min-height: 5rem;
+  padding: var(--space-3) var(--space-4);
+  background: #1d2026;
+}
+.comparisonInspectorTitle { min-width: 0; flex: 1; }
+.comparisonInspectorTitle h2 { max-width: none; font-size: var(--type-title); }
+.comparisonInspectorMeta { min-width: 0; display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); }
+.comparisonInspectorActions { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); flex: none; }
+.comparisonInspectorBody { display: block; padding: 0; overflow-y: auto; }
+
+.inspectorComparison { min-width: 0; color: var(--text-secondary); }
+.inspectorComparison .valueView {
+  max-height: none;
+  overflow-x: auto;
+  overflow-y: visible;
+}
+.comparisonToolbar {
+  position: sticky;
+  z-index: 4;
+  top: 0;
+  min-height: 3.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-3);
+  padding: var(--space-2) var(--space-4);
+  border-bottom: 1px solid var(--border);
+  background: #1b1e23;
+}
+.comparisonToolbar > div { min-width: 0; display: flex; align-items: center; gap: var(--space-2); }
+.comparisonToolbar > div:first-child > svg { color: var(--info); }
+.comparisonToolbar span { color: var(--text-secondary); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
+.comparisonCompleteness,
+.sourceCompleteness {
+  display: inline-flex;
+  align-items: center;
+  min-height: 1.6rem;
+  padding: .125rem .5rem;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  color: var(--text-muted);
+  font-size: var(--type-caption);
+  font-weight: var(--weight-semibold);
+  white-space: nowrap;
+}
+.comparisonCompleteness.is-complete,
+.sourceCompleteness.is-complete { border-color: rgba(99, 200, 131, .35); color: var(--success); }
+.comparisonCompleteness.is-partial,
+.sourceCompleteness.is-partial { border-color: rgba(244, 193, 82, .36); color: var(--warning); }
+.comparisonCompleteness.is-missing,
+.sourceCompleteness.is-missing { border-color: rgba(255, 128, 110, .36); color: var(--danger); }
+.comparisonTextAction,
+.comparisonInlineAction {
+  min-height: 2.25rem;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: .35rem;
+  padding: 0 var(--space-3);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface);
+  color: var(--text-secondary);
+  font-size: var(--type-label);
+  font-weight: var(--weight-semibold);
+}
+.comparisonTextAction:hover,
+.comparisonInlineAction:hover { border-color: var(--border-strong); background: var(--surface-control); color: var(--text-primary); }
+.comparisonNotices { display: grid; gap: var(--space-2); padding: var(--space-3) var(--space-4) 0; }
+.comparisonNotices p { margin: 0; display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-3); border: 1px solid rgba(123, 184, 255, .25); border-radius: var(--radius-sm); background: rgba(123, 184, 255, .04); color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.6; }
+.comparisonNotices p.is-warning { border-color: rgba(244, 193, 82, .28); color: #efd486; background: rgba(244, 193, 82, .04); }
+.comparisonNotices p.is-error { border-color: rgba(255, 128, 110, .3); color: #ffb0a2; background: rgba(255, 128, 110, .04); }
+.comparisonNotices svg { flex: none; margin-top: .25rem; }
+
+.comparisonColumnHeaders {
+  position: sticky;
+  z-index: 3;
+  top: 3.25rem;
+  display: grid;
+  grid-template-columns: minmax(0, 42fr) minmax(0, 24fr) minmax(0, 34fr);
+  border-top: 1px solid var(--border-subtle);
+  border-bottom: 1px solid var(--border);
+  background: #20232a;
+}
+.comparisonColumnHeaders span { min-width: 0; padding: var(--space-3) var(--space-4); color: var(--text-primary); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
+.comparisonColumnHeaders span:first-child { color: var(--accent-hover); }
+.comparisonColumnHeaders span:nth-child(2) { color: #9fcaff; border-left: 1px solid var(--border); }
+.comparisonColumnHeaders span:nth-child(3) { color: #79d5c3; border-left: 1px solid var(--border); }
+
+.comparisonModules { padding-bottom: var(--space-4); }
+.comparisonModule { min-width: 0; border-bottom: 1px solid var(--border); }
+.comparisonModuleHeader {
+  width: 100%;
+  min-height: 3.5rem;
+  display: grid;
+  grid-template-columns: auto auto minmax(0, 1fr) auto;
+  align-items: center;
+  gap: var(--space-2);
+  padding: var(--space-2) var(--space-4);
+  border: 0;
+  background: #1b1e23;
+  color: var(--text-secondary);
+  text-align: left;
+}
+.comparisonModuleHeader:hover { background: #22252b; color: var(--text-primary); }
+.comparisonModuleHeader > svg { color: var(--text-muted); }
+.comparisonModuleHeader strong { min-width: 0; color: var(--text-primary); font-size: var(--type-body); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
+.comparisonModuleHeader small { color: var(--text-muted); font-size: var(--type-caption); white-space: nowrap; }
+.moduleMarker {
+  display: inline-grid;
+  place-items: center;
+  min-width: 2.2rem;
+  height: 1.55rem;
+  padding: 0 .4rem;
+  border: 1px solid rgba(255, 109, 90, .34);
+  border-radius: .375rem;
+  background: rgba(255, 109, 90, .07);
+  color: var(--accent-hover);
+  font: var(--type-caption)/1 var(--font-mono);
+}
+.comparisonModuleGrid { min-width: 0; display: grid; grid-template-columns: minmax(0, 46fr) minmax(0, 30fr) minmax(0, 24fr); align-items: start; }
+.comparisonColumnRegion { display: contents; }
+.comparisonCell { min-width: 0; padding: var(--space-4); background: #181a1f; }
+.comparisonCell + .comparisonCell { border-left: 1px solid var(--border); }
+.businessModuleCell { background: rgba(255, 109, 90, .018); }
+.evidenceCell { background: rgba(123, 184, 255, .018); }
+.sourceRecordCell { background: rgba(88, 208, 185, .018); }
+
+.comparisonBusinessValue { min-width: 0; display: grid; gap: var(--space-3); }
+.businessSourceLinks { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); }
+.businessSourceLinks > span { color: var(--text-muted); font-size: var(--type-caption); }
+.businessDefinitionList { min-width: 0; display: grid; gap: var(--space-3); margin: 0; }
+.businessDefinitionList > div { min-width: 0; display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); gap: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
+.businessDefinitionList > div:first-child { padding-top: 0; border-top: 0; }
+.businessDefinitionList dt { color: var(--text-muted); font-size: var(--type-label); font-weight: var(--weight-medium); line-height: 1.65; }
+.businessDefinitionList dd { min-width: 0; max-width: 72ch; margin: 0; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.72; overflow-wrap: anywhere; text-wrap: pretty; }
+.businessDefinitionList .is-emphasis dd { color: var(--text-primary); font-size: var(--type-body); font-weight: var(--weight-semibold); }
+.businessPlanRoutes { min-width: 0; display: grid; gap: var(--space-3); }
+.businessPlanRoute { min-width: 0; padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(255, 255, 255, .018); }
+.businessPlanRoute > header { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); }
+.businessPlanRoute > header > span { display: grid; place-items: center; width: 1.8rem; height: 1.8rem; border-radius: 999px; background: rgba(255, 109, 90, .11); color: var(--accent-hover); font: var(--type-caption)/1 var(--font-mono); }
+.businessPlanRoute > header > div { min-width: 0; display: flex; align-items: baseline; gap: var(--space-2); }
+.businessPlanRoute h5,
+.businessNarrative h5,
+.businessRecordItem h5 { margin: 0; color: var(--text-primary); font-size: var(--type-body); font-weight: var(--weight-semibold); }
+.businessPlanRoute header small { color: var(--text-muted); font-size: var(--type-caption); }
+.businessPlanRoute dl { margin: 0; }
+.businessRevisionSummary,
+.businessRecordList { min-width: 0; display: grid; gap: var(--space-4); }
+.businessNarrative { min-width: 0; padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
+.businessNarrative p,
+.businessRecordItem p { max-width: 72ch; margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.72; overflow-wrap: anywhere; text-wrap: pretty; }
+.businessRecordItem { min-width: 0; padding-bottom: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
+.businessBulletList { max-width: 72ch; margin: 0; padding-left: 1.25rem; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.72; }
+.businessBulletList li + li { margin-top: var(--space-2); }
+.comparisonBusinessItems { min-width: 0; display: grid; }
+.comparisonBusinessItem { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
+.comparisonBusinessItem:first-child { padding-top: 0; border-top: 0; }
+.comparisonBusinessItem:last-child { padding-bottom: 0; }
+.comparisonBusinessItem > header { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
+.comparisonBusinessItem > header > div:first-child { min-width: 0; display: flex; align-items: baseline; gap: var(--space-2); }
+.comparisonBusinessItem h4 { min-width: 0; margin: 0; color: var(--text-primary); font-size: var(--type-ui); font-weight: var(--weight-semibold); line-height: 1.5; overflow-wrap: anywhere; }
+.businessItemId { color: var(--text-muted); font: var(--type-caption)/1 var(--font-mono); }
+.comparisonReadableText { max-width: 72ch; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.72; overflow-wrap: anywhere; text-wrap: pretty; }
+.comparisonReadableText > :first-child { margin-top: 0; }
+.comparisonReadableText > :last-child { margin-bottom: 0; }
+.comparisonReadableText h2,
+.comparisonReadableText h3 { color: var(--text-primary); font-size: var(--type-body); }
+.comparisonEmptyValue { margin: 0; color: var(--text-muted); font-size: var(--type-ui); line-height: 1.65; }
+.comparisonInlineAction { width: fit-content; }
+
+.bindingBadgeRow { min-width: 0; display: flex; justify-content: flex-end; gap: var(--space-1); flex-wrap: wrap; }
+.bindingBadge {
+  min-height: 2.75rem;
+  display: inline-flex;
+  align-items: center;
+  gap: .35rem;
+  padding: .25rem .75rem;
+  border: 1px solid rgba(123, 184, 255, .32);
+  border-radius: 999px;
+  background: rgba(123, 184, 255, .06);
+  color: #afd2ff;
+  font-size: var(--type-label);
+  font-weight: var(--weight-semibold);
+  line-height: 1.25;
+  text-align: left;
+}
+.bindingBadge.is-compact { width: 2.75rem; min-width: 2.75rem; padding: 0; justify-content: center; }
+.bindingBadge.is-compact > svg { display: none; }
+.bindingMarker { color: inherit; font-family: var(--font-mono); }
+.bindingBadge:hover,
+.bindingBadge.is-active { border-color: var(--info); background: rgba(123, 184, 255, .16); color: #d6e9ff; box-shadow: 0 0 0 2px rgba(123, 184, 255, .08); }
+.bindingBadge.is-adopted { border-color: rgba(99, 200, 131, .36); color: #91dca8; background: rgba(99, 200, 131, .07); }
+.bindingBadge.is-rejected { border-color: var(--border-strong); color: var(--text-secondary); background: rgba(174, 178, 187, .06); }
+.bindingBadge.is-available { border-color: rgba(244, 193, 82, .36); color: #efd486; background: rgba(244, 193, 82, .06); }
+.bindingBadge.is-calculated { border-color: rgba(183, 160, 235, .38); color: #cabaf0; background: rgba(183, 160, 235, .07); }
+.bindingBadge.is-missing { border-color: rgba(255, 128, 110, .38); color: #ffb0a2; background: rgba(255, 128, 110, .06); }
+.bindingBadge.is-unsafe { border-color: rgba(244, 193, 82, .38); color: #efd486; background: rgba(244, 193, 82, .06); }
+.bindingBadge.is-partial { border-color: rgba(183, 160, 235, .38); color: #cabaf0; background: rgba(183, 160, 235, .07); }
+.bindingBadge.is-fallback { border-color: rgba(174, 178, 187, .34); color: var(--text-secondary); }
+
+.evidenceList { min-width: 0; display: grid; gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
+.evidenceList > li { min-width: 0; display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); align-items: start; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(32, 34, 40, .55); transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out); }
+.evidenceList > li.is-active { border-color: rgba(123, 184, 255, .62); background: rgba(123, 184, 255, .08); }
+.evidenceCopy { min-width: 0; display: grid; gap: .3rem; }
+.evidenceCopy strong { color: var(--text-primary); font-size: var(--type-label); line-height: 1.45; overflow-wrap: anywhere; }
+.evidenceCopy code { color: #a9cfff; font: var(--type-caption)/1.55 var(--font-mono); overflow-wrap: anywhere; }
+.evidenceCopy span { color: var(--text-muted); font-size: var(--type-caption); line-height: 1.5; }
+
+.sourceRecordCell { display: grid; align-content: start; gap: var(--space-3); }
+.sourceRecord { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(32, 34, 40, .55); transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out); }
+.sourceRecord.is-active { border-color: rgba(88, 208, 185, .62); background: rgba(88, 208, 185, .07); }
+.sourceRecord > header { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: var(--space-2); }
+.sourceRecord > header > svg { margin-top: .2rem; color: #79d5c3; }
+.sourceRecord > header > div { min-width: 0; display: grid; gap: .2rem; }
+.sourceRecord > header strong { color: var(--text-primary); font-size: var(--type-label); line-height: 1.45; overflow-wrap: anywhere; }
+.sourceRecord > header small { color: #8ccfc2; font-size: var(--type-caption); }
+.sourceBindingMarkers { display: flex; gap: var(--space-1); flex-wrap: wrap; }
+.sourceBindingMarkers button { min-width: 2.75rem; height: 2.75rem; padding: 0 .55rem; border: 1px solid rgba(88, 208, 185, .3); border-radius: .45rem; background: rgba(88, 208, 185, .05); color: #93ded0; font: var(--type-label)/1 var(--font-mono); }
+.sourceBindingMarkers button:hover,
+.sourceBindingMarkers button.is-active { border-color: #79d5c3; background: rgba(88, 208, 185, .15); color: #d2fff6; }
+.sourceMeta { min-width: 0; display: grid; gap: .4rem; margin: 0; }
+.sourceMeta > div { min-width: 0; display: grid; grid-template-columns: 4.75rem minmax(0, 1fr); gap: var(--space-2); }
+.sourceMeta dt { color: var(--text-muted); font-size: var(--type-caption); }
+.sourceMeta dd { min-width: 0; margin: 0; color: var(--text-secondary); font-size: var(--type-caption); overflow-wrap: anywhere; }
+.sourceMeta code { color: #a8e2d7; font: var(--type-caption)/1.5 var(--font-mono); overflow-wrap: anywhere; }
+.selectedSourceValues { min-width: 0; padding-top: var(--space-2); border-top: 1px solid var(--border-subtle); }
+.selectedSourceValues > summary { min-height: 2.5rem; display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); color: #79d5c3; font-size: var(--type-label); font-weight: var(--weight-semibold); cursor: pointer; list-style: none; }
+.selectedSourceValues > summary::-webkit-details-marker { display: none; }
+.selectedSourceValues > summary small { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-regular); }
+.selectedSourceValues > .comparisonLongValue,
+.selectedSourceValues > .valueView { margin-top: var(--space-2); }
+.sourceMissingReason { margin: 0; color: #ffb0a2; font-size: var(--type-ui); line-height: 1.6; overflow-wrap: anywhere; }
+.sourceMissingReason code { font-family: var(--font-mono); }
+.unboundRuntimeRefs { min-width: 0; display: grid; gap: var(--space-2); padding: var(--space-3); border: 1px dashed var(--border); border-radius: var(--radius-sm); }
+.unboundRuntimeRefs strong { color: var(--text-primary); font-size: var(--type-label); }
+.unboundRuntimeRefs code { color: var(--text-muted); font: var(--type-caption)/1.5 var(--font-mono); overflow-wrap: anywhere; }
+
+.comparisonLongValue { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
+.comparisonLongValue > summary { min-height: 2.75rem; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-2); padding: var(--space-3); color: var(--text-secondary); cursor: pointer; list-style: none; }
+.comparisonLongValue > summary::-webkit-details-marker,
+.rawRecordDisclosure > summary::-webkit-details-marker { display: none; }
+.comparisonLongValue > summary:hover,
+.rawRecordDisclosure > summary:hover { background: rgba(255, 255, 255, .025); }
+.comparisonDisclosureTitle { min-width: 0; display: flex; align-items: center; gap: var(--space-2); }
+.comparisonDisclosureTitle svg { flex: none; color: var(--text-muted); transition: transform 180ms var(--ease-out); }
+.comparisonLongValue[open] .comparisonDisclosureTitle svg { transform: rotate(90deg); }
+.comparisonDisclosureTitle strong { color: var(--text-primary); font-size: var(--type-label); }
+.comparisonLongValue > summary > small { color: var(--text-muted); font: var(--type-caption)/1.4 var(--font-mono); white-space: nowrap; }
+.comparisonDisclosurePreview { grid-column: 1 / -1; color: var(--text-secondary); font-size: var(--type-ui); font-weight: var(--weight-regular); line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
+.comparisonDisclosureBody { padding: var(--space-3); border-top: 1px solid var(--border-subtle); }
+.comparisonDisclosureBody .valueView,
+.selectedSourceValues .valueView { max-height: none; margin: 0; }
+.rawRecordDisclosure { min-width: 0; border-top: 1px solid var(--border-subtle); }
+.rawRecordDisclosure > summary { min-height: 2.75rem; display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); color: var(--text-secondary); font-size: var(--type-label); cursor: pointer; list-style: none; }
+.rawRecordDisclosure > summary small { color: var(--text-muted); font-size: var(--type-caption); }
+.rawRecordDisclosure > div { padding-top: var(--space-2); }
+.comparisonLongValue > summary:focus-visible,
+.rawRecordDisclosure > summary:focus-visible,
+.comparisonModuleHeader:focus-visible,
+.bindingBadge:focus-visible,
+.sourceBindingMarkers button:focus-visible {
+  outline: 2px solid var(--info);
+  outline-offset: 2px;
+}
+
+.comparisonState { min-height: 22rem; display: grid; place-content: center; justify-items: center; gap: var(--space-2); padding: var(--space-5); color: var(--warning); text-align: center; }
+.comparisonState h3 { margin: var(--space-1) 0 0; color: var(--text-primary); font-size: var(--type-title); }
+.comparisonState p { max-width: 45rem; margin: 0; color: var(--text-muted); font-size: var(--type-ui); line-height: 1.65; }
+.comparisonSkeleton { min-height: 24rem; display: grid; grid-template-columns: 42fr 24fr 34fr; gap: 1px; background: var(--border); }
+.comparisonSkeleton > * { min-height: 8rem; background: var(--surface); animation: dataFlowPulse 1.5s ease-in-out infinite alternate; }
+.comparisonSkeleton > div { grid-column: 1 / -1; min-height: 3.5rem; }
+
+/* One business item, its evidence and its exact source share one physical row. */
+.comparisonItemRows { min-width: 0; }
+.comparisonItemRow {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: minmax(0, 42fr) minmax(0, 24fr) minmax(0, 34fr);
+  align-items: stretch;
+  border-top: 1px solid var(--border-subtle);
+  background: #181a1f;
+}
+.comparisonItemRow:first-child { border-top: 0; }
+.comparisonItemRow.is-active { background: rgba(123, 184, 255, .035); }
+.comparisonItemBusiness,
+.comparisonItemEvidence,
+.comparisonItemSource {
+  min-width: 0;
+  padding: var(--space-4);
+}
+.comparisonItemEvidence,
+.comparisonItemSource { border-left: 1px solid var(--border); }
+.comparisonItemBusiness { background: rgba(255, 109, 90, .018); }
+.comparisonItemEvidence { display: grid; align-content: start; gap: var(--space-3); background: rgba(123, 184, 255, .018); }
+.comparisonItemSource { display: grid; align-content: start; gap: var(--space-3); background: rgba(88, 208, 185, .018); }
+.comparisonItemBusiness > h4 {
+  max-width: 72ch;
+  margin: 0 0 var(--space-3);
+  color: var(--accent-hover);
+  font-size: var(--type-ui);
+  font-weight: var(--weight-semibold);
+  line-height: 1.5;
+  text-wrap: pretty;
+}
+.comparisonItemBusiness .comparisonReadableText,
+.comparisonItemBusiness .businessDefinitionList dd,
+.comparisonItemBusiness .businessNarrative p,
+.comparisonItemBusiness .businessRecordItem p { font-size: var(--type-body); line-height: 1.72; }
+
+.comparisonEvidenceSummary {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  align-items: start;
+  gap: var(--space-2);
+  padding-bottom: var(--space-3);
+  border-bottom: 1px solid var(--border-subtle);
+}
+.comparisonEvidenceSummary:last-child { padding-bottom: 0; border-bottom: 0; }
+.comparisonEvidenceSummary.is-active { color: var(--text-primary); }
+.comparisonEvidenceCopy { min-width: 0; display: grid; gap: .3rem; }
+.comparisonEvidenceCopy > strong { color: var(--text-primary); font-size: var(--type-label); line-height: 1.45; overflow-wrap: anywhere; }
+.comparisonEvidenceCopy > span { color: #afd2ff; font-size: var(--type-ui); font-weight: var(--weight-semibold); line-height: 1.5; }
+.comparisonEvidenceCopy > small { color: var(--text-muted); font-size: var(--type-caption); line-height: 1.5; }
+.comparisonLocatorDetails { min-width: 0; margin-top: var(--space-1); }
+.comparisonLocatorDetails > summary {
+  width: fit-content;
+  min-height: 2rem;
+  display: flex;
+  align-items: center;
+  color: var(--text-muted);
+  font-size: var(--type-caption);
+  cursor: pointer;
+}
+.comparisonLocatorDetails[open] > summary { color: #afd2ff; }
+.comparisonLocatorDetails > code,
+.comparisonLocatorDetails > small { display: block; margin-top: .35rem; color: var(--text-muted); font: var(--type-caption)/1.55 var(--font-mono); overflow-wrap: anywhere; }
+
+.comparisonSourceExcerpt {
+  min-width: 0;
+  display: grid;
+  gap: var(--space-3);
+  padding-bottom: var(--space-3);
+  border-bottom: 1px solid var(--border-subtle);
+}
+.comparisonSourceExcerpt:last-child { padding-bottom: 0; border-bottom: 0; }
+.comparisonSourceExcerpt > header {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) auto;
+  align-items: start;
+  gap: var(--space-2);
+}
+.comparisonSourceExcerpt > header > svg { margin-top: .2rem; color: #79d5c3; }
+.comparisonSourceExcerpt > header > div { min-width: 0; display: grid; gap: .2rem; }
+.comparisonSourceExcerpt > header strong { color: var(--text-primary); font-size: var(--type-label); line-height: 1.45; overflow-wrap: anywhere; }
+.comparisonSourceExcerpt > header small { color: #8ccfc2; font-size: var(--type-caption); }
+.sourceExcerptMarker {
+  display: inline-grid;
+  place-items: center;
+  min-width: 2.75rem;
+  height: 2.75rem;
+  padding: 0 .55rem;
+  border: 1px solid rgba(88, 208, 185, .3);
+  border-radius: .45rem;
+  background: rgba(88, 208, 185, .05);
+  color: #93ded0;
+  font: var(--type-label)/1 var(--font-mono);
+}
+.sourceEmptyResult {
+  display: grid;
+  gap: .25rem;
+  padding: var(--space-3);
+  border: 1px solid rgba(99, 200, 131, .3);
+  border-radius: var(--radius-sm);
+  background: rgba(99, 200, 131, .055);
+}
+.sourceEmptyResult strong { color: var(--success); font-size: var(--type-ui); }
+.sourceEmptyResult span { color: var(--text-secondary); font-size: var(--type-caption); }
+.comparisonTextExcerpt {
+  max-width: 72ch;
+  margin: 0;
+  color: var(--text-muted);
+  font-size: var(--type-ui);
+  line-height: 1.75;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+.comparisonTextExcerpt mark {
+  padding: .08em .2em;
+  border-radius: .2rem;
+  background: rgba(88, 208, 185, .18);
+  color: #d8fff7;
+}
+.sourceMemberList { min-width: 0; display: grid; gap: var(--space-3); }
+.sourceMemberList > section { min-width: 0; display: grid; gap: var(--space-2); padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
+.sourceMemberList > section:first-child { padding-top: 0; border-top: 0; }
+.sourceMemberList > section > strong { color: var(--text-primary); font-size: var(--type-label); overflow-wrap: anywhere; }
+
+@media (max-width: 1099px) {
+  .inspectorWorkbench { inset: var(--space-2); width: auto; }
+  .comparisonColumnHeaders { display: none; }
+  .comparisonModuleGrid { grid-template-columns: 1fr; }
+  .comparisonItemRow { grid-template-columns: 1fr; }
+  .comparisonItemEvidence,
+  .comparisonItemSource { border-top: 1px solid var(--border); border-left: 0; }
+  .comparisonItemBusiness::before,
+  .comparisonItemEvidence::before,
+  .comparisonItemSource::before { display: block; margin-bottom: var(--space-3); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
+  .comparisonItemBusiness::before { color: var(--accent-hover); content: "业务详情中的内容"; }
+  .comparisonItemEvidence::before { color: #9fcaff; content: "这一项的数据依据"; }
+  .comparisonItemSource::before { color: #79d5c3; content: "对应的原始字段或原文"; }
+  .comparisonCell + .comparisonCell { border-top: 1px solid var(--border); border-left: 0; }
+  .comparisonCell::before { display: block; margin-bottom: var(--space-3); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
+  .businessModuleCell::before { color: var(--accent-hover); content: "业务详情"; }
+  .evidenceCell::before { color: #9fcaff; content: "数据依据"; }
+  .sourceRecordCell::before { color: #79d5c3; content: "原始记录与运行"; }
+  .comparisonSkeleton { grid-template-columns: 1fr; }
+  .comparisonSkeleton > div { grid-column: 1; }
+}
+
+@media (max-width: 1099px) {
+  .bindingBadge,
+  .bindingBadge.is-compact,
+  .sourceBindingMarkers button { min-width: 2.75rem; width: auto; height: 2.75rem; min-height: 2.75rem; }
+}
+
+@media (max-width: 767px) {
+  .inspectorWorkbench { inset: 0; width: 100%; height: 100dvh; border: 0; border-radius: 0; }
+  .comparisonInspectorHeader { align-items: flex-start; flex-wrap: wrap; }
+  .comparisonInspectorTitle { order: 1; flex: 1 1 calc(100% - 7rem); }
+  .comparisonInspectorActions { order: 2; }
+  .comparisonInspectorActions .quietButton { width: 2.75rem; padding: 0; }
+  .comparisonInspectorActions .quietButton:not(:last-child) { font-size: 0; }
+  .comparisonInspectorActions .quietButton svg { margin: 0; }
+  .inspectorHeaderActions .quietButton { width: 2.75rem; padding: 0; font-size: 0; }
+  .inspectorHeaderActions .quietButton svg { margin: 0; }
+  .comparisonToolbar { align-items: flex-start; }
+  .comparisonToolbar > div:last-child { justify-content: flex-end; flex-wrap: wrap; }
+  .comparisonTextAction { min-height: 2.75rem; }
+  .comparisonModuleHeader { grid-template-columns: auto auto minmax(0, 1fr); }
+  .comparisonModuleHeader small { grid-column: 3; }
+  .comparisonCell { padding: var(--space-3); }
+  .comparisonBusinessItem > header { flex-direction: column; }
+  .businessDefinitionList > div { grid-template-columns: 1fr; gap: .25rem; }
+  .bindingBadgeRow { justify-content: flex-start; }
+  .evidenceList > li { grid-template-columns: 1fr; }
+  .sourceMeta > div { grid-template-columns: 1fr; gap: .15rem; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .comparisonDisclosureTitle svg,
+  .sourceRecord,
+  .evidenceList > li { transition: none !important; }
+}

+ 13 - 0
visualization-old/frontend/app/layout.tsx

@@ -0,0 +1,13 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+import "@xyflow/react/dist/style.css";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "脚本构建运行台",
+  description: "脚本构建轮次、分支、决策依据与创作表变更的真实运行可视化"
+};
+
+export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
+  return <html lang="zh-CN"><body>{children}</body></html>;
+}

+ 5 - 0
visualization-old/frontend/app/page.tsx

@@ -0,0 +1,5 @@
+import { ExecutionWorkbench } from "@/components/workbench/ExecutionWorkbench";
+
+export default function Page() {
+  return <ExecutionWorkbench />;
+}

+ 119 - 0
visualization-old/frontend/components/artifact/ScriptArtifactViewer.tsx

@@ -0,0 +1,119 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { FileText, RefreshCw, X } from "lucide-react";
+import { errorMessage, getArtifactDetail } from "@/lib/api";
+import type { ArtifactDetailProjection } from "@/lib/types";
+import { TabList } from "@/components/ui/TabList";
+import { ScriptTable } from "@/components/artifact/ScriptTable";
+import { ScriptElementTable } from "@/components/artifact/ScriptElementTable";
+import { StatusBadge } from "@/components/ui/Badges";
+
+type ViewerTab = "script" | "elements";
+
+interface Props {
+  buildId: string;
+  snapshotRef: string;
+  onClose: () => void;
+}
+
+export function ScriptArtifactViewer({ buildId, snapshotRef, onClose }: Props) {
+  const dialog = useRef<HTMLDialogElement>(null);
+  const [detail, setDetail] = useState<ArtifactDetailProjection>();
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string>();
+  const [tab, setTab] = useState<ViewerTab>("script");
+  const [selectedElement, setSelectedElement] = useState<number | string>();
+  const [reloadKey, setReloadKey] = useState(0);
+
+  useEffect(() => {
+    const node = dialog.current;
+    if (node && !node.open && typeof node.showModal === "function") node.showModal();
+    const cancel = (event: Event) => { event.preventDefault(); onClose(); };
+    node?.addEventListener("cancel", cancel);
+    const previousOverflow = document.body.style.overflow;
+    document.body.style.overflow = "hidden";
+    return () => {
+      node?.removeEventListener("cancel", cancel);
+      document.body.style.overflow = previousOverflow;
+    };
+  }, [onClose]);
+
+  useEffect(() => {
+    const controller = new AbortController();
+    setLoading(true);
+    setError(undefined);
+    void getArtifactDetail(buildId, snapshotRef, controller.signal)
+      .then(setDetail)
+      .catch((reason) => {
+        if (!controller.signal.aborted) setError(errorMessage(reason));
+      })
+      .finally(() => {
+        if (!controller.signal.aborted) setLoading(false);
+      });
+    return () => controller.abort();
+  }, [buildId, snapshotRef, reloadKey]);
+
+  const table = detail?.scriptTable;
+  const context = detail?.artifactContext;
+  const candidate = context?.type === "candidate" || snapshotRef.startsWith("artifact:branch:");
+  const roundOutput = context?.type === "round" || snapshotRef.startsWith("artifact:round:");
+  const title = candidate
+    ? context?.roundIndex
+      ? `第 ${context.roundIndex} 轮 · 方案 ${context.branchId ?? "-"}`
+      : `候选方案 ${context?.branchId ?? snapshotRef.split(":").at(-1) ?? "-"}`
+    : roundOutput
+      ? `第 ${context?.roundIndex ?? snapshotRef.split(":").at(-1) ?? "-"} 轮 · 本轮产出`
+    : `Run #${buildId} · 当前保存版本`;
+  const versionNotice = candidate ? context ? candidateVersionNotice(context) : "正在读取候选版本信息" : roundOutput ? context?.note || "正在确认本轮产出版本" : "当前保存的最终主脚本";
+  const canRefresh = candidate && ["open", "parked"].includes(String(context?.branchStatus || "").toLowerCase());
+  const openElement = (id: number | string) => {
+    setSelectedElement(id);
+    setTab("elements");
+  };
+
+  return <dialog ref={dialog} className={`scriptViewer ${candidate ? "isCandidate" : "isFinal"}`} aria-labelledby="script-viewer-title" onWheelCapture={(event) => event.stopPropagation()}>
+    <header className="scriptViewerHeader">
+      <div className="scriptViewerTitle">
+        <span><FileText size={17} />{candidate ? "完整候选脚本" : roundOutput ? "本轮产出脚本表" : "当前最终主脚本"}{candidate && context?.branchStatus ? <StatusBadge status={context.branchStatus} /> : null}</span>
+        <h2 id="script-viewer-title">{title}</h2>
+        {table ? <p>{table.counts.paragraphs} 个段落 · {table.counts.elements} 个元素 · {table.counts.links} 条关联</p> : null}
+      </div>
+      <div className="scriptViewerHeaderActions">{canRefresh ? <button className="quietButton" type="button" aria-label="刷新当前候选脚本" onClick={() => setReloadKey((value) => value + 1)} disabled={loading}><RefreshCw size={15} /><span>刷新当前候选</span></button> : null}<button autoFocus className="iconButton" type="button" onClick={onClose} aria-label={candidate ? "关闭完整候选脚本" : "关闭完整主脚本"}><X size={20} /></button></div>
+    </header>
+    <div className="scriptViewerToolbar">
+      <TabList
+        className="scriptViewerTabs"
+        tabs={[{ id: "script", label: "创作脚本" }, { id: "elements", label: `脚本元素${table ? ` ${table.counts.elements}` : ""}` }]}
+        active={tab}
+        onChange={setTab}
+        ariaLabel={candidate ? "候选脚本内容分类" : "主脚本内容分类"}
+      />
+      <p className={`scriptViewerVersion ${context?.exactness === "historical-snapshot" ? "isHistorical" : "isCurrent"}`}>{versionNotice}</p>
+    </div>
+    <div className="scriptViewerBody">
+      {loading ? <div className="scriptViewerState"><div className="skeletonLines" aria-label="正在加载完整脚本表"><i /><i /><i /></div><p>{candidate ? "正在读取候选脚本…" : "正在读取当前主脚本…"}</p></div> : null}
+      {!loading && error ? <div className="scriptViewerState isError"><strong>{candidate ? "完整候选脚本暂时无法读取" : "完整主脚本暂时无法读取"}</strong><p>{error}</p></div> : null}
+      {!loading && !error && table && tab === "script" ? table.paragraphs.length
+        ? <ScriptTable paragraphs={table.paragraphs} elements={table.elements} onOpenElement={openElement} />
+        : <div className="scriptViewerState"><strong>{candidate ? "当前候选版本还没有段落" : "当前主脚本还没有段落"}</strong><p>可以切换到“脚本元素”查看已经保存的元素。</p></div>
+        : null}
+      {!loading && !error && table && tab === "elements" ? table.elements.length
+        ? <ScriptElementTable elements={table.elements} selectedId={selectedElement} />
+        : <div className="scriptViewerState"><strong>{candidate ? "当前候选版本还没有元素" : "当前主脚本还没有元素"}</strong><p>创作脚本段落仍可在另一个页签查看。</p></div>
+        : null}
+    </div>
+  </dialog>;
+}
+
+export function candidateVersionNotice(context: ArtifactDetailProjection["artifactContext"]) {
+  const status = String(context.branchStatus || "").toLowerCase();
+  if (context.exactness === "historical-snapshot") {
+    if (status === "discarded") return "未采用前冻结保存的候选版本";
+    if (status === "merged") return "采用前冻结保存的候选版本";
+    return "处置前冻结保存的候选版本";
+  }
+  if (status === "parked") return "根据当前主脚本和暂存改动组合;不是暂存当时的冻结历史";
+  if (status === "open") return "显示截至本次读取已写入的内容;方案仍可能继续更新";
+  return context.note || "根据当前主脚本和分支改动组合的候选版本";
+}

+ 26 - 0
visualization-old/frontend/components/artifact/ScriptElementTable.tsx

@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import type { ScriptElementRow } from "@/lib/types";
+
+export function ScriptElementTable({ elements, selectedId }: { elements: ScriptElementRow[]; selectedId?: number | string }) {
+  const selected = useRef<HTMLTableRowElement>(null);
+  useEffect(() => {
+    if (typeof selected.current?.scrollIntoView === "function") selected.current.scrollIntoView({ block: "center" });
+  }, [selectedId]);
+  return <div className="elementTableViewport" tabIndex={0} aria-label="可滚动的脚本元素表">
+    <table className="elementTable">
+      <caption className="srOnly">完整脚本元素表</caption>
+      <thead><tr><th>元素名称</th><th>一级维度</th><th>二级维度</th><th>使用段落</th></tr></thead>
+      <tbody>{elements.map((element) => {
+        const active = String(element.id) === String(selectedId);
+        return <tr key={String(element.rowId)} ref={active ? selected : undefined} className={active ? "isSelected" : ""}>
+          <th scope="row">{element.name}</th>
+          <td>{element.primaryDimension || "—"}</td>
+          <td>{element.secondaryDimension || "—"}</td>
+          <td>{element.paragraphs.length ? <ul className="elementParagraphs">{element.paragraphs.map((paragraph) => <li key={String(paragraph.id)}><span>{paragraph.index ? `第 ${paragraph.index} 段` : "段落"}</span>{paragraph.name}</li>)}</ul> : <span className="emptyValue">未关联段落</span>}</td>
+        </tr>;
+      })}</tbody>
+    </table>
+  </div>;
+}

+ 210 - 0
visualization-old/frontend/components/artifact/ScriptTable.tsx

@@ -0,0 +1,210 @@
+"use client";
+
+import { Fragment, useMemo, useState } from "react";
+import type {
+  ScriptDescriptionPart,
+  ScriptDimension,
+  ScriptElementRow,
+  ScriptFacet,
+  ScriptParagraphRow,
+} from "@/lib/types";
+
+const FACETS = [
+  ["theme", "主题", "theme", "主题描述"],
+  ["form", "形式", "form", "形式描述"],
+  ["function", "作用", "action", "作用描述"],
+  ["feeling", "感受", "feeling", "感受描述"],
+] as const;
+
+const PARA_COL_WIDTH = 100;
+const ATOM_TYPE_COL_WIDTH = 72;
+const ATOM_DIM_COL_WIDTH = 108;
+const ATOM_VAL_COL_WIDTH = 108;
+const OUTLINE_DESC_WIDTH = 155;
+const FIXED_COLS_WIDTH = 150 + (OUTLINE_DESC_WIDTH + ATOM_TYPE_COL_WIDTH + ATOM_DIM_COL_WIDTH + ATOM_VAL_COL_WIDTH) * 4 + 400;
+
+interface Props {
+  paragraphs: ScriptParagraphRow[];
+  elements: ScriptElementRow[];
+  onOpenElement: (id: number | string) => void;
+}
+
+export function ScriptTable({ paragraphs, elements, onOpenElement }: Props) {
+  const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
+  const maxDepth = Math.max(1, ...paragraphs.map((row) => Number(row.depth || 0) + 1));
+  const elementLookup = useMemo(() => buildElementLookup(elements), [elements]);
+  const hiddenIds = useMemo(() => descendantIds(paragraphs, collapsed), [paragraphs, collapsed]);
+  const rowsWithChildren = useMemo(() => {
+    const parents = new Set(paragraphs.map((row) => row.parentId).filter((value) => value != null).map(String));
+    return new Set(paragraphs.filter((row) => parents.has(String(row.id)) || parents.has(String(row.rowId))).map((row) => String(row.id)));
+  }, [paragraphs]);
+
+  const toggle = (id: number | string) => setCollapsed((current) => {
+    const next = new Set(current);
+    const key = String(id);
+    if (next.has(key)) next.delete(key);
+    else next.add(key);
+    return next;
+  });
+
+  return <section className="originalScriptTable" aria-label="完整脚本创作表">
+    <div className="scriptTableTitle">
+      <strong>创作脚本</strong>
+      <span className="script-legend"><span className="legend-item"><i className="legend-swatch" />脚本元素</span></span>
+    </div>
+    <div className="script-table-wrapper" tabIndex={0} aria-label="可横向和纵向滚动的脚本创作表">
+      <table className="script-table" style={{ width: maxDepth * PARA_COL_WIDTH + FIXED_COLS_WIDTH }}>
+        <caption className="srOnly">完整脚本创作表</caption>
+        <colgroup>
+          {Array.from({ length: maxDepth }, (_, index) => <col key={`paragraph-${index}`} style={{ width: PARA_COL_WIDTH }} />)}
+          <col style={{ width: 150 }} />
+          {FACETS.flatMap(([key]) => [
+            <col key={`${key}-description`} style={{ width: OUTLINE_DESC_WIDTH }} />,
+            <col key={`${key}-type`} style={{ width: ATOM_TYPE_COL_WIDTH }} />,
+            <col key={`${key}-name`} style={{ width: ATOM_DIM_COL_WIDTH }} />,
+            <col key={`${key}-value`} style={{ width: ATOM_VAL_COL_WIDTH }} />,
+          ])}
+          <col style={{ width: 400 }} />
+        </colgroup>
+        <TableHeader maxDepth={maxDepth} />
+        <tbody>{paragraphs.map((paragraph) => hiddenIds.has(String(paragraph.id)) ? null : <tr key={String(paragraph.rowId)} className={`script-row-l${Number(paragraph.depth || 0) + 1}`}>
+          {Array.from({ length: maxDepth }, (_, columnIndex) => {
+            const isOwnLevel = columnIndex === Number(paragraph.depth || 0);
+            const isLast = columnIndex === maxDepth - 1;
+            return <td key={columnIndex} className={`col-paragraph${isLast ? " col-paragraph-last" : ""}`} style={{ left: columnIndex * PARA_COL_WIDTH }}>
+              {isOwnLevel ? <>
+                {rowsWithChildren.has(String(paragraph.id)) ? <button type="button" className={`collapse-toggle${collapsed.has(String(paragraph.id)) ? " collapsed" : ""}`} aria-label={`${collapsed.has(String(paragraph.id)) ? "展开" : "折叠"}${paragraph.name}`} aria-expanded={!collapsed.has(String(paragraph.id))} onClick={() => toggle(paragraph.id)}>{collapsed.has(String(paragraph.id)) ? "▶" : "▼"}</button> : null}
+                {paragraph.name || "未命名"}
+              </> : null}
+            </td>;
+          })}
+          <td className="col-range"><div className="content-range-tags">{paragraph.contentRange.map((tag, index) => <span className="cr-tag" key={`${tag}-${index}`}>{tag}</span>)}</div></td>
+          {FACETS.map(([key, , section]) => <FacetCells key={key} section={section} facet={paragraph.sections[key]} elementLookup={elementLookup} onOpenElement={onOpenElement} />)}
+          <td className="col-full-desc"><ExpandableDescription parts={paragraph.fullDescriptionParts} fallback={paragraph.fullDescription} elementLookup={elementLookup} onOpenElement={onOpenElement} /></td>
+        </tr>)}</tbody>
+      </table>
+    </div>
+  </section>;
+}
+
+function TableHeader({ maxDepth }: { maxDepth: number }) {
+  return <thead>
+    <tr className="st-header-group">
+      <th colSpan={maxDepth} className="col-paragraph" style={{ left: 0 }}>段落</th>
+      <th rowSpan={3} className="col-range">段落范围</th>
+      <th colSpan={16} className="col-outline">创作目录大纲(段本身)</th>
+      <th rowSpan={3} className="col-full-desc">完整描述</th>
+    </tr>
+    <tr className="st-header-subgroup">
+      {Array.from({ length: maxDepth }, (_, index) => <th key={index} rowSpan={2} className={`col-paragraph${index === maxDepth - 1 ? " col-paragraph-last" : ""}`} style={{ left: index * PARA_COL_WIDTH }}>L{index + 1}</th>)}
+      {FACETS.map(([, label, section]) => <th key={section} colSpan={4} className={`col-outline col-outline-${section}`}>{label}</th>)}
+    </tr>
+    <tr className="st-header-col">
+      {FACETS.map(([, , section, description]) => <Fragment key={section}>
+        <th className={`col-outline col-outline-${section}`}>{description}</th>
+        <th className={`col-outline col-outline-${section} col-atom-sub col-atom-type`}>维度类型</th>
+        <th className={`col-outline col-outline-${section} col-atom-sub col-atom-dim`}>维度名</th>
+        <th className={`col-outline col-outline-${section} col-atom-sub col-atom-val`}>维度值</th>
+      </Fragment>)}
+    </tr>
+  </thead>;
+}
+
+function FacetCells({ section, facet, elementLookup, onOpenElement }: { section: string; facet: ScriptFacet; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
+  const dimensions = sortDimensions(facet.dimensions, section === "feeling");
+  return <>
+    <td className={`col-outline col-outline-${section}`}><ElementText parts={facet.descriptionParts} fallback={facet.description} elementLookup={elementLookup} onOpenElement={onOpenElement} /></td>
+    {dimensions.length ? <td colSpan={3} className={`col-outline col-outline-${section} col-atom-group col-atom-group-${section}`}>
+      <table className="atom-rows-table">
+        <colgroup><col style={{ width: ATOM_TYPE_COL_WIDTH }} /><col style={{ width: ATOM_DIM_COL_WIDTH }} /><col style={{ width: ATOM_VAL_COL_WIDTH }} /></colgroup>
+        <tbody>{dimensions.map((dimension, index) => {
+          const dimClass = dimensionClass(dimension.type, section === "feeling");
+          return <tr key={`${dimension.id ?? "dimension"}-${index}`}>
+            <td className={`col-atom-type${dimClass ? ` ${dimClass}` : ""}`}>{section === "feeling" ? "—" : dimensionTypeLabel(dimension.type)}</td>
+            <td className={`col-atom-dim${dimClass ? ` ${dimClass}` : ""}`}>{dimension.name || "—"}</td>
+            <td className="col-atom-val"><span className={dimension.id != null ? "atom-val-with-id" : undefined} title={dimension.id != null ? `id: ${dimension.id}` : undefined}>{dimension.value || "—"}</span></td>
+          </tr>;
+        })}</tbody>
+      </table>
+    </td> : <td colSpan={3} className={`col-outline col-outline-${section} col-atom-group col-atom-group-${section}`} />}
+  </>;
+}
+
+function ExpandableDescription({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
+  const [expanded, setExpanded] = useState(false);
+  return <div className={`cell-expandable${expanded ? " expanded" : ""}`} role="button" tabIndex={0} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)} onKeyDown={(event) => {
+    if (event.key === "Enter" || event.key === " ") {
+      event.preventDefault();
+      setExpanded((value) => !value);
+    }
+  }}><ElementText parts={parts} fallback={fallback} elementLookup={elementLookup} onOpenElement={onOpenElement} /></div>;
+}
+
+function ElementText({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
+  if (!parts.length) return <>{highlightPlainText(fallback || "", elementLookup.names)}</>;
+  return <>{parts.map((part, index) => part.type === "text"
+    ? <Fragment key={index}>{highlightPlainText(part.text, elementLookup.names)}</Fragment>
+    : part.resolved
+      ? <button key={index} type="button" className="elem-hl-extended" title="在脚本元素中查看" onClick={(event) => { event.stopPropagation(); onOpenElement(part.id); }}>{part.name}</button>
+      : <span key={index}>{part.text}</span>)}</>;
+}
+
+type ElementLookup = { names: string[] };
+
+function buildElementLookup(elements: ScriptElementRow[]): ElementLookup {
+  return { names: [...new Set(elements.map((element) => String(element.name || "").trim()).filter(Boolean))].sort((a, b) => b.length - a.length) };
+}
+
+function highlightPlainText(text: string, names: string[]) {
+  if (!text || !names.length) return text || "";
+  const pattern = names.map(escapeRegExp).join("|");
+  if (!pattern) return text;
+  const matcher = new RegExp(`(${pattern})`, "g");
+  return text.split(matcher).map((part, index) => names.includes(part) ? <span key={index} className="elem-hl-extended">{part}</span> : <Fragment key={index}>{part}</Fragment>);
+}
+
+function escapeRegExp(value: string) {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function sortDimensions(dimensions: ScriptDimension[], isFeeling: boolean) {
+  if (isFeeling) return [...dimensions];
+  const rank: Record<string, number> = { "主维度": 0, "从维度": 1, "子元素": 2 };
+  return [...dimensions].sort((left, right) => {
+    const typeDiff = (rank[String(left.type || "")] ?? 99) - (rank[String(right.type || "")] ?? 99);
+    if (typeDiff) return typeDiff;
+    const nameDiff = String(left.name || "").localeCompare(String(right.name || ""), "zh-CN");
+    return nameDiff || String(left.value || "").localeCompare(String(right.value || ""), "zh-CN");
+  });
+}
+
+function dimensionTypeLabel(value?: string | null) {
+  if (value === "主维度") return "主";
+  if (value === "从维度") return "从";
+  return value || "—";
+}
+
+function dimensionClass(value: string | null | undefined, isFeeling: boolean) {
+  if (isFeeling) return "";
+  if (value === "主维度") return "atom-dim-main";
+  if (value === "从维度") return "atom-dim-sub";
+  return "";
+}
+
+function descendantIds(paragraphs: ScriptParagraphRow[], collapsed: Set<string>) {
+  const hidden = new Set<string>();
+  let changed = true;
+  while (changed) {
+    changed = false;
+    for (const row of paragraphs) {
+      const parent = row.parentId == null ? null : String(row.parentId);
+      if (!parent || (!collapsed.has(parent) && !hidden.has(parent))) continue;
+      const key = String(row.id);
+      if (!hidden.has(key)) {
+        hidden.add(key);
+        changed = true;
+      }
+    }
+  }
+  return hidden;
+}

+ 60 - 0
visualization-old/frontend/components/decision/AgentDecisionCard.tsx

@@ -0,0 +1,60 @@
+import { ArrowRight, FileText } from "lucide-react";
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import type { AgentDecisionProjection, CanvasItem } from "@/lib/types";
+import { CardFields } from "@/components/flow/CardFields";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+
+export function AgentDecisionCard({ data }: NodeProps) {
+  const node = data as unknown as CanvasNodeData;
+  const item = node.item;
+  if (!item) return null;
+  const decision = decisionFor(item);
+  if (!decision) return null;
+  return <article
+    className={`flowCard agentDecisionCard decision-${decision.decisionType}`}
+    data-focus-return={`${decision.id}:card`}
+    tabIndex={0}
+    aria-label={`${decision.actor.label}:${item.title}`}
+    onKeyDown={(event) => {
+      if (event.target !== event.currentTarget) return;
+      if (event.key === "Enter" || event.key === " ") {
+        event.preventDefault();
+        node.onOpen(item, "business");
+      }
+    }}
+  >
+    <Handle type="target" position={Position.Left} className="flowHandle" />
+    <Handle type="source" position={Position.Right} className="flowHandle" />
+    <header className="cardHeader">
+      <div className="cardIdentity">
+        <span className={`actorBadge actor-${actorTone(decision)}`}>{decision.actor.label}</span>
+        <span className="storyRole">{decisionTypeLabel(decision.decisionType)}</span>
+      </div>
+      <span className={`authorityBadge authority-${decision.authority}`}>{authorityLabel(decision.authority)}</span>
+    </header>
+    <h3>{item.title}</h3>
+    <CardFields card={decision.card} />
+    <footer className="cardFooter">
+      <button type="button" className="nodeAction nodrag nopan" onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>{item.itemType === "story" && item.role === "objective" ? "查看详情" : "查看决策"}<ArrowRight size={14} /></button>
+      <SourceComparisonButton item={item} onOpen={node.onOpen} />
+      {item.itemType === "story" && item.artifactRef ? <button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "artifact"); }}><FileText size={14} />查看完整候选脚本</button> : null}
+    </footer>
+  </article>;
+}
+
+export function decisionFor(item: CanvasItem): AgentDecisionProjection | undefined {
+  if (item.itemType === "story") return item.decision;
+  if (item.itemType === "multipath-review") return item.review.decisionProjection;
+  if (item.itemType === "multipath-decision") return item.decision.decisionProjection;
+  return undefined;
+}
+
+function actorTone(decision: AgentDecisionProjection) {
+  if (decision.actor.role === "main") return "main";
+  if (decision.actor.role === "implementer") return "implementer";
+  return "evaluator";
+}
+
+export const authorityLabel = (authority: AgentDecisionProjection["authority"]) => ({ final: "最终决定", recommendation: "评审建议", "implementation-scope": "本方案内有效" } as const)[authority];
+export const decisionTypeLabel = (type: AgentDecisionProjection["decisionType"]) => ({ direction: "方向决定", tradeoff: "取舍决定", evaluation: "评审判断", creative: "创作处理" } as const)[type];

+ 172 - 0
visualization-old/frontend/components/decision/DecisionDetailRenderer.tsx

@@ -0,0 +1,172 @@
+import type { DecisionDetailBlock, InspectorPresentation, InspectorSemanticMeta, InspectorVisualRole } from "@/lib/types";
+import { RichText } from "@/components/ui/RichText";
+import { ChevronRight } from "lucide-react";
+
+export function DecisionDetailRenderer({ blocks }: { blocks: DecisionDetailBlock[] }) {
+  return <div className="decisionDetailBlocks">
+    {blocks.map((block, index) => {
+      if (block.type === "implementation-plan") return <ImplementationPlan key={`${block.title}-${index}`} block={block} />;
+      if (block.type === "creative-process") return <CreativeProcess key={`${block.title}-${index}`} block={block} />;
+      if (block.type === "evaluation-branches") return <EvaluationBranches key={`${block.title}-${index}`} block={block} />;
+      if (block.type === "evaluation-matrix") return <EvaluationMatrix key={`${block.title}-${index}`} block={block} />;
+      if (block.type === "comparison") return <section {...panelProps(block, "comparisonPanel", "section", "comparison")} key={`${block.title}-${index}`}><h3>{block.title}</h3><Comparison rows={block.rows} /></section>;
+      if (block.type === "items" && block.collapsible) return <CollapsibleItems key={`${block.title}-${index}`} block={block} />;
+      if (block.type === "items") return <section {...panelProps(block, "decisionItemsPanel", "section", "list")} key={`${block.title}-${index}`}><h3>{block.title}</h3><Items items={block.items} /></section>;
+      if (block.type === "notice") return <section {...panelProps(block, `decisionNotice is-${block.level}`, "notice", "prose")} key={`${block.title || "notice"}-${index}`}><h3>{block.title || "记录说明"}</h3><RichText value={block.value} /></section>;
+      if (block.type === "artifact") return <section {...panelProps(block, "", "section", "artifact")} key={`${block.title}-${index}`}><h3>{block.title}</h3><ul>{block.refs.map((ref) => <li key={ref.artifactRef}>{ref.label}</li>)}</ul></section>;
+      return <SummaryBlock key={`${block.title}-${index}`} block={block} />;
+    })}
+  </div>;
+}
+
+type CreativeProcessBlock = Extract<DecisionDetailBlock, { type: "creative-process" }>;
+
+function CreativeProcess({ block }: { block: CreativeProcessBlock }) {
+  return <section {...panelProps(block, "creativeProcessPanel", "section", "process")}>
+    <header className="creativeProcessHeader"><div><h3>{block.title}</h3><p>{block.steps.length} 个真实执行步骤</p></div></header>
+    <ol className="creativeProcessSteps">
+      {block.steps.map((step) => <li key={`${step.eventRef || "step"}-${step.stepIndex}`}>
+        <header><span>步骤 {String(step.stepIndex).padStart(2, "0")}</span><strong>{step.action}</strong></header>
+        {step.summary && step.summary !== step.action ? <p className="creativeStepSummary">{step.summary}</p> : null}
+        {step.plan ? <div className="creativeStepPlan"><small>执行计划</small><ReadableDecisionText value={step.plan} /></div> : null}
+        {step.reasoning ? <details className="creativeStepReasoning"><summary><ChevronRight aria-hidden="true" size={15} />查看本步思考依据</summary><ReadableDecisionText value={step.reasoning} /></details> : null}
+      </li>)}
+    </ol>
+  </section>;
+}
+
+type EvaluationBranchesBlock = Extract<DecisionDetailBlock, { type: "evaluation-branches" }>;
+
+function EvaluationBranches({ block }: { block: EvaluationBranchesBlock }) {
+  return <section {...panelProps(block, "evaluationBranchesPanel", "section", "grouped-review")}>
+    <h3>{block.title}</h3>
+    <div className="evaluationBranchList">{block.branches.map((branch) => <article key={branch.subject}>
+      <header><h4>{branch.subject}</h4>{branch.conclusion ? <span>{branch.conclusion}</span> : null}</header>
+      {branch.achievements?.length ? <EvaluationList title="已达成" items={branch.achievements} tone="success" /> : null}
+      {branch.problems?.length ? <EvaluationList title="需关注" items={branch.problems} tone="warning" /> : null}
+      {branch.evidence ? <div className="evaluationEvidence"><strong>有据扎实度</strong><ReadableDecisionText value={branch.evidence} /></div> : null}
+    </article>)}</div>
+  </section>;
+}
+
+function EvaluationList({ title, items, tone }: { title: string; items: string[]; tone: "success" | "warning" }) {
+  return <div className={`evaluationList is-${tone}`}><strong>{title}</strong><ul>{items.map((item, index) => <li key={`${title}-${index}`}><ReadableDecisionText value={item} /></li>)}</ul></div>;
+}
+
+type EvaluationMatrixBlock = Extract<DecisionDetailBlock, { type: "evaluation-matrix" }>;
+
+function EvaluationMatrix({ block }: { block: EvaluationMatrixBlock }) {
+  const subjects = block.rows[0]?.candidates.map((item) => item.subject) || [];
+  return <section {...panelProps(block, "evaluationMatrixPanel", "section", "matrix")}>
+    <h3>{block.title}</h3>
+    <div className="evaluationMatrixScroll"><table><thead><tr><th scope="col">评审标准</th>{subjects.map((subject) => <th scope="col" key={subject}>{subject}</th>)}<th scope="col">差异依据</th></tr></thead><tbody>{block.rows.map((row) => <tr key={row.criterion}><th scope="row">{row.criterion}</th>{subjects.map((subject) => <td key={subject}>{row.candidates.find((item) => item.subject === subject)?.value || "未记录"}</td>)}<td>{row.basis || "未记录"}</td></tr>)}</tbody></table></div>
+  </section>;
+}
+
+type ImplementationPlanBlock = Extract<DecisionDetailBlock, { type: "implementation-plan" }>;
+
+function ImplementationPlan({ block }: { block: ImplementationPlanBlock }) {
+  return <section {...panelProps(block, "implementationPlanPanel", "section", "plan")}>
+    <header className="implementationPlanHeader">
+      <div><h3>{block.title}</h3><p>{block.routes.length} 路实现方案</p></div>
+      {block.mode ? <span>{block.mode}</span> : null}
+    </header>
+    <ol className="implementationRoutes">
+      {block.routes.map((route) => <li key={route.pathIndex}>
+        <header className="implementationRouteHeader">
+          <strong>路线 {route.pathIndex}</strong>
+          <span className="implementationRouteTags">{route.action ? <b>{route.action}</b> : null}{route.pathType ? <b>{route.pathType}</b> : null}</span>
+        </header>
+        <div className="implementationRouteBody">
+          {route.target ? <PlanField label="作用范围" value={route.target} wide /> : null}
+          {route.method ? <PlanField label="实施方法" value={route.method} /> : null}
+          {route.emphasis ? <PlanField label="路线侧重" value={route.emphasis} /> : null}
+        </div>
+      </li>)}
+    </ol>
+    {block.reasoning ? <div className="implementationReasoning"><h4>为什么这样规划</h4><ReadableDecisionText value={block.reasoning} /></div> : null}
+  </section>;
+}
+
+function PlanField({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
+  return <div className={`implementationPlanField ${wide ? "is-wide" : ""}`}><small>{label}</small><ReadableDecisionText value={value} /></div>;
+}
+
+type ItemsBlock = Extract<DecisionDetailBlock, { type: "items" }>;
+
+function CollapsibleItems({ block }: { block: ItemsBlock }) {
+  return <section {...panelProps(block, "decisionDisclosurePanel", "evidence", "list")}>
+    <details className="decisionDisclosure" open={block.defaultOpen || undefined}>
+      <summary>
+        <span className="decisionDisclosureLabel"><ChevronRight aria-hidden="true" size={16} strokeWidth={2.25} /><strong>{block.title}</strong></span>
+        <span className="decisionDisclosureCount">共 {block.items.length} 项</span>
+      </summary>
+      <div className="decisionDisclosureBody"><Items items={block.items} /></div>
+    </details>
+  </section>;
+}
+
+type SummaryDetailBlock = Extract<DecisionDetailBlock, { type: "summary" }>;
+
+function SummaryBlock({ block }: { block: SummaryDetailBlock }) {
+  const value = block.presentation === "document" ? normalizeDocumentMarkdown(block.value) : block.value;
+  return <section {...panelProps(block, "decisionSummaryPanel", "section", "prose")}>
+    <h3>{block.title}</h3>
+    <ReadableDecisionText value={value} />
+  </section>;
+}
+
+function normalizeDocumentMarkdown(value: string) {
+  return value.replace(/\*\*(["“])([^"”\n]+)(["”])\*\*/g, "$1**$2**$3");
+}
+
+function panelProps(meta: InspectorSemanticMeta, extraClass: string, fallbackRole: InspectorVisualRole, fallbackPresentation: InspectorPresentation) {
+  const role = meta.visualRole || fallbackRole;
+  return {
+    className: ["inspectorPanel", extraClass, `is-role-${role}`].filter(Boolean).join(" "),
+    "data-visual-role": role,
+    "data-presentation": meta.presentation || fallbackPresentation,
+  };
+}
+
+function Items({ items }: { items: Array<string | Record<string, unknown>> }) {
+  return <dl className="inspectorDetailItems">{items.map((item, index) => {
+    if (typeof item === "string") return <div className="inspectorDetailItem" key={`${item}-${index}`}><dt>{String(index + 1).padStart(2, "0")}</dt><dd><ReadableDecisionText value={item} /></dd></div>;
+    const severity = item.severity ? String(item.severity) : undefined;
+    const label = item.label ? String(item.label)
+      : item.subject ? String(item.subject)
+        : severity ? `问题 · ${severity}`
+          : `项目 ${index + 1}`;
+    const value = String(item.value || item.conclusion || item.summary || "");
+    const note = item.note ? String(item.note) : undefined;
+    const achievements = stringList(item.achievements);
+    const problems = stringList(item.problems);
+    return <div className="inspectorDetailItem" key={`${label}-${index}`}><dt>{label}</dt><dd>{value ? <ReadableDecisionText value={value} /> : null}{note ? <small>{note}</small> : null}{achievements.length ? <NestedList title="已达成" items={achievements} /> : null}{problems.length ? <NestedList title="需关注" items={problems} /> : null}</dd></div>;
+  })}</dl>;
+}
+
+function ReadableDecisionText({ value }: { value: string }) {
+  return <RichText value={insertNumberedLineBreaks(value)} className="decisionReadableText" />;
+}
+
+function insertNumberedLineBreaks(value: string) {
+  const markers = [...value.matchAll(/[ \t\u3000]+(\d+)\.(?=[ \t\u3000]+\S)/g)];
+  if (markers.length < 2) return value;
+  const numbers = markers.map((match) => Number(match[1]));
+  if (numbers[0] !== 1 || numbers.some((number, index) => index > 0 && number !== numbers[index - 1] + 1)) return value;
+  let markerIndex = 0;
+  return value.replace(/[ \t\u3000]+(\d+\.(?=[ \t\u3000]+\S))/g, (_match, marker: string) => `${markerIndex++ === 0 ? "\n\n" : "\n"}${marker}`);
+}
+
+function Comparison({ rows }: { rows: Array<Record<string, unknown>> }) {
+  const showHandling = rows.some((row) => Boolean(row.handling));
+  return <div className={`decisionComparison ${showHandling ? "hasHandling" : ""}`}><div className="comparisonHeader"><b>候选方案</b><b>评审建议</b><b>最终决定</b>{showHandling ? <b>对建议的处理</b> : null}</div>{rows.map((row, index) => <div className="comparisonRow" key={`${row.candidate || index}`}><strong>{String(row.candidate || `方案 ${index + 1}`)}</strong><span>{String(row.recommendation || "未记录")}</span><span>{String(row.finalDecision || "未记录")}</span>{showHandling ? <span>{String(row.handling || "未明确说明")}</span> : null}</div>)}</div>;
+}
+
+function stringList(value: unknown): string[] {
+  return Array.isArray(value) ? value.map((item) => typeof item === "string" ? item : String((item as Record<string, unknown>)?.summary || "")).filter(Boolean) : [];
+}
+
+function NestedList({ title, items }: { title: string; items: string[] }) {
+  return <div className="decisionNestedList"><strong>{title}</strong><ul>{items.map((item, index) => <li key={`${item}-${index}`}><RichText value={item} /></li>)}</ul></div>;
+}

+ 31 - 0
visualization-old/frontend/components/decision/PromptDrawer.tsx

@@ -0,0 +1,31 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { X } from "lucide-react";
+import type { PromptProjection } from "@/lib/types";
+import { RichText } from "@/components/ui/RichText";
+
+export function PromptDrawer({ prompt, loading, error, onClose }: { prompt?: PromptProjection; loading: boolean; error?: string; onClose: () => void }) {
+  const close = useRef<HTMLButtonElement>(null);
+  const dialog = useRef<HTMLElement>(null);
+  useEffect(() => { close.current?.focus(); }, []);
+  useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [onClose]);
+  const trapFocus = (event: React.KeyboardEvent<HTMLElement>) => {
+    if (event.key !== "Tab") return;
+    const focusable = [...(dialog.current?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])') || [])];
+    if (!focusable.length) return;
+    const first = focusable[0]; const last = focusable[focusable.length - 1];
+    if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
+    else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
+  };
+  return <aside ref={dialog} className="promptDrawer" role="dialog" aria-modal="true" aria-label="Agent 任务与提示词" onKeyDown={trapFocus} onWheelCapture={(event) => event.stopPropagation()}>
+    <header><div><span>Agent 提示词</span><h2>{prompt?.actor.label || "Agent"}</h2></div><button ref={close} className="iconButton" type="button" aria-label="关闭提示词" onClick={onClose}><X size={18} /></button></header>
+    <div className="promptDrawerBody">
+      {loading ? <div className="skeletonLines"><i /><i /><i /></div> : null}
+      {error ? <section className="inspectorPanel isMissing"><h3>暂时无法读取</h3><p>{error}</p></section> : null}
+      {prompt?.notices.map((notice) => <section className="inspectorPanel promptAccuracy" key={notice.code}><h3>准确性说明</h3><p>{notice.message}</p></section>)}
+      {prompt?.exactTask ? <section className="inspectorPanel"><h3>本次真实任务</h3><RichText value={prompt.exactTask.content} /></section> : null}
+      {prompt?.systemPrompt ? <section className="inspectorPanel currentPrompt"><h3>当前提示词</h3><p className="promptMeta">{prompt.systemPrompt.source === "current-db" ? "当前数据库版本" : "当前代码文件"}{prompt.systemPrompt.version ? ` · v${prompt.systemPrompt.version}` : ""}</p><RichText value={prompt.systemPrompt.content} /></section> : null}
+    </div>
+  </aside>;
+}

+ 10 - 0
visualization-old/frontend/components/flow/AgentRegion.tsx

@@ -0,0 +1,10 @@
+import type { NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+
+export function AgentRegion({ data }: NodeProps) {
+  const region = (data as unknown as CanvasNodeData).region;
+  if (!region) return null;
+  return <section className={`agentRegion region-${region.tone}`} aria-hidden="true">
+    <header><strong>{region.label}</strong><span>{region.description}</span></header>
+  </section>;
+}

+ 22 - 0
visualization-old/frontend/components/flow/BranchFrame.tsx

@@ -0,0 +1,22 @@
+import { ArrowRight, ChevronUp, GitBranch } from "lucide-react";
+import type { NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { StatusBadge } from "@/components/ui/Badges";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+
+export function BranchFrame({ data }: NodeProps) {
+  const node = data as unknown as CanvasNodeData;
+  const item = node.item;
+  if (!item || item.itemType !== "branch-summary") return null;
+  return <section className="branchFrame" aria-label={`方案 ${item.branchId} 实现 Agent 展开流程`}>
+    <header>
+      <div className="branchFrameTitle"><span><GitBranch size={14} />实现 Agent</span><strong>方案 {item.branchId}</strong></div>
+      <StatusBadge status={item.branch.status} />
+      <div className="branchFrameActions">
+        <button type="button" className="nodeAction nodrag nopan" onClick={(event) => { event.stopPropagation(); node.onToggleBranch(item.roundIndex, item.branchId); }}><ChevronUp size={14} />收起方案过程</button>
+        {item.detailRef ? <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>查看方案汇总<ArrowRight size={14} /></button> : null}
+        <SourceComparisonButton item={item} onOpen={node.onOpen} />
+      </div>
+    </header>
+  </section>;
+}

+ 31 - 0
visualization-old/frontend/components/flow/CandidateCard.tsx

@@ -0,0 +1,31 @@
+import { ArrowRight, BookOpenCheck, ChevronDown, ChevronUp, FileText, GitBranch } from "lucide-react";
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { SourceNoticeBadge, StatusBadge } from "@/components/ui/Badges";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+
+export function CandidateCard({ data }: NodeProps) {
+  const { item, onOpen, onToggleBranch } = data as unknown as CanvasNodeData;
+  if (!item || item.itemType !== "branch-summary") return null;
+  const summary = item.branch.summary;
+  return <article className="flowCard candidateCard" data-focus-return={`${item.id}:card`} aria-label={`候选方案:${item.branch.title}`} tabIndex={0} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpen(item, "business"); } }}>
+    <Handle type="target" position={Position.Left} className="flowHandle" /><Handle type="source" position={Position.Right} className="flowHandle" />
+    <header className="cardHeader"><span className="candidateLabel">{item.branch.pathType === "领域信息" ? <BookOpenCheck size={14} /> : <GitBranch size={14} />}实现 Agent · 方案 {item.branchId} · {item.branch.pathType === "unknown" ? "类型未记录" : item.branch.pathType}</span><StatusBadge status={item.branch.status} /></header>
+    {item.branch.title !== `方案 ${item.branchId}` ? <h3>{item.branch.title}</h3> : null}
+    <div className="candidateSummary">
+      <SummaryRow label="任务" value={summary.task} />
+      <SummaryRow label={item.branch.pathType === "领域信息" ? "领域事实" : "候选产出"} value={summary.output} />
+    </div>
+    <footer className="cardFooter">
+      <button type="button" className="nodeAction nodrag nopan" onClick={(event) => { event.stopPropagation(); onToggleBranch(item.roundIndex, item.branchId); }}>{data.expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}{data.expanded ? "收起方案过程" : "展开方案过程"}</button>
+      {item.detailRef ? <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}>查看方案汇总<ArrowRight size={14} /></button> : null}
+      <SourceComparisonButton item={item} onOpen={onOpen} />
+      {item.branch.output.type === "script-artifact" && item.branch.output.snapshotRef ? <button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); onOpen(item, "artifact"); }}><FileText size={14} />查看完整候选脚本</button> : null}
+    </footer>
+    <SourceNoticeBadge notice={item.branch.outcome.sourceNotice || item.branch.task.sourceNotice || item.branch.output.node.sourceNotice} />
+  </article>;
+}
+
+function SummaryRow({ label, value }: { label: string; value?: string | null }) {
+  return <div><b>{label}</b><p>{value || "未记录"}</p></div>;
+}

+ 26 - 0
visualization-old/frontend/components/flow/CardFields.tsx

@@ -0,0 +1,26 @@
+import type { CardProjection } from "@/lib/types";
+
+interface Props {
+  card: CardProjection;
+  className?: string;
+}
+
+/**
+ * Shared business-card field hierarchy for both the canvas and mobile timeline.
+ * The backend owns field selection and ordering; this component only renders it.
+ */
+export function CardFields({ card, className = "" }: Props) {
+  if (!card.primary && !card.secondary.length) return null;
+  const classes = ["cardFields", className].filter(Boolean).join(" ");
+  return <div className={classes}>
+    {card.primary ? <CardField label={card.primary.label} value={card.primary.value} primary /> : null}
+    {card.secondary.map((field) => <CardField key={field.key} label={field.label} value={field.value} tone={field.tone} />)}
+  </div>;
+}
+
+function CardField({ label, value, primary = false, tone = "normal" }: { label?: string; value: string; primary?: boolean; tone?: "normal" | "success" | "warning" | "failure" }) {
+  return <div className={`cardField ${primary ? "cardFieldPrimary" : "cardFieldSecondary"} tone-${tone}`}>
+    {label ? <b>{label}</b> : null}
+    <p>{value}</p>
+  </div>;
+}

+ 27 - 0
visualization-old/frontend/components/flow/DirectToolGroupCard.tsx

@@ -0,0 +1,27 @@
+import { useRef } from "react";
+import { ArrowRight, Database } from "lucide-react";
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+import { useRetrievalHoverPreview } from "@/components/flow/RetrievalHoverPreview";
+
+export function DirectToolGroupCard({ data }: NodeProps) {
+  const node = data as unknown as CanvasNodeData;
+  const item = node.item;
+  if (!item || item.itemType !== "retrieval-direct") return null;
+  return <DirectToolGroupCardContent node={node} item={item} />;
+}
+
+function DirectToolGroupCardContent({ node, item }: { node: CanvasNodeData; item: Extract<NonNullable<CanvasNodeData["item"]>, { itemType: "retrieval-direct" }> }) {
+  const cardRef = useRef<HTMLElement>(null);
+  const hoverPreview = useRetrievalHoverPreview({ anchorRef: cardRef, buildId: node.buildId, item, onOpenDetail: () => node.onOpen(item, "business") });
+  const sourceText = item.group.sources.map((source) => source.businessLabel).join("、") || "相关信息";
+  return <><article ref={cardRef} className="flowCard retrievalCard directToolCard" tabIndex={0} data-focus-return={`${item.id}:card`} {...hoverPreview.anchorProps} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); node.onOpen(item, "business"); } }}>
+    <Handle type="target" position={Position.Left} className="flowHandle" />
+    <Handle type="source" position={Position.Right} className="flowHandle" />
+    <header><span className="retrievalKind"><Database size={14} />工具取数</span><span className="retrievalOwner">实现 Agent 直接读取</span></header>
+    <h3>{sourceText}</h3>
+    <dl className="retrievalStats"><div><dt>读取</dt><dd>{item.group.callCount} 次</dd></div><div><dt>结果</dt><dd>{item.group.failureCount ? `${item.group.failureCount} 次失败` : item.group.interruptedCount ? `${item.group.interruptedCount} 次中断` : item.group.runningCount ? "进行中" : "已完成"}</dd></div></dl>
+    <footer><button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>查看读取内容<ArrowRight size={14} /></button><SourceComparisonButton item={item} onOpen={node.onOpen} /></footer>
+  </article>{hoverPreview.preview}</>;
+}

+ 13 - 0
visualization-old/frontend/components/flow/QueryAttemptList.tsx

@@ -0,0 +1,13 @@
+import type { CanvasItem, QueryAttempt } from "@/lib/types";
+import { queryAttemptItem } from "@/lib/layout";
+
+const statusText = { hit: "命中", empty: "无结果", failure: "失败", running: "进行中", interrupted: "构建中断", unknown: "状态未知" } as const;
+
+export function QueryAttemptList({ id, attempts, roundIndex, branchId, onOpen }: { id?: string; attempts: QueryAttempt[]; roundIndex: number; branchId: number; onOpen: (item: CanvasItem, target: "business") => void }) {
+  return <ol id={id} className="queryAttemptList">
+    {attempts.map((attempt, index) => {
+      const item = queryAttemptItem(attempt, roundIndex, branchId);
+      return <li key={attempt.id}><button type="button" className="nodrag nopan" data-focus-return={`${item.id}:query`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}><span className="queryIndex">{String(index + 1).padStart(2, "0")}</span><span className="queryText">{attempt.queryLabel}</span><span className={`queryStatus query-${attempt.status}`}>{attempt.resultCount && attempt.resultCount > 0 ? `${attempt.resultCount} 条` : statusText[attempt.status]}</span></button></li>;
+    })}
+  </ol>;
+}

+ 38 - 0
visualization-old/frontend/components/flow/RetrievalAgentCard.tsx

@@ -0,0 +1,38 @@
+import { useRef } from "react";
+import { ArrowRight, Bot, ChevronDown, ChevronUp } from "lucide-react";
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { QueryAttemptList } from "@/components/flow/QueryAttemptList";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+import { useRetrievalHoverPreview } from "@/components/flow/RetrievalHoverPreview";
+
+export function RetrievalAgentCard({ data }: NodeProps) {
+  const node = data as unknown as CanvasNodeData;
+  const item = node.item;
+  if (!item || item.itemType !== "retrieval-agent") return null;
+  return <RetrievalAgentCardContent node={node} item={item} />;
+}
+
+function RetrievalAgentCardContent({ node, item }: { node: CanvasNodeData; item: Extract<NonNullable<CanvasNodeData["item"]>, { itemType: "retrieval-agent" }> }) {
+  const cardRef = useRef<HTMLElement>(null);
+  const hoverPreview = useRetrievalHoverPreview({ anchorRef: cardRef, buildId: node.buildId, item, onOpenDetail: () => node.onOpen(item, "business") });
+  const run = item.run;
+  const expanded = Boolean(node.expanded);
+  const query = run.querySummary;
+  const queryListId = `query-list-${run.eventId}`;
+  return <><article ref={cardRef} className={`flowCard retrievalCard retrievalAgentCard status-${run.status}${expanded ? " isQueryExpanded" : ""}`} tabIndex={0} data-focus-return={`${item.id}:card`} {...hoverPreview.anchorProps} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); node.onOpen(item, "business"); } }}>
+    <Handle type="target" position={Position.Left} className="flowHandle" />
+    <Handle type="source" position={Position.Right} className="flowHandle" />
+    <header><span className="retrievalKind"><Bot size={14} />Agent 取数</span><span className={`agentRunStatus run-${run.status}`}>{run.status === "completed" ? "已完成" : run.status === "failed" ? "失败" : run.status === "interrupted" ? "构建中断" : "进行中"}</span></header>
+    <h3>{run.businessLabel}</h3>
+    <div className="agentTask"><b>取数目标</b><p>{run.taskPreview || "任务未记录"}</p></div>
+    <div className="agentQuerySummary"><b>查询过程</b><p>{query.total} 次 · <span className="query-hit">{query.hit} 次命中</span> · <span className="query-empty">{query.empty} 次无结果</span>{query.failure ? <> · <span className="query-failure">{query.failure} 次失败</span></> : null}{query.interrupted ? <> · <span className="query-interrupted">{query.interrupted} 次中断</span></> : null}{query.unknown ? <> · <span className="query-unknown">{query.unknown} 次结果待确认</span></> : null}</p></div>
+    <div className="agentScreening"><b>初筛整理</b><p>{run.screening.preview || (run.screening.state === "running" ? "正在整理查询结果" : "初筛整理未记录")}</p></div>
+    {expanded ? <QueryAttemptList id={queryListId} attempts={run.attempts} roundIndex={item.roundIndex} branchId={item.branchId} onOpen={node.onOpen} /> : null}
+    <footer>
+      {run.attempts.length ? <button type="button" className="nodeAction nodrag nopan" aria-expanded={expanded} aria-controls={queryListId} onClick={(event) => { event.stopPropagation(); node.onToggleRetrievalAgent(item.id); }}>{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}{expanded ? "收起查询" : `展开 ${run.attempts.length} 次查询`}</button> : null}
+      <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>查看 Agent 详情<ArrowRight size={14} /></button>
+      <SourceComparisonButton item={item} onOpen={node.onOpen} />
+    </footer>
+  </article>{hoverPreview.preview}</>;
+}

+ 209 - 0
visualization-old/frontend/components/flow/RetrievalHoverPreview.tsx

@@ -0,0 +1,209 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
+import { createPortal } from "react-dom";
+import { ArrowRight, Bot, Database, LoaderCircle } from "lucide-react";
+import { errorMessage, getActivityDetail } from "@/lib/api";
+import type { CanvasItem, InspectorDetailProjection } from "@/lib/types";
+
+type RetrievalItem = Extract<CanvasItem, { itemType: "retrieval-direct" | "retrieval-agent" }>;
+
+interface Options {
+  anchorRef: RefObject<HTMLElement | null>;
+  buildId?: string;
+  item: RetrievalItem;
+  onOpenDetail: () => void;
+}
+
+const detailCache = new Map<string, InspectorDetailProjection>();
+const OPEN_DELAY = 300;
+const CLOSE_DELAY = 160;
+const PREVIEW_WIDTH = 380;
+
+export function useRetrievalHoverPreview({ anchorRef, buildId, item, onOpenDetail }: Options) {
+  const previewRef = useRef<HTMLDivElement>(null);
+  const openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
+  const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
+  const [open, setOpen] = useState(false);
+  const [position, setPosition] = useState({ left: 0, top: 0 });
+
+  const clearTimers = useCallback(() => {
+    if (openTimer.current) clearTimeout(openTimer.current);
+    if (closeTimer.current) clearTimeout(closeTimer.current);
+  }, []);
+  const place = useCallback(() => {
+    const anchor = anchorRef.current;
+    if (!anchor) return;
+    const rect = anchor.getBoundingClientRect();
+    const gap = 12;
+    const left = rect.right + PREVIEW_WIDTH + gap <= window.innerWidth
+      ? rect.right + gap
+      : Math.max(gap, rect.left - PREVIEW_WIDTH - gap);
+    const estimatedHeight = Math.min(460, Math.max(260, window.innerHeight * .58));
+    const top = Math.min(Math.max(gap, rect.top), Math.max(gap, window.innerHeight - estimatedHeight - gap));
+    setPosition({ left, top });
+  }, [anchorRef]);
+  const showSoon = useCallback(() => {
+    if (closeTimer.current) clearTimeout(closeTimer.current);
+    if (open || openTimer.current) return;
+    openTimer.current = setTimeout(() => {
+      openTimer.current = undefined;
+      place();
+      setOpen(true);
+    }, OPEN_DELAY);
+  }, [open, place]);
+  const showNow = useCallback(() => {
+    clearTimers();
+    place();
+    setOpen(true);
+  }, [clearTimers, place]);
+  const hideSoon = useCallback(() => {
+    if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = undefined; }
+    if (closeTimer.current) clearTimeout(closeTimer.current);
+    closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY);
+  }, []);
+  const keepOpen = useCallback(() => {
+    if (closeTimer.current) clearTimeout(closeTimer.current);
+  }, []);
+
+  useEffect(() => clearTimers, [clearTimers]);
+  useEffect(() => {
+    if (!open) return;
+    const update = () => place();
+    window.addEventListener("resize", update);
+    window.addEventListener("scroll", update, true);
+    return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); };
+  }, [open, place]);
+
+  const preview = open && typeof document !== "undefined" ? createPortal(
+    <RetrievalPreviewPanel
+      ref={previewRef}
+      buildId={buildId}
+      item={item}
+      position={position}
+      onPointerEnter={keepOpen}
+      onPointerLeave={hideSoon}
+      onOpenDetail={onOpenDetail}
+    />,
+    document.body,
+  ) : null;
+
+  return {
+    preview,
+    anchorProps: {
+      onPointerEnter: showSoon,
+      onPointerLeave: hideSoon,
+      onFocusCapture: showNow,
+      onBlurCapture: (event: React.FocusEvent<HTMLElement>) => {
+        const next = event.relatedTarget as Node | null;
+        if (next && (anchorRef.current?.contains(next) || previewRef.current?.contains(next))) return;
+        hideSoon();
+      },
+    },
+  };
+}
+
+const RetrievalPreviewPanel = function RetrievalPreviewPanel({
+  buildId,
+  item,
+  position,
+  onPointerEnter,
+  onPointerLeave,
+  onOpenDetail,
+  ref,
+}: {
+  buildId?: string;
+  item: RetrievalItem;
+  position: { left: number; top: number };
+  onPointerEnter: () => void;
+  onPointerLeave: () => void;
+  onOpenDetail: () => void;
+  ref: RefObject<HTMLDivElement | null>;
+}) {
+  const isAgent = item.itemType === "retrieval-agent";
+  return <div
+    ref={ref}
+    className="retrievalHoverPreview nodrag nopan"
+    role="dialog"
+    aria-label={isAgent ? `${item.run.businessLabel}取数预览` : "工具取数预览"}
+    style={{ left: position.left, top: position.top }}
+    onPointerEnter={onPointerEnter}
+    onPointerLeave={onPointerLeave}
+  >
+    {isAgent ? <AgentPreview item={item} /> : <DirectToolPreview buildId={buildId} item={item} />}
+    <button type="button" className="retrievalPreviewAction" onClick={onOpenDetail}>查看完整详情<ArrowRight size={14} /></button>
+  </div>;
+};
+
+function AgentPreview({ item }: { item: Extract<RetrievalItem, { itemType: "retrieval-agent" }> }) {
+  const run = item.run;
+  const hits = run.attempts.filter((attempt) => attempt.status === "hit");
+  const returnedCount = hits.reduce((sum, attempt) => sum + (attempt.resultCount || 0), 0);
+  const screening = cleanPreview(run.screening.preview || "");
+  return <>
+    <header className="retrievalPreviewHeader"><span className="retrievalPreviewIcon is-agent"><Bot size={16} /></span><div><small>Agent 取数</small><h3>{run.businessLabel}</h3></div><strong>{run.querySummary.hit} 次命中</strong></header>
+    <p className="retrievalPreviewSummary">{run.querySummary.total} 次查询{returnedCount ? `,共返回 ${returnedCount} 条结果` : ""}{run.querySummary.empty ? `,${run.querySummary.empty} 次无结果` : ""}{run.querySummary.failure ? `,${run.querySummary.failure} 次失败` : ""}。</p>
+    {hits.length ? <section><h4>命中了什么</h4><ul>{hits.slice(0, 3).map((attempt) => <li key={attempt.id}><span>{attempt.queryLabel}</span><em>{attempt.resultCount ?? "?"} 条</em></li>)}</ul>{hits.length > 3 ? <small className="retrievalPreviewMore">另有 {hits.length - 3} 次命中</small> : null}</section> : null}
+    <section><h4>初筛得到</h4><p className="retrievalPreviewText">{screening || (run.screening.state === "running" ? "正在整理查询结果。" : "本次运行没有保存初筛结论。")}</p></section>
+  </>;
+}
+
+function DirectToolPreview({ buildId, item }: { buildId?: string; item: Extract<RetrievalItem, { itemType: "retrieval-direct" }> }) {
+  const calls = item.group.calls;
+  const [details, setDetails] = useState<Array<{ callId: string; detail?: InspectorDetailProjection; error?: string }>>([]);
+  const [loading, setLoading] = useState(Boolean(buildId && calls.length));
+  useEffect(() => {
+    let active = true;
+    if (!buildId || !calls.length) { setLoading(false); return; }
+    setLoading(true);
+    void Promise.all(calls.slice(0, 4).map(async (call) => {
+      const key = `${buildId}:${call.detailRef}`;
+      const cached = detailCache.get(key);
+      if (cached) return { callId: call.id, detail: cached };
+      try {
+        const detail = await getActivityDetail(buildId, call.detailRef);
+        detailCache.set(key, detail);
+        return { callId: call.id, detail };
+      } catch (error) {
+        return { callId: call.id, error: errorMessage(error) };
+      }
+    })).then((result) => { if (active) { setDetails(result); setLoading(false); } });
+    return () => { active = false; };
+  }, [buildId, calls]);
+
+  const summaries = useMemo(() => calls.slice(0, 4).map((call) => {
+    const detail = details.find((entry) => entry.callId === call.id);
+    return {
+      call,
+      outcome: detail?.detail?.outcome?.message,
+      items: (detail?.detail?.items || []).slice(0, 3),
+      error: detail?.error,
+    };
+  }), [calls, details]);
+
+  return <>
+    <header className="retrievalPreviewHeader"><span className="retrievalPreviewIcon is-tool"><Database size={16} /></span><div><small>工具取数</small><h3>{item.group.sources.map((source) => source.businessLabel).join("、") || "读取内容"}</h3></div><strong>{item.group.successCount}/{item.group.callCount} 完成</strong></header>
+    {loading ? <div className="retrievalPreviewLoading"><LoaderCircle size={16} />正在读取真实返回内容…</div> : null}
+    {!loading ? <div className="retrievalPreviewCalls">{summaries.map(({ call, outcome, items, error }) => <section key={call.id}>
+      <h4>{call.businessLabel}</h4>
+      {error ? <p className="retrievalPreviewError">完整返回暂时不可用:{error}</p> : <>
+        <p className="retrievalPreviewSummary">{outcome || call.resultSummary}</p>
+        {items.length ? <ul>{items.map((result) => <li key={result.id}><span>{result.title}</span>{result.subtitle ? <small>{cleanPreview(result.subtitle)}</small> : null}</li>)}</ul> : null}
+      </>}
+    </section>)}</div> : null}
+    {calls.length > 4 ? <small className="retrievalPreviewMore">完整详情中还有 {calls.length - 4} 次工具调用</small> : null}
+  </>;
+}
+
+function cleanPreview(value: string) {
+  if (!value || value.startsWith("functioncall:")) return "";
+  const clean = value
+    .replace(/```[\s\S]*?```/g, " ")
+    .replace(/^#{1,6}\s+/gm, "")
+    .replace(/^[-*+]\s+/gm, "")
+    .replace(/\|/g, " ")
+    .replace(/[*_`>#]/g, "")
+    .replace(/\s+/g, " ")
+    .trim();
+  return clean.length > 360 ? `${clean.slice(0, 360).trim()}…` : clean;
+}

+ 26 - 0
visualization-old/frontend/components/flow/RetrievalStageFrame.tsx

@@ -0,0 +1,26 @@
+import { ArrowRight, Database, GitFork } from "lucide-react";
+import type { NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+
+const modeText = {
+  single: "单项取数",
+  sequential: "依次取数",
+  parallel: "并行取数",
+  mixed: "混合执行",
+  unknown: "先后未确认",
+} as const;
+
+export function RetrievalStageFrame({ data }: NodeProps) {
+  const node = data as unknown as CanvasNodeData;
+  const item = node.item;
+  if (!item || item.itemType !== "retrieval-stage") return null;
+  const operationCount = item.stage.directToolGroups.length + item.stage.agentRuns.length;
+  return <section className={`retrievalStageFrame retrieval-${item.stage.status}`} aria-label={`取数阶段,${modeText[item.stage.observedMode]}`}>
+    <header>
+      <div><span className="retrievalStageIcon"><GitFork size={14} /></span><strong>取数阶段</strong><em>{modeText[item.stage.observedMode]}</em></div>
+      <div className="retrievalStageActions"><button type="button" className="frameDetail nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>查看取数概况<ArrowRight size={13} /></button><SourceComparisonButton item={item} onOpen={node.onOpen} className="frameDetail nodrag nopan" /></div>
+    </header>
+    {operationCount === 0 ? <div className="retrievalEmpty"><Database size={18} /><span>{item.stage.status === "running" ? "正在等待取数记录" : "未记录可确认的取数活动"}</span></div> : null}
+  </section>;
+}

+ 26 - 0
visualization-old/frontend/components/flow/RoundFrame.tsx

@@ -0,0 +1,26 @@
+import { ChevronLeft, GitBranch } from "lucide-react";
+import type { NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { StatusBadge } from "@/components/ui/Badges";
+
+export function RoundFrame({ data }: NodeProps) {
+  const node = data as unknown as CanvasNodeData;
+  const round = node.round;
+  if (!round) return null;
+  const defaultTitle = `第 ${round.roundIndex} 轮`;
+  return <section className="roundFrame" aria-label={`第 ${round.roundIndex} 轮展开流程`}>
+    <header>
+      <button
+        type="button"
+        className="roundCollapse nodrag nopan"
+        onClick={(event) => {
+          event.stopPropagation();
+          node.onToggleRound(round.roundIndex);
+        }}
+      ><ChevronLeft size={17} />折叠本轮</button>
+      <div><strong>{round.summary.title && round.summary.title !== defaultTitle ? `${defaultTitle} · ${round.summary.title}` : defaultTitle}</strong><span>{round.summary.goal.headline || "本轮目标未记录"}</span></div>
+      <span className="branchCount"><GitBranch size={13} />{round.branches.length} 个候选 · {round.convergenceBatches.length} 个收敛批次</span>
+      <StatusBadge status={round.state} />
+    </header>
+  </section>;
+}

+ 43 - 0
visualization-old/frontend/components/flow/RoundSummaryCard.tsx

@@ -0,0 +1,43 @@
+import { ArrowRight, ChevronDown, FileText } from "lucide-react";
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { StatusBadge } from "@/components/ui/Badges";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+
+export function RoundSummaryCard({ data }: NodeProps) {
+  const { item, onOpen, onToggleRound } = data as unknown as CanvasNodeData;
+  if (!item || item.itemType !== "round-summary") return null;
+  const summary = item.round.summary;
+  const approach = `${summary.approach.mode || "方式未记录"} · ${summary.approach.candidateCount} 个实现方案`;
+  const decisionMeta = summary.decision.batchCount
+    ? `${summary.decision.batchCount} 个决策批次${summary.decision.branchIds.length ? ` · 涉及方案 ${summary.decision.branchIds.join("、")}` : ""}`
+    : summary.decision.summary;
+  const outputs = [
+    summary.output.mergedContentCount ? summary.output.scriptSummary : null,
+    summary.output.domainFactCount ? summary.output.domainSummary : null,
+  ].filter((value): value is string => Boolean(value));
+  if (!outputs.length) outputs.push(summary.output.summary);
+
+  return <article className="flowCard roundSummaryCard" data-focus-return={`${item.id}:card`} aria-label={`第 ${item.roundIndex} 轮摘要`} tabIndex={0} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpen(item, "business"); } }}>
+    <Handle type="target" position={Position.Left} className="flowHandle" /><Handle type="source" position={Position.Right} className="flowHandle" />
+    <header className="cardHeader"><span className="roundNumber">第 {item.roundIndex} 轮</span><StatusBadge status={item.round.state} /></header>
+    <div className="roundSummaryContent">
+      <section className="roundGoalSummary">
+        <b>本轮目标</b>
+        <p>{summary.goal.headline}</p>
+        {summary.goal.itemCount ? <small>{summary.goal.itemCount} 项具体任务 · 完整内容见详情</small> : summary.goal.truncated ? <small>完整内容见详情</small> : null}
+      </section>
+      <div className="roundSummaryFacts">
+        <SummaryBlock label="尝试方案" value={approach} />
+        <SummaryBlock label="主 Agent 决策" value={decisionMeta} detail={summary.decision.batchCount ? summary.decision.summary : undefined} tone={summary.decision.batchCount ? "decision" : "warning"} />
+        <SummaryBlock label="本轮产出" values={outputs} tone={summary.output.mergedContentCount || summary.output.domainFactCount ? "success" : "normal"} />
+      </div>
+    </div>
+    <footer className="cardFooter"><button type="button" className="nodeAction nodrag nopan" onClick={(event) => { event.stopPropagation(); onToggleRound(item.roundIndex); }}><ChevronDown size={14} />展开本轮</button>{item.detailRef ? <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}>查看详情<ArrowRight size={14} /></button> : null}<SourceComparisonButton item={item} onOpen={onOpen} /><button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); onOpen(item, "artifact"); }}><FileText size={14} />查看本轮产出脚本表</button></footer>
+  </article>;
+}
+
+function SummaryBlock({ label, value, values, detail, tone = "normal" }: { label: string; value?: string; values?: string[]; detail?: string; tone?: "normal" | "decision" | "success" | "warning" }) {
+  const lines = values || (value ? [value] : []);
+  return <section className={`roundSummaryBlock summaryTone-${tone}`}><b>{label}</b><div>{lines.map((line) => <p key={line}>{line}</p>)}{detail && detail !== value ? <small>{detail}</small> : null}</div></section>;
+}

+ 40 - 0
visualization-old/frontend/components/flow/RunResultCard.tsx

@@ -0,0 +1,40 @@
+import { ArrowRight, CheckCircle2, FileCheck2 } from "lucide-react";
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { runResultFallback } from "@/lib/status-copy";
+import { SourceNoticeBadge, StatusBadge } from "@/components/ui/Badges";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+
+export function RunResultCard({ data }: NodeProps) {
+  const { item, onOpen } = data as unknown as CanvasNodeData;
+  if (!item || item.itemType !== "final-result") return null;
+  const result = item.result;
+  const artifact = result.artifact;
+  const summary = result.summary || result.errorMessage || runResultFallback(result.status, result.terminal);
+  const summaryPreview = resultSummaryPreview(summary);
+  const adoptedCount = result.branchCounts.merged ?? 0;
+  return <article className="flowCard runResultCard" data-focus-return={`${item.id}:card`} aria-label="最终结果" tabIndex={0} onKeyDown={(event) => { if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onOpen(item, "business"); } }}>
+    <Handle type="target" position={Position.Left} className="flowHandle" />
+    <header className="cardHeader"><span className="finalLabel"><FileCheck2 size={14} />最终结果</span><StatusBadge status={result.status} /></header>
+    {result.title !== "最终结果" ? <h3>{result.title}</h3> : null}
+    <div className="finalOutcome"><span><CheckCircle2 size={18} /></span><div><b>{result.terminal ? "当前主脚本已经形成" : "脚本构建仍在推进"}</b><p>{result.roundCount} 轮构建 · {result.branchCounts.total} 个候选方案</p></div></div>
+    {summaryPreview ? <section className="finalSummaryPreview"><b>构建摘要</b><p>{summaryPreview}</p></section> : null}
+    <div className="artifactStats"><span><b>{artifact.paragraphCount ?? 0}</b>段落</span><span><b>{artifact.elementCount ?? 0}</b>元素</span><span><b>{artifact.linkCount ?? 0}</b>关联</span></div>
+    <div className="finalRunFacts"><span><b>{adoptedCount}</b> 个方案已采用</span><span><b>{result.branchCounts.discarded ?? 0}</b> 个未采用</span></div>
+    <footer className="cardFooter"><button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}>查看结果详情</button><SourceComparisonButton item={item} onOpen={onOpen} /><button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); onOpen(item, "artifact"); }}>查看完整主脚本<ArrowRight size={14} /></button></footer>
+    <SourceNoticeBadge notice={result.sourceNotice} />
+  </article>;
+}
+
+function resultSummaryPreview(value: string) {
+  const lines = value
+    .replace(/\\n/g, "\n")
+    .split(/\n+/)
+    .map((line) => line.replace(/^\s*(?:#{1,6}\s*|[-*+]\s+|\d+[.、)]\s*)/, "").replace(/[`*_]/g, "").trim())
+    .filter(Boolean);
+  const text = lines.find((line) => line.length >= 18) || lines[0] || "";
+  if (text.length <= 150) return text;
+  const candidate = text.slice(0, 151);
+  const punctuation = Math.max(...["。", "!", "?", ";"].map((mark) => candidate.lastIndexOf(mark)));
+  return punctuation >= 72 ? candidate.slice(0, punctuation + 1) : `${text.slice(0, 149).trim()}…`;
+}

+ 23 - 0
visualization-old/frontend/components/flow/SourceComparisonButton.tsx

@@ -0,0 +1,23 @@
+import { GitCompareArrows } from "lucide-react";
+import type { CanvasItem, OpenTarget } from "@/lib/types";
+
+interface Props {
+  item: CanvasItem;
+  onOpen: (item: CanvasItem, target: OpenTarget) => void;
+  className?: string;
+}
+
+export function SourceComparisonButton({ item, onOpen, className = "nodeAction nodrag nopan" }: Props) {
+  if (!("detailRef" in item) || !item.detailRef) return null;
+  return <button
+    type="button"
+    className={`${className} lineageAction`}
+    data-focus-return={`${item.id}:lineage`}
+    onClick={(event) => {
+      event.stopPropagation();
+      onOpen(item, "lineage");
+    }}
+  >
+    <GitCompareArrows aria-hidden="true" size={14} />数据来源
+  </button>;
+}

+ 37 - 0
visualization-old/frontend/components/flow/StoryCard.tsx

@@ -0,0 +1,37 @@
+import { ArrowRight, FileText } from "lucide-react";
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import type { CanvasNodeData } from "@/lib/layout";
+import { SourceNoticeBadge, StatusBadge } from "@/components/ui/Badges";
+import { CardFields } from "@/components/flow/CardFields";
+import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
+
+export function StoryCard({ data }: NodeProps) {
+  const { item, onOpen } = data as unknown as CanvasNodeData;
+  if (!item || item.itemType !== "story") return null;
+  const role = roleLabel(item.role);
+  const actor = actorLabel(item.role);
+  const hasFields = Boolean(item.card.primary || item.card.secondary.length);
+  return <article className={`flowCard storyCard story-${item.kind} role-${item.role}`} data-focus-return={`${item.id}:card`} aria-label={`${item.title}`} tabIndex={0} onKeyDown={(event) => { if (event.target !== event.currentTarget) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpen(item, "business"); } }}>
+    <Handle type="target" position={Position.Left} className="flowHandle" />
+    <Handle type="source" position={Position.Right} className="flowHandle" />
+    <header className="cardHeader"><div className="cardIdentity"><span className={`actorBadge actor-${actor.tone}`}>{actor.label}</span>{role !== item.title ? <span className="storyRole">{role}</span> : null}</div>{item.status ? <StatusBadge status={item.status} /> : null}</header>
+    <h3>{item.title}</h3>
+    <CardFields card={item.card} />
+    <footer className="cardFooter">
+      {item.detailRef || hasFields ? <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}>查看详情<ArrowRight size={14} /></button> : null}
+      <SourceComparisonButton item={item} onOpen={onOpen} />
+      {item.artifactRef ? <button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); onOpen(item, "artifact"); }}><FileText size={14} />{item.role === "round-result" ? "查看本轮产出脚本表" : "查看完整候选脚本"}</button> : null}
+    </footer>
+    <SourceNoticeBadge notice={item.sourceNotice} />
+  </article>;
+}
+
+function roleLabel(role: string) {
+  return ({ "planning-analysis": "规划解析", objective: "创作目标", goal: "本轮目标", plan: "实现规划", "branch-task": "实现任务", "data-decision": "数据取舍", "candidate-output": "候选脚本", "domain-output": "领域事实", "multipath-review": "多方案评审", "multipath-decision": "多路决策", "round-evaluation": "主脚本整体评审", "round-result": "本轮产出", "final-result": "最终结果" } as Record<string, string>)[role] || "运行步骤";
+}
+
+function actorLabel(role: string) {
+  if (role === "multipath-review" || role === "round-evaluation") return { label: role === "multipath-review" ? "多方案评审 Agent" : "整体评审 Agent", tone: "evaluator" };
+  if (["branch-task", "data-decision", "candidate-output", "domain-output"].includes(role)) return { label: "实现 Agent", tone: "implementer" };
+  return { label: "主 Agent", tone: "main" };
+}

+ 54 - 0
visualization-old/frontend/components/inspector/BindingBadge.tsx

@@ -0,0 +1,54 @@
+"use client";
+
+import { AlertTriangle, Ban, Calculator, CheckCircle2, CircleHelp, Eye, History, PackageCheck } from "lucide-react";
+import type { SourceBinding } from "@/lib/types";
+
+interface Props {
+  binding: SourceBinding;
+  marker: string;
+  active: boolean;
+  onActivate: (bindingId: string, toggle?: boolean) => void;
+  compact?: boolean;
+}
+
+export function BindingBadge({ binding, marker, active, onActivate, compact = false }: Props) {
+  const evidence = evidencePresentation(binding);
+  const Icon = evidence.icon;
+  return <button
+    type="button"
+    className={`bindingBadge is-${evidence.tone} ${active ? "is-active" : ""} ${compact ? "is-compact" : ""}`}
+    data-binding-id={binding.id}
+    aria-pressed={active}
+    title={selectorDescription(binding)}
+    onClick={() => onActivate(binding.id, false)}
+    onFocus={() => onActivate(binding.id, false)}
+  >
+    <span className="bindingMarker">{marker}</span>
+    <Icon aria-hidden="true" size={13} />
+    {!compact ? <span>{evidence.label}</span> : null}
+  </button>;
+}
+
+export function evidencePresentation(binding: SourceBinding) {
+  if (binding.resolution === "unsafe") return { label: "无法安全关联", tone: "unsafe", icon: CircleHelp };
+  if (binding.resolution === "partial" || (binding.selector.kind === "unresolved" && binding.resolution !== "missing")) return { label: "部分可定位", tone: "partial", icon: AlertTriangle };
+  if (binding.resolution === "missing") return { label: "记录缺失", tone: "missing", icon: AlertTriangle };
+  if (binding.transform.kind === "fallback") return { label: "历史回退", tone: "fallback", icon: History };
+  if (binding.evidence.adoption === "explicitly-adopted") return { label: "明确采用", tone: "adopted", icon: CheckCircle2 };
+  if (binding.evidence.adoption === "explicitly-rejected") return { label: "明确未采用", tone: "rejected", icon: Ban };
+  if (binding.evidence.availability === "direct-read") return { label: "已读取,是否采用未记录", tone: "read", icon: Eye };
+  if (binding.evidence.availability === "returned-to-agent") return { label: "已返回给 Agent,是否采用未记录", tone: "read", icon: PackageCheck };
+  if (binding.evidence.availability === "available-upstream") return { label: "当时可获得,是否读取和采用未记录", tone: "available", icon: CircleHelp };
+  if (binding.evidence.availability === "produced-by-run") return { label: "运行产出", tone: "produced", icon: PackageCheck };
+  if (binding.evidence.availability === "visualization-derived") return { label: "可视化计算", tone: "calculated", icon: Calculator };
+  return { label: "是否采用未记录", tone: "unknown", icon: CircleHelp };
+}
+
+export function selectorDescription(binding: SourceBinding) {
+  const selector = binding.selector;
+  if (selector.kind === "json-pointer") return selector.path;
+  if (selector.kind === "text-span") return `${selector.path} · 字符 ${selector.startCodePoint}–${selector.endCodePoint}`;
+  if (selector.kind === "members") return `${selector.members.length} 项聚合 · ${selector.reducer}`;
+  if (selector.kind === "whole-record") return "完整记录";
+  return selector.reason;
+}

+ 163 - 0
visualization-old/frontend/components/inspector/BusinessValueRenderer.tsx

@@ -0,0 +1,163 @@
+"use client";
+
+import type { InspectorModule } from "@/lib/types";
+import { LongValueDisclosure } from "@/components/inspector/LongValueDisclosure";
+
+interface Props {
+  module: InspectorModule;
+  value: unknown;
+  label?: string;
+}
+
+/** Business-only renderer. Raw JSON belongs to the source column, never here. */
+export function BusinessValueRenderer({ module, value, label }: Props) {
+  if (module.id === "conditions" && Array.isArray(value) && value.length === 0) {
+    return <p className="comparisonEmptyValue">本次调用没有保存额外输入条件。</p>;
+  }
+  if (module.id === "activities" && Array.isArray(value) && value.length === 0) {
+    return <p className="comparisonEmptyValue">本次运行没有保存可下钻的结构化查询记录。</p>;
+  }
+  if (module.id === "completeness") {
+    if (label === "完整返回" && value === false) {
+      return <p className="comparisonEmptyValue">仅保存了摘要,完整返回不可用。</p>;
+    }
+    if (label === "截断说明") {
+      return <p className="comparisonEmptyValue">返回内容超过安全展示上限,已省略 {typeof value === "number" ? value : "部分"} 个字符。</p>;
+    }
+  }
+  if (typeof value === "string" || value === null || value === undefined) {
+    return <LongValueDisclosure value={value} label={label || "完整内容"} />;
+  }
+
+  if (module.id === "mode" && isStringPair(value)) {
+    return <dl className="businessDefinitionList">
+      <BusinessDefinition label="规划方式" value={value[0]} emphasis />
+      <BusinessDefinition label="为什么这样规划" value={value[1]} />
+    </dl>;
+  }
+
+  if (Array.isArray(value)) {
+    if (value.every((item) => typeof item === "string")) {
+      return <ul className="businessBulletList">{value.map((item, index) => <li key={index}>{item}</li>)}</ul>;
+    }
+    if (value.every(isPlanRoute)) return <PlanRoutes routes={value} />;
+    return <BusinessRecordList values={value} />;
+  }
+
+  if (isRecord(value)) {
+    if (Array.isArray(value.paths)) {
+      return <div className="businessRevisionSummary">
+        <dl className="businessDefinitionList">
+          {typeof value.mode === "string" ? <BusinessDefinition label="保存的规划方式" value={value.mode} emphasis /> : null}
+          <BusinessDefinition label="保存结果" value={`已保存 ${value.paths.length} 条实现路线`} />
+        </dl>
+        {typeof value.note === "string" ? <section className="businessNarrative"><h5>保存时的说明</h5><p>{value.note}</p></section> : null}
+      </div>;
+    }
+    return <BusinessRecord value={value} rowLabel={label} />;
+  }
+
+  return <p className="comparisonReadableText">{String(value)}</p>;
+}
+
+function PlanRoutes({ routes }: { routes: PlanRoute[] }) {
+  if (!routes.length) return <p className="comparisonEmptyValue">没有保存具体路线。</p>;
+  return <div className="businessPlanRoutes">{routes.map((route, index) => <article key={`${route.path_index || index}-${route.target || "route"}`} className="businessPlanRoute">
+    <header><span>{route.path_index || index + 1}</span><div><h5>路线 {route.path_index || index + 1}</h5>{route.path_type ? <small>{friendlyValue(route.path_type)}</small> : null}</div></header>
+    <dl>
+      {route.target ? <BusinessDefinition label="要实现什么" value={route.target} /> : null}
+      {route.method ? <BusinessDefinition label="怎么实现" value={friendlyValue(route.method)} /> : null}
+      {route.action ? <BusinessDefinition label="具体动作" value={friendlyValue(route.action)} /> : null}
+      {route.emphasis ? <BusinessDefinition label="这条路线的重点" value={route.emphasis} /> : null}
+    </dl>
+  </article>)}</div>;
+}
+
+function BusinessRecordList({ values }: { values: unknown[] }) {
+  return <div className="businessRecordList">{values.map((value, index) => <section key={index} className="businessRecordItem">
+    <h5>第 {index + 1} 项</h5>
+    {isRecord(value) ? <BusinessRecord value={value} /> : <p>{printSimple(value)}</p>}
+  </section>)}</div>;
+}
+
+function BusinessRecord({ value, rowLabel }: { value: Record<string, unknown>; rowLabel?: string }) {
+  const primary = value.value;
+  const entries = Object.entries(value).filter(([key, item]) => {
+    if (TECHNICAL_KEYS.has(key) || key === "value" || item === undefined || item === null || item === "") return false;
+    if (rowLabel && ["label", "title", "subject", "summary"].includes(key) && String(item) === rowLabel) return false;
+    return true;
+  });
+  if (!entries.length && (primary === undefined || primary === null || primary === "")) return <p className="comparisonEmptyValue">这条记录没有可展示的业务内容。</p>;
+  return <>{primary !== undefined && primary !== null && primary !== "" ? <LongValueDisclosure value={primary} label="完整内容" /> : null}<dl className="businessDefinitionList">{entries.map(([key, item]) => <BusinessDefinition key={key} label={fieldLabel(key)} value={printSimple(item)} />)}</dl></>;
+}
+
+function BusinessDefinition({ label, value, emphasis = false }: { label: string; value: string; emphasis?: boolean }) {
+  return <div className={emphasis ? "is-emphasis" : ""}><dt>{label}</dt><dd>{value}</dd></div>;
+}
+
+type PlanRoute = {
+  action?: string;
+  method?: string;
+  target?: string;
+  emphasis?: string;
+  path_type?: string;
+  path_index?: string | number;
+};
+
+const TECHNICAL_KEYS = new Set(["id", "path_index", "detailRef", "event_id", "eventId", "sourceNotice"]);
+const FIELD_LABELS: Record<string, string> = {
+  mode: "规划方式",
+  note: "说明",
+  goal: "目标",
+  target: "要实现什么",
+  method: "怎么实现",
+  action: "具体动作",
+  emphasis: "重点",
+  path_type: "路线类型",
+  status: "状态",
+  summary: "结果摘要",
+  reason: "原因",
+  reasoning: "判断理由",
+  decision: "最终取舍",
+  result: "结果",
+  count: "数量",
+  task: "任务",
+  scope: "范围",
+  content: "内容",
+  label: "名称",
+  title: "标题",
+  subtitle: "补充说明",
+  kind: "类型",
+  resultCount: "返回数量",
+  meta: "关键信息",
+  sections: "内容详情",
+  query: "查询内容",
+  keyword: "关键词",
+  keywords: "关键词",
+  account: "账号",
+  platform: "平台",
+  score: "匹配分数",
+  url: "链接",
+  input: "输入",
+  output: "输出",
+  error: "失败原因",
+  criteria: "评审标准",
+  suggestion: "建议",
+  conclusion: "结论",
+  issues: "发现的问题",
+  strengths: "已经达成",
+  next_step: "下一步",
+};
+
+function isRecord(value: unknown): value is Record<string, unknown> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
+function isStringPair(value: unknown): value is [string, string] { return Array.isArray(value) && value.length === 2 && value.every((item) => typeof item === "string"); }
+function isPlanRoute(value: unknown): value is PlanRoute { return isRecord(value) && ("target" in value || "method" in value || "emphasis" in value) && ("path_index" in value || "path_type" in value); }
+function fieldLabel(key: string) { return FIELD_LABELS[key] || "补充信息"; }
+function friendlyValue(value: string) { return ({ "增": "新增", "改": "修改", "删": "删除", "内容": "内容路线" } as Record<string, string>)[value] || value; }
+function printSimple(value: unknown): string {
+  if (typeof value === "string") return value;
+  if (typeof value === "number" || typeof value === "boolean") return String(value);
+  if (Array.isArray(value)) return value.map(printSimple).filter(Boolean).join(";");
+  if (isRecord(value)) return Object.entries(value).filter(([key]) => !TECHNICAL_KEYS.has(key)).map(([key, item]) => `${fieldLabel(key)}:${printSimple(item)}`).join(";");
+  return String(value ?? "");
+}

+ 178 - 0
visualization-old/frontend/components/inspector/ComparisonRowCells.tsx

@@ -0,0 +1,178 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { Activity, Calculator, Database, FileText, History, ScrollText } from "lucide-react";
+import type { InspectorComparisonRow, InspectorModule, SourceBinding, SourceRecord, SourceSelector } from "@/lib/types";
+import { BindingBadge, selectorDescription } from "@/components/inspector/BindingBadge";
+import { BusinessValueRenderer } from "@/components/inspector/BusinessValueRenderer";
+import { LongValueDisclosure } from "@/components/inspector/LongValueDisclosure";
+
+interface RowProps {
+  row: InspectorComparisonRow;
+  module: InspectorModule;
+  businessProjection: unknown;
+  sources: Record<string, SourceRecord>;
+  markers: Map<string, string>;
+  activeBindingId?: string;
+  onActivate: (bindingId: string, toggle?: boolean) => void;
+}
+
+export function ComparisonRowCells(props: RowProps) {
+  const bindings = props.row.bindingIds
+    .map((id) => props.module.bindings.find((binding) => binding.id === id))
+    .filter((binding): binding is SourceBinding => Boolean(binding));
+  const businessResult = pointerValue(props.businessProjection, props.row.businessSelector);
+  const active = bindings.some((binding) => binding.id === props.activeBindingId);
+  return <article className={`comparisonItemRow ${active ? "is-active" : ""}`} data-row-id={props.row.id}>
+    <section className="comparisonItemBusiness" aria-label="业务详情中的这一项">
+      {props.row.label ? <h4>{props.row.label}</h4> : null}
+      {businessResult.found
+        ? <BusinessValueRenderer module={props.module} value={businessResult.value} label={props.row.label || props.module.title} />
+        : <p className="comparisonEmptyValue">业务详情中的这一项无法定位。</p>}
+    </section>
+    <section className="comparisonItemEvidence" aria-label="这一项的数据依据">
+      {bindings.length ? bindings.map((binding) => <EvidenceSummary
+        key={binding.id}
+        binding={binding}
+        source={props.sources[binding.sourceId]}
+        marker={props.markers.get(binding.id) || "S?"}
+        active={props.activeBindingId === binding.id}
+        onActivate={props.onActivate}
+      />) : <p className="comparisonEmptyValue">没有返回来源绑定。</p>}
+    </section>
+    <section className="comparisonItemSource" aria-label="对应的原始字段或原文">
+      {bindings.length ? bindings.map((binding) => <SourceExcerpt
+        key={binding.id}
+        binding={binding}
+        source={props.sources[binding.sourceId]}
+        sources={props.sources}
+        marker={props.markers.get(binding.id) || "S?"}
+      />) : <p className="comparisonEmptyValue">没有可展示的原始记录。</p>}
+    </section>
+  </article>;
+}
+
+function EvidenceSummary({ binding, source, marker, active, onActivate }: { binding: SourceBinding; source?: SourceRecord; marker: string; active: boolean; onActivate: RowProps["onActivate"] }) {
+  return <div className={`comparisonEvidenceSummary ${active ? "is-active" : ""}`} data-binding-id={binding.id}>
+    <BindingBadge binding={binding} marker={marker} active={active} onActivate={onActivate} />
+    <div className="comparisonEvidenceCopy">
+      <strong>{source?.label || (binding.selector.kind === "unresolved" ? "无法定位来源" : binding.sourceId)}</strong>
+      <small>{roleLabel(binding.role)} · {transformLabel(binding)}</small>
+      <details className="comparisonLocatorDetails">
+        <summary>定位详情</summary>
+        <code>{selectorDescription(binding)}</code>
+        <small>{confidenceLabel(binding.evidence.confidence)}{binding.resolution !== "resolved" ? ` · ${resolutionLabel(binding.resolution)}` : ""}</small>
+      </details>
+    </div>
+  </div>;
+}
+
+function SourceExcerpt({ binding, source, sources, marker }: { binding: SourceBinding; source?: SourceRecord; sources: Record<string, SourceRecord>; marker: string }) {
+  const [rawOpen, setRawOpen] = useState(false);
+  const excerpt = useMemo(() => resolveExcerpt(binding.selector, source, sources), [binding.selector, source, sources]);
+  const Icon = sourceIcon(source?.kind);
+  return <div className={`comparisonSourceExcerpt is-${source?.resultState || "present"}`} data-binding-id={binding.id}>
+    <header>
+      <Icon aria-hidden="true" size={15} />
+      <div><strong>{source?.label || "原始记录不可用"}</strong><small>{sourceKindLabel(source?.kind)}</small></div>
+      <span className="sourceExcerptMarker">{marker}</span>
+    </header>
+    {source ? <SourceMeta source={source} /> : null}
+    {source?.resultState === "empty" ? <div className="sourceEmptyResult"><strong>查询完成,返回 0 条</strong><span>这不是记录缺失。</span></div> : null}
+    {excerpt.kind === "span" ? <TextSpanExcerpt before={excerpt.before} exact={excerpt.exact} after={excerpt.after} />
+      : excerpt.kind === "members" ? <div className="sourceMemberList">{excerpt.items.map((item, index) => <section key={`${item.label}-${index}`}><strong>{item.label}</strong><LongValueDisclosure value={item.value} label="完整成员值" /></section>)}</div>
+        : excerpt.kind === "missing" ? <p className="sourceMissingReason">{excerpt.reason}</p>
+          : <LongValueDisclosure value={excerpt.value} label="完整字段值" />}
+    {source?.rawRecord !== undefined && source?.rawRecord !== null ? <details className="rawRecordDisclosure" onToggle={(event) => setRawOpen(event.currentTarget.open)}>
+      <summary><span>完整原始记录</span><small>{source.truncated ? `已省略 ${source.omittedCharacters || 0} 字符` : "按需展开"}</small></summary>
+      {rawOpen ? <div><LongValueDisclosure value={source.rawRecord} label="完整原始记录" threshold={Number.MAX_SAFE_INTEGER} /></div> : null}
+    </details> : null}
+  </div>;
+}
+
+type Excerpt =
+  | { kind: "value"; value: unknown }
+  | { kind: "span"; before: string; exact: string; after: string }
+  | { kind: "members"; items: Array<{ label: string; value: unknown }> }
+  | { kind: "missing"; reason: string };
+
+function resolveExcerpt(selector: SourceSelector, source: SourceRecord | undefined, sources: Record<string, SourceRecord>): Excerpt {
+  if (selector.kind === "unresolved") return { kind: "missing", reason: selector.reason };
+  if (!source) return { kind: "missing", reason: "来源记录没有随接口响应返回。" };
+  if (selector.kind === "whole-record") return { kind: "value", value: source.rawRecord };
+  if (selector.kind === "json-pointer") {
+    const selected = pointerValue(source.rawRecord, selector.path);
+    return selected.found ? { kind: "value", value: selected.value } : { kind: "missing", reason: `原始记录中不存在字段 ${selector.path}` };
+  }
+  if (selector.kind === "text-span") {
+    const selected = pointerValue(source.rawRecord, selector.path);
+    if (!selected.found || typeof selected.value !== "string") return { kind: "missing", reason: `原始记录中不存在文本字段 ${selector.path}` };
+    const characters = Array.from(selected.value);
+    const start = selector.startCodePoint;
+    const end = selector.endCodePoint;
+    return {
+      kind: "span",
+      before: characters.slice(Math.max(0, start - 80), start).join(""),
+      exact: characters.slice(start, end).join("") || selector.exactText,
+      after: characters.slice(end, end + 80).join(""),
+    };
+  }
+  const items = selector.members.map((member, index) => {
+    const memberSource = sources[member.sourceId];
+    const resolved = resolveExcerpt(member.selector, memberSource, sources);
+    return {
+      label: memberSource?.label || `参与记录 ${index + 1}`,
+      value: resolved.kind === "value" ? resolved.value
+        : resolved.kind === "span" ? `${resolved.before}${resolved.exact}${resolved.after}`
+          : resolved.kind === "missing" ? resolved.reason
+            : resolved.items.map((item) => item.value),
+    };
+  });
+  return { kind: "members", items };
+}
+
+function TextSpanExcerpt({ before, exact, after }: { before: string; exact: string; after: string }) {
+  return <p className="comparisonTextExcerpt">{before ? <span>{before}</span> : null}<mark>{exact}</mark>{after ? <span>{after}</span> : null}</p>;
+}
+
+function pointerValue(value: unknown, pointer: string): { found: boolean; value: unknown } {
+  if (!pointer) return { found: true, value };
+  let current = value;
+  for (const rawPart of pointer.replace(/^\//, "").split("/")) {
+    const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~");
+    if (Array.isArray(current)) {
+      const index = Number(part);
+      if (!Number.isInteger(index) || index < 0 || index >= current.length) return { found: false, value: undefined };
+      current = current[index];
+    } else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, part)) {
+      current = (current as Record<string, unknown>)[part];
+    } else return { found: false, value: undefined };
+  }
+  return { found: true, value: current };
+}
+
+function SourceMeta({ source }: { source: SourceRecord }) {
+  const locator = typeof source.locator === "string" ? undefined : source.locator;
+  const fields = locator ? Object.entries(locator).filter(([, value]) => value !== undefined && value !== null && value !== "") : [];
+  return fields.length || source.status || typeof source.durationMs === "number" ? <dl className="sourceMeta">
+    {fields.map(([key, value]) => <div key={key}><dt>{locatorLabel(key)}</dt><dd><code>{printMeta(value)}</code></dd></div>)}
+    {source.status ? <div><dt>状态</dt><dd>{source.status}</dd></div> : null}
+    {typeof source.durationMs === "number" ? <div><dt>耗时</dt><dd>{source.durationMs >= 1000 ? `${(source.durationMs / 1000).toFixed(1)} 秒` : `${source.durationMs} ms`}</dd></div> : null}
+  </dl> : null;
+}
+
+function sourceIcon(kind?: string) {
+  if (kind === "database") return Database;
+  if (kind === "runtime-event") return Activity;
+  if (kind === "artifact") return FileText;
+  if (kind === "log-anchor") return ScrollText;
+  if (kind === "calculation") return Calculator;
+  return History;
+}
+function sourceKindLabel(kind?: string) { return ({ database: "数据库记录", "runtime-event": "运行事件 / Event Body", artifact: "脚本或候选产物", "log-anchor": "日志锚点", calculation: "可视化确定性计算", "historical-fallback": "历史回退", missing: "原始记录缺失" } as Record<string, string>)[kind || "missing"] || kind || "原始记录缺失"; }
+function roleLabel(role: SourceBinding["role"]) { return ({ input: "输入", basis: "判断依据", constraint: "约束", process: "运行过程", output: "产出", status: "状态" } as const)[role]; }
+function transformLabel(binding: SourceBinding) { const value = binding.transform; return value.kind === "direct" ? "直接展示" : value.kind === "parsed" ? "确定性解析" : value.kind === "combined" ? "多来源组合" : value.kind === "calculated" ? "确定性计算" : "历史回退"; }
+function confidenceLabel(value: SourceBinding["evidence"]["confidence"]) { return ({ exact: "精确对应", "safe-association": "可安全关联", deterministic: "确定性生成", unconfirmed: "关联未确认" } as const)[value]; }
+function resolutionLabel(value: SourceBinding["resolution"]) { return ({ partial: "部分定位", missing: "记录缺失", unsafe: "无法安全关联", resolved: "已定位" } as const)[value]; }
+function locatorLabel(key: string) { return ({ table: "数据库表", recordId: "记录 ID", eventId: "Event ID", eventName: "Event 名称", eventType: "Event 类型", artifactRef: "产物", logAnchor: "日志锚点", fieldPath: "字段路径", filters: "查询条件", resultCount: "结果数量", reason: "缺失原因" } as Record<string, string>)[key] || key; }
+function printMeta(value: unknown) { if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } }

部分文件因为文件数量过多而无法显示