REFACTOR: Move personas into it's own module.

This commit is contained in:
Roman Rizzi 2025-03-27 15:01:23 -03:00
parent a6b08270c0
commit 5bfe231486
No known key found for this signature in database
GPG Key ID: 64024A71CE7330D3
91 changed files with 746 additions and 819 deletions

View File

@ -15,7 +15,7 @@ module DiscourseAi
LocalizedAiPersonaSerializer.new(persona, root: false)
end
tools =
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false)
end
AiTool

View File

@ -12,11 +12,11 @@ module ::Jobs
return if message.blank?
personaClass =
DiscourseAi::AiBot::Personas::Persona.find_by(id: args[:persona_id], user: message.user)
DiscourseAi::Personas::Persona.find_by(id: args[:persona_id], user: message.user)
return if personaClass.blank?
user = User.find_by(id: personaClass.user_id)
bot = DiscourseAi::AiBot::Bot.as(user, persona: personaClass.new)
bot = DiscourseAi::Personas::Bot.as(user, persona: personaClass.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to_chat_message(
message,

View File

@ -10,13 +10,13 @@ module ::Jobs
persona_id = args[:persona_id]
begin
persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil?
persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id)
raise DiscourseAi::Personas::Bot::BOT_NOT_FOUND if persona.nil?
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to(post)
rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND
rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND
Rails.logger.warn(
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
)

View File

@ -19,7 +19,7 @@ module Jobs
return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil?
bot =
DiscourseAi::AiBot::Bot.as(
DiscourseAi::Personas::Bot.as(
Discourse.system_user,
persona: ai_persona_klass.new,
model: llm_model,

View File

@ -201,14 +201,14 @@ class AiPersona < ActiveRecord::Base
if inner_name.start_with?("custom-")
custom_tool_id = inner_name.split("-", 2).last.to_i
if AiTool.exists?(id: custom_tool_id, enabled: true)
klass = DiscourseAi::AiBot::Tools::Custom.class_instance(custom_tool_id)
klass = DiscourseAi::Personas::Tools::Custom.class_instance(custom_tool_id)
end
else
inner_name = inner_name.gsub("Tool", "")
inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name)
begin
klass = "DiscourseAi::AiBot::Tools::#{inner_name}".constantize
klass = "DiscourseAi::Personas::Tools::#{inner_name}".constantize
options[klass] = current_options if current_options
rescue StandardError
end
@ -218,7 +218,7 @@ class AiPersona < ActiveRecord::Base
klass
end
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
persona_class = DiscourseAi::Personas::Persona.system_personas_by_id[self.id]
if persona_class
instance_attributes.each do |key, value|
# description/name are localized
@ -230,7 +230,7 @@ class AiPersona < ActiveRecord::Base
ai_persona_id = self.id
Class.new(DiscourseAi::AiBot::Personas::Persona) do
Class.new(DiscourseAi::Personas::Persona) do
instance_attributes.each { |key, value| define_singleton_method(key) { value } }
define_singleton_method(:to_s) do

View File

@ -1,18 +1,18 @@
# frozen_string_literal: true
DiscourseAi::AiBot::Personas::Persona.system_personas.each do |persona_class, id|
DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id|
persona = AiPersona.find_by(id: id)
if !persona
persona = AiPersona.new
persona.id = id
if persona_class == DiscourseAi::AiBot::Personas::WebArtifactCreator
if persona_class == DiscourseAi::Personas::WebArtifactCreator
# this is somewhat sensitive, so we default it to staff
persona.allowed_group_ids = [Group::AUTO_GROUPS[:staff]]
else
persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]]
end
persona.enabled = true
persona.priority = true if persona_class == DiscourseAi::AiBot::Personas::General
persona.priority = true if persona_class == DiscourseAi::Personas::General
end
names = [

View File

@ -110,7 +110,7 @@ module DiscourseAi
scope.user.in_any_groups?(SiteSetting.ai_bot_allowed_groups_map)
end,
) do
DiscourseAi::AiBot::Personas::Persona
DiscourseAi::Personas::Persona
.all(user: scope.user)
.map do |persona|
{
@ -205,8 +205,7 @@ module DiscourseAi
include_condition: -> { SiteSetting.ai_bot_enabled && object.topic.private_message? },
) do
id = topic.custom_fields["ai_persona_id"]
name =
DiscourseAi::AiBot::Personas::Persona.find_by(user: scope.user, id: id.to_i)&.name if id
name = DiscourseAi::Personas::Persona.find_by(user: scope.user, id: id.to_i)&.name if id
name || topic.custom_fields["ai_persona"]
end

View File

@ -1,19 +0,0 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Creative < Persona
def tools
[]
end
def system_prompt
<<~PROMPT
You are a helpful bot
PROMPT
end
end
end
end
end

View File

