FEATURE: add initial support for personas (#172)

This splits out a bunch of code that used to live inside bots
into a dedicated concept called a Persona.

This allows us to start playing with multiple personas for the bot

Ships with:

artist - for making images
sql helper - for helping with data explorer
general - for everything and anything
 
Also includes a few fixes that make the generic LLM function implementation  more robust
This commit is contained in:
Sam 2023-08-30 16:15:03 +10:00 committed by GitHub
parent 6d69fb479e
commit db19e37748
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 580 additions and 88 deletions

View File

@ -3,10 +3,6 @@ import { inject as service } from "@ember/service";
import { computed } from "@ember/object";
export default class extends Component {
static shouldRender() {
return true;
}
@service currentUser;
@service siteSettings;

View File

@ -0,0 +1,7 @@
<div class="gpt-persona">
<DropdownSelectBox
@value={{this.value}}
@content={{this.botOptions}}
@options={{hash icon="robot"}}
/>
</div>

View File

@ -0,0 +1,53 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
function isBotMessage(composer, currentUser) {
if (
composer &&
composer.targetRecipients &&
currentUser.ai_enabled_chat_bots
) {
const reciepients = composer.targetRecipients.split(",");
return currentUser.ai_enabled_chat_bots.any((bot) =>
reciepients.any((username) => username === bot.username)
);
}
return false;
}
export default class BotSelector extends Component {
static shouldRender(args, container) {
return (
container?.currentUser?.ai_enabled_personas &&
isBotMessage(args.model, container.currentUser)
);
}
@service currentUser;
get composer() {
return this.args?.outletArgs?.model;
}
get botOptions() {
if (this.currentUser.ai_enabled_personas) {
return this.currentUser.ai_enabled_personas.map((persona) => {
return {
id: persona.name,
name: persona.name,
description: persona.description,
};
});
}
}
get value() {
return this._value || this.botOptions[0].id;
}
set value(val) {
this._value = val;
this.composer.metaData = { ai_persona: val };
}
}

View File

@ -4,6 +4,8 @@ import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import loadScript from "discourse/lib/load-script";
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
function isGPTBot(user) {
return user && [-110, -111, -112].includes(user.id);
@ -138,6 +140,32 @@ function initializeAIBotReplies(api) {
});
}
function initializePersonaDecorator(api) {
let topicController = null;
api.decorateWidget(`poster-name:after`, (dec) => {
if (!isGPTBot(dec.attrs.user)) {
return;
}
// this is hacky and will need to change
// trouble is we need to get the model for the topic
// and it is not available in the decorator
// long term this will not be a problem once we remove widgets and
// have a saner structure for our model
topicController =
topicController || api.container.lookup("controller:topic");
return dec.widget.attach("persona-flair", {
topicController,
});
});
registerWidgetShim(
"persona-flair",
"span.persona-flair",
hbs`{{@data.topicController.model.ai_persona_name}}`
);
}
export default {
name: "discourse-ai-bot-replies",
@ -157,6 +185,7 @@ export default {
if (aiBotEnaled && canInteractWithAIBots) {
withPluginApi("1.6.0", attachHeaderIcon);
withPluginApi("1.6.0", initializeAIBotReplies);
withPluginApi("1.6.0", initializePersonaDecorator);
}
},
};

View File

@ -2,9 +2,16 @@ nav.post-controls .actions button.cancel-streaming {
display: none;
}
.ai-bot-chat #reply-control {
.title-and-category {
display: none;
.ai-bot-chat {
#reply-control {
.title-and-category,
#private-message-users {
display: none;
}
}
.gpt-persona {
margin-bottom: 5px;
margin-top: -10px;
}
}
@ -39,3 +46,9 @@ article.streaming nav.post-controls .actions button.cancel-streaming {
}
}
}
.topic-body .persona-flair {
order: 2;
font-size: var(--font-down-1);
padding-top: 3px;
}

View File

