aireview 0.3.0 → 2.1.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 +82 -0
- data/README.md +301 -29
- data/config/defaults.yml +47 -0
- data/lib/aireview/candidate_checker.rb +24 -22
- data/lib/aireview/cli.rb +79 -19
- data/lib/aireview/config.rb +65 -180
- data/lib/aireview/config_fallbacks.rb +79 -68
- 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 +18 -17
- data/lib/aireview/diff_fetcher.rb +12 -11
- data/lib/aireview/dry_run_report.rb +33 -8
- data/lib/aireview/errors.rb +8 -0
- data/lib/aireview/gitlab_client.rb +10 -9
- data/lib/aireview/llm_client.rb +146 -0
- data/lib/aireview/llm_failure.rb +36 -19
- data/lib/aireview/llm_router.rb +315 -158
- 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/output_schemas.rb +3 -3
- 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 +102 -85
- data/lib/aireview/review_renderer.rb +19 -11
- data/lib/aireview/reviewer.rb +44 -107
- 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 +21 -5
- data/lib/aireview/result_validation.rb +0 -65
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)
|
|
@@ -72,6 +102,7 @@ module Aireview
|
|
|
72
102
|
no_fallbacks: options[:no_fallbacks] == true
|
|
73
103
|
)
|
|
74
104
|
config.require_llm_configuration!
|
|
105
|
+
config.warnings.each { |warning| @logger.warn(warning) }
|
|
75
106
|
config
|
|
76
107
|
end
|
|
77
108
|
|
|
@@ -103,8 +134,8 @@ module Aireview
|
|
|
103
134
|
[merge_request, changes]
|
|
104
135
|
end
|
|
105
136
|
|
|
106
|
-
#
|
|
107
|
-
#
|
|
137
|
+
# The diff travels on as files, not as one string: the context budget
|
|
138
|
+
# cuts it at file and hunk boundaries.
|
|
108
139
|
def prepare_changes(changes, config)
|
|
109
140
|
diff_fetcher = DiffFetcher.new(ignore_paths: config.ignore_paths, logger: @logger)
|
|
110
141
|
filtered_changes = diff_fetcher.filter(changes)
|
|
@@ -141,8 +172,8 @@ module Aireview
|
|
|
141
172
|
critique: !options[:no_critique]
|
|
142
173
|
)
|
|
143
174
|
|
|
144
|
-
#
|
|
145
|
-
#
|
|
175
|
+
# Printed before publishing: if publishing fails, the review text at
|
|
176
|
+
# least stays in the job log.
|
|
146
177
|
@out.puts(review)
|
|
147
178
|
|
|
148
179
|
publish_review(review, context, publication) if publication
|
|
@@ -150,8 +181,8 @@ module Aireview
|
|
|
150
181
|
0
|
|
151
182
|
end
|
|
152
183
|
|
|
153
|
-
#
|
|
154
|
-
#
|
|
184
|
+
# The previous review is looked up before calling the LLM: otherwise the
|
|
185
|
+
# requests are wasted even when there is nothing to publish.
|
|
155
186
|
def prepare_publication(pipeline, config, context, options)
|
|
156
187
|
return nil unless options[:post]
|
|
157
188
|
|
|
@@ -181,18 +212,41 @@ module Aireview
|
|
|
181
212
|
up_to_date = existing[:key] == key
|
|
182
213
|
return false unless up_to_date || (mode == 'once' && !retried_ci_job?(gitlab_client))
|
|
183
214
|
|
|
184
|
-
|
|
185
|
-
@out.puts("Review skipped: #{reason}")
|
|
215
|
+
@out.puts("Review skipped: #{skip_reason(existing, up_to_date: up_to_date, mode: mode)}")
|
|
186
216
|
true
|
|
187
217
|
end
|
|
188
218
|
|
|
219
|
+
# In once mode the review is not repeated on new pushes, even when the MR
|
|
220
|
+
# has changed. So besides the mode the message says whether the review is
|
|
221
|
+
# up to date: if it is not, the Retry button of the GitLab job updates it.
|
|
222
|
+
def skip_reason(existing, up_to_date:, mode:)
|
|
223
|
+
return 'existing review is up to date' unless mode == 'once'
|
|
224
|
+
|
|
225
|
+
state = if up_to_date then 'the review is up to date'
|
|
226
|
+
elsif existing[:key].nil? then "review freshness is unknown: #{update_hint}"
|
|
227
|
+
else "review inputs changed: #{update_hint}"
|
|
228
|
+
end
|
|
229
|
+
"merge request already reviewed (review_mode=once), #{state}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def update_hint
|
|
233
|
+
ci_job_context ? 'retry the job to update' : 'use --force to review again'
|
|
234
|
+
end
|
|
235
|
+
|
|
189
236
|
def retried_ci_job?(gitlab_client)
|
|
190
|
-
project_id, job_id =
|
|
191
|
-
return false
|
|
237
|
+
project_id, job_id = ci_job_context
|
|
238
|
+
return false unless project_id
|
|
192
239
|
|
|
193
240
|
gitlab_client.retried_job?(project_id, job_id)
|
|
194
241
|
end
|
|
195
242
|
|
|
243
|
+
def ci_job_context
|
|
244
|
+
project_id, job_id = @env.values_at('CI_PROJECT_ID', 'CI_JOB_ID')
|
|
245
|
+
return if Aireview::Utils.blank?(project_id) || Aireview::Utils.blank?(job_id)
|
|
246
|
+
|
|
247
|
+
[project_id, job_id]
|
|
248
|
+
end
|
|
249
|
+
|
|
196
250
|
def publish_review(review, context, publication)
|
|
197
251
|
return if merge_request_moved?(context)
|
|
198
252
|
|
|
@@ -205,19 +259,22 @@ module Aireview
|
|
|
205
259
|
)
|
|
206
260
|
end
|
|
207
261
|
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
262
|
+
# While the LLM was working the MR may have moved on: a new commit, a
|
|
263
|
+
# rebase or a target branch change. Publishing a review of a stale diff
|
|
264
|
+
# is worse than publishing nothing, and a failed check cannot be read as
|
|
265
|
+
# "all in place", so it is not swallowed.
|
|
212
266
|
def merge_request_moved?(context)
|
|
213
267
|
current = context[:gitlab_client].fetch_merge_request(
|
|
214
268
|
context[:parser_result].project_id,
|
|
215
269
|
context[:parser_result].iid
|
|
216
270
|
)
|
|
217
|
-
|
|
271
|
+
before = ReviewMarker.state(context[:merge_request])
|
|
272
|
+
after = ReviewMarker.state(current)
|
|
273
|
+
changed = after.keys.reject { |field| after[field] == before[field] }
|
|
274
|
+
return false if changed.empty?
|
|
218
275
|
|
|
219
|
-
@logger.warn("Merge request
|
|
220
|
-
'
|
|
276
|
+
@logger.warn("Merge request changed while review was running (#{changed.join(', ')}); " \
|
|
277
|
+
'skipping publication')
|
|
221
278
|
true
|
|
222
279
|
end
|
|
223
280
|
|
|
@@ -333,9 +390,12 @@ module Aireview
|
|
|
333
390
|
<<~HELP
|
|
334
391
|
Usage:
|
|
335
392
|
aireview review <merge_request_url> [options]
|
|
393
|
+
aireview models check [--config PATH] [--strict] [--verbose]
|
|
336
394
|
|
|
337
395
|
Commands:
|
|
338
|
-
review
|
|
396
|
+
review Run review for a GitLab merge request URL
|
|
397
|
+
models check Send a probe request with the generate and critique schemas
|
|
398
|
+
to every model of both stages; exit 1 if any fails
|
|
339
399
|
|
|
340
400
|
Options:
|
|
341
401
|
--post Post review as a merge request note
|
data/lib/aireview/config.rb
CHANGED
|
@@ -6,13 +6,17 @@ require_relative 'errors'
|
|
|
6
6
|
require_relative 'utils'
|
|
7
7
|
require_relative 'config_limits'
|
|
8
8
|
require_relative 'config_fallbacks'
|
|
9
|
+
require_relative 'config_layers'
|
|
10
|
+
require_relative 'config_loader'
|
|
9
11
|
|
|
10
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.
|
|
11
16
|
class Config
|
|
12
17
|
include ConfigLimits
|
|
13
18
|
include ConfigFallbacks
|
|
14
|
-
|
|
15
|
-
extend ConfigFallbacks::ClassMethods
|
|
19
|
+
include ConfigLayers
|
|
16
20
|
|
|
17
21
|
DEFAULT_SECRET_FILES = [
|
|
18
22
|
'.env',
|
|
@@ -43,159 +47,35 @@ module Aireview
|
|
|
43
47
|
'temperature' => 0,
|
|
44
48
|
'timeout' => 60,
|
|
45
49
|
'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS,
|
|
46
|
-
'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET
|
|
50
|
+
'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET,
|
|
51
|
+
'overloaded_quarantine' => ConfigFallbacks::DEFAULT_OVERLOADED_QUARANTINE
|
|
47
52
|
},
|
|
48
53
|
'context' => ConfigLimits::CONTEXT_DEFAULTS
|
|
49
54
|
}.freeze
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
'gitlab_url' => 'GITLAB_URL',
|
|
53
|
-
'gitlab_token' => 'GITLAB_TOKEN',
|
|
54
|
-
'jira_url' => 'JIRA_URL',
|
|
55
|
-
'jira_login' => 'JIRA_LOGIN',
|
|
56
|
-
'jira_password' => 'JIRA_PASSWORD',
|
|
57
|
-
'review_language' => 'REVIEW_LANGUAGE',
|
|
58
|
-
'review_mode' => 'REVIEW_MODE',
|
|
59
|
-
'llm_api_base' => 'LLM_API_BASE',
|
|
60
|
-
'ollama_api_base' => 'OLLAMA_API_BASE',
|
|
61
|
-
'llm_http_proxy' => 'LLM_HTTP_PROXY'
|
|
62
|
-
}.freeze
|
|
63
|
-
|
|
64
|
-
PROVIDER_KEY_MAPPING = {
|
|
65
|
-
'gemini' => 'GEMINI_API_KEY',
|
|
66
|
-
'ollama' => nil
|
|
67
|
-
}.freeze
|
|
68
|
-
|
|
69
|
-
attr_reader :config_path
|
|
70
|
-
|
|
71
|
-
def self.load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr))
|
|
72
|
-
load_dotenv(cwd)
|
|
73
|
-
|
|
74
|
-
file_path = config_path ? File.expand_path(config_path, cwd) : discover_file(cwd, '.aireview.yml')
|
|
75
|
-
file_config = File.file?(file_path) ? normalize_hash(YAML.load_file(file_path) || {}) : {}
|
|
76
|
-
|
|
77
|
-
merged = deep_merge(DEFAULTS, file_config)
|
|
78
|
-
merged = deep_merge(merged, env_config(env))
|
|
79
|
-
|
|
80
|
-
new(merged, config_path: File.file?(file_path) ? file_path : nil, logger: logger)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def self.load_dotenv(cwd)
|
|
84
|
-
require 'dotenv'
|
|
85
|
-
dotenv_path = discover_file(cwd, '.env')
|
|
86
|
-
Dotenv.load(dotenv_path) if File.file?(dotenv_path)
|
|
87
|
-
rescue LoadError
|
|
88
|
-
nil
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def self.discover_file(cwd, basename)
|
|
92
|
-
current = Pathname.new(cwd).expand_path
|
|
93
|
-
|
|
94
|
-
loop do
|
|
95
|
-
candidate = current.join(basename)
|
|
96
|
-
return candidate.to_s if candidate.file?
|
|
97
|
-
|
|
98
|
-
break if current.root?
|
|
99
|
-
|
|
100
|
-
current = current.parent
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
File.join(cwd, basename)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def self.env_config(env)
|
|
107
|
-
mapped_env_config(env)
|
|
108
|
-
.merge('llm' => llm_env_config(env))
|
|
109
|
-
.merge(context_env_config(env))
|
|
110
|
-
.merge(provider_key_env_config(env))
|
|
111
|
-
.merge(provider_keys_env_config(env))
|
|
112
|
-
.merge(generic_api_key_env_config(env))
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def self.mapped_env_config(env)
|
|
116
|
-
ENV_MAPPING.each_with_object({}) do |(key, env_key), config|
|
|
117
|
-
value = env[env_key]
|
|
118
|
-
config[key] = value unless Aireview::Utils.blank?(value)
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def self.llm_env_config(env)
|
|
123
|
-
{
|
|
124
|
-
'provider' => env['LLM_PROVIDER'],
|
|
125
|
-
'temperature' => parse_float(env['LLM_TEMPERATURE']),
|
|
126
|
-
'timeout' => parse_float(env['LLM_TIMEOUT']),
|
|
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'),
|
|
129
|
-
'generate' => llm_stage_env_config(env, 'GENERATE'),
|
|
130
|
-
'critique' => llm_stage_env_config(env, 'CRITIQUE')
|
|
131
|
-
}.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def self.llm_stage_env_config(env, stage)
|
|
135
|
-
{
|
|
136
|
-
'provider' => env["LLM_#{stage}_PROVIDER"],
|
|
137
|
-
'model' => env["LLM_#{stage}_MODEL"],
|
|
138
|
-
'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
|
|
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)
|
|
141
|
-
}.compact
|
|
142
|
-
end
|
|
56
|
+
attr_reader :config_path, :layers
|
|
143
57
|
|
|
144
|
-
def self.
|
|
145
|
-
|
|
146
|
-
next unless env_key
|
|
147
|
-
|
|
148
|
-
value = env[env_key]
|
|
149
|
-
config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value)
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def self.generic_api_key_env_config(env)
|
|
154
|
-
api_key = env['LLM_API_KEY']
|
|
155
|
-
return {} if Aireview::Utils.blank?(api_key)
|
|
156
|
-
|
|
157
|
-
{'llm_api_key' => api_key}
|
|
58
|
+
def self.load(**options)
|
|
59
|
+
ConfigLoader.load(**options)
|
|
158
60
|
end
|
|
159
61
|
|
|
160
|
-
def self.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
Float(value)
|
|
164
|
-
rescue ArgumentError
|
|
165
|
-
nil
|
|
62
|
+
def self.env_names
|
|
63
|
+
ConfigLoader.env_names
|
|
166
64
|
end
|
|
167
65
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
end
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def self.normalize_hash(value)
|
|
179
|
-
case value
|
|
180
|
-
when Hash
|
|
181
|
-
value.each_with_object({}) do |(key, inner_value), result|
|
|
182
|
-
result[key.to_s] = normalize_hash(inner_value)
|
|
183
|
-
end
|
|
184
|
-
when Array
|
|
185
|
-
value.map { |item| normalize_hash(item) }
|
|
186
|
-
else
|
|
187
|
-
value
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def initialize(data, config_path:, logger:)
|
|
192
|
-
@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) }
|
|
193
73
|
@config_path = config_path
|
|
194
74
|
@logger = logger
|
|
195
75
|
end
|
|
196
76
|
|
|
197
|
-
#
|
|
198
|
-
#
|
|
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.
|
|
199
79
|
def with_overrides(
|
|
200
80
|
generate_model: nil,
|
|
201
81
|
critique_model: nil,
|
|
@@ -212,7 +92,11 @@ module Aireview
|
|
|
212
92
|
overrides['fallbacks_disabled'] = true if no_fallbacks
|
|
213
93
|
return self if overrides.empty?
|
|
214
94
|
|
|
215
|
-
self.class.new(
|
|
95
|
+
self.class.new(
|
|
96
|
+
config_path: config_path,
|
|
97
|
+
logger: @logger,
|
|
98
|
+
layers: @layers + [ConfigLayers::Layer.new(name: ConfigLayers::CLI_LAYER, data: overrides)]
|
|
99
|
+
)
|
|
216
100
|
end
|
|
217
101
|
|
|
218
102
|
def gitlab_url
|
|
@@ -247,28 +131,42 @@ module Aireview
|
|
|
247
131
|
dig('llm', 'timeout') || DEFAULTS.dig('llm', 'timeout')
|
|
248
132
|
end
|
|
249
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.
|
|
250
136
|
def generate_model
|
|
251
|
-
|
|
137
|
+
routing.primary('generate').model
|
|
252
138
|
end
|
|
253
139
|
|
|
254
140
|
def critique_model
|
|
255
|
-
|
|
141
|
+
routing.primary('critique').model
|
|
256
142
|
end
|
|
257
143
|
|
|
258
144
|
def generate_provider
|
|
259
|
-
|
|
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
|
+
}
|
|
260
158
|
end
|
|
261
159
|
|
|
262
160
|
def critique_provider
|
|
263
|
-
|
|
161
|
+
routing.primary('critique').provider
|
|
264
162
|
end
|
|
265
163
|
|
|
266
164
|
def generate_temperature
|
|
267
|
-
|
|
165
|
+
stage_setting('generate', 'temperature') || DEFAULTS.dig('llm', 'temperature')
|
|
268
166
|
end
|
|
269
167
|
|
|
270
168
|
def critique_temperature
|
|
271
|
-
|
|
169
|
+
stage_setting('critique', 'temperature') || DEFAULTS.dig('llm', 'temperature')
|
|
272
170
|
end
|
|
273
171
|
|
|
274
172
|
def llm_api_base
|
|
@@ -287,8 +185,8 @@ module Aireview
|
|
|
287
185
|
@data['review_language'] || DEFAULTS['review_language']
|
|
288
186
|
end
|
|
289
187
|
|
|
290
|
-
# update —
|
|
291
|
-
# 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.
|
|
292
190
|
def review_mode
|
|
293
191
|
mode = (@data['review_mode'] || DEFAULTS['review_mode']).to_s
|
|
294
192
|
return mode if REVIEW_MODES.include?(mode)
|
|
@@ -333,42 +231,29 @@ module Aireview
|
|
|
333
231
|
raise ConfigError, 'GITLAB_TOKEN is required'
|
|
334
232
|
end
|
|
335
233
|
|
|
336
|
-
def require_models!
|
|
337
|
-
missing = []
|
|
338
|
-
missing << 'llm.generate.model (or LLM_GENERATE_MODEL)' if Aireview::Utils.blank?(generate_model)
|
|
339
|
-
missing << 'llm.critique.model (or LLM_CRITIQUE_MODEL)' if Aireview::Utils.blank?(critique_model)
|
|
340
|
-
raise ConfigError, "LLM models are required: #{missing.join(', ')}" unless missing.empty?
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
def require_llm_configuration!
|
|
344
|
-
require_models!
|
|
345
|
-
|
|
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}" }
|
|
349
|
-
end
|
|
350
|
-
raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
|
|
351
|
-
end
|
|
352
|
-
|
|
353
234
|
private
|
|
354
235
|
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
357
244
|
|
|
358
|
-
|
|
359
|
-
|
|
245
|
+
items = Array(dig('llm', 'models'))
|
|
246
|
+
return overrides.merge('model' => model) if items.empty?
|
|
360
247
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
'
|
|
365
|
-
|
|
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' => [])
|
|
252
|
+
end
|
|
366
253
|
end
|
|
367
254
|
|
|
368
255
|
def dig(*keys)
|
|
369
|
-
|
|
370
|
-
accumulator.is_a?(Hash) ? accumulator[key] : nil
|
|
371
|
-
end
|
|
256
|
+
Utils.dig(@data, *keys)
|
|
372
257
|
end
|
|
373
258
|
end
|
|
374
259
|
end
|