Skip to content

Commit 4b1db0b

Browse files
Merge pull request #18 from SergoHUH/feat/handle-structured-output-responses
feat(llm): handle structured output responses
2 parents 7aa3095 + 726d59f commit 4b1db0b

2 files changed

Lines changed: 142 additions & 10 deletions

File tree

lib/aireview/review_pipeline.rb

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,34 @@ def critique_candidates(merge_request:, changes_text:, jira_issue:, candidates:)
120120
end
121121

122122
def parse_with_repair(raw:, kind:, expected:, repair_stage:, critique_candidate_ids: nil)
123-
parse_expected_json(raw, expected, critique_candidate_ids: critique_candidate_ids)
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)
124151
rescue JSON::ParserError, SchemaError => e
125152
@logger.warn("Invalid #{kind} JSON, requesting one repair: #{e.message}")
126153
repaired = repair_json(
@@ -131,14 +158,14 @@ def parse_with_repair(raw:, kind:, expected:, repair_stage:, critique_candidate_
131158
critique_candidate_ids: critique_candidate_ids
132159
)
133160
begin
134-
parse_expected_json(repaired, expected, critique_candidate_ids: critique_candidate_ids)
161+
parse_expected_result(repaired, expected, critique_candidate_ids: critique_candidate_ids)
135162
rescue JSON::ParserError, SchemaError => second_error
136163
raise ParseError, "LLM returned invalid #{kind} JSON after repair: #{second_error.message}"
137164
end
138165
end
139166

140-
def parse_expected_json(raw, expected, critique_candidate_ids: nil)
141-
parsed = JSON.parse(strip_code_fences(raw.to_s))
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))
142169

143170
case expected
144171
when :generate
@@ -192,15 +219,15 @@ def normalize_critique_result(parsed, critique_candidate_ids:)
192219
end
193220

194221
def validate_generate_result_shape!(parsed)
195-
return if parsed.is_a?(Hash) && parsed['candidates'].is_a?(Array)
196-
197-
raise SchemaError, 'expected an object with summary and candidates array'
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)
198225
end
199226

200227
def validate_critique_result_shape!(parsed)
201-
return if parsed.is_a?(Hash) && parsed['verdicts'].is_a?(Array)
202-
203-
raise SchemaError, 'expected an object with verdicts array'
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)
204231
end
205232

206233
def validate_identifiers!(identifiers, missing_message:, duplicate_prefix:)

spec/review_pipeline_spec.rb

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ def generate_result(candidates)
6868
)
6969
end
7070

