Skip to content

Commit

Permalink
FEATURE: triage using persona
Browse files Browse the repository at this point in the history
This is far simpler to configure than using custom tools
  • Loading branch information
SamSaffron committed Mar 5, 2025
1 parent 568685c commit 94658cd
Show file tree
Hide file tree
Showing 11 changed files with 402 additions and 5 deletions.
9 changes: 9 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ en:
label: "Tool"
description: "Tool to use for triage (tool must have no parameters defined)"


llm_persona_triage:
fields:
persona:
label: "Persona"
description: "AI Persona to use for triage (must have default LLM and User set)"
whisper:
label: "Reply as Whisper"
description: "Whether the persona's response should be a whisper"
llm_triage:
fields:
system_prompt:
Expand Down
3 changes: 3 additions & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ en:
llm_tool_triage:
title: Triage posts using AI Tool
description: "Triage posts using custom logic in an AI tool"
llm_persona_triage:
title: Triage posts using AI Persona
description: "Respond to posts using a specific AI persona"
llm_triage:
title: Triage posts using AI
description: "Triage posts using a large language model"
Expand Down
55 changes: 55 additions & 0 deletions discourse_automation/llm_persona_triage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

if defined?(DiscourseAutomation)
DiscourseAutomation::Scriptable.add("llm_persona_triage") do
version 1
run_in_background

triggerables %i[post_created_edited]

field :persona,
component: :choices,
required: true,
extra: {
content: DiscourseAi::Automation.available_persona_choices,
}
field :whisper, component: :boolean

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

persona_id = fields["persona"]["value"]
whisper = fields["whisper"]["value"]

begin
RateLimiter.new(
Discourse.system_user,
"llm_persona_triage_#{post.id}",
SiteSetting.ai_automation_max_triage_per_post_per_minute,
1.minute,
).performed!

RateLimiter.new(
Discourse.system_user,
"llm_persona_triage",
SiteSetting.ai_automation_max_triage_per_minute,
1.minute,
).performed!

DiscourseAi::Automation::LlmPersonaTriage.handle(
post: post,
persona_id: persona_id,
whisper: whisper,
automation: self.automation,
)
rescue => e
Discourse.warn_exception(
e,
message: "llm_persona_triage: skipped triage on post #{post.id}",
)
raise e if Rails.env.tests?
end
end
end
end
1 change: 1 addition & 0 deletions discourse_automation/llm_tool_triage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
script do |context, fields|
tool_id = fields["tool"]["value"]
post = context["post"]
return if post&.user&.bot?

begin
RateLimiter.new(
Expand Down
9 changes: 5 additions & 4 deletions lib/ai_bot/playground.rb
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ def update_playground_with(post)
schedule_bot_reply(post) if can_attach?(post)
end

def conversation_context(post)
def conversation_context(post, style: nil)
# Pay attention to the `post_number <= ?` here.
# We want to inject the last post as context because they are translated differently.

Expand Down Expand Up @@ -205,6 +205,7 @@ def conversation_context(post)
)

builder = DiscourseAi::Completions::PromptMessagesBuilder.new
builder.topic = post.topic

context.reverse_each do |raw, username, custom_prompt, upload_ids|
custom_prompt_translation =
Expand Down Expand Up @@ -245,7 +246,7 @@ def conversation_context(post)
end
end

builder.to_a
builder.to_a(style: style || (post.topic.private_message? ? :bot : :topic))
end

