yangxiaohui 1 mês atrás
pai
commit
bac961e7b2
2 arquivos alterados com 1708 adições e 81 exclusões
  1. 544 81
      sug_v6_1_2_5.py
  2. 1164 0
      visualize_v2.js

+ 544 - 81
sug_v6_1_2_5.py

@@ -29,21 +29,26 @@ class QueryState(BaseModel):
     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")
 
-    def add_word(self, word: str):
+    def add_word(self, word: str, source: str = "unknown"):
         """添加单词到分词库"""
         if word and word.strip():
-            self.words.add(word.strip())
+            word = word.strip()
+            self.words.add(word)
+            if word not in self.word_sources:
+                self.word_sources[word] = source
 
-    def add_words(self, words: list[str]):
+    def add_words(self, words: list[str], source: str = "unknown"):
         """批量添加单词"""
         for word in words:
-            self.add_word(word)
+            self.add_word(word, source)
 
     def get_unused_word(self, current_query: str) -> str | None:
         """获取一个当前query中没有的词"""
@@ -54,7 +59,10 @@ class WordLibrary(BaseModel):
 
     def model_dump(self):
         """序列化为dict"""
-        return {"words": list(self.words)}
+        return {
+            "words": list(self.words),
+            "word_sources": self.word_sources
+        }
 
 
 class RunContext(BaseModel):
@@ -72,6 +80,9 @@ class RunContext(BaseModel):
     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
@@ -309,6 +320,93 @@ def add_step(context: RunContext, step_name: str, step_type: str, data: dict):
     return step
 
 
