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.
@@ -1,18 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
  require 'json'
3
3
  require_relative 'errors'
4
+ require_relative 'stages'
4
5
  require_relative 'context_builder'
5
6
  require_relative 'candidate_checker'
6
- require_relative 'result_validation'
7
+ require_relative 'result_parser'
7
8
  require_relative 'review_renderer'
8
9
  require_relative 'review_schemas'
9
10
  require_relative 'reviewer'
10
11
 
11
12
  module Aireview
13
+ # A review run: context → Generate → anchoring check against the diff →
14
+ # Critique → report. Invalid JSON is repaired once by the same model; when
15
+ # the repair is invalid too, the stage restarts on another model with the
16
+ # original request.
12
17
  class ReviewPipeline
13
- include ResultValidation
14
-
15
- SchemaError = ResultValidation::SchemaError
18
+ SchemaError = ResultParser::SchemaError
16
19
 
17
20
  REPAIR_SYSTEM_PROMPT = <<~PROMPT.strip.freeze
18
21
  You fix invalid JSON produced by another LLM call.
@@ -23,9 +26,15 @@ module Aireview
23
26
  DRY_RUN_CANDIDATES_JSON = '[{"id":"C1","file":"path/from/diff.rb","line":1,' \
24
27
  '"quoted_code":"...","problem":"...","why":"...","suggestion":"...",' \
25
28
  '"category":"bug","severity":"major"}]'
29
+ # Finish reasons after which an unparsable answer gets no repair.
30
+ CUT_OFF_REASONS = {
31
+ max_tokens: 'cut off at the output limit (max_tokens)',
32
+ content_filter: 'blocked by the provider (content_filter)'
33
+ }.freeze
26
34
 
27
35
  def initialize(config:, reviewer: nil, context_builder: nil, logger: Logger.new($stderr))
28
36
  @config = config
37
+ @parser = ResultParser.new
29
38
  @reviewer = reviewer || Reviewer.new(config: config, logger: logger)
30
39
  @context_builder = context_builder || ContextBuilder.new(config: config, logger: logger)
31
40
  @logger = logger
@@ -40,16 +49,18 @@ module Aireview
40
49
  )
41
50
  generate_prompt = @context_builder.build_generate_prompt(context)
42
51
  @logger.info("Pipeline generate pass started (model=#{@config.generate_model})")
43
- candidates_raw = @reviewer.generate(**generate_prompt)
44
- generate_result = parse_with_repair(
45
- raw: candidates_raw,
46
- kind: 'generate result',
47
- expected: :generate,
48
- repair_stage: :generate
49
- )
52
+ generate_result = run_stage('generate') do
53
+ parse_with_repair(
54
+ raw: @reviewer.generate(**generate_prompt),
55
+ kind: 'generate result',
56
+ expected: 'generate',
57
+ repair_stage: 'generate'
58
+ )
59
+ end
50
60
  summary = generate_result['summary']
51
61
  candidates = Array(generate_result['candidates'])
52
- @logger.info("Pipeline generate pass completed with #{candidates.size} candidate(s)")
62
+ @logger.info("Pipeline generate pass completed with #{candidates.size} candidate(s) " \
63
+ "(model=#{@reviewer.answered_model('generate')})")
53
64
  candidates = check_candidates(context: context, changes: changes, candidates: candidates)
54
65
 
55
66
  accepted = critique ? maybe_critique(context: context, candidates: candidates) : skip_critique(candidates)
@@ -60,7 +71,8 @@ module Aireview
60
71
  accepted,
61
72
  summary: summary,
62
73
  coverage: context.coverage,
63
- fallback_models: @reviewer.fallback_models
74
+ fallback_models: @reviewer.fallback_models,
75
+ critique_weaker: critique && @reviewer.critique_weaker?
64
76
  )
65
77
  end
66
78
 
@@ -85,10 +97,15 @@ module Aireview
85
97
  generate_temperature: @config.generate_temperature,
