aireview 0.3.0 → 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.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'stages'
3
+ require_relative 'utils'
4
+
5
+ module Aireview
6
+ # Configuration layers in ascending priority: built-in values, image
7
+ # defaults (AIREVIEW_DEFAULTS), the project's .aireview.yml, env, CLI. The
8
+ # layer name is shown by --dry-run, so that it is clear where a model came from.
9
+ module ConfigLayers
10
+ Layer = Struct.new(:name, :path, :data, keyword_init: true)
11
+
12
+ BUILT_IN_LAYER = 'built-in'
13
+ DATA_LAYER = 'config'
14
+ IMAGE_LAYER = 'image defaults'
15
+ FILE_LAYER = '.aireview.yml'
16
+ ENV_LAYER = 'env'
17
+ CLI_LAYER = 'cli'
18
+
19
+ # The name of the layer a value came from; nil — not set anywhere.
20
+ def source_of(*keys)
21
+ layer_of(*keys)&.name
22
+ end
23
+
24
+ # A stage setting resolved layer by layer: in every layer, top down, the
25
+ # stage value (llm.<stage>.<key>) first, then the shared one (llm.<key>).
26
+ # A stage value from the image defaults must not beat a shared value from
27
+ # the project or the environment: LLM_PROVIDER=ollama must switch both stages.
28
+ def stage_setting(stage, key)
29
+ layer = stage_setting_layer(stage, key)
30
+ layer && (Utils.dig(layer.data, 'llm', stage.to_s, key) || Utils.dig(layer.data, 'llm', key))
31
+ end
32
+
33
+ def stage_setting_layer(stage, key)
34
+ stage = stage.to_s
35
+ @layers.reverse.find do |layer|
36
+ !Utils.dig(layer.data, 'llm', stage, key).nil? || !Utils.dig(layer.data, 'llm', key).nil?
37
+ end
38
+ end
39
+
40
+ def stage_provider_source(stage)
41
+ stage_setting_layer(stage, 'provider')&.name
42
+ end
43
+
44
+ # In pool mode the stage model is set by the start or the pool itself, the reserves by the pool.
45
+ def stage_model_source(stage)
46
+ stage = stage.to_s
47
+ return source_of('llm', stage, 'model') unless routing.pool_stage?(stage)
48
+ return source_of('llm', stage, 'start') if routing.start_used?(stage)
49
+
50
+ source_of('llm', 'models')
51
+ end
52
+
53
+ def stage_fallbacks_source(stage)
54
+ stage = stage.to_s
55
+ routing.pool_stage?(stage) ? source_of('llm', 'models') : source_of('llm', stage, 'fallbacks')
56
+ end
57
+
58
+ # The stage start came from a layer below the one that set the pool: the
59
+ # image defaults versus the project's LLM_MODELS.
60
+ def start_inherited?(stage)
61
+ start_layer = layer_of('llm', stage.to_s, 'start')
62
+ models_layer = layer_of('llm', 'models')
63
+ !!(start_layer && models_layer && @layers.index(start_layer) < @layers.index(models_layer))
64
+ end
65
+
66
+ # Configuration and plan warnings; the CLI and --dry-run print them.
67
+ def warnings
68
+ STAGES.flat_map { |stage| stage_provider_warnings(stage) } + routing.warnings
69
+ end
70
+
71
+ # The paths of the file layers, for --dry-run.
72
+ def layer_paths
73
+ @layers.select(&:path).to_h { |layer| [layer.name, layer.path] }
74
+ end
75
+
76
+ # A reserve without a provider inherits the stage provider. When a
77
+ # project overrode the provider above the layer that set the reserves,
78
+ # the inherited reserves silently become "models" of the new provider.
79
+ def stage_provider_warnings(stage)
80
+ stage = stage.to_s
81
+ inherited = inherited_fallback_names(stage)
82
+ return [] if inherited.empty?
83
+
84
+ provider_layer = stage_setting_layer(stage, 'provider')
85
+ fallbacks_layer = layer_of('llm', stage, 'fallbacks')
86
+ return [] unless provider_layer && fallbacks_layer
87
+ return [] if @layers.index(provider_layer) <= @layers.index(fallbacks_layer)
88
+
89
+ provider = public_send("#{stage}_provider")
90
+ ["#{stage}: provider #{provider.inspect} comes from #{provider_layer.name}, " \
91
+ "but fallbacks without an explicit provider come from #{fallbacks_layer.name} " \
92
+ "and now inherit it: #{inherited.join(', ')}"]
93
+ end
94
+
95
+ private
96
+
97
+ def layer_of(*keys)
98
+ @layers.reverse.find { |layer| !Utils.dig(layer.data, *keys).nil? }
99
+ end
100
+
101
+ def inherited_fallback_names(stage)
102
+ Array(dig('llm', stage, 'fallbacks')).filter_map do |item|
103
+ next item.to_s if item.is_a?(String)
104
+ next unless item.is_a?(Hash) && Aireview::Utils.blank?(item['provider'])
105
+
106
+ item['model'].to_s
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
+ require_relative 'stages'
2
3
 
