Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
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
2 changes: 1 addition & 1 deletion app/models/ai_api_audit_log.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ module Provider
# post_id :integer
# feature_name :string(255)
# language_model :string(255)
#
# feature_context :jsonb
3 changes: 3 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ en:
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"
max_post_tokens:
label: "Max Post Tokens"
description: "The maximum number of tokens to scan using LLM triage"
search_for_text:
label: "Search for text"
description: "If the following text appears in the llm reply, apply this actions"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true
#
class AddFeatureContextToAiApiLog < ActiveRecord::Migration[7.1]
def change
add_column :ai_api_audit_logs, :feature_context, :jsonb
end
end
1 change: 1 addition & 0 deletions discourse_automation/llm_report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ module DiscourseAutomation::LlmReport
temperature: temperature,
top_p: top_p,
suppress_notifications: suppress_notifications,
automation: self.automation,
)
rescue => e
Discourse.warn_exception e, message: "Error running LLM report!"
Expand Down
5 changes: 5 additions & 0 deletions discourse_automation/llm_triage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

field :system_prompt, component: :message, required: false
field :search_for_text, component: :text, required: true
field :max_post_tokens, component: :text
field :model,
component: :choices,
required: true,
Expand Down Expand Up @@ -49,6 +50,9 @@
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

max_post_tokens = nil if max_post_tokens <= 0

begin
RateLimiter.new(
Expand Down Expand Up @@ -77,6 +81,7 @@
hide_topic: hide_topic,
flag_post: flag_post,
flag_type: flag_type.to_s.to_sym,
max_post_tokens: max_post_tokens,
automation: self.automation,
)
rescue => e
Expand Down
18 changes: 14 additions & 4 deletions lib/automation/llm_triage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ def self.handle(
hide_topic: nil,
flag_post: nil,
flag_type: nil,
automation: nil
automation: nil,
max_post_tokens: nil
)
if category_id.blank? && tags.blank? && canned_reply.blank? && hide_topic.blank? &&
flag_post.blank?
raise ArgumentError, "llm_triage: no action specified!"
end

llm = DiscourseAi::Completions::Llm.proxy(model)

s_prompt = system_prompt.to_s.sub("%%POST%%", "") # Backwards-compat. We no longer sub this.
prompt = DiscourseAi::Completions::Prompt.new(s_prompt)
prompt.push(type: :user, content: "title: #{post.topic.title}\n#{post.raw}")

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

llm = DiscourseAi::Completions::Llm.proxy(model)
content = llm.tokenizer.truncate(content, max_post_tokens) if max_post_tokens.present?

prompt.push(type: :user, content: content)

result = nil

