FEATURE: allow personas to provide command options (#331)
Personas now support providing options for commands. This PR introduces a single option "base_query" for the SearchCommand. When supplied all searches the persona will perform will also include the pre-supplied filter. This can allow personas to search a subset of the forum (such as documentation) This system is extensible we can add options to any command trivially.
This commit is contained in:
parent
381b0d74ca
commit
6380ebd829
|
@ -14,7 +14,7 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
commands =
|
commands =
|
||||||
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
|
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
|
||||||
{ id: command.to_s.split("::").last, name: command.name.humanize.titleize }
|
AiCommandSerializer.new(command, root: false)
|
||||||
end
|
end
|
||||||
render json: { ai_personas: ai_personas, meta: { commands: commands } }
|
render json: { ai_personas: ai_personas, meta: { commands: commands } }
|
||||||
end
|
end
|
||||||
|
@ -55,16 +55,36 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
|
|
||||||
def ai_persona_params
|
def ai_persona_params
|
||||||
|
permitted =
|
||||||
params.require(:ai_persona).permit(
|
params.require(:ai_persona).permit(
|
||||||
:name,
|
:name,
|
||||||
:description,
|
:description,
|
||||||
:enabled,
|
:enabled,
|
||||||
:system_prompt,
|
:system_prompt,
|
||||||
:enabled,
|
|
||||||
:priority,
|
:priority,
|
||||||
allowed_group_ids: [],
|
allowed_group_ids: [],
|
||||||
commands: [],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if commands = params.dig(:ai_persona, :commands)
|
||||||
|
permitted[:commands] = permit_commands(commands)
|
||||||
|
end
|
||||||
|
|
||||||
|
permitted
|
||||||
|
end
|
||||||
|
|
||||||
|
def permit_commands(commands)
|
||||||
|
return [] if !commands.is_a?(Array)
|
||||||
|
|
||||||
|
commands.filter_map do |command, options|
|
||||||
|
break nil if !command.is_a?(String)
|
||||||
|
options&.permit! if options && options.is_a?(ActionController::Parameters)
|
||||||
|
|
||||||
|
if options
|
||||||
|
[command, options]
|
||||||
|
else
|
||||||
|
command
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -87,10 +87,23 @@ class AiPersona < ActiveRecord::Base
|
||||||
name = self.name
|
name = self.name
|
||||||
description = self.description
|
description = self.description
|
||||||
ai_persona_id = self.id
|
ai_persona_id = self.id
|
||||||
|
|
||||||
|
options = {}
|
||||||
|
|
||||||
commands =
|
commands =
|
||||||
self.commands.filter_map do |inner_name|
|
self.commands.filter_map do |element|
|
||||||
|
inner_name = element
|
||||||
|
current_options = nil
|
||||||
|
|
||||||
|
if element.is_a?(Array)
|
||||||
|
inner_name = element[0]
|
||||||
|
current_options = element[1]
|
||||||
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
klass = ("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||||
|
options[klass] = current_options if current_options
|
||||||
|
klass
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
@ -134,6 +147,10 @@ class AiPersona < ActiveRecord::Base
|
||||||
commands
|
commands
|
||||||
end
|
end
|
||||||
|
|
||||||
|
define_method :options do
|
||||||
|
options
|
||||||
|
end
|
||||||
|
|
||||||
define_method :system_prompt do
|
define_method :system_prompt do
|
||||||
@ai_persona&.system_prompt || "You are a helpful bot."
|
@ai_persona&.system_prompt || "You are a helpful bot."
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AiCommandSerializer < ApplicationSerializer
|
||||||
|
attributes :options, :id, :name, :help
|
||||||
|
|
||||||
|
def include_options?
|
||||||
|
object.options.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def id
|
||||||
|
object.to_s.split("::").last
|
||||||
|
end
|
||||||
|
|
||||||
|
def name
|
||||||
|
object.name.humanize.titleize
|
||||||
|
end
|
||||||
|
|
||||||
|
def help
|
||||||
|
object.help
|
||||||
|
end
|
||||||
|
|
||||||
|
def options
|
||||||
|
options = {}
|
||||||
|
object.options.each do |option|
|
||||||
|
options[option.name] = {
|
||||||
|
name: option.localized_name,
|
||||||
|
description: option.localized_description,
|
||||||
|
type: option.type,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
options
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
import RestModel from "discourse/models/rest";
|
import RestModel from "discourse/models/rest";
|
||||||
|
|
||||||
const ATTRIBUTES = [
|
const ATTRIBUTES = [
|
||||||
|
@ -11,15 +12,81 @@ const ATTRIBUTES = [
|
||||||
"priority",
|
"priority",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
class CommandOption {
|
||||||
|
@tracked value = null;
|
||||||
|
}
|
||||||
|
|
||||||
export default class AiPersona extends RestModel {
|
export default class AiPersona extends RestModel {
|
||||||
|
// this code is here to convert the wire schema to easier to work with object
|
||||||
|
// on the wire we pass in/out commands as an Array.
|
||||||
|
// [[CommandName, {option1: value, option2: value}], CommandName2, CommandName3]
|
||||||
|
// So we rework this into a "commands" property and nested commandOptions
|
||||||
|
init(properties) {
|
||||||
|
if (properties.commands) {
|
||||||
|
properties.commands = properties.commands.map((command) => {
|
||||||
|
if (typeof command === "string") {
|
||||||
|
return command;
|
||||||
|
} else {
|
||||||
|
let [commandId, options] = command;
|
||||||
|
for (let optionId in options) {
|
||||||
|
if (!options.hasOwnProperty(optionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.getCommandOption(commandId, optionId).value =
|
||||||
|
options[optionId];
|
||||||
|
}
|
||||||
|
return commandId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
super.init(properties);
|
||||||
|
this.commands = properties.commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommandOption(commandId, optionId) {
|
||||||
|
this.commandOptions ||= {};
|
||||||
|
this.commandOptions[commandId] ||= {};
|
||||||
|
return (this.commandOptions[commandId][optionId] ||= new CommandOption());
|
||||||
|
}
|
||||||
|
|
||||||
|
populateCommandOptions(attrs) {
|
||||||
|
if (!attrs.commands) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let commandsWithOptions = [];
|
||||||
|
attrs.commands.forEach((commandId) => {
|
||||||
|
if (typeof commandId !== "string") {
|
||||||
|
commandId = commandId[0];
|
||||||
|
}
|
||||||
|
if (this.commandOptions && this.commandOptions[commandId]) {
|
||||||
|
let options = this.commandOptions[commandId];
|
||||||
|
let optionsWithValues = {};
|
||||||
|
for (let optionId in options) {
|
||||||
|
if (!options.hasOwnProperty(optionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let option = options[optionId];
|
||||||
|
optionsWithValues[optionId] = option.value;
|
||||||
|
}
|
||||||
|
commandsWithOptions.push([commandId, optionsWithValues]);
|
||||||
|
} else {
|
||||||
|
commandsWithOptions.push(commandId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
attrs.commands = commandsWithOptions;
|
||||||
|
}
|
||||||
|
|
||||||
updateProperties() {
|
updateProperties() {
|
||||||
let attrs = this.getProperties(ATTRIBUTES);
|
let attrs = this.getProperties(ATTRIBUTES);
|
||||||
attrs.id = this.id;
|
attrs.id = this.id;
|
||||||
|
this.populateCommandOptions(attrs);
|
||||||
return attrs;
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
createProperties() {
|
createProperties() {
|
||||||
return this.getProperties(ATTRIBUTES);
|
let attrs = this.getProperties(ATTRIBUTES);
|
||||||
|
this.populateCommandOptions(attrs);
|
||||||
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
workingCopy() {
|
workingCopy() {
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Input } from "@ember/component";
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="control-group ai-persona-command-option-editor">
|
||||||
|
<label>
|
||||||
|
{{@option.name}}
|
||||||
|
</label>
|
||||||
|
<div class="">
|
||||||
|
<Input @value={{@option.value.value}} />
|
||||||
|
</div>
|
||||||
|
<div class="ai-persona-command-option-editor__instructions">
|
||||||
|
{{@option.description}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,81 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import I18n from "discourse-i18n";
|
||||||
|
import AiPersonaCommandOptionEditor from "./ai-persona-command-option-editor";
|
||||||
|
|
||||||
|
export default class AiPersonaCommandOptions extends Component {
|
||||||
|
get showCommandOptions() {
|
||||||
|
const allCommands = this.args.allCommands;
|
||||||
|
if (!allCommands) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commandNames.any(
|
||||||
|
(command) => allCommands.find((c) => c.id === command)?.options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get commandNames() {
|
||||||
|
if (!this.args.commands) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.args.commands.map((command) => {
|
||||||
|
if (typeof command === "string") {
|
||||||
|
return command;
|
||||||
|
} else {
|
||||||
|
return command[0];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get commandOptions() {
|
||||||
|
if (!this.args.commands) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCommands = this.args.allCommands;
|
||||||
|
if (!allCommands) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [];
|
||||||
|
this.commandNames.forEach((commandId) => {
|
||||||
|
const command = allCommands.find((c) => c.id === commandId);
|
||||||
|
|
||||||
|
const commandName = command?.name;
|
||||||
|
const commandOptions = command?.options;
|
||||||
|
|
||||||
|
if (commandOptions) {
|
||||||
|
const mappedOptions = Object.keys(commandOptions).map((key) => {
|
||||||
|
const value = this.args.persona.getCommandOption(commandId, key);
|
||||||
|
return Object.assign({}, commandOptions[key], { id: key, value });
|
||||||
|
});
|
||||||
|
|
||||||
|
options.push({ commandName, options: mappedOptions });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if this.showCommandOptions}}
|
||||||
|
<div class="control-group">
|
||||||
|
<label>{{I18n.t "discourse_ai.ai_persona.command_options"}}</label>
|
||||||
|
<div>
|
||||||
|
{{#each this.commandOptions as |commandOption|}}
|
||||||
|
<div class="ai-persona-editor__command-options">
|
||||||
|
<div class="ai-persona-editor__command-options-name">
|
||||||
|
{{commandOption.commandName}}
|
||||||
|
</div>
|
||||||
|
<div class="ai-persona-editor__command-option-options">
|
||||||
|
{{#each commandOption.options as |option|}}
|
||||||
|
<AiPersonaCommandOptionEditor @option={{option}} />
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -12,11 +12,11 @@ import Textarea from "discourse/components/d-textarea";
|
||||||
import DToggleSwitch from "discourse/components/d-toggle-switch";
|
import DToggleSwitch from "discourse/components/d-toggle-switch";
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import Group from "discourse/models/group";
|
import Group from "discourse/models/group";
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
import GroupChooser from "select-kit/components/group-chooser";
|
import GroupChooser from "select-kit/components/group-chooser";
|
||||||
import DTooltip from "float-kit/components/d-tooltip";
|
import DTooltip from "float-kit/components/d-tooltip";
|
||||||
import AiCommandSelector from "./ai-command-selector";
|
import AiCommandSelector from "./ai-command-selector";
|
||||||
|
import AiPersonaCommandOptions from "./ai-persona-command-options";
|
||||||
|
|
||||||
export default class PersonaEditor extends Component {
|
export default class PersonaEditor extends Component {
|
||||||
@service router;
|
@service router;
|
||||||
|
@ -159,7 +159,7 @@ export default class PersonaEditor extends Component {
|
||||||
/>
|
/>
|
||||||
<DTooltip
|
<DTooltip
|
||||||
@icon="question-circle"
|
@icon="question-circle"
|
||||||
@content={{i18n "discourse_ai.ai_persona.priority_help"}}
|
@content={{I18n.t "discourse_ai.ai_persona.priority_help"}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
|
@ -188,6 +188,13 @@ export default class PersonaEditor extends Component {
|
||||||
@commands={{@personas.resultSetMeta.commands}}
|
@commands={{@personas.resultSetMeta.commands}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{{#unless this.editingModel.system}}
|
||||||
|
<AiPersonaCommandOptions
|
||||||
|
@persona={{this.editingModel}}
|
||||||
|
@commands={{this.editingModel.commands}}
|
||||||
|
@allCommands={{@personas.resultSetMeta.commands}}
|
||||||
|
/>
|
||||||
|
{{/unless}}
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
|
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
|
||||||
<GroupChooser
|
<GroupChooser
|
||||||
|
|
|
@ -14,6 +14,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-persona-command-option-editor {
|
||||||
|
&__instructions {
|
||||||
|
color: var(--primary-medium);
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
line-height: var(--line-height-large);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ai-personas__container {
|
.ai-personas__container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -26,6 +34,15 @@
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
&__command-options {
|
||||||
|
padding: 5px 10px 5px;
|
||||||
|
border: 1px solid var(--primary-low-mid);
|
||||||
|
width: 480px;
|
||||||
|
}
|
||||||
|
&__command-options-name {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
}
|
||||||
&__description {
|
&__description {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ en:
|
||||||
delete: Delete
|
delete: Delete
|
||||||
priority: Priority
|
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.
|
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.
|
||||||
|
command_options: "Command Options"
|
||||||
no_persona_selected: |
|
no_persona_selected: |
|
||||||
## What are AI Personas?
|
## What are AI Personas?
|
||||||
|
|
||||||
|
|
|
@ -152,6 +152,11 @@ en:
|
||||||
description: "AI Bot specialized in generating images using DALL-E 3"
|
description: "AI Bot specialized in generating images using DALL-E 3"
|
||||||
topic_not_found: "Summary unavailable, topic not found!"
|
topic_not_found: "Summary unavailable, topic not found!"
|
||||||
searching: "Searching for: '%{query}'"
|
searching: "Searching for: '%{query}'"
|
||||||
|
command_options:
|
||||||
|
search:
|
||||||
|
base_query:
|
||||||
|
name: "Base Search Query"
|
||||||
|
description: "Base query to use when searching. Example: '#urgent' will prepend '#urgent' to the search query and only include topics with the urgent category or tag."
|
||||||
command_summary:
|
command_summary:
|
||||||
categories: "List categories"
|
categories: "List categories"
|
||||||
search: "Search"
|
search: "Search"
|
||||||
|
@ -165,6 +170,19 @@ en:
|
||||||
schema: "Look up database schema"
|
schema: "Look up database schema"
|
||||||
search_settings: "Searching site settings"
|
search_settings: "Searching site settings"
|
||||||
dall_e: "Generate image"
|
dall_e: "Generate image"
|
||||||
|
command_help:
|
||||||
|
categories: "List all publicly visible categories on the forum"
|
||||||
|
search: "Search all public topics on the forum"
|
||||||
|
tags: "List all tags on the forum"
|
||||||
|
time: "Find time in various time zones"
|
||||||
|
summarize: "Summarize a topic"
|
||||||
|
image: "Generate image using Stable Diffusion"
|
||||||
|
google: "Search Google for a query"
|
||||||
|
read: "Read public topic on the forum"
|
||||||
|
setting_context: "Look up site setting context"
|
||||||
|
schema: "Look up database schema"
|
||||||
|
search_settings: "Search site settings"
|
||||||
|
dall_e: "Generate image using DALL-E 3"
|
||||||
command_description:
|
command_description:
|
||||||
read: "Reading: <a href='%{url}'>%{title}</a>"
|
read: "Reading: <a href='%{url}'>%{title}</a>"
|
||||||
time: "Time in %{timezone} is %{time}"
|
time: "Time in %{timezone} is %{time}"
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
class ConvertAiPersonasCommandsToJson < ActiveRecord::Migration[7.0]
|
||||||
|
def up
|
||||||
|
# this all may be a bit surprising, but interestingly this makes all our backend code
|
||||||
|
# cross compatible
|
||||||
|
# upgrading ["a", "b", "c"] to json simply works cause in both cases
|
||||||
|
# rails will cast to a string array and all code simply expectes a string array
|
||||||
|
#
|
||||||
|
# this change was made so we can also start storing parameters with the commands
|
||||||
|
execute <<~SQL
|
||||||
|
ALTER TABLE ai_personas
|
||||||
|
ALTER COLUMN commands DROP DEFAULT
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<~SQL
|
||||||
|
ALTER TABLE ai_personas
|
||||||
|
ALTER COLUMN commands
|
||||||
|
TYPE json USING array_to_json(commands)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<~SQL
|
||||||
|
ALTER TABLE ai_personas
|
||||||
|
ALTER COLUMN commands
|
||||||
|
SET DEFAULT '[]'::json
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
raise ActiveRecord::IrreversibleMigration
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,18 +3,6 @@
|
||||||
module DiscourseAi
|
module DiscourseAi
|
||||||
module AiBot
|
module AiBot
|
||||||
module Commands
|
module Commands
|
||||||
class Parameter
|
|
||||||
attr_reader :item_type, :name, :description, :type, :enum, :required
|
|
||||||
def initialize(name:, description:, type:, enum: nil, required: false, item_type: nil)
|
|
||||||
@name = name
|
|
||||||
@description = description
|
|
||||||
@type = type
|
|
||||||
@enum = enum
|
|
||||||
@required = required
|
|
||||||
@item_type = item_type
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Command
|
class Command
|
||||||
CARET = "<!-- caret -->"
|
CARET = "<!-- caret -->"
|
||||||
PROGRESS_CARET = "<!-- progress -->"
|
PROGRESS_CARET = "<!-- progress -->"
|
||||||
|
@ -38,6 +26,18 @@ module DiscourseAi
|
||||||
def parameters
|
def parameters
|
||||||
raise NotImplemented
|
raise NotImplemented
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def options
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def help
|
||||||
|
I18n.t("discourse_ai.ai_bot.command_help.#{name}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def option(name, type:)
|
||||||
|
Option.new(command: self, name: name, type: type)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_reader :bot_user, :bot
|
attr_reader :bot_user, :bot
|
||||||
|
@ -63,6 +63,22 @@ module DiscourseAi
|
||||||
@invoked = false
|
@invoked = false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def persona_options
|
||||||
|
return @persona_options if @persona_options
|
||||||
|
|
||||||
|
@persona_options = HashWithIndifferentAccess.new
|
||||||
|
|
||||||
|
# during tests we may operate without a bot
|
||||||
|
return @persona_options if !self.bot
|
||||||
|
|
||||||
|
self.class.options.each do |option|
|
||||||
|
val = self.bot.persona.options.dig(self.class, option.name)
|
||||||
|
@persona_options[option.name] = val if val
|
||||||
|
end
|
||||||
|
|
||||||
|
@persona_options
|
||||||
|
end
|
||||||
|
|
||||||
def tokenizer
|
def tokenizer
|
||||||
bot.tokenizer
|
bot.tokenizer
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
module DiscourseAi
|
||||||
|
module AiBot
|
||||||
|
module Commands
|
||||||
|
class Option
|
||||||
|
attr_reader :command, :name, :type
|
||||||
|
def initialize(command:, name:, type:)
|
||||||
|
@command = command
|
||||||
|
@name = name.to_s
|
||||||
|
@type = type
|
||||||
|
end
|
||||||
|
|
||||||
|
def localized_name
|
||||||
|
I18n.t("discourse_ai.ai_bot.command_options.#{command.name}.#{name}.name")
|
||||||
|
end
|
||||||
|
|
||||||
|
def localized_description
|
||||||
|
I18n.t("discourse_ai.ai_bot.command_options.#{command.name}.#{name}.description")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -11,6 +11,10 @@ module DiscourseAi::AiBot::Commands
|
||||||
"Will search topics in the current discourse instance, when rendering always prefer to link to the topics you find"
|
"Will search topics in the current discourse instance, when rendering always prefer to link to the topics you find"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def options
|
||||||
|
[option(:base_query, type: :string)]
|
||||||
|
end
|
||||||
|
|
||||||
def parameters
|
def parameters
|
||||||
[
|
[
|
||||||
Parameter.new(
|
Parameter.new(
|
||||||
|
@ -119,6 +123,10 @@ module DiscourseAi::AiBot::Commands
|
||||||
|
|
||||||
show_progress(I18n.t("discourse_ai.ai_bot.searching", query: search_string))
|
show_progress(I18n.t("discourse_ai.ai_bot.searching", query: search_string))
|
||||||
|
|
||||||
|
if persona_options[:base_query].present?
|
||||||
|
search_string = "#{search_string} #{persona_options[:base_query]}"
|
||||||
|
end
|
||||||
|
|
||||||
results =
|
results =
|
||||||
Search.execute(
|
Search.execute(
|
||||||
search_string.to_s + " status:public",
|
search_string.to_s + " status:public",
|
||||||
|
|
|
@ -20,6 +20,10 @@ module DiscourseAi
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def options
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
def render_commands(render_function_instructions:)
|
def render_commands(render_function_instructions:)
|
||||||
return +"" if available_commands.empty?
|
return +"" if available_commands.empty?
|
||||||
|
|
||||||
|
|
|
@ -4,9 +4,10 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
|
||||||
before { SearchIndexer.enable }
|
before { SearchIndexer.enable }
|
||||||
after { SearchIndexer.disable }
|
after { SearchIndexer.disable }
|
||||||
|
|
||||||
|
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
|
||||||
|
fab!(:admin)
|
||||||
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") }
|
||||||
|
|
||||||
fab!(:tag_funny) { Fabricate(:tag, name: "funny") }
|
fab!(:tag_funny) { Fabricate(:tag, name: "funny") }
|
||||||
fab!(:tag_sad) { Fabricate(:tag, name: "sad") }
|
fab!(:tag_sad) { Fabricate(:tag, name: "sad") }
|
||||||
fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") }
|
fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") }
|
||||||
|
@ -25,7 +26,42 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
|
||||||
|
|
||||||
before { SiteSetting.ai_bot_enabled = true }
|
before { SiteSetting.ai_bot_enabled = true }
|
||||||
|
|
||||||
|
it "can properly list options" do
|
||||||
|
options = described_class.options
|
||||||
|
expect(options.length).to eq(1)
|
||||||
|
expect(options.first.name.to_s).to eq("base_query")
|
||||||
|
expect(options.first.localized_name).not_to include("Translation missing:")
|
||||||
|
expect(options.first.localized_description).not_to include("Translation missing:")
|
||||||
|
end
|
||||||
|
|
||||||
describe "#process" do
|
describe "#process" do
|
||||||
|
it "can retreive options from persona correctly" do
|
||||||
|
persona =
|
||||||
|
Fabricate(
|
||||||
|
:ai_persona,
|
||||||
|
allowed_group_ids: [Group::AUTO_GROUPS[:admins]],
|
||||||
|
commands: [["SearchCommand", { "base_query" => "#funny" }]],
|
||||||
|
)
|
||||||
|
Group.refresh_automatic_groups!
|
||||||
|
|
||||||
|
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona_id: persona.id, user: admin)
|
||||||
|
search_post = Fabricate(:post, topic: topic_with_tags)
|
||||||
|
|
||||||
|
bot_post = Fabricate(:post)
|
||||||
|
|
||||||
|
search = described_class.new(bot: bot, post: bot_post, args: nil)
|
||||||
|
|
||||||
|
results = search.process(order: "latest")
|
||||||
|
expect(results[:rows].length).to eq(1)
|
||||||
|
|
||||||
|
search_post.topic.tags = []
|
||||||
|
search_post.topic.save!
|
||||||
|
|
||||||
|
# no longer has the tag funny
|
||||||
|
results = search.process(order: "latest")
|
||||||
|
expect(results[:rows].length).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
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: nil, post: post1, args: nil)
|
search = described_class.new(bot: nil, post: post1, args: nil)
|
||||||
|
|
|
@ -18,6 +18,41 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "returns commands options with each command" do
|
||||||
|
persona1 = Fabricate(:ai_persona, name: "search1", commands: ["SearchCommand"])
|
||||||
|
persona2 =
|
||||||
|
Fabricate(
|
||||||
|
:ai_persona,
|
||||||
|
name: "search2",
|
||||||
|
commands: [["SearchCommand", { base_query: "test" }]],
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/admin/plugins/discourse-ai/ai_personas.json"
|
||||||
|
expect(response).to be_successful
|
||||||
|
|
||||||
|
serializer_persona1 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona1.id }
|
||||||
|
serializer_persona2 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona2.id }
|
||||||
|
|
||||||
|
commands = response.parsed_body["meta"]["commands"]
|
||||||
|
search_command = commands.find { |c| c["id"] == "SearchCommand" }
|
||||||
|
|
||||||
|
expect(search_command["help"]).to eq(I18n.t("discourse_ai.ai_bot.command_help.search"))
|
||||||
|
|
||||||
|
expect(search_command["options"]).to eq(
|
||||||
|
{
|
||||||
|
"base_query" => {
|
||||||
|
"type" => "string",
|
||||||
|
"name" => I18n.t("discourse_ai.ai_bot.command_options.search.base_query.name"),
|
||||||
|
"description" =>
|
||||||
|
I18n.t("discourse_ai.ai_bot.command_options.search.base_query.description"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(serializer_persona1["commands"]).to eq(["SearchCommand"])
|
||||||
|
expect(serializer_persona2["commands"]).to eq([["SearchCommand", { "base_query" => "test" }]])
|
||||||
|
end
|
||||||
|
|
||||||
it "returns localized persona names and descriptions" do
|
it "returns localized persona names and descriptions" do
|
||||||
SiteSetting.default_locale = "fr"
|
SiteSetting.default_locale = "fr"
|
||||||
|
|
||||||
|
@ -55,16 +90,20 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
name: "superbot",
|
name: "superbot",
|
||||||
description: "Assists with tasks",
|
description: "Assists with tasks",
|
||||||
system_prompt: "you are a helpful bot",
|
system_prompt: "you are a helpful bot",
|
||||||
|
commands: [["search", { "base_query" => "test" }]],
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
it "creates a new AiPersona" do
|
it "creates a new AiPersona" do
|
||||||
expect {
|
expect {
|
||||||
post "/admin/plugins/discourse-ai/ai_personas.json",
|
post "/admin/plugins/discourse-ai/ai_personas.json",
|
||||||
params: {
|
params: { ai_persona: valid_attributes }.to_json,
|
||||||
ai_persona: valid_attributes,
|
headers: {
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
}
|
}
|
||||||
expect(response).to be_successful
|
expect(response).to be_successful
|
||||||
|
persona = AiPersona.find(response.parsed_body["ai_persona"]["id"])
|
||||||
|
expect(persona.commands).to eq([["search", { "base_query" => "test" }]])
|
||||||
}.to change(AiPersona, :count).by(1)
|
}.to change(AiPersona, :count).by(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { module, test } from "qunit";
|
||||||
|
import AiPersona from "discourse/plugins/discourse-ai/discourse/admin/models/ai-persona";
|
||||||
|
|
||||||
|
module("Discourse AI | Unit | Model | ai-persona", function () {
|
||||||
|
test("init properties", function (assert) {
|
||||||
|
const properties = {
|
||||||
|
commands: [
|
||||||
|
["CommandName", { option1: "value1", option2: "value2" }],
|
||||||
|
"CommandName2",
|
||||||
|
"CommandName3",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiPersona = AiPersona.create(properties);
|
||||||
|
|
||||||
|
assert.deepEqual(aiPersona.commands, [
|
||||||
|
"CommandName",
|
||||||
|
"CommandName2",
|
||||||
|
"CommandName3",
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
aiPersona.getCommandOption("CommandName", "option1").value,
|
||||||
|
"value1"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
aiPersona.getCommandOption("CommandName", "option2").value,
|
||||||
|
"value2"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update properties", function (assert) {
|
||||||
|
const properties = {
|
||||||
|
id: 1,
|
||||||
|
name: "Test",
|
||||||
|
commands: ["CommandName"],
|
||||||
|
allowed_group_ids: [12],
|
||||||
|
system: false,
|
||||||
|
enabled: true,
|
||||||
|
system_prompt: "System Prompt",
|
||||||
|
priority: false,
|
||||||
|
description: "Description",
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiPersona = AiPersona.create({ ...properties });
|
||||||
|
|
||||||
|
aiPersona.getCommandOption("CommandName", "option1").value = "value1";
|
||||||
|
|
||||||
|
const updatedProperties = aiPersona.updateProperties();
|
||||||
|
|
||||||
|
// perform remapping for save
|
||||||
|
properties.commands = [["CommandName", { option1: "value1" }]];
|
||||||
|
|
||||||
|
assert.deepEqual(updatedProperties, properties);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create properties", function (assert) {
|
||||||
|
const properties = {
|
||||||
|
name: "Test",
|
||||||
|
commands: ["CommandName"],
|
||||||
|
allowed_group_ids: [12],
|
||||||
|
system: false,
|
||||||
|
enabled: true,
|
||||||
|
system_prompt: "System Prompt",
|
||||||
|
priority: false,
|
||||||
|
description: "Description",
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiPersona = AiPersona.create({ ...properties });
|
||||||
|
|
||||||
|
aiPersona.getCommandOption("CommandName", "option1").value = "value1";
|
||||||
|
|
||||||
|
const createdProperties = aiPersona.createProperties();
|
||||||
|
|
||||||
|
properties.commands = [["CommandName", { option1: "value1" }]];
|
||||||
|
|
||||||
|
assert.deepEqual(createdProperties, properties);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("working copy", function (assert) {
|
||||||
|
const aiPersona = AiPersona.create({
|
||||||
|
name: "Test",
|
||||||
|
commands: ["CommandName"],
|
||||||
|
});
|
||||||
|
|
||||||
|
aiPersona.getCommandOption("CommandName", "option1").value = "value1";
|
||||||
|
|
||||||
|
const workingCopy = aiPersona.workingCopy();
|
||||||
|
|
||||||
|
assert.equal(workingCopy.name, "Test");
|
||||||
|
assert.equal(
|
||||||
|
workingCopy.getCommandOption("CommandName", "option1").value,
|
||||||
|
"value1"
|
||||||
|
);
|
||||||
|
assert.deepEqual(workingCopy.commands, ["CommandName"]);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue