aireview 0.2.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +353 -18
- data/config/.aireview.yml.example +8 -1
- data/config/defaults.yml +47 -0
- data/lib/aireview/candidate_checker.rb +169 -0
- data/lib/aireview/cli.rb +53 -13
- data/lib/aireview/config.rb +74 -179
- data/lib/aireview/config_fallbacks.rb +123 -0
- data/lib/aireview/config_layers.rb +110 -0
- data/lib/aireview/config_limits.rb +10 -35
- data/lib/aireview/config_loader.rb +240 -0
- data/lib/aireview/context_budget.rb +22 -20
- data/lib/aireview/context_builder.rb +20 -19
- data/lib/aireview/diff_fetcher.rb +12 -11
- data/lib/aireview/dry_run_report.rb +49 -10
- data/lib/aireview/errors.rb +8 -0
- data/lib/aireview/gitlab_client.rb +10 -9
- data/lib/aireview/llm_client.rb +113 -0
- data/lib/aireview/llm_failure.rb +107 -0
- data/lib/aireview/llm_router.rb +477 -0
- data/lib/aireview/model_candidate.rb +28 -0
- data/lib/aireview/model_checker.rb +148 -0
- data/lib/aireview/model_pool.rb +224 -0
- data/lib/aireview/model_state.rb +82 -0
- data/lib/aireview/prompts/critique.txt +7 -0
- data/lib/aireview/publisher.rb +6 -6
- data/lib/aireview/result_parser.rb +98 -0
- data/lib/aireview/review_marker.rb +16 -28
- data/lib/aireview/review_pipeline.rb +119 -140
- data/lib/aireview/review_renderer.rb +44 -13
- data/lib/aireview/reviewer.rb +42 -267
- data/lib/aireview/stage_chains.rb +113 -0
- data/lib/aireview/stages.rb +7 -0
- data/lib/aireview/utils.rb +29 -0
- data/lib/aireview/version.rb +1 -1
- data/lib/aireview.rb +1 -0
- metadata +23 -2
data/lib/aireview/reviewer.rb
CHANGED
|
@@ -1,295 +1,70 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require_relative 'errors'
|
|
3
3
|
require_relative 'output_schemas'
|
|
4
|
-
require_relative '
|
|
5
|
-
|
|
4
|
+
require_relative 'llm_router'
|
|
5
|
+
require_relative 'llm_client'
|
|
6
6
|
|
|
7
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.
|
|
8
11
|
class Reviewer
|
|
9
|
-
|
|
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)'
|
|
12
|
+
attr_reader :router
|
|
17
13
|
|
|
18
|
-
def initialize(config:, logger: Logger.new($stderr))
|
|
14
|
+
def initialize(config:, logger: Logger.new($stderr), router: nil, client: nil)
|
|
19
15
|
@config = config
|
|
20
16
|
@logger = logger
|
|
21
|
-
@
|
|
17
|
+
@router = router || LlmRouter.new(config: config, logger: logger)
|
|
18
|
+
@client = client || LlmClient.new(config: config, logger: logger)
|
|
22
19
|
end
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
model: @config.generate_model,
|
|
31
|
-
temperature: @config.generate_temperature,
|
|
32
|
-
provider: @config.generate_provider,
|
|
33
|
-
schema: GenerateOutputSchema
|
|
34
|
-
}
|
|
35
|
-
)
|
|
21
|
+
# pinned — a request only to the model that answered last in the stage
|
|
22
|
+
# (the repair of its own JSON): its failure is RepairImpossibleError.
|
|
23
|
+
def generate(system_prompt:, user_prompt:, pinned: false)
|
|
24
|
+
prompt = LlmClient::Prompt.new(stage: 'generate', system: system_prompt, user: user_prompt,
|
|
25
|
+
temperature: @config.generate_temperature, schema: GenerateOutputSchema)
|
|
26
|
+
call_llm(prompt, pinned: pinned)
|
|
36
27
|
end
|
|
37
28
|
|
|
38
|
-
def critique(system_prompt:, user_prompt:)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
user: user_prompt,
|
|
43
|
-
options: {
|
|
44
|
-
model: @config.critique_model,
|
|
45
|
-
temperature: @config.critique_temperature,
|
|
46
|
-
provider: @config.critique_provider,
|
|
47
|
-
schema: CritiqueOutputSchema
|
|
48
|
-
}
|
|
49
|
-
)
|
|
29
|
+
def critique(system_prompt:, user_prompt:, pinned: false)
|
|
30
|
+
prompt = LlmClient::Prompt.new(stage: 'critique', system: system_prompt, user: user_prompt,
|
|
31
|
+
temperature: @config.critique_temperature, schema: CritiqueOutputSchema)
|
|
32
|
+
call_llm(prompt, pinned: pinned)
|
|
50
33
|
end
|
|
51
34
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
require 'ruby_llm'
|
|
56
|
-
|
|
57
|
-
@config.require_llm_configuration!
|
|
58
|
-
context = llm_context(stage, options[:provider])
|
|
59
|
-
response = request_with_retries(stage: stage, model: options[:model]) do
|
|
60
|
-
perform_llm_request(
|
|
61
|
-
context: context,
|
|
62
|
-
stage: stage,
|
|
63
|
-
system: system,
|
|
64
|
-
user: user,
|
|
65
|
-
options: options
|
|
66
|
-
)
|
|
67
|
-
end
|
|
68
|
-
response.content
|
|
69
|
-
rescue LoadError => e
|
|
70
|
-
@logger.error("LLM #{stage} setup failed: #{e.message}")
|
|
71
|
-
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
|
-
end
|
|
79
|
-
|
|
80
|
-
def perform_llm_request(context:, stage:, system:, user:, options:)
|
|
81
|
-
model = options[:model]
|
|
82
|
-
temperature = options[:temperature]
|
|
83
|
-
@logger.info("LLM #{stage} request started (model=#{model}, temperature=#{temperature})")
|
|
84
|
-
chat = build_chat(context: context, stage: stage, model: model, provider: options[:provider])
|
|
85
|
-
chat = configure_reasoning(chat: chat, model: model, provider: options[:provider])
|
|
86
|
-
.with_temperature(temperature.to_f)
|
|
87
|
-
.with_schema(options[:schema])
|
|
88
|
-
chat.with_instructions(system)
|
|
89
|
-
response = Timeout.timeout(@config.llm_timeout.to_f) { chat.ask(user) }
|
|
90
|
-
@logger.info("LLM #{stage} request completed (model=#{model})")
|
|
91
|
-
response
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def configure_reasoning(chat:, model:, provider:)
|
|
95
|
-
return chat unless provider == 'ollama' && model.start_with?('gpt-oss:')
|
|
96
|
-
|
|
97
|
-
chat.with_thinking(effort: :low)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def build_chat(context:, stage:, model:, provider:)
|
|
101
|
-
context.chat(model: model, provider: provider.to_sym)
|
|
102
|
-
rescue RubyLLM::ModelNotFoundError
|
|
103
|
-
@logger.warn(
|
|
104
|
-
"LLM #{stage}: model not found in RubyLLM registry; " \
|
|
105
|
-
"using fallback with incomplete model metadata " \
|
|
106
|
-
"(model=#{model}, provider=#{provider})"
|
|
107
|
-
)
|
|
108
|
-
context.chat(model: model, provider: provider.to_sym, assume_model_exists: true)
|
|
109
|
-
end
|
|
110
|
-
|
|
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)
|
|
35
|
+
# Stages answered by a fallback model, for the report line.
|
|
36
|
+
def fallback_models
|
|
37
|
+
@router.fallback_models
|
|
169
38
|
end
|
|
170
39
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
error.is_a?(RubyLLM::OverloadedError)
|
|
40
|
+
# The model that answered last in the stage with its place in the chain, for the log.
|
|
41
|
+
def answered_model(stage)
|
|
42
|
+
@router.answered(stage)
|
|
175
43
|
end
|
|
176
44
|
|
|
177
|
-
|
|
178
|
-
|
|
45
|
+
# Critique ran on a model below Generate in the pool (allow_weaker).
|
|
46
|
+
def critique_weaker?
|
|
47
|
+
@router.critique_weaker?
|
|
179
48
|
end
|
|
180
49
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
262
|
-
RubyLLM.context do |ruby_config|
|
|
263
|
-
configure_http_proxy(ruby_config)
|
|
264
|
-
ruby_config.request_timeout = @config.llm_timeout.to_f
|
|
265
|
-
configure_provider(ruby_config, provider, api_key)
|
|
266
|
-
end
|
|
50
|
+
# An invalid result: the model is excluded for the stage, the next
|
|
51
|
+
# request of the stage goes to another. Returns the excluded model or nil.
|
|
52
|
+
def exclude_answered_model(stage:, reason:)
|
|
53
|
+
@router.exclude_answered(stage: stage, reason: reason)
|
|
267
54
|
end
|
|
268
55
|
|
|
269
|
-
|
|
270
|
-
return unless Aireview::Utils.present?(@config.llm_http_proxy)
|
|
271
|
-
|
|
272
|
-
ruby_config.http_proxy = @config.llm_http_proxy
|
|
273
|
-
end
|
|
56
|
+
private
|
|
274
57
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
when 'ollama'
|
|
282
|
-
ruby_config.ollama_api_base = @config.ollama_api_base
|
|
283
|
-
else
|
|
284
|
-
raise ConfigError, "Unsupported LLM provider: #{provider.inspect}"
|
|
58
|
+
# A pinned route giving up is not an API error for the pipeline but
|
|
59
|
+
# "repair impossible": the same fate as an invalid result.
|
|
60
|
+
def call_llm(prompt, pinned:)
|
|
61
|
+
response = @router.call(stage: prompt.stage, request_chars: prompt.chars, pinned: pinned) do |route, timeout|
|
|
62
|
+
@client.request(prompt, candidate: route.candidate, key: route.key, key_index: route.key_index,
|
|
63
|
+
timeout: timeout)
|
|
285
64
|
end
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
ruby_config.public_send("#{provider}_api_key=", api_key)
|
|
290
|
-
return unless Aireview::Utils.present?(@config.llm_api_base)
|
|
291
|
-
|
|
292
|
-
ruby_config.public_send("#{provider}_api_base=", @config.llm_api_base)
|
|
65
|
+
response.content
|
|
66
|
+
rescue RouteExhaustedError => e
|
|
67
|
+
raise RepairImpossibleError, e.message
|
|
293
68
|
end
|
|
294
69
|
end
|
|
295
70
|
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
|
data/lib/aireview/utils.rb
CHANGED
|
@@ -14,5 +14,34 @@ module Aireview
|
|
|
14
14
|
def presence(value)
|
|
15
15
|
present?(value) ? value.to_s.strip : nil
|
|
16
16
|
end
|
|
17
|
+
|
|
18
|
+
# Merges settings by key: nested hashes merge, everything else (arrays
|
|
19
|
+
# included) is replaced by the right side as a whole.
|
|
20
|
+
def deep_merge(left, right)
|
|
21
|
+
left.merge(right) do |_, old_value, new_value|
|
|
22
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
23
|
+
deep_merge(old_value, new_value)
|
|
24
|
+
else
|
|
25
|
+
new_value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Hash keys are strings at any depth: YAML, env and LLM answers arrive in
|
|
31
|
+
# different shapes, everything downstream works with one.
|
|
32
|
+
def normalize_hash(value)
|
|
33
|
+
case value
|
|
34
|
+
when Hash
|
|
35
|
+
value.each_with_object({}) { |(key, inner), result| result[key.to_s] = normalize_hash(inner) }
|
|
36
|
+
when Array
|
|
37
|
+
value.map { |item| normalize_hash(item) }
|
|
38
|
+
else
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dig(data, *keys)
|
|
44
|
+
keys.reduce(data) { |accumulator, key| accumulator.is_a?(Hash) ? accumulator[key] : nil }
|
|
45
|
+
end
|
|
17
46
|
end
|
|
18
47
|
end
|
data/lib/aireview/version.rb
CHANGED
data/lib/aireview.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative 'aireview/secret_scrubber'
|
|
|
16
16
|
require_relative 'aireview/context_builder'
|
|
17
17
|
require_relative 'aireview/reviewer'
|
|
18
18
|
require_relative 'aireview/review_pipeline'
|
|
19
|
+
require_relative 'aireview/model_checker'
|
|
19
20
|
require_relative 'aireview/gitlab_client'
|
|
20
21
|
require_relative 'aireview/jira_client'
|
|
21
22
|
require_relative 'aireview/review_marker'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: aireview
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Denis Levenko
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dotenv
|
|
@@ -31,6 +31,9 @@ dependencies:
|
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
33
|
version: '2.14'
|
|
34
|
+
- - ">="
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: 2.14.4
|
|
34
37
|
type: :runtime
|
|
35
38
|
prerelease: false
|
|
36
39
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -38,6 +41,9 @@ dependencies:
|
|
|
38
41
|
- - "~>"
|
|
39
42
|
- !ruby/object:Gem::Version
|
|
40
43
|
version: '2.14'
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 2.14.4
|
|
41
47
|
- !ruby/object:Gem::Dependency
|
|
42
48
|
name: ruby_llm
|
|
43
49
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -68,10 +74,15 @@ files:
|
|
|
68
74
|
- README.md
|
|
69
75
|
- bin/aireview
|
|
70
76
|
- config/.aireview.yml.example
|
|
77
|
+
- config/defaults.yml
|
|
71
78
|
- lib/aireview.rb
|
|
79
|
+
- lib/aireview/candidate_checker.rb
|
|
72
80
|
- lib/aireview/cli.rb
|
|
73
81
|
- lib/aireview/config.rb
|
|
82
|
+
- lib/aireview/config_fallbacks.rb
|
|
83
|
+
- lib/aireview/config_layers.rb
|
|
74
84
|
- lib/aireview/config_limits.rb
|
|
85
|
+
- lib/aireview/config_loader.rb
|
|
75
86
|
- lib/aireview/context_budget.rb
|
|
76
87
|
- lib/aireview/context_builder.rb
|
|
77
88
|
- lib/aireview/diff_fetcher.rb
|
|
@@ -79,17 +90,27 @@ files:
|
|
|
79
90
|
- lib/aireview/errors.rb
|
|
80
91
|
- lib/aireview/gitlab_client.rb
|
|
81
92
|
- lib/aireview/jira_client.rb
|
|
93
|
+
- lib/aireview/llm_client.rb
|
|
94
|
+
- lib/aireview/llm_failure.rb
|
|
95
|
+
- lib/aireview/llm_router.rb
|
|
96
|
+
- lib/aireview/model_candidate.rb
|
|
97
|
+
- lib/aireview/model_checker.rb
|
|
98
|
+
- lib/aireview/model_pool.rb
|
|
99
|
+
- lib/aireview/model_state.rb
|
|
82
100
|
- lib/aireview/mr_parser.rb
|
|
83
101
|
- lib/aireview/output_schemas.rb
|
|
84
102
|
- lib/aireview/prompts/critique.txt
|
|
85
103
|
- lib/aireview/prompts/generate.txt
|
|
86
104
|
- lib/aireview/publisher.rb
|
|
105
|
+
- lib/aireview/result_parser.rb
|
|
87
106
|
- lib/aireview/review_marker.rb
|
|
88
107
|
- lib/aireview/review_pipeline.rb
|
|
89
108
|
- lib/aireview/review_renderer.rb
|
|
90
109
|
- lib/aireview/review_schemas.rb
|
|
91
110
|
- lib/aireview/reviewer.rb
|
|
92
111
|
- lib/aireview/secret_scrubber.rb
|
|
112
|
+
- lib/aireview/stage_chains.rb
|
|
113
|
+
- lib/aireview/stages.rb
|
|
93
114
|
- lib/aireview/utils.rb
|
|
94
115
|
- lib/aireview/version.rb
|
|
95
116
|
homepage: https://github.com/DenisDenis9331/aireview
|