aireview 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,17 @@
2
2
  require 'json'
3
3
  require_relative 'errors'
4
4
  require_relative 'context_builder'
5
+ require_relative 'candidate_checker'
6
+ require_relative 'result_validation'
5
7
  require_relative 'review_renderer'
6
8
  require_relative 'review_schemas'
7
9
  require_relative 'reviewer'
8
10
 
9
11
  module Aireview
10
12
  class ReviewPipeline
11
- class SchemaError < StandardError
12
- end
13
+ include ResultValidation
14
+
15
+ SchemaError = ResultValidation::SchemaError
13
16
 
14
17
  REPAIR_SYSTEM_PROMPT = <<~PROMPT.strip.freeze
15
18
  You fix invalid JSON produced by another LLM call.
@@ -47,19 +50,18 @@ module Aireview
47
50
  summary = generate_result['summary']
48
51
  candidates = Array(generate_result['candidates'])
49
52
  @logger.info("Pipeline generate pass completed with #{candidates.size} candidate(s)")
53
+ candidates = check_candidates(context: context, changes: changes, candidates: candidates)
50
54
 
51
- accepted = if critique
52
- @logger.info("Pipeline critique pass started (model=#{@config.critique_model})")
53
- critique_candidates(context: context, candidates: candidates)
54
- else
55
- @logger.info('Pipeline critique pass skipped')
56
- candidates
57
- end
55
+ accepted = critique ? maybe_critique(context: context, candidates: candidates) : skip_critique(candidates)
58
56
 
59
57
  @logger.info("Pipeline finished with #{accepted.size} accepted finding(s)")
60
58
 
61
- renderer = ReviewRenderer.new(language: @config.review_language)
62
- renderer.render(accepted, summary: summary, coverage: context.coverage)
59
+ ReviewRenderer.new(language: @config.review_language).render(
60
+ accepted,
61
+ summary: summary,
62
+ coverage: context.coverage,
63
+ fallback_models: @reviewer.fallback_models
64
+ )
63
65
  end
64
66
 
65
67
  def dry_run_prompts(merge_request:, changes:, jira_issue: nil, critique: true)
@@ -83,6 +85,10 @@ module Aireview
83
85
  generate_temperature: @config.generate_temperature,
84
86
  critique_model: @config.critique_model,
85
87
  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]),
91
+ time_budget: @config.llm_time_budget,
86
92
  coverage: context.coverage,
87
93
  sizes: context.sizes
88
94
  }
@@ -90,6 +96,31 @@ module Aireview
90
96
 
91
97
  private
92
98
 
99
+ # Привязка к коду проверяется по диффу, который видела модель, до критика:
100
+ # ему уходят пометки, в отчёт — сброшенная строка и знак ненайденной цитаты.
101
+ def check_candidates(context:, changes:, candidates:)
102
+ CandidateChecker.new(
103
+ changes: changes,
104
+ diff_text: context.diff_text,
105
+ coverage: context.coverage,
106
+ logger: @logger
107
+ ).check(candidates)
108
+ end
109
+
110
+ def skip_critique(candidates, reason = nil)
111
+ @logger.info(['Pipeline critique pass skipped', reason].compact.join(': '))
112
+ candidates
113
+ end
114
+
115
+ # Критику нечего фильтровать без кандидатов: запрос к LLM был бы пустой
116
+ # тратой квоты и времени.
117
+ def maybe_critique(context:, candidates:)
118
+ return skip_critique(candidates, 'no candidates') if candidates.empty?
119
+
120
+ @logger.info("Pipeline critique pass started (model=#{@config.critique_model})")
121
+ critique_candidates(context: context, candidates: candidates)
122
+ end
123
+
93
124
  def critique_candidates(context:, candidates:)
94
125
  candidates_json = JSON.pretty_generate(candidates)
95
126
  candidates_by_id = index_candidates_by_id(candidates)
@@ -209,53 +240,6 @@ module Aireview
209
240
  parsed
210
241
  end
211
242
 
