FEATURE: UI to update ai personas on admin page (#290)

Introduces a UI to manage customizable personas (admin only feature)

Part of the change was some extensive internal refactoring:

- AIBot now has a persona set in the constructor, once set it never changes
- Command now takes in bot as a constructor param, so it has the correct persona and is not generating AIBot objects on the fly
- Added a .prettierignore file, due to the way ALE is configured in nvim it is a pre-req for prettier to work
- Adds a bunch of validations on the AIPersona model, system personas (artist/creative etc...) are all seeded. We now ensure
- name uniqueness, and only allow certain properties to be touched for system personas.
- (JS note) the client side design takes advantage of nested routes, the parent route for personas gets all the personas via this.store.findAll("ai-persona") then child routes simply reach into this model to find a particular persona.
- (JS note) data is sideloaded into the ai-persona model the meta property supplied from the controller, resultSetMeta
- This removes ai_bot_enabled_personas and ai_bot_enabled_chat_commands, both should be controlled from the UI on a per persona basis
- Fixes a long standing bug in token accounting ... we were doing to_json.length instead of to_json.to_s.length
- Amended it so {commands} are always inserted at the end unconditionally, no need to add it to the template of the system message as it just confuses things
- Adds a concept of required_commands to stock personas, these are commands that must be configured for this stock persona to show up.
- Refactored tests so we stop requiring inference_stubs, it was very confusing to need it, added to plugin.rb for now which at least is clearer
- Migrates the persona selector to gjs

---------

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
Sam 2023-11-21 16:56:43 +11:00 committed by GitHub
parent 491111e5c8
commit 5b5edb22c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 1310 additions and 326 deletions

21
.prettierignore Normal file
View File

@ -0,0 +1,21 @@
app/assets/stylesheets/vendor/
documentation/
package.json
config/locales/**/*.yml
!config/locales/**/*.en*.yml
script/import_scripts/**/*.yml
plugins/**/lib/javascripts/locale
public/
!/app/assets/javascripts/discourse/public
vendor/
app/assets/javascripts/discourse/tests/fixtures
spec/
node_modules/
dist/
tmp/
**/*.rb
**/*.html
**/*.json
**/*.md

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class AiPersonasController < ::Admin::AdminController
before_action :find_ai_persona, only: %i[show update destroy]
def index
ai_personas =
AiPersona.ordered.map do |persona|
# we use a special serializer here cause names and descriptions are
# localized for system personas
LocalizedAiPersonaSerializer.new(persona, root: false)
end
commands =
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
{ id: command.to_s.split("::").last, name: command.name.humanize.titleize }
end
render json: { ai_personas: ai_personas, meta: { commands: commands } }
end
def show
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
end
def create
ai_persona = AiPersona.new(ai_persona_params)
if ai_persona.save
render json: { ai_persona: ai_persona }, status: :created
else
render_json_error ai_persona
end
end
def update
if @ai_persona.update(ai_persona_params)
render json: @ai_persona
else
render_json_error @ai_persona
end
end
def destroy
if @ai_persona.destroy
head :no_content
else
render_json_error @ai_persona
end
end
private
def find_ai_persona
@ai_persona = AiPersona.find(params[:id])
end
def ai_persona_params
params.require(:ai_persona).permit(
:name,
:description,
:enabled,
:system_prompt,
:enabled,
:priority,
allowed_group_ids: [],
commands: [],
)
end
end
end
end

View File

@ -4,6 +4,13 @@ class AiPersona < ActiveRecord::Base
# places a hard limit, so per site we cache a maximum of 500 classes # places a hard limit, so per site we cache a maximum of 500 classes
MAX_PERSONAS_PER_SITE = 500 MAX_PERSONAS_PER_SITE = 500
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
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
before_destroy :ensure_not_system
class MultisiteHash class MultisiteHash
def initialize(id) def initialize(id)
@hash = Hash.new { |h, k| h[k] = {} } @hash = Hash.new { |h, k| h[k] = {} }
@ -38,61 +45,15 @@ class AiPersona < ActiveRecord::Base
@persona_cache ||= MultisiteHash.new("persona_cache") @persona_cache ||= MultisiteHash.new("persona_cache")
end end
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
def self.all_personas def self.all_personas
persona_cache[:value] ||= AiPersona persona_cache[:value] ||= AiPersona
.order(:name) .ordered
.where(enabled: true) .where(enabled: true)
.all .all
.limit(MAX_PERSONAS_PER_SITE) .limit(MAX_PERSONAS_PER_SITE)
.map do |ai_persona| .map(&:class_instance)
name = ai_persona.name
description = ai_persona.description
ai_persona_id = ai_persona.id
allowed_group_ids = ai_persona.allowed_group_ids
commands =
ai_persona.commands.filter_map do |inner_name|
begin
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
rescue StandardError
nil
end
end
Class.new(DiscourseAi::AiBot::Personas::Persona) do
define_singleton_method :name do
name
end
define_singleton_method :description do
description
end
define_singleton_method :allowed_group_ids do
allowed_group_ids
end
define_singleton_method :to_s do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end
define_singleton_method :inspect do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end
define_method :initialize do |*args, **kwargs|
@ai_persona = AiPersona.find_by(id: ai_persona_id)
super(*args, **kwargs)
end
define_method :commands do
commands
end
define_method :system_prompt do
@ai_persona&.system_prompt || "You are a helpful bot."
end
end
end
end end
after_commit :bump_cache after_commit :bump_cache
@ -100,6 +61,99 @@ class AiPersona < ActiveRecord::Base
def bump_cache def bump_cache
self.class.persona_cache.flush! self.class.persona_cache.flush!
end end
def class_instance
allowed_group_ids = self.allowed_group_ids
id = self.id
system = self.system
persona_class = DiscourseAi::AiBot::Personas.system_personas_by_id[self.id]
if persona_class
persona_class.define_singleton_method :allowed_group_ids do
allowed_group_ids
end
persona_class.define_singleton_method :id do
id
end
persona_class.define_singleton_method :system do
system
end
return persona_class
end
name = self.name
description = self.description
ai_persona_id = self.id
commands =
self.commands.filter_map do |inner_name|
begin
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
rescue StandardError
nil
end
end
Class.new(DiscourseAi::AiBot::Personas::Persona) do
define_singleton_method :id do
id
end
define_singleton_method :name do
name
end
define_singleton_method :description do
description
end
define_singleton_method :system do
system
end
define_singleton_method :allowed_group_ids do
allowed_group_ids
end
define_singleton_method :to_s do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end
define_singleton_method :inspect do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end
define_method :initialize do |*args, **kwargs|
@ai_persona = AiPersona.find_by(id: ai_persona_id)
super(*args, **kwargs)
end
define_method :commands do
commands
end
define_method :system_prompt do
@ai_persona&.system_prompt || "You are a helpful bot."
end
end
end
private
def system_persona_unchangeable
if system_prompt_changed? || commands_changed? || name_changed? || description_changed?
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
end
end
def ensure_not_system
if system
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_delete_system_persona"))
throw :abort
end
end
end end
# == Schema Information # == Schema Information
@ -110,10 +164,14 @@ end
# name :string(100) not null # name :string(100) not null
# description :string(2000) not null # description :string(2000) not null
# commands :string default([]), not null, is an Array # commands :string default([]), not null, is an Array
# system_prompt :string 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
# 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
# priority :integer default(0), not null
# #
# Indexes # Indexes
# #

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class LocalizedAiPersonaSerializer < ApplicationSerializer
root "ai_persona"
attributes :id,
:name,
:description,
:enabled,
:system,
:priority,
:commands,
:system_prompt,
:allowed_group_ids
def name
object.class_instance.name
end
def description
object.class_instance.description
end
end

View File

@ -0,0 +1,14 @@
export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("discourse-ai", { path: "discourse-ai" }, function () {
this.route("ai-personas", { path: "ai_personas" }, function () {
this.route("new", { path: "/new" });
this.route("show", { path: "/:id" });
});
});
},
};

View File

@ -0,0 +1,17 @@
import RestAdapter from "discourse/adapters/rest";
export default class Adapter extends RestAdapter {
jsonMode = true;
basePath() {
return "/admin/plugins/discourse-ai/";
}
pathFor() {
return super.pathFor(...arguments) + ".json";
}
apiNameFor() {
return "ai-persona";
}
}

View File

@ -0,0 +1,28 @@
import RestModel from "discourse/models/rest";
const ATTRIBUTES = [
"name",
"description",
"commands",
"system_prompt",
"allowed_group_ids",
"enabled",
"system",
"priority",
];
export default class AiPersona extends RestModel {
updateProperties() {
let attrs = this.getProperties(ATTRIBUTES);
attrs.id = this.id;
return attrs;
}
createProperties() {
return this.getProperties(ATTRIBUTES);
}
workingCopy() {
return AiPersona.create(this.createProperties());
}
}

View File

@ -0,0 +1,18 @@
import { computed, observer } from "@ember/object";
import MultiSelectComponent from "select-kit/components/multi-select";
export default MultiSelectComponent.extend({
_modelDisabledChanged: observer("attrs.disabled", function () {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}),
content: computed(function () {
return this.attrs.commands.value;
}),
value: "",
selectKitOptions: {
filterable: true,
},
});

View File

