REFACTOR: Move personas into its own module. (#1233)

This change moves all the personas code into its own module. We want to treat them as a building block features can built on top of, same as `Completions::Llm`.

The code to title a message was moved from `Bot` to `Playground`.
This commit is contained in:
Roman Rizzi 2025-03-31 14:42:33 -03:00 committed by GitHub
parent 5b6d39a206
commit 30242a27e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
98 changed files with 781 additions and 843 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,
@ -31,7 +31,7 @@ module Jobs
base = { query: query, model_used: llm_model.display_name }
context =
DiscourseAi::AiBot::BotContext.new(
DiscourseAi::Personas::BotContext.new(
messages: [{ type: :user, content: query }],
skip_tool_details: true,
)

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

@ -36,7 +36,7 @@ class AiTool < ActiveRecord::Base
end
def runner(parameters, llm:, bot_user:, context: nil)
DiscourseAi::AiBot::ToolRunner.new(
DiscourseAi::Personas::ToolRunner.new(
parameters: parameters,
llm: llm,
bot_user: bot_user,

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.lookup_template_param(match[1..-2])
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.messages,
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.messages,
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,8 +196,8 @@ 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)
playground = DiscourseAi::AiBot::Playground.new(bot)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona)
playground = new(bot)
playground.reply_to(
post,
@ -236,14 +234,54 @@ module DiscourseAi
include_uploads: bot.persona.class.vision_enabled,
)
bot
.get_updated_title(messages, post, user)
.tap do |new_title|
PostRevisor.new(post.topic.first_post, post.topic).revise!(
bot.bot_user,
title: new_title.sub(/\A"/, "").sub(/"\Z/, ""),
)
# conversation context may contain tool calls, and confusing user names
# clean it up
conversation = +""
messages.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
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,
)
new_title =
bot
.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/, ""),
)
allowed_users = post.topic.topic_allowed_users.pluck(:user_id)
MessageBus.publish(
@ -271,7 +309,7 @@ module DiscourseAi
end
context =
BotContext.new(
DiscourseAi::Personas::BotContext.new(
participants: participants,
message_id: message.id,
channel_id: channel.id,
@ -372,7 +410,7 @@ module DiscourseAi
end
context =
BotContext.new(
DiscourseAi::Personas::BotContext.new(
post: post,
custom_instructions: custom_instructions,
messages:
@ -575,8 +613,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

@ -7,7 +7,7 @@ module DiscourseAi
return if !tool
return if !tool.parameters.blank?
context = DiscourseAi::AiBot::BotContext.new(post: post)
context = DiscourseAi::Personas::BotContext.new(post: post)
runner = tool.runner({}, llm: nil, bot_user: Discourse.system_user, context: context)
runner.invoke

View File

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

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,19 +1,18 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Artist < Persona
def tools
[Tools::Image]
end
module Personas
class Artist < Persona
def tools
[Tools::Image]
end
def required_tools
[Tools::Image]
end
def required_tools
[Tools::Image]
end
def system_prompt
<<~PROMPT
def system_prompt
<<~PROMPT
You are artistbot and you are here to help people generate images.
You generate images using stable diffusion.
@ -31,7 +30,6 @@ module DiscourseAi
- Be creative with your prompts, offer diverse options
- You can use the seeds to regenerate the same image and amend the prompt keeping general style
PROMPT
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: DiscourseAi::Personas::General.new, 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
@llm ||= DiscourseAi::Completions::Llm.proxy(model)
end
def force_tool_if_needed(prompt, context)

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class BotContext
attr_accessor :messages,
:topic_id,

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,19 +1,18 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class DallE3 < Persona
def tools
[Tools::DallE]
end
module Personas
class DallE3 < Persona
def tools
[Tools::DallE]
end
def required_tools
[Tools::DallE]
end
def required_tools
[Tools::DallE]
end
def system_prompt
<<~PROMPT
def system_prompt
<<~PROMPT
As a DALL-E-3 bot, you're tasked with generating images based on user prompts.
- Be specific and detailed in your prompts. Include elements like subject, medium (e.g., oil on canvas), artist style, lighting, time of day, and website style (e.g., ArtStation, DeviantArt).
@ -32,7 +31,6 @@ module DiscourseAi
Just generate the images
PROMPT
end
end
end
end

View File

@ -1,15 +1,14 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class DiscourseHelper < Persona
def tools
[Tools::DiscourseMetaSearch]
end
module Personas
class DiscourseHelper < Persona
def tools
[Tools::DiscourseMetaSearch]
end
def system_prompt
<<~PROMPT
def system_prompt
<<~PROMPT
You are Discourse Helper Bot
- Discourse Helper Bot understands *markdown* and responds in Discourse **markdown**.
@ -41,7 +40,6 @@ module DiscourseAi
The date now is: {time}, much has changed since you were trained.
PROMPT
end
end
end
end

View File

@ -1,22 +1,21 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class General < Persona
def tools
[
Tools::Search,
Tools::Google,
Tools::Image,
Tools::Read,
Tools::ListCategories,
Tools::ListTags,
]
end
module Personas
class General < Persona
def tools
[
Tools::Search,
Tools::Google,
Tools::Image,
Tools::Read,
Tools::ListCategories,
Tools::ListTags,
]
end
def system_prompt
<<~PROMPT
def system_prompt
<<~PROMPT
You are a helpful Discourse assistant.
You _understand_ and **generate** Discourse Markdown.
You live in a Discourse Forum Message.
@ -27,7 +26,6 @@ module DiscourseAi
The participants in this conversation are: {participants}
The date now is: {time}, much has changed since you were trained.
PROMPT
end
end
end
end

View File

@ -1,20 +1,19 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class GithubHelper < Persona
def tools
[
Tools::GithubFileContent,
Tools::GithubPullRequestDiff,
Tools::GithubSearchCode,
Tools::GithubSearchFiles,
]
end
module Personas
class GithubHelper < Persona
def tools
[
Tools::GithubFileContent,
Tools::GithubPullRequestDiff,
Tools::GithubSearchCode,
Tools::GithubSearchFiles,
]
end
def system_prompt
<<~PROMPT
def system_prompt
<<~PROMPT
You are a helpful GitHub assistant.
You _understand_ and **generate** Discourse Flavored Markdown.
You live in a Discourse Forum Message.
@ -22,7 +21,6 @@ module DiscourseAi
Your purpose is to assist users with GitHub-related tasks and questions.
When asked about a specific repository, pull request, or file, try to use the available tools to provide accurate and helpful information.
PROMPT
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 ||= {
General => -1,
SqlHelper => -2,
Artist => -3,
SettingsExplorer => -4,
Researcher => -5,
Creative => -6,
DallE3 => -7,
DiscourseHelper => -8,
GithubHelper => -9,
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.lookup_template_param(match[1..-2])
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.messages,
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.messages,
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::Personas::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,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class QuestionConsolidator
attr_reader :llm, :messages, :user, :max_tokens

View File

@ -1,19 +1,18 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Researcher < Persona
def tools
[Tools::Google, Tools::WebBrowser]
end
module Personas
class Researcher < Persona
def tools
[Tools::Google, Tools::WebBrowser]
end
def required_tools
[Tools::Google]
end
def required_tools
[Tools::Google]
end
def system_prompt
<<~PROMPT
def system_prompt
<<~PROMPT
You are a research assistant with access to two powerful tools:
1. Google search - for finding relevant information across the internet.
@ -45,7 +44,6 @@ module DiscourseAi
Remember, efficient use of your tools not only saves time but also ensures the high quality and relevance of the information provided.
PROMPT
end
end
end
end

View File

@ -1,15 +1,14 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class SettingsExplorer < Persona
def tools
[Tools::SettingContext, Tools::SearchSettings]
end
module Personas
class SettingsExplorer < Persona
def tools
[Tools::SettingContext, Tools::SearchSettings]
end
def system_prompt
<<~PROMPT
def system_prompt
<<~PROMPT
You are Discourse Site settings bot.
- You are able to find information about all the site settings.
@ -19,7 +18,6 @@ module DiscourseAi
Current time is: {time}
PROMPT
end
end
end
end

View File

@ -1,52 +1,50 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class SqlHelper < Persona
def self.schema
return @schema if defined?(@schema)
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
user_emails
categories
groups
]
tables = Hash.new
priority_tables = %w[
posts
topics
notifications
users
user_actions
user_emails
categories
groups
]
DB.query(<<~SQL).each { |row| (tables[row.table_name] ||= []) << row.column_name }
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
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, _|
next if priority_tables.include?(table_name)
other_tables << "#{table_name} "
end
@schema = { priority_tables: priority, other_tables: other_tables }
other_tables = +""
tables.each do |table_name, _|
next if priority_tables.include?(table_name)
other_tables << "#{table_name} "
end
def tools
[Tools::DbSchema]
end
@schema = { priority_tables: priority, other_tables: other_tables }
end
def temperature
0.2
end
def tools
[Tools::DbSchema]
end
def system_prompt
<<~PROMPT
def temperature
0.2
end
def system_prompt
<<~PROMPT
You are a PostgreSQL expert.
- Avoid returning any text to the user prior to a tool call.
- You understand and generate Discourse Markdown but specialize in creating queries.
@ -100,7 +98,6 @@ module DiscourseAi
NEVER look up schema for the tables listed above, as their full schema is already provided.
PROMPT
end
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class ToolRunner
attr_reader :tool, :parameters, :llm
attr_accessor :running_attached_function, :timeout, :custom_raw
@ -14,11 +14,11 @@ module DiscourseAi
MAX_HTTP_REQUESTS = 20
def initialize(parameters:, llm:, bot_user:, context: nil, tool:, timeout: nil)
if context && !context.is_a?(DiscourseAi::AiBot::BotContext)
if context && !context.is_a?(DiscourseAi::Personas::BotContext)
raise ArgumentError, "context must be a BotContext object"
end
context ||= DiscourseAi::AiBot::BotContext.new
context ||= DiscourseAi::Personas::BotContext.new
@parameters = parameters
@llm = llm
@ -339,7 +339,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
@ -488,7 +488,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|
@ -517,7 +517,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

@ -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?
@ -63,9 +63,9 @@ module DiscourseAi
@persona_options = persona_options
@bot_user = bot_user
@llm = llm
@context = context.nil? ? DiscourseAi::AiBot::BotContext.new(messages: []) : context
if !@context.is_a?(DiscourseAi::AiBot::BotContext)
raise ArgumentError, "context must be a DiscourseAi::AiBot::Context"
@context = context.nil? ? DiscourseAi::Personas::BotContext.new(messages: []) : context
if !@context.is_a?(DiscourseAi::Personas::BotContext)
raise ArgumentError, "context must be a DiscourseAi::Personas::Context"
end
end

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,19 +1,18 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class WebArtifactCreator < Persona
def tools
[Tools::CreateArtifact, Tools::UpdateArtifact, Tools::ReadArtifact]
end
module Personas
class WebArtifactCreator < Persona
def tools
[Tools::CreateArtifact, Tools::UpdateArtifact, Tools::ReadArtifact]
end
def required_tools
[Tools::CreateArtifact, Tools::UpdateArtifact, Tools::ReadArtifact]
end
def required_tools
[Tools::CreateArtifact, Tools::UpdateArtifact, Tools::ReadArtifact]
end
def system_prompt
<<~PROMPT
def system_prompt
<<~PROMPT
You are the Web Creator, an AI assistant specializing in building interactive web components. You create engaging and functional web experiences using HTML, CSS, and JavaScript. You live in a Discourse PM and communicate using Markdown.
Core Principles:
@ -49,7 +48,6 @@ module DiscourseAi
Remember: Great components combine structure (HTML), presentation (CSS), and behavior (JavaScript) to create memorable user experiences.
PROMPT
end
end
end
end

View File

@ -83,6 +83,8 @@ after_initialize do
add_admin_route("discourse_ai.title", "discourse-ai", { use_new_show_route: true })
register_seedfu_fixtures(Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "personas"))
[
DiscourseAi::Embeddings::EntryPoint.new,
DiscourseAi::Sentiment::EntryPoint.new,

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,8 +169,8 @@ 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)
playground = DiscourseAi::AiBot::Playground.new(bot)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona_klass.new)
playground = described_class.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]
@ -968,7 +964,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 =
@ -1021,13 +1017,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,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::Bot do
RSpec.describe DiscourseAi::Personas::Bot do
subject(:bot) { described_class.as(bot_user) }
fab!(:admin)
@ -48,11 +48,11 @@ 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 = described_class.as(bot_user, persona: personaClass.new)
bot.reply(
DiscourseAi::AiBot::BotContext.new(messages: [{ type: :user, content: "test" }]),
DiscourseAi::Personas::BotContext.new(messages: [{ type: :user, content: "test" }]),
) do |_partial, _cancel, _placeholder|
# we just need the block so bot has something to call with results
end
@ -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>
@ -75,7 +75,7 @@ RSpec.describe DiscourseAi::AiBot::Bot do
HTML
context =
DiscourseAi::AiBot::BotContext.new(
DiscourseAi::Personas::BotContext.new(
messages: [{ type: :user, content: "Does my site has tags?" }],
)

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
@ -36,7 +36,7 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
end
let(:context) do
DiscourseAi::AiBot::BotContext.new(
DiscourseAi::Personas::BotContext.new(
site_url: Discourse.base_url,
site_title: "test site title",
site_description: "test site description",
@ -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.messages = [{ content: "Tell me the time", type: :user }]
@ -343,7 +316,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
context.messages = [
@ -415,7 +388,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,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::QuestionConsolidator do
RSpec.describe DiscourseAi::Personas::QuestionConsolidator do
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{Fabricate(:fake_model).id}") }
let(:fake_endpoint) { DiscourseAi::Completions::Endpoints::Fake }

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)
@ -34,7 +34,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::CreateArtifact do
{ html_body: "hello" },
bot_user: Fabricate(:user),
llm: llm,
context: DiscourseAi::AiBot::BotContext.new(post: post),
context: DiscourseAi::Personas::BotContext.new(post: post),
)
tool.parameters = { name: "hello", specification: "hello spec" }

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"] }
@ -9,7 +9,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::Image do
{ prompts: prompts, seeds: [99, 32] },
bot_user: bot_user,
llm: llm,
context: DiscourseAi::AiBot::BotContext.new,
context: DiscourseAi::Personas::BotContext.new,
)
end

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)
@ -25,7 +25,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::ReadArtifact do
{ url: "#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/#{artifact.id}" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(post: post),
context: DiscourseAi::Personas::BotContext.new(post: post),
)
result = tool.invoke {}
@ -44,7 +44,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::ReadArtifact do
{ url: "invalid-url" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(post: post),
context: DiscourseAi::Personas::BotContext.new(post: post),
)
result = tool.invoke {}
@ -58,7 +58,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::ReadArtifact do
{ url: "#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/99999" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(post: post),
context: DiscourseAi::Personas::BotContext.new(post: post),
)
result = tool.invoke {}
@ -91,7 +91,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::ReadArtifact do
{ url: "https://example.com" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(post: post),
context: DiscourseAi::Personas::BotContext.new(post: post),
)
result = tool.invoke {}
@ -120,7 +120,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::ReadArtifact do
{ url: "https://example.com" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(post: post),
context: DiscourseAi::Personas::BotContext.new(post: post),
)
result = tool.invoke {}

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}") }
@ -56,7 +56,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::Read do
persona_options: {
"read_private" => true,
},
context: DiscourseAi::AiBot::BotContext.new(user: admin),
context: DiscourseAi::Personas::BotContext.new(user: admin),
)
results = tool.invoke
expect(results[:content]).to include("hello there")
@ -66,7 +66,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::Read do
{ topic_id: topic_with_tags.id, post_numbers: [post1.post_number] },
bot_user: bot_user,
llm: llm,
context: DiscourseAi::AiBot::BotContext.new(user: admin),
context: DiscourseAi::Personas::BotContext.new(user: admin),
)
results = tool.invoke

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 }
@ -60,7 +60,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::Search do
persona_options: persona_options,
bot_user: bot_user,
llm: llm,
context: DiscourseAi::AiBot::BotContext.new(user: user),
context: DiscourseAi::Personas::BotContext.new(user: user),
)
expect(search.options[:base_query]).to eq("#funny")

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)
@ -47,7 +47,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
persona_options: {
"update_algorithm" => "full",
},
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
)
result = tool.invoke {}
@ -91,7 +91,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
persona_options: {
"update_algorithm" => "full",
},
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
)
result = tool.invoke {}
@ -115,7 +115,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
{ artifact_id: artifact.id, instructions: "Invalid update" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
)
result = tool.invoke {}
@ -129,7 +129,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
{ artifact_id: -1, instructions: "Update something" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
)
result = tool.invoke {}
@ -155,7 +155,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
persona_options: {
"update_algorithm" => "full",
},
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
)
tool.invoke {}
@ -186,7 +186,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
persona_options: {
"update_algorithm" => "full",
},
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
)
.invoke {}
end
@ -212,7 +212,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
persona_options: {
"update_algorithm" => "full",
},
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
)
result = tool.invoke {}
@ -262,7 +262,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
{ artifact_id: artifact.id, instructions: "Change the text to Updated and color to red" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
persona_options: {
"update_algorithm" => "diff",
},
@ -330,7 +330,7 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do
{ artifact_id: artifact.id, instructions: "Change the text to Updated and color to red" },
bot_user: bot_user,
llm: llm_model.to_llm,
context: DiscourseAi::AiBot::BotContext.new(messages: [], post: post),
context: DiscourseAi::Personas::BotContext.new(messages: [], post: post),
persona_options: {
"update_algorithm" => "diff",
},

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

@ -121,7 +121,7 @@ RSpec.describe AiTool do
},
)
expect { runner.invoke }.to raise_error(DiscourseAi::AiBot::ToolRunner::TooManyRequestsError)
expect { runner.invoke }.to raise_error(DiscourseAi::Personas::ToolRunner::TooManyRequestsError)
end
it "can perform GET HTTP requests" do

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

@ -55,7 +55,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)