@ -1,397 +0,0 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Persona
class << self
def rag_conversation_chunks
10
end
def vision_enabled
false
end
def vision_max_pixels
1_048_576
end
def question_consolidator_llm_id
nil
end
def force_default_llm
false
end
def allow_chat_channel_mentions
false
end
def allow_chat_direct_messages
false
end
def system_personas
@system_personas ||= {
Personas::General => -1,
Personas::SqlHelper => -2,
Personas::Artist => -3,
Personas::SettingsExplorer => -4,
Personas::Researcher => -5,
Personas::Creative => -6,
Personas::DallE3 => -7,
Personas::DiscourseHelper => -8,
Personas::GithubHelper => -9,
Personas::WebArtifactCreator => -10,
}
end
def system_personas_by_id
@system_personas_by_id ||= system_personas.invert
end
def all(user:)
# listing tools has to be dynamic cause site settings may change
AiPersona.all_personas.filter do |persona|
next false if !user.in_any_groups?(persona.allowed_group_ids)
if persona.system
instance = persona.new
(
instance.required_tools == [] ||
(instance.required_tools - all_available_tools).empty?
)
else
true
end
end
end
def find_by(id: nil, name: nil, user:)
all(user: user).find { |persona| persona.id == id || persona.name == name }
end
def name
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.name")
end
def description
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description")
end
def all_available_tools
tools = [
Tools::ListCategories,
Tools::Time,
Tools::Search,
Tools::Read,
Tools::DbSchema,
Tools::SearchSettings,
Tools::SettingContext,
Tools::RandomPicker,
Tools::DiscourseMetaSearch,
Tools::GithubFileContent,
Tools::GithubPullRequestDiff,
Tools::GithubSearchFiles,
Tools::WebBrowser,
Tools::JavascriptEvaluator,
]
if SiteSetting.ai_artifact_security.in?(%w[lax strict])
tools << Tools::CreateArtifact
tools << Tools::UpdateArtifact
tools << Tools::ReadArtifact
end
tools << Tools::GithubSearchCode if SiteSetting.ai_bot_github_access_token.present?
tools << Tools::ListTags if SiteSetting.tagging_enabled
tools << Tools::Image if SiteSetting.ai_stability_api_key.present?
tools << Tools::DallE if SiteSetting.ai_openai_api_key.present?
if SiteSetting.ai_google_custom_search_api_key.present? &&
SiteSetting.ai_google_custom_search_cx.present?
tools << Tools::Google
end
tools
end
end
def id
@ai_persona&.id || self.class.system_personas[self.class]
end
def tools
[]
end
def force_tool_use
[]
end
def forced_tool_count
-1
end
def required_tools
[]
end
def temperature
nil
end
def top_p
nil
end
def options
{}
end
def available_tools
self
.class
.all_available_tools
.filter { |tool| tools.include?(tool) }
.concat(tools.filter(&:custom?))
end
def craft_prompt(context, llm: nil)
system_insts =
system_prompt.gsub(/\{(\w+)\}/) do |match|
found = context[match[1..-2].to_sym]
found.nil? ? match : found.to_s
end
prompt_insts = <<~TEXT.strip
#{system_insts}
#{available_tools.map(&:custom_system_message).compact_blank.join("\n")}
TEXT
question_consolidator_llm = llm
if self.class.question_consolidator_llm_id.present?
question_consolidator_llm ||=
DiscourseAi::Completions::Llm.proxy(
LlmModel.find_by(id: self.class.question_consolidator_llm_id),
)
end
if context[:custom_instructions].present?
prompt_insts << "\n"
prompt_insts << context[:custom_instructions]
end
fragments_guidance =
rag_fragments_prompt(
context[:conversation_context].to_a,
llm: question_consolidator_llm,
user: context[:user],
)&.strip
prompt_insts << fragments_guidance if fragments_guidance.present?
prompt =
DiscourseAi::Completions::Prompt.new(
prompt_insts,
messages: context[:conversation_context].to_a,
topic_id: context[:topic_id],
post_id: context[:post_id],
)
prompt.max_pixels = self.class.vision_max_pixels if self.class.vision_enabled
prompt.tools = available_tools.map(&:signature) if available_tools
available_tools.each do |tool|
tool.inject_prompt(prompt: prompt, context: context, persona: self)
end
prompt
end
def find_tool(partial, bot_user:, llm:, context:, existing_tools: [])
return nil if !partial.is_a?(DiscourseAi::Completions::ToolCall)
tool_instance(
partial,
bot_user: bot_user,
llm: llm,
context: context,
existing_tools: existing_tools,
)
end
def allow_partial_tool_calls?
available_tools.any? { |tool| tool.allow_partial_tool_calls? }
end
protected
def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:)
function_id = tool_call.id
function_name = tool_call.name
return nil if function_name.nil?
tool_klass = available_tools.find { |c| c.signature.dig(:name) == function_name }
return nil if tool_klass.nil?
arguments = {}
tool_klass.signature[:parameters].to_a.each do |param|
name = param[:name]
value = tool_call.parameters[name.to_sym]
if param[:type] == "array" && value
value =
begin
JSON.parse(value)
rescue JSON::ParserError
[value.to_s]
end
elsif param[:type] == "string" && value
value = strip_quotes(value).to_s
elsif param[:type] == "integer" && value
value = strip_quotes(value).to_i
end
if param[:enum] && value && !param[:enum].include?(value)
# invalid enum value
value = nil
end
arguments[name.to_sym] = value if value
end
tool_instance =
existing_tools.find { |t| t.name == function_name && t.tool_call_id == function_id }
if tool_instance
tool_instance.parameters = arguments
tool_instance
else
tool_klass.new(
arguments,
tool_call_id: function_id || function_name,
persona_options: options[tool_klass].to_h,
bot_user: bot_user,
llm: llm,
context: context,
)
end
end
def strip_quotes(value)
if value.is_a?(String)
if value.start_with?('"') && value.end_with?('"')
value = value[1..-2]
elsif value.start_with?("'") && value.end_with?("'")
value = value[1..-2]
else
value
end
else
value
end
end
def rag_fragments_prompt(conversation_context, llm:, user:)
upload_refs =
UploadReference.where(target_id: id, target_type: "AiPersona").pluck(:upload_id)
return nil if !DiscourseAi::Embeddings.enabled?
return nil if conversation_context.blank? || upload_refs.blank?
latest_interactions =
conversation_context.select { |ctx| %i[model user].include?(ctx[:type]) }.last(10)
return nil if latest_interactions.empty?
# first response
if latest_interactions.length == 1
consolidated_question = latest_interactions[0][:content]
else
consolidated_question =
DiscourseAi::AiBot::QuestionConsolidator.consolidate_question(
llm,
latest_interactions,
user,
)
end
return nil if !consolidated_question
vector = DiscourseAi::Embeddings::Vector.instance
reranker = DiscourseAi::Inference::HuggingFaceTextEmbeddings
interactions_vector = vector.vector_from(consolidated_question)
rag_conversation_chunks = self.class.rag_conversation_chunks
search_limit =
if reranker.reranker_configured?
rag_conversation_chunks * 5
else
rag_conversation_chunks
end
schema = DiscourseAi::Embeddings::Schema.for(RagDocumentFragment)
candidate_fragment_ids =
schema
.asymmetric_similarity_search(
interactions_vector,
limit: search_limit,
offset: 0,
) { |builder| builder.join(<<~SQL, target_id: id, target_type: "AiPersona") }
rag_document_fragments ON
rag_document_fragments.id = rag_document_fragment_id AND
rag_document_fragments.target_id = :target_id AND
rag_document_fragments.target_type = :target_type
SQL
.map(&:rag_document_fragment_id)
fragments =
RagDocumentFragment.where(upload_id: upload_refs, id: candidate_fragment_ids).pluck(
:fragment,
:metadata,
)
if reranker.reranker_configured?
guidance = fragments.map { |fragment, _metadata| fragment }
ranks =
DiscourseAi::Inference::HuggingFaceTextEmbeddings
.rerank(conversation_context.last[:content], guidance)
.to_a
.take(rag_conversation_chunks)
.map { _1[:index] }
if ranks.empty?
fragments = fragments.take(rag_conversation_chunks)
else
fragments = ranks.map { |idx| fragments[idx] }
end
end
<<~TEXT
<guidance>
The following texts will give you additional guidance for your response.
We included them because we believe they are relevant to this conversation topic.
Texts:
#{
fragments
.map do |fragment, metadata|
if metadata.present?
["# #{metadata}", fragment].join("\n")
else
fragment
end
end
.join("\n")
}
</guidance>
TEXT
end
end
end
end
end

View File

