aireview 0.2.1 → 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 +66 -0
- data/README.md +353 -18
- data/config/.aireview.yml.example +8 -1
- data/config/defaults.yml +47 -0
- data/lib/aireview/candidate_checker.rb +169 -0
- data/lib/aireview/cli.rb +53 -13
- data/lib/aireview/config.rb +74 -179
- data/lib/aireview/config_fallbacks.rb +123 -0
- 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 +20 -19
- data/lib/aireview/diff_fetcher.rb +12 -11
- data/lib/aireview/dry_run_report.rb +49 -10
- 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 +107 -0
- data/lib/aireview/llm_router.rb +477 -0
- 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/prompts/critique.txt +7 -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 +119 -140
- data/lib/aireview/review_renderer.rb +44 -13
- data/lib/aireview/reviewer.rb +42 -267
- 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 +23 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'stages'
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'utils'
|
|
5
|
+
require_relative 'model_candidate'
|
|
6
|
+
require_relative 'stage_chains'
|
|
7
|
+
require_relative 'model_pool'
|
|
8
|
+
|
|
9
|
+
module Aireview
|
|
10
|
+
# Reserves for when the primary model is down or a key is out of quota:
|
|
11
|
+
# the routing plan (see StageChains and ModelPool) and the list of keys
|
|
12
|
+
# per provider. Models are set in .aireview.yml or the environment, keys
|
|
13
|
+
# only in the environment.
|
|
14
|
+
module ConfigFallbacks
|
|
15
|
+
KEYLESS_PROVIDERS = %w[ollama].freeze
|
|
16
|
+
DEFAULT_TIME_BUDGET = 1_800
|
|
17
|
+
DEFAULT_OVERLOADED_QUARANTINE = 120
|
|
18
|
+
|
|
19
|
+
# The routing plan is built once: a shared pool when llm.models is set,
|
|
20
|
+
# independent stage chains otherwise. A stage with a model of its own
|
|
21
|
+
# inside the pool is an independent chain.
|
|
22
|
+
def routing
|
|
23
|
+
@routing ||= build_routing
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stage_chain(stage)
|
|
27
|
+
routing.chain(stage)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Keys in order of preference; a single nil for a keyless provider, so
|
|
31
|
+
# that walking the chain does not depend on the provider.
|
|
32
|
+
def provider_api_keys(provider)
|
|
33
|
+
provider = provider.to_s
|
|
34
|
+
return [nil] if KEYLESS_PROVIDERS.include?(provider)
|
|
35
|
+
|
|
36
|
+
keys = Array(@data["#{provider}_api_keys"]).compact
|
|
37
|
+
keys = [provider_api_key(provider)].compact if keys.empty?
|
|
38
|
+
fallbacks_disabled? ? keys.first(1) : keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fallbacks_disabled?
|
|
42
|
+
@data['fallbacks_disabled'] == true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fallback_names(stage)
|
|
46
|
+
stage_chain(stage).drop(1).map(&:to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Only the number of keys per provider, for --dry-run; the values never
|
|
50
|
+
# leave.
|
|
51
|
+
def api_key_counts(stages)
|
|
52
|
+
providers = stages.flat_map { |stage| stage_chain(stage).map(&:provider) }.uniq
|
|
53
|
+
providers.reject { |provider| KEYLESS_PROVIDERS.include?(provider.to_s) }
|
|
54
|
+
.to_h { |provider| [provider, provider_api_keys(provider).size] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The ceiling for all LLM requests of the run, pauses between attempts
|
|
58
|
+
# included: the chain of reserves must not eat the whole CI job.
|
|
59
|
+
def llm_time_budget
|
|
60
|
+
positive_integer!(dig('llm', 'time_budget') || DEFAULT_TIME_BUDGET, 'llm.time_budget')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# For how many seconds an overloaded or hung model is skipped before the
|
|
64
|
+
# router tries it again.
|
|
65
|
+
def overloaded_quarantine
|
|
66
|
+
positive_integer!(dig('llm', 'overloaded_quarantine') || DEFAULT_OVERLOADED_QUARANTINE,
|
|
67
|
+
'llm.overloaded_quarantine')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def require_models!
|
|
71
|
+
missing = []
|
|
72
|
+
missing << 'llm.generate.model (or LLM_GENERATE_MODEL)' if Aireview::Utils.blank?(generate_model)
|
|
73
|
+
missing << 'llm.critique.model (or LLM_CRITIQUE_MODEL)' if Aireview::Utils.blank?(critique_model)
|
|
74
|
+
raise ConfigError, "LLM models are required: #{missing.join(', ')}" unless missing.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The plan is built whole (pool and critique policy validated) before the
|
|
78
|
+
# first request, not in Critique after a paid-for Generate.
|
|
79
|
+
def require_llm_configuration!
|
|
80
|
+
require_models!
|
|
81
|
+
routing
|
|
82
|
+
|
|
83
|
+
missing_keys = STAGES.flat_map do |stage|
|
|
84
|
+
providers = stage_chain(stage).map(&:provider).uniq.reject { |provider| provider_keys_present?(provider) }
|
|
85
|
+
providers.map { |provider| "#{stage}: API key is required for provider #{provider.inspect}" }
|
|
86
|
+
end
|
|
87
|
+
raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def build_routing
|
|
93
|
+
settings = STAGES.to_h { |stage| [stage, stage_settings(stage)] }
|
|
94
|
+
models = Array(dig('llm', 'models'))
|
|
95
|
+
return StageChains.build(settings, only_primary: fallbacks_disabled?) if models.empty?
|
|
96
|
+
|
|
97
|
+
own = settings.select { |_, stage_settings| Aireview::Utils.present?(stage_settings[:model]) }
|
|
98
|
+
ModelPool.new(
|
|
99
|
+
items: models, provider: llm_provider,
|
|
100
|
+
limits: STAGES.to_h { |stage| [stage, max_prompt_chars(stage)] },
|
|
101
|
+
starts: STAGES.to_h { |stage| [stage, dig('llm', stage, 'start')] },
|
|
102
|
+
inherited_starts: STAGES.select { |stage| start_inherited?(stage) },
|
|
103
|
+
rank: dig('llm', 'critique', 'rank'), allow_weaker: dig('llm', 'critique', 'allow_weaker'),
|
|
104
|
+
own_chains: StageChains.build(own, only_primary: fallbacks_disabled?), only_primary: fallbacks_disabled?
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def stage_settings(stage)
|
|
109
|
+
{
|
|
110
|
+
provider: stage_setting(stage, 'provider') || llm_provider,
|
|
111
|
+
model: dig('llm', stage, 'model'),
|
|
112
|
+
fallbacks: dig('llm', stage, 'fallbacks'),
|
|
113
|
+
max_prompt_chars: max_prompt_chars(stage)
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def provider_keys_present?(provider)
|
|
118
|
+
return true if ConfigFallbacks::KEYLESS_PROVIDERS.include?(provider.to_s)
|
|
119
|
+
|
|
120
|
+
provider_api_keys(provider).any? { |key| Aireview::Utils.present?(key) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -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 = []
|