71+
def structured_generate_result(candidates)
72+
{
73+
'summary' => 'MR recalculates order totals during checkout.',
74+
'candidates' => candidates.map { |candidate| candidate.transform_keys(&:to_s) }
75+
}
76+
end
77+
7178
it 'renders only candidates accepted by critique' do
7279
allow(reviewer).to receive(:generate).and_return(generate_result(candidates))
7380
allow(reviewer).to receive(:critique).and_return(
@@ -104,6 +111,95 @@ def generate_result(candidates)
104111
expect(result).to include('ok')
105112
end
106113

114+
it 'processes structured Hash responses without JSON parsing or repair' do
115+
allow(reviewer).to receive(:generate).and_return(structured_generate_result([candidates.first]))
116+
allow(reviewer).to receive(:critique).and_return(
117+
{
118+
'verdicts' => [
119+
{ 'id' => 'C1', 'decision' => 'keep', 'reason' => 'confirmed by diff' }
120+
]
121+
}
122+
)
123+
allow(JSON).to receive(:parse).and_call_original
124+
125+
result = pipeline.run(merge_request: merge_request, changes_text: changes_text)
126+
127+
expect(JSON).not_to have_received(:parse)
128+
expect(reviewer).to have_received(:generate).once
129+
expect(reviewer).to have_received(:critique).once
130+
expect(result).to include('Tax is no longer included')
131+
end
132+
133+
it 'continues processing JSON strings without repair' do
134+
allow(reviewer).to receive(:generate).and_return(generate_result([candidates.first]))
135+
136+
result = pipeline.run(merge_request: merge_request, changes_text: changes_text, critique: false)
137+
138+
expect(reviewer).to have_received(:generate).once
139+
expect(result).to include('Tax is no longer included')
140+
end
141+
142+
it 'rejects an invalid structured generate result without repair' do
143+
invalid_result = structured_generate_result([{ file: 'app/models/order.rb' }])
144+
allow(reviewer).to receive(:generate).and_return(invalid_result)
145+
146+
expect do
147+
pipeline.run(merge_request: merge_request, changes_text: changes_text, critique: false)
148+
end.to raise_error(
149+
Aireview::ParseError,
150+
/invalid generate result: each generate candidate must include a non-empty id/
151+
)
152+
expect(reviewer).to have_received(:generate).once
153+
end
154+
155+
it 'rejects malformed structured generate candidates without repair' do
156+
allow(reviewer).to receive(:generate).and_return(
157+
{ 'summary' => 'Malformed result', 'candidates' => [nil] }
158+
)
159+
160+
expect do
161+
pipeline.run(merge_request: merge_request, changes_text: changes_text, critique: false)
162+
end.to raise_error(
163+
Aireview::ParseError,
164+
/invalid generate result: each generate candidate must be an object/
165+
)
166+
expect(reviewer).to have_received(:generate).once
167+
end
168+
169+
it 'rejects an unsupported response type without repair' do
170+
allow(reviewer).to receive(:generate).and_return(nil)
171+
172+
expect do
173+
pipeline.run(merge_request: merge_request, changes_text: changes_text, critique: false)
174+
end.to raise_error(Aireview::ParseError, /unsupported generate result type: NilClass/)
175+
expect(reviewer).to have_received(:generate).once
176+
end
177+
178+
it 'rejects an invalid structured critique result without repair' do
179+
allow(reviewer).to receive(:generate).and_return(structured_generate_result([candidates.first]))
180+
allow(reviewer).to receive(:critique).and_return(
181+
{ 'verdicts' => [{ 'id' => 'C9', 'decision' => 'keep', 'reason' => 'hallucinated id' }] }
182+
)
183+
184+
expect do
185+
pipeline.run(merge_request: merge_request, changes_text: changes_text)
186+
end.to raise_error(Aireview::ParseError, /invalid critique result: unknown verdict ids: C9/)
187+
expect(reviewer).to have_received(:critique).once
188+
end
189+
190+
it 'rejects malformed structured critique verdicts without repair' do
191+
allow(reviewer).to receive(:generate).and_return(structured_generate_result([candidates.first]))
192+
allow(reviewer).to receive(:critique).and_return({ 'verdicts' => [42] })
193+
194+
expect do
195+
pipeline.run(merge_request: merge_request, changes_text: changes_text)
196+
end.to raise_error(
197+
Aireview::ParseError,
198+
/invalid critique result: each critique verdict must be an object/
199+
)
200+
expect(reviewer).to have_received(:critique).once
201+
end
202+
107203
it 'repairs invalid generate JSON once' do
108204
allow(reviewer).to receive(:generate).and_return('not json', generate_result([candidates.first]))
109205

@@ -113,6 +209,15 @@ def generate_result(candidates)
113209
expect(result).to include('Tax is no longer included')
114210
end
115211

212+
it 'accepts a structured Hash returned by a repair request for a JSON string' do
213+
allow(reviewer).to receive(:generate).and_return('not json', structured_generate_result([candidates.first]))
214+
215+
result = pipeline.run(merge_request: merge_request, changes_text: changes_text, critique: false)
216+
217+
expect(reviewer).to have_received(:generate).twice
218+
expect(result).to include('Tax is no longer included')
219+
end
220+
116221
it 'repairs generate candidates with invalid ids once' do
117222
invalid_generate = JSON.generate(summary: 'bad', candidates: [{ file: 'app/models/order.rb' }])
118223
allow(reviewer).to receive(:generate).and_return(invalid_generate, generate_result([candidates.first]))

0 commit comments

Comments
 (0)