From bdf3b6268b2843f4b2ba8f8875e4d18a952ef326 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 16 Oct 2024 07:20:31 +1100 Subject: [PATCH] FEATURE: smarter persona tethering (#832) Splits persona permissions so you can allow a persona on: - chat dms - personal messages - topic mentions - chat channels (any combination is allowed) Previously we did not have this flexibility. Additionally, adds the ability to "tether" a language model to a persona so it will always be used by the persona. This allows people to use a cheaper language model for one group of people and more expensive one for other people --- .../admin/ai_personas_controller.rb | 7 +- app/models/ai_persona.rb | 88 ++++++++++-------- .../localized_ai_persona_serializer.rb | 9 +- .../discourse/admin/models/ai-persona.js | 13 ++- .../components/ai-persona-editor.gjs | 90 +++++++++++++++---- .../composer-fields/persona-llm-selector.gjs | 38 ++++++-- .../modules/ai-bot/common/ai-persona.scss | 9 +- config/locales/client.en.yml | 11 ++- ...14010245_ai_persona_chat_topic_refactor.rb | 23 +++++ ...41242_ai_persona_post_migrate_drop_cols.rb | 7 ++ lib/ai_bot/entry_point.rb | 19 ++-- lib/ai_bot/personas/persona.rb | 10 ++- lib/ai_bot/playground.rb | 40 ++++++--- spec/lib/modules/ai_bot/entry_point_spec.rb | 9 +- spec/lib/modules/ai_bot/playground_spec.rb | 77 ++++++++++++++-- spec/models/ai_persona_spec.rb | 54 +++++++++-- spec/plugin_helper.rb | 5 +- .../admin/ai_personas_controller_spec.rb | 21 ++++- .../embeddings/embeddings_controller_spec.rb | 2 - .../unit/models/ai-persona-test.js | 12 ++- 20 files changed, 422 insertions(+), 122 deletions(-) create mode 100644 db/migrate/20241014010245_ai_persona_chat_topic_refactor.rb create mode 100644 db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb index d272910b..b4fd66b1 100644 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb @@ -96,7 +96,6 @@ module DiscourseAi :temperature, :default_llm, :user_id, - :mentionable, :max_context_posts, :vision_enabled, :vision_max_pixels, @@ -104,9 +103,13 @@ module DiscourseAi :rag_chunk_overlap_tokens, :rag_conversation_chunks, :question_consolidator_llm, - :allow_chat, + :allow_chat_channel_mentions, + :allow_chat_direct_messages, + :allow_topic_mentions, + :allow_personal_messages, :tool_details, :forced_tool_count, + :force_default_llm, allowed_group_ids: [], rag_uploads: [:id], ) diff --git a/app/models/ai_persona.rb b/app/models/ai_persona.rb index 350ec064..15c3c163 100644 --- a/app/models/ai_persona.rb +++ b/app/models/ai_persona.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class AiPersona < ActiveRecord::Base - # TODO remove this line 01-11-2024 - self.ignored_columns = [:commands] + # TODO remove this line 01-1-2025 + self.ignored_columns = %i[commands allow_chat mentionable] # places a hard limit, so per site we cache a maximum of 500 classes MAX_PERSONAS_PER_SITE = 500 @@ -52,30 +52,47 @@ class AiPersona < ActiveRecord::Base persona_cache[:persona_users] ||= AiPersona .where(enabled: true) .joins(:user) - .pluck( - "ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm, mentionable, allow_chat", - ) - .map do |id, user_id, username, allowed_group_ids, default_llm, mentionable, allow_chat| + .map do |persona| { - id: id, - user_id: user_id, - username: username, - allowed_group_ids: allowed_group_ids, - default_llm: default_llm, - mentionable: mentionable, - allow_chat: allow_chat, + id: persona.id, + user_id: persona.user_id, + username: persona.user.username_lower, + allowed_group_ids: persona.allowed_group_ids, + default_llm: persona.default_llm, + force_default_llm: persona.force_default_llm, + allow_chat_channel_mentions: persona.allow_chat_channel_mentions, + allow_chat_direct_messages: persona.allow_chat_direct_messages, + allow_topic_mentions: persona.allow_topic_mentions, + allow_personal_messages: persona.allow_personal_messages, } end if user - persona_users.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) } + persona_users.select { |persona_user| user.in_any_groups?(persona_user[:allowed_group_ids]) } else persona_users end end - def self.allowed_chat(user: nil) - personas = persona_cache[:allowed_chat] ||= persona_users.select { |u| u[:allow_chat] } + def self.allowed_modalities( + user: nil, + allow_chat_channel_mentions: false, + allow_chat_direct_messages: false, + allow_topic_mentions: false, + allow_personal_messages: false + ) + index = + "modality-#{allow_chat_channel_mentions}-#{allow_chat_direct_messages}-#{allow_topic_mentions}-#{allow_personal_messages}" + + personas = + persona_cache[index.to_sym] ||= persona_users.select do |persona| + next true if allow_chat_channel_mentions && persona[:allow_chat_channel_mentions] + next true if allow_chat_direct_messages && persona[:allow_chat_direct_messages] + next true if allow_topic_mentions && persona[:allow_topic_mentions] + next true if allow_personal_messages && persona[:allow_personal_messages] + false + end + if user personas.select { |u| user.in_any_groups?(u[:allowed_group_ids]) } else @@ -83,18 +100,6 @@ class AiPersona < ActiveRecord::Base end end - def self.mentionables(user: nil) - all_mentionables = - persona_cache[:mentionables] ||= persona_users.select do |mentionable| - mentionable[:mentionable] - end - if user - all_mentionables.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) } - else - all_mentionables - end - end - after_commit :bump_cache def bump_cache @@ -113,7 +118,11 @@ class AiPersona < ActiveRecord::Base vision_max_pixels rag_conversation_chunks question_consolidator_llm - allow_chat + allow_chat_channel_mentions + allow_chat_direct_messages + allow_topic_mentions + allow_personal_messages + force_default_llm name description allowed_group_ids @@ -128,6 +137,8 @@ class AiPersona < ActiveRecord::Base instance_attributes[attr] = value end + instance_attributes[:username] = user&.username_lower + if persona_class instance_attributes.each do |key, value| # description/name are localized @@ -243,7 +254,10 @@ class AiPersona < ActiveRecord::Base private def chat_preconditions - if allow_chat && !default_llm + if ( + allow_chat_channel_mentions || allow_chat_direct_messages || allow_topic_mentions || + force_default_llm + ) && !default_llm errors.add(:default_llm, I18n.t("discourse_ai.ai_bot.personas.default_llm_required")) end end @@ -281,7 +295,6 @@ end # temperature :float # top_p :float # user_id :integer -# mentionable :boolean default(FALSE), not null # default_llm :text # max_context_posts :integer # max_post_context_tokens :integer @@ -291,16 +304,15 @@ end # rag_chunk_tokens :integer default(374), not null # rag_chunk_overlap_tokens :integer default(10), not null # rag_conversation_chunks :integer default(10), not null -# 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 # question_consolidator_llm :text -# allow_chat :boolean default(FALSE), not null # tool_details :boolean default(TRUE), not null # tools :json not null +# forced_tool_count :integer default(-1), not null +# allow_chat_channel_mentions :boolean default(FALSE), not null +# allow_chat_direct_messages :boolean default(FALSE), not null +# allow_topic_mentions :boolean default(FALSE), not null +# allow_personal_message :boolean default(TRUE), not null +# force_default_llm :boolean default(FALSE), not null # # Indexes # diff --git a/app/serializers/localized_ai_persona_serializer.rb b/app/serializers/localized_ai_persona_serializer.rb index 69c9812b..81f6fa85 100644 --- a/app/serializers/localized_ai_persona_serializer.rb +++ b/app/serializers/localized_ai_persona_serializer.rb @@ -14,7 +14,6 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer :allowed_group_ids, :temperature, :top_p, - :mentionable, :default_llm, :user_id, :max_context_posts, @@ -24,9 +23,13 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer :rag_chunk_overlap_tokens, :rag_conversation_chunks, :question_consolidator_llm, - :allow_chat, :tool_details, - :forced_tool_count + :forced_tool_count, + :allow_chat_channel_mentions, + :allow_chat_direct_messages, + :allow_topic_mentions, + :allow_personal_messages, + :force_default_llm has_one :user, serializer: BasicUserSerializer, embed: :object has_many :rag_uploads, serializer: UploadSerializer, embed: :object diff --git a/assets/javascripts/discourse/admin/models/ai-persona.js b/assets/javascripts/discourse/admin/models/ai-persona.js index 53be8076..c649efbd 100644 --- a/assets/javascripts/discourse/admin/models/ai-persona.js +++ b/assets/javascripts/discourse/admin/models/ai-persona.js @@ -15,8 +15,8 @@ const CREATE_ATTRIBUTES = [ "top_p", "temperature", "user_id", - "mentionable", "default_llm", + "force_default_llm", "user", "max_context_posts", "vision_enabled", @@ -29,6 +29,10 @@ const CREATE_ATTRIBUTES = [ "allow_chat", "tool_details", "forced_tool_count", + "allow_personal_messages", + "allow_topic_mentions", + "allow_chat_channel_mentions", + "allow_chat_direct_messages", ]; const SYSTEM_ATTRIBUTES = [ @@ -38,8 +42,8 @@ const SYSTEM_ATTRIBUTES = [ "system", "priority", "user_id", - "mentionable", "default_llm", + "force_default_llm", "user", "max_context_posts", "vision_enabled", @@ -49,8 +53,11 @@ const SYSTEM_ATTRIBUTES = [ "rag_chunk_overlap_tokens", "rag_conversation_chunks", "question_consolidator_llm", - "allow_chat", "tool_details", + "allow_personal_messages", + "allow_topic_mentions", + "allow_chat_channel_mentions", + "allow_chat_direct_messages", ]; class ToolOption { diff --git a/assets/javascripts/discourse/components/ai-persona-editor.gjs b/assets/javascripts/discourse/components/ai-persona-editor.gjs index c2dd6182..0218b871 100644 --- a/assets/javascripts/discourse/components/ai-persona-editor.gjs +++ b/assets/javascripts/discourse/components/ai-persona-editor.gjs @@ -44,6 +44,7 @@ export default class PersonaEditor extends Component { @tracked selectedTools = []; @tracked selectedToolNames = []; @tracked forcedToolNames = []; + @tracked hasDefaultLlm = false; get chatPluginEnabled() { return this.siteSettings.chat_enabled; @@ -81,6 +82,7 @@ export default class PersonaEditor extends Component { @action updateModel() { this.editingModel = this.args.model.workingCopy(); + this.hasDefaultLlm = !!this.editingModel.default_llm; this.showDelete = !this.args.model.isNew && !this.args.model.system; this.maxPixelsValue = this.findClosestPixelValue( this.editingModel.vision_max_pixels @@ -183,8 +185,10 @@ export default class PersonaEditor extends Component { set mappedDefaultLlm(value) { if (value === "blank") { this.editingModel.default_llm = null; + this.hasDefaultLlm = false; } else { this.editingModel.default_llm = value; + this.hasDefaultLlm = true; } } @@ -344,6 +348,16 @@ export default class PersonaEditor extends Component { @content={{I18n.t "discourse_ai.ai_persona.default_llm_help"}} /> + {{#if this.hasDefaultLlm}} +
+ +
+ {{/if}} {{#unless @model.isNew}}
@@ -429,33 +443,73 @@ export default class PersonaEditor extends Component { disabled={{this.editingModel.system}} />
+
+ + +
{{#if this.editingModel.user}} - {{#if this.chatPluginEnabled}} -
- - -
- {{/if}} -
+
+ {{I18n.t "discourse_ai.ai_persona.allow_topic_mentions"}}
+ {{#if this.chatPluginEnabled}} +
+ + +
+
+ + +
+ {{/if}} {{/if}}
-
- -
+ {{#if this.allowLLMSelector}} +
+ +
+ {{/if}}
} diff --git a/assets/stylesheets/modules/ai-bot/common/ai-persona.scss b/assets/stylesheets/modules/ai-bot/common/ai-persona.scss index 26287529..424ffbef 100644 --- a/assets/stylesheets/modules/ai-bot/common/ai-persona.scss +++ b/assets/stylesheets/modules/ai-bot/common/ai-persona.scss @@ -68,9 +68,12 @@ &__tool-details, &__vision_enabled, - &__allow_chat, - &__priority, - &__mentionable { + &__allow_chat_direct_messages, + &__allow_chat_channel_mentions, + &__allow_topic_mentions, + &__allow_personal_messages, + &__force_default_llm, + &__priority { display: flex; align-items: center; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ea83abb9..1dc8e6c0 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -148,8 +148,15 @@ en: question_consolidator_llm_help: The language model to use for the question consolidator, you may choose a less powerful model to save costs. system_prompt: System Prompt forced_tool_strategy: Forced Tool Strategy - allow_chat: "Allow Chat" - allow_chat_help: "If enabled, users in allowed groups can DM this persona" + allow_chat_direct_messages: "Allow Chat Direct Messages" + allow_chat_direct_messages_help: "If enabled, users in allowed groups can send direct messages to this persona." + allow_chat_channel_mentions: "Allow Chat Channel Mentions" + allow_chat_channel_mentions_help: "If enabled, users in allowed groups can mention this persona in chat channels." + allow_personal_messages: "Allow Personal Messages" + allow_personal_messages_help: "If enabled, users in allowed groups can send personal messages to this persona." + allow_topic_mentions: "Allow Topic Mentions" + allow_topic_mentions_help: "If enabled, users in allowed groups can mention this persona in topics." + force_default_llm: "Always use default Language Model" save: Save saved: AI Persona Saved enabled: "Enabled?" diff --git a/db/migrate/20241014010245_ai_persona_chat_topic_refactor.rb b/db/migrate/20241014010245_ai_persona_chat_topic_refactor.rb new file mode 100644 index 00000000..9a7e9a88 --- /dev/null +++ b/db/migrate/20241014010245_ai_persona_chat_topic_refactor.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AiPersonaChatTopicRefactor < ActiveRecord::Migration[7.1] + def change + add_column :ai_personas, :allow_chat_channel_mentions, :boolean, default: false, null: false + add_column :ai_personas, :allow_chat_direct_messages, :boolean, default: false, null: false + add_column :ai_personas, :allow_topic_mentions, :boolean, default: false, null: false + add_column :ai_personas, :allow_personal_messages, :boolean, default: true, null: false + add_column :ai_personas, :force_default_llm, :boolean, default: false, null: false + + execute <<~SQL + UPDATE ai_personas + SET allow_chat_channel_mentions = mentionable, allow_chat_direct_messages = true + WHERE allow_chat = true + SQL + + execute <<~SQL + UPDATE ai_personas + SET allow_topic_mentions = true + WHERE mentionable = true + SQL + end +end diff --git a/db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb b/db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb new file mode 100644 index 00000000..02d50537 --- /dev/null +++ b/db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AiPersonaPostMigrateDropCols < ActiveRecord::Migration[7.1] + def change + remove_columns :ai_personas, :allow_chat + remove_columns :ai_personas, :mentionable + end +end diff --git a/lib/ai_bot/entry_point.rb b/lib/ai_bot/entry_point.rb index 5ff8ea36..77d9f377 100644 --- a/lib/ai_bot/entry_point.rb +++ b/lib/ai_bot/entry_point.rb @@ -8,11 +8,10 @@ module DiscourseAi Bot = Struct.new(:id, :name, :llm) def self.all_bot_ids - mentionable_persona_user_ids = - AiPersona.mentionables.map { |mentionable| mentionable[:user_id] } - mentionable_bot_users = LlmModel.joins(:user).pluck("users.id") - - mentionable_bot_users + mentionable_persona_user_ids + AiPersona + .persona_users + .map { |persona| persona[:user_id] } + .concat(LlmModel.where(enabled_chat_bot: true).pluck(:user_id)) end def self.find_participant_in(participant_ids) @@ -109,7 +108,13 @@ module DiscourseAi DiscourseAi::AiBot::Personas::Persona .all(user: scope.user) .map do |persona| - { id: persona.id, name: persona.name, description: persona.description } + { + id: persona.id, + name: persona.name, + description: persona.description, + force_default_llm: persona.force_default_llm, + username: persona.username, + } end end @@ -140,7 +145,7 @@ module DiscourseAi { "id" => persona_user[:user_id], "username" => persona_user[:username], - "mentionable" => persona_user[:mentionable], + "force_default_llm" => persona_user[:force_default_llm], "is_persona" => true, } end, diff --git a/lib/ai_bot/personas/persona.rb b/lib/ai_bot/personas/persona.rb index dd2a729a..ca47df0a 100644 --- a/lib/ai_bot/personas/persona.rb +++ b/lib/ai_bot/personas/persona.rb @@ -21,7 +21,15 @@ module DiscourseAi nil end - def allow_chat + def force_default_llm + false + end + + def allow_chat_channel_mentions + false + end + + def allow_chat_direct_messages false end diff --git a/lib/ai_bot/playground.rb b/lib/ai_bot/playground.rb index fb41b4f5..13834c92 100644 --- a/lib/ai_bot/playground.rb +++ b/lib/ai_bot/playground.rb @@ -11,17 +11,19 @@ module DiscourseAi def self.find_chat_persona(message, channel, user) if channel.direct_message_channel? - AiPersona.allowed_chat.find do |p| - p[:user_id].in?(channel.allowed_user_ids) && (user.group_ids & p[:allowed_group_ids]) - end + AiPersona + .allowed_modalities(allow_chat_direct_messages: true) + .find do |p| + p[:user_id].in?(channel.allowed_user_ids) && (user.group_ids & p[:allowed_group_ids]) + end else # let's defer on the parse if there is no @ in the message if message.message.include?("@") mentions = message.parsed_mentions.parsed_direct_mentions if mentions.present? - AiPersona.allowed_chat.find do |p| - p[:username].in?(mentions) && (user.group_ids & p[:allowed_group_ids]) - end + AiPersona + .allowed_modalities(allow_chat_channel_mentions: true) + .find { |p| p[:username].in?(mentions) && (user.group_ids & p[:allowed_group_ids]) } end end end @@ -29,8 +31,14 @@ module DiscourseAi def self.schedule_chat_reply(message, channel, user, context) return if !SiteSetting.ai_bot_enabled - return if AiPersona.allowed_chat.blank? - return if AiPersona.allowed_chat.any? { |m| m[:user_id] == user.id } + + all_chat = + AiPersona.allowed_modalities( + allow_chat_channel_mentions: true, + allow_chat_direct_messages: true, + ) + return if all_chat.blank? + return if all_chat.any? { |m| m[:user_id] == user.id } persona = find_chat_persona(message, channel, user) return if !persona @@ -56,15 +64,23 @@ module DiscourseAi def self.schedule_reply(post) return if is_bot_user_id?(post.user_id) + mentionables = nil - bot_ids = LlmModel.joins(:user).pluck("users.id") - mentionables = AiPersona.mentionables(user: post.user) + if post.topic.private_message? + mentionables = + AiPersona.allowed_modalities(user: post.user, allow_personal_messages: true) + else + mentionables = AiPersona.allowed_modalities(user: post.user, allow_topic_mentions: true) + end bot_user = nil mentioned = nil + all_llm_user_ids = LlmModel.joins(:user).pluck("users.id") + if post.topic.private_message? - bot_user = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user + # this is an edge case, you started a PM with a different bot + bot_user = post.topic.topic_allowed_users.where(user_id: all_llm_user_ids).first&.user bot_user ||= post .topic @@ -114,6 +130,8 @@ module DiscourseAi persona ||= DiscourseAi::AiBot::Personas::General + bot_user = User.find(persona.user_id) if persona && persona.force_default_llm + bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new) new(bot).update_playground_with(post) end diff --git a/spec/lib/modules/ai_bot/entry_point_spec.rb b/spec/lib/modules/ai_bot/entry_point_spec.rb index a4ebc99d..4f443280 100644 --- a/spec/lib/modules/ai_bot/entry_point_spec.rb +++ b/spec/lib/modules/ai_bot/entry_point_spec.rb @@ -35,15 +35,16 @@ RSpec.describe DiscourseAi::AiBot::EntryPoint do expect(serializer[:current_user][:can_debug_ai_bot_conversations]).to eq(true) end - it "adds mentionables to current_user_serializer" do + it "adds information about forcing default llm to current_user_serializer" do Group.refresh_automatic_groups! persona = Fabricate( :ai_persona, - mentionable: true, enabled: true, allowed_group_ids: [bot_allowed_group.id], + default_llm: "claude-2", + force_default_llm: true, ) persona.create_user! @@ -54,7 +55,7 @@ RSpec.describe DiscourseAi::AiBot::EntryPoint do persona_bot = bots.find { |bot| bot["id"] == persona.user_id } expect(persona_bot["username"]).to eq(persona.user.username) - expect(persona_bot["mentionable"]).to eq(true) + expect(persona_bot["force_default_llm"]).to eq(true) end it "includes user ids for all personas in the serializer" do @@ -69,7 +70,7 @@ RSpec.describe DiscourseAi::AiBot::EntryPoint do persona_bot = bots.find { |bot| bot["id"] == persona.user_id } expect(persona_bot["username"]).to eq(persona.user.username) - expect(persona_bot["mentionable"]).to eq(false) + expect(persona_bot["force_default_llm"]).to eq(false) end it "queues a job to generate a reply by the AI" do diff --git a/spec/lib/modules/ai_bot/playground_spec.rb b/spec/lib/modules/ai_bot/playground_spec.rb index 47f529a4..08f5d56e 100644 --- a/spec/lib/modules/ai_bot/playground_spec.rb +++ b/spec/lib/modules/ai_bot/playground_spec.rb @@ -55,6 +55,11 @@ RSpec.describe DiscourseAi::AiBot::Playground do ) end + after do + # we must reset cache on persona cause data can be rolled back + AiPersona.persona_cache.flush! + end + describe "is_bot_user_id?" do it "properly detects ALL bots as bot users" do persona = Fabricate(:ai_persona, enabled: false) @@ -227,7 +232,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do vision_enabled: true, vision_max_pixels: 1_000, default_llm: "custom:#{opus_model.id}", - mentionable: true, + allow_topic_mentions: true, ) end @@ -277,7 +282,11 @@ RSpec.describe DiscourseAi::AiBot::Playground do ) persona.create_user! - persona.update!(default_llm: "custom:#{claude_2.id}", mentionable: true) + persona.update!( + default_llm: "custom:#{claude_2.id}", + allow_chat_channel_mentions: true, + allow_topic_mentions: true, + ) persona end @@ -294,7 +303,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do SiteSetting.ai_bot_enabled = true SiteSetting.chat_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" Group.refresh_automatic_groups! - persona.update!(allow_chat: true, mentionable: true, default_llm: "custom:#{opus_model.id}") + persona.update!(allow_chat_channel_mentions: true, default_llm: "custom:#{opus_model.id}") end it "should behave in a sane way when threading is enabled" do @@ -406,8 +415,9 @@ RSpec.describe DiscourseAi::AiBot::Playground do SiteSetting.chat_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" Group.refresh_automatic_groups! persona.update!( - allow_chat: true, - mentionable: false, + allow_chat_direct_messages: true, + allow_topic_mentions: false, + allow_chat_channel_mentions: false, default_llm: "custom:#{opus_model.id}", ) SiteSetting.ai_bot_enabled = true @@ -481,7 +491,6 @@ RSpec.describe DiscourseAi::AiBot::Playground do # it also needs to include history per config - first feed some history persona.update!(enabled: false) - persona_guardian = Guardian.new(persona.user) 4.times do |i| @@ -561,6 +570,8 @@ RSpec.describe DiscourseAi::AiBot::Playground do # we still should be able to mention with no bots toggle_enabled_bots(bots: []) + persona.update!(allow_topic_mentions: true) + post = nil DiscourseAi::Completions::Llm.with_prepared_responses(["Yes I can"]) do post = @@ -574,6 +585,16 @@ RSpec.describe DiscourseAi::AiBot::Playground do last_post = post.topic.posts.order(:post_number).last expect(last_post.raw).to eq("Yes I can") expect(last_post.user_id).to eq(persona.user_id) + + persona.update!(allow_topic_mentions: false) + + post = + create_post( + title: "My public topic ABC", + raw: "Hey @#{persona.user.username}, can you help me?", + ) + + expect(post.topic.posts.last.post_number).to eq(1) end it "allows PMing a persona even when no particular bots are enabled" do @@ -603,6 +624,50 @@ RSpec.describe DiscourseAi::AiBot::Playground do expect(last_post.topic.allowed_users.pluck(:user_id)).to include(persona.user_id) expect(last_post.topic.participant_count).to eq(2) + + # ensure it can be disabled + persona.update!(allow_personal_messages: false) + + post = + create_post( + raw: "Hey there #{persona.user.username}, can you help me please", + topic_id: post.topic.id, + user: admin, + ) + + expect(post.post_number).to eq(3) + end + + it "can tether a persona unconditionally to an llm" do + gpt_35_turbo = Fabricate(:llm_model, name: "gpt-3.5-turbo") + + # If you start a PM with GPT 3.5 bot, replies should come from it, not from Claude + SiteSetting.ai_bot_enabled = true + toggle_enabled_bots(bots: [gpt_35_turbo, claude_2]) + + post = nil + persona.update!(force_default_llm: true, default_llm: "custom:#{gpt_35_turbo.id}") + + DiscourseAi::Completions::Llm.with_prepared_responses( + ["Yes I can", "Magic Title"], + llm: "custom:#{gpt_35_turbo.id}", + ) do + post = + create_post( + title: "I just made a PM", + raw: "hello world", + target_usernames: "#{user.username},#{claude_2.user.username}", + archetype: Archetype.private_message, + user: admin, + custom_fields: { + "ai_persona_id" => persona.id, + }, + ) + end + + last_post = post.topic.posts.order(:post_number).last + expect(last_post.raw).to eq("Yes I can") + expect(last_post.user_id).to eq(persona.user_id) end it "picks the correct llm for persona in PMs" do diff --git a/spec/models/ai_persona_spec.rb b/spec/models/ai_persona_spec.rb index 0d48b4cf..4783dbfb 100644 --- a/spec/models/ai_persona_spec.rb +++ b/spec/models/ai_persona_spec.rb @@ -71,9 +71,12 @@ RSpec.describe AiPersona do forum_helper = AiPersona.find_by(name: "Forum Helper") forum_helper.update!( user_id: 1, - mentionable: true, default_llm: "anthropic:claude-2", max_context_posts: 3, + allow_topic_mentions: true, + allow_personal_messages: true, + allow_chat_channel_mentions: true, + allow_chat_direct_messages: true, ) klass = forum_helper.class_instance @@ -83,9 +86,12 @@ RSpec.describe AiPersona do # tl 0 by default expect(klass.allowed_group_ids).to eq([10]) expect(klass.user_id).to eq(1) - expect(klass.mentionable).to eq(true) expect(klass.default_llm).to eq("anthropic:claude-2") expect(klass.max_context_posts).to eq(3) + expect(klass.allow_topic_mentions).to eq(true) + expect(klass.allow_personal_messages).to eq(true) + expect(klass.allow_chat_channel_mentions).to eq(true) + expect(klass.allow_chat_direct_messages).to eq(true) end it "defines singleton methods non persona classes" do @@ -98,7 +104,10 @@ RSpec.describe AiPersona do allowed_group_ids: [], default_llm: "anthropic:claude-2", max_context_posts: 3, - mentionable: true, + allow_topic_mentions: true, + allow_personal_messages: true, + allow_chat_channel_mentions: true, + allow_chat_direct_messages: true, user_id: 1, ) @@ -108,12 +117,15 @@ RSpec.describe AiPersona do expect(klass.system).to eq(false) expect(klass.allowed_group_ids).to eq([]) expect(klass.user_id).to eq(1) - expect(klass.mentionable).to eq(true) expect(klass.default_llm).to eq("anthropic:claude-2") expect(klass.max_context_posts).to eq(3) + expect(klass.allow_topic_mentions).to eq(true) + expect(klass.allow_personal_messages).to eq(true) + expect(klass.allow_chat_channel_mentions).to eq(true) + expect(klass.allow_chat_direct_messages).to eq(true) end - it "does not allow setting allow_chat without a default_llm" do + it "does not allow setting allowing chat without a default_llm" do persona = AiPersona.create( name: "test", @@ -121,7 +133,37 @@ RSpec.describe AiPersona do system_prompt: "test", allowed_group_ids: [], default_llm: nil, - allow_chat: true, + allow_chat_channel_mentions: true, + ) + + expect(persona.valid?).to eq(false) + expect(persona.errors[:default_llm].first).to eq( + I18n.t("discourse_ai.ai_bot.personas.default_llm_required"), + ) + + persona = + AiPersona.create( + name: "test", + description: "test", + system_prompt: "test", + allowed_group_ids: [], + default_llm: nil, + allow_chat_direct_messages: true, + ) + + expect(persona.valid?).to eq(false) + expect(persona.errors[:default_llm].first).to eq( + I18n.t("discourse_ai.ai_bot.personas.default_llm_required"), + ) + + persona = + AiPersona.create( + name: "test", + description: "test", + system_prompt: "test", + allowed_group_ids: [], + default_llm: nil, + allow_topic_mentions: true, ) expect(persona.valid?).to eq(false) diff --git a/spec/plugin_helper.rb b/spec/plugin_helper.rb index a01a354a..0a203834 100644 --- a/spec/plugin_helper.rb +++ b/spec/plugin_helper.rb @@ -2,7 +2,10 @@ module DiscourseAi::ChatBotHelper def toggle_enabled_bots(bots: []) - LlmModel.update_all(enabled_chat_bot: false) + models = LlmModel.all + models = models.where("id not in (?)", bots.map(&:id)) if bots.present? + models.update_all(enabled_chat_bot: false) + bots.each { |b| b.update!(enabled_chat_bot: true) } DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots end diff --git a/spec/requests/admin/ai_personas_controller_spec.rb b/spec/requests/admin/ai_personas_controller_spec.rb index 61e831ce..1dee9b73 100644 --- a/spec/requests/admin/ai_personas_controller_spec.rb +++ b/spec/requests/admin/ai_personas_controller_spec.rb @@ -40,7 +40,10 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do :ai_persona, name: "search2", tools: [["SearchCommand", { base_query: "test" }, true]], - mentionable: true, + allow_topic_mentions: true, + allow_personal_messages: true, + allow_chat_channel_mentions: true, + allow_chat_direct_messages: true, default_llm: "anthropic:claude-2", forced_tool_count: 2, ) @@ -52,7 +55,11 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do serializer_persona1 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona1.id } serializer_persona2 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona2.id } - expect(serializer_persona2["mentionable"]).to eq(true) + expect(serializer_persona2["allow_topic_mentions"]).to eq(true) + expect(serializer_persona2["allow_personal_messages"]).to eq(true) + expect(serializer_persona2["allow_chat_channel_mentions"]).to eq(true) + expect(serializer_persona2["allow_chat_direct_messages"]).to eq(true) + expect(serializer_persona2["default_llm"]).to eq("anthropic:claude-2") expect(serializer_persona2["user_id"]).to eq(persona2.user_id) expect(serializer_persona2["user"]["id"]).to eq(persona2.user_id) @@ -167,7 +174,10 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do tools: [["search", { "base_query" => "test" }, true]], top_p: 0.1, temperature: 0.5, - mentionable: true, + allow_topic_mentions: true, + allow_personal_messages: true, + allow_chat_channel_mentions: true, + allow_chat_direct_messages: true, default_llm: "anthropic:claude-2", forced_tool_count: 2, } @@ -186,9 +196,12 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do expect(persona_json["name"]).to eq("superbot") expect(persona_json["top_p"]).to eq(0.1) expect(persona_json["temperature"]).to eq(0.5) - expect(persona_json["mentionable"]).to eq(true) expect(persona_json["default_llm"]).to eq("anthropic:claude-2") expect(persona_json["forced_tool_count"]).to eq(2) + expect(persona_json["allow_topic_mentions"]).to eq(true) + expect(persona_json["allow_personal_messages"]).to eq(true) + expect(persona_json["allow_chat_channel_mentions"]).to eq(true) + expect(persona_json["allow_chat_direct_messages"]).to eq(true) persona = AiPersona.find(persona_json["id"]) diff --git a/spec/requests/embeddings/embeddings_controller_spec.rb b/spec/requests/embeddings/embeddings_controller_spec.rb index fa1c1b1c..2a411644 100644 --- a/spec/requests/embeddings/embeddings_controller_spec.rb +++ b/spec/requests/embeddings/embeddings_controller_spec.rb @@ -63,8 +63,6 @@ describe DiscourseAi::Embeddings::EmbeddingsController do context "when rate limiting is enabled" do before { RateLimiter.enable } - use_redis_snapshotting - it "will rate limit correctly" do stub_const(subject.class, :MAX_HYDE_SEARCHES_PER_MINUTE, 1) do stub_const(subject.class, :MAX_SEARCHES_PER_MINUTE, 2) do diff --git a/test/javascripts/unit/models/ai-persona-test.js b/test/javascripts/unit/models/ai-persona-test.js index f9674f44..c8178f19 100644 --- a/test/javascripts/unit/models/ai-persona-test.js +++ b/test/javascripts/unit/models/ai-persona-test.js @@ -37,8 +37,8 @@ module("Discourse AI | Unit | Model | ai-persona", function () { description: "Description", top_p: 0.8, temperature: 0.7, - mentionable: false, default_llm: "Default LLM", + force_default_llm: false, user: null, user_id: null, max_context_posts: 5, @@ -52,6 +52,10 @@ module("Discourse AI | Unit | Model | ai-persona", function () { allow_chat: false, tool_details: true, forced_tool_count: -1, + allow_personal_messages: true, + allow_topic_mentions: true, + allow_chat_channel_mentions: true, + allow_chat_direct_messages: true, }; const aiPersona = AiPersona.create({ ...properties }); @@ -82,7 +86,6 @@ module("Discourse AI | Unit | Model | ai-persona", function () { user: null, user_id: null, default_llm: "Default LLM", - mentionable: false, max_context_posts: 5, vision_enabled: true, vision_max_pixels: 100, @@ -94,6 +97,11 @@ module("Discourse AI | Unit | Model | ai-persona", function () { allow_chat: false, tool_details: true, forced_tool_count: -1, + allow_personal_messages: true, + allow_topic_mentions: true, + allow_chat_channel_mentions: true, + allow_chat_direct_messages: true, + force_default_llm: false, }; const aiPersona = AiPersona.create({ ...properties });