Skip to content
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

persona roles #590

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def ai_persona_params
:rag_chunk_overlap_tokens,
:rag_conversation_chunks,
:question_consolidator_llm,
:role,
allowed_group_ids: [],
rag_uploads: [:id],
)
Expand Down
8 changes: 7 additions & 1 deletion app/jobs/regular/create_ai_reply.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ def execute(args)
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)
Expand Down
68 changes: 66 additions & 2 deletions app/models/ai_persona.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ def self.all_personas
.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
Expand All @@ -72,6 +76,42 @@ def self.persona_users(user: nil)
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
Expand Down Expand Up @@ -114,6 +154,8 @@ def class_instance
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
Expand Down Expand Up @@ -161,6 +203,14 @@ def class_instance
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

Expand Down Expand Up @@ -252,6 +302,14 @@ def class_instance
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
Expand Down Expand Up @@ -343,8 +401,8 @@ def regenerate_rag_fragments
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
Expand Down Expand Up @@ -387,6 +445,12 @@ def ensure_not_system
# 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
#
Expand Down
7 changes: 6 additions & 1 deletion app/serializers/localized_ai_persona_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions assets/javascripts/discourse/admin/models/ai-persona.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
9 changes: 9 additions & 0 deletions assets/javascripts/discourse/components/ai-persona-editor.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions db/migrate/20240422054321_add_role_to_ai_persona.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 17 additions & 0 deletions lib/ai_bot/personas/persona.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ module AiBot
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
Expand Down
69 changes: 52 additions & 17 deletions lib/ai_bot/playground.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,33 @@ def self.is_bot_user_id?(user_id)
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)

Expand Down Expand Up @@ -126,11 +150,12 @@ def conversation_context(post)
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.
Expand All @@ -151,8 +176,14 @@ def conversation_context(post)
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),
}

Expand Down Expand Up @@ -188,8 +219,11 @@ def reply_to(post)
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,
Expand All @@ -208,7 +242,7 @@ def reply_to(post)
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?
Expand Down Expand Up @@ -309,6 +343,19 @@ def available_bot_usernames
.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)
Expand Down Expand Up @@ -347,18 +394,6 @@ def schedule_playground_titling(post)
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,
Expand Down
Loading
Loading