212
- def validate_generate_result_shape!(parsed)
213
- valid_shape = parsed.is_a?(Hash) && parsed['candidates'].is_a?(Array)
214
- raise SchemaError, 'expected an object with summary and candidates array' unless valid_shape
215
- raise SchemaError, 'each generate candidate must be an object' unless parsed['candidates'].all?(Hash)
216
- end
217
-
218
- def validate_critique_result_shape!(parsed)
219
- valid_shape = parsed.is_a?(Hash) && parsed['verdicts'].is_a?(Array)
220
- raise SchemaError, 'expected an object with verdicts array' unless valid_shape
221
- raise SchemaError, 'each critique verdict must be an object' unless parsed['verdicts'].all?(Hash)
222
- end
223
-
224
- def validate_identifiers!(identifiers, missing_message:, duplicate_prefix:)
225
- raise SchemaError, missing_message unless identifiers.all?
226
-
227
- duplicate_ids = identifiers.group_by(&:itself).select { |_, ids| ids.size > 1 }.keys
228
- return if duplicate_ids.empty?
229
-
230
- raise SchemaError, "#{duplicate_prefix}: #{duplicate_ids.join(', ')}"
231
- end
232
-
233
- def validate_expected_verdict_ids!(verdict_ids, expected_ids)
234
- return unless expected_ids
235
-
236
- unknown_ids = verdict_ids - expected_ids
237
- missing_ids = expected_ids - verdict_ids
238
- raise SchemaError, "unknown verdict ids: #{unknown_ids.join(', ')}" unless unknown_ids.empty?
239
- raise SchemaError, "missing verdict ids: #{missing_ids.join(', ')}" unless missing_ids.empty?
240
- end
241
-
242
- def validate_verdict!(verdict)
243
- id = normalize_id(value(verdict, 'id'))
244
- decision = normalize_decision(value(verdict, 'decision'))
245
- raise SchemaError, "invalid verdict decision for #{id}" unless %w[keep reject].include?(decision)
246
-
247
- refinement = value(verdict, 'refinement')
248
- raise SchemaError, "reject verdict cannot include refinement for #{id}" if invalid_refinement?(verdict, decision)
249
- return if refinement.nil?
250
- return if refinement.is_a?(Hash)
251
-
252
- raise SchemaError, "refinement must be an object for #{id}"
253
- end
254
-
255
- def invalid_refinement?(verdict, decision)
256
- refinement_key?(verdict) && decision != 'keep'
257
- end
258
-
259
243
  def repair_json(raw:, kind:, expected:, stage:, critique_candidate_ids: nil)
260
244
  schema = expected == :critique ? ReviewSchemas.critique : ReviewSchemas.generate
261
245
  user_prompt = <<~PROMPT
@@ -345,21 +329,14 @@ module Aireview
345
329
  hash[key] || hash[key.to_sym]
346
330
  end
347
331
 
348
- def normalize_id(value)
349
- presence(value)
350
- end
351
-
352
332
  def normalize_decision(value)
353
333
  value.to_s.strip.downcase
354
334
  end
355
335
 
356
- def refinement_key?(verdict)
357
- verdict.key?('refinement') || verdict.key?(:refinement)
358
- end
359
-
360
336
  def presence(value)
361
337
  string = value.to_s.strip
362
338
  string.empty? ? nil : string
363
339
  end
340
+ alias normalize_id presence
364
341
  end
365
342
  end
@@ -44,7 +44,9 @@ module Aireview
44
44
  sections_truncated: 'sections truncated',
45
45
  hunks_of: 'hunks shown',
46
46
  diff_unavailable: 'diff not available',
47
- section_list: 'Truncated sections'
47
+ section_list: 'Truncated sections',
48
+ fallback_used: 'Fallback model used',
49
+ quote_missing: 'quote not found in the diff'
48
50
  },
49
51
  'ru' => {
50
52
  summary: 'Сводка',
@@ -68,7 +70,9 @@ module Aireview
68
70
  sections_truncated: 'секций усечено',
69
71
  hunks_of: 'хунков показано',
70
72
  diff_unavailable: 'дифф недоступен',
71
- section_list: 'Усечённые секции'
73
+ section_list: 'Усечённые секции',
74
+ fallback_used: 'Использована резервная модель',
75
+ quote_missing: 'цитата не найдена в диффе'
72
76
  }
