Selaa lähdekoodia

feat: 增强选题点顺序分析和可视化

分析脚本 (analyze_creation_pattern_v5.py):
- 新增人设常量判断步骤
- 边增加 score 字段和推导路径详情
- 路径节点增加节点域字段 (帖子/人设)
- 输出目录改为 point_order_v5

可视化脚本 (build_creation_pattern_v3.py):
- 边上显示分数标签
- 点击边显示推导路径详情
- 路径格式: [域-维度] ●/■ 节点名称
- 人设常量节点显示金色外环和星号
- 修复箭头位置问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 14 tuntia sitten
vanhempi
commit
688b38d268

+ 14 - 16
script/data_processing/analyze_creation_pattern_v3.py

@@ -495,7 +495,9 @@ async def analyze_origin(nodes: List[Dict], force_llm: bool = False) -> Dict:
     # 把分析结果合并到节点
     llm_result = result.data or {}
     output_nodes = []
-    current_order = 1  # 已知节点的发现编号计数
+
+    # 同一个步骤出来的节点使用相同的发现编号
+    step_order = 1  # 起点分析步骤的编号
 
     for node in nodes:
         new_node = dict(node)  # 复制原节点
@@ -509,11 +511,10 @@ async def analyze_origin(nodes: List[Dict], force_llm: bool = False) -> Dict:
                 "分数": score,
                 "说明": analysis,
             }
-            # 高分起点标记为已知
+            # 高分起点标记为已知(同一步骤的节点使用相同编号)
             if score >= ORIGIN_SCORE_THRESHOLD:
                 new_node["是否已知"] = True
-                new_node["发现编号"] = current_order
-                current_order += 1
+                new_node["发现编号"] = step_order
         else:
             new_node["起点分析"] = None
 
