Skip to content

Commit

Permalink
getting basic responder going
Browse files Browse the repository at this point in the history
  • Loading branch information
SamSaffron committed Mar 5, 2025
1 parent ce29cad commit 6e35293
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 19 deletions.
19 changes: 0 additions & 19 deletions discourse_automation/llm_tool_triage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,6 @@

triggerables %i[post_created_edited]

field :model,
component: :choices,
required: true,
extra: {
content: DiscourseAi::Automation.available_models,
}

field :tool,
component: :choices,
required: true,
Expand All @@ -22,17 +15,8 @@
}

script do |context, fields|
model = fields["model"]["value"]
tool_id = fields["tool"]["value"]

category_id = fields.dig("category", "value")
tags = fields.dig("tags", "value")

if post.topic.private_message?
include_personal_messages = fields.dig("include_personal_messages", "value")
next if !include_personal_messages
end

begin
RateLimiter.new(
Discourse.system_user,
Expand All @@ -50,10 +34,7 @@

DiscourseAi::Automation::LlmToolTriage.handle(
post: post,
model: model,
tool_id: tool_id,
category_id: category_id,
tags: tags,
automation: self.automation,
)
rescue => e
Expand Down
126 changes: 126 additions & 0 deletions lib/ai_bot/tool_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def mini_racer_context
attach_index(ctx)
attach_upload(ctx)
attach_chain(ctx)
attach_discourse(ctx)
ctx.eval(framework_script)
ctx
end
Expand Down Expand Up @@ -71,6 +72,24 @@ def framework_script
setCustomRaw: _chain_set_custom_raw,
};
const discourse = {
getPost: _discourse_get_post,
getUser: _discourse_get_user,
getPersona: function(name) {
return {
respondTo: function(params) {
result = _discourse_respond_to_persona(name, params);
if (result.error) {
throw new Error(result.error);
}
return result;
},
};
},
};
const context = #{JSON.generate(@context)};
function details() { return ""; };
JS
end
Expand Down Expand Up @@ -239,6 +258,86 @@ def attach_chain(mini_racer_context)
mini_racer_context.attach("_chain_set_custom_raw", ->(raw) { self.custom_raw = raw })
end

def attach_discourse(mini_racer_context)
mini_racer_context.attach(
"_discourse_get_post",
->(post_id) do
in_attached_function do
post = Post.find_by(id: post_id)
return nil if post.nil?
guardian = Guardian.new(Discourse.system_user)
recursive_as_json(PostSerializer.new(post, scope: guardian, root: false))
end
end,
)

mini_racer_context.attach(
"_discourse_get_user",
->(user_id_or_username) do
in_attached_function do
user = nil

if user_id_or_username.is_a?(Integer) ||
user_id_or_username.to_i.to_s == user_id_or_username
user = User.find_by(id: user_id_or_username.to_i)
else
user = User.find_by(username: user_id_or_username)
end

return nil if user.nil?

guardian = Guardian.new(Discourse.system_user)
recursive_as_json(UserSerializer.new(user, scope: guardian, root: false))
end
end,
)

mini_racer_context.attach(
"_discourse_respond_to_persona",
->(persona_name, params) do
in_attached_function do
# if we have 1000s of personas this can be slow ... we may need to optimize
persona_class = AiPersona.all_personas.find { |persona| persona.name == persona_name }
return { error: "Persona not found" } if persona_class.nil?

persona = persona_class.new
bot = DiscourseAi::AiBot::Bot.as(@bot_user || persona.user, persona: persona)
playground = DiscourseAi::AiBot::Playground.new(bot)

if @context[:post_id]
post = Post.find_by(id: @context[:post_id])
return { error: "Post not found" } if post.nil?

reply_post = playground.reply_to(post, custom_instructions: params["instructions"])

if reply_post
return(
{ success: true, post_id: reply_post.id, post_number: reply_post.post_number }
)
else
return { error: "Failed to create reply" }
end
elsif @context[:message_id] && @context[:channel_id]
message = Chat::Message.find_by(id: @context[:message_id])
channel = Chat::Channel.find_by(id: @context[:channel_id])
return { error: "Message or channel not found" } if message.nil? || channel.nil?

reply =
playground.reply_to_chat_message(message, channel, @context[:context_post_ids])

if reply
return { success: true, message_id: reply.id }
else
return { error: "Failed to create chat reply" }
end
else
return { error: "No valid context for response" }
end
end
end,
)
end