3
4
  module Aireview
4
- # Лимиты контекста в символах: точного токенизатора для провайдеров локально
5
- # нет, а окно Ollama задаётся на сервере и клиенту не видно. Дефолты щедрые,
6
- # под конкретную модель их задают в .aireview.yml.
5
+ # Context limits in characters: there is no exact local tokenizer for the
6
+ # providers, and the Ollama window is set on the server, invisible to the
7
+ # client. The defaults are generous; a specific model gets its own in
8
+ # .aireview.yml.
7
9
  module ConfigLimits
8
- LLM_STAGES = %w[generate critique].freeze
9
10
  DEFAULT_MAX_PROMPT_CHARS = 400_000
10
11
  CONTEXT_DEFAULTS = {
11
12
  'max_diff_chars' => 120_000,
@@ -13,41 +14,15 @@ module Aireview
13
14
  'max_jira_description_chars' => 8_000,
14
15
  'max_jira_comment_chars' => 2_000
15
16
  }.freeze
16
- CONTEXT_ENV = {
17
- 'max_diff_chars' => 'MAX_DIFF_CHARS',
18
- 'max_mr_description_chars' => 'MAX_MR_DESCRIPTION_CHARS',
19
- 'max_jira_description_chars' => 'MAX_JIRA_DESCRIPTION_CHARS',
20
- 'max_jira_comment_chars' => 'MAX_JIRA_COMMENT_CHARS'
21
- }.freeze
22
-
23
- module ClassMethods
24
- def context_env_config(env)
25
- context = CONTEXT_ENV.each_with_object({}) do |(key, env_key), config|
26
- value = parse_integer(env[env_key], env_key)
27
- config[key] = value unless value.nil?
28
- end
29
- context.empty? ? {} : {'context' => context}
30
- end
31
-
32
- # Лимит, который не разобрался, нельзя молча заменять дефолтом: запрос
33
- # уйдёт в модель с окном, которого у неё нет.
34
- def parse_integer(value, name)
35
- return nil if Aireview::Utils.blank?(value)
36
-
37
- Integer(value.to_s, 10)
38
- rescue ArgumentError
39
- raise ConfigError, "#{name} must be an integer, got #{value.inspect}"
40
- end
41
- end
42
-
43
- # Лимит всего запроса стадии в символах: системный промпт плюс контекст
44
- # (для критика ещё и кандидаты). Наследуется из llm как model/temperature.
17
+ # The limit of the whole stage request in characters: the system prompt
18
+ # plus the context (plus the candidates for Critique). Inherited from llm
19
+ # like model/temperature.
45
20
  def max_prompt_chars(stage)
46
21
  stage = stage.to_s
47
- raise ArgumentError, "unknown LLM stage #{stage.inspect}" unless LLM_STAGES.include?(stage)
22
+ raise ArgumentError, "unknown LLM stage #{stage.inspect}" unless STAGES.include?(stage)
48
23
 
