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

View File

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

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"; 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() {

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

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 { .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;
} }

View File

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

View File

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

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

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" "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",

View File

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

View File

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

View File

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

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