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

Commit bd223d0

Browse files
committed
FIX: Structured output discrepancies.
This change fixes two bugs and adds a safeguard. The first issue is that the schema Gemini expected differed from the one sent, resulting in 400 errors when performing completions. The second issue was that creating a new persona won't define a method for `response_format`. This has to be explicitly defined when we wrap it inside the Persona class. Also, There was a mismatch between the default value and what we stored in the DB. Some parts of the code expected symbols as keys and others as strings. Finally, we add a safeguard when, even if asked to, the model refuses to reply with a valid JSON. In this case, we are making a best-effort to recover and stream the raw response.
1 parent c34fcc8 commit bd223d0

File tree

13 files changed

+71
-39
lines changed

13 files changed

+71
-39
lines changed

app/models/ai_persona.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ def class_instance
266266
define_method(:top_p) { @ai_persona&.top_p }
267267
define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." }
268268
define_method(:uploads) { @ai_persona&.uploads }
269+
define_method(:response_format) { @ai_persona&.response_format }
269270
define_method(:examples) { @ai_persona&.examples }
270271
end
271272
end

lib/completions/endpoints/gemini.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,13 @@ def prepare_payload(prompt, model_params, dialect)
8787
if model_params.present?
8888
payload[:generationConfig].merge!(model_params.except(:response_format))
8989

90-
if model_params[:response_format].present?
91-
# https://ai.google.dev/api/generate-content#generationconfig
92-
payload[:generationConfig][:responseSchema] = model_params[:response_format]
90+
# https://ai.google.dev/api/generate-content#generationconfig
91+
gemini_schema = model_params[:response_format].dig(:json_schema, :schema)
92+
93+
if gemini_schema.present?
94+
payload[:generationConfig][:responseSchema] = gemini_schema.except(
95+
:additionalProperties,
96+
)
9397
payload[:generationConfig][:responseMimeType] = "application/json"
9498
end
9599
end

lib/completions/json_streaming_tracker.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ def initialize(stream_consumer)
2424
end
2525
end
2626

27+
def broken?
28+
@broken
29+
end
30+
2731
def <<(json)
2832
# llm could send broken json
2933
# in that case just deal with it later

lib/completions/structured_output.rb

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,40 @@ def initialize(json_schema_properties)
1313

1414
@tracked = {}
1515

16+
@raw_response = +""
17+
@raw_cursor = 0
18+
1619
@partial_json_tracker = JsonStreamingTracker.new(self)
1720
end
1821

1922
attr_reader :last_chunk_buffer
2023

2124
def <<(raw)
25+
@raw_response << raw
2226
@partial_json_tracker << raw
2327
end
2428

25-
def read_latest_buffered_chunk
26-
@property_names.reduce({}) do |memo, pn|
27-
if @tracked[pn].present?
28-
# This means this property is a string and we want to return unread chunks.
29-
if @property_cursors[pn].present?
30-
unread = @tracked[pn][@property_cursors[pn]..]
31-
32-
memo[pn] = unread if unread.present?
33-
@property_cursors[pn] = @tracked[pn].length
34-
else
35-
# Ints and bools are always returned as is.
36-
memo[pn] = @tracked[pn]
37-
end
38-
end
29+
def read_buffered_property(prop_name)
30+
# Safeguard: If the model is misbehaving and generating something that's not a JSON,
31+
# treat response as a normal string.
32+
# This is a best-effort to recover from an unexpected scenario.
33+
if @partial_json_tracker.broken?
34+
unread_chunk = @raw_response[@raw_cursor..]
35+
@raw_cursor = @raw_response.length
36+
return unread_chunk
37+
end
3938

40-
memo
39+
# Maybe we haven't read that part of the JSON yet.
40+
return nil if @tracked[prop_name].blank?
41+
42+
# This means this property is a string and we want to return unread chunks.
43+
if @property_cursors[prop_name].present?
44+
unread = @tracked[prop_name][@property_cursors[prop_name]..]
45+
@property_cursors[prop_name] = @tracked[prop_name].length
46+
return unread
47+
else
48+
# Ints and bools are always returned as is.
49+
return @tracked[prop_name]
4150
end
4251
end
4352

lib/personas/bot.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def build_json_schema(response_format)
316316
response_format
317317
.to_a
318318
.reduce({}) do |memo, format|
319-
memo[format[:key].to_sym] = { type: format[:type] }
319+
memo[format["key"].to_sym] = { type: format["type"] }
320320
memo
321321
end
322322

lib/personas/short_summarizer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def system_prompt
3333
end
3434

3535
def response_format
36-
[{ key: "summary", type: "string" }]
36+
[{ "key" => "summary", "type" => "string" }]
3737
end
3838
end
3939
end

lib/personas/summarizer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def system_prompt
3434
end
3535

3636
def response_format
37-
[{ key: "summary", type: "string" }]
37+
[{ "key" => "summary", "type" => "string" }]
3838
end
3939

4040
def examples

lib/summarization/fold_content.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def fold(items, user, &on_partial_blk)
116116
if type == :structured_output
117117
json_summary_schema_key = bot.persona.response_format&.first.to_h
118118
partial_summary =
119-
partial.read_latest_buffered_chunk[json_summary_schema_key[:key].to_sym]
119+
partial.read_buffered_property(json_summary_schema_key["key"]&.to_sym)
120120

121121
if partial_summary.present?
122122
summary << partial_summary

spec/lib/completions/endpoints/anthropic_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@
845845
response_format: schema,
846846
) { |partial, cancel| structured_output = partial }
847847

848-
expect(structured_output.read_latest_buffered_chunk).to eq({ key: "Hello!" })
848+
expect(structured_output.read_buffered_property(:key)).to eq("Hello!")
849849

850850
expected_body = {
851851
model: "claude-3-opus-20240229",

spec/lib/completions/endpoints/aws_bedrock_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ def encode_message(message)
607607
}
608608
expect(JSON.parse(request.body)).to eq(expected)
609609

610-
expect(structured_output.read_latest_buffered_chunk).to eq({ key: "Hello!" })
610+
expect(structured_output.read_buffered_property(:key)).to eq("Hello!")
611611
end
612612
end
613613
end

0 commit comments

Comments
 (0)