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 DiscourseAi
module Admin module Admin
class AiPersonasController < ::Admin::AdminController 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 def index
ai_personas = ai_personas =
@ -16,7 +16,11 @@ module DiscourseAi
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool| DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false) AiToolSerializer.new(tool, root: false)
end 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 end
def show def show
@ -32,6 +36,11 @@ module DiscourseAi
end end
end end
def create_user
user = @ai_persona.create_user!
render json: BasicUserSerializer.new(user, root: "user")
end
def update def update
if @ai_persona.update(ai_persona_params) if @ai_persona.update(ai_persona_params)
render json: @ai_persona render json: @ai_persona
@ -64,6 +73,10 @@ module DiscourseAi
:priority, :priority,
:top_p, :top_p,
:temperature, :temperature,
:default_llm,
:user_id,
:mentionable,
:max_context_posts,
allowed_group_ids: [], allowed_group_ids: [],
) )

View File

@ -7,22 +7,11 @@ module ::Jobs
def execute(args) def execute(args)
return unless bot_user = User.find_by(id: args[:bot_user_id]) return unless bot_user = User.find_by(id: args[:bot_user_id])
return unless post = Post.includes(:topic).find_by(id: args[:post_id]) return unless post = Post.includes(:topic).find_by(id: args[:post_id])
persona_id = args[:persona_id]
begin begin
persona = nil persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
if persona_id = post.topic.custom_fields["ai_persona_id"] raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil?
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
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new) 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 :description, presence: true, length: { maximum: 2000 }
validates :system_prompt, presence: true, length: { maximum: 10_000_000 } validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
validate :system_persona_unchangeable, on: :update, if: :system 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 before_destroy :ensure_not_system
@ -56,6 +60,23 @@ class AiPersona < ActiveRecord::Base
.map(&:class_instance) .map(&:class_instance)
end 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 after_commit :bump_cache
def bump_cache def bump_cache
@ -66,6 +87,10 @@ class AiPersona < ActiveRecord::Base
allowed_group_ids = self.allowed_group_ids allowed_group_ids = self.allowed_group_ids
id = self.id id = self.id
system = self.system 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] persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
if persona_class if persona_class
@ -81,6 +106,22 @@ class AiPersona < ActiveRecord::Base
system system
end 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 return persona_class
end end
@ -124,6 +165,10 @@ class AiPersona < ActiveRecord::Base
name name
end end
define_singleton_method :user_id do
user_id
end
define_singleton_method :description do define_singleton_method :description do
description description
end end
@ -136,6 +181,22 @@ class AiPersona < ActiveRecord::Base
allowed_group_ids allowed_group_ids
end 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 define_singleton_method :to_s do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>" "#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end end
@ -171,6 +232,45 @@ class AiPersona < ActiveRecord::Base
end end
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 private
def system_persona_unchangeable def system_persona_unchangeable
@ -192,20 +292,26 @@ end
# #
# Table name: ai_personas # Table name: ai_personas
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# name :string(100) not null # name :string(100) not null
# description :string(2000) not null # description :string(2000) not null
# commands :json not null # commands :json not null
# system_prompt :string(10000000) not null # system_prompt :string(10000000) not null
# allowed_group_ids :integer default([]), not null, is an Array # allowed_group_ids :integer default([]), not null, is an Array
# created_by_id :integer # created_by_id :integer
# enabled :boolean default(TRUE), not null # enabled :boolean default(TRUE), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# system :boolean default(FALSE), not null # system :boolean default(FALSE), not null
# priority :boolean default(FALSE), not null # priority :boolean default(FALSE), not null
# temperature :float # temperature :float
# top_p :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 # Indexes
# #

View File

@ -13,7 +13,13 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
:system_prompt, :system_prompt,
:allowed_group_ids, :allowed_group_ids,
:temperature, :temperature,
:top_p :top_p,
:mentionable,
:default_llm,
:user_id,
:max_context_posts
has_one :user, serializer: BasicUserSerializer, embed: :object
def name def name
object.class_instance.name object.class_instance.name

View File