86
98
  critique_model: @config.critique_model,
87
99
  critique_temperature: @config.critique_temperature,
88
- generate_fallbacks: @config.fallback_names(:generate),
89
- critique_fallbacks: critique ? @config.fallback_names(:critique) : [],
90
- api_keys: @config.api_key_counts(critique ? %i[generate critique] : [:generate]),
100
+ generate_fallbacks: @config.fallback_names('generate'),
101
+ critique_fallbacks: critique ? @config.fallback_names('critique') : [],
102
+ sources: setting_sources(critique),
103
+ config_paths: @config.layer_paths,
104
+ warnings: @config.warnings,
105
+ critique_rule: critique ? @config.routing.rule : nil,
106
+ api_keys: @config.api_key_counts(critique ? STAGES : ['generate']),
91
107
  time_budget: @config.llm_time_budget,
108
+ overloaded_quarantine: @config.overloaded_quarantine,
92
109
  coverage: context.coverage,
93
110
  sizes: context.sizes
94
111
  }
@@ -96,8 +113,37 @@ module Aireview
96
113
 
97
114
  private
98
115
 
99
- # Привязка к коду проверяется по диффу, который видела модель, до критика:
100
- # ему уходят пометки, в отчёт — сброшенная строка и знак ненайденной цитаты.
116
+ # Where the model, provider and reserves of a stage came from, for --dry-run.
117
+ def setting_sources(critique)
118
+ (critique ? %w[generate critique] : %w[generate]).to_h do |stage|
119
+ [stage.to_sym, {
120
+ model: @config.stage_model_source(stage),
121
+ provider: @config.stage_provider_source(stage),
122
+ fallbacks: @config.stage_fallbacks_source(stage)
123
+ }]
124
+ end
125
+ end
126
+
127
+ # A stage is a request, parsing and one repair by the same model. An
128
+ # invalid result after the repair, like a repair with no requests left,
129
+ # excludes the model for the stage, and the stage starts over on the next
130
+ # one — with the original request. API errors pass through: the router
131
+ # handles them with reserves, and exhausted routes are exhausted for a
132
+ # restart too.
133
+ def run_stage(stage)
134
+ loop do
135
+ return yield
136
+ rescue ParseError => e
137
+ reason = "#{e.is_a?(RepairImpossibleError) ? 'repair impossible' : 'invalid result'}: #{e.message}"
138
+ excluded = @reviewer.exclude_answered_model(stage: stage, reason: reason)
139
+ raise unless excluded
140
+
141
+ @logger.warn("Pipeline #{stage}: restarting on the next model after #{excluded} (#{e.message})")
142
+ end
143
+ end
144
+
145
+ # Anchoring is checked against the diff the model saw, before Critique:
146
+ # it gets the notes, the report gets the reset line and the missing-quote mark.
101
147
  def check_candidates(context:, changes:, candidates:)
