Skip to content
Open
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
2 changes: 2 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,7 @@ def _run_cli() -> None:
print(" --undirected ignore edge direction")
print(" explain \"X\" plain-language explanation of a node and its neighbors")
print(" --all list every connection (default: the 20 most connected)")
print(" --rationale show the node's stored rationale when present")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" diagnose multigraph report same-endpoint edge collapse risk in graph.json")
print(" --graph <path> path to graph/extraction JSON")
Expand Down Expand Up @@ -920,6 +921,7 @@ def _run_cli() -> None:
print(" --seed X start at node X (id, label or file path; repeatable)")
print(" instead of the question's keyword matches")
print(" --budget N cap output at N tokens (default 2000)")
print(" --rationale show each node's stored rationale (WHY) when present")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" query, path and explain exit 0 with an answer, 3 when a name is ambiguous")
print(" (the candidates are listed), 4 when no node matches")
Expand Down
30 changes: 26 additions & 4 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1530,7 +1530,7 @@ def dispatch_command(cmd: str) -> None:
sys.exit(1)
elif cmd == "query":
if len(sys.argv) < 3:
print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--seed X] [--budget N] [--graph path]", file=sys.stderr)
print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--seed X] [--budget N] [--graph path] [--rationale]", file=sys.stderr)
sys.exit(1)
from graphify.serve import _QUERY_NO_MATCH, _is_ambiguity_refusal, _query_graph_text
from graphify.security import sanitize_label
Expand All @@ -1543,6 +1543,7 @@ def dispatch_command(cmd: str) -> None:
graph_path = _default_graph_path()
context_filters: list[str] = []
seeds: list[str] = []
include_rationale = False
args = sys.argv[3:]
i = 0
while i < len(args):
Expand Down Expand Up @@ -1579,6 +1580,9 @@ def dispatch_command(cmd: str) -> None:
elif args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]
i += 2
elif args[i] == "--rationale":
include_rationale = True
i += 1
else:
i += 1
gp = Path(graph_path).resolve()
Expand Down Expand Up @@ -1650,6 +1654,7 @@ def dispatch_command(cmd: str) -> None:
seeds=seeds or None,
# Same root derivation as `affected` (#2706): <root>/graphify-out/graph.json.
seed_root=_graph_root_for(gp),
include_rationale=include_rationale,
)
querylog.log_query(
kind="query",
Expand Down Expand Up @@ -2076,18 +2081,26 @@ def dispatch_command(cmd: str) -> None:

elif cmd == "explain":
if len(sys.argv) < 3:
print('Usage: graphify explain "<node>" [--all] [--graph path]', file=sys.stderr)
print('Usage: graphify explain "<node>" [--all] [--graph path] [--rationale]', file=sys.stderr)
sys.exit(1)
from graphify.serve import _is_ambiguity_refusal, _resolve_single_node
from networkx.readwrite import json_graph

label = sys.argv[2]
graph_path = _default_graph_path()
include_rationale = False
args = sys.argv[3:]
show_all = "--all" in args
for i, a in enumerate(args):
if a == "--graph" and i + 1 < len(args):
i = 0
while i < len(args):
if args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]
i += 2
elif args[i] == "--rationale":
include_rationale = True
i += 1
else:
i += 1
gp = Path(graph_path).resolve()
if not gp.exists():
print(f"error: graph file not found: {gp}", file=sys.stderr)
Expand All @@ -2114,6 +2127,15 @@ def dispatch_command(cmd: str) -> None:
)
print(f" Type: {d.get('file_type', '')}")
print(f" Community: {d.get('community_name') or d.get('community', '')}")
if include_rationale:
from graphify.security import sanitize_rationale, indent_continuation, MAX_DETAIL_RATIONALE_CHARS
rat = sanitize_rationale(
d.get("rationale"),
single_line=False,
max_chars=MAX_DETAIL_RATIONALE_CHARS,
)
if rat:
print(" Rationale: " + indent_continuation(rat))
# 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.
Expand Down
58 changes: 58 additions & 0 deletions graphify/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,64 @@ def sanitize_label(text: str | None) -> str:
return text


MAX_QUERY_RATIONALE_CHARS = 512
MAX_DETAIL_RATIONALE_CHARS = 2048

_CONTROL_CHAR_NO_NL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
_INVISIBLE_CHAR_RE = re.compile("[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]")
_LINE_BREAK_RE = re.compile("[\u2028\u2029\x85]")


