aireview 0.2.1 → 2.0.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 +66 -0
- data/README.md +353 -18
- data/config/.aireview.yml.example +8 -1
- data/config/defaults.yml +47 -0
- data/lib/aireview/candidate_checker.rb +169 -0
- data/lib/aireview/cli.rb +53 -13
- data/lib/aireview/config.rb +74 -179
- data/lib/aireview/config_fallbacks.rb +123 -0
- data/lib/aireview/config_layers.rb +110 -0
- data/lib/aireview/config_limits.rb +10 -35
- data/lib/aireview/config_loader.rb +240 -0
- data/lib/aireview/context_budget.rb +22 -20
- data/lib/aireview/context_builder.rb +20 -19
- data/lib/aireview/diff_fetcher.rb +12 -11
- data/lib/aireview/dry_run_report.rb +49 -10
- data/lib/aireview/errors.rb +8 -0
- data/lib/aireview/gitlab_client.rb +10 -9
- data/lib/aireview/llm_client.rb +113 -0
- data/lib/aireview/llm_failure.rb +107 -0
- data/lib/aireview/llm_router.rb +477 -0
- data/lib/aireview/model_candidate.rb +28 -0
- data/lib/aireview/model_checker.rb +148 -0
- data/lib/aireview/model_pool.rb +224 -0
- data/lib/aireview/model_state.rb +82 -0
- data/lib/aireview/prompts/critique.txt +7 -0
- data/lib/aireview/publisher.rb +6 -6
- data/lib/aireview/result_parser.rb +98 -0
- data/lib/aireview/review_marker.rb +16 -28
- data/lib/aireview/review_pipeline.rb +119 -140
- data/lib/aireview/review_renderer.rb +44 -13
- data/lib/aireview/reviewer.rb +42 -267
- data/lib/aireview/stage_chains.rb +113 -0
- data/lib/aireview/stages.rb +7 -0
- data/lib/aireview/utils.rb +29 -0
- data/lib/aireview/version.rb +1 -1
- data/lib/aireview.rb +1 -0
- metadata +23 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'logger'
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Aireview
|
|
6
|
+
# A mechanical check that a candidate points at the code, against the diff
|
|
7
|
+
# the model actually saw: file, line, quote. The anchoring is checked, not
|
|
8
|
+
# the bug itself: a quote that is not found is a reason for Critique to
|
|
9
|
+
# look closer, not proof of a fabrication. A file that is not among the MR
|
|
10
|
+
# changes is a different matter: there is nothing to check such a
|
|
11
|
+
# candidate against, it is dropped.
|
|
12
|
+
class CandidateChecker
|
|
13
|
+
HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
|
14
|
+
FILE_HEADER = %r{^diff --git a/(.+?) b/(.+)$}
|
|
15
|
+
NOTE_QUOTE_NOT_FOUND = 'quoted_code not found in the diff shown to the model'
|
|
16
|
+
NOTE_LINE_RESET = 'line was outside the shown hunks and has been reset to null'
|
|
17
|
+
NOTE_NOT_VERIFIED = 'file shown partially or without a diff, location not verified'
|
|
18
|
+
|
|
19
|
+
# One file of the context: the new-file line ranges of the shown hunks
|
|
20
|
+
# and the normalized text of every hunk for the quote search — the new
|
|
21
|
+
# side (context + added lines) and the old side (context + removed
|
|
22
|
+
# lines) separately, so that a multi-line quote from one version of the
|
|
23
|
+
# file is found whole. Hunks are not glued together: there is skipped
|
|
24
|
+
# code between them, and a quote across a hunk boundary is not a quote.
|
|
25
|
+
Section = Struct.new(:ranges, :hunks, :partial, keyword_init: true) do
|
|
26
|
+
def include?(quote)
|
|
27
|
+
hunks.any? { |hunk| hunk[:new_text].include?(quote) || hunk[:old_text].include?(quote) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(changes:, diff_text:, coverage:, logger: Logger.new($stderr))
|
|
32
|
+
@mr_paths = changes.flat_map { |change| change.values_at('old_path', 'new_path') }.compact.to_set
|
|
33
|
+
@coverage = coverage
|
|
34
|
+
@logger = logger
|
|
35
|
+
@sections = parse_sections(diff_text.to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the candidates with marks: a note for Critique, quote_missing
|
|
39
|
+
# for the report, line reset to null when it was not confirmed.
|
|
40
|
+
# Candidates with a file outside the MR are dropped.
|
|
41
|
+
def check(candidates)
|
|
42
|
+
candidates.filter_map do |candidate|
|
|
43
|
+
id = value(candidate, 'id')
|
|
44
|
+
path = resolve_path(value(candidate, 'file'))
|
|
45
|
+
unless path
|
|
46
|
+
@logger.warn("Candidate #{id} dropped: file #{value(candidate, 'file').inspect} is not in the merge request")
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
annotate(candidate.dup, id: id, section: @sections[path])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def annotate(candidate, id:, section:)
|
|
57
|
+
notes = []
|
|
58
|
+
if section.nil? || section.partial
|
|
59
|
+
@logger.debug("Candidate #{id}: file shown partially or not at all, location not verified")
|
|
60
|
+
notes << NOTE_NOT_VERIFIED
|
|
61
|
+
else
|
|
62
|
+
notes << check_line(candidate, id, section)
|
|
63
|
+
notes << check_quote(candidate, id, section)
|
|
64
|
+
end
|
|
65
|
+
notes.compact!
|
|
66
|
+
set(candidate, 'note', notes.join('; ')) unless notes.empty?
|
|
67
|
+
candidate
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_line(candidate, id, section)
|
|
71
|
+
line = value(candidate, 'line')
|
|
72
|
+
return nil if line.nil?
|
|
73
|
+
return nil if line.is_a?(Integer) && section.ranges.any? { |range| range.cover?(line) }
|
|
74
|
+
|
|
75
|
+
@logger.warn("Candidate #{id}: line #{line} is outside the shown hunks, reset to null")
|
|
76
|
+
set(candidate, 'line', nil)
|
|
77
|
+
NOTE_LINE_RESET
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def check_quote(candidate, id, section)
|
|
81
|
+
quote = normalize_text(value(candidate, 'quoted_code'))
|
|
82
|
+
return nil if quote.empty? || section.include?(quote)
|
|
83
|
+
|
|
84
|
+
@logger.warn("Candidate #{id}: quoted_code not found in the diff shown to the model")
|
|
85
|
+
set(candidate, 'quote_missing', true)
|
|
86
|
+
NOTE_QUOTE_NOT_FOUND
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The diff is already packed to the budget: a partially shown file may
|
|
90
|
+
# lack hunks, a file without a diff has none at all. The --- / +++
|
|
91
|
+
# headers occur only between the file header and the first @@; inside a
|
|
92
|
+
# hunk a "+++ x" line is the added code "++ x".
|
|
93
|
+
def parse_sections(diff_text)
|
|
94
|
+
sections = {}
|
|
95
|
+
section = nil
|
|
96
|
+
hunk = nil
|
|
97
|
+
diff_text.each_line do |line|
|
|
98
|
+
if (header = line.match(FILE_HEADER))
|
|
99
|
+
section = Section.new(ranges: [], hunks: [], partial: false)
|
|
100
|
+
hunk = nil
|
|
101
|
+
header.captures.map(&:strip).each { |path| sections[path] = section }
|
|
102
|
+
elsif section && (hunk_header = line.match(HUNK_HEADER))
|
|
103
|
+
hunk = start_hunk(section, hunk_header)
|
|
104
|
+
elsif hunk
|
|
105
|
+
add_line(hunk, line)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
mark_partial(sections)
|
|
109
|
+
normalize_hunks(sections)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A hunk without new lines (a deletion, @@ -1 +0,0 @@) gives no range:
|
|
113
|
+
# there is no line 0 in the new file.
|
|
114
|
+
def start_hunk(section, header)
|
|
115
|
+
start = header[1].to_i
|
|
116
|
+
length = header[2] ? header[2].to_i : 1
|
|
117
|
+
section.ranges << (start..(start + length - 1)) if length.positive?
|
|
118
|
+
hunk = {new_text: +'', old_text: +''}
|
|
119
|
+
section.hunks << hunk
|
|
120
|
+
hunk
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_line(hunk, line)
|
|
124
|
+
if line.start_with?(' ')
|
|
125
|
+
hunk[:new_text] << line[1..]
|
|
126
|
+
hunk[:old_text] << line[1..]
|
|
127
|
+
elsif line.start_with?('+')
|
|
128
|
+
hunk[:new_text] << line[1..]
|
|
129
|
+
elsif line.start_with?('-')
|
|
130
|
+
hunk[:old_text] << line[1..]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def normalize_hunks(sections)
|
|
135
|
+
sections.each_value do |section|
|
|
136
|
+
section.hunks.each { |hunk| hunk.transform_values! { |text| normalize_text(text) } }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def mark_partial(sections)
|
|
141
|
+
partial = @coverage.files_partial.map { |file| file[:path] } + @coverage.files_unavailable
|
|
142
|
+
partial += @coverage.hunks_skipped.map { |skipped| skipped[:path] }
|
|
143
|
+
partial.each { |path| sections[path]&.partial = true }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# An exact match first: a/ and b/ directories can be real. The prefix
|
|
147
|
+
# from the diff header is stripped only when the exact path is not in
|
|
148
|
+
# the MR.
|
|
149
|
+
def resolve_path(path)
|
|
150
|
+
path = path.to_s.strip
|
|
151
|
+
return path if @mr_paths.include?(path)
|
|
152
|
+
|
|
153
|
+
stripped = path.sub(%r{\A(?:\./|[ab]/)}, '')
|
|
154
|
+
stripped if @mr_paths.include?(stripped)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def normalize_text(text)
|
|
158
|
+
text.to_s.gsub(/\s+/, ' ').strip
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def value(hash, key)
|
|
162
|
+
hash[key] || hash[key.to_sym]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def set(hash, key, new_value)
|
|
166
|
+
hash[hash.key?(key.to_sym) ? key.to_sym : key] = new_value
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
data/lib/aireview/cli.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'securerandom'
|
|
3
|
+
require_relative 'model_checker'
|
|
3
4
|
|
|
4
5
|
module Aireview
|
|
5
|
-
class CLI
|
|
6
|
+
class CLI # rubocop:disable Metrics/ClassLength
|
|
6
7
|
def self.start(argv, out: $stdout, err: $stderr, env: ENV)
|
|
7
8
|
new(argv, out: out, err: err, env: env).start
|
|
8
9
|
end
|
|
@@ -24,6 +25,8 @@ module Aireview
|
|
|
24
25
|
case command
|
|
25
26
|
when 'review'
|
|
26
27
|
run_review(@argv)
|
|
28
|
+
when 'models'
|
|
29
|
+
run_models(@argv)
|
|
27
30
|
when '--help', '-h', nil
|
|
28
31
|
@out.puts(help)
|
|
29
32
|
0
|
|
@@ -41,6 +44,33 @@ module Aireview
|
|
|
41
44
|
|
|
42
45
|
private
|
|
43
46
|
|
|
47
|
+
# aireview models check [--config PATH] [--verbose]: a probe request with
|
|
48
|
+
# the production schemas to every model of the chains; see ModelChecker.
|
|
49
|
+
def run_models(argv)
|
|
50
|
+
options = parse_models_options(argv)
|
|
51
|
+
raise ParseError, "Usage: aireview models check [options] (got: #{argv.join(' ')})" unless argv == ['check']
|
|
52
|
+
|
|
53
|
+
@logger.level = Logger::DEBUG if options[:verbose]
|
|
54
|
+
config = Config.load(config_path: options[:config], cwd: Dir.pwd, env: @env, logger: @logger)
|
|
55
|
+
config.warnings.each { |warning| @logger.warn(warning) }
|
|
56
|
+
ModelChecker.new(config: config, out: @out, logger: @logger, strict: options[:strict] == true).run
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_models_options(argv)
|
|
60
|
+
options = {}
|
|
61
|
+
OptionParser.new do |parser|
|
|
62
|
+
parser.banner = 'Usage: aireview models check [options]'
|
|
63
|
+
parser.on('--config PATH', 'Path to .aireview.yml') { |value| options[:config] = value }
|
|
64
|
+
parser.on('--strict', 'Treat a skipped model (unreachable Ollama) as a failure') { options[:strict] = true }
|
|
65
|
+
parser.on('--verbose', 'Enable debug logging') { options[:verbose] = true }
|
|
66
|
+
parser.on('-h', '--help', 'Show help') do
|
|
67
|
+
@out.puts(parser)
|
|
68
|
+
raise Aireview::HelpRequested
|
|
69
|
+
end
|
|
70
|
+
end.parse!(argv)
|
|
71
|
+
options
|
|
72
|
+
end
|
|
73
|
+
|
|
44
74
|
def run_review(argv)
|
|
45
75
|
options, mr_url = review_options_and_url(argv)
|
|
46
76
|
parser_result = MrParser.parse(mr_url)
|
|
@@ -68,9 +98,11 @@ module Aireview
|
|
|
68
98
|
generate_model: options[:generate_model],
|
|
69
99
|
critique_model: options[:critique_model],
|
|
70
100
|
generate_temperature: options[:generate_temperature],
|
|
71
|
-
critique_temperature: options[:critique_temperature]
|
|
101
|
+
critique_temperature: options[:critique_temperature],
|
|
102
|
+
no_fallbacks: options[:no_fallbacks] == true
|
|
72
103
|
)
|
|
73
104
|
config.require_llm_configuration!
|
|
105
|
+
config.warnings.each { |warning| @logger.warn(warning) }
|
|
74
106
|
config
|
|
75
107
|
end
|
|
76
108
|
|
|
@@ -102,8 +134,8 @@ module Aireview
|
|
|
102
134
|
[merge_request, changes]
|
|
103
135
|
end
|
|
104
136
|
|
|
105
|
-
#
|
|
106
|
-
#
|
|
137
|
+
# The diff travels on as files, not as one string: the context budget
|
|
138
|
+
# cuts it at file and hunk boundaries.
|
|
107
139
|
def prepare_changes(changes, config)
|
|
108
140
|
diff_fetcher = DiffFetcher.new(ignore_paths: config.ignore_paths, logger: @logger)
|
|
109
141
|
filtered_changes = diff_fetcher.filter(changes)
|
|
@@ -140,8 +172,8 @@ module Aireview
|
|
|
140
172
|
critique: !options[:no_critique]
|
|
141
173
|
)
|
|
142
174
|
|
|
143
|
-
#
|
|
144
|
-
#
|
|
175
|
+
# Printed before publishing: if publishing fails, the review text at
|
|
176
|
+
# least stays in the job log.
|
|
145
177
|
@out.puts(review)
|
|
146
178
|
|
|
147
179
|
publish_review(review, context, publication) if publication
|
|
@@ -149,8 +181,8 @@ module Aireview
|
|
|
149
181
|
0
|
|
150
182
|
end
|
|
151
183
|
|
|
152
|
-
#
|
|
153
|
-
#
|
|
184
|
+
# The previous review is looked up before calling the LLM: otherwise the
|
|
185
|
+
# requests are wasted even when there is nothing to publish.
|
|
154
186
|
def prepare_publication(pipeline, config, context, options)
|
|
155
187
|
return nil unless options[:post]
|
|
156
188
|
|
|
@@ -204,10 +236,10 @@ module Aireview
|
|
|
204
236
|
)
|
|
205
237
|
end
|
|
206
238
|
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
239
|
+
# While the LLM was working the MR may have moved on: a new commit, a
|
|
240
|
+
# rebase or a target branch change. Publishing a review of a stale diff
|
|
241
|
+
# is worse than publishing nothing, and a failed check cannot be read as
|
|
242
|
+
# "all in place", so it is not swallowed.
|
|
211
243
|
def merge_request_moved?(context)
|
|
212
244
|
current = context[:gitlab_client].fetch_merge_request(
|
|
213
245
|
context[:parser_result].project_id,
|
|
@@ -280,6 +312,10 @@ module Aireview
|
|
|
280
312
|
parser.on('--no-critique', 'Skip critique pass and render Generate candidates directly') do
|
|
281
313
|
options[:no_critique] = true
|
|
282
314
|
end
|
|
315
|
+
|
|
316
|
+
parser.on('--no-fallbacks', 'Use only the primary model and the first API key of each stage') do
|
|
317
|
+
options[:no_fallbacks] = true
|
|
318
|
+
end
|
|
283
319
|
end
|
|
284
320
|
|
|
285
321
|
def add_publication_options(parser, options)
|
|
@@ -328,9 +364,12 @@ module Aireview
|
|
|
328
364
|
<<~HELP
|
|
329
365
|
Usage:
|
|
330
366
|
aireview review <merge_request_url> [options]
|
|
367
|
+
aireview models check [--config PATH] [--strict] [--verbose]
|
|
331
368
|
|
|
332
369
|
Commands:
|
|
333
|
-
review
|
|
370
|
+
review Run review for a GitLab merge request URL
|
|
371
|
+
models check Send a probe request with the generate and critique schemas
|
|
372
|
+
to every model of both stages; exit 1 if any fails
|
|
334
373
|
|
|
335
374
|
Options:
|
|
336
375
|
--post Post review as a merge request note
|
|
@@ -349,6 +388,7 @@ module Aireview
|
|
|
349
388
|
--no-jira Disable Jira enrichment
|
|
350
389
|
--dry-run Print prompts without LLM calls
|
|
351
390
|
--no-critique Skip second LLM critique pass
|
|
391
|
+
--no-fallbacks Use only the primary model and the first API key of each stage
|
|
352
392
|
--verbose Enable verbose logging
|
|
353
393
|
-h, --help Show help
|
|
354
394
|
HELP
|
data/lib/aireview/config.rb
CHANGED
|
@@ -5,11 +5,18 @@ require 'yaml'
|
|
|
5
5
|
require_relative 'errors'
|
|
6
6
|
require_relative 'utils'
|
|
7
7
|
require_relative 'config_limits'
|
|
8
|
+
require_relative 'config_fallbacks'
|
|
9
|
+
require_relative 'config_layers'
|
|
10
|
+
require_relative 'config_loader'
|
|
8
11
|
|
|
9
12
|
module Aireview
|
|
13
|
+
# Answers questions about the merged settings: values, their source
|
|
14
|
+
# (layer), the routing plan, provider keys. How settings are read from
|
|
15
|
+
# files and the environment is ConfigLoader's business.
|
|
10
16
|
class Config
|
|
11
17
|
include ConfigLimits
|
|
12
|
-
|
|
18
|
+
include ConfigFallbacks
|
|
19
|
+
include ConfigLayers
|
|
13
20
|
|
|
14
21
|
DEFAULT_SECRET_FILES = [
|
|
15
22
|
'.env',
|
|
@@ -39,171 +46,57 @@ module Aireview
|
|
|
39
46
|
'provider' => 'gemini',
|
|
40
47
|
'temperature' => 0,
|
|
41
48
|
'timeout' => 60,
|
|
42
|
-
'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS
|
|
49
|
+
'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS,
|
|
50
|
+
'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET,
|
|
51
|
+
'overloaded_quarantine' => ConfigFallbacks::DEFAULT_OVERLOADED_QUARANTINE
|
|
43
52
|
},
|
|
44
53
|
'context' => ConfigLimits::CONTEXT_DEFAULTS
|
|
45
54
|
}.freeze
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
'gitlab_url' => 'GITLAB_URL',
|
|
49
|
-
'gitlab_token' => 'GITLAB_TOKEN',
|
|
50
|
-
'jira_url' => 'JIRA_URL',
|
|
51
|
-
'jira_login' => 'JIRA_LOGIN',
|
|
52
|
-
'jira_password' => 'JIRA_PASSWORD',
|
|
53
|
-
'review_language' => 'REVIEW_LANGUAGE',
|
|
54
|
-
'review_mode' => 'REVIEW_MODE',
|
|
55
|
-
'llm_api_base' => 'LLM_API_BASE',
|
|
56
|
-
'ollama_api_base' => 'OLLAMA_API_BASE',
|
|
57
|
-
'llm_http_proxy' => 'LLM_HTTP_PROXY'
|
|
58
|
-
}.freeze
|
|
59
|
-
|
|
60
|
-
PROVIDER_KEY_MAPPING = {
|
|
61
|
-
'gemini' => 'GEMINI_API_KEY',
|
|
62
|
-
'ollama' => nil
|
|
63
|
-
}.freeze
|
|
64
|
-
|
|
65
|
-
attr_reader :config_path
|
|
56
|
+
attr_reader :config_path, :layers
|
|
66
57
|
|
|
67
|
-
def self.load(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
file_path = config_path ? File.expand_path(config_path, cwd) : discover_file(cwd, '.aireview.yml')
|
|
71
|
-
file_config = File.file?(file_path) ? normalize_hash(YAML.load_file(file_path) || {}) : {}
|
|
72
|
-
|
|
73
|
-
merged = deep_merge(DEFAULTS, file_config)
|
|
74
|
-
merged = deep_merge(merged, env_config(env))
|
|
75
|
-
|
|
76
|
-
new(merged, config_path: File.file?(file_path) ? file_path : nil, logger: logger)
|
|
58
|
+
def self.load(**options)
|
|
59
|
+
ConfigLoader.load(**options)
|
|
77
60
|
end
|
|
78
61
|
|
|
79
|
-
def self.
|
|
80
|
-
|
|
81
|
-
dotenv_path = discover_file(cwd, '.env')
|
|
82
|
-
Dotenv.load(dotenv_path) if File.file?(dotenv_path)
|
|
83
|
-
rescue LoadError
|
|
84
|
-
nil
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def self.discover_file(cwd, basename)
|
|
88
|
-
current = Pathname.new(cwd).expand_path
|
|
89
|
-
|
|
90
|
-
loop do
|
|
91
|
-
candidate = current.join(basename)
|
|
92
|
-
return candidate.to_s if candidate.file?
|
|
93
|
-
|
|
94
|
-
break if current.root?
|
|
95
|
-
|
|
96
|
-
current = current.parent
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
File.join(cwd, basename)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def self.env_config(env)
|
|
103
|
-
mapped_env_config(env)
|
|
104
|
-
.merge('llm' => llm_env_config(env))
|
|
105
|
-
.merge(context_env_config(env))
|
|
106
|
-
.merge(provider_key_env_config(env))
|
|
107
|
-
.merge(generic_api_key_env_config(env))
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def self.mapped_env_config(env)
|
|
111
|
-
ENV_MAPPING.each_with_object({}) do |(key, env_key), config|
|
|
112
|
-
value = env[env_key]
|
|
113
|
-
config[key] = value unless Aireview::Utils.blank?(value)
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def self.llm_env_config(env)
|
|
118
|
-
{
|
|
119
|
-
'provider' => env['LLM_PROVIDER'],
|
|
120
|
-
'temperature' => parse_float(env['LLM_TEMPERATURE']),
|
|
121
|
-
'timeout' => parse_float(env['LLM_TIMEOUT']),
|
|
122
|
-
'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
|
|
123
|
-
'generate' => llm_stage_env_config(env, 'GENERATE'),
|
|
124
|
-
'critique' => llm_stage_env_config(env, 'CRITIQUE')
|
|
125
|
-
}.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def self.llm_stage_env_config(env, stage)
|
|
129
|
-
{
|
|
130
|
-
'provider' => env["LLM_#{stage}_PROVIDER"],
|
|
131
|
-
'model' => env["LLM_#{stage}_MODEL"],
|
|
132
|
-
'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
|
|
133
|
-
'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS")
|
|
134
|
-
}.compact
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def self.provider_key_env_config(env)
|
|
138
|
-
PROVIDER_KEY_MAPPING.each_with_object({}) do |(provider, env_key), config|
|
|
139
|
-
next unless env_key
|
|
140
|
-
|
|
141
|
-
value = env[env_key]
|
|
142
|
-
config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value)
|
|
143
|
-
end
|
|
62
|
+
def self.env_names
|
|
63
|
+
ConfigLoader.env_names
|
|
144
64
|
end
|
|
145
65
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def self.parse_float(value)
|
|
154
|
-
return nil if Aireview::Utils.blank?(value)
|
|
155
|
-
|
|
156
|
-
Float(value)
|
|
157
|
-
rescue ArgumentError
|
|
158
|
-
nil
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def self.deep_merge(left, right)
|
|
162
|
-
left.merge(right) do |_, old_value, new_value|
|
|
163
|
-
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
164
|
-
deep_merge(old_value, new_value)
|
|
165
|
-
else
|
|
166
|
-
new_value
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def self.normalize_hash(value)
|
|
172
|
-
case value
|
|
173
|
-
when Hash
|
|
174
|
-
value.each_with_object({}) do |(key, inner_value), result|
|
|
175
|
-
result[key.to_s] = normalize_hash(inner_value)
|
|
176
|
-
end
|
|
177
|
-
when Array
|
|
178
|
-
value.map { |item| normalize_hash(item) }
|
|
179
|
-
else
|
|
180
|
-
value
|
|
181
|
-
end
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def initialize(data, config_path:, logger:)
|
|
185
|
-
@data = self.class.normalize_hash(data)
|
|
66
|
+
# The layers are the single source of truth: the merged data is computed
|
|
67
|
+
# from them. A config built from a hash (without ConfigLoader) is one
|
|
68
|
+
# layer, otherwise the CLI layer from with_overrides would be the only
|
|
69
|
+
# one and the stage settings of the original hash would be lost.
|
|
70
|
+
def initialize(data = nil, config_path: nil, logger: Logger.new($stderr), layers: nil)
|
|
71
|
+
@layers = layers || [ConfigLayers::Layer.new(name: ConfigLayers::DATA_LAYER, data: Utils.normalize_hash(data))]
|
|
72
|
+
@data = @layers.map(&:data).reduce({}) { |merged, layer_data| Utils.deep_merge(merged, layer_data) }
|
|
186
73
|
@config_path = config_path
|
|
187
74
|
@logger = logger
|
|
188
75
|
end
|
|
189
76
|
|
|
77
|
+
# CLI overrides change only the primary model of a stage, the reserves
|
|
78
|
+
# from the config stay; no_fallbacks leaves one model and one key.
|
|
190
79
|
def with_overrides(
|
|
191
80
|
generate_model: nil,
|
|
192
81
|
critique_model: nil,
|
|
193
82
|
generate_temperature: nil,
|
|
194
|
-
critique_temperature: nil
|
|
83
|
+
critique_temperature: nil,
|
|
84
|
+
no_fallbacks: false
|
|
195
85
|
)
|
|
196
86
|
llm_config = {
|
|
197
87
|
'generate' => stage_overrides(model: generate_model, temperature: generate_temperature),
|
|
198
88
|
'critique' => stage_overrides(model: critique_model, temperature: critique_temperature)
|
|
199
89
|
}.reject { |_, overrides| overrides.empty? }
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
90
|
+
overrides = {}
|
|
91
|
+
overrides['llm'] = llm_config unless llm_config.empty?
|
|
92
|
+
overrides['fallbacks_disabled'] = true if no_fallbacks
|
|
93
|
+
return self if overrides.empty?
|
|
94
|
+
|
|
95
|
+
self.class.new(
|
|
96
|
+
config_path: config_path,
|
|
97
|
+
logger: @logger,
|
|
98
|
+
layers: @layers + [ConfigLayers::Layer.new(name: ConfigLayers::CLI_LAYER, data: overrides)]
|
|
205
99
|
)
|
|
206
|
-
self.class.new(merged, config_path: config_path, logger: @logger)
|
|
207
100
|
end
|
|
208
101
|
|
|
209
102
|
def gitlab_url
|
|
@@ -238,28 +131,42 @@ module Aireview
|
|
|
238
131
|
dig('llm', 'timeout') || DEFAULTS.dig('llm', 'timeout')
|
|
239
132
|
end
|
|
240
133
|
|
|
134
|
+
# The primary model of a stage is the first in its chain: the start for
|
|
135
|
+
# Generate, the first of the pool for Critique. These go into the review key.
|
|
241
136
|
def generate_model
|
|
242
|
-
|
|
137
|
+
routing.primary('generate').model
|
|
243
138
|
end
|
|
244
139
|
|
|
245
140
|
def critique_model
|
|
246
|
-
|
|
141
|
+
routing.primary('critique').model
|
|
247
142
|
end
|
|
248
143
|
|
|
249
144
|
def generate_provider
|
|
250
|
-
|
|
145
|
+
routing.primary('generate').provider
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Everything besides the prompt that affects the review result goes into
|
|
149
|
+
# the note key (see ReviewMarker): provider, model and temperature of the
|
|
150
|
+
# stages, the shared pool with its critique policy. Reserves of per-stage
|
|
151
|
+
# chains do not change the result.
|
|
152
|
+
def result_signature
|
|
153
|
+
{
|
|
154
|
+
'generate' => [generate_provider, generate_model, generate_temperature],
|
|
155
|
+
'critique' => [critique_provider, critique_model, critique_temperature],
|
|
156
|
+
'pool' => routing.signature
|
|
157
|
+
}
|
|
251
158
|
end
|
|
252
159
|
|
|
253
160
|
def critique_provider
|
|
254
|
-
|
|
161
|
+
routing.primary('critique').provider
|
|
255
162
|
end
|
|
256
163
|
|
|
257
164
|
def generate_temperature
|
|
258
|
-
|
|
165
|
+
stage_setting('generate', 'temperature') || DEFAULTS.dig('llm', 'temperature')
|
|
259
166
|
end
|
|
260
167
|
|
|
261
168
|
def critique_temperature
|
|
262
|
-
|
|
169
|
+
stage_setting('critique', 'temperature') || DEFAULTS.dig('llm', 'temperature')
|
|
263
170
|
end
|
|
264
171
|
|
|
265
172
|
def llm_api_base
|
|
@@ -278,8 +185,8 @@ module Aireview
|
|
|
278
185
|
@data['review_language'] || DEFAULTS['review_language']
|
|
279
186
|
end
|
|
280
187
|
|
|
281
|
-
# update —
|
|
282
|
-
# once —
|
|
188
|
+
# update — our note is updated when the diff or the settings changed,
|
|
189
|
+
# once — one automatic review; a job Retry updates the review on changes.
|
|
283
190
|
def review_mode
|
|
284
191
|
mode = (@data['review_mode'] || DEFAULTS['review_mode']).to_s
|
|
285
192
|
return mode if REVIEW_MODES.include?(mode)
|
|
@@ -324,41 +231,29 @@ module Aireview
|
|
|
324
231
|
raise ConfigError, 'GITLAB_TOKEN is required'
|
|
325
232
|
end
|
|
326
233
|
|
|
327
|
-
|
|
328
|
-
missing = []
|
|
329
|
-
missing << 'llm.generate.model (or LLM_GENERATE_MODEL)' if Aireview::Utils.blank?(generate_model)
|
|
330
|
-
missing << 'llm.critique.model (or LLM_CRITIQUE_MODEL)' if Aireview::Utils.blank?(critique_model)
|
|
331
|
-
raise ConfigError, "LLM models are required: #{missing.join(', ')}" unless missing.empty?
|
|
332
|
-
end
|
|
234
|
+
private
|
|
333
235
|
|
|
334
|
-
|
|
335
|
-
|
|
236
|
+
# Without a pool an override changes only the primary model of the stage,
|
|
237
|
+
# the reserves stay. With a pool the stage mode switches explicitly: a
|
|
238
|
+
# model from the pool becomes the start and the stage's own model and
|
|
239
|
+
# fallbacks are reset; a model outside the pool is a single chain, start
|
|
240
|
+
# and fallbacks are reset.
|
|
241
|
+
def stage_overrides(model:, temperature:)
|
|
242
|
+
overrides = {'temperature' => temperature}.compact
|
|
243
|
+
return overrides unless model
|
|
336
244
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
'critique' => critique_provider
|
|
340
|
-
}.filter_map do |stage, provider|
|
|
341
|
-
next if provider.to_s == 'ollama'
|
|
342
|
-
next if Aireview::Utils.present?(provider_api_key(provider))
|
|
245
|
+
items = Array(dig('llm', 'models'))
|
|
246
|
+
return overrides.merge('model' => model) if items.empty?
|
|
343
247
|
|
|
344
|
-
|
|
248
|
+
if ModelPool.member?(items, llm_provider, model)
|
|
249
|
+
overrides.merge('start' => model, 'model' => nil, 'fallbacks' => nil)
|
|
250
|
+
else
|
|
251
|
+
overrides.merge('start' => nil, 'model' => model, 'fallbacks' => [])
|
|
345
252
|
end
|
|
346
|
-
raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
private
|
|
350
|
-
|
|
351
|
-
def stage_overrides(model:, temperature:)
|
|
352
|
-
{
|
|
353
|
-
'model' => model,
|
|
354
|
-
'temperature' => temperature
|
|
355
|
-
}.compact
|
|
356
253
|
end
|
|
357
254
|
|
|
358
255
|
def dig(*keys)
|
|
359
|
-
|
|
360
|
-
accumulator.is_a?(Hash) ? accumulator[key] : nil
|
|
361
|
-
end
|
|
256
|
+
Utils.dig(@data, *keys)
|
|
362
257
|
end
|
|
363
258
|
end
|
|
364
259
|
end
|