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

Commit 9211b21

Browse files
SamSaffronnattsw
andauthored
FEATURE: silent triage using ai persona (#1193)
This allows for a new mode in persona triage where nothing is posted on topics. This allows people to perform all triage actions using tools Additionally introduces new APIs to create chat messages from tools which can be useful in certain moderation scenarios Co-authored-by: Natalie Tay <[email protected]> * remove TODO code --------- Co-authored-by: Natalie Tay <[email protected]>
1 parent 24e6aa5 commit 9211b21

File tree

8 files changed

+229
-5
lines changed

8 files changed

+229
-5
lines changed

config/eval-llms.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ llms:
4949
max_prompt_tokens: 200000
5050
vision_enabled: true
5151

52+
claude-3.7-sonnet-thinking:
53+
display_name: Claude 3.7 Sonnet
54+
name: claude-3-7-sonnet-latest
55+
tokenizer: DiscourseAi::Tokenizer::AnthropicTokenizer
56+
api_key_env: ANTHROPIC_API_KEY
57+
provider: anthropic
58+
url: https://api.anthropic.com/v1/messages
59+
max_prompt_tokens: 200000
60+
vision_enabled: true
61+
provider_params:
62+
disable_top_p: true
63+
disable_temperature: true
64+
enable_reasoning: true
65+
reasoning_tokens: 1024
66+
5267
gemini-2.0-flash:
5368
display_name: Gemini 2.0 Flash
5469
name: gemini-2-0-flash

config/locales/client.en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ en:
107107
whisper:
108108
label: "Reply as Whisper"
109109
description: "Whether the persona's response should be a whisper"
110+
silent_mode:
111+
label: "Silent Mode"
112+
description: "In silent mode persona will receive the content but will not post anything on the forum - useful when performing triage using tools"
110113
llm_triage:
111114
fields:
112115
system_prompt:

discourse_automation/llm_persona_triage.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
content: DiscourseAi::Automation.available_persona_choices,
1515
}
1616
field :whisper, component: :boolean
17+
field :silent_mode, component: :boolean
1718

1819
script do |context, fields|
1920
post = context["post"]
2021
next if post&.user&.bot?
2122

22-
persona_id = fields["persona"]["value"]
23-
whisper = fields["whisper"]["value"]
23+
persona_id = fields.dig("persona", "value")
24+
whisper = !!fields.dig("whisper", "value")
25+
silent_mode = !!fields.dig("silent_mode", "value")
2426

2527
begin
2628
RateLimiter.new(
@@ -42,6 +44,7 @@
4244
persona_id: persona_id,
4345
whisper: whisper,
4446
automation: self.automation,
47+
silent_mode: silent_mode,
4548
)
4649
rescue => e
4750
Discourse.warn_exception(

lib/ai_bot/playground.rb

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ def self.reply_to_post(
188188
whisper: nil,
189189
add_user_to_pm: false,
190190
stream_reply: false,
191-
auto_set_title: false
191+
auto_set_title: false,
192+
silent_mode: false
192193
)
193194
ai_persona = AiPersona.find_by(id: persona_id)
194195
raise Discourse::InvalidParameters.new(:persona_id) if !ai_persona
@@ -207,7 +208,15 @@ def self.reply_to_post(
207208
add_user_to_pm: add_user_to_pm,
208209
stream_reply: stream_reply,
209210
auto_set_title: auto_set_title,
211+
silent_mode: silent_mode,
210212
)
213+
rescue => e
214+
if Rails.env.test?
215+
p e
216+
puts e.backtrace[0..10]
217+
else
218+
raise e
219+
end
211220
end
212221

213222
def initialize(bot)
@@ -475,13 +484,19 @@ def reply_to(
475484
add_user_to_pm: true,
476485
stream_reply: nil,
477486
auto_set_title: true,
487+
silent_mode: false,
478488
&blk
479489
)
480490
# this is a multithreading issue
481491
# post custom prompt is needed and it may not
482492
# be properly loaded, ensure it is loaded
483493
PostCustomPrompt.none
484494

495+
if silent_mode
496+
auto_set_title = false
497+
stream_reply = false
498+
end
499+
485500
reply = +""
486501
post_streamer = nil
487502

@@ -590,7 +605,7 @@ def reply_to(
590605
end
591606
end
592607

593-
return if reply.blank?
608+
return if reply.blank? || silent_mode
594609

595610
if stream_reply
596611
post_streamer.finish

lib/ai_bot/tool_runner.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ def framework_script
8989
},
9090
};
9191
},
92+
createChatMessage: function(params) {
93+
const result = _discourse_create_chat_message(params);
94+
if (result.error) {
95+
throw new Error(result.error);
96+
}
97+
return result;
98+
},
9299
};
93100
94101
const context = #{JSON.generate(@context)};
@@ -345,6 +352,55 @@ def attach_discourse(mini_racer_context)
345352
end,
346353
)
347354

