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:
parent
491111e5c8
commit
5b5edb22c6
|
@ -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
|
|
@ -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
|
|
@ -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,19 +45,50 @@ 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
|
end
|
||||||
description = ai_persona.description
|
|
||||||
ai_persona_id = ai_persona.id
|
after_commit :bump_cache
|
||||||
allowed_group_ids = ai_persona.allowed_group_ids
|
|
||||||
|
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 =
|
commands =
|
||||||
ai_persona.commands.filter_map do |inner_name|
|
self.commands.filter_map do |inner_name|
|
||||||
begin
|
begin
|
||||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
|
@ -59,6 +97,10 @@ class AiPersona < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
Class.new(DiscourseAi::AiBot::Personas::Persona) do
|
Class.new(DiscourseAi::AiBot::Personas::Persona) do
|
||||||
|
define_singleton_method :id do
|
||||||
|
id
|
||||||
|
end
|
||||||
|
|
||||||
define_singleton_method :name do
|
define_singleton_method :name do
|
||||||
name
|
name
|
||||||
end
|
end
|
||||||
|
@ -67,6 +109,10 @@ class AiPersona < ActiveRecord::Base
|
||||||
description
|
description
|
||||||
end
|
end
|
||||||
|
|
||||||
|
define_singleton_method :system do
|
||||||
|
system
|
||||||
|
end
|
||||||
|
|
||||||
define_singleton_method :allowed_group_ids do
|
define_singleton_method :allowed_group_ids do
|
||||||
allowed_group_ids
|
allowed_group_ids
|
||||||
end
|
end
|
||||||
|
@ -93,12 +139,20 @@ class AiPersona < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
after_commit :bump_cache
|
def ensure_not_system
|
||||||
|
if system
|
||||||
def bump_cache
|
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_delete_system_persona"))
|
||||||
self.class.persona_cache.flush!
|
throw :abort
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
|
|
@ -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
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
|
@ -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>
|
||||||
|
}
|
|
@ -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>
|
||||||
|
}
|
|
@ -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>
|
||||||
}
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
<div class="gpt-persona">
|
|
||||||
<DropdownSelectBox
|
|
||||||
@value={{this.value}}
|
|
||||||
@content={{this.botOptions}}
|
|
||||||
@options={{hash icon="robot"}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
|
export default DiscourseRoute.extend({
|
||||||
|
model() {
|
||||||
|
return this.modelFor("adminPlugins.discourse-ai.ai-personas");
|
||||||
|
},
|
||||||
|
});
|
|
@ -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")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
|
@ -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")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,7 @@
|
||||||
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
|
export default DiscourseRoute.extend({
|
||||||
|
async model() {
|
||||||
|
return this.store.findAll("ai-persona");
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,7 @@
|
||||||
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
|
export default DiscourseRoute.extend({
|
||||||
|
beforeModel() {
|
||||||
|
this.transitionTo("adminPlugins.discourse-ai.ai-personas");
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1 @@
|
||||||
|
<AiPersonaListEditor @personas={{this.model}} />
|
|
@ -0,0 +1,4 @@
|
||||||
|
<AiPersonaListEditor
|
||||||
|
@personas={{this.allPersonas}}
|
||||||
|
@currentPersona={{this.model}}
|
||||||
|
/>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<AiPersonaListEditor
|
||||||
|
@personas={{this.allPersonas}}
|
||||||
|
@currentPersona={{this.model}}
|
||||||
|
/>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
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
|
@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,30 +256,30 @@ 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
|
||||||
|
|
||||||
|
tokens = tokenize(raw.to_s + username.to_s)
|
||||||
|
|
||||||
while !raw.blank? &&
|
while !raw.blank? &&
|
||||||
tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit
|
tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit
|
||||||
raw = raw[0..-100] || ""
|
raw = raw[0..-100] || ""
|
||||||
tokens = tokenize(raw.to_s)
|
tokens = tokenize(raw.to_s + username.to_s)
|
||||||
end
|
end
|
||||||
|
|
||||||
next(memo) if raw.blank?
|
next if raw.blank?
|
||||||
|
|
||||||
total_prompt_tokens += tokens.length + extra_tokens_per_message
|
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
|
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))
|
||||||
|
@ -290,7 +287,7 @@ module DiscourseAi
|
||||||
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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
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)
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 =
|
||||||
|
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.filter do |persona|
|
||||||
personas_allowed.include?(persona.to_s.demodulize.underscore)
|
if persona.system
|
||||||
end
|
instance = persona.new
|
||||||
|
(
|
||||||
if user
|
instance.required_commands == [] ||
|
||||||
personas.concat(
|
(instance.required_commands - all_available_commands).empty?
|
||||||
AiPersona.all_personas.filter do |persona|
|
|
||||||
user.in_any_groups?(persona.allowed_group_ids)
|
|
||||||
end,
|
|
||||||
)
|
)
|
||||||
|
else
|
||||||
|
true
|
||||||
|
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
|
||||||
|
|
||||||
|
prompt =
|
||||||
system_prompt.gsub(/\{(\w+)\}/) do |match|
|
system_prompt.gsub(/\{(\w+)\}/) do |match|
|
||||||
found = substitutions[match[1..-2].to_sym]
|
found = substitutions[match[1..-2].to_sym]
|
||||||
found.nil? ? match : found.to_s
|
found.nil? ? match : found.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if allow_commands
|
||||||
|
prompt += render_commands(render_function_instructions: render_function_instructions)
|
||||||
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -63,8 +63,6 @@ module DiscourseAi
|
||||||
{{
|
{{
|
||||||
#{self.class.schema}
|
#{self.class.schema}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
{commands}
|
|
||||||
PROMPT
|
PROMPT
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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") }
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
@ -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) }
|
||||||
|
|
|
@ -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) }
|
||||||
|
|
||||||
|
|
|
@ -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) }
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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 }
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -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) }
|
||||||
|
|
|
@ -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) }
|
||||||
|
|
Loading…
Reference in New Issue