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.
@@ -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
- # Поиск прошлого ревью идёт до вызова LLM: иначе запросы тратятся впустую,
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
- # Пока работала LLM, MR мог уехать: новый коммит, перебазирование или смена
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 Run review for a GitLab merge request URL
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
@@ -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
- extend ConfigLimits::ClassMethods
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
- ENV_MAPPING = {
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(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr))
68
- load_dotenv(cwd)
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.load_dotenv(cwd)
80
- require 'dotenv'
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
- def self.generic_api_key_env_config(env)
147
- api_key = env['LLM_API_KEY']
148
- return {} if Aireview::Utils.blank?(api_key)
149
-
150
- {'llm_api_key' => api_key}
151
- end
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
- return self if llm_config.empty?
201
-
202
- merged = self.class.deep_merge(
203
- @data,
204
- 'llm' => llm_config
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
- dig('llm', 'generate', 'model')
137
+ routing.primary('generate').model
243
138
  end
244
139
 
245
140
  def critique_model
246
- dig('llm', 'critique', 'model')
141
+ routing.primary('critique').model
247
142
  end
248
143
 
249
144
  def generate_provider
250
- dig('llm', 'generate', 'provider') || llm_provider
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
- dig('llm', 'critique', 'provider') || llm_provider
161
+ routing.primary('critique').provider
255
162
  end
256
163
 
257
164
  def generate_temperature
258
- dig('llm', 'generate', 'temperature') || llm_temperature
165
+ stage_setting('generate', 'temperature') || DEFAULTS.dig('llm', 'temperature')
259
166
  end
260
167
 
261
168
  def critique_temperature
262
- dig('llm', 'critique', 'temperature') || llm_temperature
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 — ревьюим один раз автоматически; Retry джоба обновляет ревью при изменениях.
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
- def require_models!
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
- def require_llm_configuration!
335
- require_models!
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
- missing_keys = {
338
- 'generate' => generate_provider,
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
- "#{stage}: API key is required for provider #{provider.inspect}"
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
- keys.reduce(@data) do |accumulator, key|
360
- accumulator.is_a?(Hash) ? accumulator[key] : nil
361
- end
256
+ Utils.dig(@data, *keys)
362
257
  end
363
258
  end
364
259
  end