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

Commit ab5edae

Browse files
authored
FIX: make AI helper more robust (#1484)
* FIX: make AI helper more robust - If JSON is broken for structured output then lean on a more forgiving parser - Gemini 2.5 flash does not support temp, support opting out - Evals for assistant were broken, fix interface - Add some missing LLMs - Translator was not mapped correctly to the feature - fix that - Don't mix XML in prompt for translator * lint * correct logic * simplify code * implement best effort json parsing direct in the structured output object
1 parent 0f90497 commit ab5edae

File tree

15 files changed

+517
-43
lines changed

15 files changed

+517
-43
lines changed

app/models/llm_model.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ def self.provider_params
6565
google: {
6666
disable_native_tools: :checkbox,
6767
enable_thinking: :checkbox,
68+
disable_temperature: :checkbox,
69+
disable_top_p: :checkbox,
6870
thinking_tokens: :number,
6971
},
7072
azure: {

config/eval-llms.yml

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
11
llms:
2+
o3:
3+
display_name: O3
4+
name: o3
5+
tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer
6+
api_key_env: OPENAI_API_KEY
7+
provider: open_ai
8+
url: https://api.openai.com/v1/chat/completions
9+
max_prompt_tokens: 131072
10+
vision_enabled: true
11+
provider_params:
12+
disable_top_p: true
13+
disable_temperature: true
14+
15+
gpt-41:
16+
display_name: GPT-4.1
17+
name: gpt-4.1
18+
tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer
19+
api_key_env: OPENAI_API_KEY
20+
provider: open_ai
21+
url: https://api.openai.com/v1/chat/completions
22+
max_prompt_tokens: 131072
23+
vision_enabled: true
24+
225
gpt-4o:
326
display_name: GPT-4o
427
name: gpt-4o
@@ -74,12 +97,25 @@ llms:
7497
max_prompt_tokens: 1000000
7598
vision_enabled: true
7699

77-
gemini-2.0-pro-exp:
100+
gemini-2.5-flash:
101+
display_name: Gemini 2.5 Flash
102+
name: gemini-2-5-flash
103+
tokenizer: DiscourseAi::Tokenizer::GeminiTokenizer
104+
api_key_env: GEMINI_API_KEY
105+
provider: google
106+
url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash
107+
max_prompt_tokens: 1000000
108+
vision_enabled: true
109+
provider_params:
110+
disable_top_p: true
111+
disable_temperature: true
112+
113+
gemini-2.0-pro:
78114
display_name: Gemini 2.0 pro
79-
name: gemini-2-0-pro-exp
115+
name: gemini-2-0-pro
80116
tokenizer: DiscourseAi::Tokenizer::GeminiTokenizer
81117
api_key_env: GEMINI_API_KEY
82118
provider: google
83-
url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-pro-exp
119+
url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-pro
84120
max_prompt_tokens: 1000000
85121
vision_enabled: true

config/locales/client.en.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ en:
249249
markdown_tables: "Generate Markdown table"
250250
custom_prompt: "Custom prompt"
251251
image_caption: "Caption images"
252+
translator: "Translator"
252253

253254
translation:
254255
name: "Translation"
@@ -257,7 +258,7 @@ en:
257258
post_raw_translator: "Post raw translator"
258259
topic_title_translator: "Topic title translator"
259260
short_text_translator: "Short text translator"
260-
261+
261262
spam:
262263
name: "Spam"
263264
description: "Identifies potential spam using the selected LLM and flags it for site moderators to inspect in the review queue"

evals/lib/eval.rb

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,7 @@ class << user
200200
user.admin = true
201201
end
202202
result =
203-
helper.generate_and_send_prompt(
204-
name,
205-
input,
206-
current_user = user,
207-
_force_default_locale = false,
208-
)
203+
helper.generate_and_send_prompt(name, input, current_user = user, force_default_locale: false)
209204

210205
result[:suggestions].first
211206
end

lib/ai_helper/assistant.rb

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def attach_user_context(context, user = nil, force_default_locale: false)
8282
context.user_language = "#{locale_hash["name"]}"
8383

8484
if user
85-
timezone = user.user_option.timezone || "UTC"
85+
timezone = user&.user_option&.timezone || "UTC"
8686
current_time = Time.now.in_time_zone(timezone)
8787

8888
temporal_context = {
@@ -126,21 +126,29 @@ def generate_prompt(
126126
)
127127
context = attach_user_context(context, user, force_default_locale: force_default_locale)
128128

129-
helper_response = +""
129+
bad_json = false
130+
json_summary_schema_key = bot.persona.response_format&.first.to_h
131+
132+
schema_key = json_summary_schema_key["key"]&.to_sym
133+
schema_type = json_summary_schema_key["type"]
134+
135+
if schema_type == "array"
136+
helper_response = []
137+
else
138+
helper_response = +""
139+
end
130140

131141
buffer_blk =
132142
Proc.new do |partial, _, type|
133-
json_summary_schema_key = bot.persona.response_format&.first.to_h
134-
helper_response = [] if json_summary_schema_key["type"] == "array"
135-
if type == :structured_output
136-
helper_chunk = partial.read_buffered_property(json_summary_schema_key["key"]&.to_sym)
143+
if type == :structured_output && schema_type
144+
helper_chunk = partial.read_buffered_property(schema_key)
137145
if !helper_chunk.nil? && !helper_chunk.empty?
138-
if json_summary_schema_key["type"] != "array"
139-
helper_response = helper_chunk
140-
else
146+
if schema_type == "string" || schema_type == "array"
141147
helper_response << helper_chunk
148+
else
149+
helper_response = helper_chunk
142150
end
143-
block.call(helper_chunk) if block
151+
block.call(helper_chunk) if block && !bad_json
144152
end
145153
elsif type.blank?
146154
# Assume response is a regular completion.
@@ -255,7 +263,7 @@ def generate_image_caption(upload, user)
255263
Proc.new do |partial, _, type|
256264
if type == :structured_output
257265
structured_output = partial
258-
json_summary_schema_key = bot.persona.response_format&.first.to_h
266+
bot.persona.response_format&.first.to_h
259267
end
260268
end
261269

@@ -287,6 +295,11 @@ def build_bot(helper_mode, user)
287295
end
288296

289297
def find_ai_helper_model(helper_mode, persona_klass)
298+
if helper_mode == IMAGE_CAPTION && @image_caption_llm.is_a?(LlmModel)
299+
return @image_caption_llm
300+
end
301+
302+
return @helper_llm if helper_mode != IMAGE_CAPTION && @helper_llm.is_a?(LlmModel)
290303
self.class.find_ai_helper_model(helper_mode, persona_klass)
291304
end
292305

@@ -299,9 +312,9 @@ def self.find_ai_helper_model(helper_mode, persona_klass)
299312

300313
if !model_id
301314
if helper_mode == IMAGE_CAPTION
302-
model_id = @helper_llm || SiteSetting.ai_helper_image_caption_model&.split(":")&.last
315+
model_id = SiteSetting.ai_helper_image_caption_model&.split(":")&.last
303316
else
304-
model_id = @image_caption_llm || SiteSetting.ai_helper_model&.split(":")&.last
317+
model_id = SiteSetting.ai_helper_model&.split(":")&.last
305318
end
306319
end
307320

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/endpoints/gemini.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ def normalize_model_params(model_params)
3333

3434
model_params[:topP] = model_params.delete(:top_p) if model_params[:top_p]
3535

36-
# temperature already supported
36+
model_params.delete(:temperature) if llm_model.lookup_custom_param("disable_temperature")
37+
model_params.delete(:topP) if llm_model.lookup_custom_param("disable_top_p")
3738

3839
model_params
3940
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: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +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+
44+
def broken?
45+
@partial_json_tracker.broken?
46+
end
47+
2948
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.
3349
if @partial_json_tracker.broken?
34-
unread_chunk = @raw_response[@raw_cursor..]
35-
@raw_cursor = @raw_response.length
36-
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
3762
end
3863

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

lib/configuration/feature.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ def ai_helper_features
103103
DiscourseAi::Configuration::Module::AI_HELPER_ID,
104104
DiscourseAi::Configuration::Module::AI_HELPER,
105105
),
106+
new(
107+
"translator",
108+
"ai_helper_translator_persona",
109+
DiscourseAi::Configuration::Module::AI_HELPER_ID,
110+
DiscourseAi::Configuration::Module::AI_HELPER,
111+
),
106112
new(
107113
"custom_prompt",
108114
"ai_helper_custom_prompt_persona",

0 commit comments

Comments
 (0)