graph-n4j 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.
- graph_n4j/__init__.py +36 -0
- graph_n4j/agents/__init__.py +1 -0
- graph_n4j/agents/pipeline.py +336 -0
- graph_n4j/agents/primary_agent.py +185 -0
- graph_n4j/agents/translation_agent.py +98 -0
- graph_n4j/apps/__init__.py +1 -0
- graph_n4j/apps/code_chat.py +61 -0
- graph_n4j/apps/code_commenter.py +84 -0
- graph_n4j/apps/code_debugger.py +80 -0
- graph_n4j/apps/code_generator.py +76 -0
- graph_n4j/apps/code_unittester.py +81 -0
- graph_n4j/cache.py +434 -0
- graph_n4j/cli.py +233 -0
- graph_n4j/core.py +292 -0
- graph_n4j/graph_db.py +366 -0
- graph_n4j/indexer/__init__.py +1 -0
- graph_n4j/indexer/edge_completion.py +495 -0
- graph_n4j/indexer/shallow_index.py +468 -0
- graph_n4j/llm/__init__.py +1 -0
- graph_n4j/llm/groq_client.py +253 -0
- graph_n4j/py.typed +1 -0
- graph_n4j/repo_manager.py +386 -0
- graph_n4j/schema.py +240 -0
- graph_n4j-0.1.0.dist-info/METADATA +126 -0
- graph_n4j-0.1.0.dist-info/RECORD +29 -0
- graph_n4j-0.1.0.dist-info/WHEEL +5 -0
- graph_n4j-0.1.0.dist-info/entry_points.txt +2 -0
- graph_n4j-0.1.0.dist-info/licenses/LICENSE +21 -0
- graph_n4j-0.1.0.dist-info/top_level.txt +1 -0
graph_n4j/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
graph-n4j — Bridge LLM agents with code repositories via Neo4j graph databases.
|
|
3
|
+
|
|
4
|
+
Based on the CodexGraph NAACL 2025 paper:
|
|
5
|
+
"CodexGraph: Bridging Large Language Models and Code Repositories via Code Graph Databases"
|
|
6
|
+
|
|
7
|
+
Quick Start:
|
|
8
|
+
from graph_n4j import GraphN4J
|
|
9
|
+
|
|
10
|
+
g = GraphN4J.from_env() # reads GROQ_API_KEY, NEO4J_* from .env
|
|
11
|
+
g.index("./my-python-project") # index a local repo
|
|
12
|
+
result = g.ask("What does the Foo class do?")
|
|
13
|
+
print(result.answer)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
from graph_n4j.core import GraphN4J
|
|
19
|
+
from graph_n4j.graph_db import GraphDB
|
|
20
|
+
from graph_n4j.llm.groq_client import GroqClient
|
|
21
|
+
from graph_n4j.schema import SCHEMA_DESCRIPTION
|
|
22
|
+
from graph_n4j.cache import SkeletonCache, QueryCache, ContextTracker
|
|
23
|
+
from graph_n4j.repo_manager import RepoManager, RepoMetadata
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"GraphN4J",
|
|
27
|
+
"GraphDB",
|
|
28
|
+
"GroqClient",
|
|
29
|
+
"SCHEMA_DESCRIPTION",
|
|
30
|
+
"SkeletonCache",
|
|
31
|
+
"QueryCache",
|
|
32
|
+
"ContextTracker",
|
|
33
|
+
"RepoManager",
|
|
34
|
+
"RepoMetadata",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# graph_n4j.agents — Primary Agent, Translation Agent, and iterative pipeline
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pipeline.py -- Iterative dual-agent pipeline with token efficiency optimizations.
|
|
3
|
+
|
|
4
|
+
Per Section 3.2 of the paper, plus B1-B7 token efficiency improvements:
|
|
5
|
+
B1. Repo skeleton injection (--include-skeleton)
|
|
6
|
+
B2. Query/translation caching (--enable-cache)
|
|
7
|
+
B3. Context pruning — seen code replaced with reference tags (--compact-context)
|
|
8
|
+
B4. Result truncation to top-K rows (--truncate-results)
|
|
9
|
+
B5. Cheaper model for Translation Agent (GROQ_MODEL_TRANSLATION)
|
|
10
|
+
B6. Early-stopping on empty/duplicate results (--early-stop)
|
|
11
|
+
B7. All caches keyed by (repo_id, commit_sha)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from graph_n4j.graph_db import GraphDB
|
|
20
|
+
from graph_n4j.llm.groq_client import GroqClient
|
|
21
|
+
from graph_n4j.agents.primary_agent import PrimaryAgent
|
|
22
|
+
from graph_n4j.agents.translation_agent import TranslationAgent
|
|
23
|
+
from graph_n4j.cache import (
|
|
24
|
+
SkeletonCache, QueryCache, ContextTracker,
|
|
25
|
+
truncate_results, should_early_stop,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PipelineResult:
|
|
33
|
+
"""Result from a complete pipeline run."""
|
|
34
|
+
answer: str
|
|
35
|
+
rounds_used: int
|
|
36
|
+
total_queries: int
|
|
37
|
+
neo4j_roundtrips: int = 0 # B2: actual DB hits (excl. cache hits)
|
|
38
|
+
context_history: list[dict] = field(default_factory=list)
|
|
39
|
+
token_usage: dict = field(default_factory=dict)
|
|
40
|
+
cache_stats: dict = field(default_factory=dict) # B2: hit/miss rates
|
|
41
|
+
errors: list[str] = field(default_factory=list)
|
|
42
|
+
early_stopped: bool = False # B6: whether early-stop fired
|
|
43
|
+
early_stop_reason: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CodexGraphPipeline:
|
|
47
|
+
"""
|
|
48
|
+
Orchestrates the iterative dual-agent loop with token efficiency optimizations.
|
|
49
|
+
|
|
50
|
+
All B-flags default ON except B5 (cheaper translation model), which is opt-in.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
groq_client: GroqClient,
|
|
56
|
+
graph_db: GraphDB,
|
|
57
|
+
max_rounds: int = 5,
|
|
58
|
+
query_mode: str = "single_query",
|
|
59
|
+
task_instruction: str = "",
|
|
60
|
+
no_translation_agent: bool = False,
|
|
61
|
+
no_edges: bool = False,
|
|
62
|
+
# ── B-flag controls ──
|
|
63
|
+
repo_id: str = "",
|
|
64
|
+
commit_sha: str = "",
|
|
65
|
+
include_skeleton: bool = True, # B1
|
|
66
|
+
enable_cache: bool = True, # B2
|
|
67
|
+
compact_context: bool = True, # B3
|
|
68
|
+
truncate_max_rows: int = 15, # B4 (0 = disabled)
|
|
69
|
+
translation_model: Optional[str] = None, # B5
|
|
70
|
+
early_stop: bool = True, # B6
|
|
71
|
+
# Shared cache objects (reuse across pipeline calls in a session)
|
|
72
|
+
skeleton_cache: Optional[SkeletonCache] = None,
|
|
73
|
+
query_cache: Optional[QueryCache] = None,
|
|
74
|
+
):
|
|
75
|
+
self.client = groq_client
|
|
76
|
+
self.db = graph_db
|
|
77
|
+
self.max_rounds = max_rounds
|
|
78
|
+
self.query_mode = query_mode
|
|
79
|
+
self.no_translation_agent = no_translation_agent
|
|
80
|
+
self.no_edges = no_edges
|
|
81
|
+
|
|
82
|
+
# B-flag settings
|
|
83
|
+
self.repo_id = repo_id
|
|
84
|
+
self.commit_sha = commit_sha
|
|
85
|
+
self.include_skeleton = include_skeleton
|
|
86
|
+
self.enable_cache = enable_cache
|
|
87
|
+
self.compact_context = compact_context
|
|
88
|
+
self.truncate_max_rows = truncate_max_rows
|
|
89
|
+
self.translation_model = translation_model or os.getenv("GROQ_MODEL_TRANSLATION")
|
|
90
|
+
self.early_stop = early_stop
|
|
91
|
+
|
|
92
|
+
# Cache objects — shared across calls for session-level reuse (B7)
|
|
93
|
+
self.skeleton_cache = skeleton_cache or SkeletonCache()
|
|
94
|
+
self.query_cache = query_cache or QueryCache(enabled=enable_cache)
|
|
95
|
+
|
|
96
|
+
# Build task instruction with ablation modifications
|
|
97
|
+
full_instruction = task_instruction
|
|
98
|
+
if no_translation_agent:
|
|
99
|
+
full_instruction += (
|
|
100
|
+
"\n\nIMPORTANT: You must write Cypher queries directly instead of "
|
|
101
|
+
"natural language queries. Output valid Cypher in the 'queries' array."
|
|
102
|
+
)
|
|
103
|
+
if no_edges:
|
|
104
|
+
full_instruction += (
|
|
105
|
+
"\n\nIMPORTANT: You may ONLY query node attributes (properties). "
|
|
106
|
+
"Do NOT traverse any edges/relationships. Only use MATCH on single "
|
|
107
|
+
"node labels with property filters."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# B1: Build/load skeleton and inject into system prompt
|
|
111
|
+
skeleton_text = ""
|
|
112
|
+
if include_skeleton:
|
|
113
|
+
skeleton = self.skeleton_cache.get(repo_id, commit_sha)
|
|
114
|
+
if not skeleton:
|
|
115
|
+
try:
|
|
116
|
+
skeleton = self.skeleton_cache.build_from_db(graph_db, repo_id, commit_sha)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning("Failed to build skeleton: %s", e)
|
|
119
|
+
if skeleton:
|
|
120
|
+
skeleton_text = (
|
|
121
|
+
"\n\n## Repository Structure (skeleton)\n"
|
|
122
|
+
"Use this overview to plan targeted queries instead of exploring blindly.\n\n"
|
|
123
|
+
+ skeleton.to_prompt_text()
|
|
124
|
+
)
|
|
125
|
+
logger.info("B1: Skeleton injected (%d chars)", len(skeleton_text))
|
|
126
|
+
|
|
127
|
+
self.primary_agent = PrimaryAgent(
|
|
128
|
+
groq_client=groq_client,
|
|
129
|
+
query_mode=query_mode,
|
|
130
|
+
task_instruction=full_instruction + skeleton_text,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self.translation_agent = TranslationAgent(
|
|
134
|
+
groq_client=groq_client,
|
|
135
|
+
model_override=self.translation_model, # B5
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def run(self, question: str) -> PipelineResult:
|
|
139
|
+
"""Execute the full iterative pipeline for a user question."""
|
|
140
|
+
initial_tokens = self.client.usage.total_tokens
|
|
141
|
+
context_history: list[dict] = []
|
|
142
|
+
total_queries = 0
|
|
143
|
+
neo4j_roundtrips = 0
|
|
144
|
+
errors: list[str] = []
|
|
145
|
+
previous_results: list[str] = []
|
|
146
|
+
|
|
147
|
+
# B3: Context tracker for this session
|
|
148
|
+
ctx_tracker = ContextTracker(enabled=self.compact_context)
|
|
149
|
+
|
|
150
|
+
logger.info("Pipeline started -- question: '%s'", question[:100])
|
|
151
|
+
|
|
152
|
+
for round_num in range(1, self.max_rounds + 1):
|
|
153
|
+
logger.info("--- Round %d/%d ---", round_num, self.max_rounds)
|
|
154
|
+
|
|
155
|
+
# -- Step 1: Primary Agent generates analysis + queries --
|
|
156
|
+
try:
|
|
157
|
+
primary_response = self.primary_agent.run(question, context_history)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
error_msg = f"Round {round_num}: Primary Agent error -- {e}"
|
|
160
|
+
logger.error(error_msg)
|
|
161
|
+
errors.append(error_msg)
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
# Check if sufficient context
|
|
165
|
+
if primary_response.get("sufficient_context", False):
|
|
166
|
+
answer = primary_response.get("final_answer", primary_response.get("analysis", ""))
|
|
167
|
+
logger.info("Primary Agent signals sufficient context at round %d.", round_num)
|
|
168
|
+
return PipelineResult(
|
|
169
|
+
answer=answer,
|
|
170
|
+
rounds_used=round_num,
|
|
171
|
+
total_queries=total_queries,
|
|
172
|
+
neo4j_roundtrips=neo4j_roundtrips,
|
|
173
|
+
context_history=context_history,
|
|
174
|
+
token_usage=self._get_token_delta(initial_tokens),
|
|
175
|
+
cache_stats=self.query_cache.stats() if self.enable_cache else {},
|
|
176
|
+
errors=errors,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
queries = primary_response.get("queries", [])
|
|
180
|
+
if not queries:
|
|
181
|
+
logger.warning("No queries generated in round %d. Ending pipeline.", round_num)
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
# -- Step 2: Translate + Execute each query --
|
|
185
|
+
round_results: list[str] = []
|
|
186
|
+
for nl_query in queries:
|
|
187
|
+
total_queries += 1
|
|
188
|
+
|
|
189
|
+
# Get Cypher (from cache or Translation Agent)
|
|
190
|
+
try:
|
|
191
|
+
if self.no_translation_agent:
|
|
192
|
+
cypher = nl_query
|
|
193
|
+
else:
|
|
194
|
+
# B2: Check translation cache first
|
|
195
|
+
cached_cypher = self.query_cache.get_translation(nl_query)
|
|
196
|
+
if cached_cypher:
|
|
197
|
+
cypher = cached_cypher
|
|
198
|
+
logger.info("B2: Translation cache hit for '%s'", nl_query[:50])
|
|
199
|
+
else:
|
|
200
|
+
cypher = self.translation_agent.translate(nl_query)
|
|
201
|
+
self.query_cache.put_translation_with_nl(nl_query, cypher)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
error_msg = f"Translation error for '{nl_query[:50]}': {e}"
|
|
204
|
+
logger.error(error_msg)
|
|
205
|
+
errors.append(error_msg)
|
|
206
|
+
round_results.append(f"(Translation error: {e})")
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# Execute against Neo4j (with B2 result cache)
|
|
210
|
+
try:
|
|
211
|
+
# B2: Check result cache
|
|
212
|
+
cached_result = self.query_cache.get_cypher_result(
|
|
213
|
+
cypher, self.repo_id, self.commit_sha
|
|
214
|
+
)
|
|
215
|
+
if cached_result is not None:
|
|
216
|
+
records = cached_result
|
|
217
|
+
logger.info("B2: Result cache hit (%d records)", len(records))
|
|
218
|
+
else:
|
|
219
|
+
records = self.db.run_query(cypher, parameters={"repo_id": self.repo_id})
|
|
220
|
+
neo4j_roundtrips += 1
|
|
221
|
+
# Cache the result
|
|
222
|
+
try:
|
|
223
|
+
self.query_cache.put_cypher_result(
|
|
224
|
+
cypher, self.repo_id, self.commit_sha, records
|
|
225
|
+
)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass # Don't fail on cache write errors
|
|
228
|
+
|
|
229
|
+
# B4: Truncate large result sets
|
|
230
|
+
if self.truncate_max_rows > 0:
|
|
231
|
+
records, truncation_note = truncate_results(
|
|
232
|
+
records, max_rows=self.truncate_max_rows
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
truncation_note = None
|
|
236
|
+
|
|
237
|
+
formatted = self.db.format_results(records)
|
|
238
|
+
|
|
239
|
+
# B3: Compact already-seen code
|
|
240
|
+
if self.compact_context:
|
|
241
|
+
formatted = ctx_tracker.compact_results(formatted, round_num)
|
|
242
|
+
for rec in records:
|
|
243
|
+
ctx_tracker.mark_seen(rec, round_num)
|
|
244
|
+
|
|
245
|
+
if truncation_note:
|
|
246
|
+
formatted += f"\n\n{truncation_note}"
|
|
247
|
+
|
|
248
|
+
round_results.append(formatted)
|
|
249
|
+
logger.info("Query returned %d records.", len(records))
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
error_msg = f"Neo4j query error -- Cypher: '{cypher[:80]}' -- {e}"
|
|
253
|
+
logger.error(error_msg)
|
|
254
|
+
errors.append(error_msg)
|
|
255
|
+
round_results.append(f"(Query execution error: {e})")
|
|
256
|
+
|
|
257
|
+
# B6: Early-stopping heuristic
|
|
258
|
+
if self.early_stop:
|
|
259
|
+
stop, reason = should_early_stop(round_results, previous_results)
|
|
260
|
+
if stop:
|
|
261
|
+
logger.info("B6: Early stop triggered -- %s", reason)
|
|
262
|
+
# Store context and break to final synthesis
|
|
263
|
+
context_history.append({
|
|
264
|
+
"round": round_num,
|
|
265
|
+
"queries": queries,
|
|
266
|
+
"results": round_results,
|
|
267
|
+
})
|
|
268
|
+
return PipelineResult(
|
|
269
|
+
answer=self._synthesize_final(question, context_history),
|
|
270
|
+
rounds_used=round_num,
|
|
271
|
+
total_queries=total_queries,
|
|
272
|
+
neo4j_roundtrips=neo4j_roundtrips,
|
|
273
|
+
context_history=context_history,
|
|
274
|
+
token_usage=self._get_token_delta(initial_tokens),
|
|
275
|
+
cache_stats=self.query_cache.stats() if self.enable_cache else {},
|
|
276
|
+
errors=errors,
|
|
277
|
+
early_stopped=True,
|
|
278
|
+
early_stop_reason=reason,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
previous_results = round_results
|
|
282
|
+
|
|
283
|
+
# -- Step 3: Store round context --
|
|
284
|
+
context_history.append({
|
|
285
|
+
"round": round_num,
|
|
286
|
+
"queries": queries,
|
|
287
|
+
"results": round_results,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
# -- Fallback: max rounds reached --
|
|
291
|
+
logger.info("Max rounds reached. Requesting final synthesis.")
|
|
292
|
+
answer = self._synthesize_final(question, context_history)
|
|
293
|
+
|
|
294
|
+
return PipelineResult(
|
|
295
|
+
answer=answer,
|
|
296
|
+
rounds_used=self.max_rounds,
|
|
297
|
+
total_queries=total_queries,
|
|
298
|
+
neo4j_roundtrips=neo4j_roundtrips,
|
|
299
|
+
context_history=context_history,
|
|
300
|
+
token_usage=self._get_token_delta(initial_tokens),
|
|
301
|
+
cache_stats=self.query_cache.stats() if self.enable_cache else {},
|
|
302
|
+
errors=errors,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def _synthesize_final(self, question: str, context_history: list[dict]) -> str:
|
|
306
|
+
"""Ask the LLM for a final answer given all gathered context."""
|
|
307
|
+
try:
|
|
308
|
+
final_messages = [{
|
|
309
|
+
"role": "system",
|
|
310
|
+
"content": (
|
|
311
|
+
"You have reached the maximum number of retrieval rounds. "
|
|
312
|
+
"Based on all the context gathered so far, provide your best "
|
|
313
|
+
"answer to the user's question. Respond with valid JSON: "
|
|
314
|
+
'{"analysis": "...", "final_answer": "..."}'
|
|
315
|
+
),
|
|
316
|
+
}, {
|
|
317
|
+
"role": "user",
|
|
318
|
+
"content": f"Question: {question}\n\nContext gathered:\n" +
|
|
319
|
+
"\n".join(
|
|
320
|
+
f"Round {e['round']}: {e['results']}"
|
|
321
|
+
for e in context_history
|
|
322
|
+
),
|
|
323
|
+
}]
|
|
324
|
+
final = self.client.chat_json(final_messages, temperature=0.3)
|
|
325
|
+
return final.get("final_answer", final.get("analysis", "Unable to determine answer."))
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.error("Final synthesis failed: %s", e)
|
|
328
|
+
return "Unable to synthesize a final answer. See context history for partial results."
|
|
329
|
+
|
|
330
|
+
def _get_token_delta(self, initial_total: int) -> dict:
|
|
331
|
+
"""Calculate tokens used in this pipeline run."""
|
|
332
|
+
delta = self.client.usage.total_tokens - initial_total
|
|
333
|
+
return {
|
|
334
|
+
"pipeline_total_tokens": delta,
|
|
335
|
+
"cumulative": self.client.usage.to_dict(),
|
|
336
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
primary_agent.py — The Primary Agent in CodexGraph's dual-agent architecture.
|
|
3
|
+
|
|
4
|
+
Per Section 3.2 of the paper, the Primary Agent:
|
|
5
|
+
- Receives the user's code question + accumulated context from prior rounds
|
|
6
|
+
- Produces: (a) natural-language analysis, (b) one or more NL queries describing
|
|
7
|
+
what to retrieve from the graph, (c) a decision on whether enough context exists
|
|
8
|
+
- Supports both 'single_query' and 'multiple_queries' modes
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from graph_n4j.llm.groq_client import GroqClient
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ═══════════════════════════════════════════
|
|
20
|
+
# System prompts
|
|
21
|
+
# ═══════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
PRIMARY_SYSTEM_PROMPT = """You are the Primary Agent in the CodexGraph system. Your job is to help \
|
|
24
|
+
answer questions about a Python code repository by deciding what information to \
|
|
25
|
+
retrieve from a code graph database.
|
|
26
|
+
|
|
27
|
+
The graph database contains the following node and edge types:
|
|
28
|
+
|
|
29
|
+
**Nodes:** MODULE (name, file_path), CLASS (name, file_path, signature, code), \
|
|
30
|
+
FUNCTION (name, file_path, signature, code), METHOD (name, file_path, class_name, \
|
|
31
|
+
signature, code), FIELD (name, file_path, class_name), GLOBAL_VARIABLE (name, \
|
|
32
|
+
file_path, code)
|
|
33
|
+
|
|
34
|
+
**Edges:** CONTAINS (MODULE->CLASS/FUNCTION/GLOBAL_VARIABLE), \
|
|
35
|
+
HAS_METHOD (CLASS->METHOD), HAS_FIELD (CLASS->FIELD), \
|
|
36
|
+
INHERITS (CLASS->CLASS), USES (FUNCTION/METHOD->GLOBAL_VARIABLE/FIELD, \
|
|
37
|
+
with source_association_type and target_association_type)
|
|
38
|
+
|
|
39
|
+
## Your Process
|
|
40
|
+
|
|
41
|
+
Each round, you will:
|
|
42
|
+
1. Analyze what information you still need
|
|
43
|
+
2. Write natural language queries describing what to retrieve from the graph
|
|
44
|
+
3. Decide if you have gathered enough context to answer the question
|
|
45
|
+
|
|
46
|
+
## Output Format
|
|
47
|
+
|
|
48
|
+
You MUST respond with valid JSON in this exact format:
|
|
49
|
+
{{
|
|
50
|
+
"analysis": "Your analysis of what information is needed and why",
|
|
51
|
+
"queries": [
|
|
52
|
+
"Natural language description of what to retrieve from the graph database"
|
|
53
|
+
],
|
|
54
|
+
"sufficient_context": false
|
|
55
|
+
}}
|
|
56
|
+
|
|
57
|
+
When sufficient_context is true, include a "final_answer" field:
|
|
58
|
+
{{
|
|
59
|
+
"analysis": "Final analysis synthesizing all gathered context",
|
|
60
|
+
"queries": [],
|
|
61
|
+
"sufficient_context": true,
|
|
62
|
+
"final_answer": "Your comprehensive answer to the user's question"
|
|
63
|
+
}}
|
|
64
|
+
|
|
65
|
+
## Rules
|
|
66
|
+
- Each query should describe ONE specific retrieval operation
|
|
67
|
+
- Queries should reference specific node types, names, and relationships from the schema
|
|
68
|
+
- Be precise: "Find the class named X" is better than "find the relevant class"
|
|
69
|
+
- {query_mode_instruction}
|
|
70
|
+
- Use information from previous rounds to refine your queries
|
|
71
|
+
- Do NOT write Cypher — write natural language descriptions of what to retrieve
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
SINGLE_QUERY_INSTRUCTION = "Generate exactly ONE query per round (best for complex reasoning tasks)"
|
|
75
|
+
MULTIPLE_QUERIES_INSTRUCTION = "Generate up to 3 queries per round (best for gathering broad context quickly)"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_system_prompt(
|
|
79
|
+
query_mode: str = "single_query",
|
|
80
|
+
task_specific_instruction: str = "",
|
|
81
|
+
) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Build the Primary Agent's system prompt.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
query_mode: "single_query" or "multiple_queries"
|
|
87
|
+
task_specific_instruction: Additional instructions for specific tasks
|
|
88
|
+
(e.g., Code Debugger, Code Generator, etc.)
|
|
89
|
+
"""
|
|
90
|
+
instruction = (
|
|
91
|
+
SINGLE_QUERY_INSTRUCTION if query_mode == "single_query"
|
|
92
|
+
else MULTIPLE_QUERIES_INSTRUCTION
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
prompt = PRIMARY_SYSTEM_PROMPT.format(query_mode_instruction=instruction)
|
|
96
|
+
|
|
97
|
+
if task_specific_instruction:
|
|
98
|
+
prompt += f"\n\n## Task-Specific Instructions\n{task_specific_instruction}"
|
|
99
|
+
|
|
100
|
+
return prompt
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ═══════════════════════════════════════════
|
|
104
|
+
# Primary Agent class
|
|
105
|
+
# ═══════════════════════════════════════════
|
|
106
|
+
|
|
107
|
+
class PrimaryAgent:
|
|
108
|
+
"""
|
|
109
|
+
The Primary Agent: analyzes the user's question, generates NL graph queries,
|
|
110
|
+
and synthesizes a final answer when sufficient context is gathered.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
groq_client: GroqClient,
|
|
116
|
+
query_mode: str = "single_query",
|
|
117
|
+
task_instruction: str = "",
|
|
118
|
+
temperature: float = 0.3,
|
|
119
|
+
):
|
|
120
|
+
self.client = groq_client
|
|
121
|
+
self.query_mode = query_mode
|
|
122
|
+
self.temperature = temperature
|
|
123
|
+
self.system_prompt = get_system_prompt(query_mode, task_instruction)
|
|
124
|
+
|
|
125
|
+
def run(
|
|
126
|
+
self,
|
|
127
|
+
user_question: str,
|
|
128
|
+
context_history: list[dict],
|
|
129
|
+
) -> dict:
|
|
130
|
+
"""
|
|
131
|
+
Execute one round of the Primary Agent.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
user_question: The original user question.
|
|
135
|
+
context_history: List of prior round results:
|
|
136
|
+
[{"round": 1, "queries": [...], "results": [...]}, ...]
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Parsed JSON dict with keys: analysis, queries, sufficient_context,
|
|
140
|
+
and optionally final_answer.
|
|
141
|
+
"""
|
|
142
|
+
messages = [{"role": "system", "content": self.system_prompt}]
|
|
143
|
+
|
|
144
|
+
# Build the user message with accumulated context
|
|
145
|
+
user_content = f"## User Question\n{user_question}\n"
|
|
146
|
+
|
|
147
|
+
if context_history:
|
|
148
|
+
user_content += "\n## Context from Previous Rounds\n"
|
|
149
|
+
for entry in context_history:
|
|
150
|
+
user_content += f"\n### Round {entry['round']}\n"
|
|
151
|
+
for i, (query, result) in enumerate(
|
|
152
|
+
zip(entry["queries"], entry["results"]), 1
|
|
153
|
+
):
|
|
154
|
+
user_content += f"**Query {i}:** {query}\n"
|
|
155
|
+
user_content += f"**Result:** {result}\n\n"
|
|
156
|
+
else:
|
|
157
|
+
user_content += "\n(This is the first round — no prior context.)\n"
|
|
158
|
+
|
|
159
|
+
messages.append({"role": "user", "content": user_content})
|
|
160
|
+
|
|
161
|
+
# Call Groq with JSON mode
|
|
162
|
+
response = self.client.chat_json(
|
|
163
|
+
messages=messages,
|
|
164
|
+
temperature=self.temperature,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Validate response structure
|
|
168
|
+
if "analysis" not in response:
|
|
169
|
+
response["analysis"] = ""
|
|
170
|
+
if "queries" not in response:
|
|
171
|
+
response["queries"] = []
|
|
172
|
+
if "sufficient_context" not in response:
|
|
173
|
+
response["sufficient_context"] = False
|
|
174
|
+
|
|
175
|
+
# Enforce query mode limits
|
|
176
|
+
if self.query_mode == "single_query" and len(response["queries"]) > 1:
|
|
177
|
+
response["queries"] = response["queries"][:1]
|
|
178
|
+
|
|
179
|
+
logger.info(
|
|
180
|
+
"Primary Agent — round analysis: %s queries, sufficient=%s",
|
|
181
|
+
len(response["queries"]),
|
|
182
|
+
response["sufficient_context"],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return response
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
translation_agent.py — The Translation Agent in CodexGraph's dual-agent architecture.
|
|
3
|
+
|
|
4
|
+
Per Section 3.2 ("write then translate" strategy), the Translation Agent:
|
|
5
|
+
- Receives the full graph schema + a natural language query from the Primary Agent
|
|
6
|
+
- Outputs a syntactically valid Cypher query
|
|
7
|
+
- Uses temperature=0.0 for deterministic translations
|
|
8
|
+
- The system prompt embeds the full schema so the model translates reliably
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from graph_n4j.llm.groq_client import GroqClient
|
|
14
|
+
from graph_n4j.schema import SCHEMA_DESCRIPTION
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ═══════════════════════════════════════════
|
|
20
|
+
# System prompt
|
|
21
|
+
# ═══════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
TRANSLATION_SYSTEM_PROMPT = f"""You are the Translation Agent in the CodexGraph system. Your ONLY job \
|
|
24
|
+
is to translate natural language queries about a code repository into valid Cypher \
|
|
25
|
+
queries for Neo4j.
|
|
26
|
+
|
|
27
|
+
{SCHEMA_DESCRIPTION}
|
|
28
|
+
|
|
29
|
+
## Rules
|
|
30
|
+
|
|
31
|
+
1. Output ONLY the Cypher query — no explanations, no markdown fences, no comments.
|
|
32
|
+
2. Use MATCH patterns that align with the schema above.
|
|
33
|
+
3. Always use parameterized property matching where possible.
|
|
34
|
+
4. Return useful properties in your RETURN clause (name, file_path, signature, code).
|
|
35
|
+
5. Use OPTIONAL MATCH when a relationship might not exist.
|
|
36
|
+
6. Limit results to 20 rows unless the query implies exhaustive retrieval.
|
|
37
|
+
7. For inheritance chains, use variable-length paths: `-[:INHERITS*]->`.
|
|
38
|
+
8. When searching by name, use case-sensitive exact match by default.
|
|
39
|
+
9. If the query mentions "all methods" or "all fields" of a class, include inherited ones via INHERITS edges.
|
|
40
|
+
10. Always include RETURN — never write a query that only MATCHes without returning.
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
Query: "Find the class named Shape and its methods"
|
|
45
|
+
MATCH (c:CLASS {{name: 'Shape'}})-[:HAS_METHOD]->(m:METHOD) RETURN c.name, c.file_path, c.signature, m.name, m.signature, m.code
|
|
46
|
+
|
|
47
|
+
Query: "Find all classes that inherit from Shape"
|
|
48
|
+
MATCH (c:CLASS)-[:INHERITS]->(base:CLASS {{name: 'Shape'}}) RETURN c.name, c.file_path, c.signature
|
|
49
|
+
|
|
50
|
+
Query: "Find the module containing the function calculate_area"
|
|
51
|
+
MATCH (m:MODULE)-[:CONTAINS]->(f:FUNCTION {{name: 'calculate_area'}}) RETURN m.name, m.file_path, f.signature
|
|
52
|
+
|
|
53
|
+
Query: "Find all global variables used by the function process_data"
|
|
54
|
+
MATCH (f:FUNCTION {{name: 'process_data'}})-[:USES]->(gv:GLOBAL_VARIABLE) RETURN gv.name, gv.file_path, gv.code
|
|
55
|
+
|
|
56
|
+
Query: "Find the complete inheritance chain for class Circle"
|
|
57
|
+
MATCH (c:CLASS {{name: 'Circle'}})-[:INHERITS*]->(parent:CLASS) RETURN parent.name, parent.file_path, parent.signature
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TranslationAgent:
|
|
62
|
+
"""
|
|
63
|
+
The Translation Agent: converts natural language graph queries into Cypher.
|
|
64
|
+
Uses temperature=0.0 for deterministic output.
|
|
65
|
+
|
|
66
|
+
B5: Supports a separate model_override so you can run a cheaper model
|
|
67
|
+
for translation while keeping the Primary Agent on the strong model.
|
|
68
|
+
Configure via GROQ_MODEL_TRANSLATION env var or pass model_override=.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, groq_client: GroqClient, model_override: str | None = None):
|
|
72
|
+
self.client = groq_client
|
|
73
|
+
self.model_override = model_override
|
|
74
|
+
|
|
75
|
+
def translate(self, nl_query: str) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Translate a natural language query into a Cypher query.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
nl_query: Natural language description of what to retrieve.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A Cypher query string (fences stripped).
|
|
84
|
+
"""
|
|
85
|
+
messages = [
|
|
86
|
+
{"role": "system", "content": TRANSLATION_SYSTEM_PROMPT},
|
|
87
|
+
{"role": "user", "content": nl_query},
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
cypher = self.client.chat_cypher(
|
|
91
|
+
messages=messages,
|
|
92
|
+
temperature=0.0,
|
|
93
|
+
max_tokens=2048,
|
|
94
|
+
model_override=self.model_override,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
logger.info("Translation Agent -- NL: '%s' -> Cypher: '%s'", nl_query[:80], cypher[:80])
|
|
98
|
+
return cypher
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# graph_n4j.apps — 5 real-world applications from Section 6 of the paper
|