49
24
  positive_integer!(
50
- dig('llm', stage, 'max_prompt_chars') || dig('llm', 'max_prompt_chars') || DEFAULT_MAX_PROMPT_CHARS,
25
+ stage_setting(stage, 'max_prompt_chars') || DEFAULT_MAX_PROMPT_CHARS,
51
26
  "llm.#{stage}.max_prompt_chars"
52
27
  )
53
28
  end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'stages'
3
+ require 'logger'
4
+ require 'pathname'
5
+ require 'yaml'
6
+ require_relative 'errors'
7
+ require_relative 'utils'
8
+ require_relative 'model_candidate'
9
+ require_relative 'config_layers'
10
+ require_relative 'config_limits'
11
+ require_relative 'config'
12
+
13
+ module Aireview
14
+ # Builds a Config from layers: built-in values, image defaults
15
+ # (AIREVIEW_DEFAULTS), the project's .aireview.yml (searched upwards from
16
+ # cwd), env. Everything that knows environment variable names and file
17
+ # formats lives here; Config only answers questions about merged data.
18
+ module ConfigLoader
19
+ ENV_MAPPING = {
20
+ 'gitlab_url' => 'GITLAB_URL',
21
+ 'gitlab_token' => 'GITLAB_TOKEN',
22
+ 'jira_url' => 'JIRA_URL',
23
+ 'jira_login' => 'JIRA_LOGIN',
24
+ 'jira_password' => 'JIRA_PASSWORD',
25
+ 'review_language' => 'REVIEW_LANGUAGE',
26
+ 'review_mode' => 'REVIEW_MODE',
27
+ 'llm_api_base' => 'LLM_API_BASE',
28
+ 'ollama_api_base' => 'OLLAMA_API_BASE',
29
+ 'llm_http_proxy' => 'LLM_HTTP_PROXY'
30
+ }.freeze
31
+ PROVIDER_KEY_MAPPING = {'gemini' => 'GEMINI_API_KEY'}.freeze
32
+ PROVIDER_KEYS_MAPPING = {'gemini' => 'GEMINI_API_KEYS'}.freeze
33
+ CONTEXT_ENV = {
34
+ 'max_diff_chars' => 'MAX_DIFF_CHARS',
35
+ 'max_mr_description_chars' => 'MAX_MR_DESCRIPTION_CHARS',
36
+ 'max_jira_description_chars' => 'MAX_JIRA_DESCRIPTION_CHARS',
37
+ 'max_jira_comment_chars' => 'MAX_JIRA_COMMENT_CHARS'
38
+ }.freeze
39
+ LLM_ENV = %w[
40
+ LLM_PROVIDER LLM_TEMPERATURE LLM_TIMEOUT LLM_MAX_PROMPT_CHARS LLM_TIME_BUDGET LLM_OVERLOADED_QUARANTINE
41
+ LLM_MODELS LLM_CRITIQUE_RANK LLM_CRITIQUE_ALLOW_WEAKER
42
+ ].freeze
43
+ LLM_STAGE_ENV_SUFFIXES = %w[PROVIDER MODEL TEMPERATURE MAX_PROMPT_CHARS FALLBACK_MODEL START].freeze
44
+ IMAGE_DEFAULTS_ENV = 'AIREVIEW_DEFAULTS'
45
+
46
+ module_function
47
+
48
+ def load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr))
49
+ load_dotenv(cwd)
50
+
51
+ file_path = config_path ? File.expand_path(config_path, cwd) : discover_file(cwd, '.aireview.yml')
52
+ layers = [
53
+ ConfigLayers::Layer.new(name: ConfigLayers::BUILT_IN_LAYER, data: Config::DEFAULTS),
54
+ image_defaults_layer(env),
55
+ file_layer(file_path),
56
+ ConfigLayers::Layer.new(name: ConfigLayers::ENV_LAYER, data: env_config(env))
57
+ ].compact
58
+
59
+ Config.new(config_path: File.file?(file_path) ? file_path : nil, logger: logger, layers: layers)
60
+ end
61
+
62
+ # Every environment variable the loader reads. The CI template passes
63
+ # them into the container by name: a project variable missing from this
64
+ # list never reaches the review.
65
+ def env_names
66
+ stage_env = STAGES.flat_map do |stage|
67
+ LLM_STAGE_ENV_SUFFIXES.map { |suffix| "LLM_#{stage.upcase}_#{suffix}" }
68
+ end
69
+ [
70
+ *ENV_MAPPING.values, *PROVIDER_KEY_MAPPING.values, *PROVIDER_KEYS_MAPPING.values, 'LLM_API_KEY',
71
+ *LLM_ENV, *stage_env, *CONTEXT_ENV.values
72
+ ].uniq
73
+ end
74
+
75
+ def load_dotenv(cwd)
76
+ require 'dotenv'
77
+ dotenv_path = discover_file(cwd, '.env')
78
+ Dotenv.load(dotenv_path) if File.file?(dotenv_path)
79
+ rescue LoadError
80
+ nil
81
+ end
82
+
83
+ def discover_file(cwd, basename)
84
+ current = Pathname.new(cwd).expand_path
85
+
86
+ loop do
87
+ candidate = current.join(basename)
88
+ return candidate.to_s if candidate.file?
89
+
90
+ break if current.root?
91
+
92
+ current = current.parent
93
+ end
94
+
95
+ File.join(cwd, basename)
96
+ end
97
+
98
+ # Image defaults: the Dockerfile sets the path through AIREVIEW_DEFAULTS.
99
+ # A path that is set but missing means a broken image; better to learn
100
+ # that at once.
101
+ def image_defaults_layer(env)
102
+ path = env[IMAGE_DEFAULTS_ENV]
103
+ return nil if Aireview::Utils.blank?(path)
104
+ raise ConfigError, "#{IMAGE_DEFAULTS_ENV} points to a missing file: #{path}" unless File.file?(path)
105
+
106
+ ConfigLayers::Layer.new(name: ConfigLayers::IMAGE_LAYER, path: path, data: read_yaml(path))
107
+ end
108
+
109
+ def file_layer(file_path)
110
+ return nil unless File.file?(file_path)
111
+
112
+ ConfigLayers::Layer.new(name: ConfigLayers::FILE_LAYER, path: file_path, data: read_yaml(file_path))
113
+ end
114
+
115
+ def read_yaml(path)
116
+ Aireview::Utils.normalize_hash(YAML.load_file(path) || {})
117
+ end
118
+
119
+ def env_config(env)
120
+ mapped_env_config(env)
121
+ .merge('llm' => Aireview::Utils.deep_merge(llm_env_config(env), pool_env_config(env)))
122
+ .merge(context_env_config(env))
123
+ .merge(provider_key_env_config(env))
124
+ .merge(provider_keys_env_config(env))
125
+ .merge(generic_api_key_env_config(env))
126
+ end
127
+
128
+ def mapped_env_config(env)
129
+ ENV_MAPPING.each_with_object({}) do |(key, env_key), config|
130
+ value = env[env_key]
131
+ config[key] = value unless Aireview::Utils.blank?(value)
132
+ end
133
+ end
134
+
135
+ def llm_env_config(env)
136
+ {
137
+ 'provider' => env['LLM_PROVIDER'],
138
+ 'temperature' => parse_float(env['LLM_TEMPERATURE']),
139
+ 'timeout' => parse_float(env['LLM_TIMEOUT']),
140
+ 'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
141
+ 'time_budget' => parse_integer(env['LLM_TIME_BUDGET'], 'LLM_TIME_BUDGET'),
142
+ 'overloaded_quarantine' => parse_integer(env['LLM_OVERLOADED_QUARANTINE'], 'LLM_OVERLOADED_QUARANTINE'),
143
+ 'generate' => llm_stage_env_config(env, 'GENERATE'),
144
+ 'critique' => llm_stage_env_config(env, 'CRITIQUE')
145
+ }.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
146
+ end
147
+
148
+ def llm_stage_env_config(env, stage)
149
+ {
150
+ 'provider' => env["LLM_#{stage}_PROVIDER"],
151
+ 'model' => env["LLM_#{stage}_MODEL"],
152
+ 'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
153
+ 'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS"),
154
+ 'fallbacks' => fallback_models_env_config(env, stage)
155
+ }.compact
156
+ end
157
+
158
+ # LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash (or a comma-separated
159
+ # list) — the provider is separated by a slash because Ollama tags contain a colon.
160
+ def fallback_models_env_config(env, stage)
161
+ value = env["LLM_#{stage}_FALLBACK_MODEL"]
162
+ return nil if Aireview::Utils.blank?(value)
163
+
164
+ value.split(',').map(&:strip).reject(&:empty?).map { |item| ModelCandidate.parse_item(item) }
165
+ end
166
+
167
+ # LLM_MODELS=gemini/gemini-3.8-flash,gemini/gemini-3.7-flash;
168
+ # LLM_GENERATE_START / LLM_CRITIQUE_START — a model from the pool;
169
+ # LLM_CRITIQUE_RANK, LLM_CRITIQUE_ALLOW_WEAKER=true|false.
170
+ def pool_env_config(env)
171
+ models = env['LLM_MODELS'].to_s.split(',').map(&:strip).reject(&:empty?).map do |item|
172
+ ModelCandidate.parse_item(item)
173
+ end
174
+ {
175
+ 'models' => models.empty? ? nil : models,
176
+ 'generate' => {'start' => env['LLM_GENERATE_START']}.compact,
177
+ 'critique' => {
178
+ 'start' => env['LLM_CRITIQUE_START'],
179
+ 'rank' => env['LLM_CRITIQUE_RANK'],
180
+ 'allow_weaker' => parse_boolean(env['LLM_CRITIQUE_ALLOW_WEAKER'], 'LLM_CRITIQUE_ALLOW_WEAKER')
181
+ }.compact
182
+ }.compact.reject { |_, value| value.is_a?(Hash) && value.empty? }
183
+ end
184
+
185
+ def context_env_config(env)
186
+ context = CONTEXT_ENV.each_with_object({}) do |(key, env_key), config|
187
+ value = parse_integer(env[env_key], env_key)
188
+ config[key] = value unless value.nil?
189
+ end
190
+ context.empty? ? {} : {'context' => context}
191
+ end
192
+
193
+ def provider_key_env_config(env)
194
+ PROVIDER_KEY_MAPPING.each_with_object({}) do |(provider, env_key), config|
195
+ value = env[env_key]
196
+ config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value)
197
+ end
198
+ end
199
+
200
+ def provider_keys_env_config(env)
201
+ PROVIDER_KEYS_MAPPING.each_with_object({}) do |(provider, env_key), config|
202
+ keys = env[env_key].to_s.split(',').map(&:strip).reject(&:empty?)
203
+ config["#{provider}_api_keys"] = keys unless keys.empty?
204
+ end
205
+ end
206
+
207
+ def generic_api_key_env_config(env)
208
+ api_key = env['LLM_API_KEY']
209
+ return {} if Aireview::Utils.blank?(api_key)
210
+
211
+ {'llm_api_key' => api_key}
212
+ end
213
+
214
+ def parse_float(value)
215
+ return nil if Aireview::Utils.blank?(value)
216
+
217
+ Float(value)
218
+ rescue ArgumentError
219
+ nil
220
+ end
221
+
222
+ # A limit that failed to parse must not silently fall back to the
223
+ # default: the request would go to a model with a window it does not have.
224
+ def parse_integer(value, name)
225
+ return nil if Aireview::Utils.blank?(value)
226
+
227
+ Integer(value.to_s, 10)
228
+ rescue ArgumentError
229
+ raise ConfigError, "#{name} must be an integer, got #{value.inspect}"
230
+ end
231
+
232
+ def parse_boolean(value, name)
233
+ return nil if Aireview::Utils.blank?(value)
234
+ return true if %w[true 1 yes].include?(value.to_s.downcase)
235
+ return false if %w[false 0 no].include?(value.to_s.downcase)
236
+
237
+ raise ConfigError, "#{name} must be true or false, got #{value.inspect}"
238
+ end
239
+ end
240
+ end
@@ -2,12 +2,13 @@
2
2
  require_relative 'errors'
