Compare commits

...
Author SHA1 Message Date
Sam Saffron 6351c401f8 work in progress adding roles to personas
# Conflicts:
#	app/models/ai_persona.rb
2024-05-01 15:39:13 +10:00
Sam Saffron acd3ac2371 minor fix to read ... complete is just confusing 2024-04-30 17:08:23 +10:00
Sam Saffron c61af76d86 Responder for categories 2024-04-30 17:08:23 +10:00
Sam Saffron cd7790f39a message responder backend now works 2024-04-30 17:08:21 +10:00
Sam Saffron 4fcf12287f work in progress adding roles to personas 2024-04-30 17:07:22 +10:00
14 changed files with 354 additions and 23 deletions
@@ -125,6 +125,7 @@ module DiscourseAi
:rag_chunk_overlap_tokens,
:rag_conversation_chunks,
:question_consolidator_llm,
:role,
allowed_group_ids: [],
rag_uploads: [:id],
)
+7 -1
View File
@@ -10,7 +10,13 @@ module ::Jobs
persona_id = args[:persona_id]
begin
persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
persona =
if args[:skip_persona_security_check]
persona = AiPersona.all_personas.find { |persona| persona.id == persona_id }
else
persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
end
raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil?
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
+66 -2
View File
@@ -46,6 +46,10 @@ class AiPersona < ActiveRecord::Base
.map(&:class_instance)
end
def self.persona_class_by_id(id)
AiPersona.all_personas.find { |persona| persona.id == id } if id
end
def self.persona_users(user: nil)
persona_users =
persona_cache[:persona_users] ||= AiPersona
@@ -72,6 +76,42 @@ class AiPersona < ActiveRecord::Base
end
end
def self.topic_responder_for(category_id:)
return nil if !category_id
all_responders =
persona_cache[:topic_responders] ||= AiPersona
.where(role: "topic_responder")
.where(enabled: true)
.pluck(:id, :role_category_ids)
id, _ = all_responders.find { |id, role_category_ids| role_category_ids.include?(category_id) }
if id
{ id: id }
else
nil
end
end
def self.message_responder_for(group_id: nil)
return nil if !group_id
all_responders =
persona_cache[:message_responders] ||= AiPersona
.where(role: "message_responder")
.where(enabled: true)
.pluck(:id, :role_group_ids)
id, _ = all_responders.find { |id, role_group_ids| role_group_ids.include?(group_id) }
if id
{ id: id }
else
nil
end
end
def self.mentionables(user: nil)
all_mentionables =
persona_cache[:mentionable_usernames] ||= AiPersona
@@ -114,6 +154,8 @@ class AiPersona < ActiveRecord::Base
vision_max_pixels = self.vision_max_pixels
rag_conversation_chunks = self.rag_conversation_chunks
question_consolidator_llm = self.question_consolidator_llm
role = self.role
role_whispers = self.role_whispers
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
if persona_class
@@ -161,6 +203,14 @@ class AiPersona < ActiveRecord::Base
rag_conversation_chunks
end
persona_class.define_singleton_method :role_whispers do
role_whispers
end
persona_class.define_singleton_method :role do
role
end
return persona_class
end
@@ -252,6 +302,14 @@ class AiPersona < ActiveRecord::Base
question_consolidator_llm
end
define_singleton_method :role do
role
end
define_singleton_method :role_whispers do
role_whispers
end
define_singleton_method :to_s do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end
@@ -343,8 +401,8 @@ class AiPersona < ActiveRecord::Base
private
def system_persona_unchangeable
if top_p_changed? || temperature_changed? || system_prompt_changed? || commands_changed? ||
name_changed? || description_changed?
if role_changed? || top_p_changed? || temperature_changed? || system_prompt_changed? ||
commands_changed? || name_changed? || description_changed?
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
end
end
@@ -387,6 +445,12 @@ end
# rag_chunk_overlap_tokens :integer default(10), not null
# rag_conversation_chunks :integer default(10), not null
# question_consolidator_llm :text
# role :enum default("bot"), not null
# role_category_ids :integer default([]), not null, is an Array
# role_tags :string default([]), not null, is an Array
# role_group_ids :integer default([]), not null, is an Array
# role_whispers :boolean default(FALSE), not null
# role_max_responses_per_hour :integer default(50), not null
#
# Indexes
#
@@ -23,7 +23,12 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
:rag_chunk_tokens,
:rag_chunk_overlap_tokens,
:rag_conversation_chunks,
:question_consolidator_llm
:question_consolidator_llm,
:role,
:role_tags,
:role_category_ids,
:role_whispers,
:role_max_responses_per_hour
has_one :user, serializer: BasicUserSerializer, embed: :object
has_many :rag_uploads, serializer: UploadSerializer, embed: :object
@@ -26,6 +26,9 @@ const CREATE_ATTRIBUTES = [
"rag_chunk_overlap_tokens",
"rag_conversation_chunks",
"question_consolidator_llm",
"role",
"role_category_ids",
"role_whispers",
];
const SYSTEM_ATTRIBUTES = [
@@ -23,6 +23,7 @@ import DTooltip from "float-kit/components/d-tooltip";
import AiCommandSelector from "./ai-command-selector";
import AiLlmSelector from "./ai-llm-selector";
import AiPersonaCommandOptions from "./ai-persona-command-options";
import RoleSelector from "./ai-persona-role-selector";
import PersonaRagUploader from "./persona-rag-uploader";
export default class PersonaEditor extends Component {
@@ -336,6 +337,14 @@ export default class PersonaEditor extends Component {
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group ai-persona-editor__role">
<label>{{I18n.t "discourse_ai.ai_persona.role"}}</label>
<RoleSelector
class="ai-persona-editor__role_selctor"
@value={{this.editingModel.role}}
@disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.default_llm"}}</label>
<AiLlmSelector
@@ -0,0 +1,26 @@
import { computed, observer } from "@ember/object";
import I18n from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
export default ComboBox.extend({
_modelDisabledChanged: observer("attrs.disabled", function () {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}),
content: computed(function () {
return [
{
id: "bot",
name: I18n.t("discourse_ai.ai_persona.role_options.bot"),
},
{
id: "message_responder",
name: I18n.t("discourse_ai.ai_persona.role_options.message_responder"),
},
];
}),
selectKitOptions: {
filterable: false,
},
});
+4
View File
@@ -137,6 +137,10 @@ en:
mentionable: Mentionable
mentionable_help: If enabled, users in allowed groups can mention this user in posts and messages, the AI will respond as this persona.
user: User
role: Role
role_options:
bot: Bot
message_responder: Message Responder
create_user: Create User
create_user_help: You can optionally attach a user to this persona. If you do, the AI will use this user to respond to requests.
default_llm: Default Language Model
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class AddRoleToAiPersona < ActiveRecord::Migration[7.0]
def change
create_enum :ai_persona_role, %w[bot topic_responder message_responder summarizer]
add_column :ai_personas, :role, :enum, default: "bot", null: false, enum_type: :ai_persona_role
add_column :ai_personas, :role_category_ids, :integer, array: true, default: [], null: false
add_column :ai_personas, :role_tags, :string, array: true, default: [], null: false
add_column :ai_personas, :role_group_ids, :integer, array: true, default: [], null: false
add_column :ai_personas, :role_whispers, :boolean, default: false, null: false
add_column :ai_personas, :role_max_responses_per_hour, :integer, default: 50, null: false
end
end
+17
View File
@@ -5,6 +5,23 @@ module DiscourseAi
module Personas
class Persona
class << self
def as_bot
if self.respond_to?(:user_id) && self.respond_to?(:default_llm)
if self.default_llm
user = User.find_by(id: user_id)
DiscourseAi::AiBot::Bot.new(user, self.new, self.default_llm) if user
end
end
end
def role
"bot"
end
def role_whispers
false
end
def rag_conversation_chunks
10
end
+52 -17
View File
@@ -18,9 +18,33 @@ module DiscourseAi
user_id.to_i <= 0
end
def self.find_responder_persona(post)
if post.post_number == 1 && post.topic && post.topic.archetype == Archetype.private_message
# only supported responder for PMs is based on role_group_ids
group_ids = post.topic.allowed_groups.pluck(:id)
info =
group_ids
.lazy
.map { |group_id| AiPersona.message_responder_for(group_id: group_id) }
.find { |found| !found.nil? }
AiPersona.persona_class_by_id(info[:id]) if info && info[:id]
elsif post.post_number == 1 && post.topic && post.topic.archetype == Archetype.default
info = AiPersona.topic_responder_for(category_id: post.topic.category_id)
AiPersona.persona_class_by_id(info[:id]) if info && info[:id]
end
end
def self.schedule_reply(post)
return if is_bot_user_id?(post.user_id)
if responder_persona_class = find_responder_persona(post)
bot = responder_persona_class.as_bot
new(bot).schedule_bot_reply(post, skip_persona_security_check: true) if bot
return
end
bot_ids = DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS
mentionables = AiPersona.mentionables(user: post.user)
@@ -126,11 +150,12 @@ module DiscourseAi
FROM upload_references ref
WHERE ref.target_type = 'Post' AND ref.target_id = posts.id
) as upload_ids",
"post_number",
)
result = []
context.reverse_each do |raw, username, custom_prompt, upload_ids|
context.reverse_each do |raw, username, custom_prompt, upload_ids, post_number|
custom_prompt_translation =
Proc.new do |message|
# We can't keep backwards-compatibility for stored functions.
@@ -151,8 +176,14 @@ module DiscourseAi
if custom_prompt.present?
custom_prompt.each(&custom_prompt_translation)
else
content = raw
if post_number == 1 && bot.persona.class.role.include?("responder")
title = Topic.where("id = ?", post.topic_id).pluck(:title).first
content = "# #{title}\n\n#{content}"
end
context = {
content: raw,
content: content,
type: (available_bot_usernames.include?(username) ? :model : :user),
}
@@ -188,8 +219,11 @@ module DiscourseAi
reply = +""
start = Time.now
post_type =
post.post_type == Post.types[:whisper] ? Post.types[:whisper] : Post.types[:regular]
post_type = Post.types[:regular]
if post.post_type == Post.types[:whisper] || bot.persona.class.role_whispers
post_type = Post.types[:whisper]
end
context = {
site_url: Discourse.base_url,
@@ -208,7 +242,7 @@ module DiscourseAi
reply_user = User.find_by(id: bot.persona.class.user_id) || reply_user
end
stream_reply = post.topic.private_message?
stream_reply = post.topic.private_message? && !bot.persona.class.role.include?("responder")
# we need to ensure persona user is allowed to reply to the pm
if post.topic.private_message?
@@ -309,6 +343,19 @@ module DiscourseAi
.concat(DiscourseAi::AiBot::EntryPoint::BOTS.map(&:second))
end
def schedule_bot_reply(post, skip_persona_security_check: false)
persona_id =
DiscourseAi::AiBot::Personas::Persona.system_personas[bot.persona.class] ||
bot.persona.class.id
::Jobs.enqueue(
:create_ai_reply,
post_id: post.id,
bot_user_id: bot.bot_user.id,
persona_id: persona_id,
skip_persona_security_check: skip_persona_security_check,
)
end
private
def publish_final_update(reply_post)
@@ -347,18 +394,6 @@ module DiscourseAi
end
end
def schedule_bot_reply(post)
persona_id =
DiscourseAi::AiBot::Personas::Persona.system_personas[bot.persona.class] ||
bot.persona.class.id
::Jobs.enqueue(
:create_ai_reply,
post_id: post.id,
bot_user_id: bot.bot_user.id,
persona_id: persona_id,
)
end
def context(topic)
{
site_url: Discourse.base_url,
+2 -2
View File
@@ -19,7 +19,7 @@ module DiscourseAi
name: "post_number",
description: "the post number to read",
type: "integer",
required: true,
required: false,
},
],
}
@@ -74,7 +74,7 @@ module DiscourseAi
# TODO: 16k or 100k models can handle a lot more tokens
content = llm.tokenizer.truncate(content, 1500).squish
result = { topic_id: topic_id, content: content, complete: true }
result = { topic_id: topic_id, content: content }
result[:post_number] = post_number if post_number
result
end
+111
View File
@@ -530,4 +530,115 @@ RSpec.describe DiscourseAi::AiBot::Playground do
end
end
end
describe "topic_responder role" do
fab!(:category)
fab!(:tag)
it "can reply with a whisper to topics in a category" do
Jobs.run_immediately!
persona =
AiPersona.create!(
name: "Responder Persona",
description: "A responder",
system_prompt: "You are a responder",
enabled: true,
role: "topic_responder",
role_category_ids: [category.id],
role_whispers: true,
default_llm: "anthropic:claude-2",
)
persona.create_user!
post = nil
prompts = nil
DiscourseAi::Completions::Llm.with_prepared_responses(
["An amazing post just for you"],
) do |_, _, _prompts|
post =
create_post(title: "an amazing title", raw: "Howdy I need help!!", category: category)
prompts = _prompts
end
expect(prompts.length).to eq(1)
expect(prompts[0].messages[0][:content]).to eq("You are a responder")
expect(prompts[0].messages[1][:content]).to eq("# An amazing title\n\nHowdy I need help!!")
reply = post.topic.posts.find_by(post_number: 2)
expect(reply.post_type).to eq(Post.types[:whisper])
expect(reply.raw).to eq("An amazing post just for you")
# should be done responding at this point so llm will not be called
create_post(
raw: "should ignore posts that are not first on message",
topic: post.topic,
user: post.user,
)
# should not respond to topics in other categories
create_post(
title: "testing title 2",
raw: "should ignore posts that are not first on message",
user: post.user,
)
end
end
describe "message_responder role" do
fab!(:group) do
Fabricate(:group, name: "test-group", messageable_level: Group::ALIAS_LEVELS[:everyone])
end
it "can reply with a whisper to group PM" do
Jobs.run_immediately!
persona =
AiPersona.create!(
name: "Responder Persona",
description: "A responder",
system_prompt: "You are a responder",
enabled: true,
role: "message_responder",
role_group_ids: [group.id],
role_whispers: true,
default_llm: "anthropic:claude-2",
)
persona.create_user!
post = nil
prompts = nil
DiscourseAi::Completions::Llm.with_prepared_responses(
["An amazing post just for you"],
) do |_, _, _prompts|
post =
create_post(
title: "an amazing title",
raw: "Howdy I need help!!",
archetype: Archetype.private_message,
target_group_names: [group.name],
)
prompts = _prompts
end
expect(prompts.length).to eq(1)
expect(prompts[0].messages[0][:content]).to eq("You are a responder")
expect(prompts[0].messages[1][:content]).to eq("# An amazing title\n\nHowdy I need help!!")
reply = post.topic.posts.find_by(post_number: 2)
expect(reply.post_type).to eq(Post.types[:whisper])
expect(reply.raw).to eq("An amazing post just for you")
# should be done responding at this point so llm will not be called
create_post(
raw: "should ignore posts that are not first on message",
topic: post.topic,
user: post.user,
)
end
end
end
+36
View File
@@ -128,4 +128,40 @@ RSpec.describe AiPersona do
RailsMultisite::ConnectionManagement.stubs(:current_db) { "abc" }
expect(AiPersona.persona_cache[:value]).to eq(nil)
end
it "does not allow role change for system personas" do
persona = AiPersona.find_by(name: "Forum Helper")
expect(persona.update(role: "message_responder")).to eq(false)
end
describe "#message_responder_for" do
fab!(:group)
fab!(:responder) do
AiPersona.create!(
name: "responder",
description: "responser",
system_prompt: "responder prompt",
role: "message_responder",
role_group_ids: [group.id],
)
end
it "properly sets role_whispers on the persona" do
responder.update!(role_whispers: true)
expect(responder.class_instance.role_whispers).to eq(true)
end
it "returns can respond to group pms with a custom persona" do
expect(AiPersona.message_responder_for(group_id: group.id)[:id]).to eq(responder.id)
responder.update!(enabled: false)
expect(AiPersona.message_responder_for(group_id: group.id)).to eq(nil)
responder.update!(enabled: true, role: "bot")
expect(AiPersona.message_responder_for(group_id: group.id)).to eq(nil)
end
end
end