@ -0,0 +1,224 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
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 { 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 { popupAjaxError } from "discourse/lib/ajax-error";
import Group from "discourse/models/group";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import GroupChooser from "select-kit/components/group-chooser";
import DTooltip from "float-kit/components/d-tooltip";
import AiCommandSelector from "./ai-command-selector";
export default class PersonaEditor extends Component {
@service router;
@service store;
@service dialog;
@service toasts;
@tracked allGroups = [];
@tracked isSaving = false;
@tracked editingModel = null;
@tracked showDelete = false;
@action
updateModel() {
this.editingModel = this.args.model.workingCopy();
this.showDelete = !this.args.model.isNew && !this.args.model.system;
}
@action
async updateAllGroups() {
this.allGroups = await Group.findAll();
}
@action
async save() {
const isNew = this.args.model.isNew;
this.isSaving = true;
const backupModel = this.args.model.workingCopy();
this.args.model.setProperties(this.editingModel);
try {
await this.args.model.save();
this.#sortPersonas();
if (isNew) {
this.args.personas.addObject(this.args.model);
this.router.transitionTo(
"adminPlugins.discourse-ai.ai-personas.show",
this.args.model
);
} else {
this.toasts.success({
data: { message: I18n.t("discourse_ai.ai_persona.saved") },
duration: 2000,
});
}
} catch (e) {
this.args.model.setProperties(backupModel);
popupAjaxError(e);
} finally {
later(() => {
this.isSaving = false;
}, 1000);
}
}
@action
delete() {
return this.dialog.confirm({
message: I18n.t("discourse_ai.ai_persona.confirm_delete"),
didConfirm: () => {
return this.args.model.destroyRecord().then(() => {
this.args.personas.removeObject(this.args.model);
this.router.transitionTo(
"adminPlugins.discourse-ai.ai-personas.index"
);
});
},
});
}
@action
updateAllowedGroups(ids) {
this.editingModel.set("allowed_group_ids", ids);
}
@action
async toggleEnabled() {
this.args.model.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
async togglePriority() {
this.args.model.set("priority", !this.args.model.priority);
if (!this.args.model.isNew) {
try {
await this.args.model.update({ priority: this.args.model.priority });
this.#sortPersonas();
} catch (e) {
popupAjaxError(e);
}
}
}
#sortPersonas() {
const sorted = this.args.personas.toArray().sort((a, b) => {
if (a.priority && !b.priority) {
return -1;
} else if (!a.priority && b.priority) {
return 1;
} else {
return a.name.localeCompare(b.name);
}
});
this.args.personas.clear();
this.args.personas.setObjects(sorted);
}
<template>
<form
class="form-horizontal ai-persona-editor"
{{didUpdate this.updateModel @model.id}}
{{didInsert this.updateModel @model.id}}
{{didInsert this.updateAllGroups @model.id}}
>
<div class="control-group">
<DToggleSwitch
class="ai-persona-editor__enabled"
@state={{@model.enabled}}
@label="discourse_ai.ai_persona.enabled"
{{on "click" this.toggleEnabled}}
/>
</div>
<div class="control-group ai-persona-editor__priority">
<DToggleSwitch
class="ai-persona-editor__priority"
@state={{@model.priority}}
@label="discourse_ai.ai_persona.priority"
{{on "click" this.togglePriority}}
/>
<DTooltip
@icon="question-circle"
@content={{i18n "discourse_ai.ai_persona.priority_help"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
<Input
class="ai-persona-editor__name"
@type="text"
@value={{this.editingModel.name}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.description"}}</label>
<Textarea
class="ai-persona-editor__description"
@value={{this.editingModel.description}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
<AiCommandSelector
class="ai-persona-editor__commands"
@value={{this.editingModel.commands}}
@disabled={{this.editingModel.system}}
@commands={{@personas.resultSetMeta.commands}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
<GroupChooser
@value={{this.editingModel.allowed_group_ids}}
@content={{this.allGroups}}
@onChange={{this.updateAllowedGroups}}
/>
</div>
<div class="control-group">
<label for="ai-persona-editor__system_prompt">{{I18n.t
"discourse_ai.ai_persona.system_prompt"
}}</label>
<Textarea
class="ai-persona-editor__system_prompt"
@value={{this.editingModel.system_prompt}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group ai-persona-editor__action_panel">
<DButton
class="btn-primary ai-persona-editor__save"
@action={{this.save}}
@disabled={{this.isSaving}}
>{{I18n.t "discourse_ai.ai_persona.save"}}</DButton>
{{#if this.showDelete}}
<DButton
@action={{this.delete}}
class="btn-danger ai-persona-editor__delete"
>
{{I18n.t "discourse_ai.ai_persona.delete"}}
</DButton>
{{/if}}
</div>
</form>
</template>
}

View File

@ -0,0 +1,66 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { LinkTo } from "@ember/routing";
import concatClass from "discourse/helpers/concat-class";
import { cook } from "discourse/lib/text";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
import AiPersonaEditor from "./ai-persona-editor";
export default class AiPersonaListEditor extends Component {
@tracked _noPersonaText = null;
get noPersonaText() {
if (this._noPersonaText === null) {
const raw = I18n.t("discourse_ai.ai_persona.no_persona_selected");
cook(raw).then((result) => {
this._noPersonaText = result;
});
}
return this._noPersonaText;
}
<template>
<div class="ai-persona-list-editor__header">
<h3>{{I18n.t "discourse_ai.ai_persona.title"}}</h3>
{{#unless @currentPersona.isNew}}
<LinkTo
@route="adminPlugins.discourse-ai.ai-personas.new"
class="btn btn-primary"
>
{{icon "plus"}}
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
</LinkTo>
{{/unless}}
</div>
<div class="content-list ai-persona-list-editor">
<ul>
{{#each @personas as |persona|}}
<li
class={{concatClass
(if persona.enabled "" "diabled")
(if persona.priority "priority")
}}
>
<LinkTo
@route="adminPlugins.discourse-ai.ai-personas.show"
current-when="true"
@model={{persona}}
>{{persona.name}}
</LinkTo>
</li>
{{/each}}
</ul>
</div>
<section class="ai-persona-list-editor__current content-body">
{{#if @currentPersona}}
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
{{else}}
<div class="ai-persona-list-editor__empty">
{{this.noPersonaText}}
</div>
{{/if}}
</section>
</template>
}

View File

@ -34,7 +34,7 @@ export default class AISuggestionDropdown extends Component {
const minCharacterCount = 40; const minCharacterCount = 40;
const isShowAIButton = this.composer.model.replyLength > minCharacterCount; const isShowAIButton = this.composer.model.replyLength > minCharacterCount;
const composerFields = document.querySelector(".composer-fields"); const composerFields = document.querySelector(".composer-fields");
if (composerFields) { if (composerFields) {
if (isShowAIButton) { if (isShowAIButton) {
composerFields.classList.add("showing-ai-suggestions"); composerFields.classList.add("showing-ai-suggestions");

View File

@ -1,5 +1,7 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
function isBotMessage(composer, currentUser) { function isBotMessage(composer, currentUser) {
if ( if (
@ -25,6 +27,13 @@ export default class BotSelector extends Component {
} }
@service currentUser; @service currentUser;
constructor() {
super(...arguments);
if (this.botOptions && this.composer) {
this._value = this.botOptions[0].id;
this.composer.metaData = { ai_persona_id: this._value };
}
}
get composer() { get composer() {
return this.args?.outletArgs?.model; return this.args?.outletArgs?.model;
@ -34,7 +43,7 @@ export default class BotSelector extends Component {
if (this.currentUser.ai_enabled_personas) { if (this.currentUser.ai_enabled_personas) {
return this.currentUser.ai_enabled_personas.map((persona) => { return this.currentUser.ai_enabled_personas.map((persona) => {
return { return {
id: persona.name, id: persona.id,
name: persona.name, name: persona.name,
description: persona.description, description: persona.description,
}; };
@ -43,11 +52,21 @@ export default class BotSelector extends Component {
} }
get value() { get value() {
return this._value || this.botOptions[0].id; return this._value;
} }
set value(val) { set value(val) {
this._value = val; this._value = val;
this.composer.metaData = { ai_persona: val }; this.composer.metaData = { ai_persona_id: val };
} }
<template>
<div class="gpt-persona">
<DropdownSelectBox
@value={{this.value}}
@content={{this.botOptions}}
@options={{hash icon="robot"}}
/>
</div>
</template>
} }

View File

@ -1,7 +0,0 @@
<div class="gpt-persona">
<DropdownSelectBox
@value={{this.value}}
@content={{this.botOptions}}
@options={{hash icon="robot"}}
/>
</div>

View File

@ -0,0 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
model() {
return this.modelFor("adminPlugins.discourse-ai.ai-personas");
},
});

View File

@ -0,0 +1,18 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model() {
const record = this.store.createRecord("ai-persona");
// TL0
record.set("allowed_group_ids", [10]);
return record;
},
setupController(controller, model) {
this._super(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.discourse-ai.ai-personas")
);
},
});

View File

@ -0,0 +1,17 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model(params) {
const allPersonas = this.modelFor("adminPlugins.discourse-ai.ai-personas");
const id = parseInt(params.id, 10);
return allPersonas.findBy("id", id);
},
setupController(controller, model) {
this._super(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.discourse-ai.ai-personas")
);
},
});

View File

@ -0,0 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model() {
return this.store.findAll("ai-persona");
},
});

View File

@ -0,0 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
beforeModel() {
this.transitionTo("adminPlugins.discourse-ai.ai-personas");
},
});

View File

@ -0,0 +1 @@
<AiPersonaListEditor @personas={{this.model}} />

View File

@ -0,0 +1,4 @@
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
/>

View File

@ -0,0 +1,4 @@
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
/>

View File

@ -0,0 +1,45 @@
.ai-persona-list-editor {
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
&__current {
padding-left: 20px;
}
li.disabled {
opacity: 0.5;
}
}
.ai-personas__container {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
width: 100%;
}
.ai-persona-editor {
label {
display: block;
}
&__description {
width: 500px;
}
&__system_prompt {
width: 500px;
height: 400px;
}
&__priority {
display: flex;
align-items: center;
.fk-d-tooltip__icon {
padding-left: 0.25em;
color: var(--primary-medium);
}
}
}

View File

@ -39,9 +39,38 @@ en:
description: "Either gpt-4 or gpt-3-5-turbo or claude-2" description: "Either gpt-4 or gpt-3-5-turbo or claude-2"
discourse_ai: discourse_ai:
title: "AI"
modals: modals:
select_option: "Select an option..." select_option: "Select an option..."
ai_persona:
name: Name
description: Description
system_prompt: System Prompt
save: Save
saved: AI Persona Saved
enabled: Enabled
commands: Enabled Commands
allowed_groups: Allowed Groups
confirm_delete: Are you sure you want to delete this persona?
new: New
title: "AI Personas"
delete: Delete
priority: Priority
priority_help: Priority personas are displayed to users at the top of the persona list. If multiple personas have priority, they will be sorted alphabetically.
no_persona_selected: |
## What are AI Personas?
AI Personas are a powerful feature that allows you to customize the behavior of the AI engine in your Discourse forum. They act as a 'system message' that guides the AI's responses and interactions, helping to create a more personalized and engaging user experience.
## Why use AI Personas?
With AI Personas, you can tailor the AI's behavior to better fit the context and tone of your forum. Whether you want the AI to be more formal for a professional setting, more casual for a community forum, or even embody a specific character for a role-playing game, AI Personas give you the flexibility to do so.
## Group-Specific Access to AI Personas
Moreover, you can set it up so that certain user groups have access to specific personas. This means you can have different AI behaviors for different sections of your forum, further enhancing the diversity and richness of your community's interactions.
related_topics: related_topics:
title: "Related Topics" title: "Related Topics"
pill: "Related" pill: "Related"

View File

@ -73,9 +73,7 @@ en:
ai_bot_enable_chat_warning: "Display a warning when PM chat is initiated. Can be overriden by editing the translation string: discourse_ai.ai_bot.pm_warning" ai_bot_enable_chat_warning: "Display a warning when PM chat is initiated. Can be overriden by editing the translation string: discourse_ai.ai_bot.pm_warning"
ai_bot_allowed_groups: "When the GPT Bot has access to the PM, it will reply to members of these groups." ai_bot_allowed_groups: "When the GPT Bot has access to the PM, it will reply to members of these groups."
ai_bot_enabled_chat_bots: "Available models to act as an AI Bot" ai_bot_enabled_chat_bots: "Available models to act as an AI Bot"
ai_bot_enabled_chat_commands: "Available GPT integrations used to provide external functionality to the Forum Helper bot, keep in mind that certain commands may only be available if appropriate API keys are added."
ai_bot_add_to_header: "Display a button in the header to start a PM with a AI Bot" ai_bot_add_to_header: "Display a button in the header to start a PM with a AI Bot"
ai_bot_enabled_personas: "List of personas available for the AI Bot"
ai_stability_api_key: "API key for the stability.ai API" ai_stability_api_key: "API key for the stability.ai API"
ai_stability_engine: "Image generation engine to use for the stability.ai API" ai_stability_engine: "Image generation engine to use for the stability.ai API"
@ -125,6 +123,8 @@ en:
ai_bot: ai_bot:
personas: personas:
cannot_delete_system_persona: "System personas cannot be deleted, please disable it instead"
cannot_edit_system_persona: "System personas can only be renamed, you may not edit commands or system prompt, instead disable and make a copy"
general: general:
name: Forum Helper name: Forum Helper
description: "General purpose AI Bot capable of performing various tasks" description: "General purpose AI Bot capable of performing various tasks"
@ -184,7 +184,7 @@ en:
configuration_hint: configuration_hint:
one: "Configure the `%{setting}` setting first." one: "Configure the `%{setting}` setting first."
other: "Configure these settings first: %{settings}" other: "Configure these settings first: %{settings}"
sentiment: sentiment:
reports: reports:
overall_sentiment: overall_sentiment:

View File

@ -26,4 +26,11 @@ Discourse::Application.routes.draw do
get "admin/dashboard/sentiment" => "discourse_ai/admin/dashboard#sentiment", get "admin/dashboard/sentiment" => "discourse_ai/admin/dashboard#sentiment",
:constraints => StaffConstraint.new :constraints => StaffConstraint.new
scope "/admin/plugins/discourse-ai", constraints: AdminConstraint.new do
get "/", to: redirect("/admin/plugins/discourse-ai/ai_personas")
resources :ai_personas,
only: %i[index create show update destroy],
controller: "discourse_ai/admin/ai_personas"
end
end end

View File

@ -250,29 +250,6 @@ discourse_ai:
- gpt-3.5-turbo - gpt-3.5-turbo
- gpt-4 - gpt-4
- claude-2 - claude-2
ai_bot_enabled_chat_commands:
type: list
default: "categories|google|image|search|tags|time|read"
client: true
choices:
- categories
- google
- image
- search
- summarize
- read
- tags
- time
ai_bot_enabled_personas:
type: list
default: "general|artist|sql_helper|settings_explorer|researcher|creative"
choices:
- general
- artist
- sql_helper
- settings_explorer
- researcher
- creative
ai_bot_add_to_header: ai_bot_add_to_header:
default: true default: true
client: true client: true

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
DiscourseAi::AiBot::Personas.system_personas.each do |persona_class, id|
persona = AiPersona.find_by(id: id)
if !persona
persona = AiPersona.new
persona.id = id
persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]]
persona.enabled = true
persona.priority = true if persona_class == DiscourseAi::AiBot::Personas::General
end
names = [
persona_class.name,
persona_class.name + " 1",
persona_class.name + " 2",
persona_class.name + SecureRandom.hex,
]
persona.name = DB.query_single(<<~SQL, names, id).first
SELECT guess_name
FROM (
SELECT unnest(Array[?]) AS guess_name
FROM (SELECT 1) as t
) x
LEFT JOIN ai_personas ON ai_personas.name = x.guess_name AND ai_personas.id <> ?
WHERE ai_personas.id IS NULL
ORDER BY x.guess_name ASC
LIMIT 1
SQL
persona.description = persona_class.description
persona.system = true
instance = persona_class.new
persona.commands = instance.commands.map { |command| command.to_s.split("::").last }
persona.system_prompt = instance.system_prompt
persona.save!(validate: false)
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddSystemAndPriorityToAiPersonas < ActiveRecord::Migration[7.0]
def change
add_column :ai_personas, :system, :boolean, null: false, default: false
add_column :ai_personas, :priority, :boolean, null: false, default: false
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class RemoveSiteSettings < ActiveRecord::Migration[7.0]
def up
DB.exec(<<~SQL, %w[ai_bot_enabled_chat_commands ai_bot_enabled_personas])
DELETE FROM site_settings WHERE name IN (?)
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -7,11 +7,12 @@ module DiscourseAi
bot_user.id == DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID bot_user.id == DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID
end end
def bot_prompt_with_topic_context(post) def bot_prompt_with_topic_context(post, allow_commands:)
super(post).join("\n\n") + "\n\nAssistant:" super(post, allow_commands: allow_commands).join("\n\n") + "\n\nAssistant:"
end end
def prompt_limit def prompt_limit(allow_commands: true)
# no side channel for commands, so we can ignore allow commands
50_000 # https://console.anthropic.com/docs/prompt-design#what-is-a-prompt 50_000 # https://console.anthropic.com/docs/prompt-design#what-is-a-prompt
end end

View File

@ -67,12 +67,12 @@ module DiscourseAi
end end
end end
attr_reader :bot_user attr_reader :bot_user, :persona
BOT_NOT_FOUND = Class.new(StandardError) BOT_NOT_FOUND = Class.new(StandardError)
MAX_COMPLETIONS = 5 MAX_COMPLETIONS = 5
def self.as(bot_user) def self.as(bot_user, persona_id: nil, persona_name: nil, user: nil)
available_bots = [DiscourseAi::AiBot::OpenAiBot, DiscourseAi::AiBot::AnthropicBot] available_bots = [DiscourseAi::AiBot::OpenAiBot, DiscourseAi::AiBot::AnthropicBot]
bot = bot =
@ -80,12 +80,23 @@ module DiscourseAi
bot_klass.can_reply_as?(bot_user) bot_klass.can_reply_as?(bot_user)
end end
bot.new(bot_user) persona = nil
if persona_id
persona = DiscourseAi::AiBot::Personas.find_by(user: user, id: persona_id)
raise BOT_NOT_FOUND if persona.nil?
end
if !persona && persona_name
persona = DiscourseAi::AiBot::Personas.find_by(user: user, name: persona_name)
raise BOT_NOT_FOUND if persona.nil?
end
bot.new(bot_user, persona: persona&.new)
end end
def initialize(bot_user) def initialize(bot_user, persona: nil)
@bot_user = bot_user @bot_user = bot_user
@persona = DiscourseAi::AiBot::Personas::General.new @persona = persona || DiscourseAi::AiBot::Personas::General.new
end end
def update_pm_title(post) def update_pm_title(post)
@ -113,21 +124,12 @@ module DiscourseAi
# do not allow commands when we are at the end of chain (total completions == MAX_COMPLETIONS) # do not allow commands when we are at the end of chain (total completions == MAX_COMPLETIONS)
allow_commands = (total_completions < MAX_COMPLETIONS) allow_commands = (total_completions < MAX_COMPLETIONS)
@persona = DiscourseAi::AiBot::Personas::General.new(allow_commands: allow_commands)
if persona_name = post.topic.custom_fields["ai_persona"]
persona_class =
DiscourseAi::AiBot::Personas
.all(user: post.user)
.find { |current| current.name == persona_name }
@persona = persona_class.new(allow_commands: allow_commands) if persona_class
end
prompt = prompt =
if standalone && post.post_custom_prompt if standalone && post.post_custom_prompt
username, standalone_prompt = post.post_custom_prompt.custom_prompt.last username, standalone_prompt = post.post_custom_prompt.custom_prompt.last
[build_message(username, standalone_prompt)] [build_message(username, standalone_prompt)]
else else
bot_prompt_with_topic_context(post) bot_prompt_with_topic_context(post, allow_commands: allow_commands)
end end
redis_stream_key = nil redis_stream_key = nil
@ -225,12 +227,7 @@ module DiscourseAi
if command_klass = available_commands.detect { |cmd| cmd.invoked?(name) } if command_klass = available_commands.detect { |cmd| cmd.invoked?(name) }
command = command =
command_klass.new( command_klass.new(bot: self, args: args, post: bot_reply_post, parent_post: post)
bot_user: bot_user,
args: args,
post: bot_reply_post,
parent_post: post,
)
chain_intermediate, bot_reply_post = command.invoke! chain_intermediate, bot_reply_post = command.invoke!
chain ||= chain_intermediate chain ||= chain_intermediate
standalone ||= command.standalone? standalone ||= command.standalone?
@ -259,38 +256,38 @@ module DiscourseAi
0 0
end end
def bot_prompt_with_topic_context(post, prompt: "topic") def bot_prompt_with_topic_context(post, allow_commands:)
messages = [] messages = []
conversation = conversation_context(post) conversation = conversation_context(post)
rendered_system_prompt = system_prompt(post) rendered_system_prompt = system_prompt(post, allow_commands: allow_commands)
total_prompt_tokens = tokenize(rendered_system_prompt).length + extra_tokens_per_message total_prompt_tokens = tokenize(rendered_system_prompt).length + extra_tokens_per_message
messages = prompt_limit = self.prompt_limit(allow_commands: allow_commands)
conversation.reduce([]) do |memo, (raw, username, function)|
break(memo) if total_prompt_tokens >= prompt_limit
tokens = tokenize(raw.to_s) conversation.each do |raw, username, function|
break if total_prompt_tokens >= prompt_limit
while !raw.blank? && tokens = tokenize(raw.to_s + username.to_s)
tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit
raw = raw[0..-100] || ""
tokens = tokenize(raw.to_s)
end
next(memo) if raw.blank? while !raw.blank? &&
tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit
total_prompt_tokens += tokens.length + extra_tokens_per_message raw = raw[0..-100] || ""
memo.unshift(build_message(username, raw, function: !!function)) tokens = tokenize(raw.to_s + username.to_s)
end end
next if raw.blank?
total_prompt_tokens += tokens.length + extra_tokens_per_message
messages.unshift(build_message(username, raw, function: !!function))
end
messages.unshift(build_message(bot_user.username, rendered_system_prompt, system: true)) messages.unshift(build_message(bot_user.username, rendered_system_prompt, system: true))
messages messages
end end
def prompt_limit def prompt_limit(allow_commands: false)
raise NotImplemented raise NotImplemented
end end
@ -300,7 +297,7 @@ module DiscourseAi
You will never respond with anything but a topic title. You will never respond with anything but a topic title.
Suggest a 7 word title for the following topic without quoting any of it: Suggest a 7 word title for the following topic without quoting any of it:
#{post.topic.posts.map(&:raw).join("\n\n")[0..prompt_limit]} #{post.topic.posts.map(&:raw).join("\n\n")[0..prompt_limit(allow_commands: false)]}
TEXT TEXT
end end
@ -312,12 +309,14 @@ module DiscourseAi
@style = style @style = style
end end
def system_prompt(post) def system_prompt(post, allow_commands:)
return "You are a helpful Bot" if @style == :simple return "You are a helpful Bot" if @style == :simple
@persona.render_system_prompt( @persona.render_system_prompt(
topic: post.topic, topic: post.topic,
render_function_instructions: include_function_instructions_in_system_prompt?, allow_commands: allow_commands,
render_function_instructions:
allow_commands && include_function_instructions_in_system_prompt?,
) )
end end

View File

@ -40,10 +40,11 @@ module DiscourseAi
end end
end end
attr_reader :bot_user attr_reader :bot_user, :bot
def initialize(bot_user:, args:, post: nil, parent_post: nil) def initialize(bot:, args:, post: nil, parent_post: nil)
@bot_user = bot_user @bot = bot
@bot_user = bot&.bot_user
@args = args @args = args
@post = post @post = post
@parent_post = parent_post @parent_post = parent_post
@ -61,10 +62,6 @@ module DiscourseAi
@invoked = false @invoked = false
end end
def bot
@bot ||= DiscourseAi::AiBot::Bot.as(bot_user)
end
def tokenizer def tokenizer
bot.tokenizer bot.tokenizer
end end

View File

@ -76,7 +76,9 @@ module DiscourseAi
) do ) do
Personas Personas
.all(user: scope.user) .all(user: scope.user)
.map { |persona| { name: persona.name, description: persona.description } } .map do |persona|
{ id: persona.id, name: persona.name, description: persona.description }
end
end end
plugin.add_to_serializer( plugin.add_to_serializer(
@ -112,7 +114,11 @@ module DiscourseAi
:topic_view, :topic_view,
:ai_persona_name, :ai_persona_name,
include_condition: -> { SiteSetting.ai_bot_enabled && object.topic.private_message? }, include_condition: -> { SiteSetting.ai_bot_enabled && object.topic.private_message? },
) { topic.custom_fields["ai_persona"] } ) do
id = topic.custom_fields["ai_persona_id"]
name = DiscourseAi::AiBot::Personas.find_by(user: scope.user, id: id.to_i)&.name if id
name || topic.custom_fields["ai_persona"]
end
plugin.on(:post_created) do |post| plugin.on(:post_created) do |post|
bot_ids = BOTS.map(&:first) bot_ids = BOTS.map(&:first)
@ -140,7 +146,7 @@ module DiscourseAi
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) plugin.register_editable_topic_custom_field(:ai_persona_id)
end end
end end
end end

View File

@ -6,10 +6,24 @@ 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 bot = DiscourseAi::AiBot::Bot.as(bot_user)
return unless post = Post.includes(:topic).find_by(id: args[:post_id]) return unless post = Post.includes(:topic).find_by(id: args[:post_id])
bot.reply_to(post) kwargs = {}
kwargs[:user] = post.user
if persona_id = post.topic.custom_fields["ai_persona_id"]
kwargs[:persona_id] = persona_id.to_i
else
kwargs[:persona_name] = post.topic.custom_fields["ai_persona"]
end
begin
bot = DiscourseAi::AiBot::Bot.as(bot_user, **kwargs)
bot.reply_to(post)
rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND
Rails.logger.warn(
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
)
end
end end
end end
end end

View File

@ -12,13 +12,16 @@ module DiscourseAi
open_ai_bot_ids.include?(bot_user.id) open_ai_bot_ids.include?(bot_user.id)
end end
def prompt_limit def prompt_limit(allow_commands:)
# note this is about 100 tokens over, OpenAI have a more optimal representation
@function_size ||= tokenize(available_functions.to_json).length
# provide a buffer of 120 tokens - our function counting is not # provide a buffer of 120 tokens - our function counting is not
# 100% accurate and getting numbers to align exactly is very hard # 100% accurate and getting numbers to align exactly is very hard
buffer = @function_size + reply_params[:max_tokens] + 120 buffer = reply_params[:max_tokens] + 50
if allow_commands
# note this is about 100 tokens over, OpenAI have a more optimal representation
@function_size ||= tokenize(available_functions.to_json.to_s).length
buffer += @function_size
end
if bot_user.id == DiscourseAi::AiBot::EntryPoint::GPT4_ID if bot_user.id == DiscourseAi::AiBot::EntryPoint::GPT4_ID
8192 - buffer 8192 - buffer

View File

@ -8,6 +8,10 @@ module DiscourseAi
[Commands::ImageCommand] [Commands::ImageCommand]
end end
def required_commands
[Commands::ImageCommand]
end
def system_prompt def system_prompt
<<~PROMPT <<~PROMPT
You are artistbot and you are here to help people generate images. You are artistbot and you are here to help people generate images.
@ -26,9 +30,6 @@ module DiscourseAi
- When generating images, usually opt to generate 4 images unless the user specifies otherwise. - When generating images, usually opt to generate 4 images unless the user specifies otherwise.
- Be creative with your prompts, offer diverse options - Be creative with your prompts, offer diverse options
- You can use the seeds to regenerate the same image and amend the prompt keeping general style - You can use the seeds to regenerate the same image and amend the prompt keeping general style
{commands}
PROMPT PROMPT
end end
end end

View File

@ -5,7 +5,15 @@ module DiscourseAi
module Personas module Personas
class General < Persona class General < Persona
def commands def commands
all_available_commands [
Commands::SearchCommand,
Commands::GoogleCommand,
Commands::ImageCommand,
Commands::ReadCommand,
Commands::ImageCommand,
Commands::CategoriesCommand,
Commands::TagsCommand,
]
end end
def system_prompt def system_prompt
@ -19,8 +27,6 @@ module DiscourseAi
The description is: {site_description} The description is: {site_description}
The participants in this conversation are: {participants} The participants in this conversation are: {participants}
The date now is: {time}, much has changed since you were trained. The date now is: {time}, much has changed since you were trained.
{commands}
PROMPT PROMPT
end end
end end

View File

@ -3,28 +3,43 @@
module DiscourseAi module DiscourseAi
module AiBot module AiBot
module Personas module Personas
def self.all(user: nil) def self.system_personas
personas = [Personas::General, Personas::SqlHelper] @system_personas ||= {
personas << Personas::Artist if SiteSetting.ai_stability_api_key.present? Personas::General => -1,
personas << Personas::SettingsExplorer Personas::SqlHelper => -2,
personas << Personas::Researcher if SiteSetting.ai_google_custom_search_api_key.present? Personas::Artist => -3,
personas << Personas::Creative Personas::SettingsExplorer => -4,
Personas::Researcher => -5,
Personas::Creative => -6,
}
end
personas_allowed = SiteSetting.ai_bot_enabled_personas.split("|") def self.system_personas_by_id
@system_personas_by_id ||= system_personas.invert
end
def self.all(user:)
personas = personas =
personas.filter do |persona| AiPersona.all_personas.filter { |persona| user.in_any_groups?(persona.allowed_group_ids) }
personas_allowed.include?(persona.to_s.demodulize.underscore)
# this needs to be dynamic cause site settings may change
all_available_commands = Persona.all_available_commands
personas.filter do |persona|
if persona.system
instance = persona.new
(
instance.required_commands == [] ||
(instance.required_commands - all_available_commands).empty?
)
else
true
end end
if user
personas.concat(
AiPersona.all_personas.filter do |persona|
user.in_any_groups?(persona.allowed_group_ids)
end,
)
end end
end
personas def self.find_by(id: nil, name: nil, user:)
all(user: user).find { |persona| persona.id == id || persona.name == name }
end end
class Persona class Persona
@ -36,16 +51,16 @@ module DiscourseAi
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description") I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description")
end end
def initialize(allow_commands: true)
@allow_commands = allow_commands
end
def commands def commands
[] []
end end
def required_commands
[]
end
def render_commands(render_function_instructions:) def render_commands(render_function_instructions:)
return +"" if !@allow_commands return +"" if available_commands.empty?
result = +"" result = +""
if render_function_instructions if render_function_instructions
@ -57,33 +72,39 @@ module DiscourseAi
result result
end end
def render_system_prompt(topic: nil, render_function_instructions: true) def render_system_prompt(
topic: nil,
render_function_instructions: true,
allow_commands: true
)
substitutions = { substitutions = {
site_url: Discourse.base_url, site_url: Discourse.base_url,
site_title: SiteSetting.title, site_title: SiteSetting.title,
site_description: SiteSetting.site_description, site_description: SiteSetting.site_description,
time: Time.zone.now, time: Time.zone.now,
commands: render_commands(render_function_instructions: render_function_instructions),
} }
substitutions[:participants] = topic.allowed_users.map(&:username).join(", ") if topic substitutions[:participants] = topic.allowed_users.map(&:username).join(", ") if topic
system_prompt.gsub(/\{(\w+)\}/) do |match| prompt =
found = substitutions[match[1..-2].to_sym] system_prompt.gsub(/\{(\w+)\}/) do |match|
found.nil? ? match : found.to_s found = substitutions[match[1..-2].to_sym]
found.nil? ? match : found.to_s
end
if allow_commands
prompt += render_commands(render_function_instructions: render_function_instructions)
end end
prompt
end end
def available_commands def available_commands
return [] if !@allow_commands
return @available_commands if @available_commands return @available_commands if @available_commands
@available_commands = all_available_commands.filter { |cmd| commands.include?(cmd) } @available_commands = all_available_commands.filter { |cmd| commands.include?(cmd) }
end end
def available_functions def available_functions
return [] if !@allow_commands
# note if defined? can be a problem in test # note if defined? can be a problem in test
# this can never be nil so it is safe # this can never be nil so it is safe
return @available_functions if @available_functions return @available_functions if @available_functions
@ -109,15 +130,17 @@ module DiscourseAi
@function_list @function_list
end end
def all_available_commands def self.all_available_commands
return @cmds if @cmds
all_commands = [ all_commands = [
Commands::CategoriesCommand, Commands::CategoriesCommand,
Commands::TimeCommand, Commands::TimeCommand,
Commands::SearchCommand, Commands::SearchCommand,
Commands::SummarizeCommand, Commands::SummarizeCommand,
Commands::ReadCommand, Commands::ReadCommand,
Commands::DbSchemaCommand,
Commands::SearchSettingsCommand,
Commands::SummarizeCommand,
Commands::SettingContextCommand,
] ]
all_commands << Commands::TagsCommand if SiteSetting.tagging_enabled all_commands << Commands::TagsCommand if SiteSetting.tagging_enabled
@ -127,8 +150,11 @@ module DiscourseAi
all_commands << Commands::GoogleCommand all_commands << Commands::GoogleCommand
end end
allowed_commands = SiteSetting.ai_bot_enabled_chat_commands.split("|") all_commands
@cmds = all_commands.filter { |klass| allowed_commands.include?(klass.name) } end
def all_available_commands
@cmds ||= self.class.all_available_commands
end end
end end
end end

View File

@ -8,6 +8,10 @@ module DiscourseAi
[Commands::GoogleCommand] [Commands::GoogleCommand]
end end
def required_commands
[Commands::GoogleCommand]
end
def system_prompt def system_prompt
<<~PROMPT <<~PROMPT
You are research bot. With access to the internet you can find information for users. You are research bot. With access to the internet you can find information for users.
@ -15,9 +19,6 @@ module DiscourseAi
- You fully understand Discourse Markdown and generate it. - You fully understand Discourse Markdown and generate it.
- When generating responses you always cite your sources. - When generating responses you always cite your sources.
- When possible you also quote the sources. - When possible you also quote the sources.
{commands}
PROMPT PROMPT
end end
end end

View File

@ -25,9 +25,6 @@ module DiscourseAi
- Keep in mind that setting names are always a single word separated by underscores. eg. 'site_description' - Keep in mind that setting names are always a single word separated by underscores. eg. 'site_description'
Current time is: {time} Current time is: {time}
{commands}
PROMPT PROMPT
end end
end end

View File

@ -63,8 +63,6 @@ module DiscourseAi
{{ {{
#{self.class.schema} #{self.class.schema}
}} }}
{commands}
PROMPT PROMPT
end end
end end

View File

@ -17,6 +17,7 @@ enabled_site_setting :discourse_ai_enabled
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss" register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss" register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss"
register_asset "stylesheets/modules/ai-bot/common/ai-persona.scss"
register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss" register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss"
register_asset "stylesheets/modules/embeddings/common/semantic-search.scss" register_asset "stylesheets/modules/embeddings/common/semantic-search.scss"
@ -61,6 +62,8 @@ after_initialize do
require_relative "lib/modules/ai_bot/entry_point" require_relative "lib/modules/ai_bot/entry_point"
require_relative "lib/discourse_automation/llm_triage" require_relative "lib/discourse_automation/llm_triage"
add_admin_route "discourse_ai.title", "discourse-ai"
[ [
DiscourseAi::Embeddings::EntryPoint.new, DiscourseAi::Embeddings::EntryPoint.new,
DiscourseAi::NSFW::EntryPoint.new, DiscourseAi::NSFW::EntryPoint.new,
@ -80,4 +83,10 @@ after_initialize do
on(:reviewable_transitioned_to) do |new_status, reviewable| on(:reviewable_transitioned_to) do |new_status, reviewable|
ModelAccuracy.adjust_model_accuracy(new_status, reviewable) ModelAccuracy.adjust_model_accuracy(new_status, reviewable)
end end
if Rails.env.test?
require_relative "spec/support/openai_completions_inference_stubs"
require_relative "spec/support/anthropic_completion_stubs"
require_relative "spec/support/stable_diffusion_stubs"
end
end end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
Fabricator(:ai_persona) do
name "test_bot"
description "I am a test bot"
system_prompt "You are a test bot"
end

View File

@ -17,8 +17,7 @@ module ::DiscourseAi
describe "system message" do describe "system message" do
it "includes the full command framework" do it "includes the full command framework" do
SiteSetting.ai_bot_enabled_chat_commands = "read|search" prompt = bot.system_prompt(post, allow_commands: true)
prompt = bot.system_prompt(post)
expect(prompt).to include("read") expect(prompt).to include("read")
expect(prompt).to include("search_query") expect(prompt).to include("search_query")
@ -27,7 +26,6 @@ module ::DiscourseAi
describe "parsing a reply prompt" do describe "parsing a reply prompt" do
it "can correctly predict that a completion needs to be cancelled" do it "can correctly predict that a completion needs to be cancelled" do
SiteSetting.ai_bot_enabled_chat_commands = "read|search"
functions = DiscourseAi::AiBot::Bot::FunctionCalls.new functions = DiscourseAi::AiBot::Bot::FunctionCalls.new
# note anthropic API has a silly leading space, we need to make sure we can handle that # note anthropic API has a silly leading space, we need to make sure we can handle that
@ -57,7 +55,6 @@ module ::DiscourseAi
end end
it "can correctly detect commands from a prompt" do it "can correctly detect commands from a prompt" do
SiteSetting.ai_bot_enabled_chat_commands = "read|search"
functions = DiscourseAi::AiBot::Bot::FunctionCalls.new functions = DiscourseAi::AiBot::Bot::FunctionCalls.new
# note anthropic API has a silly leading space, we need to make sure we can handle that # note anthropic API has a silly leading space, we need to make sure we can handle that

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../../support/openai_completions_inference_stubs"
class FakeBot < DiscourseAi::AiBot::Bot class FakeBot < DiscourseAi::AiBot::Bot
class Tokenizer class Tokenizer
def tokenize(text) def tokenize(text)
@ -13,7 +11,7 @@ class FakeBot < DiscourseAi::AiBot::Bot
Tokenizer.new Tokenizer.new
end end
def prompt_limit def prompt_limit(allow_commands: false)
10_000 10_000
end end
@ -115,7 +113,7 @@ describe DiscourseAi::AiBot::Bot do
SiteSetting.title = "My Forum" SiteSetting.title = "My Forum"
SiteSetting.site_description = "My Forum Description" SiteSetting.site_description = "My Forum Description"
system_prompt = bot.system_prompt(second_post) system_prompt = bot.system_prompt(second_post, allow_commands: true)
expect(system_prompt).to include(SiteSetting.title) expect(system_prompt).to include(SiteSetting.title)
expect(system_prompt).to include(SiteSetting.site_description) expect(system_prompt).to include(SiteSetting.site_description)
@ -135,7 +133,7 @@ describe DiscourseAi::AiBot::Bot do
}, },
} }
prompt = bot.bot_prompt_with_topic_context(second_post) prompt = bot.bot_prompt_with_topic_context(second_post, allow_commands: true)
req_opts = bot.reply_params.merge({ functions: bot.available_functions, stream: true }) req_opts = bot.reply_params.merge({ functions: bot.available_functions, stream: true })
@ -148,7 +146,7 @@ describe DiscourseAi::AiBot::Bot do
result = result =
DiscourseAi::AiBot::Commands::SearchCommand DiscourseAi::AiBot::Commands::SearchCommand
.new(bot_user: nil, args: nil) .new(bot: nil, args: nil)
.process(query: "test search") .process(query: "test search")
.to_json .to_json

View File

@ -1,13 +1,11 @@
#frozen_string_literal: true #frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::CategoriesCommand do RSpec.describe DiscourseAi::AiBot::Commands::CategoriesCommand do
describe "#generate_categories_info" do describe "#generate_categories_info" do
it "can generate correct info" do it "can generate correct info" do
Fabricate(:category, name: "america", posts_year: 999) Fabricate(:category, name: "america", posts_year: 999)
info = DiscourseAi::AiBot::Commands::CategoriesCommand.new(bot_user: nil, args: nil).process info = DiscourseAi::AiBot::Commands::CategoriesCommand.new(bot: nil, args: nil).process
expect(info.to_s).to include("america") expect(info.to_s).to include("america")
expect(info.to_s).to include("999") expect(info.to_s).to include("999")
end end

View File

@ -1,10 +1,7 @@
#frozen_string_literal: true #frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::Command do RSpec.describe DiscourseAi::AiBot::Commands::Command do
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } let(:command) { DiscourseAi::AiBot::Commands::GoogleCommand.new(bot: nil, args: nil) }
let(:command) { DiscourseAi::AiBot::Commands::GoogleCommand.new(bot_user: bot_user, args: nil) }
before { SiteSetting.ai_bot_enabled = true } before { SiteSetting.ai_bot_enabled = true }

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true #frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Commands::DbSchemaCommand do RSpec.describe DiscourseAi::AiBot::Commands::DbSchemaCommand do
let(:command) { DiscourseAi::AiBot::Commands::DbSchemaCommand.new(bot_user: nil, args: nil) } let(:command) { DiscourseAi::AiBot::Commands::DbSchemaCommand.new(bot: nil, args: nil) }
describe "#process" do describe "#process" do
it "returns rich schema for tables" do it "returns rich schema for tables" do
result = command.process(tables: "posts,topics") result = command.process(tables: "posts,topics")

View File

@ -2,6 +2,7 @@
RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) }
before { SiteSetting.ai_bot_enabled = true } before { SiteSetting.ai_bot_enabled = true }
@ -19,7 +20,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do
"https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=some%20search%20term", "https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=some%20search%20term",
).to_return(status: 200, body: json_text, headers: {}) ).to_return(status: 200, body: json_text, headers: {})
google = described_class.new(bot_user: bot_user, post: post, args: {}.to_json) google = described_class.new(bot: nil, post: post, args: {}.to_json)
info = google.process(query: "some search term").to_json info = google.process(query: "some search term").to_json
expect(google.description_args[:count]).to eq(0) expect(google.description_args[:count]).to eq(0)
@ -61,11 +62,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do
).to_return(status: 200, body: json_text, headers: {}) ).to_return(status: 200, body: json_text, headers: {})
google = google =
described_class.new( described_class.new(bot: bot, post: post, args: { query: "some search term" }.to_json)
bot_user: bot_user,
post: post,
args: { query: "some search term" }.to_json,
)
info = google.process(query: "some search term").to_json info = google.process(query: "some search term").to_json

View File

@ -2,6 +2,7 @@
RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) }
before { SiteSetting.ai_bot_enabled = true } before { SiteSetting.ai_bot_enabled = true }
@ -30,7 +31,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do
end end
.to_return(status: 200, body: { artifacts: artifacts }.to_json) .to_return(status: 200, body: { artifacts: artifacts }.to_json)
image = described_class.new(bot_user: bot_user, post: post, args: nil) image = described_class.new(bot: bot, post: post, args: nil)
info = image.process(prompts: prompts).to_json info = image.process(prompts: prompts).to_json

View File

@ -2,6 +2,7 @@
RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user) }
fab!(:parent_category) { Fabricate(:category, name: "animals") } fab!(:parent_category) { Fabricate(:category, name: "animals") }
fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") } fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") }
@ -31,7 +32,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do
Fabricate(:post, topic: topic_with_tags, raw: "hello there") Fabricate(:post, topic: topic_with_tags, raw: "hello there")
Fabricate(:post, topic: topic_with_tags, raw: "mister sam") Fabricate(:post, topic: topic_with_tags, raw: "mister sam")
read = described_class.new(bot_user: bot_user, args: nil) read = described_class.new(bot: bot, args: nil)
results = read.process(topic_id: topic_id) results = read.process(topic_id: topic_id)

