aireview 0.2.1 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +98 -12
- data/config/.aireview.yml.example +8 -1
- data/lib/aireview/candidate_checker.rb +167 -0
- data/lib/aireview/cli.rb +7 -1
- data/lib/aireview/config.rb +27 -17
- data/lib/aireview/config_fallbacks.rb +112 -0
- data/lib/aireview/context_builder.rb +2 -2
- data/lib/aireview/dry_run_report.rb +23 -9
- data/lib/aireview/llm_failure.rb +90 -0
- data/lib/aireview/llm_router.rb +320 -0
- data/lib/aireview/prompts/critique.txt +7 -0
- data/lib/aireview/result_validation.rb +65 -0
- data/lib/aireview/review_pipeline.rb +43 -66
- data/lib/aireview/review_renderer.rb +32 -9
- data/lib/aireview/reviewer.rb +28 -181
- data/lib/aireview/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 394d2acbc7ed98d03e3a1f87e61fd9f07e1985b40ad47e4086fce47e5a18f5be
|
|
4
|
+
data.tar.gz: 85598cb2c2e81f95ff59e40bb5d2cef47d4813caf49f0ef318cf5931651adbf7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c45f78bc422def8e2392499ba1dc0e7f87e67183bad8b9e9a29b3b242512caf0a006511922ad32f6a237d6e971578861a86ebb5107790e86e4c3e34598d9c6e6
|
|
7
|
+
data.tar.gz: fb33990e5743821e8bef7900241ff6b5b7d7f575d3a22ac3eb645990c7050e792d46927c71d401ab659e200366e23acacb9d4f62eef6c74e70e78e7a76bc9111
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Fallback models per stage (`llm.<stage>.fallbacks`,
|
|
6
|
+
`LLM_<STAGE>_FALLBACK_MODEL`) and fallback API keys (`GEMINI_API_KEYS`).
|
|
7
|
+
An overloaded model or a timeout switches to the next model after one
|
|
8
|
+
short retry; an exhausted daily quota switches to the next key without
|
|
9
|
+
waiting; the last route keeps the full retry schedule.
|
|
10
|
+
- A time budget for the LLM part of the run (`llm.time_budget`,
|
|
11
|
+
`LLM_TIME_BUDGET`, 30 minutes by default) bounds pauses and request
|
|
12
|
+
timeouts; the built-in RubyLLM retries are disabled.
|
|
13
|
+
- The report names the fallback model a stage used; `--dry-run` prints the
|
|
14
|
+
chains and the number of keys; `--no-fallbacks` disables the reserves.
|
|
15
|
+
- Candidates are checked against the diff shown to the model: a file outside
|
|
16
|
+
the merge request drops the candidate, a line outside the shown hunks is
|
|
17
|
+
reset, a quote not found in the diff is marked in the report and reported
|
|
18
|
+
to Critique.
|
|
19
|
+
- Critique is skipped when Generate returns no candidates.
|
|
20
|
+
- Report limits are applied after selecting the findings to show, so a
|
|
21
|
+
finding that is never shown no longer displaces a useful one.
|
|
22
|
+
|
|
3
23
|
## 0.2.1
|
|
4
24
|
|
|
5
25
|
- An overloaded LLM (503) gets a fifth attempt: the pauses are now about
|
data/README.md
CHANGED
|
@@ -59,15 +59,19 @@ JIRA_URL=https://jira.company.com
|
|
|
59
59
|
JIRA_LOGIN=user
|
|
60
60
|
JIRA_PASSWORD=xxx
|
|
61
61
|
GEMINI_API_KEY=xxx
|
|
62
|
+
GEMINI_API_KEYS=xxx,yyy
|
|
62
63
|
LLM_PROVIDER=gemini
|
|
63
64
|
LLM_TEMPERATURE=0
|
|
64
|
-
LLM_TIMEOUT=
|
|
65
|
+
LLM_TIMEOUT=120
|
|
66
|
+
LLM_TIME_BUDGET=1800
|
|
65
67
|
LLM_HTTP_PROXY=http://127.0.0.1:8888
|
|
66
68
|
LLM_GENERATE_PROVIDER=gemini
|
|
67
69
|
LLM_GENERATE_MODEL=gemini-3.7-flash
|
|
70
|
+
LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash
|
|
68
71
|
LLM_GENERATE_TEMPERATURE=0.3
|
|
69
72
|
LLM_CRITIQUE_PROVIDER=gemini
|
|
70
73
|
LLM_CRITIQUE_MODEL=gemini-3.8-flash
|
|
74
|
+
LLM_CRITIQUE_FALLBACK_MODEL=gemini-3.7-flash
|
|
71
75
|
LLM_CRITIQUE_TEMPERATURE=0
|
|
72
76
|
REVIEW_LANGUAGE=ru
|
|
73
77
|
REVIEW_MODE=update
|
|
@@ -162,10 +166,14 @@ To run both stages locally, set `ollama` in both provider variables. The
|
|
|
162
166
|
address with `/v1` matches the
|
|
163
167
|
[Ollama configuration in RubyLLM](https://rubyllm.com/configuration/#provider-configuration).
|
|
164
168
|
`LLM_TIMEOUT` sets the timeout of every LLM request in seconds; for a slow
|
|
165
|
-
local model it can be raised
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
local model it can be raised, but not without limit: a hung request holds
|
|
170
|
+
the job for exactly that long while a fallback model sits idle. An
|
|
171
|
+
"overloaded" (503) answer from the provider or a timeout does not fail the
|
|
172
|
+
run right away: without a fallback model such a request gets up to five
|
|
173
|
+
attempts, the original one and four retries with pauses of about 2, 5, 5 and
|
|
174
|
+
5 minutes; with a fallback model, one short retry and a switch (see
|
|
175
|
+
"Fallback models and keys"). Only the failed stage is repeated, not the
|
|
176
|
+
whole run.
|
|
169
177
|
|
|
170
178
|
Project rules live in `.aireview.yml`. In YAML the `generate.model` and
|
|
171
179
|
`critique.model` settings are required for each stage and are not inherited
|
|
@@ -213,13 +221,19 @@ context:
|
|
|
213
221
|
llm:
|
|
214
222
|
provider: gemini
|
|
215
223
|
temperature: 0
|
|
216
|
-
timeout:
|
|
224
|
+
timeout: 120
|
|
225
|
+
time_budget: 1800
|
|
217
226
|
http_proxy: http://127.0.0.1:8888
|
|
218
227
|
max_prompt_chars: 400000
|
|
219
228
|
generate:
|
|
220
229
|
provider: gemini
|
|
221
230
|
model: gemini-3.7-flash
|
|
222
231
|
temperature: 0.3
|
|
232
|
+
fallbacks:
|
|
233
|
+
- gemini-3.8-flash
|
|
234
|
+
- provider: ollama
|
|
235
|
+
model: qwen2.5-coder:7b
|
|
236
|
+
max_prompt_chars: 20000
|
|
223
237
|
critique:
|
|
224
238
|
provider: ollama
|
|
225
239
|
model: qwen2.5-coder:7b
|
|
@@ -264,6 +278,77 @@ with an error instead of silently reviewing less. Raise the limits or extend
|
|
|
264
278
|
`ignore_paths`. `--dry-run` prints the sizes of every part and the coverage;
|
|
265
279
|
`--verbose` logs them during a real run.
|
|
266
280
|
|
|
281
|
+
### Fallback models and keys
|
|
282
|
+
|
|
283
|
+
The primary model can sit under load (503) for half a day, and a key can run
|
|
284
|
+
out of its daily quota. Neither is cured by waiting, so a stage can have a
|
|
285
|
+
fallback model and a provider can have a fallback key:
|
|
286
|
+
|
|
287
|
+
- `llm.generate.fallbacks` / `llm.critique.fallbacks` in YAML or
|
|
288
|
+
`LLM_GENERATE_FALLBACK_MODEL` / `LLM_CRITIQUE_FALLBACK_MODEL` in the
|
|
289
|
+
environment. One model or several separated by commas, in the order they
|
|
290
|
+
are tried. The provider goes before a slash (`ollama/qwen2.5-coder:7b`);
|
|
291
|
+
without it the stage provider is used. A fallback model can have its own
|
|
292
|
+
`max_prompt_chars`: when the assembled request does not fit, that model is
|
|
293
|
+
skipped, the context is not cut for it.
|
|
294
|
+
- `GEMINI_API_KEYS=key1,key2`: keys in order of preference; `GEMINI_API_KEY`
|
|
295
|
+
still works and means a single key. Google counts quotas per Google Cloud
|
|
296
|
+
project and per model, so a reserve key only makes sense from another
|
|
297
|
+
project. Use fallback credentials in compliance with the provider's quota
|
|
298
|
+
and billing terms: Google's
|
|
299
|
+
[API limitations](https://developers.google.com/terms#api_limitations)
|
|
300
|
+
forbid circumventing its limits regardless of the project's billing. Keys
|
|
301
|
+
live only in the environment, never in `.aireview.yml`.
|
|
302
|
+
|
|
303
|
+
What happens on which error:
|
|
304
|
+
|
|
305
|
+
| Error | Reaction |
|
|
306
|
+
|---|---|
|
|
307
|
+
| Daily quota (`quotaId` like `…PerDay…` in Google's answer) | No retries: the next key on the same model. The "key + model" pair is remembered until the end of the run so that Critique and the JSON repair do not hit it again. Out of keys: the next model with the first key. |
|
|
308
|
+
| Per-minute limit (429 with a "retry in N s" hint) | Retry after the hinted delay; one retry while there is somewhere to switch to, up to three on the last route. Then the next key, then the next model. |
|
|
309
|
+
| Overloaded (503) or timeout | One short retry (~30 s) and the next model with the same key. The last model in the chain gets the full 2/5/5/5 minute schedule. |
|
|
310
|
+
| Other API errors (schema, context length, auth) | The run fails right away: a fallback model would answer the same. |
|
|
311
|
+
|
|
312
|
+
All of this is bounded by a time budget for the LLM part of the run:
|
|
313
|
+
`llm.time_budget` / `LLM_TIME_BUDGET`, 1800 seconds by default. A pause that
|
|
314
|
+
does not fit into the remainder is skipped and the request timeout is capped
|
|
315
|
+
by the remainder; when the time is up, the run fails with an error listing
|
|
316
|
+
everything that was tried. Because of this keep `LLM_TIMEOUT` around 120–300
|
|
317
|
+
seconds: one hung request must not eat the whole budget.
|
|
318
|
+
|
|
319
|
+
The review key (see "A single comment per merge request") is computed from
|
|
320
|
+
the configured primary model, not from the one that answered: a review made
|
|
321
|
+
by a fallback model is not rewritten on the next push without changes in the
|
|
322
|
+
MR, and adding a fallback model to the config does not re-run the review on
|
|
323
|
+
every open MR. When a stage went to a fallback model, the report ends with a
|
|
324
|
+
`Fallback model used: critique — …` line. A key switch stays in the log only,
|
|
325
|
+
and key values never reach the log. `--dry-run` prints the model chains and
|
|
326
|
+
the number of keys per provider, `--no-fallbacks` leaves one model and one
|
|
327
|
+
key per stage.
|
|
328
|
+
|
|
329
|
+
### Checking that findings point at the diff
|
|
330
|
+
|
|
331
|
+
Between the passes every candidate is checked against the diff that actually
|
|
332
|
+
went to the model (after `ignore_paths`, secret scrubbing and the budget
|
|
333
|
+
cut). What is checked is the link to the code, not the bug itself:
|
|
334
|
+
|
|
335
|
+
- `file` is not among the changed files of the MR (renames included): the
|
|
336
|
+
candidate is dropped before Critique, there is nothing to check it
|
|
337
|
+
against. A file of the MR that was left out of the context by the budget
|
|
338
|
+
is a different case, see below.
|
|
339
|
+
- `line` falls into none of the shown hunks: the report shows the finding
|
|
340
|
+
without a line number.
|
|
341
|
+
- `quoted_code` is not found in the shown diff (compared ignoring
|
|
342
|
+
whitespace, on the new and on the old side): the candidate stays, but the
|
|
343
|
+
"Where" line of the report gets `(quote not found in the diff)`. Critique
|
|
344
|
+
cannot fix the quote, so the mark survives its keep.
|
|
345
|
+
- The file is shown partially, without a diff or not at all: the link cannot
|
|
346
|
+
be checked, and the coverage block of the report already says so.
|
|
347
|
+
|
|
348
|
+
Critique receives the result of the check in the candidate's `note` field
|
|
349
|
+
and decides keep/reject with it in mind. With `--no-critique` the check works
|
|
350
|
+
the same way, its marks just go straight to the report.
|
|
351
|
+
|
|
267
352
|
## Usage
|
|
268
353
|
|
|
269
354
|
```bash
|
|
@@ -284,6 +369,7 @@ bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project
|
|
|
284
369
|
- `--no-jira` turns off the Jira enrichment even when the MR carries an issue key.
|
|
285
370
|
- `--dry-run` prints the LLM settings, the context sizes and coverage, and the Generate prompt, plus the Critique prompt unless `--no-critique` is given.
|
|
286
371
|
- `--no-critique` skips the second pass and renders the Generate candidates directly.
|
|
372
|
+
- `--no-fallbacks` uses only the primary model and the first API key of each stage.
|
|
287
373
|
- `--review-mode MODE` sets the behaviour when a review has already been published: `update` or `once`.
|
|
288
374
|
- `--force` reviews again even when a review for this state of the MR is already published.
|
|
289
375
|
|
|
@@ -372,13 +458,13 @@ aireview:
|
|
|
372
458
|
- stuck_or_timeout_failure
|
|
373
459
|
```
|
|
374
460
|
|
|
375
|
-
`timeout: 45m` is
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
461
|
+
`timeout: 45m` is the job ceiling, not a guarantee: the LLM part of the run
|
|
462
|
+
is itself bounded by `LLM_TIME_BUDGET` (30 minutes by default, pauses and
|
|
463
|
+
fallback models included), the rest is reading the MR and Jira and
|
|
464
|
+
publishing.
|
|
379
465
|
|
|
380
|
-
Set secrets such as `GITLAB_TOKEN`, `GEMINI_API_KEY`
|
|
381
|
-
credentials in the GitLab CI/CD variables. If the job should publish the result
|
|
466
|
+
Set secrets such as `GITLAB_TOKEN`, `GEMINI_API_KEY` / `GEMINI_API_KEYS` and
|
|
467
|
+
the optional Jira credentials in the GitLab CI/CD variables. If the job should publish the result
|
|
382
468
|
back to the merge request, add `--post` to the review command.
|
|
383
469
|
|
|
384
470
|
For runners where the LLM provider is only reachable over WireGuard, bring up a
|
|
@@ -27,5 +27,12 @@ review_instructions: |
|
|
|
27
27
|
|
|
28
28
|
llm:
|
|
29
29
|
provider: gemini
|
|
30
|
-
model: gemini-3.7-flash
|
|
31
30
|
temperature: 0.2
|
|
31
|
+
generate:
|
|
32
|
+
model: gemini-3.7-flash
|
|
33
|
+
fallbacks:
|
|
34
|
+
- gemini-3.8-flash
|
|
35
|
+
critique:
|
|
36
|
+
model: gemini-3.8-flash
|
|
37
|
+
fallbacks:
|
|
38
|
+
- gemini-3.7-flash
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'logger'
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Aireview
|
|
6
|
+
# Механическая проверка привязки кандидата к коду по тому диффу, который
|
|
7
|
+
# реально ушёл в модель: файл, строка, цитата. Проверяется привязка, а не
|
|
8
|
+
# сам баг: ненайденная цитата — повод критику присмотреться, не
|
|
9
|
+
# доказательство выдумки. Файл, которого нет среди изменений MR, — другое
|
|
10
|
+
# дело: такого кандидата проверять нечем, он отбрасывается.
|
|
11
|
+
class CandidateChecker
|
|
12
|
+
HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
|
13
|
+
FILE_HEADER = %r{^diff --git a/(.+?) b/(.+)$}
|
|
14
|
+
NOTE_QUOTE_NOT_FOUND = 'quoted_code not found in the diff shown to the model'
|
|
15
|
+
NOTE_LINE_RESET = 'line was outside the shown hunks and has been reset to null'
|
|
16
|
+
NOTE_NOT_VERIFIED = 'file shown partially or without a diff, location not verified'
|
|
17
|
+
|
|
18
|
+
# Один файл из контекста: диапазоны строк нового файла по показанным
|
|
19
|
+
# хункам и нормализованный текст каждого хунка для поиска цитаты —
|
|
20
|
+
# отдельно новая сторона (контекст + добавленные строки) и старая
|
|
21
|
+
# (контекст + удалённые), чтобы многострочная цитата из одной версии
|
|
22
|
+
# файла находилась целиком. Хунки не склеиваются: между ними пропущенный
|
|
23
|
+
# код, и цитата через границу хунка — не цитата.
|
|
24
|
+
Section = Struct.new(:ranges, :hunks, :partial, keyword_init: true) do
|
|
25
|
+
def include?(quote)
|
|
26
|
+
hunks.any? { |hunk| hunk[:new_text].include?(quote) || hunk[:old_text].include?(quote) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(changes:, diff_text:, coverage:, logger: Logger.new($stderr))
|
|
31
|
+
@mr_paths = changes.flat_map { |change| change.values_at('old_path', 'new_path') }.compact.to_set
|
|
32
|
+
@coverage = coverage
|
|
33
|
+
@logger = logger
|
|
34
|
+
@sections = parse_sections(diff_text.to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Возвращает кандидатов с пометками: note для критика, quote_missing для
|
|
38
|
+
# отчёта, line сброшена в null, если не подтвердилась. Кандидаты с файлом
|
|
39
|
+
# не из MR отброшены.
|
|
40
|
+
def check(candidates)
|
|
41
|
+
candidates.filter_map do |candidate|
|
|
42
|
+
id = value(candidate, 'id')
|
|
43
|
+
path = resolve_path(value(candidate, 'file'))
|
|
44
|
+
unless path
|
|
45
|
+
@logger.warn("Candidate #{id} dropped: file #{value(candidate, 'file').inspect} is not in the merge request")
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
annotate(candidate.dup, id: id, section: @sections[path])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def annotate(candidate, id:, section:)
|
|
56
|
+
notes = []
|
|
57
|
+
if section.nil? || section.partial
|
|
58
|
+
@logger.debug("Candidate #{id}: file shown partially or not at all, location not verified")
|
|
59
|
+
notes << NOTE_NOT_VERIFIED
|
|
60
|
+
else
|
|
61
|
+
notes << check_line(candidate, id, section)
|
|
62
|
+
notes << check_quote(candidate, id, section)
|
|
63
|
+
end
|
|
64
|
+
notes.compact!
|
|
65
|
+
set(candidate, 'note', notes.join('; ')) unless notes.empty?
|
|
66
|
+
candidate
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_line(candidate, id, section)
|
|
70
|
+
line = value(candidate, 'line')
|
|
71
|
+
return nil if line.nil?
|
|
72
|
+
return nil if line.is_a?(Integer) && section.ranges.any? { |range| range.cover?(line) }
|
|
73
|
+
|
|
74
|
+
@logger.warn("Candidate #{id}: line #{line} is outside the shown hunks, reset to null")
|
|
75
|
+
set(candidate, 'line', nil)
|
|
76
|
+
NOTE_LINE_RESET
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def check_quote(candidate, id, section)
|
|
80
|
+
quote = normalize_text(value(candidate, 'quoted_code'))
|
|
81
|
+
return nil if quote.empty? || section.include?(quote)
|
|
82
|
+
|
|
83
|
+
@logger.warn("Candidate #{id}: quoted_code not found in the diff shown to the model")
|
|
84
|
+
set(candidate, 'quote_missing', true)
|
|
85
|
+
NOTE_QUOTE_NOT_FOUND
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Дифф уже собран под бюджет: у показанного частично файла хунков может
|
|
89
|
+
# не хватать, у файла без диффа хунков нет вовсе. Заголовки --- / +++
|
|
90
|
+
# бывают только между заголовком файла и первым @@; внутри хунка строка
|
|
91
|
+
# «+++ x» — это добавленный код «++ x».
|
|
92
|
+
def parse_sections(diff_text)
|
|
93
|
+
sections = {}
|
|
94
|
+
section = nil
|
|
95
|
+
hunk = nil
|
|
96
|
+
diff_text.each_line do |line|
|
|
97
|
+
if (header = line.match(FILE_HEADER))
|
|
98
|
+
section = Section.new(ranges: [], hunks: [], partial: false)
|
|
99
|
+
hunk = nil
|
|
100
|
+
header.captures.map(&:strip).each { |path| sections[path] = section }
|
|
101
|
+
elsif section && (hunk_header = line.match(HUNK_HEADER))
|
|
102
|
+
hunk = start_hunk(section, hunk_header)
|
|
103
|
+
elsif hunk
|
|
104
|
+
add_line(hunk, line)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
mark_partial(sections)
|
|
108
|
+
normalize_hunks(sections)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Хунк без новых строк (удаление, @@ -1 +0,0 @@) диапазона не даёт:
|
|
112
|
+
# строки 0 в новом файле нет.
|
|
113
|
+
def start_hunk(section, header)
|
|
114
|
+
start = header[1].to_i
|
|
115
|
+
length = header[2] ? header[2].to_i : 1
|
|
116
|
+
section.ranges << (start..(start + length - 1)) if length.positive?
|
|
117
|
+
hunk = {new_text: +'', old_text: +''}
|
|
118
|
+
section.hunks << hunk
|
|
119
|
+
hunk
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def add_line(hunk, line)
|
|
123
|
+
if line.start_with?(' ')
|
|
124
|
+
hunk[:new_text] << line[1..]
|
|
125
|
+
hunk[:old_text] << line[1..]
|
|
126
|
+
elsif line.start_with?('+')
|
|
127
|
+
hunk[:new_text] << line[1..]
|
|
128
|
+
elsif line.start_with?('-')
|
|
129
|
+
hunk[:old_text] << line[1..]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def normalize_hunks(sections)
|
|
134
|
+
sections.each_value do |section|
|
|
135
|
+
section.hunks.each { |hunk| hunk.transform_values! { |text| normalize_text(text) } }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def mark_partial(sections)
|
|
140
|
+
partial = @coverage.files_partial.map { |file| file[:path] } + @coverage.files_unavailable
|
|
141
|
+
partial += @coverage.hunks_skipped.map { |skipped| skipped[:path] }
|
|
142
|
+
partial.each { |path| sections[path]&.partial = true }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Сначала точное совпадение: каталоги a/ и b/ бывают настоящими. Префикс
|
|
146
|
+
# из заголовка диффа снимается только если точного пути в MR нет.
|
|
147
|
+
def resolve_path(path)
|
|
148
|
+
path = path.to_s.strip
|
|
149
|
+
return path if @mr_paths.include?(path)
|
|
150
|
+
|
|
151
|
+
stripped = path.sub(%r{\A(?:\./|[ab]/)}, '')
|
|
152
|
+
stripped if @mr_paths.include?(stripped)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def normalize_text(text)
|
|
156
|
+
text.to_s.gsub(/\s+/, ' ').strip
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def value(hash, key)
|
|
160
|
+
hash[key] || hash[key.to_sym]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def set(hash, key, new_value)
|
|
164
|
+
hash[hash.key?(key.to_sym) ? key.to_sym : key] = new_value
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
data/lib/aireview/cli.rb
CHANGED
|
@@ -68,7 +68,8 @@ module Aireview
|
|
|
68
68
|
generate_model: options[:generate_model],
|
|
69
69
|
critique_model: options[:critique_model],
|
|
70
70
|
generate_temperature: options[:generate_temperature],
|
|
71
|
-
critique_temperature: options[:critique_temperature]
|
|
71
|
+
critique_temperature: options[:critique_temperature],
|
|
72
|
+
no_fallbacks: options[:no_fallbacks] == true
|
|
72
73
|
)
|
|
73
74
|
config.require_llm_configuration!
|
|
74
75
|
config
|
|
@@ -280,6 +281,10 @@ module Aireview
|
|
|
280
281
|
parser.on('--no-critique', 'Skip critique pass and render Generate candidates directly') do
|
|
281
282
|
options[:no_critique] = true
|
|
282
283
|
end
|
|
284
|
+
|
|
285
|
+
parser.on('--no-fallbacks', 'Use only the primary model and the first API key of each stage') do
|
|
286
|
+
options[:no_fallbacks] = true
|
|
287
|
+
end
|
|
283
288
|
end
|
|
284
289
|
|
|
285
290
|
def add_publication_options(parser, options)
|
|
@@ -349,6 +354,7 @@ module Aireview
|
|
|
349
354
|
--no-jira Disable Jira enrichment
|
|
350
355
|
--dry-run Print prompts without LLM calls
|
|
351
356
|
--no-critique Skip second LLM critique pass
|
|
357
|
+
--no-fallbacks Use only the primary model and the first API key of each stage
|
|
352
358
|
--verbose Enable verbose logging
|
|
353
359
|
-h, --help Show help
|
|
354
360
|
HELP
|
data/lib/aireview/config.rb
CHANGED
|
@@ -5,11 +5,14 @@ require 'yaml'
|
|
|
5
5
|
require_relative 'errors'
|
|
6
6
|
require_relative 'utils'
|
|
7
7
|
require_relative 'config_limits'
|
|
8
|
+
require_relative 'config_fallbacks'
|
|
8
9
|
|
|
9
10
|
module Aireview
|
|
10
11
|
class Config
|
|
11
12
|
include ConfigLimits
|
|
13
|
+
include ConfigFallbacks
|
|
12
14
|
extend ConfigLimits::ClassMethods
|
|
15
|
+
extend ConfigFallbacks::ClassMethods
|
|
13
16
|
|
|
14
17
|
DEFAULT_SECRET_FILES = [
|
|
15
18
|
'.env',
|
|
@@ -39,7 +42,8 @@ module Aireview
|
|
|
39
42
|
'provider' => 'gemini',
|
|
40
43
|
'temperature' => 0,
|
|
41
44
|
'timeout' => 60,
|
|
42
|
-
'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS
|
|
45
|
+
'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS,
|
|
46
|
+
'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET
|
|
43
47
|
},
|
|
44
48
|
'context' => ConfigLimits::CONTEXT_DEFAULTS
|
|
45
49
|
}.freeze
|
|
@@ -104,6 +108,7 @@ module Aireview
|
|
|
104
108
|
.merge('llm' => llm_env_config(env))
|
|
105
109
|
.merge(context_env_config(env))
|
|
106
110
|
.merge(provider_key_env_config(env))
|
|
111
|
+
.merge(provider_keys_env_config(env))
|
|
107
112
|
.merge(generic_api_key_env_config(env))
|
|
108
113
|
end
|
|
109
114
|
|
|
@@ -120,6 +125,7 @@ module Aireview
|
|
|
120
125
|
'temperature' => parse_float(env['LLM_TEMPERATURE']),
|
|
121
126
|
'timeout' => parse_float(env['LLM_TIMEOUT']),
|
|
122
127
|
'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
|
|
128
|
+
'time_budget' => parse_integer(env['LLM_TIME_BUDGET'], 'LLM_TIME_BUDGET'),
|
|
123
129
|
'generate' => llm_stage_env_config(env, 'GENERATE'),
|
|
124
130
|
'critique' => llm_stage_env_config(env, 'CRITIQUE')
|
|
125
131
|
}.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
|
|
@@ -130,7 +136,8 @@ module Aireview
|
|
|
130
136
|
'provider' => env["LLM_#{stage}_PROVIDER"],
|
|
131
137
|
'model' => env["LLM_#{stage}_MODEL"],
|
|
132
138
|
'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
|
|
133
|
-
'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS")
|
|
139
|
+
'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS"),
|
|
140
|
+
'fallbacks' => fallback_models_env_config(env, stage)
|
|
134
141
|
}.compact
|
|
135
142
|
end
|
|
136
143
|
|
|
@@ -187,23 +194,25 @@ module Aireview
|
|
|
187
194
|
@logger = logger
|
|
188
195
|
end
|
|
189
196
|
|
|
197
|
+
# Переопределения из CLI меняют только основную модель стадии, запасные
|
|
198
|
+
# из конфига остаются; no_fallbacks оставляет одну модель и один ключ.
|
|
190
199
|
def with_overrides(
|
|
191
200
|
generate_model: nil,
|
|
192
201
|
critique_model: nil,
|
|
193
202
|
generate_temperature: nil,
|
|
194
|
-
critique_temperature: nil
|
|
203
|
+
critique_temperature: nil,
|
|
204
|
+
no_fallbacks: false
|
|
195
205
|
)
|
|
196
206
|
llm_config = {
|
|
197
207
|
'generate' => stage_overrides(model: generate_model, temperature: generate_temperature),
|
|
198
208
|
'critique' => stage_overrides(model: critique_model, temperature: critique_temperature)
|
|
199
209
|
}.reject { |_, overrides| overrides.empty? }
|
|
200
|
-
|
|
210
|
+
overrides = {}
|
|
211
|
+
overrides['llm'] = llm_config unless llm_config.empty?
|
|
212
|
+
overrides['fallbacks_disabled'] = true if no_fallbacks
|
|
213
|
+
return self if overrides.empty?
|
|
201
214
|
|
|
202
|
-
|
|
203
|
-
@data,
|
|
204
|
-
'llm' => llm_config
|
|
205
|
-
)
|
|
206
|
-
self.class.new(merged, config_path: config_path, logger: @logger)
|
|
215
|
+
self.class.new(self.class.deep_merge(@data, overrides), config_path: config_path, logger: @logger)
|
|
207
216
|
end
|
|
208
217
|
|
|
209
218
|
def gitlab_url
|
|
@@ -334,20 +343,21 @@ module Aireview
|
|
|
334
343
|
def require_llm_configuration!
|
|
335
344
|
require_models!
|
|
336
345
|
|
|
337
|
-
missing_keys =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}.filter_map do |stage, provider|
|
|
341
|
-
next if provider.to_s == 'ollama'
|
|
342
|
-
next if Aireview::Utils.present?(provider_api_key(provider))
|
|
343
|
-
|
|
344
|
-
"#{stage}: API key is required for provider #{provider.inspect}"
|
|
346
|
+
missing_keys = ConfigLimits::LLM_STAGES.flat_map do |stage|
|
|
347
|
+
providers = stage_chain(stage).map(&:provider).uniq.reject { |provider| provider_keys_present?(provider) }
|
|
348
|
+
providers.map { |provider| "#{stage}: API key is required for provider #{provider.inspect}" }
|
|
345
349
|
end
|
|
346
350
|
raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
|
|
347
351
|
end
|
|
348
352
|
|
|
349
353
|
private
|
|
350
354
|
|
|
355
|
+
def provider_keys_present?(provider)
|
|
356
|
+
return true if ConfigFallbacks::KEYLESS_PROVIDERS.include?(provider.to_s)
|
|
357
|
+
|
|
358
|
+
provider_api_keys(provider).any? { |key| Aireview::Utils.present?(key) }
|
|
359
|
+
end
|
|
360
|
+
|
|
351
361
|
def stage_overrides(model:, temperature:)
|
|
352
362
|
{
|
|
353
363
|
'model' => model,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aireview
|
|
4
|
+
# Резервы на случай, когда основная модель лежит или у ключа кончилась
|
|
5
|
+
# квота: цепочка моделей на стадию и список ключей на провайдера. Модели
|
|
6
|
+
# задаются в .aireview.yml или env, ключи — только в env.
|
|
7
|
+
module ConfigFallbacks
|
|
8
|
+
ModelCandidate = Struct.new(:provider, :model, :max_prompt_chars, keyword_init: true) do
|
|
9
|
+
def to_s
|
|
10
|
+
"#{provider}/#{model}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
KNOWN_PROVIDERS = %w[gemini ollama].freeze
|
|
15
|
+
KEYLESS_PROVIDERS = %w[ollama].freeze
|
|
16
|
+
PROVIDER_KEYS_MAPPING = {
|
|
17
|
+
'gemini' => 'GEMINI_API_KEYS'
|
|
18
|
+
}.freeze
|
|
19
|
+
DEFAULT_TIME_BUDGET = 1_800
|
|
20
|
+
|
|
21
|
+
module ClassMethods
|
|
22
|
+
# LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash (или список через запятую) —
|
|
23
|
+
# провайдер отделён слэшем, потому что теги Ollama содержат двоеточие.
|
|
24
|
+
def fallback_models_env_config(env, stage)
|
|
25
|
+
value = env["LLM_#{stage}_FALLBACK_MODEL"]
|
|
26
|
+
return nil if Aireview::Utils.blank?(value)
|
|
27
|
+
|
|
28
|
+
value.split(',').map(&:strip).reject(&:empty?).map { |item| parse_fallback_item(item) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse_fallback_item(item)
|
|
32
|
+
provider, model = item.split('/', 2)
|
|
33
|
+
return {'provider' => provider, 'model' => model} if model && KNOWN_PROVIDERS.include?(provider)
|
|
34
|
+
|
|
35
|
+
{'model' => item}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def provider_keys_env_config(env)
|
|
39
|
+
PROVIDER_KEYS_MAPPING.each_with_object({}) do |(provider, env_key), config|
|
|
40
|
+
keys = env[env_key].to_s.split(',').map(&:strip).reject(&:empty?)
|
|
41
|
+
config["#{provider}_api_keys"] = keys unless keys.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Первый элемент — основная модель стадии, дальше запасные в порядке
|
|
47
|
+
# обхода. Запасная без провайдера наследует провайдера стадии, без
|
|
48
|
+
# max_prompt_chars — лимит стадии.
|
|
49
|
+
def stage_chain(stage)
|
|
50
|
+
stage = stage.to_s
|
|
51
|
+
primary = ModelCandidate.new(
|
|
52
|
+
provider: public_send("#{stage}_provider"),
|
|
53
|
+
model: public_send("#{stage}_model"),
|
|
54
|
+
max_prompt_chars: max_prompt_chars(stage)
|
|
55
|
+
)
|
|
56
|
+
return [primary] if fallbacks_disabled?
|
|
57
|
+
|
|
58
|
+
[primary, *fallback_candidates(stage, primary)]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Ключи в порядке предпочтения; для провайдера без ключей — один nil,
|
|
62
|
+
# чтобы обход цепочки не зависел от провайдера.
|
|
63
|
+
def provider_api_keys(provider)
|
|
64
|
+
provider = provider.to_s
|
|
65
|
+
return [nil] if KEYLESS_PROVIDERS.include?(provider)
|
|
66
|
+
|
|
67
|
+
keys = Array(@data["#{provider}_api_keys"]).compact
|
|
68
|
+
keys = [provider_api_key(provider)].compact if keys.empty?
|
|
69
|
+
fallbacks_disabled? ? keys.first(1) : keys
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fallbacks_disabled?
|
|
73
|
+
@data['fallbacks_disabled'] == true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def fallback_names(stage)
|
|
77
|
+
stage_chain(stage).drop(1).map(&:to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Только число ключей на провайдера — для --dry-run; значения наружу не
|
|
81
|
+
# выходят.
|
|
82
|
+
def api_key_counts(stages)
|
|
83
|
+
providers = stages.flat_map { |stage| stage_chain(stage).map(&:provider) }.uniq
|
|
84
|
+
providers.reject { |provider| KEYLESS_PROVIDERS.include?(provider.to_s) }
|
|
85
|
+
.to_h { |provider| [provider, provider_api_keys(provider).size] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Общий потолок на все LLM-запросы прогона вместе с паузами между
|
|
89
|
+
# попытками: цепочка резервов не должна съедать всю CI-джобу.
|
|
90
|
+
def llm_time_budget
|
|
91
|
+
positive_integer!(dig('llm', 'time_budget') || DEFAULT_TIME_BUDGET, 'llm.time_budget')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def fallback_candidates(stage, primary)
|
|
97
|
+
Array(dig('llm', stage, 'fallbacks')).each_with_index.map do |item, index|
|
|
98
|
+
item = {'model' => item} if item.is_a?(String)
|
|
99
|
+
name = "llm.#{stage}.fallbacks[#{index}]"
|
|
100
|
+
raise ConfigError, "#{name} must be a model name or a hash with model" unless item.is_a?(Hash)
|
|
101
|
+
raise ConfigError, "#{name}.model is required" if Aireview::Utils.blank?(item['model'])
|
|
102
|
+
|
|
103
|
+
limit = item['max_prompt_chars']
|
|
104
|
+
ModelCandidate.new(
|
|
105
|
+
provider: (item['provider'] || primary.provider).to_s,
|
|
106
|
+
model: item['model'].to_s,
|
|
107
|
+
max_prompt_chars: limit.nil? ? primary.max_prompt_chars : positive_integer!(limit, "#{name}.max_prompt_chars")
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -23,7 +23,7 @@ module Aireview
|
|
|
23
23
|
|
|
24
24
|
# Контекст одного прогона: обе стадии получают одинаковые MR, Jira и дифф,
|
|
25
25
|
# усечённые один раз под самую тесную из стадий.
|
|
26
|
-
Context = Struct.new(:user_prompt, :coverage, :sizes, keyword_init: true)
|
|
26
|
+
Context = Struct.new(:user_prompt, :diff_text, :coverage, :sizes, keyword_init: true)
|
|
27
27
|
|
|
28
28
|
def initialize(config:, logger: Logger.new($stderr))
|
|
29
29
|
@config = config
|
|
@@ -49,7 +49,7 @@ module Aireview
|
|
|
49
49
|
|
|
50
50
|
sizes = context_sizes(fixed: fixed, packed: packed, budget: budget, diff_budget: diff_budget, critique: critique)
|
|
51
51
|
log_sizes(sizes)
|
|
52
|
-
Context.new(user_prompt: fixed + packed.text, coverage: coverage, sizes: sizes)
|
|
52
|
+
Context.new(user_prompt: fixed + packed.text, diff_text: packed.text, coverage: coverage, sizes: sizes)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def build_generate_prompt(context)
|