Skip to content

FEATURE: Use a persona when running the AI triage automation script #34010

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions plugins/discourse-ai/config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ en:
description: "In silent mode persona will receive the content but will not post anything on the forum - useful when performing triage using tools"
llm_triage:
fields:
system_prompt:
label: "System Prompt"
description: "The prompt that will be used to triage, be sure for it to reply with a single word you can use to trigger the action"
triage_persona:
label: "Persona"
description: "Persona used to triage, be sure for it to reply with a single word you can use to trigger the action"
max_post_tokens:
label: "Max Post Tokens"
description: "The maximum number of tokens to scan using LLM triage"
Expand Down Expand Up @@ -174,12 +174,6 @@ en:
reply_persona:
label: "Reply Persona"
description: "AI Persona to use for replies (must have default LLM), will be prioritized over canned reply"
model:
label: "Model"
description: "Language model used for triage"
temperature:
label: "Temperature"
description: "Temperature to use for the LLM. Increase to increase randomness (leave empty to use model default)"
max_output_tokens:
label: "Max output tokens"
description: "When specified, sets an upper bound to the maximum number of tokens the model can generate. Respects LLM's max output tokens limit"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

class SeedPersonasFromTriageScripts < ActiveRecord::Migration[8.0]
def up
script_fields = DB.query <<~SQL
SELECT fields.id, fields.name, (fields.metadata->>'value') AS value, automations.name AS automation_name, automations.id AS automation_id
FROM discourse_automation_fields fields
INNER JOIN discourse_automation_automations automations ON automations.id = fields.automation_id
WHERE fields.name IN ('model', 'system_prompt', 'temperature')
AND automations.script = 'llm_triage'
SQL

return if script_fields.empty?

script_fields =
script_fields.reduce({}) do |acc, field|
id = field.automation_id
acc[id] = { "automation_id" => id, "name" => field.automation_name } if acc[id].nil?

acc[field.automation_id].merge!(field.name => field.value)

acc
end

automation_to_persona_ids =
script_fields.transform_values do |field|
if field["system_prompt"].blank?
nil
else
name =
(
if field["name"].present?
"#{field["name"]} triage automation"
else
"Unnamed triage automation script ID #{field["automation_id"]}"
end
)
temp = field["temperature"] || "NULL"
row =
"'#{name}', 'Seeded Persona for an LLM Triage script', FALSE, '#{field["system_prompt"]}', #{temp}, #{field["model"]}, NOW(), NOW()"