def _cap_rationale(text: str, max_chars: int) -> str:
if len(text) <= max_chars:
return text
marker = "..."
if max_chars <= len(marker):
return text[:max_chars]
return text[: max_chars - len(marker)].rstrip() + marker


def indent_continuation(text: str, prefix: str = " ") -> str:
"""Indent every non-empty line after the first so a multi-line value cannot pose as a sibling field."""
first, *rest = text.split("\n")
return "\n".join([first, *(prefix + line if line else line for line in rest)])


def sanitize_rationale(
text: str | None,
*,
single_line: bool = False,
max_chars: int | None = None,
) -> str:
"""Sanitize and optionally truncate a node rationale string.

In compact (single-line) mode:
- Normalizes all whitespace runs (newlines, tabs, spaces) to single spaces
- Strips control, bidi-override and zero-width characters
- Caps output at `max_chars` (default MAX_QUERY_RATIONALE_CHARS = 512)
- Adds an explicit '...' truncation marker when capped

In detail mode:
- Preserves newlines/paragraphs while stripping unsafe characters
- Caps output at `max_chars` (default MAX_DETAIL_RATIONALE_CHARS = 2048)
- Adds an explicit '...' truncation marker when capped

Returns empty string if text is not a string, empty, or whitespace-only.
"""
if not isinstance(text, str) or not text.strip():
return ""

if max_chars is None:
max_chars = MAX_QUERY_RATIONALE_CHARS if single_line else MAX_DETAIL_RATIONALE_CHARS
max_chars = max(0, max_chars)

normalized = _LINE_BREAK_RE.sub("\n", text.replace("\r\n", "\n").replace("\r", "\n"))
cleaned = _INVISIBLE_CHAR_RE.sub("", _CONTROL_CHAR_NO_NL_RE.sub("", normalized))
if single_line:
cleaned = " ".join(cleaned.split())
return _cap_rationale(cleaned.strip(), max_chars)


# ---------------------------------------------------------------------------
# Metadata sanitisation (recursive, bounded, HTML-safe)
# ---------------------------------------------------------------------------
Expand Down
69 changes: 60 additions & 9 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@
from typing import NamedTuple
import networkx as nx
from networkx.readwrite import json_graph
from graphify.security import sanitize_label, check_graph_file_size_cap, _CONTROL_CHAR_RE
from graphify.security import (
sanitize_label,
check_graph_file_size_cap,
sanitize_rationale,
indent_continuation,
MAX_QUERY_RATIONALE_CHARS,
MAX_DETAIL_RATIONALE_CHARS,
_CONTROL_CHAR_RE,
)
from graphify.build import edge_datas, has_parallel_links
from graphify.paths import default_graph_json as _default_graph_json

Expand Down Expand Up @@ -1117,7 +1125,7 @@ def _dfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], lis
return visited, edges_seen


def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None) -> str:
def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None, include_rationale: bool = False) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _subgraph_to_text()

31 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, no change: the 31 callers are pre-existing. This PR only adds an optional keyword argument (include_rationale, default False) to _subgraph_to_text and forwards it, so no caller changes and default output is identical.

