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
|
@@ -8,13 +8,7 @@ module Aireview
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def render(dry_run)
|
|
11
|
-
|
|
12
|
-
@out.puts("Generate: #{dry_run[:generate_model]} temperature=#{dry_run[:generate_temperature]}")
|
|
13
|
-
if dry_run[:critique_prompt]
|
|
14
|
-
@out.puts("Critique: #{dry_run[:critique_model]} temperature=#{dry_run[:critique_temperature]}")
|
|
15
|
-
else
|
|
16
|
-
@out.puts('Critique: disabled')
|
|
17
|
-
end
|
|
11
|
+
render_settings(dry_run)
|
|
18
12
|
@out.puts
|
|
19
13
|
@out.puts('=== CONTEXT ===')
|
|
20
14
|
render_context_sizes(dry_run[:sizes])
|
|
@@ -37,6 +31,19 @@ module Aireview
|
|
|
37
31
|
|
|
38
32
|
private
|
|
39
33
|
|
|
34
|
+
def render_settings(dry_run)
|
|
35
|
+
@out.puts('=== LLM SETTINGS ===')
|
|
36
|
+
@out.puts("Generate: #{dry_run[:generate_model]} temperature=#{dry_run[:generate_temperature]}")
|
|
37
|
+
list('fallbacks', dry_run[:generate_fallbacks], separator: ' -> ')
|
|
38
|
+
if dry_run[:critique_prompt]
|
|
39
|
+
@out.puts("Critique: #{dry_run[:critique_model]} temperature=#{dry_run[:critique_temperature]}")
|
|
40
|
+
list('fallbacks', dry_run[:critique_fallbacks], separator: ' -> ')
|
|
41
|
+
else
|
|
42
|
+
@out.puts('Critique: disabled')
|
|
43
|
+
end
|
|
44
|
+
render_reserves(dry_run)
|
|
45
|
+
end
|
|
46
|
+
|
|
40
47
|
def render_context_sizes(sizes)
|
|
41
48
|
@out.puts("Sections: #{sizes[:sections]} chars, diff: #{sizes[:diff]} chars " \
|
|
42
49
|
"(budget #{sizes[:diff_budget]}, hunks #{sizes[:hunks_shown]}/#{sizes[:hunks_total]})")
|
|
@@ -57,8 +64,15 @@ module Aireview
|
|
|
57
64
|
list('diff not available', coverage.files_unavailable)
|
|
58
65
|
end
|
|
59
66
|
|
|
60
|
-
def
|
|
61
|
-
|
|
67
|
+
def render_reserves(dry_run)
|
|
68
|
+
keys = Array(dry_run[:api_keys]).map { |provider, count| "#{provider} #{count}" }
|
|
69
|
+
@out.puts("API keys: #{keys.join(', ')}") unless keys.empty?
|
|
70
|
+
@out.puts("Time budget: #{dry_run[:time_budget]}s") if dry_run[:time_budget]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def list(title, items, separator: ', ')
|
|
74
|
+
items = Array(items)
|
|
75
|
+
@out.puts(" #{title}: #{items.join(separator)}") unless items.empty?
|
|
62
76
|
end
|
|
63
77
|
end
|
|
64
78
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module Aireview
|
|
6
|
+
# Классификация ошибки LLM-запроса. Отвечает только на вопрос «что это»,
|
|
7
|
+
# решение «повторить, сменить ключ или модель» принимает LlmRouter.
|
|
8
|
+
#
|
|
9
|
+
# :daily_quota — суточная квота проекта на модель, повторы бесполезны;
|
|
10
|
+
# :rate_limit — минутный лимит, пройдёт через подсказанное время;
|
|
11
|
+
# :overloaded — 503/«high demand» у модели;
|
|
12
|
+
# :timeout — ответа нет дольше LLM_TIMEOUT;
|
|
13
|
+
# :fatal — ошибка API, которую резервы не лечат;
|
|
14
|
+
# :unhandled — не ошибка провайдера, пробрасывается как есть.
|
|
15
|
+
module LlmFailure
|
|
16
|
+
KINDS = %i[daily_quota rate_limit overloaded timeout fatal unhandled].freeze
|
|
17
|
+
QUOTA_FAILURE_TYPE = 'type.googleapis.com/google.rpc.QuotaFailure'
|
|
18
|
+
DAILY_QUOTA_ID = /PerDay/i
|
|
19
|
+
DAILY_QUOTA_TEXT = /\bper\s+day\b|\bdaily\b/i
|
|
20
|
+
RETRY_AFTER = /retry\s+(?:in|after)\s+(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b/i
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Сведения о квоте смотрим раньше класса исключения: RubyLLM превращает
|
|
25
|
+
# 429 со словом input_token в ContextLengthExceededError, хотя это
|
|
26
|
+
# исчерпанная токенная квота, а не слишком длинный запрос.
|
|
27
|
+
def classify(error)
|
|
28
|
+
return :timeout if transport_timeout?(error)
|
|
29
|
+
return :unhandled unless ruby_llm_error?(error)
|
|
30
|
+
|
|
31
|
+
quota_kind(error) || (overloaded?(error) ? :overloaded : :fatal)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Внешний Timeout.timeout и таймауты транспорта Faraday: последние не
|
|
35
|
+
# наследуют ни Timeout::Error, ни RubyLLM::Error.
|
|
36
|
+
def transport_timeout?(error)
|
|
37
|
+
return true if error.is_a?(Timeout::Error) || error.is_a?(Errno::ETIMEDOUT)
|
|
38
|
+
|
|
39
|
+
defined?(Faraday::TimeoutError) && error.is_a?(Faraday::TimeoutError)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def overloaded?(error)
|
|
43
|
+
error.is_a?(RubyLLM::ServiceUnavailableError) || error.is_a?(RubyLLM::OverloadedError)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ruby_llm_error?(error)
|
|
47
|
+
defined?(RubyLLM::Error) && error.is_a?(RubyLLM::Error)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Google кладёт вид квоты в QuotaFailure.violations[].quotaId
|
|
51
|
+
# (GenerateRequestsPerDay… / …PerMinute…). Метрика free_tier_requests
|
|
52
|
+
# одна и та же у обоих, по ней не различить. Текст сообщения — запасной
|
|
53
|
+
# признак, когда тела ответа нет.
|
|
54
|
+
def quota_kind(error)
|
|
55
|
+
ids = quota_ids(error)
|
|
56
|
+
return daily_quota_id?(ids) ? :daily_quota : :rate_limit unless ids.empty?
|
|
57
|
+
return unless error.is_a?(RubyLLM::RateLimitError)
|
|
58
|
+
|
|
59
|
+
error.message.to_s.match?(DAILY_QUOTA_TEXT) ? :daily_quota : :rate_limit
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def daily_quota_id?(ids)
|
|
63
|
+
ids.any? { |id| id.match?(DAILY_QUOTA_ID) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def quota_ids(error)
|
|
67
|
+
body = response_body(error)
|
|
68
|
+
details = body.is_a?(Hash) ? Array(body.dig('error', 'details')) : []
|
|
69
|
+
details.flat_map do |detail|
|
|
70
|
+
next [] unless detail.is_a?(Hash) && detail['@type'] == QUOTA_FAILURE_TYPE
|
|
71
|
+
|
|
72
|
+
Array(detail['violations']).filter_map { |violation| violation['quotaId'] if violation.is_a?(Hash) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def response_body(error)
|
|
77
|
+
body = error.respond_to?(:response) && error.response.respond_to?(:body) ? error.response.body : nil
|
|
78
|
+
return body unless body.is_a?(String)
|
|
79
|
+
|
|
80
|
+
JSON.parse(body)
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def retry_after_seconds(message)
|
|
86
|
+
match = message.to_s.match(RETRY_AFTER)
|
|
87
|
+
match[1].to_f if match
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'set'
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'llm_failure'
|
|
5
|
+
|
|
6
|
+
module Aireview
|
|
7
|
+
# Обход цепочки моделей и ключей стадии. Перегрузка — свойство модели,
|
|
8
|
+
# квота — свойство «ключ + модель», поэтому по 503 меняется модель, по
|
|
9
|
+
# квоте — ключ. Всё вместе ограничено общим бюджетом времени прогона.
|
|
10
|
+
class LlmRouter
|
|
11
|
+
Route = Struct.new(:candidate, :candidate_index, :key, :key_index, :key_count, keyword_init: true) do
|
|
12
|
+
def fallback?
|
|
13
|
+
candidate_index.positive?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s
|
|
17
|
+
key_count > 1 ? "#{candidate} (key #{key_index + 1}/#{key_count})" : candidate.to_s
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Attempt = Struct.new(:route, :kind, :attempts, :error, :note, keyword_init: true) do
|
|
22
|
+
def to_s
|
|
23
|
+
"#{route}: #{note || "#{LlmRouter::KIND_LABELS.fetch(kind)} after #{attempts} attempt(s)"}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
KIND_LABELS = {
|
|
28
|
+
daily_quota: 'daily quota exhausted',
|
|
29
|
+
rate_limit: 'rate limited',
|
|
30
|
+
overloaded: 'overloaded',
|
|
31
|
+
timeout: 'timed out'
|
|
32
|
+
}.freeze
|
|
33
|
+
SWITCH_HINT = 'Try again later or switch model via --generate-model/--critique-model.'
|
|
34
|
+
|
|
35
|
+
# Пока есть куда переключиться, модели дают один короткий повтор; полное
|
|
36
|
+
# расписание получает только последний маршрут стадии.
|
|
37
|
+
SHORT_RETRY_DELAY = 30.0
|
|
38
|
+
SHORT_RETRY_JITTER_RANGE = 0.85..1.15
|
|
39
|
+
MAX_RATE_LIMIT_RETRIES = 3
|
|
40
|
+
RATE_LIMIT_BASE_DELAY = 2.0
|
|
41
|
+
RATE_LIMIT_JITTER_RANGE = 2.0..5.0
|
|
42
|
+
PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE = 2.0..2.4
|
|
43
|
+
OVERLOADED_RETRY_DELAYS = [120.0, 300.0, 300.0, 300.0].freeze
|
|
44
|
+
OVERLOADED_RETRY_JITTER_RANGE = 0.85..1.15
|
|
45
|
+
RETRY_WAIT_LOG_FORMAT = 'LLM %<stage>s request will sleep %<delay>.1fs before retry%<source>s ' \
|
|
46
|
+
'(attempt %<next_attempt>d/%<max_attempts>d, model=%<model>s)'
|
|
47
|
+
|
|
48
|
+
def initialize(config:, logger:)
|
|
49
|
+
@config = config
|
|
50
|
+
@logger = logger
|
|
51
|
+
@exhausted = Set.new
|
|
52
|
+
@cursor = {}
|
|
53
|
+
@used = {}
|
|
54
|
+
@deadline = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Блок получает маршрут и таймаут запроса, делает запрос и возвращает
|
|
58
|
+
# ответ. Ошибка блока классифицируется, дальше — повтор, другой ключ,
|
|
59
|
+
# другая модель или ApiError, когда маршруты кончились.
|
|
60
|
+
def call(stage:, request_chars:, &request)
|
|
61
|
+
@deadline ||= monotonic_time + @config.llm_time_budget
|
|
62
|
+
attempts = []
|
|
63
|
+
chain = ordered_chain(stage).reject do |candidate, index|
|
|
64
|
+
oversized?(stage, candidate, index, request_chars, attempts)
|
|
65
|
+
end
|
|
66
|
+
carried = nil
|
|
67
|
+
chain.each_with_index do |(candidate, index), position|
|
|
68
|
+
slot = {candidate: candidate, index: index, last_model: position == chain.size - 1}
|
|
69
|
+
status, value = try_candidate(stage, slot, attempts, carried, &request)
|
|
70
|
+
return value if status == :ok
|
|
71
|
+
|
|
72
|
+
carried = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
raise ApiError, exhausted_message(stage, attempts)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Стадии, ответившие не основной моделью: для строки в отчёте.
|
|
79
|
+
def fallback_models
|
|
80
|
+
@used.select { |_, route| route.fallback? }.transform_values { |route| route.candidate.to_s }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def remaining_time
|
|
84
|
+
@deadline ? @deadline - monotonic_time : @config.llm_time_budget.to_f
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# При :next_model отдаёт текущий ключ: перегрузка — свойство модели, и
|
|
90
|
+
# следующая модель того же провайдера продолжает с того же ключа, а не
|
|
91
|
+
# возвращается к первому, у которого квота могла уже кончиться.
|
|
92
|
+
def try_candidate(stage, slot, attempts, carried, &request)
|
|
93
|
+
routes = candidate_routes(stage, slot, carried)
|
|
94
|
+
routes.each_with_index do |route, position|
|
|
95
|
+
next if quota_exhausted?(stage, route, attempts)
|
|
96
|
+
|
|
97
|
+
log_switch(stage, route, attempts)
|
|
98
|
+
status, response = try_route(stage, route, attempts,
|
|
99
|
+
{last_model: slot[:last_model], last_key: position == routes.size - 1}, &request)
|
|
100
|
+
return [:ok, remember(stage, route, response)] if status == :ok
|
|
101
|
+
return [:next_model, {provider: route.candidate.provider, key_index: route.key_index}] if status == :next_model
|
|
102
|
+
end
|
|
103
|
+
[:next_model, carried]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Следующий запрос стадии (например, починка JSON) начинается с модели,
|
|
107
|
+
# которая ответила; остальные остаются в резерве после неё.
|
|
108
|
+
def ordered_chain(stage)
|
|
109
|
+
start_candidate, = @cursor.fetch(stage, [0, 0])
|
|
110
|
+
@config.stage_chain(stage).each_with_index.to_a.rotate(start_candidate)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Ключи начинаются с перенесённого (после перегрузки — текущий ключ,
|
|
114
|
+
# после ответа — ответивший), остальные идут следом: квота привязана к
|
|
115
|
+
# сочетанию «ключ + модель», ошибка на одной модели не списывает ключ
|
|
116
|
+
# для другой.
|
|
117
|
+
def candidate_routes(stage, slot, carried)
|
|
118
|
+
candidate = slot[:candidate]
|
|
119
|
+
keys = @config.provider_api_keys(candidate.provider)
|
|
120
|
+
routes = keys.each_with_index.map do |key, key_index|
|
|
121
|
+
Route.new(candidate: candidate, candidate_index: slot[:index], key: key,
|
|
122
|
+
key_index: key_index, key_count: keys.size)
|
|
123
|
+
end
|
|
124
|
+
routes.rotate(start_key(stage, slot, carried))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def start_key(stage, slot, carried)
|
|
128
|
+
return carried[:key_index] if carried && carried[:provider] == slot[:candidate].provider
|
|
129
|
+
|
|
130
|
+
cursor_candidate, cursor_key = @cursor.fetch(stage, [0, 0])
|
|
131
|
+
cursor_candidate == slot[:index] ? cursor_key : 0
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def try_route(stage, route, attempts, position)
|
|
135
|
+
attempt = 0
|
|
136
|
+
loop do
|
|
137
|
+
attempt += 1
|
|
138
|
+
ensure_time_left!(stage, route, attempts)
|
|
139
|
+
begin
|
|
140
|
+
return [:ok, yield(route, request_timeout)]
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
kind = LlmFailure.classify(e)
|
|
143
|
+
raise if kind == :unhandled
|
|
144
|
+
|
|
145
|
+
@logger.warn("LLM #{stage} request failed (#{route}): #{e.class}: #{e.message}")
|
|
146
|
+
decision, delay = decide(kind, attempt, position, e)
|
|
147
|
+
raise ApiError, single_route_message(e) if decision == :fail
|
|
148
|
+
|
|
149
|
+
if decision == :retry
|
|
150
|
+
next if waited_before_retry?(stage, route, delay, attempt)
|
|
151
|
+
|
|
152
|
+
decision = give_up(kind)
|
|
153
|
+
end
|
|
154
|
+
@exhausted << exhausted_key(route) if kind == :daily_quota
|
|
155
|
+
attempts << Attempt.new(route: route, kind: kind, attempts: attempt, error: e)
|
|
156
|
+
return [decision, nil]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# :retry с паузой, :next_key, :next_model или :fail.
|
|
162
|
+
def decide(kind, attempt, position, error)
|
|
163
|
+
case kind
|
|
164
|
+
when :fatal then [:fail]
|
|
165
|
+
when :daily_quota then [:next_key]
|
|
166
|
+
when :rate_limit then rate_limit_decision(attempt, position, error)
|
|
167
|
+
else overloaded_decision(attempt, position)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def overloaded_decision(attempt, position)
|
|
172
|
+
unless position[:last_model]
|
|
173
|
+
return [:retry, short_delay.merge(max_attempts: 2)] if attempt == 1
|
|
174
|
+
|
|
175
|
+
return [:next_model]
|
|
176
|
+
end
|
|
177
|
+
return [:next_model] if attempt > OVERLOADED_RETRY_DELAYS.size
|
|
178
|
+
|
|
179
|
+
[:retry, overloaded_delay(attempt)]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def rate_limit_decision(attempt, position, error)
|
|
183
|
+
max_retries = position[:last_model] && position[:last_key] ? MAX_RATE_LIMIT_RETRIES : 1
|
|
184
|
+
return [:next_key] if attempt > max_retries
|
|
185
|
+
|
|
186
|
+
[:retry, rate_limit_delay(attempt, error).merge(max_attempts: max_retries + 1)]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def give_up(kind)
|
|
190
|
+
%i[overloaded timeout].include?(kind) ? :next_model : :next_key
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def short_delay
|
|
194
|
+
{delay: SHORT_RETRY_DELAY * rand(SHORT_RETRY_JITTER_RANGE)}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def overloaded_delay(attempt)
|
|
198
|
+
base_delay = OVERLOADED_RETRY_DELAYS.fetch(attempt - 1, OVERLOADED_RETRY_DELAYS.last)
|
|
199
|
+
multiplier = rand(OVERLOADED_RETRY_JITTER_RANGE)
|
|
200
|
+
{
|
|
201
|
+
delay: base_delay * multiplier,
|
|
202
|
+
source: format(' (overloaded backoff %<base>.0fs, multiplier %<multiplier>.2fx)',
|
|
203
|
+
base: base_delay, multiplier: multiplier),
|
|
204
|
+
max_attempts: OVERLOADED_RETRY_DELAYS.size + 1
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def rate_limit_delay(attempt, error)
|
|
209
|
+
hint = LlmFailure.retry_after_seconds(error.message)
|
|
210
|
+
return {delay: (RATE_LIMIT_BASE_DELAY * (2**(attempt - 1))) + rand(RATE_LIMIT_JITTER_RANGE)} unless hint
|
|
211
|
+
|
|
212
|
+
multiplier = rand(PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE)
|
|
213
|
+
{
|
|
214
|
+
delay: hint * multiplier,
|
|
215
|
+
source: format(' (provider retry hint %<hint>.1fs, multiplier %<multiplier>.2fx)',
|
|
216
|
+
hint: hint, multiplier: multiplier)
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# false — пауза не помещается в бюджет времени, повтора не будет.
|
|
221
|
+
def waited_before_retry?(stage, route, retry_delay, attempt)
|
|
222
|
+
delay = retry_delay[:delay]
|
|
223
|
+
if delay > remaining_time
|
|
224
|
+
@logger.warn(
|
|
225
|
+
format('LLM %<stage>s: no time budget left for a %<delay>.0fs pause (%<left>.0fs remaining, %<route>s)',
|
|
226
|
+
stage: stage, delay: delay, left: [remaining_time, 0].max, route: route)
|
|
227
|
+
)
|
|
228
|
+
return false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
@logger.warn(
|
|
232
|
+
format(RETRY_WAIT_LOG_FORMAT, stage: stage, delay: delay, source: retry_delay[:source].to_s,
|
|
233
|
+
next_attempt: attempt + 1, max_attempts: retry_delay[:max_attempts] || 2,
|
|
234
|
+
model: route.candidate.model)
|
|
235
|
+
)
|
|
236
|
+
started_at = monotonic_time
|
|
237
|
+
sleep(delay)
|
|
238
|
+
@logger.info(format('LLM %<stage>s retry wait completed after %<waited>.1fs (model=%<model>s)',
|
|
239
|
+
stage: stage, waited: monotonic_time - started_at, model: route.candidate.model))
|
|
240
|
+
true
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def ensure_time_left!(stage, route, attempts)
|
|
244
|
+
return if remaining_time >= 1
|
|
245
|
+
|
|
246
|
+
attempts << Attempt.new(route: route, note: 'no time budget left')
|
|
247
|
+
raise ApiError, "LLM time budget of #{@config.llm_time_budget}s is exhausted. " \
|
|
248
|
+
"#{exhausted_message(stage, attempts)}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def request_timeout
|
|
252
|
+
[@config.llm_timeout.to_f, remaining_time].min
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def oversized?(stage, candidate, index, request_chars, attempts)
|
|
256
|
+
return false if request_chars <= candidate.max_prompt_chars
|
|
257
|
+
|
|
258
|
+
note = "skipped, request #{request_chars} chars over max_prompt_chars=#{candidate.max_prompt_chars}"
|
|
259
|
+
@logger.warn("LLM #{stage}: #{candidate} #{note}")
|
|
260
|
+
attempts << Attempt.new(route: Route.new(candidate: candidate, candidate_index: index, key_count: 1), note: note)
|
|
261
|
+
true
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def quota_exhausted?(stage, route, attempts)
|
|
265
|
+
return false unless @exhausted.include?(exhausted_key(route))
|
|
266
|
+
|
|
267
|
+
@logger.info("LLM #{stage}: skipping #{route}, daily quota exhausted earlier in this run")
|
|
268
|
+
attempts << Attempt.new(route: route, note: 'daily quota exhausted earlier in this run')
|
|
269
|
+
true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def remember(stage, route, response)
|
|
273
|
+
@cursor[stage] = [route.candidate_index, route.key_index]
|
|
274
|
+
@used[stage] = route
|
|
275
|
+
response
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def exhausted_key(route)
|
|
279
|
+
[route.candidate.provider, route.key_index, route.candidate.model]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def log_switch(stage, route, attempts)
|
|
283
|
+
return if attempts.empty?
|
|
284
|
+
|
|
285
|
+
@logger.warn("LLM #{stage}: switching to #{route} after #{attempts.last}")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def exhausted_message(stage, attempts)
|
|
289
|
+
single = attempts.size == 1 && attempts.first.error
|
|
290
|
+
return single_route_message(attempts.first.error) if single
|
|
291
|
+
|
|
292
|
+
"LLM #{stage} request failed on every configured route: #{attempts.join('; ')}. #{SWITCH_HINT}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def single_route_message(error)
|
|
296
|
+
case LlmFailure.classify(error)
|
|
297
|
+
when :timeout
|
|
298
|
+
"LLM request timed out after #{@config.llm_timeout} seconds. #{SWITCH_HINT}"
|
|
299
|
+
when :overloaded
|
|
300
|
+
"LLM service is temporarily unavailable or overloaded: #{error.message}. #{SWITCH_HINT}"
|
|
301
|
+
when :rate_limit, :daily_quota
|
|
302
|
+
"LLM rate limit exceeded: #{error.message}. #{SWITCH_HINT}"
|
|
303
|
+
else
|
|
304
|
+
fatal_message(error)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def fatal_message(error)
|
|
309
|
+
if defined?(RubyLLM::ContextLengthExceededError) && error.is_a?(RubyLLM::ContextLengthExceededError)
|
|
310
|
+
"LLM context limit exceeded: #{error.message}. Try reducing the MR diff or ignore more paths."
|
|
311
|
+
else
|
|
312
|
+
"LLM API request failed: #{error.message}"
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def monotonic_time
|
|
317
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -48,6 +48,13 @@ and reject the rest as duplicates.
|
|
|
48
48
|
|
|
49
49
|
Do not add new findings. Do not change id.
|
|
50
50
|
Do not change file, line, quoted_code. They do not need to be reinvented.
|
|
51
|
+
|
|
52
|
+
A candidate may carry a note field: the result of a mechanical check of its
|
|
53
|
+
link to the diff (the quote was not found in the shown diff, the line was
|
|
54
|
+
reset to null, the file was shown partially). It means the link could not be
|
|
55
|
+
confirmed, not that the finding is made up; take the note into account when
|
|
56
|
+
deciding keep/reject and check such a candidate against the diff more
|
|
57
|
+
carefully.
|
|
51
58
|
Use refinement only when it makes a keep finding more precise.
|
|
52
59
|
|
|
53
60
|
The answer must be a valid JSON object only, without markdown and without any
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aireview
|
|
4
|
+
# Проверка формы ответов LLM после разбора JSON: кандидаты, вердикты, id.
|
|
5
|
+
# Ошибка формы — SchemaError, пайплайн решает, чинить ли ответ повторным
|
|
6
|
+
# запросом. Ожидает от включающего класса value, normalize_id и
|
|
7
|
+
# normalize_decision.
|
|
8
|
+
module ResultValidation
|
|
9
|
+
class SchemaError < StandardError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def validate_generate_result_shape!(parsed)
|
|
15
|
+
valid_shape = parsed.is_a?(Hash) && parsed['candidates'].is_a?(Array)
|
|
16
|
+
raise SchemaError, 'expected an object with summary and candidates array' unless valid_shape
|
|
17
|
+
raise SchemaError, 'each generate candidate must be an object' unless parsed['candidates'].all?(Hash)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate_critique_result_shape!(parsed)
|
|
21
|
+
valid_shape = parsed.is_a?(Hash) && parsed['verdicts'].is_a?(Array)
|
|
22
|
+
raise SchemaError, 'expected an object with verdicts array' unless valid_shape
|
|
23
|
+
raise SchemaError, 'each critique verdict must be an object' unless parsed['verdicts'].all?(Hash)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_identifiers!(identifiers, missing_message:, duplicate_prefix:)
|
|
27
|
+
raise SchemaError, missing_message unless identifiers.all?
|
|
28
|
+
|
|
29
|
+
duplicate_ids = identifiers.group_by(&:itself).select { |_, ids| ids.size > 1 }.keys
|
|
30
|
+
return if duplicate_ids.empty?
|
|
31
|
+
|
|
32
|
+
raise SchemaError, "#{duplicate_prefix}: #{duplicate_ids.join(', ')}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_expected_verdict_ids!(verdict_ids, expected_ids)
|
|
36
|
+
return unless expected_ids
|
|
37
|
+
|
|
38
|
+
unknown_ids = verdict_ids - expected_ids
|
|
39
|
+
missing_ids = expected_ids - verdict_ids
|
|
40
|
+
raise SchemaError, "unknown verdict ids: #{unknown_ids.join(', ')}" unless unknown_ids.empty?
|
|
41
|
+
raise SchemaError, "missing verdict ids: #{missing_ids.join(', ')}" unless missing_ids.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_verdict!(verdict)
|
|
45
|
+
id = normalize_id(value(verdict, 'id'))
|
|
46
|
+
decision = normalize_decision(value(verdict, 'decision'))
|
|
47
|
+
raise SchemaError, "invalid verdict decision for #{id}" unless %w[keep reject].include?(decision)
|
|
48
|
+
|
|
49
|
+
refinement = value(verdict, 'refinement')
|
|
50
|
+
raise SchemaError, "reject verdict cannot include refinement for #{id}" if invalid_refinement?(verdict, decision)
|
|
51
|
+
return if refinement.nil?
|
|
52
|
+
return if refinement.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
raise SchemaError, "refinement must be an object for #{id}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def invalid_refinement?(verdict, decision)
|
|
58
|
+
refinement_key?(verdict) && decision != 'keep'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def refinement_key?(verdict)
|
|
62
|
+
verdict.key?('refinement') || verdict.key?(:refinement)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|