|
1
|
""" |
|
2
|
NLPEngine — natural language queries, community naming, and documentation generation. |
|
3
|
|
|
4
|
Converts plain-English questions into Cypher queries, names communities with |
|
5
|
descriptive labels, and generates documentation for individual symbols. |
|
6
|
|
|
7
|
Usage:: |
|
8
|
|
|
9
|
from navegador.graph import GraphStore |
|
10
|
from navegador.llm import get_provider |
|
11
|
from navegador.intelligence.nlp import NLPEngine |
|
12
|
|
|
13
|
store = GraphStore.sqlite(".navegador/graph.db") |
|
14
|
provider = get_provider("anthropic") |
|
15
|
engine = NLPEngine(store, provider) |
|
16
|
|
|
17
|
answer = engine.natural_query("Which functions call authenticate_user?") |
|
18
|
print(answer) |
|
19
|
|
|
20
|
docs = engine.generate_docs("authenticate_user", file_path="auth.py") |
|
21
|
print(docs) |
|
22
|
""" |
|
23
|
|
|
24
|
from __future__ import annotations |
|
25
|
|
|
26
|
import json |
|
27
|
import re |
|
28
|
from typing import TYPE_CHECKING, Any |
|
29
|
|
|
30
|
if TYPE_CHECKING: |
|
31
|
from navegador.graph.store import GraphStore |
|
32
|
from navegador.intelligence.community import Community |
|
33
|
from navegador.llm import LLMProvider |
|
34
|
|
|
35
|
|
|
36
|
# ── Prompt templates ────────────────────────────────────────────────────────── |
|
37
|
|
|
38
|
_SCHEMA_SUMMARY = """ |
|
39
|
The navegador knowledge graph contains these node types: |
|
40
|
Function, Class, Method, File, Decorator — code layer |
|
41
|
Concept, Rule, Decision, WikiPage, Domain, Person — knowledge layer |
|
42
|
|
|
43
|
Common relationships: |
|
44
|
CALLS, INHERITS, DECORATES, CONTAINS, REFERENCES, IMPLEMENTS, |
|
45
|
BELONGS_TO, GOVERNS, DOCUMENTS, RELATED_TO, ASSIGNED_TO, DECIDED_BY |
|
46
|
|
|
47
|
Node properties (where present): |
|
48
|
name, file_path, line_start, docstring, description, signature, |
|
49
|
status, domain, rationale, alternatives, date, community |
|
50
|
""" |
|
51
|
|
|
52
|
_NL_TO_CYPHER_PROMPT = """\ |
|
53
|
You are a FalkorDB Cypher expert. Given the schema below and a user question, |
|
54
|
write a single Cypher query that answers the question. |
|
55
|
|
|
56
|
Return ONLY the Cypher query — no markdown fences, no explanation. |
|
57
|
|
|
58
|
{schema} |
|
59
|
|
|
60
|
User question: {question} |
|
61
|
""" |
|
62
|
|
|
63
|
_FORMAT_RESULT_PROMPT = """\ |
|
64
|
The user asked: "{question}" |
|
65
|
|
|
66
|
The Cypher query executed was: |
|
67
|
{cypher} |
|
68
|
|
|
69
|
The raw result rows are: |
|
70
|
{rows} |
|
71
|
|
|
72
|
Please summarise the result in a clear, concise paragraph (2–5 sentences). |
|
73
|
""" |
|
74
|
|
|
75
|
_NAME_COMMUNITY_PROMPT = """\ |
|
76
|
You are naming software communities detected via graph analysis. |
|
77
|
|
|
78
|
Community members (function/class/concept names): {members} |
|
79
|
|
|
80
|
Based on these names, suggest a short, descriptive community name (3–6 words). |
|
81
|
Return ONLY the name — no punctuation, no explanation. |
|
82
|
""" |
|
83
|
|
|
84
|
_GENERATE_DOCS_PROMPT = """\ |
|
85
|
Generate concise markdown documentation for the symbol described below. |
|
86
|
|
|
87
|
Symbol name: {name} |
|
88
|
File: {file_path} |
|
89
|
Type: {type} |
|
90
|
Docstring: {docstring} |
|
91
|
Signature: {signature} |
|
92
|
Callers: {callers} |
|
93
|
Callees: {callees} |
|
94
|
|
|
95
|
Write: |
|
96
|
1. A one-paragraph description. |
|
97
|
2. Parameters (if applicable). |
|
98
|
3. Returns (if applicable). |
|
99
|
4. Example usage snippet. |
|
100
|
|
|
101
|
Use markdown. |
|
102
|
""" |
|
103
|
|
|
104
|
|
|
105
|
class NLPEngine: |
|
106
|
""" |
|
107
|
LLM-powered natural language interface to the navegador graph. |
|
108
|
|
|
109
|
Args: |
|
110
|
store: A :class:`~navegador.graph.GraphStore` instance. |
|
111
|
provider: An :class:`~navegador.llm.LLMProvider` for completions. |
|
112
|
""" |
|
113
|
|
|
114
|
def __init__(self, store: "GraphStore", provider: "LLMProvider") -> None: |
|
115
|
self._store = store |
|
116
|
self._provider = provider |
|
117
|
|
|
118
|
# ── Natural language query ───────────────────────────────────────────── |
|
119
|
|
|
120
|
def natural_query(self, question: str) -> str: |
|
121
|
""" |
|
122
|
Convert a natural-language *question* into Cypher, execute it, and |
|
123
|
return an LLM-formatted answer. |
|
124
|
|
|
125
|
Args: |
|
126
|
question: Plain-English question about the codebase or knowledge |
|
127
|
graph (e.g. ``"Which functions call validate_token?"``). |
|
128
|
|
|
129
|
Returns: |
|
130
|
A human-readable answer string. |
|
131
|
""" |
|
132
|
# Step 1: translate question → Cypher |
|
133
|
cypher_prompt = _NL_TO_CYPHER_PROMPT.format(schema=_SCHEMA_SUMMARY, question=question) |
|
134
|
cypher = self._provider.complete(cypher_prompt).strip() |
|
135
|
|
|
136
|
# Strip any accidental markdown fences the model may still produce |
|
137
|
cypher = _strip_fences(cypher) |
|
138
|
|
|
139
|
# Step 2: execute |
|
140
|
try: |
|
141
|
result = self._store.query(cypher, {}) |
|
142
|
rows = result.result_set or [] |
|
143
|
except Exception as exc: # noqa: BLE001 |
|
144
|
return ( |
|
145
|
f"Failed to execute the generated Cypher query.\n\nQuery: {cypher}\n\nError: {exc}" |
|
146
|
) |
|
147
|
|
|
148
|
# Step 3: format result |
|
149
|
rows_text = json.dumps(rows[:50], indent=2, default=str) |
|
150
|
fmt_prompt = _FORMAT_RESULT_PROMPT.format(question=question, cypher=cypher, rows=rows_text) |
|
151
|
return self._provider.complete(fmt_prompt) |
|
152
|
|
|
153
|
# ── Community naming ────────────────────────────────────────────────── |
|
154
|
|
|
155
|
def name_communities(self, communities: list["Community"]) -> list[dict[str, Any]]: |
|
156
|
""" |
|
157
|
Use the LLM to generate a meaningful name for each community. |
|
158
|
|
|
159
|
Args: |
|
160
|
communities: List of :class:`~navegador.intelligence.community.Community` |
|
161
|
objects (as returned by :meth:`~CommunityDetector.detect`). |
|
162
|
|
|
163
|
Returns: |
|
164
|
List of dicts with keys ``original_name``, ``suggested_name``, |
|
165
|
``members``, ``size``. |
|
166
|
""" |
|
167
|
named: list[dict[str, Any]] = [] |
|
168
|
for comm in communities: |
|
169
|
members_str = ", ".join(comm.members[:20]) # cap to avoid huge prompts |
|
170
|
prompt = _NAME_COMMUNITY_PROMPT.format(members=members_str) |
|
171
|
try: |
|
172
|
suggested = self._provider.complete(prompt).strip() |
|
173
|
except Exception: # noqa: BLE001 |
|
174
|
suggested = comm.name |
|
175
|
named.append( |
|
176
|
{ |
|
177
|
"original_name": comm.name, |
|
178
|
"suggested_name": suggested, |
|
179
|
"members": comm.members, |
|
180
|
"size": comm.size, |
|
181
|
} |
|
182
|
) |
|
183
|
return named |
|
184
|
|
|
185
|
# ── Documentation generation ────────────────────────────────────────── |
|
186
|
|
|
187
|
def generate_docs(self, name: str, file_path: str = "") -> str: |
|
188
|
""" |
|
189
|
Generate markdown documentation for a named symbol. |
|
190
|
|
|
191
|
Retrieves graph context (type, docstring, signature, callers, callees) |
|
192
|
and asks the LLM to produce structured markdown. |
|
193
|
|
|
194
|
Args: |
|
195
|
name: Symbol name (function, class, etc.). |
|
196
|
file_path: Optional file path to disambiguate. |
|
197
|
|
|
198
|
Returns: |
|
199
|
Markdown documentation string. |
|
200
|
""" |
|
201
|
# Look up the node |
|
202
|
cypher = """ |
|
203
|
MATCH (n) |
|
204
|
WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path) |
|
205
|
RETURN labels(n)[0] AS type, n.name AS name, |
|
206
|
coalesce(n.file_path, '') AS file_path, |
|
207
|
coalesce(n.docstring, n.description, '') AS docstring, |
|
208
|
coalesce(n.signature, '') AS signature |
|
209
|
LIMIT 1 |
|
210
|
""" |
|
211
|
result = self._store.query(cypher, {"name": name, "file_path": file_path}) |
|
212
|
rows = result.result_set or [] |
|
213
|
|
|
214
|
node_type = "Unknown" |
|
215
|
docstring = "" |
|
216
|
signature = "" |
|
217
|
fp = file_path |
|
218
|
|
|
219
|
if rows: |
|
220
|
row = rows[0] |
|
221
|
node_type, _, fp, docstring, signature = (row[0], row[1], row[2], row[3], row[4]) |
|
222
|
|
|
223
|
# Fetch callers |
|
224
|
callers_result = self._store.query( |
|
225
|
"MATCH (caller)-[:CALLS]->(n {name: $name}) RETURN caller.name LIMIT 10", |
|
226
|
{"name": name}, |
|
227
|
) |
|
228
|
callers = [r[0] for r in (callers_result.result_set or []) if r[0]] |
|
229
|
|
|
230
|
# Fetch callees |
|
231
|
callees_result = self._store.query( |
|
232
|
"MATCH (n {name: $name})-[:CALLS]->(callee) RETURN callee.name LIMIT 10", |
|
233
|
{"name": name}, |
|
234
|
) |
|
235
|
callees = [r[0] for r in (callees_result.result_set or []) if r[0]] |
|
236
|
|
|
237
|
prompt = _GENERATE_DOCS_PROMPT.format( |
|
238
|
name=name, |
|
239
|
file_path=fp, |
|
240
|
type=node_type, |
|
241
|
docstring=docstring or "(none)", |
|
242
|
signature=signature or "(none)", |
|
243
|
callers=", ".join(callers) if callers else "(none)", |
|
244
|
callees=", ".join(callees) if callees else "(none)", |
|
245
|
) |
|
246
|
return self._provider.complete(prompt) |
|
247
|
|
|
248
|
|
|
249
|
# ── Helpers ─────────────────────────────────────────────────────────────────── |
|
250
|
|
|
251
|
|
|
252
|
def _strip_fences(text: str) -> str: |
|
253
|
"""Remove markdown code fences (```cypher … ```) from LLM output.""" |
|
254
|
text = re.sub(r"```[a-zA-Z]*\n?", "", text) |
|
255
|
text = text.replace("```", "") |
|
256
|
return text.strip() |
|
257
|
|