mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-25 09:02:23 +00:00
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.
This commit is contained in:
parent
453bb4821f
commit
01893bb6ed
@ -146,6 +146,12 @@ en:
|
|||||||
include_personal_messages:
|
include_personal_messages:
|
||||||
label: "Include personal messages"
|
label: "Include personal messages"
|
||||||
description: "Also scan and triage personal messages"
|
description: "Also scan and triage personal messages"
|
||||||
|
whisper:
|
||||||
|
label: "Reply as Whisper"
|
||||||
|
description: "Whether the AI's response should be a whisper"
|
||||||
|
reply_persona:
|
||||||
|
label: "Reply Persona"
|
||||||
|
description: "AI Persona to use for replies (must have default LLM), will be prioritized over canned reply"
|
||||||
model:
|
model:
|
||||||
label: "Model"
|
label: "Model"
|
||||||
description: "Language model used for triage"
|
description: "Language model used for triage"
|
||||||
|
@ -9,22 +9,27 @@ if defined?(DiscourseAutomation)
|
|||||||
|
|
||||||
triggerables %i[post_created_edited]
|
triggerables %i[post_created_edited]
|
||||||
|
|
||||||
field :system_prompt, component: :message, required: false
|
# TODO move to triggerables
|
||||||
field :search_for_text, component: :text, required: true
|
field :include_personal_messages, component: :boolean
|
||||||
field :max_post_tokens, component: :text
|
|
||||||
field :stop_sequences, component: :text_list, required: false
|
# Inputs
|
||||||
field :model,
|
field :model,
|
||||||
component: :choices,
|
component: :choices,
|
||||||
required: true,
|
required: true,
|
||||||
extra: {
|
extra: {
|
||||||
content: DiscourseAi::Automation.available_models,
|
content: DiscourseAi::Automation.available_models,
|
||||||
}
|
}
|
||||||
|
field :system_prompt, component: :message, required: false
|
||||||
|
field :search_for_text, component: :text, required: true
|
||||||
|
field :max_post_tokens, component: :text
|
||||||
|
field :stop_sequences, component: :text_list, required: false
|
||||||
|
field :temperature, component: :text
|
||||||
|
|
||||||
|
# Actions
|
||||||
field :category, component: :category
|
field :category, component: :category
|
||||||
field :tags, component: :tags
|
field :tags, component: :tags
|
||||||
field :hide_topic, component: :boolean
|
field :hide_topic, component: :boolean
|
||||||
field :flag_post, component: :boolean
|
field :flag_post, component: :boolean
|
||||||
field :include_personal_messages, component: :boolean
|
|
||||||
field :temperature, component: :text
|
|
||||||
field :flag_type,
|
field :flag_type,
|
||||||
component: :choices,
|
component: :choices,
|
||||||
required: false,
|
required: false,
|
||||||
@ -32,21 +37,40 @@ if defined?(DiscourseAutomation)
|
|||||||
content: DiscourseAi::Automation.flag_types,
|
content: DiscourseAi::Automation.flag_types,
|
||||||
},
|
},
|
||||||
default: "review"
|
default: "review"
|
||||||
field :canned_reply, component: :message
|
|
||||||
field :canned_reply_user, component: :user
|
field :canned_reply_user, component: :user
|
||||||
|
field :canned_reply, component: :message
|
||||||
|
field :reply_persona,
|
||||||
|
component: :choices,
|
||||||
|
extra: {
|
||||||
|
content:
|
||||||
|
DiscourseAi::Automation.available_persona_choices(
|
||||||
|
require_user: false,
|
||||||
|
require_default_llm: true,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
field :whisper, component: :boolean
|
||||||
|
|
||||||
script do |context, fields|
|
script do |context, fields|
|
||||||
post = context["post"]
|
post = context["post"]
|
||||||
|
next if post&.user&.bot?
|
||||||
|
|
||||||
|
if post.topic.private_message?
|
||||||
|
include_personal_messages = fields.dig("include_personal_messages", "value")
|
||||||
|
next if !include_personal_messages
|
||||||
|
end
|
||||||
|
|
||||||
canned_reply = fields.dig("canned_reply", "value")
|
canned_reply = fields.dig("canned_reply", "value")
|
||||||
canned_reply_user = fields.dig("canned_reply_user", "value")
|
canned_reply_user = fields.dig("canned_reply_user", "value")
|
||||||
|
reply_persona_id = fields.dig("reply_persona", "value")
|
||||||
|
whisper = fields.dig("whisper", "value")
|
||||||
|
|
||||||
# nothing to do if we already replied
|
# nothing to do if we already replied
|
||||||
next if post.user.username == canned_reply_user
|
next if post.user.username == canned_reply_user
|
||||||
next if post.raw.strip == canned_reply.to_s.strip
|
next if post.raw.strip == canned_reply.to_s.strip
|
||||||
|
|
||||||
system_prompt = fields["system_prompt"]["value"]
|
system_prompt = fields.dig("system_prompt", "value")
|
||||||
search_for_text = fields["search_for_text"]["value"]
|
search_for_text = fields.dig("search_for_text", "value")
|
||||||
model = fields["model"]["value"]
|
model = fields.dig("model", "value")
|
||||||
|
|
||||||
category_id = fields.dig("category", "value")
|
category_id = fields.dig("category", "value")
|
||||||
tags = fields.dig("tags", "value")
|
tags = fields.dig("tags", "value")
|
||||||
@ -65,11 +89,6 @@ if defined?(DiscourseAutomation)
|
|||||||
|
|
||||||
stop_sequences = fields.dig("stop_sequences", "value")
|
stop_sequences = fields.dig("stop_sequences", "value")
|
||||||
|
|
||||||
if post.topic.private_message?
|
|
||||||
include_personal_messages = fields.dig("include_personal_messages", "value")
|
|
||||||
next if !include_personal_messages
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
RateLimiter.new(
|
RateLimiter.new(
|
||||||
Discourse.system_user,
|
Discourse.system_user,
|
||||||
@ -94,6 +113,8 @@ if defined?(DiscourseAutomation)
|
|||||||
tags: tags,
|
tags: tags,
|
||||||
canned_reply: canned_reply,
|
canned_reply: canned_reply,
|
||||||
canned_reply_user: canned_reply_user,
|
canned_reply_user: canned_reply_user,
|
||||||
|
reply_persona_id: reply_persona_id,
|
||||||
|
whisper: whisper,
|
||||||
hide_topic: hide_topic,
|
hide_topic: hide_topic,
|
||||||
flag_post: flag_post,
|
flag_post: flag_post,
|
||||||
flag_type: flag_type.to_s.to_sym,
|
flag_type: flag_type.to_s.to_sym,
|
||||||
@ -101,9 +122,13 @@ if defined?(DiscourseAutomation)
|
|||||||
stop_sequences: stop_sequences,
|
stop_sequences: stop_sequences,
|
||||||
automation: self.automation,
|
automation: self.automation,
|
||||||
temperature: temperature,
|
temperature: temperature,
|
||||||
|
action: context["action"],
|
||||||
)
|
)
|
||||||
rescue => e
|
rescue => e
|
||||||
Discourse.warn_exception(e, message: "llm_triage: skipped triage on post #{post.id}")
|
Discourse.warn_exception(
|
||||||
|
e,
|
||||||
|
message: "llm_triage: skipped triage on post #{post.id} #{post.url}",
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -162,6 +162,20 @@ module DiscourseAi
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.reply_to_post(post:, user: nil, persona_id: nil, whisper: nil)
|
||||||
|
ai_persona = AiPersona.find_by(id: persona_id)
|
||||||
|
raise Discourse::InvalidParameters.new(:persona_id) if !ai_persona
|
||||||
|
persona_class = ai_persona.class_instance
|
||||||
|
persona = persona_class.new
|
||||||
|
|
||||||
|
bot_user = user || ai_persona.user
|
||||||
|
raise Discourse::InvalidParameters.new(:user) if bot_user.nil?
|
||||||
|
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona)
|
||||||
|
playground = DiscourseAi::AiBot::Playground.new(bot)
|
||||||
|
|
||||||
|
playground.reply_to(post, whisper: whisper, context_style: :topic)
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(bot)
|
def initialize(bot)
|
||||||
@bot = bot
|
@bot = bot
|
||||||
end
|
end
|
||||||
|
@ -38,12 +38,11 @@ module DiscourseAi
|
|||||||
values
|
values
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.available_persona_choices
|
def self.available_persona_choices(require_user: true, require_default_llm: true)
|
||||||
AiPersona
|
relation = AiPersona.joins(:user)
|
||||||
.joins(:user)
|
relation = relation.where.not(user_id: nil) if require_user
|
||||||
.where.not(user_id: nil)
|
relation = relation.where.not(default_llm: nil) if require_default_llm
|
||||||
.where.not(default_llm: nil)
|
relation.map do |persona|
|
||||||
.map do |persona|
|
|
||||||
{
|
{
|
||||||
id: persona.id,
|
id: persona.id,
|
||||||
translated_name: persona.name,
|
translated_name: persona.name,
|
||||||
|
@ -3,21 +3,16 @@ module DiscourseAi
|
|||||||
module Automation
|
module Automation
|
||||||
module LlmPersonaTriage
|
module LlmPersonaTriage
|
||||||
def self.handle(post:, persona_id:, whisper: false, automation: nil)
|
def self.handle(post:, persona_id:, whisper: false, automation: nil)
|
||||||
ai_persona = AiPersona.find_by(id: persona_id)
|
DiscourseAi::AiBot::Playground.reply_to_post(
|
||||||
return if ai_persona.nil?
|
post: post,
|
||||||
|
persona_id: persona_id,
|
||||||
persona_class = ai_persona.class_instance
|
whisper: whisper,
|
||||||
persona = persona_class.new
|
)
|
||||||
|
|
||||||
bot_user = ai_persona.user
|
|
||||||
return if bot_user.nil?
|
|
||||||
|
|
||||||
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona)
|
|
||||||
playground = DiscourseAi::AiBot::Playground.new(bot)
|
|
||||||
|
|
||||||
playground.reply_to(post, whisper: whisper, context_style: :topic)
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error("Error in LlmPersonaTriage: #{e.message}\n#{e.backtrace.join("\n")}")
|
Discourse.warn_exception(
|
||||||
|
e,
|
||||||
|
message: "Error responding to: #{post&.url} in LlmPersonaTriage.handle",
|
||||||
|
)
|
||||||
raise e if Rails.env.test?
|
raise e if Rails.env.test?
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
@ -18,13 +18,21 @@ module DiscourseAi
|
|||||||
automation: nil,
|
automation: nil,
|
||||||
max_post_tokens: nil,
|
max_post_tokens: nil,
|
||||||
stop_sequences: nil,
|
stop_sequences: nil,
|
||||||
temperature: nil
|
temperature: nil,
|
||||||
|
whisper: nil,
|
||||||
|
reply_persona_id: nil,
|
||||||
|
action: nil
|
||||||
)
|
)
|
||||||
if category_id.blank? && tags.blank? && canned_reply.blank? && hide_topic.blank? &&
|
if category_id.blank? && tags.blank? && canned_reply.blank? && hide_topic.blank? &&
|
||||||
flag_post.blank?
|
flag_post.blank? && reply_persona_id.blank?
|
||||||
raise ArgumentError, "llm_triage: no action specified!"
|
raise ArgumentError, "llm_triage: no action specified!"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if action == :edit && category_id.blank? && tags.blank? && flag_post.blank? &&
|
||||||
|
hide_topic.blank?
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
llm = DiscourseAi::Completions::Llm.proxy(model)
|
llm = DiscourseAi::Completions::Llm.proxy(model)
|
||||||
|
|
||||||
s_prompt = system_prompt.to_s.sub("%%POST%%", "") # Backwards-compat. We no longer sub this.
|
s_prompt = system_prompt.to_s.sub("%%POST%%", "") # Backwards-compat. We no longer sub this.
|
||||||
@ -54,14 +62,32 @@ module DiscourseAi
|
|||||||
|
|
||||||
if result.present? && result.downcase.include?(search_for_text.downcase)
|
if result.present? && result.downcase.include?(search_for_text.downcase)
|
||||||
user = User.find_by_username(canned_reply_user) if canned_reply_user.present?
|
user = User.find_by_username(canned_reply_user) if canned_reply_user.present?
|
||||||
|
original_user = user
|
||||||
user = user || Discourse.system_user
|
user = user || Discourse.system_user
|
||||||
if canned_reply.present?
|
if reply_persona_id.present? && action != :edit
|
||||||
|
begin
|
||||||
|
DiscourseAi::AiBot::Playground.reply_to_post(
|
||||||
|
post: post,
|
||||||
|
persona_id: reply_persona_id,
|
||||||
|
whisper: whisper,
|
||||||
|
user: original_user,
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Discourse.warn_exception(
|
||||||
|
e,
|
||||||
|
message: "Error responding to: #{post&.url} in LlmTriage.handle",
|
||||||
|
)
|
||||||
|
raise e if Rails.env.test?
|
||||||
|
end
|
||||||
|
elsif canned_reply.present? && action != :edit
|
||||||
|
post_type = whisper ? Post.types[:whisper] : Post.types[:regular]
|
||||||
PostCreator.create!(
|
PostCreator.create!(
|
||||||
user,
|
user,
|
||||||
topic_id: post.topic_id,
|
topic_id: post.topic_id,
|
||||||
raw: canned_reply,
|
raw: canned_reply,
|
||||||
reply_to_post_number: post.post_number,
|
reply_to_post_number: post.post_number,
|
||||||
skip_validations: true,
|
skip_validations: true,
|
||||||
|
post_type: post_type,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -219,6 +219,8 @@ module DiscourseAi
|
|||||||
@processor ||=
|
@processor ||=
|
||||||
DiscourseAi::Completions::AnthropicMessageProcessor.new(
|
DiscourseAi::Completions::AnthropicMessageProcessor.new(
|
||||||
streaming_mode: @streaming_mode,
|
streaming_mode: @streaming_mode,
|
||||||
|
partial_tool_calls: partial_tool_calls,
|
||||||
|
output_thinking: output_thinking,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@processor ||=
|
@processor ||=
|
||||||
|
@ -123,4 +123,84 @@ describe DiscourseAi::Automation::LlmTriage do
|
|||||||
last_post = post.topic.reload.posts.order(:post_number).last
|
last_post = post.topic.reload.posts.order(:post_number).last
|
||||||
expect(last_post.raw).to eq post.raw
|
expect(last_post.raw).to eq post.raw
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "can respond using an AI persona when configured" do
|
||||||
|
bot_user = Fabricate(:user, username: "ai_assistant")
|
||||||
|
ai_persona =
|
||||||
|
Fabricate(
|
||||||
|
:ai_persona,
|
||||||
|
name: "Help Bot",
|
||||||
|
description: "AI assistant for forum help",
|
||||||
|
system_prompt: "You are a helpful forum assistant",
|
||||||
|
default_llm: llm_model,
|
||||||
|
user_id: bot_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure the automation to use the persona instead of canned reply
|
||||||
|
add_automation_field("canned_reply", nil, type: "message") # Clear canned reply
|
||||||
|
add_automation_field("reply_persona", ai_persona.id, type: "choices")
|
||||||
|
add_automation_field("whisper", true, type: "boolean")
|
||||||
|
|
||||||
|
post = Fabricate(:post, raw: "I need help with a problem")
|
||||||
|
|
||||||
|
ai_response = "I'll help you with your problem!"
|
||||||
|
|
||||||
|
# Set up the test to provide both the triage and the persona responses
|
||||||
|
DiscourseAi::Completions::Llm.with_prepared_responses(["bad", ai_response]) do
|
||||||
|
automation.running_in_background!
|
||||||
|
automation.trigger!({ "post" => post })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verify the response was created
|
||||||
|
topic = post.topic.reload
|
||||||
|
last_post = topic.posts.order(:post_number).last
|
||||||
|
|
||||||
|
# Verify the AI persona's user created the post
|
||||||
|
expect(last_post.user_id).to eq(bot_user.id)
|
||||||
|
|
||||||
|
# Verify the content matches the AI response
|
||||||
|
expect(last_post.raw).to eq(ai_response)
|
||||||
|
|
||||||
|
# Verify it's a whisper post (since we set whisper: true)
|
||||||
|
expect(last_post.post_type).to eq(Post.types[:whisper])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not create replies when the action is edit" do
|
||||||
|
# Set up bot user and persona
|
||||||
|
bot_user = Fabricate(:user, username: "helper_bot")
|
||||||
|
ai_persona =
|
||||||
|
Fabricate(
|
||||||
|
:ai_persona,
|
||||||
|
name: "Edit Helper",
|
||||||
|
description: "AI assistant for editing",
|
||||||
|
system_prompt: "You help with editing",
|
||||||
|
default_llm: llm_model,
|
||||||
|
user_id: bot_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure the automation with both reply methods
|
||||||
|
add_automation_field("canned_reply", "This is a canned reply", type: "message")
|
||||||
|
add_automation_field("reply_persona", ai_persona.id, type: "choices")
|
||||||
|
|
||||||
|
# Create a post and capture its topic
|
||||||
|
post = Fabricate(:post, raw: "This needs to be evaluated")
|
||||||
|
topic = post.topic
|
||||||
|
|
||||||
|
# Get initial post count
|
||||||
|
initial_post_count = topic.posts.count
|
||||||
|
|
||||||
|
# Run automation with action: :edit and a matching response
|
||||||
|
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
|
||||||
|
automation.running_in_background!
|
||||||
|
automation.trigger!({ "post" => post, "action" => :edit })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Topic should be updated (if configured) but no new posts
|
||||||
|
topic.reload
|
||||||
|
expect(topic.posts.count).to eq(initial_post_count)
|
||||||
|
|
||||||
|
# Verify no replies were created
|
||||||
|
last_post = topic.posts.order(:post_number).last
|
||||||
|
expect(last_post.id).to eq(post.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
Loading…
x
Reference in New Issue
Block a user