3
3
 
4
4
  module Aireview
5
- # Укладывает контекст ревью в бюджет символов и запоминает, что при этом не
6
- # вошло. Секции MR и Jira режутся до своих лимитов с сохранением начала,
7
- # дифф по целым файлам, затем по целым хункам; внутри хунка не режем.
5
+ # Fits the review context into a character budget and remembers what was
6
+ # left out. The MR and Jira sections are cut to their limits keeping the
7
+ # beginning, the diff by whole files, then by whole hunks; a hunk is never
8
+ # cut inside.
8
9
  module ContextBudget
9
- # Пути, которые не вошли, перечисляются в конце диффа; список ограничен,
10
- # чтобы сам не съел бюджет.
10
+ # The paths that did not fit are listed at the end of the diff; the list
11
+ # is capped so that it does not eat the budget itself.
11
12
  NOT_SHOWN_LIST_LIMIT = 20
12
13
  TRAILER_RESERVE_CHARS = 400
13
14
 
@@ -26,7 +27,8 @@ module Aireview
26
27
 
27
28
  Packed = Struct.new(:text, :shown_hunks, :total_hunks, keyword_init: true)
28
29
 
29
- # Начало важнее конца: требования и критерии приёмки обычно там.
30
+ # The beginning matters more than the end: requirements and acceptance
31
+ # criteria usually live there.
30
32
  def self.truncate_section(text, limit:, label:, coverage:)
