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

Commit 01893bb

Browse files
authored
FEATURE: Add persona-based replies and whisper support to LLM triage (#1170)
This PR enhances the LLM triage automation with several important improvements: - Add ability to use AI personas for automated replies instead of canned replies - Add support for whisper responses - Refactor LLM persona reply functionality into a reusable method - Add new settings to configure response behavior in automations - Improve error handling and logging - Fix handling of personal messages in the triage flow - Add comprehensive test coverage for new features - Make personas configurable with more flexible requirements This allows for more dynamic and context-aware responses in automated workflows, with better control over visibility and attribution.
1 parent 453bb48 commit 01893bb

File tree

8 files changed

+192
-45
lines changed

8 files changed

+192
-45
lines changed

config/locales/client.en.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ en:
146146
include_personal_messages:
147147
label: "Include personal messages"
148148
description: "Also scan and triage personal messages"
149+
whisper:
150+
label: "Reply as Whisper"
151+
description: "Whether the AI's response should be a whisper"
152+
reply_persona:
153+
label: "Reply Persona"
154+
description: "AI Persona to use for replies (must have default LLM), will be prioritized over canned reply"
149155
model:
150156
label: "Model"
151157
description: "Language model used for triage"

discourse_automation/llm_triage.rb

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,68 @@
99

1010
triggerables %i[post_created_edited]
1111

12-
field :system_prompt, component: :message, required: false
13-
field :search_for_text, component: :text, required: true
14-
field :max_post_tokens, component: :text
15-
field :stop_sequences, component: :text_list, required: false
12+
# TODO move to triggerables
13+
field :include_personal_messages, component: :boolean
14+
15+
# Inputs
1616
field :model,
1717
component: :choices,
1818
required: true,
1919
extra: {
2020
content: DiscourseAi::Automation.available_models,
2121
}
22+
field :system_prompt, component: :message, required: false
23+
field :search_for_text, component: :text, required: true
24+
field :max_post_tokens, component: :text
25+
field :stop_sequences, component: :text_list, required: false
26+
field :temperature, component: :text
27+
28+
# Actions
2229
field :category, component: :category
2330
field :tags, component: :tags
2431
field :hide_topic, component: :boolean
2532
field :flag_post, component: :boolean
26-
field :include_personal_messages, component: :boolean
27-
field :temperature, component: :text
2833
field :flag_type,
2934
component: :choices,
3035
required: false,
3136
extra: {
3237
content: DiscourseAi::Automation.flag_types,
3338
},
3439
default: "review"
35-
field :canned_reply, component: :message
3640
field :canned_reply_user, component: :user
41+
field :canned_reply, component: :message
42+
field :reply_persona,
43+
component: :choices,
44+
extra: {
45+
content:
46+
DiscourseAi::Automation.available_persona_choices(
47+
require_user: false,
48+
require_default_llm: true,
49+
),
50+
}
51+
field :whisper, component: :boolean
3752

3853
script do |context, fields|
3954
post = context["post"]
55+
next if post&.user&.bot?
56+
57+
if post.topic.private_message?
58+
include_personal_messages = fields.dig("include_personal_messages", "value")
59+
next if !include_personal_messages
60+
end
61+
4062
canned_reply = fields.dig("canned_reply", "value")
4163
canned_reply_user = fields.dig("canned_reply_user", "value")
64+
reply_persona_id = fields.dig("reply_persona", "value")
65+
whisper = fields.dig("whisper", "value")
4266

4367
# nothing to do if we already replied
4468
next if post.user.username == canned_reply_user
4569
next if post.raw.strip == canned_reply.to_s.strip
4670

47-
system_prompt = fields["system_prompt"]["value"]
48-
search_for_text = fields["search_for_text"]["value"]
49-
model = fields["model"]["value"]
71+
system_prompt = fields.dig("system_prompt", "value")
72+
search_for_text = fields.dig("search_for_text", "value")
73+
model = fields.dig("model", "value")
5074

5175
category_id = fields.dig("category", "value")
5276
tags = fields.dig("tags", "value")
@@ -65,11 +89,6 @@
6589

6690
stop_sequences = fields.dig("stop_sequences", "value")
6791

68-
if post.topic.private_message?
69-
include_personal_messages = fields.dig("include_personal_messages", "value")
70-
next if !include_personal_messages
71-
end
72-
7392
begin
7493
RateLimiter.new(
7594
Discourse.system_user,
@@ -94,16 +113,22 @@
94113
tags: tags,
95114
canned_reply: canned_reply,
96115
canned_reply_user: canned_reply_user,
116+
reply_persona_id: reply_persona_id,
117+
whisper: whisper,
97118
hide_topic: hide_topic,
98119
flag_post: flag_post,
99120
flag_type: flag_type.to_s.to_sym,
100121
max_post_tokens: max_post_tokens,
101122
stop_sequences: stop_sequences,
102123
automation: self.automation,
103124
temperature: temperature,
125+
action: context["action"],
104126
)
105127
rescue => e
106-
Discourse.warn_exception(e, message: "llm_triage: skipped triage on post #{post.id}")
128+
Discourse.warn_exception(
129+
e,
130+
message: "llm_triage: skipped triage on post #{post.id} #{post.url}",
131+
)
107132
end
108133
end
109134
end

lib/ai_bot/playground.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,20 @@ def self.schedule_reply(post)
162162
end
163163
end
164164

165+
def self.reply_to_post(post:, user: nil, persona_id: nil, whisper: nil)
166+
ai_persona = AiPersona.find_by(id: persona_id)
167+
raise Discourse::InvalidParameters.new(:persona_id) if !ai_persona
168+
persona_class = ai_persona.class_instance
169+
persona = persona_class.new
170+
171+
bot_user = user || ai_persona.user
172+
raise Discourse::InvalidParameters.new(:user) if bot_user.nil?
173+
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona)
174+
playground = DiscourseAi::AiBot::Playground.new(bot)
175+
176+
playground.reply_to(post, whisper: whisper, context_style: :topic)
177+
end
178+
165179
def initialize(bot)
166180
@bot = bot
167181
end

