瀏覽代碼

Add sug_v6_1_2_8 visualization with tree copy and score fixes

- Fix Q node score display for Round 1+ (use input_q_list instead of q_list_1)
- Add tree structure copy feature with formatted text output
- Improve score lookup logic to check multiple data sources
- Add type safety for score.toFixed() to prevent errors
- Display scores for all nodes except step/round nodes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 4 周之前
父節點
當前提交
5ceed3bf7e

+ 1213 - 0
sug_v6_1_2_7.py

@@ -0,0 +1,1213 @@
+import asyncio
+import json
+import os
+import sys
+import argparse
+import time
+import re
+from datetime import datetime
+from typing import Literal, TypeVar, Type
+
+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 Seg(BaseModel):
+    """分词结果"""
+    text: str
+    score_with_o: float
+    from_o: str
+
+
+class Word(BaseModel):
+    """词库中的词"""
+    text: str
+    score_with_o: float
+    from_o: str
+
+
+class Q(BaseModel):
+    """查询"""
+    text: str
+    score_with_o: float
+    from_source: str  # "seg" | "sug" | "add"
+
+
+class Sug(BaseModel):
+    """建议查询"""
+    text: str
+    score_with_o: float
+    from_q: dict  # {"text": str, "score_with_o": float}
+    evaluation_reason: str | None = None  # 评估理由
+
+
+class Seed(BaseModel):
+    """种子查询(用于加词探索)"""
+    text: str
+    added_words: list[str] = Field(default_factory=list)
+    from_type: str  # "seg" | "sug"
+
+
+class Post(BaseModel):
+    """帖子"""
+    note_id: str = ""
+    title: str = ""
+    body_text: str = ""
+    type: str = "normal"  # "video" | "normal"
+    images: list[str] = Field(default_factory=list)
+    video: str = ""
+    interact_info: dict = Field(default_factory=dict)
+    note_url: str = ""
+
+
+class Search(BaseModel):
+    """搜索结果(继承自Sug)"""
+    text: str
+    score_with_o: float
+    from_q: dict
+    post_list: list[Post] = Field(default_factory=list)
+
+
+class RunContext(BaseModel):
+    """运行上下文"""
+    version: str
+    input_files: dict[str, str]
+    c: str  # 原始需求(context)
+    o: str  # 原始问题
+    log_url: str
+    log_dir: str
+
+    # 核心数据
+    seg_list: list[dict] = Field(default_factory=list)
+    word_lists: dict[int, list[dict]] = Field(default_factory=dict)  # {round: word_list}
+    q_lists: dict[int, list[dict]] = Field(default_factory=dict)  # {round: q_list}
+    sug_list_lists: dict[int, list[list[dict]]] = Field(default_factory=dict)  # {round: [[sug, sug], [sug]]}
+    search_lists: dict[int, list[dict]] = Field(default_factory=dict)  # {round: search_list}
+    seed_lists: dict[int, list[dict]] = Field(default_factory=dict)  # {round: seed_list}
+
+    steps: list[dict] = Field(default_factory=list)
+
+    # 新增:详细的操作记录(中文命名,但数据结构保留英文)
+    轮次记录: dict[int, dict] = Field(default_factory=dict)
+
+    # 最终结果
+    all_posts: list[dict] = Field(default_factory=list)
+    final_output: str | None = None
+
+
+# ============================================================================
+# 辅助函数:记录操作
+# ============================================================================
+
+def init_round_record(run_context: RunContext, round_num: int, round_name: str):
+    """初始化一个轮次记录"""
+    run_context.轮次记录[round_num] = {
+        "轮次": round_num,
+        "名称": round_name,
+        "操作列表": []
+    }
+
+
+def add_operation_record(
+    run_context: RunContext,
+    round_num: int,
+    操作名称: str,
+    输入: dict,
+    处理过程: dict,
+    输出: dict
+):
+    """添加一条操作记录"""
+    from datetime import datetime
+
+    operation = {
+        "操作名称": 操作名称,
+        "轮次": round_num,
+        "时间": datetime.now().isoformat(),
+        "输入": 输入,
+        "处理过程": 处理过程,
+        "输出": 输出
+    }
+
+    if round_num not in run_context.轮次记录:
+        init_round_record(run_context, round_num, f"第{round_num}轮" if round_num > 0 else "初始化阶段")
+
+    run_context.轮次记录[round_num]["操作列表"].append(operation)
+
+
+def record_agent_call(
+    agent_name: str,
+    model: str,
+    instructions: str,
+    user_message: str,
+    raw_output: dict | str,
+    parsed: bool,
+    validation_error: str | None = None,
+    input_schema: dict | None = None
+) -> dict:
+    """记录单次Agent调用"""
+    return {
+        "Agent名称": agent_name,
+        "模型": model,
+        "系统提示词": instructions,
+        "输入Schema": input_schema,
+        "用户消息": user_message,
+        "原始输出": raw_output,
+        "解析成功": parsed,
+        "验证错误": validation_error
+    }
+
+
+# ============================================================================
+# JSON后处理:处理markdown包裹的JSON响应
+# ============================================================================
+
+def clean_json_response(text: str) -> str:
+    """清理可能包含markdown代码块包裹的JSON
+
+    模型可能返回:
+    ```json
+    {"key": "value"}
+    ```
+
+    需要清理为:
+    {"key": "value"}
+    """
+    text = text.strip()
+
+    # 移除开头的 ```json 或 ```
+    if text.startswith('```json'):
+        text = text[7:]
+    elif text.startswith('```'):
+        text = text[3:]
+
+    # 移除结尾的 ```
+    if text.endswith('```'):
+        text = text[:-3]
+
+    return text.strip()
+
+
+T = TypeVar('T', bound=BaseModel)
+
+async def run_agent_with_json_cleanup(
+    agent: Agent,
+    input_text: str,
+    output_type: Type[T]
+) -> T:
+    """运行Agent并处理可能的JSON包裹问题
+
+    如果Agent返回被markdown包裹的JSON,自动清理后重新解析
+    """
+    try:
+        result = await Runner.run(agent, input_text)
+        return result.final_output
+    except Exception as e:
+        error_msg = str(e)
+
+        # 检查是否是JSON解析错误
+        if "Invalid JSON when parsing" in error_msg:
+            # 尝试从错误消息中提取JSON
+            # 错误格式: "Invalid JSON when parsing ```json\n{...}\n``` for TypeAdapter(...)"
+            match = re.search(r'when parsing (.+?) for TypeAdapter', error_msg, re.DOTALL)
+            if match:
+                json_text = match.group(1)
+                cleaned_json = clean_json_response(json_text)
+                try:
+                    # 手动解析JSON并创建Pydantic对象
+                    parsed_data = json.loads(cleaned_json)
+                    return output_type(**parsed_data)
+                except Exception as parse_error:
+                    print(f"⚠️  JSON清理后仍无法解析: {parse_error}")
+                    print(f"   清理后的JSON: {cleaned_json}")
+                    raise ValueError(f"无法解析JSON: {parse_error}\n原始错误: {error_msg}")
+
+        # 如果不是JSON解析错误,或清理失败,重新抛出原始错误
+        raise
+
+
+# ============================================================================
+# Agent 定义
+# ============================================================================
+
+# Agent 1: 分词专家
+class WordSegmentation(BaseModel):
+    """分词结果"""
+    words: list[str] = Field(..., description="分词结果列表")
+    reasoning: str = Field(..., description="分词理由")
+
+word_segmentation_instructions = """
+你是分词专家。给定一个query,将其拆分成有意义的最小单元。
+
+## 分词原则
+1. 保留有搜索意义的词汇
+2. 拆分成独立的概念
+3. 保留专业术语的完整性
+4. 去除虚词(的、吗、呢等)
+
+## 输出要求
+返回分词列表和分词理由。
+
+IMPORTANT: 直接返回纯JSON对象,不要使用markdown代码块标记(不要用```json...```包裹)。
+""".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")
+    reason: str = Field(..., description="评估理由")
+
+relevance_evaluation_instructions = """
+你是Query相关度评估专家。
+
+## 任务
+评估当前query与原始问题的匹配程度。
+
+## 评估标准
+- 主题相关性
+- 要素覆盖度
+- 意图匹配度
+
+## 输出
+- relevance_score: 0-1的相关性分数
+- reason: 详细理由
+
+IMPORTANT: 直接返回纯JSON对象,不要使用markdown代码块标记(不要用```json...```包裹)。
+""".strip()
+
+relevance_evaluator = Agent[None](
+    name="Query相关度评估专家",
+    instructions=relevance_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=RelevanceEvaluation,
+)
+
+
+# Agent 3: Word选择专家
+class WordSelection(BaseModel):
+    """Word选择结果"""
+    selected_word: str = Field(..., description="选中的词")
+    reasoning: str = Field(..., description="选择理由")
+
+word_selection_instructions = """
+你是Word选择专家。
+
+## 任务
+从候选词列表中选择一个最适合与当前seed组合的词,用于探索新的搜索query。
+
+## 选择原则
+1. 与seed的语义相关性
+2. 组合后的搜索价值
+3. 能拓展搜索范围
+
+## 输出
+返回选中的词和选择理由。
+""".strip()
+
+word_selector = Agent[None](
+    name="Word选择专家",
+    instructions=word_selection_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=WordSelection,
+)
+
+
+# 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,
+)
+
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+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 process_note_data(note: dict) -> Post:
+    """处理搜索接口返回的帖子数据,转换为Post对象"""
+    note_card = note.get("note_card", {})
+    image_list = note_card.get("image_list", [])
+    interact_info = note_card.get("interact_info", {})
+
+    # 提取图片URLs - 使用 image_url 字段
+    images = []
+    for img in image_list:
+        if "image_url" in img:
+            images.append(img["image_url"])
+
+    # 判断是否是视频
+    note_type = note_card.get("type", "normal")
+    video_url = ""
+    if note_type == "video":
+        # 视频类型可能有不同的结构,这里先留空
+        # 如果需要可以后续补充
+        pass
+
+    return Post(
+        note_id=note.get("id") or "",
+        title=note_card.get("display_title") or "",
+        body_text=note_card.get("desc") or "",
+        type=note_type,
+        images=images,
+        video=video_url,
+        interact_info={
+            "liked_count": interact_info.get("liked_count", 0),
+            "collected_count": interact_info.get("collected_count", 0),
+            "comment_count": interact_info.get("comment_count", 0),
+            "shared_count": interact_info.get("shared_count", 0)
+        },
+        note_url=f"https://www.xiaohongshu.com/explore/{note.get('id') or ''}"
+    )
+
+
+# ============================================================================
+# 核心流程函数
+# ============================================================================
+
+async def evaluate_query_with_o(query_text: str, original_o: str) -> tuple[float, str]:
+    """评估query与原始问题o的相关度
+
+    Returns:
+        (score, reason)
+    """
+    eval_input = f"""
+<原始问题>
+{original_o}
+</原始问题>
+
+<当前Query>
+{query_text}
+</当前Query>
+
+请评估当前query与原始问题的相关度。
+"""
+
+    evaluation = await run_agent_with_json_cleanup(
+        relevance_evaluator,
+        eval_input,
+        RelevanceEvaluation
+    )
+
+    return evaluation.relevance_score, evaluation.reason
+
+
+async def initialize(context: RunContext):
+    """初始化:分词 → seg_list → word_list_1, q_list_1, seed_list_1"""
+
+    print("\n" + "="*60)
+    print("初始化阶段")
+    print("="*60)
+
+    # 初始化轮次0
+    init_round_record(context, 0, "初始化阶段")
+
+    # 1. 分词
+    print(f"\n[1/4] 分词原始问题: {context.o}")
+    segmentation = await run_agent_with_json_cleanup(
+        word_segmenter,
+        context.o,
+        WordSegmentation
+    )
+
+    print(f"  分词结果: {segmentation.words}")
+    print(f"  分词理由: {segmentation.reasoning}")
+
+    # 2. 分词评估(并发)
+    print(f"\n[2/4] 评估每个seg与原始问题的相关度...")
+    seg_list = []
+    agent_calls_seg_eval = []
+
+    # 并发评估所有分词
+    eval_tasks = [evaluate_query_with_o(word, context.o) for word in segmentation.words]
+    eval_results = await asyncio.gather(*eval_tasks)
+
+    for word, (score, reason) in zip(segmentation.words, eval_results):
+        seg = Seg(text=word, score_with_o=score, from_o=context.o)
+        seg_list.append(seg.model_dump())
+        print(f"  {word}: {score:.2f}")
+
+        # 记录每个seg的评估
+        agent_calls_seg_eval.append(
+            record_agent_call(
+                agent_name="Query相关度评估专家",
+                model=MODEL_NAME,
+                instructions=relevance_evaluation_instructions,
+                user_message=f"评估query与原始问题的相关度:\n\nQuery: {word}\n原始问题: {context.o}",
+                raw_output={"score": score, "reason": reason},
+                parsed=True
+            )
+        )
+
+    context.seg_list = seg_list
+
+    # 记录分词操作
+    add_operation_record(
+        context,
+        round_num=0,
+        操作名称="分词",
+        输入={"原始问题": context.o},
+        处理过程={
+            "Agent调用": record_agent_call(
+                agent_name="分词专家",
+                model=MODEL_NAME,
+                instructions=word_segmentation_instructions,
+                user_message=f"请对以下query进行分词:{context.o}",
+                raw_output={"words": segmentation.words, "reasoning": segmentation.reasoning},
+                parsed=True,
+                input_schema={"type": "WordSegmentation", "fields": {"words": "list[str]", "reasoning": "str"}}
+            ),
+            "seg评估Agent调用列表": agent_calls_seg_eval
+        },
+        输出={"seg_list": seg_list}
+    )
+
+    # 3. 构建 word_list_1(直接从seg_list复制)
+    print(f"\n[3/4] 构建 word_list_1...")
+    word_list_1 = []
+    for seg in seg_list:
+        word = Word(text=seg["text"], score_with_o=seg["score_with_o"], from_o=seg["from_o"])
+        word_list_1.append(word.model_dump())
+
+    context.word_lists[1] = word_list_1
+    print(f"  word_list_1 大小: {len(word_list_1)}")
+
+    # 4. 构建 q_list_1 和 seed_list_1
+    print(f"\n[4/4] 构建 q_list_1 和 seed_list_1...")
+    q_list_1 = []
+    seed_list_1 = []
+
+    for seg in seg_list:
+        # q_list_1: seg作为q
+        q = Q(text=seg["text"], score_with_o=seg["score_with_o"], from_source="seg")
+        q_list_1.append(q.model_dump())
+
+        # seed_list_1: seg作为seed
+        seed = Seed(text=seg["text"], added_words=[], from_type="seg")
+        seed_list_1.append(seed.model_dump())
+
+    context.q_lists[1] = q_list_1
+    context.seed_lists[1] = seed_list_1
+
+    print(f"  q_list_1 大小: {len(q_list_1)}")
+    print(f"  seed_list_1 大小: {len(seed_list_1)}")
+
+    # 记录初始化操作
+    add_operation_record(
+        context,
+        round_num=0,
+        操作名称="初始化",
+        输入={"seg_list": seg_list},
+        处理过程={"说明": "从seg_list构建初始q_list和seed_list"},
+        输出={
+            "word_list_1": word_list_1,
+            "q_list_1": q_list_1,
+            "seed_list_1": seed_list_1
+        }
+    )
+
+    add_step(context, "初始化完成", "initialize", {
+        "seg_count": len(seg_list),
+        "word_list_1_count": len(word_list_1),
+        "q_list_1_count": len(q_list_1),
+        "seed_list_1_count": len(seed_list_1)
+    })
+
+
+async def process_round(round_num: int, context: RunContext, xiaohongshu_api: XiaohongshuSearchRecommendations, xiaohongshu_search: XiaohongshuSearch, sug_threshold: float = 0.7):
+    """处理一轮迭代
+
+    Args:
+        round_num: 当前轮数
+        context: 运行上下文
+        xiaohongshu_api: sug API
+        xiaohongshu_search: search API
+        sug_threshold: sug评分阈值(默认0.7)
+    """
+
+    print(f"\n" + "="*60)
+    print(f"第 {round_num} 轮")
+    print("="*60)
+
+    # 初始化轮次记录
+    init_round_record(context, round_num, f"第{round_num}轮迭代")
+
+    q_list_n = context.q_lists.get(round_num, [])
+    if not q_list_n:
+        print(f"  q_list_{round_num} 为空,跳过本轮")
+        return
+
+    print(f"  处理 {len(q_list_n)} 个query")
+
+    # 1. 请求sug
+    print(f"\n[1/5] 请求sug...")
+    sug_list_list_n = []
+    api_calls_detail = []
+
+    for q_data in q_list_n:
+        q_text = q_data["text"]
+        suggestions = xiaohongshu_api.get_recommendations(keyword=q_text)
+
+        if not suggestions:
+            print(f"  {q_text}: 无sug")
+            sug_list_list_n.append([])
+            api_calls_detail.append({
+                "query": q_text,
+                "sug_count": 0
+            })
+            continue
+
+        print(f"  {q_text}: 获取 {len(suggestions)} 个sug")
+        sug_list_list_n.append(suggestions)
+        api_calls_detail.append({
+            "query": q_text,
+            "sug_count": len(suggestions)
+        })
+
+    # 记录请求sug操作
+    total_sugs = sum(len(sl) for sl in sug_list_list_n)
+    add_operation_record(
+        context,
+        round_num=round_num,
+        操作名称="请求推荐词",
+        输入={"q_list": [{"text": q["text"], "score": q["score_with_o"]} for q in q_list_n]},
+        处理过程={"API调用": api_calls_detail},
+        输出={
+            "sug_list_list": [[{"text": s, "from_q": q_list_n[i]["text"]} for s in sl] for i, sl in enumerate(sug_list_list_n)],
+            "总推荐词数": total_sugs
+        }
+    )
+
+    # 2. sug评估(批量并发,限制并发数为10)
+    print(f"\n[2/5] 评估sug...")
+    sug_list_list_evaluated = []
+
+    # 收集所有需要评估的sug及其上下文
+    all_sug_tasks = []
+    sug_contexts = []  # 记录每个sug对应的q_data和位置
+
+    for i, sug_list in enumerate(sug_list_list_n):
+        q_data = q_list_n[i]
+        for sug_text in sug_list:
+            all_sug_tasks.append(evaluate_query_with_o(sug_text, context.o))
+            sug_contexts.append((i, q_data, sug_text))
+
+    # 批量并发评估(每批10个)
+    batch_size = 10
+    all_results = []
+    batches_detail = []
+
+    for batch_idx in range(0, len(all_sug_tasks), batch_size):
+        batch_tasks = all_sug_tasks[batch_idx:batch_idx+batch_size]
+        batch_results = await asyncio.gather(*batch_tasks)
+        all_results.extend(batch_results)
+
+        # 记录这个批次的Agent调用
+        batch_agent_calls = []
+        start_idx = batch_idx
+        for j, (score, reason) in enumerate(batch_results):
+            if start_idx + j < len(sug_contexts):
+                _, _, sug_text = sug_contexts[start_idx + j]
+                batch_agent_calls.append(
+                    record_agent_call(
+                        agent_name="Query相关度评估专家",
+                        model=MODEL_NAME,
+                        instructions=relevance_evaluation_instructions,
+                        user_message=f"评估query与原始问题的相关度:\n\nQuery: {sug_text}\n原始问题: {context.o}",
+                        raw_output={"score": score, "reason": reason},
+                        parsed=True
+                    )
+                )
+
+        batches_detail.append({
+            "批次ID": len(batches_detail),
+            "并发执行": True,
+            "Agent调用列表": batch_agent_calls
+        })
+
+    # 组织结果
+    result_index = 0
+    current_list_index = -1
+    evaluated_sugs = []
+
+    for list_idx, q_data, sug_text in sug_contexts:
+        if list_idx != current_list_index:
+            if evaluated_sugs:
+                sug_list_list_evaluated.append(evaluated_sugs)
+            evaluated_sugs = []
+            current_list_index = list_idx
+
+        score, reason = all_results[result_index]
+        result_index += 1
+
+        sug = Sug(
+            text=sug_text,
+            score_with_o=score,
+            from_q={"text": q_data["text"], "score_with_o": q_data["score_with_o"]},
+            evaluation_reason=reason
+        )
+        evaluated_sugs.append(sug.model_dump())
+        print(f"    {sug_text}: {score:.2f}")
+
+    # 添加最后一批
+    if evaluated_sugs:
+        sug_list_list_evaluated.append(evaluated_sugs)
+
+    context.sug_list_lists[round_num] = sug_list_list_evaluated
+
+    # 记录评估sug操作
+    add_operation_record(
+        context,
+        round_num=round_num,
+        操作名称="评估推荐词",
+        输入={
+            "待评估推荐词": [[s for s in sl] for sl in sug_list_list_n],
+            "总数": len(all_sug_tasks)
+        },
+        处理过程={"批次列表": batches_detail},
+        输出={"已评估推荐词": sug_list_list_evaluated}
+    )
+
+    # 3. 构建search_list_n(阈值>= 0.7的sug)
+    print(f"\n[3/5] 构建search_list并执行搜索...")
+    search_list_n = []
+    filter_comparisons = []
+    search_details = []
+
+    for sug_list_evaluated in sug_list_list_evaluated:
+        for sug_data in sug_list_evaluated:
+            # 记录筛选比较
+            passed = sug_data["score_with_o"] >= sug_threshold
+            filter_comparisons.append({
+                "文本": sug_data["text"],
+                "分数": sug_data["score_with_o"],
+                "阈值": sug_threshold,
+                "通过": passed
+            })
+
+            if passed:
+                print(f"  搜索: {sug_data['text']} (分数: {sug_data['score_with_o']:.2f})")
+
+                try:
+                    # 执行搜索
+                    search_result = xiaohongshu_search.search(keyword=sug_data["text"])
+                    result_str = search_result.get("result", "{}")
+                    if isinstance(result_str, str):
+                        result_data = json.loads(result_str)
+                    else:
+                        result_data = result_str
+
+                    notes = result_data.get("data", {}).get("data", [])
+                    print(f"    → 搜索到 {len(notes)} 个帖子")
+
+                    # 转换为Post对象
+                    post_list = []
+                    for note in notes:
+                        post = process_note_data(note)
+                        post_list.append(post.model_dump())
+                        context.all_posts.append(post.model_dump())
+
+                    # 创建Search对象
+                    search = Search(
+                        text=sug_data["text"],
+                        score_with_o=sug_data["score_with_o"],
+                        from_q=sug_data["from_q"],
+                        post_list=post_list
+                    )
+                    search_list_n.append(search.model_dump())
+
+                    # 记录搜索详情
+                    search_details.append({
+                        "查询": sug_data["text"],
+                        "分数": sug_data["score_with_o"],
+                        "成功": True,
+                        "帖子数量": len(post_list),
+                        "错误": None
+                    })
+
+                except Exception as e:
+                    print(f"    ✗ 搜索失败: {e}")
+                    search_details.append({
+                        "查询": sug_data["text"],
+                        "分数": sug_data["score_with_o"],
+                        "成功": False,
+                        "帖子数量": 0,
+                        "错误": str(e)
+                    })
+
+    context.search_lists[round_num] = search_list_n
+    print(f"  本轮搜索到 {len(search_list_n)} 个有效结果")
+
+    # 记录构建search和执行搜索操作(合并为一个操作)
+    total_posts = sum(len(s["post_list"]) for s in search_list_n)
+    add_operation_record(
+        context,
+        round_num=round_num,
+        操作名称="筛选并执行搜索",
+        输入={"已评估推荐词": sug_list_list_evaluated},
+        处理过程={
+            "筛选条件": f"分数 >= {sug_threshold}",
+            "筛选比较": filter_comparisons,
+            "搜索详情": search_details
+        },
+        输出={
+            "search_list": search_list_n,
+            "成功搜索数": len(search_list_n),
+            "总帖子数": total_posts
+        }
+    )
+
+    # 4. 构建word_list_(n+1)(先直接复制)
+    print(f"\n[4/5] 构建word_list_{round_num+1}...")
+    word_list_n = context.word_lists.get(round_num, [])
+    word_list_next = word_list_n.copy()
+    context.word_lists[round_num + 1] = word_list_next
+    print(f"  word_list_{round_num+1} 大小: {len(word_list_next)}")
+
+    # 5. 构建q_list_(n+1)和更新seed_list
+    print(f"\n[5/5] 构建q_list_{round_num+1}和更新seed_list...")
+    q_list_next = []
+    seed_list_n = context.seed_lists.get(round_num, [])
+    seed_list_next = seed_list_n.copy()
+
+    # 5.1 从seed加词(串行处理,避免重复)
+    print(f"  [5.1] 从seed加词生成新q(串行处理,去重)...")
+
+    add_word_attempts = []  # 记录所有尝试
+    new_queries_from_add = []
+    generated_query_texts = set()  # 记录已生成的查询文本
+
+    for seed_data in seed_list_n:
+        seed_text = seed_data["text"]
+        added_words = seed_data["added_words"]
+
+        # 过滤出未使用的词
+        candidate_words = []
+        for word_data in word_list_next:
+            word_text = word_data["text"]
+            # 简单字符串过滤
+            if word_text not in seed_text and word_text not in added_words:
+                candidate_words.append(word_data)
+
+        if not candidate_words:
+            print(f"    {seed_text}: 无可用词")
+            continue
+
+        attempt = {
+            "种子": {"text": seed_text, "已添加词": added_words},
+            "候选词": [w["text"] for w in candidate_words[:10]]
+        }
+
+        # 使用agent选择词(提供已生成的查询列表)
+        already_generated_str = ""
+        if generated_query_texts:
+            already_generated_str = f"""
+<已生成的查询>
+{', '.join(sorted(generated_query_texts))}
+</已生成的查询>
+
+注意:请避免生成与上述已存在的查询重复或过于相似的新查询。
+"""
+
+        selection_input = f"""
+<当前Seed>
+{seed_text}
+</当前Seed>
+
+<候选词列表>
+{', '.join([w['text'] for w in candidate_words[:10]])}
+</候选词列表>
+{already_generated_str}
+请从候选词中选择一个最适合与seed组合的词。
+"""
+        selection = await run_agent_with_json_cleanup(
+            word_selector,
+            selection_input,
+            WordSelection
+        )
+        selected_word = selection.selected_word
+
+        # 确保选中的词在候选列表中
+        if selected_word not in [w["text"] for w in candidate_words]:
+            # 如果agent选择的词不在候选列表中,使用第一个候选词
+            selected_word = candidate_words[0]["text"]
+
+        # 记录选词
+        attempt["步骤1_选词"] = record_agent_call(
+            agent_name="Word选择专家",
+            model=MODEL_NAME,
+            instructions=word_selection_instructions,
+            user_message=selection_input,
+            raw_output={"selected_word": selection.selected_word, "reasoning": selection.reasoning},
+            parsed=True,
+            input_schema={"type": "WordSelection", "fields": {"selected_word": "str", "reasoning": "str"}}
+        )
+
+        # 使用加词agent
+        insertion_input = f"""
+<当前Query>
+{seed_text}
+</当前Query>
+
+<要添加的词>
+{selected_word}
+</要添加的词>
+
+请将这个词加到query的最合适位置。
+"""
+        insertion = await run_agent_with_json_cleanup(
+            word_inserter,
+            insertion_input,
+            WordInsertion
+        )
+        new_query_text = insertion.new_query
+
+        # 记录插入位置
+        attempt["步骤2_插入位置"] = record_agent_call(
+            agent_name="加词位置评估专家",
+            model=MODEL_NAME,
+            instructions=word_insertion_instructions,
+            user_message=insertion_input,
+            raw_output={"new_query": insertion.new_query, "reasoning": insertion.reasoning},
+            parsed=True,
+            input_schema={"type": "WordInsertion", "fields": {"new_query": "str", "reasoning": "str"}}
+        )
+
+        # 检查是否重复
+        if new_query_text in generated_query_texts:
+            print(f"    {seed_text} + {selected_word} → {new_query_text} (重复,跳过)")
+            attempt["跳过原因"] = "查询重复"
+            add_word_attempts.append(attempt)
+            continue
+
+        # 立即评估新query
+        score, reason = await evaluate_query_with_o(new_query_text, context.o)
+
+        # 记录评估
+        attempt["步骤3_评估新查询"] = record_agent_call(
+            agent_name="Query相关度评估专家",
+            model=MODEL_NAME,
+            instructions=relevance_evaluation_instructions,
+            user_message=f"评估新query的相关度:\n\nQuery: {new_query_text}\n原始问题: {context.o}",
+            raw_output={"score": score, "reason": reason},
+            parsed=True
+        )
+        add_word_attempts.append(attempt)
+
+        # 创建新q并加入列表
+        new_q = Q(text=new_query_text, score_with_o=score, from_source="add")
+        q_list_next.append(new_q.model_dump())
+        new_queries_from_add.append(new_q.model_dump())
+        generated_query_texts.add(new_query_text)
+
+        # 更新seed的added_words
+        for seed in seed_list_next:
+            if seed["text"] == seed_text:
+                seed["added_words"].append(selected_word)
+                break
+
+        print(f"    {seed_text} + {selected_word} → {new_query_text} (分数: {score:.2f})")
+
+    # 记录加词操作
+    add_operation_record(
+        context,
+        round_num=round_num,
+        操作名称="加词生成新查询",
+        输入={
+            "seed_list": seed_list_n,
+            "word_list": word_list_next
+        },
+        处理过程={"尝试列表": add_word_attempts},
+        输出={"新查询列表": new_queries_from_add}
+    )
+
+    # 5.2 从sug加入q_list(条件:sug分数 > from_q分数)
+    print(f"  [5.2] 从sug加入q_list_{round_num+1}(条件:sug分数 > from_q分数)...")
+    sug_added_count = 0
+    sug_filter_comparisons = []
+    selected_sugs = []
+
+    for sug_list_evaluated in sug_list_list_evaluated:
+        for sug_data in sug_list_evaluated:
+            # 新条件:sug的分数 > 其来源query的分数
+            from_q_score = sug_data["from_q"]["score_with_o"]
+            passed = sug_data["score_with_o"] > from_q_score
+
+            sug_filter_comparisons.append({
+                "推荐词": sug_data["text"],
+                "推荐词分数": sug_data["score_with_o"],
+                "来源查询分数": from_q_score,
+                "通过": passed,
+                "原因": f"{sug_data['score_with_o']:.2f} > {from_q_score:.2f}" if passed else f"{sug_data['score_with_o']:.2f} <= {from_q_score:.2f}"
+            })
+
+            if passed:
+                # 检查是否已存在
+                if sug_data["text"] not in [q["text"] for q in q_list_next]:
+                    new_q = Q(text=sug_data["text"], score_with_o=sug_data["score_with_o"], from_source="sug")
+                    q_list_next.append(new_q.model_dump())
+                    selected_sugs.append(new_q.model_dump())
+                    sug_added_count += 1
+                    print(f"    ✓ {sug_data['text']} ({sug_data['score_with_o']:.2f} > {from_q_score:.2f})")
+
+    print(f"    添加 {sug_added_count} 个sug到q_list_{round_num+1}")
+
+    # 记录筛选sug操作
+    add_operation_record(
+        context,
+        round_num=round_num,
+        操作名称="筛选推荐词进入下轮",
+        输入={"已评估推荐词": sug_list_list_evaluated},
+        处理过程={
+            "筛选条件": "推荐词分数 > 来源查询分数",
+            "比较结果": sug_filter_comparisons
+        },
+        输出={"选中推荐词": selected_sugs}
+    )
+
+    # 5.3 更新seed_list(从sug中添加新seed,条件:sug分数 > from_q分数)
+    print(f"  [5.3] 更新seed_list_{round_num+1}(条件:sug分数 > from_q分数)...")
+    seed_texts_existing = [s["text"] for s in seed_list_next]
+    new_seed_count = 0
+
+    for sug_list_evaluated in sug_list_list_evaluated:
+        for sug_data in sug_list_evaluated:
+            from_q_score = sug_data["from_q"]["score_with_o"]
+            # 新条件:sug的分数 > 其来源query的分数
+            if sug_data["score_with_o"] > from_q_score and sug_data["text"] not in seed_texts_existing:
+                new_seed = Seed(text=sug_data["text"], added_words=[], from_type="sug")
+                seed_list_next.append(new_seed.model_dump())
+                seed_texts_existing.append(sug_data["text"])
+                new_seed_count += 1
+
+    print(f"    添加 {new_seed_count} 个sug到seed_list_{round_num+1}")
+
+    context.q_lists[round_num + 1] = q_list_next
+    context.seed_lists[round_num + 1] = seed_list_next
+
+    print(f"\n  q_list_{round_num+1} 大小: {len(q_list_next)}")
+    print(f"  seed_list_{round_num+1} 大小: {len(seed_list_next)}")
+
+    # 记录构建下一轮操作
+    add_operation_record(
+        context,
+        round_num=round_num,
+        操作名称="构建下一轮",
+        输入={
+            "加词新查询": new_queries_from_add,
+            "选中推荐词": selected_sugs
+        },
+        处理过程={
+            "合并": {
+                "来自加词": len(new_queries_from_add),
+                "来自推荐词": len(selected_sugs),
+                "合并前总数": len(new_queries_from_add) + len(selected_sugs)
+            },
+            "去重": {
+                "唯一数": len(q_list_next)
+            }
+        },
+        输出={
+            "下轮查询列表": q_list_next,
+            "下轮种子列表": seed_list_next
+        }
+    )
+
+    add_step(context, f"第{round_num}轮完成", "round", {
+        "round": round_num,
+        "q_list_count": len(q_list_n),
+        "sug_total_count": sum(len(s) for s in sug_list_list_evaluated),
+        "search_count": len(search_list_n),
+        "posts_found": sum(len(s["post_list"]) for s in search_list_n),
+        "q_list_next_count": len(q_list_next),
+        "seed_list_next_count": len(seed_list_next)
+    })
+
+
+async def main_loop(context: RunContext, max_rounds: int = 2):
+    """主循环
+
+    Args:
+        context: 运行上下文
+        max_rounds: 最大轮数(默认2)
+    """
+
+    print("\n" + "="*60)
+    print("开始主循环")
+    print("="*60)
+
+    # 初始化
+    await initialize(context)
+
+    # API实例
+    xiaohongshu_api = XiaohongshuSearchRecommendations()
+    xiaohongshu_search = XiaohongshuSearch()
+
+    # 迭代
+    for round_num in range(1, max_rounds + 1):
+        await process_round(round_num, context, xiaohongshu_api, xiaohongshu_search)
+
+        # 检查终止条件
+        q_list_next = context.q_lists.get(round_num + 1, [])
+        if not q_list_next:
+            print(f"\n  q_list_{round_num + 1} 为空,提前结束")
+            break
+
+    print("\n" + "="*60)
+    print("主循环完成")
+    print("="*60)
+    print(f"  总共收集 {len(context.all_posts)} 个帖子")
+
+
+# ============================================================================
+# 主函数
+# ============================================================================
+
+async def main(input_dir: str, max_rounds: int = 2, visualize: bool = False):
+    """主函数"""
+    current_time, log_url = set_trace()
+
+    # 读取输入
+    input_context_file = os.path.join(input_dir, 'context.md')
+    input_q_file = os.path.join(input_dir, 'q.md')
+
+    c = read_file_as_string(input_context_file)
+    o = read_file_as_string(input_q_file)
+
+    # 版本信息
+    version = os.path.basename(__file__)
+    version_name = os.path.splitext(version)[0]
+
+    # 日志目录
+    log_dir = os.path.join(input_dir, "output", version_name, current_time)
+
+    # 创建运行上下文
+    run_context = RunContext(
+        version=version,
+        input_files={
+            "input_dir": input_dir,
+            "context_file": input_context_file,
+            "q_file": input_q_file,
+        },
+        c=c,
+        o=o,
+        log_dir=log_dir,
+        log_url=log_url,
+    )
+
+    # 执行主循环
+    await main_loop(run_context, max_rounds=max_rounds)
+
+    # 格式化输出
+    output = f"原始需求:{run_context.c}\n"
+    output += f"原始问题:{run_context.o}\n"
+    output += f"收集帖子:{len(run_context.all_posts)} 个\n"
+    output += "\n" + "="*60 + "\n"
+
+    if run_context.all_posts:
+        output += "【收集到的帖子】\n\n"
+        for idx, post in enumerate(run_context.all_posts[:20], 1):  # 只显示前20个
+            output += f"{idx}. {post['title']}\n"
+            output += f"   类型: {post['type']}\n"
+            output += f"   URL: {post['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}")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.7 基于seed的迭代版")
+    parser.add_argument(
+        "--input-dir",
+        type=str,
+        default="input/简单扣图",
+        help="输入目录路径,默认: input/简单扣图"
+    )
+    parser.add_argument(
+        "--max-rounds",
+        type=int,
+        default=2,
+        help="最大轮数,默认: 2"
+    )
+    parser.add_argument(
+        "--visualize",
+        action="store_true",
+        default=True,
+        help="运行完成后自动生成可视化HTML"
+    )
+    args = parser.parse_args()
+
+    asyncio.run(main(args.input_dir, max_rounds=args.max_rounds, visualize=args.visualize))

