mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 10:57:13 +00:00
fa70449d80
Alembic/Flask-Migrate revisions, Django migrations, and protobuf/OpenAPI generated files produce hundreds of degree-1 rationale nodes labeled as 'possible documentation gaps'. Their module docstrings are revision annotations or boilerplate, not architectural rationale. - Add _is_autogenerated_python() in extract.py detecting Alembic, Django migrations, and generic DO-NOT-EDIT markers; skip module docstring only - Function/class docstrings inside those files still extracted as normal - report.py: exclude file_type=rationale nodes from isolated-node gaps section — rationale nodes are degree-1 by construction; flagging them as missing edges was always wrong - 5 new tests covering Alembic, Django, protobuf, false-positive guard, and function-docstring passthrough
204 lines
8.7 KiB
Python
204 lines
8.7 KiB
Python
# 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:
|
|
"""Mirrors export.safe_name so community hub filenames and report wikilinks always agree."""
|
|
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]],
|
|
cohesion_scores: dict[int, float],
|
|
community_labels: dict[int, str],
|
|
god_node_list: list[dict],
|
|
surprise_list: list[dict],
|
|
detection_result: dict,
|
|
token_cost: dict,
|
|
root: str,
|
|
suggested_questions: list[dict] | None = None,
|
|
min_community_size: int = 3,
|
|
built_at_commit: str | None = None,
|
|
) -> str:
|
|
today = date.today().isoformat()
|
|
|
|
# JSON deserialization produces string keys; normalize to int so .get(cid) works.
|
|
if community_labels:
|
|
community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()}
|
|
|
|
confidences = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
|
|
total = len(confidences) or 1
|
|
ext_pct = round(confidences.count("EXTRACTED") / total * 100)
|
|
inf_pct = round(confidences.count("INFERRED") / total * 100)
|
|
amb_pct = round(confidences.count("AMBIGUOUS") / total * 100)
|
|
|
|
inf_edges = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "INFERRED"]
|
|
inf_scores = [d.get("confidence_score", 0.5) for _, _, d in inf_edges]
|
|
inf_avg = round(sum(inf_scores) / len(inf_scores), 2) if inf_scores else None
|
|
|
|
lines = [
|
|
f"# Graph Report - {root} ({today})",
|
|
"",
|
|
"## Corpus Check",
|
|
]
|
|
if detection_result.get("warning"):
|
|
lines.append(f"- {detection_result['warning']}")
|
|
else:
|
|
lines += [
|
|
f"- {detection_result['total_files']} files · ~{detection_result['total_words']:,} words",
|
|
"- Verdict: corpus is large enough that graph structure adds value.",
|
|
]
|
|
|
|
from .analyze import _is_file_node as _ifn
|
|
non_empty = {cid: nodes for cid, nodes in communities.items()
|
|
if any(not _ifn(G, n) for n in nodes)}
|
|
thin_count_summary = sum(
|
|
1 for nodes in communities.values()
|
|
if 0 < sum(1 for n in nodes if not _ifn(G, n)) < min_community_size
|
|
)
|
|
shown_count = len(communities) - thin_count_summary
|
|
|
|
lines += [
|
|
"",
|
|
"## Summary",
|
|
f"- {G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
|
|
+ (f" ({shown_count} shown, {thin_count_summary} thin omitted)" if thin_count_summary else ""),
|
|
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",
|
|
]
|
|
|
|
if built_at_commit:
|
|
lines += [
|
|
"",
|
|
"## Graph Freshness",
|
|
f"- Built from commit: `{built_at_commit[:8]}`",
|
|
"- Run `git rev-parse HEAD` and compare to check if the graph is stale.",
|
|
"- Run `graphify update .` after code changes (no API cost).",
|
|
]
|
|
|
|
# Community hub navigation - links to _COMMUNITY_*.md files in the Obsidian vault.
|
|
# Without these, GRAPH_REPORT.md is a dead-end and the vault splits into disconnected components.
|
|
if non_empty:
|
|
lines += ["", "## Community Hubs (Navigation)"]
|
|
for cid in non_empty:
|
|
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)",
|
|
]
|
|
for i, node in enumerate(god_node_list, 1):
|
|
lines.append(f"{i}. `{node['label']}` - {node['degree']} edges")
|
|
|
|
lines += ["", "## Surprising Connections (you probably didn't know these)"]
|
|
if surprise_list:
|
|
for s in surprise_list:
|
|
relation = s.get("relation", "related_to")
|
|
note = s.get("note", "")
|
|
files = s.get("source_files", ["", ""])
|
|
conf = s.get("confidence", "EXTRACTED")
|
|
cscore = s.get("confidence_score")
|
|
if conf == "INFERRED" and cscore is not None:
|
|
conf_tag = f"INFERRED {cscore:.2f}"
|
|
else:
|
|
conf_tag = conf
|
|
sem_tag = " [semantically similar]" if relation == "semantically_similar_to" else ""
|
|
lines += [
|
|
f"- `{s['source']}` --{relation}--> `{s['target']}` [{conf_tag}]{sem_tag}",
|
|
f" {files[0]} → {files[1]}" + (f" _{note}_" if note else ""),
|
|
]
|
|
else:
|
|
lines.append("- None detected - all connections are within the same source files.")
|
|
|
|
hyperedges = G.graph.get("hyperedges", [])
|
|
if hyperedges:
|
|
lines += ["", "## Hyperedges (group relationships)"]
|
|
for h in hyperedges:
|
|
node_labels = ", ".join(h.get("nodes", []))
|
|
conf = h.get("confidence", "INFERRED")
|
|
cscore = h.get("confidence_score")
|
|
conf_tag = f"{conf} {cscore:.2f}" if cscore is not None else conf
|
|
lines.append(f"- **{h.get('label', h.get('id', ''))}** — {node_labels} [{conf_tag}]")
|
|
|
|
lines += ["", f"## Communities ({len(communities)} total, {thin_count_summary} thin omitted)"]
|
|
for cid, nodes in communities.items():
|
|
label = community_labels.get(cid, f"Community {cid}")
|
|
score = cohesion_scores.get(cid, 0.0)
|
|
# Filter method/function stubs from display - they're structural noise
|
|
real_nodes = [n for n in nodes if not _ifn(G, n)]
|
|
if not real_nodes:
|
|
continue
|
|
if len(real_nodes) < min_community_size:
|
|
continue
|
|
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"Cohesion: {score}",
|
|
f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}",
|
|
]
|
|
|
|
ambiguous = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "AMBIGUOUS"]
|
|
if ambiguous:
|
|
lines += ["", "## Ambiguous Edges - Review These"]
|
|
for u, v, d in ambiguous:
|
|
ul = G.nodes[u].get("label", u)
|
|
vl = G.nodes[v].get("label", v)
|
|
lines += [
|
|
f"- `{ul}` → `{vl}` [AMBIGUOUS]",
|
|
f" {d.get('source_file', '')} · relation: {d.get('relation', 'unknown')}",
|
|
]
|
|
|
|
# --- Gaps section ---
|
|
from .analyze import _is_file_node, _is_concept_node
|
|
|
|
isolated = [
|
|
n for n in G.nodes()
|
|
if G.degree(n) <= 1
|
|
and not _is_file_node(G, n)
|
|
and not _is_concept_node(G, n)
|
|
and G.nodes[n].get("file_type") != "rationale"
|
|
]
|
|
thin_communities = {
|
|
cid: nodes for cid, nodes in communities.items()
|
|
if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < 3
|
|
}
|
|
gap_count = len(isolated) + len(thin_communities)
|
|
|
|
if gap_count > 0 or amb_pct > 20:
|
|
lines += ["", "## Knowledge Gaps"]
|
|
if isolated:
|
|
isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]]
|
|
suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else ""
|
|
lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
|
|
lines.append(" These have ≤1 connection - possible missing edges or undocumented components.")
|
|
if thin_communities:
|
|
lines.append(f"- **{len(thin_communities)} thin communities (<{min_community_size} nodes) omitted from report** — run `graphify query` to explore isolated nodes.")
|
|
if amb_pct > 20:
|
|
lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.")
|
|
|
|
if suggested_questions:
|
|
lines += ["", "## Suggested Questions"]
|
|
no_signal = len(suggested_questions) == 1 and suggested_questions[0].get("type") == "no_signal"
|
|
if no_signal:
|
|
lines.append(f"_{suggested_questions[0]['why']}_")
|
|
else:
|
|
lines.append("_Questions this graph is uniquely positioned to answer:_")
|
|
lines.append("")
|
|
for q in suggested_questions:
|
|
if q.get("question"):
|
|
lines.append(f"- **{q['question']}**")
|
|
lines.append(f" _{q['why']}_")
|
|
|
|
return "\n".join(lines)
|