@ -5,7 +5,7 @@ export default {
map() { map() {
this.route("discourse-ai", function () { this.route("discourse-ai", function () {
this.route("ai-personas", { path: "ai_personas" }, function () { this.route("ai-personas", function () {
this.route("new"); this.route("new");
this.route("show", { path: "/:id" }); this.route("show", { path: "/:id" });
}); });

View File

@ -7,8 +7,12 @@ export default class Adapter extends RestAdapter {
return "/admin/plugins/discourse-ai/"; return "/admin/plugins/discourse-ai/";
} }
pathFor() { pathFor(store, type, findArgs) {
return super.pathFor(...arguments) + ".json"; // 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() { apiNameFor() {

View File

@ -1,4 +1,5 @@
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
const ATTRIBUTES = [ const ATTRIBUTES = [
@ -12,6 +13,11 @@ const ATTRIBUTES = [
"priority", "priority",
"top_p", "top_p",
"temperature", "temperature",
"user_id",
"mentionable",
"default_llm",
"user",
"max_context_posts",
]; ];
class CommandOption { class CommandOption {
@ -45,6 +51,18 @@ export default class AiPersona extends RestModel {
this.commands = properties.commands; 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) { getCommandOption(commandId, optionId) {
this.commandOptions ||= {}; this.commandOptions ||= {};
this.commandOptions[commandId] ||= {}; 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 { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update"; import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop"; import { later } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import Textarea from "discourse/components/d-textarea"; import Textarea from "discourse/components/d-textarea";
import DToggleSwitch from "discourse/components/d-toggle-switch"; import DToggleSwitch from "discourse/components/d-toggle-switch";
import Avatar from "discourse/helpers/bound-avatar-template";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import Group from "discourse/models/group"; import Group from "discourse/models/group";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
import GroupChooser from "select-kit/components/group-chooser"; import GroupChooser from "select-kit/components/group-chooser";
import DTooltip from "float-kit/components/d-tooltip"; import DTooltip from "float-kit/components/d-tooltip";
import AiCommandSelector from "./ai-command-selector"; import AiCommandSelector from "./ai-command-selector";
import AiLlmSelector from "./ai-llm-selector";
import AiPersonaCommandOptions from "./ai-persona-command-options"; import AiPersonaCommandOptions from "./ai-persona-command-options";
export default class PersonaEditor extends Component { export default class PersonaEditor extends Component {
@ -81,6 +85,22 @@ export default class PersonaEditor extends Component {
return this.editingModel?.top_p || !this.editingModel?.system; 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 @action
delete() { delete() {
return this.dialog.confirm({ return this.dialog.confirm({
@ -103,26 +123,42 @@ export default class PersonaEditor extends Component {
@action @action
async toggleEnabled() { async toggleEnabled() {
this.args.model.set("enabled", !this.args.model.enabled); await this.toggleField("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);
}
}
} }
@action @action
async togglePriority() { async togglePriority() {
this.args.model.set("priority", !this.args.model.priority); await this.toggleField("priority", true);
this.editingModel.set("priority", this.args.model.priority); }
@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) { if (!this.args.model.isNew) {
try { 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) { } catch (e) {
popupAjaxError(e); popupAjaxError(e);
} }
@ -170,6 +206,20 @@ export default class PersonaEditor extends Component {
@content={{I18n.t "discourse_ai.ai_persona.priority_help"}} @content={{I18n.t "discourse_ai.ai_persona.priority_help"}}
/> />
</div> </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"> <div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label> <label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
<Input <Input
@ -187,6 +237,46 @@ export default class PersonaEditor extends Component {
disabled={{this.editingModel.system}} disabled={{this.editingModel.system}}
/> />
</div> </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"> <div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label> <label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
<AiCommandSelector <AiCommandSelector
@ -221,6 +311,18 @@ export default class PersonaEditor extends Component {
disabled={{this.editingModel.system}} disabled={{this.editingModel.system}}
/> />
</div> </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}} {{#if this.showTemperature}}
<div class="control-group"> <div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label> <label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label>

View File

@ -31,6 +31,10 @@
} }
.ai-persona-editor { .ai-persona-editor {
.fk-d-tooltip__icon {
padding-left: 0.25em;
color: var(--primary-medium);
}
label { label {
display: block; display: block;
} }
@ -53,10 +57,9 @@
&__priority { &__priority {
display: flex; display: flex;
align-items: center; align-items: center;
}
.fk-d-tooltip__icon { &__mentionable {
padding-left: 0.25em; display: flex;
color: var(--primary-medium); align-items: center;
}
} }
} }

View File

@ -108,6 +108,16 @@ en:
ai_persona: ai_persona:
name: Name name: Name
description: Description 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 system_prompt: System Prompt
save: Save save: Save
saved: AI Persona Saved saved: AI Persona Saved

View File

@ -182,6 +182,7 @@ en:
name: "Base Search Query" 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." 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: command_summary:
random_picker: "Random Picker"
categories: "List categories" categories: "List categories"
search: "Search" search: "Search"
tags: "List tags" tags: "List tags"
@ -195,6 +196,7 @@ en:
search_settings: "Searching site settings" search_settings: "Searching site settings"
dall_e: "Generate image" dall_e: "Generate image"
command_help: command_help:
random_picker: "Pick a random number or a random element of a list"
categories: "List all publicly visible categories on the forum" categories: "List all publicly visible categories on the forum"
search: "Search all public topics on the forum" search: "Search all public topics on the forum"
tags: "List all tags on the forum" tags: "List all tags on the forum"
@ -208,6 +210,7 @@ en:
search_settings: "Search site settings" search_settings: "Search site settings"
dall_e: "Generate image using DALL-E 3" dall_e: "Generate image using DALL-E 3"
command_description: command_description:
random_picker: "Picking from %{options}, picked: %{result}"
read: "Reading: <a href='%{url}'>%{title}</a>" read: "Reading: <a href='%{url}'>%{title}</a>"
time: "Time in %{timezone} is %{time}" time: "Time in %{timezone} is %{time}"
summarize: "Summarized <a href='%{url}'>%{title}</a>" summarize: "Summarized <a href='%{url}'>%{title}</a>"
@ -268,6 +271,6 @@ en:
disable_embeddings: "You have to disable 'ai embeddings enabled' first." disable_embeddings: "You have to disable 'ai embeddings enabled' first."
choose_model: "Set 'ai embeddings model' 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." 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." one: "Make sure the `%{settings}` setting was configured."
other: "Make sure the settings of the provider you want were configured. Options are: %{settings}" 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 :constraints => StaffConstraint.new
scope "/admin/plugins/discourse-ai", constraints: AdminConstraint.new do 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, resources :ai_personas,
only: %i[index create show update destroy], only: %i[index create show update destroy],
path: "ai-personas",
controller: "discourse_ai/admin/ai_personas" controller: "discourse_ai/admin/ai_personas"
post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user"
end end
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 end
attr_reader :bot_user attr_reader :bot_user
attr_accessor :persona
def get_updated_title(conversation_context, post_user) def get_updated_title(conversation_context, post_user)
system_insts = <<~TEXT.strip system_insts = <<~TEXT.strip
@ -111,8 +112,6 @@ module DiscourseAi
raw_context raw_context
end end
attr_reader :persona
private private
def invoke_tool(tool, llm, cancel, &update_blk) def invoke_tool(tool, llm, cancel, &update_blk)

View File

@ -23,6 +23,8 @@ module DiscourseAi
[FAKE_ID, "fake_bot", "fake"], [FAKE_ID, "fake_bot", "fake"],
] ]
BOT_USER_IDS = BOTS.map(&:first)
def self.map_bot_model_to_user_id(model_name) def self.map_bot_model_to_user_id(model_name)
case model_name case model_name
in "gpt-4-turbo" in "gpt-4-turbo"
@ -111,16 +113,7 @@ module DiscourseAi
name || topic.custom_fields["ai_persona"] name || topic.custom_fields["ai_persona"]
end end
plugin.on(:post_created) do |post| plugin.on(:post_created) { |post| DiscourseAi::AiBot::Playground.schedule_reply(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
if plugin.respond_to?(:register_editable_topic_custom_field) if plugin.respond_to?(:register_editable_topic_custom_field)
plugin.register_editable_topic_custom_field(:ai_persona_id) plugin.register_editable_topic_custom_field(:ai_persona_id)

View File

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

View File

@ -3,26 +3,101 @@
module DiscourseAi module DiscourseAi
module AiBot module AiBot
class Playground class Playground
attr_reader :bot
# An abstraction to manage the bot and topic interactions. # An abstraction to manage the bot and topic interactions.
# The bot will take care of completions while this class updates the topic title # The bot will take care of completions while this class updates the topic title
# and stream replies. # and stream replies.
REQUIRE_TITLE_UPDATE = "discourse-ai-title-update" 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) def initialize(bot)
@bot = bot @bot = bot
end end
def update_playground_with(post) def update_playground_with(post)
if can_attach?(post) && bot.bot_user if can_attach?(post)
schedule_playground_titling(post, bot.bot_user) schedule_playground_titling(post)
schedule_bot_reply(post, bot.bot_user) schedule_bot_reply(post)
end end
end end
def conversation_context(post) def conversation_context(post)
# Pay attention to the `post_number <= ?` here. # Pay attention to the `post_number <= ?` here.
# We want to inject the last post as context because they are translated differently. # 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 = context =
post post
.topic .topic
@ -31,8 +106,8 @@ module DiscourseAi
.joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id") .joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id")
.where("post_number <= ?", post.post_number) .where("post_number <= ?", post.post_number)
.order("post_number desc") .order("post_number desc")
.where("post_type = ?", Post.types[:regular]) .where("post_type in (?)", post_types)
.limit(50) .limit(max_posts)
.pluck(:raw, :username, "post_custom_prompts.custom_prompt") .pluck(:raw, :username, "post_custom_prompts.custom_prompt")
result = [] result = []
@ -96,6 +171,9 @@ module DiscourseAi
reply = +"" reply = +""
start = Time.now start = Time.now
post_type =
post.post_type == Post.types[:whisper] ? Post.types[:whisper] : Post.types[:regular]
context = { context = {
site_url: Discourse.base_url, site_url: Discourse.base_url,
site_title: SiteSetting.title, site_title: SiteSetting.title,
@ -106,19 +184,36 @@ module DiscourseAi
user: post.user, user: post.user,
} }
reply_post = reply_user = bot.bot_user
PostCreator.create!( if bot.persona.class.respond_to?(:user_id)
bot.bot_user, reply_user = User.find_by(id: bot.persona.class.user_id) || reply_user
topic_id: post.topic_id, end
raw: "",
skip_validations: true,
skip_jobs: true,
)
publish_update(reply_post, { raw: reply_post.cooked }) stream_reply = post.topic.private_message?
redis_stream_key = "gpt_cancel:#{reply_post.id}" # we need to ensure persona user is allowed to reply to the pm
Discourse.redis.setex(redis_stream_key, 60, 1) 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 = new_custom_prompts =
bot.reply(context) do |partial, cancel, placeholder| bot.reply(context) do |partial, cancel, placeholder|
@ -126,30 +221,47 @@ module DiscourseAi
raw = reply.dup raw = reply.dup
raw << "\n\n" << placeholder if placeholder.present? 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 cancel&.call
reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) reply_post.update!(raw: reply, cooked: PrettyText.cook(reply))
end end
# Minor hack to skip the delay during tests. if stream_reply
if placeholder.blank? # Minor hack to skip the delay during tests.
next if (Time.now - start < 0.5) && !Rails.env.test? if placeholder.blank?
start = Time.now 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 end
Discourse.redis.expire(redis_stream_key, 60)
publish_update(reply_post, { raw: raw })
end end
return if reply.blank? return if reply.blank?
# land the final message prior to saving so we don't clash if stream_reply
reply_post.cooked = PrettyText.cook(reply) # land the final message prior to saving so we don't clash
publish_final_update(reply_post) 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 # not need to add a custom prompt for a single reply
if new_custom_prompts.length > 1 if new_custom_prompts.length > 1
@ -161,7 +273,7 @@ module DiscourseAi
reply_post reply_post
ensure ensure
publish_final_update(reply_post) publish_final_update(reply_post) if stream_reply
end end
private private
@ -179,33 +291,38 @@ module DiscourseAi
end end
end end
attr_reader :bot
def can_attach?(post) def can_attach?(post)
return false if bot.bot_user.nil? return false if bot.bot_user.nil?
return false if post.post_type != Post.types[:regular] return false if post.topic.private_message? && post.post_type != Post.types[:regular]
return false unless post.topic.private_message?
return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank? return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank?
true true
end end
def schedule_playground_titling(post, bot_user) def schedule_playground_titling(post)
if post.post_number == 1 if post.post_number == 1 && post.topic.private_message?
post.topic.custom_fields[REQUIRE_TITLE_UPDATE] = true post.topic.custom_fields[REQUIRE_TITLE_UPDATE] = true
post.topic.save_custom_fields post.topic.save_custom_fields
end
::Jobs.enqueue_in( ::Jobs.enqueue_in(
5.minutes, 5.minutes,
:update_ai_bot_pm_title, :update_ai_bot_pm_title,
post_id: post.id, post_id: post.id,
bot_user_id: bot_user.id, bot_user_id: bot.bot_user.id,
) )
end
end end
def schedule_bot_reply(post, bot_user) def schedule_bot_reply(post)
::Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_user.id) 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 end
def context(topic) def context(topic)

View File

@ -4,11 +4,11 @@ module DiscourseAi::AiBot::SiteSettingsExtension
def self.enable_or_disable_ai_bots def self.enable_or_disable_ai_bots
enabled_bots = SiteSetting.ai_bot_enabled_chat_bots_map enabled_bots = SiteSetting.ai_bot_enabled_chat_bots_map
enabled_bots = [] if !SiteSetting.ai_bot_enabled enabled_bots = [] if !SiteSetting.ai_bot_enabled
DiscourseAi::AiBot::EntryPoint::BOTS.each do |id, bot_name, name| DiscourseAi::AiBot::EntryPoint::BOTS.each do |id, bot_name, name|
if id == DiscourseAi::AiBot::EntryPoint::FAKE_ID if id == DiscourseAi::AiBot::EntryPoint::FAKE_ID
next if Rails.env.production? next if Rails.env.production?
end end
active = enabled_bots.include?(name) active = enabled_bots.include?(name)
user = User.find_by(id: id) 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 def models_by_provider
# ChatGPT models are listed under open_ai but they are actually available through OpenAI and Azure. # 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. # However, since they use the same URL/key settings, there's no reason to duplicate them.
{ @models_by_provider ||=
aws_bedrock: %w[claude-instant-1 claude-2], {
anthropic: %w[claude-instant-1 claude-2], aws_bedrock: %w[claude-instant-1 claude-2],
vllm: %w[ anthropic: %w[claude-instant-1 claude-2],
mistralai/Mixtral-8x7B-Instruct-v0.1 vllm: %w[
mistralai/Mistral-7B-Instruct-v0.2 mistralai/Mixtral-8x7B-Instruct-v0.1
StableBeluga2 mistralai/Mistral-7B-Instruct-v0.2
Upstage-Llama-2-*-instruct-v2 StableBeluga2
Llama2-*-chat-hf Upstage-Llama-2-*-instruct-v2
Llama2-chat-hf Llama2-*-chat-hf
], Llama2-chat-hf
hugging_face: %w[ ],
mistralai/Mixtral-8x7B-Instruct-v0.1 hugging_face: %w[
mistralai/Mistral-7B-Instruct-v0.2 mistralai/Mixtral-8x7B-Instruct-v0.1
StableBeluga2 mistralai/Mistral-7B-Instruct-v0.2
Upstage-Llama-2-*-instruct-v2 StableBeluga2
Llama2-*-chat-hf Upstage-Llama-2-*-instruct-v2
Llama2-chat-hf 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], open_ai: %w[gpt-3.5-turbo gpt-4 gpt-3.5-turbo-16k gpt-4-32k gpt-4-turbo],
}.tap { |h| h[:fake] = ["fake"] if Rails.env.test? || Rails.env.development? } google: %w[gemini-pro],
}.tap { |h| h[:fake] = ["fake"] if Rails.env.test? || Rails.env.development? }
end end
def with_prepared_responses(responses) def valid_provider_models
@canned_response = DiscourseAi::Completions::Endpoints::CannedResponse.new(responses) 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 ensure
# Don't leak prepared response if there's an exception. # Don't leak prepared response if there's an exception.
@canned_response = nil @canned_response = nil
@canned_llm = nil
end end
def proxy(model_name) def proxy(model_name)
@ -63,7 +76,12 @@ module DiscourseAi
dialect_klass = dialect_klass =
DiscourseAi::Completions::Dialects::Dialect.dialect_for(model_name_without_prov) 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 = gateway =
DiscourseAi::Completions::Endpoints::Base.endpoint_for( 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 } before { SiteSetting.min_personal_message_post_length = 5 }
it "adds a reply from the bot" do 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 DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do
subject.execute( subject.execute(
post_id: topic.first_post.id, post_id: topic.first_post.id,
bot_user_id: DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID, bot_user_id: DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID,
persona_id: persona_id,
) )
end end

View File

@ -3,16 +3,29 @@
RSpec.describe DiscourseAi::AiBot::Playground do RSpec.describe DiscourseAi::AiBot::Playground do
subject(:playground) { described_class.new(bot) } subject(:playground) { described_class.new(bot) }
before do fab!(:bot_user) do
SiteSetting.ai_bot_enabled_chat_bots = "claude-2" SiteSetting.ai_bot_enabled_chat_bots = "claude-2"
SiteSetting.ai_bot_enabled = true SiteSetting.ai_bot_enabled = true
User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID)
end end
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) } fab!(:bot) do
let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user) } 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) } fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) }
let!(:pm) do
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:pm) do
Fabricate( Fabricate(
:private_message_topic, :private_message_topic,
title: "This is my special PM", title: "This is my special PM",
@ -23,13 +36,13 @@ RSpec.describe DiscourseAi::AiBot::Playground do
], ],
) )
end 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") Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user")
end 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") Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply")
end end
let!(:third_post) do fab!(:third_post) do
Fabricate( Fabricate(
:post, :post,
topic: pm, topic: pm,
@ -39,6 +52,93 @@ RSpec.describe DiscourseAi::AiBot::Playground do
) )
end 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 describe "#title_playground" do
let(:expected_response) { "This is a suggested title" } let(:expected_response) { "This is a suggested title" }
@ -112,7 +212,16 @@ RSpec.describe DiscourseAi::AiBot::Playground do
context "with Dall E bot" do context "with Dall E bot" do
let(: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 end
it "does not include placeholders in conversation context (simulate DALL-E)" do it "does not include placeholders in conversation context (simulate DALL-E)" do
@ -155,6 +264,24 @@ RSpec.describe DiscourseAi::AiBot::Playground do
end end
describe "#conversation_context" do 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 it "includes previous posts ordered by post_number" do
context = playground.conversation_context(third_post) 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 # frozen_string_literal: true
RSpec.describe AiPersona do 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 it "does not leak caches between sites" do
AiPersona.create!( AiPersona.create!(
name: "pun_bot", name: "pun_bot",

View File

@ -8,7 +8,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
describe "GET #index" do describe "GET #index" do
it "returns a success response" 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).to be_successful
expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count) expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count)
@ -17,6 +17,17 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
) )
end 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 it "returns commands options with each command" do
persona1 = Fabricate(:ai_persona, name: "search1", commands: ["SearchCommand"]) persona1 = Fabricate(:ai_persona, name: "search1", commands: ["SearchCommand"])
persona2 = persona2 =
@ -24,14 +35,22 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
:ai_persona, :ai_persona,
name: "search2", name: "search2",
commands: [["SearchCommand", { base_query: "test" }]], 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 expect(response).to be_successful
serializer_persona1 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona1.id } 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 } 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"] commands = response.parsed_body["meta"]["commands"]
search_command = commands.find { |c| c["id"] == "Search" } search_command = commands.find { |c| c["id"] == "Search" }
@ -86,7 +105,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end end
it "returns localized persona names and descriptions" do 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 = id =
DiscourseAi::AiBot::Personas::Persona.system_personas[ DiscourseAi::AiBot::Personas::Persona.system_personas[
@ -102,7 +121,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
describe "GET #show" do describe "GET #show" do
it "returns a success response" 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).to be_successful
expect(response.parsed_body["ai_persona"]["name"]).to eq(ai_persona.name) expect(response.parsed_body["ai_persona"]["name"]).to eq(ai_persona.name)
end end
@ -118,12 +137,14 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
commands: [["search", { "base_query" => "test" }]], commands: [["search", { "base_query" => "test" }]],
top_p: 0.1, top_p: 0.1,
temperature: 0.5, temperature: 0.5,
mentionable: true,
default_llm: "anthropic:claude-2",
} }
end end
it "creates a new AiPersona" do it "creates a new AiPersona" do
expect { expect {
post "/admin/plugins/discourse-ai/ai_personas.json", post "/admin/plugins/discourse-ai/ai-personas.json",
params: { ai_persona: valid_attributes }.to_json, params: { ai_persona: valid_attributes }.to_json,
headers: { headers: {
"CONTENT_TYPE" => "application/json", "CONTENT_TYPE" => "application/json",
@ -134,6 +155,8 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
expect(persona_json["name"]).to eq("superbot") expect(persona_json["name"]).to eq("superbot")
expect(persona_json["top_p"]).to eq(0.1) expect(persona_json["top_p"]).to eq(0.1)
expect(persona_json["temperature"]).to eq(0.5) 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"]) persona = AiPersona.find(persona_json["id"])
@ -146,18 +169,27 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
context "with invalid params" do context "with invalid params" do
it "renders a JSON response with errors for the new ai_persona" 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).to have_http_status(:unprocessable_entity)
expect(response.content_type).to include("application/json") expect(response.content_type).to include("application/json")
end end
end 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 describe "PUT #update" do
it "allows us to trivially clear top_p and temperature" 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) 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: { params: {
ai_persona: { ai_persona: {
top_p: "", top_p: "",
@ -173,7 +205,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end end
it "does not allow temperature and top p changes on stock personas" do 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: { params: {
ai_persona: { ai_persona: {
top_p: 0.5, top_p: 0.5,
@ -186,7 +218,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
context "with valid params" do context "with valid params" do
it "updates the requested ai_persona" 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: { params: {
ai_persona: { ai_persona: {
name: "SuperBot", name: "SuperBot",
@ -207,7 +239,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
context "with system personas" do context "with system personas" do
it "does not allow editing of system prompts" 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: { params: {
ai_persona: { ai_persona: {
system_prompt: "you are not a helpful bot", system_prompt: "you are not a helpful bot",
@ -220,7 +252,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end end
it "does not allow editing of commands" do 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: { params: {
ai_persona: { ai_persona: {
commands: %w[SearchCommand ImageCommand], commands: %w[SearchCommand ImageCommand],
@ -233,7 +265,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end end
it "does not allow editing of name and description cause it is localized" do 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: { params: {
ai_persona: { ai_persona: {
name: "bob", name: "bob",
@ -247,7 +279,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end end
it "does allow some actions" do 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: { params: {
ai_persona: { ai_persona: {
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]], allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]],
@ -262,7 +294,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
context "with invalid params" do context "with invalid params" do
it "renders a JSON response with errors for the ai_persona" 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: { params: {
ai_persona: { ai_persona: {
name: "", name: "",
@ -277,7 +309,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
describe "DELETE #destroy" do describe "DELETE #destroy" do
it "destroys the requested ai_persona" do it "destroys the requested ai_persona" do
expect { 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) expect(response).to have_http_status(:no_content)
}.to change(AiPersona, :count).by(-1) }.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 it "is not allowed to delete system personas" do
expect { 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).to have_http_status(:unprocessable_entity)
expect(response.parsed_body["errors"].join).not_to be_blank expect(response.parsed_body["errors"].join).not_to be_blank
# let's make sure this is translated # let's make sure this is translated

View File

@ -30,7 +30,7 @@ RSpec.describe "AI personas", type: :system, js: true do
end end
it "allows creation of a persona" do 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-list-editor__header .btn-primary").click()
find(".ai-persona-editor__name").set("Test Persona") find(".ai-persona-editor__name").set("Test Persona")
find(".ai-persona-editor__description").fill_in(with: "I am a 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() 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 persona_id = page.current_path.split("/").last.to_i
@ -54,7 +54,7 @@ RSpec.describe "AI personas", type: :system, js: true do
end end
it "will not allow deletion or editing of system personas" do 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(page).not_to have_selector(".ai-persona-editor__delete")
expect(find(".ai-persona-editor__system_prompt")).to be_disabled expect(find(".ai-persona-editor__system_prompt")).to be_disabled
end 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 it "will enable persona right away when you click on enable but does not save side effects" do
persona = Fabricate(:ai_persona, enabled: false) 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") find(".ai-persona-editor__name").set("Test Persona 1")
PageObjects::Components::DToggleSwitch.new(".ai-persona-editor__enabled").toggle 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", description: "Description",
top_p: 0.8, top_p: 0.8,
temperature: 0.7, temperature: 0.7,
mentionable: false,
default_llm: "Default LLM",
user: null,
user_id: null,
max_context_posts: 5,
}; };
const aiPersona = AiPersona.create({ ...properties }); const aiPersona = AiPersona.create({ ...properties });
@ -67,6 +72,11 @@ module("Discourse AI | Unit | Model | ai-persona", function () {
description: "Description", description: "Description",
top_p: 0.8, top_p: 0.8,
temperature: 0.7, temperature: 0.7,
user: null,
user_id: null,
default_llm: "Default LLM",
mentionable: false,
max_context_posts: 5,
}; };
const aiPersona = AiPersona.create({ ...properties }); const aiPersona = AiPersona.create({ ...properties });