visualize_results.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import json
  2. import sys
  3. from pathlib import Path
  4. def generate_html_visualization(result_data: dict, cases_data: dict) -> str:
  5. merged_demands = result_data.get("merged_demands", [])
  6. mount_decisions = result_data.get("mount_decisions", [])
  7. # 构建 decision_map:demand_name → mounted_nodes
  8. decision_map = {}
  9. for d in mount_decisions:
  10. dec = d.get("decision", {})
  11. if isinstance(dec, dict):
  12. decision_map[d["demand_name"]] = dec.get("mounted_nodes", [])
  13. else:
  14. decision_map[d["demand_name"]] = []
  15. # 优先从 result_data 读取 case_info,如果没有则从 cases_data 构建
  16. if "case_info" in result_data:
  17. case_map = result_data["case_info"]
  18. else:
  19. case_map = {}
  20. for c in cases_data.get("cases", []):
  21. case_map[c["case_id"]] = {
  22. "title": c.get("title") or c.get("video_title") or c.get("post_title", ""),
  23. "images": c.get("images") or c.get("effect_images", []),
  24. "link": c.get("source_link") or c.get("video_url") or c.get("post_url", "")
  25. }
  26. nodes_js = []
  27. edges_js = []
  28. node_id = 0
  29. COLOR_CASE_BG = "#e3f2fd"
  30. COLOR_CASE_BORDER = "#2196f3"
  31. COLOR_DEMAND_BG = "#fff3e0"
  32. COLOR_DEMAND_BORDER = "#ff9800"
  33. for md in merged_demands:
  34. demand_id = node_id
  35. node_id += 1
  36. dn = md["demand_name"]
  37. # 从挂载决策中获取最终选择的节点
  38. mounted = decision_map.get(dn, [])
  39. node_tags = []
  40. for n in mounted:
  41. node_tags.append(f"{n['name']}")
  42. tags_str = " | ".join(node_tags) if node_tags else "无挂载节点"
  43. demand_label = f"<b>{dn}</b>\n\n[{tags_str}]"
  44. demand_title_html = f"""
  45. <div class='tooltip-title' style='background:{COLOR_DEMAND_BG}; border-color:{COLOR_DEMAND_BORDER}'>需求详情</div>
  46. <div class='tooltip-content'>
  47. <p><b>名称:</b> {dn}</p>
  48. <p><b>描述:</b> {md["description"]}</p>
  49. <p><b>挂载节点:</b> {tags_str}</p>
  50. {"".join(f"<p>· {n['name']}({n.get('source_type','')}) — {n.get('reason','')}</p>" for n in mounted)}
  51. </div>
  52. """
  53. nodes_js.append({
  54. "id": demand_id,
  55. "label": demand_label,
  56. "title": demand_title_html,
  57. "group": "demand",
  58. "level": 0 # 核心改动1:Demand 改为 Level 0 (根节点)
  59. })
  60. for cid in md.get("source_case_ids", []):
  61. case_id = node_id
  62. node_id += 1
  63. # 类型转换:case_info 的 key 可能是字符串
  64. cid_str = str(cid)
  65. case_info = case_map.get(cid_str) or case_map.get(cid, {"title": f"Case {cid}", "images": [], "link": ""})
  66. title_short = case_info['title'][:20] + "..." if len(case_info['title']) > 20 else case_info['title']
  67. case_label = f"<b>Case {cid}</b>\n{title_short}"
  68. img_html = ""
  69. if case_info['images']:
  70. img_url = case_info['images'][0]
  71. img_html = f'<img src="{img_url}" class="tooltip-img"/>'
  72. case_title_html = f"""
  73. <div class='tooltip-title' style='background:{COLOR_CASE_BG}; border-color:{COLOR_CASE_BORDER}'>帖子详情 (点击跳转)</div>
  74. <div class='tooltip-content'>
  75. <p><b>ID:</b> {cid}</p>
  76. <p><b>标题:</b> {case_info["title"]}</p>
  77. {img_html}
  78. </div>
  79. """
  80. nodes_js.append({
  81. "id": case_id,
  82. "label": case_label,
  83. "title": case_title_html,
  84. "url": case_info['link'],
  85. "group": "case",
  86. "level": 1 # 核心改动2:Case 改为 Level 1 (叶子节点)
  87. })
  88. # 核心改动3:在数据上把连线方向反转为 Demand -> Case
  89. edges_js.append({"from": demand_id, "to": case_id})
  90. html = f"""<!DOCTYPE html>
  91. <html><head>
  92. <meta charset="utf-8">
  93. <meta name="referrer" content="no-referrer">
  94. <title>Case → Demand 语义映射图</title>
  95. <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
  96. <style>
  97. body {{
  98. margin: 0;
  99. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  100. background-color: #f4f7f6;
  101. overflow: hidden;
  102. }}
  103. #graph {{ width: 100vw; height: 100vh; background-color: white; }}
  104. #header {{
  105. position: absolute; top: 10px; left: 20px; padding: 10px 20px;
  106. display: flex; gap: 30px; align-items: center;
  107. background: rgba(255, 255, 255, 0.9); box-shadow: 0 4px 10px rgba(0,0,0,0.1);
  108. border-radius: 8px; z-index: 10;
  109. }}
  110. .brand {{ font-size: 16px; font-weight: bold; color: #333; }}
  111. #legend {{ display: flex; gap: 15px; align-items: center; font-size: 13px; }}
  112. .legend-item {{ display: flex; align-items: center; gap: 6px; }}
  113. .dot {{ width: 14px; height: 14px; border-radius: 3px; border: 1px solid; }}
  114. div.vis-network div.vis-tooltip {{
  115. padding: 0; border: none; border-radius: 8px; background-color: white;
  116. box-shadow: 0 4px 15px rgba(0,0,0,0.25); font-size: 14px; max-width: 400px; overflow: hidden;
  117. }}
  118. .tooltip-title {{ padding: 10px 15px; font-weight: bold; border-bottom: 1px solid; color: #333; }}
  119. .tooltip-content {{ padding: 15px; color: #555; }}
  120. .tooltip-content p {{ margin: 0 0 10px 0; line-height: 1.5; }}
  121. .tooltip-img {{ max-width: 100%; max-height: 250px; display: block; margin-top: 10px; border-radius: 4px; border: 1px solid #eee; }}
  122. </style>
  123. </head><body>
  124. <div id="header">
  125. <div class="brand">语义映射网络 (鼠标拖拽平移,滚轮缩放)</div>
  126. <div id="legend">
  127. <span class="legend-item"><span class="dot" style="background:{COLOR_CASE_BG}; border-color:{COLOR_CASE_BORDER}"></span> 帖子 (Case)</span>
  128. <span class="legend-item"><span class="dot" style="background:{COLOR_DEMAND_BG}; border-color:{COLOR_DEMAND_BORDER}"></span> 需求 (Demand)</span>
  129. </div>
  130. </div>
  131. <div id="graph"></div>
  132. <script>
  133. var nodesRaw = {json.dumps(nodes_js, ensure_ascii=False)};
  134. var edgesRaw = {json.dumps(edges_js, ensure_ascii=False)};
  135. nodesRaw.forEach(function(node) {{
  136. if (node.title && typeof node.title === 'string') {{
  137. var container = document.createElement('div');
  138. container.innerHTML = node.title;
  139. node.title = container;
  140. }}
  141. }});
  142. var nodes = new vis.DataSet(nodesRaw);
  143. var edges = new vis.DataSet(edgesRaw);
  144. var container = document.getElementById("graph");
  145. var options = {{
  146. layout: {{
  147. hierarchical: {{
  148. direction: "DU", /* 核心改动4:改为从下往上 (Down-Up) 生长。Level 0(Demand)在底,Level 1(Case)在顶 */
  149. sortMethod: "directed",
  150. levelSeparation: 300,
  151. nodeSpacing: 180,
  152. treeSpacing: 350,
  153. parentCentralization: true /* 由于 Demand 在逻辑上成了父节点,这个属性会完美将它居中 */
  154. }}
  155. }},
  156. groups: {{
  157. useDefaultGroups: false,
  158. case: {{
  159. shape: "box",
  160. color: {{ background: "{COLOR_CASE_BG}", border: "{COLOR_CASE_BORDER}" }},
  161. font: {{ size: 14, multi: 'html', color: "#0d47a1" }},
  162. widthConstraint: {{ minimum: 150, maximum: 220 }},
  163. shapeProperties: {{ borderDashes: false, borderRadius: 6 }},
  164. margin: 12
  165. }},
  166. demand: {{
  167. shape: "box",
  168. color: {{ background: "{COLOR_DEMAND_BG}", border: "{COLOR_DEMAND_BORDER}" }},
  169. font: {{ size: 15, multi: 'html', color: "#e65100" }},
  170. widthConstraint: {{ minimum: 200, maximum: 300 }},
  171. shapeProperties: {{ borderDashes: false, borderRadius: 6 }},
  172. margin: 15
  173. }}
  174. }},
  175. edges: {{
  176. arrows: "from", /* 核心改动5:虽然连线是 Demand->Case,但把箭头强制画在 Demand 一侧,看起来就是 Case->Demand */
  177. color: {{ color: "#b0bec5", highlight: "#78909c" }},
  178. smooth: {{ type: "cubicBezier", forceDirection: "vertical", roundness: 0.5 }},
  179. width: 1.5
  180. }},
  181. physics: {{ enabled: false }},
  182. interaction: {{
  183. hover: true, tooltipDelay: 100, dragView: true, zoomView: true
  184. }}
  185. }};
  186. var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
  187. network.once("afterDrawing", function() {{
  188. if (nodesRaw.length > 0) {{
  189. network.focus(nodesRaw[0].id, {{
  190. scale: 1.0,
  191. animation: false,
  192. offset: {{x: 0, y: -100}}
  193. }});
  194. }}
  195. }});
  196. network.on("click", function(params) {{
  197. if (params.nodes.length > 0) {{
  198. var nodeData = nodes.get(params.nodes[0]);
  199. if (nodeData.url && nodeData.url.startsWith('http')) window.open(nodeData.url, '_blank');
  200. }}
  201. }});
  202. network.on("hoverNode", function(params) {{
  203. if (nodes.get(params.node).url) network.canvas.body.container.style.cursor = 'pointer';
  204. }});
  205. network.on("blurNode", function() {{
  206. network.canvas.body.container.style.cursor = 'default';
  207. }});
  208. </script>
  209. </body></html>"""
  210. return html
  211. def main():
  212. if len(sys.argv) < 2:
  213. print("用法: python visualize_results.py <match_nodes_result.json路径>")
  214. sys.exit(1)
  215. result_path = Path(sys.argv[1])
  216. if not result_path.exists():
  217. print(f"错误: 文件不存在: {result_path}")
  218. sys.exit(1)
  219. with open(result_path, "r", encoding="utf-8") as f:
  220. result_data = json.load(f)
  221. cases_path = result_path.parent / "02_cases.json"
  222. if not cases_path.exists():
  223. cases_data = {"cases": []}
  224. else:
  225. with open(cases_path, "r", encoding="utf-8") as f:
  226. cases_data = json.load(f)
  227. html = generate_html_visualization(result_data, cases_data)
  228. output_path = result_path.parent / "match_nodes_graph.html"
  229. with open(output_path, "w", encoding="utf-8") as f:
  230. f.write(html)
  231. print(f"[OK] Visualization generated: {output_path}")
  232. if __name__ == "__main__":
  233. main()