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:
Safi
2026-04-05 14:01:55 +01:00
parent 0061b36c29
commit a1be5abb24
6 changed files with 326 additions and 333 deletions
+8 -3
View File
@@ -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):
-1
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import hashlib
import json
import shutil
from pathlib import Path
+1 -1
View File
@@ -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
View File
@@ -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 &middot; {G.number_of_edges()} edges &middot; {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 &middot; {G.number_of_edges()} edges &middot; {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")
-1
View File
@@ -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
View File
@@ -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