+def add_query_to_graph(context: RunContext, query_state: QueryState, iteration: int, evaluation_reason: str = "", is_selected: bool = True):
+    """添加Query节点到演化图
+
+    Args:
+        context: 运行上下文
+        query_state: Query状态
+        iteration: 迭代次数
+        evaluation_reason: 评估原因(可选)
+        is_selected: 是否被选中进入处理队列(默认True)
+    """
+    query_id = query_state.query  # 直接使用query作为ID
+
+    # 初始化图结构
+    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:
+        parent_id = query_state.parent_query
+        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, note: dict):
+    """添加Note节点到演化图,并连接到对应的Query"""
+    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
+    if query in context.query_graph["nodes"]:
+        context.query_graph["edges"].append({
+            "from": query,
+            "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", {})
@@ -349,7 +447,7 @@ async def initialize_word_library(original_query: str, context: RunContext) -> W
     segmentation: WordSegmentation = result.final_output
 
     word_lib = WordLibrary()
-    word_lib.add_words(segmentation.words)
+    word_lib.add_words(segmentation.words, source="initial")
 
     print(f"初始分词库: {list(word_lib.words)}")
     print(f"分词理由: {segmentation.reasoning}")
@@ -358,9 +456,15 @@ async def initialize_word_library(original_query: str, context: RunContext) -> W
     context.word_library = word_lib.model_dump()
 
     add_step(context, "初始化分词库", "word_library_init", {
-        "original_query": original_query,
-        "words": list(word_lib.words),
-        "reasoning": segmentation.reasoning
+        "agent": "分词专家",
+        "input": original_query,
+        "output": {
+            "words": segmentation.words,
+            "reasoning": segmentation.reasoning
+        },
+        "result": {
+            "word_library": list(word_lib.words)
+        }
     })
 
     return word_lib
@@ -400,12 +504,16 @@ async def process_suggestions(
     original_need: str,
     word_lib: WordLibrary,
     context: RunContext,
-    xiaohongshu_api: XiaohongshuSearchRecommendations
+    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)
 
@@ -432,7 +540,8 @@ async def process_suggestions(
     suggestion_evaluations = []
 
     for sug in suggestions[:5]:  # 限制处理数量
-        # 评估sug的相关度
+        # 评估sug与原始需求的相关度(注意:这里是与原始需求original_need对比,而非当前query)
+        # 这样可以确保生成的suggestion始终围绕用户的核心需求
         sug_eval = await evaluate_query_relevance(sug, original_need, query_state.relevance_score, context)
 
         sug_eval_record = {
@@ -443,25 +552,37 @@ async def process_suggestions(
         }
         suggestion_evaluations.append(sug_eval_record)
 
-        # 判断是否比当前query更好
-        if sug_eval.is_improved and sug_eval.relevance_score > query_state.relevance_score:
+        # 创建query state(所有suggestion都作为query节点)
+        sug_state = QueryState(
+            query=sug,
+            level=query_state.level + 1,
+            relevance_score=sug_eval.relevance_score,
+            parent_query=query,
+            strategy="direct_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
+        )
+
+        if is_selected:
             print(f"      ✓ {sug} (分数: {sug_eval.relevance_score:.2f}, 提升: {sug_eval.is_improved})")
-
-            # 创建新的query state(直接使用suggestion)
-            new_state = QueryState(
-                query=sug,
-                level=query_state.level + 1,
-                relevance_score=sug_eval.relevance_score,
-                parent_query=query,
-                strategy="direct_sug"
-            )
-            new_queries.append(new_state)
+            new_queries.append(sug_state)
         else:
             print(f"      ✗ {sug} (分数: {sug_eval.relevance_score:.2f}, 未提升)")
 
     # 3. 改写策略(向上抽象或同义改写)
     if len(new_queries) < 3:  # 如果直接使用sug的数量不够,尝试改写
-        rewrite_input = f"""
+        # 尝试向上抽象
+        rewrite_input_abstract = f"""
 <当前Query>
 {query}
 </当前Query>
@@ -472,22 +593,111 @@ async def process_suggestions(
 
 请改写这个query。
 """
-        result = await Runner.run(query_rewriter, rewrite_input)
+        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="rewrite_abstract"
+        )
+
+        # 添加到演化图(无论是否提升)
+        add_query_to_graph(
+            context,
+            new_state,
+            iteration,
+            evaluation_reason=rewrite_eval.reason,
+            is_selected=rewrite_eval.is_improved
+        )
+
         if rewrite_eval.is_improved:
-            print(f"      ✓ 改写: {rewrite.rewritten_query} (分数: {rewrite_eval.relevance_score:.2f})")
-            new_state = QueryState(
-                query=rewrite.rewritten_query,
-                level=query_state.level + 1,
-                relevance_score=rewrite_eval.relevance_score,
-                parent_query=query,
-                strategy="rewrite"
-            )
+            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="rewrite_synonym"
+        )
+
+        # 添加到演化图(无论是否提升)
+        add_query_to_graph(
+            context,
+            new_state,
+            iteration,
+            evaluation_reason=rewrite_syn_eval.reason,
+            is_selected=rewrite_syn_eval.is_improved
+        )
+
+        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)
@@ -506,21 +716,50 @@ async def process_suggestions(
         result = await Runner.run(word_inserter, insertion_input)
         insertion: WordInsertion = result.final_output
 
+        # 收集加词Agent的输入输出
+        insertion_agent_call = {
+            "agent": "加词位置评估专家",
+            "action": "加词",
+            "input": {
+                "query": query,
+                "word_to_add": unused_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_word"
+        )
+
+        # 添加到演化图(无论是否提升)
+        add_query_to_graph(
+            context,
+            new_state,
+            iteration,
+            evaluation_reason=insertion_eval.reason,
+            is_selected=insertion_eval.is_improved
+        )
+
         if insertion_eval.is_improved:
             print(f"      ✓ 加词: {insertion.new_query} (分数: {insertion_eval.relevance_score:.2f})")
-            new_state = QueryState(
-                query=insertion.new_query,
-                level=query_state.level + 1,
-                relevance_score=insertion_eval.relevance_score,
-                parent_query=query,
-                strategy="add_word"
-            )
             new_queries.append(new_state)
+        else:
+            print(f"      ✗ 加词: {insertion.new_query} (分数: {insertion_eval.relevance_score:.2f}, 未提升)")
 
-    # 记录完整的suggestion分支处理结果
+    # 记录完整的suggestion分支处理结果(层级化)
     add_step(context, f"Suggestion分支 - {query}", "suggestion_branch", {
         "query": query,
         "query_level": query_state.level,
@@ -528,6 +767,7 @@ async def process_suggestions(
         "suggestions_count": len(suggestions),
         "suggestions_evaluated": len(suggestion_evaluations),
         "suggestion_evaluations": suggestion_evaluations[:10],  # 只保存前10个
+        "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
@@ -543,7 +783,8 @@ async def process_search_results(
     word_lib: WordLibrary,
     context: RunContext,
     xiaohongshu_search: XiaohongshuSearch,
-    relevance_threshold: float = 0.6
+    relevance_threshold: float,
+    iteration: int
 ) -> tuple[list[dict], list[QueryState]]:
     """
     处理搜索结果分支
@@ -552,6 +793,9 @@ async def process_search_results(
 
     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},跳过搜索")
@@ -607,6 +851,24 @@ async def process_search_results(
         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[:200] if len(desc) > 200 else 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,
@@ -614,35 +876,42 @@ async def process_search_results(
             "reason": evaluation.reason
         }
 
+        # 将所有评估过的帖子添加到演化图(包括satisfied、partial、unsatisfied)
+        add_note_to_graph(context, query, 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. 处理满足的帖子:提取关键词并扩充分词库
+    # 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
-
-            # 添加新词到分词库
-            for keyword in extraction.keywords:
-                if keyword not in word_lib.words:
-                    word_lib.add_word(keyword)
-                    print(f"      + 新词入库: {keyword}")
+        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:  # 如果满足的不够,基于部分匹配改进
@@ -667,21 +936,174 @@ async def process_search_results(
             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[:5]))
+                },
+                "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="improve_from_partial"
+            )
+
+            # 添加到演化图(无论是否提升)
+            add_query_to_graph(
+                context,
+                new_state,
+                iteration,
+                evaluation_reason=improved_eval.reason,
+                is_selected=improved_eval.is_improved
+            )
+
             if improved_eval.is_improved:
                 print(f"      ✓ 改进: {improvement.improved_query} (添加: {', '.join(improvement.added_aspects[:2])})")
-                new_state = QueryState(
-                    query=improvement.improved_query,
-                    level=query_state.level + 1,
-                    relevance_score=improved_eval.relevance_score,
-                    parent_query=query,
-                    strategy="improve_from_partial"
-                )
                 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="result_rewrite_abstract"
+            )
+
+            # 添加到演化图(无论是否提升)
+            add_query_to_graph(
+                context,
+                new_state,
+                iteration,
+                evaluation_reason=rewrite_eval.reason,
+                is_selected=rewrite_eval.is_improved
+            )
+
+            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}, 未提升)")
 
