mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-17 21:06:59 +00:00
ce47198be1
skill.md with full pipeline steps, Obsidian as default output (canvas, tags, dataview, graph colors), two-command install, 71 tests, .gitignore, deps
439 lines
16 KiB
Python
439 lines
16 KiB
Python
# write graph to HTML, JSON, SVG, Obsidian vault, and Neo4j Cypher
|
|
from __future__ import annotations
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
import networkx as nx
|
|
from networkx.readwrite import json_graph
|
|
|
|
COMMUNITY_COLORS = [
|
|
"#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
|
|
"#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
|
|
]
|
|
|
|
MAX_NODES_FOR_VIZ = 5_000
|
|
|
|
|
|
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}
|
|
data = json_graph.node_link_data(G, edges="links")
|
|
for node in data["nodes"]:
|
|
node["community"] = node_community.get(node["id"])
|
|
with open(output_path, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
|
|
|
|
def to_cypher(G: nx.Graph, output_path: str) -> None:
|
|
lines = ["// Neo4j Cypher import — generated by /graphify", ""]
|
|
for node_id, data in G.nodes(data=True):
|
|
label = data.get("label", node_id).replace("'", "\\'")
|
|
ftype = data.get("file_type", "unknown").capitalize()
|
|
lines.append(f"MERGE (n:{ftype} {{id: '{node_id}', label: '{label}'}});")
|
|
lines.append("")
|
|
for u, v, data in G.edges(data=True):
|
|
rel = data.get("relation", "RELATES_TO").upper().replace(" ", "_").replace("-", "_")
|
|
conf = data.get("confidence", "EXTRACTED")
|
|
lines.append(
|
|
f"MATCH (a {{id: '{u}'}}), (b {{id: '{v}'}}) "
|
|
f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);"
|
|
)
|
|
with open(output_path, "w") as f:
|
|
f.write("\n".join(lines))
|
|
|
|
|
|
def to_html(
|
|
G: nx.Graph,
|
|
communities: dict[int, list[str]],
|
|
output_path: str,
|
|
community_labels: dict[int, str] | None = None,
|
|
) -> None:
|
|
"""Generate an interactive pyvis HTML visualization of the graph.
|
|
|
|
Merged from visualizer.py. Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.
|
|
"""
|
|
from pyvis.network import Network
|
|
|
|
if G.number_of_nodes() > MAX_NODES_FOR_VIZ:
|
|
raise ValueError(
|
|
f"Graph has {G.number_of_nodes()} nodes — too large for pyvis. "
|
|
f"Use --no-viz or reduce input size."
|
|
)
|
|
|
|
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
|
|
|
|
net = Network(height="800px", width="100%", bgcolor="#1a1a2e", font_color="white")
|
|
net.barnes_hut()
|
|
|
|
for node_id, data in G.nodes(data=True):
|
|
cid = node_community.get(node_id, 0)
|
|
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
|
|
net.add_node(
|
|
node_id,
|
|
label=data.get("label", node_id),
|
|
color=color,
|
|
title=(
|
|
f"Source: {data.get('source_file', 'unknown')}\n"
|
|
f"Type: {data.get('file_type', 'unknown')}\n"
|
|
f"Community: {community_labels.get(cid, str(cid)) if community_labels else cid}"
|
|
),
|
|
)
|
|
|
|
for u, v, data in G.edges(data=True):
|
|
confidence = data.get("confidence", "EXTRACTED")
|
|
width = {"EXTRACTED": 2, "INFERRED": 1, "AMBIGUOUS": 1}.get(confidence, 1)
|
|
net.add_edge(
|
|
u, v,
|
|
title=f"{data.get('relation', '')} [{confidence}]",
|
|
width=width,
|
|
dashes=(confidence != "EXTRACTED"),
|
|
)
|
|
|
|
net.save_graph(output_path)
|
|
|
|
# Inject community legend into saved HTML
|
|
if community_labels:
|
|
legend_items = ""
|
|
for cid in sorted(community_labels.keys()):
|
|
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
|
|
label = community_labels[cid]
|
|
n_nodes = len(communities.get(cid, []))
|
|
legend_items += (
|
|
f'<div style="margin:4px 0">'
|
|
f'<span style="color:{color};font-size:18px">■</span> '
|
|
f'<span style="font-size:13px">{label} ({n_nodes})</span>'
|
|
f'</div>'
|
|
)
|
|
legend_html = (
|
|
'<div style="position:fixed;top:10px;right:10px;background:#2a2a4e;'
|
|
'padding:12px 16px;border-radius:8px;font-family:sans-serif;color:white;'
|
|
'z-index:9999;min-width:180px;">'
|
|
'<b style="font-size:14px">Communities</b><br>'
|
|
+ legend_items +
|
|
'</div>'
|
|
)
|
|
content = Path(output_path).read_text()
|
|
content = content.replace("</body>", legend_html + "\n</body>")
|
|
Path(output_path).write_text(content)
|
|
|
|
|
|
# Keep backward-compatible alias — skill.md calls generate_html
|
|
generate_html = to_html
|
|
|
|
|
|
def to_obsidian(
|
|
G: nx.Graph,
|
|
communities: dict[int, list[str]],
|
|
output_dir: str,
|
|
community_labels: dict[int, str] | None = None,
|
|
cohesion: dict[int, float] | None = None,
|
|
) -> int:
|
|
"""Export graph as an Obsidian vault — one .md file per node with [[wikilinks]],
|
|
plus one _COMMUNITY_name.md overview note per community (sorted to top by underscore prefix).
|
|
|
|
Open the output directory as a vault in Obsidian to get an interactive
|
|
graph view with community colors and full-text search over node metadata.
|
|
|
|
Returns the number of node notes + community notes written.
|
|
"""
|
|
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}
|
|
|
|
# Map node_id → safe filename so wikilinks stay consistent.
|
|
# Deduplicate: if two nodes produce the same filename, append a numeric suffix.
|
|
def safe_name(label: str) -> str:
|
|
return re.sub(r'[\\/*?:"<>|#^[\]]', "", label).strip() or "unnamed"
|
|
|
|
node_filename: dict[str, str] = {}
|
|
seen_names: dict[str, int] = {}
|
|
for node_id, data in G.nodes(data=True):
|
|
base = safe_name(data.get("label", node_id))
|
|
if base in seen_names:
|
|
seen_names[base] += 1
|
|
node_filename[node_id] = f"{base}_{seen_names[base]}"
|
|
else:
|
|
seen_names[base] = 0
|
|
node_filename[node_id] = base
|
|
|
|
# Write one .md file per node
|
|
for node_id, data in G.nodes(data=True):
|
|
label = data.get("label", node_id)
|
|
cid = node_community.get(node_id)
|
|
community_name = (
|
|
community_labels.get(cid, f"Community {cid}")
|
|
if community_labels and cid is not None
|
|
else f"Community {cid}"
|
|
)
|
|
|
|
lines: list[str] = []
|
|
|
|
# YAML frontmatter — readable in Obsidian's properties panel
|
|
lines += [
|
|
"---",
|
|
f'source_file: "{data.get("source_file", "")}"',
|
|
f'type: "{data.get("file_type", "")}"',
|
|
f'community: "{community_name}"',
|
|
]
|
|
if data.get("source_location"):
|
|
lines.append(f'location: "{data["source_location"]}"')
|
|
lines += ["---", "", f"# {label}", ""]
|
|
|
|
# Outgoing edges as wikilinks
|
|
neighbors = list(G.neighbors(node_id))
|
|
if neighbors:
|
|
lines.append("## Connections")
|
|
for neighbor in sorted(neighbors, key=lambda n: G.nodes[n].get("label", n)):
|
|
edge_data = G.edges[node_id, neighbor]
|
|
neighbor_label = node_filename[neighbor]
|
|
relation = edge_data.get("relation", "")
|
|
confidence = edge_data.get("confidence", "EXTRACTED")
|
|
lines.append(f"- [[{neighbor_label}]] — `{relation}` [{confidence}]")
|
|
|
|
fname = node_filename[node_id] + ".md"
|
|
(out / fname).write_text("\n".join(lines), encoding="utf-8")
|
|
|
|
# Write one _COMMUNITY_name.md overview note per community
|
|
# Build inter-community edge counts for "Connections to other communities"
|
|
inter_community_edges: dict[int, dict[int, int]] = {}
|
|
for cid in communities:
|
|
inter_community_edges[cid] = {}
|
|
for u, v in G.edges():
|
|
cu = node_community.get(u)
|
|
cv = node_community.get(v)
|
|
if cu is not None and cv is not None and cu != cv:
|
|
inter_community_edges.setdefault(cu, {})
|
|
inter_community_edges.setdefault(cv, {})
|
|
inter_community_edges[cu][cv] = inter_community_edges[cu].get(cv, 0) + 1
|
|
inter_community_edges[cv][cu] = inter_community_edges[cv].get(cu, 0) + 1
|
|
|
|
# Precompute per-node community reach (number of distinct communities a node connects to)
|
|
def _community_reach(node_id: str) -> int:
|
|
neighbor_cids = {
|
|
node_community[nb]
|
|
for nb in G.neighbors(node_id)
|
|
if nb in node_community and node_community[nb] != node_community.get(node_id)
|
|
}
|
|
return len(neighbor_cids)
|
|
|
|
community_notes_written = 0
|
|
for cid, members in communities.items():
|
|
community_name = (
|
|
community_labels.get(cid, f"Community {cid}")
|
|
if community_labels and cid is not None
|
|
else f"Community {cid}"
|
|
)
|
|
n_members = len(members)
|
|
coh_value = cohesion.get(cid) if cohesion else None
|
|
|
|
lines: list[str] = []
|
|
|
|
# YAML frontmatter
|
|
lines.append("---")
|
|
lines.append("type: community")
|
|
if coh_value is not None:
|
|
lines.append(f"cohesion: {coh_value:.2f}")
|
|
lines.append(f"members: {n_members}")
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append(f"# {community_name}")
|
|
lines.append("")
|
|
|
|
# Cohesion + member count summary
|
|
if coh_value is not None:
|
|
cohesion_desc = (
|
|
"tightly connected" if coh_value >= 0.7
|
|
else "moderately connected" if coh_value >= 0.4
|
|
else "loosely connected"
|
|
)
|
|
lines.append(f"**Cohesion:** {coh_value:.2f} — {cohesion_desc}")
|
|
lines.append(f"**Members:** {n_members} nodes")
|
|
lines.append("")
|
|
|
|
# Members section
|
|
lines.append("## Members")
|
|
for node_id in sorted(members, key=lambda n: G.nodes[n].get("label", n)):
|
|
data = G.nodes[node_id]
|
|
node_label = node_filename[node_id]
|
|
ftype = data.get("file_type", "")
|
|
source = data.get("source_file", "")
|
|
entry = f"- [[{node_label}]]"
|
|
if ftype:
|
|
entry += f" — {ftype}"
|
|
if source:
|
|
entry += f" — {source}"
|
|
lines.append(entry)
|
|
lines.append("")
|
|
|
|
# Connections to other communities
|
|
cross = inter_community_edges.get(cid, {})
|
|
if cross:
|
|
lines.append("## Connections to other communities")
|
|
for other_cid, edge_count in sorted(cross.items(), key=lambda x: -x[1]):
|
|
other_name = (
|
|
community_labels.get(other_cid, f"Community {other_cid}")
|
|
if community_labels and other_cid is not None
|
|
else f"Community {other_cid}"
|
|
)
|
|
other_safe = safe_name(other_name)
|
|
lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[_COMMUNITY_{other_safe}]]")
|
|
lines.append("")
|
|
|
|
# Top bridge nodes — highest degree nodes that connect to other communities
|
|
bridge_nodes = [
|
|
(node_id, G.degree(node_id), _community_reach(node_id))
|
|
for node_id in members
|
|
if _community_reach(node_id) > 0
|
|
]
|
|
bridge_nodes.sort(key=lambda x: (-x[2], -x[1]))
|
|
top_bridges = bridge_nodes[:5]
|
|
if top_bridges:
|
|
lines.append("## Top bridge nodes")
|
|
for node_id, degree, reach in top_bridges:
|
|
node_label = node_filename[node_id]
|
|
lines.append(
|
|
f"- [[{node_label}]] — degree {degree}, connects to {reach} "
|
|
f"{'community' if reach == 1 else 'communities'}"
|
|
)
|
|
|
|
community_safe = safe_name(community_name)
|
|
fname = f"_COMMUNITY_{community_safe}.md"
|
|
(out / fname).write_text("\n".join(lines), encoding="utf-8")
|
|
community_notes_written += 1
|
|
|
|
return G.number_of_nodes() + community_notes_written
|
|
|
|
|
|
def push_to_neo4j(
|
|
G: nx.Graph,
|
|
uri: str,
|
|
user: str,
|
|
password: str,
|
|
communities: dict[int, list[str]] | None = None,
|
|
) -> dict[str, int]:
|
|
"""Push graph directly to a running Neo4j instance via the Python driver.
|
|
|
|
Requires: pip install neo4j
|
|
|
|
Uses MERGE so re-running is safe — nodes and edges are upserted, not duplicated.
|
|
Returns a dict with counts of nodes and edges pushed.
|
|
"""
|
|
try:
|
|
from neo4j import GraphDatabase
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"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 {}
|
|
)
|
|
|
|
def _safe_rel(relation: str) -> str:
|
|
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
|
|
|
|
driver = GraphDatabase.driver(uri, auth=(user, password))
|
|
nodes_pushed = 0
|
|
edges_pushed = 0
|
|
|
|
with driver.session() as session:
|
|
for node_id, data in G.nodes(data=True):
|
|
props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
|
|
props["id"] = node_id
|
|
cid = node_community.get(node_id)
|
|
if cid is not None:
|
|
props["community"] = cid
|
|
ftype = data.get("file_type", "Entity").capitalize()
|
|
session.run(
|
|
f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
|
|
id=node_id,
|
|
props=props,
|
|
)
|
|
nodes_pushed += 1
|
|
|
|
for u, v, data in G.edges(data=True):
|
|
rel = _safe_rel(data.get("relation", "RELATED_TO"))
|
|
props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
|
|
session.run(
|
|
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
|
|
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
|
|
src=u,
|
|
tgt=v,
|
|
props=props,
|
|
)
|
|
edges_pushed += 1
|
|
|
|
driver.close()
|
|
return {"nodes": nodes_pushed, "edges": edges_pushed}
|
|
|
|
|
|
def to_svg(
|
|
G: nx.Graph,
|
|
communities: dict[int, list[str]],
|
|
output_path: str,
|
|
community_labels: dict[int, str] | None = None,
|
|
figsize: tuple[int, int] = (20, 14),
|
|
) -> None:
|
|
"""Export graph as an SVG file using matplotlib + spring layout.
|
|
|
|
Lightweight and embeddable — works in Obsidian notes, Notion, GitHub READMEs,
|
|
and any markdown renderer. No JavaScript required.
|
|
|
|
Node size scales with degree. Community colors match the pyvis HTML output.
|
|
"""
|
|
try:
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib.patches as mpatches
|
|
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}
|
|
|
|
fig, ax = plt.subplots(figsize=figsize, facecolor="#1a1a2e")
|
|
ax.set_facecolor("#1a1a2e")
|
|
ax.axis("off")
|
|
|
|
pos = nx.spring_layout(G, seed=42, k=2.0 / (G.number_of_nodes() ** 0.5 + 1))
|
|
|
|
degree = dict(G.degree())
|
|
max_deg = max(degree.values()) if degree else 1
|
|
|
|
node_colors = [COMMUNITY_COLORS[node_community.get(n, 0) % len(COMMUNITY_COLORS)] for n in G.nodes()]
|
|
node_sizes = [300 + 1200 * (degree.get(n, 1) / max_deg) for n in G.nodes()]
|
|
|
|
# Draw edges — dashed for non-EXTRACTED
|
|
for u, v, data in G.edges(data=True):
|
|
conf = data.get("confidence", "EXTRACTED")
|
|
style = "solid" if conf == "EXTRACTED" else "dashed"
|
|
alpha = 0.6 if conf == "EXTRACTED" else 0.3
|
|
x0, y0 = pos[u]
|
|
x1, y1 = pos[v]
|
|
ax.plot([x0, x1], [y0, y1], color="#aaaaaa", linewidth=0.8,
|
|
linestyle=style, alpha=alpha, zorder=1)
|
|
|
|
nx.draw_networkx_nodes(G, pos, ax=ax, node_color=node_colors,
|
|
node_size=node_sizes, alpha=0.9)
|
|
nx.draw_networkx_labels(G, pos, ax=ax,
|
|
labels={n: G.nodes[n].get("label", n) for n in G.nodes()},
|
|
font_size=7, font_color="white")
|
|
|
|
# Legend
|
|
if community_labels:
|
|
patches = [
|
|
mpatches.Patch(
|
|
color=COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)],
|
|
label=f"{label} ({len(communities.get(cid, []))})",
|
|
)
|
|
for cid, label in sorted(community_labels.items())
|
|
]
|
|
ax.legend(handles=patches, loc="upper left", framealpha=0.7,
|
|
facecolor="#2a2a4e", labelcolor="white", fontsize=8)
|
|
|
|
plt.tight_layout()
|
|
plt.savefig(output_path, format="svg", bbox_inches="tight",
|
|
facecolor=fig.get_facecolor())
|
|
plt.close(fig)
|