ソースを参照

Create sug_v6_1_2_6 - Copy from v6.1.2.5 with visualization

- Copy sug_v6_1_2_5.py to sug_v6_1_2_6.py
- Copy visualization/sug_v6_1_2_5/ to visualization/sug_v6_1_2_6/
- Update all references from v6.1.2.5 to v6.1.2.6
- Update package.json and README.md with new version
- Clean version without node_modules or build artifacts

Features:
- React Flow based query graph visualization
- Interactive node operations (drag, filter, search)
- Support for large datasets (1000+ nodes)
- Directory tree and breadcrumb navigation
- 'Worth searching' query panel
- Fixed React multiple copies issue
- Fixed nodeWidth layout error

🤖 Generated with Claude Code
yangxiaohui 1 ヶ月 前
コミット
24ca0fa131

+ 1551 - 0
sug_v6_1_2_6.py

@@ -0,0 +1,1551 @@
+import asyncio
+import json
+import os
+import sys
+import argparse
+from datetime import datetime
+from typing import Literal
+
+from agents import Agent, Runner
+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"
+from script.search_recommendations.xiaohongshu_search_recommendations import XiaohongshuSearchRecommendations
+from script.search.xiaohongshu_search import XiaohongshuSearch
+
+
+# ============================================================================
+# 数据模型
+# ============================================================================
+
+class QueryState(BaseModel):
+    """Query状态跟踪"""
+    query: str
+    level: int  # 当前所在层级
+    no_suggestion_rounds: int = 0  # 连续没有suggestion的轮数
+    relevance_score: float = 0.0  # 与原始需求的相关度
+    parent_query: str | None = None  # 父query
+    strategy: str | None = None  # 生成策略:direct_sug, rewrite, add_word
+    is_terminated: bool = False  # 是否已终止(不再处理)
+
+
+class WordLibrary(BaseModel):
+    """动态分词库"""
+    words: set[str] = Field(default_factory=set)
+    word_sources: dict[str, str] = Field(default_factory=dict)  # 记录词的来源:word -> source(note_id或"initial")
+    core_words: set[str] = Field(default_factory=set)  # 核心词(第一层初始分词)
+
+    def add_word(self, word: str, source: str = "unknown", is_core: bool = False):
+        """添加单词到分词库"""
+        if word and word.strip():
+            word = word.strip()
+            self.words.add(word)
+            if word not in self.word_sources:
+                self.word_sources[word] = source
+            if is_core:
+                self.core_words.add(word)
+
+    def add_words(self, words: list[str], source: str = "unknown", is_core: bool = False):
+        """批量添加单词"""
+        for word in words:
+            self.add_word(word, source, is_core)
+
+    def get_unused_word(self, current_query: str, prefer_core: bool = True) -> str | None:
+        """获取一个当前query中没有的词
+
+        Args:
+            current_query: 当前查询
+            prefer_core: 是否优先返回核心词(默认True)
+        """
+        # 优先从核心词中查找
+        if prefer_core and self.core_words:
+            for word in self.core_words:
+                if word not in current_query:
+                    return word
+
+        # 如果核心词都用完了,或者不优先使用核心词,从所有词中查找
+        for word in self.words:
+            if word not in current_query:
+                return word
+        return None
+
+    def model_dump(self):
+        """序列化为dict"""
+        return {
+            "words": list(self.words),
+            "word_sources": self.word_sources,
+            "core_words": list(self.core_words)
+        }
+
+
+class RunContext(BaseModel):
+    """运行上下文"""
+    version: str
+    input_files: dict[str, str]
+    q_with_context: str
+    q_context: str
+    q: str
+    log_url: str
+    log_dir: str
+
+    # 新增字段
+    word_library: dict = Field(default_factory=dict)  # 使用dict存储,因为set不能直接序列化
+    query_states: list[dict] = Field(default_factory=list)
+    steps: list[dict] = Field(default_factory=list)
+
+    # Query演化图
+    query_graph: dict = Field(default_factory=dict)  # 记录Query的演化路径和关系
+
+    # 最终结果
+    satisfied_notes: list[dict] = Field(default_factory=list)
+    final_output: str | None = None
+
+
+# ============================================================================
+# Agent 定义
+# ============================================================================
+
+# Agent 1: 分词专家
+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: Query相关度评估专家
+class RelevanceEvaluation(BaseModel):
+    """相关度评估"""
+    relevance_score: float = Field(..., description="相关性分数 0-1")
+    is_improved: bool = Field(..., description="是否比之前更好")
+    reason: str = Field(..., description="评估理由")
+
+relevance_evaluation_instructions = """
+你是Query相关度评估专家。
+
+## 任务
+评估当前query与原始需求的匹配程度。
+
+## 评估标准
+- 主题相关性
+- 要素覆盖度
+- 意图匹配度
+
+## 输出
+- relevance_score: 0-1的相关性分数
+- is_improved: 如果提供了previous_score,判断是否有提升
+- reason: 详细理由
+""".strip()
+
+relevance_evaluator = Agent[None](
+    name="Query相关度评估专家",
+    instructions=relevance_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=RelevanceEvaluation,
+)
+
+
+# Agent 3: Query改写专家
+class QueryRewrite(BaseModel):
+    """Query改写结果"""
+    rewritten_query: str = Field(..., description="改写后的query")
+    rewrite_type: str = Field(..., description="改写类型:abstract或synonym")
+    reasoning: str = Field(..., description="改写理由")
+
+query_rewrite_instructions = """
+你是Query改写专家。
+
+## 改写策略
+1. **向上抽象**:将具体概念泛化到更高层次
+   - 例:iPhone 13 → 智能手机
+2. **同义改写**:使用同义词或相关表达
+   - 例:购买 → 入手、获取
+
+## 输出要求
+返回改写后的query、改写类型和理由。
+""".strip()
+
+query_rewriter = Agent[None](
+    name="Query改写专家",
+    instructions=query_rewrite_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=QueryRewrite,
+)
+
+
+# Agent 4: 加词位置评估专家
+class WordInsertion(BaseModel):
+    """加词结果"""
+    new_query: str = Field(..., description="加词后的新query")
+    insertion_position: str = Field(..., description="插入位置描述")
+    reasoning: str = Field(..., description="插入理由")
+
+word_insertion_instructions = """
+你是加词位置评估专家。
+
+## 任务
+将新词加到当前query的最合适位置,保持语义通顺。
+
+## 原则
+1. 保持语法正确
+2. 语义连贯
+3. 符合搜索习惯
+
+## 输出
+返回新query、插入位置描述和理由。
+""".strip()
+
+word_inserter = Agent[None](
+    name="加词位置评估专家",
+    instructions=word_insertion_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=WordInsertion,
+)
+
+
+# Agent 5: Result匹配度评估专家
+class ResultEvaluation(BaseModel):
+    """Result评估结果"""
+    match_level: str = Field(..., description="匹配等级:satisfied, partial, unsatisfied")
+    relevance_score: float = Field(..., description="相关性分数 0-1")
+    missing_aspects: list[str] = Field(default_factory=list, description="缺失的方面")
+    reason: str = Field(..., description="评估理由")
+
+result_evaluation_instructions = """
+你是Result匹配度评估专家。
+
+## 任务
+评估搜索结果(帖子)与原始需求的匹配程度。
+
+## 评估等级
+1. **satisfied**: 完全满足需求
+2. **partial**: 部分满足,但有缺失
+3. **unsatisfied**: 基本不满足
+
+## 输出要求
+- match_level: 匹配等级
+- relevance_score: 相关性分数
+- missing_aspects: 如果是partial,列出缺失的方面
+- reason: 详细理由
+""".strip()
+
+result_evaluator = Agent[None](
+    name="Result匹配度评估专家",
+    instructions=result_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=ResultEvaluation,
+)
+
+
+# Agent 6: Query改造专家(基于缺失部分)
+class QueryImprovement(BaseModel):
+    """Query改造结果"""
+    improved_query: str = Field(..., description="改造后的query")
+    added_aspects: list[str] = Field(..., description="添加的方面")
+    reasoning: str = Field(..., description="改造理由")
+
+query_improvement_instructions = """
+你是Query改造专家。
+
+## 任务
+根据搜索结果的缺失部分,改造query使其包含这些内容。
+
+## 原则
+1. 针对性补充缺失方面
+2. 保持query简洁
+3. 符合搜索习惯
+
+## 输出
+返回改造后的query、添加的方面和理由。
+""".strip()
+
+query_improver = Agent[None](
+    name="Query改造专家",
+    instructions=query_improvement_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=QueryImprovement,
+)
+
+
+# Agent 7: 关键词提取专家
+class KeywordExtraction(BaseModel):
+    """关键词提取结果"""
+    keywords: list[str] = Field(..., description="提取的关键词列表")
+    reasoning: str = Field(..., description="提取理由")
+
+keyword_extraction_instructions = """
+你是关键词提取专家。
+
+## 任务
+从帖子标题和描述中提取核心关键词。
+
+## 提取原则
+1. 提取有搜索价值的词汇
+2. 去除虚词和通用词
+3. 保留专业术语
+4. 提取3-10个关键词
+
+## 输出
+返回关键词列表和提取理由。
+""".strip()
+
+keyword_extractor = Agent[None](
+    name="关键词提取专家",
+    instructions=keyword_extraction_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=KeywordExtraction,
+)
+
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+def add_step(context: RunContext, step_name: str, step_type: str, data: dict):
+    """添加步骤记录"""
+    step = {
+        "step_number": len(context.steps) + 1,
+        "step_name": step_name,
+        "step_type": step_type,
+        "timestamp": datetime.now().isoformat(),
+        "data": data
+    }
+    context.steps.append(step)
+    return step
+
+
+def add_query_to_graph(context: RunContext, query_state: QueryState, iteration: int, evaluation_reason: str = "", is_selected: bool = True, parent_level: int | None = None):
+    """添加Query节点到演化图
+
+    Args:
+        context: 运行上下文
+        query_state: Query状态
+        iteration: 迭代次数
+        evaluation_reason: 评估原因(可选)
+        is_selected: 是否被选中进入处理队列(默认True)
+        parent_level: 父节点的层级(用于构造parent_id)
+    """
+    # 使用 "query_level" 格式作为节点ID
+    query_id = f"{query_state.query}_{query_state.level}"
+
+    # 初始化图结构
+    if "nodes" not in context.query_graph:
+        context.query_graph["nodes"] = {}
+        context.query_graph["edges"] = []
+        context.query_graph["iterations"] = {}
+
+    # 添加Query节点(type: query)
+    context.query_graph["nodes"][query_id] = {
+        "type": "query",
+        "query": query_state.query,
+        "level": query_state.level,
+        "relevance_score": query_state.relevance_score,
+        "strategy": query_state.strategy,
+        "parent_query": query_state.parent_query,
+        "iteration": iteration,
+        "is_terminated": query_state.is_terminated,
+        "no_suggestion_rounds": query_state.no_suggestion_rounds,
+        "evaluation_reason": evaluation_reason,  # 评估原因
+        "is_selected": is_selected  # 是否被选中
+    }
+
+    # 添加边(父子关系)
+    if query_state.parent_query and parent_level is not None:
+        # 构造父节点ID: parent_query_parent_level
+        parent_id = f"{query_state.parent_query}_{parent_level}"
+        if parent_id in context.query_graph["nodes"]:
+            context.query_graph["edges"].append({
+                "from": parent_id,
+                "to": query_id,
+                "edge_type": "query_to_query",
+                "strategy": query_state.strategy,
+                "score_improvement": query_state.relevance_score - context.query_graph["nodes"][parent_id]["relevance_score"]
+            })
+
+    # 按迭代分组
+    if iteration not in context.query_graph["iterations"]:
+        context.query_graph["iterations"][iteration] = []
+    context.query_graph["iterations"][iteration].append(query_id)
+
+
+def add_note_to_graph(context: RunContext, query: str, query_level: int, note: dict):
+    """添加Note节点到演化图,并连接到对应的Query
+
+    Args:
+        context: 运行上下文
+        query: query文本
+        query_level: query所在层级
+        note: 帖子数据
+    """
+    note_id = note["note_id"]
+
+    # 初始化图结构
+    if "nodes" not in context.query_graph:
+        context.query_graph["nodes"] = {}
+        context.query_graph["edges"] = []
+        context.query_graph["iterations"] = {}
+
+    # 添加Note节点(type: note),包含完整的元信息
+    context.query_graph["nodes"][note_id] = {
+        "type": "note",
+        "note_id": note_id,
+        "title": note["title"],
+        "desc": note.get("desc", ""),  # 完整描述,不截断
+        "note_url": note.get("note_url", ""),
+        "image_list": note.get("image_list", []),  # 图片列表
+        "interact_info": note.get("interact_info", {}),  # 互动信息(点赞、收藏、评论、分享)
+        "match_level": note["evaluation"]["match_level"],
+        "relevance_score": note["evaluation"]["relevance_score"],
+        "evaluation_reason": note["evaluation"].get("reason", ""),  # 评估原因
+        "found_by_query": query
+    }
+
+    # 添加边:Query → Note,使用 query_level 格式的ID
+    query_id = f"{query}_{query_level}"
+    if query_id in context.query_graph["nodes"]:
+        context.query_graph["edges"].append({
+            "from": query_id,
+            "to": note_id,
+            "edge_type": "query_to_note",
+            "match_level": note["evaluation"]["match_level"],
+            "relevance_score": note["evaluation"]["relevance_score"]
+        })
+
+
+def process_note_data(note: dict) -> dict:
+    """处理搜索接口返回的帖子数据"""
+    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", {})
+
+    return {
+        "note_id": note.get("id", ""),
+        "title": note_card.get("display_title", ""),
+        "desc": note_card.get("desc", ""),
+        "image_list": image_list,
+        "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)
+        },
+        "user": {
+            "nickname": user_info.get("nickname", ""),
+            "user_id": user_info.get("user_id", "")
+        },
+        "type": note_card.get("type", "normal"),
+        "note_url": f"https://www.xiaohongshu.com/explore/{note.get('id', '')}"
+    }
+
+
+# ============================================================================
+# 核心流程函数
+# ============================================================================
+
+async def initialize_word_library(original_query: str, context: RunContext) -> WordLibrary:
+    """初始化分词库"""
+    print("\n[初始化] 创建分词库...")
+
+    # 使用Agent进行分词
+    result = await Runner.run(word_segmenter, original_query)
+    segmentation: WordSegmentation = result.final_output
+
+    word_lib = WordLibrary()
+    # 初始分词标记为核心词(is_core=True)
+    word_lib.add_words(segmentation.words, source="initial", is_core=True)
+
+    print(f"初始分词库(核心词): {list(word_lib.words)}")
+    print(f"分词理由: {segmentation.reasoning}")
+
+    # 保存到context
+    context.word_library = word_lib.model_dump()
+
+    add_step(context, "初始化分词库", "word_library_init", {
+        "agent": "分词专家",
+        "input": original_query,
+        "output": {
+            "words": segmentation.words,
+            "reasoning": segmentation.reasoning
+        },
+        "result": {
+            "word_library": list(word_lib.words)
+        }
+    })
+
+    return word_lib
+
+
+async def evaluate_query_relevance(
+    query: str,
+    original_need: str,
+    previous_score: float | None = None,
+    context: RunContext = None
+) -> RelevanceEvaluation:
+    """评估query与原始需求的相关度"""
+
+    eval_input = f"""
+<原始需求>
+{original_need}
+</原始需求>
+
+<当前Query>
+{query}
+</当前Query>
+
+{"<之前的相关度分数>" + str(previous_score) + "</之前的相关度分数>" if previous_score is not None else ""}
+
+请评估当前query与原始需求的相关度。
+"""
+
+    result = await Runner.run(relevance_evaluator, eval_input)
+    evaluation: RelevanceEvaluation = result.final_output
+
+    return evaluation
+
+
+async def process_suggestions(
+    query: str,
+    query_state: QueryState,
+    original_need: str,
+    word_lib: WordLibrary,
+    context: RunContext,
+    xiaohongshu_api: XiaohongshuSearchRecommendations,
+    iteration: int
+) -> list[QueryState]:
+    """处理suggestion分支,返回新的query states"""
+
+    print(f"\n  [Suggestion分支] 处理query: {query}")
+
+    # 收集本次分支处理中的所有Agent调用
+    agent_calls = []
+
+    # 1. 获取suggestions
+    suggestions = xiaohongshu_api.get_recommendations(keyword=query)
+
+    if not suggestions or len(suggestions) == 0:
+        print(f"    → 没有获取到suggestion")
+        query_state.no_suggestion_rounds += 1
+
+        # 记录步骤
+        add_step(context, f"Suggestion分支 - {query}", "suggestion_branch", {
+            "query": query,
+            "query_level": query_state.level,
+            "suggestions_count": 0,
+            "no_suggestion_rounds": query_state.no_suggestion_rounds,
+            "new_queries_generated": 0
+        })
+
+        return []
+
+    print(f"    → 获取到 {len(suggestions)} 个suggestions")
+    query_state.no_suggestion_rounds = 0  # 重置计数
+
+    # 2. 评估每个suggestion
+    new_queries = []
+    suggestion_evaluations = []
+
+    for sug in suggestions:  # 处理所有建议
+        # 评估sug与原始需求的相关度(注意:这里是与原始需求original_need对比,而非当前query)
+        # 这样可以确保生成的suggestion始终围绕用户的核心需求
+        sug_eval = await evaluate_query_relevance(sug, original_need, query_state.relevance_score, context)
+
+        sug_eval_record = {
+            "suggestion": sug,
+            "relevance_score": sug_eval.relevance_score,
+            "is_improved": sug_eval.is_improved,
+            "reason": sug_eval.reason
+        }
+        suggestion_evaluations.append(sug_eval_record)
+
+        # 创建query state(所有suggestion都作为query节点)
+        sug_state = QueryState(
+            query=sug,
+            level=query_state.level + 1,
+            relevance_score=sug_eval.relevance_score,
+            parent_query=query,
+            strategy="调用sug"
+        )
+
+        # 判断是否比当前query更好(只有提升的才加入待处理队列)
+        is_selected = sug_eval.is_improved and sug_eval.relevance_score > query_state.relevance_score
+
+        # 将所有suggestion添加到演化图(包括未提升的)
+        add_query_to_graph(
+            context,
+            sug_state,
+            iteration,
+            evaluation_reason=sug_eval.reason,
+            is_selected=is_selected,
+            parent_level=query_state.level  # 父节点的层级
+        )
+
+        if is_selected:
+            print(f"      ✓ {sug} (分数: {sug_eval.relevance_score:.2f}, 提升: {sug_eval.is_improved})")
+            new_queries.append(sug_state)
+        else:
+            print(f"      ✗ {sug} (分数: {sug_eval.relevance_score:.2f}, 未提升)")
+
+    # 3. 改写策略(向上抽象或同义改写)
+    if len(new_queries) < 3:  # 如果直接使用sug的数量不够,尝试改写
+        # 尝试向上抽象
+        rewrite_input_abstract = f"""
+<当前Query>
+{query}
+</当前Query>
+
+<改写要求>
+类型: abstract (向上抽象)
+</改写要求>
+
+请改写这个query。
+"""
+        result = await Runner.run(query_rewriter, rewrite_input_abstract)
+        rewrite: QueryRewrite = result.final_output
+
+        # 收集改写Agent的输入输出
+        rewrite_agent_call = {
+            "agent": "Query改写专家",
+            "action": "向上抽象改写",
+            "input": {
+                "query": query,
+                "rewrite_type": "abstract"
+            },
+            "output": {
+                "rewritten_query": rewrite.rewritten_query,
+                "rewrite_type": rewrite.rewrite_type,
+                "reasoning": rewrite.reasoning
+            }
+        }
+        agent_calls.append(rewrite_agent_call)
+
+        # 评估改写后的query
+        rewrite_eval = await evaluate_query_relevance(rewrite.rewritten_query, original_need, query_state.relevance_score, context)
+
+        # 创建改写后的query state
+        new_state = QueryState(
+            query=rewrite.rewritten_query,
+            level=query_state.level + 1,
+            relevance_score=rewrite_eval.relevance_score,
+            parent_query=query,
+            strategy="抽象改写"
+        )
+
+        # 添加到演化图(无论是否提升)
+        add_query_to_graph(
+            context,
+            new_state,
+            iteration,
+            evaluation_reason=rewrite_eval.reason,
+            is_selected=rewrite_eval.is_improved,
+            parent_level=query_state.level  # 父节点的层级
+        )
+
+        if rewrite_eval.is_improved:
+            print(f"      ✓ 改写(抽象): {rewrite.rewritten_query} (分数: {rewrite_eval.relevance_score:.2f})")
+            new_queries.append(new_state)
+        else:
+            print(f"      ✗ 改写(抽象): {rewrite.rewritten_query} (分数: {rewrite_eval.relevance_score:.2f}, 未提升)")
+
+    # 3.2. 同义改写策略
+    if len(new_queries) < 4:  # 如果还不够,尝试同义改写
+        rewrite_input_synonym = f"""
+<当前Query>
+{query}
+</当前Query>
+
+<改写要求>
+类型: synonym (同义改写)
+使用同义词或相关表达来改写query,保持语义相同但表达方式不同。
+</改写要求>
+
+请改写这个query。
+"""
+        result = await Runner.run(query_rewriter, rewrite_input_synonym)
+        rewrite_syn: QueryRewrite = result.final_output
+
+        # 收集同义改写Agent的输入输出
+        rewrite_syn_agent_call = {
+            "agent": "Query改写专家",
+            "action": "同义改写",
+            "input": {
+                "query": query,
+                "rewrite_type": "synonym"
+            },
+            "output": {
+                "rewritten_query": rewrite_syn.rewritten_query,
+                "rewrite_type": rewrite_syn.rewrite_type,
+                "reasoning": rewrite_syn.reasoning
+            }
+        }
+        agent_calls.append(rewrite_syn_agent_call)
+
+        # 评估改写后的query
+        rewrite_syn_eval = await evaluate_query_relevance(rewrite_syn.rewritten_query, original_need, query_state.relevance_score, context)
+
+        # 创建改写后的query state
+        new_state = QueryState(
+            query=rewrite_syn.rewritten_query,
+            level=query_state.level + 1,
+            relevance_score=rewrite_syn_eval.relevance_score,
+            parent_query=query,
+            strategy="同义改写"
+        )
+
+        # 添加到演化图(无论是否提升)
+        add_query_to_graph(
+            context,
+            new_state,
+            iteration,
+            evaluation_reason=rewrite_syn_eval.reason,
+            is_selected=rewrite_syn_eval.is_improved,
+            parent_level=query_state.level  # 父节点的层级
+        )
+
+        if rewrite_syn_eval.is_improved:
+            print(f"      ✓ 改写(同义): {rewrite_syn.rewritten_query} (分数: {rewrite_syn_eval.relevance_score:.2f})")
+            new_queries.append(new_state)
+        else:
+            print(f"      ✗ 改写(同义): {rewrite_syn.rewritten_query} (分数: {rewrite_syn_eval.relevance_score:.2f}, 未提升)")
+
+    # 4. 加词策略(优先使用核心词)
+    unused_word = word_lib.get_unused_word(query, prefer_core=True)
+    is_core_word = unused_word in word_lib.core_words if unused_word else False
+
+    if unused_word and len(new_queries) < 5:
+        word_type = "核心词" if is_core_word else "普通词"
+        insertion_input = f"""
+<当前Query>
+{query}
+</当前Query>
+
+<要添加的词>
+{unused_word}
+</要添加的词>
+
+请将这个词加到query的最合适位置。
+"""
+        result = await Runner.run(word_inserter, insertion_input)
+        insertion: WordInsertion = result.final_output
+
+        # 收集加词Agent的输入输出
+        insertion_agent_call = {
+            "agent": "加词位置评估专家",
+            "action": f"加词({word_type})",
+            "input": {
+                "query": query,
+                "word_to_add": unused_word,
+                "is_core_word": is_core_word
+            },
+            "output": {
+                "new_query": insertion.new_query,
+                "insertion_position": insertion.insertion_position,
+                "reasoning": insertion.reasoning
+            }
+        }
+        agent_calls.append(insertion_agent_call)
+
+        # 评估加词后的query
+        insertion_eval = await evaluate_query_relevance(insertion.new_query, original_need, query_state.relevance_score, context)
+
+        # 创建加词后的query state
+        new_state = QueryState(
+            query=insertion.new_query,
+            level=query_state.level + 1,
+            relevance_score=insertion_eval.relevance_score,
+            parent_query=query,
+            strategy="加词"
+        )
+
+        # 添加到演化图(无论是否提升)
+        add_query_to_graph(
+            context,
+            new_state,
+            iteration,
+            evaluation_reason=insertion_eval.reason,
+            is_selected=insertion_eval.is_improved,
+            parent_level=query_state.level  # 父节点的层级
+        )
+
+        if insertion_eval.is_improved:
+            print(f"      ✓ 加词({word_type}): {insertion.new_query} [+{unused_word}] (分数: {insertion_eval.relevance_score:.2f})")
+            new_queries.append(new_state)
+        else:
+            print(f"      ✗ 加词({word_type}): {insertion.new_query} [+{unused_word}] (分数: {insertion_eval.relevance_score:.2f}, 未提升)")
+
+    # 记录完整的suggestion分支处理结果(层级化)
+    add_step(context, f"Suggestion分支 - {query}", "suggestion_branch", {
+        "query": query,
+        "query_level": query_state.level,
+        "query_relevance": query_state.relevance_score,
+        "suggestions_count": len(suggestions),
+        "suggestions_evaluated": len(suggestion_evaluations),
+        "suggestion_evaluations": suggestion_evaluations,  # 保存所有评估
+        "agent_calls": agent_calls,  # 所有Agent调用的详细记录
+        "new_queries_generated": len(new_queries),
+        "new_queries": [{"query": nq.query, "score": nq.relevance_score, "strategy": nq.strategy} for nq in new_queries],
+        "no_suggestion_rounds": query_state.no_suggestion_rounds
+    })
+
+    return new_queries
+
+
+async def process_search_results(
+    query: str,
+    query_state: QueryState,
+    original_need: str,
+    word_lib: WordLibrary,
+    context: RunContext,
+    xiaohongshu_search: XiaohongshuSearch,
+    relevance_threshold: float,
+    iteration: int
+) -> tuple[list[dict], list[QueryState]]:
+    """
+    处理搜索结果分支
+    返回: (满足需求的notes, 需要继续迭代的新queries)
+    """
+
+    print(f"\n  [Result分支] 搜索query: {query}")
+
+    # 收集本次分支处理中的所有Agent调用
+    agent_calls = []
+
+    # 1. 判断query相关度是否达到门槛
+    if query_state.relevance_score < relevance_threshold:
+        print(f"    ✗ 相关度 {query_state.relevance_score:.2f} 低于门槛 {relevance_threshold},跳过搜索")
+        return [], []
+
+    print(f"    ✓ 相关度 {query_state.relevance_score:.2f} 达到门槛,执行搜索")
+
+    # 2. 执行搜索
+    try:
+        search_result = xiaohongshu_search.search(keyword=query)
+        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", [])
+        print(f"    → 搜索到 {len(notes)} 个帖子")
+
+    except Exception as e:
+        print(f"    ✗ 搜索失败: {e}")
+        return [], []
+
+    if not notes:
+        return [], []
+
+    # 3. 评估每个帖子
+    satisfied_notes = []
+    partial_notes = []
+
+    for note in notes:  # 评估所有帖子
+        note_data = process_note_data(note)
+        title = note_data["title"] or ""
+        desc = note_data["desc"] or ""
+
+        # 跳过空标题和描述的帖子
+        if not title and not desc:
+            continue
+
+        # 评估帖子
+        eval_input = f"""
+<原始需求>
+{original_need}
+</原始需求>
+
+<帖子>
+标题: {title}
+描述: {desc}
+</帖子>
+
+请评估这个帖子与原始需求的匹配程度。
+"""
+        result = await Runner.run(result_evaluator, eval_input)
+        evaluation: ResultEvaluation = result.final_output
+
+        # 收集Result评估Agent的输入输出
+        result_eval_agent_call = {
+            "agent": "Result匹配度评估专家",
+            "action": "评估帖子匹配度",
+            "input": {
+                "note_id": note_data.get("note_id"),
+                "title": title,
+                "desc": desc  # 完整描述
+            },
+            "output": {
+                "match_level": evaluation.match_level,
+                "relevance_score": evaluation.relevance_score,
+                "missing_aspects": evaluation.missing_aspects,
+                "reason": evaluation.reason
+            }
+        }
+        agent_calls.append(result_eval_agent_call)
+
+        note_data["evaluation"] = {
+            "match_level": evaluation.match_level,
+            "relevance_score": evaluation.relevance_score,
+            "missing_aspects": evaluation.missing_aspects,
+            "reason": evaluation.reason
+        }
+
+        # 将所有评估过的帖子添加到演化图(包括satisfied、partial、unsatisfied)
+        add_note_to_graph(context, query, query_state.level, note_data)
+
+        if evaluation.match_level == "satisfied":
+            satisfied_notes.append(note_data)
+            print(f"      ✓ 满足: {title[:30] if len(title) > 30 else title}... (分数: {evaluation.relevance_score:.2f})")
+        elif evaluation.match_level == "partial":
+            partial_notes.append(note_data)
+            print(f"      ~ 部分: {title[:30] if len(title) > 30 else title}... (缺失: {', '.join(evaluation.missing_aspects[:2])})")
+        else:  # unsatisfied
+            print(f"      ✗ 不满足: {title[:30] if len(title) > 30 else title}... (分数: {evaluation.relevance_score:.2f})")
+
+    # 4. 处理满足的帖子:不再扩充分词库(避免无限扩张)
+    new_queries = []
+
+    if satisfied_notes:
+        print(f"\n    ✓ 找到 {len(satisfied_notes)} 个满足的帖子,不再提取关键词入库")
+        # 注释掉关键词提取逻辑,保持分词库稳定
+        # for note in satisfied_notes[:3]:
+        #     extract_input = f"""
+        # <帖子>
+        # 标题: {note['title']}
+        # 描述: {note['desc']}
+        # </帖子>
+        #
+        # 请提取核心关键词。
+        # """
+        #     result = await Runner.run(keyword_extractor, extract_input)
+        #     extraction: KeywordExtraction = result.final_output
+        #
+        #     # 添加新词到分词库,标记来源
+        #     note_id = note.get('note_id', 'unknown')
+        #     for keyword in extraction.keywords:
+        #         if keyword not in word_lib.words:
+        #             word_lib.add_word(keyword, source=f"note:{note_id}")
+        #             print(f"      + 新词入库: {keyword} (来源: {note_id})")
+
+    # 5. 处理部分匹配的帖子:改造query
+    if partial_notes and len(satisfied_notes) < 5:  # 如果满足的不够,基于部分匹配改进
+        print(f"\n    基于 {len(partial_notes)} 个部分匹配帖子改造query...")
+        # 收集所有缺失方面
+        all_missing = []
+        for note in partial_notes:
+            all_missing.extend(note["evaluation"]["missing_aspects"])
+
+        if all_missing:
+            improvement_input = f"""
+<当前Query>
+{query}
+</当前Query>
+
+<缺失的方面>
+{', '.join(set(all_missing))}
+</缺失的方面>
+
+请改造query使其包含这些缺失的内容。
+"""
+            result = await Runner.run(query_improver, improvement_input)
+            improvement: QueryImprovement = result.final_output
+
+            # 收集Query改造Agent的输入输出
+            improvement_agent_call = {
+                "agent": "Query改造专家",
+                "action": "基于缺失方面改造Query",
+                "input": {
+                    "query": query,
+                    "missing_aspects": list(set(all_missing))
+                },
+                "output": {
+                    "improved_query": improvement.improved_query,
+                    "added_aspects": improvement.added_aspects,
+                    "reasoning": improvement.reasoning
+                }
+            }
+            agent_calls.append(improvement_agent_call)
+
+            # 评估改进后的query
+            improved_eval = await evaluate_query_relevance(improvement.improved_query, original_need, query_state.relevance_score, context)
+
+            # 创建改进后的query state
+            new_state = QueryState(
+                query=improvement.improved_query,
+                level=query_state.level + 1,
+                relevance_score=improved_eval.relevance_score,
+                parent_query=query,
+                strategy="基于部分匹配改进"
+            )
+
+            # 添加到演化图(无论是否提升)
+            add_query_to_graph(
+                context,
+                new_state,
+                iteration,
+                evaluation_reason=improved_eval.reason,
+                is_selected=improved_eval.is_improved,
+                parent_level=query_state.level  # 父节点的层级
+            )
+
+            if improved_eval.is_improved:
+                print(f"      ✓ 改进: {improvement.improved_query} (添加: {', '.join(improvement.added_aspects[:2])})")
+                new_queries.append(new_state)
+            else:
+                print(f"      ✗ 改进: {improvement.improved_query} (分数: {improved_eval.relevance_score:.2f}, 未提升)")
+
+    # 6. Result分支的改写策略(向上抽象和同义改写)
+    # 如果搜索结果不理想且新queries不够,尝试改写当前query
+    if len(satisfied_notes) < 3 and len(new_queries) < 2:
+        print(f"\n    搜索结果不理想,尝试改写query...")
+
+        # 6.1 向上抽象
+        if len(new_queries) < 3:
+            rewrite_input_abstract = f"""
+<当前Query>
+{query}
+</当前Query>
+
+<改写要求>
+类型: abstract (向上抽象)
+</改写要求>
+
+请改写这个query。
+"""
+            result = await Runner.run(query_rewriter, rewrite_input_abstract)
+            rewrite: QueryRewrite = result.final_output
+
+            # 收集Result分支改写(抽象)Agent的输入输出
+            rewrite_agent_call = {
+                "agent": "Query改写专家",
+                "action": "向上抽象改写(Result分支)",
+                "input": {
+                    "query": query,
+                    "rewrite_type": "abstract"
+                },
+                "output": {
+                    "rewritten_query": rewrite.rewritten_query,
+                    "rewrite_type": rewrite.rewrite_type,
+                    "reasoning": rewrite.reasoning
+                }
+            }
+            agent_calls.append(rewrite_agent_call)
+
+            # 评估改写后的query
+            rewrite_eval = await evaluate_query_relevance(rewrite.rewritten_query, original_need, query_state.relevance_score, context)
+
+            # 创建改写后的query state
+            new_state = QueryState(
+                query=rewrite.rewritten_query,
+                level=query_state.level + 1,
+                relevance_score=rewrite_eval.relevance_score,
+                parent_query=query,
+                strategy="结果分支-抽象改写"
+            )
+
+            # 添加到演化图(无论是否提升)
+            add_query_to_graph(
+                context,
+                new_state,
+                iteration,
+                evaluation_reason=rewrite_eval.reason,
+                is_selected=rewrite_eval.is_improved,
+                parent_level=query_state.level  # 父节点的层级
+            )
+
+            if rewrite_eval.is_improved:
+                print(f"      ✓ 改写(抽象): {rewrite.rewritten_query} (分数: {rewrite_eval.relevance_score:.2f})")
+                new_queries.append(new_state)
+            else:
+                print(f"      ✗ 改写(抽象): {rewrite.rewritten_query} (分数: {rewrite_eval.relevance_score:.2f}, 未提升)")
+
+        # 6.2 同义改写
+        if len(new_queries) < 4:
+            rewrite_input_synonym = f"""
+<当前Query>
+{query}
+</当前Query>
+
+<改写要求>
+类型: synonym (同义改写)
+使用同义词或相关表达来改写query,保持语义相同但表达方式不同。
+</改写要求>
+
+请改写这个query。
+"""
+            result = await Runner.run(query_rewriter, rewrite_input_synonym)
+            rewrite_syn: QueryRewrite = result.final_output
+
+            # 收集Result分支改写(同义)Agent的输入输出
+            rewrite_syn_agent_call = {
+                "agent": "Query改写专家",
+                "action": "同义改写(Result分支)",
+                "input": {
+                    "query": query,
+                    "rewrite_type": "synonym"
+                },
+                "output": {
+                    "rewritten_query": rewrite_syn.rewritten_query,
+                    "rewrite_type": rewrite_syn.rewrite_type,
+                    "reasoning": rewrite_syn.reasoning
+                }
+            }
+            agent_calls.append(rewrite_syn_agent_call)
+
+            # 评估改写后的query
+            rewrite_syn_eval = await evaluate_query_relevance(rewrite_syn.rewritten_query, original_need, query_state.relevance_score, context)
+
+            # 创建改写后的query state
+            new_state = QueryState(
+                query=rewrite_syn.rewritten_query,
+                level=query_state.level + 1,
+                relevance_score=rewrite_syn_eval.relevance_score,
+                parent_query=query,
+                strategy="结果分支-同义改写"
+            )
+
+            # 添加到演化图(无论是否提升)
+            add_query_to_graph(
+                context,
+                new_state,
+                iteration,
+                evaluation_reason=rewrite_syn_eval.reason,
+                is_selected=rewrite_syn_eval.is_improved,
+                parent_level=query_state.level  # 父节点的层级
+            )
+
+            if rewrite_syn_eval.is_improved:
+                print(f"      ✓ 改写(同义): {rewrite_syn.rewritten_query} (分数: {rewrite_syn_eval.relevance_score:.2f})")
+                new_queries.append(new_state)
+            else:
+                print(f"      ✗ 改写(同义): {rewrite_syn.rewritten_query} (分数: {rewrite_syn_eval.relevance_score:.2f}, 未提升)")
+
+    # 记录完整的result分支处理结果(层级化)
+    add_step(context, f"Result分支 - {query}", "result_branch", {
+        "query": query,
+        "query_level": query_state.level,
+        "query_relevance": query_state.relevance_score,
+        "relevance_threshold": relevance_threshold,
+        "passed_threshold": query_state.relevance_score >= relevance_threshold,
+        "notes_count": len(notes) if 'notes' in locals() else 0,
+        "satisfied_count": len(satisfied_notes),
+        "partial_count": len(partial_notes),
+        "satisfied_notes": [
+            {
+                "note_id": note["note_id"],
+                "title": note["title"],
+                "score": note["evaluation"]["relevance_score"],
+                "match_level": note["evaluation"]["match_level"]
+            }
+            for note in satisfied_notes  # 保存所有满足的帖子
+        ],
+        "agent_calls": agent_calls,  # 所有Agent调用的详细记录
+        "new_queries_generated": len(new_queries),
+        "new_queries": [{"query": nq.query, "score": nq.relevance_score, "strategy": nq.strategy} for nq in new_queries]
+    })
+
+    return satisfied_notes, new_queries
+
+
+async def iterative_search_loop(
+    context: RunContext,
+    max_iterations: int = 20,
+    relevance_threshold: float = 0.6
+) -> list[dict]:
+    """
+    主循环:迭代搜索(按层级处理)
+
+    Args:
+        context: 运行上下文
+        max_iterations: 最大迭代次数(层级数)
+        relevance_threshold: 相关度门槛
+
+    Returns:
+        满足需求的帖子列表
+    """
+
+    print(f"\n{'='*60}")
+    print(f"开始迭代搜索循环")
+    print(f"{'='*60}")
+
+    # 0. 添加原始问题作为根节点
+    root_query_state = QueryState(
+        query=context.q,
+        level=0,
+        relevance_score=1.0,  # 原始问题本身相关度为1.0
+        strategy="根节点"
+    )
+    add_query_to_graph(context, root_query_state, 0, evaluation_reason="原始问题,作为搜索的根节点", is_selected=True)
+    print(f"[根节点] 原始问题: {context.q}")
+
+    # 1. 初始化分词库
+    word_lib = await initialize_word_library(context.q, context)
+
+    # 2. 初始化query队列 - 智能选择最相关的词
+    all_words = list(word_lib.words)
+    query_queue = []
+
+    print(f"\n评估所有初始分词的相关度...")
+    word_scores = []
+
+    for word in all_words:
+        # 评估每个词的相关度
+        eval_result = await evaluate_query_relevance(word, context.q, None, context)
+        word_scores.append({
+            'word': word,
+            'score': eval_result.relevance_score,
+            'eval': eval_result
+        })
+        print(f"  {word}: {eval_result.relevance_score:.2f}")
+
+    # 按相关度排序,使用所有分词
+    word_scores.sort(key=lambda x: x['score'], reverse=True)
+    selected_words = word_scores  # 使用所有分词
+
+    # 将所有分词添加到演化图(全部被选中)
+    for item in word_scores:
+        is_selected = True  # 所有分词都被选中
+        query_state = QueryState(
+            query=item['word'],
+            level=1,
+            relevance_score=item['score'],
+            strategy="初始分词",
+            parent_query=context.q  # 父节点是原始问题
+        )
+
+        # 添加到演化图(会自动创建从parent_query到该query的边)
+        add_query_to_graph(context, query_state, 0, evaluation_reason=item['eval'].reason, is_selected=is_selected, parent_level=0)  # 父节点是根节点(level 0)
+
+        # 只有被选中的才加入队列
+        if is_selected:
+            query_queue.append(query_state)
+
+    print(f"\n初始query队列(按相关度排序): {[(q.query, f'{q.relevance_score:.2f}') for q in query_queue]}")
+    print(f"  (共评估了 {len(word_scores)} 个分词,全部加入队列)")
+
+    # 3. API实例
+    xiaohongshu_api = XiaohongshuSearchRecommendations()
+    xiaohongshu_search = XiaohongshuSearch()
+
+    # 4. 主循环
+    all_satisfied_notes = []
+    iteration = 0
+
+    while query_queue and iteration < max_iterations:
+        iteration += 1
+
+        # 获取当前层级(队列中最小的level)
+        current_level = min(q.level for q in query_queue)
+
+        # 提取当前层级的所有query
+        current_batch = [q for q in query_queue if q.level == current_level]
+        query_queue = [q for q in query_queue if q.level != current_level]
+
+        print(f"\n{'='*60}")
+        print(f"迭代 {iteration}: 处理第 {current_level} 层,共 {len(current_batch)} 个query")
+        print(f"{'='*60}")
+
+        # 记录本轮处理的queries
+        add_step(context, f"迭代 {iteration}", "iteration", {
+            "iteration": iteration,
+            "current_level": current_level,
+            "current_batch_size": len(current_batch),
+            "remaining_queue_size": len(query_queue),
+            "processing_queries": [{"query": q.query, "level": q.level} for q in current_batch]
+        })
+
+        new_queries_from_sug = []
+        new_queries_from_result = []
+
+        # 处理每个query
+        for query_state in current_batch:
+            print(f"\n处理Query [{query_state.level}]: {query_state.query} (分数: {query_state.relevance_score:.2f})")
+
+            # 检查终止条件
+            if query_state.is_terminated or query_state.no_suggestion_rounds >= 2:
+                print(f"  ✗ 已终止或连续2轮无suggestion,跳过该query")
+                query_state.is_terminated = True
+                continue
+
+            # 并行处理两个分支
+            sug_task = process_suggestions(
+                query_state.query, query_state, context.q, word_lib, context, xiaohongshu_api, iteration
+            )
+            result_task = process_search_results(
+                query_state.query, query_state, context.q, word_lib, context,
+                xiaohongshu_search, relevance_threshold, iteration
+            )
+
+            # 等待两个分支完成
+            sug_queries, (satisfied_notes, result_queries) = await asyncio.gather(
+                sug_task,
+                result_task
+            )
+
+            # 如果suggestion分支返回空,说明没有获取到suggestion,需要继承no_suggestion_rounds
+            # 注意:process_suggestions内部已经更新了query_state.no_suggestion_rounds
+            # 所以这里生成的新queries需要继承父query的no_suggestion_rounds(如果sug分支也返回空)
+            if not sug_queries and not result_queries:
+                # 两个分支都没有产生新query,标记当前query为终止
+                query_state.is_terminated = True
+                print(f"  ⚠ 两个分支均未产生新query,标记该query为终止")
+
+            new_queries_from_sug.extend(sug_queries)
+            new_queries_from_result.extend(result_queries)
+            all_satisfied_notes.extend(satisfied_notes)
+
+        # 更新队列
+        all_new_queries = new_queries_from_sug + new_queries_from_result
+
+        # 注意:不需要在这里再次添加到演化图,因为在 process_suggestions 和 process_search_results 中已经添加过了
+        # 如果在这里再次调用 add_query_to_graph,会覆盖之前设置的 evaluation_reason 等字段
+
+        query_queue.extend(all_new_queries)
+
+        # 去重(基于query文本)并过滤已终止的query
+        seen = set()
+        unique_queue = []
+        for q in query_queue:
+            if q.query not in seen and not q.is_terminated:
+                seen.add(q.query)
+                unique_queue.append(q)
+        query_queue = unique_queue
+
+        # 按相关度排序
+        query_queue.sort(key=lambda x: x.relevance_score, reverse=True)
+
+        print(f"\n本轮结果:")
+        print(f"  新增满足帖子: {len(satisfied_notes)}")
+        print(f"  累计满足帖子: {len(all_satisfied_notes)}")
+        print(f"  新增queries: {len(all_new_queries)}")
+        print(f"  队列剩余: {len(query_queue)}")
+
+        # 更新分词库到context
+        context.word_library = word_lib.model_dump()
+
+        # 如果满足条件的帖子足够多,可以提前结束
+        if len(all_satisfied_notes) >= 20:
+            print(f"\n已找到足够的满足帖子 ({len(all_satisfied_notes)}个),提前结束")
+            break
+
+    print(f"\n{'='*60}")
+    print(f"迭代搜索完成")
+    print(f"  总迭代次数: {iteration}")
+    print(f"  最终满足帖子数: {len(all_satisfied_notes)}")
+    print(f"  核心词库: {list(word_lib.core_words)}")
+    print(f"  最终分词库大小: {len(word_lib.words)}")
+    print(f"{'='*60}")
+
+    # 保存最终结果
+    add_step(context, "迭代搜索完成", "loop_complete", {
+        "total_iterations": iteration,
+        "total_satisfied_notes": len(all_satisfied_notes),
+        "core_words": list(word_lib.core_words),
+        "final_word_library_size": len(word_lib.words),
+        "final_word_library": list(word_lib.words)
+    })
+
+    return all_satisfied_notes
+
+
+# ============================================================================
+# 主函数
+# ============================================================================
+
+async def main(input_dir: str, max_iterations: int = 20, 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')
+
+    q_context = read_file_as_string(input_context_file)
+    q = read_file_as_string(input_q_file)
+    q_with_context = f"""
+<需求上下文>
+{q_context}
+</需求上下文>
+<当前问题>
+{q}
+</当前问题>
+""".strip()
+
+    # 版本信息
+    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,
+        },
+        q_with_context=q_with_context,
+        q_context=q_context,
+        q=q,
+        log_dir=log_dir,
+        log_url=log_url,
+    )
+
+    # 执行迭代搜索
+    satisfied_notes = await iterative_search_loop(
+        run_context,
+        max_iterations=max_iterations,
+        relevance_threshold=0.6
+    )
+
+    # 保存结果
+    run_context.satisfied_notes = satisfied_notes
+
+    # 格式化输出
+    output = f"原始问题:{run_context.q}\n"
+    output += f"找到满足需求的帖子:{len(satisfied_notes)} 个\n"
+    output += f"核心词库:{', '.join(run_context.word_library.get('core_words', []))}\n"
+    output += f"分词库大小:{len(run_context.word_library.get('words', []))} 个词\n"
+    output += "\n" + "="*60 + "\n"
+
+    if satisfied_notes:
+        output += "【满足需求的帖子】\n\n"
+        for idx, note in enumerate(satisfied_notes, 1):
+            output += f"{idx}. {note['title']}\n"
+            output += f"   相关度: {note['evaluation']['relevance_score']:.2f}\n"
+            output += f"   URL: {note['note_url']}\n\n"
+    else:
+        output += "未找到满足需求的帖子\n"
+
+    run_context.final_output = output
+
+    print(f"\n{'='*60}")
+    print("最终结果")
+    print(f"{'='*60}")
+    print(output)
+
+    # 保存日志
+    os.makedirs(run_context.log_dir, exist_ok=True)
+
+    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}")
+
+    steps_file_path = os.path.join(run_context.log_dir, "steps.json")
+    with open(steps_file_path, "w", encoding="utf-8") as f:
+        json.dump(run_context.steps, f, ensure_ascii=False, indent=2)
+    print(f"Steps log saved to: {steps_file_path}")
+
+    # 保存Query演化图
+    query_graph_file_path = os.path.join(run_context.log_dir, "query_graph.json")
+    with open(query_graph_file_path, "w", encoding="utf-8") as f:
+        json.dump(run_context.query_graph, f, ensure_ascii=False, indent=2)
+    print(f"Query graph saved to: {query_graph_file_path}")
+
+    # 可视化
+    if visualize:
+        import subprocess
+        output_html = os.path.join(run_context.log_dir, "visualization.html")
+        print(f"\n🎨 生成可视化HTML...")
+
+        # 获取绝对路径
+        vis_script = os.path.abspath("visualization/sug_v6_1_2_6/index.js")
+        abs_query_graph = os.path.abspath(query_graph_file_path)
+        abs_output_html = os.path.abspath(output_html)
+
+        # 在可视化脚本目录中执行,确保使用本地 node_modules
+        result = subprocess.run([
+            "node", "index.js",
+            abs_query_graph,
+            abs_output_html
+        ], cwd="visualization/sug_v6_1_2_6")
+
+        if result.returncode == 0:
+            print(f"✅ 可视化已生成: {output_html}")
+        else:
+            print(f"❌ 可视化生成失败")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.5 迭代循环版")
+    parser.add_argument(
+        "--input-dir",
+        type=str,
+        default="input/简单扣图",
+        help="输入目录路径,默认: input/简单扣图"
+    )
+    parser.add_argument(
+        "--max-iterations",
+        type=int,
+        default=20,
+        help="最大迭代次数,默认: 20"
+    )
+    parser.add_argument(
+        "--visualize",
+        action="store_true",
+        default=False,
+        help="运行完成后自动生成可视化HTML"
+    )
+    parser.add_argument(
+        "--visualize-only",
+        type=str,
+        help="仅生成可视化,指定query_graph.json文件路径"
+    )
+    args = parser.parse_args()
+
+    # 如果只是生成可视化
+    if args.visualize_only:
+        import subprocess
+        query_graph_path = args.visualize_only
+        output_html = os.path.splitext(query_graph_path)[0].replace("query_graph", "visualization") + ".html"
+        if not output_html.endswith(".html"):
+            output_html = os.path.join(os.path.dirname(query_graph_path), "visualization.html")
+
+        print(f"🎨 生成可视化HTML...")
+        print(f"输入: {query_graph_path}")
+        print(f"输出: {output_html}")
+
+        # 获取绝对路径
+        abs_query_graph = os.path.abspath(query_graph_path)
+        abs_output_html = os.path.abspath(output_html)
+
+        # 在可视化脚本目录中执行,确保使用本地 node_modules
+        result = subprocess.run([
+            "node", "index.js",
+            abs_query_graph,
+            abs_output_html
+        ], cwd="visualization/sug_v6_1_2_6")
+
+        if result.returncode == 0:
+            print(f"✅ 可视化已生成: {output_html}")
+        else:
+            print(f"❌ 可视化生成失败")
+        sys.exit(result.returncode)
+
+    asyncio.run(main(args.input_dir, max_iterations=args.max_iterations, visualize=args.visualize))

