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
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
def initialize(id)
@hash = Hash.new { |h, k| h[k] = {} }
@ -38,19 +45,50 @@ class AiPersona < ActiveRecord::Base
@persona_cache ||= MultisiteHash.new("persona_cache")
end
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
def self.all_personas
persona_cache[:value] ||= AiPersona
.order(:name)
.ordered
.where(enabled: true)
.all
.limit(MAX_PERSONAS_PER_SITE)
.map do |ai_persona|
name = ai_persona.name
description = ai_persona.description
ai_persona_id = ai_persona.id
allowed_group_ids = ai_persona.allowed_group_ids
.map(&:class_instance)
end
after_commit :bump_cache
def bump_cache
self.class.persona_cache.flush!
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 =
ai_persona.commands.filter_map do |inner_name|
self.commands.filter_map do |inner_name|
begin
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
rescue StandardError
@ -59,6 +97,10 @@ class AiPersona < ActiveRecord::Base
end
Class.new(DiscourseAi::AiBot::Personas::Persona) do
define_singleton_method :id do
id
end
define_singleton_method :name do
name
end
@ -67,6 +109,10 @@ class AiPersona < ActiveRecord::Base
description
end
define_singleton_method :system do
system
end
define_singleton_method :allowed_group_ids do
allowed_group_ids
end
@ -93,12 +139,20 @@ class AiPersona < ActiveRecord::Base
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
after_commit :bump_cache
def bump_cache
self.class.persona_cache.flush!
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
@ -110,10 +164,14 @@ end
# name :string(100) not null
# description :string(2000) not null
# 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
# created_by_id :integer
# enabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# system :boolean default(FALSE), not null
# priority :integer default(0), not null
#
# 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