def attach_upload(mini_racer_context)
mini_racer_context.attach(
"_upload_create",
Expand Down Expand Up @@ -343,6 +442,33 @@ def in_attached_function
ensure
self.running_attached_function = false
end

def recursive_as_json(obj)
case obj
when Array
obj.map { |item| recursive_as_json(item) }
when Hash
obj.transform_values { |value| recursive_as_json(value) }
when ActiveModel::Serializer, ActiveModel::ArraySerializer
recursive_as_json(obj.as_json)
when ActiveRecord::Base
recursive_as_json(obj.as_json)
else
# Handle objects that respond to as_json but aren't handled above
if obj.respond_to?(:as_json)
result = obj.as_json
if result.equal?(obj)
# If as_json returned the same object, return it to avoid infinite recursion
result
else
recursive_as_json(result)
end
else
# Primitive values like strings, numbers, booleans, nil
obj
end
end
end
end
end
end
21 changes: 21 additions & 0 deletions lib/automation/llm_tool_triage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module DiscourseAi
module Automation
module LlmToolTriage
def self.handle(post:, tool_id:, automation: nil)
tool = AiTool.find_by(id: tool_id)
return if !tool
return if !tool.parameters.blank?

context = {
post_id: post.id,
automation_id: automation&.id,
automation_name: automation&.name,
}

runner = tool.runner({}, llm: nil, bot_user: Discourse.system_user, context: context)
runner.invoke
end
end
end
end
66 changes: 66 additions & 0 deletions spec/lib/discourse_automation/llm_tool_triage_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe DiscourseAi::Automation::LlmToolTriage do
fab!(:solver) { Fabricate(:user) }
fab!(:new_user) { Fabricate(:user, trust_level: TrustLevel[0], created_at: 1.day.ago) }
fab!(:topic) { Fabricate(:topic, user: new_user) }
fab!(:post) { Fabricate(:post, topic: topic, user: new_user, raw: "How do I reset my password?") }
fab!(:llm_model)
fab!(:ai_persona) do
persona = Fabricate(:ai_persona, default_llm: llm_model)
persona.create_user
persona
end

fab!(:tool) do
tool_script = <<~JS
function invoke(params) {
const postId = context.post_id;
const post = discourse.getPost(postId);
const user = discourse.getUser(post.user_id);
if (user.trust_level > 0) {
return {
processed: false,
reason: "User is not new"
};
}
const helper = discourse.getPersona("#{ai_persona.name}");
const answer = helper.respondTo({ post_id: post.id });
return {
answer: answer,
processed: true,
reason: "answered question"
};
}
JS

AiTool.create!(
name: "New User Question Answerer",
tool_name: "new_user_question_answerer",
description: "Automatically answers questions from new users when possible",
parameters: [], # No parameters as required by llm_tool_triage
script: tool_script,
created_by_id: Discourse.system_user.id,
summary: "Answers new user questions",
enabled: true,
)
end

before do
SiteSetting.discourse_ai_enabled = true
SiteSetting.ai_bot_enabled = true
end

it "It is able to answer new user questions" do
result = nil
DiscourseAi::Completions::Llm.with_prepared_responses(
["this is how you reset your password"],
) { result = described_class.handle(post: post, tool_id: tool.id) }
p result
end
end

0 comments on commit 6e35293

Please sign in to comment.