@@ -635,13 +636,12 @@ def derive_patterns(
                 new_known_by_round[r] = []
             new_known_by_round[r].append(name)
 
-    # 分配发现编号
+    # 分配发现编号(同一轮次的节点使用相同编号)
     order_map = {}
-    current_order = max_order + 1
     for r in sorted(new_known_by_round.keys()):
+        step_order = max_order + r  # 同一轮次使用相同编号
         for name in new_known_by_round[r]:
-            order_map[name] = current_order
-            current_order += 1
+            order_map[name] = step_order
 
     output_nodes = []
     for node in nodes:
@@ -1023,10 +1023,10 @@ async def process_single_post(
     # 找出当前最大发现编号
     max_order = max((n.get("发现编号") or 0) for n in nodes_step3)
 
-    # 更新节点:把高分候选标记为已知
+    # 更新节点:把高分候选标记为已知(同一步骤的节点使用相同编号)
     nodes_step4 = []
     new_known_names = []
-    current_order = max_order + 1
+    step_order = max_order + 1  # 同一步骤的节点使用相同编号
 
     for node in nodes_step3:
         new_node = dict(node)
@@ -1036,8 +1036,7 @@ async def process_single_post(
         matching = [c for c in high_score_candidates if c["节点名称"] == name]
         if matching and not node.get("是否已知"):
             new_node["是否已知"] = True
-            new_node["发现编号"] = current_order
-            current_order += 1
+            new_node["发现编号"] = step_order  # 同一步骤使用相同编号
             new_known_names.append(name)
 
         nodes_step4.append(new_node)
@@ -1167,12 +1166,12 @@ async def process_single_post(
         candidates_iter4 = next_step_result["下一步候选"]
         high_score_iter4 = [c for c in candidates_iter4 if c["可能性分数"] >= NEXT_STEP_THRESHOLD]
 
-        # 更新节点
+        # 更新节点(同一步骤的节点使用相同编号)
         node_by_name_iter4 = {n["节点名称"]: n for n in nodes_iter3}
         max_order_iter4 = max((n.get("发现编号") or 0) for n in nodes_iter3)
         nodes_iter4 = []
         new_known_iter4 = []
-        current_order_iter4 = max_order_iter4 + 1
+        step_order_iter4 = max_order_iter4 + 1  # 同一步骤的节点使用相同编号
 
         for node in nodes_iter3:
             new_node = dict(node)
@@ -1180,8 +1179,7 @@ async def process_single_post(
             matching = [c for c in high_score_iter4 if c["节点名称"] == name]
             if matching and not node.get("是否已知"):
                 new_node["是否已知"] = True
-                new_node["发现编号"] = current_order_iter4
-                current_order_iter4 += 1
+                new_node["发现编号"] = step_order_iter4  # 同一步骤使用相同编号
                 new_known_iter4.append(name)
             nodes_iter4.append(new_node)
 

+ 14 - 16
script/data_processing/analyze_creation_pattern_v4.py

@@ -495,7 +495,9 @@ async def analyze_origin(nodes: List[Dict], force_llm: bool = False) -> Dict:
     # 把分析结果合并到节点
     llm_result = result.data or {}
     output_nodes = []
-    current_order = 1  # 已知节点的发现编号计数
+
+    # 同一个步骤出来的节点使用相同的发现编号
+    step_order = 1  # 起点分析步骤的编号
 
     for node in nodes:
         new_node = dict(node)  # 复制原节点
@@ -509,11 +511,10 @@ async def analyze_origin(nodes: List[Dict], force_llm: bool = False) -> Dict:
                 "分数": score,
                 "说明": analysis,
             }
-            # 高分起点标记为已知
+            # 高分起点标记为已知(同一步骤的节点使用相同编号)
             if score >= ORIGIN_SCORE_THRESHOLD:
                 new_node["是否已知"] = True
-                new_node["发现编号"] = current_order
-                current_order += 1
+                new_node["发现编号"] = step_order
         else:
             new_node["起点分析"] = None
 
@@ -635,13 +636,12 @@ def derive_patterns(
                 new_known_by_round[r] = []
             new_known_by_round[r].append(name)
 
-    # 分配发现编号
+    # 分配发现编号(同一轮次的节点使用相同编号)
     order_map = {}
-    current_order = max_order + 1
     for r in sorted(new_known_by_round.keys()):
+        step_order = max_order + r  # 同一轮次使用相同编号
         for name in new_known_by_round[r]:
-            order_map[name] = current_order
-            current_order += 1
+            order_map[name] = step_order
 
     output_nodes = []
     for node in nodes:
@@ -1004,10 +1004,10 @@ async def process_single_post(
     # 找出当前最大发现编号
     max_order = max((n.get("发现编号") or 0) for n in nodes_step3)
 
-    # 更新节点:把高分候选标记为已知
+    # 更新节点:把高分候选标记为已知(同一步骤的节点使用相同编号)
     nodes_step4 = []
     new_known_names = []
-    current_order = max_order + 1
+    step_order = max_order + 1  # 同一步骤的节点使用相同编号
 
     for node in nodes_step3:
         new_node = dict(node)
@@ -1017,8 +1017,7 @@ async def process_single_post(
         matching = [c for c in high_score_candidates if c["节点名称"] == name]
         if matching and not node.get("是否已知"):
             new_node["是否已知"] = True
-            new_node["发现编号"] = current_order
-            current_order += 1
+            new_node["发现编号"] = step_order  # 同一步骤使用相同编号
             new_known_names.append(name)
 
         nodes_step4.append(new_node)
@@ -1150,12 +1149,12 @@ async def process_single_post(
         candidates_iter4 = next_step_result["下一步候选"]
         high_score_iter4 = [c for c in candidates_iter4 if c["可能性分数"] >= NEXT_STEP_THRESHOLD]
 
-        # 更新节点
+        # 更新节点(同一步骤的节点使用相同编号)
         node_by_name_iter4 = {n["节点名称"]: n for n in nodes_iter3}
         max_order_iter4 = max((n.get("发现编号") or 0) for n in nodes_iter3)
         nodes_iter4 = []
         new_known_iter4 = []
-        current_order_iter4 = max_order_iter4 + 1
+        step_order_iter4 = max_order_iter4 + 1  # 同一步骤的节点使用相同编号
 
         for node in nodes_iter3:
             new_node = dict(node)
@@ -1163,8 +1162,7 @@ async def process_single_post(
             matching = [c for c in high_score_iter4 if c["节点名称"] == name]
             if matching and not node.get("是否已知"):
                 new_node["是否已知"] = True
-                new_node["发现编号"] = current_order_iter4
-                current_order_iter4 += 1
+                new_node["发现编号"] = step_order_iter4  # 同一步骤使用相同编号
                 new_known_iter4.append(name)
             nodes_iter4.append(new_node)
 

+ 1628 - 0
script/data_processing/analyze_creation_pattern_v5.py

@@ -0,0 +1,1628 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+选题点顺序分析(完整流程)
+
+整合六步流程:
+1. 数据准备:根据帖子图谱 + 人设图谱,提取待分析数据
+2. 人设常量判断:识别人设常量(匹配分数>=0.8 且 全局占比>=0.7)
+3. 起点分析:AI分析创意起点(新版prompt)
+4. 模式推导:基于共现关系的迭代推导
+5. 下一步分析:AI推导下一步最可能的点
+6. 循环:重复步骤4-5直到全部已知
+
+输入:帖子图谱 + 人设图谱
+输出:选题点顺序分析结果
+"""
+
+import asyncio
+import json
+from pathlib import Path
+from typing import Dict, List, Optional, Set
+import sys
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from lib.llm_cached import analyze, LLMConfig, AnalyzeResult
+from lib.my_trace import set_trace_smith as set_trace
+from script.data_processing.path_config import PathConfig
+
+
+# ===== 配置 =====
+TASK_NAME = "creation_pattern_v5"  # 缓存任务名称(保持不变以命中缓存)
+OUTPUT_DIR_NAME = "point_order_v5"  # 输出目录名称
+
+MATCH_SCORE_THRESHOLD = 0.8  # 匹配分数阈值
+GLOBAL_RATIO_THRESHOLD = 0.7  # 全局占比阈值(>=0.7 算常量)
+ORIGIN_SCORE_THRESHOLD = 0.8  # 起点分数阈值
+
+
+# ===== 数据加载 =====
+
+def load_json(file_path: Path) -> Dict:
+    """加载JSON文件"""
+    with open(file_path, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def get_post_graph_files(config: PathConfig) -> List[Path]:
+    """获取所有帖子图谱文件"""
+    post_graph_dir = config.intermediate_dir / "post_graph"
+    return sorted(post_graph_dir.glob("*_帖子图谱.json"))
+
+
+
+
+# ===== 第一步:数据准备 =====
+
+def extract_post_detail(post_graph: Dict) -> Dict:
+    """提取帖子详情"""
+    meta = post_graph.get("meta", {})
+    post_detail = meta.get("postDetail", {})
+
+    return {
+        "postId": meta.get("postId", ""),
+        "postTitle": meta.get("postTitle", ""),
+        "body_text": post_detail.get("body_text", ""),
+        "images": post_detail.get("images", []),
+        "video": post_detail.get("video"),
+        "publish_time": post_detail.get("publish_time", ""),
+        "like_count": post_detail.get("like_count", 0),
+        "collect_count": post_detail.get("collect_count", 0),
+    }
+
+
+def extract_analysis_nodes(post_graph: Dict, persona_graph: Dict) -> tuple:
+    """
+    提取待分析节点列表
+
+    待分析节点 = 灵感点 + 目的点 + 关键点
+    """
+    nodes = post_graph.get("nodes", {})
+    edges = post_graph.get("edges", {})
+    persona_nodes = persona_graph.get("nodes", {})
+    persona_index = persona_graph.get("index", {})
+
+    # 1. 收集关键点信息
+    keypoints = {}
+    for node_id, node in nodes.items():
+        if node.get("type") == "标签" and node.get("dimension") == "关键点":
+            keypoints[node_id] = {
+                "名称": node.get("name", ""),
+                "描述": node.get("detail", {}).get("description", ""),
+            }
+
+    # 2. 分析支撑关系
+    support_map = {}
+    for edge_id, edge in edges.items():
+        if edge.get("type") == "支撑":
+            source_id = edge.get("source", "")
+            target_id = edge.get("target", "")
+            if source_id in keypoints:
+                if target_id not in support_map:
+                    support_map[target_id] = []
+                support_map[target_id].append(keypoints[source_id])
+
+    # 3. 分析关联关系
+    relation_map = {}
+    for edge_id, edge in edges.items():
+        if edge.get("type") == "关联":
+            source_id = edge.get("source", "")
+            target_id = edge.get("target", "")
+            source_name = nodes.get(source_id, {}).get("name", "")
+            target_name = nodes.get(target_id, {}).get("name", "")
+
+            if source_id not in relation_map:
+                relation_map[source_id] = []
+            relation_map[source_id].append(target_name)
+
+            if target_id not in relation_map:
+                relation_map[target_id] = []
+            relation_map[target_id].append(source_name)
+
+    # 4. 分析人设匹配
+    match_map = {}
+    persona_out_edges = persona_index.get("outEdges", {})
+
+    def get_node_info(node_id: str) -> Optional[Dict]:
+        """获取人设节点的标准信息"""
+        node = persona_nodes.get(node_id, {})
+        if not node:
+            return None
+        detail = node.get("detail", {})
+        parent_path = detail.get("parentPath", [])
+        return {
+            "节点ID": node_id,
+            "节点名称": node.get("name", ""),
+            "节点分类": "/".join(parent_path) if parent_path else "",
+            "节点维度": node.get("dimension", ""),
+            "节点类型": node.get("type", ""),
+            "人设全局占比": detail.get("probGlobal", 0),
+            "父类下占比": detail.get("probToParent", 0),
+        }
+
+    def get_parent_category_id(node_id: str) -> Optional[str]:
+        """通过属于边获取父分类节点ID"""
+        belong_edges = persona_out_edges.get(node_id, {}).get("属于", [])
+        for edge in belong_edges:
+            target_id = edge.get("target", "")
+            target_node = persona_nodes.get(target_id, {})
+            if target_node.get("type") == "分类":
+                return target_id
+        return None
+
+    for edge_id, edge in edges.items():
+        if edge.get("type") == "匹配":
+            source_id = edge.get("source", "")
+            target_id = edge.get("target", "")
+
+            if source_id.startswith("帖子:") and target_id.startswith("人设:"):
+                match_score = edge.get("score", 0)
+                persona_node = persona_nodes.get(target_id, {})
+
+                if persona_node:
+                    node_type = persona_node.get("type", "")
+                    match_node_info = get_node_info(target_id)
+                    if not match_node_info:
+                        continue
+
+                    if node_type == "标签":
+                        category_id = get_parent_category_id(target_id)
+                    else:
+                        category_id = target_id
+
+                    category_info = None
+                    if category_id:
+                        category_node = persona_nodes.get(category_id, {})
+                        if category_node:
+                            category_detail = category_node.get("detail", {})
+                            category_path = category_detail.get("parentPath", [])
+                            category_info = {
+                                "节点ID": category_id,
+                                "节点名称": category_node.get("name", ""),
+                                "节点分类": "/".join(category_path) if category_path else "",
+                                "节点维度": category_node.get("dimension", ""),
+                                "节点类型": "分类",
+                                "人设全局占比": category_detail.get("probGlobal", 0),
+                                "父类下占比": category_detail.get("probToParent", 0),
+                                "历史共现分类": [],
+                            }
+
+                            co_occur_edges = persona_out_edges.get(category_id, {}).get("分类共现", [])
+                            co_occur_edges_sorted = sorted(co_occur_edges, key=lambda x: x.get("score", 0), reverse=True)
+                            for co_edge in co_occur_edges_sorted[:5]:
+                                co_target_id = co_edge.get("target", "")
+                                co_score = co_edge.get("score", 0)
+                                co_node = persona_nodes.get(co_target_id, {})
+                                if co_node:
+                                    co_detail = co_node.get("detail", {})
+                                    co_path = co_detail.get("parentPath", [])
+                                    category_info["历史共现分类"].append({
+                                        "节点ID": co_target_id,
+                                        "节点名称": co_node.get("name", ""),
+                                        "节点分类": "/".join(co_path) if co_path else "",
+                                        "节点维度": co_node.get("dimension", ""),
+                                        "节点类型": "分类",
+                                        "人设全局占比": co_detail.get("probGlobal", 0),
+                                        "父类下占比": co_detail.get("probToParent", 0),
+                                        "共现度": round(co_score, 4),
+                                    })
+
+                    if source_id not in match_map:
+                        match_map[source_id] = []
+                    match_map[source_id].append({
+                        "匹配节点": match_node_info,
+                        "匹配分数": round(match_score, 4),
+                        "所属分类": category_info,
+                    })
+
+    # 5. 构建待分析节点列表
+    analysis_nodes = []
+    for node_id, node in nodes.items():
+        if node.get("type") == "标签" and node.get("domain") == "帖子":
+            dimension = node.get("dimension", "")
+            if dimension in ["灵感点", "目的点", "关键点"]:
+                match_info = match_map.get(node_id)
+
+                analysis_nodes.append({
+                    "节点ID": node_id,
+                    "节点名称": node.get("name", ""),
+                    "节点分类": node.get("category", ""),
+                    "节点维度": dimension,
+                    "节点类型": node.get("type", ""),
+                    "节点描述": node.get("detail", {}).get("description", ""),
+                    "人设匹配": match_info,
+                })
+
+    # 6. 构建关系列表
+    relation_list = []
+
+    for edge_id, edge in edges.items():
+        if edge.get("type") == "支撑":
+            source_id = edge.get("source", "")
+            target_id = edge.get("target", "")
+            if source_id in keypoints:
+                relation_list.append({
+                    "来源节点": source_id,
+                    "目标节点": target_id,
+                    "关系类型": "支撑",
+                })
+
+    seen_relations = set()
+    for edge_id, edge in edges.items():
+        if edge.get("type") == "关联":
+            source_id = edge.get("source", "")
+            target_id = edge.get("target", "")
+            key = tuple(sorted([source_id, target_id]))
+            if key not in seen_relations:
+                seen_relations.add(key)
+                relation_list.append({
+                    "来源节点": source_id,
+                    "目标节点": target_id,
+                    "关系类型": "关联",
+                })
+
+    return analysis_nodes, relation_list
+
+
+def prepare_analysis_data(post_graph: Dict, persona_graph: Dict) -> Dict:
+    """
+    准备完整的分析数据
+
+    输出扁平化的节点列表 + 独立的人设共现关系数据
+    节点默认:是人设常量=False,是否已知=False,发现编号=None
+    """
+    analysis_nodes, relation_list = extract_analysis_nodes(post_graph, persona_graph)
+
+    # 扁平化节点,提取人设共现关系数据
+    flat_nodes = []
+    persona_co_occur = {}  # {分类ID: {名称, 共现分类列表}}
+
+    for node in analysis_nodes:
+        # 基础节点字段(是人设常量默认为False)
+        flat_node = {
+            "节点ID": node["节点ID"],
+            "节点名称": node["节点名称"],
+            "节点分类": node.get("节点分类", ""),
+            "节点维度": node["节点维度"],
+            "节点描述": node.get("节点描述", ""),
+            "是否已知": False,
+            "发现编号": None,
+            "是人设常量": False,  # 默认为False,在步骤2判断
+        }
+
+        # 提取人设匹配信息(list格式,支持多个匹配)
+        match_list = node.get("人设匹配") or []
+
+        if match_list:
+            flat_node["人设匹配"] = []
+            for match_info in match_list:
+                match_score = match_info.get("匹配分数", 0)
+                category_info = match_info.get("所属分类")
+                category_id = category_info.get("节点ID") if category_info else None
+
+                # 保留完整的匹配信息,但去掉历史共现分类(拆到外面)
+                clean_match = {
+                    "匹配节点": match_info.get("匹配节点"),
+                    "匹配分数": match_score,
+                }
+                if category_info:
+                    # 复制所属分类,但不包含历史共现分类
+                    clean_category = {k: v for k, v in category_info.items() if k != "历史共现分类"}
+                    clean_match["所属分类"] = clean_category
+
+                flat_node["人设匹配"].append(clean_match)
+
+                # 收集人设共现关系(去重)- 从历史共现分类拆出来
+                if category_id and category_id not in persona_co_occur:
+                    co_occur_list = category_info.get("历史共现分类", [])
+                    if co_occur_list:
+                        persona_co_occur[category_id] = [
+                            {
+                                "节点ID": c.get("节点ID"),
+                                "节点名称": c.get("节点名称"),
+                                "节点分类": c.get("节点分类", ""),
+                                "节点维度": c.get("节点维度", ""),
+                                "节点类型": c.get("节点类型", ""),
+                                "人设全局占比": c.get("人设全局占比", 0),
+                                "父类下占比": c.get("父类下占比", 0),
+                                "共现度": c.get("共现度", 0),
+                            }
+                            for c in co_occur_list
+                            if c.get("节点ID")
+                        ]
+        else:
+            flat_node["人设匹配"] = []
+
+        flat_nodes.append(flat_node)
+
+    return {
+        "帖子详情": extract_post_detail(post_graph),
+        "节点列表": flat_nodes,
+        "关系列表": relation_list,
+        "人设共现关系": persona_co_occur,
+    }
+
+
+# ===== 第二步:人设常量判断 =====
+
+def identify_persona_constants(nodes: List[Dict]) -> Dict:
+    """
+    识别人设常量
+
+    判断条件:匹配分数 >= 0.8 且 所属分类全局占比 >= 0.7
+
+    输入: 节点列表
+    输出: 节点列表(更新了是人设常量、是否已知、发现编号字段)+ 人设常量列表
+    """
+    output_nodes = []
+    persona_constants = []
+
+    for node in nodes:
+        new_node = dict(node)
+
+        # 获取最佳匹配分数和全局占比
+        match_list = node.get("人设匹配") or []
+        best_match_score = 0
+        best_global_ratio = 0
+
+        for match_info in match_list:
+            match_score = match_info.get("匹配分数", 0)
+            category_info = match_info.get("所属分类")
+            global_ratio = category_info.get("人设全局占比", 0) if category_info else 0
+
+            if match_score > best_match_score:
+                best_match_score = match_score
+                best_global_ratio = global_ratio
+
+        # 判断是否为人设常量
+        is_constant = (best_match_score >= MATCH_SCORE_THRESHOLD and
+                       best_global_ratio >= GLOBAL_RATIO_THRESHOLD)
+
+        if is_constant:
+            new_node["是人设常量"] = True
+            new_node["是否已知"] = True
+            new_node["发现编号"] = 1  # 人设常量发现编号为1
+            persona_constants.append(new_node["节点名称"])
+
+        output_nodes.append(new_node)
+
+    return {
+        "输出节点": output_nodes,
+        "人设常量": persona_constants,
+    }
+
+
+# ===== 第三步:起点分析(新版prompt) =====
+
+def get_best_match(node: Dict) -> Optional[Dict]:
+    """获取节点的最佳人设匹配(分数最高的)"""
+    match_list = node.get("人设匹配") or []
+    if not match_list:
+        return None
+    return max(match_list, key=lambda m: m.get("匹配分数", 0))
+
+
+def get_match_score(node: Dict) -> float:
+    """获取节点的最高人设匹配分数"""
+    best_match = get_best_match(node)
+    if best_match:
+        return best_match.get("匹配分数", 0)
+    return 0
+
+
+def get_category_id(node: Dict) -> Optional[str]:
+    """获取节点的所属分类ID(最佳匹配的)"""
+    best_match = get_best_match(node)
+    if best_match:
+        category = best_match.get("所属分类")
+        if category:
+            return category.get("节点ID")
+    return None
+
+
+def get_all_category_ids(node: Dict) -> List[str]:
+    """获取节点所有匹配的分类ID"""
+    match_list = node.get("人设匹配") or []
+    result = []
+    for m in match_list:
+        category = m.get("所属分类")
+        if category and category.get("节点ID"):
+            result.append(category.get("节点ID"))
+    return result
+
+
+def get_category_global_ratio(node: Dict) -> float:
+    """获取节点所属分类的人设全局占比(最佳匹配的)"""
+    best_match = get_best_match(node)
+    if best_match:
+        category = best_match.get("所属分类")
+        if category:
+            return category.get("人设全局占比", 0)
+    return 0
+
+
+def is_persona_constant(node: Dict) -> bool:
+    """判断节点是否为人设常量(匹配分数 >= 0.8 且 分类全局占比 >= 0.7)"""
+    match_score = get_match_score(node)
+    global_ratio = get_category_global_ratio(node)
+    return match_score >= MATCH_SCORE_THRESHOLD and global_ratio >= GLOBAL_RATIO_THRESHOLD
+
+
+def build_origin_context(nodes: List[Dict]) -> Dict:
+    """构造AI分析的上下文(新版格式)"""
+
+    # 所有创意标签(排除人设常量)
+    all_tags = []
+    for node in nodes:
+        if node.get("是人设常量"):
+            continue  # 跳过人设常量
+        all_tags.append({
+            "名称": node["节点名称"],
+            "人设匹配度": round(get_match_score(node), 2),
+            "所属分类全局占比": round(get_category_global_ratio(node), 2),
+        })
+
+    # 起点候选集(灵感点 + 目的点,排除人设常量)
+    candidates = [
+        node["节点名称"]
+        for node in nodes
+        if node["节点维度"] in ["灵感点", "目的点"] and not node.get("是人设常量")
+    ]
+
+    return {
+        "all_tags": all_tags,
+        "candidates": candidates,
+    }
+
+
+def format_origin_prompt(context: Dict) -> str:
+    """格式化起点分析的prompt(新版)"""
+    all_tags = context["all_tags"]
+    candidates = context["candidates"]
+
+    # 创意标签列表
+    tags_text = ""
+    for tag in all_tags:
+        tags_text += f"- {tag['名称']}\n"
+        tags_text += f"  人设匹配度: {tag['人设匹配度']} | 所属分类全局占比: {tag['所属分类全局占比']}\n\n"
+
+    # 起点候选集(一行)
+    candidates_text = "、".join(candidates)
+
+    prompt = f"""# Role
+
+你是小红书爆款内容的"逆向工程"专家。你的核心能力是透过内容的表象,还原创作者最初的脑回路。
+
+# Task
+
+我提供一组笔记的【创意标签】和一个【起点候选集】。
+
+请推理出哪些选项是真正的**创意起点**。
+
+# Input Data
+
+## 创意标签
+
+{tags_text}
+## 起点候选集
+
+{candidates_text}
+
+# 推理约束
+
+- 无法被其他项或人设推理出的点,即为起点(推理关系局限在起点候选集中)
+- 包含/被包含关系代表一种顺序:由大节点推导出被包含节点
+- 目的推理手段
+- 实质推理形式
+- 和人设匹配度越低的帖子是起点概率越大,证明这个起点具备外部性
+
+# Output Format
+
+请输出一个标准的 JSON 格式。
+
+- Key: 候选集中的词。
+- Value: 一个对象,包含:
+  - `score`: 0.0 到 1.0 的浮点数(代表是起点的可能性)。
+  - `analysis`: 一句话推理"""
+
+    return prompt
+
+
+async def analyze_origin(nodes: List[Dict], force_llm: bool = False, log_url: str = None) -> Dict:
+    """
+    执行起点分析
+
+    输入: 节点列表
+    输出: 节点列表(加了起点分析、是否已知、发现编号字段)+ 中间结果
+    """
+    context = build_origin_context(nodes)
+    prompt = format_origin_prompt(context)
+
+    print(f"\n  起点候选: {len(context['candidates'])} 个")
+
+    # 如果没有候选,直接返回
+    if not context['candidates']:
+        print(f"  (无起点候选,跳过LLM分析)")
+        return {
+            "输入上下文": {
+                "创意标签": context["all_tags"],
+                "起点候选": context["candidates"],
+            },
+            "中间结果": {},
+            "输出节点": nodes,
+            "cache_hit": None,
+            "model": None,
+            "log_url": None,
+        }
+
+    result = await analyze(
+        prompt=prompt,
+        task_name=f"{TASK_NAME}/origin",
+        force=force_llm,
+        parse_json=True,
+        log_url=log_url,
+    )
+
+    # 把分析结果合并到节点
+    llm_result = result.data or {}
+    output_nodes = []
+
+    # 同一个步骤出来的节点使用相同的发现编号
+    step_order = 1  # 起点分析步骤的编号
+
+    for node in nodes:
+        new_node = dict(node)  # 复制原节点
+        name = node["节点名称"]
+
+        # 跳过已经是已知的节点(人设常量)
+        if node.get("是否已知"):
+            output_nodes.append(new_node)
+            continue
+
+        if name in llm_result:
+            score = llm_result[name].get("score", 0)
+            analysis = llm_result[name].get("analysis", "")
+            # 加起点分析
+            new_node["起点分析"] = {
+                "分数": score,
+                "说明": analysis,
+            }
+            # 高分起点标记为已知(同一步骤的节点使用相同编号)
+            if score >= ORIGIN_SCORE_THRESHOLD:
+                new_node["是否已知"] = True
+                new_node["发现编号"] = step_order
+        else:
+            new_node["起点分析"] = None
+
+        output_nodes.append(new_node)
+
+    return {
+        "输入上下文": {
+            "创意标签": context["all_tags"],
+            "起点候选": context["candidates"],
+        },
+        "中间结果": llm_result,
+        "输出节点": output_nodes,
+        "cache_hit": result.cache_hit,
+        "model": result.model_name,
+        "log_url": result.log_url,
+    }
+
+
+# ===== 辅助函数 =====
+
+def get_node_domain(node_id: str) -> str:
+    """从节点ID中提取域(帖子/人设)"""
+    if node_id.startswith("帖子:"):
+        return "帖子"
+    elif node_id.startswith("人设:"):
+        return "人设"
+    return ""
+
+
+# ===== 第三步:模式推导 =====
+
+def derive_patterns(
+    nodes: List[Dict],
+    persona_co_occur: Dict[str, Dict],
+) -> Dict:
+    """
+    基于共现关系的迭代推导
+
+    输入: 带起点分析的节点列表 + 人设共现关系数据
+    输出: 节点列表(加了推导轮次、未知原因字段)+ 推导边列表
+    """
+    node_by_name: Dict[str, Dict] = {n["节点名称"]: n for n in nodes}
+
+    # 构建共现查找表 {节点ID: {共现节点ID: {完整信息}}}
+    co_occur_lookup = {}
+    for cat_id, co_occur_list in persona_co_occur.items():
+        co_occur_lookup[cat_id] = {
+            c["节点ID"]: {
+                "共现度": c["共现度"],
+                "节点ID": c.get("节点ID", ""),
+                "节点名称": c.get("节点名称", ""),
+                "节点维度": c.get("节点维度", ""),
+            }
+            for c in co_occur_list
+        }
+
+    def build_path_to_category(node: Dict) -> List[Dict]:
+        """
+        构建从帖子标签到人设分类的路径(包含节点和边信息)
+
+        返回格式: [节点, 边, 节点, 边, 节点, ...]
+        """
+        node_id = node["节点ID"]
+        path = [{
+            "类型": "节点",
+            "节点ID": node_id,
+            "节点名称": node["节点名称"],
+            "节点类型": "标签",
+            "节点维度": node.get("节点维度", ""),
+            "节点域": get_node_domain(node_id),
+        }]
+
+        best_match = get_best_match(node)
+        if not best_match:
+            return path
+
+        match_score = best_match.get("匹配分数", 0)
+        match_node = best_match.get("匹配节点", {})
+        category = best_match.get("所属分类", {})
+
+        # 如果匹配的是标签
+        if match_node:
+            node_type = match_node.get("节点类型", "")
+            if node_type == "标签":
+                # 添加匹配边
+                path.append({
+                    "类型": "边",
+                    "边类型": "匹配",
+                    "分数": match_score,
+                })
+                # 添加人设标签节点
+                match_node_id = match_node.get("节点ID", "")
+                path.append({
+                    "类型": "节点",
+                    "节点ID": match_node_id,
+                    "节点名称": match_node.get("节点名称", ""),
+                    "节点类型": "标签",
+                    "节点维度": match_node.get("节点维度", ""),
+                    "节点域": get_node_domain(match_node_id),
+                })
+                # 添加属于边
+                if category:
+                    path.append({
+                        "类型": "边",
+                        "边类型": "属于",
+                        "分数": 1,
+                    })
+
+        # 添加分类节点
+        if category:
+            # 如果直接匹配的是分类,添加匹配边
+            if not match_node or match_node.get("节点类型") != "标签":
+                path.append({
+                    "类型": "边",
+                    "边类型": "匹配",
+                    "分数": match_score,
+                })
+            category_id = category.get("节点ID", "")
+            path.append({
+                "类型": "节点",
+                "节点ID": category_id,
+                "节点名称": category.get("节点名称", ""),
+                "节点类型": "分类",
+                "节点维度": category.get("节点维度", ""),
+                "节点域": get_node_domain(category_id),
+            })
+
+        return path
+
+    # 1. 初始化已知点集合(已经是已知的节点)
+    known_names: Set[str] = set()
+    node_round: Dict[str, int] = {}  # {节点名称: 加入轮次}
+
+    for node in nodes:
+        if node.get("是否已知"):
+            known_names.add(node["节点名称"])
+            node_round[node["节点名称"]] = 0
+
+    unknown_names: Set[str] = set(node_by_name.keys()) - known_names
+    edges: List[Dict] = []
+
+    # 2. 迭代推导
+    round_num = 0
+    new_known_this_round = known_names.copy()
+
+    while new_known_this_round:
+        round_num += 1
+        new_known_next_round: Set[str] = set()
+
+        for known_name in new_known_this_round:
+            known_node = node_by_name.get(known_name)
+            if not known_node:
+                continue
+
+            if get_match_score(known_node) < MATCH_SCORE_THRESHOLD:
+                continue
+
+            # 获取该节点所属分类的共现列表
+            known_cat_id = get_category_id(known_node)
+            if not known_cat_id or known_cat_id not in co_occur_lookup:
+                continue
+
+            co_occur_map = co_occur_lookup[known_cat_id]
+
+            for unknown_name in list(unknown_names):
+                unknown_node = node_by_name.get(unknown_name)
+                if not unknown_node:
+                    continue
+
+                if get_match_score(unknown_node) < MATCH_SCORE_THRESHOLD:
+                    continue
+
+                # 检查未知节点的分类是否在已知节点的共现列表中
+                unknown_cat_id = get_category_id(unknown_node)
+                if unknown_cat_id and unknown_cat_id in co_occur_map:
+                    co_occur_info = co_occur_map[unknown_cat_id]
+                    co_occur_score = co_occur_info["共现度"]
+                    new_known_next_round.add(unknown_name)
+                    node_round[unknown_name] = round_num
+
+                    # 动态构建推导路径(包含节点和边)
+                    # 来源侧路径: 帖子标签 -匹配-> [人设标签 -属于->] 人设分类
+                    source_path = build_path_to_category(known_node)
+
+                    # 添加共现边
+                    source_path.append({
+                        "类型": "边",
+                        "边类型": "共现",
+                        "分数": co_occur_score,
+                    })
+
+                    # 添加共现分类节点
+                    co_occur_node_id = co_occur_info["节点ID"]
+                    source_path.append({
+                        "类型": "节点",
+                        "节点ID": co_occur_node_id,
+                        "节点名称": co_occur_info["节点名称"],
+                        "节点类型": "分类",
+                        "节点维度": co_occur_info.get("节点维度", ""),
+                        "节点域": get_node_domain(co_occur_node_id),
+                    })
+
+                    # 目标侧路径: 人设分类 -> [人设标签] -> 帖子标签(需要反转)
+                    target_path = build_path_to_category(unknown_node)
+                    target_path.reverse()
+                    # 去掉目标路径的第一个节点(分类),因为已经用共现分类表示
+                    # 但保留边信息
+                    if len(target_path) > 0 and target_path[0].get("类型") == "节点":
+                        target_path = target_path[1:]  # 去掉分类节点,保留后续的边和节点
+
+                    # 合并完整路径
+                    full_path = source_path + target_path
+
+                    edges.append({
+                        "来源": known_node["节点ID"],
+                        "目标": unknown_node["节点ID"],
+                        "关系类型": "共现推导",
+                        "score": co_occur_score,
+                        "推导轮次": round_num,
+                        "推导路径": full_path,
+                    })
+
+        known_names.update(new_known_next_round)
+        unknown_names -= new_known_next_round
+        new_known_this_round = new_known_next_round
+
+        if not new_known_next_round:
+            break
+
+    # 3. 构建输出节点(只更新是否已知、发现编号)
+    # 先找出当前最大发现编号
+    max_order = 0
+    for node in nodes:
+        if node.get("发现编号") and node["发现编号"] > max_order:
+            max_order = node["发现编号"]
+
+    # 按推导轮次排序新发现的节点,分配发现编号
+    new_known_by_round = {}
+    for name, r in node_round.items():
+        if r > 0:  # 排除起点(轮次0)
+            if r not in new_known_by_round:
+                new_known_by_round[r] = []
+            new_known_by_round[r].append(name)
+
+    # 分配发现编号(同一轮次的节点使用相同编号)
+    order_map = {}
+    for r in sorted(new_known_by_round.keys()):
+        step_order = max_order + r  # 同一轮次使用相同编号
+        for name in new_known_by_round[r]:
+            order_map[name] = step_order
+
+    output_nodes = []
+    for node in nodes:
+        new_node = dict(node)
+        name = node["节点名称"]
+
+        # 如果是新推导出来的(非起点),更新已知状态和发现编号
+        if name in node_round and node_round[name] > 0:
+            new_node["是否已知"] = True
+            new_node["发现编号"] = order_map.get(name)
+
+        output_nodes.append(new_node)
+
+    return {
+        "输出节点": output_nodes,
+        "推导边列表": edges,
+        "推导轮次": round_num,
+    }
+
+
+# ===== 第四步:下一步分析 =====
+
+def build_next_step_context(known_nodes: List[Dict], unknown_nodes: List[Dict], all_nodes: List[Dict]) -> Dict:
+    """构造下一步分析的上下文(简化版)"""
+
+    # 已知点信息(按发现顺序排序,只保留名称和维度)
+    known_sorted = sorted(known_nodes, key=lambda n: n.get("发现编号") or 999)
+    known_info = [
+        {"名称": n["节点名称"], "维度": n["节点维度"]}
+        for n in known_sorted
+    ]
+
+    # 未知点信息(只保留名称和维度)
+    unknown_info = [
+        {"名称": n["节点名称"], "维度": n["节点维度"]}
+        for n in unknown_nodes
+    ]
+
+    return {
+        "known_nodes": known_info,
+        "unknown_nodes": unknown_info,
+    }
+
+
+def format_next_step_prompt(context: Dict) -> str:
+    """格式化下一步分析的prompt(简化版)"""
+
+    # 已知点:- 名称 (维度)
+    known_text = "\n".join([
+        f"- {n['名称']} ({n['维度']})"
+        for n in context["known_nodes"]
+    ])
+
+    # 未知点:- 名称 (维度)
+    unknown_text = "\n".join([
+        f"- {n['名称']} ({n['维度']})"
+        for n in context["unknown_nodes"]
+    ])
+
+    prompt = f"""# Role
+
+你是小红书爆款内容的"逆向工程"专家。你的任务是还原创作者的思维路径。
+
+# Task
+
+基于已知的创意点,推理哪些未知点最可能是创作者**下一步直接想到**的点。
+可以有多个点同时被想到(如果它们在逻辑上是并列的)。
+
+## 已知点
+
+{known_text}
+
+## 未知点(待推理)
+
+{unknown_text}
+
+# 推理约束
+
+- 创作者的思维是有逻辑的:先有实质,再想形式
+- 包含/被包含关系代表一种顺序:由大节点推导出被包含节点
+- 只输出"下一步直接能想到"的点,不是所有未知点
+
+# Output Format
+
+输出 JSON,对每个未知点评分:
+
+- Key: 未知点名称
+- Value: 对象,包含:
+  - `score`: 0.0-1.0(下一步被想到的可能性)
+  - `from`: 从哪个已知点推导出来(已知点名称),数组
+  - `reason`: 如何从该已知点推导出来(一句话)"""
+
+    return prompt
+
+
+async def analyze_next_step(
+    nodes: List[Dict],
+    force_llm: bool = False,
+    log_url: str = None,
+) -> Dict:
+    """
+    执行下一步分析
+
+    输入: 节点列表(有已知和未知)
+    输出: 最可能的下一步点列表
+    """
+    # 分离已知和未知
+    known_nodes = [n for n in nodes if n.get("是否已知")]
+    unknown_nodes = [n for n in nodes if not n.get("是否已知")]
+
+    if not unknown_nodes:
+        return {
+            "输入上下文": {"已知点": [], "未知点": []},
+            "中间结果": [],
+            "下一步点": [],
+        }
+
+    context = build_next_step_context(known_nodes, unknown_nodes, nodes)
+    prompt = format_next_step_prompt(context)
+
+    print(f"\n  已知点: {len(known_nodes)} 个")
+    print(f"  未知点: {len(unknown_nodes)} 个")
+
+    result = await analyze(
+        prompt=prompt,
+        task_name=f"{TASK_NAME}/next_step",
+        force=force_llm,
+        parse_json=True,
+        log_url=log_url,
+    )
+
+    # 解析结果(现在是 {name: {score, from, reason}} 格式)
+    llm_result = result.data or {}
+
+    # 构建候选列表,按分数排序
+    candidates = []
+    for name, info in llm_result.items():
+        # from 现在是数组
+        from_list = info.get("from", [])
+        if isinstance(from_list, str):
+            from_list = [from_list]  # 兼容旧格式
+        candidates.append({
+            "节点名称": name,
+            "可能性分数": info.get("score", 0),
+            "推导来源": from_list,
+            "推理说明": info.get("reason", ""),
+        })
+    candidates.sort(key=lambda x: x["可能性分数"], reverse=True)
+
+    return {
+        "输入上下文": {
+            "已知点": context["known_nodes"],
+            "未知点": context["unknown_nodes"],
+        },
+        "中间结果": llm_result,
+        "下一步候选": candidates,
+        "cache_hit": result.cache_hit,
+        "model": result.model_name,
+        "log_url": result.log_url,
+    }
+
+
+# ===== 完整流程 =====
+
+def save_result(post_id: str, post_detail: Dict, steps: List, config: PathConfig) -> Path:
+    """保存结果到文件"""
+    output_dir = config.intermediate_dir / OUTPUT_DIR_NAME
+    output_dir.mkdir(parents=True, exist_ok=True)
+    output_file = output_dir / f"{post_id}_点顺序.json"
+
+    result = {
+        "帖子详情": post_detail,
+        "步骤列表": steps,
+    }
+    with open(output_file, "w", encoding="utf-8") as f:
+        json.dump(result, f, ensure_ascii=False, indent=2)
+
+    print(f"  [已保存] {output_file.name}")
+    return output_file
+
+
+async def process_single_post(
+    post_file: Path,
+    persona_graph: Dict,
+    config: PathConfig,
+    force_llm: bool = False,
+    max_step: int = 6,
+    log_url: str = None,
+) -> Dict:
+    """
+    处理单个帖子
+
+    Args:
+        force_llm: 强制重新调用LLM(跳过LLM缓存)
+        max_step: 最多运行到第几步 (1=数据准备, 2=人设常量判断, 3=起点分析, 4=模式推导, 5=下一步分析, 6=完整循环)
+    """
+    post_graph = load_json(post_file)
+    post_id = post_graph.get("meta", {}).get("postId", "unknown")
+
+    print(f"\n{'=' * 60}")
+    print(f"处理帖子: {post_id}")
+    print("-" * 60)
+
+    steps = []
+
+    # ===== 步骤1:数据准备 =====
+    print("\n[步骤1] 数据准备...")
+    data = prepare_analysis_data(post_graph, persona_graph)
+    post_detail = data["帖子详情"]
+    nodes_step1 = data["节点列表"]
+    relations_step1 = data["关系列表"]
+    persona_co_occur = data["人设共现关系"]
+
+    step1 = {
+        "步骤": "数据准备",
+        "输入": {
+            "帖子图谱": str(post_file.name),
+            "人设图谱": "人设图谱.json",
+        },
+        "输出": {
+            "新的已知节点": [],
+            "新的边": [],
+            "节点列表": nodes_step1,
+            "边列表": relations_step1,
+        },
+        "人设共现关系": persona_co_occur,
+        "摘要": {
+            "节点数": len(nodes_step1),
+            "边数": len(relations_step1),
+            "人设共现数": len(persona_co_occur),
+        },
+    }
+    steps.append(step1)
+    print(f"  节点数: {len(nodes_step1)}")
+    print(f"  关系数: {len(relations_step1)}")
+    print(f"  人设共现数: {len(persona_co_occur)}")
+
+    # 步骤1完成,保存
+    save_result(post_id, post_detail, steps, config)
+
+    if max_step == 1:
+        return {"帖子详情": post_detail, "步骤列表": steps}
+
+    # ===== 步骤2:人设常量判断 =====
+    print("\n[步骤2] 人设常量判断...")
+    constant_result = identify_persona_constants(nodes_step1)
+    nodes_step2 = constant_result["输出节点"]
+    persona_constants = constant_result["人设常量"]
+
+    step2 = {
+        "步骤": "人设常量判断",
+        "输入": {
+            "节点列表": nodes_step1,
+        },
+        "输出": {
+            "新的已知节点": persona_constants,
+            "新的边": [],
+            "节点列表": nodes_step2,
+            "边列表": relations_step1,
+        },
+        "人设常量": persona_constants,
+        "摘要": {
+            "人设常量数": len(persona_constants),
+        },
+    }
+    steps.append(step2)
+    print(f"  人设常量: {len(persona_constants)} 个")
+    if persona_constants:
+        for name in persona_constants:
+            print(f"    ◆ {name}")
+
+    # 步骤2完成,保存
+    save_result(post_id, post_detail, steps, config)
+
+    if max_step == 2:
+        return {"帖子详情": post_detail, "步骤列表": steps}
+
+    # ===== 步骤3:起点分析 =====
+    print("\n[步骤3] 起点分析...")
+    origin_result = await analyze_origin(nodes_step2, force_llm=force_llm, log_url=log_url)
+    nodes_step3 = origin_result["输出节点"]
+
+    # 统计高分起点(排除人设常量)
+    def get_origin_score(node):
+        analysis = node.get("起点分析")
+        if analysis:
+            return analysis.get("分数", 0)
+        return 0
+
+    high_score_origins = [
+        (n["节点名称"], get_origin_score(n))
+        for n in nodes_step3
+        if get_origin_score(n) >= 0.7 and not n.get("是人设常量")
+    ]
+
+    # 新发现的已知节点(起点,不包括人设常量)
+    prev_known = {n["节点名称"] for n in nodes_step2 if n.get("是否已知")}
+    new_known_nodes = [n["节点名称"] for n in nodes_step3 if n.get("是否已知") and n["节点名称"] not in prev_known]
+
+    step3 = {
+        "步骤": "起点分析",
+        "输入": {
+            "节点列表": nodes_step2,
+            "创意标签": origin_result["输入上下文"]["创意标签"],
+            "起点候选": origin_result["输入上下文"]["起点候选"],
+        },
+        "中间结果": origin_result["中间结果"],
+        "输出": {
+            "新的已知节点": new_known_nodes,
+            "新的边": [],
+            "节点列表": nodes_step3,
+            "边列表": relations_step1,  # 边没变化
+        },
+        "摘要": {
+            "新已知数": len(new_known_nodes),
+            "model": origin_result["model"],
+            "cache_hit": origin_result["cache_hit"],
+            "log_url": origin_result.get("log_url"),
+        },
+    }
+    steps.append(step3)
+
+    print(f"  高分起点 (>=0.7): {len(high_score_origins)} 个")
+    for name, score in sorted(high_score_origins, key=lambda x: -x[1]):
+        print(f"    ★ {name}: {score:.2f}")
+
+    # 步骤3完成,保存
+    save_result(post_id, post_detail, steps, config)
+
+    if max_step == 3:
+        return {"帖子详情": post_detail, "步骤列表": steps}
+
+    # ===== 步骤4:模式推导 =====
+    print("\n[步骤4] 模式推导...")
+    derivation_result = derive_patterns(nodes_step3, persona_co_occur)
+    nodes_step4 = derivation_result["输出节点"]
+    edges = derivation_result["推导边列表"]
+
+    # 统计
+    known_count = sum(1 for n in nodes_step4 if n.get("是否已知"))
+    unknown_count = len(nodes_step4) - known_count
+
+    # 新发现的已知节点(本步骤推导出来的,不包括之前的起点)
+    prev_known = {n["节点名称"] for n in nodes_step3 if n.get("是否已知")}
+    new_known_nodes = [n["节点名称"] for n in nodes_step4 if n.get("是否已知") and n["节点名称"] not in prev_known]
+
+    # 合并边列表(原有边 + 推导边)
+    all_edges = relations_step1 + edges
+
+    step4 = {
+        "步骤": "模式推导",
+        "输入": {
+            "节点列表": nodes_step3,
+            "人设共现关系": persona_co_occur,
+        },
+        "输出": {
+            "新的已知节点": new_known_nodes,
+            "新的边": edges,
+            "节点列表": nodes_step4,
+            "边列表": all_edges,
+        },
+        "摘要": {
+            "已知点数": known_count,
+            "新已知数": len(new_known_nodes),
+            "新边数": len(edges),
+            "未知点数": unknown_count,
+        },
+    }
+    steps.append(step4)
+
+    print(f"  已知点: {known_count} 个")
+    print(f"  推导边: {len(edges)} 条")
+    print(f"  未知点: {unknown_count} 个")
+
+    # 步骤4完成,保存
+    save_result(post_id, post_detail, steps, config)
+
+    if max_step == 4:
+        return {"帖子详情": post_detail, "步骤列表": steps}
+
+    # ===== 步骤5:下一步分析 =====
+    print("\n[步骤5] 下一步分析...")
+    next_step_result = await analyze_next_step(nodes_step4, force_llm=force_llm, log_url=log_url)
+
+    # 获取候选列表
+    candidates = next_step_result["下一步候选"]
+
+    # 筛选高分候选 (>= 0.8)
+    NEXT_STEP_THRESHOLD = 0.8
+    high_score_candidates = [c for c in candidates if c["可能性分数"] >= NEXT_STEP_THRESHOLD]
+
+    # 构建节点名称到节点的映射
+    node_by_name = {n["节点名称"]: n for n in nodes_step4}
+
+    # 找出当前最大发现编号
+    max_order = max((n.get("发现编号") or 0) for n in nodes_step4)
+
+    # 更新节点:把高分候选标记为已知(同一步骤的节点使用相同编号)
+    nodes_step5 = []
+    new_known_names = []
+    step_order = max_order + 1  # 同一步骤的节点使用相同编号
+
+    for node in nodes_step4:
+        new_node = dict(node)
+        name = node["节点名称"]
+
+        # 检查是否在高分候选中
+        matching = [c for c in high_score_candidates if c["节点名称"] == name]
+        if matching and not node.get("是否已知"):
+            new_node["是否已知"] = True
+            new_node["发现编号"] = step_order  # 同一步骤使用相同编号
+            new_known_names.append(name)
+
+        nodes_step5.append(new_node)
+
+    # 创建新的边(推导边,from 是数组,为每个来源创建一条边)
+    new_edges = []
+    for c in high_score_candidates:
+        target_node = node_by_name.get(c["节点名称"])
+        if not target_node:
+            continue
+        for source_name in c["推导来源"]:
+            source_node = node_by_name.get(source_name)
+            if source_node:
+                new_edges.append({
+                    "来源": source_node["节点ID"],
+                    "目标": target_node["节点ID"],
+                    "关系类型": "AI推导",
+                    "score": c["可能性分数"],
+                    "推理说明": c["推理说明"],
+                    "推导路径": [
+                        {
+                            "类型": "节点",
+                            "节点ID": source_node["节点ID"],
+                            "节点名称": source_node["节点名称"],
+                            "节点类型": "标签",
+                            "节点维度": source_node["节点维度"],
+                            "节点域": get_node_domain(source_node["节点ID"]),
+                        },
+                        {
+                            "类型": "边",
+                            "边类型": "AI推导",
+                            "分数": c["可能性分数"],
+                        },
+                        {
+                            "类型": "节点",
+                            "节点ID": target_node["节点ID"],
+                            "节点名称": target_node["节点名称"],
+                            "节点类型": "标签",
+                            "节点维度": target_node["节点维度"],
+                            "节点域": get_node_domain(target_node["节点ID"]),
+                        },
+                    ],
+                })
+
+    # 合并边列表
+    all_edges_step5 = all_edges + new_edges
+
+    step5 = {
+        "步骤": "下一步分析",
+        "输入": {
+            "已知点": next_step_result["输入上下文"]["已知点"],
+            "未知点": next_step_result["输入上下文"]["未知点"],
+        },
+        "中间结果": next_step_result["中间结果"],
+        "输出": {
+            "新的已知节点": new_known_names,
+            "新的边": new_edges,
+            "节点列表": nodes_step5,
+            "边列表": all_edges_step5,
+        },
+        "摘要": {
+            "已知点数": sum(1 for n in nodes_step5 if n.get("是否已知")),
+            "新已知数": len(new_known_names),
+            "新边数": len(new_edges),
+            "未知点数": sum(1 for n in nodes_step5 if not n.get("是否已知")),
+            "model": next_step_result.get("model"),
+            "cache_hit": next_step_result.get("cache_hit"),
+            "log_url": next_step_result.get("log_url"),
+        },
+    }
+    steps.append(step5)
+
+    # 打印高分候选
+    print(f"  候选数: {len(candidates)} 个")
+    print(f"  高分候选 (>={NEXT_STEP_THRESHOLD}): {len(high_score_candidates)} 个")
+    for c in high_score_candidates:
+        from_str = " & ".join(c["推导来源"])
+        print(f"    ★ {c['节点名称']} ({c['可能性分数']:.2f}) ← {from_str}")
+        print(f"      {c['推理说明']}")
+
+    # 步骤5完成,保存
+    save_result(post_id, post_detail, steps, config)
+
+    if max_step == 5:
+        return {"帖子详情": post_detail, "步骤列表": steps}
+
+    # ===== 循环:步骤4→步骤5 直到全部已知 =====
+    iteration = 1
+    current_nodes = nodes_step5
+    current_edges = all_edges_step5
+    MAX_ITERATIONS = 10  # 防止无限循环
+
+    while True:
+        # 检查是否还有未知节点
+        unknown_count = sum(1 for n in current_nodes if not n.get("是否已知"))
+        if unknown_count == 0:
+            print(f"\n[完成] 所有节点已变为已知")
+            break
+
+        if iteration > MAX_ITERATIONS:
+            print(f"\n[警告] 达到最大迭代次数 {MAX_ITERATIONS},停止循环")
+            break
+
+        # ===== 迭代步骤3:共现推导 =====
+        print(f"\n[迭代{iteration}-步骤3] 模式推导...")
+        derivation_result = derive_patterns(current_nodes, persona_co_occur)
+        nodes_iter3 = derivation_result["输出节点"]
+        edges_iter3 = derivation_result["推导边列表"]
+
+        # 统计新推导的
+        prev_known_names = {n["节点名称"] for n in current_nodes if n.get("是否已知")}
+        new_known_step3 = [n["节点名称"] for n in nodes_iter3 if n.get("是否已知") and n["节点名称"] not in prev_known_names]
+        new_edges_step3 = edges_iter3  # derive_patterns 返回的是本轮新增的边
+
+        all_edges_iter3 = current_edges + new_edges_step3
+
+        step_iter3 = {
+            "步骤": f"迭代{iteration}-模式推导",
+            "输入": {
+                "节点列表": current_nodes,
+                "人设共现关系": persona_co_occur,
+            },
+            "输出": {
+                "新的已知节点": new_known_step3,
+                "新的边": new_edges_step3,
+                "节点列表": nodes_iter3,
+                "边列表": all_edges_iter3,
+            },
+            "摘要": {
+                "已知点数": sum(1 for n in nodes_iter3 if n.get("是否已知")),
+                "新已知数": len(new_known_step3),
+                "新边数": len(new_edges_step3),
+                "未知点数": sum(1 for n in nodes_iter3 if not n.get("是否已知")),
+            },
+        }
+        steps.append(step_iter3)
+
+        print(f"  新已知: {len(new_known_step3)} 个")
+        print(f"  新边: {len(new_edges_step3)} 条")
+
+        save_result(post_id, post_detail, steps, config)
+
+        # 检查是否还有未知
+        unknown_after_step3 = sum(1 for n in nodes_iter3 if not n.get("是否已知"))
+        if unknown_after_step3 == 0:
+            print(f"\n[完成] 所有节点已变为已知")
+            break
+
+        # ===== 迭代步骤4:AI推导 =====
+        print(f"\n[迭代{iteration}-步骤4] 下一步分析...")
+        next_step_result = await analyze_next_step(nodes_iter3, force_llm=force_llm, log_url=log_url)
+        candidates_iter4 = next_step_result["下一步候选"]
+        high_score_iter4 = [c for c in candidates_iter4 if c["可能性分数"] >= NEXT_STEP_THRESHOLD]
+
+        # 更新节点(同一步骤的节点使用相同编号)
+        node_by_name_iter4 = {n["节点名称"]: n for n in nodes_iter3}
+        max_order_iter4 = max((n.get("发现编号") or 0) for n in nodes_iter3)
+        nodes_iter4 = []
+        new_known_iter4 = []
+        step_order_iter4 = max_order_iter4 + 1  # 同一步骤的节点使用相同编号
+
+        for node in nodes_iter3:
+            new_node = dict(node)
+            name = node["节点名称"]
+            matching = [c for c in high_score_iter4 if c["节点名称"] == name]
+            if matching and not node.get("是否已知"):
+                new_node["是否已知"] = True
+                new_node["发现编号"] = step_order_iter4  # 同一步骤使用相同编号
+                new_known_iter4.append(name)
+            nodes_iter4.append(new_node)
+
+        # 创建新边(from 是数组,为每个来源创建一条边)
+        new_edges_iter4 = []
+        for c in high_score_iter4:
+            target_node = node_by_name_iter4.get(c["节点名称"])
+            if not target_node:
+                continue
+            for source_name in c["推导来源"]:
+                source_node = node_by_name_iter4.get(source_name)
+                if source_node:
+                    new_edges_iter4.append({
+                        "来源": source_node["节点ID"],
+                        "目标": target_node["节点ID"],
+                        "关系类型": "AI推导",
+                        "score": c["可能性分数"],
+                        "推理说明": c["推理说明"],
+                        "推导路径": [
+                            {
+                                "类型": "节点",
+                                "节点ID": source_node["节点ID"],
+                                "节点名称": source_node["节点名称"],
+                                "节点类型": "标签",
+                                "节点维度": source_node["节点维度"],
+                                "节点域": get_node_domain(source_node["节点ID"]),
+                            },
+                            {
+                                "类型": "边",
+                                "边类型": "AI推导",
+                                "分数": c["可能性分数"],
+                            },
+                            {
+                                "类型": "节点",
+                                "节点ID": target_node["节点ID"],
+                                "节点名称": target_node["节点名称"],
+                                "节点类型": "标签",
+                                "节点维度": target_node["节点维度"],
+                                "节点域": get_node_domain(target_node["节点ID"]),
+                            },
+                        ],
+                    })
+
+        all_edges_iter4 = all_edges_iter3 + new_edges_iter4
+
+        step_iter4 = {
+            "步骤": f"迭代{iteration}-下一步分析",
+            "输入": {
+                "已知点": next_step_result["输入上下文"]["已知点"],
+                "未知点": next_step_result["输入上下文"]["未知点"],
+            },
+            "中间结果": next_step_result["中间结果"],
+            "输出": {
+                "新的已知节点": new_known_iter4,
+                "新的边": new_edges_iter4,
+                "节点列表": nodes_iter4,
+                "边列表": all_edges_iter4,
+            },
+            "摘要": {
+                "已知点数": sum(1 for n in nodes_iter4 if n.get("是否已知")),
+                "新已知数": len(new_known_iter4),
+                "新边数": len(new_edges_iter4),
+                "未知点数": sum(1 for n in nodes_iter4 if not n.get("是否已知")),
+                "model": next_step_result.get("model"),
+                "cache_hit": next_step_result.get("cache_hit"),
+            },
+        }
+        steps.append(step_iter4)
+
+        print(f"  新已知: {len(new_known_iter4)} 个")
+        print(f"  新边: {len(new_edges_iter4)} 条")
+
+        save_result(post_id, post_detail, steps, config)
+
+        # 如果这轮没有新进展,停止
+        if len(new_known_step3) == 0 and len(new_known_iter4) == 0:
+            print(f"\n[停止] 本轮无新进展,停止循环")
+            break
+
+        # 更新状态,进入下一轮
+        current_nodes = nodes_iter4
+        current_edges = all_edges_iter4
+        iteration += 1
+
+    return {"帖子详情": post_detail, "步骤列表": steps}
+
+
+# ===== 主函数 =====
+
+async def main(
+    post_id: str = None,
+    all_posts: bool = False,
+    force_llm: bool = False,
+    max_step: int = 6,
+):
+    """主函数"""
+    _, log_url = set_trace()
+
+    config = PathConfig()
+
+    print(f"账号: {config.account_name}")
+    print(f"Trace URL: {log_url}")
+    print(f"输出目录: {OUTPUT_DIR_NAME}")
+
+    # 加载人设图谱
+    persona_graph_file = config.intermediate_dir / "人设图谱.json"
+    if not persona_graph_file.exists():
+        print(f"错误: 人设图谱文件不存在: {persona_graph_file}")
+        return
+
+    persona_graph = load_json(persona_graph_file)
+    print(f"人设图谱节点数: {len(persona_graph.get('nodes', {}))}")
+
+    # 获取帖子图谱文件
+    post_graph_files = get_post_graph_files(config)
+    if not post_graph_files:
+        print("错误: 没有找到帖子图谱文件")
+        return
+
+    # 确定要处理的帖子
+    if post_id:
+        target_file = next(
+            (f for f in post_graph_files if post_id in f.name),
+            None
+        )
+        if not target_file:
+            print(f"错误: 未找到帖子 {post_id}")
+            return
+        files_to_process = [target_file]
+    elif all_posts:
+        files_to_process = post_graph_files
+    else:
+        files_to_process = [post_graph_files[0]]
+
+    print(f"待处理帖子数: {len(files_to_process)}")
+
+    # 处理
+    results = []
+    for i, post_file in enumerate(files_to_process, 1):
+        print(f"\n{'#' * 60}")
+        print(f"# 处理帖子 {i}/{len(files_to_process)}")
+        print(f"{'#' * 60}")
+
+        result = await process_single_post(
+            post_file=post_file,
+            persona_graph=persona_graph,
+            config=config,
+            force_llm=force_llm,
+            max_step=max_step,
+            log_url=log_url,
+        )
+        results.append(result)
+
+    # 汇总
+    print(f"\n{'#' * 60}")
+    print(f"# 完成! 共处理 {len(results)} 个帖子")
+    print(f"{'#' * 60}")
+    print(f"Trace: {log_url}")
+
+    print("\n汇总:")
+    for result in results:
+        post_id = result["帖子详情"]["postId"]
+        steps = result.get("步骤列表", [])
+        num_steps = len(steps)
+
+        if num_steps == 1:
+            step1_summary = steps[0].get("摘要", {})
+            print(f"  {post_id}: 节点数={step1_summary.get('节点数', 0)}, "
+                  f"人设常量={step1_summary.get('人设常量数', 0)} (仅数据准备)")
+        elif num_steps == 2:
+            step2_summary = steps[1].get("摘要", {})
+            print(f"  {post_id}: 起点={step2_summary.get('新已知数', 0)} (未推导)")
+        elif num_steps == 3:
+            step3_summary = steps[2].get("摘要", {})
+            print(f"  {post_id}: 已知={step3_summary.get('已知点数', 0)}, "
+                  f"未知={step3_summary.get('未知点数', 0)}")
+        elif num_steps >= 4:
+            step4_summary = steps[3].get("摘要", {})
+            print(f"  {post_id}: 已知={step4_summary.get('已知点数', 0)}, "
+                  f"新已知={step4_summary.get('新已知数', 0)}, "
+                  f"新边={step4_summary.get('新边数', 0)}, "
+                  f"未知={step4_summary.get('未知点数', 0)}")
+        else:
+            print(f"  {post_id}: 无步骤数据")
+
+
+if __name__ == "__main__":
+    import argparse
+
+    parser = argparse.ArgumentParser(description="选题点顺序分析")
+    parser.add_argument("--post-id", type=str, help="帖子ID")
+    parser.add_argument("--all-posts", action="store_true", help="处理所有帖子")
+    parser.add_argument("--force-llm", action="store_true", help="强制重新调用LLM(跳过LLM缓存)")
+    parser.add_argument("--step", type=int, default=6, choices=[1, 2, 3, 4, 5, 6],
+                        help="运行到第几步 (1=数据准备, 2=人设常量判断, 3=起点分析, 4=模式推导, 5=下一步分析, 6=完整循环)")
+    args = parser.parse_args()
+
+    asyncio.run(main(
+        post_id=args.post_id,
+        all_posts=args.all_posts,
+        force_llm=args.force_llm,
+        max_step=args.step,
+    ))

+ 532 - 0
script/visualization/build_creation_pattern_layered.py

@@ -0,0 +1,532 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+构建创作思维路径可视化 - 分层版本
+
+基于原版样式,改为从上到下分层布局,去掉动画
+"""
+
+import json
+import sys
+from pathlib import Path
+
+# 项目路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+
+
+HTML_TEMPLATE = '''<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>创作思维路径可视化</title>
+  <script src="https://d3js.org/d3.v7.min.js"></script>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+      background: #1a1a2e;
+      color: #eee;
+      overflow: hidden;
+    }
+    .container { display: flex; flex-direction: column; height: 100vh; }
+    header {
+      padding: 12px 20px;
+      background: #16213e;
+      border-bottom: 1px solid #0f3460;
+      display: flex;
+      align-items: center;
+      gap: 20px;
+    }
+    h1 { font-size: 16px; font-weight: 500; color: #e94560; }
+    .legend { display: flex; gap: 16px; font-size: 12px; color: #888; margin-left: auto; }
+    .legend-item { display: flex; align-items: center; gap: 4px; }
+    .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
+    .legend-line { width: 20px; height: 2px; }
+
+    /* 主内容区:左右布局 */
+    main { flex: 1; display: flex; overflow: hidden; }
+    .graph-area { flex: 1; position: relative; overflow: auto; }
+    .graph-area svg { display: block; }
+
+    /* 右侧面板 */
+    .right-panel {
+      width: 360px;
+      background: #16213e;
+      border-left: 1px solid #0f3460;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+    }
+
+    /* 帖子摘要区 */
+    .post-summary {
+      padding: 16px;
+      border-bottom: 1px solid #0f3460;
+      max-height: 200px;
+      overflow-y: auto;
+    }
+    .post-title {
+      font-size: 14px;
+      font-weight: 600;
+      color: #e94560;
+      margin-bottom: 8px;
+      line-height: 1.4;
+    }
+    .post-body {
+      font-size: 12px;
+      color: #aaa;
+      line-height: 1.6;
+      max-height: 100px;
+      overflow-y: auto;
+    }
+    .post-meta {
+      margin-top: 8px;
+      font-size: 11px;
+      color: #666;
+      display: flex;
+      gap: 12px;
+    }
+
+    /* 步骤时间轴 */
+    .timeline {
+      flex: 1;
+      overflow-y: auto;
+      padding: 16px;
+    }
+    .timeline-title {
+      font-size: 13px;
+      font-weight: 600;
+      color: #888;
+      margin-bottom: 12px;
+    }
+    .timeline-item {
+      position: relative;
+      padding-left: 24px;
+      padding-bottom: 16px;
+      border-left: 2px solid #0f3460;
+      margin-left: 8px;
+      cursor: pointer;
+      transition: all 0.2s;
+    }
+    .timeline-item:hover {
+      background: rgba(233, 69, 96, 0.1);
+      margin-left: 6px;
+      padding-left: 26px;
+    }
+    .timeline-item:last-child { border-left-color: transparent; }
+    .timeline-item.active { border-left-color: #e94560; }
+    .timeline-dot {
+      position: absolute;
+      left: -7px;
+      top: 0;
+      width: 12px;
+      height: 12px;
+      border-radius: 50%;
+      border: 2px solid #0f3460;
+      background: #1a1a2e;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 8px;
+      font-weight: bold;
+      color: white;
+    }
+    .timeline-item.active .timeline-dot {
+      border-color: #e94560;
+      background: #e94560;
+    }
+    .timeline-node-name {
+      font-size: 13px;
+      font-weight: 500;
+      color: #eee;
+      margin-bottom: 4px;
+    }
+    .timeline-node-dim {
+      font-size: 11px;
+      padding: 2px 6px;
+      border-radius: 3px;
+      display: inline-block;
+      margin-bottom: 6px;
+    }
+    .timeline-node-dim.灵感点 { background: rgba(255,107,107,0.2); color: #ff6b6b; }
+    .timeline-node-dim.目的点 { background: rgba(78,205,196,0.2); color: #4ecdc4; }
+    .timeline-node-dim.关键点 { background: rgba(255,230,109,0.2); color: #ffe66d; }
+    .timeline-reason {
+      font-size: 11px;
+      color: #888;
+      line-height: 1.5;
+      background: #0f3460;
+      padding: 8px;
+      border-radius: 4px;
+    }
+    .timeline-from {
+      font-size: 11px;
+      color: #666;
+      margin-bottom: 4px;
+    }
+    .timeline-from span { color: #e94560; }
+
+    /* 节点样式 */
+    .node { cursor: pointer; }
+    .node circle { stroke-width: 2px; transition: all 0.3s; }
+    .node.highlight circle { stroke-width: 4px; filter: drop-shadow(0 0 12px currentColor); }
+    .node text { font-size: 11px; fill: #ccc; text-anchor: middle; pointer-events: none; }
+    .node .order-badge { font-size: 10px; font-weight: bold; fill: white; }
+    .link { stroke-opacity: 0.6; transition: all 0.3s; }
+    .link.highlight { stroke-opacity: 1; stroke-width: 3px; }
+    .link-label { font-size: 9px; fill: #888; pointer-events: none; }
+
+    /* 层级标签 */
+    .layer-label {
+      font-size: 11px;
+      fill: #555;
+    }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <header>
+      <h1>创作思维路径</h1>
+      <div class="legend">
+        <div class="legend-item"><span class="legend-dot" style="background: #ff6b6b;"></span><span>灵感点</span></div>
+        <div class="legend-item"><span class="legend-dot" style="background: #4ecdc4;"></span><span>目的点</span></div>
+        <div class="legend-item"><span class="legend-dot" style="background: #ffe66d;"></span><span>关键点</span></div>
+        <div class="legend-item"><span class="legend-line" style="background: #e94560;"></span><span>AI推导</span></div>
+      </div>
+    </header>
+    <main>
+      <div class="graph-area">
+        <svg id="graph"></svg>
+      </div>
+      <div class="right-panel">
+        <div class="post-summary" id="postSummary"></div>
+        <div class="timeline" id="timeline">
+          <div class="timeline-title">推导过程</div>
+        </div>
+      </div>
+    </main>
+  </div>
+
+  <script>
+    const DATA = __DATA_PLACEHOLDER__;
+
+    const dimColors = { '灵感点': '#ff6b6b', '目的点': '#4ecdc4', '关键点': '#ffe66d' };
+
+    // 提取数据
+    const postDetail = DATA['帖子详情'];
+    const steps = DATA['步骤列表'];
+    const lastStep = steps[steps.length - 1];
+    const nodes = lastStep['输出']['节点列表'];
+    const edges = lastStep['输出']['边列表'].filter(e => e['关系类型'] === 'AI推导');
+
+    // 构建边的查找表(目标节点 -> 边)
+    const edgeByTarget = {};
+    edges.forEach(e => { edgeByTarget[e['目标']] = e; });
+
+    const nodeById = {};
+    const graphNodes = nodes.filter(n => n['发现编号'] !== undefined && n['发现编号'] !== null).map(n => {
+      const node = {
+        id: n['节点ID'],
+        name: n['节点名称'],
+        dimension: n['节点维度'],
+        category: n['节点分类'],
+        description: n['节点描述'],
+        order: n['发现编号'],
+        isKnown: n['是否已知']
+      };
+      nodeById[node.id] = node;
+      return node;
+    });
+
+    const graphLinks = edges.map(e => ({
+      source: e['来源'],
+      target: e['目标'],
+      type: e['关系类型'],
+      score: e['可能性分数'],
+      reason: e['推理说明']
+    })).filter(e => nodeById[e.source] && nodeById[e.target]);
+
+    // 渲染帖子摘要
+    function renderPostSummary() {
+      const container = document.getElementById('postSummary');
+      const bodyText = postDetail.body_text || '';
+      const shortBody = bodyText.length > 150 ? bodyText.slice(0, 150) + '...' : bodyText;
+      container.innerHTML = `
+        <div class="post-title">${postDetail.postTitle || '无标题'}</div>
+        <div class="post-body">${shortBody}</div>
+        <div class="post-meta">
+          <span>❤️ ${postDetail.like_count || 0}</span>
+          <span>⭐ ${postDetail.collect_count || 0}</span>
+          <span>${postDetail.publish_time || ''}</span>
+        </div>
+      `;
+    }
+
+    // 渲染时间轴
+    function renderTimeline() {
+      const container = document.getElementById('timeline');
+      const sortedNodes = [...graphNodes].sort((a, b) => a.order - b.order);
+
+      let html = '<div class="timeline-title">推导过程</div>';
+      sortedNodes.forEach(n => {
+        const edge = edgeByTarget[n.id];
+        const fromNode = edge ? nodeById[edge.source] : null;
+        html += `
+          <div class="timeline-item" data-order="${n.order}" data-node-id="${n.id}">
+            <div class="timeline-dot">${n.order}</div>
+            <div class="timeline-node-name">${n.name}</div>
+            <div class="timeline-node-dim ${n.dimension}">${n.dimension}</div>
+            ${fromNode ? `<div class="timeline-from">← 从 <span>${fromNode.name}</span> 推导</div>` : '<div class="timeline-from">起点</div>'}
+            ${edge ? `<div class="timeline-reason">${edge.reason || ''}</div>` : ''}
+          </div>
+        `;
+      });
+      container.innerHTML = html;
+
+      // 绑定事件
+      container.querySelectorAll('.timeline-item').forEach(item => {
+        item.addEventListener('click', () => highlightNode(item.dataset.nodeId));
+        item.addEventListener('mouseenter', () => highlightNode(item.dataset.nodeId));
+        item.addEventListener('mouseleave', () => clearHighlight());
+      });
+    }
+
+    // ========== 分层布局 ==========
+    // 按 order 分层
+    const layers = {};
+    graphNodes.forEach(n => {
+      if (!layers[n.order]) layers[n.order] = [];
+      layers[n.order].push(n);
+    });
+    const layerKeys = Object.keys(layers).map(Number).sort((a, b) => a - b);
+
+    // 布局参数
+    const nodeRadius = 18;
+    const layerGap = 100;
+    const nodeGap = 100;
+    const padding = 60;
+
+    // 计算每层宽度
+    let maxLayerWidth = 0;
+    layerKeys.forEach(layer => {
+      const layerWidth = layers[layer].length * nodeGap;
+      maxLayerWidth = Math.max(maxLayerWidth, layerWidth);
+    });
+
+    const svgWidth = Math.max(maxLayerWidth + padding * 2, 600);
+    const svgHeight = layerKeys.length * layerGap + padding * 2;
+
+    // 计算节点位置
+    layerKeys.forEach((layer, layerIndex) => {
+      const layerNodes = layers[layer];
+      const layerWidth = (layerNodes.length - 1) * nodeGap;
+      const startX = (svgWidth - layerWidth) / 2;
+
+      layerNodes.forEach((node, nodeIndex) => {
+        node.x = startX + nodeIndex * nodeGap;
+        node.y = padding + layerIndex * layerGap;
+      });
+    });
+
+    // 创建 SVG
+    const svg = d3.select('#graph')
+      .attr('width', svgWidth)
+      .attr('height', svgHeight)
+      .style('background', '#222');  // 调试:添加背景色
+
+    const g = svg.append('g');
+
+    // 调试:画一个测试圆
+    g.append('circle').attr('cx', 100).attr('cy', 100).attr('r', 20).attr('fill', 'red');
+
+    // 缩放
+    const zoom = d3.zoom()
+      .scaleExtent([0.3, 3])
+      .on('zoom', (event) => g.attr('transform', event.transform));
+    svg.call(zoom);
+
+    // 箭头
+    svg.append('defs').append('marker')
+      .attr('id', 'arrow')
+      .attr('viewBox', '0 -5 10 10')
+      .attr('refX', 25)
+      .attr('refY', 0)
+      .attr('markerWidth', 6)
+      .attr('markerHeight', 6)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-5L10,0L0,5')
+      .attr('fill', '#e94560');
+
+    // 层级标签
+    layerKeys.forEach((layer, layerIndex) => {
+      g.append('text')
+        .attr('class', 'layer-label')
+        .attr('x', 20)
+        .attr('y', padding + layerIndex * layerGap + 4)
+        .text(`#${layer}`);
+    });
+
+    // 绘制边(曲线)
+    const link = g.append('g')
+      .selectAll('path')
+      .data(graphLinks)
+      .join('path')
+      .attr('class', 'link')
+      .attr('stroke', '#e94560')
+      .attr('stroke-width', 2)
+      .attr('fill', 'none')
+      .attr('marker-end', 'url(#arrow)')
+      .attr('d', d => {
+        const source = nodeById[d.source];
+        const target = nodeById[d.target];
+        if (!source || !target) return '';
+        const midY = (source.y + target.y) / 2;
+        return `M${source.x},${source.y + nodeRadius} C${source.x},${midY} ${target.x},${midY} ${target.x},${target.y - nodeRadius}`;
+      });
+
+    // 边标签
+    const linkLabel = g.append('g')
+      .selectAll('text')
+      .data(graphLinks)
+      .join('text')
+      .attr('class', 'link-label')
+      .attr('x', d => {
+        const source = nodeById[d.source];
+        const target = nodeById[d.target];
+        return source && target ? (source.x + target.x) / 2 : 0;
+      })
+      .attr('y', d => {
+        const source = nodeById[d.source];
+        const target = nodeById[d.target];
+        return source && target ? (source.y + target.y) / 2 : 0;
+      })
+      .attr('text-anchor', 'middle')
+      .text(d => d.score?.toFixed(2) || '');
+
+    // 绘制节点
+    const node = g.append('g')
+      .selectAll('g')
+      .data(graphNodes)
+      .join('g')
+      .attr('class', 'node')
+      .attr('transform', d => `translate(${d.x},${d.y})`)
+      .on('click', (event, d) => highlightNode(d.id))
+      .on('mouseenter', (event, d) => highlightNode(d.id))
+      .on('mouseleave', () => clearHighlight());
+
+    node.append('circle')
+      .attr('r', nodeRadius)
+      .attr('fill', d => dimColors[d.dimension] || '#888')
+      .attr('stroke', d => dimColors[d.dimension] || '#888');
+
+    node.append('text')
+      .attr('class', 'order-badge')
+      .attr('dy', 4)
+      .text(d => d.order || '');
+
+    node.append('text')
+      .attr('dy', 35)
+      .text(d => d.name.length > 6 ? d.name.slice(0, 6) + '…' : d.name);
+
+    // 高亮函数
+    function highlightNode(nodeId) {
+      node.classed('highlight', d => d.id === nodeId);
+      link.classed('highlight', d => d.target === nodeId);
+      document.querySelectorAll('.timeline-item').forEach(item => {
+        item.classList.toggle('active', item.dataset.nodeId === nodeId);
+      });
+    }
+
+    function clearHighlight() {
+      node.classed('highlight', false);
+      link.classed('highlight', false);
+      document.querySelectorAll('.timeline-item').forEach(item => {
+        item.classList.remove('active');
+      });
+    }
+
+    // 调试信息
+    console.log('=== 调试 ===');
+    console.log('graphNodes:', graphNodes.length, graphNodes);
+    console.log('graphLinks:', graphLinks.length, graphLinks);
+    console.log('layers:', layers);
+    console.log('layerKeys:', layerKeys);
+    console.log('svgWidth:', svgWidth, 'svgHeight:', svgHeight);
+
+    // 初始化
+    renderPostSummary();
+    renderTimeline();
+
+    // 居中显示
+    setTimeout(() => {
+      const graphArea = document.querySelector('.graph-area');
+      const areaWidth = graphArea.clientWidth;
+      const areaHeight = graphArea.clientHeight;
+      const scale = Math.min(areaWidth / svgWidth, areaHeight / svgHeight, 1) * 0.9;
+      const tx = (areaWidth - svgWidth * scale) / 2;
+      const ty = (areaHeight - svgHeight * scale) / 2;
+      svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
+    }, 100);
+  </script>
+</body>
+</html>
+'''
+
+
+def main():
+    import argparse
+    parser = argparse.ArgumentParser(description='构建创作思维路径可视化(分层布局)')
+    parser.add_argument('input_dir', nargs='?', help='输入目录路径(可选,默认使用 PathConfig)')
+    args = parser.parse_args()
+
+    if args.input_dir:
+        input_dir = Path(args.input_dir)
+        print(f"输入目录: {input_dir}")
+    else:
+        config = PathConfig()
+        input_dir = config.intermediate_dir / "creation_pattern"
+        print(f"账号: {config.account_name}")
+        print(f"输入目录: {input_dir}")
+
+    if not input_dir.exists():
+        print(f"错误: 目录不存在!")
+        sys.exit(1)
+
+    # 查找所有创作模式文件
+    pattern_files = sorted(input_dir.glob("*_创作模式.json"))
+    print(f"找到 {len(pattern_files)} 个创作模式文件")
+
+    if not pattern_files:
+        print("错误: 没有找到创作模式文件!")
+        sys.exit(1)
+
+    # 为每个文件生成 HTML
+    for pattern_file in pattern_files:
+        post_id = pattern_file.stem.replace("_创作模式", "")
+        print(f"\n处理: {post_id}")
+
+        # 读取数据
+        with open(pattern_file, "r", encoding="utf-8") as f:
+            data = json.load(f)
+
+        # 生成 HTML
+        data_json = json.dumps(data, ensure_ascii=False)
+        html_content = HTML_TEMPLATE.replace("__DATA_PLACEHOLDER__", data_json)
+
+        # 输出文件
+        output_file = input_dir / f"{post_id}_分层图谱.html"
+        with open(output_file, "w", encoding="utf-8") as f:
+            f.write(html_content)
+
+        print(f"输出: {output_file}")
+
+    print("\n完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 639 - 0
script/visualization/build_creation_pattern_v2.py

@@ -0,0 +1,639 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+构建创作思维路径可视化 V2
+
+新版特性:
+- 同一个发现编号的节点使用相同的填充颜色
+- 边框颜色代表节点类型(灵感点、目的点、关键点)
+
+读取 creation_pattern 目录下的 JSON 数据,输出单文件 HTML
+"""
+
+import json
+import sys
+from pathlib import Path
+
+# 项目路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+
+
+HTML_TEMPLATE = '''<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>创作思维路径可视化</title>
+  <script src="https://d3js.org/d3.v7.min.js"></script>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+      background: #1a1a2e;
+      color: #eee;
+      overflow: hidden;
+    }
+    .container { display: flex; flex-direction: column; height: 100vh; }
+    header {
+      padding: 12px 20px;
+      background: #16213e;
+      border-bottom: 1px solid #0f3460;
+      display: flex;
+      align-items: center;
+      gap: 20px;
+    }
+    h1 { font-size: 16px; font-weight: 500; color: #e94560; }
+    .controls { display: flex; align-items: center; gap: 12px; flex: 1; }
+    .slider-container {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      background: #0f3460;
+      padding: 6px 12px;
+      border-radius: 6px;
+    }
+    .slider-container label { font-size: 13px; color: #aaa; }
+    #stepSlider { width: 200px; cursor: pointer; }
+    #stepDisplay { font-size: 14px; font-weight: 600; color: #e94560; min-width: 60px; }
+    .play-btn {
+      background: #e94560;
+      border: none;
+      color: white;
+      padding: 6px 14px;
+      border-radius: 4px;
+      cursor: pointer;
+      font-size: 13px;
+    }
+    .play-btn:hover { background: #ff6b6b; }
+
+    /* 图例 - 两行 */
+    .legend {
+      display: flex;
+      flex-direction: column;
+      gap: 6px;
+      font-size: 11px;
+      color: #888;
+      margin-left: auto;
+      background: #0f3460;
+      padding: 8px 12px;
+      border-radius: 6px;
+    }
+    .legend-row { display: flex; gap: 12px; align-items: center; }
+    .legend-label { color: #666; font-size: 10px; min-width: 50px; }
+    .legend-item { display: flex; align-items: center; gap: 4px; }
+    .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
+    .legend-dot-border {
+      width: 10px;
+      height: 10px;
+      border-radius: 50%;
+      background: transparent;
+      border: 2px solid;
+    }
+    .legend-line { width: 20px; height: 2px; }
+
+    /* 主内容区:左右布局 */
+    main { flex: 1; display: flex; overflow: hidden; }
+    .graph-area { flex: 1; position: relative; }
+    .graph-area svg { width: 100%; height: 100%; }
+
+    /* 右侧面板 */
+    .right-panel {
+      width: 360px;
+      background: #16213e;
+      border-left: 1px solid #0f3460;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+    }
+
+    /* 帖子摘要区 */
+    .post-summary {
+      padding: 16px;
+      border-bottom: 1px solid #0f3460;
+      max-height: 200px;
+      overflow-y: auto;
+    }
+    .post-title {
+      font-size: 14px;
+      font-weight: 600;
+      color: #e94560;
+      margin-bottom: 8px;
+      line-height: 1.4;
+    }
+    .post-body {
+      font-size: 12px;
+      color: #aaa;
+      line-height: 1.6;
+      max-height: 100px;
+      overflow-y: auto;
+    }
+    .post-meta {
+      margin-top: 8px;
+      font-size: 11px;
+      color: #666;
+      display: flex;
+      gap: 12px;
+    }
+
+    /* 步骤时间轴 */
+    .timeline {
+      flex: 1;
+      overflow-y: auto;
+      padding: 16px;
+    }
+    .timeline-title {
+      font-size: 13px;
+      font-weight: 600;
+      color: #888;
+      margin-bottom: 12px;
+    }
+    .timeline-item {
+      position: relative;
+      padding-left: 24px;
+      padding-bottom: 16px;
+      border-left: 2px solid #0f3460;
+      margin-left: 8px;
+      cursor: pointer;
+      transition: all 0.2s;
+    }
+    .timeline-item:hover {
+      background: rgba(233, 69, 96, 0.1);
+      margin-left: 6px;
+      padding-left: 26px;
+    }
+    .timeline-item:last-child { border-left-color: transparent; }
+    .timeline-item.active { border-left-color: #e94560; }
+    .timeline-item.future { opacity: 0.4; }
+    .timeline-dot {
+      position: absolute;
+      left: -7px;
+      top: 0;
+      width: 12px;
+      height: 12px;
+      border-radius: 50%;
+      border: 2px solid #0f3460;
+      background: #1a1a2e;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 8px;
+      font-weight: bold;
+      color: white;
+    }
+    .timeline-item.active .timeline-dot {
+      border-color: #e94560;
+      background: #e94560;
+    }
+    .timeline-node-name {
+      font-size: 13px;
+      font-weight: 500;
+      color: #eee;
+      margin-bottom: 4px;
+    }
+    .timeline-node-dim {
+      font-size: 11px;
+      padding: 2px 6px;
+      border-radius: 3px;
+      display: inline-block;
+      margin-bottom: 6px;
+    }
+    .timeline-node-dim.灵感点 { background: rgba(255,107,107,0.2); color: #ff6b6b; }
+    .timeline-node-dim.目的点 { background: rgba(78,205,196,0.2); color: #4ecdc4; }
+    .timeline-node-dim.关键点 { background: rgba(255,230,109,0.2); color: #ffe66d; }
+    .timeline-reason {
+      font-size: 11px;
+      color: #888;
+      line-height: 1.5;
+      background: #0f3460;
+      padding: 8px;
+      border-radius: 4px;
+    }
+    .timeline-from {
+      font-size: 11px;
+      color: #666;
+      margin-bottom: 4px;
+    }
+    .timeline-from span { color: #e94560; }
+
+    /* 节点样式 */
+    .node { cursor: pointer; }
+    .node circle { stroke-width: 3px; transition: all 0.3s; }
+    .node.unknown circle { fill: #2a2a4a !important; stroke: #444 !important; opacity: 0.4; }
+    .node.known circle { filter: drop-shadow(0 0 6px currentColor); }
+    .node.highlight circle { stroke-width: 5px; filter: drop-shadow(0 0 12px currentColor); }
+    .node text { font-size: 11px; fill: #ccc; text-anchor: middle; pointer-events: none; }
+    .node.unknown text { fill: #555; }
+    .node .order-badge { font-size: 10px; font-weight: bold; fill: white; }
+    .link { stroke-opacity: 0.6; transition: all 0.3s; }
+    .link.hidden { stroke-opacity: 0 !important; }
+    .link.highlight { stroke-opacity: 1; stroke-width: 3px; }
+    .link-label { font-size: 9px; fill: #888; pointer-events: none; }
+    .link-label.hidden { opacity: 0; }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <header>
+      <h1>创作思维路径</h1>
+      <div class="controls">
+        <div class="slider-container">
+          <label>步骤:</label>
+          <input type="range" id="stepSlider" min="0" max="9" value="9">
+          <span id="stepDisplay">9/9</span>
+        </div>
+        <button class="play-btn" id="playBtn">▶ 播放</button>
+      </div>
+      <div class="legend">
+        <div class="legend-row">
+          <span class="legend-label">边框类型:</span>
+          <div class="legend-item"><span class="legend-dot-border" style="border-color: #ff6b6b;"></span><span>灵感点</span></div>
+          <div class="legend-item"><span class="legend-dot-border" style="border-color: #4ecdc4;"></span><span>目的点</span></div>
+          <div class="legend-item"><span class="legend-dot-border" style="border-color: #ffe66d;"></span><span>关键点</span></div>
+        </div>
+        <div class="legend-row">
+          <span class="legend-label">填充步骤:</span>
+          <div class="legend-item"><span class="legend-dot" style="background: #e94560;"></span><span>步骤1</span></div>
+          <div class="legend-item"><span class="legend-dot" style="background: #9b59b6;"></span><span>步骤2</span></div>
+          <div class="legend-item"><span class="legend-dot" style="background: #3498db;"></span><span>步骤3...</span></div>
+        </div>
+      </div>
+    </header>
+    <main>
+      <div class="graph-area">
+        <svg id="graph"></svg>
+      </div>
+      <div class="right-panel">
+        <div class="post-summary" id="postSummary"></div>
+        <div class="timeline" id="timeline">
+          <div class="timeline-title">推导过程</div>
+        </div>
+      </div>
+    </main>
+  </div>
+
+  <script>
+    const DATA = __DATA_PLACEHOLDER__;
+
+    // 维度颜色(用于边框)
+    const dimColors = { '灵感点': '#ff6b6b', '目的点': '#4ecdc4', '关键点': '#ffe66d' };
+
+    // 步骤颜色调色板(用于填充,同一编号同一颜色)
+    const stepColors = [
+      '#e94560', // 1 - 红色
+      '#9b59b6', // 2 - 紫色
+      '#3498db', // 3 - 蓝色
+      '#1abc9c', // 4 - 青色
+      '#2ecc71', // 5 - 绿色
+      '#f39c12', // 6 - 橙色
+      '#e74c3c', // 7 - 深红
+      '#8e44ad', // 8 - 深紫
+      '#2980b9', // 9 - 深蓝
+      '#16a085', // 10 - 深青
+      '#27ae60', // 11 - 深绿
+      '#d35400', // 12 - 深橙
+      '#c0392b', // 13
+      '#7b1fa2', // 14
+      '#1565c0', // 15
+    ];
+
+    function getStepColor(order) {
+      if (!order || order <= 0) return '#2a2a4a';
+      return stepColors[(order - 1) % stepColors.length];
+    }
+
+    // 提取数据
+    const postDetail = DATA['帖子详情'];
+    const steps = DATA['步骤列表'];
+    const lastStep = steps[steps.length - 1];
+    const nodes = lastStep['输出']['节点列表'];
+    const edges = lastStep['输出']['边列表'].filter(e => e['关系类型'] === 'AI推导');
+    const maxOrder = Math.max(...nodes.map(n => n['发现编号'] || 0));
+
+    // 构建边的查找表(目标节点 -> 边)
+    const edgeByTarget = {};
+    edges.forEach(e => { edgeByTarget[e['目标']] = e; });
+
+    const nodeById = {};
+    const graphNodes = nodes.map(n => {
+      const node = {
+        id: n['节点ID'],
+        name: n['节点名称'],
+        dimension: n['节点维度'],
+        category: n['节点分类'],
+        description: n['节点描述'],
+        order: n['发现编号'],
+        isKnown: n['是否已知']
+      };
+      nodeById[node.id] = node;
+      return node;
+    });
+
+    const graphLinks = edges.map(e => ({
+      source: e['来源'],
+      target: e['目标'],
+      type: e['关系类型'],
+      score: e['可能性分数'],
+      reason: e['推理说明']
+    }));
+
+    // 渲染帖子摘要
+    function renderPostSummary() {
+      const container = document.getElementById('postSummary');
+      const bodyText = postDetail.body_text || '';
+      const shortBody = bodyText.length > 150 ? bodyText.slice(0, 150) + '...' : bodyText;
+      container.innerHTML = `
+        <div class="post-title">${postDetail.postTitle || '无标题'}</div>
+        <div class="post-body">${shortBody}</div>
+        <div class="post-meta">
+          <span>❤️ ${postDetail.like_count || 0}</span>
+          <span>⭐ ${postDetail.collect_count || 0}</span>
+          <span>${postDetail.publish_time || ''}</span>
+        </div>
+      `;
+    }
+
+    // 渲染时间轴
+    function renderTimeline() {
+      const container = document.getElementById('timeline');
+      // 按发现顺序排序节点
+      const sortedNodes = [...graphNodes].filter(n => n.order).sort((a, b) => a.order - b.order);
+
+      let html = '<div class="timeline-title">推导过程</div>';
+      sortedNodes.forEach(n => {
+        const edge = edgeByTarget[n.id];
+        const fromNode = edge ? nodeById[edge.source] : null;
+        const stepColor = getStepColor(n.order);
+        html += `
+          <div class="timeline-item" data-order="${n.order}" data-node-id="${n.id}">
+            <div class="timeline-dot" style="background: ${stepColor}; border-color: ${stepColor};">${n.order}</div>
+            <div class="timeline-node-name">${n.name}</div>
+            <div class="timeline-node-dim ${n.dimension}">${n.dimension}</div>
+            ${fromNode ? `<div class="timeline-from">← 从 <span>${fromNode.name}</span> 推导</div>` : '<div class="timeline-from">起点</div>'}
+            ${edge ? `<div class="timeline-reason">${edge.reason || ''}</div>` : ''}
+          </div>
+        `;
+      });
+      container.innerHTML = html;
+
+      // 绑定点击事件
+      container.querySelectorAll('.timeline-item').forEach(item => {
+        item.addEventListener('click', () => {
+          const order = parseInt(item.dataset.order);
+          const nodeId = item.dataset.nodeId;
+          slider.value = order;
+          updateDisplay(order);
+          highlightNode(nodeId);
+        });
+        item.addEventListener('mouseenter', () => {
+          const nodeId = item.dataset.nodeId;
+          highlightNode(nodeId);
+        });
+        item.addEventListener('mouseleave', () => {
+          clearHighlight();
+        });
+      });
+    }
+
+    let currentStep = maxOrder;
+    const slider = document.getElementById('stepSlider');
+    const stepDisplay = document.getElementById('stepDisplay');
+    const playBtn = document.getElementById('playBtn');
+
+    slider.max = maxOrder;
+    slider.value = maxOrder;
+
+    const svg = d3.select('#graph');
+    const graphArea = document.querySelector('.graph-area');
+    const width = graphArea.clientWidth;
+    const height = graphArea.clientHeight;
+
+    const g = svg.append('g');
+    const zoom = d3.zoom()
+      .scaleExtent([0.2, 3])
+      .on('zoom', (event) => g.attr('transform', event.transform));
+    svg.call(zoom);
+
+    svg.append('defs').append('marker')
+      .attr('id', 'arrow')
+      .attr('viewBox', '0 -5 10 10')
+      .attr('refX', 20)
+      .attr('refY', 0)
+      .attr('markerWidth', 6)
+      .attr('markerHeight', 6)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-5L10,0L0,5')
+      .attr('fill', '#e94560');
+
+    const simulation = d3.forceSimulation(graphNodes)
+      .force('link', d3.forceLink(graphLinks).id(d => d.id).distance(120))
+      .force('charge', d3.forceManyBody().strength(-400))
+      .force('center', d3.forceCenter(width / 2, height / 2))
+      .force('collision', d3.forceCollide().radius(50));
+
+    const link = g.append('g')
+      .selectAll('line')
+      .data(graphLinks)
+      .join('line')
+      .attr('class', 'link')
+      .attr('stroke', '#e94560')
+      .attr('stroke-width', 2)
+      .attr('marker-end', 'url(#arrow)');
+
+    const linkLabel = g.append('g')
+      .selectAll('text')
+      .data(graphLinks)
+      .join('text')
+      .attr('class', 'link-label')
+      .text(d => d.score?.toFixed(2) || '');
+
+    const node = g.append('g')
+      .selectAll('g')
+      .data(graphNodes)
+      .join('g')
+      .attr('class', d => `node dim-${d.dimension}`)
+      .call(d3.drag()
+        .on('start', dragstarted)
+        .on('drag', dragged)
+        .on('end', dragended))
+      .on('click', (event, d) => {
+        slider.value = d.order || maxOrder;
+        updateDisplay(d.order || maxOrder);
+      })
+      .on('mouseenter', (event, d) => highlightNode(d.id))
+      .on('mouseleave', () => clearHighlight());
+
+    // 节点圆形:填充色按步骤编号,边框色按维度类型
+    node.append('circle')
+      .attr('r', 18)
+      .attr('fill', d => getStepColor(d.order))  // 填充色按编号
+      .attr('stroke', d => dimColors[d.dimension] || '#888');  // 边框色按类型
+
+    node.append('text')
+      .attr('class', 'order-badge')
+      .attr('dy', 4)
+      .text(d => d.order || '');
+
+    node.append('text')
+      .attr('dy', 35)
+      .text(d => d.name);
+
+    simulation.on('tick', () => {
+      link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
+          .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
+      linkLabel.attr('x', d => (d.source.x + d.target.x) / 2)
+               .attr('y', d => (d.source.y + d.target.y) / 2 - 8);
+      node.attr('transform', d => `translate(${d.x},${d.y})`);
+    });
+
+    function dragstarted(event, d) {
+      if (!event.active) simulation.alphaTarget(0.3).restart();
+      d.fx = d.x; d.fy = d.y;
+    }
+    function dragged(event, d) { d.fx = event.x; d.fy = event.y; }
+    function dragended(event, d) {
+      if (!event.active) simulation.alphaTarget(0);
+      d.fx = null; d.fy = null;
+    }
+
+    // 高亮节点
+    function highlightNode(nodeId) {
+      node.classed('highlight', d => d.id === nodeId);
+      // 高亮相关边
+      link.classed('highlight', d => d.target.id === nodeId || d.target === nodeId);
+      // 高亮时间轴项
+      document.querySelectorAll('.timeline-item').forEach(item => {
+        item.classList.toggle('active', item.dataset.nodeId === nodeId);
+      });
+    }
+
+    function clearHighlight() {
+      node.classed('highlight', false);
+      link.classed('highlight', false);
+    }
+
+    function updateDisplay(step) {
+      currentStep = step;
+      stepDisplay.textContent = `${step}/${maxOrder}`;
+      node.classed('known', d => d.order && d.order <= step);
+      node.classed('unknown', d => !d.order || d.order > step);
+      link.classed('hidden', d => {
+        const srcKnown = nodeById[d.source.id || d.source]?.order <= step;
+        const tgtKnown = nodeById[d.target.id || d.target]?.order <= step;
+        return !srcKnown || !tgtKnown;
+      });
+      linkLabel.classed('hidden', d => {
+        const srcKnown = nodeById[d.source.id || d.source]?.order <= step;
+        const tgtKnown = nodeById[d.target.id || d.target]?.order <= step;
+        return !srcKnown || !tgtKnown;
+      });
+      // 更新时间轴状态
+      document.querySelectorAll('.timeline-item').forEach(item => {
+        const order = parseInt(item.dataset.order);
+        item.classList.toggle('future', order > step);
+      });
+    }
+
+    slider.addEventListener('input', (e) => updateDisplay(parseInt(e.target.value)));
+
+    let playing = false, playInterval = null;
+    playBtn.addEventListener('click', () => {
+      if (playing) {
+        clearInterval(playInterval);
+        playBtn.textContent = '▶ 播放';
+        playing = false;
+      } else {
+        if (currentStep >= maxOrder) { currentStep = 0; slider.value = 0; updateDisplay(0); }
+        playing = true;
+        playBtn.textContent = '⏸ 暂停';
+        playInterval = setInterval(() => {
+          currentStep++;
+          slider.value = currentStep;
+          updateDisplay(currentStep);
+          // 滚动时间轴到当前项
+          const currentItem = document.querySelector(`.timeline-item[data-order="${currentStep}"]`);
+          if (currentItem) currentItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
+          if (currentStep >= maxOrder) {
+            clearInterval(playInterval);
+            playBtn.textContent = '▶ 播放';
+            playing = false;
+          }
+        }, 1200);
+      }
+    });
+
+    // 初始化
+    renderPostSummary();
+    renderTimeline();
+    updateDisplay(maxOrder);
+
+    setTimeout(() => {
+      const bounds = g.node().getBBox();
+      const scale = Math.min((width - 60) / bounds.width, (height - 60) / bounds.height, 1.2);
+      const tx = (width - bounds.width * scale) / 2 - bounds.x * scale;
+      const ty = (height - bounds.height * scale) / 2 - bounds.y * scale;
+      svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
+    }, 1000);
+  </script>
+</body>
+</html>
+'''
+
+
+def main():
+    import argparse
+    parser = argparse.ArgumentParser(description='构建创作思维路径可视化 V2')
+    parser.add_argument('input_dir', nargs='?', help='输入目录路径(可选,默认使用 PathConfig)')
+    parser.add_argument('--version', '-v', type=str, default='v4',
+                        help='creation_pattern 版本 (v2/v3/v4),默认 v4')
+    args = parser.parse_args()
+
+    if args.input_dir:
+        input_dir = Path(args.input_dir)
+        print(f"输入目录: {input_dir}")
+    else:
+        config = PathConfig()
+        input_dir = config.intermediate_dir / f"creation_pattern_{args.version}"
+        print(f"账号: {config.account_name}")
+        print(f"输入目录: {input_dir}")
+
+    if not input_dir.exists():
+        print(f"错误: 目录不存在!")
+        sys.exit(1)
+
+    # 查找所有创作模式文件
+    pattern_files = sorted(input_dir.glob("*_创作模式.json"))
+    print(f"找到 {len(pattern_files)} 个创作模式文件")
+
+    if not pattern_files:
+        print("错误: 没有找到创作模式文件!")
+        sys.exit(1)
+
+    # 为每个文件生成 HTML
+    for pattern_file in pattern_files:
+        post_id = pattern_file.stem.replace("_创作模式", "")
+        print(f"\n处理: {post_id}")
+
+        # 读取数据
+        with open(pattern_file, "r", encoding="utf-8") as f:
+            data = json.load(f)
+
+        # 生成 HTML
+        data_json = json.dumps(data, ensure_ascii=False)
+        html_content = HTML_TEMPLATE.replace("__DATA_PLACEHOLDER__", data_json)
+
+        # 输出文件
+        output_file = input_dir / f"{post_id}_创作思维路径.html"
+        with open(output_file, "w", encoding="utf-8") as f:
+            f.write(html_content)
+
+        print(f"输出: {output_file}")
+
+    print("\n完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 894 - 0
script/visualization/build_creation_pattern_v3.py

@@ -0,0 +1,894 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+构建创作思维路径可视化 V3
+
+新版特性:
+- 按节点发现编号分层布局(同一编号在同一层)
+- 同一个发现编号的节点使用相同的填充颜色
+- Tab 切换展示目录下所有帖子
+
+读取 creation_pattern 目录下的 JSON 数据,输出单文件 HTML
+"""
+
+import json
+import sys
+from pathlib import Path
+
+# 项目路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+
+
+HTML_TEMPLATE = '''<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>创作思维路径可视化</title>
+  <script src="https://d3js.org/d3.v7.min.js"></script>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+      background: #1a1a2e;
+      color: #eee;
+      overflow: hidden;
+    }
+    .container { display: flex; flex-direction: column; height: 100vh; }
+
+    /* Tab 样式 */
+    .tab-bar {
+      display: flex;
+      background: #0f3460;
+      border-bottom: 1px solid #16213e;
+      overflow-x: auto;
+      flex-shrink: 0;
+    }
+    .tab-bar::-webkit-scrollbar { height: 4px; }
+    .tab-bar::-webkit-scrollbar-thumb { background: #e94560; border-radius: 2px; }
+    .tab-item {
+      padding: 10px 16px;
+      font-size: 12px;
+      color: #888;
+      cursor: pointer;
+      border-bottom: 2px solid transparent;
+      white-space: nowrap;
+      transition: all 0.2s;
+    }
+    .tab-item:hover { color: #ccc; background: rgba(233, 69, 96, 0.1); }
+    .tab-item.active {
+      color: #e94560;
+      border-bottom-color: #e94560;
+      background: rgba(233, 69, 96, 0.1);
+    }
+
+    header {
+      padding: 10px 20px;
+      background: #16213e;
+      border-bottom: 1px solid #0f3460;
+      display: flex;
+      align-items: center;
+      gap: 20px;
+    }
+    h1 { font-size: 16px; font-weight: 500; color: #e94560; }
+    .controls { display: flex; align-items: center; gap: 12px; flex: 1; }
+    .slider-container {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      background: #0f3460;
+      padding: 6px 12px;
+      border-radius: 6px;
+    }
+    .slider-container label { font-size: 13px; color: #aaa; }
+    #stepSlider { width: 200px; cursor: pointer; }
+    #stepDisplay { font-size: 14px; font-weight: 600; color: #e94560; min-width: 60px; }
+    .play-btn {
+      background: #e94560;
+      border: none;
+      color: white;
+      padding: 6px 14px;
+      border-radius: 4px;
+      cursor: pointer;
+      font-size: 13px;
+    }
+    .play-btn:hover { background: #ff6b6b; }
+
+    .legend {
+      display: flex;
+      gap: 12px;
+      font-size: 11px;
+      color: #888;
+      margin-left: auto;
+      background: #0f3460;
+      padding: 6px 12px;
+      border-radius: 6px;
+    }
+    .legend-item { display: flex; align-items: center; gap: 4px; }
+    .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
+
+    main { flex: 1; display: flex; overflow: hidden; }
+    .graph-area { flex: 1; position: relative; }
+    .graph-area svg { width: 100%; height: 100%; }
+
+    .right-panel {
+      width: 340px;
+      background: #16213e;
+      border-left: 1px solid #0f3460;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+    }
+
+    .post-summary {
+      padding: 16px;
+      border-bottom: 1px solid #0f3460;
+      max-height: 180px;
+      overflow-y: auto;
+    }
+    .post-title {
+      font-size: 14px;
+      font-weight: 600;
+      color: #e94560;
+      margin-bottom: 8px;
+      line-height: 1.4;
+    }
+    .post-body {
+      font-size: 12px;
+      color: #aaa;
+      line-height: 1.6;
+      max-height: 80px;
+      overflow-y: auto;
+    }
+    .post-meta {
+      margin-top: 8px;
+      font-size: 11px;
+      color: #666;
+      display: flex;
+      gap: 12px;
+    }
+
+    .timeline {
+      flex: 1;
+      overflow-y: auto;
+      padding: 16px;
+    }
+    .timeline-title {
+      font-size: 13px;
+      font-weight: 600;
+      color: #888;
+      margin-bottom: 12px;
+    }
+    .timeline-item {
+      position: relative;
+      padding-left: 24px;
+      padding-bottom: 16px;
+      border-left: 2px solid #0f3460;
+      margin-left: 8px;
+      cursor: pointer;
+      transition: all 0.2s;
+    }
+    .timeline-item:hover {
+      background: rgba(233, 69, 96, 0.1);
+      margin-left: 6px;
+      padding-left: 26px;
+    }
+    .timeline-item:last-child { border-left-color: transparent; }
+    .timeline-item.active { border-left-color: #e94560; }
+    .timeline-item.future { opacity: 0.4; }
+    .timeline-dot {
+      position: absolute;
+      left: -7px;
+      top: 0;
+      width: 12px;
+      height: 12px;
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 8px;
+      font-weight: bold;
+      color: white;
+    }
+    .timeline-node-name {
+      font-size: 13px;
+      font-weight: 500;
+      color: #eee;
+      margin-bottom: 4px;
+    }
+    .timeline-node-dim {
+      font-size: 11px;
+      padding: 2px 6px;
+      border-radius: 3px;
+      display: inline-block;
+      margin-bottom: 6px;
+    }
+    .timeline-node-dim.灵感点 { background: rgba(255,107,107,0.2); color: #ff6b6b; }
+    .timeline-node-dim.目的点 { background: rgba(78,205,196,0.2); color: #4ecdc4; }
+    .timeline-node-dim.关键点 { background: rgba(255,230,109,0.2); color: #ffe66d; }
+    .timeline-reason {
+      font-size: 11px;
+      color: #888;
+      line-height: 1.5;
+      background: #0f3460;
+      padding: 8px;
+      border-radius: 4px;
+    }
+    .timeline-from {
+      font-size: 11px;
+      color: #666;
+      margin-bottom: 4px;
+    }
+    .timeline-from span { color: #e94560; }
+
+    .layer-label { font-size: 12px; fill: #555; font-weight: 600; }
+    .layer-line { stroke: #2a2a4a; stroke-width: 1; stroke-dasharray: 4,4; }
+
+    .node { cursor: pointer; }
+    .node circle { stroke-width: 0; transition: all 0.3s; }
+    .node.unknown circle { fill: #2a2a4a !important; opacity: 0.4; }
+    .node.highlight circle { filter: drop-shadow(0 0 8px rgba(255,255,255,0.5)); }
+    .node text { font-size: 11px; fill: #ccc; text-anchor: middle; pointer-events: none; }
+    .node.unknown text { fill: #555; }
+    .node .order-badge { font-size: 10px; font-weight: bold; fill: white; }
+    .node .constant-badge { font-size: 12px; fill: #ffd700; }
+    .node.constant circle:first-child { filter: drop-shadow(0 0 4px rgba(255, 215, 0, 0.6)); }
+    .link { stroke-opacity: 0.5; transition: all 0.3s; cursor: pointer; }
+    .link.hidden { stroke-opacity: 0 !important; pointer-events: none; }
+    .link.highlight { stroke-opacity: 0.8; stroke-width: 2px; }
+    .link:hover { stroke-opacity: 0.9; stroke-width: 2.5px; }
+    .link-label {
+      font-size: 10px;
+      fill: #f39c12;
+      font-weight: 600;
+      text-anchor: middle;
+      pointer-events: none;
+    }
+    .link-label.hidden { opacity: 0; }
+    .link-label-group.hidden { opacity: 0; pointer-events: none; }
+    .link-label-bg {
+      fill: #16213e;
+      opacity: 0.85;
+    }
+
+    .edge-detail {
+      position: fixed;
+      background: #16213e;
+      border: 1px solid #0f3460;
+      border-radius: 8px;
+      padding: 12px 16px;
+      max-width: 320px;
+      box-shadow: 0 4px 20px rgba(0,0,0,0.4);
+      z-index: 1000;
+      display: none;
+    }
+    .edge-detail.show { display: block; }
+    .edge-detail-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 10px;
+    }
+    .edge-detail-type {
+      font-size: 12px;
+      padding: 3px 8px;
+      border-radius: 4px;
+      font-weight: 500;
+    }
+    .edge-detail-type.AI推导 { background: rgba(233,69,96,0.2); color: #e94560; }
+    .edge-detail-type.共现推导 { background: rgba(52,152,219,0.2); color: #3498db; }
+    .edge-detail-close {
+      background: none;
+      border: none;
+      color: #888;
+      cursor: pointer;
+      font-size: 16px;
+    }
+    .edge-detail-close:hover { color: #fff; }
+    .edge-detail-nodes {
+      font-size: 13px;
+      color: #ccc;
+      margin-bottom: 8px;
+    }
+    .edge-detail-nodes span { color: #e94560; font-weight: 500; }
+    .edge-detail-arrow { color: #666; margin: 0 6px; }
+    .edge-detail-score {
+      font-size: 11px;
+      color: #888;
+      margin-bottom: 8px;
+    }
+    .edge-detail-reason {
+      font-size: 12px;
+      color: #aaa;
+      line-height: 1.6;
+      background: #0f3460;
+      padding: 10px;
+      border-radius: 4px;
+    }
+    .edge-detail-path {
+      margin-top: 10px;
+      font-size: 11px;
+      color: #888;
+      background: #0f3460;
+      padding: 10px;
+      border-radius: 4px;
+      max-height: 200px;
+      overflow-y: auto;
+    }
+    .edge-detail-path-title {
+      font-weight: 500;
+      color: #e94560;
+      margin-bottom: 6px;
+    }
+    .edge-path-item {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      padding: 4px 0;
+      border-bottom: 1px solid #1a1a2e;
+    }
+    .edge-path-item:last-child { border-bottom: none; }
+    .path-node {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      background: rgba(233, 69, 96, 0.15);
+      padding: 2px 6px;
+      border-radius: 3px;
+    }
+    .path-node-domain {
+      color: #888;
+      font-size: 10px;
+      margin-right: 4px;
+    }
+    .path-node-shape {
+      margin-right: 4px;
+      font-size: 10px;
+    }
+    .path-node-shape.circle { color: #e94560; }
+    .path-node-shape.square { color: #3498db; }
+    .path-node-name { color: #ccc; font-weight: 500; }
+    .path-edge {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      color: #3498db;
+    }
+    .path-edge-type { font-size: 10px; }
+    .path-edge-score { color: #f39c12; font-weight: 500; }
+  </style>
+</head>
+<body>
+  <div class="edge-detail" id="edgeDetail">
+    <div class="edge-detail-header">
+      <span class="edge-detail-type" id="edgeType"></span>
+      <button class="edge-detail-close" id="edgeClose">×</button>
+    </div>
+    <div class="edge-detail-nodes" id="edgeNodes"></div>
+    <div class="edge-detail-score" id="edgeScore"></div>
+    <div class="edge-detail-reason" id="edgeReason"></div>
+    <div class="edge-detail-path" id="edgePath" style="display: none;"></div>
+  </div>
+  <div class="container">
+    <div class="tab-bar" id="tabBar"></div>
+    <header>
+      <h1>创作思维路径</h1>
+      <div class="controls">
+        <div class="slider-container">
+          <label>步骤:</label>
+          <input type="range" id="stepSlider" min="0" max="9" value="9">
+          <span id="stepDisplay">9/9</span>
+        </div>
+        <button class="play-btn" id="playBtn">▶ 播放</button>
+      </div>
+      <div class="legend">
+        <div class="legend-item"><span class="legend-dot" style="background: #e94560;"></span><span>AI推导</span></div>
+        <div class="legend-item"><span class="legend-dot" style="background: #3498db;"></span><span>共现推导</span></div>
+        <div class="legend-item"><span style="color: #ffd700; font-size: 14px;">★</span><span>人设常量</span></div>
+      </div>
+    </header>
+    <main>
+      <div class="graph-area">
+        <svg id="graph"></svg>
+      </div>
+      <div class="right-panel">
+        <div class="post-summary" id="postSummary"></div>
+        <div class="timeline" id="timeline"></div>
+      </div>
+    </main>
+  </div>
+
+  <script>
+    const ALL_DATA = __DATA_PLACEHOLDER__;
+
+    const stepColors = [
+      '#e94560', '#9b59b6', '#3498db', '#1abc9c', '#2ecc71',
+      '#f39c12', '#e74c3c', '#8e44ad', '#2980b9', '#16a085',
+      '#27ae60', '#d35400', '#c0392b', '#7b1fa2', '#1565c0',
+    ];
+
+    function getStepColor(order) {
+      if (!order || order <= 0) return '#2a2a4a';
+      return stepColors[(order - 1) % stepColors.length];
+    }
+
+    let currentPostIndex = 0;
+    let postDetail, graphNodes, graphLinks, nodeById, edgeByTarget, maxOrder;
+    let currentStep, playing = false, playInterval = null;
+    let svg, g, zoom, link, node;
+
+    const slider = document.getElementById('stepSlider');
+    const stepDisplay = document.getElementById('stepDisplay');
+    const playBtn = document.getElementById('playBtn');
+
+    // 边类型颜色
+    const edgeColors = {
+      'AI推导': '#e94560',
+      '共现推导': '#3498db',
+      '支撑': '#666',
+      '关联': '#666'
+    };
+
+    function loadPostData(index) {
+      const DATA = ALL_DATA[index];
+      postDetail = DATA['帖子详情'];
+      const steps = DATA['步骤列表'];
+      const lastStep = steps[steps.length - 1];
+      const nodes = lastStep['输出']['节点列表'];
+      const edges = lastStep['输出']['边列表'] || [];
+      maxOrder = Math.max(...nodes.map(n => n['发现编号'] || 0), 1);
+
+      // 只保留 AI推导 和 共现推导 用于 timeline 显示
+      edgeByTarget = {};
+      edges.filter(e => e['关系类型'] === 'AI推导' || e['关系类型'] === '共现推导')
+           .forEach(e => { edgeByTarget[e['目标']] = e; });
+
+      nodeById = {};
+      graphNodes = nodes.map(n => {
+        const node = {
+          id: n['节点ID'],
+          name: n['节点名称'],
+          dimension: n['节点维度'],
+          order: n['发现编号'],
+          isKnown: n['是否已知'],
+          isConstant: n['是人设常量'] || false
+        };
+        nodeById[node.id] = node;
+        return node;
+      });
+
+      // 只加载 AI推导 和 共现推导 边(支撑/关联不展示)
+      graphLinks = edges
+        .filter(e => e['关系类型'] === 'AI推导' || e['关系类型'] === '共现推导')
+        .map(e => ({
+          source: e['来源'],
+          target: e['目标'],
+          type: e['关系类型'],
+          score: e['score'] || e['可能性分数'],
+          reason: e['推理说明'],
+          path: e['推导路径']
+        }));
+    }
+
+    function renderTabs() {
+      const tabBar = document.getElementById('tabBar');
+      tabBar.innerHTML = ALL_DATA.map((data, i) => {
+        const title = data['帖子详情']?.postTitle || `帖子${i + 1}`;
+        const shortTitle = title.length > 20 ? title.slice(0, 20) + '...' : title;
+        return `<div class="tab-item ${i === 0 ? 'active' : ''}" data-index="${i}">${shortTitle}</div>`;
+      }).join('');
+
+      tabBar.querySelectorAll('.tab-item').forEach(tab => {
+        tab.addEventListener('click', () => {
+          const index = parseInt(tab.dataset.index);
+          if (index !== currentPostIndex) {
+            if (playing) { clearInterval(playInterval); playing = false; playBtn.textContent = '▶ 播放'; }
+            currentPostIndex = index;
+            tabBar.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
+            tab.classList.add('active');
+            loadPostData(index);
+            renderAll();
+          }
+        });
+      });
+    }
+
+    function renderPostSummary() {
+      const container = document.getElementById('postSummary');
+      const bodyText = postDetail.body_text || '';
+      const shortBody = bodyText.length > 150 ? bodyText.slice(0, 150) + '...' : bodyText;
+      container.innerHTML = `
+        <div class="post-title">${postDetail.postTitle || '无标题'}</div>
+        <div class="post-body">${shortBody}</div>
+        <div class="post-meta">
+          <span>❤️ ${postDetail.like_count || 0}</span>
+          <span>⭐ ${postDetail.collect_count || 0}</span>
+        </div>
+      `;
+    }
+
+    function renderTimeline() {
+      const container = document.getElementById('timeline');
+      const sortedNodes = [...graphNodes].filter(n => n.order).sort((a, b) => a.order - b.order);
+
+      let html = '<div class="timeline-title">推导过程</div>';
+      sortedNodes.forEach(n => {
+        const edge = edgeByTarget[n.id];
+        const fromNode = edge ? nodeById[edge.source] : null;
+        const stepColor = getStepColor(n.order);
+        html += `
+          <div class="timeline-item" data-order="${n.order}" data-node-id="${n.id}">
+            <div class="timeline-dot" style="background: ${stepColor};">${n.order}</div>
+            <div class="timeline-node-name">${n.name}</div>
+            <div class="timeline-node-dim ${n.dimension}">${n.dimension}</div>
+            ${fromNode ? `<div class="timeline-from">← <span>${fromNode.name}</span></div>` : '<div class="timeline-from">起点</div>'}
+            ${edge?.reason ? `<div class="timeline-reason">${edge.reason}</div>` : ''}
+          </div>
+        `;
+      });
+      container.innerHTML = html;
+
+      container.querySelectorAll('.timeline-item').forEach(item => {
+        item.addEventListener('click', () => {
+          const order = parseInt(item.dataset.order);
+          slider.value = order;
+          updateDisplay(order);
+          highlightNode(item.dataset.nodeId);
+        });
+        item.addEventListener('mouseenter', () => highlightNode(item.dataset.nodeId));
+        item.addEventListener('mouseleave', clearHighlight);
+      });
+    }
+
+    function renderGraph() {
+      const graphArea = document.querySelector('.graph-area');
+      const width = graphArea.clientWidth;
+      const height = graphArea.clientHeight;
+
+      // 清空并重建 SVG
+      d3.select('#graph').selectAll('*').remove();
+      svg = d3.select('#graph');
+      g = svg.append('g');
+
+      zoom = d3.zoom()
+        .scaleExtent([0.2, 3])
+        .on('zoom', (event) => g.attr('transform', event.transform));
+      svg.call(zoom);
+
+      // 定义不同颜色的箭头
+      const defs = svg.append('defs');
+      Object.entries(edgeColors).forEach(([type, color]) => {
+        defs.append('marker')
+          .attr('id', `arrow-${type}`)
+          .attr('viewBox', '0 -5 10 10')
+          .attr('refX', 8)
+          .attr('refY', 0)
+          .attr('markerWidth', 6)
+          .attr('markerHeight', 6)
+          .attr('orient', 'auto')
+          .append('path')
+          .attr('d', 'M0,-4L8,0L0,4')
+          .attr('fill', color);
+      });
+
+      // 分层布局
+      const layerHeight = 100;
+      const nodeSpacing = 140;
+      const startY = 60;
+
+      const nodesByOrder = {};
+      graphNodes.forEach(n => {
+        const order = n.order || 0;
+        if (!nodesByOrder[order]) nodesByOrder[order] = [];
+        nodesByOrder[order].push(n);
+      });
+
+      const orders = Object.keys(nodesByOrder).map(Number).sort((a, b) => a - b);
+      orders.forEach((order, layerIndex) => {
+        const layerNodes = nodesByOrder[order];
+        const layerWidth = (layerNodes.length - 1) * nodeSpacing;
+        const layerStartX = 80 + (width - 400 - layerWidth) / 2;
+        layerNodes.forEach((n, i) => {
+          n.x = layerStartX + i * nodeSpacing;
+          n.y = startY + layerIndex * layerHeight;
+        });
+      });
+
+      // 层级标签
+      const layerGroup = g.append('g');
+      orders.filter(o => o > 0).forEach((order, layerIndex) => {
+        const y = startY + layerIndex * layerHeight;
+        const stepColor = getStepColor(order);
+        layerGroup.append('line')
+          .attr('class', 'layer-line')
+          .attr('x1', 20).attr('y1', y)
+          .attr('x2', width - 360).attr('y2', y)
+          .attr('stroke', stepColor).attr('stroke-opacity', 0.2);
+        layerGroup.append('text')
+          .attr('class', 'layer-label')
+          .attr('x', 25).attr('y', y + 4)
+          .attr('fill', stepColor)
+          .text(`${order}`);
+      });
+
+      // 边(按类型着色)
+      link = g.append('g').selectAll('path').data(graphLinks).join('path')
+        .attr('class', d => `link link-${d.type}`)
+        .attr('stroke', d => edgeColors[d.type] || '#666')
+        .attr('stroke-width', d => (d.type === 'AI推导' || d.type === '共现推导') ? 1.5 : 1)
+        .attr('fill', 'none')
+        .attr('marker-end', d => d.type === 'AI推导' ? `url(#arrow-${d.type})` : null)
+        .attr('d', d => {
+          const s = nodeById[d.source], t = nodeById[d.target];
+          if (!s || !t) return '';
+          const sy = s.y + 20, ty = t.y - 20;
+          const offset = Math.min(Math.abs(ty - sy) * 0.4, 40);
+          return `M ${s.x} ${sy} C ${s.x} ${sy + offset} ${t.x} ${ty - offset} ${t.x} ${ty}`;
+        })
+        .on('click', (event, d) => showEdgeDetail(event, d));
+
+      // 边分数标签
+      const linkLabels = g.append('g').selectAll('g')
+        .data(graphLinks.filter(d => d.score > 0))
+        .join('g')
+        .attr('class', 'link-label-group')
+        .attr('transform', d => {
+          const s = nodeById[d.source], t = nodeById[d.target];
+          if (!s || !t) return '';
+          const mx = (s.x + t.x) / 2;
+          const my = (s.y + 20 + t.y - 20) / 2;
+          return `translate(${mx}, ${my})`;
+        });
+
+      linkLabels.append('rect')
+        .attr('class', 'link-label-bg')
+        .attr('x', -16).attr('y', -8)
+        .attr('width', 32).attr('height', 14)
+        .attr('rx', 3);
+
+      linkLabels.append('text')
+        .attr('class', 'link-label')
+        .attr('dy', 3)
+        .text(d => d.score.toFixed(2));
+
+      // 节点
+      node = g.append('g').selectAll('g').data(graphNodes).join('g')
+        .attr('class', d => `node${d.isConstant ? ' constant' : ''}`)
+        .attr('transform', d => `translate(${d.x},${d.y})`)
+        .on('click', (e, d) => { slider.value = d.order || maxOrder; updateDisplay(d.order || maxOrder); })
+        .on('mouseenter', (e, d) => highlightNode(d.id))
+        .on('mouseleave', clearHighlight);
+
+      // 常量节点外环
+      node.filter(d => d.isConstant).append('circle')
+        .attr('r', 24)
+        .attr('fill', 'none')
+        .attr('stroke', '#ffd700')
+        .attr('stroke-width', 2)
+        .attr('stroke-dasharray', '4,2');
+
+      node.append('circle').attr('r', 18).attr('fill', d => getStepColor(d.order));
+      node.append('text').attr('class', 'order-badge').attr('dy', 4).text(d => d.order || '');
+      // 常量节点显示星号
+      node.filter(d => d.isConstant).append('text')
+        .attr('class', 'constant-badge')
+        .attr('dy', -22)
+        .attr('text-anchor', 'middle')
+        .text('★');
+      node.append('text').attr('dy', 35).text(d => d.name);
+
+      // 初始视图
+      setTimeout(() => {
+        const bounds = g.node().getBBox();
+        const scale = Math.min((width - 380) / bounds.width, (height - 20) / bounds.height, 1);
+        const tx = (width - 340 - bounds.width * scale) / 2 - bounds.x * scale;
+        const ty = (height - bounds.height * scale) / 2 - bounds.y * scale;
+        svg.transition().duration(300).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
+      }, 50);
+    }
+
+    function highlightNode(nodeId) {
+      node.classed('highlight', d => d.id === nodeId);
+      link.classed('highlight', d => d.target === nodeId);
+      document.querySelectorAll('.timeline-item').forEach(item => {
+        item.classList.toggle('active', item.dataset.nodeId === nodeId);
+      });
+    }
+
+    function clearHighlight() {
+      node.classed('highlight', false);
+      link.classed('highlight', false);
+    }
+
+    // 边详情弹窗
+    const edgeDetail = document.getElementById('edgeDetail');
+    const edgeType = document.getElementById('edgeType');
+    const edgeNodes = document.getElementById('edgeNodes');
+    const edgeScore = document.getElementById('edgeScore');
+    const edgeReason = document.getElementById('edgeReason');
+    const edgePath = document.getElementById('edgePath');
+    const edgeClose = document.getElementById('edgeClose');
+
+    function renderPath(pathData) {
+      if (!pathData || pathData.length === 0) return '';
+      let html = '<div class="edge-detail-path-title">推导路径</div>';
+      pathData.forEach((item, idx) => {
+        if (item['类型'] === '节点') {
+          const domain = item['节点域'] || '';
+          const dimension = item['节点维度'] || '';
+          const nodeType = item['节点类型'] || '';
+          const domainDim = [domain, dimension].filter(Boolean).join('-');
+          const domainTag = domainDim ? `<span class="path-node-domain">[${domainDim}]</span>` : '';
+          const shapeIcon = nodeType === '分类' ? '<span class="path-node-shape square">■</span>' : '<span class="path-node-shape circle">●</span>';
+          html += `<div class="edge-path-item"><span class="path-node">${domainTag}${shapeIcon}<span class="path-node-name">${item['节点名称'] || ''}</span></span></div>`;
+        } else if (item['类型'] === '边') {
+          const scoreText = item['分数'] != null ? `<span class="path-edge-score">${item['分数']}</span>` : '';
+          html += `<div class="edge-path-item"><span class="path-edge">↓ <span class="path-edge-type">${item['边类型'] || ''}</span> ${scoreText}</span></div>`;
+        }
+      });
+      return html;
+    }
+
+    function showEdgeDetail(event, d) {
+      event.stopPropagation();
+      const sourceNode = nodeById[d.source];
+      const targetNode = nodeById[d.target];
+
+      edgeType.textContent = d.type;
+      edgeType.className = `edge-detail-type ${d.type}`;
+
+      const arrow = d.type === 'AI推导' ? '→' : '—';
+      edgeNodes.innerHTML = `<span>${sourceNode?.name || d.source}</span><span class="edge-detail-arrow">${arrow}</span><span>${targetNode?.name || d.target}</span>`;
+
+      edgeScore.textContent = d.score ? `分数: ${d.score}` : '';
+      edgeReason.textContent = d.reason || '无推理说明';
+
+      // 显示路径
+      if (d.path && d.path.length > 0) {
+        edgePath.innerHTML = renderPath(d.path);
+        edgePath.style.display = 'block';
+      } else {
+        edgePath.style.display = 'none';
+      }
+
+      // 定位弹窗
+      const x = Math.min(event.clientX + 10, window.innerWidth - 340);
+      const y = Math.min(event.clientY + 10, window.innerHeight - 200);
+      edgeDetail.style.left = x + 'px';
+      edgeDetail.style.top = y + 'px';
+      edgeDetail.classList.add('show');
+    }
+
+    function hideEdgeDetail() {
+      edgeDetail.classList.remove('show');
+    }
+
+    edgeClose.addEventListener('click', hideEdgeDetail);
+    document.addEventListener('click', (e) => {
+      if (!edgeDetail.contains(e.target)) hideEdgeDetail();
+    });
+
+    function updateDisplay(step) {
+      currentStep = step;
+      stepDisplay.textContent = `${step}/${maxOrder}`;
+      node.classed('known', d => d.order && d.order <= step);
+      node.classed('unknown', d => !d.order || d.order > step);
+      link.classed('hidden', d => {
+        const s = nodeById[d.source], t = nodeById[d.target];
+        return !s || !t || s.order > step || t.order > step;
+      });
+      // 同步控制边标签的显示
+      d3.selectAll('.link-label-group').classed('hidden', function(d) {
+        const s = nodeById[d.source], t = nodeById[d.target];
+        return !s || !t || s.order > step || t.order > step;
+      });
+      document.querySelectorAll('.timeline-item').forEach(item => {
+        item.classList.toggle('future', parseInt(item.dataset.order) > step);
+      });
+    }
+
+    function renderAll() {
+      slider.max = maxOrder;
+      slider.value = maxOrder;
+      currentStep = maxOrder;
+      renderPostSummary();
+      renderTimeline();
+      renderGraph();
+      updateDisplay(maxOrder);
+    }
+
+    slider.addEventListener('input', e => updateDisplay(parseInt(e.target.value)));
+
+    playBtn.addEventListener('click', () => {
+      if (playing) {
+        clearInterval(playInterval);
+        playBtn.textContent = '▶ 播放';
+        playing = false;
+      } else {
+        if (currentStep >= maxOrder) { currentStep = 0; slider.value = 0; updateDisplay(0); }
+        playing = true;
+        playBtn.textContent = '⏸ 暂停';
+        playInterval = setInterval(() => {
+          currentStep++;
+          slider.value = currentStep;
+          updateDisplay(currentStep);
+          const item = document.querySelector(`.timeline-item[data-order="${currentStep}"]`);
+          if (item) item.scrollIntoView({ behavior: 'smooth', block: 'center' });
+          if (currentStep >= maxOrder) {
+            clearInterval(playInterval);
+            playBtn.textContent = '▶ 播放';
+            playing = false;
+          }
+        }, 1200);
+      }
+    });
+
+    // 初始化
+    renderTabs();
+    loadPostData(0);
+    renderAll();
+  </script>
+</body>
+</html>
+'''
+
+
+def main():
+    import argparse
+    parser = argparse.ArgumentParser(description='构建创作思维路径可视化 V3(分层布局 + Tab切换)')
+    parser.add_argument('input_dir', nargs='?', help='输入目录路径(可选,默认使用 PathConfig)')
+    parser.add_argument('--version', '-v', type=str, default='v5',
+                        help='版本号 (v2/v3/v4/v5),默认 v5')
+    parser.add_argument('--type', '-t', type=str, default='point_order',
+                        help='目录类型前缀 (creation_pattern/point_order),默认 point_order')
+    args = parser.parse_args()
+
+    if args.input_dir:
+        input_dir = Path(args.input_dir)
+        print(f"输入目录: {input_dir}")
+    else:
+        config = PathConfig()
+        input_dir = config.intermediate_dir / f"{args.type}_{args.version}"
+        print(f"账号: {config.account_name}")
+        print(f"输入目录: {input_dir}")
+
+    if not input_dir.exists():
+        print(f"错误: 目录不存在: {input_dir}")
+        sys.exit(1)
+
+    # 查找所有分析文件(支持多种命名)
+    pattern_files = sorted(input_dir.glob("*_点顺序.json"))
+    if not pattern_files:
+        pattern_files = sorted(input_dir.glob("*_创作模式.json"))
+    print(f"找到 {len(pattern_files)} 个分析文件")
+
+    if not pattern_files:
+        print("错误: 没有找到分析文件 (*_点顺序.json 或 *_创作模式.json)!")
+        sys.exit(1)
+
+    # 读取所有帖子数据
+    all_data = []
+    for pattern_file in pattern_files:
+        post_id = pattern_file.stem.replace("_点顺序", "").replace("_创作模式", "")
+        print(f"  读取: {post_id}")
+        with open(pattern_file, "r", encoding="utf-8") as f:
+            data = json.load(f)
+            all_data.append(data)
+
+    # 生成单个 HTML 文件
+    data_json = json.dumps(all_data, ensure_ascii=False)
+    html_content = HTML_TEMPLATE.replace("__DATA_PLACEHOLDER__", data_json)
+
+    output_file = input_dir / "创作思维路径可视化.html"
+    with open(output_file, "w", encoding="utf-8") as f:
+        f.write(html_content)
+
+    print(f"\n输出: {output_file}")
+    print("完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 11 - 0
script/visualization/src/App.vue

@@ -14,6 +14,11 @@
           :class="{ 'tab-active': activeTab === 'match' }"
           @click="switchTab('match')"
         >待解构帖子</a>
+        <a
+          class="tab tab-sm"
+          :class="{ 'tab-active': activeTab === 'sequence' }"
+          @click="switchTab('sequence')"
+        >顺序分析</a>
       </div>
 
       <!-- 图例 -->
@@ -216,6 +221,11 @@
         />
       </div>
     </main>
+
+    <!-- 主内容区 - 顺序分析 Tab -->
+    <main v-else-if="activeTab === 'sequence'" class="flex flex-1 overflow-hidden">
+      <SequenceAnalysisView class="flex-1" />
+    </main>
   </div>
 </template>
 
@@ -227,6 +237,7 @@ import PostTreeView from './components/PostTreeView.vue'
 import DetailPanel from './components/DetailPanel.vue'
 import DerivationView from './components/DerivationView.vue'
 import RelationView from './components/RelationView.vue'
+import SequenceAnalysisView from './components/SequenceAnalysisView.vue'
 import { useGraphStore } from './stores/graph'
 import { getEdgeTypeColors } from './config/edgeStyle'
 import { getDimColors } from './config/nodeStyle'

+ 302 - 0
script/visualization/src/components/SequenceAnalysisView.vue

@@ -0,0 +1,302 @@
+<template>
+  <div class="flex flex-col h-full">
+    <!-- 头部 -->
+    <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60 shrink-0">
+      <span>顺序分析</span>
+      <div class="flex items-center gap-2">
+        <!-- 版本选择 -->
+        <select
+          v-if="store.currentPostCreationPatternVersions.length > 0"
+          v-model="store.selectedCreationPatternVersion"
+          class="select select-xs bg-base-200 min-w-16"
+          title="选择版本"
+        >
+          <option v-for="v in store.currentPostCreationPatternVersions" :key="v" :value="v">
+            {{ v }}
+          </option>
+        </select>
+        <!-- 帖子选择 -->
+        <select
+          v-if="store.postList.length > 0"
+          v-model="store.selectedPostIndex"
+          class="select select-xs bg-base-200"
+        >
+          <option v-for="post in store.postList" :key="post.index" :value="post.index">
+            {{ post.postTitle || post.postId }}
+          </option>
+        </select>
+        <span v-if="!patternData" class="text-base-content/40">暂无数据</span>
+      </div>
+    </div>
+
+    <!-- SVG 容器 -->
+    <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
+      <svg ref="svgRef" class="w-full h-full"></svg>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch, onMounted, nextTick } from 'vue'
+import * as d3 from 'd3'
+import { useGraphStore } from '../stores/graph'
+import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
+import { getEdgeStyle, createArrowMarkers } from '../config/edgeStyle'
+
+const store = useGraphStore()
+
+const containerRef = ref(null)
+const svgRef = ref(null)
+
+// 布局参数
+const layerHeight = 100  // 每层高度
+const nodeSpacing = 140  // 节点水平间距
+
+// 获取当前创作模式数据
+const patternData = computed(() => store.currentCreationPattern)
+
+// 从最后一步提取节点和边
+const graphData = computed(() => {
+  if (!patternData.value) return { nodes: [], edges: [] }
+
+  const steps = patternData.value.步骤列表 || []
+  if (steps.length === 0) return { nodes: [], edges: [] }
+
+  const lastStep = steps[steps.length - 1]
+  const output = lastStep.输出 || {}
+
+  return {
+    nodes: output.节点列表 || [],
+    edges: output.边列表 || []
+  }
+})
+
+// 将原始数据转换为 D3 需要的格式
+function prepareGraphData() {
+  const rawNodes = graphData.value.nodes
+  const rawEdges = graphData.value.edges
+
+  if (rawNodes.length === 0) return { nodes: [], links: [], layers: [] }
+
+  // 按发现编号分组
+  const layerMap = new Map()
+  rawNodes.forEach(n => {
+    const num = n.发现编号 ?? 0
+    if (!layerMap.has(num)) layerMap.set(num, [])
+    layerMap.get(num).push(n)
+  })
+
+  // 排序层
+  const sortedLayerNums = Array.from(layerMap.keys()).sort((a, b) => a - b)
+
+  // 计算布局
+  const nodes = []
+  const layers = []
+  const maxNodesInLayer = Math.max(...Array.from(layerMap.values()).map(l => l.length))
+  const graphWidth = (maxNodesInLayer - 1) * nodeSpacing
+  const startX = graphWidth / 2 + 60
+  const startY = 60
+
+  sortedLayerNums.forEach((layerNum, layerIndex) => {
+    const layerNodes = layerMap.get(layerNum)
+    const layerWidth = (layerNodes.length - 1) * nodeSpacing
+    const layerStartX = startX - layerWidth / 2
+    const y = startY + layerIndex * layerHeight
+
+    // 记录层信息
+    layers.push({
+      num: layerNum,
+      y: y,
+      x: layerStartX - 40  // 标签位置
+    })
+
+    layerNodes.forEach((node, nodeIndex) => {
+      nodes.push({
+        id: node.节点ID,
+        name: node.节点名称,
+        dimension: node.节点维度,
+        type: node.节点分类?.includes('分类') ? '分类' : '标签',
+        domain: '帖子',  // 顺序分析的节点都是帖子域
+        discoveryNum: node.发现编号,
+        x: layerStartX + nodeIndex * nodeSpacing,
+        y: y,
+        raw: node
+      })
+    })
+  })
+
+  // 转换边
+  const nodeById = new Map(nodes.map(n => [n.id, n]))
+  const links = rawEdges.map(e => {
+    const source = nodeById.get(e.来源节点)
+    const target = nodeById.get(e.目标节点)
+    if (!source || !target) return null
+    return {
+      source: source,
+      target: target,
+      type: e.关系类型 || '支撑',
+      score: e.分数 || 0
+    }
+  }).filter(Boolean)
+
+  return { nodes, links, layers }
+}
+
+// 渲染图谱
+function render() {
+  if (!svgRef.value || !containerRef.value) return
+
+  const svg = d3.select(svgRef.value)
+  svg.selectAll('*').remove()
+
+  const { nodes, links, layers } = prepareGraphData()
+  if (nodes.length === 0) return
+
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+
+  // 创建箭头标记
+  const defs = svg.append('defs')
+  createArrowMarkers(defs, { theme: store.theme })
+
+  // 主容器(用于缩放和平移)
+  const mainG = svg.append('g')
+
+  // 设置缩放
+  const zoom = d3.zoom()
+    .scaleExtent([0.3, 3])
+    .on('zoom', (event) => {
+      mainG.attr('transform', event.transform)
+    })
+  svg.call(zoom)
+
+  // 绘制层级标签
+  mainG.append('g')
+    .attr('class', 'layer-labels')
+    .selectAll('text')
+    .data(layers)
+    .join('text')
+    .attr('x', d => d.x)
+    .attr('y', d => d.y)
+    .attr('text-anchor', 'end')
+    .attr('dominant-baseline', 'central')
+    .attr('fill', store.theme === 'dark' ? '#666' : '#999')
+    .attr('font-size', '12px')
+    .attr('font-weight', 'bold')
+    .text(d => d.num)
+
+  // 绘制边
+  const linkSelection = mainG.append('g')
+    .attr('class', 'links')
+    .selectAll('path')
+    .data(links)
+    .join('path')
+    .attr('d', d => {
+      const sx = d.source.x
+      const sy = d.source.y
+      const tx = d.target.x
+      const ty = d.target.y
+
+      // 同一层级的边向下弯曲
+      if (Math.abs(sy - ty) < 10) {
+        const midX = (sx + tx) / 2
+        const dist = Math.abs(tx - sx)
+        const curveHeight = Math.max(40, dist * 0.3)  // 弯曲幅度
+        return `M ${sx} ${sy} Q ${midX} ${sy + curveHeight}, ${tx} ${ty}`
+      }
+
+      // 不同层级的边用垂直曲线
+      const ctrl1Y = sy + (ty - sy) * 0.3
+      const ctrl2Y = ty - (ty - sy) * 0.3
+      return `M ${sx} ${sy} C ${sx} ${ctrl1Y}, ${tx} ${ctrl2Y}, ${tx} ${ty}`
+    })
+    .each(function(d) {
+      const style = getEdgeStyle(d, { theme: store.theme })
+      d3.select(this)
+        .attr('stroke', style.color)
+        .attr('stroke-width', style.strokeWidth)
+        .attr('stroke-opacity', style.opacity)
+        .attr('fill', 'none')
+        .attr('marker-end', style.markerId ? `url(#${style.markerId})` : null)
+    })
+
+  // 绘制节点
+  const nodeSelection = mainG.append('g')
+    .attr('class', 'nodes')
+    .selectAll('g')
+    .data(nodes)
+    .join('g')
+    .attr('transform', d => `translate(${d.x}, ${d.y})`)
+    .attr('class', 'graph-node')
+    .style('cursor', 'pointer')
+
+  // 应用节点样式
+  nodeSelection.each(function(d) {
+    const style = getNodeStyle(d, { theme: store.theme })
+    applyNodeShape(d3.select(this), style)
+  })
+
+  // 节点标签
+  nodeSelection.append('text')
+    .attr('dy', d => {
+      const style = getNodeStyle(d, { theme: store.theme })
+      return style.size / 2 + 14
+    })
+    .attr('text-anchor', 'middle')
+    .each(function(d) {
+      const style = getNodeStyle(d, { theme: store.theme })
+      d3.select(this)
+        .attr('fill', style.text.fill)
+        .attr('font-size', style.text.fontSize)
+        .attr('font-weight', style.text.fontWeight)
+    })
+    .text(d => {
+      const name = d.name || ''
+      return name.length > 8 ? name.slice(0, 8) + '...' : name
+    })
+
+  // 自动适应视图
+  nextTick(() => {
+    fitToView(svg, zoom, nodes, width, height)
+  })
+}
+
+// 自动适应视图
+function fitToView(svg, zoom, nodes, width, height) {
+  if (nodes.length === 0) return
+
+  const padding = 60
+  const minX = Math.min(...nodes.map(n => n.x)) - padding
+  const maxX = Math.max(...nodes.map(n => n.x)) + padding
+  const minY = Math.min(...nodes.map(n => n.y)) - padding
+  const maxY = Math.max(...nodes.map(n => n.y)) + padding
+
+  const graphWidth = maxX - minX
+  const graphHeight = maxY - minY
+
+  const scaleX = width / graphWidth
+  const scaleY = height / graphHeight
+  const scale = Math.min(scaleX, scaleY, 1.5) * 0.9
+
+  const translateX = (width - graphWidth * scale) / 2 - minX * scale
+  const translateY = (height - graphHeight * scale) / 2 - minY * scale
+
+  svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
+}
+
+onMounted(() => {
+  render()
+})
+
+watch([patternData, () => store.theme], () => {
+  nextTick(() => {
+    render()
+  })
+})
+</script>
+
+<style scoped>
+.graph-node {
+  transition: opacity 0.2s;
+}
+</style>

+ 42 - 0
script/visualization/src/stores/graph.js

@@ -9,11 +9,16 @@ const postGraphListRaw = __POST_GRAPH_LIST__ || []
 const derivationGraphsRaw = __DERIVATION_GRAPHS__ || {}
 // eslint-disable-next-line no-undef
 const derivationVersionsRaw = __DERIVATION_VERSIONS__ || []
+// eslint-disable-next-line no-undef
+const creationPatternsRaw = __CREATION_PATTERNS__ || {}
+// eslint-disable-next-line no-undef
+const creationPatternVersionsRaw = __CREATION_PATTERN_VERSIONS__ || []
 
 console.log('人设图谱 loaded:', !!graphDataRaw)
 console.log('人设节点数:', Object.keys(graphDataRaw?.nodes || {}).length)
 console.log('帖子图谱数:', postGraphListRaw.length)
 console.log('推导图谱版本:', derivationVersionsRaw)
+console.log('创作模式版本:', creationPatternVersionsRaw)
 
 export const useGraphStore = defineStore('graph', () => {
   // ==================== 主题 ====================
@@ -43,6 +48,36 @@ export const useGraphStore = defineStore('graph', () => {
     derivationVersionsRaw.length > 0 ? derivationVersionsRaw[0] : ''
   )
 
+  // ==================== 创作模式数据(顺序分析) ====================
+  const creationPatterns = ref(creationPatternsRaw)
+  const creationPatternVersions = ref(creationPatternVersionsRaw)
+  // 默认选择最新版本(最后一个)
+  const selectedCreationPatternVersion = ref(
+    creationPatternVersionsRaw.length > 0
+      ? creationPatternVersionsRaw[creationPatternVersionsRaw.length - 1]
+      : ''
+  )
+
+  // 当前帖子的创作模式数据
+  const currentCreationPattern = computed(() => {
+    if (selectedPostIndex.value < 0) return null
+    const postGraph = postGraphList.value[selectedPostIndex.value]
+    const postId = postGraph?.meta?.postId
+    const version = selectedCreationPatternVersion.value
+    return creationPatterns.value[version]?.[postId] || null
+  })
+
+  // 当前帖子可用的创作模式版本
+  const currentPostCreationPatternVersions = computed(() => {
+    if (selectedPostIndex.value < 0) return []
+    const postGraph = postGraphList.value[selectedPostIndex.value]
+    const postId = postGraph?.meta?.postId
+    return creationPatternVersions.value.filter(v => creationPatterns.value[v]?.[postId])
+  })
+
+  // 检查当前帖子是否有创作模式数据
+  const hasCreationPattern = computed(() => !!currentCreationPattern.value)
+
   // 当前选中的帖子图谱(动态合并推导数据)
   const currentPostGraph = computed(() => {
     if (selectedPostIndex.value < 0 || selectedPostIndex.value >= postGraphList.value.length) {
@@ -1202,6 +1237,13 @@ export const useGraphStore = defineStore('graph', () => {
     derivationVersions,
     selectedDerivationVersion,
     currentPostDerivationVersions,
+    // 创作模式(顺序分析)
+    creationPatterns,
+    creationPatternVersions,
+    selectedCreationPatternVersion,
+    currentCreationPattern,
+    currentPostCreationPatternVersions,
+    hasCreationPattern,
     // 人设节点游走配置
     walkNodeTypes,
     walkSteps,

+ 45 - 1
script/visualization/vite.config.js

@@ -28,6 +28,11 @@ const derivationGraphs = {}
 // 所有可用的版本
 const derivationVersions = new Set()
 
+// 创作模式数据:{ version: { postId: data } }
+const creationPatterns = {}
+// 创作模式版本列表
+const creationPatternVersions = []
+
 if (postGraphDir && fs.existsSync(postGraphDir)) {
   console.log('帖子图谱目录:', postGraphDir)
   const files = fs.readdirSync(postGraphDir).filter(f => f.endsWith('_帖子图谱.json'))
@@ -72,6 +77,43 @@ if (postGraphDir && fs.existsSync(postGraphDir)) {
   })
 
   console.log('推导图谱版本:', Array.from(derivationVersions).sort())
+
+  // 读取创作模式数据(多版本)
+  const parentDir = path.dirname(postGraphDir)
+  const allDirs = fs.readdirSync(parentDir)
+  // 只匹配 creation_pattern_v 加数字的目录(如 v2, v3, v4)
+  const patternDirs = allDirs.filter(d => /^creation_pattern_v\d+$/.test(d))
+    .sort((a, b) => {
+      // 按版本号排序 v2, v3, v4...
+      const va = parseInt(a.replace('creation_pattern_v', '')) || 0
+      const vb = parseInt(b.replace('creation_pattern_v', '')) || 0
+      return va - vb
+    })
+
+  console.log('创作模式版本目录:', patternDirs)
+
+  for (const dirName of patternDirs) {
+    const version = dirName.replace('creation_pattern_', '')  // 'v2', 'v3', 'v4'
+    const dirPath = path.join(parentDir, dirName)
+
+    if (!fs.statSync(dirPath).isDirectory()) continue
+
+    creationPatternVersions.push(version)
+    creationPatterns[version] = {}
+
+    const patternFiles = fs.readdirSync(dirPath).filter(f => f.endsWith('_创作模式.json'))
+    console.log(`创作模式 [${version}] 文件数:`, patternFiles.length)
+
+    for (const file of patternFiles) {
+      const filePath = path.join(dirPath, file)
+      const patternData = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+      const postId = patternData.帖子详情?.postId
+      if (postId) {
+        creationPatterns[version][postId] = patternData
+        console.log(`  加载: ${postId}`)
+      }
+    }
+  }
 } else {
   console.log('未设置帖子图谱目录或目录不存在')
 }
@@ -83,7 +125,9 @@ export default defineConfig({
     __GRAPH_DATA__: JSON.stringify(personaGraphData),
     __POST_GRAPH_LIST__: JSON.stringify(postGraphList),
     __DERIVATION_GRAPHS__: JSON.stringify(derivationGraphs),
-    __DERIVATION_VERSIONS__: JSON.stringify(Array.from(derivationVersions).sort())
+    __DERIVATION_VERSIONS__: JSON.stringify(Array.from(derivationVersions).sort()),
+    __CREATION_PATTERNS__: JSON.stringify(creationPatterns),
+    __CREATION_PATTERN_VERSIONS__: JSON.stringify(creationPatternVersions)
   },
   build: {
     target: 'esnext',