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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +277 -28
- data/config/defaults.yml +47 -0
- data/lib/aireview/candidate_checker.rb +24 -22
- data/lib/aireview/cli.rb +46 -12
- data/lib/aireview/config.rb +65 -180
- data/lib/aireview/config_fallbacks.rb +79 -68
- data/lib/aireview/config_layers.rb +110 -0
- data/lib/aireview/config_limits.rb +10 -35
- data/lib/aireview/config_loader.rb +240 -0
- data/lib/aireview/context_budget.rb +22 -20
- data/lib/aireview/context_builder.rb +18 -17
- data/lib/aireview/diff_fetcher.rb +12 -11
- data/lib/aireview/dry_run_report.rb +33 -8
- data/lib/aireview/errors.rb +8 -0
- data/lib/aireview/gitlab_client.rb +10 -9
- data/lib/aireview/llm_client.rb +113 -0
- data/lib/aireview/llm_failure.rb +36 -19
- data/lib/aireview/llm_router.rb +315 -158
- data/lib/aireview/model_candidate.rb +28 -0
- data/lib/aireview/model_checker.rb +148 -0
- data/lib/aireview/model_pool.rb +224 -0
- data/lib/aireview/model_state.rb +82 -0
- data/lib/aireview/publisher.rb +6 -6
- data/lib/aireview/result_parser.rb +98 -0
- data/lib/aireview/review_marker.rb +16 -28
- data/lib/aireview/review_pipeline.rb +88 -86
- data/lib/aireview/review_renderer.rb +19 -11
- data/lib/aireview/reviewer.rb +37 -109
- data/lib/aireview/stage_chains.rb +113 -0
- data/lib/aireview/stages.rb +7 -0
- data/lib/aireview/utils.rb +29 -0
- data/lib/aireview/version.rb +1 -1
- data/lib/aireview.rb +1 -0
- metadata +19 -3
- data/lib/aireview/result_validation.rb +0 -65
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
22
|
+
raise ArgumentError, "unknown LLM stage #{stage.inspect}" unless STAGES.include?(stage)
|
|
48
23
|
|
|
49
24
|
positive_integer!(
|
|
50
|
-
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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!(
|
|
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!(
|
|
61
|
+
check_stage_size!('critique', system_prompt('critique'), user)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def system_prompt(stage)
|
|
65
|
-
template = stage.
|
|
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 : [
|
|
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 ==
|
|
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 : [
|
|
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
|
-
#
|
|
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
|
|
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(/^(?=@@ )/)
|