aireview 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 100adbc2d1510ad1f6c0e9b152decbfa46e71f8db30dfde36a8ba977528ae215
4
- data.tar.gz: 071d392ef71d935a6b6a055e7e86fbbf06e8f4517251d460d8bbff8087cb8ecf
3
+ metadata.gz: b27b5c59888ddb35675082d95d7e5e79dc1935295ecde9a1e13cdcaf38f24dff
4
+ data.tar.gz: 6016c5aae6d57475465c707cc2d89dedf07964d402cc1f5a68a6e01dc5f553d1
5
5
  SHA512:
6
- metadata.gz: 48d8222728f5bd0cdb27790c9971c316060c9efd89479e6be58ee6f41066e237e4e94cf2f864297b03a3012e1f225b92c4ece4b84ffeca3ad3ef66831a176e09
7
- data.tar.gz: e4cdac4c17edc362e318b548ac5d25198eaf9c916edd670424c7d21b0cdf4baab2f9f61527bb05547e737e30f192d243ee7dfcb80062a483b08bf410466bfcef
6
+ metadata.gz: bc47e9e8d2c7cece68a02a92594c896f52c5b101c8a639cee9f3c702e010906e5280dbda4282465630162c90b2532debcdca05462c8a1b390987288b150850dc
7
+ data.tar.gz: 644ad90865170c254b3b844e606f9c0121a2b82a50d7af62ffd70f4bf838bd0a258db367e3a815d899fb8bb628f183d268eee11722d2364bf700734ba463b7a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Context budget: `llm.max_prompt_chars` caps the request of each stage,
6
+ `context.max_*_chars` cap the MR description, the Jira description and
7
+ comments and the diff. The diff is cut by whole files and hunks, never in
8
+ the middle of a hunk, and both stages share the same context.
9
+ - Truncation is marked in the prompt and reported in the review: the result
10
+ line gets a `Partial review` suffix and a `Not reviewed` section lists the
11
+ files and sections that were left out. `--dry-run` and `--verbose` show the
12
+ sizes and the coverage.
13
+ - Renames, mode changes and empty new or deleted files are told apart from
14
+ diffs GitLab did not return (too large, binary, empty without a reason); the
15
+ latter are reported as not reviewed.
16
+ - Unparsable limits in the environment (`MAX_DIFF_CHARS=oops`) fail with a
17
+ `ConfigError` instead of silently falling back to the defaults.
18
+ - A run fails with a clear error when not even one hunk fits next to the
19
+ system prompt, or when the candidates push the Critique request over its
20
+ limit.
21
+
3
22
  ## 0.1.1
4
23
 
5
24
  - The prompts no longer ask the model to check whether dependency and image
