From fa8e908500eef9fb1161c23fc58a128e32759eb4 Mon Sep 17 00:00:00 2001 From: akshit Date: Mon, 31 Aug 2026 21:20:51 +0530 Subject: [PATCH 1/2] Surface node.description on query and MCP paths (#3026) Share community and description formatting between serve and explain CLI. --- graphify/cli.py | 10 ++----- graphify/serve.py | 63 ++++++++++++++++++++++++++++++--------- tests/test_explain_cli.py | 18 +++++++++++ tests/test_serve.py | 55 ++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 21 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index cb30420473..a3b0a734d1 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1507,13 +1507,9 @@ def dispatch_command(cmd: str) -> None: sys.exit(1) nid = matches[0] d = G.nodes[nid] - print(f"Node: {d.get('label', nid)}") - print(f" ID: {nid}") - print( - f" Source: {d.get('source_file', '')} {d.get('source_location', '')}".rstrip() - ) - print(f" Type: {d.get('file_type', '')}") - print(f" Community: {d.get('community_name') or d.get('community', '')}") + from graphify.serve import _format_node_detail_lines + for line in _format_node_detail_lines(nid, d): + print(line) # Work-memory overlay: a derived experiential hint from `graphify reflect`, # merged in display-only from the .graphify_learning.json sidecar next to # graph.json. No line when the node has no overlay entry. diff --git a/graphify/serve.py b/graphify/serve.py index 4cf6d83968..485aab83f0 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1038,8 +1038,8 @@ def _adj(n): f"NODE {sanitize_label(d.get('label', nid))} " f"[src={sanitize_label(str(d.get('source_file', '')))} " f"loc={sanitize_label(str(d.get('source_location', '')))} " - f"community={sanitize_label(str(d.get('community_name') or d.get('community', '')))}" - f"{learning_suffix}]" + f"community={_resolved_community_label(d)}" + f"{_node_description_suffix(d)}{learning_suffix}]" ) lines.append(line) for u, v in edges: @@ -1490,6 +1490,45 @@ def _relay() -> None: sys.stdin = open(0, "r", closefd=False) +def _resolved_community_label(d: dict) -> str: + cid = d.get("community") + name = d.get("community_name") + if name: + placeholder = f"Community {cid}" if cid is not None else "" + clean = sanitize_label(str(name)) + if clean and clean != placeholder: + return clean + if cid is not None: + return sanitize_label(str(cid)) + return "" + + +def _node_description_suffix(d: dict) -> str: + desc = d.get("description") + if not desc: + return "" + return f" desc={sanitize_label(str(desc))}" + + +def _format_node_detail_lines(nid: str, d: dict, *, degree: int | None = None) -> list[str]: + lines = [ + f"Node: {sanitize_label(d.get('label', nid))}", + f" ID: {sanitize_label(nid)}", + ( + f" Source: {sanitize_label(str(d.get('source_file', '')))} " + f"{sanitize_label(str(d.get('source_location', '')))}" + ).rstrip(), + f" Type: {sanitize_label(str(d.get('file_type', '')))}", + f" Community: {_resolved_community_label(d)}", + ] + desc = d.get("description") + if desc: + lines.append(f" Description: {sanitize_label(str(desc))}") + if degree is not None: + lines.append(f" Degree: {degree}") + return lines + + def _community_header(cid: int, community_name) -> str: # Header for get_community: "Community N — Name", matching get_node / query # output which read the community_name attribute to_json writes onto nodes. @@ -1764,15 +1803,7 @@ def _tool_get_node(arguments: dict) -> str: if not matches: return f"No node matching '{label}' found." nid, d = matches[0] - # Sanitise every LLM-derived field before concatenation (F-010). - return "\n".join([ - f"Node: {sanitize_label(d.get('label', nid))}", - f" ID: {sanitize_label(nid)}", - f" Source: {sanitize_label(str(d.get('source_file', '')))} {sanitize_label(str(d.get('source_location', '')))}", - f" Type: {sanitize_label(str(d.get('file_type', '')))}", - f" Community: {sanitize_label(str(d.get('community_name') or d.get('community', '')))}", - f" Degree: {G.degree(nid)}", - ]) + return "\n".join(_format_node_detail_lines(nid, d, degree=G.degree(nid))) def _tool_get_neighbors(arguments: dict) -> str: label = arguments["label"].lower() @@ -1806,7 +1837,8 @@ def _edge_at(d: dict) -> str: if rel_filter and rel_filter not in rel.lower(): continue lines.append( - f" --> {sanitize_label(G.nodes[nb].get('label', nb))} " + f" --> {sanitize_label(G.nodes[nb].get('label', nb))}" + f"{_node_description_suffix(G.nodes[nb])} " f"[{sanitize_label(str(rel))}] [{sanitize_label(str(d.get('confidence', '')))}]{_edge_at(d)}" ) for nb in G.predecessors(nid): @@ -1815,7 +1847,8 @@ def _edge_at(d: dict) -> str: if rel_filter and rel_filter not in rel.lower(): continue lines.append( - f" <-- {sanitize_label(G.nodes[nb].get('label', nb))} " + f" <-- {sanitize_label(G.nodes[nb].get('label', nb))}" + f"{_node_description_suffix(G.nodes[nb])} " f"[{sanitize_label(str(rel))}] [{sanitize_label(str(d.get('confidence', '')))}]{_edge_at(d)}" ) budget = int(arguments.get("token_budget", 2000)) @@ -1835,7 +1868,9 @@ def _tool_get_community(arguments: dict) -> str: # Sanitise label and source_file (F-010). lines.append( f" {sanitize_label(d.get('label', n))} " - f"[{sanitize_label(str(d.get('source_file', '')))}]" + f"[{sanitize_label(str(d.get('source_file', '')))}] " + f"community={_resolved_community_label(d)}" + f"{_node_description_suffix(d)}" ) budget = int(arguments.get("token_budget", 2000)) return _cut_lines_to_budget( diff --git a/tests/test_explain_cli.py b/tests/test_explain_cli.py index 60b3e626e9..40b6fbc052 100644 --- a/tests/test_explain_cli.py +++ b/tests/test_explain_cli.py @@ -125,6 +125,24 @@ def test_explain_no_lesson_line_for_unannotated_node(monkeypatch, tmp_path, caps assert "Lesson:" not in out +def test_explain_prints_description(monkeypatch, tmp_path, capsys): + graph_data = { + "directed": False, "multigraph": False, "graph": {}, + "nodes": [ + {"id": "validate", "label": "validateSanitySession()", + "source_file": "server/sanity-validate-session.ts", + "community": 0, "community_name": "Session", + "description": "Validates the active sanity session token."}, + ], + "links": [], + } + p = tmp_path / "graph.json" + p.write_text(json.dumps(graph_data)) + out = _run(monkeypatch, p, "validateSanitySession", capsys) + assert "Description: Validates the active sanity session token." in out + assert "Community: Session" in out + + def test_explain_connection_shows_call_site_line(monkeypatch, tmp_path, capsys): """BUG1: an explain connection shows the edge's call-SITE line (in the caller's file), not the caller's def line.""" diff --git a/tests/test_serve.py b/tests/test_serve.py index 85f77a59a2..eee5e266ad 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -31,6 +31,9 @@ _cut_lines_to_budget, _load_graph, _community_header, + _resolved_community_label, + _format_node_detail_lines, + _node_description_suffix, _search_tokens, _shortest_path_text, ) @@ -655,6 +658,58 @@ def test_subgraph_to_text_no_overlay_is_unchanged(): assert "learning=" not in text +def test_subgraph_to_text_includes_description(): + G = _make_graph() + G.nodes["n1"]["description"] = "auth handler" + text = _subgraph_to_text(G, {"n1"}, []) + assert "desc=auth handler" in text + + +def test_subgraph_to_text_description_counts_against_budget(): + G = nx.Graph() + G.add_node("n1", label="x", source_file="a.py", source_location="L1", community=0, + description="z" * 500) + bare = _subgraph_to_text(G, {"n1"}, [], token_budget=50) + assert "truncated" not in bare + annotated = _subgraph_to_text(G, {"n1"}, [], token_budget=50) + assert "truncated" in annotated or len(annotated) < len(bare) + 100 + + +def test_subgraph_to_text_no_description_unchanged_shape(): + G = _make_graph() + text = _subgraph_to_text(G, {"n1"}, []) + assert " desc=" not in text + assert "community=0" in text + + +def test_resolved_community_skips_placeholder(): + d = {"community": 2, "community_name": "Community 2"} + assert _resolved_community_label(d) == "2" + d["community_name"] = "Services" + assert _resolved_community_label(d) == "Services" + + +def test_format_node_detail_lines_includes_description(): + d = { + "label": "foo", + "source_file": "a.py", + "source_location": "L1", + "file_type": "code", + "community": 1, + "community_name": "Core", + "description": "handles auth", + } + lines = _format_node_detail_lines("foo_id", d, degree=3) + assert any("Description: handles auth" in line for line in lines) + assert any("Community: Core" in line for line in lines) + assert any("Degree: 3" in line for line in lines) + + +def test_node_description_suffix_empty_without_field(): + assert _node_description_suffix({}) == "" + assert _node_description_suffix({"description": "x"}) == " desc=x" + + def test_query_graph_text_explicit_context_filter_changes_traversal(): G = _make_graph() text = _query_graph_text(G, "extract", mode="bfs", depth=2, token_budget=2000, context_filters=["call"]) From 45928ac8086ce9e6472dbc7323d3bb21ee5512d3 Mon Sep 17 00:00:00 2001 From: akshit Date: Tue, 1 Sep 2026 01:58:31 +0530 Subject: [PATCH 2/2] Address bot review: restore get_node output shape Return stored community_name without placeholder stripping. Use compact field layout for MCP get_node to match pre-change spacing. --- graphify/serve.py | 54 +++++++++++++++++++++++++++++---------------- tests/test_serve.py | 28 +++++++++++++++++++++-- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/graphify/serve.py b/graphify/serve.py index 485aab83f0..63835380cd 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1491,16 +1491,11 @@ def _relay() -> None: def _resolved_community_label(d: dict) -> str: - cid = d.get("community") name = d.get("community_name") if name: - placeholder = f"Community {cid}" if cid is not None else "" - clean = sanitize_label(str(name)) - if clean and clean != placeholder: - return clean - if cid is not None: - return sanitize_label(str(cid)) - return "" + return sanitize_label(str(name)) + cid = d.get("community") + return sanitize_label(str(cid)) if cid is not None else "" def _node_description_suffix(d: dict) -> str: @@ -1510,22 +1505,43 @@ def _node_description_suffix(d: dict) -> str: return f" desc={sanitize_label(str(desc))}" -def _format_node_detail_lines(nid: str, d: dict, *, degree: int | None = None) -> list[str]: +def _detail_field(label: str, value: str, *, compact: bool) -> str: + if compact: + return f" {label}: {value}" + padding = { + "ID": " ", + "Source": " ", + "Type": " ", + "Community": " ", + "Description": " ", + "Degree": " ", + } + return f" {label}:{padding.get(label, ' ')}{value}" + + +def _format_node_detail_lines( + nid: str, + d: dict, + *, + degree: int | None = None, + compact: bool = False, +) -> list[str]: + source = ( + f"{sanitize_label(str(d.get('source_file', '')))} " + f"{sanitize_label(str(d.get('source_location', '')))}" + ).strip() lines = [ f"Node: {sanitize_label(d.get('label', nid))}", - f" ID: {sanitize_label(nid)}", - ( - f" Source: {sanitize_label(str(d.get('source_file', '')))} " - f"{sanitize_label(str(d.get('source_location', '')))}" - ).rstrip(), - f" Type: {sanitize_label(str(d.get('file_type', '')))}", - f" Community: {_resolved_community_label(d)}", + _detail_field("ID", sanitize_label(nid), compact=compact), + _detail_field("Source", source, compact=compact), + _detail_field("Type", sanitize_label(str(d.get('file_type', ''))), compact=compact), + _detail_field("Community", _resolved_community_label(d), compact=compact), ] desc = d.get("description") if desc: - lines.append(f" Description: {sanitize_label(str(desc))}") + lines.append(_detail_field("Description", sanitize_label(str(desc)), compact=compact)) if degree is not None: - lines.append(f" Degree: {degree}") + lines.append(_detail_field("Degree", str(degree), compact=compact)) return lines @@ -1803,7 +1819,7 @@ def _tool_get_node(arguments: dict) -> str: if not matches: return f"No node matching '{label}' found." nid, d = matches[0] - return "\n".join(_format_node_detail_lines(nid, d, degree=G.degree(nid))) + return "\n".join(_format_node_detail_lines(nid, d, degree=G.degree(nid), compact=True)) def _tool_get_neighbors(arguments: dict) -> str: label = arguments["label"].lower() diff --git a/tests/test_serve.py b/tests/test_serve.py index eee5e266ad..bb36f05172 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -682,11 +682,35 @@ def test_subgraph_to_text_no_description_unchanged_shape(): assert "community=0" in text -def test_resolved_community_skips_placeholder(): +def test_resolved_community_label_preserves_stored_name(): d = {"community": 2, "community_name": "Community 2"} - assert _resolved_community_label(d) == "2" + assert _resolved_community_label(d) == "Community 2" d["community_name"] = "Services" assert _resolved_community_label(d) == "Services" + del d["community_name"] + assert _resolved_community_label(d) == "2" + + +def test_format_node_detail_lines_mcp_compact_matches_v8(): + d = { + "label": "foo", + "source_file": "a.py", + "source_location": "L1", + "file_type": "code", + "community": 1, + "community_name": "Core", + "description": "handles auth", + } + lines = _format_node_detail_lines("foo_id", d, degree=3, compact=True) + assert lines == [ + "Node: foo", + " ID: foo_id", + " Source: a.py L1", + " Type: code", + " Community: Core", + " Description: handles auth", + " Degree: 3", + ] def test_format_node_detail_lines_includes_description():