graph-n4j 0.1.0__tar.gz

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.
Files changed (34) hide show
  1. graph_n4j-0.1.0/LICENSE +21 -0
  2. graph_n4j-0.1.0/PKG-INFO +126 -0
  3. graph_n4j-0.1.0/README.md +88 -0
  4. graph_n4j-0.1.0/graph_n4j/__init__.py +36 -0
  5. graph_n4j-0.1.0/graph_n4j/agents/__init__.py +1 -0
  6. graph_n4j-0.1.0/graph_n4j/agents/pipeline.py +336 -0
  7. graph_n4j-0.1.0/graph_n4j/agents/primary_agent.py +185 -0
  8. graph_n4j-0.1.0/graph_n4j/agents/translation_agent.py +98 -0
  9. graph_n4j-0.1.0/graph_n4j/apps/__init__.py +1 -0
  10. graph_n4j-0.1.0/graph_n4j/apps/code_chat.py +61 -0
  11. graph_n4j-0.1.0/graph_n4j/apps/code_commenter.py +84 -0
  12. graph_n4j-0.1.0/graph_n4j/apps/code_debugger.py +80 -0
  13. graph_n4j-0.1.0/graph_n4j/apps/code_generator.py +76 -0
  14. graph_n4j-0.1.0/graph_n4j/apps/code_unittester.py +81 -0
  15. graph_n4j-0.1.0/graph_n4j/cache.py +434 -0
  16. graph_n4j-0.1.0/graph_n4j/cli.py +233 -0
  17. graph_n4j-0.1.0/graph_n4j/core.py +292 -0
  18. graph_n4j-0.1.0/graph_n4j/graph_db.py +366 -0
  19. graph_n4j-0.1.0/graph_n4j/indexer/__init__.py +1 -0
  20. graph_n4j-0.1.0/graph_n4j/indexer/edge_completion.py +495 -0
  21. graph_n4j-0.1.0/graph_n4j/indexer/shallow_index.py +468 -0
  22. graph_n4j-0.1.0/graph_n4j/llm/__init__.py +1 -0
  23. graph_n4j-0.1.0/graph_n4j/llm/groq_client.py +253 -0
  24. graph_n4j-0.1.0/graph_n4j/py.typed +1 -0
  25. graph_n4j-0.1.0/graph_n4j/repo_manager.py +386 -0
  26. graph_n4j-0.1.0/graph_n4j/schema.py +240 -0
  27. graph_n4j-0.1.0/graph_n4j.egg-info/PKG-INFO +126 -0
  28. graph_n4j-0.1.0/graph_n4j.egg-info/SOURCES.txt +32 -0
  29. graph_n4j-0.1.0/graph_n4j.egg-info/dependency_links.txt +1 -0
  30. graph_n4j-0.1.0/graph_n4j.egg-info/entry_points.txt +2 -0
  31. graph_n4j-0.1.0/graph_n4j.egg-info/requires.txt +11 -0
  32. graph_n4j-0.1.0/graph_n4j.egg-info/top_level.txt +1 -0
  33. graph_n4j-0.1.0/pyproject.toml +75 -0
  34. graph_n4j-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 CodexGraph Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: graph-n4j
