Skip to content

Commit 89b8459

Browse files
Merge pull request #19 from SergoHUH/feat/add-native-output-schemas
feat(llm): add native output schemas
2 parents 4b1db0b + 360b8a5 commit 89b8459

7 files changed

Lines changed: 119 additions & 44 deletions

File tree

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,8 @@ LLM_CRITIQUE_TEMPERATURE=0
5151
REVIEW_LANGUAGE=ru
5252
```
5353

54-
Для других провайдеров используйте соответствующий ключ, например
55-
`OPENAI_API_KEY`, `OPENROUTER_API_KEY` или `ANTHROPIC_API_KEY`. Провайдеры и
56-
модели каждой стадии задаются через `LLM_GENERATE_PROVIDER`,
54+
На текущий момент поддерживаются только провайдеры `gemini` и `ollama`.
55+
Провайдеры и модели каждой стадии задаются через `LLM_GENERATE_PROVIDER`,
5756
`LLM_GENERATE_MODEL`, `LLM_CRITIQUE_PROVIDER` и `LLM_CRITIQUE_MODEL`.
5857
`LLM_PROVIDER` остаётся общим значением по умолчанию, если отдельный провайдер
5958
стадии не указан.

lib/aireview/config.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,6 @@ class Config
5050

5151
PROVIDER_KEY_MAPPING = {
5252
'gemini' => 'GEMINI_API_KEY',
53-
'openai' => 'OPENAI_API_KEY',
54-
'openrouter' => 'OPENROUTER_API_KEY',
55-
'anthropic' => 'ANTHROPIC_API_KEY',
5653
'ollama' => nil
5754
}.freeze
5855

lib/aireview/output_schemas.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
require 'ruby_llm/schema'
3+
4+
module Aireview
5+
module OutputSchemaValues
6+
CATEGORIES = %w[
7+
task_mismatch
8+
bug
9+
regression
10+
security
11+
performance
12+
data_loss
13+
edge_case
14+
test_gap
15+
maintainability
16+
].freeze
17+
SEVERITIES = %w[critical major minor].freeze
18+
DECISIONS = %w[keep reject].freeze
19+
end
20+
21+
class GenerateOutputSchema < RubyLLM::Schema
22+
string :summary
23+
array :candidates, max_items: 3 do
24+
object do
25+
string :id
26+
string :file
27+
any_of :line do
28+
integer
29+
null
30+
end
31+
string :quoted_code
32+
string :problem
33+
string :why
34+
string :suggestion
35+
string :category, enum: OutputSchemaValues::CATEGORIES
36+
string :severity, enum: OutputSchemaValues::SEVERITIES
37+
end
38+
end
39+
end
40+
41+
class CritiqueOutputSchema < RubyLLM::Schema
42+
array :verdicts do
43+
object do
44+
string :id
45+
string :decision, enum: OutputSchemaValues::DECISIONS
46+
string :reason
47+
object :refinement, required: false do
48+
string :problem
49+
string :why
50+
string :suggestion
51+
string :category, enum: OutputSchemaValues::CATEGORIES
52+
string :severity, enum: OutputSchemaValues::SEVERITIES
53+
end
54+
end
55+
end
56+
end
57+
end

lib/aireview/reviewer.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22
require_relative 'errors'
3+
require_relative 'output_schemas'
34
require_relative 'utils'
45
require 'timeout'
56

@@ -27,7 +28,8 @@ def generate(system_prompt:, user_prompt:)
2728
options: {
2829
model: @config.generate_model,
2930
temperature: @config.generate_temperature,
30-
provider: @config.generate_provider
31+
provider: @config.generate_provider,
32+
schema: GenerateOutputSchema
3133
}
3234
)
3335
end
@@ -40,7 +42,8 @@ def critique(system_prompt:, user_prompt:)
4042
options: {
4143
model: @config.critique_model,
4244
temperature: @config.critique_temperature,
43-
provider: @config.critique_provider
45+
provider: @config.critique_provider,
46+
schema: CritiqueOutputSchema
4447
}
4548
)
4649
end
@@ -79,6 +82,7 @@ def perform_llm_request(context:, stage:, system:, user:, options:)
7982
@logger.info("LLM #{stage} request started (model=#{model}, temperature=#{temperature})")
8083
chat = build_chat(context: context, stage: stage, model: model, provider: options[:provider])
8184
.with_temperature(temperature.to_f)
85+
.with_schema(options[:schema])
8286
chat.with_instructions(system)
8387
response = Timeout.timeout(@config.llm_timeout.to_f) { chat.ask(user) }
8488
@logger.info("LLM #{stage} request completed (model=#{model})")

spec/config_spec.rb

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -233,25 +233,6 @@
233233
end
234234
end
235235

236-
it 'validates API keys for generate and critique providers separately' do
237-
Dir.mktmpdir do |dir|
238-
config = described_class.load(
239-
cwd: dir,
240-
env: {
241-
'LLM_GENERATE_PROVIDER' => 'gemini',
242-
'LLM_GENERATE_MODEL' => 'gemini-2.5-pro',
243-
'GEMINI_API_KEY' => 'gemini-secret',
244-
'LLM_CRITIQUE_PROVIDER' => 'anthropic',
245-
'LLM_CRITIQUE_MODEL' => 'claude-sonnet-4'
246-
},
247-
logger: Logger.new(nil)
248-
)
249-
250-
expect { config.require_llm_configuration! }
251-
.to raise_error(Aireview::ConfigError, /critique.*anthropic/)
252-
end
253-
end
254-
255236
it 'requires a key only for the remote stage in a mixed configuration' do
256237
Dir.mktmpdir do |dir|
257238
config = described_class.load(
@@ -417,22 +398,5 @@
417398
end
418399
end
419400

420-
it 'loads OpenRouter API key from environment' do
421-
Dir.mktmpdir do |dir|
422-
config = described_class.load(
423-
cwd: dir,
424-
env: {
425-
'LLM_PROVIDER' => 'openrouter',
426-
'LLM_GENERATE_MODEL' => 'meta-llama/llama-3.3-70b-instruct:free',
427-
'LLM_CRITIQUE_MODEL' => 'meta-llama/llama-3.3-70b-instruct:free',
428-
'OPENROUTER_API_KEY' => 'sk-or-secret'
429-
},
430-
logger: Logger.new(nil)
431-
)
432-
433-
expect(config.llm_provider).to eq('openrouter')
434-
expect(config.provider_api_key).to eq('sk-or-secret')
435-
end
436-
end
437401
end
438402
end

spec/output_schemas_spec.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
require 'aireview/output_schemas'
2+
3+
RSpec.describe 'Aireview output schemas' do
4+
def schema_for(schema_class)
5+
schema_class.new.to_json_schema.fetch(:schema)
6+
end
7+
8+
it 'defines generate fields, enums, nullable line, and candidate limit' do
9+
schema = schema_for(Aireview::GenerateOutputSchema)
10+
candidates = schema.dig(:properties, :candidates)
11+
candidate = candidates.fetch(:items)
12+
properties = candidate.fetch(:properties)
13+
14+
expect(schema.fetch(:required)).to contain_exactly(:summary, :candidates)
15+
expect(schema.fetch(:additionalProperties)).to be(false)
16+
expect(schema.dig(:properties, :summary, :type)).to eq('string')
17+
expect(candidates.fetch(:type)).to eq('array')
18+
expect(candidates.fetch(:maxItems)).to eq(3)
19+
expect(candidate.fetch(:required)).to contain_exactly(
20+
:id, :file, :line, :quoted_code, :problem, :why, :suggestion, :category, :severity
21+
)
22+
expect(candidate.fetch(:additionalProperties)).to be(false)
23+
expect(properties.values_at(:id, :file, :quoted_code, :problem, :why, :suggestion).map { |field| field[:type] })
24+
.to all(eq('string'))
25+
expect(properties.dig(:line, :anyOf).map { |type| type.fetch(:type) }).to contain_exactly('integer', 'null')
26+
expect(properties.dig(:category, :enum)).to eq(Aireview::OutputSchemaValues::CATEGORIES)
27+
expect(properties.dig(:severity, :enum)).to eq(Aireview::OutputSchemaValues::SEVERITIES)
28+
end
29+
30+
it 'defines critique decision and optional refinement constraints' do
31+
schema = schema_for(Aireview::CritiqueOutputSchema)
32+
verdict = schema.dig(:properties, :verdicts, :items)
33+
properties = verdict.fetch(:properties)
34+
refinement = properties.fetch(:refinement)
35+
36+
expect(schema.fetch(:required)).to contain_exactly(:verdicts)
37+
expect(schema.fetch(:additionalProperties)).to be(false)
38+
expect(schema.dig(:properties, :verdicts, :type)).to eq('array')
39+
expect(verdict.fetch(:required)).to contain_exactly(:id, :decision, :reason)
40+
expect(verdict.fetch(:additionalProperties)).to be(false)
41+
expect(properties.values_at(:id, :decision, :reason).map { |field| field[:type] }).to all(eq('string'))
42+
expect(properties.dig(:decision, :enum)).to eq(Aireview::OutputSchemaValues::DECISIONS)
43+
expect(refinement.fetch(:additionalProperties)).to be(false)
44+
expect(refinement.fetch(:required)).to contain_exactly(:problem, :why, :suggestion, :category, :severity)
45+
expect(refinement.dig(:properties, :category, :enum)).to eq(Aireview::OutputSchemaValues::CATEGORIES)
46+
expect(refinement.dig(:properties, :severity, :enum)).to eq(Aireview::OutputSchemaValues::SEVERITIES)
47+
end
48+
end

spec/reviewer_spec.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,11 @@
9696
end
9797

9898
allow(generate_chat).to receive(:with_temperature).with(0.3).and_return(generate_chat)
99+
allow(generate_chat).to receive(:with_schema).with(Aireview::GenerateOutputSchema).and_return(generate_chat)
99100
allow(generate_chat).to receive(:with_instructions).with('system prompt').and_return(generate_chat)
100101
allow(generate_chat).to receive(:ask).with('user prompt').and_return(generate_response)
101102
allow(critique_chat).to receive(:with_temperature).with(0.0).and_return(critique_chat)
103+
allow(critique_chat).to receive(:with_schema).with(Aireview::CritiqueOutputSchema).and_return(critique_chat)
102104
allow(critique_chat).to receive(:with_instructions).with('system prompt').and_return(critique_chat)
103105
allow(critique_chat).to receive(:ask).with('user prompt').and_return(critique_response)
104106
end
@@ -123,6 +125,7 @@
123125
.to have_received(:chat)
124126
.with(model: 'gemini-2.5-pro', provider: :gemini)
125127
expect(generate_chat).to have_received(:with_temperature).with(0.3)
128+
expect(generate_chat).to have_received(:with_schema).with(Aireview::GenerateOutputSchema)
126129
end
127130

128131
it 'uses critique model and temperature for the critique pass' do
@@ -134,6 +137,7 @@
134137
.to have_received(:chat)
135138
.with(model: 'gemini-2.5-flash-lite', provider: :gemini)
136139
expect(critique_chat).to have_received(:with_temperature).with(0.0)
140+
expect(critique_chat).to have_received(:with_schema).with(Aireview::CritiqueOutputSchema)
137141
end
138142

139143
it 'creates one isolated context per stage' do
@@ -313,6 +317,8 @@
313317
expect(contexts.last)
314318
.to have_received(:chat)
315319
.with(model: 'gemini-2.5-flash-lite', provider: critique_value.to_sym)
320+
expect(generate_chat).to have_received(:with_schema).with(Aireview::GenerateOutputSchema)
321+
expect(critique_chat).to have_received(:with_schema).with(Aireview::CritiqueOutputSchema)
316322
expect(global_ruby_config.to_h).to eq(global_config_before)
317323
end
318324
end

0 commit comments

Comments
 (0)