interlinked-mapper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ """LLM adapter — translates natural language into view commands via Claude (or any LLM).
2
+
3
+ The key idea: the LLM gets a system prompt describing the full view API + current graph stats,
4
+ then returns JSON with both a human-readable explanation and executable Python commands.
5
+
6
+ This module is used by:
7
+ 1. The /api/chat endpoint (browser chat box → Claude → commands → view updates)
8
+ 2. Any external LLM agent hitting /api/command directly (e.g. Cascade)
9
+
10
+ If no API key is configured, commands pass through to a basic keyword fallback.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import traceback
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from interlinked.commander.query import QueryEngine
23
+ from interlinked.models import SymbolType, EdgeType
24
+
25
+
26
+ def get_system_prompt(engine: QueryEngine) -> str:
27
+ """Build the system prompt that teaches an LLM how to drive Interlinked.
28
+
29
+ This is also served at GET /api/system-prompt so any external agent
30
+ can read it and know how to control the view.
31
+ """
32
+ stats = engine.stats()
33
+ all_nodes = engine.graph.all_nodes(include_proposed=False)
34
+ modules = sorted(set(n.qualified_name for n in all_nodes if n.symbol_type == SymbolType.MODULE))
35
+ classes = sorted(set(n.qualified_name for n in all_nodes if n.symbol_type == SymbolType.CLASS))
36
+
37
+ return f"""\
38
+ You are the pilot of INTERLINKED, a Python codebase topology explorer.
39
+ The user is looking at an interactive graph visualization of a Python project in their browser.
40
+ You control what they see by emitting Python commands against the `view` object.
41
+
42
+ ## Current Project Stats
43
+ - Modules: {stats['modules']} | Classes: {stats['classes']} | Functions: {stats['functions']} | Methods: {stats['methods']}
44
+ - Variables: {stats['variables']} | Parameters: {stats['parameters']} | External calls: {stats['external_calls']}
45
+ - Dead/unreachable: {stats['dead_nodes']} | Total edges: {stats['total_edges']}
46
+
47
+ ## Known Modules
48
+ {chr(10).join(f' - {m}' for m in modules)}
49
+
50
+ ## Known Classes
51
+ {chr(10).join(f' - {c}' for c in classes)}
52
+
53
+ ## The `view` API
54
+
55
+ ### Navigation
56
+ - `view.zoom(level)` — Set zoom: "all", "module", "class", "function", or "variable" (variable includes parameters)
57
+ - `view.focus(node_id, depth=2)` — Focus on a node and its neighborhood
58
+ - `view.unfocus()` — Clear focus, show full graph
59
+ - `view.isolate(target, level="function", depth=3, edge_types=None)` — **PRIMARY COMMAND**: Isolate a module/class/service and show it + everything connecting to it. `target` can be a partial name. `edge_types` is optional list like ["calls", "imports"].
60
+ - `view.show(target, level="function", depth=2)` — Shorthand for isolate
61
+ - `view.reset_filter()` — Reset everything to default view
62
+
63
+ ### Queries
64
+ - `view.query("dead functions")` — Find dead/uncalled code (highlights results)
65
+ - `view.query("callers of <name>")` — Who calls this?
66
+ - `view.query("callees of <name>")` — What does this call?
67
+ - `view.query("parameters of <name>")` — Show function parameters
68
+ - `view.query("returns of <name>")` — Show what a function returns (project symbols)
69
+ - `view.query("external calls in <name>")` — Show external library calls from a module/class/function
70
+ - `view.query("external calls")` — All external calls in the project
71
+ - `view.query("modules")` / `view.query("classes")` / `view.query("functions")` / `view.query("parameters")` / `view.query("variables")`
72
+ - `view.query("<search_term>")` — Fuzzy name search
73
+
74
+ ### Tracing (powered by NetworkX graph pathfinding)
75
+ - `view.trace_variable(var_name, origin=None)` — Trace a variable's data flow: who writes it, who reads it, and the paths between them.
76
+ - `view.trace_function(name)` — Trace a function's full call chain: everything that calls it (upstream) and everything it calls (downstream).
77
+ - `view.trace_call_chain(source, target, max_depth=8)` — Find all call paths from one function to another.
78
+
79
+ ### Impact & Dependency Analysis
80
+ - `view.impact_of(name)` — **Blast radius**: highlight everything downstream affected by changing this symbol. Only follows data/control flow (calls, reads, writes, returns), not containment.
81
+ - `view.depends_on(name)` — **Upstream dependencies**: highlight everything this symbol depends on.
82
+ - `view.path_between(source, target)` — Shortest dependency chain between two symbols.
83
+ - `view.all_paths(source, target, max_depth=8)` — Every route between two symbols.
84
+
85
+ ### Architecture Health
86
+ - `view.find_cycles()` — Find and highlight circular dependencies (calls/imports).
87
+ - `view.critical_nodes(top_n=20)` — Most important symbols by PageRank.
88
+ - `view.bottlenecks(top_n=20)` — Coupling hotspots (betweenness centrality).
89
+ - `view.coupling(module_a, module_b)` — Show all cross-module edges.
90
+ - `view.health()` — Full architecture health report (JSON): cycles, dead code %, data flow resolution %, external dependencies, bottlenecks, critical nodes.
91
+
92
+ ### Similarity & Duplicates
93
+ - `view.find_duplicates(threshold=0.6, scope=None)` — Find groups of structurally similar functions.
94
+ - `view.similar_to(target, threshold=0.5)` — Find functions similar to a specific symbol.
95
+ - `view.get_context(target)` — Rich context for a symbol: source, connections, fingerprint.
96
+
97
+ ### Filtering
98
+ - `view.filter(edge_type="calls")` — Show only one edge type
99
+ - `view.set_edge_types(["calls", "reads", "writes"])` — Toggle which edge types are visible. Useful for decluttering.
100
+ - `view.filter(name_pattern="regex_pattern")` — Filter by name
101
+ - `view.show_dead(True/False)` — Toggle dead code visibility
102
+ - `view.show_proposed(True/False)` — Toggle hypothetical elements
103
+
104
+ ### Hypotheticals
105
+ - `view.propose_function(name, module, calls=[...], called_by=[...])` — Add a hypothetical function (shown in green)
106
+ - `view.clear_proposed()` — Remove all hypothetical elements
107
+
108
+ ### Display
109
+ - `view.set_color(key, hex_value)` — Change a color.
110
+ - `view.stats()` — Summary statistics (includes parameter count, external call count)
111
+
112
+ ### Edge Types
113
+ calls, imports, inherits, contains, reads, writes, returns, proposed
114
+
115
+ ### Node Types
116
+ module, class, function, method, variable, parameter
117
+
118
+ ## Response Format
119
+ Respond with a JSON object:
120
+ ```json
121
+ {{
122
+ "explanation": "Human-readable description of what you're showing and why. Describe what the user is seeing and key observations.",
123
+ "commands": ["view.isolate('analyzer.parser', level='function', depth=2)"]
124
+ }}
125
+ ```
126
+
127
+ The `commands` array contains Python expressions executed against the `view` object.
128
+ The `explanation` will be shown to the user in the chat panel — it MUST describe what the visualization is now showing.
129
+
130
+ ## Guidelines
131
+ - Use `view.isolate()` as your go-to for "show me X" requests.
132
+ - Use `view.set_edge_types(...)` to declutter when there are too many edges. For example, `view.set_edge_types(["calls"])` to show only call relationships.
133
+ - Trace/impact/dependency commands preserve the current zoom level. The graph automatically remaps highlights to visible ancestors at coarser zoom levels. Use `view.zoom("module")` to see trace results aggregated to modules, or `view.zoom("class")` for classes. This is powerful for large projects — "trace this variable at module level" shows which modules are involved without rendering thousands of nodes.
134
+ - When the user asks about architecture or structure, start at module level then drill down.
135
+ - When showing dead code, explain what it means and whether it might be intentional. Dead code now includes dead parameters (never read), dead variables (written but never read), and dead returns (return value never used).
136
+ - Node IDs are dotted qualified names like "analyzer.graph.CodeGraph.build_from"
137
+ - Partial names work for isolate/focus — "CodeGraph" will match "analyzer.graph.CodeGraph"
138
+ - Always explain what the user is looking at in your explanation.
139
+ """
140
+
141
+
142
+ class LLMAdapter:
143
+ """Bridges natural language to view commands via an LLM API."""
144
+
145
+ def __init__(self, engine: QueryEngine) -> None:
146
+ self.engine = engine
147
+ self.api_key: str | None = os.environ.get("ANTHROPIC_API_KEY")
148
+ self.model: str = "claude-sonnet-4-20250514"
149
+ self.conversation: list[dict] = []
150
+ self.max_history: int = 20
151
+
152
+ @property
153
+ def is_configured(self) -> bool:
154
+ return bool(self.api_key)
155
+
156
+ def set_api_key(self, key: str) -> None:
157
+ self.api_key = key
158
+
159
+ def set_model(self, model: str) -> None:
160
+ self.model = model
161
+
162
+ def clear_history(self) -> None:
163
+ self.conversation.clear()
164
+
165
+ async def chat(self, user_message: str) -> dict:
166
+ """Process a user message: send to Claude, execute returned commands, return result.
167
+
168
+ Returns: {"explanation": str, "commands_run": list[str], "results": list[str], "error": str|None}
169
+ """
170
+ if not self.api_key:
171
+ return {
172
+ "explanation": "No API key configured. Set ANTHROPIC_API_KEY environment variable or use the settings panel. You can also drive the view directly via the command bar with Python: view.isolate('target')",
173
+ "commands_run": [],
174
+ "results": [],
175
+ "error": "no_api_key",
176
+ }
177
+
178
+ # Add user message to conversation
179
+ self.conversation.append({"role": "user", "content": user_message})
180
+
181
+ # Trim history
182
+ if len(self.conversation) > self.max_history:
183
+ self.conversation = self.conversation[-self.max_history:]
184
+
185
+ # Call Claude
186
+ system_prompt = get_system_prompt(self.engine)
187
+ try:
188
+ async with httpx.AsyncClient(timeout=30.0) as client:
189
+ response = await client.post(
190
+ "https://api.anthropic.com/v1/messages",
191
+ headers={
192
+ "x-api-key": self.api_key,
193
+ "anthropic-version": "2023-06-01",
194
+ "content-type": "application/json",
195
+ },
196
+ json={
197
+ "model": self.model,
198
+ "max_tokens": 1024,
199
+ "system": system_prompt,
200
+ "messages": self.conversation,
201
+ },
202
+ )
203
+ response.raise_for_status()
204
+ data = response.json()
205
+ except httpx.HTTPStatusError as e:
206
+ error_msg = f"Claude API error: {e.response.status_code}"
207
+ try:
208
+ error_body = e.response.json()
209
+ error_msg += f" — {error_body.get('error', {}).get('message', '')}"
210
+ except Exception:
211
+ pass
212
+ return {
213
+ "explanation": error_msg,
214
+ "commands_run": [],
215
+ "results": [],
216
+ "error": error_msg,
217
+ }
218
+ except Exception as e:
219
+ return {
220
+ "explanation": f"Failed to reach Claude API: {e}",
221
+ "commands_run": [],
222
+ "results": [],
223
+ "error": str(e),
224
+ }
225
+
226
+ # Extract response text
227
+ assistant_text = ""
228
+ for block in data.get("content", []):
229
+ if block.get("type") == "text":
230
+ assistant_text += block["text"]
231
+
232
+ # Add assistant response to conversation
233
+ self.conversation.append({"role": "assistant", "content": assistant_text})
234
+
235
+ # Parse the JSON response
236
+ explanation, commands = self._parse_response(assistant_text)
237
+
238
+ # Execute commands
239
+ results = []
240
+ for cmd in commands:
241
+ try:
242
+ result = self._execute_command(cmd)
243
+ results.append(f"{cmd} → {result}")
244
+ except Exception as e:
245
+ results.append(f"{cmd} → ERROR: {e}")
246
+
247
+ return {
248
+ "explanation": explanation,
249
+ "commands_run": commands,
250
+ "results": results,
251
+ "error": None,
252
+ }
253
+
254
+ def _parse_response(self, text: str) -> tuple[str, list[str]]:
255
+ """Extract explanation and commands from Claude's response."""
256
+ # Try to find JSON block
257
+ json_match = None
258
+ # Look for ```json ... ``` blocks
259
+ import re
260
+ json_block = re.search(r'```json\s*\n?(.*?)\n?\s*```', text, re.DOTALL)
261
+ if json_block:
262
+ try:
263
+ json_match = json.loads(json_block.group(1))
264
+ except json.JSONDecodeError:
265
+ pass
266
+
267
+ # Try parsing the whole text as JSON
268
+ if not json_match:
269
+ try:
270
+ json_match = json.loads(text)
271
+ except json.JSONDecodeError:
272
+ pass
273
+
274
+ # Try finding a JSON object in the text
275
+ if not json_match:
276
+ brace_match = re.search(r'\{[^{}]*"explanation"[^{}]*\}', text, re.DOTALL)
277
+ if brace_match:
278
+ try:
279
+ json_match = json.loads(brace_match.group(0))
280
+ except json.JSONDecodeError:
281
+ pass
282
+
283
+ if json_match and isinstance(json_match, dict):
284
+ explanation = json_match.get("explanation", text)
285
+ commands = json_match.get("commands", [])
286
+ if isinstance(commands, str):
287
+ commands = [commands]
288
+ return explanation, commands
289
+
290
+ # Fallback: treat the whole text as explanation, look for view.* commands inline
291
+ commands = re.findall(r'(view\.\w+\([^)]*\))', text)
292
+ return text, commands
293
+
294
+ def _execute_command(self, cmd: str) -> str:
295
+ """Execute a single view command string."""
296
+ local_ns: dict[str, Any] = {"view": self.engine, "graph": self.engine.graph}
297
+ try:
298
+ result = eval(cmd, {"__builtins__": {}}, local_ns)
299
+ except SyntaxError:
300
+ exec(cmd, {"__builtins__": {}}, local_ns)
301
+ result = "OK"
302
+ if hasattr(result, "model_dump"):
303
+ return str(result.model_dump())
304
+ return str(result)