Files
graphify/graphify/export.py
T
Safi ce47198be1 feat: Claude Code skill, Obsidian vault, install, tests
skill.md with full pipeline steps, Obsidian as default output (canvas, tags,
dataview, graph colors), two-command install, 71 tests, .gitignore, deps
2026-04-04 18:53:43 +01:00

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)