"""Render subgraph as text, cutting at token_budget (approx 3 chars/token).

seeds: exact-match nodes rendered first before the degree-sorted expansion,
Expand Down Expand Up @@ -1197,13 +1205,23 @@ def _adj(n):
if len(attrs_summary) > 200:
attrs_summary = attrs_summary[:197] + "..."
attrs_suffix = f" attrs={{{sanitize_label(attrs_summary)}}}"
why_suffix = ""
if include_rationale:
rationale_text = sanitize_rationale(
d.get("rationale"),
single_line=True,
max_chars=MAX_QUERY_RATIONALE_CHARS,
)
if rationale_text:
why_suffix = f" WHY {rationale_text}"
line = (
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"{attrs_suffix}]"
f"{why_suffix}"
)
lines.append(line)
for u, v in edges:
Expand Down Expand Up @@ -1421,6 +1439,7 @@ def _query_graph_text(
graph_path: str | None = None,
seeds: list[str] | None = None,
seed_root: Path | None = None,
include_rationale: bool = False,
) -> str:
# Explicit start nodes: an external ranker -- embeddings, a learned
# retriever, grep -- can choose where the traversal begins instead of the
Expand Down Expand Up @@ -1452,7 +1471,8 @@ def _query_graph_text(
return "\n".join(ambiguous_refusals)
return "No unique node match for --seed: " + ", ".join(seeds)
return _render_query(G, question, start_nodes, mode=mode, depth=depth, token_budget=token_budget,
context_filters=context_filters, graph_path=graph_path, given=True, unresolved=unresolved)
context_filters=context_filters, graph_path=graph_path, given=True, unresolved=unresolved,
include_rationale=include_rationale)
# A question that is itself the exact name of several nodes is refused,
# matching `explain` and `path` (#4511). A sentence, or a bare keyword that
# only prefix/substring-matches labels, is not an ambiguous node name and
Expand Down Expand Up @@ -1485,7 +1505,8 @@ def _query_graph_text(
if not start_nodes:
return _QUERY_NO_MATCH
return _render_query(G, question, start_nodes, mode=mode, depth=depth, token_budget=token_budget,
context_filters=context_filters, graph_path=graph_path)
context_filters=context_filters, graph_path=graph_path,
include_rationale=include_rationale)


def _render_query(
Expand All @@ -1500,6 +1521,7 @@ def _render_query(
graph_path: str | None,
given: bool = False,
unresolved: list[str] | None = None,
include_rationale: bool = False,
) -> str:
resolved_filters, filter_source = _resolve_context_filters(question, context_filters)
traversal_graph = _filter_graph_by_context(_traversal_view(G), resolved_filters)
Expand Down Expand Up @@ -1528,7 +1550,14 @@ def _render_query(
# Pass the seeds so the queried symbol renders first and survives truncation
# (#BUG2): a branch merge had silently dropped this argument, leaving the
# seed-first ordering as dead code.
return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget, seeds=start_nodes)
return header + _subgraph_to_text(
traversal_graph,
nodes,
edges,
token_budget,
seeds=start_nodes,
include_rationale=include_rationale,
)


def _resolve_path_scoped_symbol(G: nx.Graph, path_part: str, symbol_part: str) -> list[str]:
Expand Down Expand Up @@ -2275,6 +2304,11 @@ async def list_tools() -> list[types.Tool]:
"items": {"type": "string"},
"description": "Optional explicit edge-context filter, e.g. ['call', 'field']",
},
"include_rationale": {
"type": "boolean",
"default": False,
"description": "Include WHY rationale on nodes when available",
},
},
"required": ["question"],
},
Expand All @@ -2287,6 +2321,11 @@ async def list_tools() -> list[types.Tool]:
"properties": {
"label": {"type": "string", "description": "Node label or ID to look up"},
"node_id": {"type": "string", "description": "Alias for label (node id)"},
"include_rationale": {
"type": "boolean",
"default": False,
"description": "Include node rationale if available",
},
},
},
),
Expand Down Expand Up @@ -2422,6 +2461,7 @@ def _tool_query_graph(arguments: dict) -> str:
depth = min(int(arguments.get("depth", 3)), 6)
budget = int(arguments.get("token_budget", 2000))
context_filter = arguments.get("context_filter")
include_rationale = arguments.get("include_rationale") is True
_t0 = _time.perf_counter()
result = _query_graph_text(
G,
Expand All @@ -2431,6 +2471,7 @@ def _tool_query_graph(arguments: dict) -> str:
token_budget=budget,
context_filters=context_filter,
graph_path=str(active_graph_path),
include_rationale=include_rationale,
)
querylog.log_query(
kind="mcp_query",
Expand All @@ -2449,6 +2490,7 @@ def _tool_get_node(arguments: dict) -> str:
if not raw:
return "Provide a node label or id (accepted keys: label, node_id, id)."
label = raw.lower()
include_rationale = arguments.get("include_rationale") is True
nid, err = _resolve_single_node(G, label)
if err:
return err
Expand All @@ -2461,7 +2503,7 @@ def _tool_get_node(arguments: dict) -> str:
raw_attrs = raw_attrs[:997] + "..."
attrs_line = [f" Attributes: {_CONTROL_CHAR_RE.sub('', raw_attrs)}"]
# Sanitise every LLM-derived field before concatenation (F-010).
return "\n".join([
lines = [
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', '')))}",
Expand All @@ -2473,9 +2515,18 @@ def _tool_get_node(arguments: dict) -> str:
if d.get("definition_file") else []),
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)}",
*attrs_line,
])
]
if include_rationale:
rat = sanitize_rationale(
d.get("rationale"),
single_line=False,
max_chars=MAX_DETAIL_RATIONALE_CHARS,
)
if rat:
lines.append(" Rationale: " + indent_continuation(rat))
lines.append(f" Degree: {G.degree(nid)}")
lines.extend(attrs_line)
return "\n".join(lines)

def _tool_get_neighbors(arguments: dict) -> str:
raw = _node_arg(arguments)
Expand Down
Loading
Loading