diffrag 0.2.3__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,75 @@
1
+ """Prompt templates used by the review pipeline."""
2
+
3
+ VERBOSITY_INSTRUCTIONS: dict[str, str] = {
4
+ "brief": (
5
+ "\nKeep your response under 200 words. "
6
+ "Report only the top 3 most critical issues — omit minor nits."
7
+ ),
8
+ "standard": "",
9
+ "detailed": (
10
+ "\nBe exhaustive. Include every issue found, including minor style and "
11
+ "documentation nits. For each issue provide a specific suggested fix."
12
+ ),
13
+ }
14
+
15
+ SUMMARIZE_DIFF = """\
16
+ You are an expert software engineer. Summarize the following git diff in one concise paragraph.
17
+ Focus on the logic changed, the specific functions or classes affected, and what problem is being
18
+ solved. Write in plain English without using markdown.
19
+
20
+ GIT DIFF:
21
+ {diff}
22
+ """
23
+
24
+ REVIEW_DIFF = """\
25
+ You are an expert software engineer performing a thorough code review.
26
+ Review the git diff below using the repository context provided for reference.
27
+
28
+ Check for all of the following and report only findings that are actually present:
29
+ - Breaking changes or API surface changes
30
+ - Violations of SOLID principles or clean code practices
31
+ - Code smells (duplicated logic, overly complex functions, magic values, etc.)
32
+ - Inconsistencies with the surrounding codebase style or conventions
33
+ - Outdated, missing, or incorrect documentation and comments
34
+ - Security concerns (injection risks, credential exposure, unsafe deserialization, etc.)
35
+ - Logic errors, off-by-one errors, or unhandled edge cases
36
+
37
+ REPOSITORY CONTEXT (retrieved):
38
+ {context}
39
+
40
+ GIT DIFF:
41
+ {diff}
42
+
43
+ Structure your response with clear sections. Be concise and specific — reference file paths and
44
+ line numbers where possible. If there are no issues in a category, omit that section entirely.
45
+ {verbosity_instruction}"""
46
+
47
+ CONSOLIDATE_REVIEW = """\
48
+ You are an expert software engineer. The individual file reviews below were produced separately.
49
+ Produce a single consolidated code review for the entire changeset.
50
+
51
+ Focus on:
52
+ 1. The most important bugs or risks across all files.
53
+ 2. Cross-cutting issues (patterns repeated across multiple files).
54
+ 3. A brief overall assessment (approved / needs work / rejected).
55
+
56
+ INDIVIDUAL FILE REVIEWS:
57
+ {reviews}
58
+
59
+ Write a concise, actionable consolidated review in markdown.{verbosity_instruction}
60
+ """
61
+
62
+ ASK_QUESTION = """\
63
+ You are an expert software engineer with knowledge of the repository and the current diff.
64
+ Answer the user's question based on the repository context and diff summary provided.
65
+ Be concise and accurate. Cite specific files or functions where relevant.
66
+
67
+ REPOSITORY CONTEXT (retrieved):
68
+ {context}
69
+
70
+ CURRENT DIFF SUMMARY:
71
+ {diff_summary}
72
+ {conversation_history}
73
+ QUESTION:
74
+ {question}{verbosity_instruction}
75
+ """
@@ -0,0 +1,322 @@
1
+ """CodeReviewer: orchestrates diff extraction, RAG retrieval, and LLM review."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+
7
+ import tiktoken
8
+
9
+ from ..ai.base import AIClient, ChatMessage
10
+ from ..diff.git import GitRepository
11
+ from ..diff.models import DiffResult, FileDiff
12
+ from ..indexing.indexer import RepoIndexer
13
+ from . import prompts
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ _TOKENIZER = tiktoken.get_encoding("o200k_base")
18
+
19
+
20
+ def _count_tokens(text: str) -> int:
21
+ return len(_TOKENIZER.encode(text))
22
+
23
+
24
+ @dataclass
25
+ class FileReview:
26
+ """Review result for a single file (or a hunk within a file).
27
+
28
+ Attributes:
29
+ identifier: File path, optionally with a hunk suffix like ``src/foo.py:hunk2``.
30
+ content: Markdown-formatted review text.
31
+ prompt_tokens: Total prompt tokens consumed for this review.
32
+ completion_tokens: Total completion tokens consumed for this review.
33
+ """
34
+
35
+ identifier: str
36
+ content: str
37
+ prompt_tokens: int = 0
38
+ completion_tokens: int = 0
39
+
40
+
41
+ @dataclass
42
+ class CodeReview:
43
+ """Complete code review result for a changeset.
44
+
45
+ Attributes:
46
+ base_ref: Base git ref the diff was computed from.
47
+ head_ref: Head git ref that was reviewed.
48
+ file_reviews: Per-file (or per-hunk) review objects.
49
+ summary: Final consolidated review in markdown.
50
+ total_prompt_tokens: Aggregate prompt tokens across all LLM calls.
51
+ total_completion_tokens: Aggregate completion tokens across all LLM calls.
52
+ """
53
+
54
+ base_ref: str
55
+ head_ref: str
56
+ file_reviews: list[FileReview]
57
+ summary: str
58
+ total_prompt_tokens: int = field(default=0, init=False)
59
+ total_completion_tokens: int = field(default=0, init=False)
60
+
61
+ def __post_init__(self) -> None:
62
+ self.total_prompt_tokens = sum(r.prompt_tokens for r in self.file_reviews)
63
+ self.total_completion_tokens = sum(r.completion_tokens for r in self.file_reviews)
64
+
65
+
66
+ class CodeReviewer:
67
+ """Orchestrates the full code review pipeline.
68
+
69
+ For each changed file:
70
+ 1. The diff is summarised by the LLM to create a targeted RAG query.
71
+ 2. The RAG index is queried for relevant context chunks.
72
+ 3. The LLM reviews the diff in light of the retrieved context.
73
+
74
+ Large diffs are automatically split into per-hunk reviews which are
75
+ then consolidated into a single final summary.
76
+
77
+ Args:
78
+ ai_client: Language model client for completion requests.
79
+ indexer: Vector index for context retrieval.
80
+ similarity_top_k: Number of context chunks retrieved per diff chunk.
81
+ max_prompt_tokens: Approximate token budget per diff before hunk-splitting.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ ai_client: AIClient,
87
+ indexer: RepoIndexer,
88
+ similarity_top_k: int = 5,
89
+ max_prompt_tokens: int = 6000,
90
+ verbosity: str = "standard",
91
+ ) -> None:
92
+ self._ai = ai_client
93
+ self._indexer = indexer
94
+ self._top_k = similarity_top_k
95
+ self._max_prompt_tokens = max_prompt_tokens
96
+ self._verbosity = verbosity
97
+
98
+ # ------------------------------------------------------------------
99
+ # Public API
100
+ # ------------------------------------------------------------------
101
+
102
+ async def review(
103
+ self,
104
+ repo: GitRepository,
105
+ base: str,
106
+ head: str = "HEAD",
107
+ *,
108
+ rebuild_index: bool = True,
109
+ on_progress: Callable[[str], None] | None = None,
110
+ ) -> CodeReview:
111
+ """Run a complete code review for changes between *base* and *head*.
112
+
113
+ Args:
114
+ repo: Repository to analyse.
115
+ base: Base git ref (branch, tag, or commit).
116
+ head: Head git ref. Defaults to ``"HEAD"``.
117
+ rebuild_index: When ``True`` the vector index is rebuilt before
118
+ reviewing. Pass ``False`` to reuse an existing index.
119
+ on_progress: Optional callback invoked with a human-readable status
120
+ string at each major step. Messages ending in ``"..."`` indicate
121
+ an ongoing operation; others mark a completed milestone.
122
+
123
+ Returns:
124
+ A :class:`CodeReview` with per-file results and a consolidated summary.
125
+ """
126
+ _p = on_progress or (lambda _: None)
127
+
128
+ _p("Fetching diff...")
129
+ diff_result = repo.get_diff(base, head)
130
+
131
+ if diff_result.is_empty:
132
+ log.info("No changes between %s and %s", base, head)
133
+ _p("No changes detected")
134
+ return CodeReview(
135
+ base_ref=base,
136
+ head_ref=head,
137
+ file_reviews=[],
138
+ summary="No changes detected between the two refs.",
139
+ )
140
+
141
+ n = len(diff_result.file_diffs)
142
+ _p(f"Diff: {n} file(s) changed")
143
+
144
+ commit_hash = repo.head_commit()
145
+
146
+ if rebuild_index:
147
+ if self._indexer.is_stale(repo.path, commit_hash):
148
+ await self._indexer.build_index(
149
+ repo.path, on_progress=on_progress, commit_hash=commit_hash
150
+ )
151
+ else:
152
+ log.info("Index is up to date (commit %s)", commit_hash[:8])
153
+ _p("Index is up to date — skipping rebuild")
154
+ elif self._indexer.document_count == 0:
155
+ log.warning("Index is empty and rebuild_index=False — building index anyway")
156
+ await self._indexer.build_index(
157
+ repo.path, on_progress=on_progress, commit_hash=commit_hash
158
+ )
159
+
160
+ file_reviews: list[FileReview] = []
161
+ for i, file_diff in enumerate(diff_result.file_diffs, 1):
162
+ _p(f"Reviewing {file_diff.path} ({i}/{n})...")
163
+ log.info("Reviewing %s (%s)", file_diff.path, file_diff.status)
164
+ reviews = await self._review_file(file_diff)
165
+ file_reviews.extend(reviews)
166
+ _p(f"Reviewed {file_diff.path} ({i}/{n})")
167
+
168
+ if n > 1:
169
+ _p("Consolidating review...")
170
+ summary = await self._consolidate(file_reviews)
171
+ return CodeReview(base_ref=base, head_ref=head, file_reviews=file_reviews, summary=summary)
172
+
173
+ async def ask(
174
+ self,
175
+ question: str,
176
+ diff_result: DiffResult,
177
+ history: list[tuple[str, str]] | None = None,
178
+ ) -> str:
179
+ """Answer a free-form question about the diff and codebase.
180
+
181
+ Args:
182
+ question: The user's question in plain English.
183
+ diff_result: Pre-computed diff to use as context.
184
+ history: Prior Q&A pairs from this session, oldest first. When
185
+ provided, they are injected into the prompt so the model can
186
+ refer to earlier exchanges.
187
+
188
+ Returns:
189
+ A markdown-formatted answer string.
190
+ """
191
+ diff_text = "\n\n".join(f.raw_diff for f in diff_result.file_diffs)
192
+ query = f"{question}\n{diff_text[:500]}"
193
+
194
+ context_chunks = await self._indexer.query(query, top_k=self._top_k)
195
+ context = "\n\n".join(c.content for c in context_chunks)
196
+
197
+ if history:
198
+ lines = ["PREVIOUS EXCHANGES:"]
199
+ for q, a in history:
200
+ lines += [f"User: {q}", f"Assistant: {a}", ""]
201
+ history_block = "\n".join(lines) + "\n"
202
+ else:
203
+ history_block = ""
204
+
205
+ vi = prompts.VERBOSITY_INSTRUCTIONS.get(self._verbosity, "")
206
+ prompt_text = prompts.ASK_QUESTION.format(
207
+ context=context,
208
+ diff_summary=diff_text[:1500],
209
+ conversation_history=history_block,
210
+ question=question,
211
+ verbosity_instruction=vi,
212
+ )
213
+ response = await self._ai.complete([ChatMessage(role="user", content=prompt_text)])
214
+ return response.content
215
+
216
+ # ------------------------------------------------------------------
217
+ # Internal helpers
218
+ # ------------------------------------------------------------------
219
+
220
+ async def _review_file(self, file_diff: FileDiff) -> list[FileReview]:
221
+ """Review a single file, splitting into hunks if the diff is large.
222
+
223
+ Args:
224
+ file_diff: Parsed file diff to review.
225
+
226
+ Returns:
227
+ One or more :class:`FileReview` objects.
228
+ """
229
+ token_count = _count_tokens(file_diff.raw_diff)
230
+ if token_count <= self._max_prompt_tokens:
231
+ return [await self._review_chunk(file_diff.path, file_diff.raw_diff)]
232
+
233
+ log.info(
234
+ "Diff for %s is ~%d tokens — splitting into %d hunks",
235
+ file_diff.path,
236
+ token_count,
237
+ len(file_diff.hunks),
238
+ )
239
+ reviews: list[FileReview] = []
240
+ for idx, hunk in enumerate(file_diff.hunks):
241
+ hunk_text = f"{hunk.header}\n{hunk.content}"
242
+ if _count_tokens(hunk_text) > self._max_prompt_tokens:
243
+ log.warning(
244
+ "Hunk %d of %s exceeds token budget — skipping",
245
+ idx,
246
+ file_diff.path,
247
+ )
248
+ continue
249
+ review = await self._review_chunk(f"{file_diff.path}:hunk{idx}", hunk_text)
250
+ reviews.append(review)
251
+ return reviews
252
+
253
+ async def _review_chunk(self, identifier: str, diff_text: str) -> FileReview:
254
+ """Review a single diff chunk (file or hunk) using RAG context.
255
+
256
+ Args:
257
+ identifier: Human-readable label for logging and reporting.
258
+ diff_text: Raw diff text to review.
259
+
260
+ Returns:
261
+ A :class:`FileReview` for this chunk.
262
+ """
263
+ # Step 1: summarise the diff to form a targeted query
264
+ summary_resp = await self._ai.complete(
265
+ [ChatMessage(role="user", content=prompts.SUMMARIZE_DIFF.format(diff=diff_text))]
266
+ )
267
+ log.debug("Diff summary for %s: %s", identifier, summary_resp.content[:120])
268
+
269
+ # Step 2: retrieve relevant context from the index
270
+ context_chunks = await self._indexer.query(summary_resp.content, top_k=self._top_k)
271
+ context = "\n\n".join(
272
+ f"# {c.file_path}"
273
+ + (f" — {c.node_type} `{c.symbol_name}`" if c.symbol_name else "")
274
+ + f"\n{c.content}"
275
+ for c in context_chunks
276
+ )
277
+
278
+ # Step 3: full review with retrieved context
279
+ vi = prompts.VERBOSITY_INSTRUCTIONS.get(self._verbosity, "")
280
+ review_resp = await self._ai.complete(
281
+ [
282
+ ChatMessage(
283
+ role="user",
284
+ content=prompts.REVIEW_DIFF.format(
285
+ context=context, diff=diff_text, verbosity_instruction=vi
286
+ ),
287
+ )
288
+ ]
289
+ )
290
+
291
+ return FileReview(
292
+ identifier=identifier,
293
+ content=review_resp.content,
294
+ prompt_tokens=summary_resp.prompt_tokens + review_resp.prompt_tokens,
295
+ completion_tokens=summary_resp.completion_tokens + review_resp.completion_tokens,
296
+ )
297
+
298
+ async def _consolidate(self, file_reviews: list[FileReview]) -> str:
299
+ """Consolidate multiple file reviews into a single summary.
300
+
301
+ Args:
302
+ file_reviews: All per-file or per-hunk reviews produced so far.
303
+
304
+ Returns:
305
+ Consolidated markdown review string.
306
+ """
307
+ if len(file_reviews) == 1:
308
+ return file_reviews[0].content
309
+
310
+ reviews_text = "\n\n".join(f"### {r.identifier}\n{r.content}" for r in file_reviews)
311
+ vi = prompts.VERBOSITY_INSTRUCTIONS.get(self._verbosity, "")
312
+ response = await self._ai.complete(
313
+ [
314
+ ChatMessage(
315
+ role="user",
316
+ content=prompts.CONSOLIDATE_REVIEW.format(
317
+ reviews=reviews_text, verbosity_instruction=vi
318
+ ),
319
+ )
320
+ ]
321
+ )
322
+ return response.content