Selaa lähdekoodia

feat: 添加帖子点节点层级,支持四层图结构

- 新增帖子点节点(灵感点/关键点/目的点的名称和描述)
- 创建帖子标签→帖子点的"属于"边
- 更新可视化为四层布局:
  - 第1层:帖子点节点(六边形)
  - 第2层:帖子标签节点(圆形)
  - 第3层:人设标签节点(圆形)
  - 第4层:人设分类节点(方形)
- 点节点tooltip显示描述信息

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 6 päivää sitten
vanhempi
commit
81d48221b6

+ 80 - 20
script/data_processing/build_match_graph.py

@@ -25,7 +25,13 @@ from script.data_processing.path_config import PathConfig
 
 
 def build_post_node_id(dimension: str, node_type: str, name: str) -> str:
-    """构建帖子节点ID"""
+    """构建帖子节点ID
+
+    Args:
+        dimension: 维度(灵感点/关键点/目的点)
+        node_type: 节点类型(点/标签)
+        name: 节点名称
+    """
     return f"帖子_{dimension}_{node_type}_{name}"
 
 
@@ -36,17 +42,19 @@ def build_persona_node_id(dimension: str, node_type: str, name: str) -> str:
 
 def extract_matched_nodes_and_edges(filtered_data: Dict) -> tuple:
     """
-    从匹配结果中提取帖子节点、人设节点和匹配
+    从匹配结果中提取帖子节点(点+标签)、人设节点和边
 
     Args:
         filtered_data: 匹配结果数据
 
     Returns:
-        (帖子节点列表, 人设节点ID集合, 匹配边列表)
+        (帖子节点列表, 人设节点ID集合, 边列表)
+        帖子节点包括:点节点(灵感点/关键点/目的点)和标签节点
+        边包括:点→标签的属于边 + 标签→人设的匹配边
     """
     post_nodes = []
     persona_node_ids = set()
-    match_edges = []
+    edges = []  # 包含属于边和匹配边
 
     how_result = filtered_data.get("how解构结果", {})
 
@@ -61,7 +69,28 @@ def extract_matched_nodes_and_edges(filtered_data: Dict) -> tuple:
         points = how_result.get(list_key, [])
 
         for point in points:
-            # 遍历how步骤列表
+            point_name = point.get("名称", "")
+            point_desc = point.get("描述", "")
+
+            if not point_name:
+                continue
+
+            # 创建帖子点节点
+            point_node_id = build_post_node_id(dimension, "点", point_name)
+            point_node = {
+                "节点ID": point_node_id,
+                "节点名称": point_name,
+                "节点类型": "点",
+                "节点层级": dimension,
+                "描述": point_desc,
+                "source": "帖子"
+            }
+
+            # 避免重复添加点节点
+            if not any(n["节点ID"] == point_node_id for n in post_nodes):
+                post_nodes.append(point_node)
+
+            # 遍历how步骤列表,提取标签节点
             how_steps = point.get("how步骤列表", [])
 
             for step in how_steps:
@@ -75,10 +104,10 @@ def extract_matched_nodes_and_edges(filtered_data: Dict) -> tuple:
                     if not feature_name:
                         continue
 
