mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 19:07:10 +00:00
refactor: dead imports, node_community helper, split to_html and call_tool
- Remove unused shutil (cache.py), sys (ingest.py), inline import os (detect.py) - Extract _node_community_map() helper - was copy-pasted 8 times across analyze.py + export.py - Split to_html() 285-line monolith into _html_styles() + _html_script() + thin orchestrator - Split serve.py call_tool() if/elif chain into per-tool handler functions + dispatch table - Extract _find_node() helper shared across get_node and get_neighbors handlers All 231 tests pass.
This commit is contained in:
+8
-3
@@ -3,6 +3,11 @@ from __future__ import annotations
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def _node_community_map(communities: dict[int, list[str]]) -> dict[str, int]:
|
||||
"""Invert communities dict: node_id -> community_id."""
|
||||
return {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
|
||||
|
||||
def _is_file_node(G: nx.Graph, node_id: str) -> bool:
|
||||
"""
|
||||
Return True if this node is a file-level hub node (e.g. 'client', 'models')
|
||||
@@ -187,7 +192,7 @@ def _cross_file_surprises(G: nx.Graph, communities: dict[int, list[str]], top_n:
|
||||
|
||||
Each result includes a 'why' field explaining what makes it non-obvious.
|
||||
"""
|
||||
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
node_community = _node_community_map(communities)
|
||||
candidates = []
|
||||
|
||||
for u, v, data in G.edges(data=True):
|
||||
@@ -266,7 +271,7 @@ def _cross_community_surprises(
|
||||
return result
|
||||
|
||||
# Build node → community map
|
||||
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
node_community = _node_community_map(communities)
|
||||
|
||||
surprises = []
|
||||
for u, v, data in G.edges(data=True):
|
||||
@@ -325,7 +330,7 @@ def suggest_questions(
|
||||
Each question has a 'type', 'question', and 'why' field.
|
||||
"""
|
||||
questions = []
|
||||
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
node_community = _node_community_map(communities)
|
||||
|
||||
# 1. AMBIGUOUS edges → unresolved relationship questions
|
||||
for u, v, data in G.edges(data=True):
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,7 @@
|
||||
# file discovery, type classification, and corpus health checks
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
@@ -155,7 +156,6 @@ def detect(root: Path) -> dict:
|
||||
|
||||
for scan_root in scan_paths:
|
||||
in_memory_tree = memory_dir.exists() and str(scan_root).startswith(str(memory_dir))
|
||||
import os
|
||||
for dirpath, dirnames, filenames in os.walk(scan_root, followlinks=False):
|
||||
dp = Path(dirpath)
|
||||
if not in_memory_tree:
|
||||
|
||||
+188
-192
@@ -17,8 +17,186 @@ COMMUNITY_COLORS = [
|
||||
MAX_NODES_FOR_VIZ = 5_000
|
||||
|
||||
|
||||
def _node_community_map(communities: dict[int, list[str]]) -> dict[str, int]:
|
||||
"""Invert communities dict: node_id -> community_id."""
|
||||
return {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
|
||||
|
||||
def _html_styles() -> str:
|
||||
return """<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
|
||||
#graph { flex: 1; }
|
||||
#sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
|
||||
#search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
|
||||
#search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
|
||||
#search:focus { border-color: #4E79A7; }
|
||||
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
|
||||
.search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.search-item:hover { background: #2a2a4e; }
|
||||
#info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
|
||||
#info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
#info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
|
||||
#info-content .field { margin-bottom: 5px; }
|
||||
#info-content .field b { color: #e0e0e0; }
|
||||
#info-content .empty { color: #555; font-style: italic; }
|
||||
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
|
||||
.neighbor-link:hover { background: #2a2a4e; }
|
||||
#neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
|
||||
#legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
#legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
|
||||
.legend-item:hover { background: #2a2a4e; padding-left: 4px; }
|
||||
.legend-item.dimmed { opacity: 0.35; }
|
||||
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
.legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.legend-count { color: #666; font-size: 11px; }
|
||||
#stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
|
||||
</style>"""
|
||||
|
||||
|
||||
def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
|
||||
return f"""<script>
|
||||
const RAW_NODES = {nodes_json};
|
||||
const RAW_EDGES = {edges_json};
|
||||
const LEGEND = {legend_json};
|
||||
|
||||
// Build vis datasets
|
||||
const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
|
||||
id: n.id, label: n.label, color: n.color, size: n.size,
|
||||
font: n.font, title: n.title,
|
||||
_community: n.community, _community_name: n.community_name,
|
||||
_source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
|
||||
}})));
|
||||
|
||||
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
|
||||
id: i, from: e.from, to: e.to,
|
||||
label: '',
|
||||
title: e.title,
|
||||
dashes: e.dashes,
|
||||
width: e.width,
|
||||
color: e.color,
|
||||
arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
|
||||
}})));
|
||||
|
||||
const container = document.getElementById('graph');
|
||||
const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, {{
|
||||
physics: {{
|
||||
enabled: true,
|
||||
solver: 'forceAtlas2Based',
|
||||
forceAtlas2Based: {{
|
||||
gravitationalConstant: -60,
|
||||
centralGravity: 0.005,
|
||||
springLength: 120,
|
||||
springConstant: 0.08,
|
||||
damping: 0.4,
|
||||
avoidOverlap: 0.8,
|
||||
}},
|
||||
stabilization: {{ iterations: 200, fit: true }},
|
||||
}},
|
||||
interaction: {{
|
||||
hover: true,
|
||||
tooltipDelay: 100,
|
||||
hideEdgesOnDrag: true,
|
||||
navigationButtons: false,
|
||||
keyboard: false,
|
||||
}},
|
||||
nodes: {{ shape: 'dot', borderWidth: 1.5 }},
|
||||
edges: {{ smooth: {{ type: 'continuous', roundness: 0.2 }}, selectionWidth: 3 }},
|
||||
}});
|
||||
|
||||
network.once('stabilizationIterationsDone', () => {{
|
||||
network.setOptions({{ physics: {{ enabled: false }} }});
|
||||
}});
|
||||
|
||||
function showInfo(nodeId) {{
|
||||
const n = nodesDS.get(nodeId);
|
||||
if (!n) return;
|
||||
const neighborIds = network.getConnectedNodes(nodeId);
|
||||
const neighborItems = neighborIds.map(nid => {{
|
||||
const nb = nodesDS.get(nid);
|
||||
const color = nb ? nb.color.background : '#555';
|
||||
return `<span class="neighbor-link" style="border-left-color:${{color}}" onclick="focusNode('${{nid}}')">${{nb ? nb.label : nid}}</span>`;
|
||||
}}).join('');
|
||||
document.getElementById('info-content').innerHTML = `
|
||||
<div class="field"><b>${{n.label}}</b></div>
|
||||
<div class="field">Type: ${{n._file_type || 'unknown'}}</div>
|
||||
<div class="field">Community: ${{n._community_name}}</div>
|
||||
<div class="field">Source: ${{n._source_file || '-'}}</div>
|
||||
<div class="field">Degree: ${{n._degree}}</div>
|
||||
${{neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
|
||||
`;
|
||||
}}
|
||||
|
||||
function focusNode(nodeId) {{
|
||||
network.focus(nodeId, {{ scale: 1.4, animation: true }});
|
||||
network.selectNodes([nodeId]);
|
||||
showInfo(nodeId);
|
||||
}}
|
||||
|
||||
network.on('click', params => {{
|
||||
if (params.nodes.length > 0) showInfo(params.nodes[0]);
|
||||
else document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
|
||||
}});
|
||||
|
||||
const searchInput = document.getElementById('search');
|
||||
const searchResults = document.getElementById('search-results');
|
||||
searchInput.addEventListener('input', () => {{
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
searchResults.innerHTML = '';
|
||||
if (!q) {{ searchResults.style.display = 'none'; return; }}
|
||||
const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
|
||||
if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
|
||||
searchResults.style.display = 'block';
|
||||
matches.forEach(n => {{
|
||||
const el = document.createElement('div');
|
||||
el.className = 'search-item';
|
||||
el.textContent = n.label;
|
||||
el.style.borderLeft = `3px solid ${{n.color.background}}`;
|
||||
el.style.paddingLeft = '8px';
|
||||
el.onclick = () => {{
|
||||
network.focus(n.id, {{ scale: 1.5, animation: true }});
|
||||
network.selectNodes([n.id]);
|
||||
showInfo(n.id);
|
||||
searchResults.style.display = 'none';
|
||||
searchInput.value = '';
|
||||
}};
|
||||
searchResults.appendChild(el);
|
||||
}});
|
||||
}});
|
||||
document.addEventListener('click', e => {{
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput)
|
||||
searchResults.style.display = 'none';
|
||||
}});
|
||||
|
||||
const hiddenCommunities = new Set();
|
||||
const legendEl = document.getElementById('legend');
|
||||
LEGEND.forEach(c => {{
|
||||
const item = document.createElement('div');
|
||||
item.className = 'legend-item';
|
||||
item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div>
|
||||
<span class="legend-label">${{c.label}}</span>
|
||||
<span class="legend-count">${{c.count}}</span>`;
|
||||
item.onclick = () => {{
|
||||
if (hiddenCommunities.has(c.cid)) {{
|
||||
hiddenCommunities.delete(c.cid);
|
||||
item.classList.remove('dimmed');
|
||||
}} else {{
|
||||
hiddenCommunities.add(c.cid);
|
||||
item.classList.add('dimmed');
|
||||
}}
|
||||
const updates = RAW_NODES
|
||||
.filter(n => n.community === c.cid)
|
||||
.map(n => ({{ id: n.id, hidden: hiddenCommunities.has(c.cid) }}));
|
||||
nodesDS.update(updates);
|
||||
}};
|
||||
legendEl.appendChild(item);
|
||||
}});
|
||||
</script>"""
|
||||
|
||||
|
||||
def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) -> None:
|
||||
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
node_community = _node_community_map(communities)
|
||||
data = json_graph.node_link_data(G, edges="links")
|
||||
for node in data["nodes"]:
|
||||
node["community"] = node_community.get(node["id"])
|
||||
@@ -62,7 +240,7 @@ def to_html(
|
||||
f"Use --no-viz or reduce input size."
|
||||
)
|
||||
|
||||
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
node_community = _node_community_map(communities)
|
||||
degree = dict(G.degree())
|
||||
max_deg = max(degree.values()) if degree else 1
|
||||
|
||||
@@ -118,6 +296,7 @@ def to_html(
|
||||
edges_json = json.dumps(vis_edges)
|
||||
legend_json = json.dumps(legend_data)
|
||||
title = sanitize_label(str(output_path))
|
||||
stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -125,36 +304,7 @@ def to_html(
|
||||
<meta charset="UTF-8">
|
||||
<title>graphify - {title}</title>
|
||||
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }}
|
||||
#graph {{ flex: 1; }}
|
||||
#sidebar {{ width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }}
|
||||
#search-wrap {{ padding: 12px; border-bottom: 1px solid #2a2a4e; }}
|
||||
#search {{ width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }}
|
||||
#search:focus {{ border-color: #4E79A7; }}
|
||||
#search-results {{ max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }}
|
||||
.search-item {{ padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
|
||||
.search-item:hover {{ background: #2a2a4e; }}
|
||||
#info-panel {{ padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }}
|
||||
#info-panel h3 {{ font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }}
|
||||
#info-content {{ font-size: 13px; color: #ccc; line-height: 1.6; }}
|
||||
#info-content .field {{ margin-bottom: 5px; }}
|
||||
#info-content .field b {{ color: #e0e0e0; }}
|
||||
#info-content .empty {{ color: #555; font-style: italic; }}
|
||||
.neighbor-link {{ display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }}
|
||||
.neighbor-link:hover {{ background: #2a2a4e; }}
|
||||
#neighbors-list {{ max-height: 160px; overflow-y: auto; margin-top: 4px; }}
|
||||
#legend-wrap {{ flex: 1; overflow-y: auto; padding: 12px; }}
|
||||
#legend-wrap h3 {{ font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }}
|
||||
.legend-item {{ display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }}
|
||||
.legend-item:hover {{ background: #2a2a4e; padding-left: 4px; }}
|
||||
.legend-item.dimmed {{ opacity: 0.35; }}
|
||||
.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
|
||||
.legend-label {{ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
||||
.legend-count {{ color: #666; font-size: 11px; }}
|
||||
#stats {{ padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }}
|
||||
</style>
|
||||
{_html_styles()}
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph"></div>
|
||||
@@ -171,160 +321,9 @@ def to_html(
|
||||
<h3>Communities</h3>
|
||||
<div id="legend"></div>
|
||||
</div>
|
||||
<div id="stats">
|
||||
{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities
|
||||
</div>
|
||||
<div id="stats">{stats}</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const RAW_NODES = {nodes_json};
|
||||
const RAW_EDGES = {edges_json};
|
||||
const LEGEND = {legend_json};
|
||||
|
||||
// Build vis datasets
|
||||
const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
|
||||
id: n.id, label: n.label, color: n.color, size: n.size,
|
||||
font: n.font, title: n.title,
|
||||
// store metadata for info panel
|
||||
_community: n.community, _community_name: n.community_name,
|
||||
_source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
|
||||
}})));
|
||||
|
||||
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
|
||||
id: i, from: e.from, to: e.to,
|
||||
label: '', // hide edge labels by default - too noisy
|
||||
title: e.title,
|
||||
dashes: e.dashes,
|
||||
width: e.width,
|
||||
color: e.color,
|
||||
arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
|
||||
}})));
|
||||
|
||||
const container = document.getElementById('graph');
|
||||
const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, {{
|
||||
physics: {{
|
||||
enabled: true,
|
||||
solver: 'forceAtlas2Based',
|
||||
forceAtlas2Based: {{
|
||||
gravitationalConstant: -60,
|
||||
centralGravity: 0.005,
|
||||
springLength: 120,
|
||||
springConstant: 0.08,
|
||||
damping: 0.4,
|
||||
avoidOverlap: 0.8,
|
||||
}},
|
||||
stabilization: {{ iterations: 200, fit: true }},
|
||||
}},
|
||||
interaction: {{
|
||||
hover: true,
|
||||
tooltipDelay: 100,
|
||||
hideEdgesOnDrag: true,
|
||||
navigationButtons: false,
|
||||
keyboard: false,
|
||||
}},
|
||||
nodes: {{
|
||||
shape: 'dot',
|
||||
borderWidth: 1.5,
|
||||
}},
|
||||
edges: {{
|
||||
smooth: {{ type: 'continuous', roundness: 0.2 }},
|
||||
selectionWidth: 3,
|
||||
}},
|
||||
}});
|
||||
|
||||
// After stabilization, disable physics so graph is stable
|
||||
network.once('stabilizationIterationsDone', () => {{
|
||||
network.setOptions({{ physics: {{ enabled: false }} }});
|
||||
}});
|
||||
|
||||
// --- INFO PANEL ---
|
||||
function showInfo(nodeId) {{
|
||||
const n = nodesDS.get(nodeId);
|
||||
if (!n) return;
|
||||
const neighborIds = network.getConnectedNodes(nodeId);
|
||||
const neighborItems = neighborIds.map(nid => {{
|
||||
const nb = nodesDS.get(nid);
|
||||
const color = nb ? nb.color.background : '#555';
|
||||
return `<span class="neighbor-link" style="border-left-color:${{color}}" onclick="focusNode('${{nid}}')">${{nb ? nb.label : nid}}</span>`;
|
||||
}}).join('');
|
||||
document.getElementById('info-content').innerHTML = `
|
||||
<div class="field"><b>${{n.label}}</b></div>
|
||||
<div class="field">Type: ${{n._file_type || 'unknown'}}</div>
|
||||
<div class="field">Community: ${{n._community_name}}</div>
|
||||
<div class="field">Source: ${{n._source_file || '-'}}</div>
|
||||
<div class="field">Degree: ${{n._degree}}</div>
|
||||
${{neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
|
||||
`;
|
||||
}}
|
||||
|
||||
function focusNode(nodeId) {{
|
||||
network.focus(nodeId, {{ scale: 1.4, animation: true }});
|
||||
network.selectNodes([nodeId]);
|
||||
showInfo(nodeId);
|
||||
}}
|
||||
|
||||
network.on('click', params => {{
|
||||
if (params.nodes.length > 0) showInfo(params.nodes[0]);
|
||||
else document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
|
||||
}});
|
||||
|
||||
// --- SEARCH ---
|
||||
const searchInput = document.getElementById('search');
|
||||
const searchResults = document.getElementById('search-results');
|
||||
searchInput.addEventListener('input', () => {{
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
searchResults.innerHTML = '';
|
||||
if (!q) {{ searchResults.style.display = 'none'; return; }}
|
||||
const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
|
||||
if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
|
||||
searchResults.style.display = 'block';
|
||||
matches.forEach(n => {{
|
||||
const el = document.createElement('div');
|
||||
el.className = 'search-item';
|
||||
el.textContent = n.label;
|
||||
el.style.borderLeft = `3px solid ${{n.color.background}}`;
|
||||
el.style.paddingLeft = '8px';
|
||||
el.onclick = () => {{
|
||||
network.focus(n.id, {{ scale: 1.5, animation: true }});
|
||||
network.selectNodes([n.id]);
|
||||
showInfo(n.id);
|
||||
searchResults.style.display = 'none';
|
||||
searchInput.value = '';
|
||||
}};
|
||||
searchResults.appendChild(el);
|
||||
}});
|
||||
}});
|
||||
document.addEventListener('click', e => {{
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput)
|
||||
searchResults.style.display = 'none';
|
||||
}});
|
||||
|
||||
// --- LEGEND / COMMUNITY FILTER ---
|
||||
const hiddenCommunities = new Set();
|
||||
const legendEl = document.getElementById('legend');
|
||||
LEGEND.forEach(c => {{
|
||||
const item = document.createElement('div');
|
||||
item.className = 'legend-item';
|
||||
item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div>
|
||||
<span class="legend-label">${{c.label}}</span>
|
||||
<span class="legend-count">${{c.count}}</span>`;
|
||||
item.onclick = () => {{
|
||||
if (hiddenCommunities.has(c.cid)) {{
|
||||
hiddenCommunities.delete(c.cid);
|
||||
item.classList.remove('dimmed');
|
||||
}} else {{
|
||||
hiddenCommunities.add(c.cid);
|
||||
item.classList.add('dimmed');
|
||||
}}
|
||||
// Show/hide nodes by community
|
||||
const updates = RAW_NODES
|
||||
.filter(n => n.community === c.cid)
|
||||
.map(n => ({{ id: n.id, hidden: hiddenCommunities.has(c.cid) }}));
|
||||
nodesDS.update(updates);
|
||||
}};
|
||||
legendEl.appendChild(item);
|
||||
}});
|
||||
</script>
|
||||
{_html_script(nodes_json, edges_json, legend_json)}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -353,7 +352,7 @@ def to_obsidian(
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
node_community = _node_community_map(communities)
|
||||
|
||||
# Map node_id → safe filename so wikilinks stay consistent.
|
||||
# Deduplicate: if two nodes produce the same filename, append a numeric suffix.
|
||||
@@ -755,10 +754,7 @@ def push_to_neo4j(
|
||||
"neo4j driver not installed. Run: pip install neo4j"
|
||||
) from e
|
||||
|
||||
node_community = (
|
||||
{n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
if communities else {}
|
||||
)
|
||||
node_community = _node_community_map(communities) if communities else {}
|
||||
|
||||
def _safe_rel(relation: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
|
||||
@@ -809,7 +805,7 @@ def to_graphml(
|
||||
Edge confidence (EXTRACTED/INFERRED/AMBIGUOUS) is preserved as an edge attribute.
|
||||
"""
|
||||
H = G.copy()
|
||||
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
node_community = _node_community_map(communities)
|
||||
for node_id in H.nodes():
|
||||
H.nodes[node_id]["community"] = node_community.get(node_id, -1)
|
||||
nx.write_graphml(H, output_path)
|
||||
@@ -837,7 +833,7 @@ def to_svg(
|
||||
except ImportError as e:
|
||||
raise ImportError("matplotlib not installed. Run: pip install matplotlib") from e
|
||||
|
||||
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
node_community = _node_community_map(communities)
|
||||
|
||||
fig, ax = plt.subplots(figsize=figsize, facecolor="#1a1a2e")
|
||||
ax.set_facecolor("#1a1a2e")
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
|
||||
+129
-135
@@ -93,6 +93,13 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu
|
||||
return output
|
||||
|
||||
|
||||
def _find_node(G: nx.Graph, label: str) -> list[str]:
|
||||
"""Return node IDs whose label or ID matches the search term (case-insensitive)."""
|
||||
term = label.lower()
|
||||
return [nid for nid, d in G.nodes(data=True)
|
||||
if term in d.get("label", "").lower() or term == nid.lower()]
|
||||
|
||||
|
||||
def serve(graph_path: str = "graphify-out/graph.json") -> None:
|
||||
"""Start the MCP server. Requires pip install mcp."""
|
||||
try:
|
||||
@@ -130,9 +137,7 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
|
||||
description="Get full details for a specific node by label or ID.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string", "description": "Node label or ID to look up"},
|
||||
},
|
||||
"properties": {"label": {"type": "string", "description": "Node label or ID to look up"}},
|
||||
"required": ["label"],
|
||||
},
|
||||
),
|
||||
@@ -150,24 +155,17 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
|
||||
),
|
||||
types.Tool(
|
||||
name="get_community",
|
||||
description="Get all nodes in a community by community ID or label.",
|
||||
description="Get all nodes in a community by community ID.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"community_id": {"type": "integer", "description": "Community ID (0-indexed by size)"},
|
||||
},
|
||||
"properties": {"community_id": {"type": "integer", "description": "Community ID (0-indexed by size)"}},
|
||||
"required": ["community_id"],
|
||||
},
|
||||
),
|
||||
types.Tool(
|
||||
name="god_nodes",
|
||||
description="Return the most connected nodes - the core abstractions of the knowledge graph.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"top_n": {"type": "integer", "default": 10},
|
||||
},
|
||||
},
|
||||
inputSchema={"type": "object", "properties": {"top_n": {"type": "integer", "default": 10}}},
|
||||
),
|
||||
types.Tool(
|
||||
name="graph_stats",
|
||||
@@ -189,130 +187,126 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
|
||||
),
|
||||
]
|
||||
|
||||
def _tool_query_graph(arguments: dict) -> str:
|
||||
question = arguments["question"]
|
||||
mode = arguments.get("mode", "bfs")
|
||||
depth = min(int(arguments.get("depth", 3)), 6)
|
||||
budget = int(arguments.get("token_budget", 2000))
|
||||
terms = [t.lower() for t in question.split() if len(t) > 2]
|
||||
scored = _score_nodes(G, terms)
|
||||
start_nodes = [nid for _, nid in scored[:3]]
|
||||
if not start_nodes:
|
||||
return "No matching nodes found."
|
||||
nodes, edges = _dfs(G, start_nodes, depth) if mode == "dfs" else _bfs(G, start_nodes, depth)
|
||||
header = f"Traversal: {mode.upper()} depth={depth} | Start: {[G.nodes[n].get('label', n) for n in start_nodes]} | {len(nodes)} nodes found\n\n"
|
||||
return header + _subgraph_to_text(G, nodes, edges, budget)
|
||||
|
||||
def _tool_get_node(arguments: dict) -> str:
|
||||
label = arguments["label"].lower()
|
||||
matches = [(nid, d) for nid, d in G.nodes(data=True)
|
||||
if label in d.get("label", "").lower() or label == nid.lower()]
|
||||
if not matches:
|
||||
return f"No node matching '{label}' found."
|
||||
nid, d = matches[0]
|
||||
return "\n".join([
|
||||
f"Node: {d.get('label', nid)}",
|
||||
f" ID: {nid}",
|
||||
f" Source: {d.get('source_file', '')} {d.get('source_location', '')}",
|
||||
f" Type: {d.get('file_type', '')}",
|
||||
f" Community: {d.get('community', '')}",
|
||||
f" Degree: {G.degree(nid)}",
|
||||
])
|
||||
|
||||
def _tool_get_neighbors(arguments: dict) -> str:
|
||||
label = arguments["label"].lower()
|
||||
rel_filter = arguments.get("relation_filter", "").lower()
|
||||
matches = _find_node(G, label)
|
||||
if not matches:
|
||||
return f"No node matching '{label}' found."
|
||||
nid = matches[0]
|
||||
lines = [f"Neighbors of {G.nodes[nid].get('label', nid)}:"]
|
||||
for neighbor in G.neighbors(nid):
|
||||
d = G.edges[nid, neighbor]
|
||||
rel = d.get("relation", "")
|
||||
if rel_filter and rel_filter not in rel.lower():
|
||||
continue
|
||||
lines.append(f" --> {G.nodes[neighbor].get('label', neighbor)} [{rel}] [{d.get('confidence', '')}]")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _tool_get_community(arguments: dict) -> str:
|
||||
cid = int(arguments["community_id"])
|
||||
nodes = communities.get(cid, [])
|
||||
if not nodes:
|
||||
return f"Community {cid} not found."
|
||||
lines = [f"Community {cid} ({len(nodes)} nodes):"]
|
||||
for n in nodes:
|
||||
d = G.nodes[n]
|
||||
lines.append(f" {d.get('label', n)} [{d.get('source_file', '')}]")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _tool_god_nodes(arguments: dict) -> str:
|
||||
from .analyze import god_nodes as _god_nodes
|
||||
nodes = _god_nodes(G, top_n=int(arguments.get("top_n", 10)))
|
||||
lines = ["God nodes (most connected):"]
|
||||
lines += [f" {i}. {n['label']} - {n['edges']} edges" for i, n in enumerate(nodes, 1)]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _tool_graph_stats(_: dict) -> str:
|
||||
confs = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
|
||||
total = len(confs) or 1
|
||||
return (
|
||||
f"Nodes: {G.number_of_nodes()}\n"
|
||||
f"Edges: {G.number_of_edges()}\n"
|
||||
f"Communities: {len(communities)}\n"
|
||||
f"EXTRACTED: {round(confs.count('EXTRACTED')/total*100)}%\n"
|
||||
f"INFERRED: {round(confs.count('INFERRED')/total*100)}%\n"
|
||||
f"AMBIGUOUS: {round(confs.count('AMBIGUOUS')/total*100)}%\n"
|
||||
)
|
||||
|
||||
def _tool_shortest_path(arguments: dict) -> str:
|
||||
src_scored = _score_nodes(G, [t.lower() for t in arguments["source"].split()])
|
||||
tgt_scored = _score_nodes(G, [t.lower() for t in arguments["target"].split()])
|
||||
if not src_scored:
|
||||
return f"No node matching source '{arguments['source']}' found."
|
||||
if not tgt_scored:
|
||||
return f"No node matching target '{arguments['target']}' found."
|
||||
src_nid, tgt_nid = src_scored[0][1], tgt_scored[0][1]
|
||||
max_hops = int(arguments.get("max_hops", 8))
|
||||
try:
|
||||
path_nodes = nx.shortest_path(G, src_nid, tgt_nid)
|
||||
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
||||
return f"No path found between '{G.nodes[src_nid].get('label', src_nid)}' and '{G.nodes[tgt_nid].get('label', tgt_nid)}'."
|
||||
hops = len(path_nodes) - 1
|
||||
if hops > max_hops:
|
||||
return f"Path exceeds max_hops={max_hops} ({hops} hops found)."
|
||||
segments = []
|
||||
for i in range(len(path_nodes) - 1):
|
||||
u, v = path_nodes[i], path_nodes[i + 1]
|
||||
edata = G.edges[u, v]
|
||||
rel = edata.get("relation", "")
|
||||
conf = edata.get("confidence", "")
|
||||
conf_str = f" [{conf}]" if conf else ""
|
||||
if i == 0:
|
||||
segments.append(G.nodes[u].get("label", u))
|
||||
segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}")
|
||||
return f"Shortest path ({hops} hops):\n " + " ".join(segments)
|
||||
|
||||
_handlers = {
|
||||
"query_graph": _tool_query_graph,
|
||||
"get_node": _tool_get_node,
|
||||
"get_neighbors": _tool_get_neighbors,
|
||||
"get_community": _tool_get_community,
|
||||
"god_nodes": _tool_god_nodes,
|
||||
"graph_stats": _tool_graph_stats,
|
||||
"shortest_path": _tool_shortest_path,
|
||||
}
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
||||
if name == "query_graph":
|
||||
question = arguments["question"]
|
||||
mode = arguments.get("mode", "bfs")
|
||||
depth = min(int(arguments.get("depth", 3)), 6)
|
||||
budget = int(arguments.get("token_budget", 2000))
|
||||
terms = [t.lower() for t in question.split() if len(t) > 2]
|
||||
scored = _score_nodes(G, terms)
|
||||
start_nodes = [nid for _, nid in scored[:3]]
|
||||
if not start_nodes:
|
||||
return [types.TextContent(type="text", text="No matching nodes found.")]
|
||||
if mode == "dfs":
|
||||
nodes, edges = _dfs(G, start_nodes, depth)
|
||||
else:
|
||||
nodes, edges = _bfs(G, start_nodes, depth)
|
||||
text = f"Traversal: {mode.upper()} depth={depth} | Start: {[G.nodes[n].get('label', n) for n in start_nodes]} | {len(nodes)} nodes found\n\n"
|
||||
text += _subgraph_to_text(G, nodes, edges, budget)
|
||||
return [types.TextContent(type="text", text=text)]
|
||||
|
||||
elif name == "get_node":
|
||||
label = arguments["label"].lower()
|
||||
matches = [(nid, d) for nid, d in G.nodes(data=True)
|
||||
if label in d.get("label", "").lower() or label == nid.lower()]
|
||||
if not matches:
|
||||
return [types.TextContent(type="text", text=f"No node matching '{label}' found.")]
|
||||
nid, d = matches[0]
|
||||
lines = [f"Node: {d.get('label', nid)}",
|
||||
f" ID: {nid}",
|
||||
f" Source: {d.get('source_file', '')} {d.get('source_location', '')}",
|
||||
f" Type: {d.get('file_type', '')}",
|
||||
f" Community: {d.get('community', '')}",
|
||||
f" Degree: {G.degree(nid)}"]
|
||||
return [types.TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
elif name == "get_neighbors":
|
||||
label = arguments["label"].lower()
|
||||
rel_filter = arguments.get("relation_filter", "").lower()
|
||||
matches = [nid for nid, d in G.nodes(data=True)
|
||||
if label in d.get("label", "").lower() or label == nid.lower()]
|
||||
if not matches:
|
||||
return [types.TextContent(type="text", text=f"No node matching '{label}' found.")]
|
||||
nid = matches[0]
|
||||
lines = [f"Neighbors of {G.nodes[nid].get('label', nid)}:"]
|
||||
for neighbor in G.neighbors(nid):
|
||||
d = G.edges[nid, neighbor]
|
||||
rel = d.get("relation", "")
|
||||
if rel_filter and rel_filter not in rel.lower():
|
||||
continue
|
||||
conf = d.get("confidence", "")
|
||||
nlabel = G.nodes[neighbor].get("label", neighbor)
|
||||
lines.append(f" --> {nlabel} [{rel}] [{conf}]")
|
||||
return [types.TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
elif name == "get_community":
|
||||
cid = int(arguments["community_id"])
|
||||
nodes = communities.get(cid, [])
|
||||
if not nodes:
|
||||
return [types.TextContent(type="text", text=f"Community {cid} not found.")]
|
||||
lines = [f"Community {cid} ({len(nodes)} nodes):"]
|
||||
for n in nodes:
|
||||
d = G.nodes[n]
|
||||
lines.append(f" {d.get('label', n)} [{d.get('source_file', '')}]")
|
||||
return [types.TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
elif name == "god_nodes":
|
||||
from .analyze import god_nodes as _god_nodes
|
||||
top_n = int(arguments.get("top_n", 10))
|
||||
nodes = _god_nodes(G, top_n=top_n)
|
||||
lines = ["God nodes (most connected):"]
|
||||
for i, n in enumerate(nodes, 1):
|
||||
lines.append(f" {i}. {n['label']} - {n['edges']} edges")
|
||||
return [types.TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
elif name == "graph_stats":
|
||||
confs = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
|
||||
total = len(confs) or 1
|
||||
text = (
|
||||
f"Nodes: {G.number_of_nodes()}\n"
|
||||
f"Edges: {G.number_of_edges()}\n"
|
||||
f"Communities: {len(communities)}\n"
|
||||
f"EXTRACTED: {round(confs.count('EXTRACTED')/total*100)}%\n"
|
||||
f"INFERRED: {round(confs.count('INFERRED')/total*100)}%\n"
|
||||
f"AMBIGUOUS: {round(confs.count('AMBIGUOUS')/total*100)}%\n"
|
||||
)
|
||||
return [types.TextContent(type="text", text=text)]
|
||||
|
||||
elif name == "shortest_path":
|
||||
src_terms = [t.lower() for t in arguments["source"].split()]
|
||||
tgt_terms = [t.lower() for t in arguments["target"].split()]
|
||||
max_hops = int(arguments.get("max_hops", 8))
|
||||
src_scored = _score_nodes(G, src_terms)
|
||||
tgt_scored = _score_nodes(G, tgt_terms)
|
||||
if not src_scored:
|
||||
return [types.TextContent(type="text", text=f"No node matching source '{arguments['source']}' found.")]
|
||||
if not tgt_scored:
|
||||
return [types.TextContent(type="text", text=f"No node matching target '{arguments['target']}' found.")]
|
||||
src_nid = src_scored[0][1]
|
||||
tgt_nid = tgt_scored[0][1]
|
||||
try:
|
||||
path_nodes = nx.shortest_path(G, src_nid, tgt_nid)
|
||||
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
||||
src_label = G.nodes[src_nid].get("label", src_nid)
|
||||
tgt_label = G.nodes[tgt_nid].get("label", tgt_nid)
|
||||
return [types.TextContent(type="text", text=f"No path found between '{src_label}' and '{tgt_label}'.")]
|
||||
hops = len(path_nodes) - 1
|
||||
if hops > max_hops:
|
||||
return [types.TextContent(type="text", text=f"Path exceeds max_hops={max_hops} ({hops} hops found).")]
|
||||
segments = []
|
||||
for i in range(len(path_nodes) - 1):
|
||||
u, v = path_nodes[i], path_nodes[i + 1]
|
||||
u_label = G.nodes[u].get("label", u)
|
||||
v_label = G.nodes[v].get("label", v)
|
||||
edata = G.edges[u, v]
|
||||
rel = edata.get("relation", "")
|
||||
conf = edata.get("confidence", "")
|
||||
conf_str = f" [{conf}]" if conf else ""
|
||||
if i == 0:
|
||||
segments.append(f"{u_label}")
|
||||
segments.append(f"--{rel}{conf_str}--> {v_label}")
|
||||
text = f"Shortest path ({hops} hops):\n " + " ".join(segments)
|
||||
return [types.TextContent(type="text", text=text)]
|
||||
|
||||
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
handler = _handlers.get(name)
|
||||
if not handler:
|
||||
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
return [types.TextContent(type="text", text=handler(arguments))]
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
Reference in New Issue
Block a user