@ -1,5 +1,7 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { inject as service } from "@ember/service";
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
function isBotMessage(composer, currentUser) {
if (
@ -25,6 +27,13 @@ export default class BotSelector extends Component {
}
@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() {
return this.args?.outletArgs?.model;
@ -34,7 +43,7 @@ export default class BotSelector extends Component {
if (this.currentUser.ai_enabled_personas) {
return this.currentUser.ai_enabled_personas.map((persona) => {
return {
id: persona.name,
id: persona.id,
name: persona.name,
description: persona.description,
};
@ -43,11 +52,21 @@ export default class BotSelector extends Component {
}
get value() {
return this._value || this.botOptions[0].id;
return this._value;
}
set 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"
discourse_ai:
title: "AI"
modals:
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:
title: "Related Topics"
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_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_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_enabled_personas: "List of personas available for the AI Bot"
ai_stability_api_key: "API key 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:
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:
name: Forum Helper
description: "General purpose AI Bot capable of performing various tasks"

View File

@ -26,4 +26,11 @@ Discourse::Application.routes.draw do
get "admin/dashboard/sentiment" => "discourse_ai/admin/dashboard#sentiment",
: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

View File

@ -250,29 +250,6 @@ discourse_ai:
- gpt-3.5-turbo
- gpt-4
- 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:
default: 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
end
def bot_prompt_with_topic_context(post)
super(post).join("\n\n") + "\n\nAssistant:"
def bot_prompt_with_topic_context(post, allow_commands:)
super(post, allow_commands: allow_commands).join("\n\n") + "\n\nAssistant:"
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
end

View File

@ -67,12 +67,12 @@ module DiscourseAi
end
end
attr_reader :bot_user
attr_reader :bot_user, :persona
BOT_NOT_FOUND = Class.new(StandardError)
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]
bot =
@ -80,12 +80,23 @@ module DiscourseAi
bot_klass.can_reply_as?(bot_user)
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
def initialize(bot_user)
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
def initialize(bot_user, persona: nil)
@bot_user = bot_user
@persona = DiscourseAi::AiBot::Personas::General.new
@persona = persona || DiscourseAi::AiBot::Personas::General.new
end
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)
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 =
if standalone && post.post_custom_prompt
username, standalone_prompt = post.post_custom_prompt.custom_prompt.last
[build_message(username, standalone_prompt)]
else
bot_prompt_with_topic_context(post)
bot_prompt_with_topic_context(post, allow_commands: allow_commands)
end
redis_stream_key = nil
@ -225,12 +227,7 @@ module DiscourseAi
if command_klass = available_commands.detect { |cmd| cmd.invoked?(name) }
command =
command_klass.new(
bot_user: bot_user,
args: args,
post: bot_reply_post,
parent_post: post,
)
command_klass.new(bot: self, args: args, post: bot_reply_post, parent_post: post)
chain_intermediate, bot_reply_post = command.invoke!
chain ||= chain_intermediate
standalone ||= command.standalone?
@ -259,30 +256,30 @@ module DiscourseAi
0
end
def bot_prompt_with_topic_context(post, prompt: "topic")
def bot_prompt_with_topic_context(post, allow_commands:)
messages = []
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
messages =
conversation.reduce([]) do |memo, (raw, username, function)|
break(memo) if total_prompt_tokens >= prompt_limit
prompt_limit = self.prompt_limit(allow_commands: allow_commands)
tokens = tokenize(raw.to_s)
conversation.each do |raw, username, function|
break if total_prompt_tokens >= prompt_limit
tokens = tokenize(raw.to_s + username.to_s)
while !raw.blank? &&
tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit
raw = raw[0..-100] || ""
tokens = tokenize(raw.to_s)
tokens = tokenize(raw.to_s + username.to_s)
end
next(memo) if raw.blank?
next if raw.blank?
total_prompt_tokens += tokens.length + extra_tokens_per_message
memo.unshift(build_message(username, raw, function: !!function))
messages.unshift(build_message(username, raw, function: !!function))
end
messages.unshift(build_message(bot_user.username, rendered_system_prompt, system: true))
@ -290,7 +287,7 @@ module DiscourseAi
messages
end
def prompt_limit
def prompt_limit(allow_commands: false)
raise NotImplemented
end
@ -300,7 +297,7 @@ module DiscourseAi
You will never respond with anything but a topic title.
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
end
@ -312,12 +309,14 @@ module DiscourseAi
@style = style
end
def system_prompt(post)
def system_prompt(post, allow_commands:)
return "You are a helpful Bot" if @style == :simple
@persona.render_system_prompt(
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

View File

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

View File

@ -76,7 +76,9 @@ module DiscourseAi
) do
Personas
.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
plugin.add_to_serializer(
@ -112,7 +114,11 @@ module DiscourseAi
:topic_view,
:ai_persona_name,
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|
bot_ids = BOTS.map(&:first)
@ -140,7 +146,7 @@ module DiscourseAi
end
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

View File

@ -6,10 +6,24 @@ module ::Jobs
def execute(args)
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])
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

View File

@ -12,13 +12,16 @@ module DiscourseAi
open_ai_bot_ids.include?(bot_user.id)
end
def prompt_limit
# note this is about 100 tokens over, OpenAI have a more optimal representation
@function_size ||= tokenize(available_functions.to_json).length
def prompt_limit(allow_commands:)
# provide a buffer of 120 tokens - our function counting is not
# 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
8192 - buffer

View File

@ -8,6 +8,10 @@ module DiscourseAi
[Commands::ImageCommand]
end
def required_commands
[Commands::ImageCommand]
end
def system_prompt
<<~PROMPT
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.
- 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
{commands}
PROMPT
end
end

View File

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

View File

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

View File

@ -8,6 +8,10 @@ module DiscourseAi
[Commands::GoogleCommand]
end
def required_commands
[Commands::GoogleCommand]
end
def system_prompt
<<~PROMPT
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.
- When generating responses you always cite your sources.
- When possible you also quote the sources.
{commands}
PROMPT
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'
Current time is: {time}
{commands}
PROMPT
end
end

View File

@ -63,8 +63,6 @@ module DiscourseAi
{{
#{self.class.schema}
}}
{commands}
PROMPT
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-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-search.scss"
@ -61,6 +62,8 @@ after_initialize do
require_relative "lib/modules/ai_bot/entry_point"
require_relative "lib/discourse_automation/llm_triage"
add_admin_route "discourse_ai.title", "discourse-ai"
[
DiscourseAi::Embeddings::EntryPoint.new,
DiscourseAi::NSFW::EntryPoint.new,
@ -80,4 +83,10 @@ after_initialize do
on(:reviewable_transitioned_to) do |new_status, reviewable|
ModelAccuracy.adjust_model_accuracy(new_status, reviewable)
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

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
it "includes the full command framework" do
SiteSetting.ai_bot_enabled_chat_commands = "read|search"
prompt = bot.system_prompt(post)
prompt = bot.system_prompt(post, allow_commands: true)
expect(prompt).to include("read")
expect(prompt).to include("search_query")
@ -27,7 +26,6 @@ module ::DiscourseAi
describe "parsing a reply prompt" 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
# note anthropic API has a silly leading space, we need to make sure we can handle that
@ -57,7 +55,6 @@ module ::DiscourseAi
end
it "can correctly detect commands from a prompt" do
SiteSetting.ai_bot_enabled_chat_commands = "read|search"
functions = DiscourseAi::AiBot::Bot::FunctionCalls.new
# 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
require_relative "../../../support/openai_completions_inference_stubs"
class FakeBot < DiscourseAi::AiBot::Bot
class Tokenizer
def tokenize(text)
@ -13,7 +11,7 @@ class FakeBot < DiscourseAi::AiBot::Bot
Tokenizer.new
end
def prompt_limit
def prompt_limit(allow_commands: false)
10_000
end
@ -115,7 +113,7 @@ describe DiscourseAi::AiBot::Bot do
SiteSetting.title = "My Forum"
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.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 })
@ -148,7 +146,7 @@ describe DiscourseAi::AiBot::Bot do
result =
DiscourseAi::AiBot::Commands::SearchCommand
.new(bot_user: nil, args: nil)
.new(bot: nil, args: nil)
.process(query: "test search")
.to_json

View File

@ -1,13 +1,11 @@
#frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::CategoriesCommand do
describe "#generate_categories_info" do
it "can generate correct info" do
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("999")
end

View File

@ -1,10 +1,7 @@
#frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
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_user: bot_user, args: nil) }
let(:command) { DiscourseAi::AiBot::Commands::GoogleCommand.new(bot: nil, args: nil) }
before { SiteSetting.ai_bot_enabled = true }

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
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
it "returns rich schema for tables" do
result = command.process(tables: "posts,topics")

View File

@ -2,6 +2,7 @@
RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do
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 }
@ -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",
).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
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: {})
google =
described_class.new(
bot_user: bot_user,
post: post,
args: { query: "some search term" }.to_json,
)
described_class.new(bot: bot, post: post, args: { 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
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 }
@ -30,7 +31,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do
end
.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

View File

@ -2,6 +2,7 @@
RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do
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!(: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: "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)

View File

@ -1,11 +1,6 @@
#frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
require_relative "../../../../support/embeddings_generation_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
before { SearchIndexer.enable }
after { SearchIndexer.disable }
@ -33,7 +28,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
describe "#process" do
it "can handle no results" do
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")
@ -64,7 +59,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
)
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
.any_instance
@ -83,7 +78,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
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)
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
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)
row = results[:rows].first
@ -109,7 +104,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
_post3 = Fabricate(:post, user: post1.user)
# 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)

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
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
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
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)