-                    # 创建帖子节点(无论是否有匹配结果)
-                    post_node_id = build_post_node_id(dimension, "标签", feature_name)
-                    post_node = {
-                        "节点ID": post_node_id,
+                    # 创建帖子标签节点(无论是否有匹配结果)
+                    tag_node_id = build_post_node_id(dimension, "标签", feature_name)
+                    tag_node = {
+                        "节点ID": tag_node_id,
                         "节点名称": feature_name,
                         "节点类型": "标签",
                         "节点层级": dimension,
@@ -87,9 +116,23 @@ def extract_matched_nodes_and_edges(filtered_data: Dict) -> tuple:
                         "已匹配": len(match_results) > 0  # 标记是否有匹配
                     }
 
-                    # 避免重复添加
-                    if not any(n["节点ID"] == post_node_id for n in post_nodes):
-                        post_nodes.append(post_node)
+                    # 避免重复添加标签节点
+                    if not any(n["节点ID"] == tag_node_id for n in post_nodes):
+                        post_nodes.append(tag_node)
+
+                    # 创建标签→点的属于边
+                    belong_edge = {
+                        "源节点ID": tag_node_id,
+                        "目标节点ID": point_node_id,
+                        "边类型": "属于",
+                        "边详情": {
+                            "说明": f"标签「{feature_name}」属于点「{point_name}」"
+                        }
+                    }
+                    # 避免重复添加属于边
+                    edge_key = (tag_node_id, point_node_id, "属于")
+                    if not any((e["源节点ID"], e["目标节点ID"], e["边类型"]) == edge_key for e in edges):
+                        edges.append(belong_edge)
 
                     # 如果有匹配结果,创建匹配边
                     if match_results:
@@ -110,7 +153,7 @@ def extract_matched_nodes_and_edges(filtered_data: Dict) -> tuple:
 
                             # 创建匹配边
                             match_edge = {
-                                "源节点ID": post_node_id,
+                                "源节点ID": tag_node_id,
                                 "目标节点ID": persona_node_id,
                                 "边类型": "匹配",
                                 "边详情": {
@@ -118,9 +161,9 @@ def extract_matched_nodes_and_edges(filtered_data: Dict) -> tuple:
                                     "说明": match_detail.get("说明", "")
                                 }
                             }
-                            match_edges.append(match_edge)
+                            edges.append(match_edge)
 
-    return post_nodes, persona_node_ids, match_edges
+    return post_nodes, persona_node_ids, edges
 
 
 def get_persona_nodes_details(
@@ -465,8 +508,16 @@ def process_filtered_result(
     post_detail = filtered_data.get("帖子详情", {})
     post_title = post_detail.get("title", "")
 
-    # 提取节点和边
-    post_nodes, persona_node_ids, match_edges = extract_matched_nodes_and_edges(filtered_data)
+    # 提取节点和边(包括帖子点节点、标签节点、属于边和匹配边)
+    post_nodes, persona_node_ids, post_edges_raw = extract_matched_nodes_and_edges(filtered_data)
+
+    # 分离帖子侧的边:属于边(标签→点)和匹配边(标签→人设)
+    post_belong_edges = [e for e in post_edges_raw if e["边类型"] == "属于"]
+    match_edges = [e for e in post_edges_raw if e["边类型"] == "匹配"]
+
+    # 统计帖子点节点和标签节点
+    post_point_nodes = [n for n in post_nodes if n["节点类型"] == "点"]
+    post_tag_nodes = [n for n in post_nodes if n["节点类型"] == "标签"]
 
     # 获取人设节点详情(直接匹配的,标记为非扩展)
     persona_nodes = get_persona_nodes_details(persona_node_ids, nodes_data)
@@ -584,8 +635,8 @@ def process_filtered_result(
     # 合并节点列表
     all_nodes = post_nodes + persona_nodes + useful_expanded_nodes
 
-    # 合并边列表
-    all_edges = match_edges + persona_edges + post_edges + useful_expanded_edges + useful_category_edges + post_edges_via_expanded
+    # 合并边列表(加入帖子内的属于边)
+    all_edges = post_belong_edges + match_edges + persona_edges + post_edges + useful_expanded_edges + useful_category_edges + post_edges_via_expanded
     # 去重边
     seen_edges = set()
     unique_edges = []
@@ -616,9 +667,12 @@ def process_filtered_result(
             "帖子标题": post_title,
             "描述": "帖子与人设的节点匹配关系",
             "统计": {
-                "帖子节点数": len(post_nodes),
+                "帖子点节点数": len(post_point_nodes),
+                "帖子标签节点数": len(post_tag_nodes),
+                "帖子节点总数": len(post_nodes),
                 "人设节点数(直接匹配)": len(persona_nodes),
                 "扩展节点数(有效)": len(useful_expanded_nodes),
+                "帖子属于边数": len(post_belong_edges),
                 "匹配边数": len(match_edges),
                 "人设节点间边数": len(persona_edges),
                 "扩展边数(有效)": len(useful_expanded_edges),
@@ -628,9 +682,12 @@ def process_filtered_result(
                 "总边数": len(all_edges)
             }
         },
+        "帖子点节点列表": post_point_nodes,
+        "帖子标签节点列表": post_tag_nodes,
         "帖子节点列表": post_nodes,
         "人设节点列表": persona_nodes,
         "扩展节点列表": useful_expanded_nodes,
+        "帖子属于边列表": post_belong_edges,
         "匹配边列表": match_edges,
         "人设节点间边列表": persona_edges,
         "扩展边列表": useful_expanded_edges,
@@ -648,9 +705,12 @@ def process_filtered_result(
 
     return {
         "帖子ID": post_id,
+        "帖子点节点数": len(post_point_nodes),
+        "帖子标签节点数": len(post_tag_nodes),
         "帖子节点数": len(post_nodes),
         "人设节点数": len(persona_nodes),
         "扩展节点数": len(useful_expanded_nodes),
+        "帖子属于边数": len(post_belong_edges),
         "匹配边数": len(match_edges),
         "人设边数": len(persona_edges),
         "扩展边数": len(useful_expanded_edges),

+ 62 - 20
script/data_processing/visualize_match_graph.py

@@ -186,9 +186,13 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         .node {{
             cursor: pointer;
         }}
-        .node circle, .node rect {{
+        .node circle, .node rect, .node polygon {{
             stroke-width: 3px;
         }}
+        .node .post-point-node {{
+            stroke: #fff;
+            stroke-width: 4px;
+        }}
         .node .post-node {{
             stroke: #fff;
             stroke-dasharray: 4,2;
@@ -271,7 +275,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         }}
         /* 二阶边现在使用与镜像边相同的样式(基于原始边类型) */
         /* 高亮/灰化样式 */
-        .node.dimmed circle, .node.dimmed rect {{
+        .node.dimmed circle, .node.dimmed rect, .node.dimmed polygon {{
             opacity: 0.15 !important;
         }}
         .node.dimmed text {{
@@ -283,7 +287,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         .link-group.dimmed .edge-label-group {{
             opacity: 0.15 !important;
         }}
-        .node.highlighted circle, .node.highlighted rect {{
+        .node.highlighted circle, .node.highlighted rect, .node.highlighted polygon {{
             stroke: #fff !important;
             stroke-width: 4px !important;
             filter: drop-shadow(0 0 8px rgba(255,255,255,0.5));
@@ -739,11 +743,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 "关键点": "#9b59b6"
             }};
 
-            // 三层Y坐标(带倾斜:右边高,左边低)
-            const postBaseY = height * 0.15;        // 帖子节点(顶层)
-            const personaBaseY = height * 0.45;     // 直接匹配人设节点(中层)
-            const expandedBaseY = height * 0.8;     // 扩展节点(底层)
-            const tiltAmount = height * 0.2;        // 倾斜幅度
+            // 四层Y坐标(带倾斜:右边高,左边低)
+            const postPointBaseY = height * 0.10;   // 帖子点节点(第1层-顶层)
+            const postTagBaseY = height * 0.30;     // 帖子标签节点(第2层)
+            const personaBaseY = height * 0.55;     // 人设标签节点(第3层)
+            const expandedBaseY = height * 0.82;    // 人设分类节点(第4层-底层)
+            const tiltAmount = height * 0.15;       // 倾斜幅度
 
             // 根据X位置计算Y(右边高,左边低)
             function getTiltedY(baseY, x) {{
@@ -751,9 +756,13 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 return baseY + tilt;
             }}
 
-            // 获取节点的基准Y
+            // 获取节点的基准Y(四层布局)
             function getNodeBaseY(d) {{
-                if (d.source === "帖子") return postBaseY;
+                if (d.source === "帖子") {{
+                    // 帖子侧:点节点在顶层,标签节点在第2层
+                    return d.节点类型 === "点" ? postPointBaseY : postTagBaseY;
+                }}
+                // 人设侧:扩展节点(分类)在底层,直接匹配节点在第3层
                 if (d.是否扩展) return expandedBaseY;
                 return personaBaseY;
             }}
@@ -881,19 +890,48 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .on("drag", dragged)
                     .on("end", dragended));
 
-            // 根据节点类型绘制不同形状:标签用圆形,分类用方形
-            // 扩展节点和未匹配节点用较低透明度表示
+            // 根据节点类型绘制不同形状
+            // - 点节点(帖子点):六边形(更大)
+            // - 标签节点:圆形
+            // - 分类节点:方形
             node.each(function(d) {{
                 const el = d3.select(this);
                 const isExpanded = d.是否扩展 === true;
-                const isUnmatched = d.source === "帖子" && d.已匹配 === false;
-                const size = d.source === "帖子" ? 12 : (isExpanded ? 8 : 10);
+                const isUnmatched = d.source === "帖子" && d.节点类型 === "标签" && d.已匹配 === false;
+                const isPointNode = d.节点类型 === "点";
+
+                // 点节点更大,标签节点根据来源区分大小
+                let size;
+                if (isPointNode) {{
+                    size = 16;  // 点节点最大
+                }} else if (d.source === "帖子") {{
+                    size = 12;  // 帖子标签
+                }} else if (isExpanded) {{
+                    size = 8;   // 扩展的分类节点
+                }} else {{
+                    size = 10;  // 人设标签
+                }}
+
                 const fill = levelColors[d.level] || "#666";
-                const nodeClass = d.source === "帖子" ? (isUnmatched ? "post-node unmatched" : "post-node") : "persona-node";
+                const nodeClass = d.source === "帖子"
+                    ? (isPointNode ? "post-point-node" : (isUnmatched ? "post-node unmatched" : "post-node"))
+                    : "persona-node";
                 const opacity = isExpanded ? 0.5 : (isUnmatched ? 0.4 : 1);
 
-                if (d.节点类型 === "分类") {{
-                    // 方形
+                if (isPointNode) {{
+                    // 六边形(点节点)
+                    const hexPoints = [];
+                    for (let i = 0; i < 6; i++) {{
+                        const angle = (i * 60 - 30) * Math.PI / 180;
+                        hexPoints.push([size * Math.cos(angle), size * Math.sin(angle)]);
+                    }}
+                    el.append("polygon")
+                        .attr("points", hexPoints.map(p => p.join(",")).join(" "))
+                        .attr("fill", fill)
+                        .attr("class", nodeClass)
+                        .attr("opacity", opacity);
+                }} else if (d.节点类型 === "分类") {{
+                    // 方形(分类节点)
                     el.append("rect")
                         .attr("width", size * 2)
                         .attr("height", size * 2)
@@ -904,7 +942,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                         .attr("rx", 3)
                         .attr("opacity", opacity);
                 }} else {{
-                    // 圆形(标签)
+                    // 圆形(标签节点
                     el.append("circle")
                         .attr("r", size)
                         .attr("fill", isUnmatched ? "#666" : fill)
@@ -923,8 +961,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             const tooltip = d3.select("#tooltip");
 
             node.on("mouseover", (event, d) => {{
-                tooltip.style("display", "block")
-                    .html(`<strong>${{d.节点名称}}</strong><br/>类型: ${{d.节点类型}}<br/>层级: ${{d.节点层级}}`);
+                let html = `<strong>${{d.节点名称}}</strong><br/>类型: ${{d.节点类型}}<br/>层级: ${{d.节点层级}}`;
+                // 点节点显示描述
+                if (d.描述) {{
+                    html += `<br/><br/><em style="font-size:10px;color:#aaa">${{d.描述.slice(0, 100)}}${{d.描述.length > 100 ? '...' : ''}}</em>`;
+                }}
+                tooltip.style("display", "block").html(html);
             }})
             .on("mousemove", (event) => {{
                 tooltip.style("left", (event.pageX + 15) + "px")