-    # 记录完整的result分支处理结果
+        # 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="result_rewrite_synonym"
+            )
+
+            # 添加到演化图(无论是否提升)
+            add_query_to_graph(
+                context,
+                new_state,
+                iteration,
+                evaluation_reason=rewrite_syn_eval.reason,
+                is_selected=rewrite_syn_eval.is_improved
+            )
+
+            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,
@@ -700,9 +1122,9 @@ async def process_search_results(
             }
             for note in satisfied_notes[:10]  # 只保存前10个
         ],
+        "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],
-        "word_library_expanded": len(new_queries) > 0  # 是否扩充了分词库
+        "new_queries": [{"query": nq.query, "score": nq.relevance_score, "strategy": nq.strategy} for nq in new_queries]
     })
 
     return satisfied_notes, new_queries
@@ -731,6 +1153,16 @@ async def iterative_search_loop(
     print(f"开始迭代搜索循环")
     print(f"{'='*60}")
 
+    # 0. 添加原始问题作为根节点
+    root_query_state = QueryState(
+        query=context.q,
+        level=0,
+        relevance_score=1.0,  # 原始问题本身相关度为1.0
+        strategy="root"
+    )
+    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)
 
@@ -755,15 +1187,26 @@ async def iterative_search_loop(
     word_scores.sort(key=lambda x: x['score'], reverse=True)
     selected_words = word_scores[:3]
 
-    for item in selected_words:
-        query_queue.append(QueryState(
+    # 将所有分词添加到演化图(包括未被选中的)
+    for item in word_scores:
+        is_selected = item in selected_words
+        query_state = QueryState(
             query=item['word'],
             level=1,
             relevance_score=item['score'],
-            strategy="initial"
-        ))
+            strategy="initial",
+            parent_query=context.q  # 父节点是原始问题
+        )
+
+        # 添加到演化图(会自动创建从parent_query到该query的边)
+        add_query_to_graph(context, query_state, 0, evaluation_reason=item['eval'].reason, is_selected=is_selected)
+
+        # 只有被选中的才加入队列
+        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)} 个分词,选择了前 {len(query_queue)} 个)")
 
     # 3. API实例
     xiaohongshu_api = XiaohongshuSearchRecommendations()