73
77
  }.freeze
74
78
 
@@ -80,10 +84,8 @@ module Aireview
80
84
  # result по-прежнему про найденные проблемы; неполнота покрытия
81
85
  # дописывается рядом с ним, чтобы строка результата не читалась как
82
86
  # «проверено всё».
83
- def render(accepted, summary:, coverage: nil)
84
- findings = sorted_findings(Array(accepted)).first(TOTAL_FINDINGS_LIMIT)
85
- mismatches = findings.select { |finding| category(finding) == 'task_mismatch' }.first(MISMATCH_LIMIT)
86
- important = findings.select { |finding| important_finding?(finding) }.first(IMPORTANT_LIMIT)
87
+ def render(accepted, summary:, coverage: nil, fallback_models: {})
88
+ mismatches, important = select_findings(Array(accepted))
87
89
  result = mismatches.empty? && important.empty? ? 'ok' : 'needs attention'
88
90
 
89
91
  <<~MARKDOWN.rstrip
@@ -102,13 +104,25 @@ module Aireview
102
104
  ## #{label(:result)}
103
105
 
104
106
  #{result}#{partial_note(coverage)}
105
- #{coverage_block(coverage)}
107
+ #{coverage_block(coverage)}#{fallback_note(fallback_models)}
106
108
  #{label(:disclaimer)}
107
109
  MARKDOWN
108
110
  end
109
111
 
110
112
  private
111
113
 
114
+ # Сначала отбор того, что вообще показывается, потом лимиты разделов и
115
+ # только затем общий лимит: находка, которую не показать из-за категории
116
+ # или лимита раздела, не должна занимать общий слот.
117
+ def select_findings(accepted)
118
+ sorted = sorted_findings(accepted)
119
+ mismatches = sorted.select { |finding| category(finding) == 'task_mismatch' }.first(MISMATCH_LIMIT)
120
+ important = sorted.select { |finding| important_finding?(finding) }.first(IMPORTANT_LIMIT)
121
+ shown = sorted.select { |finding| mismatches.include?(finding) || important.include?(finding) }
122
+ .first(TOTAL_FINDINGS_LIMIT)
123
+ [mismatches & shown, important & shown]
124
+ end
125
+
112
126
  def sorted_findings(findings)
113
127
  findings
114
128
  .grep(Hash)
@@ -167,13 +181,22 @@ module Aireview
167
181
  "\n## #{label(:not_reviewed)}\n\n#{lines.join("\n")}\n"
168
182
  end
169
183
 
184
+ # Смена ключа остаётся в логах; смена модели видна читателю, потому что
185
+ # запасная модель может ревьюить слабее основной.
186
+ def fallback_note(fallback_models)
187
+ return '' if fallback_models.nil? || fallback_models.empty?
188
+
189
+ used = fallback_models.map { |stage, model| "#{stage} — #{model}" }.join(', ')
190
+ "\n#{label(:fallback_used)}: #{used}.\n"
191
+ end
192
+
170
193
  def location(finding)
171
194
  file = presence(value(finding, 'file'))
172
195
  line = value(finding, 'line')
173
196
  return label(:not_specified) unless file
174
- return file if line.nil? || line.to_s.empty?
175
197
 
176
- "#{file}:#{line}"
198
+ location = line.to_s.empty? ? file : "#{file}:#{line}"
199
+ value(finding, 'quote_missing') ? "#{location} (#{label(:quote_missing)})" : location
177
200
  end
178
201
 
179
202
  def label(key)
@@ -2,22 +2,17 @@
2
2
  require_relative 'errors'
3
3
  require_relative 'output_schemas'
4
4
  require_relative 'utils'
5
+ require_relative 'llm_router'
5
6
  require 'timeout'
6
7
 
7
8
  module Aireview
8
9
  class Reviewer