31
33
  text = text.to_s
32
34
  return text if text.length <= limit
@@ -39,11 +41,11 @@ module Aireview
39
41
  Packer.new(entries, budget: budget, coverage: coverage).pack
40
42
  end
41
43
 
42
- # Файлы без хунков идут первыми: они дёшевы и всегда полезны для картины
43
- # MR. Текстовые файлы идут в порядке GitLab, пока влезают; первый файл,
44
- # который не влезает, показывается частично, всё после него не показывается.
45
- # Хунк, который не влез бы даже в пустой бюджет, пропускается с пометкой,
46
- # а не останавливает раскладку.
44
+ # Files without hunks go first: they are cheap and always useful for the
45
+ # picture of the MR. Text files go in GitLab order while they fit; the
46
+ # first file that does not fit is shown partially, everything after it is
47
+ # not shown. A hunk that would not fit even into an empty budget is
48
+ # skipped with a mark instead of stopping the layout.
47
49
  class Packer
48
50
  def initialize(entries, budget:, coverage:)
49
51
  @non_text, @text = entries.partition { |entry| !entry.text? }
@@ -62,9 +64,9 @@ module Aireview
62
64
 
63
65
  private
64
66
 
65
- # Что-то придётся опустить, значит нужен хвост со списком пропущенного.
66
- # @used считает весь собранный текст, включая разделители между
67
- # файлами: результат не должен выйти за бюджет ни на символ.
67
+ # Something has to be left out, so a trailer listing the skipped paths
68
+ # is needed. @used counts the whole assembled text, separators between
69
+ # files included: the result must not exceed the budget by a character.
68
70
  def pack_within_limit
