Browse Source

how agent 部分推导成功

liuzhiheng 20 hours ago
parent
commit
f6eed446ee

+ 85 - 40
examples_how/overall_derivation/derivation_main.md

@@ -24,6 +24,9 @@ $system$
 | 禁止自由联想 | 所有推导理由必须引用工具返回的具体数据,**禁止**使用大模型自身世界知识推断 |
 | 禁止直接搜索 | **禁止**主 agent 直接调用 `search_posts`,信息搜索只能通过 `agent(agent_type="derivation_search")` 执行 |
 | 路径原子化拆分 | 方法一、方法三每个节点单独一条路径;方法二每个 pattern 单独一条路径;**禁止**合并独立推导逻辑 |
+| 匹配分数阈值 | `matched_score >= 0.78` 为**完全推导成功**,`matched_score < 0.78` 为**部分推导成功**;`derived_success_count` 只统计完全推导成功的选题点 |
+| 多路径择优 | 同一选题点在同一轮中若被多条路径匹配到,取 `matched_score` 最高的路径作为该轮输出 |
+| 部分推导可继续 | 部分推导成功的选题点可在后续轮次继续推导,若出现更高分路径则替换;完全推导成功的选题点不再重复推导 |
 
 ---
 
@@ -38,7 +41,20 @@ $system$
 - `find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio`、`find_pattern` 三个工具的返回数据中已内置各节点/pattern 元素与帖子选题点的匹配结果,**「帖子选题点匹配」字段只列出匹配成功的帖子选题点**,主 agent 直接读取该字段判断是否匹配成功。
 - 信息搜索(方法四)产出的候选点通过单独调用 `point_match` 工具进行匹配判断。
 
-匹配成功的选题点加入已推导成功集合。每轮推导与匹配判断完成后,输出该轮的**推导日志**与**评估日志**到指定目录。
+**匹配分数阈值机制**:
+- 匹配分数阈值为 **0.78**。
+- `matched_score >= 0.78`:该选题点视为**完全推导成功**,加入 `derived_success_set`。
+- `matched_score < 0.78`(但匹配成功,即工具返回了匹配结果):该选题点视为**部分推导成功**,加入 `partial_derived_set`,不计入 `derived_success_count`,不参与提前终止条件中 85% 的计算。
+- 部分推导成功的选题点仍需加入后续轮次工具调用的 `derived_items` 参数中(与完全推导成功的选题点一样)。
+- 部分推导成功的选题点可在后续轮次中继续推导;若后续轮次出现匹配分数更高的推导路径,则更新为更高分路径;若后续轮次中匹配分数达到 0.78 以上,则升级为完全推导成功,从 `partial_derived_set` 移入 `derived_success_set`。
+- **完全推导成功的选题点不再重复推导**。
+
+**多输出路径择优机制**:
+- 每一轮推导中,可能使用了多个推导方法,每个方法可能调用多次工具,每次工具返回的可能是多条数据。因此,同一个帖子选题点可能在同一轮中被多条推导路径匹配到。
+- 当同一帖子选题点存在多条匹配路径时,取 `matched_score` **最高**的路径作为该选题点在本轮的输出路径。
+- 评估日志中只记录择优后的结果(即每个帖子选题点最多一条记录)。
+
+匹配成功的选题点根据匹配分数分别加入完全推导成功集合或部分推导成功集合。每轮推导与匹配判断完成后,输出该轮的**推导日志**与**评估日志**到指定目录。
 
 ---
 
@@ -62,19 +78,20 @@ $system$
 
 ### `derived_items` 参数说明(必须严格遵守)
 
-`derived_items` 表示**已确认匹配成功的帖子选题点集合**,其唯一来源是:历轮工具返回的「帖子选题点匹配」字段(或 `point_match` 工具返回结果)中匹配成功的帖子选题点名称(`matched_post_point`)。
+`derived_items` 表示**已确认匹配成功的帖子选题点集合**(包含完全推导成功和部分推导成功的选题点),其唯一来源是:历轮工具返回的「帖子选题点匹配」字段(或 `point_match` 工具返回结果)中匹配成功的帖子选题点名称(`matched_post_point`)。
 
 **核心规则**:
 - **首轮推导时,`derived_items` 必须为 `[]`(空数组)**,不得填入任何内容。
 - `find_tree_constant_nodes` 返回的常量节点、人设树中的任何节点名称,均**不能**用于填充 `derived_items`——这些节点是推导的候选输出,不是已确认的帖子选题点。
 - `derived_items` 非空时每项格式**严格**为两个字段:`topic`(帖子选题点名称)+ `source_node`(推导该点时对应的人设树节点名称)。
 - **禁止使用 `name`、`node`、`id` 或任何其他字段名**——工具不识别这些字段,传入会导致计算结果错误。
+- **`derived_items` 包含完全推导成功和部分推导成功的所有选题点**,不因匹配分数低于阈值而排除。两者都需要参与条件概率计算,以提高后续推导的准确性。
 
 示例:
 - 首轮(无任何已推导点):`[]`
-- 首轮后已有匹配结果时:`[{"topic": "分享", "source_node": "分享"}, {"topic": "叙事结构", "source_node": "叙事结构"}]`
+- 首轮后已有匹配结果时(含完全推导和部分推导的选题点):`[{"topic": "分享", "source_node": "分享"}, {"topic": "叙事结构", "source_node": "叙事结构"}]`
 
-主 agent 在每轮完成匹配判断并更新集合后,后续轮次可从集整理出 `derived_items`,首轮固定传 `[]`。
+主 agent 在每轮完成匹配判断并更新集合后,后续轮次可从 `derived_success_set` 和 `partial_derived_set` 的并集整理出 `derived_items`,首轮固定传 `[]`。
 
 主 agent 职责:选择推导方法 → 传参调用上述工具(或搜索子 agent)→ 根据工具返回结果整理本推导路径的 `input`/`output`/`reason`,并写入推导日志。
 
@@ -87,7 +104,8 @@ $system$
 在开始第一轮推导之前,执行以下一次性初始化操作:
 
 1. **初始化状态变量**(仅存在于 agent 工作记忆中):
-   - `derived_success_set = []`(已推导成功选题点集合,初始为空)
+   - `derived_success_set = []`(完全推导成功选题点集合,匹配分数 >= 0.78,初始为空)
+   - `partial_derived_set = []`(部分推导成功选题点集合,匹配分数 < 0.78,初始为空;每项记录 `matched_post_point`、`matched_score`、`source_node`)
    - `failed_points = []`(已失败选题点列表,初始为空)
    - `consecutive_zero_rounds = 0`(连续零匹配轮数,初始为 0;**注意是连续,不是累计**)
 
