Files
graphify/graphify/report.py
T
JunoLee 97f0dc197e fix(cluster,analyze,report): isolate AMBIGUOUS edges from top-N and modularity
AMBIGUOUS-tagged edges (confidence ≈ 0.35, often `conceptually_related_to`
from LLM semantic extraction) could glue otherwise-independent clusters
together through Leiden/Louvain modularity, and the resulting bridge
questions crowded `Suggested Questions` with low-signal entries. The
`AMBIGUOUS` flag was already correct — only downstream consumers
treated it identically to EXTRACTED/INFERRED.

Changes:
- `cluster()` gains `ambiguous_scale: float = 0.5` (overridable via
  `GRAPHIFY_AMBIGUOUS_SCALE`). Multiplier is applied to AMBIGUOUS edge
  `weight` in a graph copy before partitioning so the raw graph (and
  `/graphify path`/`explain` output) stays untouched. 1.0 restores
  legacy behavior.
- `suggest_questions()` tags each ambiguous_edge entry with
  `low_confidence=True`. Interleave + top_n contract from #3849 is
  preserved; the tag is purely metadata.
- `report.py` splits tagged entries into a new "Low-confidence Hints"
  section below the main Suggested Questions.

Tests:
- `test_cluster_ambiguous_scale.py` (new, 7 tests): default scale
  demotes AMBIGUOUS weight, scale=0.0 drops it entirely, scale=1.0
  is no-op, env override + malformed env fail-soft, raw graph not
  mutated, no copy when no AMBIGUOUS edges present.
- `test_suggest_questions_ambiguous.py` (new, 4 tests): tag present
  on every ambiguous_edge entry, never on other types.
- `test_analyze.py::test_suggest_questions_diversity_preserves_limit_and_candidates`
  relaxed to allow the optional `low_confidence` key.

Fixes #4199

