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.
- diffrag/__init__.py +101 -0
- diffrag/__main__.py +3 -0
- diffrag/ai/__init__.py +15 -0
- diffrag/ai/base.py +106 -0
- diffrag/ai/ollama.py +144 -0
- diffrag/ai/openai_compat.py +193 -0
- diffrag/cli/__init__.py +5 -0
- diffrag/cli/main.py +440 -0
- diffrag/config/__init__.py +21 -0
- diffrag/config/settings.py +190 -0
- diffrag/data/config.example.toml +53 -0
- diffrag/diff/__init__.py +11 -0
- diffrag/diff/git.py +227 -0
- diffrag/diff/models.py +76 -0
- diffrag/exceptions.py +21 -0
- diffrag/indexing/__init__.py +5 -0
- diffrag/indexing/indexer.py +387 -0
- diffrag/indexing/splitter.py +296 -0
- diffrag/py.typed +0 -0
- diffrag/review/__init__.py +5 -0
- diffrag/review/prompts.py +75 -0
- diffrag/review/reviewer.py +322 -0
- diffrag-0.2.3.dist-info/METADATA +425 -0
- diffrag-0.2.3.dist-info/RECORD +26 -0
- diffrag-0.2.3.dist-info/WHEEL +4 -0
- diffrag-0.2.3.dist-info/entry_points.txt +3 -0
|
@@ -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
|