View File

@ -1,11 +1,6 @@
#frozen_string_literal: true #frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
require_relative "../../../../support/embeddings_generation_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
before { SearchIndexer.enable } before { SearchIndexer.enable }
after { SearchIndexer.disable } after { SearchIndexer.disable }
@ -33,7 +28,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
describe "#process" do describe "#process" do
it "can handle no results" do it "can handle no results" do
post1 = Fabricate(:post, topic: topic_with_tags) post1 = Fabricate(:post, topic: topic_with_tags)
search = described_class.new(bot_user: bot_user, post: post1, args: nil) search = described_class.new(bot: nil, post: post1, args: nil)
results = search.process(query: "order:fake ABDDCDCEDGDG") results = search.process(query: "order:fake ABDDCDCEDGDG")
@ -64,7 +59,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
) )
post1 = Fabricate(:post, topic: topic_with_tags) post1 = Fabricate(:post, topic: topic_with_tags)
search = described_class.new(bot_user: bot_user, post: post1, args: nil) search = described_class.new(bot: nil, post: post1, args: nil)
DiscourseAi::Embeddings::VectorRepresentations::AllMpnetBaseV2 DiscourseAi::Embeddings::VectorRepresentations::AllMpnetBaseV2
.any_instance .any_instance
@ -83,7 +78,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
post1 = Fabricate(:post, topic: topic_with_tags) post1 = Fabricate(:post, topic: topic_with_tags)
search = described_class.new(bot_user: bot_user, post: post1, args: nil) search = described_class.new(bot: nil, post: post1, args: nil)
results = search.process(limit: 1, user: post1.user.username) results = search.process(limit: 1, user: post1.user.username)
expect(results[:rows].to_s).to include("/subfolder" + post1.url) expect(results[:rows].to_s).to include("/subfolder" + post1.url)
@ -91,7 +86,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
it "returns category and tags" do it "returns category and tags" do
post1 = Fabricate(:post, topic: topic_with_tags) post1 = Fabricate(:post, topic: topic_with_tags)
search = described_class.new(bot_user: bot_user, post: post1, args: nil) search = described_class.new(bot: nil, post: post1, args: nil)
results = search.process(user: post1.user.username) results = search.process(user: post1.user.username)
row = results[:rows].first row = results[:rows].first
@ -109,7 +104,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
_post3 = Fabricate(:post, user: post1.user) _post3 = Fabricate(:post, user: post1.user)
# search has no built in support for limit: so handle it from the outside # search has no built in support for limit: so handle it from the outside
search = described_class.new(bot_user: bot_user, post: post1, args: nil) search = described_class.new(bot: nil, post: post1, args: nil)
results = search.process(limit: 2, user: post1.user.username) results = search.process(limit: 2, user: post1.user.username)

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true #frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Commands::SearchSettingsCommand do RSpec.describe DiscourseAi::AiBot::Commands::SearchSettingsCommand do
let(:search) { described_class.new(bot_user: nil, args: nil) } let(:search) { described_class.new(bot: nil, args: nil) }
describe "#process" do describe "#process" do
it "can handle no results" do it "can handle no results" do
@ -19,7 +19,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchSettingsCommand do
it "can return descriptions if there are few matches" do it "can return descriptions if there are few matches" do
results = results =
search.process(query: "this will not be found!@,default_locale,ai_bot_enabled_personas") search.process(query: "this will not be found!@,default_locale,ai_bot_enabled_chat_bots")
expect(results[:rows].length).to eq(2) expect(results[:rows].length).to eq(2)

