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.
@@ -8,13 +8,7 @@ module Aireview
8
8
  end
9
9
 
10
10
  def render(dry_run)
11
- @out.puts('=== LLM SETTINGS ===')
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 list(title, items)
61
- @out.puts(" #{title}: #{items.join(', ')}") unless items.empty?
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