+ 828 - 0
sug_v6_1_2_8.py

@@ -0,0 +1,828 @@
+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 Seg(BaseModel):
+    """分词"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    reason: str = ""  # 评分理由
+    from_o: str = ""  # 原始问题
+
+
+class Word(BaseModel):
+    """词"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    from_o: str = ""  # 原始问题
+
+
+class QFromQ(BaseModel):
+    """Q来源信息(用于Sug中记录)"""
+    text: str
+    score_with_o: float = 0.0
+
+
+class Q(BaseModel):
+    """查询"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    reason: str = ""  # 评分理由
+    from_source: str = ""  # seg/sug/add(加词)
+
+
+class Sug(BaseModel):
+    """建议词"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    reason: str = ""  # 评分理由
+    from_q: QFromQ | None = None  # 来自的q
+
+
+class Seed(BaseModel):
+    """种子"""
+    text: str
+    added_words: list[str] = Field(default_factory=list)  # 已经增加的words
+    from_type: str = ""  # seg/sug
+    score_with_o: float = 0.0  # 与原始问题的评分
+
+
+class Post(BaseModel):
+    """帖子"""
+    title: str = ""
+    body_text: str = ""
+    type: str = "normal"  # video/normal
+    images: list[str] = Field(default_factory=list)  # 图片url列表,第一张为封面
+    video: str = ""  # 视频url
+    interact_info: dict = Field(default_factory=dict)  # 互动信息
+    note_id: str = ""
+    note_url: str = ""
+
+
+class Search(Sug):
+    """搜索结果(继承Sug)"""
+    post_list: list[Post] = Field(default_factory=list)  # 搜索得到的帖子列表
+
+
+class RunContext(BaseModel):
+    """运行上下文"""
+    version: str
+    input_files: dict[str, str]
+    c: str  # 原始需求
+    o: str  # 原始问题
+    log_url: str
+    log_dir: str
+
+    # 每轮的数据
+    rounds: 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: 相关度评估专家
+class RelevanceEvaluation(BaseModel):
+    """相关度评估"""
+    relevance_score: float = Field(..., description="相关性分数 0-1")
+    reason: str = Field(..., description="评估理由")
+
+relevance_evaluation_instructions = """
+你是相关度评估专家。
+
+## 任务
+评估当前文本与原始问题的匹配程度。
+
+## 评估标准
+- 主题相关性
+- 要素覆盖度
+- 意图匹配度
+
+## 输出
+- relevance_score: 0-1的相关性分数
+- reason: 详细理由
+""".strip()
+
+relevance_evaluator = Agent[None](
+    name="相关度评估专家",
+    instructions=relevance_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=RelevanceEvaluation,
+)
+
+
+# Agent 3: 加词选择专家
+class WordSelection(BaseModel):
+    """加词选择结果"""
+    selected_word: str = Field(..., description="选择的词")
+    combined_query: str = Field(..., description="组合后的新query")
+    reasoning: str = Field(..., description="选择理由")
+
+word_selection_instructions = """
+你是加词选择专家。
+
+## 任务
+从候选词列表中选择一个最合适的词,与当前seed组合成新的query。
+
+## 原则
+1. 选择与当前seed最相关的词
+2. 组合后的query要语义通顺
+3. 符合搜索习惯
+4. 优先选择能扩展搜索范围的词
+
+## 输出
+- selected_word: 选中的词
+- combined_query: 组合后的新query
+- reasoning: 选择理由
+""".strip()
+
+word_selector = Agent[None](
+    name="加词选择专家",
+    instructions=word_selection_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=WordSelection,
+)
+
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+def process_note_data(note: dict) -> Post:
+    """处理搜索接口返回的帖子数据"""
+    note_card = note.get("note_card", {})
+    image_list = note_card.get("image_list", [])
+    interact_info = note_card.get("interact_info", {})
+    user_info = note_card.get("user", {})
+
+    # 提取图片URL
+    images = []
+    for img in image_list:
+        if isinstance(img, dict) and "url_default" in img:
+            images.append(img["url_default"])
+
+    # 判断类型
+    note_type = note_card.get("type", "normal")
+    video_url = ""
+    if note_type == "video":
+        video_info = note_card.get("video", {})
+        if isinstance(video_info, dict):
+            # 尝试获取视频URL
+            video_url = video_info.get("media", {}).get("stream", {}).get("h264", [{}])[0].get("master_url", "")
+
+    return Post(
+        note_id=note.get("id", ""),
+        title=note_card.get("display_title", ""),
+        body_text=note_card.get("desc", ""),
+        type=note_type,
+        images=images,
+        video=video_url,
+        interact_info={
+            "liked_count": interact_info.get("liked_count", 0),
+            "collected_count": interact_info.get("collected_count", 0),
+            "comment_count": interact_info.get("comment_count", 0),
+            "shared_count": interact_info.get("shared_count", 0)
+        },
+        note_url=f"https://www.xiaohongshu.com/explore/{note.get('id', '')}"
+    )
+
+
+async def evaluate_with_o(text: str, o: str) -> tuple[float, str]:
+    """评估文本与原始问题o的相关度
+
+    Returns:
+        tuple[float, str]: (相关度分数, 评估理由)
+    """
+    eval_input = f"""
+<原始问题>
+{o}
+</原始问题>
+
+<当前文本>
+{text}
+</当前文本>
+
+请评估当前文本与原始问题的相关度。
+"""
+    result = await Runner.run(relevance_evaluator, eval_input)
+    evaluation: RelevanceEvaluation = result.final_output
+    return evaluation.relevance_score, evaluation.reason
+
+
+# ============================================================================
+# 核心流程函数
+# ============================================================================
+
+async def initialize(o: str, context: RunContext) -> tuple[list[Seg], list[Word], list[Q], list[Seed]]:
+    """
+    初始化阶段
+
+    Returns:
+        (seg_list, word_list_1, q_list_1, seed_list)
+    """
+    print(f"\n{'='*60}")
+    print(f"初始化阶段")
+    print(f"{'='*60}")
+
+    # 1. 分词:原始问题(o) ->分词-> seg_list
+    print(f"\n[步骤1] 分词...")
+    result = await Runner.run(word_segmenter, o)
+    segmentation: WordSegmentation = result.final_output
+
+    seg_list = []
+    for word in segmentation.words:
+        seg_list.append(Seg(text=word, from_o=o))
+
+    print(f"分词结果: {[s.text for s in seg_list]}")
+    print(f"分词理由: {segmentation.reasoning}")
+
+    # 2. 分词评估:seg_list -> 每个seg与o进行评分(并发)
+    print(f"\n[步骤2] 评估每个分词与原始问题的相关度...")
+
+    async def evaluate_seg(seg: Seg) -> Seg:
+        seg.score_with_o, seg.reason = await evaluate_with_o(seg.text, o)
+        return seg
+
+    if seg_list:
+        eval_tasks = [evaluate_seg(seg) for seg in seg_list]
+        await asyncio.gather(*eval_tasks)
+
+    for seg in seg_list:
+        print(f"  {seg.text}: {seg.score_with_o:.2f}")
+
+    # 3. 构建word_list_1: seg_list -> word_list_1
+    print(f"\n[步骤3] 构建word_list_1...")
+    word_list_1 = []
+    for seg in seg_list:
+        word_list_1.append(Word(
+            text=seg.text,
+            score_with_o=seg.score_with_o,
+            from_o=o
+        ))
+    print(f"word_list_1: {[w.text for w in word_list_1]}")
+
+    # 4. 构建q_list_1:seg_list 作为 q_list_1
+    print(f"\n[步骤4] 构建q_list_1...")
+    q_list_1 = []
+    for seg in seg_list:
+        q_list_1.append(Q(
+            text=seg.text,
+            score_with_o=seg.score_with_o,
+            reason=seg.reason,
+            from_source="seg"
+        ))
+    print(f"q_list_1: {[q.text for q in q_list_1]}")
+
+    # 5. 构建seed_list: seg_list -> seed_list
+    print(f"\n[步骤5] 构建seed_list...")
+    seed_list = []
+    for seg in seg_list:
+        seed_list.append(Seed(
+            text=seg.text,
+            added_words=[],
+            from_type="seg",
+            score_with_o=seg.score_with_o
+        ))
+    print(f"seed_list: {[s.text for s in seed_list]}")
+
+    return seg_list, word_list_1, q_list_1, seed_list
+
+
+async def run_round(
+    round_num: int,
+    q_list: list[Q],
+    word_list: list[Word],
+    seed_list: list[Seed],
+    o: str,
+    context: RunContext,
+    xiaohongshu_api: XiaohongshuSearchRecommendations,
+    xiaohongshu_search: XiaohongshuSearch,
+    sug_threshold: float = 0.7
+) -> tuple[list[Word], list[Q], list[Seed], list[Search]]:
+    """
+    运行一轮
+
+    Args:
+        round_num: 轮次编号
+        q_list: 当前轮的q列表
+        word_list: 当前的word列表
+        seed_list: 当前的seed列表
+        o: 原始问题
+        context: 运行上下文
+        xiaohongshu_api: 建议词API
+        xiaohongshu_search: 搜索API
+        sug_threshold: suggestion的阈值
+
+    Returns:
+        (word_list_next, q_list_next, seed_list_next, search_list)
+    """
+    print(f"\n{'='*60}")
+    print(f"第{round_num}轮")
+    print(f"{'='*60}")
+
+    round_data = {
+        "round_num": round_num,
+        "input_q_list": [{"text": q.text, "score": q.score_with_o} for q in q_list],
+        "input_word_list_size": len(word_list),
+        "input_seed_list_size": len(seed_list)
+    }
+
+    # 1. 请求sug:q_list -> 每个q请求sug接口 -> sug_list_list
+    print(f"\n[步骤1] 为每个q请求建议词...")
+    sug_list_list = []  # list of list
+    for q in q_list:
+        print(f"\n  处理q: {q.text}")
+        suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
+
+        q_sug_list = []
+        if suggestions:
+            print(f"    获取到 {len(suggestions)} 个建议词")
+            for sug_text in suggestions:
+                sug = Sug(
+                    text=sug_text,
+                    from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
+                )
+                q_sug_list.append(sug)
+        else:
+            print(f"    未获取到建议词")
+
+        sug_list_list.append(q_sug_list)
+
+    # 2. sug评估:sug_list_list -> 每个sug与o进评分(并发)
+    print(f"\n[步骤2] 评估每个建议词与原始问题的相关度...")
+
+    # 2.1 收集所有需要评估的sug,并记录它们所属的q
+    all_sugs = []
+    sug_to_q_map = {}  # 记录每个sug属于哪个q
+    for i, q_sug_list in enumerate(sug_list_list):
+        if q_sug_list:
+            q_text = q_list[i].text
+            for sug in q_sug_list:
+                all_sugs.append(sug)
+                sug_to_q_map[id(sug)] = q_text
+
+    # 2.2 并发评估所有sug
+    async def evaluate_sug(sug: Sug) -> Sug:
+        sug.score_with_o, sug.reason = await evaluate_with_o(sug.text, o)
+        return sug
+
+    if all_sugs:
+        eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
+        await asyncio.gather(*eval_tasks)
+
+    # 2.3 打印结果并组织到sug_details
+    sug_details = {}  # 保存每个Q对应的sug列表
+    for i, q_sug_list in enumerate(sug_list_list):
+        if q_sug_list:
+            q_text = q_list[i].text
+            print(f"\n  来自q '{q_text}' 的建议词:")
+            sug_details[q_text] = []
+            for sug in q_sug_list:
+                print(f"    {sug.text}: {sug.score_with_o:.2f}")
+                # 保存到sug_details
+                sug_details[q_text].append({
+                    "text": sug.text,
+                    "score": sug.score_with_o,
+                    "reason": sug.reason
+                })
+
+    # 3. search_list构建
+    print(f"\n[步骤3] 构建search_list(阈值>{sug_threshold})...")
+    search_list = []
+    high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
+
+    if high_score_sugs:
+        print(f"  找到 {len(high_score_sugs)} 个高分建议词")
+
+        # 并发搜索
+        async def search_for_sug(sug: Sug) -> Search:
+            print(f"    搜索: {sug.text}")
+            try:
+                search_result = xiaohongshu_search.search(keyword=sug.text)
+                result_str = search_result.get("result", "{}")
+                if isinstance(result_str, str):
+                    result_data = json.loads(result_str)
+                else:
+                    result_data = result_str
+
+                notes = result_data.get("data", {}).get("data", [])
+                post_list = []
+                for note in notes[:10]:  # 只取前10个
+                    post = process_note_data(note)
+                    post_list.append(post)
+
+                print(f"      → 找到 {len(post_list)} 个帖子")
+
+                return Search(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    from_q=sug.from_q,
+                    post_list=post_list
+                )
+            except Exception as e:
+                print(f"      ✗ 搜索失败: {e}")
+                return Search(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    from_q=sug.from_q,
+                    post_list=[]
+                )
+
+        search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
+        search_list = await asyncio.gather(*search_tasks)
+    else:
+        print(f"  没有高分建议词,search_list为空")
+
+    # 4. 构建word_list_next: word_list -> word_list_next(先直接复制)
+    print(f"\n[步骤4] 构建word_list_next(暂时直接复制)...")
+    word_list_next = word_list.copy()
+
+    # 5. 构建q_list_next
+    print(f"\n[步骤5] 构建q_list_next...")
+    q_list_next = []
+    add_word_details = {}  # 保存每个seed对应的组合词列表
+
+    # 5.1 对于seed_list中的每个seed,从word_list_next中选一个未加过的词
+    print(f"\n  5.1 为每个seed加词...")
+    for seed in seed_list:
+        print(f"\n    处理seed: {seed.text}")
+
+        # 简单过滤:找出不在seed.text中且未被添加过的词
+        candidate_words = []
+        for word in word_list_next:
+            # 检查词是否已在seed中
+            if word.text in seed.text:
+                continue
+            # 检查词是否已被添加过
+            if word.text in seed.added_words:
+                continue
+            candidate_words.append(word)
+
+        if not candidate_words:
+            print(f"      没有可用的候选词")
+            continue
+
+        print(f"      候选词: {[w.text for w in candidate_words]}")
+
+        # 使用Agent选择最合适的词
+        selection_input = f"""
+<原始问题>
+{o}
+</原始问题>
+
+<当前Seed>
+{seed.text}
+</当前Seed>
+
+<候选词列表>
+{', '.join([w.text for w in candidate_words])}
+</候选词列表>
+
+请从候选词中选择一个最合适的词,与当前seed组合成新的query。
+"""
+        result = await Runner.run(word_selector, selection_input)
+        selection: WordSelection = result.final_output
+
+        # 验证选择的词是否在候选列表中
+        if selection.selected_word not in [w.text for w in candidate_words]:
+            print(f"      ✗ Agent选择的词 '{selection.selected_word}' 不在候选列表中,跳过")
+            continue
+
+        print(f"      ✓ 选择词: {selection.selected_word}")
+        print(f"      ✓ 新query: {selection.combined_query}")
+        print(f"      理由: {selection.reasoning}")
+
+        # 评估新query
+        new_q_score, new_q_reason = await evaluate_with_o(selection.combined_query, o)
+        print(f"      新query评分: {new_q_score:.2f}")
+
+        # 创建新的q
+        new_q = Q(
+            text=selection.combined_query,
+            score_with_o=new_q_score,
+            reason=new_q_reason,
+            from_source="add"
+        )
+        q_list_next.append(new_q)
+
+        # 更新seed的added_words
+        seed.added_words.append(selection.selected_word)
+
+        # 保存到add_word_details
+        if seed.text not in add_word_details:
+            add_word_details[seed.text] = []
+        add_word_details[seed.text].append({
+            "text": selection.combined_query,
+            "score": new_q_score,
+            "reason": new_q_reason,
+            "selected_word": selection.selected_word
+        })
+
+    # 5.2 对于sug_list_list中,每个sug大于来自的query分数,加到q_list_next
+    print(f"\n  5.2 将高分sug加入q_list_next...")
+    for sug in all_sugs:
+        if sug.from_q and sug.score_with_o > sug.from_q.score_with_o:
+            new_q = Q(
+                text=sug.text,
+                score_with_o=sug.score_with_o,
+                reason=sug.reason,
+                from_source="sug"
+            )
+            q_list_next.append(new_q)
+            print(f"    ✓ {sug.text} (分数: {sug.score_with_o:.2f} > {sug.from_q.score_with_o:.2f})")
+
+    # 6. 更新seed_list
+    print(f"\n[步骤6] 更新seed_list...")
+    seed_list_next = seed_list.copy()  # 保留原有的seed
+
+    # 对于sug_list_list中,每个sug分数大于来源query分数的,且没在seed_list中出现过的,加入
+    existing_seed_texts = {seed.text for seed in seed_list_next}
+    for sug in all_sugs:
+        # 新逻辑:sug分数 > 对应query分数
+        if sug.from_q and sug.score_with_o > sug.from_q.score_with_o and sug.text not in existing_seed_texts:
+            new_seed = Seed(
+                text=sug.text,
+                added_words=[],
+                from_type="sug",
+                score_with_o=sug.score_with_o
+            )
+            seed_list_next.append(new_seed)
+            existing_seed_texts.add(sug.text)
+            print(f"  ✓ 新seed: {sug.text} (分数: {sug.score_with_o:.2f} > 来源query: {sug.from_q.score_with_o:.2f})")
+
+    # 记录本轮数据
+    round_data.update({
+        "sug_count": len(all_sugs),
+        "high_score_sug_count": len(high_score_sugs),
+        "search_count": len(search_list),
+        "total_posts": sum(len(s.post_list) for s in search_list),
+        "q_list_next_size": len(q_list_next),
+        "seed_list_next_size": len(seed_list_next),
+        "word_list_next_size": len(word_list_next),
+        "output_q_list": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "from": q.from_source} for q in q_list_next],
+        "seed_list_next": [{"text": seed.text, "from": seed.from_type, "score": seed.score_with_o} for seed in seed_list_next],  # 下一轮种子列表
+        "sug_details": sug_details,  # 每个Q对应的sug列表
+        "add_word_details": add_word_details  # 每个seed对应的组合词列表
+    })
+    context.rounds.append(round_data)
+
+    print(f"\n本轮总结:")
+    print(f"  建议词数量: {len(all_sugs)}")
+    print(f"  高分建议词: {len(high_score_sugs)}")
+    print(f"  搜索数量: {len(search_list)}")
+    print(f"  帖子总数: {sum(len(s.post_list) for s in search_list)}")
+    print(f"  下轮q数量: {len(q_list_next)}")
+    print(f"  seed数量: {len(seed_list_next)}")
+
+    return word_list_next, q_list_next, seed_list_next, search_list
+
+
+async def iterative_loop(
+    context: RunContext,
+    max_rounds: int = 2,
+    sug_threshold: float = 0.7
+):
+    """主迭代循环"""
+
+    print(f"\n{'='*60}")
+    print(f"开始迭代循环")
+    print(f"最大轮数: {max_rounds}")
+    print(f"sug阈值: {sug_threshold}")
+    print(f"{'='*60}")
+
+    # 初始化
+    seg_list, word_list, q_list, seed_list = await initialize(context.o, context)
+
+    # API实例
+    xiaohongshu_api = XiaohongshuSearchRecommendations()
+    xiaohongshu_search = XiaohongshuSearch()
+
+    # 保存初始化数据
+    context.rounds.append({
+        "round_num": 0,
+        "type": "initialization",
+        "seg_list": [{"text": s.text, "score": s.score_with_o, "reason": s.reason} for s in seg_list],
+        "word_list_1": [{"text": w.text, "score": w.score_with_o} for w in word_list],
+        "q_list_1": [{"text": q.text, "score": q.score_with_o, "reason": q.reason} for q in q_list],
+        "seed_list": [{"text": s.text, "from_type": s.from_type, "score": s.score_with_o} for s in seed_list]
+    })
+
+    # 收集所有搜索结果
+    all_search_list = []
+
+    # 迭代
+    round_num = 1
+    while q_list and round_num <= max_rounds:
+        word_list, q_list, seed_list, search_list = await run_round(
+            round_num=round_num,
+            q_list=q_list,
+            word_list=word_list,
+            seed_list=seed_list,
+            o=context.o,
+            context=context,
+            xiaohongshu_api=xiaohongshu_api,
+            xiaohongshu_search=xiaohongshu_search,
+            sug_threshold=sug_threshold
+        )
+
+        all_search_list.extend(search_list)
+        round_num += 1
+
+    print(f"\n{'='*60}")
+    print(f"迭代完成")
+    print(f"  总轮数: {round_num - 1}")
+    print(f"  总搜索次数: {len(all_search_list)}")
+    print(f"  总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
+    print(f"{'='*60}")
+
+    return all_search_list
+
+
+# ============================================================================
+# 主函数
+# ============================================================================
+
+async def main(input_dir: str, max_rounds: int = 2, sug_threshold: float = 0.7, visualize: bool = False):
+    """主函数"""
+    current_time, log_url = set_trace()
+
+    # 读取输入
+    input_context_file = os.path.join(input_dir, 'context.md')
+    input_q_file = os.path.join(input_dir, 'q.md')
+
+    c = read_file_as_string(input_context_file)  # 原始需求
+    o = read_file_as_string(input_q_file)  # 原始问题
+
+    # 版本信息
+    version = os.path.basename(__file__)
+    version_name = os.path.splitext(version)[0]
+
+    # 日志目录
+    log_dir = os.path.join(input_dir, "output", version_name, current_time)
+
+    # 创建运行上下文
+    run_context = RunContext(
+        version=version,
+        input_files={
+            "input_dir": input_dir,
+            "context_file": input_context_file,
+            "q_file": input_q_file,
+        },
+        c=c,
+        o=o,
+        log_dir=log_dir,
+        log_url=log_url,
+    )
+
+    # 执行迭代
+    all_search_list = await iterative_loop(
+        run_context,
+        max_rounds=max_rounds,
+        sug_threshold=sug_threshold
+    )
+
+    # 格式化输出
+    output = f"原始需求:{run_context.c}\n"
+    output += f"原始问题:{run_context.o}\n"
+    output += f"总搜索次数:{len(all_search_list)}\n"
+    output += f"总帖子数:{sum(len(s.post_list) for s in all_search_list)}\n"
+    output += "\n" + "="*60 + "\n"
+
+    if all_search_list:
+        output += "【搜索结果】\n\n"
+        for idx, search in enumerate(all_search_list, 1):
+            output += f"{idx}. 搜索词: {search.text} (分数: {search.score_with_o:.2f})\n"
+            output += f"   帖子数: {len(search.post_list)}\n"
+            if search.post_list:
+                for post_idx, post in enumerate(search.post_list[:3], 1):  # 只显示前3个
+                    output += f"   {post_idx}) {post.title}\n"
+                    output += f"      URL: {post.note_url}\n"
+            output += "\n"
+    else:
+        output += "未找到搜索结果\n"
+
+    run_context.final_output = output
+
+    print(f"\n{'='*60}")
+    print("最终结果")
+    print(f"{'='*60}")
+    print(output)
+
+    # 保存日志
+    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}")
+
+    # 保存详细的搜索结果
+    search_results_path = os.path.join(run_context.log_dir, "search_results.json")
+    search_results_data = [s.model_dump() for s in all_search_list]
+    with open(search_results_path, "w", encoding="utf-8") as f:
+        json.dump(search_results_data, f, ensure_ascii=False, indent=2)
+    print(f"Search results saved to: {search_results_path}")
+
+    # 可视化
+    if visualize:
+        import subprocess
+        output_html = os.path.join(run_context.log_dir, "visualization.html")
+        print(f"\n🎨 生成可视化HTML...")
+
+        # 获取绝对路径
+        abs_context_file = os.path.abspath(context_file_path)
+        abs_output_html = os.path.abspath(output_html)
+
+        # 运行可视化脚本
+        result = subprocess.run([
+            "node",
+            "visualization/sug_v6_1_2_8/index.js",
+            abs_context_file,
+            abs_output_html
+        ])
+
+        if result.returncode == 0:
+            print(f"✅ 可视化已生成: {output_html}")
+        else:
+            print(f"❌ 可视化生成失败")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.8 轮次迭代版")
+    parser.add_argument(
+        "--input-dir",
+        type=str,
+        default="input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?",
+        help="输入目录路径,默认: input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?"
+    )
+    parser.add_argument(
+        "--max-rounds",
+        type=int,
+        default=4,
+        help="最大轮数,默认: 2"
+    )
+    parser.add_argument(
+        "--sug-threshold",
+        type=float,
+        default=0.7,
+        help="suggestion阈值,默认: 0.7"
+    )
+    parser.add_argument(
+        "--visualize",
+        action="store_true",
+        default=True,
+        help="运行完成后自动生成可视化HTML"
+    )
+    args = parser.parse_args()
+
+    asyncio.run(main(args.input_dir, max_rounds=args.max_rounds, sug_threshold=args.sug_threshold, visualize=args.visualize))