View File

@ -1,9 +1,8 @@
#frozen_string_literal: true #frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) }
before { SiteSetting.ai_bot_enabled = true } before { SiteSetting.ai_bot_enabled = true }
@ -16,7 +15,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
body: JSON.dump({ choices: [{ message: { content: "summary stuff" } }] }), body: JSON.dump({ choices: [{ message: { content: "summary stuff" } }] }),
) )
summarizer = described_class.new(bot_user: bot_user, args: nil, post: post) summarizer = described_class.new(bot: bot, args: nil, post: post)
info = summarizer.process(topic_id: post.topic_id, guidance: "why did it happen?") info = summarizer.process(topic_id: post.topic_id, guidance: "why did it happen?")
expect(info).to include("Topic summarized") expect(info).to include("Topic summarized")
@ -32,7 +31,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
topic = Fabricate(:topic, category_id: category.id) topic = Fabricate(:topic, category_id: category.id)
post = Fabricate(:post, topic: topic) post = Fabricate(:post, topic: topic)
summarizer = described_class.new(bot_user: bot_user, post: post, args: nil) summarizer = described_class.new(bot: bot, post: post, args: nil)
info = summarizer.process(topic_id: post.topic_id, guidance: "why did it happen?") info = summarizer.process(topic_id: post.topic_id, guidance: "why did it happen?")
expect(info).not_to include(post.raw) expect(info).not_to include(post.raw)

