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.
@@ -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 → report. Invalid JSON is repaired once by the same model; when
15
- # the repair is invalid too, the stage restarts on another model with the
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
- def initialize(config:, reviewer: nil, context_builder: nil, logger: Logger.new($stderr))
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 = critique ? maybe_critique(context: context, candidates: candidates) : skip_critique(candidates)
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.require_models!
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
- def render(accepted, summary:, coverage: nil, fallback_models: {}, critique_weaker: false)
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
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Aireview
3
- VERSION = '2.1.0'
3
+ VERSION = '2.2.1'
4
4
  end
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.0
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-25 00:00:00.000000000 Z
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 and local Ollama, optional Jira context and posting
64
- a single updatable review note back to the merge request.'
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