data/CONTRIBUTORS.md ADDED
@@ -0,0 +1,8 @@
1
+ # Contributors
2
+
3
+ - [Denis Levenko](https://github.com/DenisDenis9331), author and maintainer.
4
+ - [Sergey Kondrashov](https://github.com/SergoHUH): Ollama support, Jira
5
+ self-hosted configuration, provider cleanup, prompt refactoring.
6
+
7
+ Contributions are welcome: open an issue or a pull request at
8
+ https://github.com/DenisDenis9331/aireview.
data/README.md CHANGED
@@ -204,11 +204,18 @@ review_instructions: |
204
204
 
205
205
  ollama_api_base: http://localhost:11434/v1
206
206
 
207
+ context:
208
+ max_diff_chars: 120000
209
+ max_mr_description_chars: 8000
210
+ max_jira_description_chars: 8000
211
+ max_jira_comment_chars: 2000
212
+
207
213
  llm:
208
214
  provider: gemini
209
215
  temperature: 0
210
216
  timeout: 60
211
217
  http_proxy: http://127.0.0.1:8888
218
+ max_prompt_chars: 400000
212
219
  generate:
213
220
  provider: gemini
214
221
  model: gemini-3.7-flash
@@ -217,8 +224,46 @@ llm:
217
224
  provider: ollama
218
225
  model: qwen2.5-coder:7b
219
226
  temperature: 0
227
+ max_prompt_chars: 60000
220
228
  ```
221
229
 
230
+ ### Context budget
231
+
232
+ The request to each stage is capped by `llm.max_prompt_chars` (or
233
+ `LLM_MAX_PROMPT_CHARS`; per stage `llm.generate.max_prompt_chars` /
234
+ `LLM_GENERATE_MAX_PROMPT_CHARS` and the same for `critique`). The limits are in
235
+ characters, not tokens: there is no exact tokenizer for the providers locally,
236
+ and the Ollama window is set on the server where the client cannot see it. As
237
+ a rule of thumb one token is three to four characters, so for a local model
238
+ with `OLLAMA_CONTEXT_LENGTH=8192` set the stage limit to about 20 000
239
+ characters to leave room for the answer.
240
+
241
+ The MR and Jira context is assembled once per run and shared by both stages,
242
+ so it is sized for the tighter of the two: the Critique stage also has to fit
243
+ its system prompt and a reserve for the candidates. The sections are cut to
244
+ their own limits first, keeping the beginning: `context.max_diff_chars`,
245
+ `context.max_mr_description_chars`, `context.max_jira_description_chars` and
246
+ `context.max_jira_comment_chars` (`MAX_DIFF_CHARS`, `MAX_MR_DESCRIPTION_CHARS`,
247
+ `MAX_JIRA_DESCRIPTION_CHARS`, `MAX_JIRA_COMMENT_CHARS`). Only then is the diff
248
+ cut, and only by whole files and whole hunks: files in the order GitLab returns
249
+ them, a file that does not fit is shown hunk by hunk, everything after it is
250
+ left out, and a single hunk larger than the whole budget is skipped rather
251
+ than cut in the middle. Renames, mode changes and other files without text
252
+ changes are always listed; files whose diff GitLab did not return (too large,
253
+ binary) are listed as well and reported as not reviewed.
254
+
255
+ Everything that was cut is marked in the prompt, so the model knows that a
256
+ missing requirement or missing code may simply be outside the budget. The
257
+ review reports it too: the result line gets a `Partial review: ...` suffix
258
+ and a `Not reviewed` section lists the files and sections concerned. The
259
+ result itself (`ok` / `needs attention`) is still only about the findings.
260
+
261
+ When even one hunk cannot fit next to the system prompt, or the candidates
262
+ returned by Generate push the Critique request over its limit, the run stops
263
+ with an error instead of silently reviewing less. Raise the limits or extend
264
+ `ignore_paths`. `--dry-run` prints the sizes of every part and the coverage;
265
+ `--verbose` logs them during a real run.
266
+
222
267
  ## Usage
223
268
 
224
269
  ```bash
@@ -237,7 +282,7 @@ bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project
237
282
  - `--critique-temperature VALUE` overrides the temperature for the Critique pass only.
238
283
  - `--config PATH` points at a specific `.aireview.yml`.
239
284
  - `--no-jira` turns off the Jira enrichment even when the MR carries an issue key.
240
- - `--dry-run` prints the LLM settings and the Generate prompt, plus the Critique prompt unless `--no-critique` is given.
285
+ - `--dry-run` prints the LLM settings, the context sizes and coverage, and the Generate prompt, plus the Critique prompt unless `--no-critique` is given.
241
286
  - `--no-critique` skips the second pass and renders the Generate candidates directly.
242
287
  - `--review-mode MODE` sets the behaviour when a review has already been published: `update` or `once`.
243
288
  - `--force` reviews again even when a review for this state of the MR is already published.
@@ -362,6 +407,7 @@ bundle _2.3.26_ exec rspec spec/secret_scrubber_spec.rb
362
407
 
363
408
  - The reviewer does not check whether the specified versions of dependencies and images exist: the model's knowledge of releases is outdated, and that is what CI is for. Syntax errors and contradictions with the MR/Jira requirements are checked as usual.
364
409
  - The CLI looks for `.aireview.yml` and `.env` walking up from the current working directory, so the project config can be kept in the repository root even when the tool is run from `aireview/`.
410
+ - How Ollama behaves when a request is still larger than its context window is up to the server, not to `aireview`: check the `ollama serve` log for truncation messages on your setup and size `max_prompt_chars` so it does not happen.
365
411
 
366
412
  ## Releasing
367
413
 
@@ -379,12 +425,18 @@ API key is stored anywhere. To cut a release:
379
425
  ```
380
426
 
381
427
  The workflow refuses to run when the tag does not match `Aireview::VERSION`,
382
- runs the test suite, builds the gem and pushes it.
428
+ runs the test suite, builds the gem, pushes it and then creates a GitHub
429
+ release for the tag with the matching `CHANGELOG.md` section as its notes and
430
+ the built `.gem` attached.
383
431
 
384
432
  ## Changelog
385
433
 
386
434
  See [CHANGELOG.md](CHANGELOG.md).
387
435
 
436
+ ## Contributors
437
+
438
+ See [CONTRIBUTORS.md](CONTRIBUTORS.md).
439
+
388
440
  ## License
389
441
 
390
442
  [MIT](LICENSE)
data/lib/aireview/cli.rb CHANGED
@@ -82,7 +82,7 @@ module Aireview
82
82
  parser_result: parser_result,
83
83
  gitlab_client: gitlab_client,
84
84
  merge_request: merge_request,
85
- changes_text: render_changes(changes, config),
85
+ changes: prepare_changes(changes, config),
86
86
  jira_issue: maybe_load_jira_issue(config, merge_request, options)
87
87
  }
88
88
  end
@@ -102,18 +102,18 @@ module Aireview
102
102
  [merge_request, changes]
103
103
  end
104
104
 
105
- def render_changes(changes, config)
105
+ # Дифф уходит дальше по файлам, а не одной строкой: бюджет контекста
106
+ # режет его по границам файлов и хунков.
107
+ def prepare_changes(changes, config)
106
108
  diff_fetcher = DiffFetcher.new(ignore_paths: config.ignore_paths, logger: @logger)
107
109
  filtered_changes = diff_fetcher.filter(changes)
108
110
  raise Error, 'No changes left after filtering ignore_paths' if filtered_changes.empty?
109
111
 
110
- scrubbed_changes = SecretScrubber.new(
112
+ SecretScrubber.new(
111
113
  secret_patterns: config.secret_patterns,
112
114
  secret_files: config.secret_files,
113
115
  logger: @logger
114
116
  ).scrub_changes(filtered_changes)
115
-
116
- diff_fetcher.render(scrubbed_changes)
117
117
  end
118
118
 
119
119
  def execute_review(config, context, options)
@@ -122,7 +122,7 @@ module Aireview
122
122
  if options[:dry_run]
123
123
  dry_run = pipeline.dry_run_prompts(
124
124
  merge_request: context[:merge_request],
125
- changes_text: context[:changes_text],
125
+ changes: context[:changes],
126
126
  jira_issue: context[:jira_issue],
127
127
  critique: !options[:no_critique]
128
128
  )
@@ -135,7 +135,7 @@ module Aireview
135
135
 
136
136
  review = pipeline.run(
137
137
  merge_request: context[:merge_request],
138
- changes_text: context[:changes_text],
138
+ changes: context[:changes],
139
139
  jira_issue: context[:jira_issue],
140
140
  critique: !options[:no_critique]
141
141
  )
@@ -157,7 +157,7 @@ module Aireview
157
157
  publisher = Publisher.new(gitlab_client: context[:gitlab_client], logger: @logger)
158
158
  prompts = pipeline.dry_run_prompts(
159
159
  merge_request: context[:merge_request],
160
- changes_text: context[:changes_text],
160
+ changes: context[:changes],
161
161
  jira_issue: context[:jira_issue],
162
162
  critique: !options[:no_critique]
163
163
  )
@@ -321,27 +321,7 @@ module Aireview
321
321
  end
322
322
 
323
323
  def render_dry_run(dry_run)
324
- @out.puts('=== LLM SETTINGS ===')
325
- @out.puts("Generate: #{dry_run[:generate_model]} temperature=#{dry_run[:generate_temperature]}")
326
- if dry_run[:critique_prompt]
327
- @out.puts("Critique: #{dry_run[:critique_model]} temperature=#{dry_run[:critique_temperature]}")
328
- else
329
- @out.puts('Critique: disabled')
330
- end
331
- @out.puts
332
- @out.puts('=== GENERATE SYSTEM PROMPT ===')
333
- @out.puts(dry_run.dig(:generate_prompt, :system_prompt))
334
- @out.puts
335
- @out.puts('=== GENERATE USER PROMPT ===')
336
- @out.puts(dry_run.dig(:generate_prompt, :user_prompt))
337
- return unless dry_run[:critique_prompt]
338
-
339
- @out.puts
340
- @out.puts('=== CRITIQUE SYSTEM PROMPT ===')
341
- @out.puts(dry_run.dig(:critique_prompt, :system_prompt))
342
- @out.puts
343
- @out.puts('=== CRITIQUE USER PROMPT ===')
344
- @out.puts(dry_run.dig(:critique_prompt, :user_prompt))
324
+ DryRunReport.new(@out).render(dry_run)
345
325
  end
346
326
 
347
327
  def help
@@ -4,9 +4,13 @@ require 'pathname'
4
4
  require 'yaml'
5
5
  require_relative 'errors'
6
6
  require_relative 'utils'
7
+ require_relative 'config_limits'
7
8
 
8
9
  module Aireview
9
10
  class Config
11
+ include ConfigLimits
12
+ extend ConfigLimits::ClassMethods
13
+
10
14
  DEFAULT_SECRET_FILES = [
11
15
  '.env',
12
16
  '.env.*',
@@ -21,7 +25,6 @@ module Aireview
21
25
  ].freeze
22
26
 
23
27
  REVIEW_MODES = %w[update once].freeze
24
-
25
28
  DEFAULTS = {
26
29
  'review_language' => 'en',
27
30
  'review_mode' => 'update',
@@ -35,8 +38,10 @@ module Aireview
35
38
  'llm' => {
36
39
  'provider' => 'gemini',
37
40
  'temperature' => 0,
38
- 'timeout' => 60
39
- }
41
+ 'timeout' => 60,
42
+ 'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS
43
+ },
44
+ 'context' => ConfigLimits::CONTEXT_DEFAULTS
40
45
  }.freeze
41
46
 
42
47
  ENV_MAPPING = {
@@ -97,6 +102,7 @@ module Aireview
97
102
  def self.env_config(env)
98
103
  mapped_env_config(env)
99
104
  .merge('llm' => llm_env_config(env))
105
+ .merge(context_env_config(env))
100
106
  .merge(provider_key_env_config(env))
101
107
  .merge(generic_api_key_env_config(env))
102
108
  end
@@ -113,6 +119,7 @@ module Aireview
113
119
  'provider' => env['LLM_PROVIDER'],
114
120
  'temperature' => parse_float(env['LLM_TEMPERATURE']),
115
121
  'timeout' => parse_float(env['LLM_TIMEOUT']),
122
+ 'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
116
123
  'generate' => llm_stage_env_config(env, 'GENERATE'),
117
124
  'critique' => llm_stage_env_config(env, 'CRITIQUE')
118
125
  }.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
@@ -122,7 +129,8 @@ module Aireview
122
129
  {
123
130
  'provider' => env["LLM_#{stage}_PROVIDER"],
124
131
  'model' => env["LLM_#{stage}_MODEL"],
125
- 'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"])
132
+ 'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
133
+ 'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS")
126
134
  }.compact
127
135
  end
128
136
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aireview
4
+ # Лимиты контекста в символах: точного токенизатора для провайдеров локально
5
+ # нет, а окно Ollama задаётся на сервере и клиенту не видно. Дефолты щедрые,
6
+ # под конкретную модель их задают в .aireview.yml.
7
+ module ConfigLimits
8
+ LLM_STAGES = %w[generate critique].freeze
9
+ DEFAULT_MAX_PROMPT_CHARS = 400_000
10
+ CONTEXT_DEFAULTS = {
11
+ 'max_diff_chars' => 120_000,
12
+ 'max_mr_description_chars' => 8_000,
13
+ 'max_jira_description_chars' => 8_000,
14
+ 'max_jira_comment_chars' => 2_000
15
+ }.freeze
16
+ CONTEXT_ENV = {
17
+ 'max_diff_chars' => 'MAX_DIFF_CHARS',
18
+ 'max_mr_description_chars' => 'MAX_MR_DESCRIPTION_CHARS',
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.
45
+ def max_prompt_chars(stage)
46
+ stage = stage.to_s
47
+ raise ArgumentError, "unknown LLM stage #{stage.inspect}" unless LLM_STAGES.include?(stage)
48
+
49
+ positive_integer!(
50
+ dig('llm', stage, 'max_prompt_chars') || dig('llm', 'max_prompt_chars') || DEFAULT_MAX_PROMPT_CHARS,
51
+ "llm.#{stage}.max_prompt_chars"
52
+ )
53
+ end
54
+
55
+ def max_diff_chars
56
+ context_limit('max_diff_chars')
57
+ end
58
+
59
+ def max_mr_description_chars
60
+ context_limit('max_mr_description_chars')
61
+ end
62
+
63
+ def max_jira_description_chars
64
+ context_limit('max_jira_description_chars')
65
+ end
66
+
67
+ def max_jira_comment_chars
68
+ context_limit('max_jira_comment_chars')
69
+ end
70
+
71
+ private
72
+
73
+ def context_limit(key)
74
+ positive_integer!(dig('context', key) || CONTEXT_DEFAULTS.fetch(key), "context.#{key}")
75
+ end
76
+
77
+ def positive_integer!(value, name)
78
+ integer = Integer(value, exception: false) if value.is_a?(Integer) || value.is_a?(String)
79
+ integer = value.to_i if value.is_a?(Float) && value == value.floor
80
+ return integer if integer.is_a?(Integer) && integer.positive?
81
+
82
+ raise ConfigError, "#{name} must be a positive integer, got #{value.inspect}"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'errors'
3
+
4
+ module Aireview
5
+ # Укладывает контекст ревью в бюджет символов и запоминает, что при этом не
6
+ # вошло. Секции MR и Jira режутся до своих лимитов с сохранением начала,
7
+ # дифф по целым файлам, затем по целым хункам; внутри хунка не режем.
8
+ module ContextBudget
9
+ # Пути, которые не вошли, перечисляются в конце диффа; список ограничен,
10
+ # чтобы сам не съел бюджет.
11
+ NOT_SHOWN_LIST_LIMIT = 20
12
+ TRAILER_RESERVE_CHARS = 400
13
+
14
+ Coverage = Struct.new(
15
+ :truncated_sections, :files_not_shown, :files_partial, :files_unavailable, :hunks_skipped,
16
+ keyword_init: true
17
+ ) do
18
+ def self.empty
19
+ new(truncated_sections: [], files_not_shown: [], files_partial: [], files_unavailable: [], hunks_skipped: [])
20
+ end
21
+
22
+ def complete?
23
+ to_h.values.all?(&:empty?)
24
+ end
25
+ end
26
+
27
+ Packed = Struct.new(:text, :shown_hunks, :total_hunks, keyword_init: true)
28
+
29
+ # Начало важнее конца: требования и критерии приёмки обычно там.
30
+ def self.truncate_section(text, limit:, label:, coverage:)
31
+ text = text.to_s
32
+ return text if text.length <= limit
33
+
34
+ coverage.truncated_sections << label
35
+ "#{text[0, limit]}\n[#{label} truncated: #{limit} of #{text.length} chars shown]"
36
+ end
37
+
38
+ def self.pack_entries(entries, budget:, coverage:)
39
+ Packer.new(entries, budget: budget, coverage: coverage).pack
40
+ end
41
+
42
+ # Файлы без хунков идут первыми: они дёшевы и всегда полезны для картины
43
+ # MR. Текстовые файлы идут в порядке GitLab, пока влезают; первый файл,
44
+ # который не влезает, показывается частично, всё после него не показывается.
45
+ # Хунк, который не влез бы даже в пустой бюджет, пропускается с пометкой,
46
+ # а не останавливает раскладку.
47
+ class Packer
48
+ def initialize(entries, budget:, coverage:)
49
+ @non_text, @text = entries.partition { |entry| !entry.text? }
50
+ @budget = budget
51
+ @coverage = coverage
52
+ @total_hunks = @text.sum { |entry| entry.hunks.size }
53
+ end
54
+
55
+ def pack
56
+ @non_text.each { |entry| @coverage.files_unavailable << entry.path if entry.unavailable? }
57
+ full = (@non_text + @text).map(&:render).join("\n")
58
+ return Packed.new(text: full, shown_hunks: @total_hunks, total_hunks: @total_hunks) if full.length <= @budget
59
+
60
+ pack_within_limit
61
+ end
62
+
63
+ private
64
+
65
+ # Что-то придётся опустить, значит нужен хвост со списком пропущенного.
66
+ # @used считает весь собранный текст, включая разделители между
67
+ # файлами: результат не должен выйти за бюджет ни на символ.
68
+ def pack_within_limit
69
+ @parts = @non_text.map(&:render)
70
+ @used = joined_length(@parts)
71
+ @limit = @budget - TRAILER_RESERVE_CHARS
72
+ raise_no_room!(:non_text) if @used > @limit
73
+
74
+ shown_hunks = pack_text_entries
75
+ raise_no_room!(:hunks) if shown_hunks.zero? && @total_hunks.positive?
76
+
77
+ @parts << not_shown_trailer unless @coverage.files_not_shown.empty?
78
+ Packed.new(text: @parts.join("\n"), shown_hunks: shown_hunks, total_hunks: @total_hunks)
79
+ end
80
+
81
+ def pack_text_entries
82
+ shown_hunks = 0
83
+ stopped = false
84
+ @text.each do |entry|
85
+ piece, shown, stopped = stopped ? ['', 0, true] : pack_entry(entry)
86
+ if shown.zero?
87
+ @coverage.files_not_shown << entry.path
88
+ next
89
+ end
90
+
91
+ @parts << piece
92
+ @used = joined_length(@parts)
93
+ shown_hunks += shown
94
+ end
95
+ shown_hunks
96
+ end
97
+
98
+ # Место под следующий кусок с учётом разделителя перед ним.
99
+ def remaining
100
+ @limit - @used - (@parts.empty? ? 0 : 1)
101
+ end
102
+
103
+ # Возвращает [текст, число показанных хунков, остановлена ли раскладка].
104
+ def pack_entry(entry)
105
+ full = entry.render
106
+ return [full, entry.hunks.size, false] if full.length <= remaining
107
+
108
+ body, shown, skipped, stopped = pack_hunks(entry)
109
+ return ['', 0, stopped] if shown.zero?
110
+
111
+ skipped.each { |hunk| @coverage.hunks_skipped << {path: entry.path, hunk: hunk} }
112
+ @coverage.files_partial << {path: entry.path, shown: shown, total: entry.hunks.size}
113
+ [entry.header + body + partial_marker(entry, shown), shown, stopped]
114
+ end
115
+
116
+ # Место сначала отдаётся хункам, которые можно показать, и только на
117
+ # остаток добавляются пометки о слишком больших: иначе пометки могли бы
118
+ # вытеснить единственный подходящий хунк. Факт пропуска в покрытие
119
+ # попадает независимо от того, есть ли для пометки место.
120
+ def pack_hunks(entry)
121
+ base = entry.header.length + partial_marker(entry, 0).length
122
+ shown = []
123
+ skipped = []
124
+ stopped = false
125
+ used = 0
126
+ entry.hunks.each_with_index do |hunk, index|
127
+ if base + hunk.length > @limit
128
+ skipped << index
129
+ next
130
+ end
131
+ if base + used + hunk.length > remaining
132
+ stopped = true
133
+ break
134
+ end
135
+
136
+ shown << index
137
+ used += hunk.length
138
+ end
139
+
140
+ body = render_hunks(entry, shown: shown, skipped: skipped, room: remaining - base - used)
141
+ [body, shown.size, skipped.map { |index| index + 1 }, stopped]
142
+ end
143
+
144
+ def render_hunks(entry, shown:, skipped:, room:)
145
+ marked = skipped.select do |index|
146
+ marker = skip_marker(entry, index)
147
+ next false if marker.length > room
148
+
149
+ room -= marker.length
150
+ true
151
+ end
152
+ entry.hunks.each_with_index.filter_map do |hunk, index|
153
+ next hunk if shown.include?(index)
154
+
155
+ skip_marker(entry, index) if marked.include?(index)
156
+ end.join
157
+ end
158
+
159
+ def skip_marker(entry, index)
160
+ "[hunk #{index + 1} of #{entry.hunks.size} skipped: larger than the context budget]\n"
161
+ end
162
+
163
+ def joined_length(parts)
164
+ parts.sum(&:length) + [parts.size - 1, 0].max
165
+ end
166
+
167
+ def partial_marker(entry, shown)
168
+ "[file #{entry.path}: #{shown} of #{entry.hunks.size} hunks shown]\n"
169
+ end
170
+
171
+ def not_shown_trailer
172
+ paths = @coverage.files_not_shown
173
+ listed = []
174
+ paths.first(NOT_SHOWN_LIST_LIMIT).each do |path|
175
+ break if listed.sum(&:length) + path.length > TRAILER_RESERVE_CHARS / 2
176
+
177
+ listed << path
178
+ end
179
+ rest = paths.size - listed.size
180
+ list = listed.join(', ')
181
+ list += " and #{rest} more" if rest.positive?
182
+ "[#{paths.size} file(s) not shown: #{list}]\n"
183
+ end
184
+
185
+ def raise_no_room!(reason)
186
+ detail = if reason == :non_text
187
+ 'the entries for files without text changes alone exceed it'
188
+ else
189
+ "not a single hunk fits after #{@used} chars of entries without text changes"
190
+ end
191
+ raise ContextBudgetError,
192
+ "Diff does not fit into the context budget of #{@budget} chars: #{detail}. " \
193
+ 'Raise llm.max_prompt_chars / context.max_diff_chars, or add paths to ignore_paths.'
194
+ end
195
+ end
196
+ end
197
+ end
@@ -1,16 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative 'utils'
3
+ require_relative 'errors'
3
4
  require_relative 'secret_scrubber'
5
+ require_relative 'diff_fetcher'
6
+ require_relative 'context_budget'
4
7
 
5
8
  module Aireview
6
9
  class ContextBuilder
7
- MAX_DIFF_CHARS = 120_000
8
10
  GENERATE_PROMPT_TEMPLATE = File.read(File.expand_path('prompts/generate.txt', __dir__)).strip.freeze
9
11
  CRITIQUE_PROMPT_TEMPLATE = File.read(File.expand_path('prompts/critique.txt', __dir__)).strip.freeze
10
12
  LANGUAGE_NAMES = {
11
13
  'ru' => 'Russian',
12
14
  'en' => 'English'
13
15
  }.freeze
16
+ CHANGES_HEADER = "Changes:\n"
17
+ CANDIDATES_HEADER = "\n\nCandidates JSON from Generate:\n"
18
+ # Резерв под кандидатов в промпте критика: три кандидата по ~1 500
19
+ # символов. Оценка, не гарантия; фактический размер проверяется перед
20
+ # отправкой.
21
+ CANDIDATES_RESERVE_CHARS = 4_500
22
+ STAGES = %i[generate critique].freeze
23
+
24
+ # Контекст одного прогона: обе стадии получают одинаковые MR, Jira и дифф,
25
+ # усечённые один раз под самую тесную из стадий.
26
+ Context = Struct.new(:user_prompt, :coverage, :sizes, keyword_init: true)
14
27
 
15
28
  def initialize(config:, logger: Logger.new($stderr))
16
29
  @config = config
@@ -20,32 +33,36 @@ module Aireview
20
33
  secret_files: config.secret_files,
21
34
  logger: logger
22
35
  )
36
+ @diff_fetcher = DiffFetcher.new(ignore_paths: [], logger: logger)
23
37
  end
24
38
 
25
- def build(merge_request:, changes_text:, jira_issue: nil)
26
- build_generate_prompt(merge_request: merge_request, changes_text: changes_text, jira_issue: jira_issue)
27
- end
39
+ def prepare(merge_request:, changes:, jira_issue: nil, critique: true)
40
+ coverage = ContextBudget::Coverage.empty
41
+ budget = context_budget(critique: critique)
42
+ sections = merge_request_sections(merge_request, coverage: coverage)
43
+ sections << jira_section(jira_issue, coverage: coverage) if jira_issue
44
+ fixed = "#{sections.join("\n\n")}\n\n#{CHANGES_HEADER}"
28
45
 
29
- def build_generate_prompt(merge_request:, changes_text:, jira_issue: nil)
30
- {
31
- system_prompt: system_prompt(GENERATE_PROMPT_TEMPLATE),
32
- user_prompt: user_prompt(merge_request: merge_request, changes_text: changes_text, jira_issue: jira_issue)
33
- }
34
- end
46
+ diff_budget = [budget - fixed.length, @config.max_diff_chars].min
47
+ entries = @diff_fetcher.entries(@secret_scrubber.scrub_changes(changes))
48
+ packed = ContextBudget.pack_entries(entries, budget: diff_budget, coverage: coverage)
35
49
 
36
- def build_critique_prompt(merge_request:, changes_text:, candidates_json:, jira_issue: nil)
37
- user = "#{user_prompt(merge_request: merge_request, changes_text: changes_text, jira_issue: jira_issue)}\n\n"
38
- user << "Candidates JSON from Generate:\n#{scrub_text(candidates_json)}"
50
+ sizes = context_sizes(fixed: fixed, packed: packed, budget: budget, diff_budget: diff_budget, critique: critique)
51
+ log_sizes(sizes)
52
+ Context.new(user_prompt: fixed + packed.text, coverage: coverage, sizes: sizes)
53
+ end
39
54
 
40
- {
41
- system_prompt: system_prompt(CRITIQUE_PROMPT_TEMPLATE),
42
- user_prompt: user
43
- }
55
+ def build_generate_prompt(context)
56
+ check_stage_size!(:generate, system_prompt(:generate), context.user_prompt)
44
57
  end
45
58
 
46
- private
59
+ def build_critique_prompt(context, candidates_json:)
60
+ user = "#{context.user_prompt}#{CANDIDATES_HEADER}#{scrub_text(candidates_json)}"
61
+ check_stage_size!(:critique, system_prompt(:critique), user)
62
+ end
47
63
 
48
- def system_prompt(template)
64
+ def system_prompt(stage)
65
+ template = stage.to_sym == :critique ? CRITIQUE_PROMPT_TEMPLATE : GENERATE_PROMPT_TEMPLATE
49
66
  extras = []
50
67
  if Aireview::Utils.present?(@config.review_instructions)
51
68
  extras << "Additional project instructions:\n#{scrub_text(@config.review_instructions.strip)}"
@@ -55,44 +72,113 @@ module Aireview
55
72
  [template, *extras].join("\n\n")
56
73
  end
57
74
 
58
- def user_prompt(merge_request:, changes_text:, jira_issue:)
59
- truncated_changes = truncate_changes(scrub_text(changes_text))
75
+ # Проверка перед отправкой: если кандидаты вышли за резерв и запрос не
76
+ # помещается, это ошибка, а не повод молча резать контекст, который
77
+ # генератор уже видел.
78
+ def check_stage_size!(stage, system, user)
79
+ limit = @config.max_prompt_chars(stage)
80
+ total = system.length + user.length
81
+ if total > limit
82
+ raise ContextBudgetError,
83
+ "#{stage.capitalize} request is #{total} chars, over llm.#{stage}.max_prompt_chars=#{limit} " \
84
+ "(system prompt #{system.length}, context #{user.length})"
85
+ end
86
+
87
+ {system_prompt: system, user_prompt: user}
88
+ end
89
+
90
+ private
91
+
92
+ # Минимум по стадиям: контекст один на прогон, поэтому он должен
93
+ # помещаться в каждую из них вместе с её системным промптом и резервом.
94
+ def context_budget(critique:)
95
+ stages = critique ? STAGES : [:generate]
96
+ budgets = stages.to_h { |stage| [stage, stage_budget(stage)] }
97
+ stage, budget = budgets.min_by { |_, value| value }
98
+ return budget if budget.positive?
99
+
100
+ raise ContextBudgetError,
101
+ "System prompt of the #{stage} stage (#{system_prompt(stage).length} chars, including " \
102
+ "review_instructions) leaves no room for the merge request within llm.#{stage}.max_prompt_chars=" \
103
+ "#{@config.max_prompt_chars(stage)}"
104
+ end
60
105
 
61
- sections = merge_request_sections(merge_request)
62
- sections << jira_section(jira_issue) if jira_issue
63
- sections << "Changes:\n#{truncated_changes}"
64
- sections.join("\n\n")
106
+ def stage_budget(stage)
107
+ reserve = stage == :critique ? CANDIDATES_RESERVE_CHARS + CANDIDATES_HEADER.length : 0
108
+ @config.max_prompt_chars(stage) - system_prompt(stage).length - reserve
65
109
  end
66
110
 
67
- def merge_request_sections(merge_request)
111
+ def merge_request_sections(merge_request, coverage:)
68
112
  source_branch = scrub_text(merge_request['source_branch'])
69
113
  target_branch = scrub_text(merge_request['target_branch'])
114
+ description = ContextBudget.truncate_section(
115
+ scrub_optional_text(merge_request['description']),
116
+ limit: @config.max_mr_description_chars,
117
+ label: 'MR description',
118
+ coverage: coverage
119
+ )
70
120
 
71
121
  [
72
122
  "MR: #{scrub_text(merge_request['title'])}",
73
123
  "Author: #{scrub_text(merge_request.dig('author', 'name'))}",
74
124
  "Branch: #{source_branch} -> #{target_branch}",
75
- "Description:\n#{scrub_optional_text(merge_request['description'])}"
125
+ "Description:\n#{description}"
76
126
  ]
77
127
  end
78
128
 
79
- def jira_section(jira_issue)
129
+ def jira_section(jira_issue, coverage:)
130
+ description = ContextBudget.truncate_section(
131
+ scrub_optional_text(jira_issue['description']),
132
+ limit: @config.max_jira_description_chars,
133
+ label: 'Jira description',
134
+ coverage: coverage
135
+ )
80
136
  section = "Jira task (#{scrub_text(jira_issue['key'])}):\n"
81
137
  section << "Summary: #{scrub_text(jira_issue['summary'])}\n"
82
- section << "Description:\n#{scrub_optional_text(jira_issue['description'])}"
83
-
84
- comments = jira_issue['comments']
85
- return section unless comments && !comments.empty?
86
-
87
- section << "\nRecent comments:\n#{comments.map { |comment| scrub_text(comment) }.join("\n")}"
138
+ section << "Description:\n#{description}"
139
+
140
+ comments = Array(jira_issue['comments'])
141
+ return section if comments.empty?
142
+
143
+ rendered = comments.each_with_index.map do |comment, index|
144
+ ContextBudget.truncate_section(
145
+ scrub_text(comment),
146
+ limit: @config.max_jira_comment_chars,
147
+ label: "Jira comment #{index + 1}",
148
+ coverage: coverage
149
+ )
150
+ end
151
+ section << "\nRecent comments:\n#{rendered.join("\n")}"
88
152
  end
89
153
 
90
- def truncate_changes(changes_text)
91
- text = changes_text.to_s
92
- return text if text.length <= MAX_DIFF_CHARS
154
+ def context_sizes(fixed:, packed:, budget:, diff_budget:, critique:)
155
+ stages = critique ? STAGES : [:generate]
156
+ {
157
+ context_budget: budget,
158
+ diff_budget: diff_budget,
159
+ sections: fixed.length,
160
+ diff: packed.text.length,
161
+ hunks_shown: packed.shown_hunks,
162
+ hunks_total: packed.total_hunks,
163
+ stages: stages.to_h do |stage|
164
+ system = system_prompt(stage).length
165
+ [stage, {system_prompt: system, request: system + fixed.length + packed.text.length,
166
+ max_prompt_chars: @config.max_prompt_chars(stage)}]
167
+ end
168
+ }
169
+ end
93
170
 
94
- omitted = text.length - MAX_DIFF_CHARS
95
- "#{text[0, MAX_DIFF_CHARS]}\n\n[TRUNCATED #{omitted} chars]"
171
+ def log_sizes(sizes)
172
+ @logger.debug(
173
+ "Context: sections #{sizes[:sections]} chars, diff #{sizes[:diff]} chars " \
174
+ "(budget #{sizes[:diff_budget]}, hunks #{sizes[:hunks_shown]}/#{sizes[:hunks_total]})"
175
+ )
176
+ sizes[:stages].each do |stage, stage_sizes|
177
+ @logger.debug(
178
+ "Context #{stage}: request #{stage_sizes[:request]} chars (~#{stage_sizes[:request] / 4} tokens) " \
179
+ "of max #{stage_sizes[:max_prompt_chars]}, system prompt #{stage_sizes[:system_prompt]}"
180
+ )
181
+ end
96
182
  end
97
183
 
98
184
  def scrub_optional_text(text)
@@ -3,7 +3,80 @@ require_relative 'utils'
3
3
 
4
4
  module Aireview
5
5
  class DiffFetcher
6
- DIFF_UNAVAILABLE = '[DIFF_NOT_AVAILABLE]'
6
+ NO_TEXT_CHANGES = '[no text changes]'
7
+ DIFF_UNAVAILABLE = '[diff not available]'
8
+ BINARY_DIFF = /\ABinary files .* differ/
9
+
10
+ # Один файл из ответа GitLab: заголовок, хунки и что с ним можно делать.
11
+ # kind:
12
+ # :text есть хунки, код можно проверить;
13
+ # :no_text_changes переименование, смена режима, пустой файл: проверять
14
+ # нечего;
15
+ # :unavailable GitLab не отдал дифф (too_large, бинарник, пустой
16
+ # дифф без причины): код есть, но проверить его не удалось.
17
+ class Entry
18
+ attr_reader :path, :kind, :header, :hunks
19
+
20
+ def initialize(change)
21
+ old_path = change['old_path'] || change['new_path']
22
+ new_path = change['new_path'] || change['old_path']
23
+ @path = new_path
24
+ @header = "diff --git a/#{old_path} b/#{new_path}\n--- a/#{old_path}\n+++ b/#{new_path}\n"
25
+ diff = change['diff'].to_s
26
+ @kind = classify(change, diff)
27
+ @hunks = @kind == :text ? split_hunks(diff) : []
28
+ end
29
+
30
+ def text?
31
+ kind == :text
32
+ end
33
+
34
+ def unavailable?
35
+ kind == :unavailable
36
+ end
37
+
38
+ def body
39
+ case kind
40
+ when :text then hunks.join
41
+ when :no_text_changes then "#{NO_TEXT_CHANGES}\n"
42
+ else "#{DIFF_UNAVAILABLE}\n"
43
+ end
44
+ end
45
+
46
+ def render
47
+ header + body
48
+ end
49
+
50
+ private
51
+
52
+ # Пустой дифф без объяснимой причины (переименование, смена режима,
53
+ # пустой новый или удалённый файл) считаем недоступным: код есть, но
54
+ # GitLab его не отдал.
55
+ def classify(change, diff)
56
+ return :unavailable if change['too_large']
57
+ return :unavailable if diff.match?(BINARY_DIFF)
58
+ return :text unless diff.strip.empty?
59
+
60
+ empty_diff_explained?(change) ? :no_text_changes : :unavailable
61
+ end
62
+
63
+ def empty_diff_explained?(change)
64
+ return true if %w[renamed_file new_file deleted_file].any? { |flag| change[flag] }
65
+
66
+ modes = change.values_at('a_mode', 'b_mode')
67
+ modes.none?(&:nil?) && modes.uniq.size == 2
68
+ end
69
+
70
+ # Хунки режем по заголовкам @@; текст до первого @@ (или дифф без них,
71
+ # например заглушка про секретный файл) считается одним хунком.
72
+ def split_hunks(diff)
73
+ diff = "#{diff}\n" unless diff.end_with?("\n")
74
+ pieces = diff.split(/^(?=@@ )/)
75
+ return pieces if pieces.size <= 1 || pieces.first.start_with?('@@ ')
76
+
77
+ [pieces[0] + pieces[1], *pieces[2..]]
78
+ end
79
+ end
7
80
 
8
81
  def initialize(ignore_paths:, logger: Logger.new($stderr))
9
82
  @ignore_paths = Array(ignore_paths).compact
@@ -16,8 +89,12 @@ module Aireview
16
89
  end
17
90
  end
18
91
 
92
+ def entries(changes)
93
+ Array(changes).map { |change| Entry.new(change) }
94
+ end
95
+
19
96
  def render(changes)
20
- Array(changes).map { |change| render_change(change) }.join("\n")
97
+ entries(changes).map(&:render).join("\n")
21
98
  end
22
99
 
23
100
  private
@@ -29,19 +106,5 @@ module Aireview
29
106
  File.fnmatch?(pattern, path, File::FNM_DOTMATCH | File::FNM_EXTGLOB)
30
107
  end
31
108
  end
32
-
33
- def render_change(change)
34
- old_path = change['old_path'] || change['new_path']
35
- new_path = change['new_path'] || change['old_path']
36
- diff = change['diff'].to_s
37
- diff = DIFF_UNAVAILABLE if diff.strip.empty?
38
-
39
- <<~DIFF
40
- diff --git a/#{old_path} b/#{new_path}
41
- --- a/#{old_path}
42
- +++ b/#{new_path}
43
- #{diff}
44
- DIFF
45
- end
46
109
  end
47
110
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aireview
4
+ # Вывод --dry-run: настройки, сводка контекста и промпты обеих стадий.
5
+ class DryRunReport
6
+ def initialize(out)
7
+ @out = out
8
+ end
9
+
10
+ def render(dry_run)
11
+ @out.puts('=== LLM SETTINGS ===')
12
+ @out.puts("Generate: #{dry_run[:generate_model]} temperature=#{dry_run[:generate_temperature]}")
13
+ if dry_run[:critique_prompt]
14
+ @out.puts("Critique: #{dry_run[:critique_model]} temperature=#{dry_run[:critique_temperature]}")
15
+ else
16
+ @out.puts('Critique: disabled')
17
+ end
18
+ @out.puts
19
+ @out.puts('=== CONTEXT ===')
20
+ render_context_sizes(dry_run[:sizes])
21
+ render_coverage(dry_run[:coverage])
22
+ @out.puts
23
+ @out.puts('=== GENERATE SYSTEM PROMPT ===')
24
+ @out.puts(dry_run.dig(:generate_prompt, :system_prompt))
25
+ @out.puts
26
+ @out.puts('=== GENERATE USER PROMPT ===')
27
+ @out.puts(dry_run.dig(:generate_prompt, :user_prompt))
28
+ return unless dry_run[:critique_prompt]
29
+
30
+ @out.puts
31
+ @out.puts('=== CRITIQUE SYSTEM PROMPT ===')
32
+ @out.puts(dry_run.dig(:critique_prompt, :system_prompt))
33
+ @out.puts
34
+ @out.puts('=== CRITIQUE USER PROMPT ===')
35
+ @out.puts(dry_run.dig(:critique_prompt, :user_prompt))
36
+ end
37
+
38
+ private
39
+
40
+ def render_context_sizes(sizes)
41
+ @out.puts("Sections: #{sizes[:sections]} chars, diff: #{sizes[:diff]} chars " \
42
+ "(budget #{sizes[:diff_budget]}, hunks #{sizes[:hunks_shown]}/#{sizes[:hunks_total]})")
43
+ sizes[:stages].each do |stage, stage_sizes|
44
+ @out.puts("#{stage.capitalize} request: #{stage_sizes[:request]} chars " \
45
+ "(~#{stage_sizes[:request] / 4} tokens) of max #{stage_sizes[:max_prompt_chars]}, " \
46
+ "system prompt #{stage_sizes[:system_prompt]}")
47
+ end
48
+ end
49
+
50
+ def render_coverage(coverage)
51
+ return @out.puts('Coverage: complete') if coverage.complete?
52
+
53
+ @out.puts('Coverage: partial')
54
+ list('truncated sections', coverage.truncated_sections)
55
+ list('files not shown', coverage.files_not_shown)
56
+ coverage.files_partial.each { |file| @out.puts(" #{file[:path]}: #{file[:shown]} of #{file[:total]} hunks") }
57
+ list('diff not available', coverage.files_unavailable)
58
+ end
59
+
60
+ def list(title, items)
61
+ @out.puts(" #{title}: #{items.join(', ')}") unless items.empty?
62
+ end
63
+ end
64
+ end
@@ -4,5 +4,6 @@ module Aireview
4
4
  class ConfigError < Error; end
5
5
  class ParseError < Error; end
6
6
  class ApiError < Error; end
7
+ class ContextBudgetError < Error; end
7
8
  class HelpRequested < Error; end
8
9
  end
@@ -4,6 +4,10 @@ You are given the diff, the MR description, the Jira context and the candidates
4
4
  from the first pass. For every candidate return a verdict with decision=keep
5
5
  or reject.
6
6
 
7
+ Parts of the context may be truncated to fit the budget; markers in square
8
+ brackets show where. Missing text in a truncated part does not mean a missing
9
+ requirement or missing code.
10
+
7
11
  Be a strict filter:
8
12
  - when in doubt, choose reject;
9
13
  - keep only the most important and well-supported findings;
@@ -3,6 +3,10 @@ You are doing the first pass of a merge request review.
3
3
  Look only at the diff, the MR description and the Jira context, if present.
4
4
  Do not draw conclusions about code outside the diff.
5
5
 
6
+ Parts of the context may be truncated to fit the budget; markers in square
7
+ brackets show where. Missing text in a truncated part does not mean a missing
8
+ requirement or missing code.
9
+
6
10
  Do not verify that the specified versions of packages, libraries, tools and
7
11
  image tags exist. Do not report that a version does not exist or has not been
8
12
  released yet. Check syntax errors and explicit contradictions with the MR/Jira
@@ -28,12 +28,14 @@ module Aireview
28
28
  @logger = logger
29
29
  end
30
30
 
31
- def run(merge_request:, changes_text:, jira_issue: nil, critique: true)
32
- generate_prompt = @context_builder.build_generate_prompt(
31
+ def run(merge_request:, changes:, jira_issue: nil, critique: true)
32
+ context = @context_builder.prepare(
33
33
  merge_request: merge_request,
34
- changes_text: changes_text,
35
- jira_issue: jira_issue
34
+ changes: changes,
35
+ jira_issue: jira_issue,
36
+ critique: critique
36
37
  )
38
+ generate_prompt = @context_builder.build_generate_prompt(context)
37
39
  @logger.info("Pipeline generate pass started (model=#{@config.generate_model})")
38
40
  candidates_raw = @reviewer.generate(**generate_prompt)
39
41
  generate_result = parse_with_repair(
@@ -48,12 +50,7 @@ module Aireview
48
50
 
49
51
  accepted = if critique
50
52
  @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
- )
53
+ critique_candidates(context: context, candidates: candidates)
57
54
  else
58
55
  @logger.info('Pipeline critique pass skipped')
59
56
  candidates
@@ -61,25 +58,22 @@ module Aireview
61
58
 
62
59
  @logger.info("Pipeline finished with #{accepted.size} accepted finding(s)")
63
60
 
64
- ReviewRenderer.new(language: @config.review_language).render(accepted, summary: summary)
61
+ renderer = ReviewRenderer.new(language: @config.review_language)
62
+ renderer.render(accepted, summary: summary, coverage: context.coverage)
65
63
  end
66
64
 
67
- def dry_run_prompts(merge_request:, changes_text:, jira_issue: nil, critique: true)
65
+ def dry_run_prompts(merge_request:, changes:, jira_issue: nil, critique: true)
68
66
  @config.require_models!
69
67
 
70
- generate_prompt = @context_builder.build_generate_prompt(
68
+ context = @context_builder.prepare(
71
69
  merge_request: merge_request,
72
- changes_text: changes_text,
73
- jira_issue: jira_issue
70
+ changes: changes,
71
+ jira_issue: jira_issue,
72
+ critique: critique
74
73
  )
75
-
74
+ generate_prompt = @context_builder.build_generate_prompt(context)
76
75
  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
- )
76
+ @context_builder.build_critique_prompt(context, candidates_json: DRY_RUN_CANDIDATES_JSON)
83
77
  end
84
78
 
85
79
  {
@@ -88,21 +82,18 @@ module Aireview
88
82
  generate_model: @config.generate_model,
89
83
  generate_temperature: @config.generate_temperature,
90
84
  critique_model: @config.critique_model,
91
- critique_temperature: @config.critique_temperature
85
+ critique_temperature: @config.critique_temperature,
86
+ coverage: context.coverage,
87
+ sizes: context.sizes
92
88
  }
93
89
  end
94
90
 
95
91
  private
96
92
 
97
- def critique_candidates(merge_request:, changes_text:, jira_issue:, candidates:)
93
+ def critique_candidates(context:, candidates:)
98
94
  candidates_json = JSON.pretty_generate(candidates)
99
95
  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
- )
96
+ critique_prompt = @context_builder.build_critique_prompt(context, candidates_json: candidates_json)
106
97
  critique_raw = @reviewer.critique(**critique_prompt)
107
98
  critique_result = parse_with_repair(
108
99
  raw: critique_raw,
@@ -284,10 +275,11 @@ module Aireview
284
275
  end
285
276
 
286
277
  @logger.info("Pipeline #{stage} repair started for #{kind}")
278
+ prompt = @context_builder.check_stage_size!(stage, REPAIR_SYSTEM_PROMPT, user_prompt)
287
279
  if stage == :critique
288
- @reviewer.critique(system_prompt: REPAIR_SYSTEM_PROMPT, user_prompt: user_prompt)
280
+ @reviewer.critique(**prompt)
289
281
  else
290
- @reviewer.generate(system_prompt: REPAIR_SYSTEM_PROMPT, user_prompt: user_prompt)
282
+ @reviewer.generate(**prompt)
291
283
  end
292
284
  end
293
285
 
@@ -35,7 +35,16 @@ module Aireview
35
35
  where: 'Where',
36
36
  problem: 'Problem',
37
37
  why: 'Why it matters',
38
- suggestion: 'Suggestion'
38
+ suggestion: 'Suggestion',
39
+ partial: 'Partial review',
40
+ not_reviewed: 'Not reviewed',
41
+ files_not_shown: 'files not reviewed',
42
+ files_partial: 'files reviewed partially',
43
+ files_unavailable: 'files without an available diff',
44
+ sections_truncated: 'sections truncated',
45
+ hunks_of: 'hunks shown',
46
+ diff_unavailable: 'diff not available',
47
+ section_list: 'Truncated sections'
39
48
  },
40
49
  'ru' => {
41
50
  summary: 'Сводка',
@@ -50,7 +59,16 @@ module Aireview
50
59
  where: 'Где',
51
60
  problem: 'Проблема',
52
61
  why: 'Почему важно',
53
- suggestion: 'Предложение'
62
+ suggestion: 'Предложение',
63
+ partial: 'Ревью частичное',
64
+ not_reviewed: 'Не вошло в ревью',
65
+ files_not_shown: 'файлов не проверено',
66
+ files_partial: 'файлов проверено частично',
67
+ files_unavailable: 'файлов без доступного диффа',
68
+ sections_truncated: 'секций усечено',
69
+ hunks_of: 'хунков показано',
70
+ diff_unavailable: 'дифф недоступен',
71
+ section_list: 'Усечённые секции'
54
72
  }
55
73
  }.freeze
56
74
 
@@ -58,7 +76,11 @@ module Aireview
58
76
  @labels = LABELS.fetch(language.to_s) { LABELS.fetch(DEFAULT_LANGUAGE) }
59
77
  end
60
78
 
61
- def render(accepted, summary:)
79
+ # coverage: факты усечения контекста от пайплайна, не текст модели.
80
+ # result по-прежнему про найденные проблемы; неполнота покрытия
81
+ # дописывается рядом с ним, чтобы строка результата не читалась как
82
+ # «проверено всё».
83
+ def render(accepted, summary:, coverage: nil)
62
84
  findings = sorted_findings(Array(accepted)).first(TOTAL_FINDINGS_LIMIT)
63
85
  mismatches = findings.select { |finding| category(finding) == 'task_mismatch' }.first(MISMATCH_LIMIT)
64
86
  important = findings.select { |finding| important_finding?(finding) }.first(IMPORTANT_LIMIT)
@@ -79,8 +101,8 @@ module Aireview
79
101
 
80
102
  ## #{label(:result)}
81
103
 
82
- #{result}
83
-
104
+ #{result}#{partial_note(coverage)}
105
+ #{coverage_block(coverage)}
84
106
  #{label(:disclaimer)}
85
107
  MARKDOWN
86
108
  end
@@ -117,6 +139,34 @@ module Aireview
117
139
  end.join("\n\n")
118
140
  end
119
141
 
142
+ def partial_note(coverage)
143
+ return '' if coverage.nil? || coverage.complete?
144
+
145
+ counts = {
146
+ files_not_shown: coverage.files_not_shown.size,
147
+ files_partial: coverage.files_partial.size,
148
+ files_unavailable: coverage.files_unavailable.size,
149
+ sections_truncated: coverage.truncated_sections.size
150
+ }.reject { |_, count| count.zero? }.map { |key, count| "#{count} #{label(key)}" }
151
+
152
+ ". #{label(:partial)}: #{counts.join(', ')}."
153
+ end
154
+
155
+ def coverage_block(coverage)
156
+ return '' if coverage.nil? || coverage.complete?
157
+
158
+ lines = coverage.files_not_shown.map { |path| "- #{path}" }
159
+ lines += coverage.files_partial.map do |file|
160
+ "- #{file[:path]}: #{file[:shown]}/#{file[:total]} #{label(:hunks_of)}"
161
+ end
162
+ lines += coverage.files_unavailable.map { |path| "- #{path}: #{label(:diff_unavailable)}" }
163
+ unless coverage.truncated_sections.empty?
164
+ lines << "- #{label(:section_list)}: #{coverage.truncated_sections.join(', ')}"
165
+ end
166
+
167
+ "\n## #{label(:not_reviewed)}\n\n#{lines.join("\n")}\n"
168
+ end
169
+
120
170
  def location(finding)
121
171
  file = presence(value(finding, 'file'))
122
172
  line = value(finding, 'line')
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Aireview
3
- VERSION = '0.1.1'
3
+ VERSION = '0.2.0'
4
4
  end
data/lib/aireview.rb CHANGED
@@ -20,4 +20,5 @@ require_relative 'aireview/gitlab_client'
20
20
  require_relative 'aireview/jira_client'
21
21
  require_relative 'aireview/review_marker'
22
22
  require_relative 'aireview/publisher'
23
+ require_relative 'aireview/dry_run_report'
23
24
  require_relative 'aireview/cli'
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: 0.1.1
4
+ version: 0.2.0
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 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dotenv
@@ -63,6 +63,7 @@ extensions: []
63
63
  extra_rdoc_files: []
64
64
  files:
65
65
  - CHANGELOG.md
66
+ - CONTRIBUTORS.md
66
67
  - LICENSE
67
68
  - README.md
68
69
  - bin/aireview
@@ -70,8 +71,11 @@ files:
70
71
  - lib/aireview.rb
71
72
  - lib/aireview/cli.rb
72
73
  - lib/aireview/config.rb
74
+ - lib/aireview/config_limits.rb
75
+ - lib/aireview/context_budget.rb
73
76
  - lib/aireview/context_builder.rb
74
77
  - lib/aireview/diff_fetcher.rb
78
+ - lib/aireview/dry_run_report.rb
75
79
  - lib/aireview/errors.rb
76
80
  - lib/aireview/gitlab_client.rb
77
81
  - lib/aireview/jira_client.rb
@@ -96,6 +100,7 @@ metadata:
96
100
  source_code_uri: https://github.com/DenisDenis9331/aireview
97
101
  changelog_uri: https://github.com/DenisDenis9331/aireview/blob/main/CHANGELOG.md
98
102
  bug_tracker_uri: https://github.com/DenisDenis9331/aireview/issues
103
+ funding_uri: https://ko-fi.com/denis1011101
99
104
  rubygems_mfa_required: 'true'
100
105
  post_install_message:
101
106
  rdoc_options: []