69
71
  @parts = @non_text.map(&:render)
70
72
  @used = joined_length(@parts)
@@ -95,12 +97,12 @@ module Aireview
95
97
  shown_hunks
96
98
  end
97
99
 
98
- # Место под следующий кусок с учётом разделителя перед ним.
100
+ # Room for the next piece, the separator before it included.
99
101
  def remaining
100
102
  @limit - @used - (@parts.empty? ? 0 : 1)
101
103
  end
102
104
 
103
- # Возвращает [текст, число показанных хунков, остановлена ли раскладка].
105
+ # Returns [text, number of shown hunks, whether the layout stopped].
104
106
  def pack_entry(entry)
105
107
  full = entry.render
106
108
  return [full, entry.hunks.size, false] if full.length <= remaining
@@ -113,10 +115,10 @@ module Aireview
113
115
  [entry.header + body + partial_marker(entry, shown), shown, stopped]
114
116
  end
115
117
 
116
- # Место сначала отдаётся хункам, которые можно показать, и только на
117
- # остаток добавляются пометки о слишком больших: иначе пометки могли бы
118
- # вытеснить единственный подходящий хунк. Факт пропуска в покрытие
119
- # попадает независимо от того, есть ли для пометки место.
118
+ # Room goes first to the hunks that can be shown, and only the remainder
119
+ # to the marks about oversized ones: otherwise the marks could push out
120
+ # the only fitting hunk. The skip is recorded in the coverage whether
121
+ # or not there is room for the mark.
120
122
  def pack_hunks(entry)
