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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +98 -12
- data/config/.aireview.yml.example +8 -1
- data/lib/aireview/candidate_checker.rb +167 -0
- data/lib/aireview/cli.rb +7 -1
- data/lib/aireview/config.rb +27 -17
- data/lib/aireview/config_fallbacks.rb +112 -0
- data/lib/aireview/context_builder.rb +2 -2
- data/lib/aireview/dry_run_report.rb +23 -9
- data/lib/aireview/llm_failure.rb +90 -0
- data/lib/aireview/llm_router.rb +320 -0
- data/lib/aireview/prompts/critique.txt +7 -0
- data/lib/aireview/result_validation.rb +65 -0
- data/lib/aireview/review_pipeline.rb +43 -66
- data/lib/aireview/review_renderer.rb +32 -9
- data/lib/aireview/reviewer.rb +28 -181
- data/lib/aireview/version.rb +1 -1
- metadata +6 -1
|
@@ -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
|
-
|
|
12
|
-
|
|
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 =
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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)
|
data/lib/aireview/reviewer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
data/lib/aireview/version.rb
CHANGED
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.
|
|
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
|