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:
Sam 2023-12-08 08:42:56 +11:00 committed by GitHub
parent 381b0d74ca
commit 6380ebd829
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 560 additions and 31 deletions

View File

@ -14,7 +14,7 @@ module DiscourseAi
end
commands =
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
render json: { ai_personas: ai_personas, meta: { commands: commands } }
end
@ -55,16 +55,36 @@ module DiscourseAi
end
def ai_persona_params
params.require(:ai_persona).permit(
:name,
:description,
:enabled,
:system_prompt,
:enabled,
:priority,
allowed_group_ids: [],
commands: [],
)
permitted =
params.require(:ai_persona).permit(
:name,
:description,
:enabled,
:system_prompt,
:priority,
allowed_group_ids: [],
)
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

View File

@ -87,10 +87,23 @@ class AiPersona < ActiveRecord::Base
name = self.name
description = self.description
ai_persona_id = self.id
options = {}
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
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
klass = ("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
options[klass] = current_options if current_options
klass
rescue StandardError
nil
end
@ -134,6 +147,10 @@ class AiPersona < ActiveRecord::Base
commands
end
define_method :options do
options
end
define_method :system_prompt do
@ai_persona&.system_prompt || "You are a helpful bot."
end

View File

@ -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

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import RestModel from "discourse/models/rest";
const ATTRIBUTES = [
@ -11,15 +12,81 @@ const ATTRIBUTES = [
"priority",
];
class CommandOption {
@tracked value = null;
}
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() {
let attrs = this.getProperties(ATTRIBUTES);
attrs.id = this.id;
this.populateCommandOptions(attrs);
return attrs;
}
createProperties() {
return this.getProperties(ATTRIBUTES);
let attrs = this.getProperties(ATTRIBUTES);
this.populateCommandOptions(attrs);
return attrs;
}
workingCopy() {

View File

@ -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>

View File

@ -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>
}

View File

@ -12,11 +12,11 @@ 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";
import AiPersonaCommandOptions from "./ai-persona-command-options";
export default class PersonaEditor extends Component {
@service router;
@ -159,7 +159,7 @@ export default class PersonaEditor extends Component {
/>
<DTooltip
@icon="question-circle"
@content={{i18n "discourse_ai.ai_persona.priority_help"}}
@content={{I18n.t "discourse_ai.ai_persona.priority_help"}}
/>
</div>
<div class="control-group">
@ -188,6 +188,13 @@ export default class PersonaEditor extends Component {
@commands={{@personas.resultSetMeta.commands}}
/>
</div>
{{#unless this.editingModel.system}}
<AiPersonaCommandOptions
@persona={{this.editingModel}}
@commands={{this.editingModel.commands}}
@allCommands={{@personas.resultSetMeta.commands}}
/>
{{/unless}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
<GroupChooser

View File

@ -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 {
display: flex;
flex-direction: row;
@ -26,6 +34,15 @@
label {
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 {
width: 500px;
}

View File

@ -58,6 +58,7 @@ en:
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.
command_options: "Command Options"
no_persona_selected: |
## What are AI Personas?

View File

@ -152,6 +152,11 @@ en:
description: "AI Bot specialized in generating images using DALL-E 3"
topic_not_found: "Summary unavailable, topic not found!"
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:
categories: "List categories"
search: "Search"
@ -165,6 +170,19 @@ en:
schema: "Look up database schema"
search_settings: "Searching site settings"
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:
read: "Reading: <a href='%{url}'>%{title}</a>"
time: "Time in %{timezone} is %{time}"

View File

@ -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

View File

@ -3,18 +3,6 @@
module DiscourseAi
module AiBot
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
CARET = "<!-- caret -->"
PROGRESS_CARET = "<!-- progress -->"
@ -38,6 +26,18 @@ module DiscourseAi
def parameters
raise NotImplemented
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
attr_reader :bot_user, :bot
@ -63,6 +63,22 @@ module DiscourseAi
@invoked = false
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
bot.tokenizer
end

View File

@ -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

View File

@ -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"
end
def options
[option(:base_query, type: :string)]
end
def parameters
[
Parameter.new(
@ -119,6 +123,10 @@ module DiscourseAi::AiBot::Commands
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 =
Search.execute(
search_string.to_s + " status:public",

View File

@ -20,6 +20,10 @@ module DiscourseAi
[]
end
def options
{}
end
def render_commands(render_function_instructions:)
return +"" if available_commands.empty?

View File

@ -4,9 +4,10 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
before { SearchIndexer.enable }
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!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") }
fab!(:tag_funny) { Fabricate(:tag, name: "funny") }
fab!(:tag_sad) { Fabricate(:tag, name: "sad") }
fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") }
@ -25,7 +26,42 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
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
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
post1 = Fabricate(:post, topic: topic_with_tags)
search = described_class.new(bot: nil, post: post1, args: nil)

View File

@ -18,6 +18,41 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
)
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
SiteSetting.default_locale = "fr"
@ -55,16 +90,20 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
name: "superbot",
description: "Assists with tasks",
system_prompt: "you are a helpful bot",
commands: [["search", { "base_query" => "test" }]],
}
end
it "creates a new AiPersona" do
expect {
post "/admin/plugins/discourse-ai/ai_personas.json",
params: {
ai_persona: valid_attributes,
params: { ai_persona: valid_attributes }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
}
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)
end
end

View File

@ -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"]);
});
});