121
123
  base = entry.header.length + partial_marker(entry, 0).length
122
124
  shown = []
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative 'utils'
3
3
  require_relative 'errors'
4
+ require_relative 'stages'
4
5
  require_relative 'secret_scrubber'
5
6
  require_relative 'diff_fetcher'
6
7
  require_relative 'context_budget'
@@ -15,14 +16,13 @@ module Aireview
15
16
  }.freeze
16
17
  CHANGES_HEADER = "Changes:\n"
17
18
  CANDIDATES_HEADER = "\n\nCandidates JSON from Generate:\n"
18
- # Резерв под кандидатов в промпте критика: три кандидата по ~1 500
19
- # символов. Оценка, не гарантия; фактический размер проверяется перед
20
- # отправкой.
19
+ # Room for the candidates in the Critique prompt: three candidates of
20
+ # ~1,500 characters. An estimate, not a guarantee; the actual size is
21
+ # checked before sending.
21
22
  CANDIDATES_RESERVE_CHARS = 4_500
22
- STAGES = %i[generate critique].freeze
23
23
 
24
- # Контекст одного прогона: обе стадии получают одинаковые MR, Jira и дифф,
25
- # усечённые один раз под самую тесную из стадий.
24
+ # The context of one run: both stages get the same MR, Jira and diff,
25
+ # truncated once for the tightest of the stages.
26
26
  Context = Struct.new(:user_prompt, :diff_text, :coverage, :sizes, keyword_init: true)
27
27
 
28
28
  def initialize(config:, logger: Logger.new($stderr))
@@ -53,16 +53,16 @@ module Aireview
53
53
  end
54
54
 
55
55
  def build_generate_prompt(context)
56
- check_stage_size!(:generate, system_prompt(:generate), context.user_prompt)
56
+ check_stage_size!('generate', system_prompt('generate'), context.user_prompt)
57
57
  end
58
58
 
59
59
  def build_critique_prompt(context, candidates_json:)
60
60
  user = "#{context.user_prompt}#{CANDIDATES_HEADER}#{scrub_text(candidates_json)}"
61
- check_stage_size!(:critique, system_prompt(:critique), user)
61
+ check_stage_size!('critique', system_prompt('critique'), user)
62
62
  end
63
63
 
64
64
  def system_prompt(stage)
65
- template = stage.to_sym == :critique ? CRITIQUE_PROMPT_TEMPLATE : GENERATE_PROMPT_TEMPLATE
65
+ template = stage.to_s == 'critique' ? CRITIQUE_PROMPT_TEMPLATE : GENERATE_PROMPT_TEMPLATE
66
66
  extras = []
67
67
  if Aireview::Utils.present?(@config.review_instructions)
68
68
  extras << "Additional project instructions:\n#{scrub_text(@config.review_instructions.strip)}"
@@ -72,10 +72,11 @@ module Aireview
72
72
  [template, *extras].join("\n\n")
73
73
  end
74
74
 
75
- # Проверка перед отправкой: если кандидаты вышли за резерв и запрос не
76
- # помещается, это ошибка, а не повод молча резать контекст, который
77
- # генератор уже видел.
75
+ # A check before sending: when the candidates exceed the reserve and the
76
+ # request does not fit, that is an error, not a reason to silently cut
77
+ # the context Generate has already seen.
78
78
  def check_stage_size!(stage, system, user)
79
+ stage = stage.to_s
79
80
  limit = @config.max_prompt_chars(stage)
80
81
  total = system.length + user.length