DB.query_single(<<~SQL)&.first
INSERT INTO ai_personas (name, description, enabled, system_prompt, temperature, default_llm_id, created_at, updated_at)
VALUES (#{row})
RETURNING id
SQL
end
end

new_fields =
automation_to_persona_ids
.map do |k, v|
if v.blank?
nil
else
"(#{k}, 'triage_persona', json_build_object('value', #{v}), 'choices', 'script', NOW(), NOW())"
end
end
.compact

DB.exec <<~SQL
INSERT INTO discourse_automation_fields (automation_id, name, metadata, component, target, created_at, updated_at)
VALUES #{new_fields.join(",")}
SQL
end

def down
raise ActiveRecord::IrreversibleMigration
end
end
24 changes: 9 additions & 15 deletions plugins/discourse-ai/discourse_automation/llm_triage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@
field :include_personal_messages, component: :boolean

# Inputs
field :model,
field :triage_persona,
component: :choices,
required: true,
extra: {
content: DiscourseAi::Automation.available_models,
content:
DiscourseAi::Automation.available_persona_choices(
require_user: false,
require_default_llm: true,
),
}
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
field :max_output_tokens, component: :text

# Actions
Expand Down Expand Up @@ -60,6 +62,8 @@
next if !include_personal_messages
end

triage_persona_id = fields.dig("triage_persona", "value")

canned_reply = fields.dig("canned_reply", "value")
canned_reply_user = fields.dig("canned_reply_user", "value")
reply_persona_id = fields.dig("reply_persona", "value")
Expand All @@ -69,22 +73,14 @@
next if post.user.username == canned_reply_user
next if post.raw.strip == canned_reply.to_s.strip

system_prompt = fields.dig("system_prompt", "value")
search_for_text = fields.dig("search_for_text", "value")
model = fields.dig("model", "value")

category_id = fields.dig("category", "value")
tags = fields.dig("tags", "value")
hide_topic = fields.dig("hide_topic", "value")
flag_post = fields.dig("flag_post", "value")
flag_type = fields.dig("flag_type", "value")
max_post_tokens = fields.dig("max_post_tokens", "value").to_i
temperature = fields.dig("temperature", "value")
if temperature == "" || temperature.nil?
temperature = nil
else
temperature = temperature.to_f
end

max_output_tokens = fields.dig("max_output_tokens", "value").to_i
max_output_tokens = nil if max_output_tokens <= 0
Expand All @@ -110,9 +106,8 @@

DiscourseAi::Automation::LlmTriage.handle(
post: post,
model: model,
triage_persona_id: triage_persona_id,
search_for_text: search_for_text,
system_prompt: system_prompt,
category_id: category_id,
tags: tags,
canned_reply: canned_reply,
Expand All @@ -125,7 +120,6 @@
max_post_tokens: max_post_tokens,
stop_sequences: stop_sequences,
automation: self.automation,
temperature: temperature,
max_output_tokens: max_output_tokens,
action: context["action"],
)
Expand Down
2 changes: 1 addition & 1 deletion plugins/discourse-ai/lib/ai_helper/assistant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ def find_ai_helper_model(helper_mode, persona_klass)

# Priorities are:
# 1. Persona's default LLM
# 2. SiteSetting.ai_default_llm_id (or newest LLM if not set)
# 2. SiteSetting.ai_default_llm_model (or newest LLM if not set)
def self.find_ai_helper_model(helper_mode, persona_klass)
model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model

Expand Down
64 changes: 38 additions & 26 deletions plugins/discourse-ai/lib/automation/llm_triage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ module Automation
module LlmTriage
def self.handle(
post:,
model:,
triage_persona_id:,
search_for_text:,
system_prompt:,
category_id: nil,
tags: nil,
canned_reply: nil,
Expand All @@ -18,7 +17,6 @@ def self.handle(
automation: nil,
max_post_tokens: nil,
stop_sequences: nil,
temperature: nil,
whisper: nil,
reply_persona_id: nil,
max_output_tokens: nil,
Expand All @@ -34,42 +32,55 @@ def self.handle(
return
end

llm = DiscourseAi::Completions::Llm.proxy(model)
triage_persona = AiPersona.find(triage_persona_id)
model_id = triage_persona.default_llm_id || SiteSetting.ai_default_llm_model
return if model_id.blank?
model = LlmModel.find(model_id)

s_prompt = system_prompt.to_s.sub("%%POST%%", "") # Backwards-compat. We no longer sub this.
prompt = DiscourseAi::Completions::Prompt.new(s_prompt)
bot =
DiscourseAi::Personas::Bot.as(
Discourse.system_user,
persona: triage_persona.class_instance.new,
model: model,
)

content = "title: #{post.topic.title}\n#{post.raw}"
input = "title: #{post.topic.title}\n#{post.raw}"

content =
llm.tokenizer.truncate(
content,
input =
model.tokenizer_class.truncate(
input,
max_post_tokens,
strict: SiteSetting.ai_strict_token_counting,
) if max_post_tokens.present?

if post.upload_ids.present?
content = [content]
content.concat(post.upload_ids.map { |upload_id| { upload_id: upload_id } })
input = [input]
input.concat(post.upload_ids.map { |upload_id| { upload_id: upload_id } })
end

prompt.push(type: :user, content: content)
bot_ctx =
DiscourseAi::Personas::BotContext.new(
user: Discourse.system_user,
skip_tool_details: true,
feature_name: "llm_triage",
messages: [{ type: :user, content: input }],
)

result = nil

result =
llm.generate(
prompt,
max_tokens: max_output_tokens,
temperature: temperature,
user: Discourse.system_user,
stop_sequences: stop_sequences,
feature_name: "llm_triage",
feature_context: {
automation_id: automation&.id,
automation_name: automation&.name,
},
)&.strip
llm_args = {
max_tokens: max_output_tokens,
stop_sequences: stop_sequences,
feature_context: {
automation_id: automation&.id,
automation_name: automation&.name,
},
}

result = +""
bot.reply(bot_ctx, llm_args: llm_args) do |partial, _, type|
result << partial if type.blank?
end

if result.present? && result.downcase.include?(search_for_text.downcase)
user = User.find_by_username(canned_reply_user) if canned_reply_user.present?
Expand All @@ -92,6 +103,7 @@ def self.handle(
end
elsif canned_reply.present? && action != :edit
post_type = whisper ? Post.types[:whisper] : Post.types[:regular]

PostCreator.create!(
user,
topic_id: post.topic_id,
Expand Down
2 changes: 1 addition & 1 deletion plugins/discourse-ai/lib/embeddings/semantic_search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def hypothetical_post_from(search_term)

# Priorities are:
# 1. Persona's default LLM
# 2. SiteSetting.ai_default_llm_id (or newest LLM if not set)
# 2. SiteSetting.ai_default_llm_model (or newest LLM if not set)
def find_ai_hyde_model(persona_klass)
model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model

Expand Down
2 changes: 1 addition & 1 deletion plugins/discourse-ai/lib/summarization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def chat_channel_summary(channel, time_window_in_hours)

# Priorities are:
# 1. Persona's default LLM
# 2. SiteSetting.ai_default_llm_id (or newest LLM if not set)
# 2. SiteSetting.ai_default_llm_model (or newest LLM if not set)
def find_summarization_model(persona_klass)
model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
let(:automation) { Fabricate(:automation, script: "llm_triage", enabled: true) }

fab!(:llm_model)
fab!(:ai_persona)

def add_automation_field(name, value, type: "text")
automation.fields.create!(
Expand All @@ -27,9 +28,11 @@ def add_automation_field(name, value, type: "text")
enable_current_plugin

SiteSetting.tagging_enabled = true
add_automation_field("system_prompt", "hello %%POST%%")

ai_persona.update!(default_llm: llm_model)

add_automation_field("triage_persona", ai_persona.id)
add_automation_field("search_for_text", "bad")
add_automation_field("model", llm_model.id)
add_automation_field("category", category.id, type: "category")
add_automation_field("tags", %w[aaa bbb], type: "tags")
add_automation_field("hide_topic", true, type: "boolean")
Expand All @@ -42,21 +45,17 @@ def add_automation_field(name, value, type: "text")
it "can trigger via automation" do
post = Fabricate(:post, raw: "hello " * 5000)

body = {
model: "gpt-3.5-turbo-0301",
usage: {
prompt_tokens: 337,
completion_tokens: 162,
total_tokens: 499,
},
choices: [
{ message: { role: "assistant", content: "bad" }, finish_reason: "stop", index: 0 },
],
}.to_json
chunks = <<~RESPONSE
data: {"id":"chatcmpl-B2VwlY6KzSDtHvg8pN1VAfRhhLFgn","object":"chat.completion.chunk","created":1739939159,"model": "gpt-3.5-turbo-0301","service_tier":"default","system_fingerprint":"fp_ef58bd3122","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"finish_reason":null}],"usage":null}

data: {"id":"chatcmpl-B2VwlY6KzSDtHvg8pN1VAfRhhLFgn","object":"chat.completion.chunk","created":1739939159,"model": "gpt-3.5-turbo-0301","service_tier":"default","system_fingerprint":"fp_ef58bd3122","choices":[{"index":0,"delta":{"content":"bad"},"finish_reason":null}],"usage":null}

data: [DONE]
RESPONSE

WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return(
status: 200,
body: body,
body: chunks,
)

automation.running_in_background!
Expand Down Expand Up @@ -96,7 +95,7 @@ def add_automation_field(name, value, type: "text")
# PM
reply_user.update!(admin: true)
add_automation_field("include_personal_messages", true, type: :boolean)
add_automation_field("temperature", "0.2")
ai_persona.update!(temperature: 0.2)
add_automation_field("max_output_tokens", "700")
post = Fabricate(:post, topic: personal_message)

Expand Down
Loading
Loading