刘立冬 2 mesi fa
parent
commit
d1d4905283

+ 3935 - 0
knowledge_search_traverse.py

@@ -0,0 +1,3935 @@
+import asyncio
+import json
+import os
+import sys
+import argparse
+from datetime import datetime
+from typing import Literal, Optional
+
+from agents import Agent, Runner, ModelSettings
+from lib.my_trace import set_trace
+from pydantic import BaseModel, Field
+
+from lib.utils import read_file_as_string
+from lib.client import get_model
+MODEL_NAME = "google/gemini-2.5-flash"
+# 得分提升阈值:sug或组合词必须比来源query提升至少此幅度才能进入下一轮
+REQUIRED_SCORE_GAIN = 0.02
+from script.search_recommendations.xiaohongshu_search_recommendations import XiaohongshuSearchRecommendations
+from script.search.xiaohongshu_search import XiaohongshuSearch
+from multimodal_extractor import extract_post_images
+
+
+# ============================================================================
+# 日志工具类
+# ============================================================================
+
+class TeeLogger:
+    """同时输出到控制台和日志文件的工具类"""
+    def __init__(self, stdout, log_file):
+        self.stdout = stdout
+        self.log_file = log_file
+
+    def write(self, message):
+        self.stdout.write(message)
+        self.log_file.write(message)
+        self.log_file.flush()  # 实时写入,避免丢失日志
+
+    def flush(self):
+        self.stdout.flush()
+        self.log_file.flush()
+
+
+# ============================================================================
+# 数据模型
+# ============================================================================
+
+class Seg(BaseModel):
+    """分词(旧版)- v120使用"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    reason: str = ""  # 评分理由
+    from_o: str = ""  # 原始问题
+
+
+# ============================================================================
+# 新架构数据模型 (v121)
+# ============================================================================
+
+class Segment(BaseModel):
+    """语义片段(Round 0语义分段结果)"""
+    text: str  # 片段文本
+    type: str  # 语义类型: 疑问标记/核心动作/修饰短语/中心名词/逻辑连接
+    score_with_o: float = 0.0  # 与原始问题的评分
+    reason: str = ""  # 评分理由
+    from_o: str = ""  # 原始问题
+    words: list[str] = Field(default_factory=list)  # 该片段拆分出的词列表(Round 0拆词结果)
+    word_scores: dict[str, float] = Field(default_factory=dict)  # 词的评分 {word: score}
+    word_reasons: dict[str, str] = Field(default_factory=dict)  # 词的评分理由 {word: reason}
+
+
+class DomainCombination(BaseModel):
+    """域组合(Round N的N域组合结果)"""
+    text: str  # 组合后的文本
+    domains: list[int] = Field(default_factory=list)  # 参与组合的域索引列表(对应segments的索引)
+    type_label: str = ""  # 类型标签,如 [疑问标记+核心动作+中心名词]
+    source_words: list[list[str]] = Field(default_factory=list)  # 来源词列表,每个元素是一个域的词列表,如 [["猫咪"], ["梗图"]]
+    score_with_o: float = 0.0  # 与原始问题的评分
+    reason: str = ""  # 评分理由
+    from_segments: list[str] = Field(default_factory=list)  # 来源segment的文本列表
+    source_word_details: list[dict] = Field(default_factory=list)  # 词及其得分信息 [{"domain_index":0,"segment_type":"","words":[{"text":"","score":0.0}]}]
+    source_scores: list[float] = Field(default_factory=list)  # 来源词的分数列表(扁平化)
+    max_source_score: float | None = None  # 来源词的最高分
+    is_above_source_scores: bool = False  # 组合得分是否超过所有来源词
+
+
+# ============================================================================
+# 旧架构数据模型(保留但不使用)
+# ============================================================================
+
+# class Word(BaseModel):
+#     """词(旧版)- v120使用,v121不再使用"""
+#     text: str
+#     score_with_o: float = 0.0  # 与原始问题的评分
+#     from_o: str = ""  # 原始问题
+
+
+class Word(BaseModel):
+    """词"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    from_o: str = ""  # 原始问题
+
+
+class QFromQ(BaseModel):
+    """Q来源信息(用于Sug中记录)"""
+    text: str
+    score_with_o: float = 0.0
+
+
+class Q(BaseModel):
+    """查询"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    reason: str = ""  # 评分理由
+    from_source: str = ""  # v120: seg/sug/add; v121新增: segment/domain_comb/sug
+    type_label: str = ""  # v121新增:域类型标签(仅用于domain_comb来源)
+    domain_index: int = -1  # v121新增:域索引(word来源时有效,-1表示无域)
+    domain_type: str = ""  # v121新增:域类型(word来源时表示所属segment的type,如"中心名词")
+
+
+class Sug(BaseModel):
+    """建议词"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    reason: str = ""  # 评分理由
+    from_q: QFromQ | None = None  # 来自的q
+
+
+class Seed(BaseModel):
+    """种子(旧版)- v120使用,v121不再使用"""
+    text: str
+    added_words: list[str] = Field(default_factory=list)  # 已经增加的words
+    from_type: str = ""  # seg/sug/add
+    score_with_o: float = 0.0  # 与原始问题的评分
+
+
+class Post(BaseModel):
+    """帖子"""
+    title: str = ""
+    body_text: str = ""
+    type: str = "normal"  # video/normal
+    images: list[str] = Field(default_factory=list)  # 图片url列表,第一张为封面
+    video: str = ""  # 视频url
+    interact_info: dict = Field(default_factory=dict)  # 互动信息
+    note_id: str = ""
+    note_url: str = ""
+
+
+class Search(Sug):
+    """搜索结果(继承Sug)"""
+    post_list: list[Post] = Field(default_factory=list)  # 搜索得到的帖子列表
+
+
+class RunContext(BaseModel):
+    """运行上下文"""
+    version: str
+    input_files: dict[str, str]
+    c: str  # 原始需求
+    o: str  # 原始问题
+    log_url: str
+    log_dir: str
+
+    # v121新增:语义分段结果
+    segments: list[dict] = Field(default_factory=list)  # Round 0的语义分段结果
+
+    # 每轮的数据
+    rounds: list[dict] = Field(default_factory=list)  # 每轮的详细数据
+
+    # 最终结果
+    final_output: str | None = None
+
+    # 评估缓存:避免重复评估相同文本
+    evaluation_cache: dict[str, tuple[float, str]] = Field(default_factory=dict)
+    # key: 文本, value: (score, reason)
+
+    # 历史词/组合得分追踪(用于Round 2+计算系数)
+    word_score_history: dict[str, float] = Field(default_factory=dict)
+    # key: 词/组合文本, value: 最终得分
+
+
+# ============================================================================
+# Agent 定义
+# ============================================================================
+
+# ============================================================================
+# v121 新增 Agent
+# ============================================================================
+
+# Agent: 语义分段专家 (Prompt1)
+class SemanticSegment(BaseModel):
+    """单个语义片段"""
+    segment_text: str = Field(..., description="片段文本")
+    segment_type: str = Field(..., description="语义类型(疑问标记/核心动作/修饰短语/中心名词/逻辑连接)")
+    reasoning: str = Field(..., description="分段理由")
+
+
+class SemanticSegmentation(BaseModel):
+    """语义分段结果"""
+    segments: list[SemanticSegment] = Field(..., description="语义片段列表")
+    overall_reasoning: str = Field(..., description="整体分段思路")
+
+
+semantic_segmentation_instructions = """
+你是语义分段专家。给定一个搜索query,将其拆分成不同语义类型的片段。
+
+## 语义类型定义
+1. **疑问引导**:如何、怎么、什么、哪里等疑问词
+2. **核心动作**:关键动词,如获取、制作、拍摄、寻找等
+3. **修饰短语**:形容词、副词等修饰成分
+4. **中心名词**:核心名词
+5. **逻辑连接**:并且、或者、以及等连接词(较少出现)
+
+## 分段原则
+1. **语义完整性**:每个片段应该是一个完整的语义单元
+2. **类型互斥**:每个片段只能属于一种类型
+3. **保留原文**:片段文本必须保留原query中的字符,不得改写
+4. **顺序保持**:片段顺序应与原query一致
+
+
+## 输出要求
+- segments: 片段列表
+  - segment_text: 片段文本(必须来自原query)
+  - segment_type: 语义类型(从5种类型中选择)
+  - reasoning: 为什么这样分段
+- overall_reasoning: 整体分段思路
+
+## JSON输出规范
+1. **格式要求**:必须输出标准JSON格式
+2. **引号规范**:字符串中如需表达引用,使用书名号《》或「」,不要使用英文引号或中文引号""
+""".strip()
+
+semantic_segmenter = Agent[None](
+    name="语义分段专家",
+    instructions=semantic_segmentation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=SemanticSegmentation,
+)
+
+
+# ============================================================================
+# v120 保留 Agent
+# ============================================================================
+
+# Agent 1: 分词专家(v121用于Round 0拆词)
+class WordSegmentation(BaseModel):
+    """分词结果"""
+    words: list[str] = Field(..., description="分词结果列表")
+    reasoning: str = Field(..., description="分词理由")
+
+word_segmentation_instructions = """
+你是分词专家。给定一个query,将其拆分成有意义的最小单元。
+
+## 分词原则
+1. 保留有搜索意义的词汇
+2. 拆分成独立的概念
+3. 保留专业术语的完整性
+4. 去除虚词(的、吗、呢等),但保留疑问词(如何、为什么、怎样等)
+
+## 输出要求
+返回分词列表和分词理由。
+""".strip()
+
+word_segmenter = Agent[None](
+    name="分词专家",
+    instructions=word_segmentation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=WordSegmentation,
+)
+
+
+# Agent 2: 动机维度评估专家 + 品类维度评估专家(两阶段评估)
+
+# 动机评估的嵌套模型
+class CoreMotivationExtraction(BaseModel):
+    """核心动机提取"""
+    简要说明核心动机: str = Field(..., description="核心动机说明")
+
+class MotivationEvaluation(BaseModel):
+    """动机维度评估"""
+    原始问题核心动机提取: CoreMotivationExtraction = Field(..., description="原始问题核心动机提取")
+    动机维度得分: float = Field(..., description="动机维度得分 -1~1")
+    简要说明动机维度相关度理由: str = Field(..., description="动机维度相关度理由")
+    得分为零的原因: Optional[Literal["原始问题无动机", "sug词条无动机", "动机不匹配", "不适用"]] = Field(None, description="当得分为0时的原因分类(可选,仅SUG评估使用)")
+
+class CategoryEvaluation(BaseModel):
+    """品类维度评估"""
+    品类维度得分: float = Field(..., description="品类维度得分 -1~1")
+    简要说明品类维度相关度理由: str = Field(..., description="品类维度相关度理由")
+
+class ExtensionWordEvaluation(BaseModel):
+    """延伸词评估"""
+    延伸词得分: float = Field(..., ge=-1, le=1, description="延伸词得分 -1~1")
+    简要说明延伸词维度相关度理由: str = Field(..., description="延伸词维度相关度理由")
+
+# 动机评估 prompt(统一版本)
+motivation_evaluation_instructions = """
+# 角色
+你是**专业的动机意图评估专家**。
+任务:判断<平台sug词条>与<原始问题>的**动机意图匹配度**,给出**-1到1之间**的数值评分。
+
+---
+# 输入信息
+你将接收到以下输入:
+- **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
+- **<平台sug词条>**:待评估的词条,可能是单个或多个作用域的组合
+---
+
+
+# 核心约束
+
+## 维度独立性声明
+【严格约束】本评估**仅评估动机意图维度**:
+- **只评估** 用户"想要做什么",即原始问题的行为意图和目的
+- 核心是 **动词**:获取、学习、拍摄、制作、寻找等
+- 包括:核心动作 + 使用场景 + 最终目的
+- **评估重点**:动作本身及其语义方向
+ **禁止使用"主题相关"作为评分依据**:评分理由中不得出现"主题"、"内容"、"话题"等词
+
+---
+
+# 作用域与动作意图
+
+## 什么是作用域?
+**作用域 = 动机层 + 对象层 + 场景层**
+
+## 动作意图的识别
+
+### 方法1: 显性动词直接提取
+
+当原始问题明确包含动词时,直接提取
+示例:
+"如何获取素材" → 核心动机 = "获取"
+"寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
+"制作视频教程" → 核心动机 = "制作"
+
+### 方法2: 隐性动词语义推理
+当原始问题没有显性动词时,需要结合上下文推理
+
+如果原始问题是纯名词短语,无任何动作线索:
+→ 核心动机 = 无法识别
+→ 在此情况下,动机维度得分应为 0。
+示例:
+"摄影" → 无法识别动机,动机维度得分 = 0
+"川西风光" → 无法识别动机,动机维度得分 = 0
+
+---
+
+# 部分作用域的处理
+
+## 情况1:sug词条是原始问题的部分作用域
+
+当sug词条只包含原始问题的部分作用域时,需要判断:
+1. sug词条是否包含动作意图
+2. 如果包含,动作是否匹配
+
+**示例**:
+```
+原始问题:"川西旅行行程规划"
+- 完整作用域:规划(动作)+ 旅行行程(对象)+ 川西(场景)
+
+Sug词条:"川西旅行"
+- 包含作用域:旅行(部分对象)+ 川西(场景)
+- 缺失作用域:规划(动作)
+- 动作意图评分:0(无动作意图)
+```
+
+**评分原则**:
+- 如果sug词条缺失动机层(动作) → 动作意图得分 = 0
+- 如果sug词条包含动机层 → 按动作匹配度评分
+
+---
+
+# 评分标准
+
+## 【正向匹配】
+
+### +0.9~1.0:核心动作完全一致
+**示例**:
+- "规划旅行行程" vs "安排旅行路线" → 0.98
+  - 规划≈安排,语义完全一致
+- "获取素材" vs "下载素材" → 0.97
+  - 获取≈下载,语义完全一致
+
+- 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
+例: 原始问题"扣除猫咪主体的方法" vs sug词"扣除猫咪眼睛的方法"(子集但目的一致
+**注意**:此处不考虑对象和场景是否一致,只看动作本身
+
+###+0.75~0.95: 核心动作语义相近或为同义表达
+  - 例: 原始问题"如何获取素材" vs sug词"如何下载素材"
+  - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
+
+### +0.50~0.75:动作意图相关
+**判定标准**:
+- 动作是实现原始意图的相关路径
+- 或动作是原始意图的前置/后置步骤
+
+**示例**:
+- "获取素材" vs "管理素材" → 0.65
+  - 管理是获取后的相关步骤
+- "规划行程" vs "预订酒店" → 0.60
+  - 预订是规划的具体实施步骤
+
+### +0.25~0.50:动作意图弱相关
+**判定标准**:
+- 动作在同一大类但方向不同
+- 或动作有间接关联
+
+**示例**:
+- "学习摄影技巧" vs "欣赏摄影作品" → 0.35
+  - 都与摄影有关,但学习≠欣赏
+- "规划旅行" vs "回忆旅行" → 0.30
+  - 都与旅行有关,但方向不同
+
+---
+
+## 【中性/无关】
+
+### 0:无动作意图或动作完全无关
+**适用场景**:
+1. 原始问题或sug词条无法识别动作
+2. 两者动作意图完全无关
+
+**示例**:
+- "如何获取素材" vs "摄影器材" → 0
+  - sug词条无动作意图
+- "川西风光" vs "风光摄影作品" → 0
+  - 原始问题无动作意图
+
+**理由模板**:
+- "sug词条无明确动作意图,无法评估动作匹配度"
+- "原始问题无明确动作意图,动作维度得分为0"
+
+---
+
+## 【负向偏离】
+
+### -0.2~-0.05:动作方向轻度偏离
+**示例**:
+- "学习摄影技巧" vs "销售摄影课程" → -0.10
+  - 学习 vs 销售,方向有偏差
+
+### -0.5~-0.25:动作意图明显冲突
+**示例**:
+- "获取免费素材" vs "购买素材" → -0.35
+  - 获取免费 vs 购买,明显冲突
+
+### -1.0~-0.55:动作意图完全相反
+**示例**:
+- "下载素材" vs "上传素材" → -0.70
+  - 下载 vs 上传,方向完全相反
+
+---
+
+## 得分为零的原因(语义判断)
+
+当动机维度得分为 0 时,需要在 `得分为零的原因` 字段中选择以下之一:
+- **"原始问题无动机"**:原始问题是纯名词短语,无法识别任何动作意图
+- **"sug词条无动机"**:sug词条中不包含任何动作意图
+- **"动机不匹配"**:双方都有动作,但完全无关联
+- **"不适用"**:得分不为零时使用此默认值
+
+---
+
+# 输出格式
+输出结果必须为一个 **JSON 格式**,包含以下内容:
+```json
+{
+  "原始问题核心动机提取": {
+    "简要说明核心动机": ""
+  },
+  "动机维度得分": "-1到1之间的小数",
+  "简要说明动机维度相关度理由": "评估该sug词条与原始问题动机匹配程度的理由,包含作用域覆盖情况",
+  "得分为零的原因": "原始问题无动机/sug词条无动机/动机不匹配/不适用"
+}
+```
+
+**输出约束(非常重要)**:
+1. **字符串长度限制**:\"简要说明动机维度相关度理由\"字段必须控制在**150字以内**
+2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
+3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
+
+---
+
+# 核心原则总结
+1. **只评估动作**:完全聚焦于动作意图,不管对象和场景
+2. **作用域识别**:识别作用域但只评估动机层
+3. **严格标准一致性**:对所有用例使用相同的评估标准,避免评分飘移
+4. **理由纯粹**:评分理由只能谈动作,不能谈对象、场景、主题
+""".strip()
+
+# 品类评估 prompt
+category_evaluation_instructions = """
+# 角色
+你是**专业的内容主体评估专家**。
+任务:判断<平台sug词条>与<原始问题>的**内容主体匹配度**,给出**-1到1之间**的数值评分。
+
+---
+
+# 输入信息
+- **<原始问题>**:用户的完整需求描述
+- **<平台sug词条>**:待评估的词条,可能是单个或多个作用域的组合
+---
+
+
+# 核心约束
+
+## 维度独立性声明
+【严格约束】本评估**仅评估内容主体维度**:
+- **只评估**:名词主体 + 限定词(地域、时间、场景、质量等)
+- **完全忽略**:动作、意图、目的
+- **评估重点**:内容本身的主题和属性
+
+---
+
+# 作用域与内容主体
+
+## 什么是作用域?
+**作用域 = 动机层 + 对象层 + 场景层**
+
+在Prompt2中:
+- **动机层(动作)完全忽略**
+- **只评估对象层 + 场景层(限定词)**
+
+## 内容主体的构成
+
+**内容主体 = 核心名词 + 限定词**
+
+
+---
+
+# 作用域覆盖度评估
+
+## 核心原则:越完整越高分
+
+**完整性公式**:
+```
+作用域覆盖度 = sug词条包含的作用域元素 / 原始问题的作用域元素总数
+```
+
+**评分影响**:
+- 覆盖度100% → 基础高分(0.9+)
+- 覆盖度50-99% → 中高分(0.6-0.9)
+- 覆盖度<50% → 中低分(0.3-0.6)
+- 覆盖度=0 → 低分或0分
+
+---
+
+## 部分作用域的处理
+
+### 情况1:sug词条包含原始问题的所有对象层和场景层元素
+**评分**:0.95-1.0
+
+**示例**:
+```
+原始问题:"川西秋季风光摄影素材"
+- 对象层:摄影素材
+- 场景层:川西 + 秋季 + 风光
+
+Sug词条:"川西秋季风光摄影作品"
+- 对象层:摄影作品(≈素材)
+- 场景层:川西 + 秋季 + 风光
+- 覆盖度:100%
+- 评分:0.98
+```
+
+### 情况2:sug词条包含部分场景层元素
+**评分**:根据覆盖比例
+
+**示例**:
+```
+原始问题:"川西秋季风光摄影素材"
+- 对象层:摄影素材
+- 场景层:川西 + 秋季 + 风光(3个元素)
+
+Sug词条:"川西风光摄影素材"
+- 对象层:摄影素材 ✓
+- 场景层:川西 + 风光(2个元素)
+- 覆盖度:(1+2)/(1+3) = 75%
+- 评分:0.85
+```
+
+### 情况3:sug词条只包含对象层,无场景层
+**评分**:根据对象匹配度和覆盖度
+
+**示例**:
+```
+原始问题:"川西秋季风光摄影素材"
+- 对象层:摄影素材
+- 场景层:川西 + 秋季 + 风光
+
+Sug词条:"摄影素材"
+- 对象层:摄影素材 ✓
+- 场景层:无
+- 覆盖度:1/4 = 25%
+- 评分:0.50(对象匹配但缺失所有限定)
+```
+
+### 情况4:sug词条只包含场景层,无对象层
+**评分**:较低分
+
+**示例**:
+```
+原始问题:"川西旅行行程规划"
+- 对象层:旅行行程
+- 场景层:川西
+
+Sug词条:"川西"
+- 对象层:无
+- 场景层:川西 ✓
+- 覆盖度:1/2 = 50%
+- 评分:0.35(只有场景,缺失核心对象)
+```
+
+---
+
+# 评估核心原则
+
+## 原则1:只看表面词汇,禁止联想推演
+**严格约束**:只能基于sug词实际包含的词汇评分
+
+**错误案例**:
+- ❌ "川西旅行" vs "旅行"
+  - 错误:"旅行可以包括川西,所以有关联" → 评分0.7
+  - 正确:"sug词只有'旅行',无'川西',缺失地域限定" → 评分0.50
+
+
+---
+
+# 评分标准
+
+## 【正向匹配】
+
++0.95~1.0: 核心主体+所有关键限定词完全匹配
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西秋季风光摄影作品"
+
++0.75~0.95: 核心主体匹配,存在限定词匹配
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西风光摄影素材"(缺失"秋季")
+
++0.5~0.75: 核心主体匹配,无限定词匹配或合理泛化
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"四川风光摄影"
+
++0.3~0.5: 核心主体匹配,但限定词缺失或存在语义错位
+  - 特别注意"语义身份"差异,主体词出现但上下文语义不同
+  - 例:
+    · "猫咪的XX行为"(猫咪是行为者)
+    · vs "用猫咪表达XX的梗图"(猫咪是媒介)
+    · 虽都含"猫咪+XX",但语义角色不同
+
++0.2~0.3: 主体词不匹配,限定词缺失或错位
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"风光摄影入门"
+
++0.05~0.2: 主体词过度泛化或仅抽象相似
+  - 例: sug词是通用概念,原始问题是特定概念
+    sug词"每日计划"(通用)vs 原始问题 "川西旅行行程"(特定)
+      → 评分:0.08
+
+【中性/无关】
+0: 类别明显不同,没有明确目的,无明确关联
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"人像摄影素材"
+  - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
+
+【负向偏离】
+-0.2~-0.05: 主体词或限定词存在误导性
+  - 例: 原始问题"免费摄影素材" vs sug词"付费摄影素材库"
+
+-0.5~-0.25: 主体词明显错位或品类冲突
+  - 例: 原始问题"风光摄影素材" vs sug词"人像修图教程"
+
+-1.0~-0.55: 完全错误的品类或有害引导
+  - 例: 原始问题"正版素材获取" vs sug词"盗版素材下载"
+
+
+---
+
+# 输出格式
+输出结果必须为一个 **JSON 格式**,包含以下内容:
+```json
+{
+  "品类维度得分": "-1到1之间的小数",
+  "简要说明品类维度相关度理由": "评估该sug词条与原始问题品类匹配程度的理由,包含作用域覆盖理由"
+}
+```
+
+**输出约束(非常重要)**:
+1. **字符串长度限制**:\"简要说明品类维度相关度理由\"字段必须控制在**150字以内**
+2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
+3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
+
+---
+
+# 核心原则总结
+
+1. **只看名词和限定词**:完全忽略动作和意图
+2. **作用域覆盖优先**:覆盖的作用域元素越多,分数越高
+3. **禁止联想推演**:只看sug词实际包含的词汇
+4. **通用≠特定**:通用概念不等于特定概念
+5. **理由纯粹**:评分理由只能谈对象、限定词、覆盖度
+""".strip()
+
+# 延伸词评估 prompt
+extension_word_evaluation_instructions = """
+# 角色
+你是**专业的延伸词语义评估专家**。
+任务:识别<平台sug词条>中的延伸词,评估其对原始问题作用域的补全度和目的贡献度,给出**-1到1之间**的数值评分。
+
+---
+# 输入信息
+- **<原始问题>**:用户的完整需求描述
+- **<平台sug词条>**:待评估的词条,可能是单个或多个作用域的组合
+---
+
+# 核心概念
+
+## 什么是延伸词?
+**延伸词**:<平台sug词条>中出现,但不属于<原始问题>作用域范围内的词汇或概念
+
+**关键判断**:
+```
+IF sug词的词汇属于原始问题的作用域元素(动机/对象/场景):
+   → 不是延伸词,是作用域内的词
+
+IF sug词的词汇不属于原始问题的作用域:
+   → 是延伸词
+   → 由Prompt3评估
+```
+
+---
+
+# 作用域与延伸词
+
+## 作用域
+**作用域 = 动机层 + 对象层 + 场景层**
+
+**非延伸词示例**(属于作用域内):
+```
+原始问题:"川西旅行行程规划"
+作用域:
+- 动机层:规划
+- 对象层:旅行行程
+- 场景层:川西
+
+Sug词条:"川西旅行行程规划攻略"
+- "川西"→ 属于场景层,不是延伸词
+- "旅行"→ 属于对象层,不是延伸词
+- "行程"→ 属于对象层,不是延伸词
+- "规划"→ 属于动机层,不是延伸词
+- "攻略"→ 与"规划"同义,不是延伸词
+- 结论:无延伸词
+```
+
+**延伸词示例**(不属于作用域):
+```
+原始问题:"川西旅行行程规划"
+作用域:规划 + 旅行行程 + 川西
+
+Sug词条:"川西旅行行程规划住宿推荐"
+- "住宿推荐"→ 不属于原始问题任何作用域
+- 结论:延伸词 = ["住宿推荐"]
+```
+
+---
+
+# 延伸词识别方法
+
+## 步骤1:提取原始问题的作用域元素
+```
+动机层:提取动作及其同义词
+对象层:提取核心名词及其同义词
+场景层:提取所有限定词
+```
+
+## 步骤2:提取sug词条的所有关键词
+```
+提取sug词条中的所有实词(名词、动词、形容词)
+```
+
+## 步骤3:匹配判定
+```
+FOR 每个sug词条关键词:
+   IF 该词 ∈ 原始问题作用域元素(包括同义词):
+      → 不是延伸词
+   ELSE:
+      → 是延伸词
+```
+
+## 步骤4:同义词/相近词判定规则
+
+### 不算延伸词的情况:
+**同义词**:
+- 行程 ≈ 路线 ≈ 安排 ≈ 计划
+- 获取 ≈ 下载 ≈ 寻找 ≈ 收集
+- 技巧 ≈ 方法 ≈ 教程 ≈ 攻略
+- 素材 ≈ 资源 ≈ 作品 ≈ 内容
+
+**具体化/细化**:
+- 原始:"川西旅游" + sug词:"稻城亚丁"(川西的具体地点)→ 不算延伸
+- 原始:"摄影技巧" + sug词:"风光摄影"(摄影的细化)→ 不算延伸
+- 原始:"素材" + sug词:"高清素材"(素材的质量细化)→ 不算延伸
+
+**判定逻辑**:
+```
+IF sug词的概念是原始问题概念的子集/下位词/同义词:
+   → 不算延伸词
+   → 视为对原问题的细化或重述
+```
+
+---
+
+### 算延伸词的情况:
+
+**新增维度**:原始问题未涉及的信息维度
+- 原始:"川西旅行" + sug词:"住宿" → 延伸词
+- 原始:"摄影素材" + sug词:"版权" → 延伸词
+
+**新增限定条件**:原始问题未提及的约束
+- 原始:"素材获取" + sug词:"免费" → 延伸词
+- 原始:"旅行行程" + sug词:"7天" → 延伸词
+
+**扩展主题**:相关但非原问题范围
+- 原始:"川西行程" + sug词:"美食推荐" → 延伸词
+- 原始:"摄影技巧" + sug词:"后期修图" → 延伸词
+
+**工具/方法**:原始问题未提及的具体工具
+- 原始:"视频剪辑" + sug词:"PR软件" → 延伸词
+- 原始:"图片处理" + sug词:"PS教程" → 延伸词
+
+---
+
+# 延伸词类型与评分
+
+## 核心评估维度:对原始问题作用域的贡献
+
+### 维度1:作用域补全度
+延伸词是否帮助sug词条更接近原始问题的完整作用域?
+
+
+### 维度2:目的达成度
+延伸词是否促进原始问题核心目的的达成?
+---
+####类型1:作用域增强型
+**定义**:延伸词是原始问题核心目的,或补全关键作用域
+**得分范围**:+0.12~+0.20
+
+**判定标准**:
+- 使sug词条更接近原始问题的完整需求
+---
+
+####类型2:作用域辅助型
+**定义**:延伸词对核心目的有辅助作用,但非必需
+
+**得分范围**:+0.05~+0.12
+
+**判定标准**:
+- sug词条更丰富但不改变原始需求核心
+
+---
+
+####类型3:作用域无关型
+**定义**:延伸词与核心目的无实质关联
+
+**得分**:0
+
+**示例**:
+- 原始:"如何拍摄风光" + 延伸词:"相机品牌排行"
+  - 评分:0
+  - 理由:品牌排行与拍摄技巧无关
+
+---
+
+####类型4:作用域稀释型(轻度负向)
+**定义**:延伸词稀释原始问题的聚焦度,降低内容针对性
+
+**得分范围**:-0.08~-0.18
+
+**判定标准**:
+- 引入无关信息,分散注意力
+- 降低内容的专注度和深度
+- 使sug词条偏离原始问题的核心
+
+**示例**:
+- 原始:"专业风光摄影技巧" + 延伸词:"手机拍照"
+  - 评分:-0.12
+  - 理由:手机拍照与专业摄影需求不符,稀释专业度
+
+- 原始:"川西深度游攻略" + 延伸词:"周边一日游"
+  - 评分:-0.10
+  - 理由:一日游与深度游定位冲突,稀释深度
+
+
+---
+
+# 特殊情况处理
+
+## 情况1:多个延伸词同时存在
+**处理方法**:分别评估每个延伸词,然后综合
+
+**综合规则**:
+```
+延伸词总得分 = Σ(每个延伸词得分) / 延伸词数量
+
+考虑累积效应:
+- 多个增强型延伸词 → 总分可能超过单个最高分,但上限+0.25
+- 正负延伸词并存 → 相互抵消
+- 多个冲突型延伸词 → 总分下限-0.60
+```
+
+**示例**:
+```
+原始:"川西旅行行程"
+Sug词条:"川西旅行行程住宿美食推荐"
+延伸词识别:
+- "住宿推荐"→ 增强型,+0.18
+- "美食推荐"→ 辅助型,+0.10
+总得分:(0.18 + 0.10) / 2 = 0.14
+```
+
+---
+
+## 情况2:无延伸词
+**处理方法**:
+```
+IF sug词条无延伸词:
+   延伸词得分 = 0
+   理由:"sug词条未引入延伸词,所有词汇均属于原始问题作用域范围"
+```
+
+---
+
+## 情况3:延伸词使sug词条更接近原始问题
+**特殊加成**:
+```
+IF 延伸词是原始问题隐含需求的显式化:
+   → 额外加成 +0.05
+```
+
+**示例**:
+```
+原始:"川西旅行" (隐含需要行程规划)
+Sug词条:"川西旅行行程规划"
+- "行程规划"可能被识别为延伸词,但它显式化了隐含需求
+- 给予额外加成
+```
+
+---
+
+# 输出格式
+输出结果必须为一个 **JSON 格式**,包含以下内容:
+```json
+{
+  "延伸词得分": "-1到1之间的小数",
+  "简要说明延伸词维度相关度理由": "评估延伸词对作用域的影响"
+}
+```
+
+**输出约束(非常重要)**:
+1. **字符串长度限制**:\"简要说明延伸词维度相关度理由\"字段必须控制在**150字以内**
+2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
+3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
+
+---
+
+# 核心原则总结
+
+1. **严格区分**:作用域内的词 ≠ 延伸词
+2. **同义词/细化词不算延伸**:属于作用域范围的词由其他prompt评估
+3. **作用域导向**:评估延伸词是否使sug词条更接近原始问题的完整作用域
+4. **目的导向**:评估延伸词是否促进核心目的达成
+5. **分类明确**:准确判定延伸词类型
+6. **理由充分**:每个延伸词都要说明其对作用域和目的的影响
+7. **谨慎负分**:仅在明确冲突或有害时使用负分
+""".strip()
+
+# 创建评估 Agent
+motivation_evaluator = Agent[None](
+    name="动机维度评估专家(后续轮次)",
+    instructions=motivation_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=MotivationEvaluation)
+
+category_evaluator = Agent[None](
+    name="品类维度评估专家",
+    instructions=category_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=CategoryEvaluation
+)
+
+extension_word_evaluator = Agent[None](
+    name="延伸词评估专家",
+    instructions=extension_word_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=ExtensionWordEvaluation,
+    model_settings=ModelSettings(temperature=0.2)
+)
+
+
+# ============================================================================
+# Round 0 专用 Agent(v124新增 - 需求1)
+# ============================================================================
+
+# Round 0 动机评估 prompt(不含延伸词)
+round0_motivation_evaluation_instructions = """
+#角色
+你是**专业的动机意图评估专家**
+你的任务是:判断我给你的 <词条> 与 <原始问题> 的需求动机匹配度,给出 **-1 到 1 之间** 的数值评分。
+
+---
+# 输入信息
+你将接收到以下输入:
+- **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
+- **<词条>**:平台推荐的词条列表,每个词条需要单独评估。
+
+# 核心约束
+
+## 维度独立性声明
+【严格约束】本评估**仅评估动机意图维度**:
+- **只评估** 用户"想要做什么",即原始问题的行为意图和目的
+- 核心是 **动词**:获取、学习、拍摄、制作、寻找等
+- 包括:核心动作 + 使用场景 + 最终目的
+- **评估重点**:动作本身及其语义方向
+ **禁止使用"主题相关"作为评分依据**:评分理由中不得出现"主题"、"内容"、"话题"等词
+
+---
+
+# 作用域与动作意图
+
+## 什么是作用域?
+**作用域 = 动机层 + 对象层 + 场景层**
+
+## 动作意图的识别
+
+### 方法1: 显性动词直接提取
+
+当原始问题明确包含动词时,直接提取
+示例:
+"如何获取素材" → 核心动机 = "获取"
+"寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
+"制作视频教程" → 核心动机 = "制作"
+
+### 方法2: 隐性动词语义推理
+当原始问题没有显性动词时,需要结合上下文推理
+
+如果原始问题是纯名词短语,无任何动作线索:
+→ 核心动机 = 无法识别
+→ 在此情况下,动机维度得分应为 0。
+示例:
+"摄影" → 无法识别动机,动机维度得分 = 0
+"川西风光" → 无法识别动机,动机维度得分 = 0
+
+---
+
+# 部分作用域的处理
+
+## 情况1:词条是原始问题的部分作用域
+
+当词条只包含原始问题的部分作用域时,需要判断:
+1. 词条是否包含动作意图
+2. 如果包含,动作是否匹配
+
+**示例**:
+```
+原始问题:"川西旅行行程规划"
+- 完整作用域:规划(动作)+ 旅行行程(对象)+ 川西(场景)
+
+词条:"川西旅行"
+- 包含作用域:旅行(部分对象)+ 川西(场景)
+- 缺失作用域:规划(动作)
+- 动作意图评分:0(无动作意图)
+```
+
+**评分原则**:
+- 如果sug词条缺失动机层(动作) → 动作意图得分 = 0
+- 如果sug词条包含动机层 → 按动作匹配度评分
+
+
+---
+
+#评分标准:
+
+【正向匹配】
+### +0.9~1.0:核心动作完全一致
+**示例**:
+- "规划旅行行程" vs "安排旅行路线" → 0.98
+  - 规划≈安排,语义完全一致
+- "获取素材" vs "下载素材" → 0.97
+  - 获取≈下载,语义完全一致
+
+- 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
+例: 原始问题"扣除猫咪主体的方法" vs 词条"扣除猫咪眼睛的方法"(子集但目的一致
+**注意**:此处不考虑对象和场景是否一致,只看动作本身
+
+###+0.75~0.90: 核心动作语义相近或为同义表达
+  - 例: 原始问题"如何获取素材" vs 词条"如何下载素材"
+  - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
+
+### +0.50~0.75:动作意图相关
+**判定标准**:
+- 动作是实现原始意图的相关路径
+- 或动作是原始意图的前置/后置步骤
+
+**示例**:
+- "获取素材" vs "管理素材" → 0.65
+  - 管理是获取后的相关步骤
+- "规划行程" vs "预订酒店" → 0.60
+  - 预订是规划的具体实施步骤
+
+### +0.25~0.50:动作意图弱相关
+**判定标准**:
+- 动作在同一大类但方向不同
+- 或动作有间接关联
+
+**示例**:
+- "学习摄影技巧" vs "欣赏摄影作品" → 0.35
+  - 都与摄影有关,但学习≠欣赏
+- "规划旅行" vs "回忆旅行" → 0.30
+  - 都与旅行有关,但方向不同
+
+---
+
+## 【中性/无关】
+
+### 0:无动作意图或动作完全无关
+**适用场景**:
+1. 原始问题或词条无法识别动作
+2. 两者动作意图完全无关
+
+**示例**:
+- "如何获取素材" vs "摄影器材" → 0
+  - sug词条无动作意图
+- "川西风光" vs "风光摄影作品" → 0
+  - 原始问题无动作意图
+
+**理由模板**:
+- "sug词条无明确动作意图,无法评估动作匹配度"
+- "原始问题无明确动作意图,动作维度得分为0"
+
+---
+
+## 【负向偏离】
+
+### -0.2~-0.05:动作方向轻度偏离
+**示例**:
+- "学习摄影技巧" vs "销售摄影课程" → -0.10
+  - 学习 vs 销售,方向有偏差
+
+### -0.5~-0.25:动作意图明显冲突
+**示例**:
+- "获取免费素材" vs "购买素材" → -0.35
+  - 获取免费 vs 购买,明显冲突
+
+### -1.0~-0.55:动作意图完全相反
+**示例**:
+- "下载素材" vs "上传素材" → -0.70
+  - 下载 vs 上传,方向完全相反
+
+---
+
+# 输出要求
+
+输出结果必须为一个 **JSON 格式**,包含以下内容:
+```json
+{
+  "原始问题核心动机提取": {
+    "简要说明核心动机": ""
+  },
+  "动机维度得分": "-1到1之间的小数",
+  "简要说明动机维度相关度理由": "评估该词条与原始问题动机匹配程度的理由"
+}
+```
+
+#注意事项:
+始终围绕动机维度:所有评估都基于"动机"维度,不偏离
+核心动机必须是动词:在评估前,必须先提取原始问题的核心动机(动词),这是整个评估的基础
+严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
+负分使用原则:仅当词条对原始问题动机产生误导、冲突或有害引导时给予负分
+零分使用原则:当词条与原始问题动机无明确关联,既不相关也不冲突时给予零分,或原始问题无法识别动机时。
+""".strip()
+
+# Round 0 品类评估 prompt(不含延伸词)
+round0_category_evaluation_instructions = """
+#角色
+你是一个 **专业的语言专家和语义相关性评判专家**。
+你的任务是:判断我给你的 <词条> 与 <原始问题> 的内容主体和限定词匹配度,给出 **-1 到 1 之间** 的数值评分。
+
+---
+# 核心概念与方法论
+
+## 评估维度
+本评估系统围绕 **品类维度** 进行:
+
+#  维度独立性警告
+【严格约束】本评估**只评估品类维度**,,必须遵守以下规则:
+1. **只看名词和限定词**:评估时只考虑主体、限定词的匹配度
+2. **完全忽略动词**:动作意图、目的等动机信息对本维度评分无影响
+
+### 品类维度
+**定义:** 用户"关于什么内容",即原始问题的主题对象和限定词
+- 核心是 **名词+限定词**:川西秋季风光摄影素材
+- 包括:核心主体 + 地域限定 + 时间限定 + 质量限定等
+
+## ⚠️ 品类评估核心原则(必读)
+
+### 原则1:只看词条表面,禁止联想推演
+- 只能基于词条实际包含的词汇评分
+- 禁止推测"可能包含"、"可以理解为"
+
+**错误示例:**
+原始问题:"川西旅行行程" vs 词条:"每日计划"
+- 错误 "每日计划可以包含旅行规划,所以有关联" → 这是不允许的联想
+- 正确: "词条只有'每日计划',无'旅行'字眼,品类不匹配" → 正确判断
+
+### 原则2:通用概念 ≠ 特定概念
+- **通用**:计划、方法、技巧、素材(无领域限定)
+- **特定**:旅行行程、摄影技巧、烘焙方法(有明确领域)
+
+IF 词条是通用 且 原始问题是特定:
+   → 品类不匹配 → 评分0.05~0.1
+关键:通用概念不等于特定概念,不能因为"抽象上都是规划"就给分
+
+---
+
+# 输入信息
+你将接收到以下输入:
+- **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
+- **<词条>**:平台推荐的词条列表,每个词条需要单独评估。
+
+
+#判定流程
+#评估架构
+
+输入: <原始问题> + <词条>
+         ↓
+【品类维度相关性判定】
+    ├→ 步骤1: 评估<词条>与<原始问题>的内容主体和限定词匹配度
+    └→ 输出: -1到1之间的数值 + 判定依据
+
+
+相关度评估维度详解
+维度2: 品类维度评估
+评估对象: <词条> 与 <原始问题> 的内容主体和限定词匹配度
+
+评分标准:
+
+【正向匹配】
++0.95~1.0: 核心主体+所有关键限定词完全匹配
+  - 例: 原始问题"川西秋季风光摄影素材" vs 词条"川西秋季风光摄影作品"
+
++0.75~0.95: 核心主体匹配,存在限定词匹配
+  - 例: 原始问题"川西秋季风光摄影素材" vs 词条"川西风光摄影素材"(缺失"秋季")
+
++0.5~0.75: 核心主体匹配,无限定词匹配或合理泛化
+  - 例: 原始问题"川西秋季风光摄影素材" vs 词条"四川风光摄影"
+
++0.3~0.5: 核心主体匹配,但限定词缺失或存在语义错位
+  - 特别注意"语义身份"差异,主体词出现但上下文语义不同
+  - 例:
+    · "猫咪的XX行为"(猫咪是行为者)
+    · vs "用猫咪表达XX的梗图"(猫咪是媒介)
+    · 虽都含"猫咪+XX",但语义角色不同
+
++0.2~0.3: 主体词不匹配,限定词缺失或错位
+  - 例: 原始问题"川西秋季风光摄影素材" vs 词条"风光摄影入门"
+
++0.05~0.2: 主体词过度泛化或仅抽象相似
+  - 例: 词条是通用概念,原始问题是特定概念
+    词条"每日计划"(通用)vs 原始问题 "川西旅行行程"(特定)
+      → 评分:0.08
+
+【中性/无关】
+0: 类别明显不同,没有明确目的,无明确关联
+  - 例: 原始问题"川西秋季风光摄影素材" vs 词条"人像摄影素材"
+  - 例: 原始问题无法识别动机 且 词条也无明确动作 → 0
+
+【负向偏离】
+-0.2~-0.05: 主体词或限定词存在误导性
+  - 例: 原始问题"免费摄影素材" vs 词条"付费摄影素材库"
+
+-0.5~-0.25: 主体词明显错位或品类冲突
+  - 例: 原始问题"风光摄影素材" vs 词条"人像修图教程"
+
+-1.0~-0.55: 完全错误的品类或有害引导
+  - 例: 原始问题"正版素材获取" vs 词条"盗版素材下载"
+
+---
+
+# 输出要求
+
+输出结果必须为一个 **JSON 格式**,包含以下内容:
+```json
+{
+  "品类维度得分": "-1到1之间的小数",
+  "简要说明品类维度相关度理由": "评估该词条与原始问题品类匹配程度的理由"
+}
+```
+---
+
+#注意事项:
+始终围绕品类维度:所有评估都基于"品类"维度,不偏离
+严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
+负分使用原则:仅当词条对原始问题品类产生误导、冲突或有害引导时给予负分
+零分使用原则:当词条与原始问题品类无明确关联,既不相关也不冲突时给予零分
+""".strip()
+
+# 创建 Round 0 评估 Agent
+round0_motivation_evaluator = Agent[None](
+    name="Round 0动机维度评估专家",
+    instructions=round0_motivation_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=MotivationEvaluation,
+    model_settings=ModelSettings(temperature=0.2)
+)
+
+round0_category_evaluator = Agent[None](
+    name="Round 0品类维度评估专家",
+    instructions=round0_category_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=CategoryEvaluation,
+    model_settings=ModelSettings(temperature=0.2)
+)
+
+
+# ============================================================================
+# 域内/域间 专用 Agent(v124新增 - 需求2&3)
+# ============================================================================
+
+# 域内/域间 动机评估 prompt(不含延伸词)
+scope_motivation_evaluation_instructions = """
+# 角色
+你是**专业的动机意图评估专家**。
+任务:判断<词条>与<同一作用域词条>的**动机意图匹配度**,给出**-1到1之间**的数值评分。
+
+---
+# 输入信息
+你将接收到以下输入:
+ **<同一作用域词条>**:用户的初始查询问题,代表用户的真实需求意图。
+- **<词条>**:平台推荐的词条列表,每个词条需要单独评估。
+---
+# 评估架构
+
+输入: <同一作用域词条> + <词条>
+         ↓
+【动机维度相关性判定】
+    ├→ 步骤1: 评估<词条>与<同一作用域词条>的需求动机匹配度
+    └→ 输出: -1到1之间的数值 + 判定依据
+
+# 核心约束
+## 维度独立性声明
+【严格约束】本评估**仅评估动机意图维度**:
+- **只评估** 用户"想要做什么",即原始问题的行为意图和目的
+- 核心是 **动词**:获取、学习、拍摄、制作、寻找等
+- 包括:核心动作 + 使用场景 + 最终目的
+- **评估重点**:动作本身及其语义方向
+ **禁止使用"主题相关"作为评分依据**:评分理由中不得出现"主题"、"内容"、"话题"等词
+
+---
+
+# 作用域与动作意图
+
+## 什么是作用域?
+**作用域 = 动机层 + 对象层 + 场景层**
+
+当前任务:
+- **只提取动机层**:动作意图(获取、学习、规划、拍摄等)
+
+## 动作意图的识别
+
+### 1. 动机维度
+**定义:** 用户"想要做什么",即原始问题的行为意图和目的
+- 核心是 **动词**:获取、学习、拍摄、制作、寻找等
+- 包括:核心动作 + 使用场景 + 最终目的
+
+### 方法1: 显性动词直接提取
+
+当原始问题明确包含动词时,直接提取
+示例:
+"如何获取素材" → 核心动机 = "获取"
+"寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
+"制作视频教程" → 核心动机 = "制作"
+
+### 方法2: 隐性动词语义推理
+当原始问题没有显性动词时,需要结合上下文推理
+
+
+---
+
+# 评分标准
+
+## 【正向匹配】
+
+### +0.9~1.0:核心动作完全一致
+**示例**:
+- "规划旅行行程" vs "安排旅行路线" → 0.98
+  - 规划≈安排,语义完全一致
+- "获取素材" vs "下载素材" → 0.97
+  - 获取≈下载,语义完全一致
+
+- 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
+例: 原始问题"扣除猫咪主体的方法" vs sug词"扣除猫咪眼睛的方法"(子集但目的一致
+**注意**:此处不考虑对象和场景是否一致,只看动作本身
+
+###+0.75~0.95: 核心动作语义相近或为同义表达
+  - 例: 原始问题"如何获取素材" vs sug词"如何下载素材"
+  - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
+
+### +0.50~0.75:动作意图相关
+**判定标准**:
+- 动作是实现原始意图的相关路径
+- 或动作是原始意图的前置/后置步骤
+
+**示例**:
+- "获取素材" vs "管理素材" → 0.65
+  - 管理是获取后的相关步骤
+- "规划行程" vs "预订酒店" → 0.60
+  - 预订是规划的具体实施步骤
+
+### +0.25~0.50:动作意图弱相关
+**判定标准**:
+- 动作在同一大类但方向不同
+- 或动作有间接关联
+
+**示例**:
+- "学习摄影技巧" vs "欣赏摄影作品" → 0.35
+  - 都与摄影有关,但学习≠欣赏
+- "规划旅行" vs "回忆旅行" → 0.30
+  - 都与旅行有关,但方向不同
+
+---
+
+## 【中性/无关】
+
+### 0:无动作意图或动作完全无关
+**适用场景**:
+1. 原始问题或词条无法识别动作
+2. 两者动作意图完全无关
+
+**示例**:
+- "如何获取素材" vs "摄影器材" → 0
+  - 词条无动作意图
+- "川西风光" vs "风光摄影作品" → 0
+  - 原始问题无动作意图
+
+**理由模板**:
+- "词条无明确动作意图,无法评估动作匹配度"
+- "原始问题无明确动作意图,动作维度得分为0"
+
+---
+
+## 【负向偏离】
+
+### -0.2~-0.05:动作方向轻度偏离
+**示例**:
+- "学习摄影技巧" vs "销售摄影课程" → -0.10
+  - 学习 vs 销售,方向有偏差
+
+### -0.5~-0.25:动作意图明显冲突
+**示例**:
+- "获取免费素材" vs "购买素材" → -0.35
+  - 获取免费 vs 购买,明显冲突
+
+### -1.0~-0.55:动作意图完全相反
+**示例**:
+- "下载素材" vs "上传素材" → -0.70
+  - 下载 vs 上传,方向完全相反
+
+---
+
+# 输出格式
+输出结果必须为一个 **JSON 格式**,包含以下内容:
+```json
+{
+  "原始问题核心动机提取": {
+    "简要说明核心动机": ""
+  },
+  "动机维度得分": "-1到1之间的小数",
+  "简要说明动机维度相关度理由": "评估该词条与该条作用域匹配程度的理由",
+  "得分为零的原因": "原始问题无动机/sug词条无动机/动机不匹配/不适用"
+}
+```
+
+---
+
+# 核心原则总结
+1. **只评估动作**:完全聚焦于动作意图,不管对象和场景
+2. **作用域识别**:识别作用域但只评估动机层
+3. **严格标准一致性**:对所有用例使用相同的评估标准,避免评分飘移
+4. **理由纯粹**:评分理由只能谈动作,不能谈对象、场景、主题
+""".strip()
+
+# 域内/域间 品类评估 prompt(不含延伸词)
+scope_category_evaluation_instructions = """
+#角色
+你是一个 **专业的语言专家和语义相关性评判专家**。
+你的任务是:判断我给你的 <词条> 与 <同一作用域词条> 的内容主体和限定词匹配度,给出 **-1 到 1 之间** 的数值评分。
+
+---
+# 输入信息
+你将接收到以下输入:
+- **<同一作用域词条>**:用户的初始查询问题,代表用户的真实需求意图。
+- **<词条>**:平台推荐的词条列表,每个词条需要单独评估。
+
+---
+#判定流程
+#评估架构
+
+输入: <同一作用域词条> + <词条>
+         ↓
+【品类维度相关性判定】
+    ├→ 步骤1: 评估<词条>与<同一作用域词条>的内容主体和限定词匹配度
+    └→ 输出: -1到1之间的数值 + 判定依据
+
+---
+
+# 核心概念与方法论
+
+## 评估维度
+本评估系统围绕 **品类维度** 进行:
+
+#  维度独立性警告
+【严格约束】本评估**只评估品类维度**,,必须遵守以下规则:
+1. **只看名词和限定词**:评估时只考虑主体、限定词的匹配度
+2. **完全忽略动词**:动作意图、目的等动机信息对本维度评分无影响
+
+### 品类维度
+**定义:** 用户"关于什么内容",即原始问题的主题对象和限定词
+- 核心是 **名词+限定词**:川西秋季风光摄影素材
+- 包括:核心主体 + 地域限定 + 时间限定 + 质量限定等
+
+## ⚠️ 品类评估核心原则(必读)
+
+### 原则1:只看词条表面,禁止联想推演
+- 只能基于sug词实际包含的词汇评分
+- 禁止推测"可能包含"、"可以理解为"
+
+**错误示例:**
+原始问题:"川西旅行行程" vs sug词:"每日计划"
+- 错误 "每日计划可以包含旅行规划,所以有关联" → 这是不允许的联想
+- 正确: "sug词只有'每日计划',无'旅行'字眼,品类不匹配" → 正确判断
+
+### 原则2:通用概念 ≠ 特定概念
+- **通用**:计划、方法、技巧、素材(无领域限定)
+- **特定**:旅行行程、摄影技巧、烘焙方法(有明确领域)
+
+IF sug词是通用 且 原始问题是特定:
+   → 品类不匹配 → 评分0.05~0.1
+关键:通用概念不等于特定概念,不能因为"抽象上都是规划"就给分
+
+---
+#相关度评估维度详解
+
+##评估对象: <词条> 与 <同一作用域词条> 的内容主体和限定词匹配度
+
+评分标准:
+
+【正向匹配】
++0.95~1.0: 核心主体+所有关键限定词完全匹配
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西秋季风光摄影作品"
+
++0.75~0.95: 核心主体匹配,存在限定词匹配
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西风光摄影素材"(缺失"秋季")
+
++0.5~0.75: 核心主体匹配,无限定词匹配或合理泛化
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"四川风光摄影"
+
++0.3~0.5: 核心主体匹配,但限定词缺失或存在语义错位
+  - 特别注意"语义身份"差异,主体词出现但上下文语义不同
+  - 例:
+    · "猫咪的XX行为"(猫咪是行为者)
+    · vs "用猫咪表达XX的梗图"(猫咪是媒介)
+    · 虽都含"猫咪+XX",但语义角色不同
+
++0.2~0.3: 主体词不匹配,限定词缺失或错位
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"风光摄影入门"
+
++0.05~0.2: 主体词过度泛化或仅抽象相似
+  - 例: sug词是通用概念,原始问题是特定概念
+    sug词"每日计划"(通用)vs 原始问题 "川西旅行行程"(特定)
+      → 评分:0.08
+
+【中性/无关】
+0: 类别明显不同,没有明确目的,无明确关联
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"人像摄影素材"
+  - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
+
+【负向偏离】
+-0.2~-0.05: 主体词或限定词存在误导性
+  - 例: 原始问题"免费摄影素材" vs sug词"付费摄影素材库"
+
+-0.5~-0.25: 主体词明显错位或品类冲突
+  - 例: 原始问题"风光摄影素材" vs sug词"人像修图教程"
+
+-1.0~-0.55: 完全错误的品类或有害引导
+  - 例: 原始问题"正版素材获取" vs sug词"盗版素材下载"
+
+---
+
+# 输出要求
+
+输出结果必须为一个 **JSON 格式**,包含以下内容:
+```json
+{
+  "品类维度得分": "-1到1之间的小数",
+  "简要说明品类维度相关度理由": "评估该词条与同一作用域词条品类匹配程度的理由"
+}
+```
+---
+
+#注意事项:
+始终围绕品类维度:所有评估都基于"品类"维度,不偏离
+严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
+负分使用原则:仅当词条对原始问题品类产生误导、冲突或有害引导时给予负分
+零分使用原则:当词条与原始问题品类无明确关联,既不相关也不冲突时给予零分
+""".strip()
+
+# 创建域内/域间评估 Agent
+scope_motivation_evaluator = Agent[None](
+    name="域内动机维度评估专家",
+    instructions=scope_motivation_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=MotivationEvaluation,
+   model_settings=ModelSettings(temperature=0.2)
+)
+
+scope_category_evaluator = Agent[None](
+    name="域内品类维度评估专家",
+    instructions=scope_category_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=CategoryEvaluation,
+    model_settings=ModelSettings(temperature=0.2)
+)
+
+
+# ============================================================================
+# v120 保留但不使用的 Agent(v121不再使用)
+# ============================================================================
+
+# # Agent 3: 加词选择专家(旧版 - v120使用,v121不再使用)
+# class WordCombination(BaseModel):
+#     """单个词组合"""
+#     selected_word: str = Field(..., description="选择的词")
+#     combined_query: str = Field(..., description="组合后的新query")
+#     reasoning: str = Field(..., description="选择理由")
+
+# class WordSelectionTop5(BaseModel):
+#     """加词选择结果(Top 5)"""
+#     combinations: list[WordCombination] = Field(
+#         ...,
+#         description="选择的Top 5组合(不足5个则返回所有)",
+#         min_items=1,
+#         max_items=5
+#     )
+#     overall_reasoning: str = Field(..., description="整体选择思路")
+
+# word_selection_instructions 已删除 (v121不再使用)
+
+# word_selector = Agent[None](
+#     name="加词组合专家",
+#     instructions=word_selection_instructions,
+#     model=get_model(MODEL_NAME),
+#     output_type=WordSelectionTop5,
+#     model_settings=ModelSettings(temperature=0.2),
+# )
+
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+# ============================================================================
+# v121 新增辅助函数
+# ============================================================================
+
+def get_ordered_subsets(words: list[str], min_len: int = 1) -> list[list[str]]:
+    """
+    生成words的所有有序子集(可跳过但不可重排)
+
+    使用 itertools.combinations 生成索引组合,保持原始顺序
+
+    Args:
+        words: 词列表
+        min_len: 子集最小长度
+
+    Returns:
+        所有可能的有序子集列表
+
+    Example:
+        words = ["川西", "秋季", "风光"]
+        结果:
+        - 长度1: ["川西"], ["秋季"], ["风光"]
+        - 长度2: ["川西", "秋季"], ["川西", "风光"], ["秋季", "风光"]
+        - 长度3: ["川西", "秋季", "风光"]
+        共 C(3,1) + C(3,2) + C(3,3) = 3 + 3 + 1 = 7种
+    """
+    from itertools import combinations
+
+    subsets = []
+    n = len(words)
+
+    # 遍历所有可能的长度(从min_len到n)
+    for r in range(min_len, n + 1):
+        # 生成长度为r的所有索引组合
+        for indices in combinations(range(n), r):
+            # 按照原始顺序提取词
+            subset = [words[i] for i in indices]
+            subsets.append(subset)
+
+    return subsets
+
+
+def generate_domain_combinations(segments: list[Segment], n_domains: int) -> list[DomainCombination]:
+    """
+    生成N域组合
+
+    步骤:
+    1. 从len(segments)个域中选择n_domains个域(组合,保持顺序)
+    2. 对每个选中的域,生成其words的所有有序子集
+    3. 计算笛卡尔积,生成所有可能的组合
+
+    Args:
+        segments: 语义片段列表
+        n_domains: 参与组合的域数量
+
+    Returns:
+        所有可能的N域组合列表
+
+    Example:
+        有4个域: [疑问标记, 核心动作, 修饰短语, 中心名词]
+        n_domains=2时,选择域的方式: C(4,2) = 6种
+
+        假设选中[核心动作, 中心名词]:
+        - 核心动作的words: ["获取"], 子集: ["获取"]
+        - 中心名词的words: ["风光", "摄影", "素材"], 子集: 7种
+        则该域选择下的组合数: 1 * 7 = 7种
+    """
+    from itertools import combinations, product
+
+    all_combinations = []
+    n = len(segments)
+
+    # 检查参数有效性
+    if n_domains > n or n_domains < 1:
+        return []
+
+    # 1. 选择n_domains个域(保持原始顺序)
+    for domain_indices in combinations(range(n), n_domains):
+        selected_segments = [segments[i] for i in domain_indices]
+
+        # 新增:如果所有域都只有1个词,跳过(单段落单词不组合)
+        if all(len(seg.words) == 1 for seg in selected_segments):
+            continue
+
+        # 2. 为每个选中的域生成其words的所有有序子集
+        domain_subsets = []
+        for seg in selected_segments:
+            if len(seg.words) == 0:
+                # 如果某个域没有词,跳过该域组合
+                domain_subsets = []
+                break
+            subsets = get_ordered_subsets(seg.words, min_len=1)
+            domain_subsets.append(subsets)
+
+        # 如果某个域没有词,跳过
+        if len(domain_subsets) != n_domains:
+            continue
+
+        # 3. 计算笛卡尔积
+        for word_combination in product(*domain_subsets):
+            # word_combination 是一个tuple,每个元素是一个词列表
+            # 例如: (["获取"], ["风光", "摄影"])
+
+            # 计算总词数
+            total_words = sum(len(words) for words in word_combination)
+
+            # 如果总词数<=1,跳过(组词必须大于1个词)
+            if total_words <= 1:
+                continue
+
+            # 将所有词连接成一个字符串
+            combined_text = "".join(["".join(words) for words in word_combination])
+
+            # 生成类型标签
+            type_labels = [selected_segments[i].type for i in range(n_domains)]
+            type_label = "[" + "+".join(type_labels) + "]"
+
+            # 创建DomainCombination对象
+            comb = DomainCombination(
+                text=combined_text,
+                domains=list(domain_indices),
+                type_label=type_label,
+                source_words=[list(words) for words in word_combination],  # 保存来源词
+                from_segments=[seg.text for seg in selected_segments]
+            )
+            all_combinations.append(comb)
+
+    return all_combinations
+
+
+def extract_words_from_segments(segments: list[Segment]) -> list[Q]:
+    """
+    从 segments 中提取所有 words,转换为 Q 对象列表
+
+    用于 Round 1 的输入:将 Round 0 的 words 转换为可用于请求SUG的 query 列表
+
+    Args:
+        segments: Round 0 的语义片段列表
+
+    Returns:
+        list[Q]: word 列表,每个 word 作为一个 Q 对象
+    """
+    q_list = []
+
+    for seg_idx, segment in enumerate(segments):
+        for word in segment.words:
+            # 从 segment.word_scores 获取该 word 的评分
+            word_score = segment.word_scores.get(word, 0.0)
+            word_reason = segment.word_reasons.get(word, "")
+
+            # 创建 Q 对象
+            q = Q(
+                text=word,
+                score_with_o=word_score,
+                reason=word_reason,
+                from_source="word",  # 标记来源为 word
+                type_label=f"[{segment.type}]",  # 保留域信息
+                domain_index=seg_idx,  # 添加域索引
+                domain_type=segment.type  # 添加域类型(如"中心名词"、"核心动作")
+            )
+            q_list.append(q)
+
+    return q_list
+
+
+# ============================================================================
+# v120 保留辅助函数
+# ============================================================================
+
+def calculate_final_score(
+    motivation_score: float,
+    category_score: float,
+    extension_score: float,
+    zero_reason: Optional[str],
+    extension_reason: str = ""
+) -> tuple[float, str]:
+    """
+    三维评估综合打分
+
+    实现动态权重分配:
+    - 情况1:标准情况 → 动机50% + 品类40% + 延伸词10%
+    - 情况2:原始问题无动机 → 品类70% + 延伸词30%
+    - 情况3:sug词条无动机 → 品类80% + 延伸词20%
+    - 情况4:无延伸词 → 动机70% + 品类30%
+    - 规则3:负分传导 → 核心维度严重负向时上限为0
+    - 规则4:完美匹配加成 → 双维度≥0.95时加成+0.10
+
+    Args:
+        motivation_score: 动机维度得分 -1~1
+        category_score: 品类维度得分 -1~1
+        extension_score: 延伸词得分 -1~1
+        zero_reason: 当motivation_score=0时的原因(可选)
+        extension_reason: 延伸词评估理由,用于判断是否无延伸词
+
+    Returns:
+        (最终得分, 规则说明)
+    """
+
+    # 情况2:原始问题无动作意图
+    if motivation_score == 0 and zero_reason == "原始问题无动机":
+        W1, W2, W3 = 0.0, 0.70, 0.30
+        base_score = category_score * W2 + extension_score * W3
+        rule_applied = "情况2:原始问题无动作意图,权重调整为 品类70% + 延伸词30%"
+
+    # 情况3:sug词条无动作意图(但原始问题有)
+    elif motivation_score == 0 and zero_reason == "sug词条无动机":
+        W1, W2, W3 = 0.0, 0.80, 0.20
+        base_score = category_score * W2 + extension_score * W3
+        rule_applied = "情况3:sug词条无动作意图,权重调整为 品类80% + 延伸词20%"
+
+    # 情况4:无延伸词
+    elif extension_score == 0:
+        W1, W2, W3 = 0.70, 0.30, 0.0
+        base_score = motivation_score * W1 + category_score * W2
+        rule_applied = "情况4:无延伸词,权重调整为 动机70% + 品类30%"
+
+    else:
+        # 情况1:标准权重
+        W1, W2, W3 = 0.50, 0.40, 0.10
+        base_score = motivation_score * W1 + category_score * W2 + extension_score * W3
+        rule_applied = ""
+
+    # 规则4:完美匹配加成
+    if motivation_score >= 0.95 and category_score >= 0.95:
+        base_score += 0.10
+        rule_applied += (" + " if rule_applied else "") + "规则4:双维度完美匹配,加成+0.10"
+
+    # 规则3:负分传导
+    if motivation_score <= -0.5 or category_score <= -0.5:
+        base_score = min(base_score, 0)
+        rule_applied += (" + " if rule_applied else "") + "规则3:核心维度严重负向,上限=0"
+
+    # 边界处理
+    final_score = max(-1.0, min(1.0, base_score))
+
+    return final_score, rule_applied
+
+
+def calculate_final_score_v2(
+    motivation_score: float,
+    category_score: float
+) -> tuple[float, str]:
+    """
+    两维评估综合打分(v124新增 - 需求1)
+
+    用于Round 0分词评估和域内/域间评估,不含延伸词维度
+
+    基础权重:动机70% + 品类30%
+
+    应用规则:
+    - 规则A:动机高分保护机制
+      IF 动机维度得分 ≥ 0.8:
+         品类得分即使为0或轻微负向(-0.2~0)
+         → 最终得分应该不低于0.7
+      解释: 当目的高度一致时,品类的泛化不应导致"弱相关"
+
+    - 规则B:动机低分限制机制
+      IF 动机维度得分 ≤ 0.2:
+         无论品类得分多高
+         → 最终得分不高于0.5
+      解释: 目的不符时,品类匹配的价值有限
+
+    - 规则C:动机负向决定机制
+      IF 动机维度得分 < 0:
+         → 最终得分为0
+      解释: 动作意图冲突时,推荐具有误导性,不应为正相关
+
+    Args:
+        motivation_score: 动机维度得分 -1~1
+        category_score: 品类维度得分 -1~1
+
+    Returns:
+        (最终得分, 规则说明)
+    """
+
+    rule_applied = ""
+
+    # 规则C:动机负向决定机制
+    if motivation_score < 0:
+        final_score = 0.0
+        rule_applied = "规则C:动机负向,最终得分=0"
+        return final_score, rule_applied
+
+    # 基础加权计算: 动机70% + 品类30%
+    base_score = motivation_score * 0.7 + category_score * 0.3
+
+    # 规则A:动机高分保护机制
+    if motivation_score >= 0.8:
+        if base_score < 0.7:
+            final_score = 0.7
+            rule_applied = f"规则A:动机高分保护(动机{motivation_score:.2f}≥0.8),最终得分下限=0.7"
+        else:
+            final_score = base_score
+            rule_applied = f"规则A:动机高分保护生效(动机{motivation_score:.2f}≥0.8),实际得分{base_score:.2f}已≥0.7"
+
+    # 规则B:动机低分限制机制
+    elif motivation_score <= 0.2:
+        if base_score > 0.5:
+            final_score = 0.5
+            rule_applied = f"规则B:动机低分限制(动机{motivation_score:.2f}≤0.2),最终得分上限=0.5"
+        else:
+            final_score = base_score
+            rule_applied = f"规则B:动机低分限制生效(动机{motivation_score:.2f}≤0.2),实际得分{base_score:.2f}已≤0.5"
+
+    # 无规则触发
+    else:
+        final_score = base_score
+        rule_applied = ""
+
+    # 边界处理
+    final_score = max(-1.0, min(1.0, final_score))
+
+    return final_score, rule_applied
+
+
+def clean_json_string(text: str) -> str:
+    """清理JSON中的非法控制字符(保留 \t \n \r)"""
+    import re
+    # 移除除了 \t(09) \n(0A) \r(0D) 之外的所有控制字符
+    return re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', text)
+
+
+def process_note_data(note: dict) -> Post:
+    """处理搜索接口返回的帖子数据"""
+    note_card = note.get("note_card", {})
+    image_list = note_card.get("image_list", [])
+    interact_info = note_card.get("interact_info", {})
+    user_info = note_card.get("user", {})
+
+    # ========== 调试日志 START ==========
+    note_id = note.get("id", "")
+    raw_title = note_card.get("display_title")  # 不提供默认值
+    raw_body = note_card.get("desc")
+    raw_type = note_card.get("type")
+
+    # 打印原始值类型和内容
+    print(f"\n[DEBUG] 处理帖子 {note_id}:")
+    print(f"  raw_title 类型: {type(raw_title).__name__}, 值: {repr(raw_title)}")
+    print(f"  raw_body 类型: {type(raw_body).__name__}, 值: {repr(raw_body)[:100] if raw_body else repr(raw_body)}")
+    print(f"  raw_type 类型: {type(raw_type).__name__}, 值: {repr(raw_type)}")
+
+    # 检查是否为 None
+    if raw_title is None:
+        print(f"  ⚠️  WARNING: display_title 是 None!")
+    if raw_body is None:
+        print(f"  ⚠️  WARNING: desc 是 None!")
+    if raw_type is None:
+        print(f"  ⚠️  WARNING: type 是 None!")
+    # ========== 调试日志 END ==========
+
+    # 提取图片URL - 使用新的字段名 image_url
+    images = []
+    for img in image_list:
+        if isinstance(img, dict):
+            # 尝试新字段名 image_url,如果不存在则尝试旧字段名 url_default
+            img_url = img.get("image_url") or img.get("url_default")
+            if img_url:
+                images.append(img_url)
+
+    # 判断类型
+    note_type = note_card.get("type", "normal")
+    video_url = ""
+    if note_type == "video":
+        video_info = note_card.get("video", {})
+        if isinstance(video_info, dict):
+            # 尝试获取视频URL
+            video_url = video_info.get("media", {}).get("stream", {}).get("h264", [{}])[0].get("master_url", "")
+
+    return Post(
+        note_id=note.get("id") or "",
+        title=note_card.get("display_title") or "",
+        body_text=note_card.get("desc") or "",
+        type=note_type,
+        images=images,
+        video=video_url,
+        interact_info={
+            "liked_count": interact_info.get("liked_count", 0),
+            "collected_count": interact_info.get("collected_count", 0),
+            "comment_count": interact_info.get("comment_count", 0),
+            "shared_count": interact_info.get("shared_count", 0)
+        },
+        note_url=f"https://www.xiaohongshu.com/explore/{note.get('id', '')}"
+    )
+
+
+async def evaluate_with_o(text: str, o: str, cache: dict[str, tuple[float, str]] | None = None) -> tuple[float, str]:
+    """评估文本与原始问题o的相关度
+
+    采用两阶段评估 + 代码计算规则:
+    1. 动机维度评估(权重70%)
+    2. 品类维度评估(权重30%)
+    3. 应用规则A/B/C调整得分
+
+    Args:
+        text: 待评估的文本
+        o: 原始问题
+        cache: 评估缓存(可选),用于避免重复评估
+
+    Returns:
+        tuple[float, str]: (最终相关度分数, 综合评估理由)
+    """
+    # 检查缓存
+    if cache is not None and text in cache:
+        cached_score, cached_reason = cache[text]
+        print(f"  ⚡ 缓存命中: {text} -> {cached_score:.2f}")
+        return cached_score, cached_reason
+
+    # 准备输入
+    eval_input = f"""
+<原始问题>
+{o}
+</原始问题>
+
+<平台sug词条>
+{text}
+</平台sug词条>
+
+请评估平台sug词条与原始问题的匹配度。
+"""
+
+    # 添加重试机制
+    max_retries = 2
+    last_error = None
+
+    for attempt in range(max_retries):
+        try:
+            # 并发调用三个评估器
+            motivation_task = Runner.run(motivation_evaluator, eval_input)
+            category_task = Runner.run(category_evaluator, eval_input)
+            extension_task = Runner.run(extension_word_evaluator, eval_input)
+
+            motivation_result, category_result, extension_result = await asyncio.gather(
+                motivation_task,
+                category_task,
+                extension_task
+            )
+
+            # 获取评估结果
+            motivation_eval: MotivationEvaluation = motivation_result.final_output
+            category_eval: CategoryEvaluation = category_result.final_output
+            extension_eval: ExtensionWordEvaluation = extension_result.final_output
+
+            # 提取得分
+            motivation_score = motivation_eval.动机维度得分
+            category_score = category_eval.品类维度得分
+            extension_score = extension_eval.延伸词得分
+            zero_reason = motivation_eval.得分为零的原因
+
+            # 应用规则计算最终得分
+            final_score, rule_applied = calculate_final_score(
+                motivation_score, category_score, extension_score, zero_reason,
+                extension_eval.简要说明延伸词维度相关度理由
+            )
+
+            # 组合评估理由
+            core_motivation = motivation_eval.原始问题核心动机提取.简要说明核心动机
+            motivation_reason = motivation_eval.简要说明动机维度相关度理由
+            category_reason = category_eval.简要说明品类维度相关度理由
+            extension_reason = extension_eval.简要说明延伸词维度相关度理由
+
+            combined_reason = (
+                f'【评估对象】词条"{text}" vs 原始问题"{o}"\n'
+                f"【核心动机】{core_motivation}\n"
+                f"【动机维度 {motivation_score:.2f}】{motivation_reason}\n"
+                f"【品类维度 {category_score:.2f}】{category_reason}\n"
+                f"【延伸词维度 {extension_score:.2f}】{extension_reason}\n"
+                f"【最终得分 {final_score:.2f}】"
+            )
+
+            # 添加规则说明
+            if rule_applied:
+                combined_reason += f"\n【规则说明】{rule_applied}"
+
+            # 存入缓存
+            if cache is not None:
+                cache[text] = (final_score, combined_reason)
+
+            return final_score, combined_reason
+
+        except Exception as e:
+            last_error = e
+            error_msg = str(e)
+
+            if attempt < max_retries - 1:
+                print(f"  ⚠️  评估失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:150]}")
+                print(f"  正在重试...")
+                await asyncio.sleep(1)  # 等待1秒后重试
+            else:
+                print(f"  ❌ 评估失败 (已达最大重试次数): {error_msg[:150]}")
+
+    # 所有重试失败后,返回默认值
+    fallback_reason = f"评估失败(重试{max_retries}次): {str(last_error)[:200]}"
+    print(f"  使用默认值: score=0.0, reason={fallback_reason[:100]}...")
+    return 0.0, fallback_reason
+
+
+async def evaluate_with_o_round0(text: str, o: str, cache: dict[str, tuple[float, str]] | None = None) -> tuple[float, str]:
+    """Round 0专用评估函数(v124新增 - 需求1)
+
+    用于评估segment和word与原始问题的相关度
+    不含延伸词维度,使用Round 0专用Prompt和新评分逻辑
+
+    采用两维评估:
+    1. 动机维度评估(权重70%)
+    2. 品类维度评估(权重30%)
+    3. 应用规则A/B/C调整得分
+
+    Args:
+        text: 待评估的文本(segment或word)
+        o: 原始问题
+        cache: 评估缓存(可选),用于避免重复评估
+
+    Returns:
+        tuple[float, str]: (最终相关度分数, 综合评估理由)
+    """
+    # 检查缓存
+    cache_key = f"round0:{text}:{o}"  # 添加前缀以区分不同评估类型
+    if cache is not None and cache_key in cache:
+        cached_score, cached_reason = cache[cache_key]
+        print(f"  ⚡ Round0缓存命中: {text} -> {cached_score:.2f}")
+        return cached_score, cached_reason
+
+    # 准备输入
+    eval_input = f"""
+<原始问题>
+{o}
+</原始问题>
+
+<词条>
+{text}
+</词条>
+
+请评估词条与原始问题的匹配度。
+"""
+
+    # 添加重试机制
+    max_retries = 2
+    last_error = None
+
+    for attempt in range(max_retries):
+        try:
+            # 并发调用两个评估器(不含延伸词)
+            motivation_task = Runner.run(round0_motivation_evaluator, eval_input)
+            category_task = Runner.run(round0_category_evaluator, eval_input)
+
+            motivation_result, category_result = await asyncio.gather(
+                motivation_task,
+                category_task
+            )
+
+            # 获取评估结果
+            motivation_eval: MotivationEvaluation = motivation_result.final_output
+            category_eval: CategoryEvaluation = category_result.final_output
+
+            # 提取得分
+            motivation_score = motivation_eval.动机维度得分
+            category_score = category_eval.品类维度得分
+
+            # 应用新规则计算最终得分
+            final_score, rule_applied = calculate_final_score_v2(
+                motivation_score, category_score
+            )
+
+            # 组合评估理由
+            core_motivation = motivation_eval.原始问题核心动机提取.简要说明核心动机
+            motivation_reason = motivation_eval.简要说明动机维度相关度理由
+            category_reason = category_eval.简要说明品类维度相关度理由
+
+            combined_reason = (
+                f'【评估对象】词条"{text}" vs 原始问题"{o}"\n'
+                f"【核心动机】{core_motivation}\n"
+                f"【动机维度 {motivation_score:.2f}】{motivation_reason}\n"
+                f"【品类维度 {category_score:.2f}】{category_reason}\n"
+                f"【最终得分 {final_score:.2f}】"
+            )
+
+            # 添加规则说明
+            if rule_applied:
+                combined_reason += f"\n【规则说明】{rule_applied}"
+
+            # 存入缓存
+            if cache is not None:
+                cache[cache_key] = (final_score, combined_reason)
+
+            return final_score, combined_reason
+
+        except Exception as e:
+            last_error = e
+            error_msg = str(e)
+
+            if attempt < max_retries - 1:
+                print(f"  ⚠️  Round0评估失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:150]}")
+                print(f"  正在重试...")
+                await asyncio.sleep(1)
+            else:
+                print(f"  ❌ Round0评估失败 (已达最大重试次数): {error_msg[:150]}")
+
+    # 所有重试失败后,返回默认值
+    fallback_reason = f"Round0评估失败(重试{max_retries}次): {str(last_error)[:200]}"
+    print(f"  使用默认值: score=0.0, reason={fallback_reason[:100]}...")
+    return 0.0, fallback_reason
+
+
+async def evaluate_within_scope(text: str, scope_text: str, cache: dict[str, tuple[float, str]] | None = None) -> tuple[float, str]:
+    """域内/域间专用评估函数(v124新增 - 需求2&3)
+
+    用于评估词条与作用域词条(单域或域组合)的相关度
+    不含延伸词维度,使用域内专用Prompt和新评分逻辑
+
+    采用两维评估:
+    1. 动机维度评估(权重70%)
+    2. 品类维度评估(权重30%)
+    3. 应用规则A/B/C调整得分
+
+    Args:
+        text: 待评估的词条
+        scope_text: 作用域词条(可以是单域词条或域组合词条)
+        cache: 评估缓存(可选),用于避免重复评估
+
+    Returns:
+        tuple[float, str]: (最终相关度分数, 综合评估理由)
+    """
+    # 检查缓存
+    cache_key = f"scope:{text}:{scope_text}"  # 添加前缀以区分不同评估类型
+    if cache is not None and cache_key in cache:
+        cached_score, cached_reason = cache[cache_key]
+        print(f"  ⚡ 域内缓存命中: {text} -> {cached_score:.2f}")
+        return cached_score, cached_reason
+
+    # 准备输入
+    eval_input = f"""
+<同一作用域词条>
+{scope_text}
+</同一作用域词条>
+
+<词条>
+{text}
+</词条>
+
+请评估词条与同一作用域词条的匹配度。
+"""
+
+    # 添加重试机制
+    max_retries = 2
+    last_error = None
+
+    for attempt in range(max_retries):
+        try:
+            # 并发调用两个评估器(不含延伸词)
+            motivation_task = Runner.run(scope_motivation_evaluator, eval_input)
+            category_task = Runner.run(scope_category_evaluator, eval_input)
+
+            motivation_result, category_result = await asyncio.gather(
+                motivation_task,
+                category_task
+            )
+
+            # 获取评估结果
+            motivation_eval: MotivationEvaluation = motivation_result.final_output
+            category_eval: CategoryEvaluation = category_result.final_output
+
+            # 提取得分
+            motivation_score = motivation_eval.动机维度得分
+            category_score = category_eval.品类维度得分
+
+            # 应用新规则计算最终得分
+            final_score, rule_applied = calculate_final_score_v2(
+                motivation_score, category_score
+            )
+
+            # 组合评估理由
+            core_motivation = motivation_eval.原始问题核心动机提取.简要说明核心动机
+            motivation_reason = motivation_eval.简要说明动机维度相关度理由
+            category_reason = category_eval.简要说明品类维度相关度理由
+
+            combined_reason = (
+                f'【评估对象】词条"{text}" vs 作用域词条"{scope_text}"\n'
+                f"【核心动机】{core_motivation}\n"
+                f"【动机维度 {motivation_score:.2f}】{motivation_reason}\n"
+                f"【品类维度 {category_score:.2f}】{category_reason}\n"
+                f"【最终得分 {final_score:.2f}】"
+            )
+
+            # 添加规则说明
+            if rule_applied:
+                combined_reason += f"\n【规则说明】{rule_applied}"
+
+            # 存入缓存
+            if cache is not None:
+                cache[cache_key] = (final_score, combined_reason)
+
+            return final_score, combined_reason
+
+        except Exception as e:
+            last_error = e
+            error_msg = str(e)
+
+            if attempt < max_retries - 1:
+                print(f"  ⚠️  域内评估失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:150]}")
+                print(f"  正在重试...")
+                await asyncio.sleep(1)
+            else:
+                print(f"  ❌ 域内评估失败 (已达最大重试次数): {error_msg[:150]}")
+
+    # 所有重试失败后,返回默认值
+    fallback_reason = f"域内评估失败(重试{max_retries}次): {str(last_error)[:200]}"
+    print(f"  使用默认值: score=0.0, reason={fallback_reason[:100]}...")
+    return 0.0, fallback_reason
+
+
+# ============================================================================
+# v125 新增辅助函数(用于新评分逻辑)
+# ============================================================================
+
+def get_source_word_score(
+    word_text: str,
+    segment: Segment,
+    context: RunContext
+) -> float:
+    """
+    查找来源词的得分
+
+    查找顺序:
+    1. 先查 segment.word_scores (Round 0的单个词)
+    2. 再查 context.word_score_history (Round 1+的组合)
+
+    Args:
+        word_text: 词文本
+        segment: 该词所在的segment
+        context: 运行上下文
+
+    Returns:
+        词的得分,找不到返回0.0
+    """
+    # 优先查Round 0的词得分
+    if word_text in segment.word_scores:
+        return segment.word_scores[word_text]
+
+    # 其次查历史组合得分
+    if word_text in context.word_score_history:
+        return context.word_score_history[word_text]
+
+    # 都找不到
+    print(f"  ⚠️  警告: 未找到来源词得分: {word_text}")
+    return 0.0
+
+
+async def evaluate_domain_combination_round1(
+    comb: DomainCombination,
+    segments: list[Segment],
+    context: RunContext
+) -> tuple[float, str]:
+    """
+    Round 1 域内组合评估(新逻辑)
+
+    最终得分 = 品类得分 × 原始域得分
+
+    Args:
+        comb: 域内组合对象
+        segments: 所有segment列表
+        context: 运行上下文
+
+    Returns:
+        (最终得分, 评估理由)
+    """
+    # 获取所属segment
+    domain_idx = comb.domains[0] if comb.domains else 0
+    segment = segments[domain_idx] if 0 <= domain_idx < len(segments) else None
+
+    if not segment:
+        return 0.0, "错误: 无法找到所属segment"
+
+    # 拼接作用域文本
+    scope_text = segment.text
+
+    # 准备输入
+    eval_input = f"""
+<同一作用域词条>
+{scope_text}
+</同一作用域词条>
+
+<词条>
+{comb.text}
+</词条>
+
+请评估词条与同一作用域词条的匹配度。
+"""
+
+    # 只调用品类评估器
+    try:
+        category_result = await Runner.run(scope_category_evaluator, eval_input)
+        category_eval: CategoryEvaluation = category_result.final_output
+        category_score = category_eval.品类维度得分
+        category_reason = category_eval.简要说明品类维度相关度理由
+    except Exception as e:
+        print(f"  ❌ Round 1品类评估失败: {e}")
+        return 0.0, f"评估失败: {str(e)[:100]}"
+
+    # 计算最终得分
+    domain_score = segment.score_with_o
+    final_score = category_score * domain_score
+
+    # 组合评估理由
+    combined_reason = (
+        f'【Round 1 域内评估】\n'
+        f'【评估对象】组合"{comb.text}" vs 作用域"{scope_text}"\n'
+        f'【品类得分】{category_score:.2f} - {category_reason}\n'
+        f'【原始域得分】{domain_score:.2f}\n'
+        f'【计算公式】品类得分 × 域得分 = {category_score:.2f} × {domain_score:.2f}\n'
+        f'【最终得分】{final_score:.2f}'
+    )
+
+    return final_score, combined_reason
+
+
+async def evaluate_domain_combination_round2plus(
+    comb: DomainCombination,
+    segments: list[Segment],
+    context: RunContext
+) -> tuple[float, str]:
+    """
+    Round 2+ 域间组合评估(新逻辑)
+
+    步骤:
+    1. 用现有逻辑评估得到 base_score
+    2. 计算加权系数 = Σ(来源词得分) / Σ(域得分)
+    3. 最终得分 = base_score × 系数,截断到1.0
+
+    Args:
+        comb: 域间组合对象
+        segments: 所有segment列表
+        context: 运行上下文
+
+    Returns:
+        (最终得分, 评估理由)
+    """
+    # 步骤1: 现有逻辑评估(域内评估)
+    scope_text = "".join(comb.from_segments)
+
+    base_score, base_reason = await evaluate_within_scope(
+        comb.text,
+        scope_text,
+        context.evaluation_cache
+    )
+
+    # 步骤2: 计算加权系数
+    total_source_score = 0.0
+    total_domain_score = 0.0
+    coefficient_details = []
+
+    for domain_idx, source_words_list in zip(comb.domains, comb.source_words):
+        # 获取segment
+        segment = segments[domain_idx] if 0 <= domain_idx < len(segments) else None
+        if not segment:
+            continue
+
+        domain_score = segment.score_with_o
+        total_domain_score += domain_score
+
+        # 如果该域贡献了多个词(组合),需要拼接后查找
+        if len(source_words_list) == 1:
+            # 单个词
+            source_word_text = source_words_list[0]
+        else:
+            # 多个词组合
+            source_word_text = "".join(source_words_list)
+
+        # 查找来源词得分
+        source_score = get_source_word_score(source_word_text, segment, context)
+        total_source_score += source_score
+
+        coefficient_details.append(
+            f"  域{domain_idx}[{segment.type}]: \"{source_word_text}\"得分={source_score:.2f}, 域得分={domain_score:.2f}"
+        )
+
+    # 计算系数
+    if total_domain_score > 0:
+        coefficient = total_source_score / total_domain_score
+    else:
+        coefficient = 0.0
+
+    # 步骤3: 计算最终得分并截断
+    final_score = base_score * total_source_score
+    final_score = min(1.0, max(-1.0, final_score))  # 截断到[-1.0, 1.0]
+
+    # 组合评估理由
+    coefficient_detail_str = "\n".join(coefficient_details)
+    combined_reason = (
+        f'【Round 2+ 域间评估】\n'
+        f'【评估对象】组合"{comb.text}"\n'
+        f'{base_reason}\n'
+        f'【加权系数计算】\n'
+        f'{total_source_score}\n'
+        f'  来源词总得分: {total_source_score:.2f}\n'
+        f'  系数: {total_source_score:.2f}'
+        f'【计算公式】base_score × 系数 = {base_score:.2f} × {total_source_score:.2f}\n'
+        f'【最终得分(截断后)】{final_score:.2f}'
+    )
+
+    return final_score, combined_reason
+
+
+# ============================================================================
+# 核心流程函数
+# ============================================================================
+
+async def initialize(o: str, context: RunContext) -> tuple[list[Seg], list[Word], list[Q], list[Seed]]:
+    """
+    初始化阶段
+
+    Returns:
+        (seg_list, word_list_1, q_list_1, seed_list)
+    """
+    print(f"\n{'='*60}")
+    print(f"初始化阶段")
+    print(f"{'='*60}")
+
+    # 1. 分词:原始问题(o) ->分词-> seg_list
+    print(f"\n[步骤1] 分词...")
+    result = await Runner.run(word_segmenter, o)
+    segmentation: WordSegmentation = result.final_output
+
+    seg_list = []
+    for word in segmentation.words:
+        seg_list.append(Seg(text=word, from_o=o))
+
+    print(f"分词结果: {[s.text for s in seg_list]}")
+    print(f"分词理由: {segmentation.reasoning}")
+
+    # 2. 分词评估:seg_list -> 每个seg与o进行评分(使用信号量限制并发数)
+    print(f"\n[步骤2] 评估每个分词与原始问题的相关度...")
+
+    MAX_CONCURRENT_SEG_EVALUATIONS = 10
+    seg_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEG_EVALUATIONS)
+
+    async def evaluate_seg(seg: Seg) -> Seg:
+        async with seg_semaphore:
+            # 初始化阶段的分词评估使用第一轮 prompt (round_num=1)
+            seg.score_with_o, seg.reason = await evaluate_with_o(seg.text, o, context.evaluation_cache, round_num=1)
+            return seg
+
+    if seg_list:
+        print(f"  开始评估 {len(seg_list)} 个分词(并发限制: {MAX_CONCURRENT_SEG_EVALUATIONS})...")
+        eval_tasks = [evaluate_seg(seg) for seg in seg_list]
+        await asyncio.gather(*eval_tasks)
+
+    for seg in seg_list:
+        print(f"  {seg.text}: {seg.score_with_o:.2f}")
+
+    # 3. 构建word_list_1: seg_list -> word_list_1(固定词库)
+    print(f"\n[步骤3] 构建word_list_1(固定词库)...")
+    word_list_1 = []
+    for seg in seg_list:
+        word_list_1.append(Word(
+            text=seg.text,
+            score_with_o=seg.score_with_o,
+            from_o=o
+        ))
+    print(f"word_list_1(固定): {[w.text for w in word_list_1]}")
+
+    # 4. 构建q_list_1:seg_list 作为 q_list_1
+    print(f"\n[步骤4] 构建q_list_1...")
+    q_list_1 = []
+    for seg in seg_list:
+        q_list_1.append(Q(
+            text=seg.text,
+            score_with_o=seg.score_with_o,
+            reason=seg.reason,
+            from_source="seg"
+        ))
+    print(f"q_list_1: {[q.text for q in q_list_1]}")
+
+    # 5. 构建seed_list: seg_list -> seed_list
+    print(f"\n[步骤5] 构建seed_list...")
+    seed_list = []
+    for seg in seg_list:
+        seed_list.append(Seed(
+            text=seg.text,
+            added_words=[],
+            from_type="seg",
+            score_with_o=seg.score_with_o
+        ))
+    print(f"seed_list: {[s.text for s in seed_list]}")
+
+    return seg_list, word_list_1, q_list_1, seed_list
+
+
+async def run_round(
+    round_num: int,
+    q_list: list[Q],
+    word_list_1: list[Word],
+    seed_list: list[Seed],
+    o: str,
+    context: RunContext,
+    xiaohongshu_api: XiaohongshuSearchRecommendations,
+    xiaohongshu_search: XiaohongshuSearch,
+    sug_threshold: float = 0.7
+) -> tuple[list[Q], list[Seed], list[Search]]:
+    """
+    运行一轮
+
+    Args:
+        round_num: 轮次编号
+        q_list: 当前轮的q列表
+        word_list_1: 固定的词库(第0轮分词结果)
+        seed_list: 当前的seed列表
+        o: 原始问题
+        context: 运行上下文
+        xiaohongshu_api: 建议词API
+        xiaohongshu_search: 搜索API
+        sug_threshold: suggestion的阈值
+
+    Returns:
+        (q_list_next, seed_list_next, search_list)
+    """
+    print(f"\n{'='*60}")
+    print(f"第{round_num}轮")
+    print(f"{'='*60}")
+
+    round_data = {
+        "round_num": round_num,
+        "input_q_list": [{"text": q.text, "score": q.score_with_o, "type": "query"} for q in q_list],
+        "input_word_list_1_size": len(word_list_1),
+        "input_seed_list_size": len(seed_list)
+    }
+
+    # 1. 请求sug:q_list -> 每个q请求sug接口 -> sug_list_list
+    print(f"\n[步骤1] 为每个q请求建议词...")
+    sug_list_list = []  # list of list
+    for q in q_list:
+        print(f"\n  处理q: {q.text}")
+        suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
+
+        q_sug_list = []
+        if suggestions:
+            print(f"    获取到 {len(suggestions)} 个建议词")
+            for sug_text in suggestions:
+                sug = Sug(
+                    text=sug_text,
+                    from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
+                )
+                q_sug_list.append(sug)
+        else:
+            print(f"    未获取到建议词")
+
+        sug_list_list.append(q_sug_list)
+
+    # 2. sug评估:sug_list_list -> 每个sug与o进行评分(并发)
+    print(f"\n[步骤2] 评估每个建议词与原始问题的相关度...")
+
+    # 2.1 收集所有需要评估的sug,并记录它们所属的q
+    all_sugs = []
+    sug_to_q_map = {}  # 记录每个sug属于哪个q
+    for i, q_sug_list in enumerate(sug_list_list):
+        if q_sug_list:
+            q_text = q_list[i].text
+            for sug in q_sug_list:
+                all_sugs.append(sug)
+                sug_to_q_map[id(sug)] = q_text
+
+    # 2.2 并发评估所有sug(使用信号量限制并发数)
+    # 每个 evaluate_sug 内部会并发调用 2 个 LLM,所以这里限制为 5,实际并发 LLM 请求为 10
+    MAX_CONCURRENT_EVALUATIONS = 5
+    semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
+
+    async def evaluate_sug(sug: Sug) -> Sug:
+        async with semaphore:  # 限制并发数
+            # 根据轮次选择 prompt: 第一轮使用 round1 prompt,后续使用标准 prompt
+            sug.score_with_o, sug.reason = await evaluate_with_o(sug.text, o, context.evaluation_cache, round_num=round_num)
+            return sug
+
+    if all_sugs:
+        print(f"  开始评估 {len(all_sugs)} 个建议词(并发限制: {MAX_CONCURRENT_EVALUATIONS})...")
+        eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
+        await asyncio.gather(*eval_tasks)
+
+    # 2.3 打印结果并组织到sug_details
+    sug_details = {}  # 保存每个Q对应的sug列表
+    for i, q_sug_list in enumerate(sug_list_list):
+        if q_sug_list:
+            q_text = q_list[i].text
+            print(f"\n  来自q '{q_text}' 的建议词:")
+            sug_details[q_text] = []
+            for sug in q_sug_list:
+                print(f"    {sug.text}: {sug.score_with_o:.2f}")
+                # 保存到sug_details
+                sug_details[q_text].append({
+                    "text": sug.text,
+                    "score": sug.score_with_o,
+                    "reason": sug.reason,
+                    "type": "sug"
+                })
+
+    # 2.4 剪枝判断(已禁用 - 保留所有分支)
+    pruned_query_texts = set()
+    if False:  # 原: if round_num >= 2:  # 剪枝功能已禁用,保留代码以便后续调整
+        print(f"\n[剪枝判断] 第{round_num}轮开始应用剪枝策略...")
+        for i, q in enumerate(q_list):
+            q_sug_list = sug_list_list[i]
+
+            if len(q_sug_list) == 0:
+                continue  # 没有sug则不剪枝
+
+            # 剪枝条件1: 所有sug分数都低于query分数
+            all_lower_than_query = all(sug.score_with_o < q.score_with_o for sug in q_sug_list)
+            # 剪枝条件2: 所有sug分数都低于0.5
+            all_below_threshold = all(sug.score_with_o < 0.5 for sug in q_sug_list)
+
+            if all_lower_than_query and all_below_threshold:
+                pruned_query_texts.add(q.text)
+                max_sug_score = max(sug.score_with_o for sug in q_sug_list)
+                print(f"  🔪 剪枝: {q.text} (query分数:{q.score_with_o:.2f}, sug最高分:{max_sug_score:.2f}, 全部<0.5)")
+
+        if pruned_query_texts:
+            print(f"  本轮共剪枝 {len(pruned_query_texts)} 个query")
+        else:
+            print(f"  本轮无query被剪枝")
+    else:
+        print(f"\n[剪枝判断] 剪枝功能已禁用,保留所有分支")
+
+    # 3. search_list构建
+    print(f"\n[步骤3] 构建search_list(阈值>{sug_threshold})...")
+    search_list = []
+    high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
+
+    if high_score_sugs:
+        print(f"  找到 {len(high_score_sugs)} 个高分建议词")
+
+        # 并发搜索
+        async def search_for_sug(sug: Sug) -> Search:
+            print(f"    搜索: {sug.text}")
+            try:
+                search_result = xiaohongshu_search.search(keyword=sug.text)
+                result_str = search_result.get("result", "{}")
+                if isinstance(result_str, str):
+                    result_data = json.loads(result_str)
+                else:
+                    result_data = result_str
+
+                notes = result_data.get("data", {}).get("data", [])
+                post_list = []
+                for note in notes[:10]:  # 只取前10个
+                    post = process_note_data(note)
+                    post_list.append(post)
+
+                print(f"      → 找到 {len(post_list)} 个帖子")
+
+                return Search(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    from_q=sug.from_q,
+                    post_list=post_list
+                )
+            except Exception as e:
+                print(f"      ✗ 搜索失败: {e}")
+                return Search(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    from_q=sug.from_q,
+                    post_list=[]
+                )
+
+        search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
+        search_list = await asyncio.gather(*search_tasks)
+    else:
+        print(f"  没有高分建议词,search_list为空")
+
+    # 4. 构建q_list_next
+    print(f"\n[步骤4] 构建q_list_next...")
+    q_list_next = []
+    existing_q_texts = set()  # 用于去重
+    add_word_details = {}  # 保存每个seed对应的组合词列表
+    all_seed_combinations = []  # 保存本轮所有seed的组合词(用于后续构建seed_list_next)
+
+    # 4.1 对于seed_list中的每个seed,从word_list_1中选词组合,产生Top 5
+    print(f"\n  4.1 为每个seed加词(产生Top 5组合)...")
+    for seed in seed_list:
+        print(f"\n    处理seed: {seed.text}")
+
+        # 剪枝检查:跳过被剪枝的seed
+        if seed.text in pruned_query_texts:
+            print(f"      ⊗ 跳过被剪枝的seed: {seed.text}")
+            continue
+
+        # 从固定词库word_list_1筛选候选词
+        candidate_words = []
+        for word in word_list_1:
+            # 检查词是否已在seed中
+            if word.text in seed.text:
+                continue
+            # 检查词是否已被添加过
+            if word.text in seed.added_words:
+                continue
+            candidate_words.append(word)
+
+        if not candidate_words:
+            print(f"      没有可用的候选词")
+            continue
+
+        print(f"      候选词数量: {len(candidate_words)}")
+
+        # 调用Agent一次性选择并组合Top 5(添加重试机制)
+        candidate_words_text = ', '.join([w.text for w in candidate_words])
+        selection_input = f"""
+<原始问题>
+{o}
+</原始问题>
+
+<当前Seed>
+{seed.text}
+</当前Seed>
+
+<候选词列表>
+{candidate_words_text}
+</候选词列表>
+
+请从候选词列表中选择最多5个最合适的词,分别与当前seed组合成新的query。
+"""
+
+        # 重试机制
+        max_retries = 2
+        selection_result = None
+        for attempt in range(max_retries):
+            try:
+                result = await Runner.run(word_selector, selection_input)
+                selection_result = result.final_output
+                break  # 成功则跳出
+            except Exception as e:
+                error_msg = str(e)
+                if attempt < max_retries - 1:
+                    print(f"      ⚠️  选词失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:100]}")
+                    await asyncio.sleep(1)
+                else:
+                    print(f"      ❌ 选词失败,跳过该seed: {error_msg[:100]}")
+                    break
+
+        if selection_result is None:
+            print(f"      跳过seed: {seed.text}")
+            continue
+
+        print(f"      Agent选择了 {len(selection_result.combinations)} 个组合")
+        print(f"      整体选择思路: {selection_result.overall_reasoning}")
+
+        # 并发评估所有组合的相关度
+        async def evaluate_combination(comb: WordCombination) -> dict:
+            combined = comb.combined_query
+
+            # 验证:组合结果必须包含完整的seed和word
+            # 检查是否包含seed的所有字符
+            seed_chars_in_combined = all(char in combined for char in seed.text)
+            # 检查是否包含word的所有字符
+            word_chars_in_combined = all(char in combined for char in comb.selected_word)
+
+            if not seed_chars_in_combined or not word_chars_in_combined:
+                print(f"        ⚠️  警告:组合不完整")
+                print(f"          Seed: {seed.text}")
+                print(f"          Word: {comb.selected_word}")
+                print(f"          组合: {combined}")
+                print(f"          包含完整seed? {seed_chars_in_combined}")
+                print(f"          包含完整word? {word_chars_in_combined}")
+                # 返回极低分数,让这个组合不会被选中
+                return {
+                    'word': comb.selected_word,
+                    'query': combined,
+                    'score': -1.0,  # 极低分数
+                    'reason': f"组合不完整:缺少seed或word的部分内容",
+                    'reasoning': comb.reasoning
+                }
+
+            # 正常评估,根据轮次选择 prompt
+            score, reason = await evaluate_with_o(combined, o, context.evaluation_cache, round_num=round_num)
+            return {
+                'word': comb.selected_word,
+                'query': combined,
+                'score': score,
+                'reason': reason,
+                'reasoning': comb.reasoning
+            }
+
+        eval_tasks = [evaluate_combination(comb) for comb in selection_result.combinations]
+        top_5 = await asyncio.gather(*eval_tasks)
+
+        print(f"      评估完成,得到 {len(top_5)} 个组合")
+
+        # 将Top 5全部加入q_list_next(去重检查 + 得分过滤)
+        for comb in top_5:
+            # 得分过滤:组合词必须比种子提升至少REQUIRED_SCORE_GAIN才能加入下一轮
+            if comb['score'] < seed.score_with_o + REQUIRED_SCORE_GAIN:
+                print(f"        ⊗ 跳过低分: {comb['query']} (分数{comb['score']:.2f} < 种子{seed.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
+                continue
+
+            # 去重检查
+            if comb['query'] in existing_q_texts:
+                print(f"        ⊗ 跳过重复: {comb['query']}")
+                continue
+
+            print(f"        ✓ {comb['query']} (分数: {comb['score']:.2f} > 种子: {seed.score_with_o:.2f})")
+
+            new_q = Q(
+                text=comb['query'],
+                score_with_o=comb['score'],
+                reason=comb['reason'],
+                from_source="add"
+            )
+            q_list_next.append(new_q)
+            existing_q_texts.add(comb['query'])  # 记录到去重集合
+
+            # 记录已添加的词
+            seed.added_words.append(comb['word'])
+
+        # 保存到add_word_details
+        add_word_details[seed.text] = [
+            {
+                "text": comb['query'],
+                "score": comb['score'],
+                "reason": comb['reason'],
+                "selected_word": comb['word'],
+                "seed_score": seed.score_with_o,  # 添加原始种子的得分
+                "type": "add"
+            }
+            for comb in top_5
+        ]
+
+        # 保存到all_seed_combinations(用于构建seed_list_next)
+        # 附加seed_score,用于后续过滤
+        for comb in top_5:
+            comb['seed_score'] = seed.score_with_o
+        all_seed_combinations.extend(top_5)
+
+    # 4.2 对于sug_list_list中,每个sug大于来自的query分数,加到q_list_next(去重检查)
+    print(f"\n  4.2 将高分sug加入q_list_next...")
+    for sug in all_sugs:
+        # 剪枝检查:跳过来自被剪枝query的sug
+        if sug.from_q and sug.from_q.text in pruned_query_texts:
+            print(f"    ⊗ 跳过来自被剪枝query的sug: {sug.text} (来源: {sug.from_q.text})")
+            continue
+
+        # sug必须比来源query提升至少REQUIRED_SCORE_GAIN才能加入下一轮
+        if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
+            # 去重检查
+            if sug.text in existing_q_texts:
+                print(f"    ⊗ 跳过重复: {sug.text}")
+                continue
+
+            new_q = Q(
+                text=sug.text,
+                score_with_o=sug.score_with_o,
+                reason=sug.reason,
+                from_source="sug"
+            )
+            q_list_next.append(new_q)
+            existing_q_texts.add(sug.text)  # 记录到去重集合
+            print(f"    ✓ {sug.text} (分数: {sug.score_with_o:.2f} >= 来源query: {sug.from_q.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
+
+    # 5. 构建seed_list_next(关键修改:不保留上一轮的seed)
+    print(f"\n[步骤5] 构建seed_list_next(不保留上轮seed)...")
+    seed_list_next = []
+    existing_seed_texts = set()
+
+    # 5.1 加入本轮所有组合词(只加入得分提升的)
+    print(f"  5.1 加入本轮所有组合词(得分过滤)...")
+    for comb in all_seed_combinations:
+        # 得分过滤:组合词必须比种子提升至少REQUIRED_SCORE_GAIN才作为下一轮种子
+        seed_score = comb.get('seed_score', 0)
+        if comb['score'] < seed_score + REQUIRED_SCORE_GAIN:
+            print(f"    ⊗ 跳过低分: {comb['query']} (分数{comb['score']:.2f} < 种子{seed_score:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
+            continue
+
+        if comb['query'] not in existing_seed_texts:
+            new_seed = Seed(
+                text=comb['query'],
+                added_words=[],  # 新seed的added_words清空
+                from_type="add",
+                score_with_o=comb['score']
+            )
+            seed_list_next.append(new_seed)
+            existing_seed_texts.add(comb['query'])
+            print(f"    ✓ {comb['query']} (分数: {comb['score']:.2f} >= 种子: {seed_score:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
+
+    # 5.2 加入高分sug
+    print(f"  5.2 加入高分sug...")
+    for sug in all_sugs:
+        # 剪枝检查:跳过来自被剪枝query的sug
+        if sug.from_q and sug.from_q.text in pruned_query_texts:
+            continue
+
+        # sug必须比来源query提升至少REQUIRED_SCORE_GAIN才作为下一轮种子
+        if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN and sug.text not in existing_seed_texts:
+            new_seed = Seed(
+                text=sug.text,
+                added_words=[],
+                from_type="sug",
+                score_with_o=sug.score_with_o
+            )
+            seed_list_next.append(new_seed)
+            existing_seed_texts.add(sug.text)
+            print(f"    ✓ {sug.text} (分数: {sug.score_with_o:.2f} >= 来源query: {sug.from_q.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
+
+    # 序列化搜索结果数据(包含帖子详情)
+    search_results_data = []
+    for search in search_list:
+        search_results_data.append({
+            "text": search.text,
+            "score_with_o": search.score_with_o,
+            "post_list": [
+                {
+                    "note_id": post.note_id,
+                    "note_url": post.note_url,
+                    "title": post.title,
+                    "body_text": post.body_text,
+                    "images": post.images,
+                    "interact_info": post.interact_info
+                }
+                for post in search.post_list
+            ]
+        })
+
+    # 记录本轮数据
+    round_data.update({
+        "sug_count": len(all_sugs),
+        "high_score_sug_count": len(high_score_sugs),
+        "search_count": len(search_list),
+        "total_posts": sum(len(s.post_list) for s in search_list),
+        "q_list_next_size": len(q_list_next),
+        "seed_list_next_size": len(seed_list_next),
+        "total_combinations": len(all_seed_combinations),
+        "pruned_query_count": len(pruned_query_texts),
+        "pruned_queries": list(pruned_query_texts),
+        "output_q_list": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "from": q.from_source, "type": "query"} for q in q_list_next],
+        "seed_list_next": [{"text": seed.text, "from": seed.from_type, "score": seed.score_with_o} for seed in seed_list_next],
+        "sug_details": sug_details,
+        "add_word_details": add_word_details,
+        "search_results": search_results_data
+    })
+    context.rounds.append(round_data)
+
+    print(f"\n本轮总结:")
+    print(f"  建议词数量: {len(all_sugs)}")
+    print(f"  高分建议词: {len(high_score_sugs)}")
+    print(f"  搜索数量: {len(search_list)}")
+    print(f"  帖子总数: {sum(len(s.post_list) for s in search_list)}")
+    print(f"  组合词数量: {len(all_seed_combinations)}")
+    print(f"  下轮q数量: {len(q_list_next)}")
+    print(f"  下轮seed数量: {len(seed_list_next)}")
+
+    return q_list_next, seed_list_next, search_list
+
+
+async def iterative_loop(
+    context: RunContext,
+    max_rounds: int = 2,
+    sug_threshold: float = 0.7
+):
+    """主迭代循环"""
+
+    print(f"\n{'='*60}")
+    print(f"开始迭代循环")
+    print(f"最大轮数: {max_rounds}")
+    print(f"sug阈值: {sug_threshold}")
+    print(f"{'='*60}")
+
+    # 初始化
+    seg_list, word_list_1, q_list, seed_list = await initialize(context.o, context)
+
+    # API实例
+    xiaohongshu_api = XiaohongshuSearchRecommendations()
+    xiaohongshu_search = XiaohongshuSearch()
+
+    # 保存初始化数据
+    context.rounds.append({
+        "round_num": 0,
+        "type": "initialization",
+        "seg_list": [{"text": s.text, "score": s.score_with_o, "reason": s.reason, "type": "seg"} for s in seg_list],
+        "word_list_1": [{"text": w.text, "score": w.score_with_o} for w in word_list_1],
+        "q_list_1": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "type": "query"} for q in q_list],
+        "seed_list": [{"text": s.text, "from_type": s.from_type, "score": s.score_with_o, "type": "seed"} for s in seed_list]
+    })
+
+    # 收集所有搜索结果
+    all_search_list = []
+
+    # 迭代
+    round_num = 1
+    while q_list and round_num <= max_rounds:
+        q_list, seed_list, search_list = await run_round(
+            round_num=round_num,
+            q_list=q_list,
+            word_list_1=word_list_1,  # 传递固定词库
+            seed_list=seed_list,
+            o=context.o,
+            context=context,
+            xiaohongshu_api=xiaohongshu_api,
+            xiaohongshu_search=xiaohongshu_search,
+            sug_threshold=sug_threshold
+        )
+
+        all_search_list.extend(search_list)
+        round_num += 1
+
+    print(f"\n{'='*60}")
+    print(f"迭代完成")
+    print(f"  总轮数: {round_num - 1}")
+    print(f"  总搜索次数: {len(all_search_list)}")
+    print(f"  总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
+    print(f"{'='*60}")
+
+    return all_search_list
+
+
+# ============================================================================
+# v121 新架构核心流程函数
+# ============================================================================
+
+async def initialize_v2(o: str, context: RunContext) -> list[Segment]:
+    """
+    v121 Round 0 初始化阶段
+
+    流程:
+    1. 语义分段: 调用 semantic_segmenter 将原始问题拆分成语义片段
+    2. 拆词: 对每个segment调用 word_segmenter 进行拆词
+    3. 评估: 对每个segment和词进行评估
+    4. 不进行组合(Round 0只分段和拆词)
+
+    Returns:
+        语义片段列表 (Segment)
+    """
+    print(f"\n{'='*60}")
+    print(f"Round 0: 初始化阶段(语义分段 + 拆词)")
+    print(f"{'='*60}")
+
+    # 1. 语义分段
+    print(f"\n[步骤1] 语义分段...")
+    result = await Runner.run(semantic_segmenter, o)
+    segmentation: SemanticSegmentation = result.final_output
+
+    print(f"语义分段结果: {len(segmentation.segments)} 个片段")
+    print(f"整体分段思路: {segmentation.overall_reasoning}")
+
+    segment_list = []
+    for seg_item in segmentation.segments:
+        segment = Segment(
+            text=seg_item.segment_text,
+            type=seg_item.segment_type,
+            from_o=o
+        )
+        segment_list.append(segment)
+        print(f"  - [{segment.type}] {segment.text}")
+
+    # 2. 对每个segment拆词并评估
+    print(f"\n[步骤2] 对每个segment拆词并评估...")
+
+    MAX_CONCURRENT_EVALUATIONS = 5
+    semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
+
+    async def process_segment(segment: Segment) -> Segment:
+        """处理单个segment: 拆词 + 评估segment + 评估词"""
+        async with semaphore:
+            # 2.1 拆词
+            word_result = await Runner.run(word_segmenter, segment.text)
+            word_segmentation: WordSegmentation = word_result.final_output
+            segment.words = word_segmentation.words
+
+            # 2.2 评估segment与原始问题的相关度(使用Round 0专用评估)
+            segment.score_with_o, segment.reason = await evaluate_with_o_round0(
+                segment.text, o, context.evaluation_cache
+            )
+
+            # 2.3 评估每个词与原始问题的相关度(使用Round 0专用评估)
+            word_eval_tasks = []
+            for word in segment.words:
+                async def eval_word(w: str) -> tuple[str, float, str]:
+                    score, reason = await evaluate_with_o_round0(w, o, context.evaluation_cache)
+                    return w, score, reason
+                word_eval_tasks.append(eval_word(word))
+
+            word_results = await asyncio.gather(*word_eval_tasks)
+            for word, score, reason in word_results:
+                segment.word_scores[word] = score
+                segment.word_reasons[word] = reason
+
+            return segment
+
+    if segment_list:
+        print(f"  开始处理 {len(segment_list)} 个segment(并发限制: {MAX_CONCURRENT_EVALUATIONS})...")
+        process_tasks = [process_segment(seg) for seg in segment_list]
+        await asyncio.gather(*process_tasks)
+
+    # 打印步骤1结果
+    print(f"\n[步骤1: 分段及拆词 结果]")
+    for segment in segment_list:
+        print(f"  [{segment.type}] {segment.text} (分数: {segment.score_with_o:.2f})")
+        print(f"    拆词: {segment.words}")
+        for word in segment.words:
+            score = segment.word_scores.get(word, 0.0)
+            print(f"      - {word}: {score:.2f}")
+
+    # 保存到context(保留旧格式以兼容)
+    context.segments = [
+        {
+            "text": seg.text,
+            "type": seg.type,
+            "score": seg.score_with_o,
+            "reason": seg.reason,
+            "words": seg.words,
+            "word_scores": seg.word_scores,
+            "word_reasons": seg.word_reasons
+        }
+        for seg in segment_list
+    ]
+
+    # 保存 Round 0 到 context.rounds(新格式用于可视化)
+    context.rounds.append({
+        "round_num": 0,
+        "type": "initialization",
+        "segments": [
+            {
+                "text": seg.text,
+                "type": seg.type,
+                "domain_index": idx,
+                "score": seg.score_with_o,
+                "reason": seg.reason,
+                "words": [
+                    {
+                        "text": word,
+                        "score": seg.word_scores.get(word, 0.0),
+                        "reason": seg.word_reasons.get(word, "")
+                    }
+                    for word in seg.words
+                ]
+            }
+            for idx, seg in enumerate(segment_list)
+        ]
+    })
+
+    # 🆕 存储Round 0的所有word得分到历史记录
+    print(f"\n[存储Round 0词得分到历史记录]")
+    for segment in segment_list:
+        for word, score in segment.word_scores.items():
+            context.word_score_history[word] = score
+            print(f"  {word}: {score:.2f}")
+
+    print(f"\n[Round 0 完成]")
+    print(f"  分段数: {len(segment_list)}")
+    total_words = sum(len(seg.words) for seg in segment_list)
+    print(f"  总词数: {total_words}")
+
+    return segment_list
+
+
+async def run_round_v2(
+    round_num: int,
+    query_input: list[Q],
+    segments: list[Segment],
+    o: str,
+    context: RunContext,
+    xiaohongshu_api: XiaohongshuSearchRecommendations,
+    xiaohongshu_search: XiaohongshuSearch,
+    sug_threshold: float = 0.7
+) -> tuple[list[Q], list[Search], dict]:
+    """
+    v121 Round N 执行
+
+    正确的流程顺序:
+    1. 为 query_input 请求SUG
+    2. 评估SUG
+    3. 高分SUG搜索(含多模态提取)
+    4. N域组合(从segments生成)
+    5. 评估组合
+    6. 生成 q_list_next(组合 + 高分SUG)
+
+    Args:
+        round_num: 轮次编号 (1-4)
+        query_input: 本轮的输入query列表(Round 1是words,Round 2+是上轮输出)
+        segments: 语义片段列表(用于组合)
+        o: 原始问题
+        context: 运行上下文
+        xiaohongshu_api: 建议词API
+        xiaohongshu_search: 搜索API
+        sug_threshold: SUG搜索阈值
+
+    Returns:
+        (q_list_next, search_list, extraction_results)
+    """
+    print(f"\n{'='*60}")
+    print(f"Round {round_num}: {round_num}域组合")
+    print(f"{'='*60}")
+
+    round_data = {
+        "round_num": round_num,
+        "n_domains": round_num,
+        "input_query_count": len(query_input)
+    }
+
+    MAX_CONCURRENT_EVALUATIONS = 5
+    semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
+
+    # 步骤1: 为 query_input 请求SUG
+    print(f"\n[步骤1] 为{len(query_input)}个输入query请求SUG...")
+    all_sugs = []
+    sug_details = {}
+
+    for q in query_input:
+        suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
+        if suggestions:
+            print(f"  {q.text}: 获取到 {len(suggestions)} 个SUG")
+            for sug_text in suggestions:
+                sug = Sug(
+                    text=sug_text,
+                    from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
+                )
+                all_sugs.append(sug)
+        else:
+            print(f"  {q.text}: 未获取到SUG")
+
+    print(f"  共获取 {len(all_sugs)} 个SUG")
+
+    # 步骤2: 评估SUG
+    if len(all_sugs) > 0:
+        print(f"\n[步骤2] 评估{len(all_sugs)}个SUG...")
+
+        async def evaluate_sug(sug: Sug) -> Sug:
+            async with semaphore:
+                sug.score_with_o, sug.reason = await evaluate_with_o(
+                    sug.text, o, context.evaluation_cache
+                )
+                return sug
+
+        eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
+        await asyncio.gather(*eval_tasks)
+
+        # 打印结果
+        for sug in all_sugs:
+            print(f"    {sug.text}: {sug.score_with_o:.2f}")
+            if sug.from_q:
+                if sug.from_q.text not in sug_details:
+                    sug_details[sug.from_q.text] = []
+                sug_details[sug.from_q.text].append({
+                    "text": sug.text,
+                    "score": sug.score_with_o,
+                    "reason": sug.reason,
+                    "type": "sug"
+                })
+
+    # 步骤3: 搜索高分SUG
+    print(f"\n[步骤3] 搜索高分SUG(阈值 > {sug_threshold})...")
+    high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
+    print(f"  找到 {len(high_score_sugs)} 个高分SUG")
+
+    search_list = []
+    extraction_results = {}  # 🆕 收集提取结果
+
+    if len(high_score_sugs) > 0:
+        async def search_for_sug(sug: Sug) -> tuple[Search, dict]:
+            """返回Search和提取结果"""
+            print(f"    搜索: {sug.text}")
+            post_extractions = {}  # 当前SUG的提取结果
+
+            try:
+                search_result = xiaohongshu_search.search(keyword=sug.text)
+                result_str = search_result.get("result", "{}")
+                if isinstance(result_str, str):
+                    result_data = json.loads(result_str)
+                else:
+                    result_data = result_str
+
+                notes = result_data.get("data", {}).get("data", [])
+                post_list = []
+                for note in notes[:10]:
+                    post = process_note_data(note)
+
+                    # 🆕 多模态提取(搜索后立即处理)
+                    if post.type == "normal" and len(post.images) > 0:
+                        extraction = await extract_post_images(post)
+                        if extraction:
+                            post_extractions[post.note_id] = extraction
+
+                    post_list.append(post)
+
+                print(f"      → 找到 {len(post_list)} 个帖子")
+
+                return Search(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    from_q=sug.from_q,
+                    post_list=post_list
+                ), post_extractions
+
+            except Exception as e:
+                print(f"      ✗ 搜索失败: {e}")
+                return Search(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    from_q=sug.from_q,
+                    post_list=[]
+                ), {}
+
+        search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
+        results = await asyncio.gather(*search_tasks)
+
+        # 分离搜索结果和提取结果
+        for search, extractions in results:
+            search_list.append(search)
+            extraction_results.update(extractions)
+
+    # 步骤4: 生成N域组合
+    print(f"\n[步骤4] 生成{round_num}域组合...")
+    domain_combinations = generate_domain_combinations(segments, round_num)
+    print(f"  生成了 {len(domain_combinations)} 个组合")
+
+    if len(domain_combinations) == 0:
+        print(f"  无法生成{round_num}域组合")
+        # 即使无法组合,也返回高分SUG作为下轮输入
+        q_list_next = []
+        for sug in all_sugs:
+            if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
+                q = Q(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    reason=sug.reason,
+                    from_source="sug",
+                    type_label=""
+                )
+                q_list_next.append(q)
+
+        round_data.update({
+            "domain_combinations_count": 0,
+            "sug_count": len(all_sugs),
+            "high_score_sug_count": len(high_score_sugs),
+            "search_count": len(search_list),
+            "sug_details": sug_details,
+            "q_list_next_size": len(q_list_next)
+        })
+        context.rounds.append(round_data)
+        return q_list_next, search_list
+
+    # 步骤5: 评估所有组合
+    print(f"\n[步骤5] 评估{len(domain_combinations)}个组合...")
+
+    async def evaluate_combination(comb: DomainCombination) -> DomainCombination:
+        async with semaphore:
+            # 🆕 根据轮次选择评估逻辑
+            if round_num == 1:
+                # Round 1: 域内评估(新逻辑)
+                comb.score_with_o, comb.reason = await evaluate_domain_combination_round1(
+                    comb, segments, context
+                )
+            else:
+                # Round 2+: 域间评估(新逻辑)
+                comb.score_with_o, comb.reason = await evaluate_domain_combination_round2plus(
+                    comb, segments, context
+                )
+
+            # 🆕 存储组合得分到历史记录
+            context.word_score_history[comb.text] = comb.score_with_o
+
+            return comb
+
+    eval_tasks = [evaluate_combination(comb) for comb in domain_combinations]
+    await asyncio.gather(*eval_tasks)
+
+    # 排序 - 已注释,保持原始顺序
+    # domain_combinations.sort(key=lambda x: x.score_with_o, reverse=True)
+
+    # 打印所有组合(保持原始顺序)
+    evaluation_strategy = 'Round 1 域内评估(品类×域得分)' if round_num == 1 else 'Round 2+ 域间评估(加权系数调整)'
+    print(f"  评估完成,共{len(domain_combinations)}个组合 [策略: {evaluation_strategy}]")
+    for i, comb in enumerate(domain_combinations, 1):
+        print(f"    {i}. {comb.text} {comb.type_label} (分数: {comb.score_with_o:.2f})")
+
+    # 为每个组合补充来源词分数信息,并判断是否超过所有来源词得分
+    for comb in domain_combinations:
+        word_details = []
+        flat_scores: list[float] = []
+        for domain_index, words in zip(comb.domains, comb.source_words):
+            segment = segments[domain_index] if 0 <= domain_index < len(segments) else None
+            segment_type = segment.type if segment else ""
+            segment_text = segment.text if segment else ""
+            items = []
+            for word in words:
+                score = 0.0
+                if segment and word in segment.word_scores:
+                    score = segment.word_scores[word]
+                items.append({
+                    "text": word,
+                    "score": score
+                })
+                flat_scores.append(score)
+            word_details.append({
+                "domain_index": domain_index,
+                "segment_type": segment_type,
+                "segment_text": segment_text,
+                "words": items
+            })
+        comb.source_word_details = word_details
+        comb.source_scores = flat_scores
+        comb.max_source_score = max(flat_scores) if flat_scores else None
+        comb.is_above_source_scores = bool(flat_scores) and all(
+            comb.score_with_o > score for score in flat_scores
+        )
+
+    # 步骤6: 构建 q_list_next(组合 + 高分SUG)
+    print(f"\n[步骤6] 生成下轮输入...")
+    q_list_next: list[Q] = []
+
+    # 6.1 添加高增益SUG(满足增益条件),并按分数排序
+    sug_candidates: list[tuple[Q, Sug]] = []
+    for sug in all_sugs:
+        if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
+            q = Q(
+                text=sug.text,
+                score_with_o=sug.score_with_o,
+                reason=sug.reason,
+                from_source="sug",
+                type_label=""
+            )
+            sug_candidates.append((q, sug))
+
+    sug_candidates.sort(key=lambda item: item[0].score_with_o, reverse=True)
+    q_list_next.extend([item[0] for item in sug_candidates])
+    high_gain_sugs = [item[1] for item in sug_candidates]
+    print(f"  添加 {len(high_gain_sugs)} 个高增益SUG(增益 ≥ {REQUIRED_SCORE_GAIN:.2f})")
+
+    # 6.2 添加高分组合(需超过所有来源词得分),并按分数排序
+    combination_candidates: list[tuple[Q, DomainCombination]] = []
+    for comb in domain_combinations:
+        if comb.is_above_source_scores and comb.score_with_o > 0:
+            domains_str = ','.join([f'D{d}' for d in comb.domains]) if comb.domains else ''
+            q = Q(
+                text=comb.text,
+                score_with_o=comb.score_with_o,
+                reason=comb.reason,
+                from_source="domain_comb",
+                type_label=comb.type_label,
+                domain_type=domains_str  # 添加域信息
+            )
+            combination_candidates.append((q, comb))
+
+    combination_candidates.sort(key=lambda item: item[0].score_with_o, reverse=True)
+    q_list_next.extend([item[0] for item in combination_candidates])
+    high_score_combinations = [item[1] for item in combination_candidates]
+    print(f"  添加 {len(high_score_combinations)} 个高分组合(组合得分 > 所有来源词)")
+
+    # 保存round数据(包含完整帖子信息)
+    search_results_data = []
+    for search in search_list:
+        search_results_data.append({
+            "text": search.text,
+            "score_with_o": search.score_with_o,
+            "post_list": [
+                {
+                    "note_id": post.note_id,
+                    "note_url": post.note_url,
+                    "title": post.title,
+                    "body_text": post.body_text,
+                    "images": post.images,
+                    "interact_info": post.interact_info
+                }
+                for post in search.post_list
+            ]
+        })
+
+    round_data.update({
+        "input_queries": [{"text": q.text, "score": q.score_with_o, "from_source": q.from_source, "type": "input", "domain_index": q.domain_index, "domain_type": q.domain_type} for q in query_input],
+        "domain_combinations_count": len(domain_combinations),
+        "domain_combinations": [
+            {
+                "text": comb.text,
+                "type_label": comb.type_label,
+                "score": comb.score_with_o,
+                "reason": comb.reason,
+                "domains": comb.domains,
+                "source_words": comb.source_words,
+                "from_segments": comb.from_segments,
+                "source_word_details": comb.source_word_details,
+                "source_scores": comb.source_scores,
+                "is_above_source_scores": comb.is_above_source_scores,
+                "max_source_score": comb.max_source_score
+            }
+            for comb in domain_combinations
+        ],
+        "high_score_combinations": [
+            {
+                "text": item[0].text,
+                "score": item[0].score_with_o,
+                "type_label": item[0].type_label,
+                "type": "combination",
+                "is_above_source_scores": item[1].is_above_source_scores
+            }
+            for item in combination_candidates
+        ],
+        "sug_count": len(all_sugs),
+        "sug_details": sug_details,
+        "high_score_sug_count": len(high_score_sugs),
+        "high_gain_sugs": [{"text": q.text, "score": q.score_with_o, "type": "sug"} for q in q_list_next if q.from_source == "sug"],
+        "search_count": len(search_list),
+        "search_results": search_results_data,
+        "q_list_next_size": len(q_list_next),
+        "q_list_next_sections": {
+            "sugs": [
+                {
+                    "text": item[0].text,
+                    "score": item[0].score_with_o,
+                    "from_source": "sug"
+                }
+                for item in sug_candidates
+            ],
+            "domain_combinations": [
+                {
+                    "text": item[0].text,
+                    "score": item[0].score_with_o,
+                    "from_source": "domain_comb",
+                    "is_above_source_scores": item[1].is_above_source_scores
+                }
+                for item in combination_candidates
+            ]
+        }
+    })
+    context.rounds.append(round_data)
+
+    print(f"\nRound {round_num} 总结:")
+    print(f"  输入Query数: {len(query_input)}")
+    print(f"  域组合数: {len(domain_combinations)}")
+    print(f"  高分组合: {len(high_score_combinations)}")
+    print(f"  SUG数: {len(all_sugs)}")
+    print(f"  高分SUG数: {len(high_score_sugs)}")
+    print(f"  高增益SUG: {len(high_gain_sugs)}")
+    print(f"  搜索数: {len(search_list)}")
+    print(f"  提取帖子数: {len(extraction_results)}")  # 🆕
+    print(f"  下轮Query数: {len(q_list_next)}")
+
+    return q_list_next, search_list, extraction_results  # 🆕 返回提取结果
+
+
+async def iterative_loop_v2(
+    context: RunContext,
+    max_rounds: int = 4,
+    sug_threshold: float = 0.7
+):
+    """v121 主迭代循环"""
+
+    print(f"\n{'='*60}")
+    print(f"开始v121迭代循环(语义分段跨域组词版)")
+    print(f"最大轮数: {max_rounds}")
+    print(f"sug阈值: {sug_threshold}")
+    print(f"{'='*60}")
+
+    # Round 0: 初始化(语义分段 + 拆词)
+    segments = await initialize_v2(context.o, context)
+
+    # API实例
+    xiaohongshu_api = XiaohongshuSearchRecommendations()
+    xiaohongshu_search = XiaohongshuSearch()
+
+    # 收集所有搜索结果
+    all_search_list = []
+    all_extraction_results = {}  # 🆕 收集所有提取结果
+
+    # 准备 Round 1 的输入:从 segments 提取所有 words
+    query_input = extract_words_from_segments(segments)
+    print(f"\n提取了 {len(query_input)} 个词作为 Round 1 的输入")
+
+    # Round 1-N: 迭代循环
+    num_segments = len(segments)
+    actual_max_rounds = min(max_rounds, num_segments)
+    round_num = 1
+
+    while query_input and round_num <= actual_max_rounds:
+        query_input, search_list, extraction_results = await run_round_v2(  # 🆕 接收提取结果
+            round_num=round_num,
+            query_input=query_input,  # 传递上一轮的输出
+            segments=segments,
+            o=context.o,
+            context=context,
+            xiaohongshu_api=xiaohongshu_api,
+            xiaohongshu_search=xiaohongshu_search,
+            sug_threshold=sug_threshold
+        )
+
+        all_search_list.extend(search_list)
+        all_extraction_results.update(extraction_results)  # 🆕 合并提取结果
+
+        # 如果没有新的query,提前结束
+        if not query_input:
+            print(f"\n第{round_num}轮后无新query生成,提前结束迭代")
+            break
+
+        round_num += 1
+
+    print(f"\n{'='*60}")
+    print(f"迭代完成")
+    print(f"  实际轮数: {round_num}")
+    print(f"  总搜索次数: {len(all_search_list)}")
+    print(f"  总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
+    print(f"  提取帖子数: {len(all_extraction_results)}")  # 🆕
+    print(f"{'='*60}")
+
+    return all_search_list, all_extraction_results  # 🆕 返回提取结果
+
+
+# ============================================================================
+# 主函数
+# ============================================================================
+
+async def main(input_dir: str, max_rounds: int = 2, sug_threshold: float = 0.7, visualize: bool = False):
+    """主函数"""
+    current_time, log_url = set_trace()
+
+    # 读取输入
+    input_context_file = os.path.join(input_dir, 'context.md')
+    input_q_file = os.path.join(input_dir, 'q.md')
+
+    c = read_file_as_string(input_context_file)  # 原始需求
+    o = read_file_as_string(input_q_file)  # 原始问题
+
+    # 版本信息
+    version = os.path.basename(__file__)
+    version_name = os.path.splitext(version)[0]
+
+    # 日志目录
+    log_dir = os.path.join(input_dir, "output", version_name, current_time)
+
+    # 创建运行上下文
+    run_context = RunContext(
+        version=version,
+        input_files={
+            "input_dir": input_dir,
+            "context_file": input_context_file,
+            "q_file": input_q_file,
+        },
+        c=c,
+        o=o,
+        log_dir=log_dir,
+        log_url=log_url,
+    )
+
+    # 创建日志目录
+    os.makedirs(run_context.log_dir, exist_ok=True)
+
+    # 配置日志文件
+    log_file_path = os.path.join(run_context.log_dir, "run.log")
+    log_file = open(log_file_path, 'w', encoding='utf-8')
+
+    # 重定向stdout到TeeLogger(同时输出到控制台和文件)
+    original_stdout = sys.stdout
+    sys.stdout = TeeLogger(original_stdout, log_file)
+
+    try:
+        print(f"📝 日志文件: {log_file_path}")
+        print(f"{'='*60}\n")
+
+        # 执行迭代 (v121: 使用新架构)
+        all_search_list, all_extraction_results = await iterative_loop_v2(  # 🆕 接收提取结果
+            run_context,
+            max_rounds=max_rounds,
+            sug_threshold=sug_threshold
+        )
+
+        # 格式化输出
+        output = f"原始需求:{run_context.c}\n"
+        output += f"原始问题:{run_context.o}\n"
+        output += f"总搜索次数:{len(all_search_list)}\n"
+        output += f"总帖子数:{sum(len(s.post_list) for s in all_search_list)}\n"
+        output += f"提取帖子数:{len(all_extraction_results)}\n"  # 🆕
+        output += "\n" + "="*60 + "\n"
+
+        if all_search_list:
+            output += "【搜索结果】\n\n"
+            for idx, search in enumerate(all_search_list, 1):
+                output += f"{idx}. 搜索词: {search.text} (分数: {search.score_with_o:.2f})\n"
+                output += f"   帖子数: {len(search.post_list)}\n"
+                if search.post_list:
+                    for post_idx, post in enumerate(search.post_list[:3], 1):  # 只显示前3个
+                        output += f"   {post_idx}) {post.title}\n"
+                        output += f"      URL: {post.note_url}\n"
+                output += "\n"
+        else:
+            output += "未找到搜索结果\n"
+
+        run_context.final_output = output
+
+        print(f"\n{'='*60}")
+        print("最终结果")
+        print(f"{'='*60}")
+        print(output)
+
+        # 保存上下文文件
+        context_file_path = os.path.join(run_context.log_dir, "run_context.json")
+        context_dict = run_context.model_dump()
+        with open(context_file_path, "w", encoding="utf-8") as f:
+            json.dump(context_dict, f, ensure_ascii=False, indent=2)
+        print(f"\nRunContext saved to: {context_file_path}")
+
+        # 保存详细的搜索结果
+        search_results_path = os.path.join(run_context.log_dir, "search_results.json")
+        search_results_data = [s.model_dump() for s in all_search_list]
+        with open(search_results_path, "w", encoding="utf-8") as f:
+            json.dump(search_results_data, f, ensure_ascii=False, indent=2)
+        print(f"Search results saved to: {search_results_path}")
+
+        # 🆕 保存图片提取结果
+        if all_extraction_results:
+            extraction_path = os.path.join(run_context.log_dir, "search_extract.json")
+            extraction_data = {
+                note_id: extraction.model_dump()
+                for note_id, extraction in all_extraction_results.items()
+            }
+            with open(extraction_path, "w", encoding="utf-8") as f:
+                json.dump(extraction_data, f, ensure_ascii=False, indent=2)
+            print(f"Image extractions saved to: {extraction_path}")
+            print(f"  提取了 {len(all_extraction_results)} 个帖子的图片内容")
+
+        # 可视化
+        if visualize:
+            import subprocess
+            output_html = os.path.join(run_context.log_dir, "visualization.html")
+            print(f"\n🎨 生成可视化HTML...")
+
+            # 获取绝对路径
+            abs_context_file = os.path.abspath(context_file_path)
+            abs_output_html = os.path.abspath(output_html)
+
+            # 运行可视化脚本
+            result = subprocess.run([
+                "node",
+                "visualization/knowledge_search_traverse/index.js",
+                abs_context_file,
+                abs_output_html
+            ])
+
+            if result.returncode == 0:
+                print(f"✅ 可视化已生成: {output_html}")
+            else:
+                print(f"❌ 可视化生成失败")
+
+    finally:
+        # 恢复stdout
+        sys.stdout = original_stdout
+        log_file.close()
+        print(f"\n📝 运行日志已保存: {log_file_path}")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.121 语义分段跨域组词版")
+    parser.add_argument(
+        "--input-dir",
+        type=str,
+        default="input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?",
+        help="输入目录路径,默认: input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?"
+    )
+    parser.add_argument(
+        "--max-rounds",
+        type=int,
+        default=4,
+        help="最大轮数,默认: 4"
+    )
+    parser.add_argument(
+        "--sug-threshold",
+        type=float,
+        default=0.7,
+        help="suggestion阈值,默认: 0.7"
+    )
+    parser.add_argument(
+        "--visualize",
+        action="store_true",
+        default=True,
+        help="运行完成后自动生成可视化HTML"
+    )
+    args = parser.parse_args()
+
+    asyncio.run(main(args.input_dir, max_rounds=args.max_rounds, sug_threshold=args.sug_threshold, visualize=args.visualize))

+ 287 - 0
multimodal_extractor.py

@@ -0,0 +1,287 @@
+"""
+多模态图片内容提取模块
+
+功能:
+1. 对帖子的图片进行文字提取和语义描述
+2. 支持一次性处理多张图片(最多10张)
+3. 使用Gemini多模态模型(直接调用OpenRouter API)
+4. 视频帖子自动跳过
+"""
+
+import asyncio
+import json
+import os
+from datetime import datetime
+from typing import Optional
+from pydantic import BaseModel, Field
+import requests
+
+MODEL_NAME = "google/gemini-2.5-flash"
+MAX_IMAGES_PER_POST = 10  # 最大处理图片数
+MAX_CONCURRENT_EXTRACTIONS = 5  # 最大并发提取数
+API_TIMEOUT = 120  # API 超时时间(秒)
+
+
+# ============================================================================
+# 数据模型
+# ============================================================================
+
+class ImageExtraction(BaseModel):
+    """单张图片的提取结果"""
+    image_index: int = Field(..., description="图片索引")
+    original_url: str = Field(..., description="原始图片URL")
+    description: str = Field(..., description="图片的详细描述(200-500字)")
+    extract_text: str = Field(..., description="提取的文字内容")
+
+
+class PostExtraction(BaseModel):
+    """帖子的完整提取结果"""
+    note_id: str
+    note_url: str
+    title: str
+    body_text: str
+    type: str
+    extraction_time: str
+    images: list[ImageExtraction] = Field(default_factory=list)
+
+
+# ============================================================================
+# Prompt 定义
+# ============================================================================
+
+ANALYSIS_PROMPT_TEMPLATE = """
+你是一名专业的图像内容分析和文字提取专家。
+
+请分析以下{num_images}张图片,这些图片来自标题为《{title}》的帖子。
+
+## 任务
+为**每张图片**分别提取两类信息:
+
+### 1. description(图片描述)
+对图片进行详细、全面的描述,包括但不限于:
+- **整体场景**:图片展示的主要场景或主题
+- **核心元素**:图片中的关键元素(人物、物体、文字、图表等)
+- **元素细节**:每个元素的特征(颜色、形状、位置、大小、状态等)
+- **空间布局**:元素的位置关系和排列方式
+- **视觉风格**:摄影风格、设计风格、色调、光线等
+- **文字内容**:如果有文字,简要说明文字的主题和作用
+- **情感氛围**:图片传达的情绪、氛围或意图
+
+**要求**:
+- 使用自然、流畅的语言
+- 从整体到局部、从主要到次要
+- 准确、具体、客观
+- 字数控制在200-500字之间
+
+### 2. extract_text(文字提取)
+精准提取图片中的所有可见文字内容。
+
+**要求**:
+1. 仅提取可见文字,不改写、总结或推理
+2. 如有结构(表格、图表、标题、段落),按结构输出
+3. 保持原始顺序和排版逻辑
+4. 不需要OCR校正,原样提取
+5. 舍弃与标题不相关的文字
+6. 结构不明确时,按从上到下、从左到右顺序提取
+7. 如果图片无文字,输出空字符串""
+
+## 输出要求
+必须返回一个JSON对象,包含images数组,每个元素对应一张图片:
+{{
+  "images": [
+    {{
+      "description": "第1张图片的详细描述...",
+      "extract_text": "第1张图片提取的文字内容..."
+    }},
+    {{
+      "description": "第2张图片的详细描述...",
+      "extract_text": "第2张图片提取的文字内容..."
+    }}
+  ]
+}}
+
+## 重要提示
+- images数组的顺序必须与输入图片顺序一致
+- 每张图片都必须有对应的结果
+- 如果某张图片无文字,extract_text设为空字符串""
+- 如果某张图片无法分析,description简要说明原因
+""".strip()
+
+
+# ============================================================================
+# 核心提取函数
+# ============================================================================
+
+async def extract_post_images(
+    post,  # Post对象
+    semaphore: Optional[asyncio.Semaphore] = None
+) -> Optional[PostExtraction]:
+    """
+    提取单个帖子的图片内容
+
+    Args:
+        post: Post对象(包含images列表)
+        semaphore: 可选的信号量用于并发控制
+
+    Returns:
+        PostExtraction对象,提取失败返回None
+    """
+    # 视频帖子跳过
+    if post.type == "video":
+        print(f"      ⊗ 跳过视频帖子: {post.note_id}")
+        return None
+
+    # 没有图片跳过
+    if not post.images or len(post.images) == 0:
+        print(f"      ⊗ 帖子无图片: {post.note_id}")
+        return None
+
+    # 限制图片数量
+    image_urls = post.images[:MAX_IMAGES_PER_POST]
+    image_count = len(image_urls)
+
+    print(f"      🖼️  开始提取图片内容: {post.note_id} ({image_count}张图片)")
+
+    try:
+        # 如果有信号量,使用它进行并发控制
+        if semaphore:
+            async with semaphore:
+                result = await _extract_images(image_urls, post)
+        else:
+            result = await _extract_images(image_urls, post)
+
+        print(f"      ✅ 提取完成: {post.note_id}")
+        return result
+
+    except Exception as e:
+        print(f"      ❌ 提取失败: {post.note_id} - {str(e)[:100]}")
+        return None
+
+
+async def _extract_images(image_urls: list[str], post) -> PostExtraction:
+    """
+    实际执行图片提取的内部函数 - 直接调用OpenRouter API
+    """
+    # 获取API密钥
+    api_key = os.getenv("OPENROUTER_API_KEY")
+    if not api_key:
+        raise ValueError("OPENROUTER_API_KEY environment variable not set")
+
+    # 构建提示文本
+    prompt_text = ANALYSIS_PROMPT_TEMPLATE.format(
+        num_images=len(image_urls),
+        title=post.title
+    )
+
+    # 构建消息内容:文本 + 多张图片
+    content = [{"type": "text", "text": prompt_text}]
+
+    for url in image_urls:
+        content.append({
+            "type": "image_url",
+            "image_url": {"url": url}
+        })
+
+    # 构建API请求
+    payload = {
+        "model": MODEL_NAME,
+        "messages": [{"role": "user", "content": content}],
+        "response_format": {"type": "json_object"}
+    }
+
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "Content-Type": "application/json"
+    }
+
+    # 在异步上下文中执行同步请求
+    loop = asyncio.get_event_loop()
+    response = await loop.run_in_executor(
+        None,
+        lambda: requests.post(
+            "https://openrouter.ai/api/v1/chat/completions",
+            headers=headers,
+            json=payload,
+            timeout=API_TIMEOUT
+        )
+    )
+
+    # 检查响应
+    if response.status_code != 200:
+        raise Exception(f"OpenRouter API error: {response.status_code} - {response.text[:200]}")
+
+    # 解析响应
+    result = response.json()
+
+    content_text = result["choices"][0]["message"]["content"]
+
+    # 去除Markdown代码块标记(Gemini即使设置了json_object也会返回带```json标记的内容)
+    content_text = content_text.strip()
+    if content_text.startswith("```json"):
+        content_text = content_text[7:]
+    elif content_text.startswith("```"):
+        content_text = content_text[3:]
+    if content_text.endswith("```"):
+        content_text = content_text[:-3]
+    content_text = content_text.strip()
+
+    analysis_data = json.loads(content_text)
+
+    # 构建PostExtraction
+    extraction = PostExtraction(
+        note_id=post.note_id,
+        note_url=post.note_url,
+        title=post.title,
+        body_text=post.body_text,
+        type=post.type,
+        extraction_time=datetime.now().isoformat(),
+        images=[]
+    )
+
+    # 解析每张图片的结果
+    for idx, img_result in enumerate(analysis_data.get("images", [])):
+        if idx >= len(image_urls):
+            break  # 防止结果数量不匹配
+
+        extraction.images.append(ImageExtraction(
+            image_index=idx,
+            original_url=image_urls[idx],
+            description=img_result.get("description", ""),
+            extract_text=img_result.get("extract_text", "")
+        ))
+
+    return extraction
+
+
+async def extract_all_posts(
+    posts: list,  # list[Post]
+    max_concurrent: int = MAX_CONCURRENT_EXTRACTIONS
+) -> dict[str, PostExtraction]:
+    """
+    批量提取多个帖子的图片内容(带并发控制)
+
+    Args:
+        posts: Post对象列表
+        max_concurrent: 最大并发数
+
+    Returns:
+        dict: {note_id: PostExtraction}
+    """
+    semaphore = asyncio.Semaphore(max_concurrent)
+
+    print(f"\n开始批量提取 {len(posts)} 个帖子的图片内容(并发限制: {max_concurrent})...")
+
+    tasks = [extract_post_images(post, semaphore) for post in posts]
+    results = await asyncio.gather(*tasks)
+
+    # 构建字典(过滤None)
+    extraction_dict = {}
+    success_count = 0
+    for extraction in results:
+        if extraction is not None:
+            extraction_dict[extraction.note_id] = extraction
+            success_count += 1
+
+    print(f"批量提取完成: 成功 {success_count}/{len(posts)}")
+
+    return extraction_dict

+ 287 - 0
visualization/knowledge_search_traverse/README.md

@@ -0,0 +1,287 @@
+# Knowledge Search Traverse 可视化工具
+
+## 概述
+
+这是 `knowledge_search_traverse.py` (v6.1.2.125) 的配套可视化工具。**基于 v6.1.2.121 的 React Flow 可视化引擎**,通过数据转换层将语义分段跨域组词的执行数据转换为图结构,实现美观的交互式可视化。
+
+### 🆕 新增功能:多模态图片提取展示
+
+支持展示帖子图片的文字提取和AI语义描述,提供更丰富的内容分析能力。
+
+## 🎯 核心特性
+
+### 1. 智能数据转换
+- 自动检测数据格式(v6.1.2.5 或 v6.1.2.8)
+- 将轮次数据(rounds)转换为节点-边图结构
+- 保留完整的轮次信息和来源追踪
+
+### 2. 多类型节点支持
+| 节点类型 | 颜色 | 说明 |
+|---------|------|------|
+| `root` | 紫色 (#6b21a8) | 原始问题根节点 |
+| `seg` | 绿色 (#10b981) | 初始分词结果 |
+| `q` | 蓝色 (#3b82f6) | 查询节点 |
+| `search` | 深紫 (#8b5cf6) | 搜索操作节点 |
+| `note` | 粉色 (#ec4899) | 帖子结果节点 |
+
+### 3. 来源标识
+清楚展示每个Query的来源:
+- **seg** - 来自分词
+- **add** - 加词生成
+- **sug** - 建议词生成
+
+### 4. 交互功能
+继承 v6.1.2.5 的所有交互功能:
+- ✅ 节点拖拽和缩放
+- ✅ 按层级筛选
+- ✅ 节点搜索
+- ✅ 路径高亮
+- ✅ 目录树导航(左侧面板)
+- ✅ 节点折叠/展开
+- ✅ 聚焦功能
+- ✅ **目录卡片交错布局** - 卡片左右错开,避免垂直叠加
+- ✅ **目录卡片拖动** - 支持垂直方向拖动调整位置
+
+## 📁 文件结构
+
+```
+visualization/sug_v6_1_2_8/
+├── index.js                  # 主可视化脚本(支持格式检测)
+├── convert_v8_to_graph.js   # 数据转换层
+├── package.json             # 依赖配置
+├── node_modules/            # 依赖包(React Flow, esbuild等)
+└── README.md               # 本文档
+```
+
+## 🚀 使用方式
+
+### 方式1:通过主脚本自动生成(推荐)
+
+```bash
+# 运行脚本并自动生成可视化
+python3 knowledge_search_traverse.py --visualize
+```
+
+可视化HTML会自动生成在输出目录中。
+
+### 方式2:手动生成可视化
+
+```bash
+# 从 run_context.json 生成可视化
+node visualization/knowledge_search_traverse/index.js \
+  path/to/run_context.json \
+  path/to/output.html \
+  [--simplified]
+```
+
+参数说明:
+- `path/to/run_context.json`:运行上下文文件路径(必需)
+- `path/to/output.html`:输出HTML文件路径(可选,默认为 `query_graph_output.html`)
+- `--simplified`:使用简化视图(可选)
+
+## 📦 数据来源
+
+可视化工具会读取以下文件:
+
+1. **run_context.json**(必需):主要的运行上下文数据
+2. **search_results.json**(可选):搜索结果详情
+3. **search_extract.json**(可选):🆕 图片提取结果
+
+## 🖼️ 多模态图片提取展示
+
+如果存在 `search_extract.json` 文件,可视化会展示:
+
+- **提取文字**:图片中的所有可见文字内容
+- **图片描述**:AI生成的图片详细描述
+- **原图展示**:点击可查看原始图片
+
+### search_extract.json 格式
+
+```json
+{
+  "note_id_xxx": {
+    "note_id": "xxx",
+    "note_url": "https://...",
+    "title": "帖子标题",
+    "body_text": "帖子正文",
+    "type": "normal",
+    "extraction_time": "2025-01-11T15:00:00",
+    "images": [
+      {
+        "image_index": 0,
+        "original_url": "https://...",
+        "description": "图片的详细语义描述...",
+        "extract_text": "图片中提取的文字内容..."
+      }
+    ]
+  }
+}
+```
+
+### 扩展图片提取展示
+
+如需自定义图片提取结果的展示方式,可以修改 `index.js` 中的React组件,添加图片展示的UI和交互逻辑。
+
+示例位置:
+```javascript
+// 读取提取结果
+const extractPath = path.join(path.dirname(inputFile), 'search_extract.json');
+let extractData = {};
+if (fs.existsSync(extractPath)) {
+    extractData = JSON.parse(fs.readFileSync(extractPath, 'utf-8'));
+}
+```
+
+## 📊 数据转换说明
+
+### 输入格式(v6.1.2.8)
+
+```json
+{
+  "o": "快速进行图片背景移除和替换",
+  "rounds": [
+    {
+      "round_num": 0,
+      "type": "initialization",
+      "seg_list": [
+        {"text": "快速", "score": 0.1},
+        {"text": "图片", "score": 0.1}
+      ],
+      "q_list_1": [...]
+    },
+    {
+      "round_num": 1,
+      "input_q_list": [...],
+      "output_q_list": [
+        {"text": "快速图片", "score": 0.2, "from": "add"}
+      ],
+      "search_count": 3
+    }
+  ]
+}
+```
+
+### 转换后格式(图结构)
+
+```json
+{
+  "nodes": {
+    "root_o": {
+      "type": "root",
+      "query": "快速进行图片背景移除和替换",
+      "level": 0
+    },
+    "seg_快速_0": {
+      "type": "seg",
+      "query": "快速",
+      "level": 1
+    },
+    "q_快速图片_r2_0": {
+      "type": "q",
+      "query": "快速图片",
+      "level": 2,
+      "from_source": "add"
+    }
+  },
+  "edges": [
+    {
+      "from": "root_o",
+      "to": "seg_快速_0",
+      "edge_type": "root_to_seg"
+    },
+    {
+      "from": "seg_快速_0",
+      "to": "q_快速_r1",
+      "edge_type": "seg_to_q"
+    }
+  ]
+}
+```
+
+## 🎨 可视化效果
+
+### 图布局
+- **水平布局**:从左到右按轮次展开
+- **层次化**:相同轮次的节点纵向排列
+- **自动布局**:使用 dagre 算法自动计算最佳位置
+
+### 节点样式
+- **边框颜色**:根据节点类型区分
+- **标签内容**:显示 Query 文本、分数、策略
+- **特殊标识**:
+  - 未选中的节点:红色 "未选中" 标签
+  - Search 节点:显示搜索次数和帖子数
+
+### 边样式
+- **虚线**:表示不同类型的关系
+- **颜色**:根据策略类型着色
+- **箭头**:指示数据流向
+
+## 📦 依赖
+
+所有依赖已包含在 `package.json` 中:
+- `react` + `react-dom` - UI 框架
+- `@xyflow/react` - 流程图库
+- `dagre` - 图布局算法
+- `esbuild` - 打包工具
+- `zustand` - 状态管理
+- `react-draggable` - 拖拽功能库
+
+**安装依赖:**
+```bash
+cd visualization/sug_v6_1_2_121
+npm install
+```
+
+总大小:约 34MB
+
+## 🔧 技术实现
+
+### 转换层(convert_v8_to_graph.js)
+- 解析 rounds 数据
+- 创建图节点和边
+- 维护轮次信息(iterations)
+
+### 可视化层(index.js)
+- 格式检测和自动转换
+- React Flow 渲染
+- 交互功能实现
+
+### 优势
+- ✅ 复用成熟的可视化引擎
+- ✅ 保持视觉一致性
+- ✅ 完整的交互功能
+- ✅ 易于维护和扩展
+
+## 📝 更新日志
+
+### v1.2.0 (knowledge_search_traverse - 2025-01-11)
+- 🆕 集成多模态图片提取功能
+- 📦 添加 search_extract.json 数据支持
+- 📝 更新文档说明多模态展示方式
+- 🔧 修改脚本路径为 knowledge_search_traverse
+
+### v1.1.0 (2025-11-11)
+- ✨ 新增目录树卡片交错布局(之字形排列)
+- ✨ 新增目录树卡片拖动功能(支持垂直拖动)
+- 🔧 增加卡片间距从 8px 到 20px
+- 📦 添加 react-draggable 依赖
+
+### v1.0.0 (2025-10-31)
+- 基于 v6.1.2.121 可视化引擎
+- 实现轮次数据到图结构的转换
+- 支持新的节点类型(root, segment, domain_comb, search)
+- 自动格式检测和转换
+- 完整的交互功能
+
+## 🤝 兼容性
+
+| 版本 | 支持 | 说明 |
+|------|------|------|
+| v6.1.2.121 | ✅ | 直接渲染(语义分段跨域组词版) |
+| v6.1.2.125 | ✅ | 完全兼容 + 多模态图片提取 |
+| knowledge_search_traverse | ✅ | 当前版本,完整支持 |
+
+## 📧 问题反馈
+
+如有问题或建议,请查看主项目 README 或提交 Issue。

+ 321 - 0
visualization/knowledge_search_traverse/convert_v8_to_graph.js

@@ -0,0 +1,321 @@
+/**
+ * 将 v6.1.2.8 的 run_context.json 转换成图结构
+ */
+
+function convertV8ToGraph(runContext) {
+  const nodes = {};
+  const edges = [];
+  const iterations = {};
+
+  const o = runContext.o || '原始问题';
+  const rounds = runContext.rounds || [];
+
+  // 添加原始问题根节点
+  const rootId = 'root_o';
+  nodes[rootId] = {
+    type: 'root',
+    query: o,
+    level: 0,
+    relevance_score: 1.0,
+    strategy: '原始问题',
+    iteration: 0,
+    is_terminated: false,
+    no_suggestion_rounds: 0,
+    evaluation_reason: '用户输入的原始问题',
+    is_selected: true
+  };
+
+  iterations[0] = [rootId];
+
+  // 处理每一轮
+  rounds.forEach((round, roundIndex) => {
+    if (round.type === 'initialization') {
+      // 初始化阶段:处理 seg_list
+      const roundNum = 0;
+      if (!iterations[roundNum]) iterations[roundNum] = [];
+
+      round.seg_list.forEach((seg, segIndex) => {
+        const segId = `seg_${seg.text}_${roundNum}`;
+        nodes[segId] = {
+          type: 'seg',
+          query: seg.text,
+          level: 1,
+          relevance_score: seg.score || 0,
+          strategy: '初始分词',
+          iteration: roundNum,
+          is_terminated: false,
+          evaluation_reason: '分词专家分词结果',
+          is_selected: true,
+          parent_query: o
+        };
+
+        // 添加边:root -> seg
+        edges.push({
+          from: rootId,
+          to: segId,
+          edge_type: 'root_to_seg',
+          strategy: '初始分词'
+        });
+
+        iterations[roundNum].push(segId);
+      });
+
+      // 将 seg 作为第一轮的 q
+      round.q_list_1.forEach((q, qIndex) => {
+        const qId = `q_${q.text}_r1`;
+        const segId = `seg_${q.text}_0`;
+
+        nodes[qId] = {
+          type: 'q',
+          query: q.text,
+          level: 1,
+          relevance_score: q.score || 0,
+          strategy: '来自分词',
+          iteration: 1,
+          is_terminated: false,
+          evaluation_reason: '初始Query',
+          is_selected: true,
+          parent_query: q.text,
+          from_source: 'seg'
+        };
+
+        // 添加边:seg -> q
+        if (nodes[segId]) {
+          edges.push({
+            from: segId,
+            to: qId,
+            edge_type: 'seg_to_q',
+            strategy: '作为Query'
+          });
+        }
+
+        if (!iterations[1]) iterations[1] = [];
+        iterations[1].push(qId);
+      });
+
+    } else {
+      // 普通轮次
+      const roundNum = round.round_num;
+      const nextRoundNum = roundNum + 1;
+
+      if (!iterations[nextRoundNum]) iterations[nextRoundNum] = [];
+
+      // 添加本轮的操作步骤节点
+      const stepNodes = [];
+
+      // 获取第一个输入Query作为所有操作节点的连接点(代表本轮输入)
+      // 注意:需要从已创建的节点中查找,因为节点ID可能带有index后缀
+      let firstInputQId = null;
+      if (round.input_q_list && round.input_q_list.length > 0) {
+        const firstInputQ = round.input_q_list[0];
+        // 尝试查找匹配的节点(考虑可能有index后缀)
+        firstInputQId = Object.keys(nodes).find(id => {
+          const node = nodes[id];
+          return node.type === 'q' &&
+                 node.query === firstInputQ.text &&
+                 node.iteration === roundNum;
+        });
+      }
+
+      // 步骤1:请求sug操作节点
+      if (round.sug_count > 0) {
+        const requestSugId = `operation_request_sug_r${roundNum}`;
+        nodes[requestSugId] = {
+          type: 'operation',
+          query: `步骤1: 请求建议词 (${round.sug_count}个)`,
+          level: roundNum,
+          relevance_score: 0,
+          strategy: '请求建议词',
+          iteration: roundNum,
+          operation_type: 'request_sug',
+          detail: `为${round.input_q_list?.length || 0}个Query请求建议词,获得${round.sug_count}个建议`,
+          is_selected: true
+        };
+        stepNodes.push(requestSugId);
+
+        // 平级连接:从第一个输入Query连接
+        if (firstInputQId && nodes[firstInputQId]) {
+          edges.push({
+            from: firstInputQId,
+            to: requestSugId,
+            edge_type: 'q_to_operation',
+            strategy: '请求建议词'
+          });
+        }
+      }
+
+      // 步骤2:评估sug操作节点
+      if (round.sug_count > 0) {
+        const evaluateSugId = `operation_evaluate_sug_r${roundNum}`;
+        nodes[evaluateSugId] = {
+          type: 'operation',
+          query: `步骤2: 评估建议词 (高分:${round.high_score_sug_count})`,
+          level: roundNum,
+          relevance_score: 0,
+          strategy: '评估建议词',
+          iteration: roundNum,
+          operation_type: 'evaluate_sug',
+          detail: `评估${round.sug_count}个建议词,${round.high_score_sug_count}个达到阈值`,
+          is_selected: true
+        };
+        stepNodes.push(evaluateSugId);
+
+        // 平级连接:从第一个输入Query连接
+        if (firstInputQId && nodes[firstInputQId]) {
+          edges.push({
+            from: firstInputQId,
+            to: evaluateSugId,
+            edge_type: 'q_to_operation',
+            strategy: '评估建议词'
+          });
+        }
+      }
+
+      // 步骤3:执行搜索操作节点
+      if (round.search_count > 0) {
+        const searchOpId = `operation_search_r${roundNum}`;
+        nodes[searchOpId] = {
+          type: 'operation',
+          query: `步骤3: 执行搜索 (${round.search_count}次)`,
+          level: roundNum,
+          relevance_score: 0,
+          strategy: '执行搜索',
+          iteration: roundNum,
+          operation_type: 'search',
+          search_count: round.search_count,
+          total_posts: round.total_posts,
+          detail: `搜索${round.search_count}个高分建议词,找到${round.total_posts}个帖子`,
+          is_selected: true
+        };
+        stepNodes.push(searchOpId);
+
+        // 平级连接:从第一个输入Query连接
+        if (firstInputQId && nodes[firstInputQId]) {
+          edges.push({
+            from: firstInputQId,
+            to: searchOpId,
+            edge_type: 'q_to_operation',
+            strategy: '执行搜索'
+          });
+        }
+      }
+
+      // 步骤4:加词操作节点
+      const addWordCount = round.output_q_list?.filter(q => q.from === 'add').length || 0;
+      if (addWordCount > 0) {
+        const addWordId = `operation_add_word_r${roundNum}`;
+        nodes[addWordId] = {
+          type: 'operation',
+          query: `步骤4: 智能加词 (${addWordCount}个)`,
+          level: roundNum,
+          relevance_score: 0,
+          strategy: '智能加词',
+          iteration: roundNum,
+          operation_type: 'add_word',
+          detail: `为Seed选择词并组合,生成${addWordCount}个新Query`,
+          is_selected: true
+        };
+        stepNodes.push(addWordId);
+
+        // 平级连接:从第一个输入Query连接
+        if (firstInputQId && nodes[firstInputQId]) {
+          edges.push({
+            from: firstInputQId,
+            to: addWordId,
+            edge_type: 'q_to_operation',
+            strategy: '智能加词'
+          });
+        }
+      }
+
+      // 步骤5:筛选高分sug操作节点
+      const sugCount = round.output_q_list?.filter(q => q.from === 'sug').length || 0;
+      if (sugCount > 0) {
+        const filterSugId = `operation_filter_sug_r${roundNum}`;
+        nodes[filterSugId] = {
+          type: 'operation',
+          query: `步骤5: 筛选高分sug (${sugCount}个)`,
+          level: roundNum,
+          relevance_score: 0,
+          strategy: '筛选高分sug',
+          iteration: roundNum,
+          operation_type: 'filter_sug',
+          detail: `筛选出${sugCount}个分数高于来源Query的建议词`,
+          is_selected: true
+        };
+        stepNodes.push(filterSugId);
+
+        // 平级连接:从第一个输入Query连接
+        if (firstInputQId && nodes[firstInputQId]) {
+          edges.push({
+            from: firstInputQId,
+            to: filterSugId,
+            edge_type: 'q_to_operation',
+            strategy: '筛选高分sug'
+          });
+        }
+      }
+
+      // 将操作节点添加到当前轮次
+      stepNodes.forEach(nodeId => {
+        if (!iterations[roundNum]) iterations[roundNum] = [];
+        iterations[roundNum].push(nodeId);
+      });
+
+      // 处理输出的 q_list
+      if (round.output_q_list) {
+        round.output_q_list.forEach((q, qIndex) => {
+          const qId = `q_${q.text}_r${nextRoundNum}_${qIndex}`;
+
+          nodes[qId] = {
+            type: 'q',
+            query: q.text,
+            level: nextRoundNum,
+            relevance_score: q.score || 0,
+            strategy: q.from === 'add' ? '加词生成' : q.from === 'sug' ? '建议词' : '未知',
+            iteration: nextRoundNum,
+            is_terminated: false,
+            evaluation_reason: `来源: ${q.from}`,
+            is_selected: true,
+            from_source: q.from
+          };
+
+          // 连接到对应的操作节点
+          if (q.from === 'add') {
+            // 加词生成的Query连接到加词操作节点
+            const addWordId = `operation_add_word_r${roundNum}`;
+            if (nodes[addWordId]) {
+              edges.push({
+                from: addWordId,
+                to: qId,
+                edge_type: 'operation_to_q',
+                strategy: '加词生成'
+              });
+            }
+          } else if (q.from === 'sug') {
+            // sug生成的Query连接到筛选sug操作节点
+            const filterSugId = `operation_filter_sug_r${roundNum}`;
+            if (nodes[filterSugId]) {
+              edges.push({
+                from: filterSugId,
+                to: qId,
+                edge_type: 'operation_to_q',
+                strategy: '建议词生成'
+              });
+            }
+          }
+
+          iterations[nextRoundNum].push(qId);
+        });
+      }
+    }
+  });
+
+  return {
+    nodes,
+    edges,
+    iterations
+  };
+}
+
+module.exports = { convertV8ToGraph };

+ 887 - 0
visualization/knowledge_search_traverse/convert_v8_to_graph_v2.js

@@ -0,0 +1,887 @@
+/**
+ * 将 v6.1.2.8 的 run_context.json 转换成按 Round > 步骤 > 数据 组织的图结构
+ */
+
+function convertV8ToGraphV2(runContext, searchResults) {
+  const nodes = {};
+  const edges = [];
+  const iterations = {};
+
+  const o = runContext.o || '原始问题';
+  const rounds = runContext.rounds || [];
+
+  // 添加原始问题根节点
+  const rootId = 'root_o';
+  nodes[rootId] = {
+    type: 'root',
+    query: o,
+    level: 0,
+    relevance_score: 1.0,
+    strategy: '原始问题',
+    iteration: 0,
+    is_selected: true
+  };
+
+  iterations[0] = [rootId];
+
+  // 处理每一轮
+  rounds.forEach((round, roundIndex) => {
+    if (round.type === 'initialization') {
+      // Round 0: 初始化阶段
+      const roundNum = 0;
+      const roundId = `round_${roundNum}`;
+
+      // 创建 Round 节点
+      nodes[roundId] = {
+        type: 'round',
+        query: `Round ${roundNum} (初始化)`,
+        level: roundNum,
+        relevance_score: 0,
+        strategy: '初始化',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: rootId,
+        to: roundId,
+        edge_type: 'root_to_round',
+        strategy: '初始化'
+      });
+
+      if (!iterations[roundNum]) iterations[roundNum] = [];
+      iterations[roundNum].push(roundId);
+
+      // 创建分词步骤节点
+      const segStepId = `step_seg_r${roundNum}`;
+      nodes[segStepId] = {
+        type: 'step',
+        query: `步骤:分词 (${round.seg_list?.length || 0}个分词)`,
+        level: roundNum,
+        relevance_score: 0,
+        strategy: '分词',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: roundId,
+        to: segStepId,
+        edge_type: 'round_to_step',
+        strategy: '分词'
+      });
+
+      iterations[roundNum].push(segStepId);
+
+      // 添加分词结果作为步骤的子节点
+      round.seg_list?.forEach((seg, segIndex) => {
+        const segId = `seg_${seg.text}_${roundNum}_${segIndex}`;
+        nodes[segId] = {
+          type: 'seg',
+          query: seg.text,
+          level: roundNum + 1,
+          relevance_score: seg.score || 0,
+          evaluationReason: seg.reason || '',
+          strategy: '分词结果',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: segStepId,
+          to: segId,
+          edge_type: 'step_to_data',
+          strategy: '分词结果'
+        });
+
+        if (!iterations[roundNum + 1]) iterations[roundNum + 1] = [];
+        iterations[roundNum + 1].push(segId);
+      });
+
+    } else {
+      // 普通轮次
+      const roundNum = round.round_num;
+      const roundId = `round_${roundNum}`;
+
+      // 创建 Round 节点
+      nodes[roundId] = {
+        type: 'round',
+        query: `Round ${roundNum}`,
+        level: roundNum * 10, // 使用10的倍数作为层级
+        relevance_score: 0,
+        strategy: `第${roundNum}轮`,
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: rootId,
+        to: roundId,
+        edge_type: 'root_to_round',
+        strategy: `第${roundNum}轮`
+      });
+
+      if (!iterations[roundNum * 10]) iterations[roundNum * 10] = [];
+      iterations[roundNum * 10].push(roundId);
+
+      // 步骤1: 请求&评估推荐词
+      if (round.sug_details && Object.keys(round.sug_details).length > 0) {
+        const sugStepId = `step_sug_r${roundNum}`;
+        const totalSugs = Object.values(round.sug_details).reduce((sum, list) => sum + list.length, 0);
+
+        nodes[sugStepId] = {
+          type: 'step',
+          query: `步骤1: 请求&评估推荐词 (${totalSugs}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '请求&评估推荐词',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: sugStepId,
+          edge_type: 'round_to_step',
+          strategy: '推荐词'
+        });
+
+        iterations[roundNum * 10].push(sugStepId);
+
+        // 为每个 Q 创建节点
+        Object.keys(round.sug_details).forEach((qText, qIndex) => {
+          // 从q_list_1中查找对应的q获取分数和理由
+          // Round 0: 从q_list_1查找; Round 1+: 从input_q_list查找
+          let qData = {};
+          if (roundNum === 0) {
+            qData = round.q_list_1?.find(q => q.text === qText) || {};
+          } else {
+            // 从当前轮的input_q_list中查找
+            qData = round.input_q_list?.find(q => q.text === qText) || {};
+          }
+          const qId = `q_${qText}_r${roundNum}_${qIndex}`;
+          nodes[qId] = {
+            type: 'q',
+            query: qText,
+            level: roundNum * 10 + 2,
+            relevance_score: qData.score || 0,
+            evaluationReason: qData.reason || '',
+            strategy: 'Query',
+            iteration: roundNum,
+            is_selected: true
+          };
+
+          edges.push({
+            from: sugStepId,
+            to: qId,
+            edge_type: 'step_to_q',
+            strategy: 'Query'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(qId);
+
+          // 为每个 Q 的 sug 创建节点
+          const sugs = round.sug_details[qText] || [];
+          sugs.forEach((sug, sugIndex) => {
+            const sugId = `sug_${sug.text}_r${roundNum}_q${qIndex}_${sugIndex}`;
+            nodes[sugId] = {
+              type: 'sug',
+              query: sug.text,
+              level: roundNum * 10 + 3,
+              relevance_score: sug.score || 0,
+              evaluationReason: sug.reason || '',
+              strategy: '推荐词',
+              iteration: roundNum,
+              is_selected: true
+            };
+
+            edges.push({
+              from: qId,
+              to: sugId,
+              edge_type: 'q_to_sug',
+              strategy: '推荐词'
+            });
+
+            if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+            iterations[roundNum * 10 + 3].push(sugId);
+          });
+        });
+      }
+
+      // 步骤2: 筛选并执行搜索
+      const searchStepId = `step_search_r${roundNum}`;
+      const searchCountText = round.search_count > 0
+        ? `筛选${round.high_score_sug_count}个高分词,搜索${round.search_count}次,${round.total_posts}个帖子`
+        : `无高分推荐词,未执行搜索`;
+
+      nodes[searchStepId] = {
+        type: 'step',
+        query: `步骤2: 筛选并执行搜索 (${searchCountText})`,
+        level: roundNum * 10 + 1,
+        relevance_score: 0,
+        strategy: '筛选并执行搜索',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: roundId,
+        to: searchStepId,
+        edge_type: 'round_to_step',
+        strategy: '搜索'
+      });
+
+      iterations[roundNum * 10].push(searchStepId);
+
+      // 只有在有搜索结果时才添加搜索词和帖子
+      // 优先使用 round.search_results(新格式),否则使用外部传入的 searchResults(兼容旧版本)
+      const roundSearchResults = round.search_results || searchResults;
+      if (round.search_count > 0 && roundSearchResults) {
+        if (Array.isArray(roundSearchResults)) {
+          roundSearchResults.forEach((search, searchIndex) => {
+            const searchWordId = `search_${search.text}_r${roundNum}_${searchIndex}`;
+            nodes[searchWordId] = {
+              type: 'search_word',
+              query: search.text,
+              level: roundNum * 10 + 2,
+              relevance_score: search.score_with_o || 0,
+              strategy: '搜索词',
+              iteration: roundNum,
+              is_selected: true
+            };
+
+            edges.push({
+              from: searchStepId,
+              to: searchWordId,
+              edge_type: 'step_to_search_word',
+              strategy: '搜索词'
+            });
+
+            if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+            iterations[roundNum * 10 + 2].push(searchWordId);
+
+            // 添加帖子
+            if (search.post_list && search.post_list.length > 0) {
+              search.post_list.forEach((post, postIndex) => {
+                const postId = `post_${post.note_id}_${searchIndex}_${postIndex}`;
+
+                // 准备图片列表,将URL字符串转换为对象格式供轮播图使用
+                const imageList = (post.images || []).map(url => ({
+                  image_url: url
+                }));
+
+                nodes[postId] = {
+                  type: 'post',
+                  query: post.title,
+                  level: roundNum * 10 + 3,
+                  relevance_score: 0,
+                  strategy: '帖子',
+                  iteration: roundNum,
+                  is_selected: true,
+                  note_id: post.note_id,
+                  note_url: post.note_url,
+                  body_text: post.body_text || '',
+                  images: post.images || [],
+                  image_list: imageList,
+                  interact_info: post.interact_info || {}
+                };
+
+                edges.push({
+                  from: searchWordId,
+                  to: postId,
+                  edge_type: 'search_word_to_post',
+                  strategy: '搜索结果'
+                });
+
+                if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+                iterations[roundNum * 10 + 3].push(postId);
+              });
+            }
+          });
+        }
+      }
+
+      // 步骤3: 加词生成新查询
+      if (round.add_word_details && Object.keys(round.add_word_details).length > 0) {
+        const addWordStepId = `step_add_r${roundNum}`;
+        const totalAddWords = Object.values(round.add_word_details).reduce((sum, list) => sum + list.length, 0);
+
+        nodes[addWordStepId] = {
+          type: 'step',
+          query: `步骤3: 加词生成新查询 (${totalAddWords}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '加词生成新查询',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: addWordStepId,
+          edge_type: 'round_to_step',
+          strategy: '加词'
+        });
+
+        iterations[roundNum * 10].push(addWordStepId);
+
+        // 为每个 Seed 创建节点
+        Object.keys(round.add_word_details).forEach((seedText, seedIndex) => {
+          const seedId = `seed_${seedText}_r${roundNum}_${seedIndex}`;
+
+          // 查找seed的来源信息 - 从Round 0的seed_list查找基础种子的from_type
+          const round0 = rounds.find(r => r.round_num === 0 || r.type === 'initialization');
+          const seedInfo = round0?.seed_list?.find(s => s.text === seedText) || {};
+          const fromType = seedInfo.from_type || 'unknown';
+
+          // 根据来源设置strategy
+          let strategy;
+          if (fromType === 'seg') {
+            strategy = '初始分词';
+          } else if (fromType === 'add') {
+            strategy = '加词';
+          } else if (fromType === 'sug') {
+            strategy = '调用sug';
+          } else {
+            strategy = 'Seed';  // 默认灰色
+          }
+
+          nodes[seedId] = {
+            type: 'seed',
+            query: seedText,
+            level: roundNum * 10 + 2,
+            relevance_score: 0,
+            strategy: strategy,
+            iteration: roundNum,
+            is_selected: true
+          };
+
+          edges.push({
+            from: addWordStepId,
+            to: seedId,
+            edge_type: 'step_to_seed',
+            strategy: 'Seed'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(seedId);
+
+          // 为每个 Seed 的组合词创建节点
+          const combinedWords = round.add_word_details[seedText] || [];
+          combinedWords.forEach((word, wordIndex) => {
+            const wordId = `add_${word.text}_r${roundNum}_seed${seedIndex}_${wordIndex}`;
+            nodes[wordId] = {
+              type: 'add_word',
+              query: word.text,
+              level: roundNum * 10 + 3,
+              relevance_score: word.score || 0,
+              evaluationReason: word.reason || '',
+              strategy: '加词生成',
+              iteration: roundNum,
+              is_selected: true,
+              selected_word: word.selected_word
+            };
+
+            edges.push({
+              from: seedId,
+              to: wordId,
+              edge_type: 'seed_to_add_word',
+              strategy: '组合词'
+            });
+
+            if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+            iterations[roundNum * 10 + 3].push(wordId);
+          });
+        });
+      }
+
+      // 步骤4: 筛选推荐词进入下轮
+      const filteredSugs = round.output_q_list?.filter(q => q.from === 'sug') || [];
+      if (filteredSugs.length > 0) {
+        const filterStepId = `step_filter_r${roundNum}`;
+        nodes[filterStepId] = {
+          type: 'step',
+          query: `步骤4: 筛选推荐词进入下轮 (${filteredSugs.length}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '筛选推荐词进入下轮',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: filterStepId,
+          edge_type: 'round_to_step',
+          strategy: '筛选'
+        });
+
+        iterations[roundNum * 10].push(filterStepId);
+
+        // 添加筛选出的sug
+        filteredSugs.forEach((sug, sugIndex) => {
+          const sugId = `filtered_sug_${sug.text}_r${roundNum}_${sugIndex}`;
+          nodes[sugId] = {
+            type: 'filtered_sug',
+            query: sug.text,
+            level: roundNum * 10 + 2,
+            relevance_score: sug.score || 0,
+            strategy: '进入下轮',
+            iteration: roundNum,
+            is_selected: true
+          };
+
+          edges.push({
+            from: filterStepId,
+            to: sugId,
+            edge_type: 'step_to_filtered_sug',
+            strategy: '进入下轮'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(sugId);
+        });
+      }
+
+      // 步骤5: 构建下一轮
+      const nextRoundStepId = `step_next_round_r${roundNum}`;
+      const nextQCount = round.output_q_list?.length || 0;
+      const nextSeedCount = round.seed_list_next_size || 0;
+
+      nodes[nextRoundStepId] = {
+        type: 'step',
+        query: `步骤5: 构建下一轮 (${nextQCount}个查询, ${nextSeedCount}个种子)`,
+        level: roundNum * 10 + 1,
+        relevance_score: 0,
+        strategy: '构建下一轮',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: roundId,
+        to: nextRoundStepId,
+        edge_type: 'round_to_step',
+        strategy: '构建下一轮'
+      });
+
+      iterations[roundNum * 10].push(nextRoundStepId);
+
+      // 5.1: 构建下轮查询
+      if (round.output_q_list && round.output_q_list.length > 0) {
+        const nextQStepId = `step_next_q_r${roundNum}`;
+        nodes[nextQStepId] = {
+          type: 'step',
+          query: `构建下轮查询 (${nextQCount}个)`,
+          level: roundNum * 10 + 2,
+          relevance_score: 0,
+          strategy: '下轮查询',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: nextRoundStepId,
+          to: nextQStepId,
+          edge_type: 'step_to_step',
+          strategy: '查询'
+        });
+
+        if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+        iterations[roundNum * 10 + 2].push(nextQStepId);
+
+        // 添加下轮查询列表
+        round.output_q_list.forEach((q, qIndex) => {
+          const nextQId = `next_q_${q.text}_r${roundNum}_${qIndex}`;
+
+          // 根据来源设置strategy
+          let strategy;
+          if (q.from === 'seg') {
+            strategy = '初始分词';
+          } else if (q.from === 'add') {
+            strategy = '加词';
+          } else if (q.from === 'sug') {
+            strategy = '调用sug';
+          } else {
+            strategy = 'Query'; // 默认
+          }
+
+          nodes[nextQId] = {
+            type: 'next_q',
+            query: q.text,
+            level: roundNum * 10 + 3,
+            relevance_score: q.score || 0,
+            evaluationReason: q.reason || '',
+            strategy: strategy,
+            iteration: roundNum,
+            is_selected: true,
+            from_source: q.from
+          };
+
+          edges.push({
+            from: nextQStepId,
+            to: nextQId,
+            edge_type: 'step_to_next_q',
+            strategy: strategy
+          });
+
+          if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+          iterations[roundNum * 10 + 3].push(nextQId);
+        });
+      }
+
+      // 5.2: 构建下轮种子(如果有数据的话)
+      if (nextSeedCount > 0 && round.seed_list_next) {
+        const nextSeedStepId = `step_next_seed_r${roundNum}`;
+        nodes[nextSeedStepId] = {
+          type: 'step',
+          query: `构建下轮种子 (${nextSeedCount}个)`,
+          level: roundNum * 10 + 2,
+          relevance_score: 0,
+          strategy: '下轮种子',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: nextRoundStepId,
+          to: nextSeedStepId,
+          edge_type: 'step_to_step',
+          strategy: '种子'
+        });
+
+        if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+        iterations[roundNum * 10 + 2].push(nextSeedStepId);
+
+        // 添加下轮种子列表
+        round.seed_list_next.forEach((seed, seedIndex) => {
+          const nextSeedId = `next_seed_${seed.text}_r${roundNum}_${seedIndex}`;
+
+          // 根据来源设置strategy
+          let strategy;
+          if (seed.from === 'seg') {
+            strategy = '初始分词';
+          } else if (seed.from === 'add') {
+            strategy = '加词';
+          } else if (seed.from === 'sug') {
+            strategy = '调用sug';
+          } else {
+            strategy = 'Seed'; // 默认
+          }
+
+          nodes[nextSeedId] = {
+            type: 'next_seed',
+            query: seed.text,
+            level: roundNum * 10 + 3,
+            relevance_score: seed.score || 0,
+            strategy: strategy,
+            iteration: roundNum,
+            is_selected: true,
+            from_source: seed.from
+          };
+
+          edges.push({
+            from: nextSeedStepId,
+            to: nextSeedId,
+            edge_type: 'step_to_next_seed',
+            strategy: strategy
+          });
+
+          if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+          iterations[roundNum * 10 + 3].push(nextSeedId);
+        });
+      }
+    }
+  });
+
+  return {
+    nodes,
+    edges,
+    iterations
+  };
+}
+
+/**
+ * 简化版转换:专注于query和post的演化
+ * - 合并所有query节点(不区分seg/sug/add_word)
+ * - 合并相同的帖子节点
+ * - 步骤信息放在边上
+ * - 隐藏Round/Step节点
+ */
+function convertV8ToGraphSimplified(runContext, searchResults) {
+  const mergedNodes = {};
+  const edges = [];
+  const iterations = {};
+
+  const o = runContext.o || '原始问题';
+  const rounds = runContext.rounds || [];
+
+  // 添加原始问题根节点
+  const rootId = 'root_o';
+  mergedNodes[rootId] = {
+    type: 'root',
+    query: o,
+    level: 0,
+    relevance_score: 1.0,
+    strategy: '原始问题',
+    iteration: 0,
+    is_selected: true,
+    occurrences: [{round: 0, role: 'root', score: 1.0}]
+  };
+  iterations[0] = [rootId];
+
+  // 用于记录节点之间的演化关系
+  const queryEvolution = {}; // {text: {occurrences: [], parentTexts: [], childTexts: []}}
+  const postMap = {}; // {note_id: {...}}
+
+  // 第一遍:收集所有query和post
+  rounds.forEach((round, roundIndex) => {
+    const roundNum = round.round_num || roundIndex;
+
+    if (round.type === 'initialization') {
+      // Round 0: 收集分词结果
+      (round.q_list_1 || []).forEach(q => {
+        if (!queryEvolution[q.text]) {
+          queryEvolution[q.text] = {
+            occurrences: [],
+            parentTexts: new Set([o]), // 来自原始问题
+            childTexts: new Set()
+          };
+        }
+        queryEvolution[q.text].occurrences.push({
+          round: roundNum,
+          role: 'segmentation',
+          strategy: '分词',
+          score: q.score,
+          reason: q.reason
+        });
+      });
+    } else {
+      // Round 1+
+
+      // 收集sug_details (推荐词)
+      Object.entries(round.sug_details || {}).forEach(([parentText, sugs]) => {
+        sugs.forEach(sug => {
+          if (!queryEvolution[sug.text]) {
+            queryEvolution[sug.text] = {
+              occurrences: [],
+              parentTexts: new Set(),
+              childTexts: new Set()
+            };
+          }
+          queryEvolution[sug.text].occurrences.push({
+            round: roundNum,
+            role: 'sug',
+            strategy: '调用sug',
+            score: sug.score,
+            reason: sug.reason
+          });
+          queryEvolution[sug.text].parentTexts.add(parentText);
+          if (queryEvolution[parentText]) {
+            queryEvolution[parentText].childTexts.add(sug.text);
+          }
+        });
+      });
+
+      // 收集add_word_details (加词结果)
+      Object.entries(round.add_word_details || {}).forEach(([seedText, words]) => {
+        words.forEach(word => {
+          if (!queryEvolution[word.text]) {
+            queryEvolution[word.text] = {
+              occurrences: [],
+              parentTexts: new Set(),
+              childTexts: new Set()
+            };
+          }
+          queryEvolution[word.text].occurrences.push({
+            round: roundNum,
+            role: 'add_word',
+            strategy: '加词',
+            score: word.score,
+            reason: word.reason,
+            selectedWord: word.selected_word
+          });
+          queryEvolution[word.text].parentTexts.add(seedText);
+          if (queryEvolution[seedText]) {
+            queryEvolution[seedText].childTexts.add(word.text);
+          }
+        });
+      });
+
+      // 收集搜索结果和帖子
+      const roundSearchResults = round.search_results || searchResults;
+      if (roundSearchResults && Array.isArray(roundSearchResults)) {
+        roundSearchResults.forEach(search => {
+          const searchText = search.text;
+
+          // 标记这个query被用于搜索
+          if (queryEvolution[searchText]) {
+            queryEvolution[searchText].occurrences.push({
+              round: roundNum,
+              role: 'search',
+              strategy: '执行搜索',
+              score: search.score_with_o,
+              postCount: search.post_list ? search.post_list.length : 0
+            });
+          }
+
+          // 收集帖子
+          if (search.post_list && search.post_list.length > 0) {
+            search.post_list.forEach(post => {
+              if (!postMap[post.note_id]) {
+                postMap[post.note_id] = {
+                  ...post,
+                  foundByQueries: new Set(),
+                  foundInRounds: new Set()
+                };
+              }
+              postMap[post.note_id].foundByQueries.add(searchText);
+              postMap[post.note_id].foundInRounds.add(roundNum);
+
+              // 建立query到post的关系
+              if (!queryEvolution[searchText].posts) {
+                queryEvolution[searchText].posts = new Set();
+              }
+              queryEvolution[searchText].posts.add(post.note_id);
+            });
+          }
+        });
+      }
+    }
+  });
+
+  // 第二遍:创建合并后的节点
+  Object.entries(queryEvolution).forEach(([text, data]) => {
+    const nodeId = `query_${text}`;
+
+    // 获取最新的分数
+    const latestOccurrence = data.occurrences[data.occurrences.length - 1] || {};
+    const hasSearchResults = data.posts && data.posts.size > 0;
+
+    mergedNodes[nodeId] = {
+      type: 'query',
+      query: text,
+      level: Math.max(...data.occurrences.map(o => o.round), 0) * 10 + 2,
+      relevance_score: latestOccurrence.score || 0,
+      evaluationReason: latestOccurrence.reason || '',
+      strategy: data.occurrences.map(o => o.strategy).join(' + '),
+      primaryStrategy: latestOccurrence.strategy || '未知',  // 添加主要策略字段
+      iteration: Math.max(...data.occurrences.map(o => o.round), 0),
+      is_selected: true,
+      occurrences: data.occurrences,
+      hasSearchResults: hasSearchResults,
+      postCount: data.posts ? data.posts.size : 0,
+      selectedWord: data.occurrences.find(o => o.selectedWord)?.selectedWord || ''
+    };
+
+    // 添加到对应的轮次
+    const maxRound = Math.max(...data.occurrences.map(o => o.round), 0);
+    const iterKey = maxRound * 10 + 2;
+    if (!iterations[iterKey]) iterations[iterKey] = [];
+    iterations[iterKey].push(nodeId);
+  });
+
+  // 创建帖子节点
+  Object.entries(postMap).forEach(([noteId, post]) => {
+    const postId = `post_${noteId}`;
+
+    const imageList = (post.images || []).map(url => ({
+      image_url: url
+    }));
+
+    mergedNodes[postId] = {
+      type: 'post',
+      query: post.title,
+      level: 100, // 放在最后
+      relevance_score: 0,
+      strategy: '帖子',
+      iteration: Math.max(...Array.from(post.foundInRounds)),
+      is_selected: true,
+      note_id: post.note_id,
+      note_url: post.note_url,
+      body_text: post.body_text || '',
+      images: post.images || [],
+      image_list: imageList,
+      interact_info: post.interact_info || {},
+      foundByQueries: Array.from(post.foundByQueries),
+      foundInRounds: Array.from(post.foundInRounds)
+    };
+
+    if (!iterations[100]) iterations[100] = [];
+    iterations[100].push(postId);
+  });
+
+  // 第三遍:创建边
+  // 1. 原始问题 -> 分词结果
+  Object.entries(queryEvolution).forEach(([text, data]) => {
+    const nodeId = `query_${text}`;
+    const segOccurrence = data.occurrences.find(o => o.role === 'segmentation');
+
+    if (segOccurrence && data.parentTexts.has(o)) {
+      edges.push({
+        from: rootId,
+        to: nodeId,
+        edge_type: 'segmentation',
+        strategy: '分词',
+        label: '分词',
+        round: 0
+      });
+    }
+  });
+
+  // 2. Query演化关系
+  Object.entries(queryEvolution).forEach(([text, data]) => {
+    const nodeId = `query_${text}`;
+
+    data.parentTexts.forEach(parentText => {
+      if (parentText === o) return; // 跳过原始问题(已处理)
+
+      const parentNodeId = `query_${parentText}`;
+      if (!mergedNodes[parentNodeId]) return;
+
+      // 找到这个演化的策略和轮次
+      const occurrence = data.occurrences.find(o =>
+        o.role === 'sug' || o.role === 'add_word'
+      );
+
+      edges.push({
+        from: parentNodeId,
+        to: nodeId,
+        edge_type: occurrence?.role || 'evolution',
+        strategy: occurrence?.strategy || '演化',
+        label: `${occurrence?.strategy || '演化'} (R${occurrence?.round || 0})`,
+        round: occurrence?.round || 0
+      });
+    });
+  });
+
+  // 3. Query -> Post (搜索关系)
+  Object.entries(queryEvolution).forEach(([text, data]) => {
+    const nodeId = `query_${text}`;
+
+    if (data.posts && data.posts.size > 0) {
+      const searchOccurrence = data.occurrences.find(o => o.role === 'search');
+
+      data.posts.forEach(noteId => {
+        const postId = `post_${noteId}`;
+        edges.push({
+          from: nodeId,
+          to: postId,
+          edge_type: 'search',
+          strategy: '搜索',
+          label: `搜索 (${data.posts.size}个帖子)`,
+          round: searchOccurrence?.round || 0
+        });
+      });
+    }
+  });
+
+  return {
+    nodes: mergedNodes,
+    edges,
+    iterations
+  };
+}
+
+module.exports = { convertV8ToGraphV2, convertV8ToGraphSimplified };

+ 1085 - 0
visualization/knowledge_search_traverse/convert_v8_to_graph_v3.js

@@ -0,0 +1,1085 @@
+/**
+ * 将 v6.1.2.8 的 run_context.json 转换成按 Round > 步骤 > 数据 组织的图结构
+ * v3: 增加 [Q] 和 [SUG] 标识前缀
+ */
+
+// 得分提升阈值:与Python代码保持一致
+const REQUIRED_SCORE_GAIN = 0.02;
+
+function convertV8ToGraphV2(runContext, searchResults, extractionData) {
+  const nodes = {};
+  const edges = [];
+  const iterations = {};
+  extractionData = extractionData || {}; // 默认为空对象
+
+  const o = runContext.o || '原始问题';
+  const rounds = runContext.rounds || [];
+
+  // 添加原始问题根节点
+  const rootId = 'root_o';
+  nodes[rootId] = {
+    type: 'root',
+    query: o,
+    level: 0,
+    relevance_score: 1.0,
+    strategy: '原始问题',
+    iteration: 0,
+    is_selected: true
+  };
+
+  iterations[0] = [rootId];
+
+  // 处理每一轮
+  rounds.forEach((round, roundIndex) => {
+    if (round.type === 'initialization') {
+      // Round 0: 初始化阶段(新架构)
+      const roundNum = 0;
+      const roundId = `round_${roundNum}`;
+
+      // 创建 Round 节点
+      nodes[roundId] = {
+        type: 'round',
+        query: `Round ${roundNum} (初始化)`,
+        level: roundNum * 10,
+        relevance_score: 0,
+        strategy: '初始化',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: rootId,
+        to: roundId,
+        edge_type: 'root_to_round',
+        strategy: '初始化'
+      });
+
+      if (!iterations[roundNum * 10]) iterations[roundNum * 10] = [];
+      iterations[roundNum * 10].push(roundId);
+
+      // 步骤1: 分段及拆词
+      if (round.segments && round.segments.length > 0) {
+        const segStepId = `step_seg_r${roundNum}`;
+        nodes[segStepId] = {
+          type: 'step',
+          query: `步骤1: 分段及拆词 (${round.segments.length}个segment)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '分段及拆词',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: segStepId,
+          edge_type: 'round_to_step',
+          strategy: '分段及拆词'
+        });
+
+        iterations[roundNum * 10 + 1] = [segStepId];
+
+        // 为每个 Segment 创建节点
+        round.segments.forEach((seg, segIndex) => {
+          const segId = `segment_${segIndex}_r${roundNum}`;
+          nodes[segId] = {
+            type: 'segment',
+            query: `[${seg.type}] ${seg.text}`,
+            level: roundNum * 10 + 2,
+            relevance_score: seg.score || 0,
+            evaluationReason: seg.reason || '',
+            strategy: seg.type,
+            iteration: roundNum,
+            is_selected: true,
+            segment_type: seg.type,
+            domain_index: seg.domain_index,
+            domain_type: seg.type  // 新增:让可视化显示类型而不是D0
+          };
+
+          edges.push({
+            from: segStepId,
+            to: segId,
+            edge_type: 'step_to_segment',
+            strategy: seg.type
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(segId);
+
+          // 为每个 Word 创建节点
+          if (seg.words && seg.words.length > 0) {
+            seg.words.forEach((word, wordIndex) => {
+              const wordId = `word_${word.text}_seg${segIndex}_${wordIndex}`;
+              nodes[wordId] = {
+                type: 'word',
+                query: word.text,
+                level: roundNum * 10 + 3,
+                relevance_score: word.score || 0,
+                evaluationReason: word.reason || '',
+                strategy: 'Word',
+                iteration: roundNum,
+                is_selected: true
+              };
+
+              edges.push({
+                from: segId,
+                to: wordId,
+                edge_type: 'segment_to_word',
+                strategy: 'Word'
+              });
+
+              if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+              iterations[roundNum * 10 + 3].push(wordId);
+            });
+          }
+        });
+      }
+
+      // 步骤2: 域内组词
+      if (round.step2_domain_combinations && round.step2_domain_combinations.length > 0) {
+        const combStepId = `step_comb_r${roundNum}`;
+        nodes[combStepId] = {
+          type: 'step',
+          query: `步骤2: 域内组词 (${round.step2_domain_combinations.length}个组合)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '域内组词',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: combStepId,
+          edge_type: 'round_to_step',
+          strategy: '域内组词'
+        });
+
+        if (!iterations[roundNum * 10 + 1]) iterations[roundNum * 10 + 1] = [];
+        iterations[roundNum * 10 + 1].push(combStepId);
+
+        // 为每个域内组合创建节点
+        round.step2_domain_combinations.forEach((comb, combIndex) => {
+          const combId = `comb_${comb.text}_r${roundNum}_${combIndex}`;
+          const sourceWordsStr = comb.source_words ? comb.source_words.map(words => words.join(',')).join(' + ') : '';
+
+          nodes[combId] = {
+            type: 'domain_combination',
+            query: `${comb.text} ${comb.type_label}`,
+            level: roundNum * 10 + 2,
+            relevance_score: comb.score || 0,
+            evaluationReason: comb.reason || '',
+            strategy: '域内组合',
+            iteration: roundNum,
+            is_selected: true,
+            type_label: comb.type_label,
+            source_words: comb.source_words,
+            source_segment: comb.source_segment,
+            domain_index: comb.domain_index
+          };
+
+          edges.push({
+            from: combStepId,
+            to: combId,
+            edge_type: 'step_to_comb',
+            strategy: '域内组合'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(combId);
+        });
+      }
+
+    } else {
+      // 普通轮次
+      const roundNum = round.round_num;
+      const roundId = `round_${roundNum}`;
+
+      // 创建 Round 节点
+      nodes[roundId] = {
+        type: 'round',
+        query: `Round ${roundNum}`,
+        level: roundNum * 10, // 使用10的倍数作为层级
+        relevance_score: 0,
+        strategy: `第${roundNum}轮`,
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: rootId,
+        to: roundId,
+        edge_type: 'root_to_round',
+        strategy: `第${roundNum}轮`
+      });
+
+      if (!iterations[roundNum * 10]) iterations[roundNum * 10] = [];
+      iterations[roundNum * 10].push(roundId);
+
+      // 步骤1: 请求&评估推荐词
+      if (round.sug_details && Object.keys(round.sug_details).length > 0) {
+        const sugStepId = `step_sug_r${roundNum}`;
+        const totalSugs = Object.values(round.sug_details).reduce((sum, list) => sum + list.length, 0);
+
+        nodes[sugStepId] = {
+          type: 'step',
+          query: `步骤1: 请求&评估推荐词 (${totalSugs}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '请求&评估推荐词',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: sugStepId,
+          edge_type: 'round_to_step',
+          strategy: '推荐词'
+        });
+
+        iterations[roundNum * 10].push(sugStepId);
+
+        // 为每个 Q 创建节点
+        Object.keys(round.sug_details).forEach((qText, qIndex) => {
+          // 从q_list_1中查找对应的q获取分数和理由
+          // Round 0: 从q_list_1查找; Round 1+: 从input_queries查找
+          let qData = {};
+          if (roundNum === 0) {
+            qData = round.q_list_1?.find(q => q.text === qText) || {};
+          } else {
+            // 从当前轮的input_queries中查找
+            qData = round.input_queries?.find(q => q.text === qText) || {};
+          }
+          const qId = `q_${qText}_r${roundNum}_${qIndex}`;
+          nodes[qId] = {
+            type: 'q',
+            query: '[Q] ' + qText,
+            level: roundNum * 10 + 2,
+            relevance_score: qData.score || 0,
+            evaluationReason: qData.reason || '',
+            strategy: 'Query',
+            iteration: roundNum,
+            is_selected: true,
+            type_label: qData.type_label || qData.typeLabel || '',
+            domain_index: qData.domain_index,
+            domain_type: qData.domain_type || ''
+          };
+
+          edges.push({
+            from: sugStepId,
+            to: qId,
+            edge_type: 'step_to_q',
+            strategy: 'Query'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(qId);
+
+          // 为每个 Q 的 sug 创建节点
+          const sugs = round.sug_details[qText] || [];
+          const qScore = qData.score || 0;  // 获取父Q的得分
+
+          sugs.forEach((sug, sugIndex) => {
+            const sugScore = sug.score || 0;
+            // 比较得分决定颜色:SUG得分 >= Q得分 + 0.05 → 绿色,否则 → 红色
+            const scoreColor = (sugScore >= qScore + REQUIRED_SCORE_GAIN) ? '#22c55e' : '#ef4444';
+
+            const sugId = `sug_${sug.text}_r${roundNum}_q${qIndex}_${sugIndex}`;
+            nodes[sugId] = {
+              type: 'sug',
+              query: '[SUG] ' + sug.text,
+              level: roundNum * 10 + 3,
+              relevance_score: sugScore,
+              evaluationReason: sug.reason || '',
+              strategy: '推荐词',
+              iteration: roundNum,
+              is_selected: true,
+              scoreColor: scoreColor,  // 新增:用于控制文字颜色
+              parentQScore: qScore     // 新增:保存父Q得分用于调试
+            };
+
+            edges.push({
+              from: qId,
+              to: sugId,
+              edge_type: 'q_to_sug',
+              strategy: '推荐词'
+            });
+
+            if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+            iterations[roundNum * 10 + 3].push(sugId);
+          });
+        });
+      }
+
+      // 步骤2: 域内组词(Round 1+)
+      // 兼容旧字段名 domain_combinations_top10
+      const domainCombinations = round.domain_combinations || round.domain_combinations_top10 || [];
+      if (domainCombinations.length > 0) {
+        const combStepId = `step_comb_r${roundNum}`;
+        nodes[combStepId] = {
+          type: 'step',
+          query: roundNum === 1
+            ? `步骤2: 域内组合 (${domainCombinations.length}个组合)`
+            : `步骤2: 跨${roundNum}个域组合 (${domainCombinations.length}个组合)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '域内组词',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: combStepId,
+          edge_type: 'round_to_step',
+          strategy: '域内组词'
+        });
+
+        iterations[roundNum * 10].push(combStepId);
+
+        // 为每个域内组合创建节点
+        domainCombinations.forEach((comb, combIndex) => {
+          const combId = `comb_${comb.text}_r${roundNum}_${combIndex}`;
+          const domainsStr = comb.domains ? comb.domains.map(d => `D${d}`).join(',') : '';
+          const wordDetails = comb.source_word_details || [];
+          const isAboveSources = comb.is_above_source_scores || false;
+          const scoreColor = wordDetails.length > 0
+            ? (isAboveSources ? '#22c55e' : '#ef4444')
+            : null;
+
+          nodes[combId] = {
+            type: 'domain_combination',
+            query: `${comb.text}`,  // 移除 type_label,稍后在UI中单独显示
+            level: roundNum * 10 + 2,
+            relevance_score: comb.score || 0,
+            evaluationReason: comb.reason || '',
+            strategy: '域内组合',
+            iteration: roundNum,
+            is_selected: true,
+            type_label: comb.type_label || '',
+            source_words: comb.source_words || [],
+            from_segments: comb.from_segments || [],
+            domains: comb.domains || [],
+            domains_str: domainsStr,
+            source_word_details: wordDetails,
+            source_scores: comb.source_scores || [],
+            is_above_sources: isAboveSources,
+            max_source_score: comb.max_source_score ?? null,
+            scoreColor: scoreColor
+          };
+
+          edges.push({
+            from: combStepId,
+            to: combId,
+            edge_type: 'step_to_comb',
+            strategy: '域内组合'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(combId);
+        });
+      }
+
+      // 步骤3: 筛选并执行搜索
+      const searchStepId = `step_search_r${roundNum}`;
+      const searchCountText = round.search_count > 0
+        ? `筛选${round.high_score_sug_count}个高分词,搜索${round.search_count}次,${round.total_posts}个帖子`
+        : `无高分推荐词,未执行搜索`;
+
+      nodes[searchStepId] = {
+        type: 'step',
+        query: `步骤3: 筛选并执行搜索 (${searchCountText})`,
+        level: roundNum * 10 + 1,
+        relevance_score: 0,
+        strategy: '筛选并执行搜索',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: roundId,
+        to: searchStepId,
+        edge_type: 'round_to_step',
+        strategy: '搜索'
+      });
+
+      iterations[roundNum * 10].push(searchStepId);
+
+      // 只有在有搜索结果时才添加搜索词和帖子
+      // 优先使用 round.search_results(新格式),否则使用外部传入的 searchResults(兼容旧版本)
+      const roundSearchResults = round.search_results || searchResults;
+      if (round.search_count > 0 && roundSearchResults) {
+        if (Array.isArray(roundSearchResults)) {
+          roundSearchResults.forEach((search, searchIndex) => {
+            const searchWordId = `search_${search.text}_r${roundNum}_${searchIndex}`;
+            nodes[searchWordId] = {
+              type: 'search_word',
+              query: '[SEARCH] ' + search.text,
+              level: roundNum * 10 + 2,
+              relevance_score: search.score_with_o || 0,
+              strategy: '搜索词',
+              iteration: roundNum,
+              is_selected: true
+            };
+
+            edges.push({
+              from: searchStepId,
+              to: searchWordId,
+              edge_type: 'step_to_search_word',
+              strategy: '搜索词'
+            });
+
+            if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+            iterations[roundNum * 10 + 2].push(searchWordId);
+
+            // 添加帖子
+            if (search.post_list && search.post_list.length > 0) {
+              search.post_list.forEach((post, postIndex) => {
+                const postId = `post_${post.note_id}_${searchIndex}_${postIndex}`;
+
+                // 准备图片列表,将URL字符串转换为对象格式供轮播图使用
+                const imageList = (post.images || []).map(url => ({
+                  image_url: url
+                }));
+
+                nodes[postId] = {
+                  type: 'post',
+                  query: '[R] ' + post.title,
+                  level: roundNum * 10 + 3,
+                  relevance_score: 0,
+                  strategy: '帖子',
+                  iteration: roundNum,
+                  is_selected: true,
+                  note_id: post.note_id,
+                  note_url: post.note_url,
+                  body_text: post.body_text || '',
+                  images: post.images || [],
+                  image_list: imageList,
+                  interact_info: post.interact_info || {},
+                  // 附加多模态提取数据
+                  extraction: extractionData && extractionData[post.note_id] ? extractionData[post.note_id] : null
+                };
+
+                edges.push({
+                  from: searchWordId,
+                  to: postId,
+                  edge_type: 'search_word_to_post',
+                  strategy: '搜索结果'
+                });
+
+                // 如果有提取数据,创建对应的分析节点
+                if (extractionData && extractionData[post.note_id]) {
+                  const analysisId = `analysis_${post.note_id}_${searchIndex}_${postIndex}`;
+
+                  nodes[analysisId] = {
+                    type: 'analysis',
+                    query: '[AI分析] ' + post.title,
+                    level: roundNum * 10 + 4,
+                    relevance_score: 0,
+                    strategy: 'AI分析',
+                    iteration: roundNum,
+                    is_selected: true,
+                    note_id: post.note_id,
+                    note_url: post.note_url,
+                    title: post.title,
+                    body_text: post.body_text || '',
+                    interact_info: post.interact_info || {},
+                    extraction: extractionData[post.note_id],
+                    image_list: imageList
+                  };
+
+                  edges.push({
+                    from: postId,
+                    to: analysisId,
+                    edge_type: 'post_to_analysis',
+                    strategy: 'AI分析'
+                  });
+
+                  if (!iterations[roundNum * 10 + 4]) iterations[roundNum * 10 + 4] = [];
+                  iterations[roundNum * 10 + 4].push(analysisId);
+                }
+
+                if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+                iterations[roundNum * 10 + 3].push(postId);
+              });
+            }
+          });
+        }
+      }
+
+      // 步骤3: 加词生成新查询
+      if (round.add_word_details && Object.keys(round.add_word_details).length > 0) {
+        const addWordStepId = `step_add_r${roundNum}`;
+        const totalAddWords = Object.values(round.add_word_details).reduce((sum, list) => sum + list.length, 0);
+
+        nodes[addWordStepId] = {
+          type: 'step',
+          query: `步骤3: 加词生成新查询 (${totalAddWords}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '加词生成新查询',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: addWordStepId,
+          edge_type: 'round_to_step',
+          strategy: '加词'
+        });
+
+        iterations[roundNum * 10].push(addWordStepId);
+
+        // 为每个 Seed 创建节点
+        Object.keys(round.add_word_details).forEach((seedText, seedIndex) => {
+          const seedId = `seed_${seedText}_r${roundNum}_${seedIndex}`;
+
+          // 查找seed的来源信息和分数 - 动态从正确的轮次查找
+          let seedInfo = {};
+          if (roundNum === 1) {
+            // Round 1:种子来自 Round 0 的 seed_list
+            const round0 = rounds.find(r => r.round_num === 0 || r.type === 'initialization');
+            seedInfo = round0?.seed_list?.find(s => s.text === seedText) || {};
+          } else {
+            // Round 2+:种子来自前一轮的 seed_list_next
+            const prevRound = rounds.find(r => r.round_num === roundNum - 1);
+            seedInfo = prevRound?.seed_list_next?.find(s => s.text === seedText) || {};
+          }
+          const fromType = seedInfo.from_type || seedInfo.from || 'unknown';
+
+          // 根据来源设置strategy
+          let strategy;
+          if (fromType === 'seg') {
+            strategy = '初始分词';
+          } else if (fromType === 'add') {
+            strategy = '加词';
+          } else if (fromType === 'sug') {
+            strategy = '调用sug';
+          } else {
+            strategy = 'Seed';  // 默认灰色
+          }
+
+          nodes[seedId] = {
+            type: 'seed',
+            query: seedText,
+            level: roundNum * 10 + 2,
+            relevance_score: seedInfo.score || 0,  // 从seedInfo读取种子的得分
+            strategy: strategy,
+            iteration: roundNum,
+            is_selected: true
+          };
+
+          edges.push({
+            from: addWordStepId,
+            to: seedId,
+            edge_type: 'step_to_seed',
+            strategy: 'Seed'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(seedId);
+
+          // 为每个 Seed 的组合词创建节点
+          const combinedWords = round.add_word_details[seedText] || [];
+          combinedWords.forEach((word, wordIndex) => {
+            const wordScore = word.score || 0;
+            const seedScore = word.seed_score || 0;
+            // 比较得分决定颜色:组合词得分 > 种子得分 → 绿色,否则 → 红色
+            const scoreColor = wordScore > seedScore ? '#22c55e' : '#ef4444';
+
+            const wordId = `add_${word.text}_r${roundNum}_seed${seedIndex}_${wordIndex}`;
+            nodes[wordId] = {
+              type: 'add_word',
+              query: '[Q] ' + word.text,
+              level: roundNum * 10 + 3,
+              relevance_score: wordScore,
+              evaluationReason: word.reason || '',
+              strategy: '加词生成',
+              iteration: roundNum,
+              is_selected: true,
+              selected_word: word.selected_word,
+              seed_score: seedScore,  // 原始种子的得分
+              scoreColor: scoreColor  // 用于控制文字颜色
+            };
+
+            edges.push({
+              from: seedId,
+              to: wordId,
+              edge_type: 'seed_to_add_word',
+              strategy: '组合词'
+            });
+
+            if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+            iterations[roundNum * 10 + 3].push(wordId);
+          });
+        });
+      }
+
+      // 步骤4: 筛选推荐词进入下轮
+      const filteredSugs = round.output_q_list?.filter(q => q.from === 'sug') || [];
+      if (filteredSugs.length > 0) {
+        const filterStepId = `step_filter_r${roundNum}`;
+        nodes[filterStepId] = {
+          type: 'step',
+          query: `步骤4: 筛选推荐词进入下轮 (${filteredSugs.length}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '筛选推荐词进入下轮',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: filterStepId,
+          edge_type: 'round_to_step',
+          strategy: '筛选'
+        });
+
+        iterations[roundNum * 10].push(filterStepId);
+
+        // 添加筛选出的sug
+        filteredSugs.forEach((sug, sugIndex) => {
+          const sugScore = sug.score || 0;
+
+          // 尝试从sug_details中找到这个sug对应的父Q得分
+          let parentQScore = 0;
+          if (round.sug_details) {
+            for (const [qText, sugs] of Object.entries(round.sug_details)) {
+              const matchingSug = sugs.find(s => s.text === sug.text);
+              if (matchingSug) {
+                // 找到父Q的得分
+                let qData = {};
+                if (roundNum === 0) {
+                  qData = round.q_list_1?.find(q => q.text === qText) || {};
+                } else {
+                  qData = round.input_queries?.find(q => q.text === qText) || {};
+                }
+                parentQScore = qData.score || 0;
+                break;
+              }
+            }
+          }
+
+          // 比较得分决定颜色:SUG得分 > Q得分 → 绿色,否则 → 红色
+          const scoreColor = (sugScore >= parentQScore + REQUIRED_SCORE_GAIN) ? '#22c55e' : '#ef4444';
+
+          const sugId = `filtered_sug_${sug.text}_r${roundNum}_${sugIndex}`;
+          nodes[sugId] = {
+            type: 'filtered_sug',
+            query: '[SUG] ' + sug.text,
+            level: roundNum * 10 + 2,
+            relevance_score: sugScore,
+            strategy: '进入下轮',
+            iteration: roundNum,
+            is_selected: true,
+            scoreColor: scoreColor,       // 新增:用于控制文字颜色
+            parentQScore: parentQScore    // 新增:保存父Q得分用于调试
+          };
+
+          edges.push({
+            from: filterStepId,
+            to: sugId,
+            edge_type: 'step_to_filtered_sug',
+            strategy: '进入下轮'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(sugId);
+        });
+      }
+
+      // 步骤4: 构建下一轮(Round 1+)
+      const highScoreCombinations = round.high_score_combinations || [];
+      const highGainSugs = round.high_gain_sugs || [];
+      const nextRoundItems = [...highScoreCombinations, ...highGainSugs];
+
+      if (nextRoundItems.length > 0) {
+        const nextRoundStepId = `step_next_round_r${roundNum}`;
+        nodes[nextRoundStepId] = {
+          type: 'step',
+          query: `步骤4: 构建下一轮 (${nextRoundItems.length}个查询)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '构建下一轮',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: nextRoundStepId,
+          edge_type: 'round_to_step',
+          strategy: '构建下一轮'
+        });
+
+        iterations[roundNum * 10].push(nextRoundStepId);
+
+        // 创建查询节点
+        nextRoundItems.forEach((item, index) => {
+          const itemId = `next_round_${item.text}_r${roundNum}_${index}`;
+          const isSugItem = item.type === 'sug';
+
+          nodes[itemId] = {
+            type: 'next_round_item',
+            query: '[Q] ' + item.text,
+            level: roundNum * 10 + 2,
+            relevance_score: item.score || 0,
+            strategy: item.type === 'combination' ? '域内组合' : '高增益SUG',
+            iteration: roundNum,
+            is_selected: true,
+            type_label: item.type_label || '',
+            item_type: item.type,
+            is_suggestion: isSugItem,
+            suggestion_label: isSugItem ? '[suggestion]' : ''
+          };
+
+          edges.push({
+            from: nextRoundStepId,
+            to: itemId,
+            edge_type: 'step_to_next_round',
+            strategy: item.type === 'combination' ? '域内组合' : 'SUG'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(itemId);
+        });
+      }
+    }
+  });
+
+  return {
+    nodes,
+    edges,
+    iterations
+  };
+}
+
+/**
+ * 简化版转换:专注于query和post的演化
+ * - 合并所有query节点(不区分seg/sug/add_word)
+ * - 合并相同的帖子节点
+ * - 步骤信息放在边上
+ * - 隐藏Round/Step节点
+ */
+function convertV8ToGraphSimplified(runContext, searchResults, extractionData) {
+  const mergedNodes = {};
+  const edges = [];
+  const iterations = {};
+  extractionData = extractionData || {}; // 默认为空对象
+
+  const o = runContext.o || '原始问题';
+  const rounds = runContext.rounds || [];
+
+  // 添加原始问题根节点
+  const rootId = 'root_o';
+  mergedNodes[rootId] = {
+    type: 'root',
+    query: o,
+    level: 0,
+    relevance_score: 1.0,
+    strategy: '原始问题',
+    iteration: 0,
+    is_selected: true,
+    occurrences: [{round: 0, role: 'root', score: 1.0}]
+  };
+  iterations[0] = [rootId];
+
+  // 用于记录节点之间的演化关系
+  const queryEvolution = {}; // {text: {occurrences: [], parentTexts: [], childTexts: []}}
+  const postMap = {}; // {note_id: {...}}
+
+  // 第一遍:收集所有query和post
+  rounds.forEach((round, roundIndex) => {
+    const roundNum = round.round_num || roundIndex;
+
+    if (round.type === 'initialization') {
+      // Round 0: 收集分词结果
+      (round.q_list_1 || []).forEach(q => {
+        if (!queryEvolution[q.text]) {
+          queryEvolution[q.text] = {
+            occurrences: [],
+            parentTexts: new Set([o]), // 来自原始问题
+            childTexts: new Set()
+          };
+        }
+        queryEvolution[q.text].occurrences.push({
+          round: roundNum,
+          role: 'segmentation',
+          strategy: '分词',
+          score: q.score,
+          reason: q.reason,
+          type_label: q.type_label || q.typeLabel || ''
+        });
+      });
+    } else {
+      // Round 1+
+
+      // 收集sug_details (推荐词)
+      Object.entries(round.sug_details || {}).forEach(([parentText, sugs]) => {
+        sugs.forEach(sug => {
+          if (!queryEvolution[sug.text]) {
+            queryEvolution[sug.text] = {
+              occurrences: [],
+              parentTexts: new Set(),
+              childTexts: new Set()
+            };
+          }
+          queryEvolution[sug.text].occurrences.push({
+            round: roundNum,
+            role: 'sug',
+            strategy: '调用sug',
+            score: sug.score,
+            reason: sug.reason,
+            type_label: sug.type_label || sug.typeLabel || ''
+          });
+          queryEvolution[sug.text].parentTexts.add(parentText);
+          if (queryEvolution[parentText]) {
+            queryEvolution[parentText].childTexts.add(sug.text);
+          }
+        });
+      });
+
+      // 收集add_word_details (加词结果)
+      Object.entries(round.add_word_details || {}).forEach(([seedText, words]) => {
+        words.forEach(word => {
+          if (!queryEvolution[word.text]) {
+            queryEvolution[word.text] = {
+              occurrences: [],
+              parentTexts: new Set(),
+              childTexts: new Set()
+            };
+          }
+          queryEvolution[word.text].occurrences.push({
+            round: roundNum,
+            role: 'add_word',
+            strategy: '加词',
+            score: word.score,
+            reason: word.reason,
+            selectedWord: word.selected_word,
+            seedScore: word.seed_score,  // 添加原始种子的得分
+            type_label: word.type_label || word.typeLabel || ''
+          });
+          queryEvolution[word.text].parentTexts.add(seedText);
+          if (queryEvolution[seedText]) {
+            queryEvolution[seedText].childTexts.add(word.text);
+          }
+        });
+      });
+
+      // 收集搜索结果和帖子
+      const roundSearchResults = round.search_results || searchResults;
+      if (roundSearchResults && Array.isArray(roundSearchResults)) {
+        roundSearchResults.forEach(search => {
+          const searchText = search.text;
+
+          // 标记这个query被用于搜索
+          if (queryEvolution[searchText]) {
+            queryEvolution[searchText].occurrences.push({
+              round: roundNum,
+              role: 'search',
+              strategy: '执行搜索',
+              score: search.score_with_o,
+              postCount: search.post_list ? search.post_list.length : 0
+            });
+          }
+
+          // 收集帖子
+          if (search.post_list && search.post_list.length > 0) {
+            search.post_list.forEach(post => {
+              if (!postMap[post.note_id]) {
+                postMap[post.note_id] = {
+                  ...post,
+                  foundByQueries: new Set(),
+                  foundInRounds: new Set()
+                };
+              }
+              postMap[post.note_id].foundByQueries.add(searchText);
+              postMap[post.note_id].foundInRounds.add(roundNum);
+
+              // 建立query到post的关系
+              if (!queryEvolution[searchText].posts) {
+                queryEvolution[searchText].posts = new Set();
+              }
+              queryEvolution[searchText].posts.add(post.note_id);
+            });
+          }
+        });
+      }
+    }
+  });
+
+  // 第二遍:创建合并后的节点
+  Object.entries(queryEvolution).forEach(([text, data]) => {
+    const nodeId = `query_${text}`;
+
+    // 获取最新的分数
+    const latestOccurrence = data.occurrences[data.occurrences.length - 1] || {};
+    const hasSearchResults = data.posts && data.posts.size > 0;
+
+    mergedNodes[nodeId] = {
+      type: 'query',
+      query: '[Q] ' + text,
+      level: Math.max(...data.occurrences.map(o => o.round), 0) * 10 + 2,
+      relevance_score: latestOccurrence.score || 0,
+      evaluationReason: latestOccurrence.reason || '',
+      strategy: data.occurrences.map(o => o.strategy).join(' + '),
+      primaryStrategy: latestOccurrence.strategy || '未知',  // 添加主要策略字段
+      iteration: Math.max(...data.occurrences.map(o => o.round), 0),
+      is_selected: true,
+      occurrences: data.occurrences,
+      hasSearchResults: hasSearchResults,
+      postCount: data.posts ? data.posts.size : 0,
+      selectedWord: data.occurrences.find(o => o.selectedWord)?.selectedWord || '',
+      seed_score: data.occurrences.find(o => o.seedScore)?.seedScore,  // 添加原始种子的得分
+      type_label: latestOccurrence.type_label || ''  // 使用最新的 type_label
+    };
+
+    // 添加到对应的轮次
+    const maxRound = Math.max(...data.occurrences.map(o => o.round), 0);
+    const iterKey = maxRound * 10 + 2;
+    if (!iterations[iterKey]) iterations[iterKey] = [];
+    iterations[iterKey].push(nodeId);
+  });
+
+  // 创建帖子节点
+  Object.entries(postMap).forEach(([noteId, post]) => {
+    const postId = `post_${noteId}`;
+
+    const imageList = (post.images || []).map(url => ({
+      image_url: url
+    }));
+
+    mergedNodes[postId] = {
+      type: 'post',
+      query: '[R] ' + post.title,
+      level: 100, // 放在最后
+      relevance_score: 0,
+      strategy: '帖子',
+      iteration: Math.max(...Array.from(post.foundInRounds)),
+      is_selected: true,
+      note_id: post.note_id,
+      note_url: post.note_url,
+      body_text: post.body_text || '',
+      images: post.images || [],
+      image_list: imageList,
+      interact_info: post.interact_info || {},
+      foundByQueries: Array.from(post.foundByQueries),
+      foundInRounds: Array.from(post.foundInRounds),
+      // 附加多模态提取数据
+      extraction: extractionData && extractionData[post.note_id] ? extractionData[post.note_id] : null
+    };
+
+    if (!iterations[100]) iterations[100] = [];
+    iterations[100].push(postId);
+
+    // 如果有提取数据,创建对应的分析节点
+    if (extractionData && extractionData[post.note_id]) {
+      const analysisId = `analysis_${noteId}`;
+
+      mergedNodes[analysisId] = {
+        type: 'analysis',
+        query: '[AI分析] ' + post.title,
+        level: 101,
+        relevance_score: 0,
+        strategy: 'AI分析',
+        iteration: Math.max(...Array.from(post.foundInRounds)),
+        is_selected: true,
+        note_id: post.note_id,
+        note_url: post.note_url,
+        title: post.title,
+        body_text: post.body_text || '',
+        interact_info: post.interact_info || {},
+        extraction: extractionData[post.note_id],
+        image_list: imageList
+      };
+
+      edges.push({
+        from: postId,
+        to: analysisId,
+        edge_type: 'post_to_analysis',
+        strategy: 'AI分析',
+        label: 'AI分析',
+        round: Math.max(...Array.from(post.foundInRounds))
+      });
+
+      if (!iterations[101]) iterations[101] = [];
+      iterations[101].push(analysisId);
+    }
+  });
+
+  // 第三遍:创建边
+  // 1. 原始问题 -> 分词结果
+  Object.entries(queryEvolution).forEach(([text, data]) => {
+    const nodeId = `query_${text}`;
+    const segOccurrence = data.occurrences.find(o => o.role === 'segmentation');
+
+    if (segOccurrence && data.parentTexts.has(o)) {
+      edges.push({
+        from: rootId,
+        to: nodeId,
+        edge_type: 'segmentation',
+        strategy: '分词',
+        label: '分词',
+        round: 0
+      });
+    }
+  });
+
+  // 2. Query演化关系
+  Object.entries(queryEvolution).forEach(([text, data]) => {
+    const nodeId = `query_${text}`;
+
+    data.parentTexts.forEach(parentText => {
+      if (parentText === o) return; // 跳过原始问题(已处理)
+
+      const parentNodeId = `query_${parentText}`;
+      if (!mergedNodes[parentNodeId]) return;
+
+      // 找到这个演化的策略和轮次
+      const occurrence = data.occurrences.find(o =>
+        o.role === 'sug' || o.role === 'add_word'
+      );
+
+      edges.push({
+        from: parentNodeId,
+        to: nodeId,
+        edge_type: occurrence?.role || 'evolution',
+        strategy: occurrence?.strategy || '演化',
+        label: `${occurrence?.strategy || '演化'} (R${occurrence?.round || 0})`,
+        round: occurrence?.round || 0
+      });
+    });
+  });
+
+  // 3. Query -> Post (搜索关系)
+  Object.entries(queryEvolution).forEach(([text, data]) => {
+    const nodeId = `query_${text}`;
+
+    if (data.posts && data.posts.size > 0) {
+      const searchOccurrence = data.occurrences.find(o => o.role === 'search');
+
+      data.posts.forEach(noteId => {
+        const postId = `post_${noteId}`;
+        edges.push({
+          from: nodeId,
+          to: postId,
+          edge_type: 'search',
+          strategy: '搜索',
+          label: `搜索 (${data.posts.size}个帖子)`,
+          round: searchOccurrence?.round || 0
+        });
+      });
+    }
+  });
+
+  return {
+    nodes: mergedNodes,
+    edges,
+    iterations
+  };
+}
+
+module.exports = { convertV8ToGraphV2, convertV8ToGraphSimplified };

BIN
visualization/knowledge_search_traverse/image.png


+ 2927 - 0
visualization/knowledge_search_traverse/index.js

@@ -0,0 +1,2927 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const { build } = require('esbuild');
+const { convertV8ToGraph } = require('./convert_v8_to_graph');
+const { convertV8ToGraphV2, convertV8ToGraphSimplified } = require('./convert_v8_to_graph_v3');
+
+// 读取命令行参数
+const args = process.argv.slice(2);
+if (args.length === 0) {
+  console.error('Usage: node index.js <path-to-run_context.json> [output.html] [--simplified]');
+  process.exit(1);
+}
+
+const inputFile = args[0];
+const outputFile = args[1] || 'query_graph_output.html';
+const useSimplified = args.includes('--simplified');
+
+// 读取输入数据
+const inputData = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
+
+// 检测数据格式并转换
+let data;
+if (inputData.rounds && inputData.o) {
+  // v6.1.2.8 格式,需要转换
+  console.log('✨ 检测到 v6.1.2.8 格式,正在转换为图结构...');
+
+  // 尝试读取 search_results.json(兼容旧版本)
+  let searchResults = null;
+  const searchResultsPath = path.join(path.dirname(inputFile), 'search_results.json');
+  if (fs.existsSync(searchResultsPath)) {
+    console.log('📄 读取外部搜索结果数据(兼容模式)...');
+    searchResults = JSON.parse(fs.readFileSync(searchResultsPath, 'utf-8'));
+  } else {
+    console.log('✅ 使用 run_context.json 中的内嵌搜索结果');
+  }
+
+  // 尝试读取 search_extract.json(多模态提取数据)
+  let extractionData = null;
+  const extractionPath = path.join(path.dirname(inputFile), 'search_extract.json');
+  if (fs.existsSync(extractionPath)) {
+    console.log('📸 读取多模态提取数据...');
+    extractionData = JSON.parse(fs.readFileSync(extractionPath, 'utf-8'));
+  } else {
+    console.log('ℹ️  未找到 search_extract.json,跳过多模态展示');
+  }
+
+  // 选择转换函数
+  let graphData;
+  let fullData = null; // 用于目录的完整数据
+
+  if (useSimplified) {
+    console.log('🎨 使用简化视图(合并query节点)');
+    // 生成简化版用于画布
+    graphData = convertV8ToGraphSimplified(inputData, searchResults, extractionData);
+    // 生成完整版用于目录
+    const fullGraphData = convertV8ToGraphV2(inputData, searchResults, extractionData);
+    fullData = {
+      nodes: fullGraphData.nodes,
+      edges: fullGraphData.edges,
+      iterations: fullGraphData.iterations
+    };
+    console.log(`✅ 简化版: ${Object.keys(graphData.nodes).length} 个节点, ${graphData.edges.length} 条边`);
+    console.log(`📋 完整版(用于目录): ${Object.keys(fullData.nodes).length} 个节点`);
+  } else {
+    console.log('📊 使用详细视图(完整流程)');
+    graphData = convertV8ToGraphV2(inputData, searchResults, extractionData);
+    console.log(`✅ 转换完成: ${Object.keys(graphData.nodes).length} 个节点, ${graphData.edges.length} 条边`);
+  }
+
+  data = {
+    nodes: graphData.nodes,
+    edges: graphData.edges,
+    iterations: graphData.iterations,
+    fullData: fullData // 传递完整数据
+  };
+} else if (inputData.nodes && inputData.edges) {
+  // v6.1.2.5 格式,直接使用
+  console.log('✨ 检测到 v6.1.2.5 格式,直接使用');
+  data = inputData;
+} else {
+  console.error('❌ 无法识别的数据格式');
+  process.exit(1);
+}
+
+// 创建临时 React 组件文件
+const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
+const reactComponent = `
+import React, { useState, useCallback, useMemo, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+import {
+  ReactFlow,
+  Controls,
+  Background,
+  useNodesState,
+  useEdgesState,
+  Handle,
+  Position,
+  useReactFlow,
+  ReactFlowProvider,
+} from '@xyflow/react';
+import '@xyflow/react/dist/style.css';
+
+const data = ${JSON.stringify(data, null, 2)};
+
+// 根据节点类型获取边框颜色
+function getNodeTypeColor(type) {
+  const typeColors = {
+    'root': '#6b21a8',        // 紫色 - 根节点
+    'round': '#7c3aed',       // 深紫 - Round节点
+    'step': '#f59e0b',        // 橙色 - 步骤节点
+    'seg': '#10b981',         // 绿色 - 分词
+    'q': '#3b82f6',           // 蓝色 - Query
+    'sug': '#06b6d4',         // 青色 - Sug建议词
+    'seed': '#84cc16',        // 黄绿 - Seed
+    'add_word': '#22c55e',    // 绿色 - 加词生成
+    'search_word': '#8b5cf6', // 紫色 - 搜索词
+    'post': '#ec4899',        // 粉色 - 帖子
+    'filtered_sug': '#14b8a6',// 青绿 - 筛选的sug
+    'next_q': '#2563eb',      // 深蓝 - 下轮查询
+    'next_seed': '#65a30d',   // 深黄绿 - 下轮种子
+    'search': '#8b5cf6',      // 深紫 - 搜索(兼容旧版)
+    'operation': '#f59e0b',   // 橙色 - 操作节点(兼容旧版)
+    'query': '#3b82f6',       // 蓝色 - 查询(兼容旧版)
+    'note': '#ec4899',        // 粉色 - 帖子(兼容旧版)
+  };
+  return typeColors[type] || '#9ca3af';
+}
+
+// 查询节点组件 - 卡片样式
+function QueryNode({ id, data, sourcePosition, targetPosition }) {
+  // 所有节点默认展开
+  const expanded = true;
+
+  // 获取节点类型颜色
+  const typeColor = getNodeTypeColor(data.nodeType || 'query');
+
+  return (
+    <div>
+      <Handle
+        type="target"
+        position={targetPosition || Position.Left}
+        style={{ background: typeColor, width: 8, height: 8 }}
+      />
+      <div
+        style={{
+          padding: '12px',
+          borderRadius: '8px',
+          border: data.isHighlighted ? \`3px solid \${typeColor}\` :
+                  data.isCollapsed ? \`2px solid \${typeColor}\` :
+                  data.isSelected === false ? '2px dashed #d1d5db' :
+                  \`2px solid \${typeColor}\`,
+          background: data.isHighlighted ? '#eef2ff' :
+                      data.isSelected === false ? '#f9fafb' : 'white',
+          minWidth: '200px',
+          maxWidth: '280px',
+          boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
+                     data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
+                     data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
+          transition: 'all 0.3s ease',
+          cursor: 'pointer',
+          position: 'relative',
+          opacity: data.isSelected === false ? 0.6 : 1,
+        }}
+      >
+        {/* 折叠当前节点按钮 - 左边 */}
+        <div
+          style={{
+            position: 'absolute',
+            top: '6px',
+            left: '6px',
+            width: '20px',
+            height: '20px',
+            borderRadius: '50%',
+            background: '#f59e0b',
+            color: 'white',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            fontSize: '11px',
+            fontWeight: 'bold',
+            cursor: 'pointer',
+            transition: 'all 0.2s ease',
+            zIndex: 10,
+          }}
+          onClick={(e) => {
+            e.stopPropagation();
+            if (data.onHideSelf) {
+              data.onHideSelf();
+            }
+          }}
+          onMouseEnter={(e) => {
+            e.currentTarget.style.background = '#d97706';
+          }}
+          onMouseLeave={(e) => {
+            e.currentTarget.style.background = '#f59e0b';
+          }}
+          title="隐藏当前节点"
+        >
+          ×
+        </div>
+
+        {/* 聚焦按钮 - 右上角 */}
+        <div
+          style={{
+            position: 'absolute',
+            top: '6px',
+            right: '6px',
+            width: '20px',
+            height: '20px',
+            borderRadius: '50%',
+            background: data.isFocused ? '#10b981' : '#e5e7eb',
+            color: data.isFocused ? 'white' : '#6b7280',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            fontSize: '11px',
+            fontWeight: 'bold',
+            cursor: 'pointer',
+            transition: 'all 0.2s ease',
+            zIndex: 10,
+          }}
+          onClick={(e) => {
+            e.stopPropagation();
+            if (data.onFocus) {
+              data.onFocus();
+            }
+          }}
+          onMouseEnter={(e) => {
+            if (!data.isFocused) {
+              e.currentTarget.style.background = '#d1d5db';
+            }
+          }}
+          onMouseLeave={(e) => {
+            if (!data.isFocused) {
+              e.currentTarget.style.background = '#e5e7eb';
+            }
+          }}
+          title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
+        >
+          🎯
+        </div>
+
+        {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
+        {data.hasChildren && (
+          <div
+            style={{
+              position: 'absolute',
+              top: '6px',
+              right: '30px',
+              width: '20px',
+              height: '20px',
+              borderRadius: '50%',
+              background: data.isCollapsed ? '#667eea' : '#e5e7eb',
+              color: data.isCollapsed ? 'white' : '#6b7280',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              fontSize: '11px',
+              fontWeight: 'bold',
+              cursor: 'pointer',
+              transition: 'all 0.2s ease',
+              zIndex: 10,
+            }}
+            onClick={(e) => {
+              e.stopPropagation();
+              data.onToggleCollapse();
+            }}
+            title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
+          >
+            {data.isCollapsed ? '+' : '−'}
+          </div>
+        )}
+
+        {/* 卡片内容 */}
+        <div>
+          {/* 标题行 */}
+          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
+            <div style={{ flex: 1 }}>
+              <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
+                <div style={{
+                  fontSize: '13px',
+                  fontWeight: data.level === 0 ? '700' : '600',
+                  color: data.level === 0 ? '#6b21a8' : '#1f2937',
+                  lineHeight: '1.3',
+                  flex: 1,
+                }}>
+                  {data.title}
+                </div>
+                {data.isSelected === false && (
+                  <div style={{
+                    fontSize: '9px',
+                    padding: '1px 4px',
+                    borderRadius: '3px',
+                    background: '#fee2e2',
+                    color: '#991b1b',
+                    fontWeight: '500',
+                    flexShrink: 0,
+                  }}>
+                    未选中
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+
+        {/* 展开的详细信息 - 始终显示 */}
+        <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
+            <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
+              <span style={{
+                display: 'inline-block',
+                padding: '1px 6px',
+                borderRadius: '10px',
+                background: '#eff6ff',
+                color: '#3b82f6',
+                fontSize: '10px',
+                fontWeight: '500',
+              }}>
+                Lv.{data.level}
+              </span>
+              <span style={{
+                display: 'inline-block',
+                padding: '1px 6px',
+                borderRadius: '10px',
+                background: '#f0fdf4',
+                color: '#16a34a',
+                fontSize: '10px',
+                fontWeight: '500',
+              }}>
+                {data.score}
+              </span>
+              {data.strategy && data.strategy !== 'root' && (
+                <span style={{
+                  display: 'inline-block',
+                  padding: '1px 6px',
+                  borderRadius: '10px',
+                  background: '#fef3c7',
+                  color: '#92400e',
+                  fontSize: '10px',
+                  fontWeight: '500',
+                }}>
+                  {data.strategy}
+                </span>
+              )}
+              {(data.typeLabel || data.type_label) && (
+                <span style={{
+                  display: 'inline-block',
+                  padding: '1px 6px',
+                  borderRadius: '10px',
+                  background: '#fce7f3',
+                  color: '#9f1239',
+                  fontSize: '10px',
+                  fontWeight: '500',
+                }}>
+                  {data.typeLabel || data.type_label}
+                </span>
+              )}
+            {data.is_suggestion && data.suggestion_label && (
+              <span style={{
+                display: 'inline-block',
+                padding: '1px 6px',
+                borderRadius: '10px',
+                background: '#ede9fe',
+                color: '#6d28d9',
+                fontSize: '10px',
+                fontWeight: '600',
+              }}>
+                {data.suggestion_label}
+              </span>
+            )}
+            </div>
+
+            {data.parent && (
+              <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
+                <strong>Parent:</strong> {data.parent}
+              </div>
+            )}
+            {data.nodeType === 'domain_combination' && Array.isArray(data.source_word_details) && data.source_word_details.length > 0 && (
+              <div style={{
+                marginTop: '6px',
+                paddingTop: '6px',
+                borderTop: '1px solid #f3f4f6',
+                fontSize: '10px',
+                color: '#6b7280',
+                lineHeight: '1.5',
+              }}>
+                <strong style={{ color: '#4b5563' }}>来源词得分:</strong>
+                <div style={{ marginTop: '4px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
+                  {data.source_word_details.map((detail, idx) => {
+                    const words = (detail.words || []).map((w) => {
+                      const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
+                      const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
+                      return w.text + ' (' + formattedScore + ')';
+                    }).join(' + ');
+                    return (
+                      <div key={idx} style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', alignItems: 'center' }}>
+                        <span style={{ color: '#2563eb' }}>{words}</span>
+                      </div>
+                    );
+                  })}
+                </div>
+                <div style={{ marginTop: '4px', fontWeight: '500', color: data.is_above_sources ? '#16a34a' : '#dc2626' }}>
+                  {data.is_above_sources ? '✅ 组合得分高于所有来源词' : '⚠️ 组合得分未超过全部来源词'}
+                </div>
+              </div>
+            )}
+            {data.selectedWord && (
+              <div style={{
+                marginTop: '6px',
+                paddingTop: '6px',
+                borderTop: '1px solid #f3f4f6',
+                fontSize: '10px',
+                color: '#6b7280',
+                lineHeight: '1.5',
+              }}>
+                <strong style={{ color: '#4b5563' }}>选择词:</strong>
+                <span style={{ marginLeft: '4px', color: '#3b82f6', fontWeight: '500' }}>{data.selectedWord}</span>
+                {data.seed_score !== undefined && (
+                  <div style={{ marginTop: '4px' }}>
+                    <strong style={{ color: '#4b5563' }}>种子得分:</strong>
+                    <span style={{ marginLeft: '4px', color: '#16a34a', fontWeight: '500' }}>
+                      {typeof data.seed_score === 'number' ? data.seed_score.toFixed(2) : data.seed_score}
+                    </span>
+                  </div>
+                )}
+              </div>
+            )}
+            {data.evaluationReason && (
+              <div style={{
+                marginTop: '6px',
+                paddingTop: '6px',
+                borderTop: '1px solid #f3f4f6',
+                fontSize: '10px',
+                color: '#6b7280',
+                lineHeight: '1.5',
+              }}>
+                <strong style={{ color: '#4b5563' }}>评估:</strong>
+                <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
+              </div>
+            )}
+            {data.occurrences && data.occurrences.length > 1 && (
+              <div style={{
+                marginTop: '6px',
+                paddingTop: '6px',
+                borderTop: '1px solid #f3f4f6',
+                fontSize: '10px',
+                color: '#6b7280',
+              }}>
+                <strong style={{ color: '#4b5563' }}>演化历史 ({data.occurrences.length}次):</strong>
+                <div style={{ marginTop: '4px' }}>
+                  {data.occurrences.map((occ, idx) => (
+                    <div key={idx} style={{ marginTop: '2px', paddingLeft: '8px' }}>
+                      <span style={{ color: '#3b82f6', fontWeight: '500' }}>R{occ.round}</span>
+                      {' · '}
+                      <span>{occ.strategy}</span>
+                      {occ.score !== undefined && (
+                        <span style={{ color: '#16a34a', marginLeft: '4px' }}>
+                          ({typeof occ.score === 'number' ? occ.score.toFixed(2) : occ.score})
+                        </span>
+                      )}
+                    </div>
+                  ))}
+                </div>
+              </div>
+            )}
+            {data.hasSearchResults && (
+              <div style={{
+                marginTop: '6px',
+                paddingTop: '6px',
+                borderTop: '1px solid #f3f4f6',
+                fontSize: '10px',
+                background: '#fef3c7',
+                padding: '4px 6px',
+                borderRadius: '4px',
+                color: '#92400e',
+                fontWeight: '500',
+              }}>
+                🔍 找到 {data.postCount} 个帖子
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+      <Handle
+        type="source"
+        position={sourcePosition || Position.Right}
+        style={{ background: '#667eea', width: 8, height: 8 }}
+      />
+    </div>
+  );
+}
+
+// 笔记节点组件 - 卡片样式,带轮播图
+function NoteNode({ id, data, sourcePosition, targetPosition }) {
+  const [currentImageIndex, setCurrentImageIndex] = useState(0);
+  const expanded = true;
+  const hasImages = data.imageList && data.imageList.length > 0;
+
+  const nextImage = (e) => {
+    e.stopPropagation();
+    if (hasImages) {
+      setCurrentImageIndex((prev) => (prev + 1) % data.imageList.length);
+    }
+  };
+
+  const prevImage = (e) => {
+    e.stopPropagation();
+    if (hasImages) {
+      setCurrentImageIndex((prev) => (prev - 1 + data.imageList.length) % data.imageList.length);
+    }
+  };
+
+  return (
+    <div>
+      <Handle
+        type="target"
+        position={targetPosition || Position.Left}
+        style={{ background: '#ec4899', width: 8, height: 8 }}
+      />
+      <div
+        style={{
+          padding: '14px',
+          borderRadius: '20px',
+          border: data.isHighlighted ? '3px solid #ec4899' : '2px solid #fce7f3',
+          background: data.isHighlighted ? '#eef2ff' : 'white',
+          minWidth: '220px',
+          maxWidth: '300px',
+          boxShadow: data.isHighlighted ? '0 0 0 4px rgba(236, 72, 153, 0.25), 0 4px 16px rgba(236, 72, 153, 0.4)' : '0 4px 12px rgba(236, 72, 153, 0.15)',
+          transition: 'all 0.3s ease',
+          cursor: 'pointer',
+        }}
+      >
+        {/* 笔记标题 */}
+        <div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '8px' }}>
+          <div style={{ flex: 1 }}>
+            <div style={{
+              fontSize: '13px',
+              fontWeight: '600',
+              color: '#831843',
+              lineHeight: '1.4',
+              marginBottom: '4px',
+            }}>
+              {data.title}
+            </div>
+          </div>
+        </div>
+
+        {/* 轮播图 */}
+        {hasImages && (
+          <div style={{
+            position: 'relative',
+            marginBottom: '8px',
+            borderRadius: '12px',
+            overflow: 'hidden',
+          }}>
+            <img
+              src={data.imageList[currentImageIndex].image_url}
+              alt={\`Image \${currentImageIndex + 1}\`}
+              style={{
+                width: '100%',
+                height: '160px',
+                objectFit: 'cover',
+                display: 'block',
+              }}
+              onError={(e) => {
+                e.target.style.display = 'none';
+              }}
+            />
+            {data.imageList.length > 1 && (
+              <>
+                {/* 左右切换按钮 */}
+                <button
+                  onClick={prevImage}
+                  style={{
+                    position: 'absolute',
+                    left: '4px',
+                    top: '50%',
+                    transform: 'translateY(-50%)',
+                    background: 'rgba(0, 0, 0, 0.5)',
+                    color: 'white',
+                    border: 'none',
+                    borderRadius: '50%',
+                    width: '24px',
+                    height: '24px',
+                    cursor: 'pointer',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    fontSize: '14px',
+                  }}
+                >
+                  ‹
+                </button>
+                <button
+                  onClick={nextImage}
+                  style={{
+                    position: 'absolute',
+                    right: '4px',
+                    top: '50%',
+                    transform: 'translateY(-50%)',
+                    background: 'rgba(0, 0, 0, 0.5)',
+                    color: 'white',
+                    border: 'none',
+                    borderRadius: '50%',
+                    width: '24px',
+                    height: '24px',
+                    cursor: 'pointer',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    fontSize: '14px',
+                  }}
+                >
+                  ›
+                </button>
+                {/* 图片计数 */}
+                <div style={{
+                  position: 'absolute',
+                  bottom: '4px',
+                  right: '4px',
+                  background: 'rgba(0, 0, 0, 0.6)',
+                  color: 'white',
+                  padding: '2px 6px',
+                  borderRadius: '10px',
+                  fontSize: '10px',
+                }}>
+                  {currentImageIndex + 1}/{data.imageList.length}
+                </div>
+              </>
+            )}
+          </div>
+        )}
+
+        {/* 互动数据 */}
+        {data.interact_info && (
+          <div style={{
+            display: 'flex',
+            gap: '8px',
+            marginBottom: '8px',
+            flexWrap: 'wrap',
+            fontSize: '11px',
+            color: '#9f1239',
+          }}>
+            {data.interact_info.liked_count > 0 && (
+              <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
+                ❤️ {data.interact_info.liked_count}
+              </span>
+            )}
+            {data.interact_info.collected_count > 0 && (
+              <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
+                ⭐ {data.interact_info.collected_count}
+              </span>
+            )}
+            {data.interact_info.comment_count > 0 && (
+              <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
+                💬 {data.interact_info.comment_count}
+              </span>
+            )}
+            {data.interact_info.shared_count > 0 && (
+              <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
+                🔗 {data.interact_info.shared_count}
+              </span>
+            )}
+          </div>
+        )}
+
+        {/* 被哪些query找到 */}
+        {data.foundByQueries && data.foundByQueries.length > 0 && (
+          <div style={{
+            marginBottom: '8px',
+            padding: '6px 8px',
+            background: '#f0fdf4',
+            borderRadius: '6px',
+            fontSize: '10px',
+          }}>
+            <strong style={{ color: '#16a34a' }}>🔍 被找到:</strong>
+            <div style={{ marginTop: '4px', display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
+              {data.foundByQueries.map((query, idx) => (
+                <span key={idx} style={{
+                  display: 'inline-block',
+                  padding: '2px 6px',
+                  background: '#dcfce7',
+                  color: '#166534',
+                  borderRadius: '4px',
+                  fontSize: '9px',
+                }}>
+                  {query}
+                </span>
+              ))}
+            </div>
+            {data.foundInRounds && data.foundInRounds.length > 0 && (
+              <div style={{ marginTop: '4px', color: '#6b7280' }}>
+                出现在: Round {data.foundInRounds.join(', ')}
+              </div>
+            )}
+          </div>
+        )}
+
+        {/* 标签 */}
+        {(data.matchLevel || data.score) && (
+          <div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
+            {data.matchLevel && (
+              <span style={{
+                display: 'inline-block',
+                padding: '2px 8px',
+                borderRadius: '12px',
+                background: '#fff1f2',
+                color: '#be123c',
+                fontSize: '10px',
+                fontWeight: '500',
+              }}>
+                {data.matchLevel}
+              </span>
+            )}
+            {data.score && (
+              <span style={{
+                display: 'inline-block',
+                padding: '2px 8px',
+                borderRadius: '12px',
+                background: '#fff7ed',
+                color: '#c2410c',
+                fontSize: '10px',
+                fontWeight: '500',
+              }}>
+                Score: {data.score}
+              </span>
+            )}
+          </div>
+        )}
+
+        {/* 描述 */}
+        {expanded && data.description && (
+          <div style={{
+            fontSize: '11px',
+            color: '#9f1239',
+            lineHeight: '1.5',
+            paddingTop: '8px',
+            borderTop: '1px solid #fbcfe8',
+          }}>
+            {data.description}
+          </div>
+        )}
+
+        {/* 评估理由 */}
+        {expanded && data.evaluationReason && (
+          <div style={{
+            fontSize: '10px',
+            color: '#831843',
+            lineHeight: '1.5',
+            paddingTop: '8px',
+            marginTop: '8px',
+            borderTop: '1px solid #fbcfe8',
+          }}>
+            <strong style={{ color: '#9f1239' }}>评估:</strong>
+            <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
+          </div>
+        )}
+      </div>
+      <Handle
+        type="source"
+        position={sourcePosition || Position.Right}
+        style={{ background: '#ec4899', width: 8, height: 8 }}
+      />
+    </div>
+  );
+}
+
+// AnalysisNode 组件:展示AI分析(左侧OCR文字,右侧缩略图+描述)
+function AnalysisNode({ data }) {
+  const nodeStyle = {
+    background: '#fffbeb',
+    border: '2px solid #fbbf24',
+    borderRadius: '8px',
+    padding: '12px',
+    minWidth: '700px',
+    maxWidth: '900px',
+    fontSize: '12px',
+    boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
+  };
+
+  return (
+    <div style={nodeStyle}>
+      <Handle
+        type="target"
+        position={Position.Left}
+        style={{ background: '#fbbf24', width: 8, height: 8 }}
+      />
+
+      {/* 标题 */}
+      <div style={{
+        fontSize: '14px',
+        fontWeight: 'bold',
+        marginBottom: '8px',
+        color: '#92400e',
+      }}>
+        🖼️ {data.query}
+      </div>
+
+      {/* 评分和互动数据 */}
+      <div style={{
+        display: 'flex',
+        justifyContent: 'space-between',
+        marginBottom: '8px',
+        padding: '6px',
+        background: '#fef3c7',
+        borderRadius: '4px',
+      }}>
+        <div style={{ fontSize: '11px', fontWeight: 'bold' }}>
+          Score: {data.interact_info?.relevance_score || 0}
+        </div>
+        <div style={{ display: 'flex', gap: '12px', fontSize: '11px' }}>
+          {data.interact_info?.liked_count > 0 && (
+            <span>❤️ {data.interact_info.liked_count}</span>
+          )}
+          {data.interact_info?.collected_count > 0 && (
+            <span>⭐ {data.interact_info.collected_count}</span>
+          )}
+          {data.interact_info?.comment_count > 0 && (
+            <span>💬 {data.interact_info.comment_count}</span>
+          )}
+        </div>
+      </div>
+
+      {/* 完整正文内容 */}
+      {data.body_text && (
+        <div style={{
+          padding: '8px',
+          background: 'white',
+          borderRadius: '4px',
+          marginBottom: '12px',
+          fontSize: '11px',
+          lineHeight: '1.5',
+          border: '1px solid #fbbf24',
+          whiteSpace: 'pre-wrap',
+          wordBreak: 'break-word',
+        }}>
+          {data.body_text}
+        </div>
+      )}
+
+      {/* AI分析 - 左右分栏 */}
+      {data.extraction && data.extraction.images && (
+        <div style={{
+          display: 'flex',
+          flexDirection: 'column',
+          gap: '12px',
+        }}>
+          {data.extraction.images.map((img, idx) => (
+            <div
+              key={idx}
+              style={{
+                display: 'flex',
+                flexDirection: 'row',
+                gap: '16px',
+                padding: '10px',
+                background: 'white',
+                borderRadius: '4px',
+                border: '1px solid #d97706',
+                alignItems: 'flex-start',
+              }}
+            >
+              {/* 左侧:OCR提取文字 */}
+              <div style={{
+                flex: '1',  // 1/3宽度
+                minWidth: '0',
+              }}>
+                <div style={{
+                  fontSize: '11px',
+                  fontWeight: 'bold',
+                  color: '#92400e',
+                  marginBottom: '6px',
+                }}>
+                  📝 图片 {idx + 1}/{data.extraction.images.length}
+                </div>
+
+                {img.extract_text && (
+                  <div style={{
+                    fontSize: '11px',
+                    color: '#1f2937',
+                    lineHeight: '1.6',
+                    padding: '8px',
+                    background: '#fef9e7',
+                    borderRadius: '3px',
+                    borderLeft: '3px solid #f39c12',
+                    wordBreak: 'break-word',
+                  }}>
+                    <div style={{
+                      fontSize: '10px',
+                      fontWeight: 'bold',
+                      color: '#d97706',
+                      marginBottom: '4px',
+                    }}>
+                      【提取文字】
+                    </div>
+                    {img.extract_text}
+                  </div>
+                )}
+              </div>
+
+              {/* 右侧:缩略图 + 描述 */}
+              <div style={{
+                flex: '2',  // 2/3宽度
+                display: 'flex',
+                flexDirection: 'column',
+                gap: '8px',
+                minWidth: '200px',
+              }}>
+                {/* 缩略图 */}
+                {data.image_list && data.image_list[idx] && (
+                  <img
+                    src={(data.image_list[idx].image_url || data.image_list[idx])}
+                    alt={'图片' + (idx + 1)}
+                    style={{
+                      width: '100%',
+                      height: 'auto',
+                      maxHeight: '180px',
+                      objectFit: 'contain',
+                      borderRadius: '4px',
+                      border: '1px solid #d97706',
+                      cursor: 'pointer',
+                    }}
+                    onError={(e) => {
+                      e.target.style.display = 'none';
+                    }}
+                  />
+                )}
+
+                {/* 描述文字(完整展示) */}
+                {img.description && (
+                  <div
+                    style={{
+                      fontSize: '10px',
+                      color: '#78350f',
+                      lineHeight: '1.5',
+                      wordBreak: 'break-word',
+                      padding: '8px',
+                      background: '#fef9e7',
+                      borderRadius: '3px',
+                      border: '1px solid #f39c12',
+                    }}
+                  >
+                    <div style={{
+                      fontSize: '9px',
+                      fontWeight: 'bold',
+                      color: '#d97706',
+                      marginBottom: '4px',
+                    }}>
+                      【图片描述】
+                    </div>
+                    {img.description}
+                  </div>
+                )}
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* 查看原帖链接 */}
+      {data.note_url && (
+        <div style={{ marginTop: '8px', fontSize: '10px' }}>
+          <a
+            href={data.note_url}
+            target="_blank"
+            rel="noopener noreferrer"
+            style={{ color: '#92400e', textDecoration: 'underline' }}
+          >
+            🔗 查看原帖
+          </a>
+        </div>
+      )}
+
+      <Handle
+        type="source"
+        position={Position.Right}
+        style={{ background: '#fbbf24', width: 8, height: 8 }}
+      />
+    </div>
+  );
+}
+
+const nodeTypes = {
+  query: QueryNode,
+  note: NoteNode,
+  analysis: AnalysisNode,
+};
+
+// 根据 score 获取颜色
+function getScoreColor(score) {
+  if (score >= 0.7) return '#10b981'; // 绿色 - 高分
+  if (score >= 0.4) return '#f59e0b'; // 橙色 - 中分
+  return '#ef4444'; // 红色 - 低分
+}
+
+// 截断文本,保留头尾,中间显示省略号
+function truncateMiddle(text, maxLength = 20) {
+  if (!text || text.length <= maxLength) return text;
+  const headLength = Math.ceil(maxLength * 0.4);
+  const tailLength = Math.floor(maxLength * 0.4);
+  const head = text.substring(0, headLength);
+  const tail = text.substring(text.length - tailLength);
+  return \`\${head}...\${tail}\`;
+}
+
+// 根据策略获取颜色
+// 智能提取主要策略的辅助函数
+function getPrimaryStrategy(nodeData) {
+  // 优先级1: 使用 primaryStrategy 字段
+  if (nodeData.primaryStrategy) {
+    return nodeData.primaryStrategy;
+  }
+
+  // 优先级2: 从 occurrences 数组中获取最新的策略
+  if (nodeData.occurrences && Array.isArray(nodeData.occurrences) && nodeData.occurrences.length > 0) {
+    const latestOccurrence = nodeData.occurrences[nodeData.occurrences.length - 1];
+    if (latestOccurrence && latestOccurrence.strategy) {
+      return latestOccurrence.strategy;
+    }
+  }
+
+  // 优先级3: 拆分组合策略字符串,取第一个
+  if (nodeData.strategy && typeof nodeData.strategy === 'string') {
+    const strategies = nodeData.strategy.split(' + ');
+    if (strategies.length > 0 && strategies[0]) {
+      return strategies[0].trim();
+    }
+  }
+
+  // 默认返回原始strategy或未知
+  return nodeData.strategy || '未知';
+}
+
+function getStrategyColor(strategy) {
+  const strategyColors = {
+    '初始分词': '#10b981',
+    '调用sug': '#06b6d4',
+    '同义改写': '#f59e0b',
+    '加词': '#3b82f6',
+    '抽象改写': '#8b5cf6',
+    '基于部分匹配改进': '#ec4899',
+    '结果分支-抽象改写': '#a855f7',
+    '结果分支-同义改写': '#fb923c',
+    // v6.1.2.8 新增策略
+    '原始问题': '#6b21a8',
+    '来自分词': '#10b981',
+    '加词生成': '#ef4444',
+    '建议词': '#06b6d4',
+    '执行搜索': '#8b5cf6',
+    // 添加简化版本的策略映射
+    '分词': '#10b981',
+    '推荐词': '#06b6d4',
+  };
+  return strategyColors[strategy] || '#9ca3af';
+}
+
+// 树节点组件
+function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
+  const hasChildren = children && children.length > 0;
+  const score = node.data.score ? parseFloat(node.data.score) : 0;
+  const strategy = getPrimaryStrategy(node.data);  // 使用智能提取函数
+  const strategyColor = getStrategyColor(strategy);
+  const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
+  const isDomainCombination = nodeActualType === 'domain_combination';
+
+  let sourceSummary = '';
+  if (isDomainCombination && Array.isArray(node.data.source_word_details) && node.data.source_word_details.length > 0) {
+    const summaryParts = [];
+    node.data.source_word_details.forEach((detail) => {
+      const words = Array.isArray(detail.words) ? detail.words : [];
+      const wordTexts = [];
+      words.forEach((w) => {
+        const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
+        const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
+        wordTexts.push(w.text + ' (' + formattedScore + ')');
+      });
+      if (wordTexts.length > 0) {
+        const segmentLabel = detail.segment_type ? '[' + detail.segment_type + '] ' : '';
+        summaryParts.push(segmentLabel + wordTexts.join(' + '));
+      }
+    });
+    sourceSummary = summaryParts.join(' | ');
+  }
+
+  // 计算字体颜色:根据分数提升幅度判断
+  let fontColor = '#374151'; // 默认颜色
+  if (node.type === 'note') {
+    fontColor = node.data.matchLevel === 'unsatisfied' ? '#ef4444' : '#374151';
+  } else if (node.data.seed_score !== undefined) {
+    const parentScore = parseFloat(node.data.seed_score);
+    const gain = score - parentScore;
+    fontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
+  } else if (node.data.isSelected === false) {
+    fontColor = '#ef4444';
+  }
+
+  return (
+    <div style={{ marginLeft: level * 12 + 'px', marginBottom: '8px' }}>
+      <div
+        style={{
+          padding: '6px 8px',
+          borderRadius: '4px',
+          cursor: 'pointer',
+          background: 'transparent',
+          border: isSelected ? '1px solid #3b82f6' : '1px solid transparent',
+          display: 'flex',
+          alignItems: 'center',
+          gap: '6px',
+          transition: 'all 0.2s ease',
+          position: 'relative',
+          overflow: 'visible',
+        }}
+        onMouseEnter={(e) => {
+          if (!isSelected) e.currentTarget.style.background = '#f9fafb';
+        }}
+        onMouseLeave={(e) => {
+          if (!isSelected) e.currentTarget.style.background = 'transparent';
+        }}
+      >
+        {/* 策略类型竖线 */}
+        <div style={{
+          width: '3px',
+          height: '20px',
+          background: strategyColor,
+          borderRadius: '2px',
+          flexShrink: 0,
+          position: 'relative',
+          zIndex: 1,
+        }} />
+
+        {hasChildren && (
+          <span
+            style={{
+              fontSize: '10px',
+              color: '#6b7280',
+              cursor: 'pointer',
+              width: '16px',
+              textAlign: 'center',
+              position: 'relative',
+              zIndex: 1,
+            }}
+            onClick={(e) => {
+              e.stopPropagation();
+              onToggle();
+            }}
+          >
+            {isCollapsed ? '▶' : '▼'}
+          </span>
+        )}
+        {!hasChildren && <span style={{ width: '16px', position: 'relative', zIndex: 1 }}></span>}
+
+        <div
+          style={{
+            flex: 1,
+            fontSize: '12px',
+            color: '#374151',
+            position: 'relative',
+            zIndex: 1,
+            minWidth: 0,
+            display: 'flex',
+            flexDirection: 'column',
+            gap: '4px',
+          }}
+          onClick={onSelect}
+        >
+          <div style={{
+            display: 'flex',
+            alignItems: 'center',
+            gap: '8px',
+          }}>
+            {/* 文本标题 - 左侧 */}
+            <div style={{
+              fontWeight: level === 0 ? '600' : '400',
+              flex: 1,
+              minWidth: 0,
+              color: node.data.scoreColor || fontColor,
+              overflow: 'hidden',
+              textOverflow: 'ellipsis',
+              whiteSpace: 'nowrap',
+            }}
+            title={node.data.title || node.id}
+            >
+              {node.data.title || node.id}
+            </div>
+
+            {/* 域标识 - 右侧,挨着分数,优先显示域类型,否则显示域索引或域字符串,但domain_combination节点不显示 */}
+            {(node.data.domain_type || node.data.domains_str || (node.data.domain_index !== null && node.data.domain_index !== undefined)) && nodeActualType !== 'domain_combination' && (
+              <span style={{
+                fontSize: '12px',
+                color: '#fff',
+                background: '#6366f1',
+                padding: '2px 5px',
+                borderRadius: '3px',
+                flexShrink: 0,
+                fontWeight: '600',
+                marginLeft: '4px',
+              }}
+              title={
+                node.data.domain_type ? '域: ' + node.data.domain_type + ' (D' + node.data.domain_index + ')' :
+                node.data.domains_str ? '域: ' + node.data.domains_str :
+                '域 D' + node.data.domain_index
+              }
+              >
+                {node.data.domain_type || node.data.domains_str || ('D' + node.data.domain_index)}
+              </span>
+            )}
+
+            {node.data.is_suggestion && node.data.suggestion_label && (
+              <span style={{
+                fontSize: '12px',
+                color: '#fff',
+                background: '#8b5cf6',
+                padding: '2px 5px',
+                borderRadius: '3px',
+                flexShrink: 0,
+                fontWeight: '600',
+              }}
+              >
+                {node.data.suggestion_label}
+              </span>
+            )}
+
+            {/* 类型标签 - 显示在右侧靠近分数,蓝色背景 */}
+            {node.data.type_label && (
+              <span style={{
+                fontSize: '12px',
+                color: '#fff',
+                background: '#6366f1',
+                padding: '2px 5px',
+                borderRadius: '3px',
+                flexShrink: 0,
+                fontWeight: '600',
+              }}
+              title={'类型: ' + node.data.type_label}
+              >
+                {node.data.type_label}
+              </span>
+            )}
+
+            {/* 分数显示 - 步骤和轮次节点不显示分数 */}
+            {nodeActualType !== 'step' && nodeActualType !== 'round' && (
+              <span style={{
+                fontSize: '11px',
+                color: '#6b7280',
+                fontWeight: '500',
+                flexShrink: 0,
+                minWidth: '35px',
+                textAlign: 'right',
+              }}>
+                {score.toFixed(2)}
+              </span>
+            )}
+          </div>
+
+          {/* 域组合的来源词得分(树状视图,右对齐) */}
+          {isDomainCombination && sourceSummary && (
+            <div style={{
+              fontSize: '10px',
+              color: '#2563eb',
+              lineHeight: '1.4',
+              display: 'flex',
+              flexDirection: 'column',
+              alignItems: 'flex-end',
+              gap: '2px',
+              textAlign: 'right',
+            }}>
+              {node.data.source_word_details.map((detail, idx) => {
+                const words = Array.isArray(detail.words) ? detail.words : [];
+                const summary = words.map((w) => {
+                  const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
+                  const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
+                  return w.text + ' (' + formattedScore + ')';
+                }).join(' + ');
+                return (
+                  <span key={idx} title={summary}>
+                    {summary}
+                  </span>
+                );
+              })}
+            </div>
+          )}
+
+          {/* 分数下划线 - 步骤和轮次节点不显示 */}
+          {nodeActualType !== 'step' && nodeActualType !== 'round' && (
+            <div style={{
+              width: (score * 100) + '%',
+              height: '2px',
+              background: getScoreColor(score),
+              borderRadius: '1px',
+            }} />
+          )}
+        </div>
+      </div>
+
+      {hasChildren && !isCollapsed && (
+        <div>
+          {children}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 使用 dagre 自动布局
+function getLayoutedElements(nodes, edges, direction = 'LR') {
+  console.log('🎯 Starting layout with dagre...');
+  console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
+
+  // 检查 dagre 是否加载
+  if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
+    console.warn('⚠️ Dagre not loaded, using fallback layout');
+    // 降级到简单布局
+    const levelGroups = {};
+    nodes.forEach(node => {
+      const level = node.data.level || 0;
+      if (!levelGroups[level]) levelGroups[level] = [];
+      levelGroups[level].push(node);
+    });
+
+    Object.entries(levelGroups).forEach(([level, nodeList]) => {
+      const x = parseInt(level) * 480;
+      nodeList.forEach((node, index) => {
+        node.position = { x, y: index * 260 };
+        node.targetPosition = 'left';
+        node.sourcePosition = 'right';
+      });
+    });
+
+    return { nodes, edges };
+  }
+
+  try {
+    const dagreGraph = new window.dagre.graphlib.Graph();
+    dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+    const isHorizontal = direction === 'LR';
+    dagreGraph.setGraph({
+      rankdir: direction,
+      nodesep: 180,   // 垂直间距 - 增加以避免节点重叠
+      ranksep: 360,  // 水平间距 - 增加以容纳更宽的节点
+    });
+
+    // 添加节点 - 根据节点类型设置不同的尺寸
+    nodes.forEach((node) => {
+      let nodeWidth = 320;
+      let nodeHeight = 220;
+
+      // note 节点有轮播图,需要更大的空间
+      if (node.type === 'note') {
+        nodeWidth = 360;
+        nodeHeight = 380;  // 增加高度以容纳轮播图
+      }
+      // analysis 节点内容很多,需要更大的空间
+      else if (node.type === 'analysis') {
+        nodeWidth = 900;   // 宽度足够容纳左右分栏
+        nodeHeight = 600;  // 高度足够容纳多张图片
+      }
+
+      dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
+    });
+
+    // 添加边
+    edges.forEach((edge) => {
+      dagreGraph.setEdge(edge.source, edge.target);
+    });
+
+    // 计算布局
+    window.dagre.layout(dagreGraph);
+    console.log('✅ Dagre layout completed');
+
+    // 更新节点位置和 handle 位置
+    nodes.forEach((node) => {
+      const nodeWithPosition = dagreGraph.node(node.id);
+
+      if (!nodeWithPosition) {
+        console.warn('Node position not found for:', node.id);
+        return;
+      }
+
+      node.targetPosition = isHorizontal ? 'left' : 'top';
+      node.sourcePosition = isHorizontal ? 'right' : 'bottom';
+
+      // 根据节点类型获取尺寸
+      let nodeWidth = 320;
+      let nodeHeight = 220;
+      if (node.type === 'note') {
+        nodeWidth = 360;
+        nodeHeight = 380;
+      }
+
+      // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
+      node.position = {
+        x: nodeWithPosition.x - nodeWidth / 2,
+        y: nodeWithPosition.y - nodeHeight / 2,
+      };
+    });
+
+    console.log('✅ Layout completed, sample node:', nodes[0]);
+    return { nodes, edges };
+  } catch (error) {
+    console.error('❌ Error in dagre layout:', error);
+    console.error('Error details:', error.message, error.stack);
+
+    // 降级处理
+    console.log('Using fallback layout...');
+    const levelGroups = {};
+    nodes.forEach(node => {
+      const level = node.data.level || 0;
+      if (!levelGroups[level]) levelGroups[level] = [];
+      levelGroups[level].push(node);
+    });
+
+    Object.entries(levelGroups).forEach(([level, nodeList]) => {
+      const x = parseInt(level) * 480;
+      nodeList.forEach((node, index) => {
+        node.position = { x, y: index * 260 };
+        node.targetPosition = 'left';
+        node.sourcePosition = 'right';
+      });
+    });
+
+    return { nodes, edges };
+  }
+}
+
+function transformData(data) {
+  const nodes = [];
+  const edges = [];
+
+  const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
+  const canvasIdToNodeData = {}; // 避免重复创建相同的节点
+  let analysisNodeCount = 0; // 用于给analysis节点添加X偏移
+
+  // 创建节点
+  Object.entries(data.nodes).forEach(([originalId, node]) => {
+    // 统一处理所有类型的节点
+    const nodeType = node.type || 'query';
+
+    // 直接使用originalId作为canvasId,避免冲突
+    const canvasId = originalId;
+
+    originalIdToCanvasId[originalId] = canvasId;
+
+    // 如果这个 canvasId 还没有创建过节点,则创建
+    if (!canvasIdToNodeData[canvasId]) {
+      canvasIdToNodeData[canvasId] = true;
+
+      // 根据节点类型创建不同的数据结构
+      if (nodeType === 'note' || nodeType === 'post') {
+        nodes.push({
+          id: canvasId,
+          originalId: originalId,
+          type: 'note',
+          data: {
+            title: node.query || node.title || '帖子',
+            matchLevel: node.match_level,
+            score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
+            description: node.body_text || node.desc || '',
+            isSelected: node.is_selected !== undefined ? node.is_selected : true,
+            imageList: node.image_list || [],
+            noteUrl: node.note_url || '',
+            evaluationReason: node.evaluationReason || node.evaluation_reason || '',
+            interact_info: node.interact_info || {},
+            nodeType: nodeType,
+          },
+          position: { x: 0, y: 0 },
+        });
+      } else if (nodeType === 'analysis') {
+        // AI分析节点 - 添加X偏移避免叠加
+        const xOffset = analysisNodeCount * 150; // 每个节点偏移150px
+        analysisNodeCount++;
+
+        nodes.push({
+          id: canvasId,
+          originalId: originalId,
+          type: 'analysis',
+          data: {
+            query: node.query || '[AI分析]',
+            note_id: node.note_id,
+            note_url: node.note_url,
+            title: node.title || '',
+            body_text: node.body_text || '',
+            interact_info: node.interact_info || {},
+            extraction: node.extraction || null,
+            image_list: node.image_list || [],
+          },
+          position: { x: xOffset, y: 0 },
+        });
+      } else {
+        // query, seg, q, search, root 等节点
+        let displayTitle = node.query || originalId;
+
+        nodes.push({
+          id: canvasId,
+          originalId: originalId,
+          type: 'query', // 使用 query 组件渲染所有非note节点
+          data: {
+            title: displayTitle,
+            level: node.level || 0,
+            score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
+            strategy: node.strategy || '',
+            parent: node.parent_query || '',
+            isSelected: node.is_selected !== undefined ? node.is_selected : true,
+            evaluationReason: node.evaluationReason || node.evaluation_reason || '',
+            nodeType: nodeType, // 传递实际节点类型用于样式
+            searchCount: node.search_count, // search 节点特有
+            totalPosts: node.total_posts, // search 节点特有
+            selectedWord: node.selected_word || '', // 加词节点特有 - 显示选择的词
+            scoreColor: node.scoreColor || null,        // SUG节点的颜色标识
+            parentQScore: node.parentQScore || 0,       // 父Q得分(用于调试)
+            domain_index: node.domain_index !== undefined ? node.domain_index : null, // 域索引
+            domain_type: node.domain_type || '', // 域类型(如"中心名词"、"核心动作"),只有Q节点有,segment节点不显示
+            segment_type: node.segment_type || '', // segment类型(只有segment节点才有)
+            type_label: node.type_label || '', // 类型标签
+            domains: node.domains || [], // 域索引数组(domain_combination节点特有)
+            domains_str: node.domains_str || '', // 域标识字符串(如"D0,D1")
+            from_segments: node.from_segments || [], // 来源segments(domain_combination节点特有)
+            source_word_details: node.source_word_details || [], // 组合来源词及其得分
+            source_scores: node.source_scores || [], // 扁平来源得分
+            is_above_sources: node.is_above_sources || false, // 组合是否高于来源得分
+            max_source_score: node.max_source_score !== undefined ? node.max_source_score : null, // 来源最高分
+            item_type: node.item_type || '', // 构建下一轮节点来源类型
+            is_suggestion: node.is_suggestion || false,
+            suggestion_label: node.suggestion_label || '',
+          },
+          position: { x: 0, y: 0 },
+        });
+      }
+    }
+  });
+
+  // 创建边 - 使用虚线样式,映射到画布ID
+  data.edges.forEach((edge, index) => {
+    const edgeColors = {
+      '初始分词': '#10b981',
+      '调用sug': '#06b6d4',
+      '同义改写': '#f59e0b',
+      '加词': '#3b82f6',
+      '抽象改写': '#8b5cf6',
+      '基于部分匹配改进': '#ec4899',
+      '结果分支-抽象改写': '#a855f7',
+      '结果分支-同义改写': '#fb923c',
+      'query_to_note': '#ec4899',
+    };
+
+    const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
+    const isNoteEdge = edge.edge_type === 'query_to_note';
+
+    edges.push({
+      id: \`edge-\${index}\`,
+      source: originalIdToCanvasId[edge.from], // 使用画布ID
+      target: originalIdToCanvasId[edge.to],   // 使用画布ID
+      type: 'simplebezier', // 使用简单贝塞尔曲线
+      animated: isNoteEdge,
+      style: {
+        stroke: color,
+        strokeWidth: isNoteEdge ? 2.5 : 2,
+        strokeDasharray: isNoteEdge ? '5,5' : '8,4',
+      },
+      markerEnd: {
+        type: 'arrowclosed',
+        color: color,
+        width: 20,
+        height: 20,
+      },
+    });
+  });
+
+  // 使用 dagre 自动计算布局 - 从左到右
+  return getLayoutedElements(nodes, edges, 'LR');
+}
+
+function FlowContent() {
+  // 画布使用简化数据
+  const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
+    console.log('🔍 Transforming data for canvas...');
+    const result = transformData(data);
+    console.log('✅ Canvas data:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
+    return result;
+  }, []);
+
+  // 目录使用完整数据(如果存在)
+  const { nodes: fullNodes, edges: fullEdges } = useMemo(() => {
+    if (data.fullData) {
+      console.log('🔍 Transforming full data for tree directory...');
+      const result = transformData(data.fullData);
+      console.log('✅ Directory data:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
+      return result;
+    }
+    // 如果没有 fullData,使用简化数据
+    return { nodes: initialNodes, edges: initialEdges };
+  }, [initialNodes, initialEdges]);
+
+  // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
+  const initialCollapsedNodes = useMemo(() => {
+    const nodesWithChildren = new Set();
+    initialEdges.forEach(edge => {
+      nodesWithChildren.add(edge.source);
+    });
+    // 排除根节点(level 0),让根节点默认展开
+    const rootNode = initialNodes.find(n => n.data.level === 0);
+    if (rootNode) {
+      nodesWithChildren.delete(rootNode.id);
+    }
+    return nodesWithChildren;
+  }, [initialNodes, initialEdges]);
+
+  // 树节点的折叠状态需要在树构建后初始化
+  const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
+  const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
+  const [selectedNodeId, setSelectedNodeId] = useState(null);
+  const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
+  const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
+  const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
+  const [sidebarWidth, setSidebarWidth] = useState(400); // 左侧目录宽度
+  const [isResizing, setIsResizing] = useState(false); // 是否正在拖拽调整宽度
+
+  // 拖拽调整侧边栏宽度的处理逻辑
+  const handleMouseDown = useCallback(() => {
+    setIsResizing(true);
+  }, []);
+
+  useEffect(() => {
+    if (!isResizing) return;
+
+    const handleMouseMove = (e) => {
+      const newWidth = e.clientX;
+      // 限制宽度范围:300px - 700px
+      if (newWidth >= 300 && newWidth <= 700) {
+        setSidebarWidth(newWidth);
+      }
+    };
+
+    const handleMouseUp = () => {
+      setIsResizing(false);
+    };
+
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+
+    return () => {
+      document.removeEventListener('mousemove', handleMouseMove);
+      document.removeEventListener('mouseup', handleMouseUp);
+    };
+  }, [isResizing]);
+
+  // 获取 React Flow 实例以控制画布
+  const { setCenter, fitView } = useReactFlow();
+
+  // 获取某个节点的所有后代节点ID
+  const getDescendants = useCallback((nodeId) => {
+    const descendants = new Set();
+    const queue = [nodeId];
+
+    while (queue.length > 0) {
+      const current = queue.shift();
+      initialEdges.forEach(edge => {
+        if (edge.source === current && !descendants.has(edge.target)) {
+          descendants.add(edge.target);
+          queue.push(edge.target);
+        }
+      });
+    }
+
+    return descendants;
+  }, [initialEdges]);
+
+  // 获取直接父节点
+  const getDirectParents = useCallback((nodeId) => {
+    const parents = [];
+    initialEdges.forEach(edge => {
+      if (edge.target === nodeId) {
+        parents.push(edge.source);
+      }
+    });
+    return parents;
+  }, [initialEdges]);
+
+  // 获取直接子节点
+  const getDirectChildren = useCallback((nodeId) => {
+    const children = [];
+    initialEdges.forEach(edge => {
+      if (edge.source === nodeId) {
+        children.push(edge.target);
+      }
+    });
+    return children;
+  }, [initialEdges]);
+
+  // 切换节点折叠状态
+  const toggleNodeCollapse = useCallback((nodeId) => {
+    setCollapsedNodes(prev => {
+      const newSet = new Set(prev);
+      const descendants = getDescendants(nodeId);
+
+      if (newSet.has(nodeId)) {
+        // 展开:移除此节点,但保持其他折叠的节点
+        newSet.delete(nodeId);
+      } else {
+        // 折叠:添加此节点
+        newSet.add(nodeId);
+      }
+
+      return newSet;
+    });
+  }, [getDescendants]);
+
+  // 过滤可见的节点和边,并重新计算布局
+  const { nodes, edges } = useMemo(() => {
+    const nodesToHide = new Set();
+
+    // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
+    const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
+
+    // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
+    if (effectiveFocusNodeId) {
+      const visibleInFocus = new Set([effectiveFocusNodeId]);
+
+      // 添加所有父节点
+      initialEdges.forEach(edge => {
+        if (edge.target === effectiveFocusNodeId) {
+          visibleInFocus.add(edge.source);
+        }
+      });
+
+      // 添加所有直接子节点
+      initialEdges.forEach(edge => {
+        if (edge.source === effectiveFocusNodeId) {
+          visibleInFocus.add(edge.target);
+        }
+      });
+
+      // 隐藏不在聚焦范围内的节点
+      initialNodes.forEach(node => {
+        if (!visibleInFocus.has(node.id)) {
+          nodesToHide.add(node.id);
+        }
+      });
+    } else {
+      // 非聚焦模式:使用原有的折叠逻辑
+      // 收集所有被折叠节点的后代
+      collapsedNodes.forEach(collapsedId => {
+        const descendants = getDescendants(collapsedId);
+        descendants.forEach(id => nodesToHide.add(id));
+      });
+    }
+
+    // 添加用户手动隐藏的节点
+    hiddenNodes.forEach(id => nodesToHide.add(id));
+
+    const visibleNodes = initialNodes
+      .filter(node => !nodesToHide.has(node.id))
+      .map(node => ({
+        ...node,
+        data: {
+          ...node.data,
+          isCollapsed: collapsedNodes.has(node.id),
+          hasChildren: initialEdges.some(e => e.source === node.id),
+          onToggleCollapse: () => toggleNodeCollapse(node.id),
+          onHideSelf: () => {
+            setHiddenNodes(prev => {
+              const newSet = new Set(prev);
+              newSet.add(node.id);
+              return newSet;
+            });
+          },
+          onFocus: () => {
+            // 切换聚焦状态
+            if (focusedNodeId === node.id) {
+              setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
+            } else {
+              // 先取消之前的聚焦,然后聚焦到当前节点
+              setFocusedNodeId(node.id);
+
+              // 延迟聚焦视图到该节点
+              setTimeout(() => {
+                fitView({
+                  nodes: [{ id: node.id }],
+                  duration: 800,
+                  padding: 0.3,
+                });
+              }, 100);
+            }
+          },
+          isFocused: focusedNodeId === node.id,
+          isHighlighted: selectedNodeId === node.id,
+        }
+      }));
+
+    const visibleEdges = initialEdges.filter(
+      edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
+    );
+
+    // 重新计算布局 - 只对可见节点
+    if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
+      try {
+        const dagreGraph = new window.dagre.graphlib.Graph();
+        dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+        dagreGraph.setGraph({
+          rankdir: 'LR',
+          nodesep: 180,   // 垂直间距 - 增加以避免节点重叠
+          ranksep: 360,  // 水平间距 - 增加以容纳更宽的节点
+        });
+
+        visibleNodes.forEach((node) => {
+          let nodeWidth = 320;
+          let nodeHeight = 220;
+
+          // note 节点有轮播图,需要更大的空间
+          if (node.type === 'note') {
+            nodeWidth = 360;
+            nodeHeight = 380;
+          }
+
+          dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
+        });
+
+        visibleEdges.forEach((edge) => {
+          dagreGraph.setEdge(edge.source, edge.target);
+        });
+
+        window.dagre.layout(dagreGraph);
+
+        visibleNodes.forEach((node) => {
+          const nodeWithPosition = dagreGraph.node(node.id);
+          if (nodeWithPosition) {
+            // 根据节点类型获取对应的尺寸
+            let nodeWidth = 320;
+            let nodeHeight = 220;
+            if (node.type === 'note') {
+              nodeWidth = 360;
+              nodeHeight = 380;
+            }
+
+            node.position = {
+              x: nodeWithPosition.x - nodeWidth / 2,
+              y: nodeWithPosition.y - nodeHeight / 2,
+            };
+            node.targetPosition = 'left';
+            node.sourcePosition = 'right';
+          }
+        });
+
+        console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
+      } catch (error) {
+        console.error('❌ Error in dynamic layout:', error);
+      }
+    }
+
+    return { nodes: visibleNodes, edges: visibleEdges };
+  }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
+
+  // 构建树形结构 - 允许一个节点有多个父节点
+  // 为目录构建树(使用完整数据)
+  const buildTree = useCallback(() => {
+    // 使用完整数据构建目录树
+    const nodeMap = new Map();
+    fullNodes.forEach(node => {
+      nodeMap.set(node.id, node);
+    });
+
+    // 为每个节点创建树节点的副本(允许多次出现)
+    const createTreeNode = (nodeId, pathKey) => {
+      const node = nodeMap.get(nodeId);
+      if (!node) return null;
+
+      return {
+        ...node,
+        treeKey: pathKey, // 唯一的树路径key,用于React key
+        children: []
+      };
+    };
+
+    // 构建父子关系映射:记录每个节点的所有父节点,去重边
+    const parentToChildren = new Map();
+    const childToParents = new Map();
+
+    fullEdges.forEach(edge => {
+      // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
+      if (!parentToChildren.has(edge.source)) {
+        parentToChildren.set(edge.source, []);
+      }
+      const children = parentToChildren.get(edge.source);
+      if (!children.includes(edge.target)) {
+        children.push(edge.target);
+      }
+
+      // 记录子->父关系(用于判断是否有多个父节点,也去重)
+      if (!childToParents.has(edge.target)) {
+        childToParents.set(edge.target, []);
+      }
+      const parents = childToParents.get(edge.target);
+      if (!parents.includes(edge.source)) {
+        parents.push(edge.source);
+      }
+    });
+
+    // 递归构建树
+    const buildSubtree = (nodeId, pathKey, visitedInPath) => {
+      // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
+      if (visitedInPath.has(nodeId)) {
+        return null;
+      }
+
+      const treeNode = createTreeNode(nodeId, pathKey);
+      if (!treeNode) return null;
+
+      const newVisitedInPath = new Set(visitedInPath);
+      newVisitedInPath.add(nodeId);
+
+      const children = parentToChildren.get(nodeId) || [];
+      treeNode.children = children
+        .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
+        .filter(child => child !== null);
+
+      return treeNode;
+    };
+
+    // 找出所有根节点(没有入边的节点)
+    const hasParent = new Set();
+    fullEdges.forEach(edge => {
+      hasParent.add(edge.target);
+    });
+
+    const roots = [];
+    fullNodes.forEach((node, index) => {
+      if (!hasParent.has(node.id)) {
+        const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
+        if (treeNode) roots.push(treeNode);
+      }
+    });
+
+    return roots;
+  }, [fullNodes, fullEdges]);
+
+  const treeRoots = useMemo(() => buildTree(), [buildTree]);
+
+  // 生成树形文本结构(使用完整数据)
+  const generateTreeText = useCallback(() => {
+    const lines = [];
+
+    // 递归生成树形文本
+    const traverse = (nodes, prefix = '', isLast = true, depth = 0) => {
+      nodes.forEach((node, index) => {
+        const isLastNode = index === nodes.length - 1;
+        const nodeData = fullNodes.find(n => n.id === node.id)?.data || {};
+        const nodeType = nodeData.nodeType || node.data?.nodeType || 'unknown';
+        const title = nodeData.title || node.data?.title || node.id;
+
+        // 优先从node.data获取score,然后从nodeData获取
+        let score = null;
+        if (node.data?.score !== undefined && node.data?.score !== null) {
+          score = node.data.score;
+        } else if (node.data?.relevance_score !== undefined && node.data?.relevance_score !== null) {
+          score = node.data.relevance_score;
+        } else if (nodeData.score !== undefined && nodeData.score !== null) {
+          score = nodeData.score;
+        } else if (nodeData.relevance_score !== undefined && nodeData.relevance_score !== null) {
+          score = nodeData.relevance_score;
+        }
+
+        const strategy = nodeData.strategy || node.data?.strategy || '';
+
+        // 构建当前行 - score可能是数字或字符串,step/round节点不显示分数
+        const connector = isLastNode ? '└─' : '├─';
+        let scoreText = '';
+        if (nodeType !== 'step' && nodeType !== 'round' && score !== null && score !== undefined) {
+          // score可能已经是字符串格式(如 "0.05"),也可能是数字
+          const scoreStr = typeof score === 'number' ? score.toFixed(2) : score;
+          scoreText = \` (分数: \${scoreStr})\`;
+        }
+        const strategyText = strategy ? \` [\${strategy}]\` : '';
+
+        lines.push(\`\${prefix}\${connector} \${title}\${scoreText}\${strategyText}\`);
+
+        // 递归处理子节点
+        if (node.children && node.children.length > 0) {
+          const childPrefix = prefix + (isLastNode ? '   ' : '│  ');
+          traverse(node.children, childPrefix, isLastNode, depth + 1);
+        }
+      });
+    };
+
+    // 添加标题
+    const rootNode = fullNodes.find(n => n.data?.level === 0);
+    if (rootNode) {
+      lines.push(\`📊 查询扩展树形结构\`);
+      lines.push(\`原始问题: \${rootNode.data.title || rootNode.data.query}\`);
+      lines.push('');
+    }
+
+    traverse(treeRoots);
+
+    return lines.join('\\n');
+  }, [treeRoots, fullNodes]);
+
+  // 复制树形结构到剪贴板
+  const copyTreeToClipboard = useCallback(async () => {
+    try {
+      const treeText = generateTreeText();
+      await navigator.clipboard.writeText(treeText);
+      alert('✅ 树形结构已复制到剪贴板!');
+    } catch (err) {
+      console.error('复制失败:', err);
+      alert('❌ 复制失败,请手动复制');
+    }
+  }, [generateTreeText]);
+
+  // 初始化树节点折叠状态
+  useEffect(() => {
+    const getAllTreeKeys = (nodes) => {
+      const keys = new Set();
+      const traverse = (node) => {
+        if (node.children && node.children.length > 0) {
+          // 排除根节点
+          if (node.data.level !== 0) {
+            keys.add(node.treeKey);
+          }
+          node.children.forEach(traverse);
+        }
+      };
+      nodes.forEach(traverse);
+      return keys;
+    };
+
+    setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
+  }, [treeRoots]);
+
+  // 映射完整节点ID到画布简化节点ID
+  const mapTreeNodeToCanvasNode = useCallback((treeNodeId) => {
+    // 如果是简化模式,需要映射
+    if (data.fullData) {
+      // 从完整数据中找到节点
+      const fullNode = fullNodes.find(n => n.id === treeNodeId);
+      if (!fullNode) return treeNodeId;
+
+      // 根据节点类型和文本找到画布上的简化节点
+      const nodeText = fullNode.data.title || fullNode.data.query;
+      const nodeType = fullNode.data.nodeType || fullNode.type;
+
+      // Query类节点:找 query_xxx
+      if (['q', 'seg', 'sug', 'add_word', 'query'].includes(nodeType)) {
+        const canvasNode = initialNodes.find(n =>
+          (n.data.title === nodeText || n.data.query === nodeText) &&
+          ['query'].includes(n.type)
+        );
+        return canvasNode ? canvasNode.id : treeNodeId;
+      }
+
+      // Post节点:按note_id查找
+      if (nodeType === 'post' || nodeType === 'note') {
+        const noteId = fullNode.data.note_id;
+        if (noteId) {
+          const canvasNode = initialNodes.find(n => n.data.note_id === noteId);
+          return canvasNode ? canvasNode.id : treeNodeId;
+        }
+      }
+
+      // 其他节点类型(Round/Step等):直接返回
+      return treeNodeId;
+    }
+
+    // 非简化模式,直接返回
+    return treeNodeId;
+  }, [data.fullData, fullNodes, initialNodes]);
+
+  const renderTree = useCallback((treeNodes, level = 0) => {
+    return treeNodes.map(node => {
+      // 使用 treeKey 来区分树中的不同实例
+      const isCollapsed = collapsedTreeNodes.has(node.treeKey);
+      const isSelected = selectedNodeId === node.id;
+
+      return (
+        <TreeNode
+          key={node.treeKey}
+          node={node}
+          level={level}
+          isCollapsed={isCollapsed}
+          isSelected={isSelected}
+          onToggle={() => {
+            setCollapsedTreeNodes(prev => {
+              const newSet = new Set(prev);
+              if (newSet.has(node.treeKey)) {
+                newSet.delete(node.treeKey);
+              } else {
+                newSet.add(node.treeKey);
+              }
+              return newSet;
+            });
+          }}
+          onSelect={() => {
+            // 将目录节点ID映射到画布节点ID
+            const treeNodeId = node.id;
+            const canvasNodeId = mapTreeNodeToCanvasNode(treeNodeId);
+
+            // 检查画布上是否存在这个节点
+            const canvasNodeExists = initialNodes.some(n => n.id === canvasNodeId);
+            if (!canvasNodeExists) {
+              console.warn(\`节点 \${canvasNodeId} 在画布上不存在(可能被简化了)\`);
+              return;
+            }
+
+            const nodeId = canvasNodeId;
+
+            // 展开所有祖先节点
+            const ancestorIds = [nodeId];
+            const findAncestors = (id) => {
+              initialEdges.forEach(edge => {
+                if (edge.target === id && !ancestorIds.includes(edge.source)) {
+                  ancestorIds.push(edge.source);
+                  findAncestors(edge.source);
+                }
+              });
+            };
+            findAncestors(nodeId);
+
+            // 如果节点或其祖先被隐藏,先恢复它们
+            setHiddenNodes(prev => {
+              const newSet = new Set(prev);
+              ancestorIds.forEach(id => newSet.delete(id));
+              return newSet;
+            });
+
+            setSelectedNodeId(nodeId);
+
+            // 获取选中节点的直接子节点
+            const childrenIds = [];
+            initialEdges.forEach(edge => {
+              if (edge.source === nodeId) {
+                childrenIds.push(edge.target);
+              }
+            });
+
+            setCollapsedNodes(prev => {
+              const newSet = new Set(prev);
+              // 展开所有祖先节点
+              ancestorIds.forEach(id => newSet.delete(id));
+              // 展开选中节点本身
+              newSet.delete(nodeId);
+              // 展开选中节点的直接子节点
+              childrenIds.forEach(id => newSet.delete(id));
+              return newSet;
+            });
+
+            // 延迟聚焦,等待节点展开和布局重新计算
+            setTimeout(() => {
+              fitView({
+                nodes: [{ id: nodeId }],
+                duration: 800,
+                padding: 0.3,
+              });
+            }, 300);
+          }}
+        >
+          {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
+        </TreeNode>
+      );
+    });
+  }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView, mapTreeNodeToCanvasNode, initialNodes, setHiddenNodes]);
+
+  console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
+
+  if (nodes.length === 0) {
+    return (
+      <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
+        ERROR: No nodes to display!
+      </div>
+    );
+  }
+
+  return (
+    <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
+      {/* 顶部面包屑导航栏 */}
+      <div style={{
+        minHeight: '48px',
+        maxHeight: '120px',
+        background: 'white',
+        borderBottom: '1px solid #e5e7eb',
+        display: 'flex',
+        alignItems: 'flex-start',
+        padding: '12px 24px',
+        zIndex: 1000,
+        boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
+        flexShrink: 0,
+        overflowY: 'auto',
+      }}>
+        <div style={{ width: '100%' }}>
+          {selectedNodeId ? (
+            <div style={{ fontSize: '12px', color: '#6b7280' }}>
+              {/* 面包屑导航 - 显示所有路径 */}
+              {(() => {
+                const selectedNode = nodes.find(n => n.id === selectedNodeId);
+                if (!selectedNode) return null;
+
+                // 找到所有从根节点到当前节点的路径
+                const findAllPaths = (targetId) => {
+                  const paths = [];
+
+                  const buildPath = (nodeId, currentPath) => {
+                    const node = initialNodes.find(n => n.id === nodeId);
+                    if (!node) return;
+
+                    const newPath = [node, ...currentPath];
+
+                    // 找到所有父节点
+                    const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
+
+                    if (parents.length === 0) {
+                      // 到达根节点
+                      paths.push(newPath);
+                    } else {
+                      // 递归处理所有父节点
+                      parents.forEach(parentId => {
+                        buildPath(parentId, newPath);
+                      });
+                    }
+                  };
+
+                  buildPath(targetId, []);
+                  return paths;
+                };
+
+                const allPaths = findAllPaths(selectedNodeId);
+
+                // 去重:将路径转换为字符串进行比较
+                const uniquePaths = [];
+                const pathStrings = new Set();
+                allPaths.forEach(path => {
+                  const pathString = path.map(n => n.id).join('->');
+                  if (!pathStrings.has(pathString)) {
+                    pathStrings.add(pathString);
+                    uniquePaths.push(path);
+                  }
+                });
+
+                return (
+                  <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
+                    {uniquePaths.map((path, pathIndex) => (
+                      <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
+                        {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
+                        {path.map((node, index) => {
+                          // 获取节点的 score、strategy 和 isSelected
+                          const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
+                          const nodeStrategy = getPrimaryStrategy(node.data);  // 使用智能提取函数
+                          const strategyColor = getStrategyColor(nodeStrategy);
+                          const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
+                          const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
+
+                          // 计算路径节点字体颜色:根据分数提升幅度判断
+                          let pathFontColor = '#374151'; // 默认颜色
+                          if (node.type === 'note') {
+                            pathFontColor = node.data.matchLevel === 'unsatisfied' ? '#ef4444' : '#374151';
+                          } else if (node.data.seed_score !== undefined) {
+                            const parentScore = parseFloat(node.data.seed_score);
+                            const gain = nodeScore - parentScore;
+                            pathFontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
+                          } else if (index > 0) {
+                            const prevNode = path[index - 1];
+                            const prevScore = prevNode.data.score ? parseFloat(prevNode.data.score) : 0;
+                            const gain = nodeScore - prevScore;
+                            pathFontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
+                          } else if (node.data.isSelected === false) {
+                            pathFontColor = '#ef4444';
+                          }
+
+                          return (
+                          <React.Fragment key={node.id + '-' + index}>
+                            <span
+                              onClick={() => {
+                                const nodeId = node.id;
+
+                                // 找到所有祖先节点
+                                const ancestorIds = [nodeId];
+                                const findAncestors = (id) => {
+                                  initialEdges.forEach(edge => {
+                                    if (edge.target === id && !ancestorIds.includes(edge.source)) {
+                                      ancestorIds.push(edge.source);
+                                      findAncestors(edge.source);
+                                    }
+                                  });
+                                };
+                                findAncestors(nodeId);
+
+                                // 如果节点或其祖先被隐藏,先恢复它们
+                                setHiddenNodes(prev => {
+                                  const newSet = new Set(prev);
+                                  ancestorIds.forEach(id => newSet.delete(id));
+                                  return newSet;
+                                });
+
+                                // 展开目录树中到达该节点的路径
+                                // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
+                                setCollapsedTreeNodes(prev => {
+                                  const newSet = new Set(prev);
+                                  // 清空所有折叠状态,让目录树完全展开到选中节点
+                                  // 这样可以确保选中节点在目录中可见
+                                  return new Set();
+                                });
+
+                                setSelectedNodeId(nodeId);
+                                setTimeout(() => {
+                                  fitView({
+                                    nodes: [{ id: nodeId }],
+                                    duration: 800,
+                                    padding: 0.3,
+                                  });
+                                }, 100);
+                              }}
+                              style={{
+                                padding: '6px 8px',
+                                borderRadius: '4px',
+                                background: 'white',
+                                border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
+                                color: '#374151',
+                                fontWeight: index === path.length - 1 ? '600' : '400',
+                                width: '180px',
+                                cursor: 'pointer',
+                                transition: 'all 0.2s ease',
+                                position: 'relative',
+                                display: 'inline-flex',
+                                flexDirection: 'column',
+                                gap: '4px',
+                              }}
+                              onMouseEnter={(e) => {
+                                e.currentTarget.style.opacity = '0.8';
+                              }}
+                              onMouseLeave={(e) => {
+                                e.currentTarget.style.opacity = '1';
+                              }}
+                              title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
+                            >
+                              {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
+                              <div style={{
+                                display: 'flex',
+                                alignItems: 'center',
+                                gap: '6px',
+                              }}>
+                                {/* 策略类型竖线 */}
+                                <div style={{
+                                  width: '3px',
+                                  height: '16px',
+                                  background: strategyColor,
+                                  borderRadius: '2px',
+                                  flexShrink: 0,
+                                }} />
+
+                                {/* 节点文字 - 左侧 */}
+                                <span style={{
+                                  flex: 1,
+                                  fontSize: '12px',
+                                  color: pathFontColor,
+                                  overflow: 'hidden',
+                                  textOverflow: 'ellipsis',
+                                  whiteSpace: 'nowrap',
+                                }}>
+                                  {node.data.title || node.id}
+                                </span>
+
+                                {/* 域标识 - 右侧,挨着分数 */}
+                                {(node.data.domain_type || node.data.domains_str || (node.data.domain_index !== null && node.data.domain_index !== undefined)) && (
+                                  <span style={{
+                                    fontSize: '12px',
+                                    color: '#fff',
+                                    background: '#6366f1',
+                                    padding: '2px 5px',
+                                    borderRadius: '3px',
+                                    flexShrink: 0,
+                                    fontWeight: '600',
+                                    marginLeft: '4px',
+                                  }}
+                                  title={
+                                    node.data.domain_type ? '域: ' + node.data.domain_type + ' (D' + node.data.domain_index + ')' :
+                                    node.data.domains_str ? '域: ' + node.data.domains_str :
+                                    '域 D' + node.data.domain_index
+                                  }
+                                  >
+                                    {node.data.domain_type || node.data.domains_str || ('D' + node.data.domain_index)}
+                                  </span>
+                                )}
+
+                                {/* 分数显示 - 步骤和轮次节点不显示分数 */}
+                                {nodeActualType !== 'step' && nodeActualType !== 'round' && (
+                                  <span style={{
+                                    fontSize: '10px',
+                                    color: '#6b7280',
+                                    fontWeight: '500',
+                                    flexShrink: 0,
+                                    minWidth: '35px',
+                                    textAlign: 'right',
+                                    marginLeft: '4px',
+                                  }}>
+                                    {nodeScore.toFixed(2)}
+                                  </span>
+                                )}
+                              </div>
+
+                              {/* 分数下划线 - 步骤和轮次节点不显示 */}
+                              {nodeActualType !== 'step' && nodeActualType !== 'round' && (
+                                <div style={{
+                                  width: (nodeScore * 100) + '%',
+                                  height: '2px',
+                                  background: getScoreColor(nodeScore),
+                                  borderRadius: '1px',
+                                  marginLeft: '9px',
+                                }} />
+                              )}
+                            </span>
+                            {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
+                          </React.Fragment>
+                        )})}
+                      </div>
+                    ))}
+                  </div>
+                );
+              })()}
+            </div>
+          ) : (
+            <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
+              选择一个节点查看路径
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* 主内容区:目录 + 画布 */}
+      <div style={{
+        display: 'flex',
+        flex: 1,
+        overflow: 'hidden',
+        cursor: isResizing ? 'col-resize' : 'default',
+        userSelect: isResizing ? 'none' : 'auto',
+      }}>
+        {/* 左侧目录树 */}
+        <div style={{
+          width: \`\${sidebarWidth}px\`,
+          background: 'white',
+          borderRight: '1px solid #e5e7eb',
+          display: 'flex',
+          flexDirection: 'column',
+          flexShrink: 0,
+        }}>
+          <div style={{
+            padding: '12px 16px',
+            borderBottom: '1px solid #e5e7eb',
+            display: 'flex',
+            justifyContent: 'space-between',
+            alignItems: 'center',
+          }}>
+            <span style={{
+              fontWeight: '600',
+              fontSize: '14px',
+              color: '#111827',
+            }}>
+              节点目录
+            </span>
+            <div style={{ display: 'flex', gap: '6px' }}>
+              <button
+                onClick={() => {
+                  setCollapsedTreeNodes(new Set());
+                }}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #d1d5db',
+                  background: 'white',
+                  color: '#6b7280',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                }}
+                title="展开全部节点"
+              >
+                全部展开
+              </button>
+              <button
+                onClick={() => {
+                  const getAllTreeKeys = (nodes) => {
+                    const keys = new Set();
+                    const traverse = (node) => {
+                      if (node.children && node.children.length > 0) {
+                        keys.add(node.treeKey);
+                        node.children.forEach(traverse);
+                      }
+                    };
+                    nodes.forEach(traverse);
+                    return keys;
+                  };
+                  setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
+                }}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #d1d5db',
+                  background: 'white',
+                  color: '#6b7280',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                }}
+                title="折叠全部节点"
+              >
+                全部折叠
+              </button>
+              <button
+                onClick={copyTreeToClipboard}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #3b82f6',
+                  background: '#3b82f6',
+                  color: 'white',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                  transition: 'all 0.2s',
+                }}
+                onMouseEnter={(e) => e.currentTarget.style.background = '#2563eb'}
+                onMouseLeave={(e) => e.currentTarget.style.background = '#3b82f6'}
+                title="复制树形结构为文本格式"
+              >
+                📋 复制树形结构
+              </button>
+            </div>
+          </div>
+          <div style={{
+            flex: 1,
+            overflowX: 'auto',
+            overflowY: 'auto',
+            padding: '8px',
+          }}>
+            <div style={{ minWidth: 'fit-content' }}>
+              {renderTree(treeRoots)}
+            </div>
+          </div>
+        </div>
+
+        {/* 可拖拽的分隔条 */}
+        <div
+          onMouseDown={handleMouseDown}
+          style={{
+            width: '4px',
+            cursor: 'col-resize',
+            background: isResizing ? '#3b82f6' : 'transparent',
+            transition: isResizing ? 'none' : 'background 0.2s',
+            flexShrink: 0,
+            position: 'relative',
+          }}
+          onMouseEnter={(e) => e.currentTarget.style.background = '#e5e7eb'}
+          onMouseLeave={(e) => {
+            if (!isResizing) e.currentTarget.style.background = 'transparent';
+          }}
+        >
+          {/* 拖拽提示线 */}
+          <div style={{
+            position: 'absolute',
+            top: '50%',
+            left: '50%',
+            transform: 'translate(-50%, -50%)',
+            width: '1px',
+            height: '40px',
+            background: '#9ca3af',
+            opacity: isResizing ? 1 : 0.3,
+          }} />
+        </div>
+
+        {/* 画布区域 */}
+        <div style={{ flex: 1, position: 'relative' }}>
+
+          {/* 右侧图例 */}
+          <div style={{
+            position: 'absolute',
+            top: '20px',
+            right: '20px',
+            background: 'white',
+            padding: '16px',
+            borderRadius: '12px',
+            boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
+            zIndex: 1000,
+            maxWidth: '260px',
+            border: '1px solid #e5e7eb',
+          }}>
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
+          <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
+          <button
+            onClick={() => setFocusMode(!focusMode)}
+            style={{
+              fontSize: '11px',
+              padding: '4px 8px',
+              borderRadius: '4px',
+              border: '1px solid',
+              borderColor: focusMode ? '#3b82f6' : '#d1d5db',
+              background: focusMode ? '#3b82f6' : 'white',
+              color: focusMode ? 'white' : '#6b7280',
+              cursor: 'pointer',
+              fontWeight: '500',
+            }}
+            title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
+          >
+            {focusMode ? '🎯 聚焦' : '📊 全图'}
+          </button>
+        </div>
+
+        <div style={{ fontSize: '12px' }}>
+          {/* 画布节点展开/折叠控制 */}
+          <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
+            <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
+            <div style={{ display: 'flex', gap: '6px' }}>
+              <button
+                onClick={() => {
+                  setCollapsedNodes(new Set());
+                }}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #d1d5db',
+                  background: 'white',
+                  color: '#6b7280',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                  flex: 1,
+                }}
+                title="展开画布中所有节点的子节点"
+              >
+                全部展开
+              </button>
+              <button
+                onClick={() => {
+                  const allNodeIds = new Set(initialNodes.map(n => n.id));
+                  setCollapsedNodes(allNodeIds);
+                }}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #d1d5db',
+                  background: 'white',
+                  color: '#6b7280',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                  flex: 1,
+                }}
+                title="折叠画布中所有节点的子节点"
+              >
+                全部折叠
+              </button>
+            </div>
+          </div>
+
+          <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
+            <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
+            </div>
+          </div>
+
+          <div style={{
+            marginTop: '12px',
+            paddingTop: '12px',
+            borderTop: '1px solid #f3f4f6',
+            fontSize: '11px',
+            color: '#9ca3af',
+            lineHeight: '1.5',
+          }}>
+            💡 点击节点左上角 × 隐藏节点
+          </div>
+
+          {/* 隐藏节点列表 - 在图例内部 */}
+          {hiddenNodes.size > 0 && (
+            <div style={{
+              marginTop: '12px',
+              paddingTop: '12px',
+              borderTop: '1px solid #f3f4f6',
+            }}>
+              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
+                <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
+                <button
+                  onClick={() => setHiddenNodes(new Set())}
+                  style={{
+                    fontSize: '10px',
+                    color: '#3b82f6',
+                    background: 'none',
+                    border: 'none',
+                    cursor: 'pointer',
+                    textDecoration: 'underline',
+                  }}
+                >
+                  全部恢复
+                </button>
+              </div>
+              <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
+                {Array.from(hiddenNodes).map(nodeId => {
+                  const node = initialNodes.find(n => n.id === nodeId);
+                  if (!node) return null;
+                  return (
+                    <div
+                      key={nodeId}
+                      style={{
+                        display: 'flex',
+                        justifyContent: 'space-between',
+                        alignItems: 'center',
+                        padding: '6px 8px',
+                        margin: '4px 0',
+                        background: '#f9fafb',
+                        borderRadius: '6px',
+                        fontSize: '11px',
+                      }}
+                    >
+                      <span
+                        style={{
+                          flex: 1,
+                          overflow: 'hidden',
+                          textOverflow: 'ellipsis',
+                          whiteSpace: 'nowrap',
+                          color: '#374151',
+                        }}
+                        title={node.data.title || nodeId}
+                      >
+                        {node.data.title || nodeId}
+                      </span>
+                      <button
+                        onClick={() => {
+                          setHiddenNodes(prev => {
+                            const newSet = new Set(prev);
+                            newSet.delete(nodeId);
+                            return newSet;
+                          });
+                        }}
+                        style={{
+                          marginLeft: '8px',
+                          fontSize: '10px',
+                          color: '#10b981',
+                          background: 'none',
+                          border: 'none',
+                          cursor: 'pointer',
+                          flexShrink: 0,
+                        }}
+                      >
+                        恢复
+                      </button>
+                    </div>
+                  );
+                })}
+              </div>
+            </div>
+          )}
+          </div>
+          </div>
+
+          {/* React Flow 画布 */}
+          <ReactFlow
+            nodes={nodes}
+            edges={edges}
+            nodeTypes={nodeTypes}
+            fitView
+            fitViewOptions={{ padding: 0.2, duration: 500 }}
+            minZoom={0.1}
+            maxZoom={1.5}
+            nodesDraggable={true}
+            nodesConnectable={false}
+            elementsSelectable={true}
+            defaultEdgeOptions={{
+              type: 'smoothstep',
+            }}
+            proOptions={{ hideAttribution: true }}
+            onNodeClick={(event, clickedNode) => {
+              setSelectedNodeId(clickedNode.id);
+            }}
+          >
+            <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
+            <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
+          </ReactFlow>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function App() {
+  return (
+    <ReactFlowProvider>
+      <FlowContent />
+    </ReactFlowProvider>
+  );
+}
+
+const root = createRoot(document.getElementById('root'));
+root.render(<App />);
+`;
+
+fs.writeFileSync(reactComponentPath, reactComponent);
+
+// 使用 esbuild 打包
+console.log('🎨 Building modern visualization...');
+
+build({
+  entryPoints: [reactComponentPath],
+  bundle: true,
+  outfile: path.join(__dirname, 'bundle_v2.js'),
+  format: 'iife',
+  loader: {
+    '.css': 'css',
+  },
+  minify: false,
+  sourcemap: 'inline',
+  // 强制所有 React 引用指向同一个位置,避免多副本
+  alias: {
+    'react': path.join(__dirname, 'node_modules/react'),
+    'react-dom': path.join(__dirname, 'node_modules/react-dom'),
+    'react/jsx-runtime': path.join(__dirname, 'node_modules/react/jsx-runtime'),
+    'react/jsx-dev-runtime': path.join(__dirname, 'node_modules/react/jsx-dev-runtime'),
+  },
+}).then(() => {
+  // 读取打包后的 JS
+  const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
+
+  // 读取 CSS
+  const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
+  const css = fs.readFileSync(cssPath, 'utf-8');
+
+  // 生成最终 HTML
+  const html = `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>查询图可视化</title>
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+    <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
+    <script>
+      // 过滤特定的 React 警告
+      const originalError = console.error;
+      console.error = (...args) => {
+        if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
+          return;
+        }
+        originalError.apply(console, args);
+      };
+    </script>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        body {
+            font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+            overflow: hidden;
+            -webkit-font-smoothing: antialiased;
+            -moz-osx-font-smoothing: grayscale;
+        }
+        #root {
+            width: 100vw;
+            height: 100vh;
+        }
+        ${css}
+
+        /* 自定义样式覆盖 */
+        .react-flow__edge-path {
+            stroke-linecap: round;
+        }
+        .react-flow__controls {
+            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+            border: 1px solid #e5e7eb;
+            border-radius: 8px;
+        }
+        .react-flow__controls-button {
+            border: none;
+            border-bottom: 1px solid #e5e7eb;
+        }
+        .react-flow__controls-button:hover {
+            background: #f9fafb;
+        }
+    </style>
+</head>
+<body>
+    <div id="root"></div>
+    <script>${bundleJs}</script>
+</body>
+</html>`;
+
+  // 写入输出文件
+  fs.writeFileSync(outputFile, html);
+
+  // 清理临时文件
+  fs.unlinkSync(reactComponentPath);
+  fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
+
+  console.log('✅ Visualization generated: ' + outputFile);
+  console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
+  console.log('🔗 Edges: ' + data.edges.length);
+}).catch(error => {
+  console.error('❌ Build error:', error);
+  process.exit(1);
+});

+ 26 - 0
visualization/knowledge_search_traverse/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "sug-v6-1-2-5-visualization",
+  "version": "1.1.0",
+  "description": "可视化工具 for sug_v6_1_2_5.py - React Flow based query graph visualization with draggable directory tree",
+  "main": "index.js",
+  "scripts": {
+    "visualize": "node index.js"
+  },
+  "dependencies": {
+    "react": "^19.2.0",
+    "react-dom": "^19.2.0",
+    "esbuild": "^0.25.11",
+    "@xyflow/react": "^12.9.1",
+    "dagre": "^0.8.5",
+    "zustand": "^5.0.2",
+    "react-draggable": "^4.4.6"
+  },
+  "keywords": [
+    "visualization",
+    "react-flow",
+    "query-graph",
+    "dagre"
+  ],
+  "author": "",
+  "license": "ISC"
+}