lib/automation.rb

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,17 @@ def self.available_models
3838
values
3939
end
4040

41-
def self.available_persona_choices
42-
AiPersona
43-
.joins(:user)
44-
.where.not(user_id: nil)
45-
.where.not(default_llm: nil)
46-
.map do |persona|
47-
{
48-
id: persona.id,
49-
translated_name: persona.name,
50-
description: "#{persona.name} (#{persona.user.username})",
51-
}
52-
end
41+
def self.available_persona_choices(require_user: true, require_default_llm: true)
42+
relation = AiPersona.joins(:user)
43+
relation = relation.where.not(user_id: nil) if require_user
44+
relation = relation.where.not(default_llm: nil) if require_default_llm
45+
relation.map do |persona|
46+
{
47+
id: persona.id,
48+
translated_name: persona.name,
49+
description: "#{persona.name} (#{persona.user.username})",
50+
}
51+
end
5352
end
5453
end
5554
end

lib/automation/llm_persona_triage.rb

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,16 @@ module DiscourseAi
33
module Automation
44
module LlmPersonaTriage
55
def self.handle(post:, persona_id:, whisper: false, automation: nil)
6-
ai_persona = AiPersona.find_by(id: persona_id)
7-
return if ai_persona.nil?
8-
9-
persona_class = ai_persona.class_instance
10-
persona = persona_class.new
11-
12-
bot_user = ai_persona.user
13-
return if bot_user.nil?
14-
15-
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona)
16-
playground = DiscourseAi::AiBot::Playground.new(bot)
17-
18-
playground.reply_to(post, whisper: whisper, context_style: :topic)
6+
DiscourseAi::AiBot::Playground.reply_to_post(
7+
post: post,
8+
persona_id: persona_id,
9+
whisper: whisper,
10+
)
1911
rescue => e
20-
Rails.logger.error("Error in LlmPersonaTriage: #{e.message}\n#{e.backtrace.join("\n")}")
12+
Discourse.warn_exception(
13+
e,
14+
message: "Error responding to: #{post&.url} in LlmPersonaTriage.handle",
15+
)
2116
raise e if Rails.env.test?
2217
nil
2318
end

lib/automation/llm_triage.rb

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,21 @@ def self.handle(
1818
automation: nil,
1919
max_post_tokens: nil,
2020
stop_sequences: nil,
21-
temperature: nil
21+
temperature: nil,
22+
whisper: nil,
23+
reply_persona_id: nil,
24+
action: nil
2225
)
2326
if category_id.blank? && tags.blank? && canned_reply.blank? && hide_topic.blank? &&
24-
flag_post.blank?
27+
flag_post.blank? && reply_persona_id.blank?
2528
raise ArgumentError, "llm_triage: no action specified!"
2629
end
2730

31+
if action == :edit && category_id.blank? && tags.blank? && flag_post.blank? &&
32+
hide_topic.blank?
33+
return
34+
end
35+
2836
llm = DiscourseAi::Completions::Llm.proxy(model)
2937