def title_playground(post, user)
Expand Down Expand Up @@ -418,7 +419,7 @@ def get_context(participants:, conversation_context:, user:, skip_tool_details:
result
end

def reply_to(post, custom_instructions: nil, whisper: nil, &blk)
def reply_to(post, custom_instructions: nil, whisper: nil, context_style: nil, &blk)
# this is a multithreading issue
# post custom prompt is needed and it may not
# be properly loaded, ensure it is loaded
Expand All @@ -439,7 +440,7 @@ def reply_to(post, custom_instructions: nil, whisper: nil, &blk)
context =
get_context(
participants: post.topic.allowed_users.map(&:username).join(", "),
conversation_context: conversation_context(post),
conversation_context: conversation_context(post, style: context_style),
user: post.user,
)
context[:post_id] = post.id
Expand Down
14 changes: 14 additions & 0 deletions lib/automation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,19 @@ def self.available_models

values
end

def self.available_persona_choices
AiPersona
.joins(:user)
.where.not(user_id: nil)
.where.not(default_llm: nil)
.map do |persona|
{
id: persona.id,
translated_name: persona.name,
description: "#{persona.name} (#{persona.user.username})",
}
end
end
end
end
26 changes: 26 additions & 0 deletions lib/automation/llm_persona_triage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true
module DiscourseAi
module Automation
module LlmPersonaTriage
def self.handle(post:, persona_id:, whisper: false, automation: nil)
ai_persona = AiPersona.find_by(id: persona_id)
return if ai_persona.nil?

persona_class = ai_persona.class_instance
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
Rails.logger.error("Error in LlmPersonaTriage: #{e.message}\n#{e.backtrace.join("\n")}")
raise e if Rails.env.test?
nil
end
end
end
end
57 changes: 56 additions & 1 deletion lib/completions/prompt_messages_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ module DiscourseAi
module Completions
class PromptMessagesBuilder
MAX_CHAT_UPLOADS = 5
MAX_TOPIC_UPLOADS = 5
attr_reader :chat_context_posts
attr_reader :chat_context_post_upload_ids
attr_accessor :topic

def initialize
@raw_messages = []
Expand Down Expand Up @@ -41,6 +43,7 @@ def set_chat_context_posts(post_ids, guardian, include_uploads:)

def to_a(limit: nil, style: nil)
return chat_array(limit: limit) if style == :chat
return topic_array if style == :topic
result = []

# this will create a "valid" messages array
Expand Down Expand Up @@ -127,6 +130,58 @@ def push(type:, content:, name: nil, upload_ids: nil, id: nil, thinking: nil)

private

def topic_array
raw_messages = @raw_messages.dup

user_content = +"You are operating in a Discourse forum.\n\n"

if @topic
if @topic.private_message?
user_content << "Private message info.\n"
else
user_content << "Topic information:\n"
end

user_content << "- URL: #{@topic.url}\n"
user_content << "- Title: #{@topic.title}\n"
if SiteSetting.tagging_enabled
tags = @topic.tags.pluck(:name)
tags -= DiscourseTagging.hidden_tag_names if tags.present?
user_content << "- Tags: #{tags.join(", ")}\n" if tags.present?
end
if !@topic.private_message?
user_content << "- Category: #{@topic.category.name}\n" if @topic.category
end
user_content << "- Number of replies: #{@topic.posts_count - 1}\n\n"
end

last_user_message = raw_messages.pop

upload_ids = []
if raw_messages.present?
user_content << "Here is the conversation so far:\n"
raw_messages.each do |message|
user_content << "#{message[:name] || "User"}: #{message[:content]}\n"
upload_ids.concat(message[:upload_ids]) if message[:upload_ids].present?
end
end

if last_user_message
user_content << "You are responding to #{last_user_message[:name] || "User"} who just said:\n #{last_user_message[:content]}"
if last_user_message[:upload_ids].present?
upload_ids.concat(last_user__message[:upload_ids])
end
end

user_message = { type: :user, content: user_content }

if upload_ids.present?
user_message[:upload_ids] = upload_ids[-MAX_TOPIC_UPLOADS..-1] || upload_ids
end

[user_message]
end

def chat_array(limit:)
if @raw_messages.length > 1
buffer =
Expand Down Expand Up @@ -155,7 +210,7 @@ def chat_array(limit:)
end

last_message = @raw_messages[-1]
buffer << "#{last_message[:name] || "User"} said #{last_message[:content]} "
buffer << "#{last_message[:name] || "User"}: #{last_message[:content]} "

message = { type: :user, content: buffer }
upload_ids.concat(last_message[:upload_ids]) if last_message[:upload_ids].present?
Expand Down
1 change: 1 addition & 0 deletions plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def self.public_asset_path(name)
require_relative "discourse_automation/llm_triage"
require_relative "discourse_automation/llm_report"
require_relative "discourse_automation/llm_tool_triage"
require_relative "discourse_automation/llm_persona_triage"

add_admin_route("discourse_ai.title", "discourse-ai", { use_new_show_route: true })

Expand Down
24 changes: 24 additions & 0 deletions spec/lib/completions/prompt_messages_builder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,28 @@
expected = [{ type: :user, content: "Alice: Echo 123 please\nJames: OK" }]
expect(builder.to_a).to eq(expected)
end

it "should format messages for topic style" do
# Create a topic with tags
topic = Fabricate(:topic, title: "This is an Example Topic")

# Add tags to the topic
topic.tags = [Fabricate(:tag, name: "tag1"), Fabricate(:tag, name: "tag2")]
topic.save!

builder.topic = topic
builder.push(type: :user, content: "I like frogs", name: "Bob")
builder.push(type: :user, content: "How do I solve this?", name: "Alice")

result = builder.to_a(style: :topic)

content = result[0][:content]

expect(content).to include("This is an Example Topic")
expect(content).to include("tag1")
expect(content).to include("tag2")
expect(content).to include("Bob: I like frogs")
expect(content).to include("Alice")
expect(content).to include("How do I solve this")
end
end
Loading

0 comments on commit 94658cd

Please sign in to comment.