Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6351c401f8 | ||
|
|
acd3ac2371 | ||
|
|
c61af76d86 | ||
|
|
cd7790f39a | ||
|
|
4fcf12287f |
@@ -125,6 +125,7 @@ module DiscourseAi
|
||||
:rag_chunk_overlap_tokens,
|
||||
:rag_conversation_chunks,
|
||||
:question_consolidator_llm,
|
||||
:role,
|
||||
allowed_group_ids: [],
|
||||
rag_uploads: [:id],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user