result =
llm.generate(
Expand All @@ -37,6 +43,10 @@ def self.handle(
max_tokens: 700, # ~500 words
user: Discourse.system_user,
feature_name: "llm_triage",
feature_context: {
automation_id: automation&.id,
automation_name: automation&.name,
},
)&.strip

if result.present? && result.downcase.include?(search_for_text.downcase)
Expand Down
8 changes: 7 additions & 1 deletion lib/automation/report_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ def initialize(
exclude_tags: nil,
top_p: 0.1,
temperature: 0.2,
suppress_notifications: false
suppress_notifications: false,
automation: nil
)
@sender = User.find_by(username: sender_username)
@receivers = User.where(username: receivers)
Expand Down Expand Up @@ -90,6 +91,7 @@ def initialize(
if !@topic_id && [email protected]? && !@email_receivers.present?
raise ArgumentError, "Must specify topic_id or receivers"
end
@automation = automation
end

def run!
Expand Down Expand Up @@ -153,6 +155,10 @@ def run!
top_p: @top_p,
user: Discourse.system_user,
feature_name: "ai_report",
feature_context: {
automation_id: @automation&.id,
automation_name: @automation&.name,
},
Copy link
Contributor

@lis2 lis2 Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor but maybe feature_context should be an empty hash and only merge automation_id and automation_name when @automation exists?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am so on the fence here ... automation being nil is kind of a usage bug so the odds of this happening is just tiny.

) do |response|
print response if Rails.env.development? && @debug_mode
result << response
Expand Down
10 changes: 9 additions & 1 deletion lib/completions/endpoints/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ def xml_tags_to_strip(dialect)
[]
end

def perform_completion!(dialect, user, model_params = {}, feature_name: nil, &blk)
def perform_completion!(
dialect,
user,
model_params = {},
feature_name: nil,
feature_context: nil,
&blk
)
allow_tools = dialect.prompt.has_tools?
model_params = normalize_model_params(model_params)
orig_blk = blk
Expand Down Expand Up @@ -111,6 +118,7 @@ def perform_completion!(dialect, user, model_params = {}, feature_name: nil, &bl
post_id: dialect.prompt.post_id,
feature_name: feature_name,
language_model: llm_model.name,
feature_context: feature_context.present? ? feature_context.as_json : nil,
)

if !@streaming_mode
Expand Down
8 changes: 7 additions & 1 deletion lib/completions/endpoints/canned_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ def prompt_messages
dialect.prompt.messages
end

def perform_completion!(dialect, _user, _model_params, feature_name: nil)
def perform_completion!(
dialect,
_user,
_model_params,
feature_name: nil,
feature_context: nil
)
@dialect = dialect
response = responses[completions]
if response.nil?
Expand Down
8 changes: 7 additions & 1 deletion lib/completions/endpoints/fake.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ def self.last_call=(params)
@last_call = params
end

def perform_completion!(dialect, user, model_params = {}, feature_name: nil)
def perform_completion!(
dialect,
user,
model_params = {},
feature_name: nil,
feature_context: nil
)
self.class.last_call = { dialect: dialect, user: user, model_params: model_params }

content = self.class.fake_content
Expand Down
9 changes: 8 additions & 1 deletion lib/completions/endpoints/open_ai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ def provider_id
AiApiAuditLog::Provider::OpenAI
end

def perform_completion!(dialect, user, model_params = {}, feature_name: nil, &blk)
def perform_completion!(
dialect,
user,
model_params = {},
feature_name: nil,
feature_context: nil,
&blk
)
if dialect.respond_to?(:is_gpt_o?) && dialect.is_gpt_o? && block_given?
# we need to disable streaming and simulate it
blk.call "", lambda { |*| }
Expand Down
2 changes: 2 additions & 0 deletions lib/completions/llm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ def generate(
stop_sequences: nil,
user:,
feature_name: nil,
feature_context: nil,
&partial_read_blk
)
self.class.record_prompt(prompt)
Expand Down Expand Up @@ -224,6 +225,7 @@ def generate(
user,
model_params,
feature_name: feature_name,
feature_context: feature_context,
&partial_read_blk
)
end
Expand Down
34 changes: 34 additions & 0 deletions spec/lib/completions/llm_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,40 @@
expect(log.topic_id).to eq(123)
expect(log.post_id).to eq(1)
end

it "can track feature_name and feature_context" do
body = {
model: "gpt-3.5-turbo-0301",
usage: {
prompt_tokens: 337,
completion_tokens: 162,
total_tokens: 499,
},
choices: [
{ message: { role: "assistant", content: "test" }, finish_reason: "stop", index: 0 },
],
}.to_json

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

result =
described_class.proxy("custom:#{model.id}").generate(
"Hello",
user: user,
feature_name: "llm_triage",
feature_context: {
foo: "bar",
},
)

expect(result).to eq("test")
log = AiApiAuditLog.order("id desc").first
expect(log.feature_name).to eq("llm_triage")
expect(log.feature_context).to eq({ "foo" => "bar" })
end
end

describe "#generate with fake model" do
Expand Down
38 changes: 33 additions & 5 deletions spec/lib/discourse_automation/llm_triage_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,31 @@ def add_automation_field(name, value, type: "text")
add_automation_field("flag_post", true, type: "boolean")
add_automation_field("canned_reply", "Yo this is a reply")
add_automation_field("canned_reply_user", reply_user.username, type: "user")
add_automation_field("max_post_tokens", 100)
end

it "can trigger via automation" do
post = Fabricate(:post)
post = Fabricate(:post, raw: "hello " * 5000)

DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
automation.running_in_background!
automation.trigger!({ "post" => post })
end
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

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

automation.running_in_background!
automation.trigger!({ "post" => post })

topic = post.topic.reload
expect(topic.category_id).to eq(category.id)
Expand All @@ -49,6 +65,18 @@ def add_automation_field(name, value, type: "text")
reply = topic.posts.order(:post_number).last
expect(reply.raw).to eq("Yo this is a reply")
expect(reply.user.id).to eq(reply_user.id)

ai_log = AiApiAuditLog.order("id desc").first
expect(ai_log.feature_name).to eq("llm_triage")
expect(ai_log.feature_context).to eq(
{ "automation_id" => automation.id, "automation_name" => automation.name },
)

count = ai_log.raw_request_payload.scan("hello").size
# we could use the exact count here but it can get fragile
# as we change tokenizers, this will give us reasonable confidence
expect(count).to be <= (100)
expect(count).to be > (50)
end

it "does not reply to the canned_reply_user" do
Expand Down