@@ -98,10 +116,12 @@ $system$
 每一轮推导按以下四个步骤顺序执行,**不可跳步、不可乱序**:
 
 **步骤一:策略决策**
-执行推导前,先明确本轮方向:当前处于广召回阶段还是收敛阶段?上一轮评估结果如何,哪些方向值得延伸或放弃?本轮应选用哪些方法与参数组合?同时检查 `failed_points` 列表,确保本轮不重复已失败的推导方向。
+执行推导前,先明确本轮方向:当前处于广召回阶段还是收敛阶段?上一轮评估结果如何,哪些方向值得延伸或放弃?本轮应选用哪些方法与参数组合?同时检查 `failed_points` 列表,确保本轮不重复已失败的推导方向。此外,检查 `partial_derived_set` 中是否有部分推导成功的选题点尚未达到完全推导阈值,本轮可尝试为其寻找更高分的推导路径。
 
 **步骤二:执行推导**
-以**已推导成功的选题点集合**为基础(首轮为空),按步骤一确定的方法与参数,分条执行推导路径。调用 `find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio`、`find_pattern` 时须传入 `post_id`。**总轮次上限为 15 轮**。
+以**已推导成功的选题点集合**(`derived_success_set` 与 `partial_derived_set` 的并集)为基础(首轮为空),按步骤一确定的方法与参数,分条执行推导路径。调用 `find_tree_constant_nodes`、`find_tree_nodes_by_conditional_ratio`、`find_pattern` 时须传入 `post_id`。**总轮次上限为 15 轮**。
+
+**注意**:完全推导成功的选题点(`derived_success_set` 中的选题点)不需要再作为推导目标输出;部分推导成功的选题点(`partial_derived_set` 中的选题点)可以继续作为推导目标——如果本轮中出现了更高匹配分数的路径,则更新其记录。
 
 **推导路径的粒度原则(强制拆分规则)**:一条推导路径表示"用最小输入信息推导出哪些选题点"。**必须严格执行以下拆分规则**: 
 1. **方法一(人设常量)**:工具返回的每一个常量节点必须**单独**成为一条推导路径。例如工具返回了节点 A 和节点 B,必须拆分为两条路径:路径1 input=[A] output=[A],路径2 input=[B] output=[B]。**禁止**将多个独立常量节点合并到同一条路径中。
@@ -110,23 +130,30 @@ $system$
 4. 方法四允许同一次搜索的多个候选点合并在一条路径中
 5. **通用判断标准**:路径中每一个输入对产出该路径所有输出点都是必要的。如果去掉某个输入,剩余输入仍能独立推导出部分输出,则说明需要拆分。
 
-**步骤三:匹配判断**
+**步骤三:匹配判断与多路径择优**
 推导完成后,逐一对本轮所有推导输出点进行匹配判断:
 
 - **方法一/二/三产出的点**:直接读取工具返回数据中的「帖子选题点匹配」字段。该字段**只列出匹配成功的帖子选题点**:
-  - 若某推导输出点(节点名称或 pattern 元素名称)出现在该字段中,则 `is_matched=true`;`matched_post_point` 填写该字段中对应的**帖子选题点名称**(括号前部分),`matched_reason` 填写匹配分数(括号内数值)。
-  - 推导输出点名称与帖子选题点名称**可能不同**(如输出点 `趣味道具`,匹配到帖子选题点 `夸张道具(0.7831)`,则 `matched_post_point="夸张道具"`);也可能相同(如输出点 `分享`,匹配到 `分享(1.0)`)。
+  - 若某推导输出点(节点名称或 pattern 元素名称)出现在该字段中,则 `is_matched=true`;`matched_post_point` 填写该字段中对应的**帖子选题点名称**(括号前部分),`matched_score` 填写匹配分数(括号内数值)。
+  - 推导输出点名称与帖子选题点名称**可能不同**(如输出点 `趣味道具`,匹配到帖子选题点 `夸张道具(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`,也不得联想补充任何工具未返回的词汇进行匹配。
 - **方法四(信息搜索)产出的点**:搜索在步骤二中由 `derivation_search` 子 agent 执行,候选点收集完毕后,在步骤三中调用 `point_match` 工具(仅调用一次),传入候选点列表、`account_name`、`post_id`,依据返回结果判断各点是否匹配成功。
 
+**多路径择优**:完成所有匹配判断后,检查本轮是否有同一个 `matched_post_point` 被多条路径匹配到的情况。若存在,取 `matched_score` 最高的路径作为该选题点本轮的输出。评估日志中只记录择优后的结果。
+
+**部分推导升级检查**:对于 `partial_derived_set` 中已有的选题点,若本轮出现了更高 `matched_score` 的路径,则更新该选题点的记录(包括分数和对应的推导路径信息)。若更新后 `matched_score >= 0.78`,则将其从 `partial_derived_set` 移入 `derived_success_set`。
+
 **步骤四:写入日志 + 更新集合(每轮必须执行,不可省略)**
 - 将本轮推导路径按**推导日志**格式写入 `output/{account_name}/推导日志/{帖子ID}/{log_id}/{轮次}_推导.json`
 - 将步骤三的匹配判断结果按**评估日志**格式写入 `output/{account_name}/推导日志/{帖子ID}/{log_id}/{轮次}_评估.json`
 - 直接调用工具写入文件即可,不需要创建目录
-- 根据匹配结果更新 `derived_success_set`:将 `is_matched=true` 的 `matched_post_point`(帖子选题点名称)加入集合(详见「已推导成功选题点的更新规则」)
+- 根据匹配结果更新集合:
+  - `is_matched=true` 且 `matched_score >= 0.78`:将 `matched_post_point` 加入 `derived_success_set`(完全推导成功)
+  - `is_matched=true` 且 `matched_score < 0.78`:若该选题点不在 `derived_success_set` 中,将其加入 `partial_derived_set`(部分推导成功),或更新 `partial_derived_set` 中已有记录的分数(取更高分)
+  - `is_matched=false`:将推导选题点记入 `failed_points`
 - 根据匹配结果更新 `failed_points`:将 `is_matched=false` 的推导选题点记录在案,后续推导中**原则上不得再次输出**该名称;若确有必要重新推导,须换用完全不同的推导方法与输入组合
-- **更新 `consecutive_zero_rounds`**:若本轮匹配率 = 0%,则 `consecutive_zero_rounds += 1`;否则重置为 `0`
+- **更新 `consecutive_zero_rounds`**:若本轮匹配率 = 0%(无任何 `is_matched=true` 的记录,包括完全推导和部分推导),则 `consecutive_zero_rounds += 1`;否则重置为 `0`
 
 > **日志输出要求(强制)**:上述两个 JSON 文件是每轮唯一合法的输出载体。**禁止**以 markdown 文件、汇总报告或任何其他格式替代按轮次写入的 JSON 日志文件。每轮的推导日志和评估日志必须在该轮匹配判断完成后**立即写入**,不得延迟到任务结束后统一输出。
 
@@ -146,7 +173,8 @@ $system$
   - 叙事结构  概率=0.6949  全局常量  帖子选题点匹配=无
   ```
   - **推导路径的 `output`**:填写工具返回的**人设树节点名称**(如 `分享`)。
-  - **匹配判断**:读取「帖子选题点匹配」字段——若有值(如 `分享(1.0)`),则 `is_matched=true`,评估日志中 `matched_post_point` 填写括号前的帖子选题点名称(如 `分享`),`matched_reason` 填写匹配分数(如 `1.0`);若字段值为「无」,则 `is_matched=false`。
+  - **匹配判断**:读取「帖子选题点匹配」字段——若有值(如 `分享(1.0)`),则 `is_matched=true`,评估日志中 `matched_post_point` 填写括号前的帖子选题点名称(如 `分享`),`matched_score` 填写匹配分数数值(如 `1.0`),`matched_reason` 填写匹配分数描述(如 `匹配分数=1.0`);若字段值为「无」,则 `is_matched=false`。
+  - **匹配分数阈值判断**:`matched_score >= 0.78` 为完全推导成功,`matched_score < 0.78` 为部分推导成功。
   - 注意:节点名称与其匹配到的帖子选题点名称**可能不同**,`output` 始终是节点名称,`matched_post_point` 始终是帖子选题点名称。
 - 模拟样例(基于上方工具返回数据,工具返回了两个匹配成功的节点 `分享` 和 `叙事结构`,**必须拆分为两条独立路径**):
 ```json
@@ -189,8 +217,8 @@ $system$
 这违反原子化规则,因为"分享"和"叙事结构"的推导彼此独立,去掉任一输入不影响另一个输出。
 
 对应评估日志:
-- `derivation_output_point="分享"`,`is_matched=true`,`matched_post_point="分享"`,`matched_reason="匹配分数=1.0"`
-- `derivation_output_point="叙事结构"`,`is_matched=true`,`matched_post_point="叙事结构"`,`matched_reason="匹配分数=1.0"`
+- `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=1.0`,`matched_reason="匹配分数=1.0"`(完全推导成功,`matched_score >= 0.78`)
 
 #### 方法二:账号 pattern 复用
 - **适用场景**:通过 pattern 数据发现选题点共现关系;任何轮次都可调用。
@@ -200,8 +228,9 @@ $system$
   - 图片文字+状态与描绘+补充说明式  条件概率=0.578947 帖子选题点匹配=图片文字→图片文字(1.0)、补充说明式→补充说明式(1.0)
   - 动物形象+搞笑风格+结构模式  条件概率=0.1356  帖子选题点匹配=无
   ```
-  - **推导路径的 `output`**:填写工具返回的 **pattern 中尚未推导成功的元素名称**(如 `日常物品`),若某 pattern 中所有元素均已推导成功,则跳过该 pattern,不生成推导路径。
-  - **匹配判断**:读取「帖子选题点匹配」字段——格式为 `元素名称→帖子选题点名称(分数)`;若某输出点的元素名称出现在该字段中,则 `is_matched=true`,评估日志中 `matched_post_point` 填写箭头后的帖子选题点名称(如 `日常物品`),`matched_reason` 填写匹配分数;若字段值为「无」或该元素未出现,则 `is_matched=false`。
+  - **推导路径的 `output`**:填写工具返回的 **pattern 中尚未完全推导成功的元素名称**(如 `日常物品`),若某 pattern 中所有元素均已完全推导成功,则跳过该 pattern,不生成推导路径。注意:部分推导成功的元素仍可作为输出,以争取更高匹配分数。
+  - **匹配判断**:读取「帖子选题点匹配」字段——格式为 `元素名称→帖子选题点名称(分数)`;若某输出点的元素名称出现在该字段中,则 `is_matched=true`,评估日志中 `matched_post_point` 填写箭头后的帖子选题点名称(如 `日常物品`),`matched_score` 填写匹配分数数值,`matched_reason` 填写匹配分数描述;若字段值为「无」或该元素未出现,则 `is_matched=false`。
+  - **匹配分数阈值判断**:`matched_score >= 0.78` 为完全推导成功,`matched_score < 0.78` 为部分推导成功。
   - 注意:pattern 元素名称与其匹配到的帖子选题点名称**可能不同**,`output` 始终是元素名称,`matched_post_point` 始终是帖子选题点名称。
 - **优先级**:优先使用条件概率高、pattern 长度(节点数)大的结果;与已推导选题点重合多的 pattern 更优先(工具已自动排序)。
 - 模拟样例(基于上方工具返回数据,假设此时 `derived_success_set` 中已包含 `分享` 和 `图片文字`):
@@ -270,9 +299,9 @@ $system$
 这违反原子化规则,因为两个pattern的推导彼此独立,去掉任一输入不影响另一个输出。
 
 对应评估日志:
-- `derivation_output_point="日常物品"`,`is_matched=true`,`matched_post_point="日常物品"`,`matched_reason="匹配分数=1.0"`
-- `derivation_output_point="补充说明式"`,`is_matched=true`,`matched_post_point="补充说明式"`,`matched_reason="匹配分数=1.0"`
-- `derivation_output_point="状态与描绘"`,`is_matched=false`,`matched_post_point=null`,`matched_reason=null`
+- `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=1.0`,`matched_reason="匹配分数=1.0"`(完全推导成功)
+- `derivation_output_point="状态与描绘"`,`is_matched=false`,`matched_post_point=null`,`matched_score=null`,`matched_reason=null`
 
 #### 方法三:人设推导
 - **适用场景**:通过人设树条件概率关联推导相关节点;非首轮进行内部推导时可以使用。
@@ -283,8 +312,9 @@ $system$
   - 第一人称视角  条件概率=1.0  父节点=体验式呈现  帖子选题点匹配=无
   ```
   - **推导路径的 `output`**:填写工具返回的**人设树节点名称**(如 `趣味道具`)。
-  - **匹配判断**:读取「帖子选题点匹配」字段——若有值(如 `夸张道具(0.7831)`),则 `is_matched=true`,评估日志中 `matched_post_point` 填写括号前的帖子选题点名称(如 `夸张道具`),`matched_reason` 填写匹配分数(如 `0.7831`);若字段值为「无」,则 `is_matched=false`。
-  - ⚠️ **关键区分**:`output` 是人设树节点名称(`趣味道具`),`matched_post_point` 是帖子中真实的选题点名称(`夸张道具`)——两者**可以不同**,加入 `derived_success_set` 的是 `matched_post_point`,而非 `output`。
+  - **匹配判断**:读取「帖子选题点匹配」字段——若有值(如 `夸张道具(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`,属于完全推导成功。
+  - ⚠️ **关键区分**:`output` 是人设树节点名称(`趣味道具`),`matched_post_point` 是帖子中真实的选题点名称(`夸张道具`)——两者**可以不同**,加入 `derived_success_set` 或 `partial_derived_set` 的是 `matched_post_point`,而非 `output`。
   - 推导理由须引用条件概率等数据,**禁止**使用大模型自身世界知识联想。
 - 模拟样例(基于上方工具返回数据):
 ```json
@@ -339,8 +369,8 @@ $system$
 这违反原子化规则,因为"趣味道具"和"第一人称视角"的推导彼此独立,去掉任一输入不影响另一个输出。
 
 对应评估日志:
--`derivation_output_point="趣味道具"`,`is_matched=true`,`matched_post_point="夸张道具"`,`matched_reason="匹配分数=0.7831"`
--`derivation_output_point="第一人称视角"`,`is_matched=false`,`matched_post_point=null`,`matched_reason=null`
+- `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`
 
 #### 方法四:信息搜索
 - **适用场景**:方法二和方法三均难以推导出新选题点时,或需要验证某个推导假设时。
@@ -397,7 +427,7 @@ $system$
 **目标**:围绕已确认的选题点,向深度方向精准延伸,挖掘与之强关联的剩余选题点。
 
 **执行要点**:
-- **账号 pattern 复用(方法二)仍是每轮首选**:传入非空 `derived_items`,工具会自动优先返回包含已推导选题点的 pattern;重点关注这些 pattern 中尚未推导的元素,作为下一步候选;同时利用工具返回的「帖子选题点匹配」字段,优先选取匹配成功的 pattern 元素
+- **账号 pattern 复用(方法二)仍是每轮首选**:传入非空 `derived_items`(包含完全推导和部分推导成功的选题点),工具会自动优先返回包含已推导选题点的 pattern;重点关注这些 pattern 中尚未推导的元素,作为下一步候选;同时利用工具返回的「帖子选题点匹配」字段,优先选取匹配成功的 pattern 元素
 - 人设推导(方法三)传入非空 `derived_items`,利用条件概率补充 pattern 未覆盖的关联节点
 - 每轮推导聚焦于与已推导点关联性强的维度,避免回到无目标的散点式探索
 - 若某轮内部方法(方法二、三)在严格依据工具返回数据判定后仍无法推导出新点(即全部输出点均为 `is_matched=false`),方可触发信息搜索(方法四)——不得在触发信息搜索前,先用 `point_match` 对内部方法的输出进行额外探索
@@ -413,7 +443,7 @@ $system$
 - **搜索后的跟进**:每次搜索后至少安排 1~2 轮内部方法推导,将搜索发现的新方向优先在 pattern 库中验证,再结合人设树延伸
 
 #### 内部推导结果重合处理
-当某一轮使用了多个内部推导方法推导出了同一个选题点,优先使用人设常量和账号 pattern 复用方法作为推导输出结果。
+当某一轮使用了多个内部推导方法推导出了同一个选题点(即匹配到同一个 `matched_post_point`),按多路径择优机制取 `matched_score` 最高的路径作为输出。若分数相同,优先使用人设常量和账号 pattern 复用方法作为推导输出结果。
 
 #### 内部推导方法阈值动态调整
 内部推导方法二、三的 `conditional_ratio_threshold`(条件概率阈值)、`top_n`(最大返回记录条数)由 agent 动态调整:
@@ -443,7 +473,7 @@ $system$
 
 - `consecutive_zero_rounds >= 5`(**注意:是连续 5 轮匹配率为 0%,不是累计出现了 5 轮匹配率为 0%**)
 - 达到总轮次上限 15 轮
-- 提前完成:当前待推导的帖子选题点总数量 = `{post_point_count}`,如果已推导的帖子选题点占总数量的 85% 以上,且连续 2 轮匹配率为 0%,可提前终止
+- 提前完成:当前待推导的帖子选题点总数量 = `{post_point_count}`,如果**完全推导成功**的帖子选题点(`derived_success_set` 的长度,不含 `partial_derived_set`)占总数量的 85% 以上,且连续 2 轮匹配率为 0%,可提前终止
 
 ---
 
@@ -451,8 +481,8 @@ $system$
 
 - **推导方法原子化使用**:每条推导路径只能使用一种方法,只调用一次对应工具;不得在一条路径中混用多种方法,也不得将多步调用结果合并为一步。每条路径的 `output` 通常只有一两个选题点;账号 pattern 复用因一个 pattern 中有多个元素,可以推导出多个选题点;信息搜索也可能一次推导出多个候选点。
 - **一次工具调用可形成多条推导路径**:工具返回多个节点或多个 pattern 时,每个产出推导点的最小输入单元可单独拆为一条路径。路径拆分的判断标准是:该路径的所有输入对其所有输出都是必要的,可以分开推导的不要混在同一条路径中。
-- **每轮多方法覆盖**:每轮推导应至少使用 2 种不同的推导方法,每种方法尝试多种输入组合,不局限于 1~2 种可能。已推导成功 ≥ 70% 后,可放宽为每轮至少使用 1 种方法,仅在未匹配时补充第 2 种,降低冗余工具调用。
-- **避免重复推导**:每轮推导前检查 `failed_points` 列表,列表中的选题点名称原则上不得再次输出;若确有必要重新推导,须换用完全不同的推导方法与输入组合。
+- **每轮多方法覆盖**:每轮推导应至少使用 2 种不同的推导方法,每种方法尝试多种输入组合,不局限于 1~2 种可能。已推导成功 ≥ 70%(此处的"已推导成功"仅统计 `derived_success_set` 即完全推导成功的选题点)后,可放宽为每轮至少使用 1 种方法,仅在未匹配时补充第 2 种,降低冗余工具调用。
+- **避免重复推导**:每轮推导前检查 `failed_points` 列表,列表中的选题点名称原则上不得再次输出;若确有必要重新推导,须换用完全不同的推导方法与输入组合。同时,**完全推导成功的选题点不得再次作为推导输出**;部分推导成功的选题点可以再次输出,以争取更高匹配分数。
 - **每一条推导路径必须包含**:输入节点、输出节点、推导方法、推导理由。
   - **输入节点**:必须是已推导成功的选题点(帖子选题点名称),或人设树节点、pattern 节点。
   - **输出节点**:本次推导产出的候选选题点。
@@ -553,11 +583,14 @@ $system$
       "derivation_output_point": "本轮推导输出的待评估选题点名称",
       "is_matched": true,
       "matched_post_point": "若匹配,则为帖子解构中匹配到的选题点;若不匹配则为 null",
-      "matched_reason": "匹配成功理由,如:工具返回匹配分数=0.92"
+      "matched_score": 0.92,
+      "matched_reason": "匹配成功理由,如:匹配分数=0.92",
+      "is_fully_derived": true
     }
   ],
   "derivation_progress": {
     "derived_success_count": 3,
+    "partial_derived_count": 1,
     "need_next_round": true
   }
 }
@@ -565,23 +598,32 @@ $system$
 
 - **说明**:
   - `round`: 当前轮次,与同轮推导日志一致
-  - `eval_results`: 本轮所有推导输出点的匹配判断结果,每项对应一个输出点
-    - `path_id`: 整数,对应推导日志中的推导路径 `id`
+  - `eval_results`: 本轮所有推导输出点的匹配判断结果(经多路径择优后,同一 `matched_post_point` 只保留分数最高的一条记录),每项对应一个输出点
+    - `path_id`: 整数,对应推导日志中的推导路径 `id`(择优后选中的那条路径)
     - `item_id`: 整数,同一路径内第几个输出点,从 1 开始;同一路径有多个输出点时用于区分
     - `derivation_output_point`: 本轮推导路径输出的待评估选题点名称
     - `is_matched`: 布尔值,该推导点是否匹配到帖子选题点
     - `matched_post_point`: 字符串或 `null`,匹配到的帖子选题点名称;未匹配则为 `null`
-    - `matched_reason`: 字符串或 `null`,匹配依据(如匹配分数);未匹配则为 `null`
+    - `matched_score`: 数值或 `null`,匹配分数(括号内的数值);未匹配则为 `null`
+    - `matched_reason`: 字符串或 `null`,匹配依据描述(如 `匹配分数=0.92`);未匹配则为 `null`
+    - `is_fully_derived`: 布尔值,`matched_score >= 0.78` 时为 `true`(完全推导成功),`matched_score < 0.78` 时为 `false`(部分推导成功),未匹配时为 `false`
   - `derivation_progress`: 由主 agent 根据当前已推导成功集合整理
-    - `derived_success_count`: 整数,累计已推导成功选题点数量(`derived_success_set` 的长度)
+    - `derived_success_count`: 整数,累计**完全推导成功**选题点数量(仅 `derived_success_set` 的长度,不含 `partial_derived_set`)
+    - `partial_derived_count`: 整数,累计**部分推导成功**选题点数量(`partial_derived_set` 的长度)
     - `need_next_round`: 布尔值,主 agent 判断是否需要继续下一轮推导
 
 ### 已推导成功选题点的更新规则
 
-完成步骤三匹配判断后,主 agent 按以下规则更新「已推导成功的选题点」集合:
-- 对本轮匹配判断结果中 `is_matched` 为 `true` 的记录,将 `matched_post_point`(帖子选题点名称)加入 `derived_success_set`。
-- **核心区分**:加入集合的是**帖子选题点名称**(即 `matched_post_point`),而非推导输出的节点名称(`derivation_output_point`)。两者可能不同——例如推导输出节点 `趣味道具` 匹配到帖子选题点 `夸张道具`,则加入集合的是 `夸张道具`,而非 `趣味道具`。
-- 后续轮次推导时,`derived_items` 中的 `topic` 字段和 `input.derived_nodes` 中引用的已推导成功选题点,均应使用帖子选题点名称(`matched_post_point`),而非人设树节点名称。
+完成步骤三匹配判断后,主 agent 按以下规则更新集合:
+
+1. 对本轮匹配判断结果中 `is_matched` 为 `true` 的记录:
+   - 若 `matched_score >= 0.78`:将 `matched_post_point` 加入 `derived_success_set`(完全推导成功)。若该选题点之前在 `partial_derived_set` 中,将其从 `partial_derived_set` 移除。
+   - 若 `matched_score < 0.78`:
+     - 若该 `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`)。两者可能不同——例如推导输出节点 `趣味道具` 匹配到帖子选题点 `夸张道具`,则加入集合的是 `夸张道具`,而非 `趣味道具`。
+3. 后续轮次推导时,`derived_items` 中的 `topic` 字段和 `input.derived_nodes` 中引用的已推导成功选题点,均应使用帖子选题点名称(`matched_post_point`),`derived_items` 需要包含 `derived_success_set` 和 `partial_derived_set` 的并集。
 
 ---
 
@@ -591,7 +633,7 @@ $system$
 2. **编码**: 所有文件使用 UTF-8 编码
 3. **JSON 格式**: 输出 JSON 使用 `ensure_ascii=False` 和 `indent=4`
 4. **数据完整性**:
-   - 推导成功的选题点 + 未推导成功的选题点 = 所有选题点
+   - 完全推导成功的选题点 + 部分推导成功的选题点 + 未推导成功的选题点 = 所有选题点
    - 每轮推导日志的 `derivation_results` 按步骤记录,避免重复条目
    - 每条推导路径的 `method` 必须是四种定义方法之一,不得自创方法名
 
@@ -602,10 +644,13 @@ $system$
 确保每轮输出的日志文件:
 1. JSON 格式正确,可以正常解析
 2. 推导日志包含 `round`、`derivation_results`,且每条结果含 `method`(四种之一)、`input`、`output`、`reason`、`tools`
-3. 评估日志包含 `round`、`eval_results`、`derivation_progress`,`is_matched` 为布尔值,`need_next_round` 为布尔值,`matched_reason` 引用工具返回的匹配分数等具体数据
+3. 评估日志包含 `round`、`eval_results`、`derivation_progress`,`is_matched` 为布尔值,`need_next_round` 为布尔值,`matched_reason` 引用工具返回的匹配分数等具体数据,`matched_score` 为数值或 `null`,`is_fully_derived` 为布尔值
 4. 推导理由中不包含对匹配结果反馈的引用(如"匹配结果显示...")
 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` 的部分推导成功选题点
 
 $user$
 请开始执行 account_name={account_name},帖子ID={帖子ID} 的选题点整体推导任务。所有路径均相对于项目根目录。帖子的选题点数量={post_point_count}

+ 212 - 60
examples_how/overall_derivation/generate_visualize_data.py

@@ -111,36 +111,130 @@ def build_derivation_result(
     """
     生成整体推导结果:每轮 轮次、推导成功的选题点、未推导成功的选题点、本次新推导成功的选题点。
     选题点用 topic_points 中的完整信息;按 name 判定是否被推导(评估中的 match_post_point)。
+    若之前推导成功的选题点 is_fully_derived=false,本轮变为 is_fully_derived=true,则算本次新推导成功的选题点,
+    且 matched_score、is_fully_derived 在本轮后更新为该轮评估值。
+    推导成功的选题点:使用当前已更新的 best (matched_score, is_fully_derived)。
+    本次新推导成功的选题点:用当轮评估的 matched_score、is_fully_derived。
+    未推导成功的选题点:不包含 matched_score、is_fully_derived。
     """
     all_keys = {_topic_point_key(t) for t in topic_points}
     topic_by_key = {_topic_point_key(t): t for t in topic_points}
 
+    # 分轮次收集 (round_num, name) -> (matched_score, is_fully_derived),同一轮同名取首次出现
+    score_by_round_name: dict[tuple[int, str], tuple[float, bool]] = {}
+    for round_idx, eval_data in enumerate(evals):
+        round_num = eval_data.get("round", round_idx + 1)
+        for er in eval_data.get("eval_results") or []:
+            if not (er.get("is_matched") is True or er.get("match_result") == "匹配"):
+                continue
+            mp = (er.get("matched_post_point") or er.get("matched_post_topic") or er.get("match_post_point") or "").strip()
+            if not mp:
+                continue
+            key = (round_num, mp)
+            if key in score_by_round_name:
+                continue
+            score = er.get("matched_score")
+            if score is None:
+                score = 1.0
+            else:
+                try:
+                    score = float(score)
+                except (TypeError, ValueError):
+                    score = 1.0
+            is_fully = er.get("is_fully_derived", True)
+            score_by_round_name[key] = (score, bool(is_fully))
+
     result = []
     derived_names_so_far: set[str] = set()
+    fully_derived_names_so_far: set[str] = set()  # 已出现过 is_fully_derived=true 的选题点
+    best_score_by_name: dict[str, tuple[float, bool]] = {}  # name -> (matched_score, is_fully_derived),遇 is_fully=true 时更新
 
     for i, (derivation, eval_data) in enumerate(zip(derivations, evals)):
         round_num = derivation.get("round", i + 1)
         eval_results = eval_data.get("eval_results") or []
         matched_post_points = set()
         for er in eval_results:
-            # 新格式: is_matched;旧格式: match_result == "匹配"
             if not (er.get("is_matched") is True or er.get("match_result") == "匹配"):
                 continue
             mp = er.get("matched_post_point") or er.get("matched_post_topic") or er.get("match_post_point") or ""
             if mp and str(mp).strip():
                 matched_post_points.add(str(mp).strip())
 
-        new_derived_names = matched_post_points - derived_names_so_far
+        # 本轮每个匹配名的 (score, is_fully)
+        this_round_scores: dict[str, tuple[float, bool]] = {}
+        for name in matched_post_points:
+            val = score_by_round_name.get((round_num, name))
+            if val is not None:
+                this_round_scores[name] = val
+
+        # 本次新推导成功:首次匹配 或 之前 is_fully=false 且本轮 is_fully=true
+        new_derived_names = set()
+        for name in matched_post_points:
+            score, is_fully = this_round_scores.get(name, (None, False))
+            if name not in derived_names_so_far:
+                new_derived_names.add(name)
+            elif name not in fully_derived_names_so_far and is_fully:
+                new_derived_names.add(name)
+
+        # 更新推导集合与 best:首次出现或本轮 is_fully=true 时更新 best
         derived_names_so_far |= matched_post_points
+        for name in matched_post_points:
+            val = this_round_scores.get(name)
+            if val is None:
+                continue
+            score, is_fully = val
+            if name not in best_score_by_name:
+                best_score_by_name[name] = (score, is_fully)
+            elif is_fully:
+                best_score_by_name[name] = (score, is_fully)
+            if is_fully:
+                fully_derived_names_so_far.add(name)
 
-        # 推导成功的选题点:name 在 derived_names_so_far 中的选题点(每 name 取一条,与 topic_points 顺序一致)
         derived_keys = {k for k in all_keys if topic_by_key[k]["name"] in derived_names_so_far}
         new_derived_keys = {k for k in all_keys if topic_by_key[k]["name"] in new_derived_names}
         not_derived_keys = all_keys - derived_keys
 
-        derived_list = [dict(topic_by_key[k]) for k in sorted(derived_keys, key=lambda k: (topic_by_key[k]["name"], k[1], k[2]))]
-        new_list = [dict(topic_by_key[k]) for k in sorted(new_derived_keys, key=lambda k: (topic_by_key[k]["name"], k[1], k[2]))]
-        not_derived_list = [dict(topic_by_key[k]) for k in sorted(not_derived_keys, key=lambda k: (topic_by_key[k]["name"], k[1], k[2]))]
+        sort_derived = sorted(derived_keys, key=lambda k: (topic_by_key[k]["name"], k[1], k[2]))
+        sort_new = sorted(new_derived_keys, key=lambda k: (topic_by_key[k]["name"], k[1], k[2]))
+        sort_not = sorted(not_derived_keys, key=lambda k: (topic_by_key[k]["name"], k[1], k[2]))
+
+        def add_score_fields(keys: set, sort_keys: list, round_for_score: int | None) -> list[dict]:
+            """round_for_score: 用该轮评估的分数;若为 None 则不添加 score 字段。"""
+            out = []
+            for k in sort_keys:
+                if k not in keys:
+                    continue
+                obj = dict(topic_by_key[k])
+                if round_for_score is not None:
+                    name = obj.get("name", "")
+                    val = score_by_round_name.get((round_for_score, name))
+                    if val is not None:
+                        obj["matched_score"] = val[0]
+                        obj["is_fully_derived"] = val[1]
+                    else:
+                        obj["matched_score"] = None
+                        obj["is_fully_derived"] = False
+                out.append(obj)
+            return out
+
+        # 推导成功的选题点:用当前已更新的 best (matched_score, is_fully_derived)
+        derived_list = []
+        for k in sort_derived:
+            if k not in derived_keys:
+                continue
+            obj = dict(topic_by_key[k])
+            name = obj.get("name", "")
+            val = best_score_by_name.get(name)
+            if val is not None:
+                obj["matched_score"] = val[0]
+                obj["is_fully_derived"] = val[1]
+            else:
+                obj["matched_score"] = None
+                obj["is_fully_derived"] = False
+            derived_list.append(obj)
+
+        new_list = add_score_fields(new_derived_keys, sort_new, round_for_score=round_num)
+        not_derived_list = [dict(topic_by_key[k]) for k in sort_not]  # 不带 matched_score、is_fully_derived
 
         result.append({
             "轮次": round_num,
@@ -182,75 +276,109 @@ def build_visualize_edges(
 ) -> tuple[list[dict], list[dict]]:
     """
     生成 node_list(所有评估通过的帖子选题点)和 edge_list(只保留评估通过的推导路径)。
-    按轮次从小到大处理,保证每个输出节点最多只出现在一条边的 output_nodes 里,且保留的是前面轮次的数据。
+    - node_list:同一轮内节点不重复,重复时保留 matched_score 更高的;节点带 matched_score、is_fully_derived。
+    - edge_list:边带 level(与 output 节点 level 一致);同一轮内 output 节点不重复;若前面轮次该节点匹配分更高则本轮不保留该节点。
+    评估数据支持 path_id(对应推导 derivation_results[].id)、item_id(output 中元素从 1 起的序号)、matched_score、is_fully_derived。
     """
-    # 按轮次从小到大排序,确保优先使用前面轮次的输出节点
     derivations = sorted(derivations, key=lambda d: d.get("round", 0))
     evals = sorted(evals, key=lambda e: e.get("round", 0))
 
-    topic_by_name = {}
-    for t in topic_points:
-        name = t["name"]
-        if name not in topic_by_name:
-            topic_by_name[name] = t
+    topic_by_name = {t["name"]: t for t in topic_points}
 
-    # 不依赖 id,仅用 (round, derivation_output_point) 与推导的 output 节点名匹配关联评估结果
-    # key=(round_num, derivation_output_point) -> (matched_post_point, matched_reason);同轮同节点取首次匹配
-    match_by_round_output: dict[tuple[int, str], tuple[str, str]] = {}
+    # 评估匹配:(round_num, path_id, item_id) -> (matched_post_point, matched_reason, matched_score, is_fully_derived)
+    # path_id = 推导中 derivation_results[].id,item_id = output 中元素从 1 起的序号
+    match_by_path_item: dict[tuple[int, int, int], tuple[str, str, float, bool]] = {}
+    match_by_round_output: dict[tuple[int, str], tuple[str, str, float, bool]] = {}  # 兼容无 path_id/item_id
     for round_idx, eval_data in enumerate(evals):
         round_num = eval_data.get("round", round_idx + 1)
         for er in eval_data.get("eval_results") or []:
             if not (er.get("is_matched") is True or er.get("match_result") == "匹配"):
                 continue
+            mp = (er.get("matched_post_point") or er.get("matched_post_topic") or er.get("match_post_point") or "").strip()
+            if not mp:
+                continue
             out_point = (er.get("derivation_output_point") or "").strip()
-            mp = er.get("matched_post_point") or er.get("matched_post_topic") or er.get("match_post_point") or ""
-            matched_reason = er.get("matched_reason") or er.get("match_reason") or ""
-            if out_point and mp and str(mp).strip():
-                mp = str(mp).strip()
+            reason = (er.get("matched_reason") or er.get("match_reason") or "").strip()
+            score = er.get("matched_score")
+            if score is None:
+                score = 1.0
+            else:
+                try:
+                    score = float(score)
+                except (TypeError, ValueError):
+                    score = 1.0
+            is_fully = er.get("is_fully_derived", True)
+            val = (mp, reason, score, bool(is_fully))
+            path_id = er.get("path_id")
+            item_id = er.get("item_id")
+            if path_id is not None and item_id is not None:
+                try:
+                    match_by_path_item[(round_num, int(path_id), int(item_id))] = val
+                except (TypeError, ValueError):
+                    pass
+            if out_point:
                 k = (round_num, out_point)
                 if k not in match_by_round_output:
-                    match_by_round_output[k] = (mp, str(matched_reason).strip() if matched_reason else "")
+                    match_by_round_output[k] = val
+
+    # 按 (round_num, mp) 收集节点候选,同轮同节点保留 matched_score 最高的一条
+    node_candidates: dict[tuple[int, str], dict] = {}  # (round_num, mp) -> node_dict (含 score, is_fully_derived)
+
+    def get_match(round_num: int, path_id: int | None, item_id: int | None, out_item: str) -> tuple[str, str, float, bool] | None:
+        if path_id is not None and item_id is not None:
+            v = match_by_path_item.get((round_num, path_id, item_id))
+            if v is not None:
+                return v
+        return match_by_round_output.get((round_num, out_item))
 
-    node_list = []
-    seen_nodes = set()
     edge_list = []
-    level_by_name = {}
-    output_nodes_seen: set[str] = set()  # 已在之前边的 output_nodes 中出现过的节点,避免同一输出节点对应多条边
+    round_output_seen: set[tuple[int, str]] = set()  # (round_num, node_name) 本轮已作为某边的 output
+    best_score_by_node: dict[str, float] = {}  # node_name -> 已出现过的最高 matched_score
 
     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 []
-            matched_outputs = []
-            matched_reasons = []
-            matched_derivation_outputs = []
-            for out_item in output_list:
-                key = (round_num, out_item)
-                pair = match_by_round_output.get(key)
-                if not pair:
+            path_id = dr.get("id")
+            matched: list[tuple[str, str, float, bool, str]] = []  # (mp, reason, score, is_fully, derivation_out)
+            for i, out_item in enumerate(output_list):
+                item_id = i + 1
+                v = get_match(round_num, path_id, item_id, out_item)
+                if not v:
                     continue
-                mp, reason = pair
-                matched_outputs.append(mp)
-                matched_reasons.append(reason)
-                matched_derivation_outputs.append(out_item)
-                if mp not in seen_nodes:
-                    seen_nodes.add(mp)
-                    node = dict(topic_by_name.get(mp, {"name": mp, "point": "", "dimension": "", "root_source": "", "root_sources_desc": ""}))
-                    node["level"] = round_num
-                    if "original_word" not in node:
-                        node["original_word"] = node.get("name", mp)
-                    node["derivation_type"] = dr.get("method", "")
-                    level_by_name[mp] = round_num
-                    node_list.append(node)
+                mp, reason, score, is_fully = v
+                matched.append((mp, reason, score, is_fully, out_item))
 
-            if not matched_outputs:
+            if not matched:
                 continue
 
-            # 只保留尚未在之前边的 output_nodes 中出现过的节点,避免同一输出节点对应多条边
-            output_names_this_edge = [x for x in matched_outputs if x not in output_nodes_seen]
+            # 同一轮内 output 节点不重复;若前面轮次该节点匹配分更高则本轮不保留
+            output_names_this_edge = []
+            for mp, reason, score, is_fully, out_item in matched:
+                if (round_num, mp) in round_output_seen:
+                    continue
+                if score <= best_score_by_node.get(mp, -1.0):
+                    continue
+                output_names_this_edge.append((mp, reason, score, is_fully, out_item))
+
             if not output_names_this_edge:
                 continue
-            output_nodes_seen.update(output_names_this_edge)
+
+            for mp, _r, score, _f, _o in output_names_this_edge:
+                round_output_seen.add((round_num, mp))
+                best_score_by_node[mp] = max(best_score_by_node.get(mp, -1.0), score)
+
+            # 节点候选:同轮同节点保留匹配分更高的
+            for mp, _reason, score, is_fully, _out_item in output_names_this_edge:
+                key = (round_num, mp)
+                if key not in node_candidates or node_candidates[key].get("matched_score", 0) < score:
+                    node = dict(topic_by_name.get(mp, {"name": mp, "point": "", "dimension": "", "root_source": "", "root_sources_desc": ""}))
+                    node["level"] = round_num
+                    node.setdefault("original_word", node.get("name", mp))
+                    node["derivation_type"] = dr.get("method", "")
+                    node["matched_score"] = score
+                    node["is_fully_derived"] = is_fully
+                    node_candidates[key] = node
 
             input_data = dr.get("input") or {}
             derived_nodes = input_data.get("derived_nodes") or []
@@ -266,23 +394,26 @@ def build_visualize_edges(
             else:
                 input_pattern_nodes = []
 
-            output_nodes = [{"name": x} for x in output_names_this_edge]
-            # 与 output_names_this_edge 顺序对应的匹配理由、推导输出节点名
-            mp_to_reason = dict(zip(matched_outputs, matched_reasons))
-            mp_to_derivation_out = dict(zip(matched_outputs, matched_derivation_outputs))
-            reason_for_this_edge = [mp_to_reason.get(name, "") for name in output_names_this_edge]
-            derivation_points_this_edge = [mp_to_derivation_out.get(name, "") for name in output_names_this_edge]
+            output_nodes = []
+            reasons_list = []
+            derivation_points_list = []
+            for mp, reason, score, is_fully, out_item in output_names_this_edge:
+                output_nodes.append({"name": mp, "matched_score": score, "is_fully_derived": is_fully})
+                reasons_list.append(reason)
+                derivation_points_list.append(out_item)
+
             detail = {
                 "reason": dr.get("reason", ""),
                 "评估结果": "匹配成功",
             }
-            if any(reason_for_this_edge):
-                detail["匹配理由"] = reason_for_this_edge
-            detail["待比对的推导选题点"] = derivation_points_this_edge
+            if any(reasons_list):
+                detail["匹配理由"] = reasons_list
+            detail["待比对的推导选题点"] = derivation_points_list
             if dr.get("tools"):
                 detail["tools"] = dr["tools"]
             edge_list.append({
                 "name": dr.get("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,
@@ -290,12 +421,24 @@ def build_visualize_edges(
                 "detail": detail,
             })
 
+    node_list = list(node_candidates.values())
     return node_list, edge_list
 
 
+def _find_project_root() -> Path:
+    """从脚本所在目录向上查找包含 .git 的项目根目录。"""
+    p = Path(__file__).resolve().parent
+    while p != p.parent:
+        if (p / ".git").is_dir():
+            return p
+        p = p.parent
+    return Path(__file__).resolve().parent
+
+
 def generate_visualize_data(account_name: str, post_id: str, log_id: str, base_dir: Path | None = None) -> None:
     """
     主流程:读取解构内容与推导日志,生成整体推导结果与整体推导路径可视化两个 JSON。
+    base_dir 默认为脚本所在目录;若其下 output/.../推导日志 不存在,则尝试项目根目录下的 output/...(兼容从项目根运行)。
     """
     if base_dir is None:
         base_dir = Path(__file__).resolve().parent
@@ -303,6 +446,15 @@ def generate_visualize_data(account_name: str, post_id: str, log_id: str, base_d
     log_dir = base_dir / "output" / account_name / "推导日志" / post_id / log_id
     result_dir = base_dir / "output" / account_name / "整体推导结果"
     visualize_dir = base_dir / "output" / account_name / "整体推导路径可视化"
+    # 兼容:若推导日志不在 base_dir 下,尝试项目根目录下的 output/
+    if not log_dir.is_dir():
+        project_root = _find_project_root()
+        if project_root != base_dir:
+            alt_log = project_root / "output" / account_name / "推导日志" / post_id / log_id
+            if alt_log.is_dir():
+                log_dir = alt_log
+                result_dir = project_root / "output" / account_name / "整体推导结果"
+                visualize_dir = project_root / "output" / account_name / "整体推导路径可视化"
 
     deconstruct_path = input_dir / f"{post_id}.json"
     topic_points = parse_topic_points_from_deconstruct(deconstruct_path)
@@ -340,6 +492,6 @@ def main(account_name, post_id, log_id):
 
 if __name__ == "__main__":
     account_name="家有大志"
-    post_id = "69185d49000000000d00f94e"
-    log_id="20260310111409"
+    post_id = "68fb6a5c000000000302e5de"
+    log_id="20260310220945"
     main(account_name, post_id, log_id)