View File

@ -1,7 +1,5 @@
#frozen_string_literal: true #frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::TagsCommand do RSpec.describe DiscourseAi::AiBot::Commands::TagsCommand do
describe "#process" do describe "#process" do
it "can generate correct info" do it "can generate correct info" do
@ -10,7 +8,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::TagsCommand do
Fabricate(:tag, name: "america", public_topic_count: 100) Fabricate(:tag, name: "america", public_topic_count: 100)
Fabricate(:tag, name: "not_here", public_topic_count: 0) Fabricate(:tag, name: "not_here", public_topic_count: 0)
info = DiscourseAi::AiBot::Commands::TagsCommand.new(bot_user: nil, args: nil).process info = DiscourseAi::AiBot::Commands::TagsCommand.new(bot: nil, args: nil).process
expect(info.to_s).to include("america") expect(info.to_s).to include("america")
expect(info.to_s).not_to include("not_here") expect(info.to_s).not_to include("not_here")

View File

@ -1,14 +1,12 @@
#frozen_string_literal: true #frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::TimeCommand do RSpec.describe DiscourseAi::AiBot::Commands::TimeCommand do
describe "#process" do describe "#process" do
it "can generate correct info" do it "can generate correct info" do
freeze_time freeze_time
args = { timezone: "America/Los_Angeles" } args = { timezone: "America/Los_Angeles" }
info = DiscourseAi::AiBot::Commands::TimeCommand.new(bot_user: nil, args: nil).process(**args) info = DiscourseAi::AiBot::Commands::TimeCommand.new(bot: nil, args: nil).process(**args)
expect(info).to eq({ args: args, time: Time.now.in_time_zone("America/Los_Angeles").to_s }) expect(info).to eq({ args: args, time: Time.now.in_time_zone("America/Los_Angeles").to_s })
expect(info.to_s).not_to include("not_here") expect(info.to_s).not_to include("not_here")