---
한국어 요약: AMBIGUOUS edge (LLM 추론, 신뢰도 ~0.35) 가 Leiden 모듈러리티로 독립
클러스터를 묶거나 Suggested Questions top-N 을 잠식하던 문제 수정. cluster() 에
ambiguous_scale 파라미터 (default 0.5, env override) 추가, 원본 그래프는 복사 후
수정하여 /graphify path/explain 영향 없음. suggest_questions 는 low_confidence
플래그만 추가하고 interleave contract (#3849) 유지. report.py 가 분리 렌더링.
regression test 11 개 신규 + 기존 1 개 relax.

(cherry picked from commit ff52a29266)
2026-10-08 14:27:06 +01:00

409 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# generate GRAPH_REPORT.md - the human-readable audit trail
from __future__ import annotations
import re
from datetime import date
from pathlib import Path
import networkx as nx
def _portable_root_label(root: str) -> str:
"""Portable label for the report header — the project directory basename.
GRAPH_REPORT.md is a tracked artifact in practice, so its header must not
bake the generator host's absolute path into the file: the same graph would
otherwise produce different bytes on different machines and leak the build
machine's directory layout into git history (#2628, same class as #2598).
Taking the basename strips any leading absolute path without touching the
filesystem, and makes `graphify update .`, `graphify update ./proj`, and
`graphify update /abs/path/proj` all label the header `proj`. Only the
degenerate `.`/``/`..` cases need a cwd resolve to recover the real name;
if even that fails, fall back to the raw value.
"""
raw = str(root).replace("\\", "/")
name = Path(raw).name
if name in ("", ".", ".."):
try:
name = Path(raw).resolve().name
except (OSError, RuntimeError):
name = ""
return name or raw
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 load_learning_for_report(graph_path) -> dict | None:
"""Assemble the report's work-memory inputs from sibling artifacts.
Reads the ``.graphify_learning.json`` overlay (preferred sources) next to
``graph_path`` and re-aggregates the memory docs for the query-scoped
dead-ends. Best-effort: returns None if neither is available, so the report
simply omits the section. Never raises.
"""
from pathlib import Path as _Path
try:
gp = _Path(graph_path)
from graphify.reflect import load_learning_overlay, load_memory_docs, aggregate_lessons
overlay = load_learning_overlay(gp)
dead_ends: list[dict] = []
mem = gp.parent / "memory"
if mem.is_dir():
agg = aggregate_lessons(load_memory_docs(mem))
dead_ends = agg.get("dead_ends", [])
if not overlay and not dead_ends:
return None
return {"overlay": overlay, "dead_ends": dead_ends}
except Exception:
return None
def _learning_section(lines: list, learning: dict | None, top_n: int = 10) -> None:
"""Append the ``## Work-memory lessons`` section, or nothing when empty."""
if not learning:
return
overlay = learning.get("overlay") or {}
dead_ends = learning.get("dead_ends") or []
preferred = [
(nid, e) for nid, e in overlay.items()
if isinstance(e, dict) and e.get("status") == "preferred"
]
# Most-corroborated first (uses desc), then by score, then id for stability.
preferred.sort(key=lambda kv: (-kv[1].get("uses", 0),
-float(kv[1].get("score", 0) or 0), kv[0]))
if not preferred and not dead_ends:
return
lines += ["", "## Work-memory lessons"]
if preferred:
lines += ["", "**Preferred sources** — corroborated by past sessions; start here."]
for nid, e in preferred[:top_n]:
label = e.get("label") or nid
stale = " _(code changed — re-verify)_" if e.get("stale") else ""
lines.append(f"- `{label}` ({e.get('uses', 0)}× useful, "
f"score={e.get('score', 0)}){stale}")
if dead_ends:
lines += ["", "**Known dead ends** — questions that led nowhere; don't re-derive."]
for d in dead_ends:
nodes = ", ".join(f"`{n}`" for n in d.get("nodes", []))
lines.append(f"- \"{d.get('question', '')}\""
+ (f" -> {nodes}" if nodes else ""))
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,
learning: dict | None = None,
obsidian: bool = False,
) -> 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 - {_portable_root_label(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.",
]
# #3511: files detect() saw but could not classify (no supported
# extension/shebang) were counted nowhere -- a corpus that is mostly
# an unsupported language reported the same "well covered" verdict as
# one that was actually extracted. Surface the count and its biggest
# extensions so a near-total miss (e.g. a Lean/Zig/whatever repo with
# no matching extractor) is visible here instead of silent.
unclassified = detection_result.get("unclassified") or []
if unclassified:
from collections import Counter as _Counter
ext_counts = _Counter(Path(p).suffix or "(none)" for p in unclassified)
top = ", ".join(f"{ext} {n}" for ext, n in ext_counts.most_common(3))
lines.append(
f"- Unclassified: {len(unclassified)} file(s) not represented in "
f"the graph (top: {top})"
)
from .analyze import _is_file_node, _is_concept_node, _is_contains_only_ast_node
def _real_node(n) -> bool:
# The one definition of "real" (structural, not file/concept/
# rationale) every figure this function prints about itself must
# agree on (#3148) -- file nodes are structural scaffolding, concept
# nodes are semantic-layer entities, and rationale nodes are
# docstring fragments the LLM extracted for context, none of which
# are a code declaration a reader would recognize as a "node" in
# this list. A community-listing loop that filtered only file nodes
# let rationale/concept content leak into "Nodes (N): ..." as if it
# were a real declaration, and inflated N by counting it (#3794).
return (
not _is_file_node(G, n)
and not _is_concept_node(G, n)
and G.nodes[n].get("file_type") != "rationale"
)
def _real_count(nodes) -> int:
return sum(1 for n in nodes if _real_node(n))
non_empty = {cid: nodes for cid, nodes in communities.items()
if any(_real_node(n) for n in nodes)}
# One predicate for every figure the report prints about itself (#3148):
# "thin" is real < min_community_size (ZERO real nodes included), and
# "shown" is what the render loop below actually renders (real >=
# min_community_size) - previously shown was total-thin, which also
# counted communities with ZERO real nodes that the loop skips,
# overstating the count (#2129's residual). A subsequent `0 <` guard on
# "thin" fixed that overcount but introduced the opposite gap: an
# all-file-node community (real == 0) is skipped by the SAME render loop
# yet was excluded from "thin" too, so it went uncounted anywhere and
# `shown + thin` silently undercounted `len(communities)` (#3548).
thin_count_summary = sum(
1 for nodes in communities.values()
if _real_count(nodes) < min_community_size
)
shown_count = sum(
1 for nodes in communities.values()
if _real_count(nodes) >= min_community_size
)
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. The `_COMMUNITY_*.md` notes these wikilinks target
# are only created by the opt-in `--obsidian` export, and the report is written
# at build time (before any export runs), so emitting wikilinks by default left
# every link dangling — polluting an Obsidian vault's graph view and rendering as
# literal brackets everywhere else (#1712). Emit wikilinks only when the caller
# signals Obsidian output; otherwise a plain list, which navigates nowhere-to-break.
if non_empty:
lines += ["", "## Community Hubs (Navigation)"]
for cid in non_empty:
label = community_labels.get(cid, f"Community {cid}")
if obsidian:
safe = _safe_community_name(label)
lines.append(f"- [[_COMMUNITY_{safe}|{label}]]")
else:
lines.append(f"- {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.")
# Circular imports surfaced from file-level dependency graph. Only meaningful
# for code — a documents-only corpus has no imports, so the section is pure
# noise there ("None detected" on every run). Emit it only when the graph
# actually contains code (#1657).
_has_code = any(
d.get("file_type") == "code" for _, d in G.nodes(data=True)
) or any(
d.get("relation") in ("imports", "imports_from")
for *_e, d in G.edges(data=True)
)
if _has_code:
from .analyze import find_import_cycles
cycles = find_import_cycles(G)
lines += ["", "## Import Cycles"]
if cycles:
for c in cycles:
cycle = c.get("cycle", [])
length = c.get("length", len(cycle))
if not cycle:
continue
cycle_path = " -> ".join(cycle + [cycle[0]])
lines.append(f"- {length}-file cycle: `{cycle_path}`")
else:
lines.append("- None detected.")
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 file/concept/rationale nodes from display - they're
# structural noise or docstring prose, not a code declaration (#3794).
real_nodes = [n for n in nodes if _real_node(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:.2f}",
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 ---
isolated = [
n for n in G.nodes()
if G.degree(n) <= 1 and _real_node(n) and not _is_contains_only_ast_node(G, n)
]
# Same threshold the Summary and Communities headers used (#3148): this
# was a hardcoded 3, so with --min-community-size anything else the count
# here disagreed with the label text beside it, which already printed
# min_community_size. No `0 <` guard, matching thin_count_summary above
# (#3548): an all-file-node community is skipped by the same render loop
# as a genuinely thin one, so it belongs in this count too.
thin_communities = {
cid: nodes for cid, nodes in communities.items()
if _real_count(nodes) < min_community_size
}
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 ""
raw_isolated = sum(1 for n in G.nodes() if G.degree(n) <= 1)
lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
# "Undocumented components" only means anything when a semantic
# layer exists: document/paper/image nodes an LLM extracted
# meaning from, where an isolated node genuinely could be
# "mentioned but never explained further." A code-only graph
# (--code-only, or any run where semantic extraction never
# happened) has no such nodes at all, so offering it as a live
# possibility on every report is misleading (#3801).
has_semantic_layer = any(
G.nodes[n].get("file_type") in ("document", "paper", "image")
for n in G.nodes()
)
reason = (
"possible missing edges or undocumented components" if has_semantic_layer
else "possible missing edges"
)
lines.append(
f" These have ≤1 connection - {reason}. "
f"(Counts symbols only; {raw_isolated} node(s) total have ≤1 connection when "
"file, concept and rationale nodes are included.)"
)
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.")
# --- Work-memory lessons (derived overlay) ---
# Preferred sources come from the .graphify_learning.json sidecar; the
# query-scoped dead-ends come from the reflect aggregate. Section omitted
# entirely when neither is present, so a graph with no work-memory is
# byte-identical to the pre-feature report.
_learning_section(lines, learning)
if suggested_questions:
no_signal = len(suggested_questions) == 1 and suggested_questions[0].get("type") == "no_signal"
# #4199 — split AMBIGUOUS-tagged questions out of the main list so
# low-confidence hints do not crowd EXTRACTED/INFERRED bridges.
high_conf = [q for q in suggested_questions if not q.get("low_confidence")]
low_conf = [q for q in suggested_questions if q.get("low_confidence")]
lines += ["", "## Suggested Questions"]
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 high_conf:
if q.get("question"):
lines.append(f"- **{q['question']}**")
lines.append(f" _{q['why']}_")
if low_conf:
lines += ["", "### Low-confidence Hints"]
lines.append("_AMBIGUOUS edges — the extractor was unsure. Verify before acting on these._")
lines.append("")
for q in low_conf:
if q.get("question"):
lines.append(f"- **{q['question']}**")
lines.append(f" _{q['why']}_")
return "\n".join(lines)