Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 0faa989

Browse files
committed
implement best effort json parsing direct in the structured output object
1 parent fcdf028 commit 0faa989

File tree

7 files changed

+110
-32
lines changed

7 files changed

+110
-32
lines changed

lib/ai_helper/assistant.rb

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,10 @@ def generate_prompt(
142142
Proc.new do |partial, _, type|
143143
if type == :structured_output && schema_type
144144
helper_chunk = partial.read_buffered_property(schema_key)
145-
bad_json ||= partial.broken?
146145
if !helper_chunk.nil? && !helper_chunk.empty?
147-
if bad_json || schema_type == "string" || schema_type == "array"
146+
if schema_type == "string" || schema_type == "array"
148147
helper_response << helper_chunk
149148
else
150-
# TODO this feels a bit odd
151-
# why is this allowed to throw away potential data?
152149
helper_response = helper_chunk
153150
end
154151
block.call(helper_chunk) if block && !bad_json
@@ -162,17 +159,6 @@ def generate_prompt(
162159

163160
bot.reply(context, &buffer_blk)
164161

165-
# handle edge cases where structured output is all over the place
166-
if bad_json
167-
helper_response = helper_response.join if helper_response.is_a?(Array)
168-
helper_response =
169-
DiscourseAi::Utils::BestEffortJsonParser.extract_key(
170-
helper_response,
171-
schema_type,
172-
schema_key,
173-
)
174-
block.call(helper_response) if block
175-
end
176162
helper_response
177163
end
178164

lib/completions/endpoints/base.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,10 @@ def perform_completion!(
187187
blk =
188188
lambda do |partial|
189189
if partial.is_a?(String)
190-
partial = xml_stripper << partial if xml_stripper
190+
partial = xml_stripper << partial if xml_stripper && !partial.empty?
191191

192192
if structured_output.present?
193-
structured_output << partial
193+
structured_output << partial if !partial.empty?
194194
partial = structured_output
195195
end
196196
end
@@ -252,6 +252,15 @@ def perform_completion!(
252252
end
253253
xml_tool_processor.finish.each { |partial| blk.call(partial) } if xml_tool_processor
254254
decode_chunk_finish.each { |partial| blk.call(partial) }
255+
256+
if structured_output
257+
structured_output.finish
258+
if structured_output.broken?
259+
# signal last partial output which will get parsed
260+
# by best effort json parser
261+
blk.call("")
262+
end
263+
end
255264
return response_data
256265
ensure
257266
if log
@@ -448,6 +457,7 @@ def non_streaming_response(
448457

449458
if structured_output.present?
450459
response_data.each { |data| structured_output << data if data.is_a?(String) }
460+
structured_output.finish
451461

452462
return structured_output
453463
end

lib/completions/json_streaming_parser.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def initialize
5353
#
5454
# Returns a UTF-8 encoded String.
5555
def <<(data)
56+
data = data.dup if data.frozen?
5657
# Avoid state machine for complete UTF-8.
5758
if @buffer.empty?
5859
data.force_encoding(Encoding::UTF_8)

lib/completions/structured_output.rb

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,48 @@ def initialize(json_schema_properties)
1717
@raw_cursor = 0
1818

1919
@partial_json_tracker = JsonStreamingTracker.new(self)
20+
21+
@type_map = {}
22+
json_schema_properties.each { |name, prop| @type_map[name.to_sym] = prop[:type].to_sym }
23+
24+
@done = false
25+
end
26+
27+
def to_s
28+
# we may want to also normalize the JSON here for the broken case
29+
@raw_response
2030
end
2131

2232
attr_reader :last_chunk_buffer
2333

2434
def <<(raw)
35+
raise "Cannot append to a completed StructuredOutput" if @done
2536
@raw_response << raw
2637
@partial_json_tracker << raw
2738
end
2839

40+
def finish
41+
@done = true
42+
end
43+
2944
def broken?
3045
@partial_json_tracker.broken?
3146
end
3247

3348
def read_buffered_property(prop_name)
34-
# Safeguard: If the model is misbehaving and generating something that's not a JSON,
35-
# treat response as a normal string.
36-
# This is a best-effort to recover from an unexpected scenario.
3749
if @partial_json_tracker.broken?
38-
unread_chunk = @raw_response[@raw_cursor..]
39-
@raw_cursor = @raw_response.length
40-
return unread_chunk
50+
if @done
51+
return nil if @type_map[prop_name.to_sym].nil?
52+
return(
53+
DiscourseAi::Utils::BestEffortJsonParser.extract_key(
54+
@raw_response,
55+
@type_map[prop_name.to_sym],
56+
prop_name,
57+
)
58+
)
59+
else
60+
return nil
61+
end
4162
end
4263

4364
# Maybe we haven't read that part of the JSON yet.

lib/utils/best_effort_json_parser.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ def extract_key(helper_response, schema_type, schema_key)
1818
manual_extract(cleaned, schema_key, schema_type)
1919

2020
value = parsed.is_a?(Hash) ? parsed[schema_key.to_s] : parsed
21-
parsed = cast_value(value, schema_type)
21+
22+
cast_value(value, schema_type)
2223
end
2324

2425
private
@@ -119,6 +120,9 @@ def cast_value(value, schema_type)
119120
value.is_a?(Array) ? value : []
120121
when :object
121122
value.is_a?(Hash) ? value : {}
123+
when :boolean
124+
return value if [true, false, nil].include?(value)
125+
value.to_s.downcase == "true"
122126
else
123127
value.to_s
124128
end

spec/lib/completions/endpoints/open_ai_spec.rb

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def stub_raw(chunks, body_blk: nil)
5959
stub.to_return(status: 200, body: chunks)
6060
end
6161

62-
def stub_streamed_response(prompt, deltas, tool_call: false)
62+
def stub_streamed_response(prompt, deltas, tool_call: false, skip_body_check: false)
6363
chunks =
6464
deltas.each_with_index.map do |_, index|
6565
if index == (deltas.length - 1)
@@ -71,10 +71,13 @@ def stub_streamed_response(prompt, deltas, tool_call: false)
7171

7272
chunks = (chunks.join("\n\n") << "data: [DONE]").split("")
7373

74-
WebMock
75-
.stub_request(:post, "https://api.openai.com/v1/chat/completions")
76-
.with(body: request_body(prompt, stream: true, tool_call: tool_call))
77-
.to_return(status: 200, body: chunks)
74+
mock = WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions")
75+
76+
if !skip_body_check
77+
mock = mock.with(body: request_body(prompt, stream: true, tool_call: tool_call))
78+
end
79+
80+
mock.to_return(status: 200, body: chunks)
7881

7982
yield if block_given?
8083
end
@@ -401,6 +404,41 @@ def request_body(prompt, stream: false, tool_call: false)
401404
end
402405
end
403406

407+
describe "structured outputs" do
408+
it "falls back to best-effort parsing on broken JSON responses" do
409+
prompt = compliance.generic_prompt
410+
deltas = ["```json\n{ message: 'hel", "lo' }"]
411+
412+
model_params = {
413+
response_format: {
414+
json_schema: {
415+
schema: {
416+
properties: {
417+
message: {
418+
type: "string",
419+
},
420+
},
421+
},
422+
},
423+
},
424+
}
425+
426+
read_properties = []
427+
open_ai_mock.with_chunk_array_support do
428+
# skip body check cause of response format
429+
open_ai_mock.stub_streamed_response(prompt, deltas, skip_body_check: true)
430+
431+
dialect = compliance.dialect(prompt: prompt)
432+
433+
endpoint.perform_completion!(dialect, user, model_params) do |partial|
434+
read_properties << partial.read_buffered_property(:message)
435+
end
436+
end
437+
438+
expect(read_properties.join).to eq("hello")
439+
end
440+
end
441+
404442
describe "disabled tool use" do
405443
it "can properly disable tool use with :none" do
406444
llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}")

spec/lib/completions/structured_output_spec.rb

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,31 @@
127127
chunks = [+"I'm not", +"a", +"JSON :)"]
128128

129129
structured_output << chunks[0]
130-
expect(structured_output.read_buffered_property(nil)).to eq("I'm not")
130+
expect(structured_output.read_buffered_property(:bob)).to eq(nil)
131131

132132
structured_output << chunks[1]
133-
expect(structured_output.read_buffered_property(nil)).to eq("a")
133+
expect(structured_output.read_buffered_property(:bob)).to eq(nil)
134134

135135
structured_output << chunks[2]
136-
expect(structured_output.read_buffered_property(nil)).to eq("JSON :)")
136+
137+
structured_output.finish
138+
expect(structured_output.read_buffered_property(:bob)).to eq(nil)
139+
end
140+
141+
it "can handle broken JSON" do
142+
broken_json = <<~JSON
143+
```json
144+
{
145+
"message": "This is a broken JSON",
146+
bool: true
147+
}
148+
JSON
149+
150+
structured_output << broken_json
151+
structured_output.finish
152+
153+
expect(structured_output.read_buffered_property(:message)).to eq("This is a broken JSON")
154+
expect(structured_output.read_buffered_property(:bool)).to eq(true)
137155
end
138156
end
139157
end

0 commit comments

Comments
 (0)