Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,15 @@ def to_obsidian(
# 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.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip() or "unnamed"
cleaned = re.sub(
r'[\\/*?:"<>|#^[\]]',
"",
label.replace("\r\n", " ").replace("\r", " ").replace("\n", " "),
).strip()
# Strip trailing Markdown extensions so filenames don't collide as foo.md.md
# when the node label is itself a markdown filename (e.g. "CLAUDE.md").
cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
return cleaned or "unnamed"

node_filename: dict[str, str] = {}
seen_names: dict[str, int] = {}
Expand Down Expand Up @@ -703,7 +711,14 @@ def to_canvas(
CANVAS_COLORS = ["1", "2", "3", "4", "5", "6"] # red, orange, yellow, green, cyan, purple

def safe_name(label: str) -> str:
return re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip() or "unnamed"
cleaned = re.sub(
r'[\\/*?:"<>|#^[\]]',
"",
label.replace("\r\n", " ").replace("\r", " ").replace("\n", " "),
).strip()
# Strip trailing Markdown extensions so filenames don't collide as foo.md.md.
cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
return cleaned or "unnamed"

# Build node_filenames if not provided (same dedup logic as to_obsidian)
if node_filenames is None:
Expand Down
34 changes: 33 additions & 1 deletion graphify/report.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
# generate GRAPH_REPORT.md - the human-readable audit trail
from __future__ import annotations
import re
from datetime import date
import networkx as nx


def _safe_community_name(label: str) -> str:
"""Mirror of export.safe_name so community hub filenames and report wikilinks match.

Strips Obsidian-unsafe filename chars and trailing Markdown extensions so a
community labelled e.g. "CLAUDE.md" produces the same hub filename in both
the Obsidian vault export and the report's [[_COMMUNITY_*]] navigation links.
"""
cleaned = re.sub(
r'[\\/*?:"<>|#^[\]]',
"",
label.replace("\r\n", " ").replace("\r", " ").replace("\n", " "),
).strip()
cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
return cleaned or "unnamed"


def generate(
G: nx.Graph,
communities: dict[int, list[str]],
Expand Down Expand Up @@ -48,6 +65,20 @@ def generate(
f"- Extraction: {ext_pct}% EXTRACTED · {inf_pct}% INFERRED · {amb_pct}% AMBIGUOUS"
+ (f" · INFERRED: {len(inf_edges)} edges (avg confidence: {inf_avg})" if inf_avg is not None else ""),
f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output",
]

# Navigation block — emit [[_COMMUNITY_<safe>|<label>]] for every community
# so the report is never a dead-end when dropped into an Obsidian vault.
# Without these links the whole community subgraph is unreachable from the
# graph-report and forms a disconnected component in the vault graph.
if communities:
lines += ["", "## Community Hubs (Navigation)"]
for cid in communities:
label = community_labels.get(cid, f"Community {cid}")
safe = _safe_community_name(label)
lines.append(f"- [[_COMMUNITY_{safe}|{label}]]")

lines += [
"",
"## God Nodes (most connected - your core abstractions)",
]
Expand Down Expand Up @@ -89,13 +120,14 @@ def generate(
for cid, nodes in communities.items():
label = community_labels.get(cid, f"Community {cid}")
score = cohesion_scores.get(cid, 0.0)
safe = _safe_community_name(label)
# Filter method/function stubs from display - they're structural noise
real_nodes = [n for n in nodes if not _ifn(G, n)]
display = [G.nodes[n].get("label", n) for n in real_nodes[:8]]
suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else ""
lines += [
"",
f"### Community {cid} - \"{label}\"",
f"### Community {cid} - [[_COMMUNITY_{safe}|{label}]]",
f"Cohesion: {score}",
f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}",
]
Expand Down