aireview 2.1.0 → 2.2.1
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 +47 -0
- data/README.md +182 -5
- data/config/.aireview.yml.example +6 -0
- data/lib/aireview/cli.rb +11 -2
- data/lib/aireview/config.rb +26 -8
- data/lib/aireview/config_fallbacks.rb +43 -15
- data/lib/aireview/config_jev.rb +137 -0
- data/lib/aireview/config_layers.rb +1 -1
- data/lib/aireview/config_loader.rb +32 -6
- data/lib/aireview/context_builder.rb +13 -10
- data/lib/aireview/dry_run_prompts.rb +104 -0
- data/lib/aireview/dry_run_report.rb +40 -8
- data/lib/aireview/errors.rb +11 -0
- data/lib/aireview/jev_client.rb +129 -0
- data/lib/aireview/jev_critic.rb +387 -0
- data/lib/aireview/jev_shadow.rb +82 -0
- data/lib/aireview/jev_stage.rb +97 -0
- data/lib/aireview/llm_client.rb +52 -21
- data/lib/aireview/llm_router.rb +4 -4
- data/lib/aireview/model_candidate.rb +34 -5
- data/lib/aireview/model_checker.rb +46 -5
- data/lib/aireview/model_pool.rb +57 -28
- data/lib/aireview/prompts/jev_questions.yml +79 -0
- data/lib/aireview/review_marker.rb +7 -2
- data/lib/aireview/review_pipeline.rb +40 -55
- data/lib/aireview/review_renderer.rb +18 -2
- data/lib/aireview/stage_chains.rb +6 -3
- data/lib/aireview/version.rb +1 -1
- metadata +12 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'json'
|
|
3
3
|
require_relative 'errors'
|
|
4
|
+
require_relative 'utils'
|
|
4
5
|
require_relative 'stages'
|
|
5
6
|
require_relative 'context_builder'
|
|
6
7
|
require_relative 'candidate_checker'
|
|
@@ -8,12 +9,15 @@ require_relative 'result_parser'
|
|
|
8
9
|
require_relative 'review_renderer'
|
|
9
10
|
require_relative 'review_schemas'
|
|
10
11
|
require_relative 'reviewer'
|
|
12
|
+
require_relative 'jev_shadow'
|
|
13
|
+
require_relative 'jev_stage'
|
|
14
|
+
require_relative 'dry_run_prompts'
|
|
11
15
|
|
|
12
16
|
module Aireview
|
|
13
17
|
# A review run: context → Generate → anchoring check against the diff →
|
|
14
|
-
# Critique
|
|
15
|
-
#
|
|
16
|
-
# original request.
|
|
18
|
+
# Critique (an LLM, or Jev with llm.critique.engine: jev) → report. Invalid
|
|
19
|
+
# JSON is repaired once by the same model; when the repair is invalid too,
|
|
20
|
+
# the stage restarts on another model with the original request.
|
|
17
21
|
class ReviewPipeline
|
|
18
22
|
SchemaError = ResultParser::SchemaError
|
|
19
23
|
|
|
@@ -23,20 +27,22 @@ module Aireview
|
|
|
23
27
|
Do not use markdown, code fences, comments, or text outside JSON.
|
|
24
28
|
Do not add new review findings.
|
|
25
29
|
PROMPT
|
|
26
|
-
DRY_RUN_CANDIDATES_JSON = '[{"id":"C1","file":"path/from/diff.rb","line":1,' \
|
|
27
|
-
'"quoted_code":"...","problem":"...","why":"...","suggestion":"...",' \
|
|
28
|
-
'"category":"bug","severity":"major"}]'
|
|
29
30
|
# Finish reasons after which an unparsable answer gets no repair.
|
|
30
31
|
CUT_OFF_REASONS = {
|
|
31
32
|
max_tokens: 'cut off at the output limit (max_tokens)',
|
|
32
33
|
content_filter: 'blocked by the provider (content_filter)'
|
|
33
34
|
}.freeze
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
# jev — jev_shadow: and jev_stage: for tests; by default they are built
|
|
37
|
+
# from the config, the stage only when Jev is the engine.
|
|
38
|
+
def initialize(config:, reviewer: nil, context_builder: nil, logger: Logger.new($stderr), **jev)
|
|
36
39
|
@config = config
|
|
37
40
|
@parser = ResultParser.new
|
|
38
41
|
@reviewer = reviewer || Reviewer.new(config: config, logger: logger)
|
|
39
42
|
@context_builder = context_builder || ContextBuilder.new(config: config, logger: logger)
|
|
43
|
+
@jev_shadow = jev[:jev_shadow] ||
|
|
44
|
+
JevShadow.new(config: config, scrub: @context_builder.method(:scrub_text), logger: logger)
|
|
45
|
+
@jev_stage = jev[:jev_stage]
|
|
40
46
|
@logger = logger
|
|
41
47
|
end
|
|
42
48
|
|
|
@@ -63,7 +69,11 @@ module Aireview
|
|
|
63
69
|
"(model=#{@reviewer.answered_model('generate')})")
|
|
64
70
|
candidates = check_candidates(context: context, changes: changes, candidates: candidates)
|
|
65
71
|
|
|
66
|
-
accepted
|
|
72
|
+
accepted, jev_note = if critique
|
|
73
|
+
maybe_critique(context: context, candidates: candidates)
|
|
74
|
+
else
|
|
75
|
+
skip_critique(candidates)
|
|
76
|
+
end
|
|
67
77
|
|
|
68
78
|
@logger.info("Pipeline finished with #{accepted.size} accepted finding(s)")
|
|
69
79
|
|
|
@@ -72,58 +82,19 @@ module Aireview
|
|
|
72
82
|
summary: summary,
|
|
73
83
|
coverage: context.coverage,
|
|
74
84
|
fallback_models: @reviewer.fallback_models,
|
|
75
|
-
critique_weaker: critique && @reviewer.critique_weaker
|
|
85
|
+
critique_weaker: critique && @reviewer.critique_weaker?,
|
|
86
|
+
jev_note: jev_note
|
|
76
87
|
)
|
|
77
88
|
end
|
|
78
89
|
|
|
90
|
+
# The prompts of the run and everything --dry-run shows; see DryRunPrompts.
|
|
79
91
|
def dry_run_prompts(merge_request:, changes:, jira_issue: nil, critique: true)
|
|
80
|
-
@config
|
|
81
|
-
|
|
82
|
-
context = @context_builder.prepare(
|
|
83
|
-
merge_request: merge_request,
|
|
84
|
-
changes: changes,
|
|
85
|
-
jira_issue: jira_issue,
|
|
86
|
-
critique: critique
|
|
87
|
-
)
|
|
88
|
-
generate_prompt = @context_builder.build_generate_prompt(context)
|
|
89
|
-
critique_prompt = if critique
|
|
90
|
-
@context_builder.build_critique_prompt(context, candidates_json: DRY_RUN_CANDIDATES_JSON)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
{
|
|
94
|
-
generate_prompt: generate_prompt,
|
|
95
|
-
critique_prompt: critique_prompt,
|
|
96
|
-
generate_model: @config.generate_model,
|
|
97
|
-
generate_temperature: @config.generate_temperature,
|
|
98
|
-
critique_model: @config.critique_model,
|
|
99
|
-
critique_temperature: @config.critique_temperature,
|
|
100
|
-
generate_fallbacks: @config.fallback_names('generate'),
|
|
101
|
-
critique_fallbacks: critique ? @config.fallback_names('critique') : [],
|
|
102
|
-
sources: setting_sources(critique),
|
|
103
|
-
config_paths: @config.layer_paths,
|
|
104
|
-
warnings: @config.warnings,
|
|
105
|
-
critique_rule: critique ? @config.routing.rule : nil,
|
|
106
|
-
api_keys: @config.api_key_counts(critique ? STAGES : ['generate']),
|
|
107
|
-
time_budget: @config.llm_time_budget,
|
|
108
|
-
overloaded_quarantine: @config.overloaded_quarantine,
|
|
109
|
-
coverage: context.coverage,
|
|
110
|
-
sizes: context.sizes
|
|
111
|
-
}
|
|
92
|
+
DryRunPrompts.new(config: @config, context_builder: @context_builder, logger: @logger)
|
|
93
|
+
.build(merge_request: merge_request, changes: changes, jira_issue: jira_issue, critique: critique)
|
|
112
94
|
end
|
|
113
95
|
|
|
114
96
|
private
|
|
115
97
|
|
|
116
|
-
# Where the model, provider and reserves of a stage came from, for --dry-run.
|
|
117
|
-
def setting_sources(critique)
|
|
118
|
-
(critique ? %w[generate critique] : %w[generate]).to_h do |stage|
|
|
119
|
-
[stage.to_sym, {
|
|
120
|
-
model: @config.stage_model_source(stage),
|
|
121
|
-
provider: @config.stage_provider_source(stage),
|
|
122
|
-
fallbacks: @config.stage_fallbacks_source(stage)
|
|
123
|
-
}]
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
98
|
# A stage is a request, parsing and one repair by the same model. An
|
|
128
99
|
# invalid result after the repair, like a repair with no requests left,
|
|
129
100
|
# excludes the model for the stage, and the stage starts over on the next
|
|
@@ -153,18 +124,32 @@ module Aireview
|
|
|
153
124
|
).check(candidates)
|
|
154
125
|
end
|
|
155
126
|
|
|
127
|
+
# Returns the candidates and no report note, like every critique path.
|
|
156
128
|
def skip_critique(candidates, reason = nil)
|
|
157
129
|
@logger.info(['Pipeline critique pass skipped', reason].compact.join(': '))
|
|
158
|
-
candidates
|
|
130
|
+
[candidates, nil]
|
|
159
131
|
end
|
|
160
132
|
|
|
161
133
|
# Critique has nothing to filter without candidates: the LLM request
|
|
162
|
-
# would waste quota and time.
|
|
134
|
+
# would waste quota and time. Returns [accepted, note for the report].
|
|
163
135
|
def maybe_critique(context:, candidates:)
|
|
164
136
|
return skip_critique(candidates, 'no candidates') if candidates.empty?
|
|
137
|
+
return jev_critique(context: context, candidates: candidates) if @config.jev_critique?
|
|
165
138
|
|
|
166
139
|
@logger.info("Pipeline critique pass started (model=#{@config.critique_model})")
|
|
167
|
-
critique_candidates(context: context, candidates: candidates)
|
|
140
|
+
accepted = critique_candidates(context: context, candidates: candidates)
|
|
141
|
+
@jev_shadow.run(context: context, candidates: candidates, accepted: accepted) if @config.jev_shadow?
|
|
142
|
+
[accepted, nil]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def jev_critique(context:, candidates:)
|
|
146
|
+
@jev_stage ||= JevStage.new(
|
|
147
|
+
config: @config, logger: @logger,
|
|
148
|
+
critic: JevCritic.build(config: @config, scrub: @context_builder.method(:scrub_text), logger: @logger)
|
|
149
|
+
)
|
|
150
|
+
llm_critique = ->(subset) { critique_candidates(context: context, candidates: subset) }
|
|
151
|
+
outcome = @jev_stage.run(context: context, candidates: candidates, llm_critique: llm_critique)
|
|
152
|
+
[outcome.accepted, outcome.note]
|
|
168
153
|
end
|
|
169
154
|
|
|
170
155
|
def critique_candidates(context:, candidates:)
|
|
@@ -47,6 +47,10 @@ module Aireview
|
|
|
47
47
|
section_list: 'Truncated sections',
|
|
48
48
|
fallback_used: 'Fallback model used',
|
|
49
49
|
critique_weaker: 'Critique ran on a model weaker than Generate: the findings were checked less strictly.',
|
|
50
|
+
jev: 'The findings were checked by Jev, a fast classifier, without refining their wording.',
|
|
51
|
+
jev_partial: 'The findings were checked by Jev, a fast classifier, without refining their wording; ' \
|
|
52
|
+
'those Jev could not judge were checked by the LLM critique.',
|
|
53
|
+
jev_failed: 'Jev was unavailable: the findings were checked by the LLM critique.',
|
|
50
54
|
quote_missing: 'quote not found in the diff'
|
|
51
55
|
},
|
|
52
56
|
'ru' => {
|
|
@@ -74,6 +78,10 @@ module Aireview
|
|
|
74
78
|
section_list: 'Усечённые секции',
|
|
75
79
|
fallback_used: 'Использована резервная модель',
|
|
76
80
|
critique_weaker: 'Критика выполнена моделью слабее generate: замечания проверены менее строго.',
|
|
81
|
+
jev: 'Замечания проверены быстрым классификатором Jev, без уточнения формулировок.',
|
|
82
|
+
jev_partial: 'Замечания проверены быстрым классификатором Jev, без уточнения формулировок; ' \
|
|
83
|
+
'те, что Jev не смог оценить, проверила LLM-критика.',
|
|
84
|
+
jev_failed: 'Jev был недоступен: замечания проверила LLM-критика.',
|
|
77
85
|
quote_missing: 'цитата не найдена в диффе'
|
|
78
86
|
}
|
|
79
87
|
}.freeze
|
|
@@ -86,7 +94,9 @@ module Aireview
|
|
|
86
94
|
# result is still about the findings; incomplete coverage is written
|
|
87
95
|
# next to it so that the result line does not read as "everything was
|
|
88
96
|
# checked".
|
|
89
|
-
|
|
97
|
+
# jev_note — how Jev took part in the critique (see JevStage::Outcome).
|
|
98
|
+
# The facts about the run are named one by one on purpose.
|
|
99
|
+
def render(accepted, summary:, coverage: nil, fallback_models: {}, critique_weaker: false, jev_note: nil) # rubocop:disable Metrics/ParameterLists
|
|
90
100
|
mismatches, important = select_findings(Array(accepted))
|
|
91
101
|
result = mismatches.empty? && important.empty? ? 'ok' : 'needs attention'
|
|
92
102
|
|
|
@@ -106,7 +116,7 @@ module Aireview
|
|
|
106
116
|
## #{label(:result)}
|
|
107
117
|
|
|
108
118
|
#{result}#{partial_note(coverage)}
|
|
109
|
-
#{coverage_block(coverage)}#{fallback_note(fallback_models)}#{weaker_note(critique_weaker)}
|
|
119
|
+
#{coverage_block(coverage)}#{fallback_note(fallback_models)}#{weaker_note(critique_weaker)}#{jev_note(jev_note)}
|
|
110
120
|
#{label(:disclaimer)}
|
|
111
121
|
MARKDOWN
|
|
112
122
|
end
|
|
@@ -191,6 +201,12 @@ module Aireview
|
|
|
191
201
|
"\n#{label(:critique_weaker)}\n"
|
|
192
202
|
end
|
|
193
203
|
|
|
204
|
+
# Jev decides keep/reject but cannot refine: the reader should know the
|
|
205
|
+
# wording is the first pass's own.
|
|
206
|
+
def jev_note(note)
|
|
207
|
+
note ? "\n#{label(note)}\n" : ''
|
|
208
|
+
end
|
|
209
|
+
|
|
194
210
|
def fallback_note(fallback_models)
|
|
195
211
|
return '' if fallback_models.nil? || fallback_models.empty?
|
|
196
212
|
|
|
@@ -26,7 +26,8 @@ module Aireview
|
|
|
26
26
|
primary = ModelCandidate.new(
|
|
27
27
|
provider: settings.fetch(:provider).to_s,
|
|
28
28
|
model: settings[:model],
|
|
29
|
-
max_prompt_chars: settings.fetch(:max_prompt_chars)
|
|
29
|
+
max_prompt_chars: settings.fetch(:max_prompt_chars),
|
|
30
|
+
api_base: ModelCandidate.api_base(settings[:api_base], "llm.#{stage}.api_base")
|
|
30
31
|
)
|
|
31
32
|
return [primary] if only_primary
|
|
32
33
|
|
|
@@ -34,7 +35,8 @@ module Aireview
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# A reserve without a provider inherits the stage provider, without a
|
|
37
|
-
# limit its limit.
|
|
38
|
+
# limit its limit. The address is not inherited: a reserve on another
|
|
39
|
+
# server names its own.
|
|
38
40
|
def self.fallbacks(stage, items, primary)
|
|
39
41
|
items.each_with_index.map do |item, index|
|
|
40
42
|
item = ModelCandidate.parse_item(item)
|
|
@@ -46,7 +48,8 @@ module Aireview
|
|
|
46
48
|
ModelCandidate.new(
|
|
47
49
|
provider: (item['provider'] || primary.provider).to_s,
|
|
48
50
|
model: item['model'].to_s,
|
|
49
|
-
max_prompt_chars: limit.nil? ? primary.max_prompt_chars : positive_limit(limit, name)
|
|
51
|
+
max_prompt_chars: limit.nil? ? primary.max_prompt_chars : positive_limit(limit, name),
|
|
52
|
+
api_base: ModelCandidate.api_base(item['api_base'], "#{name}.api_base")
|
|
50
53
|
)
|
|
51
54
|
end
|
|
52
55
|
end
|
data/lib/aireview/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: aireview
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.1
|
|
4
|
+
version: 2.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Denis Levenko
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dotenv
|
|
@@ -60,8 +60,9 @@ dependencies:
|
|
|
60
60
|
version: 2.0.0
|
|
61
61
|
description: 'Reviews self-hosted GitLab merge requests with a two-pass LLM pipeline:
|
|
62
62
|
the first pass finds candidate findings, the second one critiques them and drops
|
|
63
|
-
the weak ones. Supports Gemini
|
|
64
|
-
|
|
63
|
+
the weak ones. Supports Gemini, Ollama, OpenAI, Anthropic, OpenRouter and OpenAI-compatible
|
|
64
|
+
servers, Jev as the critic, optional Jira context and posting a single updatable
|
|
65
|
+
review note back to the merge request.'
|
|
65
66
|
email:
|
|
66
67
|
executables:
|
|
67
68
|
- aireview
|
|
@@ -80,15 +81,21 @@ files:
|
|
|
80
81
|
- lib/aireview/cli.rb
|
|
81
82
|
- lib/aireview/config.rb
|
|
82
83
|
- lib/aireview/config_fallbacks.rb
|
|
84
|
+
- lib/aireview/config_jev.rb
|
|
83
85
|
- lib/aireview/config_layers.rb
|
|
84
86
|
- lib/aireview/config_limits.rb
|
|
85
87
|
- lib/aireview/config_loader.rb
|
|
86
88
|
- lib/aireview/context_budget.rb
|
|
87
89
|
- lib/aireview/context_builder.rb
|
|
88
90
|
- lib/aireview/diff_fetcher.rb
|
|
91
|
+
- lib/aireview/dry_run_prompts.rb
|
|
89
92
|
- lib/aireview/dry_run_report.rb
|
|
90
93
|
- lib/aireview/errors.rb
|
|
91
94
|
- lib/aireview/gitlab_client.rb
|
|
95
|
+
- lib/aireview/jev_client.rb
|
|
96
|
+
- lib/aireview/jev_critic.rb
|
|
97
|
+
- lib/aireview/jev_shadow.rb
|
|
98
|
+
- lib/aireview/jev_stage.rb
|
|
92
99
|
- lib/aireview/jira_client.rb
|
|
93
100
|
- lib/aireview/llm_client.rb
|
|
94
101
|
- lib/aireview/llm_failure.rb
|
|
@@ -101,6 +108,7 @@ files:
|
|
|
101
108
|
- lib/aireview/output_schemas.rb
|
|
102
109
|
- lib/aireview/prompts/critique.txt
|
|
103
110
|
- lib/aireview/prompts/generate.txt
|
|
111
|
+
- lib/aireview/prompts/jev_questions.yml
|
|
104
112
|
- lib/aireview/publisher.rb
|
|
105
113
|
- lib/aireview/result_parser.rb
|
|
106
114
|
- lib/aireview/review_marker.rb
|