View File

@ -1,8 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../../../../support/openai_completions_inference_stubs"
require_relative "../../../../../support/anthropic_completion_stubs"
RSpec.describe Jobs::CreateAiReply do RSpec.describe Jobs::CreateAiReply do
before do before do
# got to do this cause we include times in system message # got to do this cause we include times in system message
@ -31,7 +28,10 @@ RSpec.describe Jobs::CreateAiReply do
freeze_time freeze_time
OpenAiCompletionsInferenceStubs.stub_streamed_response( OpenAiCompletionsInferenceStubs.stub_streamed_response(
DiscourseAi::AiBot::OpenAiBot.new(bot_user).bot_prompt_with_topic_context(post), DiscourseAi::AiBot::OpenAiBot.new(bot_user).bot_prompt_with_topic_context(
post,
allow_commands: true,
),
deltas, deltas,
model: bot.model_for, model: bot.model_for,
req_opts: { req_opts: {
@ -83,7 +83,10 @@ RSpec.describe Jobs::CreateAiReply do
bot_user = User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) bot_user = User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID)
AnthropicCompletionStubs.stub_streamed_response( AnthropicCompletionStubs.stub_streamed_response(
DiscourseAi::AiBot::AnthropicBot.new(bot_user).bot_prompt_with_topic_context(post), DiscourseAi::AiBot::AnthropicBot.new(bot_user).bot_prompt_with_topic_context(
post,
allow_commands: true,
),
deltas, deltas,
model: "claude-2", model: "claude-2",
req_opts: { req_opts: {

View File

@ -19,26 +19,6 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
SiteSetting.ai_bot_enabled = true SiteSetting.ai_bot_enabled = true
end end
context "when changing available commands" do
it "contains all commands by default" do
# this will break as we add commands, but it is important as a sanity check
SiteSetting.ai_stability_api_key = "test"
SiteSetting.ai_google_custom_search_api_key = "test"
SiteSetting.ai_google_custom_search_cx = "test"
expect(subject.available_commands.length).to eq(
SiteSetting.ai_bot_enabled_chat_commands.split("|").length,
)
end
it "can properly filter out commands" do
SiteSetting.ai_bot_enabled_chat_commands = "time|tags"
expect(subject.available_commands.length).to eq(2)
expect(subject.available_commands).to eq(
[DiscourseAi::AiBot::Commands::TimeCommand, DiscourseAi::AiBot::Commands::TagsCommand],
)
end
end
context "when cleaning usernames" do context "when cleaning usernames" do
it "can properly clean usernames so OpenAI allows it" do it "can properly clean usernames so OpenAI allows it" do
expect(subject.clean_username("test test")).to eq("test_test") expect(subject.clean_username("test test")).to eq("test_test")
@ -51,7 +31,7 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
fab!(:post_1) { Fabricate(:post, topic: topic, raw: post_body(1), post_number: 1) } fab!(:post_1) { Fabricate(:post, topic: topic, raw: post_body(1), post_number: 1) }
it "includes it in the prompt" do it "includes it in the prompt" do
prompt_messages = subject.bot_prompt_with_topic_context(post_1) prompt_messages = subject.bot_prompt_with_topic_context(post_1, allow_commands: true)
post_1_message = prompt_messages[-1] post_1_message = prompt_messages[-1]
@ -65,7 +45,7 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
fab!(:post_1) { Fabricate(:post, topic: topic, raw: "test " * 6000, post_number: 1) } fab!(:post_1) { Fabricate(:post, topic: topic, raw: "test " * 6000, post_number: 1) }
it "trims the prompt" do it "trims the prompt" do
prompt_messages = subject.bot_prompt_with_topic_context(post_1) prompt_messages = subject.bot_prompt_with_topic_context(post_1, allow_commands: true)
# trimming is tricky... it needs to account for system message as # trimming is tricky... it needs to account for system message as
# well... just make sure we trim for now # well... just make sure we trim for now
@ -81,7 +61,7 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
let!(:post_3) { Fabricate(:post, topic: topic, raw: post_body(3), post_number: 3) } let!(:post_3) { Fabricate(:post, topic: topic, raw: post_body(3), post_number: 3) }
it "includes them in the prompt respecting the post number order" do it "includes them in the prompt respecting the post number order" do
prompt_messages = subject.bot_prompt_with_topic_context(post_3) prompt_messages = subject.bot_prompt_with_topic_context(post_3, allow_commands: true)
# negative cause we may have grounding prompts # negative cause we may have grounding prompts
expect(prompt_messages[-3][:role]).to eq("user") expect(prompt_messages[-3][:role]).to eq("user")

View File

@ -16,8 +16,6 @@ class TestPersona < DiscourseAi::AiBot::Personas::Persona
{site_description} {site_description}
{participants} {participants}
{time} {time}
{commands}
PROMPT PROMPT
end end
end end
@ -34,18 +32,20 @@ module DiscourseAi::AiBot::Personas
topic topic
end end
fab!(:user) { Fabricate(:user) } after do
# we are rolling back transactions so we can create poison cache
AiPersona.persona_cache.flush!
end
it "can disable commands via constructor" do fab!(:user)
persona = TestPersona.new(allow_commands: false)
rendered = it "can disable commands" do
persona.render_system_prompt(topic: topic_with_users, render_function_instructions: true) persona = TestPersona.new
rendered = persona.render_system_prompt(topic: topic_with_users, allow_commands: false)
expect(rendered).not_to include("!tags") expect(rendered).not_to include("!tags")
expect(rendered).not_to include("!search") expect(rendered).not_to include("!search")
expect(persona.available_functions).to be_empty
end end
it "renders the system prompt" do it "renders the system prompt" do
@ -81,7 +81,7 @@ module DiscourseAi::AiBot::Personas
# define an ai persona everyone can see # define an ai persona everyone can see
persona = persona =
AiPersona.create!( AiPersona.create!(
name: "pun_bot", name: "zzzpun_bot",
description: "you write puns", description: "you write puns",
system_prompt: "you are pun bot", system_prompt: "you are pun bot",
commands: ["ImageCommand"], commands: ["ImageCommand"],
@ -89,7 +89,7 @@ module DiscourseAi::AiBot::Personas
) )
custom_persona = DiscourseAi::AiBot::Personas.all(user: user).last custom_persona = DiscourseAi::AiBot::Personas.all(user: user).last
expect(custom_persona.name).to eq("pun_bot") expect(custom_persona.name).to eq("zzzpun_bot")
expect(custom_persona.description).to eq("you write puns") expect(custom_persona.description).to eq("you write puns")
instance = custom_persona.new instance = custom_persona.new
@ -99,53 +99,58 @@ module DiscourseAi::AiBot::Personas
) )
# should update # should update
persona.update!(name: "pun_bot2") persona.update!(name: "zzzpun_bot2")
custom_persona = DiscourseAi::AiBot::Personas.all(user: user).last custom_persona = DiscourseAi::AiBot::Personas.all(user: user).last
expect(custom_persona.name).to eq("pun_bot2") expect(custom_persona.name).to eq("zzzpun_bot2")
# can be disabled # can be disabled
persona.update!(enabled: false) persona.update!(enabled: false)
last_persona = DiscourseAi::AiBot::Personas.all(user: user).last last_persona = DiscourseAi::AiBot::Personas.all(user: user).last
expect(last_persona.name).not_to eq("pun_bot2") expect(last_persona.name).not_to eq("zzzpun_bot2")
persona.update!(enabled: true) persona.update!(enabled: true)
# no groups have access # no groups have access
persona.update!(allowed_group_ids: []) persona.update!(allowed_group_ids: [])
last_persona = DiscourseAi::AiBot::Personas.all(user: user).last last_persona = DiscourseAi::AiBot::Personas.all(user: user).last
expect(last_persona.name).not_to eq("pun_bot2") expect(last_persona.name).not_to eq("zzzpun_bot2")
end end
end end
describe "available personas" do describe "available personas" do
it "includes all personas by default" do it "includes all personas by default" do
Group.refresh_automatic_groups!
# must be enabled to see it # must be enabled to see it
SiteSetting.ai_stability_api_key = "abc" SiteSetting.ai_stability_api_key = "abc"
SiteSetting.ai_google_custom_search_api_key = "abc" SiteSetting.ai_google_custom_search_api_key = "abc"
SiteSetting.ai_google_custom_search_cx = "abc123"
expect(DiscourseAi::AiBot::Personas.all).to contain_exactly( # should be ordered by priority and then alpha
General, expect(DiscourseAi::AiBot::Personas.all(user: user)).to eq(
SqlHelper, [General, Artist, Creative, Researcher, SettingsExplorer, SqlHelper],
Artist,
SettingsExplorer,
Researcher,
Creative,
) )
end
it "does not include personas that require api keys by default" do # omits personas if key is missing
expect(DiscourseAi::AiBot::Personas.all).to contain_exactly( SiteSetting.ai_stability_api_key = ""
SiteSetting.ai_google_custom_search_api_key = ""
expect(DiscourseAi::AiBot::Personas.all(user: user)).to contain_exactly(
General, General,
SqlHelper, SqlHelper,
SettingsExplorer, SettingsExplorer,
Creative, Creative,
) )
end
it "can be modified via site settings" do AiPersona.find(DiscourseAi::AiBot::Personas.system_personas[General]).update!(
SiteSetting.ai_bot_enabled_personas = "general|sql_helper" enabled: false,
)
expect(DiscourseAi::AiBot::Personas.all).to contain_exactly(General, SqlHelper) expect(DiscourseAi::AiBot::Personas.all(user: user)).to contain_exactly(
SqlHelper,
SettingsExplorer,
Creative,
)
end end
end end
end end

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiHelper::LlmPrompt do RSpec.describe DiscourseAi::AiHelper::LlmPrompt do
let(:prompt) { CompletionPrompt.find_by(name: mode, provider: "openai") } let(:prompt) { CompletionPrompt.find_by(name: mode, provider: "openai") }

View File

@ -1,8 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../../support/openai_completions_inference_stubs"
require_relative "../../../support/stable_difussion_stubs"
RSpec.describe DiscourseAi::AiHelper::Painter do RSpec.describe DiscourseAi::AiHelper::Painter do
subject(:painter) { described_class.new } subject(:painter) { described_class.new }
@ -29,7 +26,7 @@ RSpec.describe DiscourseAi::AiHelper::Painter do
let(:expected_image_prompt) { <<~TEXT.strip } let(:expected_image_prompt) { <<~TEXT.strip }
Visualize a vibrant scene of an inkwell bursting, spreading colors across a blank canvas, Visualize a vibrant scene of an inkwell bursting, spreading colors across a blank canvas,
embodying words in tangible forms, symbolizing the rhythm and emotion evoked by poetry, embodying words in tangible forms, symbolizing the rhythm and emotion evoked by poetry,
under the soft glow of a full moon. under the soft glow of a full moon.
TEXT TEXT

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../../support/embeddings_generation_stubs" require_relative "../../../support/embeddings_generation_stubs"
require_relative "../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::Embeddings::SemanticSearch do RSpec.describe DiscourseAi::Embeddings::SemanticSearch do
fab!(:post) { Fabricate(:post) } fab!(:post) { Fabricate(:post) }

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../../../support/anthropic_completion_stubs"
RSpec.describe DiscourseAi::Summarization::Models::Anthropic do RSpec.describe DiscourseAi::Summarization::Models::Anthropic do
subject(:model) { described_class.new(model_name, max_tokens: max_tokens) } subject(:model) { described_class.new(model_name, max_tokens: max_tokens) }

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::Summarization::Models::OpenAi do RSpec.describe DiscourseAi::Summarization::Models::OpenAi do
subject(:model) { described_class.new(model_name, max_tokens: max_tokens) } subject(:model) { described_class.new(model_name, max_tokens: max_tokens) }

View File

@ -12,7 +12,7 @@ RSpec.describe AiPersona do
AiPersona.all_personas AiPersona.all_personas
expect(AiPersona.persona_cache[:value].length).to eq(1) expect(AiPersona.persona_cache[:value].length).to be > (0)
RailsMultisite::ConnectionManagement.stubs(:current_db) { "abc" } RailsMultisite::ConnectionManagement.stubs(:current_db) { "abc" }
expect(AiPersona.persona_cache[:value]).to eq(nil) expect(AiPersona.persona_cache[:value]).to eq(nil)
end end

View File

@ -0,0 +1,191 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe DiscourseAi::Admin::AiPersonasController do
fab!(:admin)
fab!(:ai_persona)
before { sign_in(admin) }
describe "GET #index" do
it "returns a success response" do
get "/admin/plugins/discourse-ai/ai_personas.json"
expect(response).to be_successful
expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count)
expect(response.parsed_body["meta"]["commands"].length).to eq(
DiscourseAi::AiBot::Personas::Persona.all_available_commands.length,
)
end
it "returns localized persona names and descriptions" do
SiteSetting.default_locale = "fr"
get "/admin/plugins/discourse-ai/ai_personas.json"
TranslationOverride.upsert!(:fr, "discourse_ai.ai_bot.personas.general.name", "Général")
TranslationOverride.upsert!(
:fr,
"discourse_ai.ai_bot.personas.general.description",
"Général Description",
)
id = DiscourseAi::AiBot::Personas.system_personas[DiscourseAi::AiBot::Personas::General]
name = I18n.t("discourse_ai.ai_bot.personas.general.name")
description = I18n.t("discourse_ai.ai_bot.personas.general.description")
persona = response.parsed_body["ai_personas"].find { |p| p["id"] == id }
expect(persona["name"]).to eq(name)
expect(persona["description"]).to eq(description)
end
end
describe "GET #show" do
it "returns a success response" do
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
end
describe "POST #create" do
context "with valid params" do
let(:valid_attributes) do
{
name: "superbot",
description: "Assists with tasks",
system_prompt: "you are a helpful bot",
}
end
it "creates a new AiPersona" do
expect {
post "/admin/plugins/discourse-ai/ai_personas.json",
params: {
ai_persona: valid_attributes,
}
expect(response).to be_successful
}.to change(AiPersona, :count).by(1)
end
end
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
expect(response).to have_http_status(:unprocessable_entity)
expect(response.content_type).to include("application/json")
end
end
end
describe "PUT #update" do
context "with valid params" do
it "updates the requested ai_persona" do
put "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json",
params: {
ai_persona: {
name: "SuperBot",
enabled: false,
commands: ["search"],
},
}
expect(response).to have_http_status(:ok)
expect(response.content_type).to include("application/json")
ai_persona.reload
expect(ai_persona.name).to eq("SuperBot")
expect(ai_persona.enabled).to eq(false)
expect(ai_persona.commands).to eq(["search"])
end
end
context "with system personas" do
it "does not allow editing of system prompts" do
put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}.json",
params: {
ai_persona: {
system_prompt: "you are not a helpful bot",
},
}
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 include("en.discourse")
end
it "does not allow editing of commands" do
put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}.json",
params: {
ai_persona: {
commands: %w[SearchCommand ImageCommand],
},
}
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 include("en.discourse")
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.system_personas.values.first}.json",
params: {
ai_persona: {
name: "bob",
dscription: "the bob",
},
}
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 include("en.discourse")
end
it "does allow some actions" do
put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}.json",
params: {
ai_persona: {
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]],
enabled: false,
priority: 989,
},
}
expect(response).to be_successful
end
end
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",
params: {
ai_persona: {
name: "",
},
} # invalid attribute
expect(response).to have_http_status(:unprocessable_entity)
expect(response.content_type).to include("application/json")
end
end
end
describe "DELETE #destroy" do
it "destroys the requested ai_persona" do
expect {
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)
end
it "is not allowed to delete system personas" do
expect {
delete "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.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
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
}.not_to change(AiPersona, :count)
end
end
end

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiHelper::AssistantController do RSpec.describe DiscourseAi::AiHelper::AssistantController do
describe "#suggest" do describe "#suggest" do
let(:text) { OpenAiCompletionsInferenceStubs.translated_response } let(:text) { OpenAiCompletionsInferenceStubs.translated_response }

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../support/anthropic_completion_stubs"
RSpec.describe DiscourseAi::Inference::AnthropicCompletions do RSpec.describe DiscourseAi::Inference::AnthropicCompletions do
before { SiteSetting.ai_anthropic_api_key = "abc-123" } before { SiteSetting.ai_anthropic_api_key = "abc-123" }