9
- MAX_TRANSIENT_RETRIES = 3
10
- TRANSIENT_RETRY_BASE_DELAY = 2.0
11
- TRANSIENT_RETRY_JITTER_RANGE = 2.0..5.0
12
- PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE = 2.0..2.4
13
- OVERLOADED_RETRY_DELAYS = [120.0, 300.0, 300.0, 300.0].freeze
14
- OVERLOADED_RETRY_JITTER_RANGE = 0.85..1.15
15
- RETRY_WAIT_LOG_FORMAT = 'LLM %<stage>s request will sleep %<delay>.1fs before retry%<source>s ' \
16
- '(attempt %<next_attempt>d/%<max_attempts>d, model=%<model>s)'
10
+ attr_reader :router
17
11
 
18
- def initialize(config:, logger: Logger.new($stderr))
12
+ def initialize(config:, logger: Logger.new($stderr), router: nil)
19
13
  @config = config
20
14
  @logger = logger
15
+ @router = router || LlmRouter.new(config: config, logger: logger)
21
16
  @llm_contexts = {}
22
17
  end
23
18
 
@@ -26,12 +21,7 @@ module Aireview
26
21
  stage: 'generate',
27
22
  system: system_prompt,
28
23
  user: user_prompt,
29
- options: {
30
- model: @config.generate_model,
31
- temperature: @config.generate_temperature,
32
- provider: @config.generate_provider,
33
- schema: GenerateOutputSchema
34
- }
24
+ options: {temperature: @config.generate_temperature, schema: GenerateOutputSchema}
35
25
  )
36
26
  end
37
27
 
@@ -40,41 +30,34 @@ module Aireview
40
30
  stage: 'critique',
41
31
  system: system_prompt,
42
32
  user: user_prompt,
43
- options: {
44
- model: @config.critique_model,
45
- temperature: @config.critique_temperature,
46
- provider: @config.critique_provider,
47
- schema: CritiqueOutputSchema
48
- }
33
+ options: {temperature: @config.critique_temperature, schema: CritiqueOutputSchema}
49
34
  )
50
35
  end
51
36
 
37
+ # Стадии, ответившие запасной моделью, для строки в отчёте.
38
+ def fallback_models
39
+ @router.fallback_models
40
+ end
41
+
52
42
  private
53
43
 
54
44
  def call_llm(stage:, system:, user:, options:)
55
45
  require 'ruby_llm'
56
46
 
57
47
  @config.require_llm_configuration!
58
- context = llm_context(stage, options[:provider])
59
- response = request_with_retries(stage: stage, model: options[:model]) do
48
+ response = @router.call(stage: stage, request_chars: system.length + user.length) do |route, timeout|
60
49
  perform_llm_request(
61
- context: context,
50
+ context: llm_context(stage, route),
62
51
  stage: stage,
63
52
  system: system,
64
53
  user: user,
65
- options: options
54
+ options: options.merge(model: route.candidate.model, provider: route.candidate.provider, timeout: timeout)
66
55
  )
67
56
  end
68
57
  response.content
69
58
  rescue LoadError => e
70
59
  @logger.error("LLM #{stage} setup failed: #{e.message}")
71
60
  raise ConfigError, "Missing dependency: #{e.message}"
72
- rescue Timeout::Error
73
- @logger.warn(
74
- "LLM #{stage} request timed out after #{@config.llm_timeout} seconds (model=#{options[:model]})"
75
- )
76
- raise ApiError, "LLM request timed out after #{@config.llm_timeout} seconds. " \
77
- "Try again later or switch model via --generate-model/--critique-model."
78
61
  end
79
62
 
80
63
  def perform_llm_request(context:, stage:, system:, user:, options:)
@@ -86,9 +69,12 @@ module Aireview
86
69
  .with_temperature(temperature.to_f)
87
70
  .with_schema(options[:schema])
88
71
  chat.with_instructions(system)
89
- response = Timeout.timeout(@config.llm_timeout.to_f) { chat.ask(user) }
72
+ response = Timeout.timeout(options[:timeout]) { chat.ask(user) }
90
73
  @logger.info("LLM #{stage} request completed (model=#{model})")
91
74
  response
75
+ rescue Timeout::Error
76
+ @logger.warn("LLM #{stage} request timed out after #{options[:timeout].round} seconds (model=#{model})")
77
+ raise
92
78
  end
93
79
 