@ -93,6 +93,16 @@ en:
markdown_table: Generate Markdown table
ai_bot:
personas:
general:
name: Forum Helper
description: "General purpose AI Bot capable of performing various tasks"
artist:
name: Artist
description: "AI Bot specialized in generating images"
sql_helper:
name: SQL Helper
description: "AI Bot specialized in helping craft SQL queries on this Discourse instance"
default_pm_prefix: "[Untitled AI bot PM]"
topic_not_found: "Summary unavailable, topic not found!"
command_summary:
@ -105,6 +115,7 @@ en:
google: "Search Google"
read: "Read topic"
setting_context: "Look up site setting context"
schema: "Look up database schema"
command_description:
read: "Reading: <a href='%{url}'>%{title}</a>"
time: "Time in %{timezone} is %{time}"
@ -123,6 +134,7 @@ en:
one: "Found %{count} <a href='%{url}'>result</a> for '%{query}'"
other: "Found %{count} <a href='%{url}'>results</a> for '%{query}'"
setting_context: "Reading context for: %{setting_name}"
schema: "%{tables}"
summarization:
configuration_hint:

View File

@ -58,6 +58,7 @@ module DiscourseAi
def initialize(bot_user)
@bot_user = bot_user
@persona = DiscourseAi::AiBot::Personas::General.new
end
def update_pm_title(post)
@ -90,6 +91,13 @@ module DiscourseAi
)
return if total_completions > MAX_COMPLETIONS
@persona = DiscourseAi::AiBot::Personas::General.new
if persona_name = post.topic.custom_fields["ai_persona"]
persona_class =
DiscourseAi::AiBot::Personas.all.find { |current| current.name == persona_name }
@persona = persona_class.new if persona_class
end
prompt =
if standalone && post.post_custom_prompt
username, standalone_prompt = post.post_custom_prompt.custom_prompt.last
@ -265,27 +273,7 @@ module DiscourseAi
end
def available_commands
return @cmds if @cmds
all_commands =
[
Commands::CategoriesCommand,
Commands::TimeCommand,
Commands::SearchCommand,
Commands::SummarizeCommand,
Commands::ReadCommand,
Commands::SettingContextCommand,
].tap do |cmds|
cmds << Commands::TagsCommand if SiteSetting.tagging_enabled
cmds << Commands::ImageCommand if SiteSetting.ai_stability_api_key.present?
if SiteSetting.ai_google_custom_search_api_key.present? &&
SiteSetting.ai_google_custom_search_cx.present?
cmds << Commands::GoogleCommand
end
end
allowed_commands = SiteSetting.ai_bot_enabled_chat_commands.split("|")
@cmds = all_commands.filter { |klass| allowed_commands.include?(klass.name) }
@persona.available_commands
end
def system_prompt_style!(style)
@ -295,26 +283,10 @@ module DiscourseAi
def system_prompt(post)
return "You are a helpful Bot" if @style == :simple
prompt = +<<~TEXT
You are a helpful Discourse assistant.
You understand and generate Discourse Markdown.
You live in a Discourse Forum Message.
You live in the forum with the URL: #{Discourse.base_url}
The title of your site: #{SiteSetting.title}
The description is: #{SiteSetting.site_description}
The participants in this conversation are: #{post.topic.allowed_users.map(&:username).join(", ")}
The date now is: #{Time.zone.now}, much has changed since you were trained.
TEXT
if include_function_instructions_in_system_prompt?
prompt << "\n"
prompt << function_list.system_prompt
prompt << "\n"
end
prompt << available_commands.map(&:custom_system_message).compact.join("\n")
prompt
@persona.render_system_prompt(
topic: post.topic,
render_function_instructions: include_function_instructions_in_system_prompt?,
)
end
def include_function_instructions_in_system_prompt?
@ -322,11 +294,7 @@ module DiscourseAi
end
def function_list
return @function_list if @function_list
@function_list = DiscourseAi::Inference::FunctionList.new
available_functions.each { |function| @function_list << function }
@function_list
@persona.function_list
end
def tokenizer
@ -363,29 +331,7 @@ module DiscourseAi
end
def available_functions
# note if defined? can be a problem in test
# this can never be nil so it is safe
return @available_functions if @available_functions
functions = []
functions =
available_commands.map do |command|
function =
DiscourseAi::Inference::Function.new(name: command.name, description: command.desc)
command.parameters.each do |parameter|
function.add_parameter(
name: parameter.name,
type: parameter.type,
description: parameter.description,
required: parameter.required,
enum: parameter.enum,
)
end
function
end
@available_functions = functions
@persona.available_functions
end
protected

View File