3
+ Version: 0.1.0
4
+ Summary: Bridge LLM agents with code repositories via Neo4j graph databases. Based on the CodexGraph NAACL 2025 paper.
5
+ Author: Shubham Kumar Sahu
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ShubhamKumarSahu-svg/graph-n4j
8
+ Project-URL: Documentation, https://github.com/ShubhamKumarSahu-svg/graph-n4j#readme
9
+ Project-URL: Repository, https://github.com/ShubhamKumarSahu-svg/graph-n4j
10
+ Project-URL: Issues, https://github.com/ShubhamKumarSahu-svg/graph-n4j/issues
11
+ Keywords: neo4j,graph-database,code-analysis,llm,code-graph,static-analysis,code-intelligence,groq,cypher
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: Software Development :: Code Generators
22
+ Classifier: Topic :: Database
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: groq>=0.11.0
28
+ Requires-Dist: neo4j>=5.20.0
29
+ Requires-Dist: python-dotenv>=1.0.0
30
+ Requires-Dist: gitpython>=3.1.40
31
+ Requires-Dist: diskcache>=5.6.0
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
34
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
35
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
36
+ Requires-Dist: mypy>=1.8.0; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # graph-n4j
40
+
41
+ Bridge LLM agents with code repositories via Neo4j graph databases. Based on the CodexGraph NAACL 2025 paper.
42
+
43
+ `graph-n4j` builds a highly structured code graph (AST, inheritance, composition, calls) into Neo4j and uses a dual-agent LLM pipeline to let you chat with your entire codebase with perfect accuracy and drastically reduced token consumption.
44
+
45
+ ## Prerequisites
46
+
47
+ To use `graph-n4j`, you need two things running/available:
48
+
49
+ 1. **Neo4j Database**: You need a running Neo4j instance.
50
+ - *Quickest way (Docker)*: `docker run -d -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:latest`
51
+ - *Cloud option*: You can also use a free Neo4j AuraDB instance.
52
+
53
+ 2. **Groq API Key**: You need an API key from [Groq](https://console.groq.com/) to power the LLM agents.
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install graph-n4j
59
+ ```
60
+
61
+ ## Setup & Configuration
62
+
63
+ Configure the package using environment variables. You can set them in your terminal or create a `.env` file in the directory where you run your commands:
64
+
65
+ ```env
66
+ # Required: Groq API Key
67
+ GROQ_API_KEY=gsk_your_api_key_here
68
+
69
+ # Optional: Set the model (Defaults to llama-3.3-70b-versatile)
70
+ GROQ_MODEL=llama-3.3-70b-versatile
71
+ # B5 optimization: Use a cheaper/faster model for translation to save costs
72
+ GROQ_MODEL_TRANSLATION=llama-3.1-8b-instant
73
+
74
+ # Neo4j connection (These are the defaults)
75
+ NEO4J_URI=bolt://localhost:7687
76
+ NEO4J_USER=neo4j
77
+ NEO4J_PASSWORD=password
78
+ ```
79
+
80
+ ## CLI Usage
81
+
82
+ The package installs a handy command-line tool `graph-n4j`:
83
+
84
+ ```bash
85
+ # 1. Index a local Python repository
86
+ graph-n4j index /path/to/your/python/repo
87
+
88
+ # OR Clone and index a GitHub repository directly
89
+ graph-n4j index-github pallets/click
90
+
91
+ # 2. Chat with your indexed codebase!
92
+ graph-n4j ask "How does the command line parsing work in this repo?"
93
+
94
+ # Check graph statistics
95
+ graph-n4j stats
96
+
97
+ # List all indexed repos
98
+ graph-n4j repos
99
+ ```
100
+
101
+ ## Python API Usage
102
+
103
+ You can also use it programmatically in your own apps:
104
+
105
+ ```python
106
+ import os
107
+ from graph_n4j import GraphN4J
108
+
109
+ # Set up environment or let it load from .env
110
+ os.environ["GROQ_API_KEY"] = "gsk_your_api_key_here"
111
+
112
+ # Initialize the API
113
+ g = GraphN4J.from_env()
114
+
115
+ # Index a repository
116
+ g.index("./my-project")
117
+
118
+ # Chat with the codebase
119
+ result = g.ask("What does the UserManager class do and what does it inherit from?")
120
+ print(result.answer)
121
+ ```
122
+
123
+ ## Features
124
+ - **B1-B7 Token Efficiency**: Implements all 7 token optimizations from the CodexGraph paper (Skeleton caching, Context pruning, Early-stopping, etc.)
125
+ - **Multi-repo support**: Seamlessly index and query across multiple isolated repositories.
126
+ - **AST Parsing**: Highly accurate edge extraction for classes, functions, globals, fields, and inheritance.
@@ -0,0 +1,88 @@
1
+ # graph-n4j
2
+
3
+ Bridge LLM agents with code repositories via Neo4j graph databases. Based on the CodexGraph NAACL 2025 paper.
4
+
5
+ `graph-n4j` builds a highly structured code graph (AST, inheritance, composition, calls) into Neo4j and uses a dual-agent LLM pipeline to let you chat with your entire codebase with perfect accuracy and drastically reduced token consumption.
6
+
7
+ ## Prerequisites
8
+
9
+ To use `graph-n4j`, you need two things running/available:
10
+
11
+ 1. **Neo4j Database**: You need a running Neo4j instance.
12
+ - *Quickest way (Docker)*: `docker run -d -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:latest`
13
+ - *Cloud option*: You can also use a free Neo4j AuraDB instance.
14
+
15
+ 2. **Groq API Key**: You need an API key from [Groq](https://console.groq.com/) to power the LLM agents.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install graph-n4j
21
+ ```
22
+
23
+ ## Setup & Configuration
24
+
25
+ Configure the package using environment variables. You can set them in your terminal or create a `.env` file in the directory where you run your commands:
26
+
27
+ ```env
28
+ # Required: Groq API Key
29
+ GROQ_API_KEY=gsk_your_api_key_here
30
+
31
+ # Optional: Set the model (Defaults to llama-3.3-70b-versatile)
32
+ GROQ_MODEL=llama-3.3-70b-versatile
33
+ # B5 optimization: Use a cheaper/faster model for translation to save costs
34
+ GROQ_MODEL_TRANSLATION=llama-3.1-8b-instant
35
+
36
+ # Neo4j connection (These are the defaults)
37
+ NEO4J_URI=bolt://localhost:7687
38
+ NEO4J_USER=neo4j
39
+ NEO4J_PASSWORD=password
40
+ ```
41
+
42
+ ## CLI Usage
43
+
44
+ The package installs a handy command-line tool `graph-n4j`:
45
+
46
+ ```bash
47
+ # 1. Index a local Python repository
48
+ graph-n4j index /path/to/your/python/repo
49
+
50
+ # OR Clone and index a GitHub repository directly
51
+ graph-n4j index-github pallets/click
52
+
53
+ # 2. Chat with your indexed codebase!
54
+ graph-n4j ask "How does the command line parsing work in this repo?"
55
+
56
+ # Check graph statistics
57
+ graph-n4j stats
58
+
59
+ # List all indexed repos
60
+ graph-n4j repos
61
+ ```
62
+
63
+ ## Python API Usage
64
+
65
+ You can also use it programmatically in your own apps:
66
+
67
+ ```python
68
+ import os
69
+ from graph_n4j import GraphN4J
70
+
71
+ # Set up environment or let it load from .env
72
+ os.environ["GROQ_API_KEY"] = "gsk_your_api_key_here"
73
+
74
+ # Initialize the API
75
+ g = GraphN4J.from_env()
76
+
77
+ # Index a repository
78
+ g.index("./my-project")
79
+
80
+ # Chat with the codebase
81
+ result = g.ask("What does the UserManager class do and what does it inherit from?")
82
+ print(result.answer)
83
+ ```
84
+
85
+ ## Features
86
+ - **B1-B7 Token Efficiency**: Implements all 7 token optimizations from the CodexGraph paper (Skeleton caching, Context pruning, Early-stopping, etc.)
87
+ - **Multi-repo support**: Seamlessly index and query across multiple isolated repositories.
88
+ - **AST Parsing**: Highly accurate edge extraction for classes, functions, globals, fields, and inheritance.
@@ -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
+ }