+ 107 - 0
visualization/sug_v6_1_2_6/README.md

@@ -0,0 +1,107 @@
+# sug_v6_1_2_6 可视化工具
+
+这是 `sug_v6_1_2_6.py` 的配套可视化工具,用于将 `query_graph.json` 转换为交互式 HTML 可视化页面。
+
+## 📁 文件结构
+
+```
+visualization/
+└── sug_v6_1_2_6/           # 与主脚本名称对应
+    ├── index.js            # 主可视化脚本(统一命名)
+    ├── package.json        # 依赖配置文件
+    ├── node_modules/       # 所有依赖包(通过 npm install 安装)
+    │   ├── esbuild/
+    │   ├── @xyflow/
+    │   ├── react/
+    │   ├── react-dom/
+    │   ├── dagre/
+    │   └── ...
+    └── README.md           # 本文件
+```
+
+## 📦 安装依赖
+
+首次使用或克隆代码后,需要安装依赖包:
+
+```bash
+cd visualization/sug_v6_1_2_6
+npm install
+```
+
+这将安装所有必需的依赖包(约 33MB)。
+
+## ✨ 特性
+
+- **独立运行**:所有依赖都包含在文件夹内,无需外部 node_modules
+- **React Flow 可视化**:基于 @xyflow/react 的现代化图形展示
+- **交互功能**:
+  - 节点拖拽
+  - 层级筛选
+  - 节点搜索
+  - 路径高亮
+  - 目录树导航
+  - 面包屑路径
+  - 值得搜索的查询列表
+- **节点类型区分**:
+  - 🔍 查询节点(query)
+  - 📝 帖子节点(note)
+- **状态标识**:
+  - 未选中查询:红色文字
+  - 不满足的帖子(match_level: unsatisfied):红色文字
+
+## 🚀 使用方式
+
+### 方式1:通过 sug_v6_1_2_6.py 调用
+
+```bash
+# 运行主程序并自动生成可视化
+python sug_v6_1_2_6.py --input-dir "input/某目录" --visualize
+
+# 仅生成可视化(不运行主程序)
+python sug_v6_1_2_6.py --visualize-only "path/to/query_graph.json"
+```
+
+### 方式2:直接调用脚本
+
+```bash
+node visualization/sug_v6_1_2_6/index.js <query_graph.json> [output.html]
+```
+
+## 📊 输入/输出
+
+**输入**:`query_graph.json` - 包含节点和边信息的 JSON 文件
+```json
+{
+  "nodes": {
+    "node_id": {
+      "type": "query" | "note",
+      "query": "...",
+      "level": 1,
+      "relevance_score": 0.95,
+      ...
+    }
+  },
+  "edges": [
+    {
+      "from": "node_id_1",
+      "to": "node_id_2",
+      "edge_type": "..."
+    }
+  ]
+}
+```
+
+**输出**:`visualization.html` - 独立的 HTML 文件,包含所有 JavaScript 和 CSS
+
+## 📦 依赖大小
+
+- 总大小:约 33MB
+- 所有依赖已打包,无需额外安装
+
+## 🔧 技术栈
+
+- Node.js 运行环境
+- esbuild(打包工具)
+- React + React DOM
+- @xyflow/react(流程图库)
+- dagre(图布局算法)