3038
s_prompt = system_prompt.to_s.sub("%%POST%%", "") # Backwards-compat. We no longer sub this.
@@ -54,14 +62,32 @@ def self.handle(
5462

5563
if result.present? && result.downcase.include?(search_for_text.downcase)
5664
user = User.find_by_username(canned_reply_user) if canned_reply_user.present?
65+
original_user = user
5766
user = user || Discourse.system_user
58-
if canned_reply.present?
67+
if reply_persona_id.present? && action != :edit
68+
begin
69+
DiscourseAi::AiBot::Playground.reply_to_post(
70+
post: post,
71+
persona_id: reply_persona_id,
72+
whisper: whisper,
73+
user: original_user,
74+
)
75+
rescue StandardError => e
76+
Discourse.warn_exception(
77+
e,
78+
message: "Error responding to: #{post&.url} in LlmTriage.handle",
79+
)
80+
raise e if Rails.env.test?
81+
end
82+
elsif canned_reply.present? && action != :edit
83+
post_type = whisper ? Post.types[:whisper] : Post.types[:regular]
5984
PostCreator.create!(
6085
user,
6186
topic_id: post.topic_id,
6287
raw: canned_reply,
6388
reply_to_post_number: post.post_number,
6489
skip_validations: true,
90+
post_type: post_type,
6591
)
6692
end
6793

lib/completions/endpoints/aws_bedrock.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ def processor
219219
@processor ||=
220220
DiscourseAi::Completions::AnthropicMessageProcessor.new(
221221
streaming_mode: @streaming_mode,
222+
partial_tool_calls: partial_tool_calls,
223+
output_thinking: output_thinking,
222224
)
223225
else
224226
@processor ||=

spec/lib/discourse_automation/llm_triage_spec.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,84 @@ def add_automation_field(name, value, type: "text")
123123
last_post = post.topic.reload.posts.order(:post_number).last
124124
expect(last_post.raw).to eq post.raw
125125
end
126+
127+
it "can respond using an AI persona when configured" do
128+
bot_user = Fabricate(:user, username: "ai_assistant")
129+
ai_persona =
130+
Fabricate(
131+
:ai_persona,
132+
name: "Help Bot",
133+
description: "AI assistant for forum help",
134+
system_prompt: "You are a helpful forum assistant",
135+
default_llm: llm_model,
136+
user_id: bot_user.id,
137+
)
138+
139+
# Configure the automation to use the persona instead of canned reply
140+
add_automation_field("canned_reply", nil, type: "message") # Clear canned reply
141+
add_automation_field("reply_persona", ai_persona.id, type: "choices")
142+
add_automation_field("whisper", true, type: "boolean")
143+
144+
post = Fabricate(:post, raw: "I need help with a problem")
145+
146+
ai_response = "I'll help you with your problem!"
147+
148+
# Set up the test to provide both the triage and the persona responses
149+
DiscourseAi::Completions::Llm.with_prepared_responses(["bad", ai_response]) do
150+
automation.running_in_background!
151+
automation.trigger!({ "post" => post })
152+
end
153+
154+
# Verify the response was created
155+
topic = post.topic.reload
156+
last_post = topic.posts.order(:post_number).last
157+
158+
# Verify the AI persona's user created the post
159+
expect(last_post.user_id).to eq(bot_user.id)
160+
161+
# Verify the content matches the AI response
162+
expect(last_post.raw).to eq(ai_response)
163+
164+
# Verify it's a whisper post (since we set whisper: true)
165+
expect(last_post.post_type).to eq(Post.types[:whisper])
166+
end
167+
168+
it "does not create replies when the action is edit" do
169+
# Set up bot user and persona
170+
bot_user = Fabricate(:user, username: "helper_bot")
171+
ai_persona =
172+
Fabricate(
173+
:ai_persona,
174+
name: "Edit Helper",
175+
description: "AI assistant for editing",
176+
system_prompt: "You help with editing",
177+
default_llm: llm_model,
178+
user_id: bot_user.id,
179+
)
180+
181+
# Configure the automation with both reply methods
182+
add_automation_field("canned_reply", "This is a canned reply", type: "message")
183+
add_automation_field("reply_persona", ai_persona.id, type: "choices")
184+
185+
# Create a post and capture its topic
186+
post = Fabricate(:post, raw: "This needs to be evaluated")
187+
topic = post.topic
188+
189+
# Get initial post count
190+
initial_post_count = topic.posts.count
191+
192+
# Run automation with action: :edit and a matching response
193+
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
194+
automation.running_in_background!
195+
automation.trigger!({ "post" => post, "action" => :edit })
196+
end
197+
198+
# Topic should be updated (if configured) but no new posts
199+
topic.reload
200+
expect(topic.posts.count).to eq(initial_post_count)
201+
202+
# Verify no replies were created
203+
last_post = topic.posts.order(:post_number).last
204+
expect(last_post.id).to eq(post.id)
205+
end
126206
end

0 commit comments

Comments
 (0)