94
80
  def configure_reasoning(chat:, model:, provider:)
@@ -108,160 +94,21 @@ module Aireview
108
94
  context.chat(model: model, provider: provider.to_sym, assume_model_exists: true)
109
95
  end
110
96
 
111
- def request_with_retries(stage:, model:)
112
- attempt = 0
113
-
114
- begin
115
- attempt += 1
116
- yield
117
- rescue StandardError => e
118
- raise unless ruby_llm_api_error?(e)
119
-
120
- @logger.warn("LLM #{stage} request failed (model=#{model}): #{e.class}: #{e.message}")
121
- raise ApiError, llm_error_message(e) unless retry_llm_request?(e, attempt)
122
-
123
- wait_before_retry(error: e, attempt: attempt, stage: stage, model: model)
124
- retry
125
- end
126
- end
127
-
128
- def retry_llm_request?(error, attempt)
129
- transient_llm_error?(error) && attempt <= max_retries(error)
130
- end
131
-
132
- # Перегруженному провайдеру даётся на один повтор больше: 503 у Gemini
133
- # держится дольше, чем rate limit или сетевой сбой.
134
- def max_retries(error)
135
- overloaded_llm_error?(error) ? OVERLOADED_RETRY_DELAYS.size : MAX_TRANSIENT_RETRIES
136
- end
137
-
138
- def wait_before_retry(error:, attempt:, stage:, model:)
139
- retry_delay = transient_retry_delay(error: error, attempt: attempt)
140
- @logger.warn(
141
- format(
142
- RETRY_WAIT_LOG_FORMAT,
143
- stage: stage,
144
- delay: retry_delay[:delay],
145
- source: retry_delay_source(retry_delay),
146
- next_attempt: attempt + 1,
147
- max_attempts: max_retries(error) + 1,
148
- model: model
149
- )
150
- )
151
- sleep_started_at = monotonic_time
152
- sleep(retry_delay[:delay])
153
- log_retry_wait_completed(stage, model, monotonic_time - sleep_started_at)
154
- end
155
-
156
- def log_retry_wait_completed(stage, model, waited)
157
- @logger.info(
158
- format(
159
- 'LLM %<stage>s retry wait completed after %<waited>.1fs (model=%<model>s)',
160
- stage: stage,
161
- waited: waited,
162
- model: model
163
- )
164
- )
165
- end
166
-
167
- def ruby_llm_api_error?(error)
168
- defined?(RubyLLM::Error) && error.is_a?(RubyLLM::Error)
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)
169
102
  end
170
103
 
