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.
- interlinked/__init__.py +3 -0
- interlinked/analyzer/__init__.py +7 -0
- interlinked/analyzer/dead_code.py +137 -0
- interlinked/analyzer/graph.py +822 -0
- interlinked/analyzer/parser.py +1141 -0
- interlinked/analyzer/similarity.py +486 -0
- interlinked/cli.py +136 -0
- interlinked/commander/__init__.py +6 -0
- interlinked/commander/llm.py +304 -0
- interlinked/commander/query.py +966 -0
- interlinked/commander/repl.py +50 -0
- interlinked/mcp_server.py +324 -0
- interlinked/models.py +107 -0
- interlinked/visualizer/__init__.py +1 -0
- interlinked/visualizer/layouts.py +181 -0
- interlinked/visualizer/server.py +428 -0
- interlinked_mapper-0.1.0.dist-info/METADATA +26 -0
- interlinked_mapper-0.1.0.dist-info/RECORD +21 -0
- interlinked_mapper-0.1.0.dist-info/WHEEL +5 -0
- interlinked_mapper-0.1.0.dist-info/entry_points.txt +2 -0
- interlinked_mapper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|