diff --git a/graphify/export.py b/graphify/export.py index d35edafc2c..274e31e0ef 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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] = {} @@ -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: diff --git a/graphify/report.py b/graphify/report.py index 91f331cc3b..9cdf538a3e 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -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]], @@ -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_|