102
148
  CandidateChecker.new(
103
149
  changes: changes,
@@ -112,8 +158,8 @@ module Aireview
112
158
  candidates
113
159
  end
114
160
 
115
- # Критику нечего фильтровать без кандидатов: запрос к LLM был бы пустой
116
- # тратой квоты и времени.
161
+ # Critique has nothing to filter without candidates: the LLM request
162
+ # would waste quota and time.
117
163
  def maybe_critique(context:, candidates:)
118
164
  return skip_critique(candidates, 'no candidates') if candidates.empty?
119
165
 
@@ -125,16 +171,18 @@ module Aireview
125
171
  candidates_json = JSON.pretty_generate(candidates)
126
172
  candidates_by_id = index_candidates_by_id(candidates)
127
173
  critique_prompt = @context_builder.build_critique_prompt(context, candidates_json: candidates_json)
128
- critique_raw = @reviewer.critique(**critique_prompt)
129
- critique_result = parse_with_repair(
130
- raw: critique_raw,
131
- kind: 'critique result',
132
- expected: :critique,
133
- repair_stage: :critique,
134
- critique_candidate_ids: candidates_by_id.keys
135
- )
174
+ critique_result = run_stage('critique') do
175
+ parse_with_repair(
176
+ raw: @reviewer.critique(**critique_prompt),
177
+ kind: 'critique result',
178
+ expected: 'critique',
179
+ repair_stage: 'critique',
180
+ critique_candidate_ids: candidates_by_id.keys
181
+ )
182
+ end
136
183
  verdicts = Array(critique_result['verdicts'])
137
- @logger.info("Pipeline critique pass completed with #{verdicts.size} verdict(s)")
184
+ @logger.info("Pipeline critique pass completed with #{verdicts.size} verdict(s) " \
185
+ "(model=#{@reviewer.answered_model('critique')})")
138
186
  apply_critique_verdicts(
139
187
  verdicts: verdicts,
140
188
  candidates_by_id: candidates_by_id
@@ -171,6 +219,7 @@ module Aireview
171
219
  def parse_string_with_repair(raw:, kind:, expected:, repair_stage:, critique_candidate_ids: nil)
172
220
  parse_expected_result(raw, expected, critique_candidate_ids: critique_candidate_ids)
173
221
  rescue JSON::ParserError, SchemaError => e
222
+ raise_if_cut_off(stage: repair_stage, kind: kind, error: e)
174
223
  @logger.warn("Invalid #{kind} JSON, requesting one repair: #{e.message}")
175
224
  repaired = repair_json(
176
225
  raw: raw,
@@ -182,66 +231,25 @@ module Aireview
182
231
  begin
183
232
  parse_expected_result(repaired, expected, critique_candidate_ids: critique_candidate_ids)
184
233
  rescue JSON::ParserError, SchemaError => second_error
234
+ raise_if_cut_off(stage: repair_stage, kind: "#{kind} repair", error: second_error)
185
235
  raise ParseError, "LLM returned invalid #{kind} JSON after repair: #{second_error.message}"
186
236
  end
187
237
  end
188
238
 
189
- def parse_expected_result(raw, expected, critique_candidate_ids: nil)
190
- parsed = raw.is_a?(Hash) ? raw : JSON.parse(strip_code_fences(raw.to_s))
191
-
192
- case expected
193
- when :generate
194
- parsed = normalize_generate_result(parsed)
195
- when :critique
196
- parsed = normalize_critique_result(parsed, critique_candidate_ids: critique_candidate_ids)
197
- else
198
- raise ArgumentError, "Unknown expected JSON schema: #{expected.inspect}"
199
- end
200
-
201
- parsed
202
- end
203
-
204
- def strip_code_fences(text)
205
- stripped = text.to_s.strip
206
- return stripped unless stripped.start_with?('```')
207
-
208
- stripped
209
- .sub(/\A```[[:alnum:]_-]*[ \t]*\r?\n?/, '')
210
- .sub(/\r?\n?```[ \t]*\z/, '')
211
- .strip
239
+ # An answer the provider cut off or blocked is not a JSON mistake: the
240
+ # same model would cut the repair off too, so the stage goes to the next
241
+ # model at once. A valid answer is taken whatever the reason.
242
+ def raise_if_cut_off(stage:, kind:, error:)
243
+ reason = CUT_OFF_REASONS[@reviewer.finish_reason(stage)]
244
+ raise ParseError, "LLM #{kind} was #{reason}: #{error.message}" if reason
212
245
  end
213
246
 
214
- def normalize_generate_result(parsed)
215
- parsed = {'summary' => nil, 'candidates' => parsed} if parsed.is_a?(Array)
216
- validate_generate_result_shape!(parsed)
217
- parsed['summary'] = nil unless parsed.key?('summary')
218
- candidate_ids = parsed['candidates'].map { |candidate| normalize_id(value(candidate, 'id')) }
219
- validate_identifiers!(
220
- candidate_ids,
221
- missing_message: 'each generate candidate must include a non-empty id',
222
- duplicate_prefix: 'duplicate generate candidate ids'
223
- )
224
-
225
- parsed
226
- end
227
-
228
- def normalize_critique_result(parsed, critique_candidate_ids:)
229
- validate_critique_result_shape!(parsed)
230
- verdicts = parsed['verdicts']
231
- verdict_ids = verdicts.map { |verdict| normalize_id(value(verdict, 'id')) }
232
- validate_identifiers!(
233
- verdict_ids,
234
- missing_message: 'each verdict must include a non-empty id',
235
- duplicate_prefix: 'duplicate verdict ids'
236
- )
237
- validate_expected_verdict_ids!(verdict_ids, critique_candidate_ids)
238
- verdicts.each { |verdict| validate_verdict!(verdict) }
239
-
240
- parsed
247
+ def parse_expected_result(raw, expected, critique_candidate_ids: nil)
248
+ @parser.parse(raw, expected: expected, critique_candidate_ids: critique_candidate_ids)
241
249
  end
242
250
 
243
251
  def repair_json(raw:, kind:, expected:, stage:, critique_candidate_ids: nil)
244
- schema = expected == :critique ? ReviewSchemas.critique : ReviewSchemas.generate
252
+ schema = expected == 'critique' ? ReviewSchemas.critique : ReviewSchemas.generate
245
253
  user_prompt = <<~PROMPT
246
254
  The previous #{kind} response was invalid.
247
255
 
@@ -254,19 +262,28 @@ module Aireview
254
262
  Invalid response:
255
263
  #{raw}
256
264
  PROMPT
257
- if stage == :critique && critique_candidate_ids
265
+ if stage == 'critique' && critique_candidate_ids
258
266
  user_prompt << "\nExpected candidate ids: #{critique_candidate_ids.join(', ')}\n"
259
267
  end
260
268
 
261
269
  @logger.info("Pipeline #{stage} repair started for #{kind}")
262
- prompt = @context_builder.check_stage_size!(stage, REPAIR_SYSTEM_PROMPT, user_prompt)
263
- if stage == :critique
264
- @reviewer.critique(**prompt)
270
+ prompt = repair_prompt(stage, user_prompt)
271
+ if stage == 'critique'
272
+ @reviewer.critique(**prompt, pinned: true)
265
273
  else
266
- @reviewer.generate(**prompt)
274
+ @reviewer.generate(**prompt, pinned: true)
267
275
  end
268
276
  end
269
277
 
278
+ # A repair that does not fit the stage limit is an invalid result of this
279
+ # model, not a size error of the original request: the stage moves to
280
+ # the next model with the original prompt.
281
+ def repair_prompt(stage, user_prompt)
282
+ @context_builder.check_stage_size!(stage, REPAIR_SYSTEM_PROMPT, user_prompt)
283
+ rescue ContextBudgetError => e
284
+ raise ParseError, "repair request does not fit the stage limit: #{e.message}"
285
+ end
286
+
270
287
  def index_candidates_by_id(candidates)
271
288
  candidates.each_with_object({}) do |candidate, result|
272
289
  next unless candidate.is_a?(Hash)
@@ -46,6 +46,7 @@ module Aireview
46
46
  diff_unavailable: 'diff not available',
47
47
  section_list: 'Truncated sections',
48
48
  fallback_used: 'Fallback model used',
49
+ critique_weaker: 'Critique ran on a model weaker than Generate: the findings were checked less strictly.',
49
50
  quote_missing: 'quote not found in the diff'
50
51
  },
51
52
  'ru' => {
@@ -72,6 +73,7 @@ module Aireview
72
73
  diff_unavailable: 'дифф недоступен',
73
74
  section_list: 'Усечённые секции',
74
75
  fallback_used: 'Использована резервная модель',
76
+ critique_weaker: 'Критика выполнена моделью слабее generate: замечания проверены менее строго.',
75
77
  quote_missing: 'цитата не найдена в диффе'
76
78
  }
77
79
  }.freeze
@@ -80,11 +82,11 @@ module Aireview
80
82
  @labels = LABELS.fetch(language.to_s) { LABELS.fetch(DEFAULT_LANGUAGE) }
81
83
  end
82
84
 
83
- # coverage: факты усечения контекста от пайплайна, не текст модели.
84
- # result по-прежнему про найденные проблемы; неполнота покрытия
85
- # дописывается рядом с ним, чтобы строка результата не читалась как
86
- # «проверено всё».
87
- def render(accepted, summary:, coverage: nil, fallback_models: {})
85
+ # coverage: the truncation facts from the pipeline, not the model's text.
86
+ # result is still about the findings; incomplete coverage is written
87
+ # next to it so that the result line does not read as "everything was
88
+ # checked".
89
+ def render(accepted, summary:, coverage: nil, fallback_models: {}, critique_weaker: false)
88
90
  mismatches, important = select_findings(Array(accepted))
89
91
  result = mismatches.empty? && important.empty? ? 'ok' : 'needs attention'
90
92
 
@@ -104,16 +106,16 @@ module Aireview
104
106
  ## #{label(:result)}
105
107
 
106
108
  #{result}#{partial_note(coverage)}
107
- #{coverage_block(coverage)}#{fallback_note(fallback_models)}
109
+ #{coverage_block(coverage)}#{fallback_note(fallback_models)}#{weaker_note(critique_weaker)}
108
110
  #{label(:disclaimer)}
109
111
  MARKDOWN
110
112
  end
111
113
 
112
114
  private
113
115
 
114
- # Сначала отбор того, что вообще показывается, потом лимиты разделов и
115
- # только затем общий лимит: находка, которую не показать из-за категории
116
- # или лимита раздела, не должна занимать общий слот.
116
+ # First select what is shown at all, then the section limits and only
117
+ # then the total limit: a finding that cannot be shown because of its
118
+ # category or a section limit must not take a slot in the total.
117
119
  def select_findings(accepted)
118
120
  sorted = sorted_findings(accepted)
119
121
  mismatches = sorted.select { |finding| category(finding) == 'task_mismatch' }.first(MISMATCH_LIMIT)
@@ -181,8 +183,14 @@ module Aireview
181
183
  "\n## #{label(:not_reviewed)}\n\n#{lines.join("\n")}\n"
182
184
  end
183
185
 
184
- # Смена ключа остаётся в логах; смена модели видна читателю, потому что
185
- # запасная модель может ревьюить слабее основной.
186
+ # A key switch stays in the logs; a model switch is visible to the
187
+ # reader, because a fallback model may review less well than the primary.
188
+ def weaker_note(critique_weaker)
189
+ return '' unless critique_weaker
190
+
191
+ "\n#{label(:critique_weaker)}\n"
192
+ end
193
+
186
194
  def fallback_note(fallback_models)
187
195
  return '' if fallback_models.nil? || fallback_models.empty?
188
196
 
@@ -1,142 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative 'errors'
3
3
  require_relative 'output_schemas'
4
- require_relative 'utils'
5
4
  require_relative 'llm_router'
6
- require 'timeout'
5
+ require_relative 'llm_client'
7
6
 
8
7
  module Aireview
8
+ # The review stages on top of the router: Generate and Critique with their
9
+ # own schemas and temperatures. LlmClient makes the request, LlmRouter
10
+ # walks the models.
9
11
  class Reviewer
10
12
  attr_reader :router
11
13
 
12
- def initialize(config:, logger: Logger.new($stderr), router: nil)
14
+ def initialize(config:, logger: Logger.new($stderr), router: nil, client: nil)
13
15
  @config = config
14
16
  @logger = logger
15
17
  @router = router || LlmRouter.new(config: config, logger: logger)
16
- @llm_contexts = {}
18
+ @client = client || LlmClient.new(config: config, logger: logger)
19
+ @finish_reasons = {}
17
20
  end
18
21
 
19
- def generate(system_prompt:, user_prompt:)
20
- call_llm(
21
- stage: 'generate',
22
- system: system_prompt,
23
- user: user_prompt,
24
- options: {temperature: @config.generate_temperature, schema: GenerateOutputSchema}
25
- )
22
+ # pinned — a request only to the model that answered last in the stage
23
+ # (the repair of its own JSON): its failure is RepairImpossibleError.
24
+ def generate(system_prompt:, user_prompt:, pinned: false)
25
+ prompt = LlmClient::Prompt.new(stage: 'generate', system: system_prompt, user: user_prompt,
26
+ temperature: @config.generate_temperature, schema: GenerateOutputSchema)
27
+ call_llm(prompt, pinned: pinned)
26
28
  end
27
29
 
28
- def critique(system_prompt:, user_prompt:)
29
- call_llm(
30
- stage: 'critique',
31
- system: system_prompt,
32
- user: user_prompt,
33
- options: {temperature: @config.critique_temperature, schema: CritiqueOutputSchema}
34
- )
30
+ def critique(system_prompt:, user_prompt:, pinned: false)
31
+ prompt = LlmClient::Prompt.new(stage: 'critique', system: system_prompt, user: user_prompt,
32
+ temperature: @config.critique_temperature, schema: CritiqueOutputSchema)
33
+ call_llm(prompt, pinned: pinned)
35
34
  end
36
35
 
37
- # Стадии, ответившие запасной моделью, для строки в отчёте.
36
+ # Stages answered by a fallback model, for the report line.
38
37
  def fallback_models
39
38
  @router.fallback_models
40
39
  end
41
40
 
42
- private
43
-
44
- def call_llm(stage:, system:, user:, options:)
45
- require 'ruby_llm'
46
-
47
- @config.require_llm_configuration!
48
- response = @router.call(stage: stage, request_chars: system.length + user.length) do |route, timeout|
49
- perform_llm_request(
50
- context: llm_context(stage, route),
51
- stage: stage,
52
- system: system,
53
- user: user,
54
- options: options.merge(model: route.candidate.model, provider: route.candidate.provider, timeout: timeout)
55
- )
56
- end
57
- response.content
58
- rescue LoadError => e
59
- @logger.error("LLM #{stage} setup failed: #{e.message}")
60
- raise ConfigError, "Missing dependency: #{e.message}"
61
- end
62
-
63
- def perform_llm_request(context:, stage:, system:, user:, options:)
64
- model = options[:model]
65
- temperature = options[:temperature]
66
- @logger.info("LLM #{stage} request started (model=#{model}, temperature=#{temperature})")
67
- chat = build_chat(context: context, stage: stage, model: model, provider: options[:provider])
68
- chat = configure_reasoning(chat: chat, model: model, provider: options[:provider])
69
- .with_temperature(temperature.to_f)
70
- .with_schema(options[:schema])
71
- chat.with_instructions(system)
72
- response = Timeout.timeout(options[:timeout]) { chat.ask(user) }
73
- @logger.info("LLM #{stage} request completed (model=#{model})")
74
- response
75
- rescue Timeout::Error
76
- @logger.warn("LLM #{stage} request timed out after #{options[:timeout].round} seconds (model=#{model})")
77
- raise
78
- end
79
-
80
- def configure_reasoning(chat:, model:, provider:)
81
- return chat unless provider == 'ollama' && model.start_with?('gpt-oss:')
82
-
83
- chat.with_thinking(effort: :low)
41
+ # The model that answered last in the stage with its place in the chain, for the log.
42
+ def answered_model(stage)
43
+ @router.answered(stage)
84
44
  end
85
45
 
86
- def build_chat(context:, stage:, model:, provider:)
87
- context.chat(model: model, provider: provider.to_sym)
88
- rescue RubyLLM::ModelNotFoundError
89
- @logger.warn(
90
- "LLM #{stage}: model not found in RubyLLM registry; " \
91
- "using fallback with incomplete model metadata " \
92
- "(model=#{model}, provider=#{provider})"
93
- )
94
- context.chat(model: model, provider: provider.to_sym, assume_model_exists: true)
46
+ # Critique ran on a model below Generate in the pool (allow_weaker).
47
+ def critique_weaker?
48
+ @router.critique_weaker?
95
49
  end
96
50
 
97
- # Контекст RubyLLM на стадию, провайдера и номер ключа: смена ключа —
98
- # это другой контекст, а не правка глобального конфига.
99
- def llm_context(stage, route)
100
- cache_key = [stage, route.candidate.provider, route.key_index]
101
- @llm_contexts[cache_key] ||= build_llm_context(route.candidate.provider.to_s, route.key)
51
+ # Why the last answer of the stage stopped (:stop, :max_tokens,
52
+ # :content_filter…), nil when the provider did not say or no answer came.
53
+ def finish_reason(stage)
54
+ @finish_reasons[stage.to_s]
102
55
  end
103
56
 
104
- # Повторами занимается роутер: встроенные ретраи RubyLLM/Faraday (по
105
- # умолчанию 3) превратили бы каждую нашу попытку в четыре HTTP-запроса и
106
- # жгли бы квоту до того, как ошибка дойдёт до классификатора.
107
- def build_llm_context(provider, api_key)
108
- RubyLLM.context do |ruby_config|
109
- configure_http_proxy(ruby_config)
110
- ruby_config.request_timeout = @config.llm_timeout.to_f
111
- ruby_config.max_retries = 0
112
- configure_provider(ruby_config, provider, api_key)
113
- end
57
+ # An invalid result: the model is excluded for the stage, the next
58
+ # request of the stage goes to another. Returns the excluded model or nil.
59
+ def exclude_answered_model(stage:, reason:)
60
+ @router.exclude_answered(stage: stage, reason: reason)
114
61
  end
115
62
 
116
- def configure_http_proxy(ruby_config)
117
- return unless Aireview::Utils.present?(@config.llm_http_proxy)
118
-
119
- ruby_config.http_proxy = @config.llm_http_proxy
120
- end
63
+ private
121
64
 
122
- def configure_provider(ruby_config, provider, api_key)
123
- case provider
124
- when 'gemini', 'openai', 'openrouter'
125
- configure_remote_provider(ruby_config, provider, api_key)
126
- when 'anthropic'
127
- ruby_config.anthropic_api_key = api_key
128
- when 'ollama'
129
- ruby_config.ollama_api_base = @config.ollama_api_base
130
- else
131
- raise ConfigError, "Unsupported LLM provider: #{provider.inspect}"
65
+ # A pinned route giving up is not an API error for the pipeline but
66
+ # "repair impossible": the same fate as an invalid result.
67
+ def call_llm(prompt, pinned:)
68
+ @finish_reasons.delete(prompt.stage)
69
+ response = @router.call(stage: prompt.stage, request_chars: prompt.chars, pinned: pinned) do |route, timeout|
70
+ @client.request(prompt, candidate: route.candidate, key: route.key, key_index: route.key_index,
71
+ timeout: timeout)
132
72
  end
133
- end
134
-
135
- def configure_remote_provider(ruby_config, provider, api_key)
136
- ruby_config.public_send("#{provider}_api_key=", api_key)
137
- return unless Aireview::Utils.present?(@config.llm_api_base)
138
-
139
- ruby_config.public_send("#{provider}_api_base=", @config.llm_api_base)
73
+ @finish_reasons[prompt.stage] = response.finish_reason
74
+ LlmClient.content(response)
75
+ rescue RouteExhaustedError => e
76
+ raise RepairImpossibleError, e.message
140
77
  end
141
78
  end
142
79
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'errors'
3
+ require_relative 'utils'
4
+ require_relative 'model_candidate'
5
+ require_relative 'stages'
6
+
7
+ module Aireview
8
+ # A routing plan of independent chains: every stage has its own primary
9
+ # model and reserves in walking order. Critique does not depend on which
10
+ # model answered in Generate.
11
+ #
12
+ # The plan interface (ModelPool implements it too): chain(stage),
13
+ # critique_chain(after:), primary(stage), weaker?, signature, rule,
14
+ # pool?, pool_stage?, pool_member?, start_used?, warnings.
15
+ class StageChains
16
+ # settings — per stage: provider, model, fallbacks (the raw list from the
17
+ # config), max_prompt_chars. only_primary keeps one model.
18
+ def self.build(settings, only_primary: false)
19
+ chains = settings.to_h do |stage, stage_settings|
20
+ [stage.to_s, stage_chain(stage.to_s, stage_settings, only_primary: only_primary)]
21
+ end
22
+ new(chains)
23
+ end
24
+
25
+ def self.stage_chain(stage, settings, only_primary:)
26
+ primary = ModelCandidate.new(
27
+ provider: settings.fetch(:provider).to_s,
28
+ model: settings[:model],
29
+ max_prompt_chars: settings.fetch(:max_prompt_chars)
30
+ )
31
+ return [primary] if only_primary
32
+
33
+ [primary, *fallbacks(stage, Array(settings[:fallbacks]), primary)]
34
+ end
35
+
36
+ # A reserve without a provider inherits the stage provider, without a
37
+ # limit its limit.
38
+ def self.fallbacks(stage, items, primary)
39
+ items.each_with_index.map do |item, index|
40
+ item = ModelCandidate.parse_item(item)
41
+ name = "llm.#{stage}.fallbacks[#{index}]"
42
+ raise ConfigError, "#{name} must be a model name or a hash with model" unless item.is_a?(Hash)
43
+ raise ConfigError, "#{name}.model is required" if Aireview::Utils.blank?(item['model'])
44
+
45
+ limit = item['max_prompt_chars']
46
+ ModelCandidate.new(
47
+ provider: (item['provider'] || primary.provider).to_s,
48
+ model: item['model'].to_s,
49
+ max_prompt_chars: limit.nil? ? primary.max_prompt_chars : positive_limit(limit, name)
50
+ )
51
+ end
52
+ end
53
+
54
+ def self.positive_limit(value, name)
55
+ integer = Integer(value, exception: false)
56
+ return integer if integer&.positive?
57
+
58
+ raise ConfigError, "#{name}.max_prompt_chars must be a positive integer, got #{value.inspect}"
59
+ end
60
+
61
+ def initialize(chains)
62
+ @chains = chains
63
+ end
64
+
65
+ def chain(stage)
66
+ @chains.fetch(stage.to_s) { raise ArgumentError, "unknown LLM stage #{stage.inspect}" }
67
+ end
68
+
69
+ def stage?(stage)
70
+ @chains.key?(stage.to_s)
71
+ end
72
+
73
+ def critique_chain(after:)
74
+ chain('critique')
75
+ end
76
+
77
+ def primary(stage)
78
+ chain(stage).first
79
+ end
80
+
81
+ def weaker?(_critique_candidate, _generate_candidate)
82
+ false
83
+ end
84
+
85
+ def signature
86
+ nil
87
+ end
88
+
89
+ def rule
90
+ nil
91
+ end
92
+
93
+ def pool?
94
+ false
95
+ end
96
+
97
+ def pool_stage?(_stage)
98
+ false
99
+ end
100
+
101
+ def pool_member?(_model)
102
+ false
103
+ end
104
+
105
+ def start_used?(_stage)
106
+ false
107
+ end
108
+
109
+ def warnings
110
+ []
111
+ end
112
+ end
113
+ end