@ -150,13 +150,11 @@ module DiscourseAi
persona = nil
if persona_id
persona =
DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id.to_i)
persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id.to_i)
end
if !persona && persona_name = post.topic.custom_fields["ai_persona"]
persona =
DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, name: persona_name)
persona = DiscourseAi::Personas::Persona.find_by(user: post.user, name: persona_name)
end
# edge case, llm was mentioned in an ai persona conversation
@ -172,11 +170,11 @@ module DiscourseAi
end
end
persona ||= DiscourseAi::AiBot::Personas::General
persona ||= DiscourseAi::Personas::General
bot_user = User.find(persona.user_id) if persona && persona.force_default_llm
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new)
new(bot).update_playground_with(post)
end
end
@ -198,7 +196,7 @@ module DiscourseAi
bot_user = user || ai_persona.user
raise Discourse::InvalidParameters.new(:user) if bot_user.nil?
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona)
playground = DiscourseAi::AiBot::Playground.new(bot)
playground.reply_to(
@ -307,16 +305,52 @@ module DiscourseAi
end
def title_playground(post, user)
context = conversation_context(post)
conversation =
conversation_context(post).reduce(+"") do |memo, c|
if c[:type] == :user || c[:type] == :model
memo << "#{c[:type].to_s.humanize} said:\n#{c[:content]}\n\n"
end
memo
end
system_insts = <<~TEXT.strip
You are titlebot. Given a conversation, you will suggest a title.
- You will never respond with anything but the suggested title.
- You will always match the conversation language in your title suggestion.
- Title will capture the essence of the conversation.
TEXT
instruction = <<~TEXT.strip
Given the following conversation:
{{{
#{conversation}
}}}
Reply only with a title that is 7 words or less.
TEXT
title_prompt =
DiscourseAi::Completions::Prompt.new(
system_insts,
messages: [type: :user, content: instruction],
topic_id: post.topic_id,
)
title =
bot
.get_updated_title(context, post, user)
.tap do |new_title|
.llm
.generate(title_prompt, user: user, feature_name: "bot_title")
.strip
.split("\n")
.last
PostRevisor.new(post.topic.first_post, post.topic).revise!(
bot.bot_user,
title: new_title.sub(/\A"/, "").sub(/"\Z/, ""),
title: title.sub(/\A"/, "").sub(/"\Z/, ""),
)
end
allowed_users = post.topic.topic_allowed_users.pluck(:user_id)
MessageBus.publish(
@ -710,8 +744,7 @@ module DiscourseAi
def schedule_bot_reply(post)
persona_id =
DiscourseAi::AiBot::Personas::Persona.system_personas[bot.persona.class] ||
bot.persona.class.id
DiscourseAi::Personas::Persona.system_personas[bot.persona.class] || bot.persona.class.id
::Jobs.enqueue(
:create_ai_reply,
post_id: post.id,

View File

@ -77,8 +77,8 @@ module DiscourseAi
io.flush
persona_class =
DiscourseAi::AiBot::Personas::Persona.find_by(id: persona.id, user: current_user)
bot = DiscourseAi::AiBot::Bot.as(persona.user, persona: persona_class.new)
DiscourseAi::Personas::Persona.find_by(id: persona.id, user: current_user)
bot = DiscourseAi::Personas::Bot.as(persona.user, persona: persona_class.new)
data =
{

View File

@ -333,7 +333,7 @@ module DiscourseAi
return { error: "Persona not found" } if persona_class.nil?
persona = persona_class.new
bot = DiscourseAi::AiBot::Bot.as(@bot_user || persona.user, persona: persona)
bot = DiscourseAi::Personas::Bot.as(@bot_user || persona.user, persona: persona)
playground = DiscourseAi::AiBot::Playground.new(bot)
if @context[:post_id]
@ -482,7 +482,7 @@ module DiscourseAi
headers = (options && options["headers"]) || {}
result = {}
DiscourseAi::AiBot::Tools::Tool.send_http_request(
DiscourseAi::Personas::Tools::Tool.send_http_request(
url,
headers: headers,
) do |response|
@ -511,7 +511,7 @@ module DiscourseAi
body = options && options["body"]
result = {}
DiscourseAi::AiBot::Tools::Tool.send_http_request(
DiscourseAi::Personas::Tools::Tool.send_http_request(
url,
method: method,
headers: headers,

View File

@ -9,12 +9,7 @@ module DiscourseAi
.all_personas
.find { |persona| persona.id == SiteSetting.ai_discord_search_persona.to_i }
.new
@bot =
DiscourseAi::AiBot::Bot.as(
Discourse.system_user,
persona: @persona,
model: LlmModel.find(@persona.class.default_llm_id),
)
@bot = DiscourseAi::Personas::Bot.as(Discourse.system_user, persona: @persona)
super(body)
end

View File

@ -4,7 +4,7 @@ module DiscourseAi
module Discord::Bot
class Search < Base
def initialize(body)
@search = DiscourseAi::AiBot::Tools::Search
@search = DiscourseAi::Personas::Tools::Search
super(body)
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module ArtifactUpdateStrategies
class InvalidFormatError < StandardError
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module ArtifactUpdateStrategies
class Diff < Base
attr_reader :failed_searches

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module ArtifactUpdateStrategies
class Full < Base
private

View File

@ -1,7 +1,6 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Artist < Persona
def tools
@ -35,4 +34,3 @@ module DiscourseAi
end
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Bot
attr_reader :model
@ -13,7 +13,7 @@ module DiscourseAi
# limit is arbitrary, but 5 which was used in the past was too low
MAX_TOOLS = 20
def self.as(bot_user, persona: DiscourseAi::AiBot::Personas::General.new, model: nil)
def self.as(bot_user, persona:, model: nil)
new(bot_user, persona, model)
end
@ -27,49 +27,8 @@ module DiscourseAi
attr_reader :bot_user
attr_accessor :persona
def get_updated_title(conversation_context, post, user)
system_insts = <<~TEXT.strip
You are titlebot. Given a conversation, you will suggest a title.
- You will never respond with anything but the suggested title.
- You will always match the conversation language in your title suggestion.
- Title will capture the essence of the conversation.
TEXT
# conversation context may contain tool calls, and confusing user names
# clean it up
conversation = +""
conversation_context.each do |context|
if context[:type] == :user
conversation << "User said:\n#{context[:content]}\n\n"
elsif context[:type] == :model
conversation << "Model said:\n#{context[:content]}\n\n"
end
end
instruction = <<~TEXT.strip
Given the following conversation:
{{{
#{conversation}
}}}
Reply only with a title that is 7 words or less.
TEXT
title_prompt =
DiscourseAi::Completions::Prompt.new(
system_insts,
messages: [type: :user, content: instruction],
topic_id: post.topic_id,
)
DiscourseAi::Completions::Llm
.proxy(model)
.generate(title_prompt, user: user, feature_name: "bot_title")
.strip
.split("\n")
.last
def llm
DiscourseAi::Completions::Llm.proxy(model)
end
def force_tool_if_needed(prompt, context)
@ -93,8 +52,8 @@ module DiscourseAi
end
def reply(context, &update_blk)
llm = DiscourseAi::Completions::Llm.proxy(model)
prompt = persona.craft_prompt(context, llm: llm)
current_llm = llm
prompt = persona.craft_prompt(context, llm: current_llm)
total_completions = 0
ongoing_chain = true
@ -120,7 +79,7 @@ module DiscourseAi
current_thinking = []
result =
llm.generate(
current_llm.generate(
prompt,
feature_name: "bot",
partial_tool_calls: allow_partial_tool_calls,
@ -131,7 +90,7 @@ module DiscourseAi
persona.find_tool(
partial,
bot_user: user,
llm: llm,
llm: current_llm,
context: context,
existing_tools: existing_tools,
)
@ -158,7 +117,7 @@ module DiscourseAi
process_tool(
tool: tool,
raw_context: raw_context,
llm: llm,
current_llm: current_llm,
cancel: cancel,
update_blk: update_blk,
prompt: prompt,
@ -242,7 +201,7 @@ module DiscourseAi
def process_tool(
tool:,
raw_context:,
llm:,
current_llm:,
cancel:,
update_blk:,
prompt:,
@ -250,7 +209,7 @@ module DiscourseAi
current_thinking:
)
tool_call_id = tool.tool_call_id
invocation_result_json = invoke_tool(tool, llm, cancel, context, &update_blk).to_json
invocation_result_json = invoke_tool(tool, cancel, context, &update_blk).to_json
tool_call_message = {
type: :tool_call,
@ -296,7 +255,7 @@ module DiscourseAi
raw_context << [invocation_result_json, tool_call_id, "tool", tool.name]
end
def invoke_tool(tool, llm, cancel, context, &update_blk)
def invoke_tool(tool, cancel, context, &update_blk)
show_placeholder = !context[:skip_tool_details] && !tool.class.allow_partial_tool_calls?
update_blk.call("", cancel, build_placeholder(tool.summary, "")) if show_placeholder

17
lib/personas/creative.rb Normal file
View File

@ -0,0 +1,17 @@
#frozen_string_literal: true
module DiscourseAi
module Personas
class Creative < Persona
def tools
[]
end
def system_prompt
<<~PROMPT
You are a helpful bot
PROMPT
end
end
end
end

View File

@ -1,7 +1,6 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class DallE3 < Persona
def tools
@ -36,4 +35,3 @@ module DiscourseAi
end
end
end
end

View File

@ -1,7 +1,6 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class DiscourseHelper < Persona
def tools
@ -45,4 +44,3 @@ module DiscourseAi
end
end
end
end

View File

@ -1,7 +1,6 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class General < Persona
def tools
@ -31,4 +30,3 @@ module DiscourseAi
end
end
end
end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class GithubHelper < Persona
def tools
@ -26,4 +25,3 @@ module DiscourseAi
end
end
end
end

395
lib/personas/persona.rb Normal file
View File

@ -0,0 +1,395 @@
#frozen_string_literal: true
module DiscourseAi
module Personas
class Persona
class << self
def rag_conversation_chunks
10
end
def vision_enabled
false
end
def vision_max_pixels
1_048_576
end
def question_consolidator_llm_id
nil
end
def force_default_llm
false
end
def allow_chat_channel_mentions
false
end
def allow_chat_direct_messages
false
end
def system_personas
@system_personas ||= {
Personas::General => -1,
Personas::SqlHelper => -2,
Personas::Artist => -3,
Personas::SettingsExplorer => -4,
Personas::Researcher => -5,
Personas::Creative => -6,
Personas::DallE3 => -7,
Personas::DiscourseHelper => -8,
Personas::GithubHelper => -9,
Personas::WebArtifactCreator => -10,
}
end
def system_personas_by_id
@system_personas_by_id ||= system_personas.invert
end
def all(user:)
# listing tools has to be dynamic cause site settings may change
AiPersona.all_personas.filter do |persona|
next false if !user.in_any_groups?(persona.allowed_group_ids)
if persona.system
instance = persona.new
(
instance.required_tools == [] ||
(instance.required_tools - all_available_tools).empty?
)
else
true
end
end
end
def find_by(id: nil, name: nil, user:)
all(user: user).find { |persona| persona.id == id || persona.name == name }
end
def name
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.name")
end
def description
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description")
end
def all_available_tools
tools = [
Tools::ListCategories,
Tools::Time,
Tools::Search,
Tools::Read,
Tools::DbSchema,
Tools::SearchSettings,
Tools::SettingContext,
Tools::RandomPicker,
Tools::DiscourseMetaSearch,
Tools::GithubFileContent,
Tools::GithubPullRequestDiff,
Tools::GithubSearchFiles,
Tools::WebBrowser,
Tools::JavascriptEvaluator,
]
if SiteSetting.ai_artifact_security.in?(%w[lax strict])
tools << Tools::CreateArtifact
tools << Tools::UpdateArtifact
tools << Tools::ReadArtifact
end
tools << Tools::GithubSearchCode if SiteSetting.ai_bot_github_access_token.present?
tools << Tools::ListTags if SiteSetting.tagging_enabled
tools << Tools::Image if SiteSetting.ai_stability_api_key.present?
tools << Tools::DallE if SiteSetting.ai_openai_api_key.present?
if SiteSetting.ai_google_custom_search_api_key.present? &&
SiteSetting.ai_google_custom_search_cx.present?
tools << Tools::Google
end
tools
end
end
def id
@ai_persona&.id || self.class.system_personas[self.class]
end
def tools
[]
end
def force_tool_use
[]
end
def forced_tool_count
-1
end
def required_tools
[]
end
def temperature
nil
end
def top_p
nil
end
def options
{}
end
def available_tools
self
.class
.all_available_tools
.filter { |tool| tools.include?(tool) }
.concat(tools.filter(&:custom?))
end
def craft_prompt(context, llm: nil)
system_insts =
system_prompt.gsub(/\{(\w+)\}/) do |match|
found = context[match[1..-2].to_sym]
found.nil? ? match : found.to_s
end
prompt_insts = <<~TEXT.strip
#{system_insts}
#{available_tools.map(&:custom_system_message).compact_blank.join("\n")}
TEXT
question_consolidator_llm = llm
if self.class.question_consolidator_llm_id.present?
question_consolidator_llm ||=
DiscourseAi::Completions::Llm.proxy(
LlmModel.find_by(id: self.class.question_consolidator_llm_id),
)
end
if context[:custom_instructions].present?
prompt_insts << "\n"
prompt_insts << context[:custom_instructions]
end
fragments_guidance =
rag_fragments_prompt(
context[:conversation_context].to_a,
llm: question_consolidator_llm,
user: context[:user],
)&.strip
prompt_insts << fragments_guidance if fragments_guidance.present?
prompt =
DiscourseAi::Completions::Prompt.new(
prompt_insts,
messages: context[:conversation_context].to_a,
topic_id: context[:topic_id],
post_id: context[:post_id],
)
prompt.max_pixels = self.class.vision_max_pixels if self.class.vision_enabled
prompt.tools = available_tools.map(&:signature) if available_tools
available_tools.each do |tool|
tool.inject_prompt(prompt: prompt, context: context, persona: self)
end
prompt
end
def find_tool(partial, bot_user:, llm:, context:, existing_tools: [])
return nil if !partial.is_a?(DiscourseAi::Completions::ToolCall)
tool_instance(
partial,
bot_user: bot_user,
llm: llm,
context: context,
existing_tools: existing_tools,
)
end
def allow_partial_tool_calls?
available_tools.any? { |tool| tool.allow_partial_tool_calls? }
end
protected
def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:)
function_id = tool_call.id
function_name = tool_call.name
return nil if function_name.nil?
tool_klass = available_tools.find { |c| c.signature.dig(:name) == function_name }
return nil if tool_klass.nil?
arguments = {}
tool_klass.signature[:parameters].to_a.each do |param|
name = param[:name]
value = tool_call.parameters[name.to_sym]
if param[:type] == "array" && value
value =
begin
JSON.parse(value)
rescue JSON::ParserError
[value.to_s]
end
elsif param[:type] == "string" && value
value = strip_quotes(value).to_s
elsif param[:type] == "integer" && value
value = strip_quotes(value).to_i
end
if param[:enum] && value && !param[:enum].include?(value)
# invalid enum value
value = nil
end
arguments[name.to_sym] = value if value
end
tool_instance =
existing_tools.find { |t| t.name == function_name && t.tool_call_id == function_id }
if tool_instance
tool_instance.parameters = arguments
tool_instance
else
tool_klass.new(
arguments,
tool_call_id: function_id || function_name,
persona_options: options[tool_klass].to_h,
bot_user: bot_user,
llm: llm,
context: context,
)
end
end
def strip_quotes(value)
if value.is_a?(String)
if value.start_with?('"') && value.end_with?('"')
value = value[1..-2]
elsif value.start_with?("'") && value.end_with?("'")
value = value[1..-2]
else
value
end
else
value
end
end
def rag_fragments_prompt(conversation_context, llm:, user:)
upload_refs =
UploadReference.where(target_id: id, target_type: "AiPersona").pluck(:upload_id)
return nil if !DiscourseAi::Embeddings.enabled?
return nil if conversation_context.blank? || upload_refs.blank?
latest_interactions =
conversation_context.select { |ctx| %i[model user].include?(ctx[:type]) }.last(10)
return nil if latest_interactions.empty?
# first response
if latest_interactions.length == 1
consolidated_question = latest_interactions[0][:content]
else
consolidated_question =
DiscourseAi::AiBot::QuestionConsolidator.consolidate_question(
llm,
latest_interactions,
user,
)
end
return nil if !consolidated_question
vector = DiscourseAi::Embeddings::Vector.instance
reranker = DiscourseAi::Inference::HuggingFaceTextEmbeddings
interactions_vector = vector.vector_from(consolidated_question)
rag_conversation_chunks = self.class.rag_conversation_chunks
search_limit =
if reranker.reranker_configured?
rag_conversation_chunks * 5
else
rag_conversation_chunks
end
schema = DiscourseAi::Embeddings::Schema.for(RagDocumentFragment)
candidate_fragment_ids =
schema
.asymmetric_similarity_search(
interactions_vector,
limit: search_limit,
offset: 0,
) { |builder| builder.join(<<~SQL, target_id: id, target_type: "AiPersona") }
rag_document_fragments ON
rag_document_fragments.id = rag_document_fragment_id AND
rag_document_fragments.target_id = :target_id AND
rag_document_fragments.target_type = :target_type
SQL
.map(&:rag_document_fragment_id)
fragments =
RagDocumentFragment.where(upload_id: upload_refs, id: candidate_fragment_ids).pluck(
:fragment,
:metadata,
)
if reranker.reranker_configured?
guidance = fragments.map { |fragment, _metadata| fragment }
ranks =
DiscourseAi::Inference::HuggingFaceTextEmbeddings
.rerank(conversation_context.last[:content], guidance)
.to_a
.take(rag_conversation_chunks)
.map { _1[:index] }
if ranks.empty?
fragments = fragments.take(rag_conversation_chunks)
else
fragments = ranks.map { |idx| fragments[idx] }
end
end
<<~TEXT
<guidance>
The following texts will give you additional guidance for your response.
We included them because we believe they are relevant to this conversation topic.
Texts:
#{
fragments
.map do |fragment, metadata|
if metadata.present?
["# #{metadata}", fragment].join("\n")
else
fragment
end
end
.join("\n")
}
</guidance>
TEXT
end
end
end
end

View File

@ -1,7 +1,6 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Researcher < Persona
def tools
@ -49,4 +48,3 @@ module DiscourseAi
end
end
end
end

View File

@ -1,7 +1,6 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class SettingsExplorer < Persona
def tools
@ -23,4 +22,3 @@ module DiscourseAi
end
end
end
end

View File

@ -1,7 +1,6 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class SqlHelper < Persona
def self.schema
@ -25,8 +24,7 @@ module DiscourseAi
order by table_name
SQL
priority =
+(priority_tables.map { |name| "#{name}(#{tables[name].join(",")})" }.join("\n"))
priority = +(priority_tables.map { |name| "#{name}(#{tables[name].join(",")})" }.join("\n"))
other_tables = +""
tables.each do |table_name, _|
@ -104,4 +102,3 @@ module DiscourseAi
end
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class CreateArtifact < Tool
def self.name

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class Custom < Tool
def self.class_instance(tool_id)

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class DallE < Tool
def self.signature

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class DbSchema < Tool
def self.signature

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class DiscourseMetaSearch < Tool
class << self

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class GithubFileContent < Tool
def self.signature

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class GithubPullRequestDiff < Tool
LARGE_OBJECT_THRESHOLD = 30_000

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class GithubSearchCode < Tool
def self.signature

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class GithubSearchFiles < Tool
def self.signature

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class Google < Tool
def self.signature

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class Image < Tool
def self.signature

View File

@ -4,7 +4,7 @@ require "mini_racer"
require "json"
module DiscourseAi
module AiBot
module Personas
module Tools
class JavascriptEvaluator < Tool
TIMEOUT = 500

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class ListCategories < Tool
def self.signature

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class ListTags < Tool
def self.signature

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class Option
attr_reader :tool, :name, :type, :values, :default

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class RandomPicker < Tool
def self.signature

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
MAX_POSTS = 100
module Tools

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class ReadArtifact < Tool
MAX_HTML_SIZE = 30.kilobytes

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class Search < Tool
attr_reader :last_query

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class SearchSettings < Tool
INCLUDE_DESCRIPTIONS_MAX_LENGTH = 10

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class SettingContext < Tool
MAX_CONTEXT_TOKENS = 2000

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class Summarize < Tool
def self.signature

View File

@ -1,7 +1,7 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class Time < Tool
def self.signature

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class Tool
# Why 30 mega bytes?

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class UpdateArtifact < Tool
def self.name

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
module Tools
class WebBrowser < Tool
def self.signature

View File

@ -1,7 +1,6 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class WebArtifactCreator < Persona
def tools
@ -53,4 +52,3 @@ module DiscourseAi
end
end
end
end

View File

@ -13,7 +13,7 @@ RSpec.describe DiscourseAi::Discord::Bot::PersonaReplier do
before do
SiteSetting.ai_discord_search_persona = persona.id.to_s
allow_any_instance_of(DiscourseAi::AiBot::Bot).to receive(:reply).and_return(
allow_any_instance_of(DiscourseAi::Personas::Bot).to receive(:reply).and_return(
"This is a reply from bot!",
)
allow(persona_replier).to receive(:create_reply)

View File

@ -20,7 +20,7 @@ RSpec.describe DiscourseAi::Discord::Bot::Search do
describe "#handle_interaction!" do
it "creates a reply with search results" do
allow_any_instance_of(DiscourseAi::AiBot::Tools::Search).to receive(:invoke).and_return(
allow_any_instance_of(DiscourseAi::Personas::Tools::Search).to receive(:invoke).and_return(
{ rows: [%w[Title /link]] },
)
search.handle_interaction!

View File

@ -22,14 +22,10 @@ RSpec.describe DiscourseAi::AiBot::Playground do
fab!(:bot) do
persona =
AiPersona
.find(
DiscourseAi::AiBot::Personas::Persona.system_personas[
DiscourseAi::AiBot::Personas::General
],
)
.find(DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::General])
.class_instance
.new
DiscourseAi::AiBot::Bot.as(bot_user, persona: persona)
DiscourseAi::Personas::Bot.as(bot_user, persona: persona)
end
fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) }
@ -103,7 +99,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do
)
end
let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user, persona: ai_persona.class_instance.new) }
let(:bot) { DiscourseAi::Personas::Bot.as(bot_user, persona: ai_persona.class_instance.new) }
let(:playground) { DiscourseAi::AiBot::Playground.new(bot) }
@ -173,7 +169,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do
it "uses custom tool in conversation" do
persona_klass = AiPersona.all_personas.find { |p| p.name == ai_persona.name }
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona_klass.new)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona_klass.new)
playground = DiscourseAi::AiBot::Playground.new(bot)
responses = [tool_call, "custom tool did stuff (maybe)"]
@ -213,7 +209,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do
custom_tool.update!(enabled: false)
# so we pick up new cache
persona_klass = AiPersona.all_personas.find { |p| p.name == ai_persona.name }
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona_klass.new)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona_klass.new)
playground = DiscourseAi::AiBot::Playground.new(bot)
responses = ["custom tool did stuff (maybe)", tool_call]
@ -965,7 +961,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do
it "supports disabling tool details" do
persona = Fabricate(:ai_persona, tool_details: false, tools: ["Search"])
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.class_instance.new)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.class_instance.new)
playground = described_class.new(bot)
response1 =
@ -1018,13 +1014,11 @@ RSpec.describe DiscourseAi::AiBot::Playground do
let(:persona) do
AiPersona.find(
DiscourseAi::AiBot::Personas::Persona.system_personas[
DiscourseAi::AiBot::Personas::DallE3
],
DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::DallE3],
)
end
let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.class_instance.new) }
let(:bot) { DiscourseAi::Personas::Bot.as(bot_user, persona: persona.class_instance.new) }
let(:data) do
image =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::ArtifactUpdateStrategies::Diff do
RSpec.describe DiscourseAi::Personas::ArtifactUpdateStrategies::Diff do
fab!(:user)
fab!(:post)
fab!(:artifact) { Fabricate(:ai_artifact) }

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Bot do
subject(:bot) { described_class.as(bot_user) }
RSpec.describe DiscourseAi::Personas::Bot do
subject(:bot) { described_class.as(bot_user, persona: DiscourseAi::Personas::General.new) }
fab!(:admin)
fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") }
@ -48,9 +48,9 @@ RSpec.describe DiscourseAi::AiBot::Bot do
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
)
personaClass = DiscourseAi::AiBot::Personas::Persona.find_by(user: admin, name: "TestPersona")
personaClass = DiscourseAi::Personas::Persona.find_by(user: admin, name: "TestPersona")
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: personaClass.new)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: personaClass.new)
bot.reply(
{ conversation_context: [{ type: :user, content: "test" }] },
) do |_partial, _cancel, _placeholder|
@ -64,7 +64,7 @@ RSpec.describe DiscourseAi::AiBot::Bot do
context "when using function chaining" do
it "yields a loading placeholder while proceeds to invoke the command" do
tool = DiscourseAi::AiBot::Tools::ListCategories.new({}, bot_user: nil, llm: nil)
tool = DiscourseAi::Personas::Tools::ListCategories.new({}, bot_user: nil, llm: nil)
partial_placeholder = +(<<~HTML)
<details>
<summary>#{tool.summary}</summary>

