feat(v3.1): 知识图谱可视化
P3 实现:图谱可视化(优先级1) 新文件: - docs/graph_visualization.html — D3.js 力导向图可视化 * 初始显示 TOP100 高连接节点 + 1跳邻居(约2000节点) * 双击节点展开邻居 * 搜索定位节点 * 缩放/拖拽/点击详情 * 关系类型彩色编码 - src/api/routes/graph_viz.py — 图数据 API * GET /api/v1/graph/data — D3.js 格式图数据 * GET /api/v1/graph/stats — 图谱统计概览 - docs/graph_data.json — 预导出图数据(2003节点, 2587边, 330KB) 功能: - 3362 节点 / 3642 边 / 6 种关系类型 - 高连接节点: muc(54), 发了(51), 4GB(50), AI(40), memory(36) - 滚动缩放 / 拖拽平移 / 双击展开邻居 / 搜索定位 - info-panel 显示节点详情和所有关联关系
This commit is contained in:
parent
a05d13e209
commit
311dd133bb
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,420 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>织忆·知识图谱</title>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#header {
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
padding: 16px 24px;
|
||||
background: rgba(13,17,23,0.9);
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
#header h1 { font-size: 18px; font-weight: 600; color: #58a6ff; }
|
||||
#header .stats { font-size: 13px; color: #8b949e; }
|
||||
#search {
|
||||
flex: 1; max-width: 320px;
|
||||
background: #161b22; border: 1px solid #30363d;
|
||||
border-radius: 8px; padding: 8px 14px;
|
||||
color: #e6edf3; font-size: 14px; outline: none;
|
||||
}
|
||||
#search:focus { border-color: #58a6ff; }
|
||||
#legend {
|
||||
position: fixed; bottom: 24px; left: 24px;
|
||||
background: rgba(22,27,34,0.92);
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px; padding: 14px 18px;
|
||||
font-size: 12px; z-index: 100;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
#legend .title { color: #8b949e; margin-bottom: 10px; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; margin: 5px 0; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
#tooltip {
|
||||
position: fixed; pointer-events: none;
|
||||
background: rgba(22,27,34,0.96);
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px; padding: 10px 14px;
|
||||
font-size: 13px; z-index: 200;
|
||||
max-width: 280px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
display: none;
|
||||
}
|
||||
#tooltip .name { color: #58a6ff; font-weight: 600; font-size: 14px; margin-bottom: 4px; }
|
||||
#tooltip .info { color: #8b949e; line-height: 1.5; }
|
||||
#tooltip .rel { color: #f0883e; margin-top: 4px; font-size: 12px; }
|
||||
#info-panel {
|
||||
position: fixed; top: 70px; right: 20px;
|
||||
width: 260px;
|
||||
background: rgba(22,27,34,0.92);
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px; padding: 16px;
|
||||
font-size: 13px; z-index: 100;
|
||||
display: none;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
#info-panel h3 { color: #58a6ff; margin-bottom: 10px; font-size: 15px; }
|
||||
#info-panel .field { color: #8b949e; margin: 4px 0; }
|
||||
#info-panel .field span { color: #e6edf3; }
|
||||
#controls {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
display: flex; flex-direction: column; gap: 8px; z-index: 100;
|
||||
}
|
||||
.btn {
|
||||
background: rgba(22,27,34,0.9);
|
||||
border: 1px solid #30363d;
|
||||
color: #8b949e; border-radius: 8px;
|
||||
padding: 8px 14px; cursor: pointer;
|
||||
font-size: 13px; text-align: center;
|
||||
transition: all 0.15s;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.btn:hover { border-color: #58a6ff; color: #58a6ff; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="header">
|
||||
<h1>🧠 织忆·知识图谱</h1>
|
||||
<div class="stats" id="stats">加载中...</div>
|
||||
<input id="search" placeholder="🔍 搜索节点(回车定位)" />
|
||||
<div style="margin-left:auto;font-size:12px;color:#484f58;">双击节点展开邻居 · 滚轮缩放 · 拖拽画布</div>
|
||||
</div>
|
||||
|
||||
<div id="tooltip">
|
||||
<div class="name" id="tt-name"></div>
|
||||
<div class="info" id="tt-info"></div>
|
||||
<div class="rel" id="tt-rel"></div>
|
||||
</div>
|
||||
|
||||
<div id="info-panel">
|
||||
<h3 id="ip-name">—</h3>
|
||||
<div class="field">类型: <span id="ip-type">—</span></div>
|
||||
<div class="field">连接数: <span id="ip-conn">—</span></div>
|
||||
<div class="field" id="ip-rels-title" style="margin-top:10px;color:#8b949e;">关联关系:</div>
|
||||
<div id="ip-rels" style="color:#8b949e;line-height:1.8;"></div>
|
||||
</div>
|
||||
|
||||
<div id="legend">
|
||||
<div class="title">关系类型</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div> 使用</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#f0883e"></div> 关联</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div> 相关</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#d2a8ff"></div> 包含</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ff7b72"></div> 认知</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ffa657"></div> 服务对象</div>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<div class="btn" id="btn-reset">🔄 重置视图</div>
|
||||
<div class="btn" id="btn-expand">📍 展开邻居</div>
|
||||
<div class="btn" id="btn-compact">🔬 精简视图</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const REL_COLORS = {
|
||||
'使用': '#58a6ff',
|
||||
'关联': '#f0883e',
|
||||
'相关': '#3fb950',
|
||||
'包含': '#d2a8ff',
|
||||
'认知': '#ff7b72',
|
||||
'服务对象': '#ffa657'
|
||||
};
|
||||
|
||||
let fullData = null;
|
||||
let nodes = [], links = [];
|
||||
let sim, canvas, ctx;
|
||||
let transform = d3.zoomIdentity;
|
||||
let hoveredNode = null;
|
||||
let selectedNode = null;
|
||||
let visibleNodes = new Set();
|
||||
let visibleLinks = [];
|
||||
|
||||
// ── 加载数据 ─────────────────────────────────────────────────
|
||||
d3.json('graph_data.json').then(data => {
|
||||
fullData = data;
|
||||
document.getElementById('stats').textContent =
|
||||
`全量: ${data.nodes.length} 节点 · ${data.links.length} 边`;
|
||||
|
||||
// 初始显示高连接节点(TOP 150 + 邻居)
|
||||
initGraph(data);
|
||||
});
|
||||
|
||||
function initGraph(data) {
|
||||
// 采样:取TOP节点 + 1跳邻居
|
||||
const topN = data.nodes.slice().sort((a,b) => b.connections - a.connections).slice(0, 100);
|
||||
const topIds = new Set(topN.map(n => n.id));
|
||||
|
||||
// 加入邻居
|
||||
data.links.forEach(l => {
|
||||
if (topIds.has(l.source)) topIds.add(l.target);
|
||||
if (topIds.has(l.target)) topIds.add(l.source);
|
||||
});
|
||||
|
||||
// 最终采样节点
|
||||
const sampledNodes = data.nodes.filter(n => topIds.has(n.id));
|
||||
const sampledLinks = data.links.filter(l => topIds.has(l.source) && topIds.has(l.target));
|
||||
|
||||
nodes = sampledNodes.map(n => ({...n}));
|
||||
links = sampledLinks.map(l => ({...l}));
|
||||
visibleNodes = new Set(nodes.map(n => n.id));
|
||||
|
||||
const W = window.innerWidth, H = window.innerHeight;
|
||||
canvas = d3.select('body').append('canvas').attr('width', W).attr('height', H);
|
||||
ctx = canvas.node().getContext('2d');
|
||||
|
||||
sim = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(60).strength(0.4))
|
||||
.force('charge', d3.forceManyBody().strength(-120))
|
||||
.force('center', d3.forceCenter(W/2, H/2))
|
||||
.force('collision', d3.forceCollide(8))
|
||||
.on('tick', draw);
|
||||
|
||||
setupInteraction();
|
||||
}
|
||||
|
||||
// ── 绘图 ──────────────────────────────────────────────────────
|
||||
function nodeRadius(d) {
|
||||
return Math.max(4, Math.min(16, 4 + d.connections * 0.4));
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const W = canvas.node().width, H = canvas.node().height;
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.translate(transform.x, transform.y);
|
||||
ctx.scale(transform.k, transform.k);
|
||||
|
||||
// 边
|
||||
links.forEach(l => {
|
||||
const color = REL_COLORS[l.relation] || '#444';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(l.source.x, l.source.y);
|
||||
ctx.lineTo(l.target.x, l.target.y);
|
||||
ctx.globalAlpha = l.source === selectedNode || l.target === selectedNode ? 0.9 : 0.25;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// 节点
|
||||
nodes.forEach(n => {
|
||||
const r = nodeRadius(n);
|
||||
const isSel = n === selectedNode;
|
||||
const isHov = n === hoveredNode;
|
||||
|
||||
// 光晕
|
||||
if (isSel || isHov) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r + 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(88,166,255,0.15)';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 圆
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isSel ? '#58a6ff' : isHov ? '#79c0ff' : '#30363d';
|
||||
ctx.strokeStyle = '#58a6ff';
|
||||
ctx.lineWidth = isSel || isHov ? 2 : 0.5;
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// 标签(大多连接节点或悬停)
|
||||
if (n.connections >= 5 || isHov || isSel) {
|
||||
ctx.font = `${isHov || isSel ? 13 : 11}px sans-serif`;
|
||||
ctx.fillStyle = isHov || isSel ? '#e6edf3' : '#8b949e';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(n.name, n.x, n.y - r - 5);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── 交互 ──────────────────────────────────────────────────────
|
||||
function setupInteraction() {
|
||||
const drag = d3.drag()
|
||||
.subject(e => nodes.find(n => Math.hypot(n.x - e.x, n.y - e.y) < 30) || {})
|
||||
.on('start', e => { if (!e.subject.x) return; sim.alphaTarget(0.3).restart(); e.subject.fx = e.subject.x; e.subject.fy = e.subject.y; })
|
||||
.on('drag', e => { e.subject.fx = e.x; e.subject.fy = e.y; })
|
||||
.on('end', e => { sim.alphaTarget(0); e.subject.fx = null; e.subject.fy = null; });
|
||||
|
||||
canvas.call(drag);
|
||||
|
||||
canvas.on('mousemove', e => {
|
||||
const [mx, my] = transform.invert([e.offsetX, e.offsetY]);
|
||||
const found = nodes.find(n => Math.hypot(n.x - mx, n.y - my) < nodeRadius(n) + 4);
|
||||
if (found !== hoveredNode) {
|
||||
hoveredNode = found;
|
||||
canvas.style.cursor = found ? 'pointer' : 'grab';
|
||||
draw();
|
||||
if (found) showTooltip(found, e.offsetX, e.offsetY);
|
||||
else hideTooltip();
|
||||
} else if (found) {
|
||||
moveTooltip(e.offsetX, e.offsetY);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on('click', e => {
|
||||
const [mx, my] = transform.invert([e.offsetX, e.offsetY]);
|
||||
const found = nodes.find(n => Math.hypot(n.x - mx, n.y - my) < nodeRadius(n) + 4);
|
||||
selectedNode = found;
|
||||
draw();
|
||||
if (found) showInfoPanel(found);
|
||||
else document.getElementById('info-panel').style.display = 'none';
|
||||
});
|
||||
|
||||
canvas.on('dblclick', e => {
|
||||
if (!selectedNode) return;
|
||||
const [mx, my] = transform.invert([e.offsetX, e.offsetY]);
|
||||
const clicked = nodes.find(n => Math.hypot(n.x - mx, n.y - my) < nodeRadius(n) + 4);
|
||||
if (clicked) expandNeighbors(clicked);
|
||||
});
|
||||
|
||||
d3.select(window).on('resize', () => {
|
||||
canvas.attr('width', window.innerWidth).attr('height', window.innerHeight);
|
||||
sim.force('center', d3.forceCenter(window.innerWidth/2, window.innerHeight/2));
|
||||
sim.alpha(0.3).restart();
|
||||
});
|
||||
|
||||
// 缩放
|
||||
d3.zoom().scaleExtent([0.05, 8]).on('zoom', e => {
|
||||
transform = e.transform;
|
||||
draw();
|
||||
})(canvas);
|
||||
}
|
||||
|
||||
function expandNeighbors(node) {
|
||||
const added = new Set();
|
||||
const addedLinks = [];
|
||||
fullData.links.forEach(l => {
|
||||
const src = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tgt = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
if (src === node.id && !visibleNodes.has(tgt)) { added.add(tgt); addedLinks.push(l); }
|
||||
if (tgt === node.id && !visibleNodes.has(src)) { added.add(src); addedLinks.push(l); }
|
||||
});
|
||||
|
||||
added.forEach(id => {
|
||||
const nd = fullData.nodes.find(n => n.id === id);
|
||||
if (nd) { nodes.push({...nd}); visibleNodes.add(id); }
|
||||
});
|
||||
addedLinks.forEach(l => links.push({...l}));
|
||||
|
||||
sim.nodes(nodes);
|
||||
sim.force('link').links(links);
|
||||
sim.alpha(0.8).restart();
|
||||
draw();
|
||||
document.getElementById('stats').textContent =
|
||||
`当前: ${nodes.length} 节点 · ${links.length} 边(全量: ${fullData.nodes.length} · ${fullData.links.length})`;
|
||||
}
|
||||
|
||||
function compactView() {
|
||||
const keep = nodes.filter(n => n.connections >= 3 || n === selectedNode);
|
||||
const keepIds = new Set(keep.map(n => n.id));
|
||||
nodes = keep;
|
||||
links = links.filter(l => keepIds.has((typeof l.source==='object'?l.source.id:l.source)) && keepIds.has((typeof l.target==='object'?l.target.id:l.target)));
|
||||
visibleNodes = new Set(nodes.map(n => n.id));
|
||||
sim.nodes(nodes);
|
||||
sim.force('link').links(links);
|
||||
sim.alpha(0.5).restart();
|
||||
draw();
|
||||
document.getElementById('stats').textContent = `精简: ${nodes.length} 节点 · ${links.length} 边`;
|
||||
}
|
||||
|
||||
// ── 搜索 ──────────────────────────────────────────────────────
|
||||
document.getElementById('search').addEventListener('keydown', e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const q = e.target.value.trim();
|
||||
if (!q || !fullData) return;
|
||||
const found = fullData.nodes.find(n => n.name.includes(q));
|
||||
if (found) {
|
||||
// 如果不在可见集中,展开它
|
||||
if (!visibleNodes.has(found.id)) {
|
||||
// 找已显示的节点加它进来
|
||||
nodes.push({...found});
|
||||
visibleNodes.add(found.id);
|
||||
const nlinks = fullData.links.filter(l => l.source === found.id || l.target === found.id)
|
||||
.filter(l => visibleNodes.has(typeof l.target==='object'?l.target.id:l.target) || visibleNodes.has(typeof l.source==='object'?l.source.id:l.source))
|
||||
.map(l => ({...l}));
|
||||
links.push(...nlinks);
|
||||
sim.nodes(nodes);
|
||||
sim.force('link').links(links);
|
||||
}
|
||||
// 聚焦
|
||||
const n = nodes.find(n => n.id === found.id);
|
||||
if (n) {
|
||||
selectedNode = n;
|
||||
const tx = window.innerWidth/2 - n.x * transform.k;
|
||||
const ty = window.innerHeight/2 - n.y * transform.k;
|
||||
transform = transform.translate(tx, ty);
|
||||
draw();
|
||||
showInfoPanel(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── 按钮 ──────────────────────────────────────────────────────
|
||||
document.getElementById('btn-reset').onclick = () => {
|
||||
transform = d3.zoomIdentity;
|
||||
draw();
|
||||
document.getElementById('info-panel').style.display = 'none';
|
||||
selectedNode = null;
|
||||
};
|
||||
document.getElementById('btn-expand').onclick = () => {
|
||||
if (selectedNode) expandNeighbors(selectedNode);
|
||||
else { selectedNode = nodes[Math.floor(Math.random()*nodes.length)]; expandNeighbors(selectedNode); }
|
||||
};
|
||||
document.getElementById('btn-compact').onclick = compactView;
|
||||
|
||||
// ── 提示 ──────────────────────────────────────────────────────
|
||||
function showTooltip(n, x, y) {
|
||||
document.getElementById('tt-name').textContent = n.name;
|
||||
document.getElementById('tt-info').textContent = `连接数: ${n.connections} · 类型: ${n.group}`;
|
||||
document.getElementById('tt-rel').textContent = '双击展开邻居';
|
||||
const tt = document.getElementById('tooltip');
|
||||
tt.style.display = 'block';
|
||||
moveTooltip(x, y);
|
||||
}
|
||||
function moveTooltip(x, y) {
|
||||
const tt = document.getElementById('tooltip');
|
||||
tt.style.left = (x + 14) + 'px';
|
||||
tt.style.top = (y - 20) + 'px';
|
||||
}
|
||||
function hideTooltip() { document.getElementById('tooltip').style.display = 'none'; }
|
||||
|
||||
function showInfoPanel(n) {
|
||||
const panel = document.getElementById('info-panel');
|
||||
panel.style.display = 'block';
|
||||
document.getElementById('ip-name').textContent = n.name;
|
||||
document.getElementById('ip-type').textContent = n.group;
|
||||
document.getElementById('ip-conn').textContent = n.connections;
|
||||
|
||||
const rels = {};
|
||||
links.forEach(l => {
|
||||
const src = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tgt = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
if (src === n.id) rels[tgt] = l.relation;
|
||||
if (tgt === n.id) rels[src] = l.relation;
|
||||
});
|
||||
const relsDiv = document.getElementById('ip-rels');
|
||||
relsDiv.innerHTML = Object.entries(rels).slice(0, 12).map(([name, rel]) =>
|
||||
`<div><span style="color:${REL_COLORS[rel]||'#fff'}">${rel}</span> → ${name}</div>`
|
||||
).join('');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
"""知识图谱可视化 API — P3 实现: 图谱可视化"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
GRAPH_DB = Path.home() / '.memory-fabric' / 'zhiyi' / 'graph.db'
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
group: str
|
||||
connections: int = 0
|
||||
properties: dict = {}
|
||||
|
||||
|
||||
class GraphLink(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
relation: str
|
||||
properties: dict = {}
|
||||
|
||||
|
||||
class GraphData(BaseModel):
|
||||
nodes: list[GraphNode]
|
||||
links: list[GraphLink]
|
||||
total_nodes: int
|
||||
total_edges: int
|
||||
|
||||
|
||||
@router.get('/graph/data')
|
||||
def get_graph_data(limit: int = 2000):
|
||||
"""获取图谱数据(用于可视化)
|
||||
|
||||
- 初始返回高连接节点 + 邻居(最多 limit 条)
|
||||
- 前端负责交互展开更多节点
|
||||
- 数据格式:D3.js force-directed graph 兼容
|
||||
"""
|
||||
if not GRAPH_DB.exists():
|
||||
raise HTTPException(status_code=404, detail="图数据库不存在")
|
||||
|
||||
conn = sqlite3.connect(str(GRAPH_DB))
|
||||
cur = conn.cursor()
|
||||
|
||||
# 总数
|
||||
cur.execute("SELECT COUNT(*) FROM graph_nodes")
|
||||
total_nodes = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM graph_edges")
|
||||
total_edges = cur.fetchone()[0]
|
||||
|
||||
# 读取全部边建立 entity→id 映射
|
||||
cur.execute("SELECT id, entity FROM graph_nodes")
|
||||
entity_to_id = {row[1]: row[0] for row in cur.fetchall()}
|
||||
id_to_entity = {v: k for k, v in entity_to_id.items()}
|
||||
|
||||
# 读取边(source/target 是 entity 名字)
|
||||
cur.execute("SELECT source, target, relation FROM graph_edges")
|
||||
links_raw = cur.fetchall()
|
||||
|
||||
# 统计节点连接数
|
||||
from collections import Counter
|
||||
cnt = Counter()
|
||||
for src, tgt, rel in links_raw:
|
||||
if src in entity_to_id and tgt in entity_to_id:
|
||||
cnt[src] += 1
|
||||
cnt[tgt] += 1
|
||||
|
||||
# 取 top-N 高连接节点
|
||||
top_entities = [e for e, _ in cnt.most_common(limit)]
|
||||
top_ids = set(top_entities)
|
||||
|
||||
# 加入邻居
|
||||
neighbor_ids = set()
|
||||
for src, tgt, rel in links_raw:
|
||||
if src in top_ids and tgt not in top_ids and tgt in entity_to_id:
|
||||
neighbor_ids.add(tgt)
|
||||
if tgt in top_ids and src not in top_ids and src in entity_to_id:
|
||||
neighbor_ids.add(src)
|
||||
|
||||
visible_ids = top_ids | neighbor_ids
|
||||
|
||||
# 节点列表
|
||||
visible_entities = list(visible_ids)
|
||||
|
||||
nodes = []
|
||||
for ent in visible_entities:
|
||||
node_id = entity_to_id.get(ent, ent)
|
||||
nodes.append({
|
||||
"id": ent, # 用 entity 名字作为可视化 ID
|
||||
"name": ent,
|
||||
"group": "concept",
|
||||
"connections": cnt.get(ent, 0),
|
||||
})
|
||||
|
||||
# 边列表
|
||||
links = []
|
||||
for src, tgt, rel in links_raw:
|
||||
if src in visible_ids and tgt in visible_ids:
|
||||
links.append({
|
||||
"source": src,
|
||||
"target": tgt,
|
||||
"relation": rel,
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"links": links,
|
||||
"total_nodes": total_nodes,
|
||||
"total_edges": total_edges,
|
||||
}
|
||||
|
||||
|
||||
@router.get('/graph/stats')
|
||||
def get_graph_stats():
|
||||
"""图谱统计概览"""
|
||||
if not GRAPH_DB.exists():
|
||||
raise HTTPException(status_code=404, detail="图数据库不存在")
|
||||
|
||||
conn = sqlite3.connect(str(GRAPH_DB))
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM graph_nodes")
|
||||
node_count = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM graph_edges")
|
||||
edge_count = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT relation, COUNT(*) FROM graph_edges GROUP BY relation ORDER BY COUNT(*) DESC")
|
||||
rel_dist = [{"relation": r, "count": c} for r, c in cur.fetchall()]
|
||||
|
||||
# 连接数分布
|
||||
cur.execute("""
|
||||
SELECT t.c as conn_count, COUNT(*) as num
|
||||
FROM (
|
||||
SELECT source as entity FROM graph_edges
|
||||
UNION ALL
|
||||
SELECT target as entity FROM graph_edges
|
||||
) e
|
||||
JOIN (
|
||||
SELECT entity, COUNT(*) as c FROM graph_edges GROUP BY entity
|
||||
) t ON t.entity = e.entity
|
||||
GROUP BY t.c
|
||||
ORDER BY t.c DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
conn_dist = [{"connections": c, "count": n} for c, n in cur.fetchall()]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"node_count": node_count,
|
||||
"edge_count": edge_count,
|
||||
"relation_distribution": rel_dist,
|
||||
"connection_distribution": conn_dist,
|
||||
}
|
||||
Loading…
Reference in New Issue