171
- def transient_llm_error?(error)
172
- error.is_a?(RubyLLM::RateLimitError) ||
173
- error.is_a?(RubyLLM::ServiceUnavailableError) ||
174
- error.is_a?(RubyLLM::OverloadedError)
175
- end
176
-
177
- def monotonic_time
178
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
179
- end
180
-
181
- def transient_retry_delay(error:, attempt:)
182
- provider_delay = retry_after_seconds(error.message)
183
- if provider_delay
184
- multiplier = rand(PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE)
185
- return {
186
- delay: provider_delay * multiplier,
187
- provider_delay: provider_delay,
188
- multiplier: multiplier,
189
- strategy: :provider_hint
190
- }
191
- end
192
-
193
- if overloaded_llm_error?(error)
194
- base_delay = OVERLOADED_RETRY_DELAYS.fetch(attempt - 1, OVERLOADED_RETRY_DELAYS.last)
195
- multiplier = rand(OVERLOADED_RETRY_JITTER_RANGE)
196
- return {
197
- delay: base_delay * multiplier,
198
- base_delay: base_delay,
199
- multiplier: multiplier,
200
- strategy: :overloaded
201
- }
202
- end
203
-
204
- base_delay = TRANSIENT_RETRY_BASE_DELAY * (2**(attempt - 1))
205
- {
206
- delay: base_delay + rand(TRANSIENT_RETRY_JITTER_RANGE),
207
- provider_delay: nil,
208
- multiplier: nil,
209
- strategy: :fallback
210
- }
211
- end
212
-
213
- def retry_delay_source(retry_delay)
214
- if retry_delay[:strategy] == :overloaded
215
- return format(
216
- ' (overloaded backoff %<base_delay>.0fs, multiplier %<multiplier>.2fx)',
217
- base_delay: retry_delay[:base_delay],
218
- multiplier: retry_delay[:multiplier]
219
- )
220
- end
221
- return '' unless retry_delay[:provider_delay]
222
-
223
- format(
224
- ' (provider retry hint %<provider_delay>.1fs, multiplier %<multiplier>.2fx)',
225
- provider_delay: retry_delay[:provider_delay],
226
- multiplier: retry_delay[:multiplier]
227
- )
228
- end
229
-
230
- def overloaded_llm_error?(error)
231
- error.is_a?(RubyLLM::ServiceUnavailableError) ||
232
- error.is_a?(RubyLLM::OverloadedError)
233
- end
234
-
235
- def retry_after_seconds(message)
236
- match = message.to_s.match(/retry\s+(?:in|after)\s+(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b/i)
237
- match[1].to_f if match
238
- end
239
-
240
- def llm_error_message(error)
241
- case error
242
- when RubyLLM::ServiceUnavailableError, RubyLLM::OverloadedError
243
- "LLM service is temporarily unavailable or overloaded: #{error.message}. " \
244
- "Try again later or switch model via --generate-model/--critique-model."
245
- when RubyLLM::RateLimitError
246
- "LLM rate limit exceeded: #{error.message}. " \
247
- "Try again later or switch model via --generate-model/--critique-model."
248
- when RubyLLM::ContextLengthExceededError
249
- "LLM context limit exceeded: #{error.message}. Try reducing the MR diff or ignore more paths."
250
- else
251
- "LLM API request failed: #{error.message}"
252
- end
253
- end
254
-
255
- def llm_context(stage, provider)
256
- @llm_contexts[stage] ||= build_llm_context(provider.to_s)
257
- end
258
-
259
- def build_llm_context(provider)
260
- api_key = @config.provider_api_key(provider) unless provider == 'ollama'
261
-
104
+ # Повторами занимается роутер: встроенные ретраи RubyLLM/Faraday (по
105
+ # умолчанию 3) превратили бы каждую нашу попытку в четыре HTTP-запроса и
106
+ # жгли бы квоту до того, как ошибка дойдёт до классификатора.
107
+ def build_llm_context(provider, api_key)
262
108
  RubyLLM.context do |ruby_config|
263
109
  configure_http_proxy(ruby_config)
264
110
  ruby_config.request_timeout = @config.llm_timeout.to_f
111
+ ruby_config.max_retries = 0
265
112
  configure_provider(ruby_config, provider, api_key)
266
113
  end
267
114
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Aireview
3
- VERSION = '0.2.1'
3
+ VERSION = '0.3.0'
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aireview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Levenko
@@ -69,8 +69,10 @@ files:
69
69
  - bin/aireview
70
70
  - config/.aireview.yml.example
71
71
  - lib/aireview.rb
72
+ - lib/aireview/candidate_checker.rb
72
73
  - lib/aireview/cli.rb
73
74
  - lib/aireview/config.rb
75
+ - lib/aireview/config_fallbacks.rb
74
76
  - lib/aireview/config_limits.rb
75
77
  - lib/aireview/context_budget.rb
76
78
  - lib/aireview/context_builder.rb
@@ -79,11 +81,14 @@ files:
79
81
  - lib/aireview/errors.rb
80
82
  - lib/aireview/gitlab_client.rb
81
83
  - lib/aireview/jira_client.rb
84
+ - lib/aireview/llm_failure.rb
85
+ - lib/aireview/llm_router.rb
82
86
  - lib/aireview/mr_parser.rb
83
87
  - lib/aireview/output_schemas.rb
84
88
  - lib/aireview/prompts/critique.txt
85
89
  - lib/aireview/prompts/generate.txt
86
90
  - lib/aireview/publisher.rb
91
+ - lib/aireview/result_validation.rb
87
92
  - lib/aireview/review_marker.rb
88
93
  - lib/aireview/review_pipeline.rb
89
94
  - lib/aireview/review_renderer.rb