@@ -798,17 +1241,18 @@ async def iterative_search_loop(
             print(f"\n处理Query [{query_state.level}]: {query_state.query} (分数: {query_state.relevance_score:.2f})")
 
             # 检查终止条件
-            if query_state.no_suggestion_rounds >= 2:
-                print(f"  ✗ 连续2轮无suggestion,终止该分支")
+            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
+                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
+                xiaohongshu_search, relevance_threshold, iteration
             )
 
             # 等待两个分支完成
@@ -817,19 +1261,32 @@ async def iterative_search_loop(
                 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
+
+        # 将新生成的queries添加到演化图
+        for new_q in all_new_queries:
+            add_query_to_graph(context, new_q, iteration)
+
         query_queue.extend(all_new_queries)
 
-        # 去重(基于query文本)
+        # 去重(基于query文本)并过滤已终止的query
         seen = set()
         unique_queue = []
         for q in query_queue:
-            if q.query not in seen:
+            if q.query not in seen and not q.is_terminated:
                 seen.add(q.query)
                 unique_queue.append(q)
         query_queue = unique_queue
@@ -961,6 +1418,12 @@ async def main(input_dir: str, max_iterations: int = 20, visualize: bool = False
         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

+ 1164 - 0
visualize_v2.js

@@ -0,0 +1,1164 @@
+#!/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 visualize_v2.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' :
+                      data.level === 0 ? 'linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%)' : '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,
+        }}
+      >
+        {/* 折叠/展开子节点按钮 */}
+        {data.hasChildren && (
+          <div
+            style={{
+              position: 'absolute',
+              top: '6px',
+              right: '6px',
+              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', paddingRight: data.hasChildren ? '24px' : '0' }}>
+            <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.4',
+                maxHeight: '60px',
+                overflow: 'auto',
+              }}>
+                <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 expanded = true;
+
+  return (
+    <div>
+      <Handle
+        type="target"
+        position={targetPosition || Position.Left}
+        style={{ background: '#ec4899', width: 8, height: 8 }}
+      />
+      <div
+        style={{
+          padding: '14px',
+          borderRadius: '12px',
+          border: data.isHighlighted ? '3px solid #ec4899' : '2px solid #fce7f3',
+          background: data.isHighlighted ? '#fef1f7' : 'linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%)',
+          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>
+
+        {/* 标签 */}
+        <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',
+            maxHeight: '100px',
+            overflow: 'auto',
+          }}>
+            {data.description}
+          </div>
+        )}
+      </div>
+      <Handle
+        type="source"
+        position={sourcePosition || Position.Right}
+        style={{ background: '#ec4899', width: 8, height: 8 }}
+      />
+    </div>
+  );
+}
+
+const nodeTypes = {
+  query: QueryNode,
+  note: NoteNode,
+};
+
+// 树节点组件
+function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
+  const hasChildren = children && children.length > 0;
+
+  return (
+    <div style={{ marginLeft: level * 12 + 'px' }}>
+      <div
+        style={{
+          padding: '6px 8px',
+          borderRadius: '4px',
+          cursor: 'pointer',
+          background: isSelected ? '#eff6ff' : 'transparent',
+          borderLeft: isSelected ? '3px solid #3b82f6' : '3px solid transparent',
+          display: 'flex',
+          alignItems: 'center',
+          gap: '6px',
+          transition: 'all 0.2s ease',
+        }}
+        onMouseEnter={(e) => {
+          if (!isSelected) e.currentTarget.style.background = '#f9fafb';
+        }}
+        onMouseLeave={(e) => {
+          if (!isSelected) e.currentTarget.style.background = 'transparent';
+        }}
+      >
+        {hasChildren && (
+          <span
+            style={{
+              fontSize: '10px',
+              color: '#6b7280',
+              cursor: 'pointer',
+              width: '16px',
+              textAlign: 'center',
+            }}
+            onClick={(e) => {
+              e.stopPropagation();
+              onToggle();
+            }}
+          >
+            {isCollapsed ? '▶' : '▼'}
+          </span>
+        )}
+        {!hasChildren && <span style={{ width: '16px' }}></span>}
+
+        <div
+          style={{ flex: 1, fontSize: '12px', color: '#374151' }}
+          onClick={onSelect}
+        >
+          <div style={{
+            fontWeight: level === 0 ? '600' : '400',
+            overflow: 'hidden',
+            textOverflow: 'ellipsis',
+            whiteSpace: 'nowrap',
+            fontFamily: 'monospace',
+          }}>
+            {node.id}
+          </div>
+          {node.data.isSelected === false && (
+            <span style={{
+              fontSize: '9px',
+              color: '#ef4444',
+              marginLeft: '4px',
+            }}>
+              ✕
+            </span>
+          )}
+        </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 nodeWidth = 280;
+    const nodeHeight = 180;
+
+    const isHorizontal = direction === 'LR';
+    dagreGraph.setGraph({
+      rankdir: direction,
+      nodesep: 80,
+      ranksep: 300,
+    });
+
+    // 添加节点
+    nodes.forEach((node) => {
+      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';
+
+      // 将 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 = [];
+
+  // 创建节点
+  Object.entries(data.nodes).forEach(([id, node]) => {
+    if (node.type === 'query') {
+      nodes.push({
+        id: id,  // 使用循环中的 id 参数,不是 node.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') {
+      nodes.push({
+        id: id,  // 使用循环中的 id 参数,不是 node.id
+        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,
+        },
+        position: { x: 0, y: 0 },
+      });
+    }
+  });
+
+  // 创建边 - 使用虚线样式
+  data.edges.forEach((edge, index) => {
+    const edgeColors = {
+      direct_sug: '#10b981',
+      rewrite_synonym: '#f59e0b',
+      add_word: '#3b82f6',
+      rewrite_abstract: '#8b5cf6',
+      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: edge.from,
+      target: edge.to,
+      type: 'smoothstep',
+      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);
+
+  // 获取 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 hiddenNodes = new Set();
+
+    // 收集所有被折叠节点的后代
+    collapsedNodes.forEach(collapsedId => {
+      const descendants = getDescendants(collapsedId);
+      descendants.forEach(id => hiddenNodes.add(id));
+    });
+
+    const visibleNodes = initialNodes
+      .filter(node => !hiddenNodes.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),
+          isHighlighted: selectedNodeId === node.id,
+        }
+      }));
+
+    const visibleEdges = initialEdges.filter(
+      edge => !hiddenNodes.has(edge.source) && !hiddenNodes.has(edge.target)
+    );
+
+    // 重新计算布局 - 只对可见节点
+    if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
+      try {
+        const dagreGraph = new window.dagre.graphlib.Graph();
+        dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+        const nodeWidth = 280;
+        const nodeHeight = 180;
+
+        dagreGraph.setGraph({
+          rankdir: 'LR',
+          nodesep: 80,
+          ranksep: 300,
+        });
+
+        visibleNodes.forEach((node) => {
+          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) {
+            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, 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;
+            setSelectedNodeId(nodeId);
+
+            // 展开所有祖先节点
+            const ancestorIds = [];
+            const findAncestors = (id) => {
+              initialEdges.forEach(edge => {
+                if (edge.target === id) {
+                  ancestorIds.push(edge.source);
+                  findAncestors(edge.source);
+                }
+              });
+            };
+            findAncestors(nodeId);
+
+            setCollapsedNodes(prev => {
+              const newSet = new Set(prev);
+              ancestorIds.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' }}>
+      {/* 顶部标题栏 */}
+      <div style={{
+        position: 'absolute',
+        top: 0,
+        left: 0,
+        right: 0,
+        height: '60px',
+        background: 'white',
+        borderBottom: '1px solid #e5e7eb',
+        display: 'flex',
+        alignItems: 'center',
+        padding: '0 24px',
+        zIndex: 1000,
+        boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
+      }}>
+        <h1 style={{ fontSize: '18px', fontWeight: '600', color: '#111827', margin: 0 }}>
+          查询图可视化
+        </h1>
+        <div style={{ marginLeft: 'auto', display: 'flex', gap: '12px', fontSize: '13px', color: '#6b7280' }}>
+          <span>📊 {nodes.length} 节点</span>
+          <span>🔗 {edges.length} 连线</span>
+        </div>
+      </div>
+
+      {/* 左侧目录树 */}
+      <div style={{
+        position: 'absolute',
+        top: '80px',
+        left: '20px',
+        bottom: '20px',
+        width: '280px',
+        background: 'white',
+        borderRadius: '12px',
+        boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
+        zIndex: 1000,
+        border: '1px solid #e5e7eb',
+        overflow: 'hidden',
+        display: 'flex',
+        flexDirection: 'column',
+      }}>
+        <div style={{
+          padding: '16px',
+          borderBottom: '1px solid #e5e7eb',
+          fontWeight: '600',
+          fontSize: '14px',
+          color: '#111827',
+        }}>
+          节点目录
+        </div>
+        <div style={{
+          flex: 1,
+          overflow: 'auto',
+          padding: '8px',
+        }}>
+          {renderTree(treeRoots)}
+        </div>
+      </div>
+
+      {/* 右侧图例 */}
+      <div style={{
+        position: 'absolute',
+        top: '80px',
+        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',
+      }}>
+        <h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', color: '#111827' }}>图例</h3>
+
+        <div style={{ fontSize: '12px' }}>
+          <div style={{ marginBottom: '12px' }}>
+            <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点类型</div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '16px', height: '16px', borderRadius: '4px', marginRight: '8px', background: 'white', border: '2px solid #e5e7eb' }}></div>
+              <span style={{ color: '#6b7280' }}>查询节点</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '16px', height: '16px', borderRadius: '4px', marginRight: '8px', background: 'linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%)', border: '2px solid #fce7f3' }}></div>
+              <span style={{ color: '#6b7280' }}>笔记节点</span>
+            </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' }}>direct_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' }}>rewrite_synonym</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' }}>add_word</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' }}>rewrite_abstract</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' }}>query_to_note</span>
+            </div>
+          </div>
+
+          <div style={{
+            marginTop: '12px',
+            paddingTop: '12px',
+            borderTop: '1px solid #f3f4f6',
+            fontSize: '11px',
+            color: '#9ca3af',
+            lineHeight: '1.5',
+          }}>
+            💡 点击节点选中并高亮
+          </div>
+        </div>
+      </div>
+
+      {/* React Flow 画布 */}
+      <div style={{
+        position: 'absolute',
+        top: '60px',
+        left: 0,
+        right: 0,
+        bottom: 0,
+      }}>
+        <ReactFlow
+          nodes={nodes}
+          edges={edges}
+          nodeTypes={nodeTypes}
+          fitView
+          fitViewOptions={{ padding: 0.2, duration: 500 }}
+          minZoom={0.1}
+          maxZoom={1.5}
+          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>
+  );
+}
+
+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',
+}).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);
+});