View File

@ -1,9 +1,8 @@
#frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
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 }
@ -16,7 +15,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
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?")
expect(info).to include("Topic summarized")
@ -32,7 +31,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
topic = Fabricate(:topic, category_id: category.id)
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?")
expect(info).not_to include(post.raw)

View File

@ -1,7 +1,5 @@
#frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::TagsCommand do
describe "#process" 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: "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).not_to include("not_here")

View File

@ -1,14 +1,12 @@
#frozen_string_literal: true
require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::TimeCommand do
describe "#process" do
it "can generate correct info" do
freeze_time
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_s).not_to include("not_here")

View File

@ -1,8 +1,5 @@
# frozen_string_literal: true
require_relative "../../../../../support/openai_completions_inference_stubs"
require_relative "../../../../../support/anthropic_completion_stubs"
RSpec.describe Jobs::CreateAiReply do
before do
# got to do this cause we include times in system message
@ -31,7 +28,10 @@ RSpec.describe Jobs::CreateAiReply do
freeze_time
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,
model: bot.model_for,
req_opts: {
@ -83,7 +83,10 @@ RSpec.describe Jobs::CreateAiReply do
bot_user = User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID)
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,
model: "claude-2",
req_opts: {

View File

@ -19,26 +19,6 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
SiteSetting.ai_bot_enabled = true
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
it "can properly clean usernames so OpenAI allows it" do
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) }
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]
@ -65,7 +45,7 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
fab!(:post_1) { Fabricate(:post, topic: topic, raw: "test " * 6000, post_number: 1) }
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
# 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) }
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
expect(prompt_messages[-3][:role]).to eq("user")

View File

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

View File

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

View File

@ -1,8 +1,5 @@
# frozen_string_literal: true
require_relative "../../../support/openai_completions_inference_stubs"
require_relative "../../../support/stable_difussion_stubs"
RSpec.describe DiscourseAi::AiHelper::Painter do
subject(:painter) { described_class.new }

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ RSpec.describe AiPersona do
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" }
expect(AiPersona.persona_cache[:value]).to eq(nil)
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
require_relative "../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiHelper::AssistantController do
describe "#suggest" do
let(:text) { OpenAiCompletionsInferenceStubs.translated_response }

View File

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

View File

@ -1,8 +1,6 @@
# frozen_string_literal: true
require "rails_helper"
require_relative "../../support/openai_completions_inference_stubs"
describe DiscourseAi::Inference::OpenAiCompletions do
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
require_relative "../../support/openai_completions_inference_stubs"
RSpec.describe "AI Composer helper", type: :system, js: true do
fab!(:user) { Fabricate(:admin) }
fab!(:non_member_group) { Fabricate(:group) }

View File

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