Kaynağa Gözat

how 推导 agent 从召回的数据中挑选,在评估匹配,给出决策reason

liuzhiheng 1 ay önce
ebeveyn
işleme
a60909a875

+ 24 - 24
agent/tools/builtin/search.py

@@ -166,30 +166,30 @@ class SuggestSearchChannel(str, Enum):
     ZHIHU = "zhihu"       # 知乎
 
 
-@tool(
-    display={
-        "zh": {
-            "name": "帖子搜索",
-            "params": {
-                "keyword": "搜索关键词",
-                "channel": "搜索渠道",
-                "cursor": "分页游标",
-                "max_count": "返回条数",
-                "content_type": "内容类型-视频/图文"
-            }
-        },
-        "en": {
-            "name": "Search Posts",
-            "params": {
-                "keyword": "Search keyword",
-                "channel": "Search channel",
-                "cursor": "Pagination cursor",
-                "max_count": "Max results",
-                "content_type": "content type-视频/图文"
-            }
-        }
-    }
-)
+# @tool(
+#     display={
+#         "zh": {
+#             "name": "帖子搜索",
+#             "params": {
+#                 "keyword": "搜索关键词",
+#                 "channel": "搜索渠道",
+#                 "cursor": "分页游标",
+#                 "max_count": "返回条数",
+#                 "content_type": "内容类型-视频/图文"
+#             }
+#         },
+#         "en": {
+#             "name": "Search Posts",
+#             "params": {
+#                 "keyword": "Search keyword",
+#                 "channel": "Search channel",
+#                 "cursor": "Pagination cursor",
+#                 "max_count": "Max results",
+#                 "content_type": "content type-视频/图文"
+#             }
+#         }
+#     }
+# )
 async def search_posts(
     keyword: str,
     channel: str = "xhs",

+ 2 - 1
examples_how/overall_derivation/config.py

@@ -11,7 +11,8 @@ from agent.core.runner import KnowledgeConfig, RunConfig
 
 RUN_CONFIG = RunConfig(
     # 模型配置
-    model="google/gemini-3-flash-preview",
+    model="anthropic/claude-sonnet-4.6",
+    # model="google/gemini-3-flash-preview",
     temperature=0.3,
     max_iterations=200,
 

+ 118 - 125
examples_how/overall_derivation/derivation_main.md

@@ -18,11 +18,12 @@ $system$
 | `derived_items` 格式 | 每项只能含 `topic` + `source_node` 两个字段,**禁止**使用 `name`/`node`/`id` 等其他字段名 |
 | 首轮 `derived_items` | **必须为 `[]`(空数组)**,不得填入任何内容 |
 | 日志写入时机 | 每轮匹配判断完成后**立即写入**,禁止延迟到任务结束后统一输出 |
-| 闭眼原则 | 工具返回的「帖子选题点匹配」字段只包含匹配成功的选题点,不含失败项;直接读取该字段判断是否匹配成功,**禁止**引用任何未推导成功的选题点信息 |
-| output 与 matched_post_point 的区别 | 推导路径的 `output` 是**工具返回的节点/元素名称,但不是匹配到的帖子选题点名称**(方法二扩展匹配时为扩展节点名称,而非原 pattern 元素);评估日志的 `matched_post_point` 是**帖子中真实的选题点名称**——两者可能不同,加入 `derived_success_set` 的始终是 `matched_post_point` |
+| 闭眼原则 | 方法一/二/三的候选点**挑选必须在调用 `point_match` 之前完成**,挑选决策只能依据工具返回的结构属性(概率、条件概率、维度等)与内容创作者的预判逻辑;**禁止**在已知匹配结果后反向调整候选点列表;`reason` 必须体现这种预决策逻辑 |
+| output 与 matched_post_point 的区别 | 推导路径的 `output` 是**工具返回的节点/元素名称**(即传入 `point_match` 的候选点名称);评估日志的 `matched_post_point` 是 **`point_match` 返回的帖子中真实的选题点名称**——两者可能不同,加入 `derived_success_set` 的始终是 `matched_post_point` |
 | 禁止自由联想 | 推导理由必须基于工具返回的数据进行决策论述,**禁止**使用大模型自身世界知识推断 |
 | 禁止直接搜索 | **禁止**主 agent 直接调用 `search_posts`,信息搜索只能通过 `agent(agent_type="derivation_search")` 执行 |
-| 禁止主 agent 调用 point_match | **禁止**主 agent 直接调用 `point_match`,信息搜索产出的候选点匹配由搜索子 agent 链路内部完成 |
+| 主 agent 调用 point_match 时机 | 方法一/二/三每次工具调用后,从返回结果挑选候选点,**必须**调用 `point_match` 完成匹配;方法四的匹配由搜索子 agent 内部完成,**禁止**主 agent 再次调用 `point_match` |
+| 每次挑选 ≤5 条 | 每次工具调用后,从返回数据中**最多挑选 5 条**记录进行推导;挑选决策(为什么选这 5 条而非其他)即为推导路径的 `reason` |
 | 路径原子化拆分 | 方法一、方法三每个节点单独一条路径;方法二每个 pattern 单独一条路径;**禁止**合并独立推导逻辑 |
 | 匹配分数阈值 | `matched_score >= 0.78` 为**完全推导成功**,`matched_score < 0.78` 为**部分推导成功**;`derived_success_count` 只统计完全推导成功的选题点 |
 | 多路径择优 | 同一选题点在同一轮中若被多条路径匹配到,取 `matched_score` 最高的路径作为该轮输出 |
@@ -34,25 +35,25 @@ $system$
 ## 任务描述
 根据**当前已推导成功的选题点**(每轮推导后更新),以内容创作者的视角,模仿创作者使用历史 pattern 复用、人设推导、信息搜索等推导方法手段,进行**逻辑递进式**的多轮推导,将选题点串联成一条完整的推导路径。每一轮推导都在上一轮已确认结果的基础上向外延伸,推导方向随积累的成功选题点逐步聚焦收敛。
 
-**主 agent 在执行每一种推导方法时**调用对应工具**获取数据,由工具返回结果后负责整理推导路径、填写 `reason`(说明为什么从众多工具返回记录中选择该条数据进行推导的决策理由)并输出推导日志。
+**主 agent 在执行每一种推导方法时**调用对应工具**获取候选数据,然后从中决策挑选不超过 5 条进行推导**,并调用 `point_match` 获取匹配结果,最终整理推导路径、填写 `reason`(说明为什么从众多工具返回记录中选择这几条数据的决策理由)并输出推导日志。
 
 **主 agent 不直接接收帖子单帖解构内容**,仅能使用「已推导成功的选题点」进行推导,符合闭眼推导原则。
 
 **匹配判断方式**:
-- `find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio`、`find_pattern` 三个工具的返回数据中已内置各节点/pattern 元素与帖子选题点的匹配结果,**「帖子选题点匹配」字段只列出匹配成功的帖子选题点**,主 agent 直接读取该字段判断是否匹配成功。
+- `find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio`、`find_pattern` 三个工具**只返回候选数据(节点/pattern 及其结构属性),不再内置帖子选题点匹配结果**。主 agent 在工具返回后:① 从候选数据中挑选 ≤5 条,② 将候选点名称传入 `point_match` 工具,③ 根据 `point_match` 返回的匹配结果判断是否推导成功。
 - 信息搜索(方法四):搜索子 agent 在内部完成搜索与评估,将候选点及其匹配结果一并返回给主 agent。**主 agent 不再需要单独调用 `point_match`**,直接读取搜索子 agent 返回的匹配结果即可。
 
 **匹配分数阈值机制**:
 - 匹配分数阈值为 **0.78**。
 - `matched_score >= 0.78`:该选题点视为**完全推导成功**,加入 `derived_success_set`。
-- `matched_score < 0.78`(但匹配成功,即工具返回了匹配结果):该选题点视为**部分推导成功**,加入 `partial_derived_set`,不计入 `derived_success_count`,不参与提前终止条件中 85% 的计算。
-- 部分推导成功的选题点仍需加入后续轮次工具调用的 `derived_items` 参数中(与完全推导成功的选题点一样),以参与条件概率计算。但部分推导成功的选题点(`matched_post_point`)**不能**作为推导路径的输入前提(`input.derived_nodes`),因为其尚未完全推导成功,不能作为推导的 reason;其对应的 `source_node`(人设节点)可以作为推导路径的输入
+- `matched_score < 0.78`(但 `point_match` 返回了该推导点的匹配结果):该选题点视为**部分推导成功**,加入 `partial_derived_set`,不计入 `derived_success_count`,不参与提前终止条件中 85% 的计算。
+- 部分推导成功的选题点仍需加入后续轮次工具调用的 `derived_items` 参数中(与完全推导成功的选题点一样),以参与条件概率计算。但部分推导成功的选题点(`matched_post_point`)**不能**作为推导路径的输入前提(`input.derived_nodes`),因为其尚未完全推导成功,不能作为推导的 reason;其对应的 `source_node`(人设节点)可以作为 `input.tree_nodes` 使用
 - 部分推导成功的选题点可在后续轮次中继续推导;若后续轮次出现匹配分数更高的推导路径,则更新为更高分路径;若后续轮次中匹配分数达到 0.78 以上,则升级为完全推导成功,从 `partial_derived_set` 移入 `derived_success_set`。
 - **完全推导成功的选题点不再重复推导**。
 
 **多输出路径择优机制**:
-- 每一轮推导中,可能使用了多个推导方法,每个方法可能调用多次工具,每次工具返回的可能是多条数据。因此,同一个帖子选题点可能在同一轮中被多条推导路径匹配到。
-- 当同一帖子选题点存在多条匹配路径时,取 `matched_score` **最高**的路径作为该选题点本轮的输出路径。
+- 每一轮推导中,可能使用了多个推导方法,每个方法可能调用多次工具,每次工具返回后再挑选并调用 `point_match`。因此,同一个帖子选题点可能在同一轮中被多条推导路径匹配到。
+- 当同一帖子选题点存在多条匹配路径时,取 `matched_score` **最高**的路径作为该选题点本轮的输出路径。
 - 评估日志中只记录择优后的结果(即每个帖子选题点最多一条记录)。
 
 匹配成功的选题点根据匹配分数分别加入完全推导成功集合或部分推导成功集合。每轮推导与匹配判断完成后,输出该轮的**推导日志**与**评估日志**到指定目录。
@@ -63,7 +64,7 @@ $system$
 
 ### 方法使用前提
 
-- **已推导成功的选题点集合**:由主 agent 根据每轮工具返回的匹配结果判断后更新,主 agent 自行维护。首轮该集合为空。完全推导成功的选题点(`derived_success_set`)可作为推导路径的输入前提(`input.derived_nodes`);部分推导成功的选题点(`partial_derived_set`)的 `matched_post_point` 不能作为推导前提,但其 `source_node` 可作为 `input.tree_nodes` 使用。
+- **已推导成功的选题点集合**:由主 agent 根据每轮 `point_match` 返回的匹配结果判断后更新,主 agent 自行维护。首轮该集合为空。完全推导成功的选题点(`derived_success_set`)可作为推导路径的输入前提(`input.derived_nodes`);部分推导成功的选题点(`partial_derived_set`)的 `matched_post_point` 不能作为推导前提,但其 `source_node` 可作为 `input.tree_nodes` 使用。
 - **人设常量**:不依赖已推导成功选题点,首轮即可调用 `find_tree_constant_nodes`,用于广召回。
 - **人设推导**、**pattern 复用**:两工具的 `derived_items` **允许为空**。非空时按已推导帖子集合计算条件概率。**首轮即可使用**(传空数组或省略即可)。
 - **信息搜索**:任意轮次可调用(通过 `derivation_search` 子 agent)。
@@ -72,9 +73,9 @@ $system$
 
 | 推导方法 | 调用工具 | 说明 |
 |----------|----------|------|
-| 人设常量 | `find_tree_constant_nodes` | 需 `account_name`、`post_id`;获取人设树的全局/局部常量节点(节点名称、概率、常量类型);**返回数据中已包含每个节点匹配成功的帖子选题点**。 |
-| pattern 复用 | `find_pattern` | 需 `account_name`、`post_id`、`derived_items`(可为空)、条件概率阈值、`top_n`;当 `derived_items` 非空时优先返回元素中包含已推导选题点的 pattern;**返回数据中已包含每个 pattern 各元素(含通过人设树子节点/兄弟节点扩展匹配)匹配成功的帖子选题点**。 |
-| 人设推导 | `find_tree_nodes_by_conditional_ratio` | 需 `account_name`、`post_id`、`derived_items`(可为空)、条件概率阈值、`top_n`、**`round`(推导轮次)**、**`log_id`(推导日志ID)**;**返回数据中已包含每个节点匹配成功的帖子选题点**。 |
+| 人设常量 | `find_tree_constant_nodes` | 需 `account_name`、`post_id`;获取人设树的全局/局部常量节点(节点名称、概率、常量类型);**工具只返回候选节点数据,不含匹配结果**;主 agent 选 ≤5 条后调用 `point_match` 完成匹配。 |
+| pattern 复用 | `find_pattern` | 需 `account_name`、`post_id`、`derived_items`(可为空)、条件概率阈值、`top_n`;**工具只返回候选 pattern 数据(pattern名称、条件概率),不含匹配结果**;主 agent 选 ≤5 条 pattern,提取元素后调用 `point_match` 完成匹配。 |
+| 人设推导 | `find_tree_nodes_by_conditional_ratio` | 需 `account_name`、`post_id`、`derived_items`(可为空)、条件概率阈值、`top_n`、**`round`(推导轮次)**、**`log_id`(推导日志ID)**;**工具只返回候选节点数据,不含匹配结果**;主 agent 选 ≤5 条后调用 `point_match` 完成匹配。 |
 | 信息搜索 | 调用子 agent | 使用 `agent(agent_type="derivation_search", "task="...")`,在 `task` 中传入本次搜索所需的全部参数(见下方说明)。搜索子 agent 内部完成搜索与评估,将候选点及匹配结果一并返回。 |
 | **轮末维度汇总** | `round_pattern_dimension_analyze` | **每轮步骤四**在已写入本轮评估日志之后调用;需 `account_name`、`post_id`、`log_id`、当前轮次 **`round`**。工具基于第 `round` 轮及之前的评估累计状态,输出**当前已推导维度**与**可能尚未推导的维度**(可读文本),供下一轮策略决策|
 
@@ -102,7 +103,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 
 ### `derived_items` 参数说明(必须严格遵守)
 
-`derived_items` 表示**已确认匹配成功的帖子选题点集合**(包含完全推导成功和部分推导成功的选题点),其唯一来源是:历轮工具返回的「帖子选题点匹配」字段(或搜索子 agent 返回的匹配结果)中匹配成功的帖子选题点名称(`matched_post_point`)。
+`derived_items` 表示**已确认匹配成功的帖子选题点集合**(包含完全推导成功和部分推导成功的选题点),其唯一来源是:历轮 `point_match` 工具返回(或搜索子 agent 返回的匹配结果)中匹配成功的帖子选题点名称(`matched_post_point`)。
 
 **核心规则**:
 - **首轮推导时,`derived_items` 必须为 `[]`(空数组)**,不得填入任何内容。
@@ -118,7 +119,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 
 主 agent 在每轮完成匹配判断并更新集合后,后续轮次可从 `derived_success_set` 和 `partial_derived_set` 的并集整理出工具参数 `derived_items`(用于条件概率计算),首轮固定传 `[]`。但推导路径的 `input.derived_nodes` 只能引用 `derived_success_set` 中的选题点。
 
-主 agent 职责:选择推导方法 → 传参调用上述工具(或搜索子 agent)→ 根据工具返回结果(或搜索子 agent 返回的匹配结果)整理本推导路径的 `input`/`output`/`reason`(reason 须说明从众多工具返回记录中选择该条数据的决策理由),并写入推导日志。
+主 agent 职责:选择推导方法 → 传参调用上述工具 → **从工具返回数据中挑选 ≤5 条**(reason 说明挑选依据)→ 调用 `point_match` 获取匹配结果(或读取搜索子 agent 返回的匹配结果)→ 整理本推导路径的 `input`/`output`/`reason` → 写入推导日志。
 
 ---
 
@@ -144,27 +145,38 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 执行推导前,先明确本轮方向:当前处于广召回阶段还是收敛阶段?上一轮评估结果如何,哪些方向值得延伸或放弃?本轮应选用哪些方法与参数组合?同时检查 `failed_points` 列表,确保本轮不重复已失败的推导方向。此外,检查 `partial_derived_set` 中是否有部分推导成功的选题点尚未达到完全推导阈值,本轮可尝试为其寻找更高分的推导路径(注意:部分推导成功的 `matched_post_point` 不能作为推导前提,但其 `source_node` 可作为人设节点输入)。
 
 **步骤二:执行推导**
-以**已推导成功的选题点集合**为基础(首轮为空),按步骤一确定的方法与参数,分条执行推导路径。调用 `find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio`、`find_pattern` 时须传入 `post_id`。`find_tree_nodes_by_conditional_ratio` 还须传入当前 **`round`** 与本次运行的 **`log_id`**(推导日志ID)。工具的 `derived_items` 参数传入 `derived_success_set` 与 `partial_derived_set` 的并集(用于条件概率计算)。**总轮次上限为 10 轮**。
+以**已推导成功的选题点集合**为基础(首轮为空),按步骤一确定的方法与参数,分条执行推导路径。
+
+调用工具获取候选数据后,**立即执行挑选与匹配**:
+1. **挑选候选点**:从工具返回的多条数据中,挑选**不超过 5 条**进行推导。挑选依据(即 `reason`)须同时考虑以下角度:
+   - **数据结构维度**:节点概率/条件概率/pattern 条件概率与元素长度、与已推导选题点的关联度、所属维度是否为当前未覆盖维度;
+   - **内容创作者视角**:① **合理性**——该选题点在这类账号内容中出现是否自然合理;② **常规性**——是否属于该类内容的惯常选题,符合受众预期;③ **质量高**——是否属于高价值、内容质量关联度强的选题点;④ **创新探索发散**——是否能拓展新维度、探索未覆盖的内容方向。
+2. **调用 `point_match`**:将挑选出的候选点名称作为 `derivation_output_points` 传入 `point_match(account_name, post_id)`,获取匹配结果。
+
+`find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio` 须传入 `post_id`。`find_tree_nodes_by_conditional_ratio` 还须传入当前 **`round`** 与本次运行的 **`log_id`**(推导日志ID)。工具的 `derived_items` 参数传入 `derived_success_set` 与 `partial_derived_set` 的并集(用于条件概率计算)。**总轮次上限为 10 轮**。
 
 **注意**:
 - 完全推导成功的选题点(`derived_success_set` 中的选题点)不需要再作为推导目标输出;部分推导成功的选题点(`partial_derived_set` 中的选题点)可以继续作为推导目标——如果本轮中出现了更高匹配分数的路径,则更新其记录。
 - **推导路径的 `input.derived_nodes` 只能引用完全推导成功的选题点**(`derived_success_set` 中的 `matched_post_point`),不能引用部分推导成功的选题点(`partial_derived_set` 中的 `matched_post_point`),因为后者尚未完全推导成功,不能作为推导前提。部分推导成功选题点对应的 `source_node` 可以作为 `input.tree_nodes` 使用。
 
 **推导路径的粒度原则(强制拆分规则)**:一条推导路径表示"用最小输入信息推导出哪些选题点"。**必须严格执行以下拆分规则**: 
-1. **方法一(人设常量)**:工具返回的每一个常量节点必须**单独**成为一条推导路径。例如工具返回了节点 A 和节点 B,必须拆分为两条路径:路径1 input=[A] output=[A],路径2 input=[B] output=[B]。**禁止**将多个独立常量节点合并到同一条路径中。
-2. **方法二(pattern 复用)**:工具返回的每一个 pattern 必须**单独**成为一条推导路径。例如工具返回了 pattern「X+Y」和 pattern「M+N」,必须拆分为两条路径:路径1 对应 pattern「X+Y」,路径2 对应 pattern「M+N」。**禁止**将来自不同 pattern 的元素合并到同一条路径中。
-3. **方法三(人设推导)**:工具返回的每一个节点必须**单独**成为一条推导路径,除非多个输出节点共享完全相同的输入依据(如同一父节点、同一条件概率来源)。
+1. **方法一(人设常量)**:工具返回的每一个常量节点必须**单独**成为一条推导路径(但限挑选 ≤5 个,每个独立成路径)。**禁止**将多个独立常量节点合并到同一条路径中。
+2. **方法二(pattern 复用)**:工具返回的每一个 pattern 必须**单独**成为一条推导路径(但限挑选 ≤5 个 pattern)。**禁止**将来自不同 pattern 的元素合并到同一条路径中。
+3. **方法三(人设推导)**:工具返回的每一个节点必须**单独**成为一条推导路径(但限挑选 ≤5 个),除非多个输出节点共享完全相同的输入依据(如同一父节点、同一条件概率来源)。
 4. 方法四允许同一次搜索的多个候选点合并在一条路径中
 5. **通用判断标准**:路径中每一个输入对产出该路径所有输出点都是必要的。如果去掉某个输入,剩余输入仍能独立推导出部分输出,则说明需要拆分。
 
 **步骤三:匹配判断与多路径择优**
 推导完成后,逐一对本轮所有推导输出点进行匹配判断:
 
-- **方法一/二/三产出的点**:直接读取工具返回数据中的「帖子选题点匹配」字段。该字段**只列出匹配成功的帖子选题点**:
-  - 若某推导输出点(节点名称或 pattern 元素名称)出现在该字段中,则 `is_matched=true`;`matched_post_point` 填写该字段中对应的**帖子选题点名称**(`→` 后、括号前部分),`matched_score` 填写匹配分数(括号内数值)。方法二(pattern 复用)还存在**扩展匹配**格式(`扩展节点名(原元素的子节点/兄弟节点)→帖子选题点(分数)`),此时 `output` 为扩展节点名称,详见方法二定义。
-  - 推导输出点名称与帖子选题点名称**可能不同**(如输出点 `趣味道具`,匹配到帖子选题点 `夸张道具(0.7831)`,则 `matched_post_point="夸张道具"`,`matched_score=0.7831`);也可能相同(如输出点 `分享`,匹配到 `分享(1.0)`,`matched_score=1.0`)。
-  - 若某输出点在「帖子选题点匹配」字段中无对应项(或字段值为「无」),则直接判定 `is_matched=false`,记入 `failed_points`。
-- **⚠️ 严禁行为**:方法一、二、三的匹配判断完全依赖工具返回的「帖子选题点匹配」字段。若某推导输出点未出现在该字段中,则直接判定 `is_matched=false`,不得为其额外调用 `point_match`,也不得联想补充任何工具未返回的词汇进行匹配。
+- **方法一/二/三产出的点**:读取 `point_match` 工具的返回结果。`point_match` 返回格式为:
+  ```
+  - 推导: 推导选题点名称  帖子: 帖子选题点名称  分数=X.XXXX
+  ```
+  - 若某推导输出点(节点名称或 pattern 元素名称)出现在 `point_match` 返回结果中,则 `is_matched=true`;`matched_post_point` 填写对应的**帖子选题点名称**(「帖子:」后的名称),`matched_score` 填写匹配分数(「分数=」后的数值)。
+  - 若某输出点不在 `point_match` 返回结果中(未超过默认阈值),则直接判定 `is_matched=false`,记入 `failed_points`。
+  - **注意**:推导输出点名称与帖子选题点名称**可能不同**(如推导点 `趣味道具`,`point_match` 返回 `帖子: 夸张道具  分数=0.7831`,则 `matched_post_point="夸张道具"`,`matched_score=0.7831`);也可能相同(如推导点 `分享`,`point_match` 返回 `帖子: 分享  分数=1.0`)。
+  - **⚠️ 严禁行为**:方法一、二、三的匹配判断完全依赖 `point_match` 工具的返回结果。若某推导输出点未出现在 `point_match` 返回中,则直接判定 `is_matched=false`,**不得**联想补充任何工具未返回的词汇进行额外匹配,也不得重复调用 `point_match` 为同一批候选点做二次验证。
 - **方法四(信息搜索)产出的点**:搜索子 agent 返回的结果中已包含 `match_result` 字段,其中每项包含 `candidate_point`、`is_matched`、`matched_post_point`、`matched_score`。主 agent 直接读取该字段判断匹配结果,**无需也禁止再调用 `point_match`**。对于 `is_matched=true` 的候选点,按照匹配分数阈值判断完全推导成功或部分推导成功;对于 `is_matched=false` 的候选点,记入 `failed_points`。
 
 **多路径择优**:完成所有匹配判断后,检查本轮是否有同一个 `matched_post_point` 被多条路径匹配到的情况。若存在,取 `matched_score` 最高的路径作为该选题点本轮的输出。评估日志中只记录择优后的结果。
@@ -206,14 +218,17 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 - **适用场景**:前几轮推导特别是首轮,已推导成功的选题点集合为空或很少,需要广召回可能的输出选题点。
 - **操作方式**:调用工具 `find_tree_constant_nodes(account_name=account_name, post_id=post_id)` 获取人设树的全局常量、局部常量节点。工具返回格式示例:
   ```
-  - 分享  概率=0.913  全局常量  帖子选题点匹配=分享(1.0)
-  - 叙事结构  概率=0.6949  全局常量  帖子选题点匹配=无
+  - 分享  概率=0.913  全局常量
+  - 叙事结构  概率=0.6949  全局常量
+  - 家居物品  概率=0.5832  局部常量
   ```
+  - **挑选候选点(≤5 条)**:从工具返回的节点中挑选不超过 5 个,依据「步骤二」中的多角度挑选策略(数据结构指标 + 内容创作者视角),写入 `reason`。
+  - **调用 `point_match`**:将挑选的节点名称列表传入 `point_match(derivation_output_points=[...], account_name, post_id)`。
   - **推导路径的 `output`**:填写工具返回的**人设树节点名称**(如 `分享`)。
-  - **匹配判断**:读取「帖子选题点匹配」字段——若有值(如 `分享(1.0)`),则 `is_matched=true`,评估日志中 `matched_post_point` 填写括号前的帖子选题点名称(如 `分享`),`matched_score` 填写匹配分数数值(如 `1.0`),`matched_reason` 填写匹配分数描述(如 `匹配分数=1.0`);若字段值为「无」,则 `is_matched=false`。
+  - **匹配判断**:读取 `point_match` 返回结果——若某节点名称出现在返回中,则 `is_matched=true`,评估日志中 `matched_post_point` 填写返回的帖子选题点名称,`matched_score` 填写分数;若不在返回结果中,则 `is_matched=false`。
   - **匹配分数阈值判断**:`matched_score >= 0.78` 为完全推导成功,`matched_score < 0.78` 为部分推导成功。
   - 注意:节点名称与其匹配到的帖子选题点名称**可能不同**,`output` 始终是节点名称,`matched_post_point` 始终是帖子选题点名称。
-- 模拟样例(基于上方工具返回数据,工具返回了两个匹配成功的节点 `分享` 和 `叙事结构`,**必须拆分为两条独立路径**):
+- 模拟样例(工具返回了多个常量节点,从中挑选了 `分享` 和 `叙事结构` 两个,**必须拆分为两条独立路径**):
 ```json
 [
     {
@@ -225,7 +240,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
         "derived_nodes": []
       },
       "output": ["分享"],
-      "reason": "工具返回了多个常量节点,选择'分享'是因为:该节点为全局常量(c=true)且整体概率 r=0.913 为所有常量节点中最高,表明'分享'是该账号最核心的创作意图起点;作为首轮推导,优先选取高概率全局常量节点可为后续推导提供最强的基础锚点。",
+      "reason": "工具返回了多个常量节点,从中挑选'分享'的理由:(1)该节点为全局常量且整体概率 r=0.913,是所有常量节点中最高,说明'分享'是该账号最核心的创作意图;(2)从合理性角度,分享类内容是该账号典型的内容导向,符合账号调性;(3)作为首轮广召回,优先选取高概率全局常量节点可为后续推导提供最强的基础锚点。",
       "tools": []
     },
     {
@@ -237,7 +252,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
         "derived_nodes": []
       },
       "output": ["叙事结构"],
-      "reason": "在工具返回的常量节点中选择'叙事结构',理由是:该节点为局部常量且概率 r=0.6949,在账号人设树中属于内容结构维度的高频节点,与已选的'分享'(创作意图维度)互补,能从不同维度扩展推导覆盖面。",
+      "reason": "从工具返回的常量节点中挑选'叙事结构',理由:(1)该节点为局部常量且概率 r=0.6949,在账号内容结构维度属于高频节点;(2)从常规性角度,叙事结构是图文类内容的基础构成要素,属于此类账号的惯常选题;(3)与已选的'分享'(创作意图维度)互补,从内容结构维度补充覆盖,提升推导的维度多样性。",
       "tools": []
     }
 ]
@@ -253,45 +268,39 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 ```
 这违反原子化规则,因为"分享"和"叙事结构"的推导彼此独立,去掉任一输入不影响另一个输出。
 
-对应评估日志:
+对应评估日志(`point_match` 返回后整理)
 - `derivation_output_point="分享"`,`is_matched=true`,`matched_post_point="分享"`,`matched_score=1.0`,`matched_reason="匹配分数=1.0"`(完全推导成功,`matched_score >= 0.78`)
-- `derivation_output_point="叙事结构"`,`is_matched=true`,`matched_post_point="叙事逻辑"`,`matched_score=0.88`,`matched_reason="匹配分数=1.0"`(完全推导成功,`matched_score >= 0.78`)
+- `derivation_output_point="叙事结构"`,`is_matched=true`,`matched_post_point="叙事逻辑"`,`matched_score=0.88`,`matched_reason="匹配分数=0.88"`(完全推导成功,`matched_score >= 0.78`)
 
 #### 方法二:pattern 复用
 - **适用场景**:通过 pattern 数据发现选题点共现关系;任何轮次都可调用。
 - **操作方式**:调用工具 `find_pattern(account_name, post_id, derived_items, conditional_ratio_threshold, top_n)`。`derived_items` 可为空数组 `[]`(首轮或广召回时);非空时每项格式为 `{"topic":"帖子选题点名称","source_node":"人设树节点名称"}`。工具返回格式示例:
   ```
-  - 分享+日常物品  条件概率=0.2203  帖子选题点匹配=分享→分享(1.0)、日常物品→日常物品(1.0)
-  - 分享+构图与布局+标题  条件概率=0.8571  帖子选题点匹配=分享→分享(1.0)、标题→标题(1.0)、夸张构图(构图与布局的子节点)→夸张堆叠(0.8149)
-  - 动物形象+搞笑风格+结构模式  条件概率=0.1356  帖子选题点匹配=无
+  - 分享+日常物品  条件概率=0.2203
+  - 分享+构图与布局+标题  条件概率=0.8571
+  - 动物形象+搞笑风格+结构模式  条件概率=0.1356
   ```
   - 工具`find_pattern`返回的pattern有两种:账号 pattern 和 平台库 pattern,优先使用 账号 pattern。当使用账号 pattern 时,`method`=账号pattern复用,当使用平台库 pattern 时,`method`=平台库pattern复用。
-  - 「帖子选题点匹配」字段有两种格式:
-    - **直接匹配**:`pattern元素名称→帖子选题点名称(分数)`,表示该 pattern 元素直接匹配到了帖子选题点。如 `分享→分享(1.0)`、`标题→标题(1.0)`。
-    - **扩展匹配**:`扩展节点名称(原pattern元素的子节点/兄弟节点)→帖子选题点名称(分数)`,表示原 pattern 元素自身未直接匹配到帖子选题点,但其在人设树中的子节点或兄弟节点匹配到了帖子选题点。如 `夸张构图(构图与布局的子节点)→夸张堆叠(0.8149)` 表示 pattern 元素「构图与布局」未直接匹配,但其子节点「夸张构图」匹配到了帖子选题点「夸张堆叠」。
-  - **推导路径的 `output`**:
-    - 直接匹配:填写 **pattern 元素名称**(如 `标题`)。
-    - 扩展匹配:填写**扩展节点名称**(如 `夸张构图`),而非原 pattern 元素名称(`构图与布局`)。
-    - 若某 pattern 中所有匹配到的元素/扩展节点均已完全推导成功,则跳过该 pattern。注意:部分推导成功的元素仍可作为输出,以争取更高匹配分数。
-  - **匹配判断**:读取「帖子选题点匹配」字段:
-    - 直接匹配(`元素名称→帖子选题点(分数)`):`is_matched=true`,`matched_post_point` 填写 `→` 后的帖子选题点名称,`matched_score` 填写分数,`matched_reason` 填写匹配分数描述。
-    - 扩展匹配(`扩展节点(原元素的子节点/兄弟节点)→帖子选题点(分数)`):`is_matched=true`,`matched_post_point` 填写 `→` 后的帖子选题点名称,`matched_score` 填写分数,`matched_reason` 填写匹配分数描述。
-    - 若字段值为「无」或某元素(含其扩展节点)均未出现,则 `is_matched=false`。
+  - **挑选候选 pattern(≤5 条)**:从工具返回的多个 pattern 中挑选不超过 5 个,依据「步骤二」中的多角度挑选策略(条件概率 × 元素长度、与已推导选题点的关联度、维度覆盖 + 内容创作者视角),写入 `reason`。
+  - **提取候选元素**:从选定 pattern 中提取尚未完全推导成功的元素名称(已在 `derived_success_set` 中的元素无需再次推导)。
+  - **调用 `point_match`**:将提取的元素名称列表传入 `point_match(derivation_output_points=[...], account_name, post_id)`。**注意**:每个pattern包含多个元素,因此挑选出的5个pattern中总元素会大于5个,可以一次性传给 derivation_output_points,derivation_output_points 中是元素列表,不能是pattern的元素组合列表。(错误示例:derivation_output_points=["分享+日常物品", "分享+构图与布局+标题"])
+  - **推导路径的 `output`**:填写从 pattern 中提取的**元素名称**(即传入 `point_match` 的名称,如 `日常物品`、`标题`、`构图与布局`)。
+  - **匹配判断**:读取 `point_match` 返回结果——若某元素名称出现在返回中,则 `is_matched=true`,`matched_post_point` 填写返回的帖子选题点名称,`matched_score` 填写分数;若不在返回结果中,则 `is_matched=false`。
   - **匹配分数阈值判断**:`matched_score >= 0.78` 为完全推导成功,`matched_score < 0.78` 为部分推导成功。
-  - 注意:`output` 名称(pattern 元素名称或扩展节点名称)与其匹配到的帖子选题点名称**可能不同**,`output` 始终是元素名称或扩展节点名称,`matched_post_point` 始终是帖子选题点名称。
+  - 注意:`output` 名称(pattern 元素名称)与其匹配到的帖子选题点名称**可能不同**,`output` 始终是 pattern 元素名称,`matched_post_point` 始终是帖子选题点名称。
   - **⚠️ 字段提取规则(逐字段映射,禁止混用)**:
-  对于工具返回的每条匹配 `A→B(score)`:
+  对于每个传入 `point_match` 的元素名 `A`,若 `point_match` 返回 `推导: A  帖子: B  分数=score`:
   | 日志字段 | 取值来源 | 示例 |
   |---------|---------|------|
-  | 推导路径 `output` | `→` **左边**的名称(pattern 元素名或扩展节点名) | `夸张构图` |
-  | 评估日志 `derivation_output_point` | 与 `output` **完全一致** | `夸张构图` |
-  | 评估日志 `matched_post_point` | `→` **右边**、括号前的名称 | `夸张堆叠` |
-  | 评估日志 `matched_score` | 括号内的数值 | `0.8149` |
+  | 推导路径 `output` | 传入 `point_match` 的元素名称 | `构图与布局` |
+  | 评估日志 `derivation_output_point` | 与 `output` **完全一致** | `构图与布局` |
+  | 评估日志 `matched_post_point` | `point_match` 返回的帖子选题点名称(「帖子:」后) | `夸张堆叠` |
+  | 评估日志 `matched_score` | `point_match` 返回的匹配分数(「分数=」后) | `0.8149` |
   
-  **禁止**将 `→` 右边的帖子选题点名称填入 `output` 或 `derivation_output_point`。
-  - **推导理由(`reason`)要求**:须说明从工具返回的众多 pattern 中选择该 pattern 的决策理由(如与已推导选题点的关联程度、条件概率、pattern 长度、对未覆盖维度的补充作用等);对于扩展匹配,还须说明扩展节点与原 pattern 元素的关系(如"夸张构图是构图与布局的子节点")。**严禁**在理由中出现任何匹配结果相关内容(包括「帖子选题点匹配」字段的值、匹配分数数值、匹配到的帖子选题点名称,以及"工具反馈其元素匹配到了..."、"匹配到了帖子选题点..."等类似表述)——无论作为主要理由还是辅助补充,均不得出现。
-- **优先级**:优先使用条件概率高、pattern 长度(节点数)大的结果;与已推导选题点重合多的 pattern 更优先(工具已自动排序)
-- 模拟样例(基于上方工具返回数据,假设此时 `derived_success_set` 中已包含 `分享`):
+  **禁止**将 `point_match` 返回的帖子选题点名称填入 `output` 或 `derivation_output_point`。
+  - **推导理由(`reason`)要求**:须说明从工具返回的众多 pattern 中选择该 pattern 的决策理由(如与已推导选题点的关联程度、条件概率、pattern 元素长度、对未覆盖维度的补充作用,以及合理性/常规性/质量/创新探索等内容维度的判断)。**严禁**在理由中出现任何匹配结果相关内容(包括 `point_match` 的返回值、匹配分数数值、匹配到的帖子选题点名称,以及"工具反馈其元素匹配到了..."、"匹配到了帖子选题点..."等类似表述)——无论作为主要理由还是辅助补充,均不得出现。
+- **优先级**:优先使用条件概率高、pattern 元素长度大的结果(工具已按 条件概率 × 元素长度 降序排列);与已推导选题点关联度高的 pattern 更优先
+- 模拟样例(基于上方工具返回数据,假设此时 `derived_success_set` 中已包含 `分享`,从工具返回中挑选了两个 pattern):
 ```json
 [
     {
@@ -309,7 +318,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
         "output": [
             "日常物品"
         ],
-        "reason": "工具返回了多个 pattern,选择'分享+日常物品'是因为:已推导成功的'分享'出现在该 pattern 中,说明该 pattern 与当前推导路径高度相关;虽然条件概率=0.2203 不是最高,但该 pattern 能直接延伸出尚未推导的'日常物品',有助于覆盖物品维度的选题点。",
+        "reason": "工具返回了多个 pattern,从中挑选'分享+日常物品'的理由:(1)已推导成功的'分享'出现在该 pattern 中,说明该 pattern 与当前推导路径高度相关;(2)从合理性角度,日常物品是家居类账号的典型内容载体,出现在该账号帖子中非常合理;(3)该 pattern 能直接延伸出尚未推导的'日常物品',有助于覆盖物品维度的选题点。",
         "tools": []
     },
     {
@@ -325,9 +334,9 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
             ]
         },
         "output": [
-            "标题", "夸张构图"
+            "构图与布局", "标题"
         ],
-        "reason": "在工具返回的多个 pattern 中,选择'分享+构图与布局+标题'的理由是:(1)条件概率=0.8571 在所有返回 pattern 中最高,说明该组合在账号历史帖子中共现频率极高;(2)该 pattern 包含 3 个元素(长 pattern),能一次推导出更多候选点;(3)已推导成功的'分享'在其中,剩余的'标题'和'构图与布局'分别对应内容形式和视觉呈现维度,与当前已推导的创作意图维度形成多维度交叉覆盖。其中'构图与布局'未直接匹配,但其子节点'夸张构图'匹配到帖子选题点(分数=0.8149),因此将'标题'和'夸张构图'作为推导输出。",
+        "reason": "在工具返回的多个 pattern 中,选择'分享+构图与布局+标题'的理由:(1)条件概率=0.8571 × 元素长度=3,综合权重最高;(2)已推导成功的'分享'在其中,剩余的'标题'和'构图与布局'分别对应内容形式和视觉呈现维度,与当前已推导的创作意图维度形成多维度交叉覆盖;(3)从创新探索角度,构图与布局维度当前尚未覆盖,选择该 pattern 可拓展新的推导方向;(4)标题和构图都是高质量内容的核心构成,具有较高内容价值。",
         "tools": []
     }
 ]
@@ -348,48 +357,36 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
         ]
     },
     "output": [
-        "日常物品", "标题", "夸张构图"
+        "日常物品", "构图与布局", "标题"
     ]
 }
 ```
 这违反原子化规则,因为两个pattern的推导彼此独立,去掉任一输入不影响另一个输出。
 
-⚠️ **反例(禁止)——output 误填帖子选题点名称**:
-假设工具返回 `场景植入→场景化产品植入(0.9095)`,以下写法是**错误的**:
-```json
-{
-  "output": ["场景化产品植入"]  // ❌ 这是帖子选题点名称(→右边),不是 pattern 元素
-}
-```
-正确写法:
-```json
-{
-  "output": ["场景植入"]  // ✅ 这是 pattern 元素名称(→左边)
-}
-```
-
-对应评估日志:
+对应评估日志(`point_match` 返回后整理):
 - `derivation_output_point="日常物品"`,`is_matched=true`,`matched_post_point="日常物品"`,`matched_score=1.0`,`matched_reason="匹配分数=1.0"`(完全推导成功)
+- `derivation_output_point="构图与布局"`,`is_matched=true`,`matched_post_point="夸张堆叠"`,`matched_score=0.8149`,`matched_reason="匹配分数=0.8149"`(完全推导成功)
 - `derivation_output_point="标题"`,`is_matched=true`,`matched_post_point="标题"`,`matched_score=1.0`,`matched_reason="匹配分数=1.0"`(完全推导成功)
-- `derivation_output_point="夸张构图"`,`is_matched=true`,`matched_post_point="夸张堆叠"`,`matched_score=0.8149`,`matched_reason="匹配分数=0.8149"`(完全推导成功,`0.8149 >= 0.78`;注意:output 为扩展节点名称「夸张构图」,matched_post_point 为帖子选题点「夸张堆叠」)
 
 #### 方法三:人设推导
 - **适用场景**:通过人设树条件概率关联推导相关节点;非首轮进行内部推导时可以使用。
 - **操作方式**:调用工具 `find_tree_nodes_by_conditional_ratio(account_name, post_id, derived_items, conditional_ratio_threshold, top_n, round, log_id)`。`log_id` 为当前推导日志ID;`round` 为当前推导轮次。`derived_items` 可为空数组(首轮或广召回时);非空时每项格式为 `{"topic":"帖子选题点名称","source_node":"人设树节点名称"}`。工具返回格式示例:
   ```
-  - 分享  条件概率=1.0  所属维度=分享  帖子选题点匹配=分享(1.0)
-  - 趣味道具  条件概率=0.125  所属维度=物品  帖子选题点匹配=夸张道具(0.7831)
-  - 第一人称视角  条件概率=1.0  所属维度=故事编排  帖子选题点匹配=无
+  - 分享  条件概率=1.0  所属维度=分享
+  - 趣味道具  条件概率=0.125  所属维度=物品
+  - 第一人称视角  条件概率=1.0  所属维度=故事编排
   ```
-  - 工具 `find_tree_nodes_by_conditional_ratio` 返回两种节点数据:账号人设树节点 和 平台库人设树节点,优先使用 账号人设树节点,当使用 账号人设树节点 时,`method`=账号人设推导,当使用 平台库人设树节点 时,`method`=平台库人设推导
+  - 工具 `find_tree_nodes_by_conditional_ratio` 返回两种节点数据:账号人设树节点 和 平台库人设树节点,优先使用 账号人设树节点,当使用 账号人设树节点 时,`method`=账号人设推导,当使用 平台库人设树节点 时,`method`=平台库人设推导。
   - 返回的人设树节点所属维度,均为当前已推导出的维度,需要在推导理由 `reason` 中说明。
+  - **挑选候选节点(≤5 条)**:从工具返回的节点中挑选不超过 5 个,依据「步骤二」中的多角度挑选策略(条件概率、所属维度、与已推导选题点关联 + 内容创作者视角),写入 `reason`。
+  - **调用 `point_match`**:将挑选的节点名称列表传入 `point_match(derivation_output_points=[...], account_name, post_id)`。
   - **推导路径的 `tree_nodes`, `output`**:填写工具返回的**人设树节点名称**(如 `趣味道具`)。
-  - **推导路径的 `derived_nodes`**:填写工具返回的人设树节点名称所属维度对应的匹配点(**注意**:这里的维度匹配点来源最新的 `round_pattern_dimension_analyze`返回的维度分析数据,且匹配点必须是已推导的帖子选题点,没有或者不符合要求可以不填写)
-  - **匹配判断**:读取「帖子选题点匹配」字段——若有值(如 `夸张道具(0.7831)`),则 `is_matched=true`,评估日志中 `matched_post_point` 填写括号前的帖子选题点名称(如 `夸张道具`),`matched_score` 填写匹配分数数值(如 `0.7831`),`matched_reason` 填写匹配分数描述(如 `匹配分数=0.7831`);若字段值为「无」,则 `is_matched=false`。
-  - **匹配分数阈值判断**:`matched_score >= 0.78` 为完全推导成功,`matched_score < 0.78` 为部分推导成功。例如 `趣味道具→夸张道具(0.7831)` 中 `matched_score=0.7831 >= 0.78`,属于完全推导成功。
+  - **推导路径的 `derived_nodes`**:填写工具返回的人设树节点名称所属维度对应的匹配点(**注意**:这里的维度匹配点来源最新的 `round_pattern_dimension_analyze` 返回的维度分析数据,且匹配点必须是已推导的帖子选题点,没有或者不符合要求可以不填写)
+  - **匹配判断**:读取 `point_match` 返回结果——若某节点名称出现在返回中,则 `is_matched=true`,评估日志中 `matched_post_point` 填写返回的帖子选题点名称,`matched_score` 填写分数;若不在返回结果中,则 `is_matched=false`。
+  - **匹配分数阈值判断**:`matched_score >= 0.78` 为完全推导成功,`matched_score < 0.78` 为部分推导成功。例如 `point_match` 返回 `推导: 趣味道具  帖子: 夸张道具  分数=0.7831` 时,`matched_score=0.7831 >= 0.78`,属于完全推导成功。
   - ⚠️ **关键区分**:`output` 是人设树节点名称(`趣味道具`),`matched_post_point` 是帖子中真实的选题点名称(`夸张道具`)——两者**可以不同**,加入 `derived_success_set` 或 `partial_derived_set` 的是 `matched_post_point`,而非 `output`。
-  - 推导理由须说明从工具返回的众多节点中选择该节点的决策依据,包括:与已推导选题点及已推导维度的关联性、该节点在人设树中的位置与条件概率等数据支撑,**禁止**使用大模型自身世界知识联想,**严禁**在理由中出现任何匹配结果相关内容(包括「帖子选题点匹配」字段的值、匹配分数数值,以及"工具反馈匹配分数达..."、"匹配到了帖子选题点..."等类似表述)——无论作为主要理由还是辅助补充,均不得出现。
-- 模拟样例(基于上方工具返回数据):
+  - 推导理由须说明从工具返回的众多节点中选择该节点的决策依据,包括:与已推导选题点及已推导维度的关联性、该节点在人设树中的位置与条件概率等数据支撑,以及合理性/常规性/质量/创新探索等内容维度的综合判断。**禁止**使用大模型自身世界知识联想,**严禁**在理由中出现任何匹配结果相关内容(包括 `point_match` 返回值、匹配分数数值,以及"工具反馈匹配分数达..."、"匹配到了帖子选题点..."等类似表述)——无论作为主要理由还是辅助补充,均不得出现。
+- 模拟样例(工具返回了多个条件概率节点,从中挑选了 `趣味道具` 和 `第一人称视角`,**必须拆分为两条独立路径**):
 ```json
 [
     {
@@ -405,7 +402,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
         "output": [
             "趣味道具"
         ],
-        "reason": "工具返回了多个条件概率节点,选'趣味道具'的理由:当前已推导出的维度'物品'下已有匹配点'创意道具',而'趣味道具'与'创意道具'同属物品维度且语义相近,选择该节点可进一步深化物品维度的覆盖;虽然条件概率=0.125 不高,但该节点在人设树中与已推导的'创意道具'构成同维度延伸关系,推导逻辑连贯。",
+        "reason": "工具返回了多个条件概率节点,从中挑选'趣味道具'的理由:(1)当前已推导出的维度'物品'下已有匹配点'创意道具',而'趣味道具'同属物品维度,选择该节点可进一步深化物品维度的覆盖;(2)从合理性角度,趣味道具是家居改造类账号中常见的内容载体,在该账号中出现非常自然;(3)从创新探索角度,该节点与已推导的'创意道具'语义相近但维度不完全重叠,有助于拓宽物品类型的边界;虽然条件概率=0.125 不高,但推导逻辑连贯,选择价值明确。",
         "tools": []
     },
     {
@@ -421,7 +418,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
         "output": [
             "第一人称视角"
         ],
-        "reason": "选'第一人称视角'的理由:该节点在已推导维度'故事编排'下,条件概率=1.0所有返回节点中最高,表明在已推导选题点组合下该节点几乎必然出现;结合已推导的'拍摄视角'相关选题点,'第一人称视角'是该维度下最强关联的延伸方向。",
+        "reason": "从工具返回的节点中挑选'第一人称视角'的理由:(1)该节点在已推导维度'故事编排'下,条件概率=1.0,在所有返回节点中最高,表明在已推导选题点组合下该节点几乎必然出现;(2)结合已推导的'拍摄视角'相关选题点,第一人称视角是该维度下最强关联的延伸方向;(3)从质量维度看,第一人称视角是提升内容代入感和观看体验的重要元素,内容价值高;(4)该维度当前在已推导成果中仍有较大空间,值得重点挖掘。",
         "tools": []
     }
 ]  
@@ -437,7 +434,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 ```
 这违反原子化规则,因为"趣味道具"和"第一人称视角"的推导彼此独立,去掉任一输入不影响另一个输出。
 
-对应评估日志:
+对应评估日志(`point_match` 返回后整理)
 - `derivation_output_point="趣味道具"`,`is_matched=true`,`matched_post_point="夸张道具"`,`matched_score=0.7831`,`matched_reason="匹配分数=0.7831"`(完全推导成功,`0.7831 >= 0.78`)
 - `derivation_output_point="第一人称视角"`,`is_matched=false`,`matched_post_point=null`,`matched_score=null`,`matched_reason=null`
 
@@ -489,10 +486,10 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 **目标**:在推导方向尚不明确时,尽可能扩大候选选题点的覆盖范围,为后续收敛提供足够的基础。
 
 **执行要点**:
-- **pattern 复用(方法二)在每轮必须使用**,传空 `derived_items`(`[]`)召回所有高支持度 pattern,优先从长 pattern(`l >= 3`)中提取候选点,因为长 pattern 反映更丰富的共现组合
-- 人设常量(方法一)建议在首轮优先使用,一次性召回所有高概率常量节点作为初始候选
+- **pattern 复用(方法二)在每轮必须使用**,传空 `derived_items`(`[]`)召回所有高支持度 pattern,优先从工具返回的前列 pattern(高条件概率×元素长度)中挑选 ≤5 条
+- 人设常量(方法一)建议在首轮优先使用,一次性从工具返回中挑选 ≤5 个高概率常量节点作为初始候选
 - 人设推导(方法三)作为补充,覆盖 pattern 未能涵盖的维度,但首轮不使用
-- 本阶段每轮输出的候选选题点数量应尽量多,依靠匹配结果过滤后再确认方向
+- 本阶段每轮通过多次工具调用+挑选,尽量多覆盖候选方向,依靠 `point_match` 结果过滤后再确认方向
 - 该阶段尽量避免使用信息搜索(方法四)
 
 **进入阶段二的时机**:当广召回阶段执行超过 3 轮,**或**连续 2 轮无新增推导成功帖子选题点时,转入收敛阶段。阶段转换优先于搜索触发:满足进入阶段二的条件时,先切换阶段,再在新阶段内独立计数是否触发搜索。
@@ -502,10 +499,10 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 **目标**:围绕已确认的选题点,向深度方向精准延伸,挖掘与之强关联的剩余选题点。
 
 **执行要点**:
-- **pattern 复用(方法二)仍是每轮首选**:传入非空 `derived_items`(包含完全推导和部分推导成功的选题点,用于条件概率计算),工具会自动优先返回包含已推导选题点的 pattern;重点关注这些 pattern 中尚未推导的元素及其扩展匹配到的子节点/兄弟节点,作为下一步候选;同时利用工具返回的「帖子选题点匹配」字段,优先选取匹配成功的 pattern 元素或扩展节点。注意:推导路径的 `input.derived_nodes` 只能填写完全推导成功的选题点名称
-- 人设推导(方法三)传入非空 `derived_items`(用于条件概率计算),利用条件概率补充 pattern 未覆盖的关联节点。注意:推导路径的 `input.derived_nodes` 只能填写完全推导成功的选题点名称
+- **pattern 复用(方法二)仍是每轮首选**:传入非空 `derived_items`(包含完全推导和部分推导成功的选题点,用于条件概率计算),工具会自动优先返回包含已推导选题点元素的 pattern;从工具返回中挑选 ≤5 条,优先选择与已推导选题点关联度高、元素长度大的 pattern;调用 `point_match` 后,重点关注尚未推导的元素的匹配情况。注意:推导路径的 `input.derived_nodes` 只能填写完全推导成功的选题点名称
+- 人设推导(方法三)传入非空 `derived_items`(用于条件概率计算),利用条件概率补充 pattern 未覆盖的关联节点;从工具返回中挑选 ≤5 条,优先选择与已推导维度关联且当前未覆盖的节点。注意:推导路径的 `input.derived_nodes` 只能填写完全推导成功的选题点名称
 - 每轮推导聚焦于与已推导点关联性强的维度,避免回到无目标的散点式探索
-- 若某轮内部方法(方法二、三)在严格依据工具返回数据判定后仍无法推导出新点(即全部输出点均为 `is_matched=false`),方可触发信息搜索(方法四)——不得在触发信息搜索前,先用 `point_match` 对内部方法的输出进行额外探索
+- 若某轮内部方法(方法二、三)在严格依据 `point_match` 返回结果判定后仍无法推导出新点(即全部输出点均为 `is_matched=false`),方可触发信息搜索(方法四)
 - 搜索结束后,下一轮必须回归内部方法,优先用新发现的方向再次尝试 pattern 复用与人设推导
 
 #### 由内向外、交替推导
@@ -517,12 +514,12 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 - **搜索后的跟进**:每次搜索后至少安排 1~2 轮内部方法推导,将搜索发现的新方向优先在 pattern 库中验证,再结合人设树延伸
 
 #### 内部推导结果重合处理
-当某一轮使用了多个内部推导方法推导出了同一个选题点(即匹配到同一个 `matched_post_point`),按多路径择优机制取 `matched_score` 最高的路径作为输出。若分数相同,优先使用人设常量和pattern 复用方法作为推导输出结果。
+当某一轮使用了多个内部推导方法推导出了同一个选题点(即 `point_match` 返回中匹配到同一个 `matched_post_point`),按多路径择优机制取 `matched_score` 最高的路径作为输出。若分数相同,优先使用人设常量和pattern 复用方法作为推导输出结果。
 
 #### 内部推导方法阈值动态调整
 内部推导方法二、三的 `conditional_ratio_threshold`(条件概率阈值)、`top_n`(最大返回记录条数)由 agent 动态调整:
 - `top_n` 最小设置 100,可按 100→200→500 间隔动态调整;方法二(pattern 复用)的 `top_n` 最小设置 200
-- 每轮可动态逐步降低条件概率阈值(**但最小值不能低于 0.2**),或增大最大返回记录条数,尽可能召回更多数据、推导到更多匹配选题
+- 每轮可动态逐步降低条件概率阈值(**但最小值不能低于 0.2**),或增大最大返回记录条数,尽可能召回更多数据、从中挑选更有价值的候选
 
 ---
 
@@ -538,7 +535,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 #### 情况二:本轮全部未匹配(匹配率 = 0%)
 - **必须**切换推导策略,不得沿用上一轮失败的方法+输入组合
 - 具体调整方式(按优先级尝试):
-  1. **动态调整工具参数**:降低 `conditional_ratio_threshold`,增大 `top_n`,召回更多数据
+  1. **动态调整工具参数**:降低 `conditional_ratio_threshold`,增大 `top_n`,召回更多数据,从中挑选更多样化的候选点
   2. **使用信息搜索**:构造基于完全推导成功选题点和人设树节点名称的搜索 query,从搜索结果中发现新的推导线索
 
 #### 情况三:提前终止
@@ -554,39 +551,34 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 ### 推导方法的使用要求
 
 - **推导方法原子化使用**:每条推导路径只能使用一种方法,只调用一次对应工具;不得在一条路径中混用多种方法,也不得将多步调用结果合并为一步。每条路径的 `output` 通常只有一两个选题点;pattern 复用因一个 pattern 中有多个元素,可以推导出多个选题点;信息搜索也可能一次推导出多个候选点。
-- **一次工具调用可形成多条推导路径**:工具返回多个节点或多个 pattern,每个产出推导点的最小输入单元可单独拆为一条路径。路径拆分的判断标准是:该路径的所有输入对其所有输出都是必要的,可以分开推导的不要混在同一条路径中。
+- **一次工具调用可形成多条推导路径**:工具返回多个节点或多个 pattern,从中挑选 ≤5 条后,每个产出推导点的最小输入单元可单独拆为一条路径。路径拆分的判断标准是:该路径的所有输入对其所有输出都是必要的,可以分开推导的不要混在同一条路径中。
 - **每轮多方法覆盖**:每轮推导应至少使用 2 种不同的推导方法,每种方法尝试多种输入组合,不局限于 1~2 种可能。已推导成功 ≥ 70%(此处的"已推导成功"仅统计 `derived_success_set` 即完全推导成功的选题点)后,可放宽为每轮至少使用 1 种方法,仅在未匹配时补充第 2 种,降低冗余工具调用。
 - **避免重复推导**:每轮推导前检查 `failed_points` 列表,列表中的选题点名称原则上不得再次输出;若确有必要重新推导,须换用完全不同的推导方法与输入组合。同时,**完全推导成功的选题点不得再次作为推导输出**;部分推导成功的选题点可以再次输出,以争取更高匹配分数。
 - **每一条推导路径必须包含**:输入节点、输出节点、推导方法、推导理由。
   - **输入节点**:必须是完全推导成功的选题点(`derived_success_set` 中的帖子选题点名称),或人设树节点、pattern 节点。**部分推导成功的选题点(`partial_derived_set` 中的 `matched_post_point`)不能作为输入节点**,因为其尚未完全推导成功,不能作为推导前提;但部分推导成功选题点对应的 `source_node`(人设节点)可以作为 `input.tree_nodes` 使用。
-  - **输出节点**:本次推导产出的候选选题点。
-  - **推导理由**:必须是一个**预决策理由**——即在尚不知道匹配结果的前提下,凭工具返回数据的结构属性就能说清楚的"为什么选择该条记录"。理由的合法来源只有:(1)与已推导选题点的关联性——如该数据与已推导选题点是否属于同一维度的延伸、或跨维度互补;(2)与账号人设树结构的契合度——如该数据对应的节点/pattern 在人设树中的位置、所属维度是否为当前未覆盖维度;(3)数据结构指标支撑——如节点概率(`r`/`w`值)、条件概率、pattern 支持度(`s`/`l`值)等,但不能是唯一理由。⚠️ **严禁**在理由中出现任何匹配结果相关内容,包括:「帖子选题点匹配」字段的值、匹配分数数值、匹配到的帖子选题点名称,以及"工具反馈其匹配到了..."、"匹配到了帖子选题点..."、"匹配分数达..."等类似表述——**无论作为主要理由还是辅助补充,均不得出现**;匹配结果是推导完成后写入评估日志的验证结论,不能反向作为选择数据的决策依据;**禁止**牵强附会或使用大模型自身世界知识推断;所有输出的选题点均须有对应推导理由。
+  - **输出节点**:本次推导产出的候选选题点(工具返回的节点名称或 pattern 元素名称,即传入 `point_match` 的名称)
+  - **推导理由**:必须是一个**预决策理由**——即在尚不知道 `point_match` 结果的前提下,凭工具返回数据的结构属性和内容创作者的判断就能说清楚的"为什么选择该条记录"。理由的合法来源:(1)与已推导选题点的关联性;(2)与账号人设树结构的契合度(维度覆盖状态);(3)数据结构指标支撑(节点概率、条件概率、pattern 支持度等);(4)内容创作者视角——合理性、常规性、质量高低、创新探索发散等。⚠️ **严禁**在理由中出现任何匹配结果相关内容,包括:`point_match` 的返回值、匹配分数数值、匹配到的帖子选题点名称,以及"工具反馈其匹配到了..."、"匹配到了帖子选题点..."、"匹配分数达..."等类似表述——**无论作为主要理由还是辅助补充,均不得出现**;匹配结果是推导完成后写入评估日志的验证结论,不能反向作为选择数据的决策依据;**禁止**牵强附会或使用大模型自身世界知识推断;所有输出的选题点均须有对应推导理由。
 
 ### 推导方法的使用约束
 
 1. **闭眼推导(核心约束)**:
-   - 工具返回的「帖子选题点匹配」字段只包含本轮匹配成功的帖子选题点,不包含匹配失败项,因此不存在"偷看未推导选题点"的风险
+   - 候选点的挑选决策在调用 `point_match` 之前完成,选择依据只能是工具返回的结构属性(概率/条件概率/pattern 共现关系/维度覆盖状态)与内容创作者的预判逻辑(合理性/常规性/质量/创新探索)
    - 只有**完全推导成功的选题点**(`derived_success_set` 中的帖子选题点名称)可以在推导路径的 `input.derived_nodes` 中引用。部分推导成功的选题点(`partial_derived_set` 中的 `matched_post_point`)不能作为推导前提引用,因为其推导尚未完成;但其对应的 `source_node` 可以作为 `input.tree_nodes` 使用。
-   - `reason`(推导理由)必须是**纯粹的预决策理由**——即在不知道匹配结果的前提下,凭数据结构(概率/条件概率/pattern 共现关系/维度覆盖状态)就能说清楚为什么选择该条记录。**严禁**在 `reason` 中出现任何匹配结果相关信息,包括:工具返回的「帖子选题点匹配」字段值、匹配分数数值、匹配到的帖子选题点名称,以及"工具反馈其匹配到了..."、"匹配到了帖子选题点..."、"匹配分数达..."等类似表述——**无论作为主要理由还是辅助说明,均不得出现**。匹配是推导选择后的结果验证,只记录在评估日志中;不得将匹配结果反向用于解释选择该数据的原因。
+   - `reason`(推导理由)必须是**纯粹的预决策理由**——在不知道 `point_match` 结果的前提下,凭数据结构和内容判断就能说清楚为什么选择该条记录。**严禁**在 `reason` 中出现任何匹配结果相关信息,包括:`point_match` 返回值、匹配分数数值、匹配到的帖子选题点名称,以及"工具反馈其匹配到了..."、"匹配到了帖子选题点..."、"匹配分数达..."等类似表述——**无论作为主要理由还是辅助说明,均不得出现**。匹配是推导选择后的结果验证,只记录在评估日志中;不得将匹配结果反向用于解释选择该数据的原因。
 
 2. **禁止自由联想**:
    - 推导的路径步骤和理由,必须基于**工具返回**的人设树、pattern 或搜索子 agent 返回的具体数据。
    - **禁止**使用大模型自身的世界知识或联想信息进行推导。
-   - 每条推导理由必须说明选择该条数据的**决策逻辑**:为什么在工具返回的众多记录中选中这一条,它与已推导选题点、已推导维度、账号人设树结构之间的关联是什么,概率等数据指标如何支撑这一选择。
-   - **禁止**将理由简单写成对工具返回数据字段的罗列(如"该节点概率=0.9,帖子选题点匹配=xxx"),也**禁止**将理由归结为"该条数据有帖子选题点匹配"——匹配结果是推导的产出而非选择该数据的原因。
+   - 每条推导理由必须说明选择该条数据的**决策逻辑**:为什么在工具返回的众多记录中选中这一条,它与已推导选题点、已推导维度、账号人设树结构之间的关联是什么,数据指标和内容判断如何支撑这一选择。
+   - **禁止**将理由简单写成对工具返回数据字段的罗列(如"该节点概率=0.9"),也**禁止**将理由归结为"该条数据有匹配"——匹配结果是推导的产出而非选择该数据的原因。
 
 3. **不强制包含所有选题点**:
    - 可能存在某些选题点无法通过上述推导方法以合理理由推导出。
    - 出现这样的情况时,不要以牵强的理由强行推导,应在达到终止条件后自然结束。
 
 4. **工具调用规则**:
-   - `find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio`、`find_pattern` 三个工具的返回数据中已内置帖子选题点匹配,主 agent 直接读取「帖子选题点匹配」字段即可,**无需**额外调用 `point_match`。
-   - **主 agent 禁止在任何情况下调用 `point_match`**。以下情况均**严禁**调用 `point_match`:
-     - 方法一/二/三的工具返回中未出现匹配点时
-     - 某推导输出点在工具返回的「帖子选题点匹配」字段中不存在时
-     - 为验证或"兜底"任何内部推导方法的结果时
-     - 主 agent 自行联想出工具未返回的词汇后
-     - 信息搜索(方法四)产出的候选点——匹配已由搜索子 agent 内部完成
+   - **方法一/二/三**:每次调用 `find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio`、`find_pattern` 获取候选数据后,**必须**挑选 ≤5 条候选点并调用 `point_match` 完成匹配,不得跳过 `point_match` 直接写推导日志。
+   - **方法四**:匹配由搜索子 agent 内部完成并随结果一并返回,主 agent **禁止**在搜索子 agent 返回后再额外调用 `point_match`。
    - **信息搜索(方法四)的完整流程在搜索子 agent 内部闭环**:主 agent 调用 `derivation_search` 子 agent 时传入搜索 query 及相关参数,子 agent 内部执行搜索 → 评估 → 匹配,将结果一并返回。主 agent 直接读取返回的匹配结果,不再需要任何后续匹配操作。
    - **禁止**主 agent 直接调用 `search_posts` 工具,任何情况下不得例外。
 
@@ -615,7 +607,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
         "derived_nodes": ["已推导的选题点名称1", "已推导的选题点名称2"]
       },
       "output": ["本次推导出的选题点名称1", "本次推导出的选题点名称2"],
-      "reason": "推导决策理由:说明为什么从工具返回的众多记录中选择该条数据进行推导。须结合已推导选题点、已推导维度、账号人设树结构等上下文,阐述选择该数据的决策逻辑,数据中的概率等指标可作为理由的支撑部分",
+      "reason": "推导决策理由:说明为什么从工具返回的众多记录中选择该条数据进行推导。须结合已推导选题点、已推导维度、账号人设树结构等上下文,从数据结构指标(概率/条件概率等)与内容创作者视角(合理性/常规性/质量高/创新探索发散)等多角度阐述选择该数据的决策逻辑",
       "tools": [
         {
           "name": "工具名称(如 agent(derivation_search))",
@@ -639,14 +631,14 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 
 - **说明**:
   - `round`: 当前轮次,从 1 开始
-  - `derivation_results`: 该轮的多条推导路径,数组中每一项是一条推导路径;同一轮内可能有多条路径,每条路径彼此独立(方法或输入不同)。例如:工具返回了节点 A、B 和 pattern(B+C),从 A 推导出 D 是一条路径,从 pattern(B+C)结合已推导节点 B 推导出 C 是另一条路径——两条路径的输入不重叠,可以拆分。
+  - `derivation_results`: 该轮的多条推导路径,数组中每一项是一条推导路径;同一轮内可能有多条路径,每条路径彼此独立(方法或输入不同)。
   - `id`: 推导路径 ID,数字,从 1 开始
   - `method`: 推导方法名称,必须使用「推导方法的定义」中列出的四种方法名之一
-  - `input.tree_nodes`: 本路径用到的人设树节点名称列表,只需节点名称,不需要完整路径,如 `创意展示` 而非 `形式.内容风格.氛围特征.创意性.创意展示`
+  - `input.tree_nodes`: 本路径用到的人设树节点名称列表,只需节点名称,不需要完整路径
   - `input.patterns`: 本路径用到的 pattern 选题点拼接列表(与 `processed_edge_data.json` 中 `i` 格式一致,如 `"名称1+名称2"`)
   - `input.derived_nodes`: 本路径用到的已推导成功选题点名称列表(**只能引用 `derived_success_set` 中完全推导成功的选题点名称**,不能引用 `partial_derived_set` 中部分推导成功的选题点名称)
-  - `output`: 本路径产出的待评估选题点名称列表(可多个
-  - `reason`: 推导决策理由,须说明从工具返回的众多记录中**为什么选择该条数据**进行推导;决策依据只能来自:与已推导选题点的关联性(同维度延伸、跨维度互补)、与账号人设树结构的契合度(所属维度是否为未覆盖维度)、数据结构指标(条件概率、整体概率、pattern 支持度等)。**严禁**在 `reason` 中出现匹配结果相关内容(包括「帖子选题点匹配」字段值、匹配分数、匹配到的帖子选题点名称,以及"工具反馈其匹配到了..."、"匹配分数达..."等类似表述)——无论作为主要理由还是辅助补充,均不得出现;禁止将"该条数据有帖子选题点匹配"作为选择依据;禁止牵强或凭空联想
+  - `output`: 本路径产出的待评估选题点名称列表(工具返回的节点名称或 pattern 元素名称,即传入 `point_match` 的候选点名称
+  - `reason`: 推导决策理由,须说明从工具返回的众多记录中**为什么选择该条数据**进行推导;决策依据只能来自:与已推导选题点的关联性、与账号人设树结构的契合度、数据结构指标(条件概率/整体概率/pattern 支持度等),以及内容创作者视角(合理性/常规性/质量高/创新探索发散)。**严禁**在 `reason` 中出现匹配结果相关内容(包括 `point_match` 返回值、匹配分数、匹配到的帖子选题点名称,以及"工具反馈其匹配到了..."、"匹配分数达..."等类似表述)——无论作为主要理由还是辅助补充,均不得出现;禁止将"该条数据有匹配"作为选择依据;禁止牵强或凭空联想
   - `tools`: 本路径使用的工具列表;若使用搜索工具,必须包含 `query`、`result`(数据摘要或关键内容)、`candidate_points`(评估子 agent 筛选的候选点)和 `match_result`(匹配结果);若未使用工具则为空数组 `[]`
 
 > **原子化要求体现在日志中**:每条推导路径遵循最小输入输出原子化规则——即用最少输入数据推导出哪些必要的选题点;路径中所有输入对产出该路径每个输出点都是必要的;逻辑上可以分开的推导路径不要混在一起。
@@ -654,7 +646,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 ### 2. 评估日志(每轮一份)
 
 - **路径**: `/Users/liuzhiheng/work/aigc/code/Agent/examples_how/overall_derivation/output/{account_name}/推导日志/{帖子ID}/{log_id}/{轮次}_评估.json`
-- **作用**: 记录该轮各推导输出点的匹配判断结果与推导进度,**内容由主 agent 根据工具返回的匹配数据(或搜索子 agent 返回的匹配结果)直接整理得到**
+- **作用**: 记录该轮各推导输出点的匹配判断结果与推导进度,**内容由主 agent 根据 `point_match` 工具返回(或搜索子 agent 返回的匹配结果)直接整理得到**
 - **格式要求**:
 
 ```json
@@ -685,10 +677,10 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
   - `eval_results`: 本轮所有推导输出点的匹配判断结果(经多路径择优后,同一 `matched_post_point` 只保留分数最高的一条记录),每项对应一个输出点
     - `path_id`: 整数,对应推导日志中的推导路径 `id`(择优后选中的那条路径)
     - `item_id`: 整数,同一路径内第几个输出点,从 1 开始;同一路径有多个输出点时用于区分
-    - `derivation_output_point`: 本轮推导路径输出的待评估选题点名称,**注意:这里是推导路径中`output`中的选题点,不是匹配到的帖子选题点**
+    - `derivation_output_point`: 本轮推导路径输出的待评估选题点名称,**注意:这里是推导路径中`output`中的选题点(即传入 `point_match` 的名称),不是匹配到的帖子选题点**
     - `is_matched`: 布尔值,该推导点是否匹配到帖子选题点
-    - `matched_post_point`: 字符串或 `null`,匹配到的帖子选题点名称;未匹配则为 `null`
-    - `matched_score`: 数值或 `null`,匹配分数(括号内的数值);未匹配则为 `null`
+    - `matched_post_point`: 字符串或 `null`,`point_match` 返回的帖子选题点名称;未匹配则为 `null`
+    - `matched_score`: 数值或 `null`,`point_match` 返回的匹配分数;未匹配则为 `null`
     - `matched_reason`: 字符串或 `null`,匹配依据描述(如 `匹配分数=0.92`);未匹配则为 `null`
     - `is_fully_derived`: 布尔值,`matched_score >= 0.78` 时为 `true`(完全推导成功),`matched_score < 0.78` 时为 `false`(部分推导成功),未匹配时为 `false`
   - `derivation_progress`: 由主 agent 根据当前已推导成功集合整理
@@ -706,7 +698,7 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
      - 若该 `matched_post_point` 已在 `derived_success_set` 中(即之前已完全推导成功),则忽略本次低分结果,不做任何更新。
      - 若该 `matched_post_point` 已在 `partial_derived_set` 中,且本次 `matched_score` 高于已记录的分数,则更新为本次更高分数。
      - 若该 `matched_post_point` 不在任何集合中,则加入 `partial_derived_set`。
-2. **核心区分**:加入集合的是**帖子选题点名称**(即 `matched_post_point`),而非推导输出的节点名称(`derivation_output_point`)。两者可能不同——例如推导输出节点 `趣味道具` 匹配到帖子选题点 `夸张道具`,则加入集合的是 `夸张道具`,而非 `趣味道具`。
+2. **核心区分**:加入集合的是**帖子选题点名称**(即 `matched_post_point`),而非推导输出的节点名称(`derivation_output_point`)。两者可能不同——例如推导输出节点 `趣味道具` 经 `point_match` 匹配到帖子选题点 `夸张道具`,则加入集合的是 `夸张道具`,而非 `趣味道具`。
 3. 后续轮次推导时:
    - **工具参数 `derived_items`**:需要包含 `derived_success_set` 和 `partial_derived_set` 的并集,`topic` 字段使用帖子选题点名称(`matched_post_point`),用于条件概率计算。
    - **推导路径 `input.derived_nodes`**:只能引用 `derived_success_set` 中的帖子选题点名称(`matched_post_point`),**不能引用 `partial_derived_set` 中的 `matched_post_point`**。部分推导成功选题点对应的 `source_node` 可以作为 `input.tree_nodes` 使用。
@@ -730,14 +722,15 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 确保每轮输出的日志文件:
 1. JSON 格式正确,可以正常解析
 2. 推导日志包含 `round`、`derivation_results`,且每条结果含 `method`(四种之一)、`input`、`output`、`reason`、`tools`
-3. 评估日志包含 `round`、`eval_results`、`derivation_progress`,`is_matched` 为布尔值,`need_next_round` 为布尔值,`matched_reason` 引用工具返回的匹配分数等具体数据,`matched_score` 为数值或 `null`,`is_fully_derived` 为布尔值
-4. 推导理由中**不得出现任何匹配结果相关内容**——包括「帖子选题点匹配」字段的值、匹配分数数值、匹配到的帖子选题点名称,以及"工具反馈其匹配到了..."、"匹配到了帖子选题点..."、"匹配分数达..."等类似表述;也不能将"该条数据有帖子选题点匹配"作为选择依据(无论主要或辅助);`reason` 只应包含在未知匹配结果前提下就能成立的预决策逻辑(关联性、维度覆盖、数据结构指标等)
+3. 评估日志包含 `round`、`eval_results`、`derivation_progress`,`is_matched` 为布尔值,`need_next_round` 为布尔值,`matched_reason` 引用 `point_match` 返回的匹配分数等具体数据,`matched_score` 为数值或 `null`,`is_fully_derived` 为布尔值
+4. 推导理由中**不得出现任何匹配结果相关内容**——包括 `point_match` 返回值、匹配分数数值、匹配到的帖子选题点名称,以及"工具反馈其匹配到了..."、"匹配到了帖子选题点..."、"匹配分数达..."等类似表述;也不能将"该条数据有匹配"作为选择依据(无论主要或辅助);`reason` 只应包含在未知匹配结果前提下就能成立的预决策逻辑(关联性、维度覆盖、数据结构指标、内容创作者视角等)
 5. 每条评估记录包含 `path_id` 和 `item_id` 两个 ID 字段,与推导日志路径对应
 6. 原子化拆分校验:方法一(人设常量)、方法三(人设推导)的每条路径 `input.tree_nodes` 长度为 1 且 `output` 长度为 1;方法二(pattern 复用)的每条路径 `input.patterns` 长度为 1
 7. 多路径择优校验:同一 `matched_post_point` 在同一轮评估日志中不应出现多条记录,只保留 `matched_score` 最高的一条
 8. `derived_success_count` 只统计 `matched_score >= 0.78` 的完全推导成功选题点,不包含部分推导成功的选题点
 9. `partial_derived_count` 统计 `matched_score < 0.78` 的部分推导成功选题点
-10. output/derivation_output_point 校验:推导日志中每条路径的 `output` 值必须是工具返回的节点名称或 pattern 元素名称,不得是帖子选题点名称;评估日志中 `derivation_output_point` 必须与对应路径 `output` 中的值逐字一致。若使用方法二(pattern 复用),`output` 中每个值必须是该 pattern 的元素名称(直接匹配)或扩展节点名称(扩展匹配),不得是 `matched_post_point` 的值。
+10. output/derivation_output_point 校验:推导日志中每条路径的 `output` 值必须是工具返回的节点名称或 pattern 元素名称(即传入 `point_match` 的名称),不得是帖子选题点名称;评估日志中 `derivation_output_point` 必须与对应路径 `output` 中的值逐字一致
+11. 每次工具调用挑选数量校验:每次 `find_tree_constant_nodes`/`find_tree_nodes_by_conditional_ratio`/`find_pattern` 调用后,所产生的推导路径数量(即该次调用对应的挑选条数)不超过 5 条
 
 ## 账号人设树
 账号人设树是通过对该账号一些帖子进行解构得到大量的选题点,再对这些选题点进行聚类得到的人设树,数据如下:
@@ -745,4 +738,4 @@ agent(agent_type="derivation_search", task="执行搜索任务,account_name=xx
 {account_tree_data}
 
 $user$
-请开始执行 account_name={account_name},帖子ID={帖子ID},log_id={log_id} 的选题点整体推导任务。所有路径均相对于项目根目录。帖子的选题点数量={post_point_count}
+请开始执行 account_name={account_name},帖子ID={帖子ID},log_id={log_id} 的选题点整体推导任务。所有路径均相对于项目根目录。帖子的选题点数量={post_point_count}

+ 136 - 40
examples_how/overall_derivation/generate_visualize_data.py

@@ -126,20 +126,24 @@ def enrich_visualize_with_used_tree_nodes(
     persona_by_name: Dict[str, Dict[str, Any]],
 ) -> Dict[str, Any]:
     """
-    为 edge_list 每条 edge 增加 used_tree_nodes,顶层增加 all_used_tree_nodes(与 process_pipeline 一致)。
+    为 edge_list / fail_edge_list 每条 edge 增加 used_tree_nodes;
+    顶层 all_used_tree_nodes 聚合 edge_list 的 tree nodes,
+    fail_all_used_tree_nodes 单独聚合 fail_edge_list 的 tree nodes。
     """
-    edge_list = data.get("edge_list")
-    if not edge_list:
-        data["all_used_tree_nodes"] = []
-        return data
-
-    all_used: List[Dict[str, Any]] = []
-    for edge in edge_list:
-        used = extract_used_tree_nodes_from_edge(edge, persona_by_name)
-        edge["used_tree_nodes"] = used
-        all_used.extend(used)
-
-    data["all_used_tree_nodes"] = _dedup_node_objs(all_used)
+    for list_key, agg_key in (
+        ("edge_list", "all_used_tree_nodes"),
+        ("fail_edge_list", "fail_all_used_tree_nodes"),
+    ):
+        edge_list = data.get(list_key)
+        if not edge_list:
+            data[agg_key] = []
+            continue
+        all_used: List[Dict[str, Any]] = []
+        for edge in edge_list:
+            used = extract_used_tree_nodes_from_edge(edge, persona_by_name)
+            edge["used_tree_nodes"] = used
+            all_used.extend(used)
+        data[agg_key] = _dedup_node_objs(all_used)
     return data
 
 
@@ -443,11 +447,12 @@ def build_visualize_edges(
     derivations: list[dict],
     evals: list[dict],
     topic_points: list[dict],
-) -> tuple[list[dict], list[dict]]:
+) -> tuple[list[dict], list[dict], list[dict], list[dict]]:
     """
-    生成 node_list(所有评估通过的帖子选题点)和 edge_list(只保留评估通过的推导路径)。
-    - node_list:同一轮内节点不重复,重复时保留 matched_score 更高的;节点带 matched_score、is_fully_derived。
-    - edge_list:边带 level(与 output 节点 level 一致);同一轮内 output 节点不重复;若前面轮次该节点匹配分更高则本轮不保留该节点。
+    生成 node_list(评估通过的帖子选题点)、edge_list(评估通过的推导路径)、
+    fail_node_list(评估不通过的帖子选题点)、fail_edge_list(评估不通过的推导路径)。
+    - node_list / edge_list:同一轮内节点不重复,重复时保留 matched_score 更高的;节点带 matched_score、is_fully_derived。
+    - fail_node_list / fail_edge_list:数据结构与 node_list / edge_list 一致,保存未通过评估的推导输出节点及对应路径。
     评估数据支持 path_id(对应推导 derivation_results[].id)、derivation_output_point(与推导 output 中字符串对齐)、matched_score、is_fully_derived;不按 item_id 对齐。
     """
     derivations = sorted(derivations, key=lambda d: d.get("round", 0))
@@ -518,25 +523,119 @@ def build_visualize_edges(
                     best_node_info_by_round_mp[key] = (score, bool(is_fully), out_item, dr.get("method", ""))
 
     edge_list = []
+    fail_edge_list: list[dict] = []
+    fail_node_list: list[dict] = []
+    # 跨轮次全局去重:derivation_output_point 在任意轮次出现后,后续轮次不再重复记录
+    fail_node_seen: set[str] = set()
     round_output_seen: set[tuple[int, str]] = set()  # (round_num, node_name) 本轮已作为某边的 output
     prev_best_by_node: dict[str, tuple[float, bool]] = {}  # node_name -> (score, is_fully) of last included round
 
+    def _add_fail_node(out_item: str, round_num: int, method: str) -> None:
+        """将推导输出点加入 fail_node_list(跨轮次去重)。name/original_word 均设为 out_item。"""
+        if out_item in fail_node_seen:
+            return
+        fail_node_seen.add(out_item)
+        base = dict(topic_by_name.get(
+            out_item,
+            {"name": out_item, "point": "", "dimension": "", "root_source": "", "root_sources_desc": ""},
+        ))
+        # 未匹配到帖子选题点,name/original_word 统一使用推导输出点本身
+        base["name"] = out_item
+        base["level"] = round_num
+        base["original_word"] = out_item
+        base["derivation_type"] = method
+        base["matched_score"] = 0
+        base["is_fully_derived"] = False
+        base["derivation_output_point"] = out_item
+        fail_node_list.append(base)
+
     for round_idx, derivation in enumerate(derivations):
         round_num = derivation.get("round", round_idx + 1)
         for dr in derivation.get("derivation_results") or []:
             output_list = dr.get("output") or []
             path_id = dr.get("id")
+            method = dr.get("method", "")
+
+            # 提前构建输入节点(成功边与失败边均需要)
+            input_data = dr.get("input") or {}
+            derived_nodes = input_data.get("derived_nodes") or []
+            tree_nodes = input_data.get("tree_nodes") or []
+            patterns = input_data.get("patterns") or []
+
+            input_post_nodes = [{"name": x} for x in derived_nodes]
+            input_tree_nodes = [_to_tree_node(_tree_node_display_name(x)) for x in tree_nodes]
+            if patterns and isinstance(patterns[0], str):
+                input_pattern_nodes = [_to_pattern_node(p) for p in patterns]
+            elif patterns and isinstance(patterns[0], dict):
+                input_pattern_nodes = patterns
+            else:
+                input_pattern_nodes = []
+
             matched: list[tuple[str, str, float, bool, str]] = []  # (mp, reason, score, is_fully, derivation_out)
+            unmatched_out_items: list[str] = []
             for out_item in output_list:
+                out_item = (out_item or "").strip()
+                if not out_item:
+                    continue
                 v = get_match(round_num, path_id, out_item)
                 if not v:
+                    unmatched_out_items.append(out_item)
                     continue
                 mp, reason, score, is_fully = v
                 matched.append((mp, reason, score, is_fully, out_item))
 
             if not matched:
+                # 全部未匹配 → 记录 fail_edge;仅有新 fail_node 时才写入 fail_edge
+                new_fail_items = [o for o in unmatched_out_items if o not in fail_node_seen]
+                if new_fail_items:
+                    for out_item in new_fail_items:
+                        _add_fail_node(out_item, round_num, method)
+                    fail_output_nodes = [
+                        {"name": o, "matched_score": 0, "is_fully_derived": False}
+                        for o in unmatched_out_items
+                    ]
+                    fail_detail: dict = {
+                        "reason": dr.get("reason", ""),
+                        "评估结果": "匹配失败",
+                    }
+                    if dr.get("tools"):
+                        fail_detail["tools"] = dr["tools"]
+                    fail_edge_list.append({
+                        "name": method or f"推导-{round_num}",
+                        "level": round_num,
+                        "input_post_nodes": input_post_nodes,
+                        "input_tree_nodes": input_tree_nodes,
+                        "input_pattern_nodes": input_pattern_nodes,
+                        "output_nodes": fail_output_nodes,
+                        "detail": fail_detail,
+                    })
                 continue
 
+            # 部分匹配 → 收集未匹配的 out_item 到 fail_node,并为新出现的未匹配输出建 fail_edge
+            new_unmatched = [o for o in unmatched_out_items if o not in fail_node_seen]
+            for out_item in unmatched_out_items:
+                _add_fail_node(out_item, round_num, method)
+            if new_unmatched:
+                fail_output_nodes = [
+                    {"name": o, "matched_score": 0, "is_fully_derived": False}
+                    for o in unmatched_out_items
+                ]
+                partial_fail_detail: dict = {
+                    "reason": dr.get("reason", ""),
+                    "评估结果": "部分匹配失败",
+                }
+                if dr.get("tools"):
+                    partial_fail_detail["tools"] = dr["tools"]
+                fail_edge_list.append({
+                    "name": method or f"推导-{round_num}",
+                    "level": round_num,
+                    "input_post_nodes": input_post_nodes,
+                    "input_tree_nodes": input_tree_nodes,
+                    "input_pattern_nodes": input_pattern_nodes,
+                    "output_nodes": fail_output_nodes,
+                    "detail": partial_fail_detail,
+                })
+
             # 同一轮内 output 节点不重复;若前面轮次该节点已完全推导,或分数未提升且未从 false 变 true,则本轮跳过;
             # 并且只保留与 node_list 中该轮该节点的最高分记录一致的边
             output_names_this_edge = []
@@ -564,20 +663,6 @@ def build_visualize_edges(
                 if prev is None or (not prev[1] and (is_fully or score > prev[0])):
                     prev_best_by_node[mp] = (score, is_fully)
 
-            input_data = dr.get("input") or {}
-            derived_nodes = input_data.get("derived_nodes") or []
-            tree_nodes = input_data.get("tree_nodes") or []
-            patterns = input_data.get("patterns") or []
-
-            input_post_nodes = [{"name": x} for x in derived_nodes]
-            input_tree_nodes = [_to_tree_node(_tree_node_display_name(x)) for x in tree_nodes]
-            if patterns and isinstance(patterns[0], str):
-                input_pattern_nodes = [_to_pattern_node(p) for p in patterns]
-            elif patterns and isinstance(patterns[0], dict):
-                input_pattern_nodes = patterns
-            else:
-                input_pattern_nodes = []
-
             output_nodes = []
             reasons_list = []
             compare_detail_list = []
@@ -598,7 +683,7 @@ def build_visualize_edges(
             if dr.get("tools"):
                 detail["tools"] = dr["tools"]
             edge_list.append({
-                "name": dr.get("method", "") or f"推导-{round_num}",
+                "name": method or f"推导-{round_num}",
                 "level": round_num,
                 "input_post_nodes": input_post_nodes,
                 "input_tree_nodes": input_tree_nodes,
@@ -641,7 +726,8 @@ def build_visualize_edges(
         node_list.append(base)
 
     node_list.sort(key=lambda n: (n.get("level", 0), str(n.get("name", ""))))
-    return node_list, edge_list
+    fail_node_list.sort(key=lambda n: (n.get("level", 0), str(n.get("name", ""))))
+    return node_list, edge_list, fail_node_list, fail_edge_list
 
 
 def _find_project_root() -> Path:
@@ -691,7 +777,7 @@ def generate_visualize_data(account_name: str, post_id: str, log_id: str, base_d
     print(f"已写入整体推导结果: {result_path}")
 
     # 2.2 整体推导路径可视化(人设节点补全:used_tree_nodes / all_used_tree_nodes,数据来自处理后数据/tree 人设树)
-    node_list, edge_list = build_visualize_edges(derivations, evals, topic_points)
+    node_list, edge_list, fail_node_list, fail_edge_list = build_visualize_edges(derivations, evals, topic_points)
     tree_dir = base_dir / "input" / account_name / "处理后数据" / "tree"
     persona_by_name = build_persona_by_name_from_tree_dir(tree_dir)
     if persona_by_name:
@@ -702,7 +788,12 @@ def generate_visualize_data(account_name: str, post_id: str, log_id: str, base_d
         print(
             f"警告: 未从人设树目录加载到节点(请确认存在 *_point_tree_how.json): {tree_dir}"
         )
-    visualize_payload: Dict[str, Any] = {"node_list": node_list, "edge_list": edge_list}
+    visualize_payload: Dict[str, Any] = {
+        "node_list": node_list,
+        "edge_list": edge_list,
+        "fail_node_list": fail_node_list,
+        "fail_edge_list": fail_edge_list,
+    }
     enrich_visualize_with_used_tree_nodes(visualize_payload, persona_by_name)
 
     visualize_path = visualize_dir / f"{post_id}.json"
@@ -725,6 +816,11 @@ def main(account_name, post_id, log_id):
 if __name__ == "__main__":
     from tools.pattern_dimension_analyze import main as pattern_dimension_analyze_main
 
+    account_name = "家有大志"
+    items = [
+        {"post_id": "68fb6a5c000000000302e5de", "log_id": "20260324172323"},
+    ]
+
     # account_name="阿里多多酱"
     # items = [
     #     {"post_id": "6915dfc400000000070224d9", "log_id": "20260322135142"},
@@ -743,11 +839,11 @@ if __name__ == "__main__":
     #     {"post_id": "6951c718000000001e0105b7", "log_id": "20260322211126"},
     # ]
 
-    account_name = "空间点阵设计研究室"
-    items = [
-        {"post_id": "687ee6fc000000001c032bb1", "log_id": "20260322211748"},
-        {"post_id": "68843a4d000000001c037591", "log_id": "20260322213024"},
-    ]
+    # account_name = "空间点阵设计研究室"
+    # items = [
+    #     {"post_id": "687ee6fc000000001c032bb1", "log_id": "20260322211748"},
+    #     {"post_id": "68843a4d000000001c037591", "log_id": "20260322213024"},
+    # ]
 
     for item in items:
         post_id = item["post_id"]

+ 99 - 401
examples_how/overall_derivation/tools/find_pattern.py

@@ -2,10 +2,9 @@
 查找 Pattern Tool - 从 pattern 库中获取符合条件概率阈值的 pattern
 
 功能:
-- 账号:读取 input/{账号}/处理后数据/pattern/pattern.json,条件概率基于账号人设树;
-  元素与帖子选题点匹配走账号 match_data / point_match,并支持人设树子节点、兄弟节点扩展。
-- 平台库:读取 input/xiaohongshu/pattern/processed_edge_data.json,条件概率基于 xiaohongshu/tree;
-  元素匹配仅使用 input/xiaohongshu/match_data/{post_id}_匹配_all.json。
+- 账号:读取 input/{账号}/处理后数据/pattern/pattern.json,条件概率基于账号人设树。
+- 平台库:读取 input/xiaohongshu/pattern/processed_edge_data.json,条件概率基于 xiaohongshu/tree。
+所有 pattern 按 条件概率 * pattern元素长度 降序;账号占 60% 配额,平台库占 40% 配额。
 """
 
 import json
@@ -24,10 +23,7 @@ from utils.conditional_ratio_calc import (
 )
 from tools.point_match import (
     DEFAULT_MATCH_THRESHOLD,
-    _load_match_data,
-    match_derivation_to_post_points,
 )
-from tools.find_tree_node import _load_trees
 
 try:
     from agent.tools import tool, ToolResult, ToolContext
@@ -53,42 +49,10 @@ SUB_KEYS = ["two_x", "one_x", "zero_x"]
 
 _BASE_INPUT = Path(__file__).resolve().parent.parent / "input"
 
-# 排序时「已推导选题点 ↔ pattern 元素」在 match_data 中的高分优先阈值(与账号段原逻辑一致)
-_MATCH_PRIOR_MIN_SCORE = 0.8
-
 _PLATFORM_TREE_DIR = _BASE_INPUT / "xiaohongshu" / "tree"
 _PLATFORM_PATTERN_FILE = _BASE_INPUT / "xiaohongshu" / "pattern" / "processed_edge_data.json"
 
 
-def _build_node_info(account_name: str) -> dict[str, dict]:
-    """
-    构建人设树节点信息映射: node_name -> {
-        "type": 节点 _type("class" / "ID" 等),
-        "children": 子节点名称列表(仅分类节点有值),
-        "siblings": 兄弟节点名称列表(不含自身),
-    }
-    """
-    node_info: dict[str, dict] = {}
-
-    def _walk(node_dict: dict):
-        children_dict = node_dict.get("children") or {}
-        child_entries = [(n, c) for n, c in children_dict.items() if isinstance(c, dict)]
-        child_names = [n for n, _ in child_entries]
-        for name, child in child_entries:
-            sub_children = child.get("children") or {}
-            sub_child_names = [n for n, c in sub_children.items() if isinstance(c, dict)]
-            node_info[name] = {
-                "type": child.get("_type", ""),
-                "children": sub_child_names,
-                "siblings": [n for n in child_names if n != name],
-            }
-            _walk(child)
-
-    for _dim_name, root in _load_trees(account_name):
-        _walk(root)
-    return node_info
-
-
 def _pattern_file(account_name: str) -> Path:
     """pattern 库文件:../input/{account_name}/处理后数据/pattern/pattern.json"""
     return _BASE_INPUT / account_name / "处理后数据" / "pattern" / "pattern.json"
@@ -173,19 +137,16 @@ def _load_and_merge_platform_patterns() -> list[dict]:
     return _merge_and_dedupe(all_patterns)
 
 
-def _load_platform_match_pair_lookup(post_id: str) -> dict[tuple[str, str], float]:
+def _load_match_lookup(file_path: Path) -> dict[tuple[str, str], float]:
     """
-    xiaohongshu/match_data/{post_id}_匹配_all.json
-    -> (帖子选题点, 人设树节点名) -> 最高 match_score(跨 dimension 合并)。
+    读取 match_data 文件,返回 (帖子选题点, 人设树节点) -> 最高匹配分。
+    文件格式:[{"name": 帖子选题点, "match_personas": [{"name": 节点名, "match_score": float}]}]
     """
     lookup: dict[tuple[str, str], float] = {}
-    if not post_id:
-        return lookup
-    path = _BASE_INPUT / "xiaohongshu" / "match_data" / f"{post_id}_匹配_all.json"
-    if not path.is_file():
+    if not file_path.is_file():
         return lookup
     try:
-        with open(path, "r", encoding="utf-8") as f:
+        with open(file_path, "r", encoding="utf-8") as f:
             data = json.load(f)
     except Exception:
         return lookup
@@ -204,76 +165,34 @@ def _load_platform_match_pair_lookup(post_id: str) -> dict[tuple[str, str], floa
         for mp in personas:
             if not isinstance(mp, dict):
                 continue
-            elem = mp.get("name")
+            node = mp.get("name")
             score = mp.get("match_score")
-            if elem is None or score is None:
-                continue
-            elem_s = str(elem).strip()
-            if not elem_s:
+            if node is None or score is None:
                 continue
             try:
                 sc = float(score)
             except (TypeError, ValueError):
                 continue
-            key = (topic_s, elem_s)
+            key = (topic_s, str(node).strip())
             if key not in lookup or sc > lookup[key]:
                 lookup[key] = sc
     return lookup
 
 
-def _platform_element_post_match_map(
-    post_id: str,
-    match_score_threshold: float,
-) -> dict[str, dict[str, float]]:
-    """
-    平台库:节点名称(不区分 dimension)-> {帖子选题点: 最高分},
-    仅保留 match_score >= match_score_threshold 的对。
-    """
-    out: dict[str, dict[str, float]] = {}
-    if not post_id:
-        return out
-    path = _BASE_INPUT / "xiaohongshu" / "match_data" / f"{post_id}_匹配_all.json"
-    if not path.is_file():
-        return out
-    try:
-        with open(path, "r", encoding="utf-8") as f:
-            data = json.load(f)
-    except Exception:
-        return out
-    if not isinstance(data, list):
-        return out
-    thr = float(match_score_threshold)
-    for item in data:
-        if not isinstance(item, dict):
-            continue
-        topic = item.get("name")
-        personas = item.get("match_personas")
-        if topic is None or not isinstance(personas, list):
+def _pattern_has_derived_match(
+    pattern_name: str,
+    derived_topics: set[str],
+    match_lookup: dict[tuple[str, str], float],
+    threshold: float,
+) -> bool:
+    """pattern 中至少有一个元素与任意 derived_topic 的匹配分 >= threshold。"""
+    for elem in (e.strip() for e in pattern_name.split("+")):
+        if not elem:
             continue
-        topic_s = str(topic).strip()
-        if not topic_s:
-            continue
-        for mp in personas:
-            if not isinstance(mp, dict):
-                continue
-            elem = mp.get("name")
-            score = mp.get("match_score")
-            if elem is None or score is None:
-                continue
-            try:
-                sc = float(score)
-            except (TypeError, ValueError):
-                continue
-            if sc < thr:
-                continue
-            elem_s = str(elem).strip()
-            if not elem_s:
-                continue
-            bucket = out.setdefault(elem_s, {})
-            prev = bucket.get(topic_s)
-            if prev is None or sc > prev:
-                bucket[topic_s] = sc
-    return out
+        for topic in derived_topics:
+            if match_lookup.get((topic, elem), 0.0) >= threshold:
+                return True
+    return False
 
 
 def _parse_derived_list(derived_items: list[dict[str, str]]) -> list[tuple[str, str]]:
@@ -295,15 +214,9 @@ def get_patterns_by_conditional_ratio(
     derived_list: list[tuple[str, str]],
     conditional_ratio_threshold: float,
     top_n: int,
-    post_id: str = "",
 ) -> list[dict[str, Any]]:
     """
-    从 pattern 库中获取条件概率 >= 阈值的 pattern,按以下优先级排序后返回 top_n 条:
-      1. pattern 元素中直接包含已推导选题点(topic)的排最前;
-      2. pattern 元素与任意已推导选题点的匹配分 >= 0.8 的次之(从 match_data 文件读取,
-         key 为 (帖子选题点, 人设树节点),pattern 元素视为人设树节点);
-      3. 按条件概率降序;
-      4. 按 length 降序。
+    从 pattern 库中获取条件概率 >= 阈值的 pattern,按 条件概率 * pattern元素长度 降序返回 top_n 条。
     derived_list 为空时,条件概率使用 pattern 自身的 support(s)。
     返回每项:pattern名称(nameA+nameB+nameC)、条件概率。
     """
@@ -314,7 +227,6 @@ def get_patterns_by_conditional_ratio(
     scored: list[tuple[dict, float]] = []
 
     if not derived_list:
-        # derived_items 为空:条件概率取 pattern 本身的 support (s)
         for p in merged:
             ratio = float(p.get("s", 0))
             if ratio >= conditional_ratio_threshold:
@@ -327,31 +239,7 @@ def get_patterns_by_conditional_ratio(
             if ratio >= conditional_ratio_threshold:
                 scored.append((p, ratio))
 
-    derived_topics = {topic for topic, _ in derived_list} if derived_list else set()
-
-    # 次优先:从 match_data 文件加载 (帖子选题点, 人设树节点) -> 匹配分,
-    # 用已推导选题点(topic)作为帖子选题点,pattern 元素作为人设树节点,
-    # 检查是否存在匹配分 >= 0.8 的组合。
-    match_lookup: dict[tuple[str, str], float] = {}
-    if derived_topics and post_id:
-        match_lookup = _load_match_data(account_name, post_id)
-
-    def _sort_key(x: tuple[dict, float]) -> tuple:
-        p, ratio = x
-        elements = set(p["i"].split("+"))
-        has_derived = bool(elements & derived_topics)
-        has_high_match = False
-        if not has_derived and match_lookup:
-            for elem in elements:
-                for dt in derived_topics:
-                    if match_lookup.get((dt, elem), 0.0) >= _MATCH_PRIOR_MIN_SCORE:
-                        has_high_match = True
-                        break
-                if has_high_match:
-                    break
-        return (not has_derived, not has_high_match, -ratio, -p["l"])
-
-    scored.sort(key=_sort_key)
+    scored.sort(key=lambda x: -(x[1] * x[0]["l"]))
     result = []
     for p, ratio in scored[:top_n]:
         result.append({
@@ -365,12 +253,11 @@ def get_platform_patterns_by_conditional_ratio(
     derived_list: list[tuple[str, str]],
     conditional_ratio_threshold: float,
     top_n: int,
-    post_id: str = "",
 ) -> list[dict[str, Any]]:
     """
     平台库 pattern:数据来自 xiaohongshu/pattern/processed_edge_data.json,
     条件概率基于 xiaohongshu/tree 的节点索引(与账号侧 calc_pattern 规则一致)。
-    排序优先级规则与 get_patterns_by_conditional_ratio 一致,高分参照 xiaohongshu/match_data
+    按 条件概率 * pattern元素长度 降序返回 top_n 条
     """
     merged = _load_and_merge_platform_patterns()
     if not merged:
@@ -389,27 +276,7 @@ def get_platform_patterns_by_conditional_ratio(
             if ratio >= conditional_ratio_threshold:
                 scored.append((p, ratio))
 
-    derived_topics = {topic for topic, _ in derived_list} if derived_list else set()
-    match_lookup: dict[tuple[str, str], float] = {}
-    if derived_topics and post_id:
-        match_lookup = _load_platform_match_pair_lookup(post_id)
-
-    def _sort_key(x: tuple[dict, float]) -> tuple:
-        p, ratio = x
-        elements = set(p["i"].split("+"))
-        has_derived = bool(elements & derived_topics)
-        has_high_match = False
-        if not has_derived and match_lookup:
-            for elem in elements:
-                for dt in derived_topics:
-                    if match_lookup.get((dt, elem), 0.0) >= _MATCH_PRIOR_MIN_SCORE:
-                        has_high_match = True
-                        break
-                if has_high_match:
-                    break
-        return (not has_derived, not has_high_match, -ratio, -p["l"])
-
-    scored.sort(key=_sort_key)
+    scored.sort(key=lambda x: -(x[1] * x[0]["l"]))
     result = []
     for p, ratio in scored[:top_n]:
         result.append({
@@ -419,34 +286,9 @@ def get_platform_patterns_by_conditional_ratio(
     return result
 
 
-def _attach_platform_pattern_post_matches(
-    items: list[dict[str, Any]],
-    post_id: str,
-    match_score_threshold: float,
-) -> None:
-    """就地写入 帖子选题点匹配:仅使用 xiaohongshu/match_data,元素为节点名(跨 dimension 聚合)。"""
-    if not items or not post_id:
-        for it in items:
-            it["帖子选题点匹配"] = "无"
-        return
-    elem_map = _platform_element_post_match_map(post_id, float(match_score_threshold))
-    for item in items:
-        pattern_matches: list[dict[str, Any]] = []
-        for elem in item["pattern名称"].split("+"):
-            elem = elem.strip()
-            if not elem:
-                continue
-            for post_topic, sc in (elem_map.get(elem) or {}).items():
-                pattern_matches.append({
-                    "pattern元素": elem,
-                    "帖子选题点": post_topic,
-                    "匹配分数": round(sc, 6),
-                })
-        distinct_post_points = len({m["帖子选题点"] for m in pattern_matches})
-        item["帖子选题点匹配"] = (
-            pattern_matches if distinct_post_points >= 2 else "无"
-        )
-
+# ---------------------------------------------------------------------------
+# Agent Tool
+# ---------------------------------------------------------------------------
 
 @tool()
 async def find_pattern(
@@ -458,59 +300,20 @@ async def find_pattern(
     match_score_threshold: float = DEFAULT_MATCH_THRESHOLD,
 ) -> ToolResult:
     """
-    按条件概率阈值从 pattern 库筛选:第一节为账号 pattern,第二节为平台库 pattern(xiaohongshu/pattern)
-    账号段帖子匹配走账号 match_data + point_match;平台段元素匹配仅走 xiaohongshu/match_data
+    按条件概率阈值从 pattern 库筛选:第一节为账号 pattern(优先使用),第二节为平台库 pattern。
+    所有 pattern 按 条件概率 * pattern元素长度 降序排列
 
     Args:
     account_name : 账号名,用于定位该账号的 pattern 库。
-    post_id : 帖子ID。
+    post_id : 帖子ID,用于加载 match_data 过滤(derived_items 非空时生效)
     derived_items : 已推导选题点列表,可为空。
     conditional_ratio_threshold : 条件概率阈值。
-    top_n : 账号段与平台段各自最多返回条数(各自经匹配过滤后可能更少)
-    match_score_threshold : 帖子选题点匹配分阈值。
+    top_n : 最终返回总条数上限
+    match_score_threshold : pattern 元素与帖子选题点匹配分阈值。
 
     Returns:
     ToolResult:output 分「账号 pattern」「平台库 pattern」两段;平台段已排除与账号段 pattern 名称完全相同的项。
     """
-    def _split_by_post_match(
-        items: list[dict[str, Any]],
-    ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
-        matched: list[dict[str, Any]] = []
-        unmatched: list[dict[str, Any]] = []
-        for x in items:
-            if isinstance(x.get("帖子选题点匹配"), list):
-                matched.append(x)
-            else:
-                unmatched.append(x)
-        return matched, unmatched
-
-    def _pick_with_quota(
-        items: list[dict[str, Any]],
-        target_count: int,
-    ) -> list[dict[str, Any]]:
-        return items[:max(0, int(target_count))]
-
-    def _mix_by_ratio(
-        items: list[dict[str, Any]],
-        target_count: int,
-    ) -> list[dict[str, Any]]:
-        if target_count <= 0:
-            return []
-        matched, unmatched = _split_by_post_match(items)
-        matched_quota = target_count // 2
-        unmatched_quota = target_count - matched_quota
-        selected = _pick_with_quota(matched, matched_quota)
-        selected.extend(_pick_with_quota(unmatched, unmatched_quota))
-
-        if len(selected) < target_count:
-            selected_names = {str(x.get("pattern名称", "")) for x in selected}
-            fallback_pool = [
-                x for x in items
-                if str(x.get("pattern名称", "")) not in selected_names
-            ]
-            selected.extend(_pick_with_quota(fallback_pool, target_count - len(selected)))
-        return selected
-
     pattern_path = _pattern_file(account_name)
     if not pattern_path.is_file():
         return ToolResult(
@@ -520,173 +323,68 @@ async def find_pattern(
         )
     try:
         derived_list = _parse_derived_list(derived_items or [])
+        derived_topics = {topic for topic, _ in derived_list}
         thr = float(match_score_threshold)
         total_top_n = max(0, int(top_n))
         account_top_n = int(total_top_n * 0.6)
         platform_top_n = total_top_n - account_top_n
-        # 候选池适当放大,避免按“有/无匹配”分桶后数量不足
-        candidate_top_n = max(total_top_n * 4, total_top_n + 100)
-
-        # ---------- 账号 pattern(原逻辑:match_data + 子节点/兄弟扩展)----------
-        items_account = get_patterns_by_conditional_ratio(
-            account_name, derived_list, conditional_ratio_threshold, candidate_top_n, post_id
-        )
-        if not post_id:
-            for item in items_account:
-                item["帖子选题点匹配"] = "无"
-        if items_account and post_id:
-            all_elements: list[str] = []
-            seen_elements: set[str] = set()
-            for item in items_account:
-                for elem in item["pattern名称"].split("+"):
-                    elem = elem.strip()
-                    if elem and elem not in seen_elements:
-                        all_elements.append(elem)
-                        seen_elements.add(elem)
-            matched_results = await match_derivation_to_post_points(
-                all_elements, account_name, post_id, match_threshold=thr
+        # 有过滤时候选池放大,以保证过滤后仍有足够数量
+        candidate_mult = max(total_top_n * 5, 500) if derived_topics and post_id else 0
+
+        # 预加载 match_lookup(仅当 derived_topics 非空且有 post_id 时)
+        account_match_lookup: dict[tuple[str, str], float] = {}
+        platform_match_lookup: dict[tuple[str, str], float] = {}
+        if derived_topics and post_id:
+            account_match_file = (
+                _BASE_INPUT / account_name / "处理后数据" / "match_data"
+                / f"{post_id}_匹配_all.json"
+            )
+            platform_match_file = (
+                _BASE_INPUT / "xiaohongshu" / "match_data" / f"{post_id}_匹配_all.json"
             )
-            elem_match_map: dict[str, list] = {}
-            for m in matched_results:
-                elem_match_map.setdefault(m["推导选题点"], []).append({
-                    "帖子选题点": m["帖子选题点"],
-                    "匹配分数": m["匹配分数"],
-                })
-            for item in items_account:
-                pattern_matches = []
-                for elem in item["pattern名称"].split("+"):
-                    elem = elem.strip()
-                    for post_match in elem_match_map.get(elem, []):
-                        pattern_matches.append({
-                            "pattern元素": elem,
-                            "帖子选题点": post_match["帖子选题点"],
-                            "匹配分数": post_match["匹配分数"],
-                        })
-                distinct_post_points = len({m["帖子选题点"] for m in pattern_matches})
-                item["帖子选题点匹配"] = (
-                    pattern_matches if distinct_post_points >= 2 else "无"
+            account_match_lookup = _load_match_lookup(account_match_file)
+            platform_match_lookup = _load_match_lookup(platform_match_file)
+
+        def _filter_by_derived_match(
+            items: list[dict],
+            match_lookup: dict[tuple[str, str], float],
+        ) -> list[dict]:
+            """derived_topics 非空时过滤:pattern 至少有一个元素与任意 topic 匹配分 >= thr。"""
+            if not derived_topics or not post_id:
+                return items
+            return [
+                x for x in items
+                if _pattern_has_derived_match(
+                    str(x.get("pattern名称", "")), derived_topics, match_lookup, thr
                 )
+            ]
 
-        if items_account and post_id:
-            node_info_map = _build_node_info(account_name)
-            all_candidates_set: set[str] = set()
-            item_unmatched_info: list[list[tuple[str, list[str], str]]] = []
-
-            for item in items_account:
-                pattern_matches = item.get("帖子选题点匹配", [])
-                matched_elems = (
-                    {m["pattern元素"] for m in pattern_matches}
-                    if isinstance(pattern_matches, list) else set()
-                )
-                all_elems = [e.strip() for e in item["pattern名称"].split("+")]
-                unmatched = [e for e in all_elems if e not in matched_elems]
-
-                elem_candidates: list[tuple[str, list[str], str]] = []
-                for elem in unmatched:
-                    info = node_info_map.get(elem)
-                    if not info:
-                        continue
-                    if info["type"] == "class" and info["children"]:
-                        candidates = info["children"]
-                        expand_type = "子节点"
-                    else:
-                        candidates = info["siblings"]
-                        expand_type = "兄弟节点"
-                    if candidates:
-                        elem_candidates.append((elem, candidates, expand_type))
-                        all_candidates_set.update(candidates)
-                item_unmatched_info.append(elem_candidates)
-
-            if all_candidates_set:
-                candidate_matches = await match_derivation_to_post_points(
-                    list(all_candidates_set), account_name, post_id, match_threshold=thr
-                )
-                cand_match_map: dict[str, list[tuple[str, float]]] = {}
-                for m in candidate_matches:
-                    cand_match_map.setdefault(m["推导选题点"], []).append(
-                        (m["帖子选题点"], m["匹配分数"])
-                    )
-                for item, elem_cands in zip(items_account, item_unmatched_info):
-                    for elem, candidates, expand_type in elem_cands:
-                        best_cand, best_pp, best_sc = None, None, -1.0
-                        for cand in candidates:
-                            for pp, sc in cand_match_map.get(cand, []):
-                                if sc > best_sc:
-                                    best_cand, best_pp, best_sc = cand, pp, sc
-                        if best_cand is not None:
-                            if not isinstance(item.get("帖子选题点匹配"), list):
-                                item["帖子选题点匹配"] = []
-                            item["帖子选题点匹配"].append({
-                                "pattern元素": elem,
-                                "帖子选题点": best_pp,
-                                "匹配分数": best_sc,
-                                "扩展节点": best_cand,
-                                "扩展类型": expand_type,
-                            })
-
-        for item in items_account:
-            matches = item.get("帖子选题点匹配")
-            if not isinstance(matches, list):
-                continue
-            best_by_pp: dict[str, dict] = {}
-            for m in matches:
-                pp = m["帖子选题点"]
-                if pp not in best_by_pp or m["匹配分数"] > best_by_pp[pp]["匹配分数"]:
-                    best_by_pp[pp] = m
-            item["帖子选题点匹配"] = list(best_by_pp.values())
-
-        items_account = _mix_by_ratio(items_account, account_top_n)
+        # ---------- 账号 pattern ----------
+        account_candidate_n = candidate_mult if candidate_mult else account_top_n
+        items_account_raw = get_patterns_by_conditional_ratio(
+            account_name, derived_list, conditional_ratio_threshold, account_candidate_n
+        )
+        items_account = _filter_by_derived_match(items_account_raw, account_match_lookup)[:account_top_n]
         account_pattern_names = {str(x.get("pattern名称", "")).strip() for x in items_account}
 
-        # ---------- 平台库 pattern(xiaohongshu/tree 条件概率 + xiaohongshu/match_data 匹配)----------
-        items_platform: list[dict[str, Any]] = []
-        items_platform = get_platform_patterns_by_conditional_ratio(
-            derived_list, conditional_ratio_threshold / 5, candidate_top_n, post_id
+        # ---------- 平台库 pattern ----------
+        platform_candidate_n = (candidate_mult + len(account_pattern_names)) if candidate_mult else (platform_top_n + len(account_pattern_names))
+        items_platform_raw = get_platform_patterns_by_conditional_ratio(
+            derived_list,
+            conditional_ratio_threshold / 5,
+            platform_candidate_n,
         )
-        if post_id:
-            _attach_platform_pattern_post_matches(items_platform, post_id, thr)
-        else:
-            for item in items_platform:
-                item["帖子选题点匹配"] = "无"
-        items_platform = [
-            x for x in items_platform
-            if str(x.get("pattern名称", "")).strip() not in account_pattern_names
-        ]
-        for item in items_platform:
-            matches = item.get("帖子选题点匹配")
-            if not isinstance(matches, list):
-                continue
-            best_by_pp: dict[str, dict] = {}
-            for m in matches:
-                pp = m["帖子选题点"]
-                if pp not in best_by_pp or m["匹配分数"] > best_by_pp[pp]["匹配分数"]:
-                    best_by_pp[pp] = m
-            item["帖子选题点匹配"] = list(best_by_pp.values())
-        items_platform = _mix_by_ratio(items_platform, platform_top_n)
+        items_platform = _filter_by_derived_match(
+            [x for x in items_platform_raw if str(x.get("pattern名称", "")).strip() not in account_pattern_names],
+            platform_match_lookup,
+        )[:platform_top_n]
 
         def _format_pattern_block(xs: list[dict[str, Any]]) -> list[str]:
-            lines: list[str] = []
-            for x in xs:
-                match_info = x.get("帖子选题点匹配", "无")
-                if isinstance(match_info, list):
-                    match_str = "、".join(
-                        (
-                            f"{m['扩展节点']}({m['pattern元素']}的{m['扩展类型']})→{m['帖子选题点']}({m['匹配分数']})"
-                            if "扩展节点" in m else
-                            f"{m['pattern元素']}→{m['帖子选题点']}({m['匹配分数']})"
-                        )
-                        for m in match_info
-                    )
-                else:
-                    match_str = str(match_info)
-                lines.append(
-                    f"- {x['pattern名称']}\t条件概率={x['条件概率']}\t帖子选题点匹配={match_str}"
-                )
-            return lines
+            return [f"- {x['pattern名称']}\t条件概率={x['条件概率']}" for x in xs]
 
         lines_out: list[str] = []
         lines_out.append(
-            "【优先使用】第一节为账号 pattern;第二节为平台库 pattern。"
+            "【优先使用】第一节为账号 pattern(优先使用);第二节为平台库 pattern。"
         )
         lines_out.append("")
         lines_out.append("—— 账号 pattern ——")
@@ -699,9 +397,7 @@ async def find_pattern(
         lines_out.append("")
         lines_out.append("—— 平台库 pattern ——")
         if not items_platform:
-            lines_out.append(
-                "(无:未找到达标 pattern)"
-            )
+            lines_out.append("(无:未找到达标 pattern)")
         else:
             lines_out.extend(_format_pattern_block(items_platform))
 
@@ -712,8 +408,11 @@ async def find_pattern(
             metadata={
                 "account_name": account_name,
                 "conditional_ratio_threshold": conditional_ratio_threshold,
-                "match_score_threshold": thr,
                 "top_n": top_n,
+                "quota": {
+                    "account_top_n": account_top_n,
+                    "platform_top_n": platform_top_n,
+                },
                 "account_pattern_count": len(items_account),
                 "platform_pattern_count": len(items_platform),
                 "count": len(items_account) + len(items_platform),
@@ -728,32 +427,31 @@ async def find_pattern(
 
 
 def main() -> None:
-    """本地测试:用家有大志账号、已推导选题点,查询符合条件概率阈值的 pattern(含帖子匹配)。"""
+    """本地测试:用家有大志账号、已推导选题点,查询符合条件概率阈值的 pattern。"""
     import asyncio
 
     account_name = "家有大志"
     post_id = "68fb6a5c000000000302e5de"
-    # 已推导选题点,每项:已推导的选题点 + 推导来源人设树节点
-    # derived_items = [
-    #     {"topic": "分享", "source_node": "分享"},
-    #     {"topic": "植入方式", "source_node": "植入方式"},
-    #     {"topic": "叙事结构", "source_node": "叙事结构"},
-    # ]
-    derived_items = derived_items = []
+    derived_items = [
+        {"topic": "分享", "source_node": "分享"},
+        {"topic": "植入方式", "source_node": "植入方式"},
+        {"topic": "叙事结构", "source_node": "叙事结构"},
+    ]
+    derived_items: list[dict[str, str]] = []
     conditional_ratio_threshold = 0.2
-    top_n = 200
+    top_n = 500
 
-    # 1)直接调用核心函数(不含帖子匹配,仅验证排序逻辑)
+    # 1)直接调用核心函数(仅验证排序逻辑)
     # derived_list = _parse_derived_list(derived_items)
     # items = get_patterns_by_conditional_ratio(
-    #     account_name, derived_list, conditional_ratio_threshold, top_n, post_id
+    #     account_name, derived_list, conditional_ratio_threshold, top_n
     # )
     # print(f"账号: {account_name}, 阈值: {conditional_ratio_threshold}, top_n: {top_n}")
     # print(f"共 {len(items)} 条 pattern:\n")
     # for x in items:
     #     print(f"  - {x['pattern名称']}\t条件概率={x['条件概率']}")
 
-    # 2)有 agent 时通过 tool 接口再跑一遍(含帖子选题点匹配)
+    # 2)有 agent 时通过 tool 接口再跑一遍
     if ToolResult is not None:
         async def run_tool():
             result = await find_pattern(

+ 42 - 105
examples_how/overall_derivation/tools/find_tree_node.py

@@ -25,10 +25,6 @@ from utils.conditional_ratio_calc import (  # noqa: E402
     calc_node_conditional_ratio,
     load_persona_trees_from_dir,
 )
-from tools.point_match import (  # noqa: E402
-    DEFAULT_MATCH_THRESHOLD,
-    match_derivation_to_post_points,
-)
 
 try:
     from agent.tools import tool, ToolResult, ToolContext
@@ -585,16 +581,16 @@ async def find_tree_constant_nodes(
     post_id: str,
 ) -> ToolResult:
     """
-    获取人设树中的常量节点列表(全局常量与局部常量),并检查每个节点与帖子选题点的匹配情况
+    获取人设树中的常量节点列表(全局常量与局部常量)。
 
     Args:
     account_name : 账号名,用于定位该账号的人设树数据。
-    post_id : 帖子ID,用于加载帖子选题点并与各常量节点做匹配判断
+    post_id : 帖子ID(保留参数,当前版本暂不使用)
 
     Returns:
     ToolResult:
         - title: 结果标题。
-        - output: 可读的节点列表文本(每行:节点名称、概率、常量类型、帖子匹配情况)。
+        - output: 可读的节点列表文本(每行:节点名称、概率、常量类型)。
         - 出错时 error 为错误信息。
     """
     tree_dir = _tree_dir(account_name)
@@ -606,32 +602,10 @@ async def find_tree_constant_nodes(
         )
     try:
         items = get_constant_nodes(account_name)
-        # 批量匹配所有节点与帖子选题点
-        if items and post_id:
-            node_names = [x["节点名称"] for x in items]
-            matched_results = await match_derivation_to_post_points(
-                node_names, account_name, post_id, match_threshold=float(DEFAULT_MATCH_THRESHOLD)
-            )
-            node_match_map: dict[str, list] = {}
-            for m in matched_results:
-                node_match_map.setdefault(m["推导选题点"], []).append({
-                    "帖子选题点": m["帖子选题点"],
-                    "匹配分数": m["匹配分数"],
-                })
-            for item in items:
-                matches = node_match_map.get(item["节点名称"], [])
-                item["帖子选题点匹配"] = matches if matches else "无"
         if not items:
             output = "未找到常量节点"
         else:
-            lines = []
-            for x in items:
-                match_info = x.get("帖子选题点匹配", "无")
-                if isinstance(match_info, list):
-                    match_str = "、".join(f"{m['帖子选题点']}({m['匹配分数']})" for m in match_info)
-                else:
-                    match_str = str(match_info)
-                lines.append(f"- {x['节点名称']}\t概率={x['概率']}\t{x['常量类型']}\t帖子选题点匹配={match_str}")
+            lines = [f"- {x['节点名称']}\t概率={x['概率']}\t{x['常量类型']}" for x in items]
             output = "\n".join(lines)
         return ToolResult(
             title=f"常量节点 ({account_name})",
@@ -655,31 +629,26 @@ async def find_tree_nodes_by_conditional_ratio(
     top_n: int = 100,
     round: int = 1,
     log_id: str = "",
-    match_score_threshold: float = DEFAULT_MATCH_THRESHOLD,
+    match_score_threshold: float = 0.7,
 ) -> ToolResult:
     """
-    按条件概率阈值筛选节点:先账号人设树(优先使用),再平台库人设树;两段不合并。
-    条件概率计算对两棵树使用同一套规则(calc_node_conditional_ratio / 节点 _post_ids)。
-    返回结果按以下配额分配(合计 top_n 条):
-      - 账号人设树节点占 60%,其中有帖子选题点匹配的记录和无帖子选题点匹配的记录各占一半;
-      - 平台库人设树节点占 40%,其中有帖子选题点匹配的记录和无帖子选题点匹配的记录各占一半。
-    「帖子选题点匹配」仅收录匹配分 >= match_score_threshold 的选题点。
+    按条件概率阈值筛选节点,第一节为账号人设树节点(优先使用),第二节为平台库人设树节点。
 
     Args:
     account_name : 账号名,用于定位该账号的人设树数据。
-    post_id : 帖子ID,用于加载帖子选题点并与各节点做匹配判断
+    post_id : 帖子ID,用于定位推导日志目录(维度分析文件)。
     derived_items : 已推导选题点列表,可为空。非空时每项为字典,需含 topic(或「已推导的选题点」)与 source_node(或「推导来源人设树节点」)
     conditional_ratio_threshold : 条件概率阈值,仅返回条件概率 >= 该值的节点。
-    top_n : 最终返回总条数上限,按 账号60%/平台40%、有匹配/无匹配各半 分配。
+    top_n : 最终返回总条数上限,按 账号60%/平台40% 分配。
     round : 推导轮次。
     log_id : 推导日志ID
-    match_score_threshold : 帖子选题点匹配分阈值,与 point_match 默认一致
+    match_score_threshold : 帖子选题点匹配分阈值(保留参数,当前版本暂不使用)
 
     Returns:
     ToolResult:
         - title: 结果标题。
         - output: 两段文本——先账号人设树,后平台库人设树;
-          账号侧匹配来自 input/{账号}/match_data;平台侧条件概率基于 input/xiaohongshu/tree,匹配来自 input/xiaohongshu/match_data
+          平台侧条件概率基于 input/xiaohongshu/tree。
         - 出错时 error 为错误信息。
     """
     tree_dir = _tree_dir(account_name)
@@ -723,7 +692,7 @@ async def find_tree_nodes_by_conditional_ratio(
         if derived_items_len > 15 and derived_dim_names:
             derived_list = [(n, n) for n in derived_dim_names]
 
-        # 1)账号人设树:按条件概率筛选;帖子选题点匹配仅走账号 match_data(match_derivation_to_post_points)
+        # 1)账号人设树:按条件概率筛选
         items = get_nodes_by_conditional_ratio(
             account_name,
             derived_list,
@@ -732,68 +701,43 @@ async def find_tree_nodes_by_conditional_ratio(
             allowed_node_names=allowed,
             node_belonging_dim=node_belonging_dim if node_belonging_dim else None,
         )
-        if items and post_id:
-            node_names = [x["节点名称"] for x in items]
-            matched_results = await match_derivation_to_post_points(
-                node_names, account_name, post_id, match_threshold=float(match_score_threshold)
-            )
-            node_match_map: dict[str, list] = {}
-            for m in matched_results:
-                node_match_map.setdefault(m["推导选题点"], []).append({
-                    "帖子选题点": m["帖子选题点"],
-                    "匹配分数": m["匹配分数"],
-                })
-            for item in items:
-                matches = node_match_map.get(item["节点名称"], [])
-                item["帖子选题点匹配"] = matches if matches else "无"
-
-        # 账号配额:占 top_n 的 60%,有/无匹配各一半
+
+        # 账号配额:占 top_n 的 60%
         account_quota = int(top_n * 0.6 + 0.5)
-        account_with_n = account_quota // 2
-        account_without_n = account_quota - account_with_n
-        items_with_match = [x for x in items if isinstance(x.get("帖子选题点匹配"), list)]
-        items_without_match = [x for x in items if not isinstance(x.get("帖子选题点匹配"), list)]
-        items = items_with_match[:account_with_n] + items_without_match[:account_without_n]
-
-        # 2)平台库人设树(条件概率 + xiaohongshu 匹配文件)
-        # 平台配额:占 top_n 的 40%,有/无匹配各一半
+        items = items[:account_quota]
+
+        # 2)平台库人设树:按条件概率筛选,排除账号段已有节点名
         platform_quota = top_n - account_quota
-        platform_with_n = platform_quota // 2
-        platform_without_n = platform_quota - platform_with_n
-        # 平台「有匹配」排除账号侧已有帖子选题点匹配的节点名(与账号段去重)。
-        # 平台「无匹配」排除已在账号段输出里出现过的节点名(避免重复罗列无新信息的同名节点)。
-        account_matched_names = {str(x.get("节点名称", "")).strip() for x in items if isinstance(x.get("帖子选题点匹配"), list)}
         account_all_names = {str(x.get("节点名称", "")).strip() for x in items}
         platform_items: list[dict[str, Any]] = []
-        if post_id:
-            p_matched_raw, p_unmatched_raw = _load_platform_nodes_split(
-                post_id=post_id,
-                derived_list=derived_list,
-                conditional_ratio_threshold=float(conditional_ratio_threshold),
-                match_score_threshold=float(match_score_threshold),
-                top_n=top_n,
-                node_belonging_dim_platform=node_belonging_dim_platform,
-            )
-            p_matched = [p for p in p_matched_raw if str(p.get("节点名称", "")).strip() not in account_matched_names]
-            p_unmatched = [p for p in p_unmatched_raw if str(p.get("节点名称", "")).strip() not in account_all_names]
-            platform_items = p_matched[:platform_with_n] + p_unmatched[:platform_without_n]
+        all_platform_scored = _collect_platform_scored_tuples(
+            derived_list, float(conditional_ratio_threshold)
+        )
+        for name, ratio, parent, dim in all_platform_scored:
+            if str(name).strip() in account_all_names:
+                continue
+            out_dim = "—"
+            if node_belonging_dim_platform is not None:
+                out_dim = node_belonging_dim_platform.get(str(name).strip()) or "—"
+            if node_belonging_dim_platform is not None and out_dim == "—":
+                continue
+            platform_items.append({
+                "节点名称": name,
+                "条件概率": ratio,
+                "父节点名称": parent,
+                "所属维度": out_dim,
+            })
+            if len(platform_items) >= platform_quota:
+                break
 
         def _format_node_line(x: dict[str, Any]) -> str:
-            match_info = x.get("帖子选题点匹配", "无")
-            if isinstance(match_info, list):
-                match_str = "、".join(f"{m['帖子选题点']}({m['匹配分数']})" for m in match_info)
-            else:
-                match_str = str(match_info)
             dim_label = x.get("所属维度", "—")
-            return (
-                f"- {x['节点名称']}\t条件概率={x['条件概率']}\t所属维度={dim_label}"
-                f"\t帖子选题点匹配={match_str}"
-            )
+            return f"- {x['节点名称']}\t条件概率={x['条件概率']}\t所属维度={dim_label}"
 
         lines: list[str] = []
         lines.append(
-            "【优先使用】第一节为账号人设树中条件概率达标的节点(占60%配额,有/无帖子匹配各半);"
-            "第二节为平台库人设树中条件概率达标的节点(占40%配额,有/无帖子匹配各半);"
+            "【优先使用】第一节为账号人设树中条件概率达标的节点;"
+            "第二节为平台库人设树中条件概率达标的节点;"
         )
         lines.append("")
         lines.append("—— 账号人设树节点 ——")
@@ -804,9 +748,7 @@ async def find_tree_nodes_by_conditional_ratio(
         lines.append("")
         lines.append("—— 平台库人设树节点 ——")
         if not platform_items:
-            lines.append(
-                "(无:未找到条件概率达标的节点)"
-            )
+            lines.append("(无:未找到条件概率达标的节点)")
         else:
             lines.extend(_format_node_line(x) for x in platform_items)
 
@@ -817,15 +759,10 @@ async def find_tree_nodes_by_conditional_ratio(
             metadata={
                 "account_name": account_name,
                 "threshold": conditional_ratio_threshold,
-                "match_score_threshold": float(match_score_threshold),
                 "top_n": top_n,
                 "quota": {
                     "account_quota": account_quota,
-                    "account_with_match": len([x for x in items if isinstance(x.get("帖子选题点匹配"), list)]),
-                    "account_without_match": len([x for x in items if not isinstance(x.get("帖子选题点匹配"), list)]),
                     "platform_quota": platform_quota,
-                    "platform_with_match": len([x for x in platform_items if isinstance(x.get("帖子选题点匹配"), list)]),
-                    "platform_without_match": len([x for x in platform_items if not isinstance(x.get("帖子选题点匹配"), list)]),
                 },
                 "account_tree_count": len(items),
                 "platform_tree_count": len(platform_items),
@@ -853,7 +790,7 @@ def main() -> None:
 
     account_name = "家有大志"
     post_id = "68fb6a5c000000000302e5de"
-    log_id = "20260319134630"
+    log_id = "20260324141307"
     round = 4
     # derived_items = [
     #     {"topic": "分享", "source_node": "分享"},
@@ -861,7 +798,7 @@ def main() -> None:
     # ]
     derived_items = [{"topic":"推广","source_node":"推广"},{"topic":"视觉调性","source_node":"视觉调性"}]
     conditional_ratio_threshold = 0.2
-    top_n = 200
+    top_n = 100
 
     # # 1)常量节点(核心函数,无匹配)
     # constant_nodes = get_constant_nodes(account_name)
@@ -880,7 +817,7 @@ def main() -> None:
     #     print(f"  - {x['节点名称']}\t条件概率={x['条件概率']}\t父节点={x['父节点名称']}")
     # print()
 
-    # 3)有 agent 时通过 tool 接口再跑一遍(含帖子选题点匹配)
+    # 3)有 agent 时通过 tool 接口再跑一遍
     if ToolResult is not None:
         async def run_tools():
             r1 = await find_tree_constant_nodes(account_name, post_id=post_id)

+ 8 - 8
examples_how/overall_derivation/tools/pattern_dimension_analyze.py

@@ -624,7 +624,7 @@ def _analyze_single_round(
         ]
         if not item_names:
             continue
-        if len(item_names) < 5:
+        if len(item_names) < 3:
             continue
         derived_count = sum(1 for name in item_names if name in derived_ancestor_set)
         if derived_count / len(item_names) >= 0.5:
@@ -1005,13 +1005,13 @@ if __name__ == "__main__":
                 globals()["ToolContext"] = _m.ToolContext
 
     # ---------- 开关与参数(改这里即可) ----------
-    run_round_pattern_test = False
-    run_full_pattern_analyze = True
+    run_round_pattern_test = True
+    run_full_pattern_analyze = False
 
-
-    test_post_id = "6915dfc400000000070224d9"
-    test_log_id = "20260322134324"
-    test_round = 1
+    test_account_name = "家有大志"
+    test_post_id = "68fb6a5c000000000302e5de"
+    test_log_id = "20260324162232"
+    test_round = 2
 
     # account_name = "阿里多多酱"
     # items = [
@@ -1039,7 +1039,7 @@ if __name__ == "__main__":
 
     if run_round_pattern_test:
         main_round_pattern_dimension_analyze(
-            account_name,
+            test_account_name,
             test_post_id,
             test_log_id,
             test_round,

+ 18 - 6
examples_how/overall_derivation/tools/point_match.py

@@ -92,8 +92,21 @@ def _load_post_topic_points(account_name: str, post_id: str) -> List[str]:
 
 
 def _to_derivation_points(derivation_output_points: List[str]) -> List[str]:
-    """从推导选题点字符串列表中筛出非空并 strip,返回列表。"""
-    return [s.strip() for s in derivation_output_points if s is not None and str(s).strip()]
+    """
+    从推导选题点字符串列表中筛出非空并 strip,返回去重后的列表。
+    兼容 "叙事编排+商业融入+物品" 格式:先按 "+" 拆分,再展开为独立选题点,最终去重(保持首次出现顺序)。
+    """
+    seen: set[str] = set()
+    result: List[str] = []
+    for s in derivation_output_points:
+        if s is None:
+            continue
+        for part in str(s).split("+"):
+            part = part.strip()
+            if part and part not in seen:
+                seen.add(part)
+                result.append(part)
+    return result
 
 
 async def match_derivation_to_post_points(
@@ -149,13 +162,12 @@ async def match_derivation_to_post_points(
     return matched
 
 
-# @tool()
+@tool()
 async def point_match(
     derivation_output_points: List[str],
     account_name: str,
     post_id: str,
     match_threshold: float = DEFAULT_MATCH_THRESHOLD,
-    context: Optional[ToolContext] = None,
 ) -> ToolResult:
     """
     判断推导选题点与帖子选题点是否匹配,返回达到阈值的匹配对。
@@ -164,7 +176,7 @@ async def point_match(
     derivation_output_points : 推导选题点字符串列表。
     account_name : 账号名,用于定位 input 下的账号目录。
     post_id : 帖子ID,用于定位该帖的选题点与匹配数据。
-    match_threshold : 匹配分数阈值,分数 >= 该值视为匹配成功,默认 0.8
+    match_threshold : 匹配分数阈值,分数 >= 该值视为匹配成功,默认 0.6
 
     Returns:
     ToolResult:
@@ -222,7 +234,7 @@ def main() -> None:
 
     account_name = "家有大志"
     post_id = "68fb6a5c000000000302e5de"
-    derivation_output_points = ["分享", "创意改造", "柴犬", "不存在的点"]
+    derivation_output_points = ["分享+创意改造", "柴犬", "不存在的点"]
 
     async def run():
         matched = await match_derivation_to_post_points(

+ 68 - 11
examples_how/overall_derivation/visualize_paths.py

@@ -150,6 +150,8 @@ def generate_all_in_one_visualization(
             transition: background 0.2s;
         }}
         .controls button:hover {{ background: #2563eb; }}
+        #btn-toggle-fail-path.active {{ background: #64748b; }}
+        #btn-toggle-fail-path.active:hover {{ background: #475569; }}
 
         /* 画布区域 */
         #app-container {{
@@ -242,6 +244,26 @@ def generate_all_in_one_visualization(
                 linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px),
                 linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px);
         }}
+        .node-card.failed-path {{
+            background: #f8fafc;
+            border-color: #94a3b8;
+            color: #334155;
+        }}
+        .node-card.failed-path .node-header,
+        .node-card.failed-path .key,
+        .node-card.failed-path .val {{ color: #475569; }}
+        .node-card.failed-path.not-fully-derived {{
+            background-color: #f8fafc;
+            background-image:
+                linear-gradient(90deg, #94a3b8 0 8px, transparent 8px 20px),
+                linear-gradient(90deg, #94a3b8 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #94a3b8 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #94a3b8 0 8px, transparent 8px 20px);
+        }}
+        .edge-path.failed-path {{ stroke: #cbd5e1; }}
+        .edge-label-text.failed-path {{ fill: #64748b; }}
+        .edge-label-sub.failed-path {{ fill: #94a3b8; }}
+        .connector-dot.failed-path {{ fill: #cbd5e1; }}
 
         .edge-path.dimmed {{
             opacity: 0.05;
@@ -596,6 +618,7 @@ def generate_all_in_one_visualization(
                 {"".join([f'<option value="{k}">{k}</option>' for k in data_map.keys()])}
             </select>
             <input type="text" id="search-input" placeholder="输入关键字 并回车定位..." />
+            <button id="btn-toggle-fail-path" onclick="toggleFailPathMode()">显示推导失败的路径</button>
             <button onclick="resetView()">重置视图</button>
             <button onclick="toggleDerivationProgress()">推导进度</button>
         </div>
@@ -693,6 +716,7 @@ def generate_all_in_one_visualization(
         let flatData = {{ nodesByLevel: {{}}, map: {{}} }};
         let edgeGroups = {{}};
         let currentPostKey = "{first_key}";
+        let showFailedPaths = false;
 
         // 1. 数据解析 - 适配 node_list 和 edge_list 格式
         function parseData(postKey) {{
@@ -700,9 +724,9 @@ def generate_all_in_one_visualization(
             edgeGroups = {{}};
             
             const data = allData[postKey];
-            const nodesData = data.node_list || [];
-            const edgesData = data.edge_list || [];
-            const allUsedTreeNodes = data.all_used_tree_nodes || [];
+            const nodesData = showFailedPaths ? (data.fail_node_list || []) : (data.node_list || []);
+            const edgesData = showFailedPaths ? (data.fail_edge_list || []) : (data.edge_list || []);
+            const allUsedTreeNodes = showFailedPaths ? (data.fail_all_used_tree_nodes || []) : (data.all_used_tree_nodes || []);
 
             // 创建节点映射
             const nodeMap = {{}};
@@ -840,7 +864,8 @@ def generate_all_in_one_visualization(
                         sourceMap: inputSourceMap, // 保存输入节点的来源映射
                         edgeName: edgeName,
                         edgeScore: edgeScore,
-                        edgeData: edge  // 保存完整的边数据
+                        edgeData: edge,  // 保存完整的边数据
+                        isFailed: showFailedPaths
                     }};
                 }}
 
@@ -938,14 +963,16 @@ def generate_all_in_one_visualization(
                     `;
                 }} else if (node.level === -1) {{
                     // level -1 的常量节点(人设/全局常量),只显示 name 和 type
-                    el.className = 'node-card';
+                    el.className = 'node-card' + (showFailedPaths ? ' failed-path' : '');
                     el.style.height = (node.height || estimateHeight(node)) + 'px';
                     let html = `<div class="node-header">${{node.name}}</div>`;
                     if (node.data.type) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.type}}</span></div>`;
                     el.innerHTML = html;
                 }} else {{
                     // node_list 节点:is_fully_derived=false 时用虚线框,名称显示 derivation_output_point,只显示帖子选题点
-                    el.className = 'node-card' + (node.data.is_fully_derived === false ? ' not-fully-derived' : '');
+                    el.className = 'node-card'
+                        + (node.data.is_fully_derived === false ? ' not-fully-derived' : '')
+                        + (showFailedPaths ? ' failed-path' : '');
                     el.style.height = (node.height || estimateHeight(node)) + 'px';
                     const displayName = (node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
                         ? node.data.derivation_output_point : node.name;
@@ -1006,6 +1033,7 @@ def generate_all_in_one_visualization(
             }};
             defs.appendChild(createMarker('arrow-head', '#cbd5e1'));
             defs.appendChild(createMarker('arrow-head-highlight', '#2563eb'));
+            defs.appendChild(createMarker('arrow-head-failed', '#cbd5e1'));
             svg.appendChild(defs);
 
             Object.values(edgeGroups).forEach(group => {{
@@ -1134,7 +1162,7 @@ def generate_all_in_one_visualization(
                     const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
                     p.setAttribute('d', `M ${{forkX}} ${{t.inputPoint.y}} L ${{t.inputPoint.x}} ${{t.inputPoint.y}}`);
                     p.classList.add('edge-path', 'entry');
-                    p.setAttribute('marker-end', 'url(#arrow-head)');
+                    p.setAttribute('marker-end', showFailedPaths ? 'url(#arrow-head-failed)' : 'url(#arrow-head)');
                     p.style.cursor = 'pointer';
                     p.addEventListener('click', handleGroupClick);
                     g.appendChild(p);
@@ -1189,6 +1217,11 @@ def generate_all_in_one_visualization(
                     }}
                 }}
 
+                if (group.isFailed) {{
+                    g.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
+                        el.classList.add('failed-path');
+                    }});
+                }}
                 svg.appendChild(g);
             }});
 
@@ -1360,7 +1393,7 @@ def generate_all_in_one_visualization(
                 el.classList.add('dimmed');
             }});
             document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
-            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', showFailedPaths ? 'url(#arrow-head-failed)' : 'url(#arrow-head)'));
             
             // 高亮源节点(根据来源区分查找)
             const sourceMap = group.sourceMap || {{}};
@@ -1431,7 +1464,7 @@ def generate_all_in_one_visualization(
                 el.classList.add('dimmed');
             }});
             document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
-            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', showFailedPaths ? 'url(#arrow-head-failed)' : 'url(#arrow-head)'));
 
             let nodesToHighlight = [targetNode];
 
@@ -1644,7 +1677,7 @@ def generate_all_in_one_visualization(
             document.querySelectorAll('.highlight').forEach(el => el.classList.remove('highlight'));
             document.querySelectorAll('.dimmed').forEach(el => el.classList.remove('dimmed'));
             document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
-            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', showFailedPaths ? 'url(#arrow-head-failed)' : 'url(#arrow-head)'));
             document.getElementById('search-input').value = '';
             currentSelectedNode = null; // 清除当前选中的节点
             currentSelectedEdgeGroup = null; // 清除当前选中的边组
@@ -3184,6 +3217,29 @@ def generate_all_in_one_visualization(
             renderDerivationProgress(val);
         }}
 
+        function updateFailPathToggleButton() {{
+            const btn = document.getElementById('btn-toggle-fail-path');
+            if (!btn) return;
+            if (showFailedPaths) {{
+                btn.classList.add('active');
+                btn.textContent = '显示推导成功的路径';
+            }} else {{
+                btn.classList.remove('active');
+                btn.textContent = '显示推导失败的路径';
+            }}
+        }}
+
+        function toggleFailPathMode() {{
+            showFailedPaths = !showFailedPaths;
+            updateFailPathToggleButton();
+            parseData(currentPostKey);
+            calculateLayout();
+            renderNodes();
+            renderEdges();
+            updateTransform();
+            resetView();
+        }}
+
         function closeDimensionPatternsModal() {{
             const modal = document.getElementById('dimension-patterns-modal');
             if (modal) modal.classList.remove('active');
@@ -3554,6 +3610,7 @@ def generate_all_in_one_visualization(
         renderNodes();
         renderEdges();
         updateTransform();
+        updateFailPathToggleButton();
         renderDerivationProgress(currentPostKey);
     </script>
 </body>
@@ -3661,4 +3718,4 @@ def main(account_name) -> None:
 
 
 if __name__ == "__main__":
-    main(account_name="空间点阵设计研究室")
+    main(account_name="家有大志")

+ 3664 - 0
examples_how/overall_derivation/visualize_paths_bak0324.py

@@ -0,0 +1,3664 @@
+import json
+import os
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional
+
+
+def _build_selection_points_from_decode(decode_data: Dict) -> List[Dict]:
+    """将 examples 解构内容 JSON 转为可视化「帖子选题表」所需的选题点行。"""
+    result: List[Dict] = []
+    point_types = ["灵感点", "目的点", "关键点"]
+    for point_type in point_types:
+        points = decode_data.get(point_type, [])
+        if not isinstance(points, list):
+            continue
+        for item in points:
+            if not isinstance(item, dict):
+                continue
+            title = item.get("选题点") or item.get("点") or ""
+            essence: List[str] = []
+            form: List[str] = []
+            intent: List[str] = []
+            for el in item.get("选题点元素") or []:
+                if not isinstance(el, dict):
+                    continue
+                name = el.get("元素名称")
+                if not name:
+                    continue
+                et = el.get("元素类型") or ""
+                if et == "实质":
+                    essence.append(name)
+                elif et == "形式":
+                    form.append(name)
+                elif et == "意图":
+                    intent.append(name)
+                else:
+                    essence.append(name)
+            result.append(
+                {
+                    "类型": point_type,
+                    "选题点": title,
+                    "实质": essence,
+                    "形式": form,
+                    "意图": intent,
+                }
+            )
+    return result
+
+
+def load_post_detail_for_visualization(account_name: str, post_id: str) -> Optional[Dict]:
+    """
+    从 Agent 示例目录读取原始帖子与解构内容,供「待解构帖子详情」弹窗与侧边栏使用。
+    - post_data: input/{account}/原始数据/post_data/{post_id}.json
+    - 解构: input/{account}/原始数据/解构内容/{post_id}.json
+    """
+    base = Path(__file__).resolve().parent
+    post_path = base / "input" / account_name / "原始数据" / "post_data" / f"{post_id}.json"
+    decode_path = base / "input" / account_name / "原始数据" / "解构内容" / f"{post_id}.json"
+    try:
+        with open(post_path, "r", encoding="utf-8") as f:
+            post_data = json.load(f)
+    except Exception:
+        return None
+    decode_data: Dict = {}
+    try:
+        with open(decode_path, "r", encoding="utf-8") as f:
+            decode_data = json.load(f)
+    except Exception:
+        pass
+    out = dict(post_data)
+    out["选题点"] = _build_selection_points_from_decode(decode_data) if decode_data else []
+    pid = out.get("channel_content_id") or decode_data.get("帖子ID")
+    if pid and not out.get("id"):
+        out["id"] = pid
+    return out
+
+def generate_all_in_one_visualization(
+    data_map: Dict[str, dict],
+    output_path: str,
+    account_name: str,
+    derivation_data: Dict[str, list] = None,
+    post_detail_map: Dict[str, dict] = None,
+    dimension_analyze_map: Dict[str, dict] = None,
+):
+    """
+    将所有帖子的数据整合到一个 HTML 中,支持动态切换
+    data_map: { "文件名": json_data, ... }
+    derivation_data: { "文件名": 推导结果列表, ... }
+    post_detail_map: { "文件名": 帖子详情(含选题点),来自 load_post_detail_for_visualization }
+    dimension_analyze_map: { post_id: 整体推导维度分析 JSON(含 rounds.derived_dims 等)}
+    """
+    # 提取第一个帖子的数据作为默认展示
+    first_key = list(data_map.keys())[0]
+    
+    # 将整个 data_map 转换为 JS 对象
+    json_data_js = json.dumps(data_map, ensure_ascii=False)
+    
+    # 将推导数据转换为 JS 对象
+    if derivation_data is None:
+        derivation_data = {}
+    derivation_data_js = json.dumps(derivation_data, ensure_ascii=False)
+    
+    # 将帖子详情数据转换为 JS 对象(供「待解构帖子」弹窗使用)
+    if post_detail_map is None:
+        post_detail_map = {}
+    post_detail_map_js = json.dumps(post_detail_map, ensure_ascii=False)
+
+    if dimension_analyze_map is None:
+        dimension_analyze_map = {}
+    dimension_analyze_data_js = json.dumps(dimension_analyze_map, ensure_ascii=False)
+
+    account_name_js = json.dumps(account_name, ensure_ascii=False)
+
+    html_content = rf'''<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>多源数据流可视化 - 完整全景版</title>
+    <style>
+        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
+        body {{
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+            background: #f8fafc;
+            overflow: hidden;
+            user-select: none;
+        }}
+
+        /* 顶部工具栏 */
+        #top-bar {{
+            position: fixed; top: 0; left: 0; right: 0; height: 60px;
+            background: white; border-bottom: 1px solid #e2e8f0;
+            display: flex; align-items: center; justify-content: space-between;
+            padding: 0 24px; z-index: 100;
+            box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+        }}
+        .controls {{ display: flex; gap: 16px; align-items: center; }}
+        .controls input {{
+            padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
+            font-size: 14px; width: 220px; transition: border 0.2s;
+        }}
+        .controls input:focus {{ border-color: #3b82f6; outline: none; }}
+        .controls select {{
+            padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
+            font-size: 13px; width: 320px; transition: border 0.2s;
+        }}
+        .controls select:focus {{ border-color: #3b82f6; outline: none; }}
+        .controls button {{
+            padding: 8px 16px; background: #3b82f6; color: white; border: none;
+            border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
+            transition: background 0.2s;
+        }}
+        .controls button:hover {{ background: #2563eb; }}
+
+        /* 画布区域 */
+        #app-container {{
+            position: fixed; top: 60px; left: 0; right: 0; bottom: 0;
+            overflow: hidden; cursor: grab; background: #f8fafc;
+            /* 移除 transition,让画布缩放瞬间完成 */
+            z-index: 1;
+            transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+        }}
+        #app-container.grabbing {{ cursor: grabbing; }}
+        /* 当侧边栏显示时,画布缩小并向右移动(宽度通过 JavaScript 动态设置) */
+        #app-container.sidebar-open {{
+            /* right 和 width 通过 JavaScript 动态设置 */
+        }}
+
+        #canvas {{
+            position: absolute;
+            transform-origin: 0 0;
+            transition: transform 0.1s linear;
+        }}
+        #canvas.animating {{ transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1); }}
+
+        /* 列标题 */
+        .column-header {{
+            position: absolute;
+            height: 36px; line-height: 36px;
+            font-size: 14px; font-weight: 600; color: #64748b;
+            background: #f1f5f9; border-radius: 18px;
+            text-align: center; padding: 0 20px;
+            z-index: 2; white-space: nowrap;
+            box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+        }}
+
+        /* 卡片样式(实线框颜色略深) */
+        .constant-card, .node-card {{
+            position: absolute; background: white;
+            border: 1px solid #64748b; border-radius: 10px;
+            padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+            cursor: pointer; transition: all 0.2s ease-out; z-index: 10;
+        }}
+        /* 当侧边栏打开时,提高节点卡片的 z-index */
+        #app-container.sidebar-open .constant-card,
+        #app-container.sidebar-open .node-card {{
+            z-index: 150;
+        }}
+        .constant-card {{ width: 280px; border-left: 5px solid #8b5cf6; }}
+        .node-card {{ width: 320px; }}
+
+        .constant-card:hover, .node-card:hover {{
+            transform: translateY(-2px);
+            box-shadow: 0 10px 20px -5px rgba(0,0,0,0.1);
+            border-color: #475569;
+        }}
+
+        /* 高亮样式(实线蓝框) */
+        .highlight {{
+            border: 2px solid #2563eb !important;
+            background: #eff6ff !important;
+            box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
+            z-index: 20;
+        }}
+        /* 虚线框节点高亮时保持虚线蓝框(用渐变虚线,去掉实线 border) */
+        .node-card.not-fully-derived.highlight {{
+            border: none !important;
+            background-color: #eff6ff !important;
+            box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
+        }}
+
+        /* 变暗样式 */
+        .dimmed {{ opacity: 0.1; filter: grayscale(100%); pointer-events: none; }}
+
+        /* 未完全推导的节点:虚线框(用渐变模拟较疏虚线,颜色略深) */
+        .node-card.not-fully-derived {{
+            border: none;
+            border-radius: 10px;
+            background-color: white;
+            background-image:
+                linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
+                linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #475569 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #475569 0 8px, transparent 8px 20px);
+            background-size: 28px 2px, 28px 2px, 2px 28px, 2px 28px;
+            background-position: left top, left bottom, left top, right top;
+            background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
+        }}
+        .node-card.not-fully-derived.highlight {{
+            background-image:
+                linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
+                linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px),
+                linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px);
+        }}
+
+        .edge-path.dimmed {{
+            opacity: 0.05;
+            marker-end: none !important;
+        }}
+
+        .edge-label-text.dimmed {{ opacity: 0; }}
+        .edge-label-sub.dimmed {{ opacity: 0; }}
+        .connector-dot.dimmed {{ opacity: 0; }}
+
+        /* 内容排版 */
+        .node-header {{ font-weight: 700; font-size: 15px; margin-bottom: 12px; color: #0f172a; }}
+        .constant-name {{ font-weight: 700; font-size: 14px; color: #1e293b; margin-bottom: 6px; }}
+        .constant-value {{ font-size: 13px; color: #64748b; }}
+
+        .row {{ display: flex; margin-bottom: 6px; font-size: 12px; line-height: 1.5; align-items: baseline; }}
+        .key {{ color: #94a3b8; width: 50px; flex-shrink: 0; text-align: right; margin-right: 12px; white-space: nowrap; }}
+        .val {{ color: #334155; font-weight: 500; }}
+        .row-root-source .key {{ width: 80px; }}
+        .row-root-source .val {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }}
+        .row-post-topic {{ margin-top: 6px; }}
+        .row-post-topic .key {{ margin-right: 20px; }}
+
+        /* SVG */
+        .edge-layer {{ position: absolute; top: 0; left: 0; pointer-events: none; z-index: 1; }}
+        .edge-layer g {{ pointer-events: all; }}
+
+        .edge-path {{
+            fill: none; stroke: #cbd5e1; stroke-width: 1.5px;
+            stroke-linejoin: round; transition: stroke 0.3s, opacity 0.3s;
+            pointer-events: stroke; cursor: pointer;
+        }}
+        .edge-path.highlight {{ stroke: #2563eb; stroke-width: 2.5px; opacity: 1; }}
+
+        /* 主标签样式 */
+        .edge-label-text {{
+            font-size: 12px; fill: #475569; text-anchor: middle;
+            font-family: monospace; paint-order: stroke;
+            stroke: #f8fafc; stroke-width: 4px;
+            transition: opacity 0.3s;
+            pointer-events: all; cursor: pointer;
+        }}
+
+        /* 副标签样式(概率) */
+        .edge-label-sub {{
+            font-size: 10px; fill: #94a3b8; text-anchor: middle;
+            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+            paint-order: stroke; stroke: #f8fafc; stroke-width: 3px;
+            transition: opacity 0.3s;
+            pointer-events: all; cursor: pointer;
+        }}
+
+        .edge-label-text.highlight {{ fill: #2563eb; font-weight: 700; opacity: 1; }}
+        .edge-label-sub.highlight {{ fill: #2563eb; font-weight: 600; opacity: 1; }}
+
+        .connector-dot {{ fill: #cbd5e1; transition: fill 0.3s; pointer-events: all; cursor: pointer; }}
+        .connector-dot.highlight {{ fill: #2563eb; }}
+
+        /* 侧边栏 */
+        #sidebar {{
+            position: fixed; top: 60px; right: 0; width: 380px; min-width: 250px; max-width: 60vw;
+            height: calc(100vh - 60px);
+            background: white; border-left: 1px solid #e2e8f0; box-shadow: -4px 0 15px rgba(0,0,0,0.05);
+            transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+            z-index: 100; display: flex; flex-direction: column;
+        }}
+        #sidebar.active {{ transform: translateX(0); }}
+        
+        /* 侧边栏拉伸器 */
+        #sidebar-resizer {{
+            position: fixed; top: 60px; right: 0; width: 8px; height: calc(100vh - 60px);
+            background: #e0e0e0; cursor: col-resize; z-index: 101;
+            transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+            display: flex; align-items: center; justify-content: center;
+        }}
+        #sidebar-resizer.active {{ transform: translateX(0); }}
+        #sidebar-resizer:hover {{ background: #3b82f6; }}
+        #sidebar-resizer::before {{
+            content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
+            width: 2px; background: #999; transform: translateX(-50%);
+        }}
+        #sidebar-resizer:hover::before {{ background: #3b82f6; }}
+        body.resizing {{ user-select: none; }}
+        body.resizing #sidebar-resizer {{ background: #3b82f6; }}
+        .sidebar-header {{ padding: 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc; }}
+        .sidebar-content {{ padding: 20px; overflow-y: auto; flex: 1; }}
+        .detail-item {{ margin-bottom: 20px; }}
+        .detail-item label {{ display: block; font-size: 11px; font-weight: 600; color: #94a3b8; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }}
+        .detail-val {{ font-size: 14px; color: #1e293b; padding: 12px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; line-height: 1.6; white-space: pre-wrap; }}
+        .detail-empty {{ color: #999; font-style: italic; text-align: center; padding: 40px 20px; }}
+        .query-block-header {{ cursor: pointer; padding: 8px 0; user-select: none; }}
+        .query-block-header:hover {{ color: #3b82f6; }}
+        .query-block-body {{ margin-top: 8px; }}
+        .external-post-card {{ border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-top: 12px; background: #fafafa; }}
+        .root-detail-section {{ margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #eee; }}
+        .root-detail-section:last-child {{ border-bottom: none; }}
+        .root-detail-title {{ font-size: 18px; font-weight: bold; color: #333; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }}
+        .root-detail-title::before {{ content: ""; display: inline-block; width: 4px; height: 18px; background: #3b82f6; border-radius: 2px; }}
+        .post-title {{ font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #444; }}
+        .post-body {{ font-size: 14px; white-space: pre-wrap; color: #666; background: #f9f9f9; padding: 12px; border-radius: 6px; margin-bottom: 15px; }}
+        .post-stats {{ display: flex; gap: 20px; margin-bottom: 15px; font-size: 14px; color: #888; }}
+        .post-stats span {{ display: flex; align-items: center; gap: 4px; }}
+        .image-gallery {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; margin-top: 10px; }}
+        .image-item {{ width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; cursor: pointer; transition: transform 0.2s; border: 1px solid #ddd; }}
+        .image-item:hover {{ transform: scale(1.05); border-color: #3b82f6; }}
+        .jump-link {{ display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f0f4f8; color: #3b82f6; text-decoration: none; border-radius: 8px; font-weight: bold; transition: background 0.2s; margin-bottom: 10px; }}
+        .jump-link:hover {{ background: #e1e9f0; color: #2563eb; }}
+        .jump-link::after {{ content: "→"; font-size: 18px; }}
+        
+        /* 推导进度区域 - 底部面板 */
+        #derivation-progress-section {{
+            position: fixed; bottom: 0; left: 0; right: 0;
+            height: 600px; max-height: 80vh; min-height: 200px;
+            background: white; border-top: 1px solid #e2e8f0; box-shadow: 0 -2px 15px rgba(0,0,0,0.05);
+            transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+            z-index: 99; display: flex; flex-direction: column;
+        }}
+        #derivation-resizer {{
+            position: absolute; top: 0; left: 0; right: 0; height: 6px;
+            cursor: row-resize; z-index: 100;
+            background: transparent;
+        }}
+        #derivation-resizer:hover, #derivation-resizer.active {{
+            background: #3b82f6;
+        }}
+        #derivation-progress-section.active {{ transform: translateY(0); }}
+        .derivation-progress-title {{
+            padding: 15px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc;
+            flex-shrink: 0;
+        }}
+        .derivation-progress-title span {{
+            font-weight: 700; color: #334155; font-size: 16px;
+        }}
+        .derivation-color-legend {{
+            display: flex; gap: 15px; align-items: center; margin-left: 20px; font-size: 12px;
+        }}
+        .derivation-color-legend-item {{
+            display: flex; align-items: center; gap: 6px;
+        }}
+        .derivation-color-legend-color {{
+            width: 16px; height: 16px; border-radius: 4px; border: 1px solid #d1d5db;
+        }}
+        .legend-black {{ background: #f3f4f6; border-color: #d1d5db; }}
+        .legend-yellow {{ background: #fef3c7; border-color: #fcd34d; }}
+        .legend-green {{ background: #d1fae5; border-color: #6ee7b7; }}
+        .derivation-progress-toggle {{
+            padding: 6px 12px; background: #3b82f6; color: white; border: none;
+            border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500;
+            transition: background 0.2s;
+        }}
+        .derivation-progress-toggle:hover {{ background: #2563eb; }}
+        #derivation-progress-content {{
+            padding: 20px; overflow-x: auto; overflow-y: auto; flex: 1;
+        }}
+        
+        /* 当推导进度面板打开时,调整画布底部边距 */
+        #app-container.derivation-open {{
+            bottom: 600px;
+            transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+        }}
+        
+        .derivation-empty {{
+            color: #999; font-style: italic; text-align: center; padding: 40px 20px;
+        }}
+        .derivation-timeline {{
+            display: flex; flex-direction: row; gap: 20px; align-items: flex-start;
+            min-width: max-content;
+        }}
+        .derivation-round-block {{
+            border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; background: #fafafa;
+            min-width: 350px; max-width: 450px; flex-shrink: 0;
+            display: flex; flex-direction: column;
+        }}
+        .derivation-round-title {{
+            font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #3b82f6;
+            flex-shrink: 0;
+        }}
+        .derivation-table {{
+            width: 100%; border-collapse: collapse; font-size: 11px;
+        }}
+        .derivation-table th {{
+            background: #f1f5f9; padding: 6px 4px; text-align: left; font-weight: 600; color: #475569; border: 1px solid #e2e8f0;
+            font-size: 10px;
+        }}
+        .derivation-table td {{
+            padding: 4px; border: 1px solid #e2e8f0; vertical-align: top;
+            font-size: 10px;
+        }}
+        .derivation-table .col-type {{ width: 50px; }}
+        .derivation-table .col-source {{ width: 100px; }}
+        .derivation-table .col-dim {{ width: auto; min-width: 80px; }}
+        .derivation-topic-item {{
+            display: inline-block; margin: 2px 4px 2px 0; padding: 2px 6px; border-radius: 4px; font-size: 11px;
+            cursor: pointer; transition: all 0.2s;
+        }}
+        /* 未点亮的词 - 黑色 */
+        .derivation-topic-underedived {{
+            background: #f3f4f6; color: #000000; border: 1px solid #d1d5db;
+        }}
+        .derivation-dimension-extra {{
+            margin-top: 12px; padding-top: 10px; border-top: 1px dashed #e2e8f0;
+            font-size: 11px; line-height: 1.5;
+        }}
+        .derivation-dim-line {{ margin-bottom: 6px; word-break: break-all; }}
+        .derivation-dim-label {{
+            display: inline-block; min-width: 72px; font-weight: 600; color: #64748b;
+        }}
+        .derivation-dim-val.dim-derived {{ color: #15803d; }}
+        .derivation-dim-val.dim-underived {{ color: #b45309; }}
+        .derivation-dim-line.dim-muted {{ color: #94a3b8; font-style: italic; }}
+        .btn-dimension-patterns {{
+            margin-top: 8px; padding: 5px 12px; font-size: 11px;
+            background: #6366f1; color: white; border: none; border-radius: 6px;
+            cursor: pointer; font-weight: 500;
+        }}
+        .btn-dimension-patterns:hover {{ background: #4f46e5; }}
+        #dimension-patterns-modal {{
+            display: none; position: fixed; inset: 0; z-index: 200;
+            background: rgba(15, 23, 42, 0.45); align-items: center; justify-content: center;
+            padding: 24px;
+        }}
+        #dimension-patterns-modal.active {{ display: flex; }}
+        .dimension-patterns-dialog {{
+            background: white; border-radius: 12px; max-width: 900px; width: 100%;
+            max-height: 80vh; display: flex; flex-direction: column;
+            box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
+        }}
+        .dimension-patterns-head {{
+            padding: 14px 18px; border-bottom: 1px solid #e2e8f0;
+            display: flex; justify-content: space-between; align-items: center;
+            flex-shrink: 0;
+        }}
+        .dimension-patterns-head span {{ font-weight: 700; color: #1e293b; font-size: 15px; }}
+        .dimension-patterns-close {{
+            padding: 6px 14px; background: #f1f5f9; border: none; border-radius: 6px;
+            cursor: pointer; font-size: 13px;
+        }}
+        .dimension-patterns-close:hover {{ background: #e2e8f0; }}
+        .dimension-patterns-body {{
+            padding: 16px 18px; overflow-y: auto; font-size: 12px; line-height: 1.6;
+        }}
+        .dimension-patterns-title {{ font-weight: 600; color: #475569; margin-bottom: 12px; }}
+        .pattern-line {{
+            padding: 8px 10px; margin-bottom: 6px; background: #f8fafc;
+            border-radius: 6px; border: 1px solid #e2e8f0; word-break: break-all;
+        }}
+        .pattern-plus {{ color: #94a3b8; font-weight: 600; margin: 0 4px; }}
+        .pattern-item-derived {{
+            color: #15803d; font-weight: 700; background: #dcfce7;
+            padding: 1px 4px; border-radius: 4px;
+        }}
+        /* 当前轮次点亮的点 - 黄色 */
+        .derivation-topic-new {{
+            background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; font-weight: 600;
+        }}
+        /* 未完全推导的选题点:虚线框 */
+        .derivation-topic-item.derivation-topic-not-fully-derived {{
+            border: 1px dashed #475569 !important;
+        }}
+        /* 之前已经点亮过的点 - 绿色 */
+        .derivation-topic-derived {{
+            background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7;
+        }}
+        /* 推导结果为空时,由解构内容回填的基准选题(不可点击定位) */
+        .derivation-topic-baseline {{
+            background: #e0e7ff; color: #312e81; border: 1px solid #a5b4fc; cursor: default;
+        }}
+        .derivation-topic-item.derivation-topic-baseline:hover {{
+            transform: none; box-shadow: none;
+        }}
+        .derivation-topic-item:hover {{
+            transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+        }}
+        .derivation-topic-search-icon {{ color: #2196F3; margin-left: 2px; }}
+        .derivation-topic-tool-icon {{ color: #ff9800; margin-left: 2px; }}
+
+        /* 待解构帖子数据 入口 */
+        .top-bar-left {{ display: flex; align-items: center; gap: 16px; }}
+        #btn-pending-decode-post {{
+            padding: 8px 16px; background: #8b5cf6; color: white; border: none;
+            border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
+            transition: background 0.2s;
+        }}
+        #btn-pending-decode-post:hover {{ background: #7c3aed; }}
+        .modal-overlay {{
+            position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+            background: rgba(0,0,0,0.4); z-index: 1000; display: none;
+            align-items: center; justify-content: center;
+        }}
+        .modal-overlay.active {{ display: flex; }}
+        .modal-box {{
+            background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15);
+            max-width: 480px; width: 90%; max-height: 85vh; overflow: hidden;
+            display: flex; flex-direction: column;
+        }}
+        .modal-box.post-detail-modal {{ max-width: 720px; }}
+        .modal-header {{
+            padding: 16px 20px; border-bottom: 1px solid #e2e8f0; display: flex;
+            justify-content: space-between; align-items: center; background: #f8fafc;
+        }}
+        .modal-header span {{ font-weight: 700; font-size: 16px; color: #334155; }}
+        .modal-close {{ background: none; border: none; font-size: 24px; cursor: pointer; color: #94a3b8; line-height: 1; }}
+        .modal-close:hover {{ color: #64748b; }}
+        .modal-body {{ padding: 20px; overflow-y: auto; flex: 1; }}
+
+        /* 图集大图查看(灯箱) */
+        #image-lightbox {{
+            position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+            background: rgba(0,0,0,0.9); z-index: 2000; display: none;
+            align-items: center; justify-content: center;
+        }}
+        #image-lightbox.active {{ display: flex; }}
+        #image-lightbox .lightbox-close {{
+            position: absolute; top: 16px; right: 20px;
+            background: none; border: none; color: #fff; font-size: 32px;
+            cursor: pointer; line-height: 1; opacity: 0.8;
+        }}
+        #image-lightbox .lightbox-close:hover {{ opacity: 1; }}
+        #image-lightbox .lightbox-prev,
+        #image-lightbox .lightbox-next {{
+            position: absolute; top: 50%; transform: translateY(-50%);
+            width: 48px; height: 48px; border: none; border-radius: 50%;
+            background: rgba(255,255,255,0.2); color: #fff; font-size: 24px;
+            cursor: pointer; display: flex; align-items: center; justify-content: center;
+            transition: background 0.2s;
+        }}
+        #image-lightbox .lightbox-prev:hover,
+        #image-lightbox .lightbox-next:hover {{ background: rgba(255,255,255,0.35); }}
+        #image-lightbox .lightbox-prev {{ left: 20px; }}
+        #image-lightbox .lightbox-next {{ right: 20px; }}
+        #image-lightbox .lightbox-img-wrap {{
+            max-width: 90vw; max-height: 85vh; display: flex; align-items: center; justify-content: center;
+        }}
+        #image-lightbox .lightbox-img-wrap img {{
+            max-width: 100%; max-height: 85vh; object-fit: contain;
+        }}
+        #image-lightbox .lightbox-counter {{
+            position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
+            color: rgba(255,255,255,0.9); font-size: 14px;
+        }}
+    </style>
+</head>
+<body>
+
+    <div id="top-bar">
+        <div class="top-bar-left">
+            <button type="button" id="btn-pending-decode-post">待解构帖子数据</button>
+            <h2 style="font-size:18px; color:#1e293b; font-weight:600;">多源数据流可视化 - 完整全景版</h2>
+        </div>
+        <div class="controls">
+            <select id="postSelector" onchange="switchPost(this.value)">
+                {"".join([f'<option value="{k}">{k}</option>' for k in data_map.keys()])}
+            </select>
+            <input type="text" id="search-input" placeholder="输入关键字 并回车定位..." />
+            <button onclick="resetView()">重置视图</button>
+            <button onclick="toggleDerivationProgress()">推导进度</button>
+        </div>
+    </div>
+
+    <div id="sidebar">
+        <div class="sidebar-header">
+            <div id="sidebar-title" style="font-weight:700; color:#334155;">节点详情</div>
+            <button onclick="closeSidebar()" style="background:none; border:none; font-size:24px; cursor:pointer; color:#94a3b8;">&times;</button>
+        </div>
+        <div class="sidebar-content" id="sidebar-content"></div>
+    </div>
+    <div id="sidebar-resizer"></div>
+
+    <div id="app-container">
+        <div id="canvas"></div>
+    </div>
+    
+    <div id="derivation-progress-section">
+        <div id="derivation-resizer"></div>
+        <div class="derivation-progress-title">
+            <div style="display: flex; align-items: center;">
+                <span>推导进度</span>
+                <div class="derivation-color-legend">
+                    <div class="derivation-color-legend-item">
+                        <div class="derivation-color-legend-color legend-black"></div>
+                        <span>未点亮</span>
+                    </div>
+                    <div class="derivation-color-legend-item">
+                        <div class="derivation-color-legend-color legend-yellow"></div>
+                        <span>当前轮次点亮</span>
+                    </div>
+                    <div class="derivation-color-legend-item">
+                        <div class="derivation-color-legend-color legend-green"></div>
+                        <span>之前已点亮</span>
+                    </div>
+                </div>
+            </div>
+            <button class="derivation-progress-toggle" onclick="toggleDerivationProgress()">收起</button>
+        </div>
+        <div id="derivation-progress-content"></div>
+    </div>
+
+    <!-- 待解构帖子详情弹窗 -->
+    <div id="post-detail-modal" class="modal-overlay">
+        <div class="modal-box post-detail-modal">
+            <div class="modal-header">
+                <span>待解构帖子详情</span>
+                <button type="button" class="modal-close" onclick="closePostDetailModal()">&times;</button>
+            </div>
+            <div class="modal-body" id="post-detail-modal-content"></div>
+        </div>
+    </div>
+
+    <!-- 图集大图灯箱 -->
+    <div id="image-lightbox">
+        <button type="button" class="lightbox-close" onclick="closeImageLightbox()">&times;</button>
+        <button type="button" class="lightbox-prev" onclick="lightboxPrev()">&#10094;</button>
+        <div class="lightbox-img-wrap">
+            <img id="lightbox-img" src="" alt="" />
+        </div>
+        <button type="button" class="lightbox-next" onclick="lightboxNext()">&#10095;</button>
+        <div class="lightbox-counter" id="lightbox-counter"></div>
+    </div>
+
+    <div id="dimension-patterns-modal" onclick="if(event.target===this) closeDimensionPatternsModal()">
+        <div class="dimension-patterns-dialog" onclick="event.stopPropagation()">
+            <div class="dimension-patterns-head">
+                <span id="dimension-patterns-modal-title">维度 patterns</span>
+                <button type="button" class="dimension-patterns-close" onclick="closeDimensionPatternsModal()">关闭</button>
+            </div>
+            <div class="dimension-patterns-body" id="dimension-patterns-modal-body"></div>
+        </div>
+    </div>
+
+    <script>
+        const allData = {json_data_js};
+        const derivationData = {derivation_data_js};
+        const dimensionAnalyzeData = {dimension_analyze_data_js};
+        const postDetailMap = {post_detail_map_js};
+        const accountName = {account_name_js};
+
+        const CONFIG = {{
+            cardWidth: 320,
+            constWidth: 280,
+            colSpacing: 900,
+            rowSpacing: 30,
+            paddingX: 80,
+            paddingY: 100,
+            busOffset: 450,
+            forkOffset: 40
+        }};
+
+        const canvas = document.getElementById('canvas');
+        let flatData = {{ nodesByLevel: {{}}, map: {{}} }};
+        let edgeGroups = {{}};
+        let currentPostKey = "{first_key}";
+
+        // 1. 数据解析 - 适配 node_list 和 edge_list 格式
+        function parseData(postKey) {{
+            flatData = {{ nodesByLevel: {{}}, map: {{}} }};
+            edgeGroups = {{}};
+            
+            const data = allData[postKey];
+            const nodesData = data.node_list || [];
+            const edgesData = data.edge_list || [];
+            const allUsedTreeNodes = data.all_used_tree_nodes || [];
+
+            // 创建节点映射
+            const nodeMap = {{}};
+            nodesData.forEach(node => {{
+                nodeMap[node.name] = node;
+            }});
+
+            // 处理人设/全局常量节点(放在 level -1,第一轮推导左侧)
+            // 注意:所有节点都需要展示,不受 is_constant 和 is_local_constant 字段影响
+            const constantLevel = -1;
+            if (!flatData.nodesByLevel[constantLevel]) flatData.nodesByLevel[constantLevel] = [];
+            
+            // 遍历所有节点,全部添加到列表中(不进行任何过滤,全部展示)
+            allUsedTreeNodes.forEach((constantNode, index) => {{
+                // 使用索引确保即使名称重复也能区分
+                const uniqueId = constantNode.name + '_const_' + index;
+                const item = {{
+                    id: uniqueId,
+                    name: constantNode.name,
+                    data: {{
+                        ...constantNode,
+                        type: constantNode.type || '',
+                        is_constant: constantNode.is_constant || false,
+                        is_local_constant: constantNode.is_local_constant || false
+                    }},
+                    type: 'node',
+                    level: constantLevel,
+                    sources: [],
+                    edgeName: '',
+                    edgeScore: 0
+                }};
+                
+                // 添加到数组和映射中(数组用于渲染,确保所有节点都显示)
+                flatData.nodesByLevel[constantLevel].push(item);
+                flatData.map[item.id] = item;
+                // 名称映射用于查找(如果有重复名称,最后一个会覆盖,但不影响数组中的显示)
+                flatData.map[item.name] = item;
+            }});
+
+            // 按 level 分组节点(同名节点可能出现在多轮,如「居家生活场景」level 1 与 level 2 各有一个)
+            nodesData.forEach(node => {{
+                const level = node.level || 0;
+                if (!flatData.nodesByLevel[level]) flatData.nodesByLevel[level] = [];
+                const uniqueId = node.name + '__L' + level;
+                const item = {{
+                    id: node.name,
+                    uid: uniqueId,
+                    name: node.name,
+                    data: node,
+                    type: 'node',
+                    level: level,
+                    sources: [],
+                    edgeName: '',
+                    edgeScore: 0
+                }};
+                flatData.nodesByLevel[level].push(item);
+                flatData.map[uniqueId] = item;
+                flatData.map[item.name] = item;
+            }});
+
+            // 处理边,建立连接关系
+            // 边对象有 level 字段,表示轮次;边只能连接同轮次的 output 输出节点
+            // output_nodes 为对象列表,每项有 name 字段表示输出节点名称
+            edgesData.forEach(edge => {{
+                const outputNodes = edge.output_nodes || [];
+                const inputPostNodes = edge.input_post_nodes || [];
+                const usedTreeNodes = edge.used_tree_nodes || edge.input_tree_nodes || [];
+
+                const edgeLevel = edge.level;
+
+                // 处理 input_post_nodes 作为输入节点(这些节点在推导过程中,不在 level -1)
+                const inputPostNames = inputPostNodes.map(n => n.name || n).filter(name => name);
+
+                // 处理 used_tree_nodes / input_tree_nodes,匹配到 all_used_tree_nodes 中的节点(这些节点在 level -1)
+                const usedTreeNames = [];
+                usedTreeNodes.forEach(usedNode => {{
+                    const usedName = usedNode.name || usedNode;
+                    // 在 all_used_tree_nodes 中查找匹配的节点(通过 name 匹配)
+                    const matchedNode = allUsedTreeNodes.find(n => n.name === usedName);
+                    if (matchedNode) {{
+                        usedTreeNames.push(usedName);
+                    }}
+                }});
+
+                // 合并所有输入节点名称(但需要区分来源)
+                // 保存输入节点的来源信息,用于后续查找正确的节点
+                const allInputNames = [];
+                const inputSourceMap = {{}}; // 记录每个输入节点的来源:'post' 或 'tree'
+                
+                inputPostNames.forEach(name => {{
+                    allInputNames.push(name);
+                    inputSourceMap[name] = 'post'; // 来自推导节点
+                }});
+                
+                usedTreeNames.forEach(name => {{
+                    allInputNames.push(name);
+                    inputSourceMap[name] = 'tree'; // 来自人设/全局常量节点(level -1)
+                }});
+
+                // 先收集所有有效的输出节点:仅同轮次(边只能连接 edge.level 对应的 output 节点)
+                // 同名节点可能出现在多轮,需按 name + level 查找
+                const validOutputItems = [];
+                outputNodes.forEach(outputNode => {{
+                    const outputName = (typeof outputNode === 'object' && outputNode !== null && outputNode.name != null) ? outputNode.name : outputNode;
+                    let outputItem = null;
+                    if (edgeLevel != null && flatData.nodesByLevel[edgeLevel]) {{
+                        outputItem = flatData.nodesByLevel[edgeLevel].find(n => n.name === outputName) || null;
+                    }}
+                    if (!outputItem) outputItem = flatData.map[outputName] || null;
+                    if (outputItem && (edgeLevel == null || outputItem.level === edgeLevel)) {{
+                        validOutputItems.push(outputItem);
+                    }}
+                }});
+
+                // 如果没有有效的输出节点,跳过
+                if (validOutputItems.length === 0) return;
+
+                // 为整个边创建一个边组(所有输出节点共享同一个边组)
+                const edgeName = edge.name || '';
+                const edgeScore = edge.score || 0;
+                // 收集输出节点名称用于生成唯一的 edgeKey
+                const outputNames = validOutputItems.map(item => item.name).sort();
+                // edgeKey 需要包含输入节点、输出节点和边名称,确保每条边都有唯一的 key
+                const inputKey = allInputNames.length > 0 
+                    ? allInputNames.slice().sort().join('|')
+                    : 'empty';
+                const outputKey = outputNames.join('|');
+                const edgeKey = inputKey + '||' + outputKey + '||' + edgeName;
+                
+                if (!edgeGroups[edgeKey]) {{
+                    edgeGroups[edgeKey] = {{
+                        key: edgeKey,
+                        targets: [],
+                        sources: allInputNames,
+                        sourceMap: inputSourceMap, // 保存输入节点的来源映射
+                        edgeName: edgeName,
+                        edgeScore: edgeScore,
+                        edgeData: edge  // 保存完整的边数据
+                    }};
+                }}
+
+                // 将所有输出节点添加到同一个边组,并设置相同的边信息
+                validOutputItems.forEach(outputItem => {{
+                    // 更新输出节点的边信息
+                    outputItem.sources = allInputNames;
+                    outputItem.edgeName = edgeName;
+                    outputItem.edgeScore = edgeScore;
+                    outputItem.edgeGroupKey = edgeKey;
+                    
+                    // 添加到边组
+                    edgeGroups[edgeKey].targets.push(outputItem);
+                }});
+            }});
+        }}
+
+        // 2. 布局计算(按 level 排序,但 x 用列索引排列,空缺的 level 不占位)
+        function calculateLayout() {{
+            const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
+            levels.forEach((level, colIndex) => {{
+                const nodes = flatData.nodesByLevel[level];
+                // level -1 放第一列;其余列按 colIndex 紧密排列,不因 level 空缺留白
+                const x = CONFIG.paddingX + colIndex * CONFIG.colSpacing;
+                let y = CONFIG.paddingY;
+
+                createHeader(level, x);
+
+                nodes.forEach(node => {{
+                    const h = estimateHeight(node);
+                    node.x = x;
+                    node.y = y;
+                    node.width = node.type === 'constant' ? CONFIG.constWidth : CONFIG.cardWidth;
+                    node.height = h;
+                    node.inputPoint = {{ x: node.x, y: node.y + h/2 }};
+                    node.outputPoint = {{ x: node.x + node.width, y: node.y + h/2 }};
+                    y += h + CONFIG.rowSpacing;
+                }});
+            }});
+        }}
+
+        function estimateHeight(node) {{
+            if (node.type === 'constant') return 80;
+            // level -1 的常量节点,只显示 name 和 type,固定高度
+            if (node.level === -1) {{
+                return 60 + 22; // name + type
+            }}
+            // 为了保证不同节点类型高度一致,这里统一按 point/dimension/root_source 的存在情况估算行数
+            let lines = 1; // node-header
+            if (node.data && node.data.point) lines++;
+            if (node.data && node.data.dimension) lines++;
+            if (node.data && node.data.root_source) lines++;
+            return 60 + lines * 22;
+        }}
+
+        function createHeader(level, x) {{
+            const existing = document.querySelector(`.column-header[data-level="${{level}}"]`);
+            if (existing) existing.remove();
+            
+            const el = document.createElement('div');
+            el.className = 'column-header';
+            el.dataset.level = level;
+            el.style.left = x + 'px';
+            el.style.top = '40px';
+            el.style.width = CONFIG.cardWidth + 'px';
+            
+            if (level === -1) {{
+                el.textContent = '人设/全局常量';
+            }} else {{
+                const nums = ['一','二','三','四','五','六','七','八','九','十'];
+                el.textContent = `第${{nums[level-1] || level}}轮推导`;
+            }}
+            canvas.appendChild(el);
+        }}
+
+        function renderNodes() {{
+            // 清空现有节点
+            document.querySelectorAll('.node-card, .constant-card').forEach(el => el.remove());
+            const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
+            levels.forEach(level => {{
+                const nodes = flatData.nodesByLevel[level] || [];
+                nodes.forEach(node => {{
+                const el = document.createElement('div');
+                el.dataset.id = node.uid != null ? node.uid : node.id;
+                el.style.left = node.x + 'px';
+                el.style.top = node.y + 'px';
+                el.style.width = node.width + 'px';
+
+                if (node.type === 'constant') {{
+                    el.className = 'constant-card';
+                    el.style.height = (node.height || estimateHeight(node)) + 'px';
+                    el.innerHTML = `
+                        <div class="constant-name">${{node.name}}</div>
+                        <div class="constant-value">${{node.data.value || ''}}</div>
+                    `;
+                }} else if (node.level === -1) {{
+                    // level -1 的常量节点(人设/全局常量),只显示 name 和 type
+                    el.className = 'node-card';
+                    el.style.height = (node.height || estimateHeight(node)) + 'px';
+                    let html = `<div class="node-header">${{node.name}}</div>`;
+                    if (node.data.type) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.type}}</span></div>`;
+                    el.innerHTML = html;
+                }} else {{
+                    // node_list 节点:is_fully_derived=false 时用虚线框,名称显示 derivation_output_point,只显示帖子选题点
+                    el.className = 'node-card' + (node.data.is_fully_derived === false ? ' not-fully-derived' : '');
+                    el.style.height = (node.height || estimateHeight(node)) + 'px';
+                    const displayName = (node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
+                        ? node.data.derivation_output_point : node.name;
+                    let html = `<div class="node-header">${{displayName}}</div>`;
+                    if (node.data.is_fully_derived === false) {{
+                        // 未完全推导:只显示「帖子选题点」,值为原 node_list.name
+                        if (node.data.name != null && node.data.name !== '') html += `<div class="row row-post-topic"><span class="key">帖子选题点</span><span class="val">${{node.data.name}}</span></div>`;
+                    }} else {{
+                        // 常规节点:显示 类型(原关键点)、维度、所属选题点
+                        if (node.data.point) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.point}}</span></div>`;
+                        if (node.data.dimension) html += `<div class="row"><span class="key">维度</span><span class="val">${{node.data.dimension}}</span></div>`;
+                        if (node.data.root_source) html += `<div class="row row-root-source"><span class="key">所属选题点</span><span class="val">${{node.data.root_source}}</span></div>`;
+                    }}
+                    el.innerHTML = html;
+                }}
+                el.onclick = (e) => {{
+                    e.stopPropagation();
+                    // 保存当前选中的节点
+                    currentSelectedNode = node;
+                    currentSelectedEdgeGroup = null; // 清除边组选中状态
+                    // 不自动缩放,保持当前视图大小和位置
+                    // 立即高亮和显示侧边栏(无延迟)
+                    highlightDirectSources(node);
+                    const sidebarTitle = (node.data && node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
+                        ? `节点: ${{node.data.derivation_output_point}}` : `节点: ${{node.name}}`;
+                    showSidebar(node.data, sidebarTitle, node, 'node');
+                }};
+                canvas.appendChild(el);
+                node.el = el;
+                }});
+            }});
+        }}
+
+        // 3. 渲染连线 - 按组渲染
+        function renderEdges() {{
+            // 移除旧的 SVG
+            const oldSvg = document.querySelector('.edge-layer');
+            if (oldSvg) oldSvg.remove();
+
+            const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+            svg.classList.add('edge-layer');
+            svg.setAttribute('width', '10000');
+            svg.setAttribute('height', '8000');
+
+            // 定义箭头
+            const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
+            const createMarker = (id, color) => {{
+                const m = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
+                m.setAttribute('id', id);
+                m.setAttribute('markerWidth', '10'); m.setAttribute('markerHeight', '7');
+                m.setAttribute('refX', '9'); m.setAttribute('refY', '3.5');
+                m.setAttribute('orient', 'auto');
+                const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                p.setAttribute('d', 'M0,0 L0,7 L9,3.5 z');
+                p.setAttribute('fill', color);
+                m.appendChild(p);
+                return m;
+            }};
+            defs.appendChild(createMarker('arrow-head', '#cbd5e1'));
+            defs.appendChild(createMarker('arrow-head-highlight', '#2563eb'));
+            svg.appendChild(defs);
+
+            Object.values(edgeGroups).forEach(group => {{
+                const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+                g.dataset.edgeGroup = group.key;
+
+                const targets = group.targets;
+                const sourceNames = group.sources;
+                
+                // 如果没有目标节点,跳过
+                if (!targets.length) return;
+
+                const targetX = targets[0].inputPoint.x;
+                const busX = targetX - CONFIG.busOffset;
+                const forkX = targetX - CONFIG.forkOffset;
+
+                // 获取源节点(同名多轮时取低于目标层级的源)
+                const sourceNodes = [];
+                const sourceMap = group.sourceMap || {{}};
+                const minTargetLevel = targets.length ? Math.min(...targets.map(t => t.level)) : 0;
+                
+                sourceNames.forEach(name => {{
+                    const sourceType = sourceMap[name];
+                    let node = null;
+                    if (sourceType === 'tree') {{
+                        const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                        node = levelMinusOneNodes.find(n => n.name === name);
+                    }} else {{
+                        for (let l = minTargetLevel - 1; l >= 0; l--) {{
+                            const found = (flatData.nodesByLevel[l] || []).find(n => n.name === name);
+                            if (found) {{ node = found; break; }}
+                        }}
+                        if (!node) {{
+                            const candidate = flatData.map[name];
+                            if (candidate && candidate.level !== -1) node = candidate;
+                        }}
+                    }}
+                    if (node) sourceNodes.push(node);
+                }});
+
+                targets.sort((a,b) => a.y - b.y);
+                const tMinY = targets[0].inputPoint.y;
+                const tMaxY = targets[targets.length - 1].inputPoint.y;
+
+                // 核心连线的 Y 坐标(使用第一个目标节点的 Y 坐标,与参考文件保持一致)
+                const mainY = tMinY;
+
+                // 创建点击事件处理函数
+                const handleGroupClick = (e) => {{
+                    e.stopPropagation();
+                    handleEdgeClick(group);
+                }};
+
+                // 如果有源节点,渲染左侧部分
+                if (sourceNodes.length > 0) {{
+                    let sMinY = Infinity, sMaxY = -Infinity;
+                    sourceNodes.forEach(s => {{
+                        sMinY = Math.min(sMinY, s.outputPoint.y);
+                        sMaxY = Math.max(sMaxY, s.outputPoint.y);
+                    }});
+
+                    // A. 左侧源头馈线
+                    sourceNodes.forEach(s => {{
+                        const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                        p.setAttribute('d', `M ${{s.outputPoint.x}} ${{s.outputPoint.y}} L ${{busX}} ${{s.outputPoint.y}}`);
+                        p.classList.add('edge-path', 'feeder');
+                        p.style.cursor = 'pointer';
+                        p.addEventListener('click', handleGroupClick);
+                        g.appendChild(p);
+                        const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+                        dot.setAttribute('cx', s.outputPoint.x); dot.setAttribute('cy', s.outputPoint.y);
+                        dot.setAttribute('r', 3); dot.classList.add('connector-dot');
+                        dot.style.cursor = 'pointer';
+                        dot.addEventListener('click', handleGroupClick);
+                        g.appendChild(dot);
+                    }});
+
+                    // B. 左侧主干
+                    if (sMinY !== sMaxY) {{
+                        const trunk = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                        trunk.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{sMaxY}}`);
+                        trunk.classList.add('edge-path', 'trunk');
+                        trunk.style.cursor = 'pointer';
+                        trunk.addEventListener('click', handleGroupClick);
+                        g.appendChild(trunk);
+                    }}
+
+                    // C. 长连接线 (连接源头区域到主线高度)
+                    if (mainY < sMinY) {{
+                        const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                        link.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{mainY}}`);
+                        link.classList.add('edge-path', 'trunk');
+                        link.style.cursor = 'pointer';
+                        link.addEventListener('click', handleGroupClick);
+                        g.appendChild(link);
+                    }} else if (mainY > sMaxY) {{
+                        const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                        link.setAttribute('d', `M ${{busX}} ${{sMaxY}} L ${{busX}} ${{mainY}}`);
+                        link.classList.add('edge-path', 'trunk');
+                        link.style.cursor = 'pointer';
+                        link.addEventListener('click', handleGroupClick);
+                        g.appendChild(link);
+                    }}
+                }}
+
+                // 核心连线(无论是否有源节点都要渲染)
+                const mainLine = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                mainLine.setAttribute('d', `M ${{busX}} ${{mainY}} L ${{forkX}} ${{mainY}}`);
+                mainLine.classList.add('edge-path', 'main-flow');
+                mainLine.style.cursor = 'pointer';
+                mainLine.addEventListener('click', handleGroupClick);
+                g.appendChild(mainLine);
+
+                // D. 右侧分叉主干
+                if (targets.length > 1) {{
+                    const fork = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                    fork.setAttribute('d', `M ${{forkX}} ${{mainY}} L ${{forkX}} ${{tMaxY}}`);
+                    fork.classList.add('edge-path', 'trunk');
+                    fork.style.cursor = 'pointer';
+                    fork.addEventListener('click', handleGroupClick);
+                    g.appendChild(fork);
+                }}
+
+                // E. 目标接入线
+                targets.forEach(t => {{
+                    const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                    p.setAttribute('d', `M ${{forkX}} ${{t.inputPoint.y}} L ${{t.inputPoint.x}} ${{t.inputPoint.y}}`);
+                    p.classList.add('edge-path', 'entry');
+                    p.setAttribute('marker-end', 'url(#arrow-head)');
+                    p.style.cursor = 'pointer';
+                    p.addEventListener('click', handleGroupClick);
+                    g.appendChild(p);
+                    
+                    if (targets.length > 1) {{
+                        const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+                        dot.setAttribute('cx', forkX); dot.setAttribute('cy', t.inputPoint.y);
+                        dot.setAttribute('r', 2); dot.classList.add('connector-dot');
+                        dot.style.cursor = 'pointer';
+                        dot.addEventListener('click', handleGroupClick);
+                        g.appendChild(dot);
+                    }}
+                }});
+
+                // 连接点标记(只在有源节点时显示)
+                if (sourceNodes.length > 0) {{
+                    const busDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+                    busDot.setAttribute('cx', busX); busDot.setAttribute('cy', mainY);
+                    busDot.setAttribute('r', 3); busDot.classList.add('connector-dot');
+                    busDot.style.cursor = 'pointer';
+                    busDot.addEventListener('click', handleGroupClick);
+                    g.appendChild(busDot);
+                }}
+
+                // F. 文字标签
+                if (group.edgeName) {{
+                    const textX = (busX + forkX) / 2;
+                    const textY = mainY - 5;
+                    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+                    text.setAttribute('x', textX); text.setAttribute('y', textY);
+                    text.classList.add('edge-label-text');
+                    text.textContent = group.edgeName;
+                    text.style.cursor = 'pointer';
+                    text.addEventListener('click', handleGroupClick);
+                    g.appendChild(text);
+
+                    // 仅当边数据中有 score 字段时才在连线下方显示条件概率
+                    const hasScore = group.edgeData && group.edgeData.score !== undefined && group.edgeData.score !== null;
+                    if (hasScore) {{
+                        const subText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+                        subText.setAttribute('x', textX);
+                        subText.setAttribute('y', mainY + 14);
+                        subText.classList.add('edge-label-sub');
+                        let labelPrefix = "条件概率";
+                        if (group.edgeName && group.edgeName.startsWith("外部搜索")) {{
+                            labelPrefix = "搜索出现概率";
+                        }}
+                        subText.textContent = `${{labelPrefix}}:${{group.edgeScore}}`;
+                        subText.style.cursor = 'pointer';
+                        subText.addEventListener('click', handleGroupClick);
+                        g.appendChild(subText);
+                    }}
+                }}
+
+                svg.appendChild(g);
+            }});
+
+            canvas.insertBefore(svg, canvas.firstChild);
+        }}
+
+        // 处理边的点击事件
+        function handleEdgeClick(group) {{
+            // 保存当前选中的边组
+            currentSelectedEdgeGroup = group;
+            currentSelectedNode = null; // 清除节点选中状态
+            
+            // 获取边的详细信息
+            const edgeData = group.edgeData || {{}};
+            const edgeDetail = edgeData.detail || {{}};
+            const edgeName = group.edgeName || '';
+            const edgeScore = group.edgeScore || 0;
+            
+            // 获取目标节点名称(用于外部边和工具边的展示)
+            const targetNames = group.targets.map(t => t.name) || [];
+            const targetNodeName = targetNames.length > 0 ? targetNames[0] : '';
+            
+            // 构建边的完整数据对象
+            const fullEdgeData = {{
+                name: targetNodeName,  // 用于外部边和工具边的展示
+                edgeName: edgeName,
+                edgeScore: edgeScore,
+                sources: group.sources || [],
+                targets: targetNames,
+                type: edgeName.includes('外部搜索') || edgeName.includes('外部寻找') ? '外部边' : 
+                      edgeName.includes('工具') ? '工具边' : '普通边',
+                ...edgeData
+            }};
+            
+            // 高亮相关的节点和边
+            highlightEdgeGroup(group);
+            
+            // 显示边的详情(先打开侧边栏)
+            const sourceNames = group.sources.join('、');
+            const targetNamesStr = targetNames.join('、');
+            const title = `连线: ${{sourceNames}} → ${{targetNamesStr}}`;
+            showSidebar(edgeDetail, title, fullEdgeData, 'edge');
+        }}
+
+        // 计算边组相关节点的边界框并缩放显示
+        function fitEdgeGroupToView(group, useAnimation = true) {{
+            if (!group) return;
+
+            // 收集所有相关节点(源节点和目标节点)
+            const relatedNodes = new Set();
+            
+            // 添加所有源节点(需要根据来源区分查找)
+            const sourceMap = group.sourceMap || {{}};
+            group.sources.forEach(sName => {{
+                const sourceType = sourceMap[sName];
+                let sNode = null;
+                
+                if (sourceType === 'tree') {{
+                    // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
+                    const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                    sNode = levelMinusOneNodes.find(n => n.name === sName);
+                }} else {{
+                    // input_post_nodes:从推导节点中查找(排除 level -1)
+                    const candidate = flatData.map[sName];
+                    if (candidate && candidate.level !== -1) {{
+                        sNode = candidate;
+                    }} else {{
+                        // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
+                        for (let level in flatData.nodesByLevel) {{
+                            const levelNum = parseInt(level);
+                            if (levelNum !== -1) {{
+                                const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
+                                if (found) {{
+                                    sNode = found;
+                                    break;
+                                }}
+                            }}
+                        }}
+                    }}
+                }}
+                
+                if (sNode && sNode.x !== undefined) {{
+                    relatedNodes.add(sNode);
+                }}
+            }});
+            
+            // 添加所有目标节点
+            group.targets.forEach(t => {{
+                if (t && t.x !== undefined) {{
+                    relatedNodes.add(t);
+                }}
+            }});
+
+            // 如果没有任何节点,直接返回
+            if (relatedNodes.size === 0) return;
+
+            // 计算边界框(包括节点和连线可能延伸的区域)
+            let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+            relatedNodes.forEach(node => {{
+                if (node.x !== undefined && node.y !== undefined) {{
+                    // 节点本身的边界
+                    minX = Math.min(minX, node.x);
+                    minY = Math.min(minY, node.y);
+                    maxX = Math.max(maxX, node.x + (node.width || 0));
+                    maxY = Math.max(maxY, node.y + (node.height || 0));
+                    
+                    // 考虑连线可能延伸到左侧(busOffset)
+                    if (node.inputPoint) {{
+                        const leftExtend = node.inputPoint.x - CONFIG.busOffset;
+                        minX = Math.min(minX, leftExtend);
+                    }}
+                    if (node.outputPoint) {{
+                        const rightExtend = node.outputPoint.x + CONFIG.busOffset;
+                        maxX = Math.max(maxX, rightExtend);
+                    }}
+                }}
+            }});
+
+            // 如果边界框无效,直接返回
+            if (minX === Infinity) return;
+
+            // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
+            const padding = 40;
+            const contentWidth = maxX - minX + padding * 2;
+            const contentHeight = maxY - minY + padding * 2;
+            const contentCenterX = (minX + maxX) / 2;
+            const contentCenterY = (minY + maxY) / 2;
+
+            // 获取视口大小(考虑侧边栏是否打开)
+            const sidebar = document.getElementById('sidebar');
+            const isSidebarOpen = sidebar && sidebar.classList.contains('active');
+            // 当侧边栏打开时,画布宽度会缩小(减去侧边栏实际宽度)
+            const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
+            const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
+            const viewH = container.offsetHeight;
+
+            // 计算缩放比例,确保内容能完全显示
+            const scaleX = (viewW - padding * 2) / contentWidth;
+            const scaleY = (viewH - padding * 2) / contentHeight;
+            // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
+            scale = Math.min(scaleX, scaleY, 2.0);
+
+            // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
+            const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
+            translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
+            translateY = (viewH / 2) - (contentCenterY * scale);
+
+            // 根据参数决定是否使用动画
+            if (useAnimation) {{
+                canvas.classList.add('animating');
+                updateTransform();
+                setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
+            }} else {{
+                // 移除动画类,确保瞬间完成
+                canvas.classList.remove('animating');
+                updateTransform();
+            }}
+        }}
+
+        // 高亮边组
+        function highlightEdgeGroup(group) {{
+            // Reset
+            document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
+                el.classList.remove('highlight');
+                el.classList.add('dimmed');
+            }});
+            document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
+                el.classList.remove('highlight');
+                el.classList.add('dimmed');
+            }});
+            document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+            
+            // 高亮源节点(根据来源区分查找)
+            const sourceMap = group.sourceMap || {{}};
+            group.sources.forEach(sourceName => {{
+                const sourceType = sourceMap[sourceName];
+                let sourceNode = null;
+                
+                if (sourceType === 'tree') {{
+                    // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
+                    const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                    sourceNode = levelMinusOneNodes.find(n => n.name === sourceName);
+                }} else {{
+                    // input_post_nodes:从推导节点中查找(排除 level -1)
+                    const candidate = flatData.map[sourceName];
+                    if (candidate && candidate.level !== -1) {{
+                        sourceNode = candidate;
+                    }} else {{
+                        // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
+                        for (let level in flatData.nodesByLevel) {{
+                            const levelNum = parseInt(level);
+                            if (levelNum !== -1) {{
+                                const found = flatData.nodesByLevel[levelNum].find(n => n.name === sourceName);
+                                if (found) {{
+                                    sourceNode = found;
+                                    break;
+                                }}
+                            }}
+                        }}
+                    }}
+                }}
+                
+                if (sourceNode && sourceNode.el) {{
+                    sourceNode.el.classList.remove('dimmed');
+                    sourceNode.el.classList.add('highlight');
+                }}
+            }});
+            
+            // 高亮目标节点
+            group.targets.forEach(target => {{
+                if (target.el) {{
+                    target.el.classList.remove('dimmed');
+                    target.el.classList.add('highlight');
+                }}
+            }});
+            
+            // 高亮边组
+            const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
+            if (edgeGroupEl) {{
+                Array.from(edgeGroupEl.children).forEach(child => {{
+                    child.classList.remove('dimmed');
+                    child.classList.add('highlight');
+                    if (child.classList.contains('entry')) {{
+                        child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
+                    }}
+                }});
+            }}
+        }}
+
+        // 4. 交互:高亮组
+        function highlightDirectSources(targetNode) {{
+            // Reset
+            document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
+                el.classList.remove('highlight');
+                el.classList.add('dimmed');
+            }});
+            document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
+                el.classList.remove('highlight');
+                el.classList.add('dimmed');
+            }});
+            document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+
+            let nodesToHighlight = [targetNode];
+
+            if (targetNode.edgeGroupKey) {{
+                const group = edgeGroups[targetNode.edgeGroupKey];
+                if (group) {{
+                    nodesToHighlight = group.targets;
+                    const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
+                    if (edgeGroupEl) {{
+                        Array.from(edgeGroupEl.children).forEach(child => {{
+                            child.classList.remove('dimmed');
+                            child.classList.add('highlight');
+                            if(child.classList.contains('entry')) {{
+                                child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
+                            }}
+                        }});
+                    }}
+                    // 高亮源节点(根据来源区分查找;同名多轮时取作为“源”的那一轮)
+                    const sourceMap = group.sourceMap || {{}};
+                    const minTargetLevel = group.targets.length ? Math.min(...group.targets.map(t => t.level)) : 0;
+                    group.sources.forEach(sName => {{
+                        const sourceType = sourceMap[sName];
+                        let sNode = null;
+                        
+                        if (sourceType === 'tree') {{
+                            const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                            sNode = levelMinusOneNodes.find(n => n.name === sName);
+                        }} else {{
+                            // input_post_nodes:取层级低于目标且同名的节点(从最接近目标的一轮开始找)
+                            for (let l = minTargetLevel - 1; l >= 0; l--) {{
+                                const found = (flatData.nodesByLevel[l] || []).find(n => n.name === sName);
+                                if (found) {{ sNode = found; break; }}
+                            }}
+                            if (!sNode) {{
+                                const candidate = flatData.map[sName];
+                                if (candidate && candidate.level !== -1) sNode = candidate;
+                            }}
+                        }}
+                        
+                        if (sNode && sNode.el) {{
+                            sNode.el.classList.remove('dimmed');
+                            sNode.el.classList.add('highlight');
+                        }}
+                    }});
+                }}
+            }}
+
+            nodesToHighlight.forEach(n => {{
+                if (n.el) {{
+                    n.el.classList.remove('dimmed');
+                    n.el.classList.add('highlight');
+                }}
+            }});
+        }}
+
+        // --- 视图控制 ---
+        let scale = 0.8, translateX = 50, translateY = 50;
+        let isDragging = false, startClientX, startClientY, startTranslateX, startTranslateY;
+        const DRAG_SENSITIVITY = 1.35; // 拖拽灵敏度,>1 更跟手
+        let currentSelectedNode = null; // 跟踪当前选中的节点
+        let currentSelectedEdgeGroup = null; // 跟踪当前选中的边组
+        const container = document.getElementById('app-container');
+
+        function updateTransform() {{
+            // 采用先缩放再平移的顺序,使平移量与缩放无关,便于以视图中心进行缩放和平移
+            canvas.style.transform = `scale(${{scale}}) translate(${{translateX}}px, ${{translateY}}px)`;
+        }}
+
+        // 计算相关节点的边界框并缩放显示(当前节点及其连线上的节点)
+        function fitRelatedNodesToView(targetNode, useAnimation = true) {{
+            if (!targetNode || targetNode.x === undefined) return;
+
+            // 收集所有相关节点
+            const relatedNodes = new Set();
+            relatedNodes.add(targetNode);
+
+            // 如果节点有边组,添加同组的其他目标节点
+            if (targetNode.edgeGroupKey) {{
+                const group = edgeGroups[targetNode.edgeGroupKey];
+                if (group) {{
+                    // 添加同组的所有目标节点
+                    group.targets.forEach(t => {{
+                        if (t && t.x !== undefined) relatedNodes.add(t);
+                    }});
+                    
+                    // 添加所有源节点(需要根据来源区分查找)
+                    const sourceMap = group.sourceMap || {{}};
+                    group.sources.forEach(sName => {{
+                        const sourceType = sourceMap[sName];
+                        let sNode = null;
+                        
+                        if (sourceType === 'tree') {{
+                            // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
+                            const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
+                            sNode = levelMinusOneNodes.find(n => n.name === sName);
+                        }} else {{
+                            // input_post_nodes:从推导节点中查找(排除 level -1)
+                            const candidate = flatData.map[sName];
+                            if (candidate && candidate.level !== -1) {{
+                                sNode = candidate;
+                            }} else {{
+                                // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
+                                for (let level in flatData.nodesByLevel) {{
+                                    const levelNum = parseInt(level);
+                                    if (levelNum !== -1) {{
+                                        const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
+                                        if (found) {{
+                                            sNode = found;
+                                            break;
+                                        }}
+                                    }}
+                                }}
+                            }}
+                        }}
+                        
+                        if (sNode && sNode.x !== undefined) {{
+                            relatedNodes.add(sNode);
+                        }}
+                    }});
+                }}
+            }}
+
+            const sidebar = document.getElementById('sidebar');
+            const isSidebarOpen = sidebar && sidebar.classList.contains('active');
+            const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
+            const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
+            const viewH = container.offsetHeight;
+
+            // 仅当节点没有连线(只有一个相关节点)时:只平移使节点居中,不改变缩放,避免视图被放大超出
+            if (relatedNodes.size === 1) {{
+                const nodeCenterX = targetNode.x + (targetNode.width || 0) / 2;
+                const nodeCenterY = targetNode.y + (targetNode.height || 0) / 2;
+                const offsetX = isSidebarOpen ? 0 : 0;
+                translateX = (viewW / 2) - (nodeCenterX * scale) + offsetX;
+                translateY = (viewH / 2) - (nodeCenterY * scale);
+                if (useAnimation) {{
+                    canvas.classList.add('animating');
+                    updateTransform();
+                    setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
+                }} else {{
+                    canvas.classList.remove('animating');
+                    updateTransform();
+                }}
+                return;
+            }}
+
+            // 计算边界框(包括节点和连线可能延伸的区域)
+            let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+            relatedNodes.forEach(node => {{
+                if (node.x !== undefined && node.y !== undefined) {{
+                    // 节点本身的边界
+                    minX = Math.min(minX, node.x);
+                    minY = Math.min(minY, node.y);
+                    maxX = Math.max(maxX, node.x + (node.width || 0));
+                    maxY = Math.max(maxY, node.y + (node.height || 0));
+                    
+                    // 考虑连线可能延伸到左侧(busOffset)
+                    if (node.inputPoint) {{
+                        const leftExtend = node.inputPoint.x - CONFIG.busOffset;
+                        minX = Math.min(minX, leftExtend);
+                    }}
+                }}
+            }});
+
+            // 如果边界框无效,直接返回
+            if (minX === Infinity) return;
+
+            // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
+            const padding = 40;
+            const contentWidth = maxX - minX + padding * 2;
+            const contentHeight = maxY - minY + padding * 2;
+            const contentCenterX = (minX + maxX) / 2;
+            const contentCenterY = (minY + maxY) / 2;
+
+            // 计算缩放比例,确保内容能完全显示
+            const scaleX = (viewW - padding * 2) / contentWidth;
+            const scaleY = (viewH - padding * 2) / contentHeight;
+            // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
+            scale = Math.min(scaleX, scaleY, 2.0);
+
+            // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
+            const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
+            translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
+            translateY = (viewH / 2) - (contentCenterY * scale);
+
+            // 根据参数决定是否使用动画
+            if (useAnimation) {{
+                canvas.classList.add('animating');
+                updateTransform();
+                setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
+            }} else {{
+                // 移除动画类,确保瞬间完成
+                canvas.classList.remove('animating');
+                updateTransform();
+            }}
+        }}
+
+        let hasDragged = false; // 标记是否发生了拖动
+        
+        container.addEventListener('click', e => {{
+            // 只有在没有拖动的情况下才重置视图
+            if (!hasDragged && (e.target.id === 'app-container' || e.target.id === 'canvas' || e.target.classList.contains('edge-layer'))) {{
+                resetView();
+            }}
+            // 处理完点击事件后重置标志
+            hasDragged = false;
+        }});
+
+        function resetView() {{
+            document.querySelectorAll('.highlight').forEach(el => el.classList.remove('highlight'));
+            document.querySelectorAll('.dimmed').forEach(el => el.classList.remove('dimmed'));
+            document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
+            document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
+            document.getElementById('search-input').value = '';
+            currentSelectedNode = null; // 清除当前选中的节点
+            currentSelectedEdgeGroup = null; // 清除当前选中的边组
+            closeSidebar();
+        }}
+
+        const searchInput = document.getElementById('search-input');
+        searchInput.addEventListener('input', (e) => {{
+            const val = e.target.value.toLowerCase();
+            document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
+                const name = el.innerText.toLowerCase();
+                if(!val) el.classList.remove('dimmed');
+                else if (name.includes(val)) el.classList.remove('dimmed');
+                else el.classList.add('dimmed');
+            }});
+        }});
+        searchInput.addEventListener('keydown', (e) => {{
+            if (e.key === 'Enter') {{
+                const val = searchInput.value.toLowerCase();
+                if (!val) return;
+                const match = Object.values(flatData.map).find(n => n.name.toLowerCase().includes(val));
+                if (match) focusOnNode(match);
+            }}
+        }});
+
+        function focusOnNode(node) {{
+            const nodeCenterX = node.x + node.width / 2;
+            const nodeCenterY = node.y + node.height / 2;
+            const viewW = container.offsetWidth;
+            const viewH = container.offsetHeight;
+            translateX = (viewW / 2) - (nodeCenterX * scale);
+            translateY = (viewH / 2) - (nodeCenterY * scale);
+            canvas.classList.add('animating');
+            updateTransform();
+            setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
+            highlightDirectSources(node);
+        }}
+
+        container.addEventListener('mousedown', e => {{
+            if (e.target === container || e.target.id === 'canvas' || e.target.classList.contains('edge-layer')) {{
+                isDragging = true; 
+                hasDragged = false; // 重置拖动标志
+                startClientX = e.clientX;
+                startClientY = e.clientY;
+                startTranslateX = translateX;
+                startTranslateY = translateY;
+                container.classList.add('grabbing');
+            }}
+        }});
+        window.addEventListener('mousemove', e => {{
+            if (isDragging) {{
+                e.preventDefault(); 
+                translateX = startTranslateX + (e.clientX - startClientX) * DRAG_SENSITIVITY;
+                translateY = startTranslateY + (e.clientY - startClientY) * DRAG_SENSITIVITY;
+                updateTransform();
+                hasDragged = true; // 标记发生了拖动
+            }}
+        }});
+        window.addEventListener('mouseup', () => {{
+            isDragging = false; 
+            container.classList.remove('grabbing');
+        }});
+        container.addEventListener('wheel', e => {{
+            e.preventDefault();
+            
+            // 以当前视图中心为缩放中心,保证缩放时画面不会“往左上角跑”
+            const viewW = container.offsetWidth;
+            const viewH = container.offsetHeight;
+            const centerScreenX = viewW / 2;
+            const centerScreenY = viewH / 2;
+
+            // 当前视图中心对应的画布坐标(world coords)
+            const centerWorldX = (centerScreenX - translateX) / scale;
+            const centerWorldY = (centerScreenY - translateY) / scale;
+
+            // 更小的缩放步长,让滚轮缩放更平滑
+            const zoomStep = 0.05; // 每次滚轮约 5% 的缩放变化
+            const zoomFactor = e.deltaY > 0 ? (1 - zoomStep) : (1 + zoomStep);
+            const newScale = Math.max(0.1, Math.min(3, scale * zoomFactor));
+
+            // 根据新的缩放比例,调整平移量,使视图中心保持不动
+            translateX = centerScreenX - centerWorldX * newScale;
+            translateY = centerScreenY - centerWorldY * newScale;
+            scale = newScale;
+
+            updateTransform();
+        }}, {{ passive: false }});
+
+        function escapeHtml(text) {{
+            if (!text) return "";
+            const div = document.createElement("div");
+            div.textContent = text;
+            return div.innerHTML;
+        }}
+
+        function showSidebar(detail, title, fullData, sidebarType) {{
+            const container = document.getElementById('sidebar-content');
+            const sidebar = document.getElementById('sidebar');
+            const appContainer = document.getElementById('app-container');
+            const titleEl = document.getElementById('sidebar-title');
+            if (titleEl) titleEl.textContent = (sidebarType === 'edge') ? '边详情' : '节点详情';
+            container.innerHTML = '';
+
+            if (fullData && (fullData.id === "root" || fullData.name === "root")) {{
+                renderRootDetail(detail, container);
+                sidebar.classList.add('active');
+                appContainer.classList.add('sidebar-open');
+                updateCanvasWidth();
+                return;
+            }}
+
+            // 添加标题
+            const titleDiv = document.createElement('div');
+            titleDiv.className = 'detail-item';
+            const titleLabel = document.createElement('label');
+            titleLabel.textContent = title || '节点详情';
+            titleDiv.appendChild(titleLabel);
+            container.appendChild(titleDiv);
+
+            // 检查是否是外部边或工具边(通过 edgeName 判断)
+            const edgeName = fullData?.edgeName || '';
+            const isExternalEdge = edgeName && (edgeName.includes('外部搜索') || edgeName.includes('外部寻找'));
+            const isToolEdge = edgeName && edgeName.includes('工具');
+
+            // 外部边:特殊展示逻辑
+            if (isExternalEdge) {{
+                renderExternalEdgeDetail(detail, container, fullData?.name || '');
+                if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
+                    const scoreItem = document.createElement('div');
+                    scoreItem.className = 'detail-item';
+                    const scoreLabel = document.createElement('label');
+                    scoreLabel.textContent = 'Score:';
+                    scoreItem.appendChild(scoreLabel);
+                    const scoreVal = document.createElement('div');
+                    scoreVal.className = 'detail-val';
+                    scoreVal.textContent = fullData.edgeScore.toFixed(4);
+                    scoreItem.appendChild(scoreVal);
+                    container.appendChild(scoreItem);
+                }}
+                sidebar.classList.add('active');
+                appContainer.classList.add('sidebar-open');
+                updateCanvasWidth();
+                return;
+            }}
+
+            // 工具边:特殊展示逻辑
+            if (isToolEdge) {{
+                renderToolEdgeDetail(detail, container, fullData?.name || '');
+                if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
+                    const scoreItem = document.createElement('div');
+                    scoreItem.className = 'detail-item';
+                    const scoreLabel = document.createElement('label');
+                    scoreLabel.textContent = 'Score:';
+                    scoreItem.appendChild(scoreLabel);
+                    const scoreVal = document.createElement('div');
+                    scoreVal.className = 'detail-val';
+                    scoreVal.textContent = fullData.edgeScore.toFixed(4);
+                    scoreItem.appendChild(scoreVal);
+                    container.appendChild(scoreItem);
+                }}
+                sidebar.classList.add('active');
+                appContainer.classList.add('sidebar-open');
+                updateCanvasWidth();
+                return;
+            }}
+
+            // 过滤掉路径相关的字段
+            const pathFields = ['source', 'target', 'id', 'originalData', 'internal', 'external', 'internal_edge', 'external_edge', 'children', 'parent_edge', 'sources', 'edgeName', 'edgeScore', 'edgeGroupKey'];
+
+            // 过滤detail中的路径字段
+            const filteredDetail = {{}};
+            if (detail && typeof detail === "object") {{
+                Object.entries(detail).forEach(([key, value]) => {{
+                    if (!pathFields.includes(key)) {{
+                        filteredDetail[key] = value;
+                    }}
+                }});
+            }}
+
+            // 如果没有detail内容
+            if (!filteredDetail || Object.keys(filteredDetail).length === 0) {{
+                const emptyDiv = document.createElement('div');
+                emptyDiv.className = 'detail-empty';
+                emptyDiv.textContent = '暂无详情信息';
+                container.appendChild(emptyDiv);
+                sidebar.classList.add('active');
+                appContainer.classList.add('sidebar-open');
+                updateCanvasWidth();
+                return;
+            }}
+
+            // 显示detail内容
+            Object.entries(filteredDetail).forEach(([key, value]) => {{
+                // 跳过空值
+                if (value === null || value === undefined || value === "") return;
+
+                const item = document.createElement('div');
+                item.className = 'detail-item';
+
+                const label = document.createElement('label');
+                label.textContent = key + ':';
+                item.appendChild(label);
+
+                if (typeof value === "object" && value !== null && !Array.isArray(value)) {{
+                    // 对象结构,展示 KV 列表
+                    const subContainer = document.createElement('div');
+                    subContainer.className = 'detail-val';
+                    subContainer.style.paddingLeft = '15px';
+                    subContainer.style.borderLeft = '3px solid #eee';
+                    subContainer.style.marginTop = '10px';
+                    subContainer.style.fontSize = '14px';
+                    
+                    Object.entries(value).forEach(([subKey, subValue]) => {{
+                        if (subValue === null || subValue === undefined || subValue === "") return;
+                        const subItem = document.createElement('div');
+                        subItem.style.marginBottom = '8px';
+                        const subKeySpan = document.createElement('span');
+                        subKeySpan.style.color = '#666';
+                        subKeySpan.textContent = subKey + ': ';
+                        subItem.appendChild(subKeySpan);
+                        const subValSpan = document.createElement('span');
+                        subValSpan.textContent = typeof subValue === 'object' ? JSON.stringify(subValue) : subValue;
+                        subItem.appendChild(subValSpan);
+                        subContainer.appendChild(subItem);
+                    }});
+                    item.appendChild(subContainer);
+                }} else {{
+                    const valueContainer = document.createElement('div');
+                    valueContainer.className = 'detail-val';
+
+                    if (Array.isArray(value)) {{
+                        if (value.length === 0) return;
+                        
+                        // 检查数组元素是否为对象
+                        const isArrayOfObjects = value.length > 0 && typeof value[0] === 'object' && value[0] !== null;
+                        
+                        if (isArrayOfObjects) {{
+                            // 数组元素为对象时,使用表格展示
+                            const table = document.createElement('table');
+                            table.style.width = '100%';
+                            table.style.borderCollapse = 'collapse';
+                            table.style.fontSize = '13px';
+                            table.style.marginTop = '5px';
+
+                            // 统计所有列名(字段)
+                            const columnsSet = new Set();
+                            value.forEach(v => {{
+                                if (v && typeof v === 'object') {{
+                                    Object.keys(v).forEach(k => columnsSet.add(k));
+                                }}
+                            }});
+                            const columns = Array.from(columnsSet);
+
+                            // 表头
+                            const thead = document.createElement('thead');
+                            const headerRow = document.createElement('tr');
+                            const thIndex = document.createElement('th');
+                            thIndex.textContent = '#';
+                            thIndex.style.borderBottom = '1px solid #eee';
+                            thIndex.style.padding = '4px 6px';
+                            thIndex.style.textAlign = 'left';
+                            thIndex.style.color = '#888';
+                            headerRow.appendChild(thIndex);
+                            columns.forEach(col => {{
+                                const th = document.createElement('th');
+                                th.textContent = col;
+                                th.style.borderBottom = '1px solid #eee';
+                                th.style.padding = '4px 6px';
+                                th.style.textAlign = 'left';
+                                th.style.color = '#555';
+                                headerRow.appendChild(th);
+                            }});
+                            thead.appendChild(headerRow);
+                            table.appendChild(thead);
+
+                            // 表体
+                            const tbody = document.createElement('tbody');
+                            value.forEach((v, i) => {{
+                                const row = document.createElement('tr');
+                                row.style.borderBottom = '1px dashed #f0f0f0';
+                                const tdIndex = document.createElement('td');
+                                tdIndex.textContent = i + 1;
+                                tdIndex.style.padding = '4px 6px';
+                                tdIndex.style.color = '#999';
+                                row.appendChild(tdIndex);
+                                columns.forEach(col => {{
+                                    const cellVal = v && typeof v === 'object' ? v[col] : undefined;
+                                    const td = document.createElement('td');
+                                    td.textContent = cellVal === undefined || cellVal === null
+                                        ? ""
+                                        : (typeof cellVal === 'object' ? JSON.stringify(cellVal) : cellVal);
+                                    td.style.padding = '4px 6px';
+                                    td.style.color = '#666';
+                                    td.style.verticalAlign = 'top';
+                                    row.appendChild(td);
+                                }});
+                                tbody.appendChild(row);
+                            }});
+                            table.appendChild(tbody);
+                            valueContainer.appendChild(table);
+                        }} else {{
+                            // 普通数组:每个元素一行的单列表格
+                            const table = document.createElement('table');
+                            table.style.width = '100%';
+                            table.style.borderCollapse = 'collapse';
+                            table.style.fontSize = '13px';
+                            table.style.marginTop = '5px';
+
+                            const thead = document.createElement('thead');
+                            const headerRow = document.createElement('tr');
+                            const thIndex = document.createElement('th');
+                            thIndex.textContent = '#';
+                            thIndex.style.borderBottom = '1px solid #eee';
+                            thIndex.style.padding = '4px 6px';
+                            thIndex.style.textAlign = 'left';
+                            thIndex.style.color = '#888';
+                            headerRow.appendChild(thIndex);
+                            const thValue = document.createElement('th');
+                            thValue.textContent = 'value';
+                            thValue.style.borderBottom = '1px solid #eee';
+                            thValue.style.padding = '4px 6px';
+                            thValue.style.textAlign = 'left';
+                            thValue.style.color = '#555';
+                            headerRow.appendChild(thValue);
+                            thead.appendChild(headerRow);
+                            table.appendChild(thead);
+
+                            const tbody = document.createElement('tbody');
+                            value.forEach((v, i) => {{
+                                const row = document.createElement('tr');
+                                row.style.borderBottom = '1px dashed #f0f0f0';
+                                const tdIndex = document.createElement('td');
+                                tdIndex.textContent = i + 1;
+                                tdIndex.style.padding = '4px 6px';
+                                tdIndex.style.color = '#999';
+                                row.appendChild(tdIndex);
+                                const tdValue = document.createElement('td');
+                                tdValue.textContent = typeof v === 'object' ? JSON.stringify(v) : v;
+                                tdValue.style.padding = '4px 6px';
+                                tdValue.style.color = '#666';
+                                tdValue.style.verticalAlign = 'top';
+                                row.appendChild(tdValue);
+                                tbody.appendChild(row);
+                            }});
+                            table.appendChild(tbody);
+                            valueContainer.appendChild(table);
+                        }}
+                    }} else {{
+                        let displayValue = value;
+                        if (typeof value === "string" && value.length > 500) {{
+                            displayValue = value.substring(0, 500) + "...";
+                        }}
+                        valueContainer.textContent = displayValue;
+                    }}
+                    item.appendChild(valueContainer);
+                }}
+                container.appendChild(item);
+            }});
+
+            // 如果有score,也显示
+            if (fullData && fullData.score !== undefined && fullData.score !== null) {{
+                const scoreItem = document.createElement('div');
+                scoreItem.className = 'detail-item';
+                const scoreLabel = document.createElement('label');
+                scoreLabel.textContent = 'Score:';
+                scoreItem.appendChild(scoreLabel);
+                const scoreVal = document.createElement('div');
+                scoreVal.className = 'detail-val';
+                scoreVal.textContent = fullData.score.toFixed(4);
+                scoreItem.appendChild(scoreVal);
+                container.appendChild(scoreItem);
+            }}
+
+            sidebar.classList.add('active');
+            appContainer.classList.add('sidebar-open');
+            updateCanvasWidth();
+        }}
+        function renderExternalEdgeDetail(detail, container, targetNodeName) {{
+            if (!detail) return;
+
+            const targetName = targetNodeName || "";
+
+            // 1. 全局常量
+            const globalConstants = detail["全局常量"] || [];
+            if (Array.isArray(globalConstants) && globalConstants.length > 0) {{
+                const globalSection = document.createElement('div');
+                globalSection.className = 'detail-item';
+                const globalLabel = document.createElement('label');
+                globalLabel.textContent = '全局常量:';
+                globalSection.appendChild(globalLabel);
+                const globalValue = document.createElement('div');
+                globalValue.className = 'detail-val';
+                globalValue.style.marginTop = '8px';
+                globalValue.textContent = globalConstants.join('、');
+                globalSection.appendChild(globalValue);
+                container.appendChild(globalSection);
+            }}
+
+            // 2. 局部常量
+            const localConstants = detail["局部常量"] || [];
+            if (Array.isArray(localConstants) && localConstants.length > 0) {{
+                const localSection = document.createElement('div');
+                localSection.className = 'detail-item';
+                const localLabel = document.createElement('label');
+                localLabel.textContent = '局部常量:';
+                localSection.appendChild(localLabel);
+                const localValue = document.createElement('div');
+                localValue.className = 'detail-val';
+                localValue.style.marginTop = '8px';
+                localValue.textContent = localConstants.join('、');
+                localSection.appendChild(localValue);
+                container.appendChild(localSection);
+            }}
+
+            // 3. 匹配到的模式
+            const matchedPatterns = detail["匹配到的模式"] || [];
+            if (Array.isArray(matchedPatterns) && matchedPatterns.length > 0) {{
+                const patternSection = document.createElement('div');
+                patternSection.className = 'detail-item';
+                const patternLabel = document.createElement('label');
+                patternLabel.textContent = '匹配到的模式:';
+                patternSection.appendChild(patternLabel);
+                const patternValue = document.createElement('div');
+                patternValue.className = 'detail-val';
+                patternValue.style.marginTop = '8px';
+                container.appendChild(patternSection);
+                patternSection.appendChild(patternValue);
+
+                matchedPatterns.forEach((pattern, idx) => {{
+                    const patternBlock = document.createElement('div');
+                    patternBlock.style.marginBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
+                    patternBlock.style.paddingBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
+                    patternBlock.style.borderBottom = idx < matchedPatterns.length - 1 ? '1px solid #eee' : 'none';
+
+                    // 显示 id 和 support
+                    const patternHeader = document.createElement('div');
+                    patternHeader.style.fontWeight = 'bold';
+                    patternHeader.style.marginBottom = '6px';
+                    patternHeader.style.fontSize = '13px';
+                    patternHeader.textContent = '模式 ID: ' + (pattern.id || "") + ', Support: ' + (pattern.support !== undefined ? pattern.support.toFixed(4) : "");
+                    patternBlock.appendChild(patternHeader);
+
+                    // items 展示成表格
+                    const items = pattern.items || [];
+                    if (items.length > 0) {{
+                        const itemsTable = document.createElement('table');
+                        itemsTable.style.width = '100%';
+                        itemsTable.style.borderCollapse = 'collapse';
+                        itemsTable.style.fontSize = '13px';
+                        itemsTable.style.marginTop = '6px';
+                        itemsTable.style.marginBottom = '6px';
+
+                        const thead = document.createElement('thead');
+                        const headerRow = document.createElement('tr');
+                        ["name", "point", "dimension", "type"].forEach(col => {{
+                            const th = document.createElement('th');
+                            th.textContent = col;
+                            th.style.border = '1px solid #ddd';
+                            th.style.padding = '6px 8px';
+                            th.style.textAlign = 'left';
+                            th.style.background = '#f5f5f5';
+                            headerRow.appendChild(th);
+                        }});
+                        thead.appendChild(headerRow);
+                        itemsTable.appendChild(thead);
+
+                        const tbody = document.createElement('tbody');
+                        items.forEach(item => {{
+                            const tr = document.createElement('tr');
+                            ["name", "point", "dimension", "type"].forEach(col => {{
+                                const td = document.createElement('td');
+                                td.textContent = item[col] || "";
+                                td.style.border = '1px solid #ddd';
+                                td.style.padding = '6px 8px';
+                                tr.appendChild(td);
+                            }});
+                            tbody.appendChild(tr);
+                        }});
+                        itemsTable.appendChild(tbody);
+                        patternBlock.appendChild(itemsTable);
+                    }}
+
+                    // match_points
+                    const matchPoints = pattern.match_points || [];
+                    if (matchPoints.length > 0) {{
+                        const matchPointsDiv = document.createElement('div');
+                        matchPointsDiv.style.fontSize = '13px';
+                        matchPointsDiv.style.color = '#666';
+                        matchPointsDiv.style.marginTop = '4px';
+                        matchPointsDiv.textContent = '匹配点: ' + matchPoints.join('、');
+                        patternBlock.appendChild(matchPointsDiv);
+                    }}
+
+                    patternValue.appendChild(patternBlock);
+                }});
+            }}
+
+            // 4. 动态常量
+            const dynamicConstants = detail["动态常量"] || [];
+            if (Array.isArray(dynamicConstants) && dynamicConstants.length > 0) {{
+                const dynamicSection = document.createElement('div');
+                dynamicSection.className = 'detail-item';
+                const dynamicLabel = document.createElement('label');
+                dynamicLabel.textContent = '动态常量:';
+                dynamicSection.appendChild(dynamicLabel);
+                const dynamicValue = document.createElement('div');
+                dynamicValue.className = 'detail-val';
+                dynamicValue.style.marginTop = '8px';
+                container.appendChild(dynamicSection);
+                dynamicSection.appendChild(dynamicValue);
+
+                const dynamicTable = document.createElement('table');
+                dynamicTable.style.width = '100%';
+                dynamicTable.style.borderCollapse = 'collapse';
+                dynamicTable.style.fontSize = '13px';
+
+                const thead = document.createElement('thead');
+                const headerRow = document.createElement('tr');
+                const columns = ["point", "tree_parent_node", "match_score", "tree_child_node", "relative_ratio"];
+                columns.forEach(col => {{
+                    const th = document.createElement('th');
+                    th.textContent = col;
+                    th.style.border = '1px solid #ddd';
+                    th.style.padding = '6px 8px';
+                    th.style.textAlign = 'left';
+                    th.style.background = '#f5f5f5';
+                    headerRow.appendChild(th);
+                }});
+                thead.appendChild(headerRow);
+                dynamicTable.appendChild(thead);
+
+                // 计算每列的合并信息
+                const getValue = (dc, col) => {{
+                    if (col === "match_score") return dc.match_score !== undefined ? dc.match_score.toFixed(4) : "";
+                    if (col === "relative_ratio") return dc.relative_ratio !== undefined ? dc.relative_ratio.toFixed(4) : "";
+                    return dc[col] || "";
+                }};
+
+                const getRowspan = (colIdx, rowIdx) => {{
+                    const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
+                    let span = 1;
+                    for (let i = rowIdx + 1; i < dynamicConstants.length; i++) {{
+                        const nextValue = getValue(dynamicConstants[i], columns[colIdx]);
+                        if (nextValue === currentValue) {{
+                            span++;
+                        }} else {{
+                            break;
+                        }}
+                    }}
+                    return span;
+                }};
+
+                const shouldSkipCell = (colIdx, rowIdx) => {{
+                    if (rowIdx === 0) return false;
+                    const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
+                    const prevValue = getValue(dynamicConstants[rowIdx - 1], columns[colIdx]);
+                    return currentValue === prevValue;
+                }};
+
+                const tbody = document.createElement('tbody');
+                dynamicConstants.forEach((dc, rowIdx) => {{
+                    const tr = document.createElement('tr');
+                    columns.forEach((col, colIdx) => {{
+                        if (shouldSkipCell(colIdx, rowIdx)) {{
+                            // 跳过,因为会被上一行的 rowspan 覆盖
+                            return;
+                        }}
+                        const cellValue = getValue(dc, col);
+                        const span = getRowspan(colIdx, rowIdx);
+                        const td = document.createElement('td');
+                        td.textContent = cellValue;
+                        td.style.border = '1px solid #ddd';
+                        td.style.padding = '6px 8px';
+                        if (span > 1) {{
+                            td.setAttribute('rowspan', span);
+                        }}
+                        tr.appendChild(td);
+                    }});
+                    tbody.appendChild(tr);
+                }});
+                dynamicTable.appendChild(tbody);
+                dynamicValue.appendChild(dynamicTable);
+            }}
+
+            // 4.5. 推导成功的选题点
+            const successfulPointsRaw = detail["推导成功的选题点"] || [];
+            if (Array.isArray(successfulPointsRaw) && successfulPointsRaw.length > 0) {{
+                // 处理字符串数组或对象数组(对象需有 name 字段)
+                const successfulPoints = successfulPointsRaw.map(item => {{
+                    if (typeof item === "string") {{
+                        return item;
+                    }} else if (item && typeof item === "object" && item.name) {{
+                        return item.name;
+                    }}
+                    return "";
+                }}).filter(Boolean);
+                
+                if (successfulPoints.length > 0) {{
+                    const successfulSection = document.createElement('div');
+                    successfulSection.className = 'detail-item';
+                    const successfulLabel = document.createElement('label');
+                    successfulLabel.textContent = '推导成功的选题点:';
+                    successfulSection.appendChild(successfulLabel);
+                    const successfulValue = document.createElement('div');
+                    successfulValue.className = 'detail-val';
+                    successfulValue.style.marginTop = '8px';
+                    successfulValue.textContent = successfulPoints.join('、');
+                    successfulSection.appendChild(successfulValue);
+                    container.appendChild(successfulSection);
+                }}
+            }}
+
+            // 5. Query列表
+            const querySection = document.createElement('div');
+            querySection.className = 'detail-item';
+            const queryLabelRow = document.createElement('div');
+            queryLabelRow.style.display = 'flex';
+            queryLabelRow.style.justifyContent = 'space-between';
+            queryLabelRow.style.alignItems = 'center';
+            queryLabelRow.style.marginBottom = '8px';
+            const queryLabel = document.createElement('label');
+            queryLabel.style.margin = '0';
+            queryLabel.textContent = 'Query列表:';
+            queryLabelRow.appendChild(queryLabel);
+            querySection.appendChild(queryLabelRow);
+            const queryList = detail.query_list || [];
+            const queryStrs = queryList.map(q => q && q.query_str ? q.query_str : (typeof q === "string" ? q : "")).filter(Boolean);
+            const queryValueDiv = document.createElement('div');
+            queryValueDiv.className = 'detail-val';
+            queryValueDiv.style.marginTop = '4px';
+            queryValueDiv.style.whiteSpace = 'pre-wrap';
+            if (queryStrs.length) {{
+                queryValueDiv.textContent = queryStrs.join('\\n');
+            }} else {{
+                queryValueDiv.textContent = '暂无';
+            }}
+            querySection.appendChild(queryValueDiv);
+            container.appendChild(querySection);
+
+            // 6. 外部寻找结果 (match_result 为数组,按匹配率倒序显示)
+            const matchSection = document.createElement('div');
+            matchSection.className = 'detail-item';
+            const matchLabelRow = document.createElement('div');
+            matchLabelRow.style.display = 'flex';
+            matchLabelRow.style.justifyContent = 'space-between';
+            matchLabelRow.style.alignItems = 'center';
+            matchLabelRow.style.marginBottom = '8px';
+            const matchLabel = document.createElement('label');
+            matchLabel.style.margin = '0';
+            matchLabel.textContent = '外部寻找结果:';
+            matchLabelRow.appendChild(matchLabel);
+            matchSection.appendChild(matchLabelRow);
+            const matchResultArr = detail.match_result || [];
+
+            const matchWithStats = matchResultArr.map(mr => {{
+                const nodeList = mr.node_list || [];
+                const searchCount = nodeList.length;
+                const matchCount = nodeList.filter(n => n.eval_result && n.eval_result.匹配类型 === "完全匹配").length;
+                const matchRate = searchCount > 0 ? (matchCount / searchCount * 100) : 0;
+                return {{ ...mr, searchCount, matchCount, matchRate }};
+            }});
+            const sortedMatchResult = [...matchWithStats].sort((a, b) => b.matchRate - a.matchRate);
+
+            const matchValue = document.createElement('div');
+            matchValue.className = 'detail-val';
+            matchValue.style.marginTop = '8px';
+            matchSection.appendChild(matchValue);
+            container.appendChild(matchSection);
+
+            sortedMatchResult.forEach((matchResult, mrIdx) => {{
+                const nodeList = matchResult.node_list || [];
+                const sortedNodes = [...nodeList].sort((a, b) => {{
+                    const scoreA = a.eval_result && a.eval_result.综合得分 !== undefined ? a.eval_result.综合得分 : -1;
+                    const scoreB = b.eval_result && b.eval_result.综合得分 !== undefined ? b.eval_result.综合得分 : -1;
+                    return scoreB - scoreA;
+                }});
+
+                const queryBlock = document.createElement('div');
+                queryBlock.className = 'query-block';
+                queryBlock.style.marginBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
+                queryBlock.style.paddingBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
+                queryBlock.style.borderBottom = mrIdx < sortedMatchResult.length - 1 ? '1px solid #eee' : 'none';
+
+                const queryBody = document.createElement('div');
+                queryBody.className = 'query-block-body';
+
+                const headerDiv = document.createElement('div');
+                headerDiv.className = 'query-block-header';
+                headerDiv.style.fontWeight = 'bold';
+                headerDiv.style.fontSize = '14px';
+                headerDiv.style.cursor = 'pointer';
+                const toggleSpan = document.createElement('span');
+                toggleSpan.className = 'query-toggle';
+                toggleSpan.style.marginRight = '6px';
+                toggleSpan.style.display = 'inline-block';
+                toggleSpan.style.width = '16px';
+                toggleSpan.textContent = '▼';
+                headerDiv.appendChild(toggleSpan);
+                const querySpan = document.createElement('span');
+                querySpan.textContent = 'Query: ' + (matchResult.query_str || "暂无");
+                headerDiv.appendChild(querySpan);
+                const statsDiv = document.createElement('div');
+                statsDiv.style.fontSize = '13px';
+                statsDiv.style.color = '#666';
+                statsDiv.style.fontWeight = 'normal';
+                statsDiv.style.marginTop = '4px';
+                statsDiv.textContent = '搜索帖子数: ' + matchResult.searchCount + ',匹配帖子数: ' + matchResult.matchCount + ',匹配率: ' + matchResult.matchRate.toFixed(1) + '%';
+                headerDiv.appendChild(statsDiv);
+
+                let isExpanded = true;
+                headerDiv.addEventListener('click', function() {{
+                    isExpanded = !isExpanded;
+                    queryBody.style.display = isExpanded ? 'block' : 'none';
+                    toggleSpan.textContent = isExpanded ? '▼' : '▶';
+                }});
+
+                queryBlock.insertBefore(headerDiv, queryBlock.firstChild);
+                queryBlock.appendChild(queryBody);
+
+                sortedNodes.forEach((node, i) => {{
+                    const postCard = document.createElement('div');
+                    postCard.className = 'external-post-card';
+                    postCard.style.border = '1px solid #eee';
+                    postCard.style.borderRadius = '8px';
+                    postCard.style.padding = '12px';
+                    postCard.style.marginTop = '12px';
+                    postCard.style.background = '#fafafa';
+
+                    const titleDiv = document.createElement('div');
+                    titleDiv.style.fontWeight = 'bold';
+                    titleDiv.style.marginBottom = '6px';
+                    titleDiv.style.fontSize = '14px';
+                    titleDiv.textContent = (i + 1) + '. ' + (node.title || "无标题");
+                    postCard.appendChild(titleDiv);
+
+                    const bodyText = node.body_text || "";
+                    const bodyDiv = document.createElement('div');
+                    bodyDiv.style.fontSize = '13px';
+                    bodyDiv.style.color = '#666';
+                    bodyDiv.style.marginBottom = '8px';
+                    bodyDiv.style.whiteSpace = 'pre-wrap';
+                    bodyDiv.style.maxHeight = '100px';
+                    bodyDiv.style.overflowY = 'auto';
+                    bodyDiv.textContent = bodyText.length > 200 ? bodyText.substring(0, 200) + "..." : bodyText;
+                    postCard.appendChild(bodyDiv);
+
+                    const imgList = node.image_url_list || [];
+                    const urlList = imgList.map(img => (img && img.image_url) ? img.image_url : (typeof img === "string" ? img : "")).filter(Boolean);
+                    if (urlList.length > 0) {{
+                        const gallery = document.createElement('div');
+                        gallery.style.display = 'flex';
+                        gallery.style.flexWrap = 'wrap';
+                        gallery.style.gap = '6px';
+                        gallery.style.marginBottom = '8px';
+                        urlList.forEach((url, idx) => {{
+                            const thumb = document.createElement('div');
+                            thumb.style.display = 'block';
+                            thumb.style.cursor = 'pointer';
+                            const img = document.createElement('img');
+                            img.src = url;
+                            img.alt = '图' + (idx + 1);
+                            img.setAttribute('data-url', url);
+                            img.style.width = '60px';
+                            img.style.height = '60px';
+                            img.style.objectFit = 'cover';
+                            img.style.borderRadius = '4px';
+                            img.style.pointerEvents = 'none';
+                            thumb.appendChild(img);
+                            gallery.appendChild(thumb);
+                        }});
+                        postCard.appendChild(gallery);
+                    }}
+
+                    const evalResult = node.eval_result || {{}};
+                    if (evalResult && Object.keys(evalResult).length > 0) {{
+                        const evalDiv = document.createElement('div');
+                        evalDiv.style.marginTop = '8px';
+                        evalDiv.style.padding = '8px';
+                        evalDiv.style.background = '#fff';
+                        evalDiv.style.borderRadius = '4px';
+                        evalDiv.style.borderLeft = '3px solid #2196F3';
+                        const matchType = evalResult.匹配类型 || "无";
+                        const matchTypeColor = matchType === "完全匹配" ? "#5ba85f" : "inherit";
+                        const matchTypeDiv = document.createElement('div');
+                        matchTypeDiv.style.fontSize = '13px';
+                        matchTypeDiv.style.marginBottom = '4px';
+                        matchTypeDiv.innerHTML = '<strong>匹配类型:</strong> <strong style="color:' + matchTypeColor + '">' + escapeHtml(matchType) + '</strong>';
+                        evalDiv.appendChild(matchTypeDiv);
+                        const reasonDiv = document.createElement('div');
+                        reasonDiv.style.fontSize = '13px';
+                        reasonDiv.style.marginBottom = '4px';
+                        reasonDiv.innerHTML = '<strong>评分说明:</strong> ' + (evalResult.评分说明 || "无");
+                        evalDiv.appendChild(reasonDiv);
+                        const keyPoints = evalResult.关键匹配点;
+                        if (keyPoints && Array.isArray(keyPoints) && keyPoints.length > 0) {{
+                            const keyPointsLabel = document.createElement('div');
+                            keyPointsLabel.style.fontSize = '13px';
+                            keyPointsLabel.style.marginBottom = '4px';
+                            keyPointsLabel.style.fontWeight = 'bold';
+                            keyPointsLabel.textContent = '关键匹配点:';
+                            evalDiv.appendChild(keyPointsLabel);
+                            keyPoints.forEach(kp => {{
+                                const kpDiv = document.createElement('div');
+                                kpDiv.style.fontSize = '14px';
+                                kpDiv.style.fontWeight = 'bold';
+                                kpDiv.style.color = '#555';
+                                kpDiv.style.marginLeft = '12px';
+                                kpDiv.textContent = '• ' + kp;
+                                evalDiv.appendChild(kpDiv);
+                            }});
+                        }}
+                        postCard.appendChild(evalDiv);
+                    }}
+                    queryBody.appendChild(postCard);
+                }});
+
+                matchValue.appendChild(queryBlock);
+            }});
+        }}
+
+        function renderToolEdgeDetail(detail, container, targetNodeName) {{
+            if (!detail) return;
+
+            const toolDataList = detail.tool_data_list || [];
+            if (toolDataList.length === 0) {{
+                const emptyDiv = document.createElement('div');
+                emptyDiv.className = 'detail-empty';
+                emptyDiv.textContent = '暂无工具数据';
+                container.appendChild(emptyDiv);
+                return;
+            }}
+
+            // 添加列表标题
+            const listHeader = document.createElement('div');
+            listHeader.style.fontSize = '18px';
+            listHeader.style.fontWeight = 'bold';
+            listHeader.style.color = '#333';
+            listHeader.style.marginBottom = '15px';
+            listHeader.style.paddingBottom = '10px';
+            listHeader.style.borderBottom = '2px solid #2196F3';
+            listHeader.textContent = '工具数据列表 (共 ' + toolDataList.length + ' 项)';
+            container.appendChild(listHeader);
+
+            // 遍历每个工具数据
+            toolDataList.forEach((toolData, idx) => {{
+                const toolBlock = document.createElement('div');
+                toolBlock.className = 'tool-block';
+                toolBlock.style.marginBottom = idx < toolDataList.length - 1 ? '12px' : '0';
+                toolBlock.style.paddingBottom = idx < toolDataList.length - 1 ? '12px' : '0';
+                toolBlock.style.borderBottom = idx < toolDataList.length - 1 ? '1px solid #eee' : 'none';
+
+                const toolBody = document.createElement('div');
+                toolBody.className = 'tool-block-body';
+                toolBody.style.display = 'block';  // 默认展开
+
+                // 获取关键信息用于标题显示
+                const toolInfo = toolData.tool_info || {{}};
+                const toolName = toolInfo.name || "未知工具";
+                const eval = toolData.evaluation || {{}};
+                const matchLevel = eval.match_level || "未评估";
+                const matchLevelColor = matchLevel === "完全匹配" ? "#5ba85f" : 
+                                       matchLevel === "部分匹配" ? "#ff9800" : "#999";
+
+                // 添加可点击的标题栏(显示关键信息)
+                const headerDiv = document.createElement('div');
+                headerDiv.className = 'tool-block-header';
+                headerDiv.style.cursor = 'pointer';
+                headerDiv.style.userSelect = 'none';
+                headerDiv.style.fontWeight = 'bold';
+                headerDiv.style.fontSize = '14px';
+                headerDiv.style.padding = '10px 12px';
+                headerDiv.style.background = '#f5f5f5';
+                headerDiv.style.borderRadius = '6px';
+                headerDiv.style.borderLeft = '4px solid #2196F3';
+                headerDiv.style.marginBottom = '8px';
+                headerDiv.style.transition = 'background 0.2s';
+                headerDiv.addEventListener('mouseenter', function() {{
+                    this.style.background = '#e8f4f8';
+                }});
+                headerDiv.addEventListener('mouseleave', function() {{
+                    this.style.background = '#f5f5f5';
+                }});
+
+                const toggleSpan = document.createElement('span');
+                toggleSpan.className = 'tool-toggle';
+                toggleSpan.style.marginRight = '8px';
+                toggleSpan.style.display = 'inline-block';
+                toggleSpan.style.width = '16px';
+                toggleSpan.style.color = '#2196F3';
+                toggleSpan.textContent = '▼';
+                headerDiv.appendChild(toggleSpan);
+
+                const headerContent = document.createElement('span');
+                const toolNumSpan = document.createElement('span');
+                toolNumSpan.style.color = '#333';
+                toolNumSpan.textContent = '工具 ' + (idx + 1) + ' / ' + toolDataList.length + ': ';
+                headerContent.appendChild(toolNumSpan);
+                const toolNameSpan = document.createElement('span');
+                toolNameSpan.style.color = '#2196F3';
+                toolNameSpan.style.fontWeight = 'bold';
+                toolNameSpan.textContent = toolName;
+                headerContent.appendChild(toolNameSpan);
+                headerDiv.appendChild(headerContent);
+
+                // 添加关键信息摘要
+                const summaryDiv = document.createElement('div');
+                summaryDiv.style.fontSize = '13px';
+                summaryDiv.style.color = '#666';
+                summaryDiv.style.fontWeight = 'normal';
+                summaryDiv.style.marginTop = '6px';
+                summaryDiv.style.paddingLeft = '24px';
+
+                if (matchLevel && matchLevel !== "未评估") {{
+                    const matchLevelSpan = document.createElement('span');
+                    matchLevelSpan.style.marginRight = '15px';
+                    matchLevelSpan.innerHTML = '匹配级别: <strong style="color:' + matchLevelColor + '">' + escapeHtml(matchLevel) + '</strong>';
+                    summaryDiv.appendChild(matchLevelSpan);
+                }}
+                headerDiv.appendChild(summaryDiv);
+
+                headerDiv.addEventListener('click', function() {{
+                    const isExpanded = toolBody.style.display !== 'none';
+                    toolBody.style.display = isExpanded ? 'none' : 'block';
+                    toggleSpan.textContent = isExpanded ? '▶' : '▼';
+                }});
+
+                toolBlock.insertBefore(headerDiv, toolBlock.firstChild);
+                toolBlock.appendChild(toolBody);
+
+                const toolSection = document.createElement('div');
+                toolSection.className = 'detail-item';
+                toolSection.style.paddingTop = '10px';
+                toolBody.appendChild(toolSection);
+
+                // 1. 工具信息
+                if (Object.keys(toolInfo).length > 0) {{
+                    const toolInfoSection = document.createElement('div');
+                    toolInfoSection.style.marginBottom = '15px';
+                    const toolInfoTitle = document.createElement('div');
+                    toolInfoTitle.style.fontSize = '15px';
+                    toolInfoTitle.style.fontWeight = 'bold';
+                    toolInfoTitle.style.color = '#333';
+                    toolInfoTitle.style.marginBottom = '12px';
+                    toolInfoTitle.textContent = '工具信息';
+                    toolInfoSection.appendChild(toolInfoTitle);
+
+                    const toolInfoTable = document.createElement('table');
+                    toolInfoTable.style.width = '100%';
+                    toolInfoTable.style.borderCollapse = 'collapse';
+                    toolInfoTable.style.fontSize = '13px';
+                    toolInfoTable.style.background = '#fafafa';
+                    toolInfoTable.style.borderRadius = '6px';
+                    toolInfoTable.style.overflow = 'hidden';
+
+                    const toolInfoFields = [
+                        {{ key: "name", label: "工具名称" }},
+                        {{ key: "tool_description", label: "工具描述" }}
+                    ];
+
+                    toolInfoFields.forEach(field => {{
+                        const value = toolInfo[field.key];
+                        if (value !== undefined && value !== null && value !== "") {{
+                            const row = document.createElement('tr');
+                            const labelTd = document.createElement('td');
+                            labelTd.textContent = field.label + ':';
+                            labelTd.style.padding = '8px 12px';
+                            labelTd.style.fontWeight = 'bold';
+                            labelTd.style.color = '#555';
+                            labelTd.style.width = '120px';
+                            labelTd.style.verticalAlign = 'top';
+                            labelTd.style.background = '#f0f0f0';
+                            row.appendChild(labelTd);
+                            const valueTd = document.createElement('td');
+                            valueTd.textContent = String(value);
+                            valueTd.style.padding = '8px 12px';
+                            valueTd.style.color = '#666';
+                            valueTd.style.whiteSpace = 'pre-wrap';
+                            valueTd.style.wordBreak = 'break-word';
+                            row.appendChild(valueTd);
+                            toolInfoTable.appendChild(row);
+                        }}
+                    }});
+                    toolInfoSection.appendChild(toolInfoTable);
+                    toolSection.appendChild(toolInfoSection);
+                }}
+
+                // 2. 工具参数
+                if (toolData.params && toolData.params.prompt) {{
+                    const paramsSection = document.createElement('div');
+                    paramsSection.style.marginBottom = '15px';
+                    const paramsTitle = document.createElement('div');
+                    paramsTitle.style.fontSize = '15px';
+                    paramsTitle.style.fontWeight = 'bold';
+                    paramsTitle.style.color = '#333';
+                    paramsTitle.style.marginBottom = '8px';
+                    paramsTitle.textContent = '工具参数';
+                    paramsSection.appendChild(paramsTitle);
+
+                    const paramsDiv = document.createElement('div');
+                    paramsDiv.className = 'detail-val';
+                    paramsDiv.style.background = '#f0f7ff';
+                    paramsDiv.style.padding = '12px';
+                    paramsDiv.style.borderRadius = '6px';
+                    paramsDiv.style.borderLeft = '3px solid #2196F3';
+                    paramsDiv.style.whiteSpace = 'pre-wrap';
+                    paramsDiv.style.wordBreak = 'break-word';
+                    paramsDiv.style.fontSize = '14px';
+                    paramsDiv.style.lineHeight = '1.6';
+                    paramsDiv.style.color = '#333';
+                    paramsDiv.textContent = toolData.params.prompt;
+                    paramsSection.appendChild(paramsDiv);
+                    toolSection.appendChild(paramsSection);
+                }}
+
+                // 3. 工具内容
+                if (toolData.content) {{
+                    const contentSection = document.createElement('div');
+                    contentSection.style.marginBottom = '15px';
+                    const contentTitle = document.createElement('div');
+                    contentTitle.style.fontSize = '16px';
+                    contentTitle.style.fontWeight = 'bold';
+                    contentTitle.style.color = '#333';
+                    contentTitle.style.marginBottom = '8px';
+                    contentTitle.textContent = '工具返回内容';
+                    contentSection.appendChild(contentTitle);
+
+                    const contentDiv = document.createElement('div');
+                    contentDiv.className = 'detail-val';
+                    contentDiv.style.background = '#f9f9f9';
+                    contentDiv.style.padding = '12px';
+                    contentDiv.style.borderRadius = '6px';
+                    contentDiv.style.borderLeft = '3px solid #2196F3';
+                    contentDiv.style.whiteSpace = 'pre-wrap';
+                    contentDiv.style.wordBreak = 'break-word';
+                    contentDiv.style.fontSize = '14px';
+                    contentDiv.style.lineHeight = '1.6';
+                    contentDiv.style.color = '#333';
+                    contentDiv.style.maxHeight = '400px';
+                    contentDiv.style.overflowY = 'auto';
+                    contentDiv.textContent = toolData.content;
+                    contentSection.appendChild(contentDiv);
+                    toolSection.appendChild(contentSection);
+                }}
+
+                // 4. 评估结果
+                if (toolData.evaluation) {{
+                    const evalSection = document.createElement('div');
+                    const evalTitle = document.createElement('div');
+                    evalTitle.style.fontSize = '16px';
+                    evalTitle.style.fontWeight = 'bold';
+                    evalTitle.style.color = '#333';
+                    evalTitle.style.marginBottom = '8px';
+                    evalTitle.textContent = '评估结果';
+                    evalSection.appendChild(evalTitle);
+
+                    const evalDiv = document.createElement('div');
+                    evalDiv.style.background = '#fff';
+                    evalDiv.style.padding = '12px';
+                    evalDiv.style.borderRadius = '6px';
+                    evalDiv.style.borderLeft = '3px solid #2196F3';
+                    evalDiv.style.marginTop = '8px';
+
+                    const eval = toolData.evaluation;
+                    
+                    // 匹配级别
+                    if (eval.match_level) {{
+                        const matchLevelColor = eval.match_level === "完全匹配" ? "#5ba85f" : 
+                                               eval.match_level === "部分匹配" ? "#ff9800" : "#999";
+                        const matchLevelDiv = document.createElement('div');
+                        matchLevelDiv.style.fontSize = '14px';
+                        matchLevelDiv.style.marginBottom = '8px';
+                        matchLevelDiv.innerHTML = '<strong>匹配级别:</strong> <strong style="color:' + matchLevelColor + '">' + escapeHtml(eval.match_level) + '</strong>';
+                        evalDiv.appendChild(matchLevelDiv);
+                    }}
+
+                    // 核心主体
+                    if (eval.core_subject) {{
+                        const coreSubjectDiv = document.createElement('div');
+                        coreSubjectDiv.style.fontSize = '14px';
+                        coreSubjectDiv.style.marginBottom = '8px';
+                        coreSubjectDiv.innerHTML = '<strong>核心主体:</strong> ' + escapeHtml(eval.core_subject);
+                        evalDiv.appendChild(coreSubjectDiv);
+                    }}
+
+                    // 核心事件
+                    if (eval.core_event) {{
+                        const coreEventDiv = document.createElement('div');
+                        coreEventDiv.style.fontSize = '14px';
+                        coreEventDiv.style.marginBottom = '8px';
+                        coreEventDiv.innerHTML = '<strong>核心事件:</strong> ' + escapeHtml(eval.core_event);
+                        evalDiv.appendChild(coreEventDiv);
+                    }}
+
+                    // 原因说明
+                    if (eval.reason) {{
+                        const reasonDiv = document.createElement('div');
+                        reasonDiv.style.fontSize = '14px';
+                        reasonDiv.style.marginTop = '8px';
+                        reasonDiv.style.paddingTop = '8px';
+                        reasonDiv.style.borderTop = '1px solid #eee';
+                        reasonDiv.innerHTML = '<strong>原因说明:</strong> ' + escapeHtml(eval.reason);
+                        evalDiv.appendChild(reasonDiv);
+                    }}
+
+                    evalSection.appendChild(evalDiv);
+                    toolSection.appendChild(evalSection);
+                }}
+
+                // 工具统计信息
+                if (detail.tools_count !== undefined || detail.successful_tools_count !== undefined) {{
+                    const statsSection = document.createElement('div');
+                    statsSection.style.marginTop = '12px';
+                    statsSection.style.paddingTop = '12px';
+                    statsSection.style.borderTop = '1px solid #eee';
+                    statsSection.style.fontSize = '13px';
+                    statsSection.style.color = '#666';
+                    
+                    if (detail.tools_count !== undefined) {{
+                        const toolsCountSpan = document.createElement('span');
+                        toolsCountSpan.textContent = '工具总数: ' + detail.tools_count;
+                        toolsCountSpan.style.marginRight = '15px';
+                        statsSection.appendChild(toolsCountSpan);
+                    }}
+                    if (detail.successful_tools_count !== undefined) {{
+                        const successfulToolsCountSpan = document.createElement('span');
+                        successfulToolsCountSpan.textContent = '成功工具数: ' + detail.successful_tools_count;
+                        statsSection.appendChild(successfulToolsCountSpan);
+                    }}
+                    toolSection.appendChild(statsSection);
+                }}
+
+                container.appendChild(toolBlock);
+            }});
+        }}
+
+        function renderRootDetail(detail, container) {{
+            if (!detail) return;
+
+            // 1. 帖子详情
+            const postSection = document.createElement('div');
+            postSection.className = 'root-detail-section';
+            const postTitle = document.createElement('div');
+            postTitle.className = 'root-detail-title';
+            postTitle.textContent = '1. 帖子详情';
+            postSection.appendChild(postTitle);
+            
+            // 显示帖子 ID(尝试多个可能的字段名)
+            const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
+            if (postId) {{
+                const postIdDiv = document.createElement('div');
+                postIdDiv.style.marginBottom = '10px';
+                postIdDiv.style.fontSize = '14px';
+                postIdDiv.style.color = '#666';
+                const postIdLabel = document.createElement('span');
+                postIdLabel.style.fontWeight = 'bold';
+                postIdLabel.textContent = '帖子 ID: ';
+                postIdDiv.appendChild(postIdLabel);
+                const postIdValue = document.createElement('span');
+                postIdValue.textContent = postId;
+                postIdDiv.appendChild(postIdValue);
+                postSection.appendChild(postIdDiv);
+            }}
+            
+            if (detail.title) {{
+                const titleDiv = document.createElement('div');
+                titleDiv.className = 'post-title';
+                titleDiv.textContent = detail.title;
+                postSection.appendChild(titleDiv);
+            }}
+            
+            if (detail.body_text) {{
+                const bodyDiv = document.createElement('div');
+                bodyDiv.className = 'post-body';
+                bodyDiv.textContent = detail.body_text;
+                postSection.appendChild(bodyDiv);
+            }}
+
+            const stats = document.createElement('div');
+            stats.className = 'post-stats';
+            if (detail.like_count !== null && detail.like_count !== undefined) {{
+                const likeSpan = document.createElement('span');
+                likeSpan.textContent = `❤️ ${{detail.like_count}}`;
+                stats.appendChild(likeSpan);
+            }}
+            if (detail.collect_count !== null && detail.collect_count !== undefined) {{
+                const collectSpan = document.createElement('span');
+                collectSpan.textContent = `⭐ ${{detail.collect_count}}`;
+                stats.appendChild(collectSpan);
+            }}
+            postSection.appendChild(stats);
+
+            if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
+                const gallery = document.createElement('div');
+                gallery.className = 'image-gallery';
+                detail.images.forEach(imgUrl => {{
+                    const img = document.createElement('img');
+                    img.className = 'image-item';
+                    img.src = imgUrl;
+                    img.addEventListener('click', function() {{
+                        // 可以在这里添加图片查看功能
+                    }});
+                    gallery.appendChild(img);
+                }});
+                postSection.appendChild(gallery);
+            }}
+
+            container.appendChild(postSection);
+
+            // 2. 选题结果
+            const topicSection = document.createElement('div');
+            topicSection.className = 'root-detail-section';
+            const topicTitle = document.createElement('div');
+            topicTitle.className = 'root-detail-title';
+            topicTitle.textContent = '2. 选题结果';
+            topicSection.appendChild(topicTitle);
+            const topicLink = document.createElement('a');
+            topicLink.className = 'jump-link';
+            topicLink.href = `${{accountName}}_标签匹配可视化.html`;
+            topicLink.target = '_blank';
+            topicLink.textContent = '选题匹配结果';
+            topicSection.appendChild(topicLink);
+            container.appendChild(topicSection);
+            
+            // 3. 选题点拆解(选题点: list[dict])
+            const selectionPoints = detail["选题点"];
+            if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
+                const selectionSection = document.createElement('div');
+                selectionSection.className = 'root-detail-section';
+                const selectionTitle = document.createElement('div');
+                selectionTitle.className = 'root-detail-title';
+                selectionTitle.textContent = '3. 选题点拆解';
+                selectionSection.appendChild(selectionTitle);
+
+                const table = document.createElement('table');
+                table.style.width = '100%';
+                table.style.borderCollapse = 'collapse';
+                table.style.fontSize = '13px';
+                table.style.marginTop = '8px';
+
+                // 表头
+                const thead = document.createElement('thead');
+                const headerRow = document.createElement('tr');
+                const headerStyle = (th) => {{
+                    th.style.borderBottom = '1px solid #eee';
+                    th.style.padding = '6px 8px';
+                    th.style.textAlign = 'left';
+                    th.style.color = '#555';
+                    th.style.background = '#fafafa';
+                }};
+
+                const thType = document.createElement('th');
+                thType.textContent = '类型';
+                headerStyle(thType);
+                headerRow.appendChild(thType);
+                const thTopic = document.createElement('th');
+                thTopic.textContent = '选题点';
+                headerStyle(thTopic);
+                headerRow.appendChild(thTopic);
+                const thSubstantial = document.createElement('th');
+                thSubstantial.textContent = '实质';
+                headerStyle(thSubstantial);
+                headerRow.appendChild(thSubstantial);
+                const thForm = document.createElement('th');
+                thForm.textContent = '形式';
+                headerStyle(thForm);
+                headerRow.appendChild(thForm);
+                const thIntent = document.createElement('th');
+                thIntent.textContent = '意图';
+                headerStyle(thIntent);
+                headerRow.appendChild(thIntent);
+                thead.appendChild(headerRow);
+                table.appendChild(thead);
+
+                // 表体
+                const tbody = document.createElement('tbody');
+                selectionPoints.forEach((item, idx) => {{
+                    if (!item || typeof item !== "object") return;
+                    const row = document.createElement('tr');
+                    row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
+
+                    const rowCellStyle = (td) => {{
+                        td.style.padding = '6px 8px';
+                        td.style.verticalAlign = 'top';
+                        td.style.color = '#666';
+                    }};
+
+                    const toJoined = (v) => {{
+                        if (Array.isArray(v)) return v.join('、');
+                        if (v === null || v === undefined) return "";
+                        return String(v);
+                    }};
+
+                    const tdType = document.createElement('td');
+                    tdType.textContent = item["类型"] || "";
+                    rowCellStyle(tdType);
+                    row.appendChild(tdType);
+                    const tdTopic = document.createElement('td');
+                    tdTopic.textContent = item["选题点"] || "";
+                    rowCellStyle(tdTopic);
+                    row.appendChild(tdTopic);
+                    const tdSubstantial = document.createElement('td');
+                    tdSubstantial.textContent = toJoined(item["实质"]);
+                    rowCellStyle(tdSubstantial);
+                    row.appendChild(tdSubstantial);
+                    const tdForm = document.createElement('td');
+                    tdForm.textContent = toJoined(item["形式"]);
+                    rowCellStyle(tdForm);
+                    row.appendChild(tdForm);
+                    const tdIntent = document.createElement('td');
+                    tdIntent.textContent = toJoined(item["意图"]);
+                    rowCellStyle(tdIntent);
+                    row.appendChild(tdIntent);
+                    tbody.appendChild(row);
+                }});
+                table.appendChild(tbody);
+                selectionSection.appendChild(table);
+                container.appendChild(selectionSection);
+            }}
+        }}
+
+        // 更新画布宽度以适应侧边栏
+        function updateCanvasWidth() {{
+            const sidebar = document.getElementById('sidebar');
+            const appContainer = document.getElementById('app-container');
+            const sidebarResizer = document.getElementById('sidebar-resizer');
+            
+            if (sidebar.classList.contains('active')) {{
+                const sidebarWidth = sidebar.offsetWidth;
+                appContainer.style.right = sidebarWidth + 'px';
+                appContainer.style.width = `calc(100% - ${{sidebarWidth}}px)`;
+                sidebarResizer.style.right = sidebarWidth + 'px';
+                sidebarResizer.classList.add('active');
+            }} else {{
+                appContainer.style.right = '';
+                appContainer.style.width = '';
+                sidebarResizer.style.right = '';
+                sidebarResizer.classList.remove('active');
+            }}
+        }}
+
+        function closeSidebar() {{
+            const sidebar = document.getElementById('sidebar');
+            const appContainer = document.getElementById('app-container');
+            sidebar.classList.remove('active');
+            appContainer.classList.remove('sidebar-open');
+            updateCanvasWidth();
+        }}
+
+        // 侧边栏拖拽调整宽度
+        const sidebarResizer = document.getElementById('sidebar-resizer');
+        const sidebar = document.getElementById('sidebar');
+        let isSidebarResizing = false;
+        let sidebarStartX = 0;
+        let sidebarStartWidth = 0;
+
+        sidebarResizer.addEventListener('mousedown', function(e) {{
+            if (!sidebar.classList.contains('active')) return;
+            isSidebarResizing = true;
+            sidebarStartX = e.clientX;
+            sidebarStartWidth = sidebar.offsetWidth;
+            document.body.classList.add('resizing');
+            e.preventDefault();
+        }});
+
+        document.addEventListener('mousemove', function(e) {{
+            if (!isSidebarResizing) return;
+            
+            const deltaX = sidebarStartX - e.clientX; // 向左拖拽增加宽度
+            const newWidth = sidebarStartWidth + deltaX;
+            const minWidth = 250;
+            const maxWidth = window.innerWidth * 0.6;
+            
+            if (newWidth >= minWidth && newWidth <= maxWidth) {{
+                sidebar.style.width = newWidth + 'px';
+                updateCanvasWidth();
+            }}
+        }});
+
+        document.addEventListener('mouseup', function() {{
+            if (isSidebarResizing) {{
+                isSidebarResizing = false;
+                document.body.classList.remove('resizing');
+            }}
+        }});
+
+        function openPostDetailModal() {{
+            const detail = postDetailMap[currentPostKey] || null;
+            const container = document.getElementById('post-detail-modal-content');
+            container.innerHTML = '';
+            if (!detail) {{
+                const empty = document.createElement('div');
+                empty.className = 'derivation-empty';
+                empty.textContent = '暂无当前帖子的详情数据';
+                container.appendChild(empty);
+            }} else {{
+                renderPostDetailForModal(detail, container);
+            }}
+            document.getElementById('post-detail-modal').classList.add('active');
+        }}
+        function closePostDetailModal() {{
+            document.getElementById('post-detail-modal').classList.remove('active');
+        }}
+        function renderPostDetailForModal(detail, container) {{
+            if (!detail) return;
+            const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
+            if (postId) {{
+                const div = document.createElement('div');
+                div.style.marginBottom = '10px'; div.style.fontSize = '14px'; div.style.color = '#666';
+                div.innerHTML = '<span style="font-weight:bold;">帖子 ID: </span>' + escapeHtml(postId);
+                container.appendChild(div);
+            }}
+            if (detail.title) {{
+                const titleDiv = document.createElement('div');
+                titleDiv.className = 'post-title';
+                titleDiv.textContent = detail.title;
+                container.appendChild(titleDiv);
+            }}
+            if (detail.body_text) {{
+                const bodyDiv = document.createElement('div');
+                bodyDiv.className = 'post-body';
+                bodyDiv.textContent = detail.body_text;
+                container.appendChild(bodyDiv);
+            }}
+            const stats = document.createElement('div');
+            stats.className = 'post-stats';
+            if (detail.like_count != null) {{ const s = document.createElement('span'); s.textContent = '❤️ ' + detail.like_count; stats.appendChild(s); }}
+            if (detail.collect_count != null) {{ const s = document.createElement('span'); s.textContent = '⭐ ' + detail.collect_count; stats.appendChild(s); }}
+            if (stats.childNodes.length) container.appendChild(stats);
+            if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
+                const gallery = document.createElement('div');
+                gallery.className = 'image-gallery';
+                detail.images.forEach((imgUrl, idx) => {{
+                    const img = document.createElement('img');
+                    img.className = 'image-item';
+                    img.src = imgUrl;
+                    img.addEventListener('click', function() {{ openImageLightbox(detail.images, idx); }});
+                    gallery.appendChild(img);
+                }});
+                container.appendChild(gallery);
+            }}
+            const selectionPoints = detail["选题点"];
+            if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
+                const sectionTitle = document.createElement('div');
+                sectionTitle.className = 'root-detail-title';
+                sectionTitle.textContent = '帖子选题表';
+                sectionTitle.style.marginTop = '16px';
+                container.appendChild(sectionTitle);
+                const table = document.createElement('table');
+                table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.fontSize = '13px'; table.style.marginTop = '8px';
+                const thead = document.createElement('thead');
+                const headerRow = document.createElement('tr');
+                ['类型','选题点','实质','形式','意图'].forEach(txt => {{
+                    const th = document.createElement('th');
+                    th.textContent = txt;
+                    th.style.borderBottom = '1px solid #eee'; th.style.padding = '6px 8px'; th.style.textAlign = 'left'; th.style.background = '#fafafa';
+                    headerRow.appendChild(th);
+                }});
+                thead.appendChild(headerRow); table.appendChild(thead);
+                const tbody = document.createElement('tbody');
+                const toJoined = (v) => Array.isArray(v) ? v.join('、') : (v == null || v === undefined ? '' : String(v));
+                selectionPoints.forEach((item, idx) => {{
+                    if (!item || typeof item !== 'object') return;
+                    const row = document.createElement('tr');
+                    row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
+                    const cellStyle = td => {{ td.style.padding = '6px 8px'; td.style.verticalAlign = 'top'; td.style.color = '#666'; }};
+                    [item["类型"]||"", item["选题点"]||"", toJoined(item["实质"]), toJoined(item["形式"]), toJoined(item["意图"])].forEach(text => {{
+                        const td = document.createElement('td');
+                        td.textContent = text;
+                        cellStyle(td);
+                        row.appendChild(td);
+                    }});
+                    tbody.appendChild(row);
+                }});
+                table.appendChild(tbody);
+                container.appendChild(table);
+            }}
+        }}
+
+        document.getElementById('btn-pending-decode-post').addEventListener('click', openPostDetailModal);
+        document.getElementById('post-detail-modal').addEventListener('click', function(e) {{
+            if (e.target === this) closePostDetailModal();
+        }});
+
+        // --- 图集大图灯箱(左右切换)---
+        let currentLightboxImages = [];
+        let currentLightboxIndex = 0;
+
+        function openImageLightbox(images, index) {{
+            if (!images || !images.length) return;
+            currentLightboxImages = images;
+            currentLightboxIndex = (index >= 0 && index < images.length) ? index : 0;
+            updateLightboxImage();
+            document.getElementById('image-lightbox').classList.add('active');
+            document.addEventListener('keydown', lightboxKeydown);
+        }}
+        function closeImageLightbox() {{
+            document.getElementById('image-lightbox').classList.remove('active');
+            document.removeEventListener('keydown', lightboxKeydown);
+        }}
+        function updateLightboxImage() {{
+            const img = document.getElementById('lightbox-img');
+            const counter = document.getElementById('lightbox-counter');
+            if (!currentLightboxImages.length) return;
+            const idx = ((currentLightboxIndex % currentLightboxImages.length) + currentLightboxImages.length) % currentLightboxImages.length;
+            currentLightboxIndex = idx;
+            img.src = currentLightboxImages[idx];
+            counter.textContent = (idx + 1) + ' / ' + currentLightboxImages.length;
+        }}
+        function lightboxPrev() {{
+            if (!currentLightboxImages.length) return;
+            currentLightboxIndex = (currentLightboxIndex - 1 + currentLightboxImages.length) % currentLightboxImages.length;
+            updateLightboxImage();
+        }}
+        function lightboxNext() {{
+            if (!currentLightboxImages.length) return;
+            currentLightboxIndex = (currentLightboxIndex + 1) % currentLightboxImages.length;
+            updateLightboxImage();
+        }}
+        function lightboxKeydown(e) {{
+            if (e.key === 'Escape') {{ closeImageLightbox(); return; }}
+            if (e.key === 'ArrowLeft') {{ lightboxPrev(); e.preventDefault(); return; }}
+            if (e.key === 'ArrowRight') {{ lightboxNext(); e.preventDefault(); return; }}
+        }}
+        document.getElementById('image-lightbox').addEventListener('click', function(e) {{
+            if (e.target === this) closeImageLightbox();
+        }});
+        document.querySelector('#image-lightbox .lightbox-img-wrap').addEventListener('click', function(e) {{ e.stopPropagation(); }});
+
+        function switchPost(val) {{
+            currentPostKey = val;
+            parseData(val);
+            calculateLayout();
+            renderNodes();
+            renderEdges();
+            updateTransform();
+            resetView();
+            renderDerivationProgress(val);
+        }}
+
+        function closeDimensionPatternsModal() {{
+            const modal = document.getElementById('dimension-patterns-modal');
+            if (modal) modal.classList.remove('active');
+        }}
+        function showDimensionPatternsModal(postId, roundNum) {{
+            const modal = document.getElementById('dimension-patterns-modal');
+            const body = document.getElementById('dimension-patterns-modal-body');
+            const titleEl = document.getElementById('dimension-patterns-modal-title');
+            if (!modal || !body) return;
+            const doc = dimensionAnalyzeData[postId];
+            if (!doc || !doc.rounds) {{
+                if (titleEl) titleEl.textContent = '维度 patterns';
+                body.innerHTML = '<p style="color:#64748b;">暂无整体推导维度分析数据</p>';
+                modal.classList.add('active');
+                return;
+            }}
+            const r = doc.rounds.find(function(x) {{ return x.round === roundNum; }});
+            const label = (roundNum === 0) ? '选起点' : ('第' + roundNum + '轮');
+            if (titleEl) titleEl.textContent = '维度patterns · ' + label;
+            if (!r || !r.patterns || !r.patterns.length) {{
+                body.innerHTML = '<p style="color:#64748b;">该轮暂无 patterns 数据</p>';
+                modal.classList.add('active');
+                return;
+            }}
+            let parts = [];
+            parts.push('<div class="dimension-patterns-title">共 ' + r.patterns.length + ' 条 pattern(is_derived 已高亮)</div>');
+            r.patterns.forEach(function(pat) {{
+                const items = pat.items || [];
+                const segs = items.map(function(it) {{
+                    const nm = escapeHtml(it.name || '');
+                    return it.is_derived ? '<span class="pattern-item-derived">' + nm + '</span>' : nm;
+                }});
+                parts.push('<div class="pattern-line">' + segs.join('<span class="pattern-plus"> + </span>') + '</div>');
+            }});
+            body.innerHTML = parts.join('');
+            modal.classList.add('active');
+        }}
+        
+        // 渲染推导进度
+        function renderDerivationProgress(fileKey) {{
+            const container = document.getElementById('derivation-progress-content');
+            // 从文件名中提取文件ID(去掉.json扩展名)
+            const fileId = fileKey.replace(/\.json$/, '');
+            const rounds = derivationData[fileId] || derivationData[fileKey];
+            
+            if (!rounds || !Array.isArray(rounds) || rounds.length === 0) {{
+                container.innerHTML = '<div class="derivation-empty">暂无推导进度数据</div>';
+                return;
+            }}
+            
+            // 收集所有已推导成功的选题点名称(用于判断是否为之前已点亮)
+            const allDerivedNames = new Set();
+            rounds.forEach(round => {{
+                const derived = round.推导成功的选题点 || [];
+                derived.forEach(p => {{
+                    if (p.name) allDerivedNames.add(p.name);
+                }});
+            }});
+            
+            let html = '<div class="derivation-timeline">';
+            
+            for (let ri = 0; ri < rounds.length; ri++) {{
+                const round = rounds[ri];
+                // 推导结果数据中轮次从 1 开始(第一轮=1);轮次 0 表示选起点
+                const roundLabel = (round.轮次 === 0) ? "选起点" : ("第" + round.轮次 + "轮");
+                const derived = round.推导成功的选题点 || [];
+                const underived = round.未推导成功的选题点 || [];
+                
+                // 获取当前轮次新推导成功的选题点名称
+                const newInRoundRaw = round.本次新推导成功的选题点 || [];
+                const newInRoundNames = new Set(newInRoundRaw.map(p => p.name));
+                
+                // 如果是第一轮(轮次0),所有推导成功的都是新点亮的
+                if (ri === 0) {{
+                    derived.forEach(p => {{ if (p.name) newInRoundNames.add(p.name); }});
+                }} else if (newInRoundNames.size === 0) {{
+                    // 如果没有本次新推导成功的,则找出在当前轮次首次出现的
+                    const prevDerivedNames = new Set();
+                    for (let i = 0; i < ri; i++) {{
+                        const prevDerived = rounds[i].推导成功的选题点 || [];
+                        prevDerived.forEach(p => {{ if (p.name) prevDerivedNames.add(p.name); }});
+                    }}
+                    derived.forEach(p => {{
+                        if (p.name && !prevDerivedNames.has(p.name)) {{
+                            newInRoundNames.add(p.name);
+                        }}
+                    }});
+                }}
+                
+                // 收集所有root_source
+                const allRootSources = new Set();
+                derived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
+                underived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
+                
+                const pointsByRoot = {{}};
+                const dimDataByRoot = {{}};
+                
+                // 处理推导成功的选题点
+                derived.forEach(p => {{
+                    if (!p.root_source) return;
+                    if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
+                    if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
+                    const dim = p.dimension || "实质";
+                    // 判断颜色:当前轮次新点亮的为黄色,之前已点亮的为绿色
+                    const cls = newInRoundNames.has(p.name) ? "derivation-topic-new" : "derivation-topic-derived";
+                    dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: cls, derivation_type: p.derivation_type || "", is_fully_derived: p.is_fully_derived }});
+                }});
+                
+                // 处理未推导成功的选题点(黑色)
+                underived.forEach(p => {{
+                    if (!p.root_source) return;
+                    if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
+                    if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
+                    const dim = p.dimension || "实质";
+                    dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: "derivation-topic-underedived", derivation_type: p.derivation_type || "" }});
+                }});
+                
+                // 按point类型排序
+                const pointOrder = {{ "灵感点": 0, "目的点": 1, "关键点": 2 }};
+                let rootSourceList = Array.from(allRootSources).sort((a, b) => {{
+                    const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
+                    const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
+                    if (pa !== pb) return pa - pb;
+                    return (a || "").localeCompare(b || "");
+                }});
+                // 整体推导结果里若未写入选题点(或解析不到分词),用待解构帖子详情中的选题表回填,避免表格无行
+                if (rootSourceList.length === 0) {{
+                    const pd = postDetailMap[fileKey];
+                    const rows = (pd && Array.isArray(pd["选题点"])) ? pd["选题点"] : [];
+                    for (let ri = 0; ri < rows.length; ri++) {{
+                        const row = rows[ri];
+                        if (!row || typeof row !== "object") continue;
+                        const rs = String(row["选题点"] || "").trim();
+                        if (!rs) continue;
+                        const pt = row["类型"] || "";
+                        pointsByRoot[rs] = pt;
+                        if (!dimDataByRoot[rs]) dimDataByRoot[rs] = {{ 实质: [], 形式: [], 意图: [] }};
+                        ["实质", "形式", "意图"].forEach(function(dim) {{
+                            const arr = row[dim];
+                            const list = Array.isArray(arr) ? arr : [];
+                            list.forEach(function(nm) {{
+                                const s = (typeof nm === "string") ? nm.trim() : String(nm || "").trim();
+                                if (!s) return;
+                                dimDataByRoot[rs][dim].push({{
+                                    name: s,
+                                    cls: "derivation-topic-baseline",
+                                    derivation_type: "",
+                                    is_fully_derived: undefined
+                                }});
+                            }});
+                        }});
+                    }}
+                    rootSourceList = Object.keys(pointsByRoot).sort((a, b) => {{
+                        const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
+                        const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
+                        if (pa !== pb) return pa - pb;
+                        return (a || "").localeCompare(b || "");
+                    }});
+                }}
+                
+                html += '<div class="derivation-round-block">';
+                html += '<div class="derivation-round-title">' + escapeHtml(roundLabel) + '</div>';
+                html += '<table class="derivation-table"><thead><tr>';
+                html += '<th class="col-type">类型</th><th class="col-source">选题点</th>';
+                html += '<th class="col-dim">实质</th><th class="col-dim">形式</th><th class="col-dim">意图</th>';
+                html += '</tr></thead><tbody>';
+                
+                rootSourceList.forEach(rs => {{
+                    const point = pointsByRoot[rs] || "";
+                    const dimData = dimDataByRoot[rs] || {{ 实质: [], 形式: [], 意图: [] }};
+                    html += '<tr>';
+                    html += '<td class="col-type">' + escapeHtml(point) + '</td>';
+                    html += '<td class="col-source">' + escapeHtml(rs) + '</td>';
+                    for (const dim of ["实质", "形式", "意图"]) {{
+                        const items = (dimData[dim] || []).sort((a, b) => (a.name || "").localeCompare(b.name || ""));
+                        html += '<td class="col-dim">';
+                        items.forEach(it => {{
+                            const searchIcon = (it.derivation_type === "search") ? ' <span class="derivation-topic-search-icon" title="外部寻找">🔍</span>' : '';
+                            const toolIcon = (it.derivation_type === "tool") ? ' <span class="derivation-topic-tool-icon" title="工具调用">🔧</span>' : '';
+                            // 只有推导成功的选题点可以点击(黄色和绿色),未推导成功的(黑色)不可点击
+                            const isClickable = it.cls === "derivation-topic-derived" || it.cls === "derivation-topic-new";
+                            const dataAttr = isClickable ? ' data-topic-name="' + (it.name || "").replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '"' : '';
+                            const notFullyClass = (it.is_fully_derived === false) ? ' derivation-topic-not-fully-derived' : '';
+                            html += '<span class="derivation-topic-item ' + it.cls + notFullyClass + '"' + dataAttr + '>' + escapeHtml(it.name) + searchIcon + toolIcon + '</span>';
+                        }});
+                        if (items.length === 0) html += '<span style="color:#999;">-</span>';
+                        html += '</td>';
+                    }}
+                    html += '</tr>';
+                }});
+                if (rootSourceList.length === 0) {{
+                    html += '<tr><td colspan="5" style="color:#94a3b8;text-align:center;padding:12px;">暂无选题表数据(请检查整体推导结果与 input 解构内容)</td></tr>';
+                }}
+                html += '</tbody></table>';
+                const _dimDoc = (dimensionAnalyzeData && dimensionAnalyzeData[fileId]) ? dimensionAnalyzeData[fileId] : null;
+                const _dimRounds = (_dimDoc && _dimDoc.rounds) ? _dimDoc.rounds : [];
+                const _dimForRound = _dimRounds.find(function(dr) {{ return dr.round === round.轮次; }}) || null;
+                html += '<div class="derivation-dimension-extra">';
+                if (_dimForRound) {{
+                    const _dd = (_dimForRound.derived_dims || []).map(function(d) {{
+                        if (d && typeof d === 'object') {{
+                            const tn = d.tree_node_name || '';
+                            const dim = d.dimension || '';
+                            const mp = d.matched_point || '';
+                            let s = tn;
+                            if (dim) {{
+                                s += '->' + dim;
+                            }}
+                            if (mp) {{
+                                s += '(' + mp + ')';
+                            }}
+                            return escapeHtml(s);
+                        }}
+                        return escapeHtml(String(d));
+                    }}).join('、');
+                    const _ud = (_dimForRound.underived_dims || []).map(function(d) {{
+                        if (d && typeof d === 'object') {{
+                            const tn = d.tree_node_name || '';
+                            const dim = d.dimension || '';
+                            const mp = d.matched_point || '';
+                            let s = tn;
+                            if (dim) {{
+                                s += '->' + dim;
+                            }}
+                            if (mp) {{
+                                s += '(' + mp + ')';
+                            }}
+                            return escapeHtml(s);
+                        }}
+                        return escapeHtml(String(d));
+                    }}).join('、');
+                    html += '<div class="derivation-dim-line"><span class="derivation-dim-label">已推导维度</span> <span class="derivation-dim-val dim-derived">' + (_dd || '—') + '</span></div>';
+                    html += '<div class="derivation-dim-line"><span class="derivation-dim-label">未推导维度</span> <span class="derivation-dim-val dim-underived">' + (_ud || '—') + '</span></div>';
+                }} else {{
+                    html += '<div class="derivation-dim-line dim-muted">暂无与本轮对应的整体推导维度分析</div>';
+                }}
+                const _pidAttr = String(fileId).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
+                html += '<button type="button" class="btn-dimension-patterns" data-post-id="' + _pidAttr + '" data-round="' + String(round.轮次) + '">维度patterns</button>';
+                html += '</div>';
+                html += '</div>';
+            }}
+            html += '</div>';
+            container.innerHTML = html;
+            
+            container.querySelectorAll('.btn-dimension-patterns').forEach(function(el) {{
+                el.addEventListener('click', function() {{
+                    const pid = this.getAttribute('data-post-id');
+                    const rn = parseInt(this.getAttribute('data-round'), 10);
+                    showDimensionPatternsModal(pid, rn);
+                }});
+            }});
+            // 添加点击事件:点击已推导成功的选题点,在画布中定位(只关联node_list中的节点)
+            container.querySelectorAll('.derivation-topic-item[data-topic-name]').forEach(el => {{
+                el.addEventListener('click', function() {{
+                    const topicName = this.getAttribute('data-topic-name');
+                    if (topicName) {{
+                        focusOnNodeByName(topicName);
+                    }}
+                }});
+            }});
+        }}
+        
+        // 根据选题点名称定位节点(只关联node_list中的节点,不关联all_used_tree_nodes)
+        function focusOnNodeByName(topicName) {{
+            // 只在node_list中查找,排除level -1的节点(all_used_tree_nodes)
+            let node = null;
+            for (let level in flatData.nodesByLevel) {{
+                const levelNum = parseInt(level);
+                if (levelNum !== -1) {{  // 排除level -1的节点
+                    const found = flatData.nodesByLevel[levelNum].find(n => n.name === topicName);
+                    if (found) {{
+                        node = found;
+                        break;
+                    }}
+                }}
+            }}
+            
+            if (node) {{
+                focusOnNode(node);
+                highlightDirectSources(node);
+            }} else {{
+                // 如果在当前数据中找不到,尝试搜索
+                const searchInput = document.getElementById('search-input');
+                if (searchInput) {{
+                    searchInput.value = topicName;
+                    // 再次搜索,排除level -1
+                    for (let level in flatData.nodesByLevel) {{
+                        const levelNum = parseInt(level);
+                        if (levelNum !== -1) {{
+                            const match = flatData.nodesByLevel[levelNum].find(n => n.name.toLowerCase().includes(topicName.toLowerCase()));
+                            if (match) {{
+                                focusOnNode(match);
+                                highlightDirectSources(match);
+                                return;
+                            }}
+                        }}
+                    }}
+                }}
+            }}
+        }}
+        
+        // 切换推导进度显示
+        function toggleDerivationProgress() {{
+            const section = document.getElementById('derivation-progress-section');
+            const appContainer = document.getElementById('app-container');
+            const btn = document.querySelector('.derivation-progress-toggle');
+            if (section.classList.contains('active')) {{
+                section.classList.remove('active');
+                appContainer.classList.remove('derivation-open');
+                appContainer.style.bottom = '';
+                btn.textContent = '展开';
+            }} else {{
+                section.classList.add('active');
+                appContainer.classList.add('derivation-open');
+                // 设置画布底部边距为推导进度面板的高度
+                const sectionHeight = section.offsetHeight;
+                appContainer.style.bottom = sectionHeight + 'px';
+                btn.textContent = '收起';
+            }}
+        }}
+
+        // 推导进度面板高度拖拽调整
+        (function() {{
+            const resizer = document.getElementById('derivation-resizer');
+            const section = document.getElementById('derivation-progress-section');
+            const appContainer = document.getElementById('app-container');
+            let isResizing = false;
+            let startY = 0;
+            let startHeight = 0;
+
+            resizer.addEventListener('mousedown', function(e) {{
+                isResizing = true;
+                startY = e.clientY;
+                startHeight = section.offsetHeight;
+                resizer.classList.add('active');
+                document.body.classList.add('resizing');
+                section.style.transition = 'none';
+                appContainer.style.transition = 'none';
+                e.preventDefault();
+            }});
+
+            document.addEventListener('mousemove', function(e) {{
+                if (!isResizing) return;
+                const delta = startY - e.clientY;
+                const minH = 200;
+                const maxH = Math.floor(window.innerHeight * 0.8);
+                const newHeight = Math.min(maxH, Math.max(minH, startHeight + delta));
+                section.style.height = newHeight + 'px';
+                if (section.classList.contains('active')) {{
+                    appContainer.style.bottom = newHeight + 'px';
+                }}
+            }});
+
+            document.addEventListener('mouseup', function() {{
+                if (isResizing) {{
+                    isResizing = false;
+                    resizer.classList.remove('active');
+                    document.body.classList.remove('resizing');
+                    section.style.transition = '';
+                    appContainer.style.transition = '';
+                }}
+            }});
+        }})();
+
+        // 初始化
+        parseData(currentPostKey);
+        calculateLayout();
+        renderNodes();
+        renderEdges();
+        updateTransform();
+        renderDerivationProgress(currentPostKey);
+    </script>
+</body>
+</html>
+'''
+    with open(output_path, 'w', encoding='utf-8') as f:
+        f.write(html_content)
+    print(f"最终结果可视化已生成: {output_path}")
+
+def main(account_name) -> None:
+    name = account_name
+
+    base = Path(__file__).resolve().parent
+    output_base = base / "output" / name
+    data_dir = output_base / "整体推导路径可视化"
+
+    if not data_dir.exists():
+        print(f"错误: 找不到数据目录 {data_dir}")
+        return
+
+    json_files = sorted(f for f in os.listdir(data_dir) if f.endswith(".json"))
+    if not json_files:
+        print(f"在目录 {data_dir} 中未找到 .json 文件。")
+        return
+
+    data_map: Dict[str, dict] = {}
+    print("\n" + "=" * 50)
+    print(f"账号: {name}")
+    print(f"数据目录: {data_dir}")
+    print(f"正在读取 {len(json_files)} 个帖子数据...")
+
+    for filename in json_files:
+        json_path = data_dir / filename
+        try:
+            with open(json_path, "r", encoding="utf-8") as f:
+                data_map[filename] = json.load(f)
+            print(f"  -> 已读取: {filename}")
+        except Exception as e:
+            print(f"     [错误] 读取 {filename} 时出错: {e}")
+
+    if not data_map:
+        print("没有成功读取到任何数据。")
+        return
+
+    post_detail_map: Dict[str, dict] = {}
+    for filename in data_map.keys():
+        post_id = Path(filename).stem
+        try:
+            detail = load_post_detail_for_visualization(name, post_id)
+            if detail is not None:
+                post_detail_map[filename] = detail
+        except Exception as e:
+            print(f"     [警告] 加载帖子详情 {filename} 时出错: {e}")
+
+    derivation_dir = output_base / "整体推导结果"
+    derivation_data: Dict[str, list] = {}
+    if derivation_dir.exists():
+        print("\n正在读取推导进度数据...")
+        for json_file in derivation_dir.glob("*.json"):
+            try:
+                with open(json_file, "r", encoding="utf-8") as f:
+                    derivation_data[json_file.stem] = json.load(f)
+                print(f"  -> 已加载推导进度: {json_file.name}")
+            except Exception as e:
+                print(f"     [警告] 读取推导进度 {json_file.name} 时出错: {e}")
+    else:
+        print(f"     [提示] 推导结果目录不存在: {derivation_dir}")
+
+    dimension_analyze_dir = output_base / "整体推导维度分析"
+    dimension_analyze_map: Dict[str, dict] = {}
+    if dimension_analyze_dir.exists():
+        print("\n正在读取整体推导维度分析...")
+        suf = "_pattern_dimension_analyze"
+        for json_file in sorted(dimension_analyze_dir.glob(f"*{suf}.json")):
+            stem = json_file.stem
+            if not stem.endswith(suf):
+                continue
+            post_id_key = stem[: -len(suf)]
+            try:
+                with open(json_file, "r", encoding="utf-8") as f:
+                    dimension_analyze_map[post_id_key] = json.load(f)
+                print(f"  -> 已加载维度分析: {json_file.name}")
+            except Exception as e:
+                print(f"     [警告] 读取维度分析 {json_file.name} 时出错: {e}")
+    else:
+        print(f"     [提示] 整体推导维度分析目录不存在: {dimension_analyze_dir}")
+
+    output_base.mkdir(parents=True, exist_ok=True)
+    ts = datetime.now().strftime("%Y%m%d%H%M%S")
+    output_path = output_base / f"{name}_how推导可视化_{ts}.html"
+
+    generate_all_in_one_visualization(
+        data_map,
+        str(output_path),
+        name,
+        derivation_data=derivation_data,
+        post_detail_map=post_detail_map,
+        dimension_analyze_map=dimension_analyze_map,
+    )
+
+    print("\n" + "=" * 50)
+    print("处理完成!")
+    print(f"输出文件: {output_path}")
+    print("=" * 50 + "\n")
+
+
+if __name__ == "__main__":
+    main(account_name="家有大志")