@ -0,0 +1,54 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class DbSchemaCommand < Command
class << self
def name
"schema"
end
def desc
"Will load schema information for specific tables in the database"
end
def parameters
[
Parameter.new(
name: "tables",
description:
"list of tables to load schema information for, comma seperated list eg: (users,posts))",
type: "string",
required: true,
),
]
end
end
def result_name
"results"
end
def description_args
{ tables: @tables.join(", ") }
end
def process(tables:)
@tables = tables.split(",").map(&:strip)
table_info = {}
DB
.query(<<~SQL, @tables)
select table_name, column_name, data_type from information_schema.columns
where table_schema = 'public'
and table_name in (?)
order by table_name
SQL
.each { |row| (table_info[row.table_name] ||= []) << "#{row.column_name} #{row.data_type}" }
schema_info =
table_info.map { |table_name, columns| "#{table_name}(#{columns.join(",")})" }.join("\n")
{ tables: @tables, schema_info: schema_info }
end
end
end

View File

@ -39,6 +39,11 @@ module DiscourseAi
require_relative "commands/google_command"
require_relative "commands/read_command"
require_relative "commands/setting_context_command"
require_relative "commands/db_schema_command"
require_relative "personas/persona"
require_relative "personas/artist"
require_relative "personas/general"
require_relative "personas/sql_helper"
end
def inject_into(plugin)
@ -46,6 +51,17 @@ module DiscourseAi
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
)
plugin.add_to_serializer(
:current_user,
:ai_enabled_personas,
include_condition: -> do
SiteSetting.ai_bot_enabled && scope.authenticated? &&
scope.user.in_any_groups?(SiteSetting.ai_bot_allowed_groups_map)
end,
) do
Personas.all.map { |persona| { name: persona.name, description: persona.description } }
end
plugin.add_to_serializer(
:current_user,
:ai_enabled_chat_bots,
@ -75,6 +91,12 @@ module DiscourseAi
plugin.register_svg_icon("robot")
plugin.add_to_serializer(
:topic_view,
:ai_persona_name,
include_condition: -> { SiteSetting.ai_bot_enabled && object.topic.private_message? },
) { topic.custom_fields["ai_persona"] }
plugin.on(:post_created) do |post|
bot_ids = BOTS.map(&:first)

View File

@ -0,0 +1,33 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Artist < Persona
def commands
[Commands::ImageCommand]
end
def system_prompt
<<~PROMPT
You are artistbot and you are here to help people generate images.
You generate images using stable diffusion.
- A good prompt needs to be detailed and specific.
- You can specify subject, medium (e.g. oil on canvas), artist (person who drew it or photographed it)
- You can specify details about lighting or time of day.
- You can specify a particular website you would like to emulate (artstation or deviantart)
- You can specify additional details such as "beutiful, dystopian, futuristic, etc."
- Prompts should generally be 10-20 words long
- Do not include any connector words such as "and" or "but" etc.
- You are extremely creative, when given short non descriptive prompts from a user you add your own details
{commands}
PROMPT
end
end
end
end
end

View File

@ -0,0 +1,29 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class General < Persona
def commands
all_available_commands
end
def system_prompt
<<~PROMPT
You are a helpful Discourse assistant.
You understand and generate Discourse Markdown.
You live in a Discourse Forum Message.
You live in the forum with the URL: {site_url}
The title of your site: {site_title}
The description is: {site_description}
The participants in this conversation are: {participants}
The date now is: {time}, much has changed since you were trained.
{commands}
PROMPT
end
end
end
end
end

View File

@ -0,0 +1,110 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
def self.all
personas = [Personas::General, Personas::SqlHelper]
personas << Personas::Artist if SiteSetting.ai_stability_api_key.present?
personas
end
class Persona
def self.name
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.name")
end
def self.description
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description")
end
def commands
[]
end
def render_commands(render_function_instructions:)
result = +""
if render_function_instructions
result << "\n"
result << function_list.system_prompt
result << "\n"
end
result << available_commands.map(&:custom_system_message).compact.join("\n")
result
end
def render_system_prompt(topic: nil, render_function_instructions: true)
substitutions = {
site_url: Discourse.base_url,
site_title: SiteSetting.title,
site_description: SiteSetting.site_description,
time: Time.zone.now,
commands: render_commands(render_function_instructions: render_function_instructions),
}
substitutions[:participants] = topic.allowed_users.map(&:username).join(", ") if topic
system_prompt.gsub(/\{(\w+)\}/) do |match|
found = substitutions[match[1..-2].to_sym]
found.nil? ? match : found.to_s
end
end
def available_commands
return @available_commands if @available_commands
@available_commands = all_available_commands.filter { |cmd| commands.include?(cmd) }
end
def available_functions
# note if defined? can be a problem in test
# this can never be nil so it is safe
return @available_functions if @available_functions
functions = []
functions =
available_commands.map do |command|
function =
DiscourseAi::Inference::Function.new(name: command.name, description: command.desc)
command.parameters.each { |parameter| function.add_parameter(parameter) }
function
end
@available_functions = functions
end
def function_list
return @function_list if @function_list
@function_list = DiscourseAi::Inference::FunctionList.new
available_functions.each { |function| @function_list << function }
@function_list
end
def all_available_commands
return @cmds if @cmds
all_commands = [
Commands::CategoriesCommand,
Commands::TimeCommand,
Commands::SearchCommand,
Commands::SummarizeCommand,
Commands::ReadCommand,
Commands::SettingContextCommand,
]
all_commands << Commands::TagsCommand if SiteSetting.tagging_enabled
all_commands << Commands::ImageCommand if SiteSetting.ai_stability_api_key.present?
if SiteSetting.ai_google_custom_search_api_key.present? &&
SiteSetting.ai_google_custom_search_cx.present?
all_commands << Commands::GoogleCommand
end
allowed_commands = SiteSetting.ai_bot_enabled_chat_commands.split("|")
@cmds = all_commands.filter { |klass| allowed_commands.include?(klass.name) }
end
end
end
end
end

View File

@ -0,0 +1,64 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class SqlHelper < Persona
def self.schema
return @schema if defined?(@schema)
tables = Hash.new
priority_tables = %w[posts topics notifications users user_actions]
DB.query(<<~SQL).each { |row| (tables[row.table_name] ||= []) << row.column_name }
select table_name, column_name from information_schema.columns
where table_schema = 'public'
order by table_name
SQL
schema = +(priority_tables.map { |name| "#{name}(#{tables[name].join(",")})" }.join("\n"))
schema << "\nOther tables (schema redacted, available on request): "
tables.each do |table_name, _|
next if priority_tables.include?(table_name)
schema << "#{table_name} "
end
@schema = schema
end
def commands
all_available_commands
end
def all_available_commands
[DiscourseAi::AiBot::Commands::DbSchemaCommand]
end
def system_prompt
<<~PROMPT
You are a PostgreSQL expert.
You understand and generate Discourse Markdown but specialize in creating queries.
You live in a Discourse Forum Message.
The schema in your training set MAY be out of date.
The user_actions tables stores likes (action_type 1).
the topics table stores private/personal messages it uses archetype private_message for them.
notification_level can be: {muted: 0, regular: 1, tracking: 2, watching: 3, watching_first_post: 4}.
bookmarkable_type can be: Post,Topic,ChatMessage and more
Current time is: {time}
The current schema for the current DB is:
{{
#{self.class.schema}
}}
{commands}
PROMPT
end
end
end
end
end

View File

@ -12,7 +12,21 @@ module ::DiscourseAi
@parameters = []
end
def add_parameter(name:, type:, description:, enum: nil, required: false)
def add_parameter(parameter = nil, **kwargs)
if parameter
add_parameter_kwargs(
name: parameter.name,
type: parameter.type,
description: parameter.description,
required: parameter.required,
enum: parameter.enum,
)
else
add_parameter_kwargs(**kwargs)
end
end
def add_parameter_kwargs(name:, type:, description:, enum: nil, required: false)
@parameters << {
name: name,
type: type,

View File

@ -28,7 +28,22 @@ module ::DiscourseAi
next if function.blank?
arguments = arguments[0..-2] if arguments.end_with?(")")
arguments = arguments.split(",").map(&:strip)
temp_string = +""
in_string = nil
replace = SecureRandom.hex(10)
arguments.each_char do |char|
if %w[" '].include?(char) && !in_string
in_string = char
elsif char == in_string
in_string = nil
elsif char == "," && in_string
char = replace
end
temp_string << char
end
arguments = temp_string.split(",").map { |s| s.gsub(replace, ",").strip }
parsed_arguments = {}
arguments.each do |argument|
@ -76,8 +91,8 @@ module ::DiscourseAi
PROMPT
@functions.each do |function|
prompt << " // #{function.description}\n"
prompt << " #{function.name}"
prompt << "// #{function.description}\n"
prompt << "!#{function.name}"
if function.parameters.present?
prompt << "("
function.parameters.each_with_index do |parameter, index|
@ -96,8 +111,9 @@ module ::DiscourseAi
prompt << " /* #{description} */" if description.present?
end
prompt << ")\n"
prompt << ")"
end
prompt << "\n"
end
prompt << <<~PROMPT
@ -109,8 +125,8 @@ module ::DiscourseAi
For example for a function defined as:
{
// echo a string
echo(message: string [required])
// echo a string
!echo(message: string [required])
}
Human: please echo out "hello"

View File

@ -0,0 +1,16 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Commands::DbSchemaCommand do
let(:command) { DiscourseAi::AiBot::Commands::DbSchemaCommand.new(bot_user: nil, args: nil) }
describe "#process" do
it "returns rich schema for tables" do
result = command.process(tables: "posts,topics")
expect(result[:schema_info]).to include("raw text")
expect(result[:schema_info]).to include("views integer")
expect(result[:schema_info]).to include("posts")
expect(result[:schema_info]).to include("topics")
expect(result[:tables]).to eq(%w[posts topics])
end
end
end

View File

@ -0,0 +1,61 @@
#frozen_string_literal: true
class TestPersona < DiscourseAi::AiBot::Personas::Persona
def commands
[
DiscourseAi::AiBot::Commands::TagsCommand,
DiscourseAi::AiBot::Commands::SearchCommand,
DiscourseAi::AiBot::Commands::ImageCommand,
]
end
def system_prompt
<<~PROMPT
{site_url}
{site_title}
{site_description}
{participants}
{time}
{commands}
PROMPT
end
end
RSpec.describe DiscourseAi::AiBot::Personas::Persona do
let :persona do
TestPersona.new
end
let :topic_with_users do
topic = Topic.new
topic.allowed_users = [User.new(username: "joe"), User.new(username: "jane")]
topic
end
it "renders the system prompt" do
freeze_time
SiteSetting.title = "test site title"
SiteSetting.site_description = "test site description"
rendered =
persona.render_system_prompt(topic: topic_with_users, render_function_instructions: true)
expect(rendered).to include(Discourse.base_url)
expect(rendered).to include("test site title")
expect(rendered).to include("test site description")
expect(rendered).to include("joe, jane")
expect(rendered).to include(Time.zone.now.to_s)
expect(rendered).to include("!search")
expect(rendered).to include("!tags")
# needs to be configured so it is not available
expect(rendered).not_to include("!image")
rendered =
persona.render_system_prompt(topic: topic_with_users, render_function_instructions: false)
expect(rendered).not_to include("!search")
expect(rendered).not_to include("!tags")
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Personas::SqlHelper do
let :sql_helper do
subject
end
it "renders schema" do
prompt = sql_helper.render_system_prompt
expect(prompt).to include("posts(")
expect(prompt).to include("topics(")
expect(prompt).not_to include("translation_key") # not a priority table
expect(prompt).to include("user_api_keys") # not a priority table
expect(sql_helper.available_commands).to eq([DiscourseAi::AiBot::Commands::DbSchemaCommand])
end
end

View File

@ -29,16 +29,16 @@ module DiscourseAi::Inference
it "can handle complex parsing" do
raw_prompt = <<~PROMPT
!get_weather(location: "sydney", unit: "f")
!get_weather(location: "sydney,melbourne", unit: "f")
!get_weather (location: sydney)
!get_weather(location : 'sydney's', unit: "m", invalid: "invalid")
!get_weather(location : "sydney's", unit: "m", invalid: "invalid")
!get_weather(unit: "f", invalid: "invalid")
PROMPT
parsed = function_list.parse_prompt(raw_prompt)
expect(parsed).to eq(
[
{ name: "get_weather", arguments: { location: "sydney", unit: "f" } },
{ name: "get_weather", arguments: { location: "sydney,melbourne", unit: "f" } },
{ name: "get_weather", arguments: { location: "sydney" } },
{ name: "get_weather", arguments: { location: "sydney's" } },
],
@ -52,8 +52,8 @@ module DiscourseAi::Inference
#
expected = <<~PROMPT
{
// Get the weather in a city (default to c)
get_weather(location: string [required] /* the city name */, unit: string [optional] /* the unit of measurement celcius c or fahrenheit f [valid values: c,f] */)
// Get the weather in a city (default to c)
!get_weather(location: string [required] /* the city name */, unit: string [optional] /* the unit of measurement celcius c or fahrenheit f [valid values: c,f] */)
}
PROMPT
expect(prompt).to include(expected)