aireview 0.1.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,373 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require_relative 'errors'
4
+ require_relative 'context_builder'
5
+ require_relative 'review_renderer'
6
+ require_relative 'review_schemas'
7
+ require_relative 'reviewer'
8
+
9
+ module Aireview
10
+ class ReviewPipeline
11
+ class SchemaError < StandardError
12
+ end
13
+
14
+ REPAIR_SYSTEM_PROMPT = <<~PROMPT.strip.freeze
15
+ You fix invalid JSON produced by another LLM call.
16
+ Return only valid JSON matching the requested schema.
17
+ Do not use markdown, code fences, comments, or text outside JSON.
18
+ Do not add new review findings.
19
+ PROMPT
20
+ DRY_RUN_CANDIDATES_JSON = '[{"id":"C1","file":"path/from/diff.rb","line":1,' \
21
+ '"quoted_code":"...","problem":"...","why":"...","suggestion":"...",' \
22
+ '"category":"bug","severity":"major"}]'
23
+
24
+ def initialize(config:, reviewer: nil, context_builder: nil, logger: Logger.new($stderr))
25
+ @config = config
26
+ @reviewer = reviewer || Reviewer.new(config: config, logger: logger)
27
+ @context_builder = context_builder || ContextBuilder.new(config: config, logger: logger)
28
+ @logger = logger
29
+ end
30
+
31
+ def run(merge_request:, changes_text:, jira_issue: nil, critique: true)
32
+ generate_prompt = @context_builder.build_generate_prompt(
33
+ merge_request: merge_request,
34
+ changes_text: changes_text,
35
+ jira_issue: jira_issue
36
+ )
37
+ @logger.info("Pipeline generate pass started (model=#{@config.generate_model})")
38
+ candidates_raw = @reviewer.generate(**generate_prompt)
39
+ generate_result = parse_with_repair(
40
+ raw: candidates_raw,
41
+ kind: 'generate result',
42
+ expected: :generate,
43
+ repair_stage: :generate
44
+ )
45
+ summary = generate_result['summary']
46
+ candidates = Array(generate_result['candidates'])
47
+ @logger.info("Pipeline generate pass completed with #{candidates.size} candidate(s)")
48
+
49
+ accepted = if critique
50
+ @logger.info("Pipeline critique pass started (model=#{@config.critique_model})")
51
+ critique_candidates(
52
+ merge_request: merge_request,
53
+ changes_text: changes_text,
54
+ jira_issue: jira_issue,
55
+ candidates: candidates
56
+ )
57
+ else
58
+ @logger.info('Pipeline critique pass skipped')
59
+ candidates
60
+ end
61
+
62
+ @logger.info("Pipeline finished with #{accepted.size} accepted finding(s)")
63
+
64
+ ReviewRenderer.new(language: @config.review_language).render(accepted, summary: summary)
65
+ end
66
+
67
+ def dry_run_prompts(merge_request:, changes_text:, jira_issue: nil, critique: true)
68
+ @config.require_models!
69
+
70
+ generate_prompt = @context_builder.build_generate_prompt(
71
+ merge_request: merge_request,
72
+ changes_text: changes_text,
73
+ jira_issue: jira_issue
74
+ )
75
+
76
+ critique_prompt = if critique
77
+ @context_builder.build_critique_prompt(
78
+ merge_request: merge_request,
79
+ changes_text: changes_text,
80
+ jira_issue: jira_issue,
81
+ candidates_json: DRY_RUN_CANDIDATES_JSON
82
+ )
83
+ end
84
+
85
+ {
86
+ generate_prompt: generate_prompt,
87
+ critique_prompt: critique_prompt,
88
+ generate_model: @config.generate_model,
89
+ generate_temperature: @config.generate_temperature,
90
+ critique_model: @config.critique_model,
91
+ critique_temperature: @config.critique_temperature
92
+ }
93
+ end
94
+
95
+ private
96
+
97
+ def critique_candidates(merge_request:, changes_text:, jira_issue:, candidates:)
98
+ candidates_json = JSON.pretty_generate(candidates)
99
+ candidates_by_id = index_candidates_by_id(candidates)
100
+ critique_prompt = @context_builder.build_critique_prompt(
101
+ merge_request: merge_request,
102
+ changes_text: changes_text,
103
+ jira_issue: jira_issue,
104
+ candidates_json: candidates_json
105
+ )
106
+ critique_raw = @reviewer.critique(**critique_prompt)
107
+ critique_result = parse_with_repair(
108
+ raw: critique_raw,
109
+ kind: 'critique result',
110
+ expected: :critique,
111
+ repair_stage: :critique,
112
+ critique_candidate_ids: candidates_by_id.keys
113
+ )
114
+ verdicts = Array(critique_result['verdicts'])
115
+ @logger.info("Pipeline critique pass completed with #{verdicts.size} verdict(s)")
116
+ apply_critique_verdicts(
117
+ verdicts: verdicts,
118
+ candidates_by_id: candidates_by_id
119
+ )
120
+ end
121
+
122
+ def parse_with_repair(raw:, kind:, expected:, repair_stage:, critique_candidate_ids: nil)
123
+ if raw.is_a?(Hash)
124
+ return parse_structured_result(
125
+ raw,
126
+ kind: kind,
127
+ expected: expected,
128
+ critique_candidate_ids: critique_candidate_ids
129
+ )
130
+ end
131
+
132
+ raise ParseError, "LLM returned unsupported #{kind} type: #{raw.class}" unless raw.is_a?(String)
133
+
134
+ parse_string_with_repair(
135
+ raw: raw,
136
+ kind: kind,
137
+ expected: expected,
138
+ repair_stage: repair_stage,
139
+ critique_candidate_ids: critique_candidate_ids
140
+ )
141
+ end
142
+
143
+ def parse_structured_result(raw, kind:, expected:, critique_candidate_ids: nil)
144
+ parse_expected_result(raw, expected, critique_candidate_ids: critique_candidate_ids)
145
+ rescue SchemaError => e
146
+ raise ParseError, "LLM returned invalid #{kind}: #{e.message}"
147
+ end
148
+
149
+ def parse_string_with_repair(raw:, kind:, expected:, repair_stage:, critique_candidate_ids: nil)
150
+ parse_expected_result(raw, expected, critique_candidate_ids: critique_candidate_ids)
151
+ rescue JSON::ParserError, SchemaError => e
152
+ @logger.warn("Invalid #{kind} JSON, requesting one repair: #{e.message}")
153
+ repaired = repair_json(
154
+ raw: raw,
155
+ kind: kind,
156
+ expected: expected,
157
+ stage: repair_stage,
158
+ critique_candidate_ids: critique_candidate_ids
159
+ )
160
+ begin
161
+ parse_expected_result(repaired, expected, critique_candidate_ids: critique_candidate_ids)
162
+ rescue JSON::ParserError, SchemaError => second_error
163
+ raise ParseError, "LLM returned invalid #{kind} JSON after repair: #{second_error.message}"
164
+ end
165
+ end
166
+
167
+ def parse_expected_result(raw, expected, critique_candidate_ids: nil)
168
+ parsed = raw.is_a?(Hash) ? raw : JSON.parse(strip_code_fences(raw.to_s))
169
+
170
+ case expected
171
+ when :generate
172
+ parsed = normalize_generate_result(parsed)
173
+ when :critique
174
+ parsed = normalize_critique_result(parsed, critique_candidate_ids: critique_candidate_ids)
175
+ else
176
+ raise ArgumentError, "Unknown expected JSON schema: #{expected.inspect}"
177
+ end
178
+
179
+ parsed
180
+ end
181
+
182
+ def strip_code_fences(text)
183
+ stripped = text.to_s.strip
184
+ return stripped unless stripped.start_with?('```')
185
+
186
+ stripped
187
+ .sub(/\A```[[:alnum:]_-]*[ \t]*\r?\n?/, '')
188
+ .sub(/\r?\n?```[ \t]*\z/, '')
189
+ .strip
190
+ end
191
+
192
+ def normalize_generate_result(parsed)
193
+ parsed = {'summary' => nil, 'candidates' => parsed} if parsed.is_a?(Array)
194
+ validate_generate_result_shape!(parsed)
195
+ parsed['summary'] = nil unless parsed.key?('summary')
196
+ candidate_ids = parsed['candidates'].map { |candidate| normalize_id(value(candidate, 'id')) }
197
+ validate_identifiers!(
198
+ candidate_ids,
199
+ missing_message: 'each generate candidate must include a non-empty id',
200
+ duplicate_prefix: 'duplicate generate candidate ids'
201
+ )
202
+
203
+ parsed
204
+ end
205
+
206
+ def normalize_critique_result(parsed, critique_candidate_ids:)
207
+ validate_critique_result_shape!(parsed)
208
+ verdicts = parsed['verdicts']
209
+ verdict_ids = verdicts.map { |verdict| normalize_id(value(verdict, 'id')) }
210
+ validate_identifiers!(
211
+ verdict_ids,
212
+ missing_message: 'each verdict must include a non-empty id',
213
+ duplicate_prefix: 'duplicate verdict ids'
214
+ )
215
+ validate_expected_verdict_ids!(verdict_ids, critique_candidate_ids)
216
+ verdicts.each { |verdict| validate_verdict!(verdict) }
217
+
218
+ parsed
219
+ end
220
+
221
+ def validate_generate_result_shape!(parsed)
222
+ valid_shape = parsed.is_a?(Hash) && parsed['candidates'].is_a?(Array)
223
+ raise SchemaError, 'expected an object with summary and candidates array' unless valid_shape
224
+ raise SchemaError, 'each generate candidate must be an object' unless parsed['candidates'].all?(Hash)
225
+ end
226
+
227
+ def validate_critique_result_shape!(parsed)
228
+ valid_shape = parsed.is_a?(Hash) && parsed['verdicts'].is_a?(Array)
229
+ raise SchemaError, 'expected an object with verdicts array' unless valid_shape
230
+ raise SchemaError, 'each critique verdict must be an object' unless parsed['verdicts'].all?(Hash)
231
+ end
232
+
233
+ def validate_identifiers!(identifiers, missing_message:, duplicate_prefix:)
234
+ raise SchemaError, missing_message unless identifiers.all?
235
+
236
+ duplicate_ids = identifiers.group_by(&:itself).select { |_, ids| ids.size > 1 }.keys
237
+ return if duplicate_ids.empty?
238
+
239
+ raise SchemaError, "#{duplicate_prefix}: #{duplicate_ids.join(', ')}"
240
+ end
241
+
242
+ def validate_expected_verdict_ids!(verdict_ids, expected_ids)
243
+ return unless expected_ids
244
+
245
+ unknown_ids = verdict_ids - expected_ids
246
+ missing_ids = expected_ids - verdict_ids
247
+ raise SchemaError, "unknown verdict ids: #{unknown_ids.join(', ')}" unless unknown_ids.empty?
248
+ raise SchemaError, "missing verdict ids: #{missing_ids.join(', ')}" unless missing_ids.empty?
249
+ end
250
+
251
+ def validate_verdict!(verdict)
252
+ id = normalize_id(value(verdict, 'id'))
253
+ decision = normalize_decision(value(verdict, 'decision'))
254
+ raise SchemaError, "invalid verdict decision for #{id}" unless %w[keep reject].include?(decision)
255
+
256
+ refinement = value(verdict, 'refinement')
257
+ raise SchemaError, "reject verdict cannot include refinement for #{id}" if invalid_refinement?(verdict, decision)
258
+ return if refinement.nil?
259
+ return if refinement.is_a?(Hash)
260
+
261
+ raise SchemaError, "refinement must be an object for #{id}"
262
+ end
263
+
264
+ def invalid_refinement?(verdict, decision)
265
+ refinement_key?(verdict) && decision != 'keep'
266
+ end
267
+
268
+ def repair_json(raw:, kind:, expected:, stage:, critique_candidate_ids: nil)
269
+ schema = expected == :critique ? ReviewSchemas.critique : ReviewSchemas.generate
270
+ user_prompt = <<~PROMPT
271
+ The previous #{kind} response was invalid.
272
+
273
+ Convert it into valid JSON matching this schema:
274
+ #{schema}
275
+
276
+ Do not add new findings. Do not remove findings unless they cannot be represented.
277
+ Return only JSON.
278
+
279
+ Invalid response:
280
+ #{raw}
281
+ PROMPT
282
+ if stage == :critique && critique_candidate_ids
283
+ user_prompt << "\nExpected candidate ids: #{critique_candidate_ids.join(', ')}\n"
284
+ end
285
+
286
+ @logger.info("Pipeline #{stage} repair started for #{kind}")
287
+ if stage == :critique
288
+ @reviewer.critique(system_prompt: REPAIR_SYSTEM_PROMPT, user_prompt: user_prompt)
289
+ else
290
+ @reviewer.generate(system_prompt: REPAIR_SYSTEM_PROMPT, user_prompt: user_prompt)
291
+ end
292
+ end
293
+
294
+ def index_candidates_by_id(candidates)
295
+ candidates.each_with_object({}) do |candidate, result|
296
+ next unless candidate.is_a?(Hash)
297
+
298
+ result[normalize_id(value(candidate, 'id'))] = candidate
299
+ end
300
+ end
301
+
302
+ def apply_critique_verdicts(verdicts:, candidates_by_id:)
303
+ accepted = []
304
+
305
+ verdicts.each do |verdict|
306
+ id = normalize_id(value(verdict, 'id'))
307
+ decision = normalize_decision(value(verdict, 'decision'))
308
+ reason = presence(value(verdict, 'reason')) || 'No reason provided.'
309
+ refinement = value(verdict, 'refinement')
310
+
311
+ if decision == 'reject'
312
+ @logger.info("Critique reject #{id}: #{reason}")
313
+ next
314
+ end
315
+
316
+ candidate = candidates_by_id.fetch(id)
317
+ merged = merge_candidate_refinement(candidate: candidate, refinement: refinement)
318
+ @logger.debug("Critique keep #{id}#{refinement_delta(candidate, merged)}: #{reason}")
319
+ accepted << merged
320
+ end
321
+
322
+ accepted
323
+ end
324
+
325
+ def merge_candidate_refinement(candidate:, refinement:)
326
+ return candidate unless refinement.is_a?(Hash)
327
+
328
+ merged = candidate.dup
329
+ %w[problem why suggestion category severity].each do |key|
330
+ next unless refinement.key?(key) || refinement.key?(key.to_sym)
331
+ next unless (new_value = presence(value(refinement, key)))
332
+
333
+ merged[key] = new_value
334
+ end
335
+ merged
336
+ end
337
+
338
+ def refinement_delta(original, refined)
339
+ changes = []
340
+ %w[category severity].each do |key|
341
+ before = presence(value(original, key))
342
+ after = presence(value(refined, key))
343
+ next if before == after
344
+
345
+ changes << "#{key} #{before || 'nil'}->#{after || 'nil'}"
346
+ end
347
+ return '' if changes.empty?
348
+
349
+ " (#{changes.join(', ')})"
350
+ end
351
+
352
+ def value(hash, key)
353
+ hash[key] || hash[key.to_sym]
354
+ end
355
+
356
+ def normalize_id(value)
357
+ presence(value)
358
+ end
359
+
360
+ def normalize_decision(value)
361
+ value.to_s.strip.downcase
362
+ end
363
+
364
+ def refinement_key?(verdict)
365
+ verdict.key?('refinement') || verdict.key?(:refinement)
366
+ end
367
+
368
+ def presence(value)
369
+ string = value.to_s.strip
370
+ string.empty? ? nil : string
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aireview
4
+ class ReviewRenderer
5
+ MISMATCH_LIMIT = 2
6
+ IMPORTANT_LIMIT = 3
7
+ TOTAL_FINDINGS_LIMIT = 3
8
+ IMPORTANT_CATEGORIES = %w[
9
+ bug
10
+ regression
11
+ security
12
+ performance
13
+ data_loss
14
+ edge_case
15
+ ].freeze
16
+
17
+ SEVERITY_ORDER = {
18
+ 'critical' => 0,
19
+ 'major' => 1,
20
+ 'minor' => 2
21
+ }.freeze
22
+
23
+ DEFAULT_LANGUAGE = 'en'
24
+ LABELS = {
25
+ 'en' => {
26
+ summary: 'Summary',
27
+ mismatches: 'Mismatches',
28
+ important: 'Important findings',
29
+ result: 'Result',
30
+ disclaimer: 'This report was generated by an AI and may contain mistakes. ' \
31
+ 'Verify the findings by hand before acting on them.',
32
+ no_summary: 'The first pass did not return a summary of the changes.',
33
+ none_found: 'None found.',
34
+ not_specified: 'Not specified.',
35
+ where: 'Where',
36
+ problem: 'Problem',
37
+ why: 'Why it matters',
38
+ suggestion: 'Suggestion'
39
+ },
40
+ 'ru' => {
41
+ summary: 'Сводка',
42
+ mismatches: 'Несоответствия',
43
+ important: 'Важные замечания',
44
+ result: 'Результат',
45
+ disclaimer: 'Отчёт сгенерирован ИИ и может содержать ошибки. ' \
46
+ 'Проверьте замечания вручную перед принятием решений.',
47
+ no_summary: 'Сводка изменений не была возвращена на первом проходе.',
48
+ none_found: 'Не найдено.',
49
+ not_specified: 'Не указано.',
50
+ where: 'Где',
51
+ problem: 'Проблема',
52
+ why: 'Почему важно',
53
+ suggestion: 'Предложение'
54
+ }
55
+ }.freeze
56
+
57
+ def initialize(language: DEFAULT_LANGUAGE)
58
+ @labels = LABELS.fetch(language.to_s) { LABELS.fetch(DEFAULT_LANGUAGE) }
59
+ end
60
+
61
+ def render(accepted, summary:)
62
+ findings = sorted_findings(Array(accepted)).first(TOTAL_FINDINGS_LIMIT)
63
+ mismatches = findings.select { |finding| category(finding) == 'task_mismatch' }.first(MISMATCH_LIMIT)
64
+ important = findings.select { |finding| important_finding?(finding) }.first(IMPORTANT_LIMIT)
65
+ result = mismatches.empty? && important.empty? ? 'ok' : 'needs attention'
66
+
67
+ <<~MARKDOWN.rstrip
68
+ ## #{label(:summary)}
69
+
70
+ #{summary_text(summary)}
71
+
72
+ ## #{label(:mismatches)}
73
+
74
+ #{render_findings(mismatches)}
75
+
76
+ ## #{label(:important)}
77
+
78
+ #{render_findings(important)}
79
+
80
+ ## #{label(:result)}
81
+
82
+ #{result}
83
+
84
+ #{label(:disclaimer)}
85
+ MARKDOWN
86
+ end
87
+
88
+ private
89
+
90
+ def sorted_findings(findings)
91
+ findings
92
+ .grep(Hash)
93
+ .sort_by { |finding| [SEVERITY_ORDER.fetch(severity(finding), 99), value(finding, 'id').to_s] }
94
+ end
95
+
96
+ def important_finding?(finding)
97
+ return false if category(finding) == 'task_mismatch'
98
+ return false if severity(finding) == 'minor'
99
+
100
+ IMPORTANT_CATEGORIES.include?(category(finding))
101
+ end
102
+
103
+ def summary_text(summary)
104
+ presence(summary) || label(:no_summary)
105
+ end
106
+
107
+ def render_findings(findings)
108
+ return label(:none_found) if findings.empty?
109
+
110
+ findings.map do |finding|
111
+ <<~ITEM.rstrip
112
+ - **#{label(:where)}**: #{location(finding)}
113
+ - **#{label(:problem)}**: #{presence(value(finding, 'problem')) || label(:not_specified)}
114
+ - **#{label(:why)}**: #{presence(value(finding, 'why')) || label(:not_specified)}
115
+ - **#{label(:suggestion)}**: #{presence(value(finding, 'suggestion')) || label(:not_specified)}
116
+ ITEM
117
+ end.join("\n\n")
118
+ end
119
+
120
+ def location(finding)
121
+ file = presence(value(finding, 'file'))
122
+ line = value(finding, 'line')
123
+ return label(:not_specified) unless file
124
+ return file if line.nil? || line.to_s.empty?
125
+
126
+ "#{file}:#{line}"
127
+ end
128
+
129
+ def label(key)
130
+ @labels.fetch(key)
131
+ end
132
+
133
+ def value(hash, key)
134
+ hash[key] || hash[key.to_sym]
135
+ end
136
+
137
+ def category(finding)
138
+ value(finding, 'category').to_s.downcase
139
+ end
140
+
141
+ def severity(finding)
142
+ value(finding, 'severity').to_s.downcase
143
+ end
144
+
145
+ def presence(value)
146
+ string = value.to_s.strip
147
+ string.empty? ? nil : string
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aireview
4
+ module ReviewSchemas
5
+ module_function
6
+
7
+ def generate
8
+ <<~SCHEMA.strip
9
+ {
10
+ "summary": "1-2 sentences about the essence of the MR changes",
11
+ "candidates": [
12
+ {
13
+ "id": "C1",
14
+ "file": "path/from/diff.rb",
15
+ "line": 42,
16
+ "quoted_code": "changed code fragment",
17
+ "problem": "finding text",
18
+ "why": "why it matters",
19
+ "suggestion": "what to fix or check",
20
+ "category": "bug",
21
+ "severity": "major"
22
+ }
23
+ ]
24
+ }
25
+ SCHEMA
26
+ end
27
+
28
+ def critique
29
+ <<~SCHEMA.strip
30
+ {
31
+ "verdicts": [
32
+ {
33
+ "id": "C1",
34
+ "decision": "keep",
35
+ "reason": "why the finding is confirmed",
36
+ "refinement": {
37
+ "problem": "refined finding text",
38
+ "why": "why this is really a problem",
39
+ "suggestion": "what to fix or check",
40
+ "category": "bug",
41
+ "severity": "major"
42
+ }
43
+ },
44
+ {
45
+ "id": "C2",
46
+ "decision": "reject",
47
+ "reason": "why the finding is rejected"
48
+ }
49
+ ]
50
+ }
51
+ SCHEMA
52
+ end
53
+ end
54
+ end