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 <martin@discourse.org>
This commit is contained in:
Sam 2024-02-15 16:37:59 +11:00 committed by GitHub
parent 33164a0fec
commit 3a8d95f6b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 984 additions and 169 deletions

View File

@ -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: [],
)

View File

@ -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)

View File

@ -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
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
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
#

View File

@ -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

View File

@ -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" });
});

View File

@ -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() {

View File

@ -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] ||= {};

View File

@ -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,
},
});

View File

@ -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"}}
/>
</div>
{{#if this.editingModel.user}}
<div class="control-group ai-persona-editor__mentionable">
<DToggleSwitch
class="ai-persona-editor__mentionable_toggle"
@state={{@model.mentionable}}
@label="discourse_ai.ai_persona.mentionable"
{{on "click" this.toggleMentionable}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.mentionable_help"}}
/>
</div>
{{/if}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
<Input
@ -187,6 +237,46 @@ export default class PersonaEditor extends Component {
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.default_llm"}}</label>
<AiLlmSelector
class="ai-persona-editor__llms"
@value={{this.mappedDefaultLlm}}
@llms={{@personas.resultSetMeta.llms}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.default_llm_help"}}
/>
</div>
{{#unless @model.isNew}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.user"}}</label>
{{#if this.editingModel.user}}
<a
class="avatar"
href={{this.editingModel.user.path}}
data-user-card={{this.editingModel.user.username}}
>
{{Avatar this.editingModel.user.avatar_template "small"}}
</a>
<LinkTo @route="adminUser" @model={{this.adminUser}}>
{{this.editingModel.user.username}}
</LinkTo>
{{else}}
<DButton
@action={{this.createUser}}
class="ai-persona-editor__create-user"
>
{{I18n.t "discourse_ai.ai_persona.create_user"}}
</DButton>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.create_user_help"}}
/>
{{/if}}
</div>
{{/unless}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
<AiCommandSelector
@ -221,6 +311,18 @@ export default class PersonaEditor extends Component {
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.max_context_posts"}}</label>
<Input
@type="number"
class="ai-persona-editor__max_context_posts"
@value={{this.editingModel.max_context_posts}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.max_context_posts_help"}}
/>
</div>
{{#if this.showTemperature}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label>

View File

@ -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;
}
}

View File

@ -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

View File

@ -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: <a href='%{url}'>%{title}</a>"
time: "Time in %{timezone} is %{time}"
summarize: "Summarized <a href='%{url}'>%{title}</a>"
@ -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}"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -61,6 +61,7 @@ module DiscourseAi
Tools::SearchSettings,
Tools::Summarize,
Tools::SettingContext,
Tools::RandomPicker,
]
tools << Tools::ListTags if SiteSetting.tagging_enabled

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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 });