81
82
  if total > limit
@@ -89,10 +90,10 @@ module Aireview
89
90
 
90
91
  private
91
92
 
92
- # Минимум по стадиям: контекст один на прогон, поэтому он должен
93
- # помещаться в каждую из них вместе с её системным промптом и резервом.
93
+ # The minimum over the stages: the context is one per run, so it must fit
94
+ # into each of them together with its system prompt and reserve.
94
95
  def context_budget(critique:)
95
- stages = critique ? STAGES : [:generate]
96
+ stages = critique ? STAGES : ['generate']
96
97
  budgets = stages.to_h { |stage| [stage, stage_budget(stage)] }
97
98
  stage, budget = budgets.min_by { |_, value| value }
98
99
  return budget if budget.positive?
@@ -104,7 +105,7 @@ module Aireview
104
105
  end
105
106
 
106
107
  def stage_budget(stage)
107
- reserve = stage == :critique ? CANDIDATES_RESERVE_CHARS + CANDIDATES_HEADER.length : 0
108
+ reserve = stage == 'critique' ? CANDIDATES_RESERVE_CHARS + CANDIDATES_HEADER.length : 0
108
109
  @config.max_prompt_chars(stage) - system_prompt(stage).length - reserve
109
110
  end
110
111
 
@@ -152,7 +153,7 @@ module Aireview
152
153
  end
153
154
 
154
155
  def context_sizes(fixed:, packed:, budget:, diff_budget:, critique:)
155
- stages = critique ? STAGES : [:generate]
156
+ stages = critique ? STAGES : ['generate']
156
157
  {
157
158
  context_budget: budget,
158
159
  diff_budget: diff_budget,
@@ -7,13 +7,14 @@ module Aireview
7
7
  DIFF_UNAVAILABLE = '[diff not available]'
8
8
  BINARY_DIFF = /\ABinary files .* differ/
9
9
 
10
- # Один файл из ответа GitLab: заголовок, хунки и что с ним можно делать.
10
+ # One file from the GitLab answer: the header, the hunks and what can be done with it.
11
11
  # kind:
12
- # :text есть хунки, код можно проверить;
13
- # :no_text_changes переименование, смена режима, пустой файл: проверять
14
- # нечего;
15
- # :unavailable GitLab не отдал дифф (too_large, бинарник, пустой
16
- # дифф без причины): код есть, но проверить его не удалось.
12
+ # :text there are hunks, the code can be checked;
13
+ # :no_text_changes a rename, a mode change, an empty file: nothing to
14
+ # check;
15
+ # :unavailable GitLab did not return the diff (too_large, a binary,
16
+ # an empty diff without a reason): the code exists,
17
+ # but could not be checked.
17
18
  class Entry
18
19
  attr_reader :path, :kind, :header, :hunks
19
20
 
@@ -49,9 +50,9 @@ module Aireview
49
50
 
50
51
  private
51
52
 
52
- # Пустой дифф без объяснимой причины (переименование, смена режима,
53
- # пустой новый или удалённый файл) считаем недоступным: код есть, но
54
- # GitLab его не отдал.
53
+ # An empty diff without an explainable reason (a rename, a mode change,
54
+ # an empty new or deleted file) counts as unavailable: the code exists,
55
+ # but GitLab did not return it.
55
56
  def classify(change, diff)
56
57
  return :unavailable if change['too_large']
57
58
  return :unavailable if diff.match?(BINARY_DIFF)
@@ -67,8 +68,8 @@ module Aireview
67
68
  modes.none?(&:nil?) && modes.uniq.size == 2
68
69
  end
69
70
 
70
- # Хунки режем по заголовкам @@; текст до первого @@ (или дифф без них,
71
- # например заглушка про секретный файл) считается одним хунком.
71
+ # Hunks are split at the @@ headers; the text before the first @@ (or a
72
+ # diff without any, such as the secret-file placeholder) counts as one hunk.
72
73
  def split_hunks(diff)
73
74
  diff = "#{diff}\n" unless diff.end_with?("\n")
74
75
  pieces = diff.split(/^(?=@@ )/)