+ 1027 - 0
sug_v6_1_2_9.py

@@ -0,0 +1,1027 @@
+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 Seg(BaseModel):
+    """分词"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    from_o: str = ""  # 原始问题
+
+
+class Word(BaseModel):
+    """词"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    from_o: str = ""  # 原始问题
+
+
+class QFromQ(BaseModel):
+    """Q来源信息(用于Sug中记录)"""
+    text: str
+    score_with_o: float = 0.0
+
+
+class Q(BaseModel):
+    """查询"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    from_source: str = ""  # seg/sug/add(加词)
+
+
+class Sug(BaseModel):
+    """建议词"""
+    text: str
+    score_with_o: float = 0.0  # 与原始问题的评分
+    from_q: QFromQ | None = None  # 来自的q
+
+
+class Seed(BaseModel):
+    """种子"""
+    text: str
+    added_words: list[str] = Field(default_factory=list)  # 已经增加的words
+    from_type: str = ""  # seg/sug
+    score_with_o: float = 0.0  # 与原始问题的评分
+
+
+class Post(BaseModel):
+    """帖子"""
+    title: str = ""
+    body_text: str = ""
+    type: str = "normal"  # video/normal
+    images: list[str] = Field(default_factory=list)  # 图片url列表,第一张为封面
+    video: str = ""  # 视频url
+    interact_info: dict = Field(default_factory=dict)  # 互动信息
+    note_id: str = ""
+    note_url: str = ""
+
+
+class Search(Sug):
+    """搜索结果(继承Sug)"""
+    post_list: list[Post] = Field(default_factory=list)  # 搜索得到的帖子列表
+
+
+class RunContext(BaseModel):
+    """运行上下文"""
+    version: str
+    input_files: dict[str, str]
+    c: str  # 原始需求
+    o: str  # 原始问题
+    log_url: str
+    log_dir: str
+
+    # 每轮的数据
+    rounds: 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: 相关度评估专家
+class RelevanceEvaluation(BaseModel):
+    """相关度评估"""
+    reason: str = Field(..., description="评估理由")
+    relevance_score: float = Field(..., description="相关性分数 -1~1")
+   
+
+relevance_evaluation_instructions = """
+# 角色定义
+你是一个 **专业的语言专家和语义相关性评判专家**。你的任务是:判断我给你的 <平台sug词条> 与 <原始问题> 的相关度满足度,给出 **-1 到 1 之间** 的数值评分。
+
+---
+
+# 核心概念与方法论
+
+## 两大评估维度
+本评估系统始终围绕 **两个核心维度** 进行:
+
+### 1. 动机维度(权重70%)
+**定义:** 用户"想要做什么",即原始问题的行为意图和目的
+- 核心是 **动词**:获取、学习、拍摄、制作、寻找等
+- 包括:核心动作 + 使用场景 + 最终目的
+
+### 2. 品类维度(权重30%)
+**定义:** 用户"关于什么内容",即原始问题的主题对象和限定词
+- 核心是 **名词+限定词**:川西秋季风光摄影素材
+- 包括:核心主体 + 地域限定 + 时间限定 + 质量限定等
+
+---
+
+## 如何识别原始问题的核心动机
+
+**核心动机必须是动词**,识别方法如下:
+
+### 方法1: 显性动词直接提取
+
+当原始问题明确包含动词时,直接提取
+示例:
+"如何获取素材" → 核心动机 = "获取"
+"寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
+"制作视频教程" → 核心动机 = "制作"
+
+### 方法2: 隐性动词语义推理
+
+当原始问题没有显性动词时,需要结合上下文推理
+示例:
+例: "川西秋天风光摄影" → 隐含动作="拍摄"
+→ 需结合上下文判断
+
+如果原始问题是纯名词短语,无任何动作线索:
+→ 核心动机 = 无法识别
+→ 初始权重 = 0
+→ 相关度评估以品类匹配为主
+示例:
+"摄影" → 无法识别动机,初始权重=0
+"川西风光" → 无法识别动机,初始权重=0
+
+
+
+# 输入信息
+你将接收到以下输入:
+- **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
+- **<平台sug词条>**:平台推荐的词条列表,每个词条需要单独评估。
+
+
+#判定流程
+#评估架构
+
+输入: <原始问题> + <平台sug词条>
+         ↓
+【综合相关性判定】
+    ├→ 步骤1: 评估<sug词条>与<原始问题>的相关度
+    └→ 输出: -1到1之间的数值 + 分维度得分 + 判定依据
+
+
+相关度评估维度详解
+维度1: 动机维度评估(权重70%)
+评估对象: <平台sug词条> 与 <原始问题> 的需求动机匹配度
+说明: 核心动作是用户需求的第一优先级,决定了推荐的基本有效性
+
+
+评分标准:
+
+【正向匹配】
++1.0: 核心动作完全一致
+  - 例: 原始问题"如何获取素材" vs sug词"素材获取方法"
+  - 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
+    · 例: 原始问题"扣除猫咪主体的方法" vs sug词"扣除猫咪眼睛的方法"(子集但目的一致)
+
++0.8~0.95: 核心动作语义相近或为同义表达
+  - 例: 原始问题"如何获取素材" vs sug词"素材下载教程"
+  - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
+
++0.5~0.75: 核心动作相关但非直接对应(相关实现路径)
+  - 例: 原始问题"如何获取素材" vs sug词"素材管理整理"
+
++0.2~0.45: 核心动作弱相关(同领域不同动作)
+  - 例: 原始问题"如何拍摄风光" vs sug词"风光摄影欣赏"
+
+【中性/无关】
+0: 没有明确目的,动作意图无明确关联
+  - 例: 原始问题"如何获取素材" vs sug词"摄影器材推荐"
+  - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
+
+【负向偏离】
+-0.2~-0.05: 动作意图轻度冲突或误导
+  - 例: 原始问题"如何获取素材" vs sug词"素材版权保护须知"
+
+-0.5~-0.25: 动作意图明显对立
+  - 例: 原始问题"如何获取免费素材" vs sug词"如何售卖素材"
+
+-1.0~-0.55: 动作意图完全相反或产生严重负面引导
+  - 例: 原始问题"免费素材获取" vs sug词"付费素材强制推销"
+
+维度2: 品类维度评估(权重30%)
+评估对象: <平台sug词条> 与 <原始问题> 的内容主体和限定词匹配度
+
+评分标准:
+
+【正向匹配】
++1.0: 核心主体+所有关键限定词完全匹配
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西秋季风光摄影作品"
+
++0.75~0.95: 核心主体匹配,大部分限定词匹配
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西风光摄影素材"(缺失"秋季")
+
++0.5~0.7: 核心主体匹配,少量限定词匹配或合理泛化
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"四川风光摄影"
+
++0.2~0.45: 仅主体词匹配,限定词全部缺失或错位
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"风光摄影入门"
+
++0.05~0.15: 主题领域相关但品类不同
+  - 例: 原始问题"风光摄影素材" vs sug词"人文摄影素材"
+
+【中性/无关】
+0: 主体词部分相关但类别明显不同
+  - 例: 原始问题"川西秋季风光摄影素材" vs sug词"人像摄影素材"
+
+【负向偏离】
+-0.2~-0.05: 主体词或限定词存在误导性
+  - 例: 原始问题"免费摄影素材" vs sug词"付费摄影素材库"
+
+-0.5~-0.25: 主体词明显错位或品类冲突
+  - 例: 原始问题"风光摄影素材" vs sug词"人像修图教程"
+
+-1.0~-0.55: 完全错误的品类或有害引导
+  - 例: 原始问题"正版素材获取" vs sug词"盗版素材下载"
+
+
+综合得分计算与规则调整
+步骤1: 应用依存性规则
+规则A: 动机高分保护机制
+
+如果 动机维度得分 ≥ 0.8:
+   → 品类得分即使为0或轻微负向(-0.2~0)
+   → 最终得分 = max(初步得分, 0.55)
+
+解释: 当目的高度一致时,品类的泛化不应导致"弱相关"
+
+规则B: 动机低分限制机制
+如果 动机维度得分 ≤ 0.2:
+   → 无论品类得分多高
+   → 最终得分 = min(初步得分, 0.4)
+
+解释: 目的不符时,品类匹配的价值有限
+
+规则C: 动机负向决定机制
+如果 动机维度得分 < 0:
+   → 最终得分 = min(初步得分, 0)
+
+解释: 动作意图冲突时,推荐具有误导性,不应为正相关
+
+步骤3: 输出最终得分
+
+#基础加权计算
+应用规则后的调整得分 = 目的动机维度得分 × 0.7 + 品类维度得分 × 0.3
+取值范围: -1.0 ~ +1.0
+
+---
+
+# 得分档位解释
+
+高度相关】+0.8 ~ +1.0
+相关性高度契合,用户可直接使用
+动机和品类均高度匹配
+典型场景: 动机≥0.85 且 品类≥0.7
+【中高相关】+0.6 ~ +0.79
+相关性较好,用户基本满意
+动机匹配但品类有泛化,或反之
+典型场景: 动机≥0.8 且 品类≥0.3
+【中度相关】+0.3 ~ +0.59
+部分相关,用户需要调整搜索策略
+动机或品类存在一定偏差
+典型场景: 动机0.4-0.7 且 品类0.3-0.7
+【弱相关】+0.01 ~ +0.29
+关联微弱,参考价值有限
+仅有表层词汇重叠
+【无关】0
+无明确关联
+原始问题无法识别动机 且 sug词无明确动作
+没有目的性且没有品类匹配
+【轻度负向】-0.29 ~ -0.01
+产生轻微误导或干扰
+【中度负向】-0.69 ~ -0.3
+存在明显冲突或误导
+【严重负向】-1.0 ~ -0.7
+完全违背意图或产生有害引导
+
+---
+
+# 输出要求
+输出结果必须为一个 **JSON 格式**,包含以下内容:
+
+#注意事项:
+始终围绕两个核心维度:所有评估都基于"动机"和"品类"两个维度,不偏离
+核心动机必须是动词:在评估前,必须先提取原始问题的核心动机(动词),这是整个评估的基础
+严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
+负分使用原则:仅当sug词条对原始问题产生误导、冲突或有害引导时给予负分
+零分使用原则:当sug词条与原始问题无明确关联,既不相关也不冲突时给予零分
+分维度独立评分:
+先提取原始问题核心动机
+分别计算动机维度(含两个子维度)和品类维度得分
+按70:30加权得到初步得分
+应用规则调整得到最终得分
+动机优先原则:当动机高度一致时,品类的合理泛化或具体化不应导致低评分
+技巧类需求特殊对待:包含"技巧/方法/教程"等词的需求,对动作一致性要求更严格
+
+## 输出
+- reason: 详细理由
+- relevance_score: 0-1的相关性分数
+""".strip()
+
+relevance_evaluator = Agent[None](
+    name="相关度评估专家",
+    instructions=relevance_evaluation_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=RelevanceEvaluation,
+)
+
+
+# Agent 3: 加词选择专家
+class WordSelection(BaseModel):
+    """加词选择结果"""
+    selected_word: str = Field(..., description="选择的词")
+    combined_query: str = Field(..., description="组合后的新query")
+    reasoning: str = Field(..., description="选择理由")
+
+word_selection_instructions = """
+你是加词选择专家。
+
+## 任务
+从候选词列表中选择一个最合适的词,与当前seed组合成新的query。
+
+## 原则
+1. 选择与当前seed最相关的词
+2. 组合后的query要语义通顺
+3. 符合搜索习惯
+4. 优先选择能扩展搜索范围的词
+
+## 输出
+- selected_word: 选中的词
+- combined_query: 组合后的新query
+- reasoning: 选择理由
+""".strip()
+
+word_selector = Agent[None](
+    name="加词选择专家",
+    instructions=word_selection_instructions,
+    model=get_model(MODEL_NAME),
+    output_type=WordSelection,
+)
+
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+def process_note_data(note: dict) -> Post:
+    """处理搜索接口返回的帖子数据"""
+    note_card = note.get("note_card", {})
+    image_list = note_card.get("image_list", [])
+    interact_info = note_card.get("interact_info", {})
+    user_info = note_card.get("user", {})
+
+    # 提取图片URL
+    images = []
+    for img in image_list:
+        if isinstance(img, dict) and "url_default" in img:
+            images.append(img["url_default"])
+
+    # 判断类型
+    note_type = note_card.get("type", "normal")
+    video_url = ""
+    if note_type == "video":
+        video_info = note_card.get("video", {})
+        if isinstance(video_info, dict):
+            # 尝试获取视频URL
+            video_url = video_info.get("media", {}).get("stream", {}).get("h264", [{}])[0].get("master_url", "")
+
+    return Post(
+        note_id=note.get("id", ""),
+        title=note_card.get("display_title", ""),
+        body_text=note_card.get("desc", ""),
+        type=note_type,
+        images=images,
+        video=video_url,
+        interact_info={
+            "liked_count": interact_info.get("liked_count", 0),
+            "collected_count": interact_info.get("collected_count", 0),
+            "comment_count": interact_info.get("comment_count", 0),
+            "shared_count": interact_info.get("shared_count", 0)
+        },
+        note_url=f"https://www.xiaohongshu.com/explore/{note.get('id', '')}"
+    )
+
+
+async def evaluate_with_o(text: str, o: str) -> float:
+    """评估文本与原始问题o的相关度"""
+    eval_input = f"""
+<原始问题>
+{o}
+</原始问题>
+
+<当前文本>
+{text}
+</当前文本>
+
+请评估当前文本与原始问题的相关度。
+"""
+    result = await Runner.run(relevance_evaluator, eval_input)
+    evaluation: RelevanceEvaluation = result.final_output
+    return evaluation.relevance_score
+
+
+# ============================================================================
+# 核心流程函数
+# ============================================================================
+
+async def initialize(o: str, context: RunContext) -> tuple[list[Seg], list[Word], list[Q], list[Seed]]:
+    """
+    初始化阶段
+
+    Returns:
+        (seg_list, word_list_1, q_list_1, seed_list)
+    """
+    print(f"\n{'='*60}")
+    print(f"初始化阶段")
+    print(f"{'='*60}")
+
+    # 1. 分词:原始问题(o) ->分词-> seg_list
+    print(f"\n[步骤1] 分词...")
+    result = await Runner.run(word_segmenter, o)
+    segmentation: WordSegmentation = result.final_output
+
+    seg_list = []
+    for word in segmentation.words:
+        seg_list.append(Seg(text=word, from_o=o))
+
+    print(f"分词结果: {[s.text for s in seg_list]}")
+    print(f"分词理由: {segmentation.reasoning}")
+
+    # 2. 分词评估:seg_list -> 每个seg与o进行评分(并发)
+    print(f"\n[步骤2] 评估每个分词与原始问题的相关度...")
+
+    async def evaluate_seg(seg: Seg) -> Seg:
+        seg.score_with_o = await evaluate_with_o(seg.text, o)
+        return seg
+
+    if seg_list:
+        eval_tasks = [evaluate_seg(seg) for seg in seg_list]
+        await asyncio.gather(*eval_tasks)
+
+    for seg in seg_list:
+        print(f"  {seg.text}: {seg.score_with_o:.2f}")
+
+    # 3. 构建word_list_1: seg_list -> word_list_1
+    print(f"\n[步骤3] 构建word_list_1...")
+    word_list_1 = []
+    for seg in seg_list:
+        word_list_1.append(Word(
+            text=seg.text,
+            score_with_o=seg.score_with_o,
+            from_o=o
+        ))
+    print(f"word_list_1: {[w.text for w in word_list_1]}")
+
+    # 4. 构建q_list_1:seg_list 作为 q_list_1
+    print(f"\n[步骤4] 构建q_list_1...")
+    q_list_1 = []
+    for seg in seg_list:
+        q_list_1.append(Q(
+            text=seg.text,
+            score_with_o=seg.score_with_o,
+            from_source="seg"
+        ))
+    print(f"q_list_1: {[q.text for q in q_list_1]}")
+
+    # 5. 构建seed_list: seg_list -> seed_list
+    print(f"\n[步骤5] 构建seed_list...")
+    seed_list = []
+    for seg in seg_list:
+        seed_list.append(Seed(
+            text=seg.text,
+            added_words=[],
+            from_type="seg",
+            score_with_o=seg.score_with_o
+        ))
+    print(f"seed_list: {[s.text for s in seed_list]}")
+
+    return seg_list, word_list_1, q_list_1, seed_list
+
+
+async def run_round(
+    round_num: int,
+    q_list: list[Q],
+    word_list: list[Word],
+    seed_list: list[Seed],
+    o: str,
+    context: RunContext,
+    xiaohongshu_api: XiaohongshuSearchRecommendations,
+    xiaohongshu_search: XiaohongshuSearch,
+    sug_threshold: float = 0.7
+) -> tuple[list[Word], list[Q], list[Seed], list[Search]]:
+    """
+    运行一轮
+
+    Args:
+        round_num: 轮次编号
+        q_list: 当前轮的q列表
+        word_list: 当前的word列表
+        seed_list: 当前的seed列表
+        o: 原始问题
+        context: 运行上下文
+        xiaohongshu_api: 建议词API
+        xiaohongshu_search: 搜索API
+        sug_threshold: suggestion的阈值
+
+    Returns:
+        (word_list_next, q_list_next, seed_list_next, search_list)
+    """
+    print(f"\n{'='*60}")
+    print(f"第{round_num}轮")
+    print(f"{'='*60}")
+
+    round_data = {
+        "round_num": round_num,
+        "input_q_list": [{"text": q.text, "score": q.score_with_o} for q in q_list],
+        "input_word_list_size": len(word_list),
+        "input_seed_list_size": len(seed_list)
+    }
+
+    # 1. 请求sug:q_list -> 每个q请求sug接口 -> sug_list_list
+    print(f"\n[步骤1] 为每个q请求建议词...")
+    sug_list_list = []  # list of list
+    for q in q_list:
+        print(f"\n  处理q: {q.text}")
+        suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
+
+        q_sug_list = []
+        if suggestions:
+            print(f"    获取到 {len(suggestions)} 个建议词")
+            for sug_text in suggestions:
+                sug = Sug(
+                    text=sug_text,
+                    from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
+                )
+                q_sug_list.append(sug)
+        else:
+            print(f"    未获取到建议词")
+
+        sug_list_list.append(q_sug_list)
+
+    # 2. sug评估:sug_list_list -> 每个sug与o进评分(并发)
+    print(f"\n[步骤2] 评估每个建议词与原始问题的相关度...")
+
+    # 2.1 收集所有需要评估的sug,并记录它们所属的q
+    all_sugs = []
+    sug_to_q_map = {}  # 记录每个sug属于哪个q
+    for i, q_sug_list in enumerate(sug_list_list):
+        if q_sug_list:
+            q_text = q_list[i].text
+            for sug in q_sug_list:
+                all_sugs.append(sug)
+                sug_to_q_map[id(sug)] = q_text
+
+    # 2.2 并发评估所有sug
+    async def evaluate_sug(sug: Sug) -> Sug:
+        sug.score_with_o = await evaluate_with_o(sug.text, o)
+        return sug
+
+    if all_sugs:
+        eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
+        await asyncio.gather(*eval_tasks)
+
+    # 2.3 打印结果并组织到sug_details
+    sug_details = {}  # 保存每个Q对应的sug列表
+    for i, q_sug_list in enumerate(sug_list_list):
+        if q_sug_list:
+            q_text = q_list[i].text
+            print(f"\n  来自q '{q_text}' 的建议词:")
+            sug_details[q_text] = []
+            for sug in q_sug_list:
+                print(f"    {sug.text}: {sug.score_with_o:.2f}")
+                # 保存到sug_details
+                sug_details[q_text].append({
+                    "text": sug.text,
+                    "score": sug.score_with_o
+                })
+
+    # 3. search_list构建
+    print(f"\n[步骤3] 构建search_list(阈值>{sug_threshold})...")
+    search_list = []
+    high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
+
+    if high_score_sugs:
+        print(f"  找到 {len(high_score_sugs)} 个高分建议词")
+
+        # 并发搜索
+        async def search_for_sug(sug: Sug) -> Search:
+            print(f"    搜索: {sug.text}")
+            try:
+                search_result = xiaohongshu_search.search(keyword=sug.text)
+                result_str = search_result.get("result", "{}")
+                if isinstance(result_str, str):
+                    result_data = json.loads(result_str)
+                else:
+                    result_data = result_str
+
+                notes = result_data.get("data", {}).get("data", [])
+                post_list = []
+                for note in notes[:10]:  # 只取前10个
+                    post = process_note_data(note)
+                    post_list.append(post)
+
+                print(f"      → 找到 {len(post_list)} 个帖子")
+
+                return Search(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    from_q=sug.from_q,
+                    post_list=post_list
+                )
+            except Exception as e:
+                print(f"      ✗ 搜索失败: {e}")
+                return Search(
+                    text=sug.text,
+                    score_with_o=sug.score_with_o,
+                    from_q=sug.from_q,
+                    post_list=[]
+                )
+
+        search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
+        search_list = await asyncio.gather(*search_tasks)
+    else:
+        print(f"  没有高分建议词,search_list为空")
+
+    # 4. 构建word_list_next: word_list -> word_list_next(先直接复制)
+    print(f"\n[步骤4] 构建word_list_next(暂时直接复制)...")
+    word_list_next = word_list.copy()
+
+    # 5. 构建q_list_next
+    print(f"\n[步骤5] 构建q_list_next...")
+    q_list_next = []
+    add_word_details = {}  # 保存每个seed对应的组合词列表
+
+    # 5.1 对于seed_list中的每个seed,从word_list_next中选一个未加过的词
+    print(f"\n  5.1 为每个seed加词...")
+    for seed in seed_list:
+        print(f"\n    处理seed: {seed.text}")
+
+        # 简单过滤:找出不在seed.text中且未被添加过的词
+        candidate_words = []
+        for word in word_list_next:
+            # 检查词是否已在seed中
+            if word.text in seed.text:
+                continue
+            # 检查词是否已被添加过
+            if word.text in seed.added_words:
+                continue
+            candidate_words.append(word)
+
+        if not candidate_words:
+            print(f"      没有可用的候选词")
+            continue
+
+        print(f"      候选词: {[w.text for w in candidate_words]}")
+
+        # 使用Agent选择最合适的词
+        selection_input = f"""
+<原始问题>
+{o}
+</原始问题>
+
+<当前Seed>
+{seed.text}
+</当前Seed>
+
+<候选词列表>
+{', '.join([w.text for w in candidate_words])}
+</候选词列表>
+
+请从候选词中选择一个最合适的词,与当前seed组合成新的query。
+"""
+        result = await Runner.run(word_selector, selection_input)
+        selection: WordSelection = result.final_output
+
+        # 验证选择的词是否在候选列表中
+        if selection.selected_word not in [w.text for w in candidate_words]:
+            print(f"      ✗ Agent选择的词 '{selection.selected_word}' 不在候选列表中,跳过")
+            continue
+
+        print(f"      ✓ 选择词: {selection.selected_word}")
+        print(f"      ✓ 新query: {selection.combined_query}")
+        print(f"      理由: {selection.reasoning}")
+
+        # 评估新query
+        new_q_score = await evaluate_with_o(selection.combined_query, o)
+        print(f"      新query评分: {new_q_score:.2f}")
+
+        # 创建新的q
+        new_q = Q(
+            text=selection.combined_query,
+            score_with_o=new_q_score,
+            from_source="add"
+        )
+        q_list_next.append(new_q)
+
+        # 更新seed的added_words
+        seed.added_words.append(selection.selected_word)
+
+        # 保存到add_word_details
+        if seed.text not in add_word_details:
+            add_word_details[seed.text] = []
+        add_word_details[seed.text].append({
+            "text": selection.combined_query,
+            "score": new_q_score,
+            "selected_word": selection.selected_word
+        })
+
+    # 5.2 对于sug_list_list中,每个sug大于来自的query分数,加到q_list_next
+    print(f"\n  5.2 将高分sug加入q_list_next...")
+    for sug in all_sugs:
+        if sug.from_q and sug.score_with_o > sug.from_q.score_with_o:
+            new_q = Q(
+                text=sug.text,
+                score_with_o=sug.score_with_o,
+                from_source="sug"
+            )
+            q_list_next.append(new_q)
+            print(f"    ✓ {sug.text} (分数: {sug.score_with_o:.2f} > {sug.from_q.score_with_o:.2f})")
+
+    # 6. 更新seed_list
+    print(f"\n[步骤6] 更新seed_list...")
+    seed_list_next = seed_list.copy()  # 保留原有的seed
+
+    # 对于sug_list_list中,每个sug分数大于来源query分数的,且没在seed_list中出现过的,加入
+    existing_seed_texts = {seed.text for seed in seed_list_next}
+    for sug in all_sugs:
+        # 新逻辑:sug分数 > 对应query分数
+        if sug.from_q and sug.score_with_o > sug.from_q.score_with_o and sug.text not in existing_seed_texts:
+            new_seed = Seed(
+                text=sug.text,
+                added_words=[],
+                from_type="sug",
+                score_with_o=sug.score_with_o
+            )
+            seed_list_next.append(new_seed)
+            existing_seed_texts.add(sug.text)
+            print(f"  ✓ 新seed: {sug.text} (分数: {sug.score_with_o:.2f} > 来源query: {sug.from_q.score_with_o:.2f})")
+
+    # 记录本轮数据
+    round_data.update({
+        "sug_count": len(all_sugs),
+        "high_score_sug_count": len(high_score_sugs),
+        "search_count": len(search_list),
+        "total_posts": sum(len(s.post_list) for s in search_list),
+        "q_list_next_size": len(q_list_next),
+        "seed_list_next_size": len(seed_list_next),
+        "word_list_next_size": len(word_list_next),
+        "output_q_list": [{"text": q.text, "score": q.score_with_o, "from": q.from_source} for q in q_list_next],
+        "seed_list_next": [{"text": seed.text, "from": seed.from_type, "score": seed.score_with_o} for seed in seed_list_next],  # 下一轮种子列表
+        "sug_details": sug_details,  # 每个Q对应的sug列表
+        "add_word_details": add_word_details  # 每个seed对应的组合词列表
+    })
+    context.rounds.append(round_data)
+
+    print(f"\n本轮总结:")
+    print(f"  建议词数量: {len(all_sugs)}")
+    print(f"  高分建议词: {len(high_score_sugs)}")
+    print(f"  搜索数量: {len(search_list)}")
+    print(f"  帖子总数: {sum(len(s.post_list) for s in search_list)}")
+    print(f"  下轮q数量: {len(q_list_next)}")
+    print(f"  seed数量: {len(seed_list_next)}")
+
+    return word_list_next, q_list_next, seed_list_next, search_list
+
+
+async def iterative_loop(
+    context: RunContext,
+    max_rounds: int = 2,
+    sug_threshold: float = 0.7
+):
+    """主迭代循环"""
+
+    print(f"\n{'='*60}")
+    print(f"开始迭代循环")
+    print(f"最大轮数: {max_rounds}")
+    print(f"sug阈值: {sug_threshold}")
+    print(f"{'='*60}")
+
+    # 初始化
+    seg_list, word_list, q_list, seed_list = await initialize(context.o, context)
+
+    # API实例
+    xiaohongshu_api = XiaohongshuSearchRecommendations()
+    xiaohongshu_search = XiaohongshuSearch()
+
+    # 保存初始化数据
+    context.rounds.append({
+        "round_num": 0,
+        "type": "initialization",
+        "seg_list": [{"text": s.text, "score": s.score_with_o} for s in seg_list],
+        "word_list_1": [{"text": w.text, "score": w.score_with_o} for w in word_list],
+        "q_list_1": [{"text": q.text, "score": q.score_with_o} for q in q_list],
+        "seed_list": [{"text": s.text, "from_type": s.from_type, "score": s.score_with_o} for s in seed_list]
+    })
+
+    # 收集所有搜索结果
+    all_search_list = []
+
+    # 迭代
+    round_num = 1
+    while q_list and round_num <= max_rounds:
+        word_list, q_list, seed_list, search_list = await run_round(
+            round_num=round_num,
+            q_list=q_list,
+            word_list=word_list,
+            seed_list=seed_list,
+            o=context.o,
+            context=context,
+            xiaohongshu_api=xiaohongshu_api,
+            xiaohongshu_search=xiaohongshu_search,
+            sug_threshold=sug_threshold
+        )
+
+        all_search_list.extend(search_list)
+        round_num += 1
+
+    print(f"\n{'='*60}")
+    print(f"迭代完成")
+    print(f"  总轮数: {round_num - 1}")
+    print(f"  总搜索次数: {len(all_search_list)}")
+    print(f"  总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
+    print(f"{'='*60}")
+
+    return all_search_list
+
+
+# ============================================================================
+# 主函数
+# ============================================================================
+
+async def main(input_dir: str, max_rounds: int = 2, sug_threshold: float = 0.7, visualize: bool = False):
+    """主函数"""
+    current_time, log_url = set_trace()
+
+    # 读取输入
+    input_context_file = os.path.join(input_dir, 'context.md')
+    input_q_file = os.path.join(input_dir, 'q.md')
+
+    c = read_file_as_string(input_context_file)  # 原始需求
+    o = read_file_as_string(input_q_file)  # 原始问题
+
+    # 版本信息
+    version = os.path.basename(__file__)
+    version_name = os.path.splitext(version)[0]
+
+    # 日志目录
+    log_dir = os.path.join(input_dir, "output", version_name, current_time)
+
+    # 创建运行上下文
+    run_context = RunContext(
+        version=version,
+        input_files={
+            "input_dir": input_dir,
+            "context_file": input_context_file,
+            "q_file": input_q_file,
+        },
+        c=c,
+        o=o,
+        log_dir=log_dir,
+        log_url=log_url,
+    )
+
+    # 执行迭代
+    all_search_list = await iterative_loop(
+        run_context,
+        max_rounds=max_rounds,
+        sug_threshold=sug_threshold
+    )
+
+    # 格式化输出
+    output = f"原始需求:{run_context.c}\n"
+    output += f"原始问题:{run_context.o}\n"
+    output += f"总搜索次数:{len(all_search_list)}\n"
+    output += f"总帖子数:{sum(len(s.post_list) for s in all_search_list)}\n"
+    output += "\n" + "="*60 + "\n"
+
+    if all_search_list:
+        output += "【搜索结果】\n\n"
+        for idx, search in enumerate(all_search_list, 1):
+            output += f"{idx}. 搜索词: {search.text} (分数: {search.score_with_o:.2f})\n"
+            output += f"   帖子数: {len(search.post_list)}\n"
+            if search.post_list:
+                for post_idx, post in enumerate(search.post_list[:3], 1):  # 只显示前3个
+                    output += f"   {post_idx}) {post.title}\n"
+                    output += f"      URL: {post.note_url}\n"
+            output += "\n"
+    else:
+        output += "未找到搜索结果\n"
+
+    run_context.final_output = output
+
+    print(f"\n{'='*60}")
+    print("最终结果")
+    print(f"{'='*60}")
+    print(output)
+
+    # 保存日志
+    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}")
+
+    # 保存详细的搜索结果
+    search_results_path = os.path.join(run_context.log_dir, "search_results.json")
+    search_results_data = [s.model_dump() for s in all_search_list]
+    with open(search_results_path, "w", encoding="utf-8") as f:
+        json.dump(search_results_data, f, ensure_ascii=False, indent=2)
+    print(f"Search results saved to: {search_results_path}")
+
+    # 可视化
+    if visualize:
+        import subprocess
+        output_html = os.path.join(run_context.log_dir, "visualization.html")
+        print(f"\n🎨 生成可视化HTML...")
+
+        # 获取绝对路径
+        abs_context_file = os.path.abspath(context_file_path)
+        abs_output_html = os.path.abspath(output_html)
+
+        # 运行可视化脚本
+        result = subprocess.run([
+            "node",
+            "visualization/sug_v6_1_2_8/index.js",
+            abs_context_file,
+            abs_output_html
+        ])
+
+        if result.returncode == 0:
+            print(f"✅ 可视化已生成: {output_html}")
+        else:
+            print(f"❌ 可视化生成失败")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.8 轮次迭代版")
+    parser.add_argument(
+        "--input-dir",
+        type=str,
+        default="input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?",
+        help="输入目录路径,默认: input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?"
+    )
+    parser.add_argument(
+        "--max-rounds",
+        type=int,
+        default=3,
+        help="最大轮数,默认: 2"
+    )
+    parser.add_argument(
+        "--sug-threshold",
+        type=float,
+        default=0.7,
+        help="suggestion阈值,默认: 0.7"
+    )
+    parser.add_argument(
+        "--visualize",
+        action="store_true",
+        default=True,
+        help="运行完成后自动生成可视化HTML"
+    )
+    args = parser.parse_args()
+
+    asyncio.run(main(args.input_dir, max_rounds=args.max_rounds, sug_threshold=args.sug_threshold, visualize=args.visualize))

+ 200 - 0
visualization/sug_v6_1_2_8/README.md

@@ -0,0 +1,200 @@
+# sug_v6_1_2_8 可视化工具
+
+## 概述
+
+这是 `sug_v6_1_2_8.py` 的配套可视化工具。**基于 v6.1.2.5 的 React Flow 可视化引擎**,通过数据转换层将 v6.1.2.8 的轮次数据转换为图结构,实现美观的交互式可视化。
+
+## 🎯 核心特性
+
+### 1. 智能数据转换
+- 自动检测数据格式(v6.1.2.5 或 v6.1.2.8)
+- 将轮次数据(rounds)转换为节点-边图结构
+- 保留完整的轮次信息和来源追踪
+
+### 2. 多类型节点支持
+| 节点类型 | 颜色 | 说明 |
+|---------|------|------|
+| `root` | 紫色 (#6b21a8) | 原始问题根节点 |
+| `seg` | 绿色 (#10b981) | 初始分词结果 |
+| `q` | 蓝色 (#3b82f6) | 查询节点 |
+| `search` | 深紫 (#8b5cf6) | 搜索操作节点 |
+| `note` | 粉色 (#ec4899) | 帖子结果节点 |
+
+### 3. 来源标识
+清楚展示每个Query的来源:
+- **seg** - 来自分词
+- **add** - 加词生成
+- **sug** - 建议词生成
+
+### 4. 交互功能
+继承 v6.1.2.5 的所有交互功能:
+- ✅ 节点拖拽和缩放
+- ✅ 按层级筛选
+- ✅ 节点搜索
+- ✅ 路径高亮
+- ✅ 目录树导航
+- ✅ 节点折叠/展开
+- ✅ 聚焦功能
+
+## 📁 文件结构
+
+```
+visualization/sug_v6_1_2_8/
+├── index.js                  # 主可视化脚本(支持格式检测)
+├── convert_v8_to_graph.js   # 数据转换层
+├── package.json             # 依赖配置
+├── node_modules/            # 依赖包(React Flow, esbuild等)
+└── README.md               # 本文档
+```
+
+## 🚀 使用方式
+
+### 方式1:通过主脚本自动生成
+
+```bash
+# 运行脚本并自动生成可视化
+python sug_v6_1_2_8.py --max-rounds 2 --sug-threshold 0.5 --visualize
+```
+
+### 方式2:手动生成可视化
+
+```bash
+# 从 run_context.json 生成可视化
+node visualization/sug_v6_1_2_8/index.js \
+  input/简单扣图/output/sug_v6_1_2_8/20251031/164016_6e/run_context.json \
+  output.html
+```
+
+## 📊 数据转换说明
+
+### 输入格式(v6.1.2.8)
+
+```json
+{
+  "o": "快速进行图片背景移除和替换",
+  "rounds": [
+    {
+      "round_num": 0,
+      "type": "initialization",
+      "seg_list": [
+        {"text": "快速", "score": 0.1},
+        {"text": "图片", "score": 0.1}
+      ],
+      "q_list_1": [...]
+    },
+    {
+      "round_num": 1,
+      "input_q_list": [...],
+      "output_q_list": [
+        {"text": "快速图片", "score": 0.2, "from": "add"}
+      ],
+      "search_count": 3
+    }
+  ]
+}
+```
+
+### 转换后格式(图结构)
+
+```json
+{
+  "nodes": {
+    "root_o": {
+      "type": "root",
+      "query": "快速进行图片背景移除和替换",
+      "level": 0
+    },
+    "seg_快速_0": {
+      "type": "seg",
+      "query": "快速",
+      "level": 1
+    },
+    "q_快速图片_r2_0": {
+      "type": "q",
+      "query": "快速图片",
+      "level": 2,
+      "from_source": "add"
+    }
+  },
+  "edges": [
+    {
+      "from": "root_o",
+      "to": "seg_快速_0",
+      "edge_type": "root_to_seg"
+    },
+    {
+      "from": "seg_快速_0",
+      "to": "q_快速_r1",
+      "edge_type": "seg_to_q"
+    }
+  ]
+}
+```
+
+## 🎨 可视化效果
+
+### 图布局
+- **水平布局**:从左到右按轮次展开
+- **层次化**:相同轮次的节点纵向排列
+- **自动布局**:使用 dagre 算法自动计算最佳位置
+
+### 节点样式
+- **边框颜色**:根据节点类型区分
+- **标签内容**:显示 Query 文本、分数、策略
+- **特殊标识**:
+  - 未选中的节点:红色 "未选中" 标签
+  - Search 节点:显示搜索次数和帖子数
+
+### 边样式
+- **虚线**:表示不同类型的关系
+- **颜色**:根据策略类型着色
+- **箭头**:指示数据流向
+
+## 📦 依赖
+
+所有依赖已包含在 `node_modules/` 中:
+- `react` + `react-dom` - UI 框架
+- `@xyflow/react` - 流程图库
+- `dagre` - 图布局算法
+- `esbuild` - 打包工具
+- `zustand` - 状态管理
+
+总大小:约 33MB
+
+## 🔧 技术实现
+
+### 转换层(convert_v8_to_graph.js)
+- 解析 rounds 数据
+- 创建图节点和边
+- 维护轮次信息(iterations)
+
+### 可视化层(index.js)
+- 格式检测和自动转换
+- React Flow 渲染
+- 交互功能实现
+
+### 优势
+- ✅ 复用成熟的可视化引擎
+- ✅ 保持视觉一致性
+- ✅ 完整的交互功能
+- ✅ 易于维护和扩展
+
+## 📝 更新日志
+
+### v1.0.0 (2025-10-31)
+- 基于 v6.1.2.5 可视化引擎
+- 实现轮次数据到图结构的转换
+- 支持新的节点类型(root, seg, q, search)
+- 自动格式检测和转换
+- 完整的交互功能
+
+## 🤝 兼容性
+
+| 版本 | 支持 | 说明 |
+|------|------|------|
+| v6.1.2.5 | ✅ | 直接渲染 query_graph.json |
+| v6.1.2.8 | ✅ | 自动转换 run_context.json |
+
+## 📧 问题反馈
+
+如有问题或建议,请查看主项目 README 或提交 Issue。

+ 321 - 0
visualization/sug_v6_1_2_8/convert_v8_to_graph.js

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

+ 549 - 0
visualization/sug_v6_1_2_8/convert_v8_to_graph_v2.js

@@ -0,0 +1,549 @@
+/**
+ * 将 v6.1.2.8 的 run_context.json 转换成按 Round > 步骤 > 数据 组织的图结构
+ */
+
+function convertV8ToGraphV2(runContext, searchResults) {
+  const nodes = {};
+  const edges = [];
+  const iterations = {};
+
+  const o = runContext.o || '原始问题';
+  const rounds = runContext.rounds || [];
+
+  // 添加原始问题根节点
+  const rootId = 'root_o';
+  nodes[rootId] = {
+    type: 'root',
+    query: o,
+    level: 0,
+    relevance_score: 1.0,
+    strategy: '原始问题',
+    iteration: 0,
+    is_selected: true
+  };
+
+  iterations[0] = [rootId];
+
+  // 处理每一轮
+  rounds.forEach((round, roundIndex) => {
+    if (round.type === 'initialization') {
+      // Round 0: 初始化阶段
+      const roundNum = 0;
+      const roundId = `round_${roundNum}`;
+
+      // 创建 Round 节点
+      nodes[roundId] = {
+        type: 'round',
+        query: `Round ${roundNum} (初始化)`,
+        level: roundNum,
+        relevance_score: 0,
+        strategy: '初始化',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: rootId,
+        to: roundId,
+        edge_type: 'root_to_round',
+        strategy: '初始化'
+      });
+
+      if (!iterations[roundNum]) iterations[roundNum] = [];
+      iterations[roundNum].push(roundId);
+
+      // 创建分词步骤节点
+      const segStepId = `step_seg_r${roundNum}`;
+      nodes[segStepId] = {
+        type: 'step',
+        query: `步骤:分词 (${round.seg_list?.length || 0}个分词)`,
+        level: roundNum,
+        relevance_score: 0,
+        strategy: '分词',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: roundId,
+        to: segStepId,
+        edge_type: 'round_to_step',
+        strategy: '分词'
+      });
+
+      iterations[roundNum].push(segStepId);
+
+      // 添加分词结果作为步骤的子节点
+      round.seg_list?.forEach((seg, segIndex) => {
+        const segId = `seg_${seg.text}_${roundNum}_${segIndex}`;
+        nodes[segId] = {
+          type: 'seg',
+          query: seg.text,
+          level: roundNum + 1,
+          relevance_score: seg.score || 0,
+          evaluationReason: seg.reason || '',
+          strategy: '分词结果',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: segStepId,
+          to: segId,
+          edge_type: 'step_to_data',
+          strategy: '分词结果'
+        });
+
+        if (!iterations[roundNum + 1]) iterations[roundNum + 1] = [];
+        iterations[roundNum + 1].push(segId);
+      });
+
+    } else {
+      // 普通轮次
+      const roundNum = round.round_num;
+      const roundId = `round_${roundNum}`;
+
+      // 创建 Round 节点
+      nodes[roundId] = {
+        type: 'round',
+        query: `Round ${roundNum}`,
+        level: roundNum * 10, // 使用10的倍数作为层级
+        relevance_score: 0,
+        strategy: `第${roundNum}轮`,
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: rootId,
+        to: roundId,
+        edge_type: 'root_to_round',
+        strategy: `第${roundNum}轮`
+      });
+
+      if (!iterations[roundNum * 10]) iterations[roundNum * 10] = [];
+      iterations[roundNum * 10].push(roundId);
+
+      // 步骤1: 请求&评估推荐词
+      if (round.sug_details && Object.keys(round.sug_details).length > 0) {
+        const sugStepId = `step_sug_r${roundNum}`;
+        const totalSugs = Object.values(round.sug_details).reduce((sum, list) => sum + list.length, 0);
+
+        nodes[sugStepId] = {
+          type: 'step',
+          query: `步骤1: 请求&评估推荐词 (${totalSugs}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '请求&评估推荐词',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: sugStepId,
+          edge_type: 'round_to_step',
+          strategy: '推荐词'
+        });
+
+        iterations[roundNum * 10].push(sugStepId);
+
+        // 为每个 Q 创建节点
+        Object.keys(round.sug_details).forEach((qText, qIndex) => {
+          // 从q_list_1中查找对应的q获取分数和理由
+          // Round 0: 从q_list_1查找; Round 1+: 从input_q_list查找
+          let qData = {};
+          if (roundNum === 0) {
+            qData = round.q_list_1?.find(q => q.text === qText) || {};
+          } else {
+            // 从当前轮的input_q_list中查找
+            qData = round.input_q_list?.find(q => q.text === qText) || {};
+          }
+          const qId = `q_${qText}_r${roundNum}_${qIndex}`;
+          nodes[qId] = {
+            type: 'q',
+            query: qText,
+            level: roundNum * 10 + 2,
+            relevance_score: qData.score || 0,
+            evaluationReason: qData.reason || '',
+            strategy: 'Query',
+            iteration: roundNum,
+            is_selected: true
+          };
+
+          edges.push({
+            from: sugStepId,
+            to: qId,
+            edge_type: 'step_to_q',
+            strategy: 'Query'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(qId);
+
+          // 为每个 Q 的 sug 创建节点
+          const sugs = round.sug_details[qText] || [];
+          sugs.forEach((sug, sugIndex) => {
+            const sugId = `sug_${sug.text}_r${roundNum}_q${qIndex}_${sugIndex}`;
+            nodes[sugId] = {
+              type: 'sug',
+              query: sug.text,
+              level: roundNum * 10 + 3,
+              relevance_score: sug.score || 0,
+              evaluationReason: sug.reason || '',
+              strategy: '推荐词',
+              iteration: roundNum,
+              is_selected: true
+            };
+
+            edges.push({
+              from: qId,
+              to: sugId,
+              edge_type: 'q_to_sug',
+              strategy: '推荐词'
+            });
+
+            if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+            iterations[roundNum * 10 + 3].push(sugId);
+          });
+        });
+      }
+
+      // 步骤2: 筛选并执行搜索
+      const searchStepId = `step_search_r${roundNum}`;
+      const searchCountText = round.search_count > 0
+        ? `筛选${round.high_score_sug_count}个高分词,搜索${round.search_count}次,${round.total_posts}个帖子`
+        : `无高分推荐词,未执行搜索`;
+
+      nodes[searchStepId] = {
+        type: 'step',
+        query: `步骤2: 筛选并执行搜索 (${searchCountText})`,
+        level: roundNum * 10 + 1,
+        relevance_score: 0,
+        strategy: '筛选并执行搜索',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: roundId,
+        to: searchStepId,
+        edge_type: 'round_to_step',
+        strategy: '搜索'
+      });
+
+      iterations[roundNum * 10].push(searchStepId);
+
+      // 只有在有搜索结果时才添加搜索词和帖子
+      if (round.search_count > 0 && searchResults) {
+        if (Array.isArray(searchResults)) {
+          searchResults.forEach((search, searchIndex) => {
+            const searchWordId = `search_${search.text}_r${roundNum}_${searchIndex}`;
+            nodes[searchWordId] = {
+              type: 'search_word',
+              query: search.text,
+              level: roundNum * 10 + 2,
+              relevance_score: search.score_with_o || 0,
+              strategy: '搜索词',
+              iteration: roundNum,
+              is_selected: true
+            };
+
+            edges.push({
+              from: searchStepId,
+              to: searchWordId,
+              edge_type: 'step_to_search_word',
+              strategy: '搜索词'
+            });
+
+            if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+            iterations[roundNum * 10 + 2].push(searchWordId);
+
+            // 添加帖子
+            if (search.post_list && search.post_list.length > 0) {
+              search.post_list.forEach((post, postIndex) => {
+                const postId = `post_${post.note_id}_${searchIndex}_${postIndex}`;
+                nodes[postId] = {
+                  type: 'post',
+                  query: post.title,
+                  level: roundNum * 10 + 3,
+                  relevance_score: 0,
+                  strategy: '帖子',
+                  iteration: roundNum,
+                  is_selected: true,
+                  note_id: post.note_id,
+                  note_url: post.note_url
+                };
+
+                edges.push({
+                  from: searchWordId,
+                  to: postId,
+                  edge_type: 'search_word_to_post',
+                  strategy: '搜索结果'
+                });
+
+                if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+                iterations[roundNum * 10 + 3].push(postId);
+              });
+            }
+          });
+        }
+      }
+
+      // 步骤3: 加词生成新查询
+      if (round.add_word_details && Object.keys(round.add_word_details).length > 0) {
+        const addWordStepId = `step_add_r${roundNum}`;
+        const totalAddWords = Object.values(round.add_word_details).reduce((sum, list) => sum + list.length, 0);
+
+        nodes[addWordStepId] = {
+          type: 'step',
+          query: `步骤3: 加词生成新查询 (${totalAddWords}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '加词生成新查询',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: addWordStepId,
+          edge_type: 'round_to_step',
+          strategy: '加词'
+        });
+
+        iterations[roundNum * 10].push(addWordStepId);
+
+        // 为每个 Seed 创建节点
+        Object.keys(round.add_word_details).forEach((seedText, seedIndex) => {
+          const seedId = `seed_${seedText}_r${roundNum}_${seedIndex}`;
+          nodes[seedId] = {
+            type: 'seed',
+            query: seedText,
+            level: roundNum * 10 + 2,
+            relevance_score: 0,
+            strategy: 'Seed',
+            iteration: roundNum,
+            is_selected: true
+          };
+
+          edges.push({
+            from: addWordStepId,
+            to: seedId,
+            edge_type: 'step_to_seed',
+            strategy: 'Seed'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(seedId);
+
+          // 为每个 Seed 的组合词创建节点
+          const combinedWords = round.add_word_details[seedText] || [];
+          combinedWords.forEach((word, wordIndex) => {
+            const wordId = `add_${word.text}_r${roundNum}_seed${seedIndex}_${wordIndex}`;
+            nodes[wordId] = {
+              type: 'add_word',
+              query: word.text,
+              level: roundNum * 10 + 3,
+              relevance_score: word.score || 0,
+              evaluationReason: word.reason || '',
+              strategy: '加词生成',
+              iteration: roundNum,
+              is_selected: true,
+              selected_word: word.selected_word
+            };
+
+            edges.push({
+              from: seedId,
+              to: wordId,
+              edge_type: 'seed_to_add_word',
+              strategy: '组合词'
+            });
+
+            if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+            iterations[roundNum * 10 + 3].push(wordId);
+          });
+        });
+      }
+
+      // 步骤4: 筛选推荐词进入下轮
+      const filteredSugs = round.output_q_list?.filter(q => q.from === 'sug') || [];
+      if (filteredSugs.length > 0) {
+        const filterStepId = `step_filter_r${roundNum}`;
+        nodes[filterStepId] = {
+          type: 'step',
+          query: `步骤4: 筛选推荐词进入下轮 (${filteredSugs.length}个)`,
+          level: roundNum * 10 + 1,
+          relevance_score: 0,
+          strategy: '筛选推荐词进入下轮',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: roundId,
+          to: filterStepId,
+          edge_type: 'round_to_step',
+          strategy: '筛选'
+        });
+
+        iterations[roundNum * 10].push(filterStepId);
+
+        // 添加筛选出的sug
+        filteredSugs.forEach((sug, sugIndex) => {
+          const sugId = `filtered_sug_${sug.text}_r${roundNum}_${sugIndex}`;
+          nodes[sugId] = {
+            type: 'filtered_sug',
+            query: sug.text,
+            level: roundNum * 10 + 2,
+            relevance_score: sug.score || 0,
+            strategy: '进入下轮',
+            iteration: roundNum,
+            is_selected: true
+          };
+
+          edges.push({
+            from: filterStepId,
+            to: sugId,
+            edge_type: 'step_to_filtered_sug',
+            strategy: '进入下轮'
+          });
+
+          if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+          iterations[roundNum * 10 + 2].push(sugId);
+        });
+      }
+
+      // 步骤5: 构建下一轮
+      const nextRoundStepId = `step_next_round_r${roundNum}`;
+      const nextQCount = round.output_q_list?.length || 0;
+      const nextSeedCount = round.seed_list_next_size || 0;
+
+      nodes[nextRoundStepId] = {
+        type: 'step',
+        query: `步骤5: 构建下一轮 (${nextQCount}个查询, ${nextSeedCount}个种子)`,
+        level: roundNum * 10 + 1,
+        relevance_score: 0,
+        strategy: '构建下一轮',
+        iteration: roundNum,
+        is_selected: true
+      };
+
+      edges.push({
+        from: roundId,
+        to: nextRoundStepId,
+        edge_type: 'round_to_step',
+        strategy: '构建下一轮'
+      });
+
+      iterations[roundNum * 10].push(nextRoundStepId);
+
+      // 5.1: 构建下轮查询
+      if (round.output_q_list && round.output_q_list.length > 0) {
+        const nextQStepId = `step_next_q_r${roundNum}`;
+        nodes[nextQStepId] = {
+          type: 'step',
+          query: `构建下轮查询 (${nextQCount}个)`,
+          level: roundNum * 10 + 2,
+          relevance_score: 0,
+          strategy: '下轮查询',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: nextRoundStepId,
+          to: nextQStepId,
+          edge_type: 'step_to_step',
+          strategy: '查询'
+        });
+
+        if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+        iterations[roundNum * 10 + 2].push(nextQStepId);
+
+        // 添加下轮查询列表
+        round.output_q_list.forEach((q, qIndex) => {
+          const nextQId = `next_q_${q.text}_r${roundNum}_${qIndex}`;
+          nodes[nextQId] = {
+            type: 'next_q',
+            query: q.text,
+            level: roundNum * 10 + 3,
+            relevance_score: q.score || 0,
+            evaluationReason: q.reason || '',
+            strategy: q.from === 'add' ? '来自加词' : '来自推荐词',
+            iteration: roundNum,
+            is_selected: true,
+            from_source: q.from
+          };
+
+          edges.push({
+            from: nextQStepId,
+            to: nextQId,
+            edge_type: 'step_to_next_q',
+            strategy: q.from === 'add' ? '加词' : '推荐词'
+          });
+
+          if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+          iterations[roundNum * 10 + 3].push(nextQId);
+        });
+      }
+
+      // 5.2: 构建下轮种子(如果有数据的话)
+      if (nextSeedCount > 0 && round.seed_list_next) {
+        const nextSeedStepId = `step_next_seed_r${roundNum}`;
+        nodes[nextSeedStepId] = {
+          type: 'step',
+          query: `构建下轮种子 (${nextSeedCount}个)`,
+          level: roundNum * 10 + 2,
+          relevance_score: 0,
+          strategy: '下轮种子',
+          iteration: roundNum,
+          is_selected: true
+        };
+
+        edges.push({
+          from: nextRoundStepId,
+          to: nextSeedStepId,
+          edge_type: 'step_to_step',
+          strategy: '种子'
+        });
+
+        if (!iterations[roundNum * 10 + 2]) iterations[roundNum * 10 + 2] = [];
+        iterations[roundNum * 10 + 2].push(nextSeedStepId);
+
+        // 添加下轮种子列表
+        round.seed_list_next.forEach((seed, seedIndex) => {
+          const nextSeedId = `next_seed_${seed.text}_r${roundNum}_${seedIndex}`;
+          nodes[nextSeedId] = {
+            type: 'next_seed',
+            query: seed.text,
+            level: roundNum * 10 + 3,
+            relevance_score: seed.score || 0,
+            strategy: seed.from === 'seg' ? '来自分词' : '来自推荐词',
+            iteration: roundNum,
+            is_selected: true,
+            from_source: seed.from
+          };
+
+          edges.push({
+            from: nextSeedStepId,
+            to: nextSeedId,
+            edge_type: 'step_to_next_seed',
+            strategy: seed.from === 'seg' ? '分词' : '推荐词'
+          });
+
+          if (!iterations[roundNum * 10 + 3]) iterations[roundNum * 10 + 3] = [];
+          iterations[roundNum * 10 + 3].push(nextSeedId);
+        });
+      }
+    }
+  });
+
+  return {
+    nodes,
+    edges,
+    iterations
+  };
+}
+
+module.exports = { convertV8ToGraphV2 };

+ 2118 - 0
visualization/sug_v6_1_2_8/index.js

@@ -0,0 +1,2118 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const { build } = require('esbuild');
+const { convertV8ToGraph } = require('./convert_v8_to_graph');
+const { convertV8ToGraphV2 } = require('./convert_v8_to_graph_v2');
+
+// 读取命令行参数
+const args = process.argv.slice(2);
+if (args.length === 0) {
+  console.error('Usage: node index.js <path-to-run_context.json> [output.html]');
+  process.exit(1);
+}
+
+const inputFile = args[0];
+const outputFile = args[1] || 'query_graph_output.html';
+
+// 读取输入数据
+const inputData = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
+
+// 检测数据格式并转换
+let data;
+if (inputData.rounds && inputData.o) {
+  // v6.1.2.8 格式,需要转换
+  console.log('✨ 检测到 v6.1.2.8 格式,正在转换为图结构...');
+
+  // 尝试读取 search_results.json
+  let searchResults = null;
+  const searchResultsPath = path.join(path.dirname(inputFile), 'search_results.json');
+  if (fs.existsSync(searchResultsPath)) {
+    console.log('📄 读取搜索结果数据...');
+    searchResults = JSON.parse(fs.readFileSync(searchResultsPath, 'utf-8'));
+  }
+
+  // 使用新的转换函数(按 Round > 步骤 > 数据 组织)
+  const graphData = convertV8ToGraphV2(inputData, searchResults);
+  data = {
+    nodes: graphData.nodes,
+    edges: graphData.edges,
+    iterations: graphData.iterations
+  };
+  console.log(`✅ 转换完成: ${Object.keys(data.nodes).length} 个节点, ${data.edges.length} 条边`);
+} else if (inputData.nodes && inputData.edges) {
+  // v6.1.2.5 格式,直接使用
+  console.log('✨ 检测到 v6.1.2.5 格式,直接使用');
+  data = inputData;
+} else {
+  console.error('❌ 无法识别的数据格式');
+  process.exit(1);
+}
+
+// 创建临时 React 组件文件
+const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
+const reactComponent = `
+import React, { useState, useCallback, useMemo, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+import {
+  ReactFlow,
+  Controls,
+  Background,
+  useNodesState,
+  useEdgesState,
+  Handle,
+  Position,
+  useReactFlow,
+  ReactFlowProvider,
+} from '@xyflow/react';
+import '@xyflow/react/dist/style.css';
+
+const data = ${JSON.stringify(data, null, 2)};
+
+// 根据节点类型获取边框颜色
+function getNodeTypeColor(type) {
+  const typeColors = {
+    'root': '#6b21a8',        // 紫色 - 根节点
+    'round': '#7c3aed',       // 深紫 - Round节点
+    'step': '#f59e0b',        // 橙色 - 步骤节点
+    'seg': '#10b981',         // 绿色 - 分词
+    'q': '#3b82f6',           // 蓝色 - Query
+    'sug': '#06b6d4',         // 青色 - Sug建议词
+    'seed': '#84cc16',        // 黄绿 - Seed
+    'add_word': '#22c55e',    // 绿色 - 加词生成
+    'search_word': '#8b5cf6', // 紫色 - 搜索词
+    'post': '#ec4899',        // 粉色 - 帖子
+    'filtered_sug': '#14b8a6',// 青绿 - 筛选的sug
+    'next_q': '#2563eb',      // 深蓝 - 下轮查询
+    'next_seed': '#65a30d',   // 深黄绿 - 下轮种子
+    'search': '#8b5cf6',      // 深紫 - 搜索(兼容旧版)
+    'operation': '#f59e0b',   // 橙色 - 操作节点(兼容旧版)
+    'query': '#3b82f6',       // 蓝色 - 查询(兼容旧版)
+    'note': '#ec4899',        // 粉色 - 帖子(兼容旧版)
+  };
+  return typeColors[type] || '#9ca3af';
+}
+
+// 查询节点组件 - 卡片样式
+function QueryNode({ id, data, sourcePosition, targetPosition }) {
+  // 所有节点默认展开
+  const expanded = true;
+
+  // 获取节点类型颜色
+  const typeColor = getNodeTypeColor(data.nodeType || 'query');
+
+  return (
+    <div>
+      <Handle
+        type="target"
+        position={targetPosition || Position.Left}
+        style={{ background: typeColor, width: 8, height: 8 }}
+      />
+      <div
+        style={{
+          padding: '12px',
+          borderRadius: '8px',
+          border: data.isHighlighted ? \`3px solid \${typeColor}\` :
+                  data.isCollapsed ? \`2px solid \${typeColor}\` :
+                  data.isSelected === false ? '2px dashed #d1d5db' :
+                  \`2px solid \${typeColor}\`,
+          background: data.isHighlighted ? '#eef2ff' :
+                      data.isSelected === false ? '#f9fafb' : 'white',
+          minWidth: '200px',
+          maxWidth: '280px',
+          boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
+                     data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
+                     data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
+          transition: 'all 0.3s ease',
+          cursor: 'pointer',
+          position: 'relative',
+          opacity: data.isSelected === false ? 0.6 : 1,
+        }}
+      >
+        {/* 折叠当前节点按钮 - 左边 */}
+        <div
+          style={{
+            position: 'absolute',
+            top: '6px',
+            left: '6px',
+            width: '20px',
+            height: '20px',
+            borderRadius: '50%',
+            background: '#f59e0b',
+            color: 'white',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            fontSize: '11px',
+            fontWeight: 'bold',
+            cursor: 'pointer',
+            transition: 'all 0.2s ease',
+            zIndex: 10,
+          }}
+          onClick={(e) => {
+            e.stopPropagation();
+            if (data.onHideSelf) {
+              data.onHideSelf();
+            }
+          }}
+          onMouseEnter={(e) => {
+            e.currentTarget.style.background = '#d97706';
+          }}
+          onMouseLeave={(e) => {
+            e.currentTarget.style.background = '#f59e0b';
+          }}
+          title="隐藏当前节点"
+        >
+          ×
+        </div>
+
+        {/* 聚焦按钮 - 右上角 */}
+        <div
+          style={{
+            position: 'absolute',
+            top: '6px',
+            right: '6px',
+            width: '20px',
+            height: '20px',
+            borderRadius: '50%',
+            background: data.isFocused ? '#10b981' : '#e5e7eb',
+            color: data.isFocused ? 'white' : '#6b7280',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            fontSize: '11px',
+            fontWeight: 'bold',
+            cursor: 'pointer',
+            transition: 'all 0.2s ease',
+            zIndex: 10,
+          }}
+          onClick={(e) => {
+            e.stopPropagation();
+            if (data.onFocus) {
+              data.onFocus();
+            }
+          }}
+          onMouseEnter={(e) => {
+            if (!data.isFocused) {
+              e.currentTarget.style.background = '#d1d5db';
+            }
+          }}
+          onMouseLeave={(e) => {
+            if (!data.isFocused) {
+              e.currentTarget.style.background = '#e5e7eb';
+            }
+          }}
+          title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
+        >
+          🎯
+        </div>
+
+        {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
+        {data.hasChildren && (
+          <div
+            style={{
+              position: 'absolute',
+              top: '6px',
+              right: '30px',
+              width: '20px',
+              height: '20px',
+              borderRadius: '50%',
+              background: data.isCollapsed ? '#667eea' : '#e5e7eb',
+              color: data.isCollapsed ? 'white' : '#6b7280',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              fontSize: '11px',
+              fontWeight: 'bold',
+              cursor: 'pointer',
+              transition: 'all 0.2s ease',
+              zIndex: 10,
+            }}
+            onClick={(e) => {
+              e.stopPropagation();
+              data.onToggleCollapse();
+            }}
+            title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
+          >
+            {data.isCollapsed ? '+' : '−'}
+          </div>
+        )}
+
+        {/* 卡片内容 */}
+        <div>
+          {/* 标题行 */}
+          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
+            <div style={{ flex: 1 }}>
+              <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
+                <div style={{
+                  fontSize: '13px',
+                  fontWeight: data.level === 0 ? '700' : '600',
+                  color: data.level === 0 ? '#6b21a8' : '#1f2937',
+                  lineHeight: '1.3',
+                  flex: 1,
+                }}>
+                  {data.title}
+                </div>
+                {data.isSelected === false && (
+                  <div style={{
+                    fontSize: '9px',
+                    padding: '1px 4px',
+                    borderRadius: '3px',
+                    background: '#fee2e2',
+                    color: '#991b1b',
+                    fontWeight: '500',
+                    flexShrink: 0,
+                  }}>
+                    未选中
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+
+        {/* 展开的详细信息 - 始终显示 */}
+        <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
+            <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
+              <span style={{
+                display: 'inline-block',
+                padding: '1px 6px',
+                borderRadius: '10px',
+                background: '#eff6ff',
+                color: '#3b82f6',
+                fontSize: '10px',
+                fontWeight: '500',
+              }}>
+                Lv.{data.level}
+              </span>
+              <span style={{
+                display: 'inline-block',
+                padding: '1px 6px',
+                borderRadius: '10px',
+                background: '#f0fdf4',
+                color: '#16a34a',
+                fontSize: '10px',
+                fontWeight: '500',
+              }}>
+                {data.score}
+              </span>
+              {data.strategy && data.strategy !== 'root' && (
+                <span style={{
+                  display: 'inline-block',
+                  padding: '1px 6px',
+                  borderRadius: '10px',
+                  background: '#fef3c7',
+                  color: '#92400e',
+                  fontSize: '10px',
+                  fontWeight: '500',
+                }}>
+                  {data.strategy}
+                </span>
+              )}
+            </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' }}>
+          <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',
+    // v6.1.2.8 新增策略
+    '原始问题': '#6b21a8',
+    '来自分词': '#10b981',
+    '加词生成': '#ef4444',
+    '建议词': '#06b6d4',
+    '执行搜索': '#8b5cf6',
+  };
+  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);
+  const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
+
+  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',
+          }}>
+            <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>
+
+            {/* 分数显示 - 步骤和轮次节点不显示分数 */}
+            {nodeActualType !== 'step' && nodeActualType !== 'round' && (
+              <span style={{
+                fontSize: '11px',
+                color: '#6b7280',
+                fontWeight: '500',
+                flexShrink: 0,
+              }}>
+                {score.toFixed(2)}
+              </span>
+            )}
+          </div>
+
+          {/* 分数下划线 - 步骤和轮次节点不显示 */}
+          {nodeActualType !== 'step' && nodeActualType !== 'round' && (
+            <div style={{
+              width: (score * 100) + '%',
+              height: '2px',
+              background: getScoreColor(score),
+              borderRadius: '1px',
+            }} />
+          )}
+        </div>
+      </div>
+
+      {hasChildren && !isCollapsed && (
+        <div>
+          {children}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 使用 dagre 自动布局
+function getLayoutedElements(nodes, edges, direction = 'LR') {
+  console.log('🎯 Starting layout with dagre...');
+  console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
+
+  // 检查 dagre 是否加载
+  if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
+    console.warn('⚠️ Dagre not loaded, using fallback layout');
+    // 降级到简单布局
+    const levelGroups = {};
+    nodes.forEach(node => {
+      const level = node.data.level || 0;
+      if (!levelGroups[level]) levelGroups[level] = [];
+      levelGroups[level].push(node);
+    });
+
+    Object.entries(levelGroups).forEach(([level, nodeList]) => {
+      const x = parseInt(level) * 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]) => {
+    // 统一处理所有类型的节点
+    const nodeType = node.type || 'query';
+
+    // 直接使用originalId作为canvasId,避免冲突
+    const canvasId = originalId;
+
+    originalIdToCanvasId[originalId] = canvasId;
+
+    // 如果这个 canvasId 还没有创建过节点,则创建
+    if (!canvasIdToNodeData[canvasId]) {
+      canvasIdToNodeData[canvasId] = true;
+
+      // 根据节点类型创建不同的数据结构
+      if (nodeType === 'note') {
+        nodes.push({
+          id: canvasId,
+          originalId: originalId,
+          type: 'note',
+          data: {
+            title: node.title || '帖子',
+            matchLevel: node.match_level,
+            score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
+            description: node.desc || '',
+            isSelected: node.is_selected !== undefined ? node.is_selected : true,
+            imageList: node.image_list || [],
+            noteUrl: node.note_url || '',
+            evaluationReason: node.evaluation_reason || '',
+            nodeType: 'note',
+          },
+          position: { x: 0, y: 0 },
+        });
+      } else {
+        // query, seg, q, search, root 等节点
+        let displayTitle = node.query || originalId;
+
+        nodes.push({
+          id: canvasId,
+          originalId: originalId,
+          type: 'query', // 使用 query 组件渲染所有非note节点
+          data: {
+            title: displayTitle,
+            level: node.level || 0,
+            score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
+            strategy: node.strategy || '',
+            parent: node.parent_query || '',
+            isSelected: node.is_selected !== undefined ? node.is_selected : true,
+            evaluationReason: node.evaluation_reason || '',
+            nodeType: nodeType, // 传递实际节点类型用于样式
+            searchCount: node.search_count, // search 节点特有
+            totalPosts: node.total_posts, // search 节点特有
+          },
+          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]);
+
+  // 生成树形文本结构
+  const generateTreeText = useCallback(() => {
+    const lines = [];
+
+    // 递归生成树形文本
+    const traverse = (nodes, prefix = '', isLast = true, depth = 0) => {
+      nodes.forEach((node, index) => {
+        const isLastNode = index === nodes.length - 1;
+        const nodeData = initialNodes.find(n => n.id === node.id)?.data || {};
+        const nodeType = nodeData.nodeType || node.data?.nodeType || 'unknown';
+        const title = nodeData.title || node.data?.title || node.id;
+
+        // 优先从node.data获取score,然后从nodeData获取
+        let score = null;
+        if (node.data?.score !== undefined) {
+          score = node.data.score;
+        } else if (node.data?.relevance_score !== undefined) {
+          score = node.data.relevance_score;
+        } else if (nodeData.score !== undefined) {
+          score = nodeData.score;
+        } else if (nodeData.relevance_score !== undefined) {
+          score = nodeData.relevance_score;
+        }
+
+        const strategy = nodeData.strategy || node.data?.strategy || '';
+
+        // 构建当前行 - 确保score为数字且不是step/round节点时显示
+        const connector = isLastNode ? '└─' : '├─';
+        const scoreText = (nodeType !== 'step' && nodeType !== 'round' && typeof score === 'number') ?
+                         \` (分数: \${score.toFixed(2)})\` : '';
+        const strategyText = strategy ? \` [\${strategy}]\` : '';
+
+        lines.push(\`\${prefix}\${connector} \${title}\${scoreText}\${strategyText}\`);
+
+        // 递归处理子节点
+        if (node.children && node.children.length > 0) {
+          const childPrefix = prefix + (isLastNode ? '   ' : '│  ');
+          traverse(node.children, childPrefix, isLastNode, depth + 1);
+        }
+      });
+    };
+
+    // 添加标题
+    const rootNode = initialNodes.find(n => n.data?.level === 0);
+    if (rootNode) {
+      lines.push(\`📊 查询扩展树形结构\`);
+      lines.push(\`原始问题: \${rootNode.data.title || rootNode.data.query}\`);
+      lines.push('');
+    }
+
+    traverse(treeRoots);
+
+    return lines.join('\\n');
+  }, [treeRoots, initialNodes]);
+
+  // 复制树形结构到剪贴板
+  const copyTreeToClipboard = useCallback(async () => {
+    try {
+      const treeText = generateTreeText();
+      await navigator.clipboard.writeText(treeText);
+      alert('✅ 树形结构已复制到剪贴板!');
+    } catch (err) {
+      console.error('复制失败:', err);
+      alert('❌ 复制失败,请手动复制');
+    }
+  }, [generateTreeText]);
+
+  // 初始化树节点折叠状态
+  useEffect(() => {
+    const getAllTreeKeys = (nodes) => {
+      const keys = new Set();
+      const traverse = (node) => {
+        if (node.children && node.children.length > 0) {
+          // 排除根节点
+          if (node.data.level !== 0) {
+            keys.add(node.treeKey);
+          }
+          node.children.forEach(traverse);
+        }
+      };
+      nodes.forEach(traverse);
+      return keys;
+    };
+
+    setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
+  }, [treeRoots]);
+
+  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;
+                          const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
+
+                          return (
+                          <React.Fragment key={node.id + '-' + index}>
+                            <span
+                              onClick={() => {
+                                const nodeId = node.id;
+
+                                // 找到所有祖先节点
+                                const ancestorIds = [nodeId];
+                                const findAncestors = (id) => {
+                                  initialEdges.forEach(edge => {
+                                    if (edge.target === id && !ancestorIds.includes(edge.source)) {
+                                      ancestorIds.push(edge.source);
+                                      findAncestors(edge.source);
+                                    }
+                                  });
+                                };
+                                findAncestors(nodeId);
+
+                                // 如果节点或其祖先被隐藏,先恢复它们
+                                setHiddenNodes(prev => {
+                                  const newSet = new Set(prev);
+                                  ancestorIds.forEach(id => newSet.delete(id));
+                                  return newSet;
+                                });
+
+                                // 展开目录树中到达该节点的路径
+                                // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
+                                setCollapsedTreeNodes(prev => {
+                                  const newSet = new Set(prev);
+                                  // 清空所有折叠状态,让目录树完全展开到选中节点
+                                  // 这样可以确保选中节点在目录中可见
+                                  return new Set();
+                                });
+
+                                setSelectedNodeId(nodeId);
+                                setTimeout(() => {
+                                  fitView({
+                                    nodes: [{ id: nodeId }],
+                                    duration: 800,
+                                    padding: 0.3,
+                                  });
+                                }, 100);
+                              }}
+                              style={{
+                                padding: '6px 8px',
+                                borderRadius: '4px',
+                                background: 'white',
+                                border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
+                                color: '#374151',
+                                fontWeight: index === path.length - 1 ? '600' : '400',
+                                width: '180px',
+                                cursor: 'pointer',
+                                transition: 'all 0.2s ease',
+                                position: 'relative',
+                                display: 'inline-flex',
+                                flexDirection: 'column',
+                                gap: '4px',
+                              }}
+                              onMouseEnter={(e) => {
+                                e.currentTarget.style.opacity = '0.8';
+                              }}
+                              onMouseLeave={(e) => {
+                                e.currentTarget.style.opacity = '1';
+                              }}
+                              title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
+                            >
+                              {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
+                              <div style={{
+                                display: 'flex',
+                                alignItems: 'center',
+                                gap: '6px',
+                              }}>
+                                {/* 策略类型竖线 */}
+                                <div style={{
+                                  width: '3px',
+                                  height: '16px',
+                                  background: strategyColor,
+                                  borderRadius: '2px',
+                                  flexShrink: 0,
+                                }} />
+
+                                {/* 节点文字 */}
+                                <span style={{
+                                  flex: 1,
+                                  fontSize: '12px',
+                                  color: nodeIsSelected ? '#374151' : '#ef4444',
+                                }}>
+                                  {truncateMiddle(node.data.title || node.id, 18)}
+                                </span>
+
+                                {/* 分数显示 - 步骤和轮次节点不显示分数 */}
+                                {nodeActualType !== 'step' && nodeActualType !== 'round' && (
+                                  <span style={{
+                                    fontSize: '10px',
+                                    color: '#6b7280',
+                                    fontWeight: '500',
+                                    flexShrink: 0,
+                                  }}>
+                                    {nodeScore.toFixed(2)}
+                                  </span>
+                                )}
+                              </div>
+
+                              {/* 分数下划线 - 步骤和轮次节点不显示 */}
+                              {nodeActualType !== 'step' && nodeActualType !== 'round' && (
+                                <div style={{
+                                  width: (nodeScore * 100) + '%',
+                                  height: '2px',
+                                  background: getScoreColor(nodeScore),
+                                  borderRadius: '1px',
+                                  marginLeft: '9px',
+                                }} />
+                              )}
+                            </span>
+                            {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
+                          </React.Fragment>
+                        )})}
+                      </div>
+                    ))}
+                  </div>
+                );
+              })()}
+            </div>
+          ) : (
+            <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
+              选择一个节点查看路径
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* 主内容区:目录 + 画布 */}
+      <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
+        {/* 左侧目录树 */}
+        <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>
+              <button
+                onClick={copyTreeToClipboard}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #3b82f6',
+                  background: '#3b82f6',
+                  color: 'white',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                  transition: 'all 0.2s',
+                }}
+                onMouseEnter={(e) => e.currentTarget.style.background = '#2563eb'}
+                onMouseLeave={(e) => e.currentTarget.style.background = '#3b82f6'}
+                title="复制树形结构为文本格式"
+              >
+                📋 复制树形结构
+              </button>
+            </div>
+          </div>
+          <div style={{
+            flex: 1,
+            overflowX: 'auto',
+            overflowY: 'auto',
+            padding: '8px',
+          }}>
+            <div style={{ minWidth: 'fit-content' }}>
+              {renderTree(treeRoots)}
+            </div>
+          </div>
+        </div>
+
+        {/* 画布区域 */}
+        <div 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_8/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "sug-v6-1-2-5-visualization",
+  "version": "1.0.0",
+  "description": "可视化工具 for sug_v6_1_2_5.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"
+}