+ 1966 - 0
visualization/sug_v6_1_2_6/index.js

@@ -0,0 +1,1966 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const { build } = require('esbuild');
+
+// 读取命令行参数
+const args = process.argv.slice(2);
+if (args.length === 0) {
+  console.error('Usage: node index.js <path-to-query_graph.json> [output.html]');
+  process.exit(1);
+}
+
+const inputFile = args[0];
+const outputFile = args[1] || 'query_graph_output.html';
+
+// 读取输入数据
+const data = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
+
+// 创建临时 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 QueryNode({ id, data, sourcePosition, targetPosition }) {
+  // 所有节点默认展开
+  const expanded = true;
+
+  return (
+    <div>
+      <Handle
+        type="target"
+        position={targetPosition || Position.Left}
+        style={{ background: '#667eea', width: 8, height: 8 }}
+      />
+      <div
+        style={{
+          padding: '12px',
+          borderRadius: '8px',
+          border: data.isHighlighted ? '3px solid #667eea' :
+                  data.isCollapsed ? '2px solid #667eea' :
+                  data.isSelected === false ? '2px dashed #d1d5db' :
+                  data.level === 0 ? '2px solid #8b5cf6' : '1px solid #e5e7eb',
+          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>
+              )}
+            </div>
+
+            {data.parent && (
+              <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
+                <strong>Parent:</strong> {data.parent}
+              </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>
+            )}
+          </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' }}>
+          <span style={{ fontSize: '16px', marginRight: '8px' }}>📝</span>
+          <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>
+        )}
+
+        {/* 标签 */}
+        <div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
+          <span style={{
+            display: 'inline-block',
+            padding: '2px 8px',
+            borderRadius: '12px',
+            background: '#fff1f2',
+            color: '#be123c',
+            fontSize: '10px',
+            fontWeight: '500',
+          }}>
+            {data.matchLevel}
+          </span>
+          <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>
+  );
+}
+
+const nodeTypes = {
+  query: QueryNode,
+  note: NoteNode,
+};
+
+// 根据 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 getStrategyColor(strategy) {
+  const strategyColors = {
+    '初始分词': '#10b981',
+    '调用sug': '#06b6d4',
+    '同义改写': '#f59e0b',
+    '加词': '#3b82f6',
+    '抽象改写': '#8b5cf6',
+    '基于部分匹配改进': '#ec4899',
+    '结果分支-抽象改写': '#a855f7',
+    '结果分支-同义改写': '#fb923c',
+  };
+  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 = node.data.strategy || '';
+  const strategyColor = getStrategyColor(strategy);
+
+  return (
+    <div style={{ marginLeft: level * 12 + 'px' }}>
+      <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',
+          }}>
+            {/* 节点类型图标 */}
+            <span style={{
+              fontSize: '12px',
+              flexShrink: 0,
+            }}>
+              {node.type === 'note' ? '📝' : '🔍'}
+            </span>
+
+            <div style={{
+              fontWeight: level === 0 ? '600' : '400',
+              maxWidth: '180px',
+              flex: 1,
+              minWidth: 0,
+              color: (node.type === 'note' ? node.data.matchLevel === 'unsatisfied' : node.data.isSelected === false) ? '#ef4444' : '#374151',
+            }}
+            title={node.data.title || node.id}
+            >
+              {truncateMiddle(node.data.title || node.id, 18)}
+            </div>
+
+            {/* 分数显示 */}
+            <span style={{
+              fontSize: '11px',
+              color: '#6b7280',
+              fontWeight: '500',
+              flexShrink: 0,
+            }}>
+              {score.toFixed(2)}
+            </span>
+          </div>
+
+          {/* 分数下划线 */}
+          <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) * 350;
+      nodeList.forEach((node, index) => {
+        node.position = { x, y: index * 150 };
+        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: 120,   // 垂直间距 - 增加以避免节点重叠
+      ranksep: 280,  // 水平间距 - 增加以容纳更宽的节点
+    });
+
+    // 添加节点 - 根据节点类型设置不同的尺寸
+    nodes.forEach((node) => {
+      let nodeWidth = 280;
+      let nodeHeight = 180;
+
+      // note 节点有轮播图,需要更大的空间
+      if (node.type === 'note') {
+        nodeWidth = 320;
+        nodeHeight = 350;  // 增加高度以容纳轮播图
+      }
+
+      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 = 280;
+      let nodeHeight = 180;
+      if (node.type === 'note') {
+        nodeWidth = 320;
+        nodeHeight = 350;
+      }
+
+      // 将 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) * 350;
+      nodeList.forEach((node, index) => {
+        node.position = { x, y: index * 150 };
+        node.targetPosition = 'left';
+        node.sourcePosition = 'right';
+      });
+    });
+
+    return { nodes, edges };
+  }
+}
+
+function transformData(data) {
+  const nodes = [];
+  const edges = [];
+
+  const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
+  const canvasIdToNodeData = {}; // 避免重复创建相同的节点
+
+  // 创建节点
+  Object.entries(data.nodes).forEach(([originalId, node]) => {
+    if (node.type === 'query') {
+      // 使用 query_level 作为唯一ID
+      const canvasId = node.query + '_' + node.level;
+      originalIdToCanvasId[originalId] = canvasId;
+
+      // 如果这个 canvasId 还没有创建过节点,则创建
+      if (!canvasIdToNodeData[canvasId]) {
+        canvasIdToNodeData[canvasId] = true;
+        nodes.push({
+          id: canvasId, // 使用 query_level 格式
+          originalId: originalId, // 保留原始ID用于调试
+          type: 'query',
+          data: {
+            title: node.query,
+            level: node.level,
+            score: node.relevance_score.toFixed(2),
+            strategy: node.strategy,
+            parent: node.parent_query,
+            isSelected: node.is_selected,
+            evaluationReason: node.evaluation_reason || '',
+          },
+          position: { x: 0, y: 0 }, // 初始位置,会被 dagre 覆盖
+        });
+      }
+    } else if (node.type === 'note') {
+      // note节点直接使用原始ID
+      originalIdToCanvasId[originalId] = originalId;
+
+      if (!canvasIdToNodeData[originalId]) {
+        canvasIdToNodeData[originalId] = true;
+        nodes.push({
+          id: originalId,
+          originalId: originalId,
+          type: 'note',
+          data: {
+            title: node.title,
+            matchLevel: node.match_level,
+            score: node.relevance_score.toFixed(2),
+            description: node.desc,
+            isSelected: node.is_selected !== undefined ? node.is_selected : true,
+            imageList: node.image_list || [], // 添加图片列表
+            noteUrl: node.note_url || '', // 添加帖子链接
+            evaluationReason: node.evaluation_reason || '', // 添加评估理由
+          },
+          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...');
+    const result = transformData(data);
+    console.log('✅ Transformed:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
+    return result;
+  }, []);
+
+  // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
+  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
+
+  // 获取 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: 120,   // 垂直间距 - 增加以避免节点重叠
+          ranksep: 280,  // 水平间距 - 增加以容纳更宽的节点
+        });
+
+        visibleNodes.forEach((node) => {
+          let nodeWidth = 280;
+          let nodeHeight = 180;
+
+          // note 节点有轮播图,需要更大的空间
+          if (node.type === 'note') {
+            nodeWidth = 320;
+            nodeHeight = 350;
+          }
+
+          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 = 280;
+            let nodeHeight = 180;
+            if (node.type === 'note') {
+              nodeWidth = 320;
+              nodeHeight = 350;
+            }
+
+            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();
+    initialNodes.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();
+
+    initialEdges.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();
+    initialEdges.forEach(edge => {
+      hasParent.add(edge.target);
+    });
+
+    const roots = [];
+    initialNodes.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;
+  }, [initialNodes, initialEdges]);
+
+  const treeRoots = useMemo(() => buildTree(), [buildTree]);
+
+  // 初始化树节点折叠状态
+  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]);
+
+  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={() => {
+            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;
+            });
+
+            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]);
+
+  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 = node.data.strategy || '';
+                          const strategyColor = getStrategyColor(nodeStrategy);
+                          const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
+
+                          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={{
+                                  fontSize: '11px',
+                                  flexShrink: 0,
+                                }}>
+                                  {node.type === 'note' ? '📝' : '🔍'}
+                                </span>
+
+                                {/* 节点文字 */}
+                                <span style={{
+                                  flex: 1,
+                                  fontSize: '12px',
+                                  color: nodeIsSelected ? '#374151' : '#ef4444',
+                                }}>
+                                  {truncateMiddle(node.data.title || node.id, 18)}
+                                </span>
+
+                                {/* 分数显示 */}
+                                <span style={{
+                                  fontSize: '10px',
+                                  color: '#6b7280',
+                                  fontWeight: '500',
+                                  flexShrink: 0,
+                                }}>
+                                  {nodeScore.toFixed(2)}
+                                </span>
+                              </div>
+
+                              {/* 分数下划线 */}
+                              <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' }}>
+        {/* 左侧目录树 */}
+        <div style={{
+          width: '320px',
+          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>
+            </div>
+          </div>
+          <div style={{
+            flex: 1,
+            overflowX: 'auto',
+            overflowY: 'auto',
+            padding: '8px',
+          }}>
+            <div style={{ minWidth: 'fit-content' }}>
+              {renderTree(treeRoots)}
+            </div>
+          </div>
+        </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);
+});

+ 25 - 0
visualization/sug_v6_1_2_6/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "sug-v6-1-2-6-visualization",
+  "version": "1.0.0",
+  "description": "可视化工具 for sug_v6_1_2_6.py - React Flow based query graph visualization",
+  "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"
+  },
+  "keywords": [
+    "visualization",
+    "react-flow",
+    "query-graph",
+    "dagre"
+  ],
+  "author": "",
+  "license": "ISC"
+}