355+
mini_racer_context.attach(
356+
"_discourse_create_chat_message",
357+
->(params) do
358+
in_attached_function do
359+
params = params.symbolize_keys
360+
channel_name = params[:channel_name]
361+
username = params[:username]
362+
message = params[:message]
363+
364+
# Validate parameters
365+
return { error: "Missing required parameter: channel_name" } if channel_name.blank?
366+
return { error: "Missing required parameter: username" } if username.blank?
367+
return { error: "Missing required parameter: message" } if message.blank?
368+
369+
# Find the user
370+
user = User.find_by(username: username)
371+
return { error: "User not found: #{username}" } if user.nil?
372+
373+
# Find the channel
374+
channel = Chat::Channel.find_by(name: channel_name)
375+
if channel.nil?
376+
# Try finding by slug if not found by name
377+
channel = Chat::Channel.find_by(slug: channel_name.parameterize)
378+
end
379+
return { error: "Channel not found: #{channel_name}" } if channel.nil?
380+
381+
begin
382+
guardian = Guardian.new(user)
383+
message =
384+
ChatSDK::Message.create(
385+
raw: message,
386+
channel_id: channel.id,
387+
guardian: guardian,
388+
enforce_membership: !channel.direct_message_channel?,
389+
)
390+
391+
{
392+
success: true,
393+
message_id: message.id,
394+
message: message.message,
395+
created_at: message.created_at.iso8601,
396+
}
397+
rescue => e
398+
{ error: "Failed to create chat message: #{e.message}" }
399+
end
400+
end
401+
end,
402+
)
403+
348404
mini_racer_context.attach(
349405
"_discourse_search",
350406
->(params) do

lib/automation/llm_persona_triage.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
module DiscourseAi
33
module Automation
44
module LlmPersonaTriage
5-
def self.handle(post:, persona_id:, whisper: false, automation: nil)
5+
def self.handle(post:, persona_id:, whisper: false, silent_mode: false, automation: nil)
66
DiscourseAi::AiBot::Playground.reply_to_post(
77
post: post,
88
persona_id: persona_id,
99
whisper: whisper,
10+
silent_mode: silent_mode,
1011
)
1112
rescue => e
1213
Discourse.warn_exception(

lib/completions/prompt.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,49 @@ def initialize(
4141
@tool_choice = tool_choice
4242
end
4343

44+
# this new api tries to create symmetry between responses and prompts
45+
# this means anything we get back from the model via endpoint can be easily appended
46+
def push_model_response(response)
47+
response = [response] if !response.is_a? Array
48+
49+
thinking, thinking_signature, redacted_thinking_signature = nil
50+
51+
response.each do |message|
52+
if message.is_a?(Thinking)
53+
# we can safely skip partials here
54+
next if message.partial?
55+
if message.redacted
56+
redacted_thinking_signature = message.signature
57+
else
58+
thinking = message.message
59+
thinking_signature = message.signature
60+
end
61+
elsif message.is_a?(ToolCall)
62+
next if message.partial?
63+
# this is a bit surprising about the API
64+
# needing to add arguments is not ideal
65+
push(
66+
type: :tool_call,
67+
content: { arguments: message.parameters }.to_json,
68+
id: message.id,
69+
name: message.name,
70+
)
71+
elsif message.is_a?(String)
72+
push(type: :model, content: message)
73+
else
74+
raise ArgumentError, "response must be an array of strings, ToolCalls, or Thinkings"
75+
end
76+
end
77+
78+
# anthropic rules are that we attach thinking to last for the response
79+
# it is odd, I wonder if long term we just keep thinking as a separate object
80+
if thinking || redacted_thinking_signature
81+
messages.last[:thinking] = thinking
82+
messages.last[:thinking_signature] = thinking_signature
83+
messages.last[:redacted_thinking_signature] = redacted_thinking_signature
84+
end
85+
end
86+
4487
def push(
4588
type:,
4689
content:,

spec/lib/discourse_automation/llm_persona_triage_spec.rb

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,92 @@ def add_automation_field(name, value, type: "text")
239239
# should not inject persona into allowed users
240240
expect(topic.topic_allowed_users.pluck(:user_id).sort).to eq(original_user_ids.sort)
241241
end
242+
243+
describe "LLM Persona Triage with Chat Message Creation" do
244+
fab!(:user)
245+
fab!(:bot_user) { Fabricate(:user) }
246+
fab!(:chat_channel) { Fabricate(:category_channel) }
247+
248+
fab!(:custom_tool) do
249+
AiTool.create!(
250+
name: "Chat Notifier",
251+
tool_name: "chat_notifier",
252+
description: "Creates a chat notification in a channel",
253+
parameters: [
254+
{ name: "channel_id", type: "integer", description: "Chat channel ID" },
255+
{ name: "message", type: "string", description: "Message to post" },
256+
],
257+
script: <<~JS,
258+
function invoke(params) {
259+
// Create a chat message using the Chat API
260+
const result = discourse.createChatMessage({
261+
channel_name: '#{chat_channel.name}',
262+
username: '#{user.username}',
263+
message: params.message
264+
});
265+
266+
chain.setCustomRaw("We are done, stopping chaing");
267+
268+
return {
269+
success: true,
270+
message_id: result.message_id,
271+
url: result.url,
272+
message: params.message
273+
};
274+
}
275+
JS
276+
summary: "Notify in chat channel",
277+
created_by: Discourse.system_user,
278+
)
279+
end
280+
281+
before do
282+
SiteSetting.chat_enabled = true
283+
284+
ai_persona.update!(tools: ["custom-#{custom_tool.id}"])
285+
286+
# Set up automation fields
287+
automation.fields.create!(
288+
component: "choices",
289+
name: "persona",
290+
metadata: {
291+
value: ai_persona.id,
292+
},
293+
target: "script",
294+
)
295+
296+
automation.fields.create!(
297+
component: "boolean",
298+
name: "silent_mode",
299+
metadata: {
300+
value: true,
301+
},
302+
target: "script",
303+
)
304+
end
305+
306+
it "can silently analyze a post and create a chat notification" do
307+
post = Fabricate(:post, raw: "Please help with my billing issue")
308+
309+
# Tool response from LLM
310+
tool_call =
311+
DiscourseAi::Completions::ToolCall.new(
312+
name: "chat_notifier",
313+
parameters: {
314+
"message" => "Hello world!",
315+
},
316+
id: "tool_call_1",
317+
)
318+
319+
DiscourseAi::Completions::Llm.with_prepared_responses([tool_call]) do
320+
automation.running_in_background!
321+
automation.trigger!({ "post" => post })
322+
end
323+
324+
expect(post.topic.reload.posts.count).to eq(1)
325+
326+
expect(chat_channel.chat_messages.count).to eq(1)
327+
expect(chat_channel.chat_messages.last.message).to eq("Hello world!")
328+
end
329+
end
242330
end

0 commit comments

Comments
 (0)