From 3a8d95f6b279c6fffc3e2f2cf261e0f15c961328 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 15 Feb 2024 16:37:59 +1100 Subject: [PATCH] FEATURE: mentionable personas and random picker tool, context limits (#466) 1. Personas are now optionally mentionable, meaning that you can mention them either from public topics or PMs - Mentioning from PMs helps "switch" persona mid conversation, meaning if you want to look up sites setting you can invoke the site setting bot, or if you want to generate an image you can invoke dall e - Mentioning outside of PMs allows you to inject a bot reply in a topic trivially - We also add the support for max_context_posts this allow you to limit the amount of context you feed in, which can help control costs 2. Add support for a "random picker" tool that can be used to pick random numbers 3. Clean up routing ai_personas -> ai-personas 4. Add Max Context Posts so users can control how much history a persona can consume (this is important for mentionable personas) Co-authored-by: Martin Brennan --- .../admin/ai_personas_controller.rb | 17 +- app/jobs/regular/create_ai_reply.rb | 17 +- app/models/ai_persona.rb | 134 +++++++++-- .../localized_ai_persona_serializer.rb | 8 +- .../admin-discourse-ai-personas-route-map.js | 2 +- .../discourse/admin/adapters/ai-persona.js | 8 +- .../discourse/admin/models/ai-persona.js | 18 ++ .../discourse/components/ai-llm-selector.js | 22 ++ .../components/ai-persona-editor.gjs | 128 +++++++++-- .../modules/ai-bot/common/ai-persona.scss | 13 +- config/locales/client.en.yml | 10 + config/locales/server.en.yml | 5 +- config/routes.rb | 6 +- ..._mentionable_default_llm_to_ai_personas.rb | 11 + ...20240213051213_add_limits_to_ai_persona.rb | 9 + lib/ai_bot/bot.rb | 3 +- lib/ai_bot/entry_point.rb | 13 +- lib/ai_bot/personas/persona.rb | 1 + lib/ai_bot/playground.rb | 209 ++++++++++++++---- lib/ai_bot/site_settings_extension.rb | 2 +- lib/ai_bot/tools/random_picker.rb | 73 ++++++ lib/completions/llm.rb | 70 +++--- .../jobs/regular/create_ai_reply_spec.rb | 2 + spec/lib/modules/ai_bot/playground_spec.rb | 145 +++++++++++- .../ai_bot/tools/random_picker_spec.rb | 57 +++++ spec/models/ai_persona_spec.rb | 86 +++++++ .../admin/ai_personas_controller_spec.rb | 66 ++++-- spec/system/ai_bot/persona_spec.rb | 8 +- .../unit/models/ai-persona-test.js | 10 + 29 files changed, 984 insertions(+), 169 deletions(-) create mode 100644 assets/javascripts/discourse/components/ai-llm-selector.js create mode 100644 db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb create mode 100644 db/migrate/20240213051213_add_limits_to_ai_persona.rb create mode 100644 lib/ai_bot/tools/random_picker.rb create mode 100644 spec/lib/modules/ai_bot/tools/random_picker_spec.rb diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb index 1a994aa2..0c5b75b1 100644 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb @@ -3,7 +3,7 @@ module DiscourseAi module Admin class AiPersonasController < ::Admin::AdminController - before_action :find_ai_persona, only: %i[show update destroy] + before_action :find_ai_persona, only: %i[show update destroy create_user] def index ai_personas = @@ -16,7 +16,11 @@ module DiscourseAi DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool| AiToolSerializer.new(tool, root: false) end - render json: { ai_personas: ai_personas, meta: { commands: tools } } + llms = + DiscourseAi::Configuration::LlmEnumerator.values.map do |hash| + { id: hash[:value], name: hash[:name] } + end + render json: { ai_personas: ai_personas, meta: { commands: tools, llms: llms } } end def show @@ -32,6 +36,11 @@ module DiscourseAi end end + def create_user + user = @ai_persona.create_user! + render json: BasicUserSerializer.new(user, root: "user") + end + def update if @ai_persona.update(ai_persona_params) render json: @ai_persona @@ -64,6 +73,10 @@ module DiscourseAi :priority, :top_p, :temperature, + :default_llm, + :user_id, + :mentionable, + :max_context_posts, allowed_group_ids: [], ) diff --git a/app/jobs/regular/create_ai_reply.rb b/app/jobs/regular/create_ai_reply.rb index c634e380..16f24b2e 100644 --- a/app/jobs/regular/create_ai_reply.rb +++ b/app/jobs/regular/create_ai_reply.rb @@ -7,22 +7,11 @@ module ::Jobs def execute(args) return unless bot_user = User.find_by(id: args[:bot_user_id]) return unless post = Post.includes(:topic).find_by(id: args[:post_id]) + persona_id = args[:persona_id] begin - persona = nil - if persona_id = post.topic.custom_fields["ai_persona_id"] - persona = - DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id.to_i) - raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil? - end - - if !persona && persona_name = post.topic.custom_fields["ai_persona"] - persona = - DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, name: persona_name) - raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil? - end - - persona ||= DiscourseAi::AiBot::Personas::General + persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id) + raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil? bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new) diff --git a/app/models/ai_persona.rb b/app/models/ai_persona.rb index 54e54020..587d2872 100644 --- a/app/models/ai_persona.rb +++ b/app/models/ai_persona.rb @@ -8,6 +8,10 @@ class AiPersona < ActiveRecord::Base validates :description, presence: true, length: { maximum: 2000 } validates :system_prompt, presence: true, length: { maximum: 10_000_000 } validate :system_persona_unchangeable, on: :update, if: :system + validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true + + belongs_to :created_by, class_name: "User" + belongs_to :user before_destroy :ensure_not_system @@ -56,6 +60,23 @@ class AiPersona < ActiveRecord::Base .map(&:class_instance) end + def self.mentionables + persona_cache[:mentionable_usernames] ||= AiPersona + .where(mentionable: true) + .where(enabled: true) + .joins(:user) + .pluck("ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm") + .map do |id, user_id, username, allowed_group_ids, default_llm| + { + id: id, + user_id: user_id, + username: username, + allowed_group_ids: allowed_group_ids, + default_llm: default_llm, + } + end + end + after_commit :bump_cache def bump_cache @@ -66,6 +87,10 @@ class AiPersona < ActiveRecord::Base allowed_group_ids = self.allowed_group_ids id = self.id system = self.system + user_id = self.user_id + mentionable = self.mentionable + default_llm = self.default_llm + max_context_posts = self.max_context_posts persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id] if persona_class @@ -81,6 +106,22 @@ class AiPersona < ActiveRecord::Base system end + persona_class.define_singleton_method :user_id do + user_id + end + + persona_class.define_singleton_method :mentionable do + mentionable + end + + persona_class.define_singleton_method :default_llm do + default_llm + end + + persona_class.define_singleton_method :max_context_posts do + max_context_posts + end + return persona_class end @@ -124,6 +165,10 @@ class AiPersona < ActiveRecord::Base name end + define_singleton_method :user_id do + user_id + end + define_singleton_method :description do description end @@ -136,6 +181,22 @@ class AiPersona < ActiveRecord::Base allowed_group_ids end + define_singleton_method :user_id do + user_id + end + + define_singleton_method :mentionable do + mentionable + end + + define_singleton_method :default_llm do + default_llm + end + + define_singleton_method :max_context_posts do + max_context_posts + end + define_singleton_method :to_s do "#" end @@ -171,6 +232,45 @@ class AiPersona < ActiveRecord::Base end end + FIRST_PERSONA_USER_ID = -1200 + + def create_user! + raise "User already exists" if user_id && User.exists?(user_id) + + # find the first id smaller than FIRST_USER_ID that is not taken + id = nil + + id = DB.query_single(<<~SQL, FIRST_PERSONA_USER_ID, FIRST_PERSONA_USER_ID - 200).first + WITH seq AS ( + SELECT generate_series(?, ?, -1) AS id + ) + SELECT seq.id FROM seq + LEFT JOIN users ON users.id = seq.id + WHERE users.id IS NULL + ORDER BY seq.id DESC + SQL + + id = DB.query_single(<<~SQL).first if id.nil? + SELECT min(id) - 1 FROM users + SQL + + # note .invalid is a reserved TLD which will route nowhere + user = + User.new( + email: "#{SecureRandom.hex}@does-not-exist.invalid", + name: name.titleize, + username: UserNameSuggester.suggest(name + "_bot"), + active: true, + approved: true, + trust_level: TrustLevel[4], + id: id, + ) + user.save!(validate: false) + + update!(user_id: user.id) + user + end + private def system_persona_unchangeable @@ -192,20 +292,26 @@ end # # Table name: ai_personas # -# id :bigint not null, primary key -# name :string(100) not null -# description :string(2000) not null -# commands :json not null -# system_prompt :string(10000000) not null -# allowed_group_ids :integer default([]), not null, is an Array -# created_by_id :integer -# enabled :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null -# system :boolean default(FALSE), not null -# priority :boolean default(FALSE), not null -# temperature :float -# top_p :float +# id :bigint not null, primary key +# name :string(100) not null +# description :string(2000) not null +# commands :json not null +# system_prompt :string(10000000) not null +# allowed_group_ids :integer default([]), not null, is an Array +# created_by_id :integer +# enabled :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# system :boolean default(FALSE), not null +# priority :boolean default(FALSE), not null +# 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 +# max_context_tokens :integer # # Indexes # diff --git a/app/serializers/localized_ai_persona_serializer.rb b/app/serializers/localized_ai_persona_serializer.rb index fca5f6d2..cebaf694 100644 --- a/app/serializers/localized_ai_persona_serializer.rb +++ b/app/serializers/localized_ai_persona_serializer.rb @@ -13,7 +13,13 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer :system_prompt, :allowed_group_ids, :temperature, - :top_p + :top_p, + :mentionable, + :default_llm, + :user_id, + :max_context_posts + + has_one :user, serializer: BasicUserSerializer, embed: :object def name object.class_instance.name diff --git a/assets/javascripts/discourse/admin-discourse-ai-personas-route-map.js b/assets/javascripts/discourse/admin-discourse-ai-personas-route-map.js index ed0b33b5..aff18154 100644 --- a/assets/javascripts/discourse/admin-discourse-ai-personas-route-map.js +++ b/assets/javascripts/discourse/admin-discourse-ai-personas-route-map.js @@ -5,7 +5,7 @@ export default { map() { this.route("discourse-ai", function () { - this.route("ai-personas", { path: "ai_personas" }, function () { + this.route("ai-personas", function () { this.route("new"); this.route("show", { path: "/:id" }); }); diff --git a/assets/javascripts/discourse/admin/adapters/ai-persona.js b/assets/javascripts/discourse/admin/adapters/ai-persona.js index 309eb273..6a837690 100644 --- a/assets/javascripts/discourse/admin/adapters/ai-persona.js +++ b/assets/javascripts/discourse/admin/adapters/ai-persona.js @@ -7,8 +7,12 @@ export default class Adapter extends RestAdapter { return "/admin/plugins/discourse-ai/"; } - pathFor() { - return super.pathFor(...arguments) + ".json"; + pathFor(store, type, findArgs) { + // removes underscores which are implemented in base + let path = + this.basePath(store, type, findArgs) + + store.pluralize(this.apiNameFor(type)); + return this.appendQueryParams(path, findArgs); } apiNameFor() { diff --git a/assets/javascripts/discourse/admin/models/ai-persona.js b/assets/javascripts/discourse/admin/models/ai-persona.js index 8d7c4171..80cca511 100644 --- a/assets/javascripts/discourse/admin/models/ai-persona.js +++ b/assets/javascripts/discourse/admin/models/ai-persona.js @@ -1,4 +1,5 @@ import { tracked } from "@glimmer/tracking"; +import { ajax } from "discourse/lib/ajax"; import RestModel from "discourse/models/rest"; const ATTRIBUTES = [ @@ -12,6 +13,11 @@ const ATTRIBUTES = [ "priority", "top_p", "temperature", + "user_id", + "mentionable", + "default_llm", + "user", + "max_context_posts", ]; class CommandOption { @@ -45,6 +51,18 @@ export default class AiPersona extends RestModel { this.commands = properties.commands; } + async createUser() { + const result = await ajax( + `/admin/plugins/discourse-ai/ai-personas/${this.id}/create-user.json`, + { + type: "POST", + } + ); + this.user = result.user; + this.user_id = this.user.id; + return this.user; + } + getCommandOption(commandId, optionId) { this.commandOptions ||= {}; this.commandOptions[commandId] ||= {}; diff --git a/assets/javascripts/discourse/components/ai-llm-selector.js b/assets/javascripts/discourse/components/ai-llm-selector.js new file mode 100644 index 00000000..be372885 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-llm-selector.js @@ -0,0 +1,22 @@ +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: "blank", + name: I18n.t("discourse_ai.ai_persona.no_llm_selected"), + }, + ].concat(this.llms); + }), + + selectKitOptions: { + filterable: true, + }, +}); diff --git a/assets/javascripts/discourse/components/ai-persona-editor.gjs b/assets/javascripts/discourse/components/ai-persona-editor.gjs index b172d0de..ddb0e32e 100644 --- a/assets/javascripts/discourse/components/ai-persona-editor.gjs +++ b/assets/javascripts/discourse/components/ai-persona-editor.gjs @@ -5,17 +5,21 @@ import { on } from "@ember/modifier"; import { action } from "@ember/object"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import { LinkTo } from "@ember/routing"; import { later } from "@ember/runloop"; import { inject as service } from "@ember/service"; import DButton from "discourse/components/d-button"; import Textarea from "discourse/components/d-textarea"; import DToggleSwitch from "discourse/components/d-toggle-switch"; +import Avatar from "discourse/helpers/bound-avatar-template"; import { popupAjaxError } from "discourse/lib/ajax-error"; import Group from "discourse/models/group"; import I18n from "discourse-i18n"; +import AdminUser from "admin/models/admin-user"; import GroupChooser from "select-kit/components/group-chooser"; 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"; export default class PersonaEditor extends Component { @@ -81,6 +85,22 @@ export default class PersonaEditor extends Component { return this.editingModel?.top_p || !this.editingModel?.system; } + get adminUser() { + return AdminUser.create(this.editingModel?.user); + } + + get mappedDefaultLlm() { + return this.editingModel?.default_llm || "blank"; + } + + set mappedDefaultLlm(value) { + if (value === "blank") { + this.editingModel.default_llm = null; + } else { + this.editingModel.default_llm = value; + } + } + @action delete() { return this.dialog.confirm({ @@ -103,26 +123,42 @@ export default class PersonaEditor extends Component { @action async toggleEnabled() { - this.args.model.set("enabled", !this.args.model.enabled); - this.editingModel.set("enabled", this.args.model.enabled); - if (!this.args.model.isNew) { - try { - await this.args.model.update({ enabled: this.args.model.enabled }); - } catch (e) { - popupAjaxError(e); - } - } + await this.toggleField("enabled"); } @action async togglePriority() { - this.args.model.set("priority", !this.args.model.priority); - this.editingModel.set("priority", this.args.model.priority); + await this.toggleField("priority", true); + } + + @action + async toggleMentionable() { + await this.toggleField("mentionable"); + } + + @action + async createUser() { + try { + let user = await this.args.model.createUser(); + this.editingModel.set("user", user); + this.editingModel.set("user_id", user.id); + } catch (e) { + popupAjaxError(e); + } + } + + async toggleField(field, sortPersonas) { + this.args.model.set(field, !this.args.model[field]); + this.editingModel.set(field, this.args.model[field]); if (!this.args.model.isNew) { try { - await this.args.model.update({ priority: this.args.model.priority }); + const args = {}; + args[field] = this.args.model[field]; - this.#sortPersonas(); + await this.args.model.update(args); + if (sortPersonas) { + this.#sortPersonas(); + } } catch (e) { popupAjaxError(e); } @@ -170,6 +206,20 @@ export default class PersonaEditor extends Component { @content={{I18n.t "discourse_ai.ai_persona.priority_help"}} /> + {{#if this.editingModel.user}} +
+ + +
+ {{/if}}
+
+ + + +
+ {{#unless @model.isNew}} +
+ + {{#if this.editingModel.user}} + + {{Avatar this.editingModel.user.avatar_template "small"}} + + + {{this.editingModel.user.username}} + + {{else}} + + {{I18n.t "discourse_ai.ai_persona.create_user"}} + + + {{/if}} +
+ {{/unless}}
+
+ + + +
{{#if this.showTemperature}}
diff --git a/assets/stylesheets/modules/ai-bot/common/ai-persona.scss b/assets/stylesheets/modules/ai-bot/common/ai-persona.scss index 80f54644..96df9d12 100644 --- a/assets/stylesheets/modules/ai-bot/common/ai-persona.scss +++ b/assets/stylesheets/modules/ai-bot/common/ai-persona.scss @@ -31,6 +31,10 @@ } .ai-persona-editor { + .fk-d-tooltip__icon { + padding-left: 0.25em; + color: var(--primary-medium); + } label { display: block; } @@ -53,10 +57,9 @@ &__priority { display: flex; align-items: center; - - .fk-d-tooltip__icon { - padding-left: 0.25em; - color: var(--primary-medium); - } + } + &__mentionable { + display: flex; + align-items: center; } } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 4d3e9a61..a2f1eb9d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -108,6 +108,16 @@ en: ai_persona: name: Name description: Description + no_llm_selected: "No language model selected" + max_context_posts: "Max Context Posts" + max_context_posts_help: "The maximum number of posts to use as context for the AI when responding to a user. (empty for default)" + 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 + 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 + default_llm_help: The default language model to use for this persona. Required if you wish to mention persona on public posts. system_prompt: System Prompt save: Save saved: AI Persona Saved diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index cd747829..79ab4877 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -182,6 +182,7 @@ en: name: "Base Search Query" description: "Base query to use when searching. Example: '#urgent' will prepend '#urgent' to the search query and only include topics with the urgent category or tag." command_summary: + random_picker: "Random Picker" categories: "List categories" search: "Search" tags: "List tags" @@ -195,6 +196,7 @@ en: search_settings: "Searching site settings" dall_e: "Generate image" command_help: + random_picker: "Pick a random number or a random element of a list" categories: "List all publicly visible categories on the forum" search: "Search all public topics on the forum" tags: "List all tags on the forum" @@ -208,6 +210,7 @@ en: search_settings: "Search site settings" dall_e: "Generate image using DALL-E 3" command_description: + random_picker: "Picking from %{options}, picked: %{result}" read: "Reading: %{title}" time: "Time in %{timezone} is %{time}" summarize: "Summarized %{title}" @@ -268,6 +271,6 @@ en: disable_embeddings: "You have to disable 'ai embeddings enabled' first." choose_model: "Set 'ai embeddings model' first." model_unreachable: "We failed to generate a test embedding with this model. Check your settings are correct." - hint: + hint: one: "Make sure the `%{settings}` setting was configured." other: "Make sure the settings of the provider you want were configured. Options are: %{settings}" diff --git a/config/routes.rb b/config/routes.rb index 66a29e02..54d6820f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,9 +27,13 @@ Discourse::Application.routes.draw do :constraints => StaffConstraint.new scope "/admin/plugins/discourse-ai", constraints: AdminConstraint.new do - get "/", to: redirect("/admin/plugins/discourse-ai/ai_personas") + get "/", to: redirect("/admin/plugins/discourse-ai/ai-personas") + resources :ai_personas, only: %i[index create show update destroy], + path: "ai-personas", controller: "discourse_ai/admin/ai_personas" + + post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user" end end diff --git a/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb b/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb new file mode 100644 index 00000000..cf5e0315 --- /dev/null +++ b/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +# +class AddUserIdMentionableDefaultLlmToAiPersonas < ActiveRecord::Migration[7.0] + def change + change_table :ai_personas do |t| + t.integer :user_id, null: true + t.boolean :mentionable, default: false, null: false + t.text :default_llm, null: true, length: 250 + end + end +end diff --git a/db/migrate/20240213051213_add_limits_to_ai_persona.rb b/db/migrate/20240213051213_add_limits_to_ai_persona.rb new file mode 100644 index 00000000..fac906e4 --- /dev/null +++ b/db/migrate/20240213051213_add_limits_to_ai_persona.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddLimitsToAiPersona < ActiveRecord::Migration[7.0] + def change + change_table :ai_personas do |t| + t.integer :max_context_posts, null: true + end + end +end diff --git a/lib/ai_bot/bot.rb b/lib/ai_bot/bot.rb index 655f1c8a..c7a38496 100644 --- a/lib/ai_bot/bot.rb +++ b/lib/ai_bot/bot.rb @@ -16,6 +16,7 @@ module DiscourseAi end attr_reader :bot_user + attr_accessor :persona def get_updated_title(conversation_context, post_user) system_insts = <<~TEXT.strip @@ -111,8 +112,6 @@ module DiscourseAi raw_context end - attr_reader :persona - private def invoke_tool(tool, llm, cancel, &update_blk) diff --git a/lib/ai_bot/entry_point.rb b/lib/ai_bot/entry_point.rb index f0a48fa0..da8e8183 100644 --- a/lib/ai_bot/entry_point.rb +++ b/lib/ai_bot/entry_point.rb @@ -23,6 +23,8 @@ module DiscourseAi [FAKE_ID, "fake_bot", "fake"], ] + BOT_USER_IDS = BOTS.map(&:first) + def self.map_bot_model_to_user_id(model_name) case model_name in "gpt-4-turbo" @@ -111,16 +113,7 @@ module DiscourseAi name || topic.custom_fields["ai_persona"] end - plugin.on(:post_created) do |post| - bot_ids = BOTS.map(&:first) - - # Don't schedule a reply for a bot reply. - if !bot_ids.include?(post.user_id) - bot_user = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user - bot = DiscourseAi::AiBot::Bot.as(bot_user) - DiscourseAi::AiBot::Playground.new(bot).update_playground_with(post) - end - end + plugin.on(:post_created) { |post| DiscourseAi::AiBot::Playground.schedule_reply(post) } if plugin.respond_to?(:register_editable_topic_custom_field) plugin.register_editable_topic_custom_field(:ai_persona_id) diff --git a/lib/ai_bot/personas/persona.rb b/lib/ai_bot/personas/persona.rb index e56d3362..4db8690b 100644 --- a/lib/ai_bot/personas/persona.rb +++ b/lib/ai_bot/personas/persona.rb @@ -61,6 +61,7 @@ module DiscourseAi Tools::SearchSettings, Tools::Summarize, Tools::SettingContext, + Tools::RandomPicker, ] tools << Tools::ListTags if SiteSetting.tagging_enabled diff --git a/lib/ai_bot/playground.rb b/lib/ai_bot/playground.rb index a4116439..65ea6788 100644 --- a/lib/ai_bot/playground.rb +++ b/lib/ai_bot/playground.rb @@ -3,26 +3,101 @@ module DiscourseAi module AiBot class Playground + attr_reader :bot + # An abstraction to manage the bot and topic interactions. # The bot will take care of completions while this class updates the topic title # and stream replies. REQUIRE_TITLE_UPDATE = "discourse-ai-title-update" + def self.schedule_reply(post) + bot_ids = DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS + + return if bot_ids.include?(post.user_id) + if AiPersona.mentionables.any? { |mentionable| mentionable[:user_id] == post.user_id } + return + end + + bot_user = nil + mentioned = nil + + if post.topic.private_message? + bot_user = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user + end + + if AiPersona.mentionables.length > 0 + mentions = post.mentions.map(&:downcase) + mentioned = + AiPersona.mentionables.find do |mentionable| + mentions.include?(mentionable[:username]) && + (post.user.group_ids & mentionable[:allowed_group_ids]).present? + end + + # PM always takes precedence + if mentioned && !bot_user + model_without_provider = mentioned[:default_llm].split(":").last + user_id = + DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(model_without_provider) + + if !user_id + Rails.logger.warn( + "Model #{mentioned[:default_llm]} not found for persona #{mentioned[:username]}", + ) + if Rails.env.development? || Rails.env.test? + raise "Model #{mentioned[:default_llm]} not found for persona #{mentioned[:username]}" + end + else + bot_user = User.find_by(id: user_id) + end + end + end + + if bot_user + persona_id = mentioned&.dig(:id) || post.topic.custom_fields["ai_persona_id"] + persona = nil + + if persona_id + persona = + DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id.to_i) + end + + if !persona && persona_name = post.topic.custom_fields["ai_persona"] + persona = + DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, name: persona_name) + end + + persona ||= DiscourseAi::AiBot::Personas::General + + bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new) + new(bot).update_playground_with(post) + end + end + def initialize(bot) @bot = bot end def update_playground_with(post) - if can_attach?(post) && bot.bot_user - schedule_playground_titling(post, bot.bot_user) - schedule_bot_reply(post, bot.bot_user) + if can_attach?(post) + schedule_playground_titling(post) + schedule_bot_reply(post) end end def conversation_context(post) # Pay attention to the `post_number <= ?` here. # We want to inject the last post as context because they are translated differently. + + # also setting default to 40, allowing huge contexts costs lots of tokens + max_posts = 40 + if bot.persona.class.respond_to?(:max_context_posts) + max_posts = bot.persona.class.max_context_posts || 40 + end + + post_types = [Post.types[:regular]] + post_types << Post.types[:whisper] if post.post_type == Post.types[:whisper] + context = post .topic @@ -31,8 +106,8 @@ module DiscourseAi .joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id") .where("post_number <= ?", post.post_number) .order("post_number desc") - .where("post_type = ?", Post.types[:regular]) - .limit(50) + .where("post_type in (?)", post_types) + .limit(max_posts) .pluck(:raw, :username, "post_custom_prompts.custom_prompt") result = [] @@ -96,6 +171,9 @@ module DiscourseAi reply = +"" start = Time.now + post_type = + post.post_type == Post.types[:whisper] ? Post.types[:whisper] : Post.types[:regular] + context = { site_url: Discourse.base_url, site_title: SiteSetting.title, @@ -106,19 +184,36 @@ module DiscourseAi user: post.user, } - reply_post = - PostCreator.create!( - bot.bot_user, - topic_id: post.topic_id, - raw: "", - skip_validations: true, - skip_jobs: true, - ) + reply_user = bot.bot_user + if bot.persona.class.respond_to?(:user_id) + reply_user = User.find_by(id: bot.persona.class.user_id) || reply_user + end - publish_update(reply_post, { raw: reply_post.cooked }) + stream_reply = post.topic.private_message? - redis_stream_key = "gpt_cancel:#{reply_post.id}" - Discourse.redis.setex(redis_stream_key, 60, 1) + # we need to ensure persona user is allowed to reply to the pm + if post.topic.private_message? + if !post.topic.topic_allowed_users.exists?(user_id: reply_user.id) + post.topic.topic_allowed_users.create!(user_id: reply_user.id) + end + end + + if stream_reply + reply_post = + PostCreator.create!( + reply_user, + topic_id: post.topic_id, + raw: "", + skip_validations: true, + skip_jobs: true, + post_type: post_type, + ) + + publish_update(reply_post, { raw: reply_post.cooked }) + + redis_stream_key = "gpt_cancel:#{reply_post.id}" + Discourse.redis.setex(redis_stream_key, 60, 1) + end new_custom_prompts = bot.reply(context) do |partial, cancel, placeholder| @@ -126,30 +221,47 @@ module DiscourseAi raw = reply.dup raw << "\n\n" << placeholder if placeholder.present? - if !Discourse.redis.get(redis_stream_key) + if stream_reply && !Discourse.redis.get(redis_stream_key) cancel&.call - reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) end - # Minor hack to skip the delay during tests. - if placeholder.blank? - next if (Time.now - start < 0.5) && !Rails.env.test? - start = Time.now + if stream_reply + # Minor hack to skip the delay during tests. + if placeholder.blank? + next if (Time.now - start < 0.5) && !Rails.env.test? + start = Time.now + end + + Discourse.redis.expire(redis_stream_key, 60) + + publish_update(reply_post, { raw: raw }) end - - Discourse.redis.expire(redis_stream_key, 60) - - publish_update(reply_post, { raw: raw }) end return if reply.blank? - # land the final message prior to saving so we don't clash - reply_post.cooked = PrettyText.cook(reply) - publish_final_update(reply_post) + if stream_reply + # land the final message prior to saving so we don't clash + reply_post.cooked = PrettyText.cook(reply) + publish_final_update(reply_post) - reply_post.revise(bot.bot_user, { raw: reply }, skip_validations: true, skip_revision: true) + reply_post.revise( + bot.bot_user, + { raw: reply }, + skip_validations: true, + skip_revision: true, + ) + else + reply_post = + PostCreator.create!( + reply_user, + topic_id: post.topic_id, + raw: reply, + skip_validations: true, + post_type: post_type, + ) + end # not need to add a custom prompt for a single reply if new_custom_prompts.length > 1 @@ -161,7 +273,7 @@ module DiscourseAi reply_post ensure - publish_final_update(reply_post) + publish_final_update(reply_post) if stream_reply end private @@ -179,33 +291,38 @@ module DiscourseAi end end - attr_reader :bot - def can_attach?(post) return false if bot.bot_user.nil? - return false if post.post_type != Post.types[:regular] - return false unless post.topic.private_message? + return false if post.topic.private_message? && post.post_type != Post.types[:regular] return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank? true end - def schedule_playground_titling(post, bot_user) - if post.post_number == 1 + def schedule_playground_titling(post) + if post.post_number == 1 && post.topic.private_message? post.topic.custom_fields[REQUIRE_TITLE_UPDATE] = true post.topic.save_custom_fields - end - ::Jobs.enqueue_in( - 5.minutes, - :update_ai_bot_pm_title, - post_id: post.id, - bot_user_id: bot_user.id, - ) + ::Jobs.enqueue_in( + 5.minutes, + :update_ai_bot_pm_title, + post_id: post.id, + bot_user_id: bot.bot_user.id, + ) + end end - def schedule_bot_reply(post, bot_user) - ::Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_user.id) + 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) diff --git a/lib/ai_bot/site_settings_extension.rb b/lib/ai_bot/site_settings_extension.rb index 4e8d11a2..29a404e3 100644 --- a/lib/ai_bot/site_settings_extension.rb +++ b/lib/ai_bot/site_settings_extension.rb @@ -4,11 +4,11 @@ module DiscourseAi::AiBot::SiteSettingsExtension def self.enable_or_disable_ai_bots enabled_bots = SiteSetting.ai_bot_enabled_chat_bots_map enabled_bots = [] if !SiteSetting.ai_bot_enabled + DiscourseAi::AiBot::EntryPoint::BOTS.each do |id, bot_name, name| if id == DiscourseAi::AiBot::EntryPoint::FAKE_ID next if Rails.env.production? end - active = enabled_bots.include?(name) user = User.find_by(id: id) diff --git a/lib/ai_bot/tools/random_picker.rb b/lib/ai_bot/tools/random_picker.rb new file mode 100644 index 00000000..60000bb6 --- /dev/null +++ b/lib/ai_bot/tools/random_picker.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class RandomPicker < Tool + def self.signature + { + name: name, + description: + "Handles a variety of random decisions based on the format of each input element", + parameters: [ + { + name: "options", + description: + "An array where each element is either a range (e.g., '1-6') or a comma-separated list of options (e.g., 'sam,jane,joe')", + type: "array", + item_type: "string", + required: true, + }, + ], + } + end + + def self.name + "random_picker" + end + + def options + parameters[:options] + end + + def invoke(_bot_user, _llm) + result = nil + # can be a naive list of strings + if options.none? { |option| option.match?(/\A\d+-\d+\z/) || option.include?(",") } + result = options.sample + else + result = + options.map do |option| + case option + when /\A\d+-\d+\z/ # Range format, e.g., "1-6" + random_range(option) + when /,/ # Comma-separated values, e.g., "sam,jane,joe" + pick_list(option) + else + "Invalid format: #{option}" + end + end + end + + @last_result = result + { options: options, result: result } + end + + private + + def random_range(range_str) + low, high = range_str.split("-").map(&:to_i) + rand(low..high) + end + + def pick_list(list_str) + list_str.split(",").map(&:strip).sample + end + + def description_args + { options: options, result: @last_result } + end + end + end + end +end diff --git a/lib/completions/llm.rb b/lib/completions/llm.rb index a7932fe9..5bae7500 100644 --- a/lib/completions/llm.rb +++ b/lib/completions/llm.rb @@ -21,37 +21,50 @@ module DiscourseAi def models_by_provider # ChatGPT models are listed under open_ai but they are actually available through OpenAI and Azure. # However, since they use the same URL/key settings, there's no reason to duplicate them. - { - aws_bedrock: %w[claude-instant-1 claude-2], - anthropic: %w[claude-instant-1 claude-2], - vllm: %w[ - mistralai/Mixtral-8x7B-Instruct-v0.1 - mistralai/Mistral-7B-Instruct-v0.2 - StableBeluga2 - Upstage-Llama-2-*-instruct-v2 - Llama2-*-chat-hf - Llama2-chat-hf - ], - hugging_face: %w[ - mistralai/Mixtral-8x7B-Instruct-v0.1 - mistralai/Mistral-7B-Instruct-v0.2 - StableBeluga2 - Upstage-Llama-2-*-instruct-v2 - Llama2-*-chat-hf - Llama2-chat-hf - ], - open_ai: %w[gpt-3.5-turbo gpt-4 gpt-3.5-turbo-16k gpt-4-32k gpt-4-turbo], - google: %w[gemini-pro], - }.tap { |h| h[:fake] = ["fake"] if Rails.env.test? || Rails.env.development? } + @models_by_provider ||= + { + aws_bedrock: %w[claude-instant-1 claude-2], + anthropic: %w[claude-instant-1 claude-2], + vllm: %w[ + mistralai/Mixtral-8x7B-Instruct-v0.1 + mistralai/Mistral-7B-Instruct-v0.2 + StableBeluga2 + Upstage-Llama-2-*-instruct-v2 + Llama2-*-chat-hf + Llama2-chat-hf + ], + hugging_face: %w[ + mistralai/Mixtral-8x7B-Instruct-v0.1 + mistralai/Mistral-7B-Instruct-v0.2 + StableBeluga2 + Upstage-Llama-2-*-instruct-v2 + Llama2-*-chat-hf + Llama2-chat-hf + ], + open_ai: %w[gpt-3.5-turbo gpt-4 gpt-3.5-turbo-16k gpt-4-32k gpt-4-turbo], + google: %w[gemini-pro], + }.tap { |h| h[:fake] = ["fake"] if Rails.env.test? || Rails.env.development? } end - def with_prepared_responses(responses) - @canned_response = DiscourseAi::Completions::Endpoints::CannedResponse.new(responses) + def valid_provider_models + return @valid_provider_models if defined?(@valid_provider_models) - yield(@canned_response) + valid_provider_models = [] + models_by_provider.each do |provider, models| + valid_provider_models.concat(models.map { |model| "#{provider}:#{model}" }) + end + @valid_provider_models = Set.new(valid_provider_models) + end + + def with_prepared_responses(responses, llm: nil) + @canned_response = DiscourseAi::Completions::Endpoints::CannedResponse.new(responses) + @canned_llm = llm + + yield(@canned_response, llm) ensure # Don't leak prepared response if there's an exception. @canned_response = nil + @canned_llm = nil end def proxy(model_name) @@ -63,7 +76,12 @@ module DiscourseAi dialect_klass = DiscourseAi::Completions::Dialects::Dialect.dialect_for(model_name_without_prov) - return new(dialect_klass, @canned_response, model_name) if @canned_response + if @canned_response + if @canned_llm && @canned_llm != model_name + raise "Invalid call LLM call, expected #{@canned_llm} but got #{model_name}" + end + return new(dialect_klass, @canned_response, model_name) + end gateway = DiscourseAi::Completions::Endpoints::Base.endpoint_for( diff --git a/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb b/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb index 8fe58306..ce78af12 100644 --- a/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb +++ b/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb @@ -14,10 +14,12 @@ RSpec.describe Jobs::CreateAiReply do before { SiteSetting.min_personal_message_post_length = 5 } it "adds a reply from the bot" do + persona_id = AiPersona.find_by(name: "Forum Helper").id DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do subject.execute( post_id: topic.first_post.id, bot_user_id: DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID, + persona_id: persona_id, ) end diff --git a/spec/lib/modules/ai_bot/playground_spec.rb b/spec/lib/modules/ai_bot/playground_spec.rb index 63a3c16e..918a3973 100644 --- a/spec/lib/modules/ai_bot/playground_spec.rb +++ b/spec/lib/modules/ai_bot/playground_spec.rb @@ -3,16 +3,29 @@ RSpec.describe DiscourseAi::AiBot::Playground do subject(:playground) { described_class.new(bot) } - before do + fab!(:bot_user) do SiteSetting.ai_bot_enabled_chat_bots = "claude-2" SiteSetting.ai_bot_enabled = true + User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) end - let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) } - let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user) } + fab!(:bot) do + persona = + AiPersona + .find( + DiscourseAi::AiBot::Personas::Persona.system_personas[ + DiscourseAi::AiBot::Personas::General + ], + ) + .class_instance + .new + DiscourseAi::AiBot::Bot.as(bot_user, persona: persona) + end - fab!(:user) { Fabricate(:user) } - let!(:pm) do + fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) } + + fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } + fab!(:pm) do Fabricate( :private_message_topic, title: "This is my special PM", @@ -23,13 +36,13 @@ RSpec.describe DiscourseAi::AiBot::Playground do ], ) end - let!(:first_post) do + fab!(:first_post) do Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user") end - let!(:second_post) do + fab!(:second_post) do Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply") end - let!(:third_post) do + fab!(:third_post) do Fabricate( :post, topic: pm, @@ -39,6 +52,93 @@ RSpec.describe DiscourseAi::AiBot::Playground do ) end + describe "persona with user support" do + before do + Jobs.run_immediately! + SiteSetting.ai_bot_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" + end + + fab!(:persona) do + persona = + AiPersona.create!( + name: "Test Persona", + description: "A test persona", + allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], + enabled: true, + system_prompt: "You are a helpful bot", + ) + + persona.create_user! + persona.update!(default_llm: "claude-2", mentionable: true) + persona + end + + it "replies to whispers with a whisper" do + post = nil + DiscourseAi::Completions::Llm.with_prepared_responses(["Yes I can"]) do + post = + create_post( + title: "My public topic", + raw: "Hey @#{persona.user.username}, can you help me?", + post_type: Post.types[:whisper], + ) + end + + post.topic.reload + 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) + expect(last_post.post_type).to eq(Post.types[:whisper]) + end + + it "allows mentioning a persona" do + post = nil + DiscourseAi::Completions::Llm.with_prepared_responses(["Yes I can"]) do + post = + create_post( + title: "My public topic", + raw: "Hey @#{persona.user.username}, can you help me?", + ) + end + + post.topic.reload + 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 + # If you start a PM with GPT 3.5 bot, replies should come from it, not from Claude + SiteSetting.ai_bot_enabled = true + SiteSetting.ai_bot_enabled_chat_bots = "gpt-3.5-turbo|claude-2" + + post = nil + gpt3_5_bot_user = User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) + + # title is queued first, ensures it uses the llm targeted via target_usernames not claude + DiscourseAi::Completions::Llm.with_prepared_responses( + ["Magic title", "Yes I can"], + llm: "open_ai:gpt-3.5-turbo-16k", + ) do + post = + create_post( + title: "I just made a PM", + raw: "Hey @#{persona.user.username}, can you help me?", + target_usernames: "#{user.username},#{gpt3_5_bot_user.username}", + archetype: Archetype.private_message, + user: admin, + ) + 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) + + last_post.topic.reload + expect(last_post.topic.allowed_users.pluck(:user_id)).to include(persona.user_id) + end + end + describe "#title_playground" do let(:expected_response) { "This is a suggested title" } @@ -112,7 +212,16 @@ RSpec.describe DiscourseAi::AiBot::Playground do context "with Dall E bot" do let(:bot) do - DiscourseAi::AiBot::Bot.as(bot_user, persona: DiscourseAi::AiBot::Personas::DallE3.new) + persona = + AiPersona + .find( + DiscourseAi::AiBot::Personas::Persona.system_personas[ + DiscourseAi::AiBot::Personas::DallE3 + ], + ) + .class_instance + .new + DiscourseAi::AiBot::Bot.as(bot_user, persona: persona) end it "does not include placeholders in conversation context (simulate DALL-E)" do @@ -155,6 +264,24 @@ RSpec.describe DiscourseAi::AiBot::Playground do end describe "#conversation_context" do + context "with limited context" do + before do + @old_persona = playground.bot.persona + persona = Fabricate(:ai_persona, max_context_posts: 1) + playground.bot.persona = persona.class_instance.new + end + + after { playground.bot.persona = @old_persona } + + it "respects max_context_post" do + context = playground.conversation_context(third_post) + + expect(context).to contain_exactly( + *[{ type: :user, id: user.username, content: third_post.raw }], + ) + end + end + it "includes previous posts ordered by post_number" do context = playground.conversation_context(third_post) diff --git a/spec/lib/modules/ai_bot/tools/random_picker_spec.rb b/spec/lib/modules/ai_bot/tools/random_picker_spec.rb new file mode 100644 index 00000000..80c24548 --- /dev/null +++ b/spec/lib/modules/ai_bot/tools/random_picker_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DiscourseAi::AiBot::Tools::RandomPicker do + describe "#invoke" do + subject { described_class.new({ options: options }).invoke(nil, nil) } + + context "with options as simple list of strings" do + let(:options) { %w[apple banana cherry] } + + it "returns one of the options" do + expect(options).to include(subject[:result]) + end + end + + context "with options as ranges" do + let(:options) { %w[1-3 10-20] } + + it "returns a number within one of the provided ranges" do + results = subject[:result] + expect(results).to all( + satisfy { |result| (1..3).include?(result) || (10..20).include?(result) }, + ) + end + end + + context "with options as comma-separated values" do + let(:options) { %w[red,green,blue mon,tue,wed] } + + it "returns one value from each comma-separated list" do + results = subject[:result] + expect(results).to include(a_kind_of(String)) + results.each { |result| expect(result.split(",")).to include(result) } + end + end + + context "with mixed options (list, range, and comma-separated)" do + let(:options) { %w[apple 1-3 mon,tue,wed] } + + it "handles each option appropriately" do + results = subject[:result] + expect(results.size).to eq(options.size) + # Verifying each type of option is respected needs a more elaborate setup, + # potentially mocking or specific expectations for each type. + end + end + + context "with an invalid format in options" do + let(:options) { ["invalid_format"] } + + it "returns an error message for invalid formats" do + expect(subject[:result]).to include("invalid_format") + end + end + end +end diff --git a/spec/models/ai_persona_spec.rb b/spec/models/ai_persona_spec.rb index 7e79c2d1..e159299d 100644 --- a/spec/models/ai_persona_spec.rb +++ b/spec/models/ai_persona_spec.rb @@ -1,6 +1,92 @@ # frozen_string_literal: true RSpec.describe AiPersona do + it "validates context settings" do + persona = + AiPersona.new( + name: "test", + description: "test", + system_prompt: "test", + commands: [], + allowed_group_ids: [], + ) + + expect(persona.valid?).to eq(true) + + persona.max_context_posts = 0 + expect(persona.valid?).to eq(false) + expect(persona.errors[:max_context_posts]).to eq(["must be greater than 0"]) + + persona.max_context_posts = 1 + expect(persona.valid?).to eq(true) + + persona.max_context_posts = nil + expect(persona.valid?).to eq(true) + end + + it "allows creation of user" do + persona = + AiPersona.create!( + name: "test", + description: "test", + system_prompt: "test", + commands: [], + allowed_group_ids: [], + ) + + user = persona.create_user! + expect(user.username).to eq("test_bot") + expect(user.name).to eq("Test") + expect(user.bot?).to be(true) + expect(user.id).to be <= AiPersona::FIRST_PERSONA_USER_ID + end + + it "defines singleton methods on system persona classes" 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, + ) + + klass = forum_helper.class_instance + + expect(klass.id).to eq(forum_helper.id) + expect(klass.system).to eq(true) + # 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) + end + + it "defines singleton methods non persona classes" do + persona = + AiPersona.create!( + name: "test", + description: "test", + system_prompt: "test", + commands: [], + allowed_group_ids: [], + default_llm: "anthropic:claude-2", + max_context_posts: 3, + mentionable: true, + user_id: 1, + ) + + klass = persona.class_instance + + expect(klass.id).to eq(persona.id) + 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) + end + it "does not leak caches between sites" do AiPersona.create!( name: "pun_bot", diff --git a/spec/requests/admin/ai_personas_controller_spec.rb b/spec/requests/admin/ai_personas_controller_spec.rb index 3136cc8f..02dbcee8 100644 --- a/spec/requests/admin/ai_personas_controller_spec.rb +++ b/spec/requests/admin/ai_personas_controller_spec.rb @@ -8,7 +8,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do describe "GET #index" do it "returns a success response" do - get "/admin/plugins/discourse-ai/ai_personas.json" + get "/admin/plugins/discourse-ai/ai-personas.json" expect(response).to be_successful expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count) @@ -17,6 +17,17 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do ) end + it "sideloads llms" do + get "/admin/plugins/discourse-ai/ai-personas.json" + expect(response).to be_successful + + expect(response.parsed_body["meta"]["llms"]).to eq( + DiscourseAi::Configuration::LlmEnumerator.values.map do |hash| + { "id" => hash[:value], "name" => hash[:name] } + end, + ) + end + it "returns commands options with each command" do persona1 = Fabricate(:ai_persona, name: "search1", commands: ["SearchCommand"]) persona2 = @@ -24,14 +35,22 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do :ai_persona, name: "search2", commands: [["SearchCommand", { base_query: "test" }]], + mentionable: true, + default_llm: "anthropic:claude-2", ) + persona2.create_user! - get "/admin/plugins/discourse-ai/ai_personas.json" + get "/admin/plugins/discourse-ai/ai-personas.json" expect(response).to be_successful 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["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) + commands = response.parsed_body["meta"]["commands"] search_command = commands.find { |c| c["id"] == "Search" } @@ -86,7 +105,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "returns localized persona names and descriptions" do - get "/admin/plugins/discourse-ai/ai_personas.json" + get "/admin/plugins/discourse-ai/ai-personas.json" id = DiscourseAi::AiBot::Personas::Persona.system_personas[ @@ -102,7 +121,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do describe "GET #show" do it "returns a success response" do - get "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json" + get "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json" expect(response).to be_successful expect(response.parsed_body["ai_persona"]["name"]).to eq(ai_persona.name) end @@ -118,12 +137,14 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do commands: [["search", { "base_query" => "test" }]], top_p: 0.1, temperature: 0.5, + mentionable: true, + default_llm: "anthropic:claude-2", } end it "creates a new AiPersona" do expect { - post "/admin/plugins/discourse-ai/ai_personas.json", + post "/admin/plugins/discourse-ai/ai-personas.json", params: { ai_persona: valid_attributes }.to_json, headers: { "CONTENT_TYPE" => "application/json", @@ -134,6 +155,8 @@ 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") persona = AiPersona.find(persona_json["id"]) @@ -146,18 +169,27 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do context "with invalid params" do it "renders a JSON response with errors for the new ai_persona" do - post "/admin/plugins/discourse-ai/ai_personas.json", params: { ai_persona: { foo: "" } } # invalid attribute + post "/admin/plugins/discourse-ai/ai-personas.json", params: { ai_persona: { foo: "" } } # invalid attribute expect(response).to have_http_status(:unprocessable_entity) expect(response.content_type).to include("application/json") end end end + describe "POST #create_user" do + it "creates a user for the persona" do + post "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}/create-user.json" + ai_persona.reload + + expect(response).to be_successful + expect(response.parsed_body["user"]["id"]).to eq(ai_persona.user_id) + end + end + describe "PUT #update" do it "allows us to trivially clear top_p and temperature" do persona = Fabricate(:ai_persona, name: "test_bot2", top_p: 0.5, temperature: 0.1) - - put "/admin/plugins/discourse-ai/ai_personas/#{persona.id}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json", params: { ai_persona: { top_p: "", @@ -173,7 +205,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does not allow temperature and top p changes on stock personas" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { top_p: 0.5, @@ -186,7 +218,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do context "with valid params" do it "updates the requested ai_persona" do - put "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json", params: { ai_persona: { name: "SuperBot", @@ -207,7 +239,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do context "with system personas" do it "does not allow editing of system prompts" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { system_prompt: "you are not a helpful bot", @@ -220,7 +252,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does not allow editing of commands" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { commands: %w[SearchCommand ImageCommand], @@ -233,7 +265,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does not allow editing of name and description cause it is localized" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { name: "bob", @@ -247,7 +279,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does allow some actions" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]], @@ -262,7 +294,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do context "with invalid params" do it "renders a JSON response with errors for the ai_persona" do - put "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json", params: { ai_persona: { name: "", @@ -277,7 +309,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do describe "DELETE #destroy" do it "destroys the requested ai_persona" do expect { - delete "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json" + delete "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json" expect(response).to have_http_status(:no_content) }.to change(AiPersona, :count).by(-1) @@ -285,7 +317,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do it "is not allowed to delete system personas" do expect { - delete "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json" + delete "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json" expect(response).to have_http_status(:unprocessable_entity) expect(response.parsed_body["errors"].join).not_to be_blank # let's make sure this is translated diff --git a/spec/system/ai_bot/persona_spec.rb b/spec/system/ai_bot/persona_spec.rb index b296c515..bbaabee1 100644 --- a/spec/system/ai_bot/persona_spec.rb +++ b/spec/system/ai_bot/persona_spec.rb @@ -30,7 +30,7 @@ RSpec.describe "AI personas", type: :system, js: true do end it "allows creation of a persona" do - visit "/admin/plugins/discourse-ai/ai_personas" + visit "/admin/plugins/discourse-ai/ai-personas" find(".ai-persona-list-editor__header .btn-primary").click() find(".ai-persona-editor__name").set("Test Persona") find(".ai-persona-editor__description").fill_in(with: "I am a test persona") @@ -42,7 +42,7 @@ RSpec.describe "AI personas", type: :system, js: true do find(".ai-persona-editor__save").click() - expect(page).not_to have_current_path("/admin/plugins/discourse-ai/ai_personas/new") + expect(page).not_to have_current_path("/admin/plugins/discourse-ai/ai-personas/new") persona_id = page.current_path.split("/").last.to_i @@ -54,7 +54,7 @@ RSpec.describe "AI personas", type: :system, js: true do end it "will not allow deletion or editing of system personas" do - visit "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}" + visit "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}" expect(page).not_to have_selector(".ai-persona-editor__delete") expect(find(".ai-persona-editor__system_prompt")).to be_disabled end @@ -62,7 +62,7 @@ RSpec.describe "AI personas", type: :system, js: true do it "will enable persona right away when you click on enable but does not save side effects" do persona = Fabricate(:ai_persona, enabled: false) - visit "/admin/plugins/discourse-ai/ai_personas/#{persona.id}" + visit "/admin/plugins/discourse-ai/ai-personas/#{persona.id}" find(".ai-persona-editor__name").set("Test Persona 1") PageObjects::Components::DToggleSwitch.new(".ai-persona-editor__enabled").toggle diff --git a/test/javascripts/unit/models/ai-persona-test.js b/test/javascripts/unit/models/ai-persona-test.js index 84ba05af..a83bf2e9 100644 --- a/test/javascripts/unit/models/ai-persona-test.js +++ b/test/javascripts/unit/models/ai-persona-test.js @@ -41,6 +41,11 @@ module("Discourse AI | Unit | Model | ai-persona", function () { description: "Description", top_p: 0.8, temperature: 0.7, + mentionable: false, + default_llm: "Default LLM", + user: null, + user_id: null, + max_context_posts: 5, }; const aiPersona = AiPersona.create({ ...properties }); @@ -67,6 +72,11 @@ module("Discourse AI | Unit | Model | ai-persona", function () { description: "Description", top_p: 0.8, temperature: 0.7, + user: null, + user_id: null, + default_llm: "Default LLM", + mentionable: false, + max_context_posts: 5, }; const aiPersona = AiPersona.create({ ...properties });