View File

@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require "rails_helper" require "rails_helper"
require_relative "../../support/openai_completions_inference_stubs"
describe DiscourseAi::Inference::OpenAiCompletions do describe DiscourseAi::Inference::OpenAiCompletions do
before { SiteSetting.ai_openai_api_key = "abc-123" } before { SiteSetting.ai_openai_api_key = "abc-123" }

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
RSpec.describe "AI personas", type: :system, js: true do
fab!(:admin)
before do
SiteSetting.ai_bot_enabled = true
SiteSetting.ai_bot_enabled_chat_bots = "gpt-4|gpt-3.5-turbo"
sign_in(admin)
end
it "allows creation of a persona" do
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")
find(".ai-persona-editor__system_prompt").fill_in(with: "You are a helpful bot")
find(".ai-persona-editor__save").click()
expect(page).not_to have_current_path("/admin/plugins/discourse-ai/ai_personas/new")
persona_id = page.current_path.split("/").last.to_i
persona = AiPersona.find(persona_id)
expect(persona.name).to eq("Test Persona")
expect(persona.description).to eq("I am a test persona")
expect(persona.system_prompt).to eq("You are a helpful bot")
end
it "will not allow deletion or editing of system personas" do
visit "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.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
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}"
find(".ai-persona-editor__name").set("Test Persona 1")
PageObjects::Components::DToggleSwitch.new(".ai-persona-editor__enabled").toggle
try_until_success { expect(persona.reload.enabled).to eq(true) }
persona.reload
expect(persona.enabled).to eq(true)
expect(persona.name).not_to eq("Test Persona 1")
end
end

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../support/openai_completions_inference_stubs"
RSpec.describe "AI Composer helper", type: :system, js: true do RSpec.describe "AI Composer helper", type: :system, js: true do
fab!(:user) { Fabricate(:admin) } fab!(:user) { Fabricate(:admin) }
fab!(:non_member_group) { Fabricate(:group) } fab!(:non_member_group) { Fabricate(:group) }

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "../../support/openai_completions_inference_stubs"
RSpec.describe "AI Composer helper", type: :system, js: true do RSpec.describe "AI Composer helper", type: :system, js: true do
fab!(:user) { Fabricate(:admin) } fab!(:user) { Fabricate(:admin) }
fab!(:non_member_group) { Fabricate(:group) } fab!(:non_member_group) { Fabricate(:group) }