View File

@ -1,11 +1,11 @@
#frozen_string_literal: true
class TestPersona < DiscourseAi::AiBot::Personas::Persona
class TestPersona < DiscourseAi::Personas::Persona
def tools
[
DiscourseAi::AiBot::Tools::ListTags,
DiscourseAi::AiBot::Tools::Search,
DiscourseAi::AiBot::Tools::Image,
DiscourseAi::Personas::Tools::ListTags,
DiscourseAi::Personas::Tools::Search,
DiscourseAi::Personas::Tools::Image,
]
end
def system_prompt
@ -19,7 +19,7 @@ class TestPersona < DiscourseAi::AiBot::Personas::Persona
end
end
RSpec.describe DiscourseAi::AiBot::Personas::Persona do
RSpec.describe DiscourseAi::Personas::Persona do
let :persona do
TestPersona.new
end
@ -84,12 +84,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
)
tool_instance =
DiscourseAi::AiBot::Personas::Artist.new.find_tool(
tool_call,
bot_user: nil,
llm: nil,
context: nil,
)
DiscourseAi::Personas::Artist.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil)
expect(tool_instance.parameters[:prompts]).to eq(["cat oil painting", "big car"])
expect(tool_instance.parameters[:aspect_ratio]).to eq("16:9")
@ -108,12 +103,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
)
tool_instance =
DiscourseAi::AiBot::Personas::General.new.find_tool(
tool_call,
bot_user: nil,
llm: nil,
context: nil,
)
DiscourseAi::Personas::General.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil)
expect(tool_instance.parameters.key?(:status)).to eq(false)
@ -129,12 +119,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
)
tool_instance =
DiscourseAi::AiBot::Personas::General.new.find_tool(
tool_call,
bot_user: nil,
llm: nil,
context: nil,
)
DiscourseAi::Personas::General.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil)
expect(tool_instance.parameters[:status]).to eq("open")
end
@ -152,12 +137,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
)
search =
DiscourseAi::AiBot::Personas::General.new.find_tool(
tool_call,
bot_user: nil,
llm: nil,
context: nil,
)
DiscourseAi::Personas::General.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil)
expect(search.parameters[:max_posts]).to eq(3)
expect(search.parameters[:search_query]).to eq("hello world")
@ -177,12 +157,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
)
tool_instance =
DiscourseAi::AiBot::Personas::DallE3.new.find_tool(
tool_call,
bot_user: nil,
llm: nil,
context: nil,
)
DiscourseAi::Personas::DallE3.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil)
expect(tool_instance.parameters[:prompts]).to eq(["cat oil painting", "big car"])
end
@ -200,29 +175,29 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
)
custom_persona = DiscourseAi::AiBot::Personas::Persona.all(user: user).last
custom_persona = DiscourseAi::Personas::Persona.all(user: user).last
expect(custom_persona.name).to eq("zzzpun_bot")
expect(custom_persona.description).to eq("you write puns")
instance = custom_persona.new
expect(instance.tools).to eq([DiscourseAi::AiBot::Tools::Image])
expect(instance.tools).to eq([DiscourseAi::Personas::Tools::Image])
expect(instance.craft_prompt(context).messages.first[:content]).to eq("you are pun bot")
# should update
persona.update!(name: "zzzpun_bot2")
custom_persona = DiscourseAi::AiBot::Personas::Persona.all(user: user).last
custom_persona = DiscourseAi::Personas::Persona.all(user: user).last
expect(custom_persona.name).to eq("zzzpun_bot2")
# can be disabled
persona.update!(enabled: false)
last_persona = DiscourseAi::AiBot::Personas::Persona.all(user: user).last
last_persona = DiscourseAi::Personas::Persona.all(user: user).last
expect(last_persona.name).not_to eq("zzzpun_bot2")
persona.update!(enabled: true)
# no groups have access
persona.update!(allowed_group_ids: [])
last_persona = DiscourseAi::AiBot::Personas::Persona.all(user: user).last
last_persona = DiscourseAi::Personas::Persona.all(user: user).last
expect(last_persona.name).not_to eq("zzzpun_bot2")
end
end
@ -237,31 +212,31 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
SiteSetting.ai_google_custom_search_cx = "abc123"
# should be ordered by priority and then alpha
expect(DiscourseAi::AiBot::Personas::Persona.all(user: user)).to eq(
expect(DiscourseAi::Personas::Persona.all(user: user)).to eq(
[
DiscourseAi::AiBot::Personas::General,
DiscourseAi::AiBot::Personas::Artist,
DiscourseAi::AiBot::Personas::Creative,
DiscourseAi::AiBot::Personas::DiscourseHelper,
DiscourseAi::AiBot::Personas::GithubHelper,
DiscourseAi::AiBot::Personas::Researcher,
DiscourseAi::AiBot::Personas::SettingsExplorer,
DiscourseAi::AiBot::Personas::SqlHelper,
DiscourseAi::Personas::General,
DiscourseAi::Personas::Artist,
DiscourseAi::Personas::Creative,
DiscourseAi::Personas::DiscourseHelper,
DiscourseAi::Personas::GithubHelper,
DiscourseAi::Personas::Researcher,
DiscourseAi::Personas::SettingsExplorer,
DiscourseAi::Personas::SqlHelper,
],
)
# it should allow staff access to WebArtifactCreator
expect(DiscourseAi::AiBot::Personas::Persona.all(user: admin)).to eq(
expect(DiscourseAi::Personas::Persona.all(user: admin)).to eq(
[
DiscourseAi::AiBot::Personas::General,
DiscourseAi::AiBot::Personas::Artist,
DiscourseAi::AiBot::Personas::Creative,
DiscourseAi::AiBot::Personas::DiscourseHelper,
DiscourseAi::AiBot::Personas::GithubHelper,
DiscourseAi::AiBot::Personas::Researcher,
DiscourseAi::AiBot::Personas::SettingsExplorer,
DiscourseAi::AiBot::Personas::SqlHelper,
DiscourseAi::AiBot::Personas::WebArtifactCreator,
DiscourseAi::Personas::General,
DiscourseAi::Personas::Artist,
DiscourseAi::Personas::Creative,
DiscourseAi::Personas::DiscourseHelper,
DiscourseAi::Personas::GithubHelper,
DiscourseAi::Personas::Researcher,
DiscourseAi::Personas::SettingsExplorer,
DiscourseAi::Personas::SqlHelper,
DiscourseAi::Personas::WebArtifactCreator,
],
)
@ -270,27 +245,25 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
SiteSetting.ai_google_custom_search_api_key = ""
SiteSetting.ai_artifact_security = "disabled"
expect(DiscourseAi::AiBot::Personas::Persona.all(user: admin)).to contain_exactly(
DiscourseAi::AiBot::Personas::General,
DiscourseAi::AiBot::Personas::SqlHelper,
DiscourseAi::AiBot::Personas::SettingsExplorer,
DiscourseAi::AiBot::Personas::Creative,
DiscourseAi::AiBot::Personas::DiscourseHelper,
DiscourseAi::AiBot::Personas::GithubHelper,
expect(DiscourseAi::Personas::Persona.all(user: admin)).to contain_exactly(
DiscourseAi::Personas::General,
DiscourseAi::Personas::SqlHelper,
DiscourseAi::Personas::SettingsExplorer,
DiscourseAi::Personas::Creative,
DiscourseAi::Personas::DiscourseHelper,
DiscourseAi::Personas::GithubHelper,
)
AiPersona.find(
DiscourseAi::AiBot::Personas::Persona.system_personas[
DiscourseAi::AiBot::Personas::General
],
DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::General],
).update!(enabled: false)
expect(DiscourseAi::AiBot::Personas::Persona.all(user: user)).to contain_exactly(
DiscourseAi::AiBot::Personas::SqlHelper,
DiscourseAi::AiBot::Personas::SettingsExplorer,
DiscourseAi::AiBot::Personas::Creative,
DiscourseAi::AiBot::Personas::DiscourseHelper,
DiscourseAi::AiBot::Personas::GithubHelper,
expect(DiscourseAi::Personas::Persona.all(user: user)).to contain_exactly(
DiscourseAi::Personas::SqlHelper,
DiscourseAi::Personas::SettingsExplorer,
DiscourseAi::Personas::Creative,
DiscourseAi::Personas::DiscourseHelper,
DiscourseAi::Personas::GithubHelper,
)
end
end
@ -304,7 +277,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
SiteSetting.ai_embeddings_enabled = true
end
let(:ai_persona) { DiscourseAi::AiBot::Personas::Persona.all(user: user).first.new }
let(:ai_persona) { DiscourseAi::Personas::Persona.all(user: user).first.new }
let(:with_cc) do
context.merge(conversation_context: [{ content: "Tell me the time", type: :user }])
@ -342,7 +315,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
UploadReference.ensure_exist!(target: custom_ai_persona, upload_ids: [upload.id])
custom_persona =
DiscourseAi::AiBot::Personas::Persona.find_by(id: custom_ai_persona.id, user: user).new
DiscourseAi::Personas::Persona.find_by(id: custom_ai_persona.id, user: user).new
# this means that we will consolidate
ctx =
@ -417,7 +390,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
UploadReference.ensure_exist!(target: custom_ai_persona, upload_ids: [upload.id])
custom_persona =
DiscourseAi::AiBot::Personas::Persona.find_by(id: custom_ai_persona.id, user: user).new
DiscourseAi::Personas::Persona.find_by(id: custom_ai_persona.id, user: user).new
expect(custom_persona.class.rag_conversation_chunks).to eq(3)

View File

@ -1,13 +1,13 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Personas::Researcher do
RSpec.describe DiscourseAi::Personas::Researcher do
let :researcher do
subject
end
it "renders schema" do
expect(researcher.tools).to eq(
[DiscourseAi::AiBot::Tools::Google, DiscourseAi::AiBot::Tools::WebBrowser],
[DiscourseAi::Personas::Tools::Google, DiscourseAi::Personas::Tools::WebBrowser],
)
end
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Personas::SettingsExplorer do
RSpec.describe DiscourseAi::Personas::SettingsExplorer do
let :settings_explorer do
subject
end
@ -14,7 +14,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::SettingsExplorer do
expect(prompt).to include("site_description")
expect(settings_explorer.tools).to eq(
[DiscourseAi::AiBot::Tools::SettingContext, DiscourseAi::AiBot::Tools::SearchSettings],
[DiscourseAi::Personas::Tools::SettingContext, DiscourseAi::Personas::Tools::SearchSettings],
)
end
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Personas::SqlHelper do
RSpec.describe DiscourseAi::Personas::SqlHelper do
let :sql_helper do
subject
end
@ -12,6 +12,6 @@ RSpec.describe DiscourseAi::AiBot::Personas::SqlHelper do
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.tools).to eq([DiscourseAi::AiBot::Tools::DbSchema])
expect(sql_helper.tools).to eq([DiscourseAi::Personas::Tools::DbSchema])
end
end

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::CreateArtifact do
RSpec.describe DiscourseAi::Personas::Tools::CreateArtifact do
fab!(:llm_model)
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }
fab!(:post)

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::DallE do
RSpec.describe DiscourseAi::Personas::Tools::DallE do
let(:prompts) { ["a pink cow", "a red cow"] }
fab!(:gpt_35_turbo) { Fabricate(:llm_model, name: "gpt-3.5-turbo") }

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::DbSchema do
RSpec.describe DiscourseAi::Personas::Tools::DbSchema do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::DiscourseMetaSearch do
RSpec.describe DiscourseAi::Personas::Tools::DiscourseMetaSearch do
before { SiteSetting.ai_bot_enabled = true }
fab!(:llm_model) { Fabricate(:llm_model, max_prompt_tokens: 8192) }

View File

@ -2,7 +2,7 @@
require "rails_helper"
RSpec.describe DiscourseAi::AiBot::Tools::GithubFileContent do
RSpec.describe DiscourseAi::Personas::Tools::GithubFileContent do
fab!(:llm_model)
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -2,7 +2,7 @@
require "rails_helper"
RSpec.describe DiscourseAi::AiBot::Tools::GithubPullRequestDiff do
RSpec.describe DiscourseAi::Personas::Tools::GithubPullRequestDiff do
let(:bot_user) { Fabricate(:user) }
fab!(:llm_model)
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -2,7 +2,7 @@
require "rails_helper"
RSpec.describe DiscourseAi::AiBot::Tools::GithubSearchCode do
RSpec.describe DiscourseAi::Personas::Tools::GithubSearchCode do
let(:bot_user) { Fabricate(:user) }
fab!(:llm_model)
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -2,7 +2,7 @@
require "rails_helper"
RSpec.describe DiscourseAi::AiBot::Tools::GithubSearchFiles do
RSpec.describe DiscourseAi::Personas::Tools::GithubSearchFiles do
fab!(:llm_model)
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::Google do
RSpec.describe DiscourseAi::Personas::Tools::Google do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::Image do
RSpec.describe DiscourseAi::Personas::Tools::Image do
let(:progress_blk) { Proc.new {} }
let(:prompts) { ["a pink cow", "a red cow"] }

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::JavascriptEvaluator do
RSpec.describe DiscourseAi::Personas::Tools::JavascriptEvaluator do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::ListCategories do
RSpec.describe DiscourseAi::Personas::Tools::ListCategories do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::ListTags do
RSpec.describe DiscourseAi::Personas::Tools::ListTags do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -2,7 +2,7 @@
require "rails_helper"
RSpec.describe DiscourseAi::AiBot::Tools::RandomPicker do
RSpec.describe DiscourseAi::Personas::Tools::RandomPicker do
describe "#invoke" do
subject { described_class.new({ options: options }, bot_user: nil, llm: nil).invoke }

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::ReadArtifact do
RSpec.describe DiscourseAi::Personas::Tools::ReadArtifact do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
fab!(:post)

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::Read do
RSpec.describe DiscourseAi::Personas::Tools::Read do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::SearchSettings do
RSpec.describe DiscourseAi::Personas::Tools::SearchSettings do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::Search do
RSpec.describe DiscourseAi::Personas::Tools::Search do
before { SearchIndexer.enable }
after { SearchIndexer.disable }

View File

@ -8,7 +8,7 @@ def has_rg?
end
end
RSpec.describe DiscourseAi::AiBot::Tools::SettingContext, if: has_rg? do
RSpec.describe DiscourseAi::Personas::Tools::SettingContext, if: has_rg? do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::Summarize do
RSpec.describe DiscourseAi::Personas::Tools::Summarize do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,6 +1,6 @@
#frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::Time do
RSpec.describe DiscourseAi::Personas::Tools::Time do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::Tool do
RSpec.describe DiscourseAi::Personas::Tools::Tool do
let :tool_class do
described_class
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
RSpec.describe DiscourseAi::Personas::Tools::UpdateArtifact do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
fab!(:post)

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Tools::WebBrowser do
RSpec.describe DiscourseAi::Personas::Tools::WebBrowser do
fab!(:llm_model)
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }

View File

@ -19,7 +19,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count)
expect(response.parsed_body["meta"]["tools"].length).to eq(
DiscourseAi::AiBot::Personas::Persona.all_available_tools.length,
DiscourseAi::Personas::Persona.all_available_tools.length,
)
end
@ -136,10 +136,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
it "returns localized persona names and descriptions" do
get "/admin/plugins/discourse-ai/ai-personas.json"
id =
DiscourseAi::AiBot::Personas::Persona.system_personas[
DiscourseAi::AiBot::Personas::General
]
id = DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::General]
persona = response.parsed_body["ai_personas"].find { |p| p["id"] == id }
expect(persona["name"]).to eq("Général")
@ -301,7 +298,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end
it "does not allow temperature and top p changes on stock personas" do
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json",
params: {
ai_persona: {
top_p: 0.5,
@ -335,7 +332,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
context "with system personas" do
it "does not allow editing of system prompts" do
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json",
params: {
ai_persona: {
system_prompt: "you are not a helpful bot",
@ -348,7 +345,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end
it "does not allow editing of tools" do
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json",
params: {
ai_persona: {
tools: %w[SearchCommand ImageCommand],
@ -361,7 +358,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end
it "does not allow editing of name and description cause it is localized" do
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json",
params: {
ai_persona: {
name: "bob",
@ -375,7 +372,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
end
it "does allow some actions" do
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json",
params: {
ai_persona: {
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]],
@ -413,7 +410,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
it "is not allowed to delete system personas" do
expect {
delete "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json"
delete "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json"
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body["errors"].join).not_to be_blank
# let's make sure this is translated

View File

@ -51,7 +51,7 @@ RSpec.describe "Admin AI persona configuration", type: :system, js: true do
end
it "will not allow deletion or editing of system personas" do
visit "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}/edit"
visit "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}/edit"
expect(page).not_to have_selector(".ai-persona-editor__delete")
expect(form.field("system_prompt")).to be_disabled
end

View File

@ -16,7 +16,7 @@ RSpec.describe "AI personas", type: :system, js: true do
persona_selector =
PageObjects::Components::SelectKit.new(".persona-llm-selector__persona-dropdown")
id = DiscourseAi::AiBot::Personas::Persona.all(user: admin).first.id
id = DiscourseAi::Personas::Persona.all(user: admin).first.id
expect(persona_selector).to have_selected_value(id)