diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb index 83708e7b..120b7c32 100644 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb @@ -12,11 +12,11 @@ module DiscourseAi # localized for system personas LocalizedAiPersonaSerializer.new(persona, root: false) end - commands = - DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command| - AiCommandSerializer.new(command, root: false) + tools = + DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool| + AiToolSerializer.new(tool, root: false) end - render json: { ai_personas: ai_personas, meta: { commands: commands } } + render json: { ai_personas: ai_personas, meta: { commands: tools } } end def show diff --git a/app/jobs/regular/create_ai_reply.rb b/app/jobs/regular/create_ai_reply.rb index 00837454..c634e380 100644 --- a/app/jobs/regular/create_ai_reply.rb +++ b/app/jobs/regular/create_ai_reply.rb @@ -8,17 +8,25 @@ module ::Jobs return unless bot_user = User.find_by(id: args[:bot_user_id]) return unless post = Post.includes(:topic).find_by(id: args[:post_id]) - kwargs = {} - kwargs[:user] = post.user - if persona_id = post.topic.custom_fields["ai_persona_id"] - kwargs[:persona_id] = persona_id.to_i - else - kwargs[:persona_name] = post.topic.custom_fields["ai_persona"] - end - begin - bot = DiscourseAi::AiBot::Bot.as(bot_user, **kwargs) - bot.reply_to(post) + persona = nil + if persona_id = post.topic.custom_fields["ai_persona_id"] + persona = + DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id.to_i) + raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil? + end + + if !persona && persona_name = post.topic.custom_fields["ai_persona"] + persona = + DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, name: persona_name) + raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil? + end + + persona ||= DiscourseAi::AiBot::Personas::General + + bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new) + + DiscourseAi::AiBot::Playground.new(bot).reply_to(post) rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND Rails.logger.warn( "Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled", diff --git a/app/jobs/regular/update_ai_bot_pm_title.rb b/app/jobs/regular/update_ai_bot_pm_title.rb index 2ba7a9c3..5f181e0d 100644 --- a/app/jobs/regular/update_ai_bot_pm_title.rb +++ b/app/jobs/regular/update_ai_bot_pm_title.rb @@ -11,7 +11,7 @@ module ::Jobs return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE] - bot.update_pm_title(post) + DiscourseAi::AiBot::Playground.new(bot).title_playground(post) end end end diff --git a/app/models/ai_persona.rb b/app/models/ai_persona.rb index 206a046a..e49aff62 100644 --- a/app/models/ai_persona.rb +++ b/app/models/ai_persona.rb @@ -67,7 +67,7 @@ class AiPersona < ActiveRecord::Base id = self.id system = self.system - persona_class = DiscourseAi::AiBot::Personas.system_personas_by_id[self.id] + persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id] if persona_class persona_class.define_singleton_method :allowed_group_ids do allowed_group_ids @@ -90,8 +90,10 @@ class AiPersona < ActiveRecord::Base options = {} - commands = - self.commands.filter_map do |element| + tools = self.respond_to?(:commands) ? self.commands : self.tools + + tools = + tools.filter_map do |element| inner_name = element current_options = nil @@ -100,8 +102,12 @@ class AiPersona < ActiveRecord::Base current_options = element[1] end + # Won't migrate data yet. Let's rewrite to the tool name. + inner_name = inner_name.gsub("Command", "") + inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name) + begin - klass = ("DiscourseAi::AiBot::Commands::#{inner_name}").constantize + klass = ("DiscourseAi::AiBot::Tools::#{inner_name}").constantize options[klass] = current_options if current_options klass rescue StandardError @@ -143,8 +149,8 @@ class AiPersona < ActiveRecord::Base super(*args, **kwargs) end - define_method :commands do - commands + define_method :tools do + tools end define_method :options do diff --git a/app/serializers/ai_command_serializer.rb b/app/serializers/ai_tool_serializer.rb similarity index 77% rename from app/serializers/ai_command_serializer.rb rename to app/serializers/ai_tool_serializer.rb index 2333655f..8cb7bf72 100644 --- a/app/serializers/ai_command_serializer.rb +++ b/app/serializers/ai_tool_serializer.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -class AiCommandSerializer < ApplicationSerializer +class AiToolSerializer < ApplicationSerializer attributes :options, :id, :name, :help def include_options? - object.options.present? + object.accepted_options.present? end def id @@ -21,7 +21,7 @@ class AiCommandSerializer < ApplicationSerializer def options options = {} - object.options.each do |option| + object.accepted_options.each do |option| options[option.name] = { name: option.localized_name, description: option.localized_description, diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index b9324646..f58cb080 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -132,6 +132,8 @@ en: attribution: "Image by Stable Diffusion XL" ai_bot: + placeholder_reply: "I will reply shortly..." + personas: cannot_delete_system_persona: "System personas cannot be deleted, please disable it instead" cannot_edit_system_persona: "System personas can only be renamed, you may not edit commands or system prompt, instead disable and make a copy" @@ -157,6 +159,7 @@ en: name: "DALL-E 3" description: "AI Bot specialized in generating images using DALL-E 3" topic_not_found: "Summary unavailable, topic not found!" + summarizing: "Summarizing topic" searching: "Searching for: '%{query}'" command_options: search: diff --git a/db/fixtures/ai_bot/603_bot_ai_personas.rb b/db/fixtures/ai_bot/603_bot_ai_personas.rb index 7ddc520a..7c110dba 100644 --- a/db/fixtures/ai_bot/603_bot_ai_personas.rb +++ b/db/fixtures/ai_bot/603_bot_ai_personas.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -DiscourseAi::AiBot::Personas.system_personas.each do |persona_class, id| +DiscourseAi::AiBot::Personas::Persona.system_personas.each do |persona_class, id| persona = AiPersona.find_by(id: id) if !persona persona = AiPersona.new @@ -32,7 +32,7 @@ DiscourseAi::AiBot::Personas.system_personas.each do |persona_class, id| persona.system = true instance = persona_class.new - persona.commands = instance.commands.map { |command| command.to_s.split("::").last } + persona.commands = instance.tools.map { |tool| tool.to_s.split("::").last } persona.system_prompt = instance.system_prompt persona.save!(validate: false) end diff --git a/lib/ai_bot/anthropic_bot.rb b/lib/ai_bot/anthropic_bot.rb deleted file mode 100644 index 1549144b..00000000 --- a/lib/ai_bot/anthropic_bot.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class AnthropicBot < Bot - def self.can_reply_as?(bot_user) - bot_user.id == DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID - end - - def bot_prompt_with_topic_context(post, allow_commands:) - super(post, allow_commands: allow_commands).join("\n\n") + "\n\nAssistant:" - end - - def prompt_limit(allow_commands: true) - # no side channel for commands, so we can ignore allow commands - 50_000 # https://console.anthropic.com/docs/prompt-design#what-is-a-prompt - end - - def title_prompt(post) - super(post).join("\n\n") + "\n\nAssistant:" - end - - def get_delta(partial, context) - completion = partial[:completion] - if completion&.start_with?(" ") && !context[:processed_first] - completion = completion[1..-1] - context[:processed_first] = true - end - completion - end - - def tokenizer - DiscourseAi::Tokenizer::AnthropicTokenizer - end - - private - - def build_message(poster_username, content, system: false, function: nil) - role = poster_username == bot_user.username ? "Assistant" : "Human" - - if system || function - content - else - "#{role}: #{content}" - end - end - - def model_for - "claude-2" - end - - def get_updated_title(prompt) - DiscourseAi::Inference::AnthropicCompletions.perform!( - prompt, - model_for, - temperature: 0.4, - max_tokens: 40, - ).dig(:completion) - end - - def submit_prompt(prompt, post: nil, prefer_low_cost: false, &blk) - DiscourseAi::Inference::AnthropicCompletions.perform!( - prompt, - model_for, - temperature: 0.4, - max_tokens: 3000, - post: post, - stop_sequences: ["\n\nHuman:", ""], - &blk - ) - end - end - end -end diff --git a/lib/ai_bot/bot.rb b/lib/ai_bot/bot.rb index c6804e00..f0d4178b 100644 --- a/lib/ai_bot/bot.rb +++ b/lib/ai_bot/bot.rb @@ -3,464 +3,152 @@ module DiscourseAi module AiBot class Bot - class FunctionCalls - attr_accessor :maybe_buffer, :maybe_found, :custom - - def initialize - @functions = [] - @current_function = nil - @found = false - @cancel_completion = false - @maybe_buffer = +"" - @maybe_found = false - @custom = false - end - - def custom? - @custom - end - - def found? - !@functions.empty? || @found - end - - def found! - @found = true - end - - def maybe_found? - @maybe_found - end - - def cancel_completion? - @cancel_completion - end - - def cancel_completion! - @cancel_completion = true - end - - def add_function(name) - @current_function = { name: name, arguments: +"" } - @functions << @current_function - end - - def add_argument_fragment(fragment) - @current_function[:arguments] << fragment - end - - def length - @functions.length - end - - def each - @functions.each { |function| yield function } - end - - def to_a - @functions - end - end - - attr_reader :bot_user, :persona - BOT_NOT_FOUND = Class.new(StandardError) MAX_COMPLETIONS = 5 - def self.as(bot_user, persona_id: nil, persona_name: nil, user: nil) - available_bots = [DiscourseAi::AiBot::OpenAiBot, DiscourseAi::AiBot::AnthropicBot] - - bot = - available_bots.detect(-> { raise BOT_NOT_FOUND }) do |bot_klass| - bot_klass.can_reply_as?(bot_user) - end - - persona = nil - if persona_id - persona = DiscourseAi::AiBot::Personas.find_by(user: user, id: persona_id) - raise BOT_NOT_FOUND if persona.nil? - end - - if !persona && persona_name - persona = DiscourseAi::AiBot::Personas.find_by(user: user, name: persona_name) - raise BOT_NOT_FOUND if persona.nil? - end - - bot.new(bot_user, persona: persona&.new) + def self.as(bot_user, persona: DiscourseAi::AiBot::Personas::General.new) + new(bot_user, persona) end - def initialize(bot_user, persona: nil) + def initialize(bot_user, persona) @bot_user = bot_user - @persona = persona || DiscourseAi::AiBot::Personas::General.new + @persona = persona end - def update_pm_title(post) - prompt = title_prompt(post) + attr_reader :bot_user - new_title = get_updated_title(prompt).strip.split("\n").last - - PostRevisor.new(post.topic.first_post, post.topic).revise!( - bot_user, - title: new_title.sub(/\A"/, "").sub(/"\Z/, ""), - ) - post.topic.custom_fields.delete(DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE) - post.topic.save_custom_fields - end - - def reply_to( - post, - total_completions: 0, - bot_reply_post: nil, - prefer_low_cost: false, - standalone: false - ) - return if total_completions > MAX_COMPLETIONS - - # do not allow commands when we are at the end of chain (total completions == MAX_COMPLETIONS) - allow_commands = (total_completions < MAX_COMPLETIONS) - - prompt = - if standalone && post.post_custom_prompt - username, standalone_prompt = post.post_custom_prompt.custom_prompt.last - [build_message(username, standalone_prompt)] - else - bot_prompt_with_topic_context(post, allow_commands: allow_commands) - end - - redis_stream_key = nil - partial_reply = +"" - reply = +(bot_reply_post ? bot_reply_post.raw.dup : "") - start = Time.now - - setup_cancel = false - context = {} - functions = FunctionCalls.new - - submit_prompt(prompt, post: post, prefer_low_cost: prefer_low_cost) do |partial, cancel| - current_delta = get_delta(partial, context) - partial_reply << current_delta - - if !available_functions.empty? - populate_functions( - partial: partial, - reply: partial_reply, - functions: functions, - current_delta: current_delta, - done: false, - ) - - cancel&.call if functions.cancel_completion? - end - - if functions.maybe_buffer.present? && !functions.maybe_found? - reply << functions.maybe_buffer - functions.maybe_buffer = +"" - end - - reply << current_delta if !functions.found? && !functions.maybe_found? - - if redis_stream_key && !Discourse.redis.get(redis_stream_key) - cancel&.call - - bot_reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) if bot_reply_post - end - - # Minor hack to skip the delay during tests. - next if (Time.now - start < 0.5) && !Rails.env.test? - - if bot_reply_post - Discourse.redis.expire(redis_stream_key, 60) - start = Time.now - - publish_update(bot_reply_post, raw: reply.dup) - else - bot_reply_post = - PostCreator.create!( - bot_user, - topic_id: post.topic_id, - raw: reply, - skip_validations: true, - ) - end - - if !setup_cancel && bot_reply_post - redis_stream_key = "gpt_cancel:#{bot_reply_post.id}" - Discourse.redis.setex(redis_stream_key, 60, 1) - setup_cancel = true - end - end - - if !available_functions.empty? - populate_functions( - partial: nil, - reply: partial_reply, - current_delta: "", - functions: functions, - done: true, - ) - end - - if functions.maybe_buffer.present? - reply << functions.maybe_buffer - functions.maybe_buffer = +"" - end - - if bot_reply_post - publish_update(bot_reply_post, done: true) - - bot_reply_post.revise( - bot_user, - { raw: reply }, - skip_validations: true, - skip_revision: true, - ) - - bot_reply_post.post_custom_prompt ||= post.build_post_custom_prompt(custom_prompt: []) - prompt = post.post_custom_prompt.custom_prompt || [] - - truncated_reply = partial_reply - - # TODO: we may want to move this code - if functions.length > 0 && partial_reply.include?("") - # recover stop word potentially - truncated_reply = - partial_reply.split("").first + "\n" - end - - prompt << [truncated_reply, bot_user.username] if truncated_reply.present? - - post.post_custom_prompt.update!(custom_prompt: prompt) - end - - if functions.length > 0 - chain = false - standalone = false - - functions.each do |function| - name, args = function[:name], function[:arguments] - - if command_klass = available_commands.detect { |cmd| cmd.invoked?(name) } - command = - command_klass.new( - bot: self, - args: args, - post: bot_reply_post, - parent_post: post, - xml_format: !functions.custom?, - ) - chain_intermediate, bot_reply_post = command.invoke! - chain ||= chain_intermediate - standalone ||= command.standalone? - end - end - - if chain - reply_to( - bot_reply_post, - total_completions: total_completions + 1, - bot_reply_post: bot_reply_post, - standalone: standalone, - ) - end - end - rescue => e - if Rails.env.development? - p e - puts e.backtrace - end - raise e if Rails.env.test? - Discourse.warn_exception(e, message: "ai-bot: Reply failed") - end - - def extra_tokens_per_message - 0 - end - - def bot_prompt_with_topic_context(post, allow_commands:) - messages = [] - conversation = conversation_context(post) - - rendered_system_prompt = system_prompt(post, allow_commands: allow_commands) - total_prompt_tokens = tokenize(rendered_system_prompt).length + extra_tokens_per_message - - prompt_limit = self.prompt_limit(allow_commands: allow_commands) - - conversation.each do |raw, username, function| - break if total_prompt_tokens >= prompt_limit - - tokens = tokenize(raw.to_s + username.to_s) - - while !raw.blank? && - tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit - raw = raw[0..-100] || "" - tokens = tokenize(raw.to_s + username.to_s) - end - - next if raw.blank? - - total_prompt_tokens += tokens.length + extra_tokens_per_message - messages.unshift(build_message(username, raw, function: !!function)) - end - - messages.unshift(build_message(bot_user.username, rendered_system_prompt, system: true)) - - messages - end - - def prompt_limit(allow_commands: false) - raise NotImplemented - end - - def title_prompt(post) - prompt = <<~TEXT - You are titlebot. Given a topic you will figure out a title. - You will never respond with anything but a 7 word topic title. + def get_updated_title(conversation_context, post_user) + title_prompt = { insts: <<~TEXT, conversation_context: conversation_context } + You are titlebot. Given a topic, you will figure out a title. + You will never respond with anything but 7 word topic title. TEXT - messages = [build_message(bot_user.username, prompt, system: true)] - messages << build_message("User", <<~TEXT) - Suggest a 7 word title for the following topic without quoting any of it: + title_prompt[ + :input + ] = "Based on our previous conversation, suggest a 7 word title without quoting any of it." - - #{post.topic.posts.map(&:raw).join("\n\n")[0..prompt_limit(allow_commands: false)]} - - TEXT - messages + DiscourseAi::Completions::Llm + .proxy(model) + .generate(title_prompt, user: post_user) + .strip + .split("\n") + .last end - def available_commands - @persona.available_commands - end + def reply(context, &update_blk) + prompt = persona.craft_prompt(context) - def system_prompt_style!(style) - @style = style - end + total_completions = 0 + ongoing_chain = true + low_cost = false + raw_context = [] - def system_prompt(post, allow_commands:) - return "You are a helpful Bot" if @style == :simple + while total_completions <= MAX_COMPLETIONS && ongoing_chain + current_model = model(prefer_low_cost: low_cost) + llm = DiscourseAi::Completions::Llm.proxy(current_model) + tool_found = false - @persona.render_system_prompt( - topic: post.topic, - allow_commands: allow_commands, - render_function_instructions: - allow_commands && include_function_instructions_in_system_prompt?, - ) - end + llm.generate(prompt, user: context[:user]) do |partial, cancel| + if (tool = persona.find_tool(partial)) + tool_found = true + ongoing_chain = tool.chain_next_response? + low_cost = tool.low_cost? + tool_call_id = tool.tool_call_id + invocation_result_json = invoke_tool(tool, llm, cancel, &update_blk).to_json - def include_function_instructions_in_system_prompt? - true - end + invocation_context = { + type: "tool", + name: tool_call_id, + content: invocation_result_json, + } + tool_context = { + type: "tool_call", + name: tool_call_id, + content: { name: tool.name, arguments: tool.parameters }.to_json, + } - def function_list - @persona.function_list - end + prompt[:conversation_context] ||= [] - def tokenizer - raise NotImplemented - end - - def tokenize(text) - tokenizer.tokenize(text) - end - - def submit_prompt( - prompt, - post:, - prefer_low_cost: false, - temperature: nil, - max_tokens: nil, - &blk - ) - raise NotImplemented - end - - def get_delta(partial, context) - raise NotImplemented - end - - def populate_functions(partial:, reply:, functions:, done:, current_delta:) - if !done - search_length = "".length - index = -1 - while index > -search_length - substr = reply[index..-1] || reply - index -= 1 - - functions.maybe_found = "".start_with?(substr) - break if functions.maybe_found? - end - - functions.maybe_buffer << current_delta if functions.maybe_found? - functions.found! if reply.match?(/^/i) - if functions.found? - functions.maybe_buffer = functions.maybe_buffer.to_s.split("<")[0..-2].join("<") - functions.cancel_completion! if reply.match?(%r{}i) - end - else - functions_string = reply.scan(%r{((.*?))}im)&.first&.first - if functions_string - function_list - .parse_prompt(functions_string + "") - .each do |function| - functions.add_function(function[:name]) - functions.add_argument_fragment(function[:arguments].to_json) + if tool.standalone? + prompt[:conversation_context] = [invocation_context, tool_context] + else + prompt[:conversation_context] = [invocation_context, tool_context] + + prompt[:conversation_context] end - end - end - end - def available_functions - @persona.available_functions - end - - protected - - def get_updated_title(prompt) - raise NotImplemented - end - - def model_for(bot) - raise NotImplemented - end - - def conversation_context(post) - context = - post - .topic - .posts - .includes(:user) - .joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id") - .where("post_number <= ?", post.post_number) - .order("post_number desc") - .where("post_type = ?", Post.types[:regular]) - .limit(50) - .pluck(:raw, :username, "post_custom_prompts.custom_prompt") - - result = [] - - first = true - context.each do |raw, username, custom_prompt| - if custom_prompt.present? - if first - custom_prompt.reverse_each { |message| result << message } - first = false + raw_context << [tool_context[:content], tool_call_id, "tool_call"] + raw_context << [invocation_result_json, tool_call_id, "tool"] else - result << custom_prompt.first + update_blk.call(partial, cancel, nil) end - else - result << [raw, username] end + + ongoing_chain = false if !tool_found + total_completions += 1 + + # do not allow tools when we are at the end of a chain (total_completions == MAX_COMPLETIONS) + prompt.delete(:tools) if total_completions == MAX_COMPLETIONS end + raw_context + end + + private + + attr_reader :persona + + def invoke_tool(tool, llm, cancel, &update_blk) + update_blk.call("", cancel, build_placeholder(tool.summary, "")) + + result = + tool.invoke(bot_user, llm) do |progress| + placeholder = build_placeholder(tool.summary, progress) + update_blk.call("", cancel, placeholder) + end + + tool_details = build_placeholder(tool.summary, tool.details, custom_raw: tool.custom_raw) + update_blk.call(tool_details, cancel, nil) + result end - def publish_update(bot_reply_post, payload) - MessageBus.publish( - "discourse-ai/ai-bot/topic/#{bot_reply_post.topic_id}", - payload.merge(post_id: bot_reply_post.id, post_number: bot_reply_post.post_number), - user_ids: bot_reply_post.topic.allowed_user_ids, - ) + def model(prefer_low_cost: false) + default_model = + case bot_user.id + when DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID + "claude-2" + when DiscourseAi::AiBot::EntryPoint::GPT4_ID + "gpt-4" + when DiscourseAi::AiBot::EntryPoint::GPT4_TURBO_ID + "gpt-4-turbo" + when DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID + "gpt-3.5-turbo-16k" + else + nil + end + + if %w[gpt-4 gpt-4-turbo].include?(default_model) && prefer_low_cost + return "gpt-3.5-turbo-16k" + end + + default_model + end + + def tool_invocation?(partial) + Nokogiri::HTML5.fragment(partial).at("invoke").present? + end + + def build_placeholder(summary, details, custom_raw: nil) + placeholder = +(<<~HTML) +
+ #{summary} +

#{details}

+
+ HTML + + placeholder << custom_raw << "\n" if custom_raw + + placeholder end end end diff --git a/lib/ai_bot/commands/categories_command.rb b/lib/ai_bot/commands/categories_command.rb deleted file mode 100644 index c796b6d3..00000000 --- a/lib/ai_bot/commands/categories_command.rb +++ /dev/null @@ -1,45 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class CategoriesCommand < Command - class << self - def name - "categories" - end - - def desc - "Will list the categories on the current discourse instance, prefer to format with # in front of the category name" - end - - def parameters - [] - end - end - - def result_name - "results" - end - - def description_args - { count: @last_count || 0 } - end - - def process - columns = { - name: "Name", - slug: "Slug", - description: "Description", - posts_year: "Posts Year", - posts_month: "Posts Month", - posts_week: "Posts Week", - id: "id", - parent_category_id: "parent_category_id", - } - - rows = Category.where(read_restricted: false).limit(100).pluck(*columns.keys) - @last_count = rows.length - - format_results(rows, columns.values) - end - end -end diff --git a/lib/ai_bot/commands/command.rb b/lib/ai_bot/commands/command.rb deleted file mode 100644 index 1fa98417..00000000 --- a/lib/ai_bot/commands/command.rb +++ /dev/null @@ -1,237 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module AiBot - module Commands - class Command - CARET = "" - PROGRESS_CARET = "" - - class << self - def name - raise NotImplemented - end - - def invoked?(cmd_name) - cmd_name == name - end - - def desc - raise NotImplemented - end - - def custom_system_message - end - - def parameters - raise NotImplemented - end - - def options - [] - end - - def help - I18n.t("discourse_ai.ai_bot.command_help.#{name}") - end - - def option(name, type:) - Option.new(command: self, name: name, type: type) - end - end - - attr_reader :bot_user, :bot - - def initialize(bot:, args:, post: nil, parent_post: nil, xml_format: false) - @bot = bot - @bot_user = bot&.bot_user - @args = args - @post = post - @parent_post = parent_post - @xml_format = xml_format - - @placeholder = +(<<~HTML).strip -
- #{I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")} -

- #{CARET} -

-
- #{PROGRESS_CARET} - HTML - - @invoked = false - end - - def persona_options - return @persona_options if @persona_options - - @persona_options = HashWithIndifferentAccess.new - - # during tests we may operate without a bot - return @persona_options if !self.bot - - self.class.options.each do |option| - val = self.bot.persona.options.dig(self.class, option.name) - @persona_options[option.name] = val if val - end - - @persona_options - end - - def tokenizer - bot.tokenizer - end - - def standalone? - false - end - - def low_cost? - false - end - - def result_name - raise NotImplemented - end - - def name - raise NotImplemented - end - - def process(post) - raise NotImplemented - end - - def description_args - {} - end - - def custom_raw - end - - def chain_next_response - true - end - - def show_progress(text, progress_caret: false) - return if !@post - return if !@placeholder - - # during tests we may have none - caret = progress_caret ? PROGRESS_CARET : CARET - new_placeholder = @placeholder.sub(caret, text + caret) - raw = @post.raw.sub(@placeholder, new_placeholder) - @placeholder = new_placeholder - - @post.revise(bot_user, { raw: raw }, skip_validations: true, skip_revision: true) - end - - def localized_description - I18n.t( - "discourse_ai.ai_bot.command_description.#{self.class.name}", - self.description_args, - ) - end - - def invoke! - raise StandardError.new("Command can only be invoked once!") if @invoked - - @invoked = true - - if !@post - @post = - PostCreator.create!( - bot_user, - raw: @placeholder, - topic_id: @parent_post.topic_id, - skip_validations: true, - skip_rate_limiter: true, - ) - else - @post.revise( - bot_user, - { raw: @post.raw + "\n\n" + @placeholder + "\n\n" }, - skip_validations: true, - skip_revision: true, - ) - end - - @post.post_custom_prompt ||= @post.build_post_custom_prompt(custom_prompt: []) - prompt = @post.post_custom_prompt.custom_prompt || [] - - parsed_args = JSON.parse(@args).symbolize_keys - - function_results = process(**parsed_args).to_json - function_results = <<~XML if @xml_format - - - #{self.class.name} - - #{function_results} - - - - XML - prompt << [function_results, self.class.name, "function"] - @post.post_custom_prompt.update!(custom_prompt: prompt) - - raw = +(<<~HTML) -
- #{I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")} -

- #{localized_description} -

-
- - HTML - - raw << custom_raw if custom_raw.present? - - raw = @post.raw.sub(@placeholder, raw) - - @post.revise(bot_user, { raw: raw }, skip_validations: true, skip_revision: true) - - if chain_next_response - # somewhat annoying but whitespace was stripped in revise - # so we need to save again - @post.raw = raw - @post.save!(validate: false) - end - - [chain_next_response, @post] - end - - def format_results(rows, column_names = nil, args: nil) - rows = rows&.map { |row| yield row } if block_given? - - if !column_names - index = -1 - column_indexes = {} - - rows = - rows&.map do |data| - new_row = [] - data.each do |key, value| - found_index = column_indexes[key.to_s] ||= (index += 1) - new_row[found_index] = value - end - new_row - end - column_names = column_indexes.keys - end - - # this is not the most efficient format - # however this is needed cause GPT 3.5 / 4 was steered using JSON - result = { column_names: column_names, rows: rows } - result[:args] = args if args - result - end - - protected - - attr_reader :bot_user, :args - end - end - end -end diff --git a/lib/ai_bot/commands/dall_e_command.rb b/lib/ai_bot/commands/dall_e_command.rb deleted file mode 100644 index 8d92398a..00000000 --- a/lib/ai_bot/commands/dall_e_command.rb +++ /dev/null @@ -1,122 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class DallECommand < Command - class << self - def name - "dall_e" - end - - def desc - "Renders images from supplied descriptions" - end - - def parameters - [ - Parameter.new( - name: "prompts", - description: - "The prompts used to generate or create or draw the image (5000 chars or less, be creative) up to 4 prompts", - type: "array", - item_type: "string", - required: true, - ), - ] - end - end - - def result_name - "results" - end - - def description_args - { prompt: @last_prompt } - end - - def chain_next_response - false - end - - def custom_raw - @custom_raw - end - - def process(prompts:) - # max 4 prompts - prompts = prompts.take(4) - - @last_prompt = prompts[0] - - show_progress(localized_description) - - results = nil - - # this ensures multisite safety since background threads - # generate the images - api_key = SiteSetting.ai_openai_api_key - api_url = SiteSetting.ai_openai_dall_e_3_url - - threads = [] - prompts.each_with_index do |prompt, index| - threads << Thread.new(prompt) do |inner_prompt| - attempts = 0 - begin - DiscourseAi::Inference::OpenAiImageGenerator.perform!( - inner_prompt, - api_key: api_key, - api_url: api_url, - ) - rescue => e - attempts += 1 - sleep 2 - retry if attempts < 3 - Discourse.warn_exception(e, message: "Failed to generate image for prompt #{prompt}") - nil - end - end - end - - while true - show_progress(".", progress_caret: true) - break if threads.all? { |t| t.join(2) } - end - - results = threads.filter_map(&:value) - - if results.blank? - return { prompts: prompts, error: "Something went wrong, could not generate image" } - end - - uploads = [] - - results.each_with_index do |result, index| - result[:data].each do |image| - Tempfile.create("v1_txt2img_#{index}.png") do |file| - file.binmode - file.write(Base64.decode64(image[:b64_json])) - file.rewind - uploads << { - prompt: image[:revised_prompt], - upload: UploadCreator.new(file, "image.png").create_for(bot_user.id), - } - end - end - end - - @custom_raw = <<~RAW - - [grid] - #{ - uploads - .map do |item| - "![#{item[:prompt].gsub(/\|\'\"/, "")}|512x512, 50%](#{item[:upload].short_url})" - end - .join(" ") - } - [/grid] - RAW - - { prompts: uploads.map { |item| item[:prompt] } } - end - end -end diff --git a/lib/ai_bot/commands/db_schema_command.rb b/lib/ai_bot/commands/db_schema_command.rb deleted file mode 100644 index 96831ae8..00000000 --- a/lib/ai_bot/commands/db_schema_command.rb +++ /dev/null @@ -1,54 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class DbSchemaCommand < Command - class << self - def name - "schema" - end - - def desc - "Will load schema information for specific tables in the database" - end - - def parameters - [ - Parameter.new( - name: "tables", - description: - "list of tables to load schema information for, comma seperated list eg: (users,posts))", - type: "string", - required: true, - ), - ] - end - end - - def result_name - "results" - end - - def description_args - { tables: @tables.join(", ") } - end - - def process(tables:) - @tables = tables.split(",").map(&:strip) - - table_info = {} - DB - .query(<<~SQL, @tables) - select table_name, column_name, data_type from information_schema.columns - where table_schema = 'public' - and table_name in (?) - order by table_name - SQL - .each { |row| (table_info[row.table_name] ||= []) << "#{row.column_name} #{row.data_type}" } - - schema_info = - table_info.map { |table_name, columns| "#{table_name}(#{columns.join(",")})" }.join("\n") - - { schema_info: schema_info, tables: tables } - end - end -end diff --git a/lib/ai_bot/commands/google_command.rb b/lib/ai_bot/commands/google_command.rb deleted file mode 100644 index 7c829034..00000000 --- a/lib/ai_bot/commands/google_command.rb +++ /dev/null @@ -1,82 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class GoogleCommand < Command - class << self - def name - "google" - end - - def desc - "Will search using Google - global internet search (supports all Google search operators)" - end - - def parameters - [ - Parameter.new( - name: "query", - description: "The search query", - type: "string", - required: true, - ), - ] - end - - def custom_system_message - "You were trained on OLD data, lean on search to get up to date information from the web" - end - end - - def result_name - "results" - end - - def description_args - { - count: @last_num_results || 0, - query: @last_query || "", - url: "https://google.com/search?q=#{CGI.escape(@last_query || "")}", - } - end - - def process(query:) - @last_query = query - - show_progress(localized_description) - - api_key = SiteSetting.ai_google_custom_search_api_key - cx = SiteSetting.ai_google_custom_search_cx - query = CGI.escape(query) - uri = - URI("https://www.googleapis.com/customsearch/v1?key=#{api_key}&cx=#{cx}&q=#{query}&num=10") - body = Net::HTTP.get(uri) - - parse_search_json(body, query) - end - - def minimize_field(result, field, max_tokens: 100) - data = result[field] - return "" if data.blank? - - data = ::DiscourseAi::Tokenizer::BertTokenizer.truncate(data, max_tokens).squish - data - end - - def parse_search_json(json_data, query) - parsed = JSON.parse(json_data) - results = parsed["items"] - - @last_num_results = parsed.dig("searchInformation", "totalResults").to_i - - format_results(results, args: query) do |result| - { - title: minimize_field(result, "title"), - link: minimize_field(result, "link"), - snippet: minimize_field(result, "snippet", max_tokens: 120), - displayLink: minimize_field(result, "displayLink"), - formattedUrl: minimize_field(result, "formattedUrl"), - } - end - end - end -end diff --git a/lib/ai_bot/commands/image_command.rb b/lib/ai_bot/commands/image_command.rb deleted file mode 100644 index cedf9208..00000000 --- a/lib/ai_bot/commands/image_command.rb +++ /dev/null @@ -1,135 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class ImageCommand < Command - class << self - def name - "image" - end - - def desc - "Renders an image from the description (remove all connector words, keep it to 40 words or less). Despite being a text based bot you can generate images! (when user asks to draw, paint or other synonyms try this)" - end - - def parameters - [ - Parameter.new( - name: "prompts", - description: - "The prompts used to generate or create or draw the image (40 words or less, be creative) up to 4 prompts", - type: "array", - item_type: "string", - required: true, - ), - Parameter.new( - name: "seeds", - description: - "The seed used to generate the image (optional) - can be used to retain image style on amended prompts", - type: "array", - item_type: "integer", - ), - ] - end - end - - def result_name - "results" - end - - def description_args - { prompt: @last_prompt } - end - - def chain_next_response - false - end - - def custom_raw - @custom_raw - end - - def process(prompts:, seeds: nil) - # max 4 prompts - prompts = prompts[0..3] - seeds = seeds[0..3] if seeds - - @last_prompt = prompts[0] - - show_progress(localized_description) - - results = nil - - # this ensures multisite safety since background threads - # generate the images - api_key = SiteSetting.ai_stability_api_key - engine = SiteSetting.ai_stability_engine - api_url = SiteSetting.ai_stability_api_url - - threads = [] - prompts.each_with_index do |prompt, index| - seed = seeds ? seeds[index] : nil - threads << Thread.new(seed, prompt) do |inner_seed, inner_prompt| - attempts = 0 - begin - DiscourseAi::Inference::StabilityGenerator.perform!( - inner_prompt, - engine: engine, - api_key: api_key, - api_url: api_url, - image_count: 1, - seed: inner_seed, - ) - rescue => e - attempts += 1 - retry if attempts < 3 - Rails.logger.warn("Failed to generate image for prompt #{prompt}: #{e}") - nil - end - end - end - - while true - show_progress(".", progress_caret: true) - break if threads.all? { |t| t.join(2) } - end - - results = threads.map(&:value).compact - - if !results.present? - return { prompts: prompts, error: "Something went wrong, could not generate image" } - end - - uploads = [] - - results.each_with_index do |result, index| - result[:artifacts].each do |image| - Tempfile.create("v1_txt2img_#{index}.png") do |file| - file.binmode - file.write(Base64.decode64(image[:base64])) - file.rewind - uploads << { - prompt: prompts[index], - upload: UploadCreator.new(file, "image.png").create_for(bot_user.id), - seed: image[:seed], - } - end - end - end - - @custom_raw = <<~RAW - - [grid] - #{ - uploads - .map do |item| - "![#{item[:prompt].gsub(/\|\'\"/, "")}|512x512, 50%](#{item[:upload].short_url})" - end - .join(" ") - } - [/grid] - RAW - - { prompts: uploads.map { |item| item[:prompt] }, seeds: uploads.map { |item| item[:seed] } } - end - end -end diff --git a/lib/ai_bot/commands/option.rb b/lib/ai_bot/commands/option.rb deleted file mode 100644 index 4705dcba..00000000 --- a/lib/ai_bot/commands/option.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module AiBot - module Commands - class Option - attr_reader :command, :name, :type - def initialize(command:, name:, type:) - @command = command - @name = name.to_s - @type = type - end - - def localized_name - I18n.t("discourse_ai.ai_bot.command_options.#{command.name}.#{name}.name") - end - - def localized_description - I18n.t("discourse_ai.ai_bot.command_options.#{command.name}.#{name}.description") - end - end - end - end -end diff --git a/lib/ai_bot/commands/parameter.rb b/lib/ai_bot/commands/parameter.rb deleted file mode 100644 index b4a22a55..00000000 --- a/lib/ai_bot/commands/parameter.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module AiBot - module Commands - class Parameter - attr_reader :item_type, :name, :description, :type, :enum, :required - def initialize(name:, description:, type:, enum: nil, required: false, item_type: nil) - @name = name - @description = description - @type = type - @enum = enum - @required = required - @item_type = item_type - end - end - end - end -end diff --git a/lib/ai_bot/commands/read_command.rb b/lib/ai_bot/commands/read_command.rb deleted file mode 100644 index b8025b51..00000000 --- a/lib/ai_bot/commands/read_command.rb +++ /dev/null @@ -1,77 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class ReadCommand < Command - class << self - def name - "read" - end - - def desc - "Will read a topic or a post on this Discourse instance" - end - - def parameters - [ - Parameter.new( - name: "topic_id", - description: "the id of the topic to read", - type: "integer", - required: true, - ), - Parameter.new( - name: "post_number", - description: "the post number to read", - type: "integer", - required: false, - ), - ] - end - end - - def description_args - { title: @title, url: @url } - end - - def process(topic_id:, post_number: nil) - not_found = { topic_id: topic_id, description: "Topic not found" } - - @title = "" - - topic_id = topic_id.to_i - - topic = Topic.find_by(id: topic_id) - return not_found if !topic || !Guardian.new.can_see?(topic) - - @title = topic.title - - posts = Post.secured(Guardian.new).where(topic_id: topic_id).order(:post_number).limit(40) - @url = topic.relative_url(post_number) - - posts = posts.where("post_number = ?", post_number) if post_number - - content = +<<~TEXT.strip - title: #{topic.title} - TEXT - - category_names = [topic.category&.parent_category&.name, topic.category&.name].compact.join( - " ", - ) - content << "\ncategories: #{category_names}" if category_names.present? - - if topic.tags.length > 0 - tags = DiscourseTagging.filter_visible(topic.tags, Guardian.new) - content << "\ntags: #{tags.map(&:name).join(", ")}\n\n" if tags.length > 0 - end - - posts.each { |post| content << "\n\n#{post.username} said:\n\n#{post.raw}" } - - # TODO: 16k or 100k models can handle a lot more tokens - content = tokenizer.truncate(content, 1500).squish - - result = { topic_id: topic_id, content: content, complete: true } - result[:post_number] = post_number if post_number - result - end - end -end diff --git a/lib/ai_bot/commands/search_command.rb b/lib/ai_bot/commands/search_command.rb deleted file mode 100644 index f2bfd8af..00000000 --- a/lib/ai_bot/commands/search_command.rb +++ /dev/null @@ -1,232 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class SearchCommand < Command - class << self - def name - "search" - end - - def desc - "Will search topics in the current discourse instance, when rendering always prefer to link to the topics you find" - end - - def options - [option(:base_query, type: :string), option(:max_results, type: :integer)] - end - - def parameters - [ - Parameter.new( - name: "search_query", - description: - "Specific keywords to search for, space seperated (correct bad spelling, remove connector words)", - type: "string", - ), - Parameter.new( - name: "user", - description: - "Filter search results to this username (only include if user explicitly asks to filter by user)", - type: "string", - ), - Parameter.new( - name: "order", - description: "search result order", - type: "string", - enum: %w[latest latest_topic oldest views likes], - ), - Parameter.new( - name: "limit", - description: - "Number of results to return. Defaults to maximum number of results. Only set if absolutely necessary", - type: "integer", - ), - Parameter.new( - name: "max_posts", - description: - "maximum number of posts on the topics (topics where lots of people posted)", - type: "integer", - ), - Parameter.new( - name: "tags", - description: - "list of tags to search for. Use + to join with OR, use , to join with AND", - type: "string", - ), - Parameter.new( - name: "category", - description: "category name to filter to", - type: "string", - ), - Parameter.new( - name: "before", - description: "only topics created before a specific date YYYY-MM-DD", - type: "string", - ), - Parameter.new( - name: "after", - description: "only topics created after a specific date YYYY-MM-DD", - type: "string", - ), - Parameter.new( - name: "status", - description: "search for topics in a particular state", - type: "string", - enum: %w[open closed archived noreplies single_user], - ), - ] - end - - def custom_system_message - <<~TEXT - You were trained on OLD data, lean on search to get up to date information about this forum - When searching try to SIMPLIFY search terms - Discourse search joins all terms with AND. Reduce and simplify terms to find more results. - TEXT - end - end - - def result_name - "results" - end - - def description_args - { - count: @last_num_results || 0, - query: @last_query || "", - url: "#{Discourse.base_path}/search?q=#{CGI.escape(@last_query || "")}", - } - end - - MIN_SEMANTIC_RESULTS = 5 - - def max_semantic_results - max_results / 4 - end - - def max_results - return 20 if !bot - - max_results = persona_options[:max_results].to_i - return [max_results, 100].min if max_results > 0 - - if bot.prompt_limit(allow_commands: false) > 30_000 - 60 - elsif bot.prompt_limit(allow_commands: false) > 10_000 - 40 - else - 20 - end - end - - def process(**search_args) - limit = nil - - search_string = - search_args - .map do |key, value| - if key == :search_query - value - elsif key == :limit - limit = value.to_i - nil - else - "#{key}:#{value}" - end - end - .compact - .join(" ") - - @last_query = search_string - - show_progress(I18n.t("discourse_ai.ai_bot.searching", query: search_string)) - - if persona_options[:base_query].present? - search_string = "#{search_string} #{persona_options[:base_query]}" - end - - results = - Search.execute( - search_string.to_s + " status:public", - search_type: :full_page, - guardian: Guardian.new(), - ) - - # let's be frugal with tokens, 50 results is too much and stuff gets cut off - limit ||= max_results - limit = max_results if limit > max_results - - should_try_semantic_search = SiteSetting.ai_embeddings_semantic_search_enabled - should_try_semantic_search &&= (limit == max_results) - should_try_semantic_search &&= (search_args[:search_query].present?) - - limit = limit - max_semantic_results if should_try_semantic_search - - posts = results&.posts || [] - posts = posts[0..limit - 1] - - if should_try_semantic_search - semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(Guardian.new()) - topic_ids = Set.new(posts.map(&:topic_id)) - - search = Search.new(search_string, guardian: Guardian.new) - - results = nil - begin - results = semantic_search.search_for_topics(search.term) - rescue => e - Discourse.warn_exception(e, message: "Semantic search failed") - end - - if results - results = search.apply_filters(results) - - results.each do |post| - next if topic_ids.include?(post.topic_id) - - topic_ids << post.topic_id - posts << post - - break if posts.length >= max_results - end - end - end - - @last_num_results = posts.length - # this is the general pattern from core - # if there are millions of hidden tags it may fail - hidden_tags = nil - - if posts.blank? - { args: search_args, rows: [], instruction: "nothing was found, expand your search" } - else - format_results(posts, args: search_args) do |post| - category_names = [ - post.topic.category&.parent_category&.name, - post.topic.category&.name, - ].compact.join(" > ") - row = { - title: post.topic.title, - url: Discourse.base_path + post.url, - username: post.user&.username, - excerpt: post.excerpt, - created: post.created_at, - category: category_names, - likes: post.like_count, - topic_views: post.topic.views, - topic_likes: post.topic.like_count, - topic_replies: post.topic.posts_count - 1, - } - - if SiteSetting.tagging_enabled - hidden_tags ||= DiscourseTagging.hidden_tag_names - # using map over pluck to avoid n+1 (assuming caller preloading) - tags = post.topic.tags.map(&:name) - hidden_tags - row[:tags] = tags.join(", ") if tags.present? - end - row - end - end - end - end -end diff --git a/lib/ai_bot/commands/search_settings_command.rb b/lib/ai_bot/commands/search_settings_command.rb deleted file mode 100644 index b1c86337..00000000 --- a/lib/ai_bot/commands/search_settings_command.rb +++ /dev/null @@ -1,85 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class SearchSettingsCommand < Command - class << self - def name - "search_settings" - end - - def desc - "Will search through site settings and return top 20 results" - end - - def parameters - [ - Parameter.new( - name: "query", - description: - "comma delimited list of settings to search for (e.g. 'setting_1,setting_2')", - type: "string", - required: true, - ), - ] - end - end - - def result_name - "results" - end - - def description_args - { count: @last_num_results || 0, query: @last_query || "" } - end - - INCLUDE_DESCRIPTIONS_MAX_LENGTH = 10 - MAX_RESULTS = 200 - - def process(query:) - @last_query = query - @last_num_results = 0 - - terms = query.split(",").map(&:strip).map(&:downcase).reject(&:blank?) - - found = - SiteSetting.all_settings.filter do |setting| - name = setting[:setting].to_s.downcase - description = setting[:description].to_s.downcase - plugin = setting[:plugin].to_s.downcase - - search_string = "#{name} #{description} #{plugin}" - - terms.any? { |term| search_string.include?(term) } - end - - if found.blank? - { - args: { - query: query, - }, - rows: [], - instruction: "no settings matched #{query}, expand your search", - } - else - include_descriptions = false - - if found.length > MAX_RESULTS - found = found[0..MAX_RESULTS] - elsif found.length < INCLUDE_DESCRIPTIONS_MAX_LENGTH - include_descriptions = true - end - - @last_num_results = found.length - - format_results(found, args: { query: query }) do |setting| - result = { name: setting[:setting] } - if include_descriptions - result[:description] = setting[:description] - result[:plugin] = setting[:plugin] - end - result - end - end - end - end -end diff --git a/lib/ai_bot/commands/setting_context_command.rb b/lib/ai_bot/commands/setting_context_command.rb deleted file mode 100644 index 65dd1cca..00000000 --- a/lib/ai_bot/commands/setting_context_command.rb +++ /dev/null @@ -1,154 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - MAX_CONTEXT_TOKENS = 2000 - - class SettingContextCommand < Command - def self.rg_installed? - if defined?(@rg_installed) - @rg_installed - else - @rg_installed = - begin - Discourse::Utils.execute_command("which", "rg") - true - rescue Discourse::Utils::CommandError - false - end - end - end - - class << self - def name - "setting_context" - end - - def desc - "Will provide you with full context regarding a particular site setting in Discourse" - end - - def parameters - [ - Parameter.new( - name: "setting_name", - description: "The name of the site setting we need context for", - type: "string", - required: true, - ), - ] - end - end - - def result_name - "context" - end - - def description_args - { setting_name: @setting_name } - end - - CODE_FILE_EXTENSIONS = "rb,js,gjs,hbs" - - def process(setting_name:) - if !self.class.rg_installed? - return( - { - setting_name: setting_name, - context: "This command requires the rg command line tool to be installed on the server", - } - ) - end - - @setting_name = setting_name - if !SiteSetting.has_setting?(setting_name) - { setting_name: setting_name, context: "This setting does not exist" } - else - description = SiteSetting.description(setting_name) - result = +"# #{setting_name}\n#{description}\n\n" - - setting_info = - find_setting_info(setting_name, [Rails.root.join("config", "site_settings.yml").to_s]) - if !setting_info - setting_info = - find_setting_info(setting_name, Dir[Rails.root.join("plugins/**/settings.yml")]) - end - - result << setting_info - result << "\n\n" - - %w[lib app plugins].each do |dir| - path = Rails.root.join(dir).to_s - result << Discourse::Utils.execute_command( - "rg", - setting_name, - path, - "-g", - "!**/spec/**", - "-g", - "!**/dist/**", - "-g", - "*.{#{CODE_FILE_EXTENSIONS}}", - "-C", - "10", - "--color", - "never", - "--heading", - "--no-ignore", - chdir: path, - success_status_codes: [0, 1], - ) - end - - result.gsub!(/^#{Regexp.escape(Rails.root.to_s)}/, "") - - result = tokenizer.truncate(result, MAX_CONTEXT_TOKENS) - - { setting_name: setting_name, context: result } - end - end - - def find_setting_info(name, paths) - path, result = nil - - paths.each do |search_path| - result = - Discourse::Utils.execute_command( - "rg", - name, - search_path, - "-g", - "*.{#{CODE_FILE_EXTENSIONS}}", - "-A", - "10", - "--color", - "never", - "--heading", - success_status_codes: [0, 1], - ) - if !result.blank? - path = search_path - break - end - end - - if result.blank? - nil - else - rows = result.split("\n") - leading_spaces = rows[0].match(/^\s*/)[0].length - - filtered = [] - - rows.each do |row| - if !filtered.blank? - break if row.match(/^\s*/)[0].length <= leading_spaces - end - filtered << row - end - - filtered.unshift("#{path}") - filtered.join("\n") - end - end - end -end diff --git a/lib/ai_bot/commands/summarize_command.rb b/lib/ai_bot/commands/summarize_command.rb deleted file mode 100644 index b8fcd13b..00000000 --- a/lib/ai_bot/commands/summarize_command.rb +++ /dev/null @@ -1,184 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class SummarizeCommand < Command - class << self - def name - "summarize" - end - - def desc - "Will summarize a topic attempting to answer question in guidance" - end - - def parameters - [ - Parameter.new( - name: "topic_id", - description: "The discourse topic id to summarize", - type: "integer", - required: true, - ), - Parameter.new( - name: "guidance", - description: "Special guidance on how to summarize the topic", - type: "string", - ), - ] - end - end - - def result_name - "summary" - end - - def standalone? - true - end - - def low_cost? - true - end - - def description_args - { url: "#{Discourse.base_path}/t/-/#{@last_topic_id}", title: @last_topic_title || "" } - end - - def process(topic_id:, guidance: nil) - @last_topic_id = topic_id - - topic_id = topic_id.to_i - topic = nil - if topic_id > 0 - topic = Topic.find_by(id: topic_id) - topic = nil if !topic || !Guardian.new.can_see?(topic) - end - - @last_summary = nil - - if topic - @last_topic_title = topic.title - - posts = - Post - .where(topic_id: topic.id) - .where("post_type in (?)", [Post.types[:regular], Post.types[:small_action]]) - .where("not hidden") - .order(:post_number) - - columns = ["posts.id", :post_number, :raw, :username] - - current_post_numbers = posts.limit(5).pluck(:post_number) - current_post_numbers += posts.reorder("posts.score desc").limit(50).pluck(:post_number) - current_post_numbers += posts.reorder("post_number desc").limit(5).pluck(:post_number) - - data = - Post - .where(topic_id: topic.id) - .joins(:user) - .where("post_number in (?)", current_post_numbers) - .order(:post_number) - .pluck(*columns) - - @last_summary = summarize(data, guidance, topic) - end - - if !@last_summary - "Say: No topic found!" - else - "Topic summarized" - end - end - - def custom_raw - @last_summary || I18n.t("discourse_ai.ai_bot.topic_not_found") - end - - def chain_next_response - false - end - - def summarize(data, guidance, topic) - text = +"" - data.each do |id, post_number, raw, username| - text << "(#{post_number} #{username} said: #{raw}" - end - - summaries = [] - current_section = +"" - split = [] - - text - .split(/\s+/) - .each_slice(20) do |slice| - current_section << " " - current_section << slice.join(" ") - - # somehow any more will get closer to limits - if bot.tokenize(current_section).length > 2500 - split << current_section - current_section = +"" - end - end - - split << current_section if current_section.present? - - split = split[0..3] + split[-3..-1] if split.length > 5 - - split.each do |section| - # TODO progress meter - summary = - generate_gpt_summary( - section, - topic: topic, - context: "Guidance: #{guidance}\nYou are summarizing the topic: #{topic.title}", - ) - summaries << summary - end - - if summaries.length > 1 - messages = [] - messages << { role: "system", content: "You are a helpful bot" } - messages << { - role: "user", - content: - "concatenated the disjoint summaries, creating a cohesive narrative:\n#{summaries.join("\n")}}", - } - bot.submit_prompt(messages, temperature: 0.6, max_tokens: 500, prefer_low_cost: true).dig( - :choices, - 0, - :message, - :content, - ) - else - summaries.first - end - end - - def generate_gpt_summary(text, topic:, context: nil, length: nil) - length ||= 400 - - prompt = <<~TEXT - #{context} - Summarize the following in #{length} words: - - #{text} - TEXT - - system_prompt = <<~TEXT - You are a summarization bot. - You effectively summarise any text. - You condense it into a shorter version. - You understand and generate Discourse forum markdown. - Try generating links as well the format is #{topic.url}/POST_NUMBER. eg: [ref](#{topic.url}/77) - TEXT - - messages = [{ role: "system", content: system_prompt }] - messages << { role: "user", content: prompt } - - result = - bot.submit_prompt(messages, temperature: 0.6, max_tokens: length, prefer_low_cost: true) - result.dig(:choices, 0, :message, :content) - end - end -end diff --git a/lib/ai_bot/commands/tags_command.rb b/lib/ai_bot/commands/tags_command.rb deleted file mode 100644 index fa8381a4..00000000 --- a/lib/ai_bot/commands/tags_command.rb +++ /dev/null @@ -1,42 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class TagsCommand < Command - class << self - def name - "tags" - end - - def desc - "Will list the 100 most popular tags on the current discourse instance" - end - - def parameters - [] - end - end - - def result_name - "results" - end - - def description_args - { count: @last_count || 0 } - end - - def process - column_names = { name: "Name", public_topic_count: "Topic Count" } - - tags = - Tag - .where("public_topic_count > 0") - .order(public_topic_count: :desc) - .limit(100) - .pluck(*column_names.keys) - - @last_count = tags.length - - format_results(tags, column_names.values) - end - end -end diff --git a/lib/ai_bot/commands/time_command.rb b/lib/ai_bot/commands/time_command.rb deleted file mode 100644 index 39e2f46e..00000000 --- a/lib/ai_bot/commands/time_command.rb +++ /dev/null @@ -1,49 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi::AiBot::Commands - class TimeCommand < Command - class << self - def name - "time" - end - - def desc - "Will generate the time in a timezone" - end - - def parameters - [ - Parameter.new( - name: "timezone", - description: "ALWAYS supply a Ruby compatible timezone", - type: "string", - required: true, - ), - ] - end - end - - def result_name - "time" - end - - def description_args - { timezone: @last_timezone, time: @last_time } - end - - def process(timezone:) - time = - begin - Time.now.in_time_zone(timezone) - rescue StandardError - nil - end - time = Time.now if !time - - @last_timezone = timezone - @last_time = time.to_s - - { args: { timezone: timezone }, time: time.to_s } - end - end -end diff --git a/lib/ai_bot/entry_point.rb b/lib/ai_bot/entry_point.rb index c231927c..0567490e 100644 --- a/lib/ai_bot/entry_point.rb +++ b/lib/ai_bot/entry_point.rb @@ -50,7 +50,7 @@ module DiscourseAi scope.user.in_any_groups?(SiteSetting.ai_bot_allowed_groups_map) end, ) do - DiscourseAi::AiBot::Personas + DiscourseAi::AiBot::Personas::Persona .all(user: scope.user) .map do |persona| { id: persona.id, name: persona.name, description: persona.description } @@ -92,32 +92,19 @@ module DiscourseAi include_condition: -> { SiteSetting.ai_bot_enabled && object.topic.private_message? }, ) do id = topic.custom_fields["ai_persona_id"] - name = DiscourseAi::AiBot::Personas.find_by(user: scope.user, id: id.to_i)&.name if id + name = + DiscourseAi::AiBot::Personas::Persona.find_by(user: scope.user, id: id.to_i)&.name if id name || topic.custom_fields["ai_persona"] end plugin.on(:post_created) do |post| bot_ids = BOTS.map(&:first) - if post.post_type == Post.types[:regular] && post.topic.private_message? && - !bot_ids.include?(post.user_id) - if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).present? - bot_id = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user_id - - if bot_id - if post.post_number == 1 - post.topic.custom_fields[REQUIRE_TITLE_UPDATE] = true - post.topic.save_custom_fields - end - ::Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_id) - ::Jobs.enqueue_in( - 5.minutes, - :update_ai_bot_pm_title, - post_id: post.id, - bot_user_id: bot_id, - ) - end - end + # Don't schedule a reply for a bot reply. + if !bot_ids.include?(post.user_id) + bot_user = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user + bot = DiscourseAi::AiBot::Bot.as(bot_user) + DiscourseAi::AiBot::Playground.new(bot).update_playground_with(post) end end diff --git a/lib/ai_bot/open_ai_bot.rb b/lib/ai_bot/open_ai_bot.rb deleted file mode 100644 index 39f4b0b2..00000000 --- a/lib/ai_bot/open_ai_bot.rb +++ /dev/null @@ -1,157 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class OpenAiBot < Bot - def self.can_reply_as?(bot_user) - open_ai_bot_ids = [ - DiscourseAi::AiBot::EntryPoint::GPT4_TURBO_ID, - DiscourseAi::AiBot::EntryPoint::GPT4_ID, - DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID, - ] - - open_ai_bot_ids.include?(bot_user.id) - end - - def prompt_limit(allow_commands:) - # provide a buffer of 120 tokens - our function counting is not - # 100% accurate and getting numbers to align exactly is very hard - buffer = reply_params[:max_tokens] + 50 - - if allow_commands - # note this is about 100 tokens over, OpenAI have a more optimal representation - @function_size ||= tokenize(available_functions.to_json.to_s).length - buffer += @function_size - end - - if bot_user.id == DiscourseAi::AiBot::EntryPoint::GPT4_TURBO_ID - 150_000 - buffer - elsif bot_user.id == DiscourseAi::AiBot::EntryPoint::GPT4_ID - 8192 - buffer - else - 16_384 - buffer - end - end - - def reply_params - # technically we could allow GPT-3.5 16k more tokens - # but lets just keep it here for now - { temperature: 0.4, top_p: 0.9, max_tokens: 2500 } - end - - def extra_tokens_per_message - # open ai defines about 4 tokens per message of overhead - 4 - end - - def submit_prompt( - prompt, - prefer_low_cost: false, - post: nil, - temperature: nil, - top_p: nil, - max_tokens: nil, - &blk - ) - params = - reply_params.merge( - temperature: temperature, - top_p: top_p, - max_tokens: max_tokens, - ) { |key, old_value, new_value| new_value.nil? ? old_value : new_value } - - model = model_for(low_cost: prefer_low_cost) - - params[:functions] = available_functions if available_functions.present? - - DiscourseAi::Inference::OpenAiCompletions.perform!( - prompt, - model, - **params, - post: post, - &blk - ) - end - - def tokenizer - DiscourseAi::Tokenizer::OpenAiTokenizer - end - - def model_for(low_cost: false) - if low_cost || bot_user.id == DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID - "gpt-3.5-turbo-16k" - elsif bot_user.id == DiscourseAi::AiBot::EntryPoint::GPT4_ID - "gpt-4" - else - # not quite released yet, once released we should replace with - # gpt-4-turbo - "gpt-4-1106-preview" - end - end - - def clean_username(username) - if username.match?(/\0[a-zA-Z0-9_-]{1,64}\z/) - username - else - # not the best in the world, but this is what we have to work with - # if sites enable unicode usernames this can get messy - username.gsub(/[^a-zA-Z0-9_-]/, "_")[0..63] - end - end - - def include_function_instructions_in_system_prompt? - # open ai uses a bespoke system for function calls - false - end - - private - - def populate_functions(partial:, reply:, functions:, done:, current_delta:) - return if !partial - fn = partial.dig(:choices, 0, :delta, :function_call) - if fn - functions.add_function(fn[:name]) if fn[:name].present? - functions.add_argument_fragment(fn[:arguments]) if !fn[:arguments].nil? - functions.custom = true - end - end - - def build_message(poster_username, content, function: false, system: false) - is_bot = poster_username == bot_user.username - - if function - role = "function" - elsif system - role = "system" - else - role = is_bot ? "assistant" : "user" - end - - result = { role: role, content: content } - - if function - result[:name] = poster_username - elsif !system && poster_username != bot_user.username && poster_username.present? - # Open AI restrict name to 64 chars and only A-Za-z._ (work around) - result[:name] = clean_username(poster_username) - end - - result - end - - def get_delta(partial, _context) - partial.dig(:choices, 0, :delta, :content).to_s - end - - def get_updated_title(prompt) - DiscourseAi::Inference::OpenAiCompletions.perform!( - prompt, - model_for, - temperature: 0.7, - top_p: 0.9, - max_tokens: 40, - ).dig(:choices, 0, :message, :content) - end - end - end -end diff --git a/lib/ai_bot/personas.rb b/lib/ai_bot/personas.rb deleted file mode 100644 index ec105986..00000000 --- a/lib/ai_bot/personas.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - module Personas - def self.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, - } - end - - def self.system_personas_by_id - @system_personas_by_id ||= system_personas.invert - end - - def self.all(user:) - # this needs to be dynamic cause site settings may change - all_available_commands = Persona.all_available_commands - - 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_commands == [] || - (instance.required_commands - all_available_commands).empty? - ) - else - true - end - end - end - - def self.find_by(id: nil, name: nil, user:) - all(user: user).find { |persona| persona.id == id || persona.name == name } - end - end - end -end diff --git a/lib/ai_bot/personas/artist.rb b/lib/ai_bot/personas/artist.rb index a6b0e034..d16d74e4 100644 --- a/lib/ai_bot/personas/artist.rb +++ b/lib/ai_bot/personas/artist.rb @@ -4,12 +4,12 @@ module DiscourseAi module AiBot module Personas class Artist < Persona - def commands - [Commands::ImageCommand] + def tools + [Tools::Image] end - def required_commands - [Commands::ImageCommand] + def required_tools + [Tools::Image] end def system_prompt diff --git a/lib/ai_bot/personas/creative.rb b/lib/ai_bot/personas/creative.rb index 8a0e9ea6..338577b5 100644 --- a/lib/ai_bot/personas/creative.rb +++ b/lib/ai_bot/personas/creative.rb @@ -4,7 +4,7 @@ module DiscourseAi module AiBot module Personas class Creative < Persona - def commands + def tools [] end diff --git a/lib/ai_bot/personas/dall_e_3.rb b/lib/ai_bot/personas/dall_e_3.rb index 75fb035f..65666284 100644 --- a/lib/ai_bot/personas/dall_e_3.rb +++ b/lib/ai_bot/personas/dall_e_3.rb @@ -4,12 +4,12 @@ module DiscourseAi module AiBot module Personas class DallE3 < Persona - def commands - [Commands::DallECommand] + def tools + [Tools::DallE] end - def required_commands - [Commands::DallECommand] + def required_tools + [Tools::DallE] end def system_prompt diff --git a/lib/ai_bot/personas/general.rb b/lib/ai_bot/personas/general.rb index 0d31e6aa..dfb79ad6 100644 --- a/lib/ai_bot/personas/general.rb +++ b/lib/ai_bot/personas/general.rb @@ -4,15 +4,15 @@ module DiscourseAi module AiBot module Personas class General < Persona - def commands + def tools [ - Commands::SearchCommand, - Commands::GoogleCommand, - Commands::ImageCommand, - Commands::ReadCommand, - Commands::ImageCommand, - Commands::CategoriesCommand, - Commands::TagsCommand, + Tools::Search, + Tools::Google, + Tools::Image, + Tools::Read, + Tools::Image, + Tools::ListCategories, + Tools::ListTags, ] end diff --git a/lib/ai_bot/personas/persona.rb b/lib/ai_bot/personas/persona.rb index 3caf2a24..481d0ddc 100644 --- a/lib/ai_bot/personas/persona.rb +++ b/lib/ai_bot/personas/persona.rb @@ -4,19 +4,84 @@ module DiscourseAi module AiBot module Personas class Persona - def self.name - I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.name") + class << self + 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, + } + 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::Summarize, + Tools::Read, + Tools::DbSchema, + Tools::SearchSettings, + Tools::Summarize, + Tools::SettingContext, + ] + + 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 self.description - I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description") - end - - def commands + def tools [] end - def required_commands + def required_tools [] end @@ -24,104 +89,55 @@ module DiscourseAi {} end - def render_commands(render_function_instructions:) - return +"" if available_commands.empty? - - result = +"" - if render_function_instructions - result << "\n" - result << function_list.system_prompt - result << "\n" - end - result << available_commands.map(&:custom_system_message).compact.join("\n") - result + def available_tools + self.class.all_available_tools.filter { |tool| tools.include?(tool) } end - def render_system_prompt( - topic: nil, - render_function_instructions: true, - allow_commands: true - ) - substitutions = { - site_url: Discourse.base_url, - site_title: SiteSetting.title, - site_description: SiteSetting.site_description, - time: Time.zone.now, - } - - substitutions[:participants] = topic.allowed_users.map(&:username).join(", ") if topic - - prompt = + def craft_prompt(context) + system_insts = system_prompt.gsub(/\{(\w+)\}/) do |match| - found = substitutions[match[1..-2].to_sym] + found = context[match[1..-2].to_sym] found.nil? ? match : found.to_s end - if allow_commands - prompt += render_commands(render_function_instructions: render_function_instructions) + insts = <<~TEXT + #{system_insts} + #{available_tools.map(&:custom_system_message).compact_blank.join("\n")} + TEXT + + { insts: insts }.tap do |prompt| + prompt[:tools] = available_tools.map(&:signature) if available_tools + prompt[:conversation_context] = context[:conversation_context] if context[ + :conversation_context + ] end - - prompt end - def available_commands - return @available_commands if @available_commands - @available_commands = all_available_commands.filter { |cmd| commands.include?(cmd) } - end + def find_tool(partial) + parsed_function = Nokogiri::HTML5.fragment(partial) + function_id = parsed_function.at("tool_id")&.text + function_name = parsed_function.at("tool_name")&.text + return false if function_name.nil? - def available_functions - # note if defined? can be a problem in test - # this can never be nil so it is safe - return @available_functions if @available_functions + tool_klass = available_tools.find { |c| c.signature.dig(:name) == function_name } + return false if tool_klass.nil? - functions = [] + arguments = + tool_klass.signature[:parameters] + .to_a + .reduce({}) do |memo, p| + argument = parsed_function.at(p[:name])&.text + next(memo) unless argument - functions = - available_commands.map do |command| - function = - DiscourseAi::Inference::Function.new(name: command.name, description: command.desc) - command.parameters.each { |parameter| function.add_parameter(parameter) } - function - end + memo[p[:name].to_sym] = argument + memo + end - @available_functions = functions - end - - def function_list - return @function_list if @function_list - - @function_list = DiscourseAi::Inference::FunctionList.new - available_functions.each { |function| @function_list << function } - @function_list - end - - def self.all_available_commands - all_commands = [ - Commands::CategoriesCommand, - Commands::TimeCommand, - Commands::SearchCommand, - Commands::SummarizeCommand, - Commands::ReadCommand, - Commands::DbSchemaCommand, - Commands::SearchSettingsCommand, - Commands::SummarizeCommand, - Commands::SettingContextCommand, - ] - - all_commands << Commands::TagsCommand if SiteSetting.tagging_enabled - all_commands << Commands::ImageCommand if SiteSetting.ai_stability_api_key.present? - - all_commands << Commands::DallECommand if SiteSetting.ai_openai_api_key.present? - if SiteSetting.ai_google_custom_search_api_key.present? && - SiteSetting.ai_google_custom_search_cx.present? - all_commands << Commands::GoogleCommand - end - - all_commands - end - - def all_available_commands - @cmds ||= self.class.all_available_commands + tool_klass.new( + arguments, + tool_call_id: function_id, + persona_options: options[tool_klass].to_h, + ) end end end diff --git a/lib/ai_bot/personas/researcher.rb b/lib/ai_bot/personas/researcher.rb index c565bce5..5c7f8998 100644 --- a/lib/ai_bot/personas/researcher.rb +++ b/lib/ai_bot/personas/researcher.rb @@ -4,12 +4,12 @@ module DiscourseAi module AiBot module Personas class Researcher < Persona - def commands - [Commands::GoogleCommand] + def tools + [Tools::Google] end - def required_commands - [Commands::GoogleCommand] + def required_tools + [Tools::Google] end def system_prompt diff --git a/lib/ai_bot/personas/settings_explorer.rb b/lib/ai_bot/personas/settings_explorer.rb index 4ba07aba..018df156 100644 --- a/lib/ai_bot/personas/settings_explorer.rb +++ b/lib/ai_bot/personas/settings_explorer.rb @@ -4,15 +4,8 @@ module DiscourseAi module AiBot module Personas class SettingsExplorer < Persona - def commands - all_available_commands - end - - def all_available_commands - [ - DiscourseAi::AiBot::Commands::SettingContextCommand, - DiscourseAi::AiBot::Commands::SearchSettingsCommand, - ] + def tools + [Tools::SettingContext, Tools::SearchSettings] end def system_prompt diff --git a/lib/ai_bot/personas/sql_helper.rb b/lib/ai_bot/personas/sql_helper.rb index 650e099e..b048d7e3 100644 --- a/lib/ai_bot/personas/sql_helper.rb +++ b/lib/ai_bot/personas/sql_helper.rb @@ -27,12 +27,8 @@ module DiscourseAi @schema = schema end - def commands - all_available_commands - end - - def all_available_commands - [DiscourseAi::AiBot::Commands::DbSchemaCommand] + def tools + [Tools::DbSchema] end def system_prompt diff --git a/lib/ai_bot/playground.rb b/lib/ai_bot/playground.rb new file mode 100644 index 00000000..7869cfda --- /dev/null +++ b/lib/ai_bot/playground.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + class Playground + # An abstraction to manage the bot and topic interactions. + # The bot will take care of completions while this class updates the topic title + # and stream replies. + + REQUIRE_TITLE_UPDATE = "discourse-ai-title-update" + + def initialize(bot) + @bot = bot + end + + def update_playground_with(post) + if can_attach?(post) && bot.bot_user + schedule_playground_titling(post, bot.bot_user) + schedule_bot_reply(post, bot.bot_user) + end + end + + def conversation_context(post) + # Pay attention to the `post_number <= ?` here. + # We want to inject the last post as context because they are translated differently. + context = + post + .topic + .posts + .includes(:user) + .joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id") + .where("post_number <= ?", post.post_number) + .order("post_number desc") + .where("post_type = ?", Post.types[:regular]) + .limit(50) + .pluck(:raw, :username, "post_custom_prompts.custom_prompt") + + result = [] + + first = true + context.each do |raw, username, custom_prompt| + custom_prompt_translation = + Proc.new do |message| + # We can't keep backwards-compatibility for stored functions. + # Tool syntax requires a tool_call_id which we don't have. + if message[2] != "function" + custom_context = { + content: message[0], + type: message[2].present? ? message[2] : "assistant", + } + + custom_context[:name] = message[1] if custom_context[:type] != "assistant" + + result << custom_context + end + end + + if custom_prompt.present? + if first + custom_prompt.reverse_each(&custom_prompt_translation) + first = false + else + tool_call_and_tool = custom_prompt.first(2) + tool_call_and_tool.reverse_each(&custom_prompt_translation) + end + else + context = { + content: raw, + type: (available_bot_usernames.include?(username) ? "assistant" : "user"), + } + + context[:name] = username if context[:type] == "user" + + result << context + end + end + + result + end + + def title_playground(post) + context = conversation_context(post) + + bot + .get_updated_title(context, 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/, ""), + ) + post.topic.custom_fields.delete(DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE) + post.topic.save_custom_fields + end + end + + def reply_to(post) + reply = +"" + start = Time.now + + context = { + site_url: Discourse.base_url, + site_title: SiteSetting.title, + site_description: SiteSetting.site_description, + time: Time.zone.now, + participants: post.topic.allowed_users.map(&:username).join(", "), + conversation_context: conversation_context(post), + user: post.user, + } + + reply_post = + PostCreator.create!( + bot.bot_user, + topic_id: post.topic_id, + raw: I18n.t("discourse_ai.ai_bot.placeholder_reply"), + skip_validations: true, + ) + + redis_stream_key = "gpt_cancel:#{reply_post.id}" + Discourse.redis.setex(redis_stream_key, 60, 1) + + new_custom_prompts = + bot.reply(context) do |partial, cancel, placeholder| + reply << partial + raw = reply.dup + raw << "\n\n" << placeholder if placeholder.present? + + if !Discourse.redis.get(redis_stream_key) + cancel&.call + + reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) + end + + # Minor hack to skip the delay during tests. + if placeholder.blank? + next if (Time.now - start < 0.5) && !Rails.env.test? + start = Time.now + end + + Discourse.redis.expire(redis_stream_key, 60) + + publish_update(reply_post, raw: raw) + end + + return if reply.blank? + + reply_post.tap do |bot_reply| + publish_update(bot_reply, done: true) + + bot_reply.revise( + bot.bot_user, + { raw: reply }, + skip_validations: true, + skip_revision: true, + ) + + bot_reply.post_custom_prompt ||= bot_reply.build_post_custom_prompt(custom_prompt: []) + prompt = bot_reply.post_custom_prompt.custom_prompt || [] + prompt.concat(new_custom_prompts) + prompt << [reply, bot.bot_user.username] + bot_reply.post_custom_prompt.update!(custom_prompt: prompt) + end + end + + private + + attr_reader :bot + + def can_attach?(post) + return false if bot.bot_user.nil? + return false if post.post_type != Post.types[:regular] + return false unless post.topic.private_message? + return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank? + + true + end + + def schedule_playground_titling(post, bot_user) + if post.post_number == 1 + post.topic.custom_fields[REQUIRE_TITLE_UPDATE] = true + post.topic.save_custom_fields + end + + ::Jobs.enqueue_in( + 5.minutes, + :update_ai_bot_pm_title, + post_id: post.id, + bot_user_id: bot_user.id, + ) + end + + def schedule_bot_reply(post, bot_user) + ::Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_user.id) + end + + def context(topic) + { + site_url: Discourse.base_url, + site_title: SiteSetting.title, + site_description: SiteSetting.site_description, + time: Time.zone.now, + participants: topic.allowed_users.map(&:username).join(", "), + } + end + + def publish_update(bot_reply_post, payload) + MessageBus.publish( + "discourse-ai/ai-bot/topic/#{bot_reply_post.topic_id}", + payload.merge(post_id: bot_reply_post.id, post_number: bot_reply_post.post_number), + user_ids: bot_reply_post.topic.allowed_user_ids, + ) + end + + def available_bot_usernames + @bot_usernames ||= DiscourseAi::AiBot::EntryPoint::BOTS.map(&:second) + end + + def clean_username(username) + if username.match?(/\0[a-zA-Z0-9_-]{1,64}\z/) + username + else + # not the best in the world, but this is what we have to work with + # if sites enable unicode usernames this can get messy + username.gsub(/[^a-zA-Z0-9_-]/, "_")[0..63] + end + end + end + end +end diff --git a/lib/ai_bot/tools/dall_e.rb b/lib/ai_bot/tools/dall_e.rb new file mode 100644 index 00000000..1e9532c3 --- /dev/null +++ b/lib/ai_bot/tools/dall_e.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class DallE < Tool + def self.signature + { + name: name, + description: "Renders images from supplied descriptions", + parameters: [ + { + name: "prompts", + description: + "The prompts used to generate or create or draw the image (5000 chars or less, be creative) up to 4 prompts", + type: "array", + item_type: "string", + required: true, + }, + ], + } + end + + def self.name + "dall_e" + end + + def prompts + parameters[:prompts] + end + + def chain_next_response? + false + end + + def invoke(bot_user, _llm) + # max 4 prompts + max_prompts = prompts.take(4) + progress = +"" + + yield(progress) + + results = nil + + # this ensures multisite safety since background threads + # generate the images + api_key = SiteSetting.ai_openai_api_key + api_url = SiteSetting.ai_openai_dall_e_3_url + + threads = [] + max_prompts.each_with_index do |prompt, index| + threads << Thread.new(prompt) do |inner_prompt| + attempts = 0 + begin + DiscourseAi::Inference::OpenAiImageGenerator.perform!( + inner_prompt, + api_key: api_key, + api_url: api_url, + ) + rescue => e + attempts += 1 + sleep 2 + retry if attempts < 3 + Discourse.warn_exception( + e, + message: "Failed to generate image for prompt #{prompt}", + ) + nil + end + end + end + + while true + progress << "." + yield(progress) + break if threads.all? { |t| t.join(2) } + end + + results = threads.filter_map(&:value) + + if results.blank? + return { prompts: max_prompts, error: "Something went wrong, could not generate image" } + end + + uploads = [] + + results.each_with_index do |result, index| + result[:data].each do |image| + Tempfile.create("v1_txt2img_#{index}.png") do |file| + file.binmode + file.write(Base64.decode64(image[:b64_json])) + file.rewind + uploads << { + prompt: image[:revised_prompt], + upload: UploadCreator.new(file, "image.png").create_for(bot_user.id), + } + end + end + end + + self.custom_raw = <<~RAW + + [grid] + #{ + uploads + .map do |item| + "![#{item[:prompt].gsub(/\|\'\"/, "")}|512x512, 50%](#{item[:upload].short_url})" + end + .join(" ") + } + [/grid] + RAW + + { prompts: uploads.map { |item| item[:prompt] } } + end + + protected + + def description_args + { prompt: prompts.first } + end + end + end + end +end diff --git a/lib/ai_bot/tools/db_schema.rb b/lib/ai_bot/tools/db_schema.rb new file mode 100644 index 00000000..7359260f --- /dev/null +++ b/lib/ai_bot/tools/db_schema.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class DbSchema < Tool + def self.signature + { + name: name, + description: "Will load schema information for specific tables in the database", + parameters: [ + { + name: "tables", + description: + "list of tables to load schema information for, comma seperated list eg: (users,posts))", + type: "string", + required: true, + }, + ], + } + end + + def self.name + "schema" + end + + def tables + parameters[:tables] + end + + def invoke(_bot_user, _llm) + tables_arr = tables.split(",").map(&:strip) + + table_info = {} + DB + .query(<<~SQL, tables_arr) + select table_name, column_name, data_type from information_schema.columns + where table_schema = 'public' + and table_name in (?) + order by table_name + SQL + .each do |row| + (table_info[row.table_name] ||= []) << "#{row.column_name} #{row.data_type}" + end + + schema_info = + table_info + .map { |table_name, columns| "#{table_name}(#{columns.join(",")})" } + .join("\n") + + { schema_info: schema_info, tables: tables } + end + + protected + + def description_args + { tables: tables.join(", ") } + end + end + end + end +end diff --git a/lib/ai_bot/tools/google.rb b/lib/ai_bot/tools/google.rb new file mode 100644 index 00000000..64930ebe --- /dev/null +++ b/lib/ai_bot/tools/google.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class Google < Tool + def self.signature + { + name: name, + description: + "Will search using Google - global internet search (supports all Google search operators)", + parameters: [ + { name: "query", description: "The search query", type: "string", required: true }, + ], + } + end + + def self.custom_system_message + "You were trained on OLD data, lean on search to get up to date information from the web" + end + + def self.name + "google" + end + + def query + parameters[:query].to_s + end + + def invoke(bot_user, llm) + yield("") # Triggers placeholder update + + api_key = SiteSetting.ai_google_custom_search_api_key + cx = SiteSetting.ai_google_custom_search_cx + escaped_query = CGI.escape(query) + uri = + URI( + "https://www.googleapis.com/customsearch/v1?key=#{api_key}&cx=#{cx}&q=#{escaped_query}&num=10", + ) + body = Net::HTTP.get(uri) + + parse_search_json(body, escaped_query, llm) + end + + attr_reader :results_count + + protected + + def description_args + { + count: results_count || 0, + query: query, + url: "https://google.com/search?q=#{CGI.escape(query)}", + } + end + + private + + def minimize_field(result, field, llm, max_tokens: 100) + data = result[field] + return "" if data.blank? + + llm.tokenizer.truncate(data, max_tokens).squish + end + + def parse_search_json(json_data, escaped_query, llm) + parsed = JSON.parse(json_data) + results = parsed["items"] + + @results_count = parsed.dig("searchInformation", "totalResults").to_i + + format_results(results, args: escaped_query) do |result| + { + title: minimize_field(result, "title", llm), + link: minimize_field(result, "link", llm), + snippet: minimize_field(result, "snippet", llm, max_tokens: 120), + displayLink: minimize_field(result, "displayLink", llm), + formattedUrl: minimize_field(result, "formattedUrl", llm), + } + end + end + end + end + end +end diff --git a/lib/ai_bot/tools/image.rb b/lib/ai_bot/tools/image.rb new file mode 100644 index 00000000..e9d47fa3 --- /dev/null +++ b/lib/ai_bot/tools/image.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class Image < Tool + def self.signature + { + name: name, + description: + "Renders an image from the description (remove all connector words, keep it to 40 words or less). Despite being a text based bot you can generate images! (when user asks to draw, paint or other synonyms try this)", + parameters: [ + { + name: "prompts", + description: + "The prompts used to generate or create or draw the image (40 words or less, be creative) up to 4 prompts", + type: "array", + item_type: "string", + required: true, + }, + { + name: "seeds", + description: + "The seed used to generate the image (optional) - can be used to retain image style on amended prompts", + type: "array", + item_type: "integer", + required: true, + }, + ], + } + end + + def self.name + "image" + end + + def prompts + JSON.parse(parameters[:prompts].to_s) + end + + def seeds + parameters[:seeds] + end + + def chain_next_response? + false + end + + def invoke(bot_user, _llm) + # max 4 prompts + selected_prompts = prompts.take(4) + seeds = seeds.take(4) if seeds + + progress = +"" + yield(progress) + + results = nil + + # this ensures multisite safety since background threads + # generate the images + api_key = SiteSetting.ai_stability_api_key + engine = SiteSetting.ai_stability_engine + api_url = SiteSetting.ai_stability_api_url + + threads = [] + selected_prompts.each_with_index do |prompt, index| + seed = seeds ? seeds[index] : nil + threads << Thread.new(seed, prompt) do |inner_seed, inner_prompt| + attempts = 0 + begin + DiscourseAi::Inference::StabilityGenerator.perform!( + inner_prompt, + engine: engine, + api_key: api_key, + api_url: api_url, + image_count: 1, + seed: inner_seed, + ) + rescue => e + attempts += 1 + retry if attempts < 3 + Rails.logger.warn("Failed to generate image for prompt #{prompt}: #{e}") + nil + end + end + end + + while true + progress << "." + yield(progress) + break if threads.all? { |t| t.join(2) } + end + + results = threads.map(&:value).compact + + if !results.present? + return { prompts: prompts, error: "Something went wrong, could not generate image" } + end + + uploads = [] + + results.each_with_index do |result, index| + result[:artifacts].each do |image| + Tempfile.create("v1_txt2img_#{index}.png") do |file| + file.binmode + file.write(Base64.decode64(image[:base64])) + file.rewind + uploads << { + prompt: prompts[index], + upload: UploadCreator.new(file, "image.png").create_for(bot_user.id), + seed: image[:seed], + } + end + end + end + + @custom_raw = <<~RAW + + [grid] + #{ + uploads + .map do |item| + "![#{item[:prompt].gsub(/\|\'\"/, "")}|512x512, 50%](#{item[:upload].short_url})" + end + .join(" ") + } + [/grid] + RAW + + { + prompts: uploads.map { |item| item[:prompt] }, + seeds: uploads.map { |item| item[:seed] }, + } + end + + protected + + def description_args + { prompt: prompts.first } + end + end + end + end +end diff --git a/lib/ai_bot/tools/list_categories.rb b/lib/ai_bot/tools/list_categories.rb new file mode 100644 index 00000000..52bf0cb6 --- /dev/null +++ b/lib/ai_bot/tools/list_categories.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class ListCategories < Tool + def self.signature + { + name: name, + description: + "Will list the categories on the current discourse instance, prefer to format with # in front of the category name", + } + end + + def self.name + "categories" + end + + def invoke(_bot_user, _llm) + columns = { + name: "Name", + slug: "Slug", + description: "Description", + posts_year: "Posts Year", + posts_month: "Posts Month", + posts_week: "Posts Week", + id: "id", + parent_category_id: "parent_category_id", + } + + rows = Category.where(read_restricted: false).limit(100).pluck(*columns.keys) + + @last_count = rows.length + + { rows: rows, column_names: columns.values } + end + + private + + def description_args + { count: @last_count || 0 } + end + end + end + end +end diff --git a/lib/ai_bot/tools/list_tags.rb b/lib/ai_bot/tools/list_tags.rb new file mode 100644 index 00000000..e12c2491 --- /dev/null +++ b/lib/ai_bot/tools/list_tags.rb @@ -0,0 +1,41 @@ +#frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class ListTags < Tool + def self.signature + { + name: name, + description: "Will list the 100 most popular tags on the current discourse instance", + } + end + + def self.name + "tags" + end + + def invoke(_bot_user, _llm) + column_names = { name: "Name", public_topic_count: "Topic Count" } + + tags = + Tag + .where("public_topic_count > 0") + .order(public_topic_count: :desc) + .limit(100) + .pluck(*column_names.keys) + + @last_count = tags.length + + format_results(tags, column_names.values) + end + + private + + def description_args + { count: @last_count || 0 } + end + end + end + end +end diff --git a/lib/ai_bot/tools/option.rb b/lib/ai_bot/tools/option.rb new file mode 100644 index 00000000..1723e7d1 --- /dev/null +++ b/lib/ai_bot/tools/option.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class Option + attr_reader :tool, :name, :type + + def initialize(tool:, name:, type:) + @tool = tool + @name = name.to_s + @type = type + end + + def localized_name + I18n.t("discourse_ai.ai_bot.command_options.#{tool.signature[:name]}.#{name}.name") + end + + def localized_description + I18n.t("discourse_ai.ai_bot.command_options.#{tool.signature[:name]}.#{name}.description") + end + end + end + end +end diff --git a/lib/ai_bot/tools/read.rb b/lib/ai_bot/tools/read.rb new file mode 100644 index 00000000..12a522e6 --- /dev/null +++ b/lib/ai_bot/tools/read.rb @@ -0,0 +1,90 @@ +#frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class Read < Tool + def self.signature + { + name: name, + description: "Will read a topic or a post on this Discourse instance", + parameters: [ + { + name: "topic_id", + description: "the id of the topic to read", + type: "integer", + required: true, + }, + { + name: "post_number", + description: "the post number to read", + type: "integer", + required: true, + }, + ], + } + end + + def self.name + "read" + end + + attr_reader :title, :url + + def topic_id + parameters[:topic_id] + end + + def post_number + parameters[:post_number] + end + + def invoke(_bot_user, llm) + not_found = { topic_id: topic_id, description: "Topic not found" } + + @title = "" + + topic = Topic.find_by(id: topic_id.to_i) + return not_found if !topic || !Guardian.new.can_see?(topic) + + @title = topic.title + + posts = Post.secured(Guardian.new).where(topic_id: topic_id).order(:post_number).limit(40) + @url = topic.relative_url(post_number) + + posts = posts.where("post_number = ?", post_number) if post_number + + content = +<<~TEXT.strip + title: #{topic.title} + TEXT + + category_names = [ + topic.category&.parent_category&.name, + topic.category&.name, + ].compact.join(" ") + content << "\ncategories: #{category_names}" if category_names.present? + + if topic.tags.length > 0 + tags = DiscourseTagging.filter_visible(topic.tags, Guardian.new) + content << "\ntags: #{tags.map(&:name).join(", ")}\n\n" if tags.length > 0 + end + + posts.each { |post| content << "\n\n#{post.username} said:\n\n#{post.raw}" } + + # TODO: 16k or 100k models can handle a lot more tokens + content = llm.tokenizer.truncate(content, 1500).squish + + result = { topic_id: topic_id, content: content, complete: true } + result[:post_number] = post_number if post_number + result + end + + protected + + def description_args + { title: title, url: url } + end + end + end + end +end diff --git a/lib/ai_bot/tools/search.rb b/lib/ai_bot/tools/search.rb new file mode 100644 index 00000000..33b4f6d0 --- /dev/null +++ b/lib/ai_bot/tools/search.rb @@ -0,0 +1,223 @@ +#frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class Search < Tool + MIN_SEMANTIC_RESULTS = 5 + + class << self + def signature + { + name: name, + description: + "Will search topics in the current discourse instance, when rendering always prefer to link to the topics you find", + parameters: [ + { + name: "search_query", + description: + "Specific keywords to search for, space seperated (correct bad spelling, remove connector words)", + type: "string", + }, + { + name: "user", + description: + "Filter search results to this username (only include if user explicitly asks to filter by user)", + type: "string", + }, + { + name: "order", + description: "search result order", + type: "string", + enum: %w[latest latest_topic oldest views likes], + }, + { + name: "limit", + description: + "limit number of results returned (generally prefer to just keep to default)", + type: "integer", + }, + { + name: "max_posts", + description: + "maximum number of posts on the topics (topics where lots of people posted)", + type: "integer", + }, + { + name: "tags", + description: + "list of tags to search for. Use + to join with OR, use , to join with AND", + type: "string", + }, + { name: "category", description: "category name to filter to", type: "string" }, + { + name: "before", + description: "only topics created before a specific date YYYY-MM-DD", + type: "string", + }, + { + name: "after", + description: "only topics created after a specific date YYYY-MM-DD", + type: "string", + }, + { + name: "status", + description: "search for topics in a particular state", + type: "string", + enum: %w[open closed archived noreplies single_user], + }, + ], + } + end + + def name + "search" + end + + def custom_system_message + <<~TEXT + You were trained on OLD data, lean on search to get up to date information about this forum + When searching try to SIMPLIFY search terms + Discourse search joins all terms with AND. Reduce and simplify terms to find more results. + TEXT + end + + def accepted_options + [option(:base_query, type: :string), option(:max_results, type: :integer)] + end + end + + def search_args + parameters.slice(:user, :order, :max_posts, :tags, :before, :after, :status) + end + + def invoke(bot_user, llm) + search_string = + search_args.reduce(+parameters[:search_query].to_s) do |memo, (key, value)| + return memo if value.blank? + memo << " " << "#{key}:#{value}" + end + + @last_query = search_string + + yield(I18n.t("discourse_ai.ai_bot.searching", query: search_string)) + + if options[:base_query].present? + search_string = "#{search_string} #{options[:base_query]}" + end + + results = + ::Search.execute( + search_string.to_s + " status:public", + search_type: :full_page, + guardian: Guardian.new(), + ) + + # let's be frugal with tokens, 50 results is too much and stuff gets cut off + max_results = calculate_max_results(llm) + results_limit = parameters[:limit] || max_results + results_limit = max_results if parameters[:limit].to_i > max_results + + should_try_semantic_search = + SiteSetting.ai_embeddings_semantic_search_enabled && results_limit == max_results && + parameters[:search_query].present? + + max_semantic_results = max_results / 4 + results_limit = results_limit - max_semantic_results if should_try_semantic_search + + posts = results&.posts || [] + posts = posts[0..results_limit.to_i - 1] + + if should_try_semantic_search + semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(Guardian.new()) + topic_ids = Set.new(posts.map(&:topic_id)) + + search = ::Search.new(search_string, guardian: Guardian.new) + + results = nil + begin + results = semantic_search.search_for_topics(search.term) + rescue => e + Discourse.warn_exception(e, message: "Semantic search failed") + end + + if results + results = search.apply_filters(results) + + results.each do |post| + next if topic_ids.include?(post.topic_id) + + topic_ids << post.topic_id + posts << post + + break if posts.length >= max_results + end + end + end + + @last_num_results = posts.length + # this is the general pattern from core + # if there are millions of hidden tags it may fail + hidden_tags = nil + + if posts.blank? + { args: parameters, rows: [], instruction: "nothing was found, expand your search" } + else + format_results(posts, args: parameters) do |post| + category_names = [ + post.topic.category&.parent_category&.name, + post.topic.category&.name, + ].compact.join(" > ") + row = { + title: post.topic.title, + url: Discourse.base_path + post.url, + username: post.user&.username, + excerpt: post.excerpt, + created: post.created_at, + category: category_names, + likes: post.like_count, + topic_views: post.topic.views, + topic_likes: post.topic.like_count, + topic_replies: post.topic.posts_count - 1, + } + + if SiteSetting.tagging_enabled + hidden_tags ||= DiscourseTagging.hidden_tag_names + # using map over pluck to avoid n+1 (assuming caller preloading) + tags = post.topic.tags.map(&:name) - hidden_tags + row[:tags] = tags.join(", ") if tags.present? + end + + row + end + end + end + + protected + + def description_args + { + count: @last_num_results || 0, + query: @last_query || "", + url: "#{Discourse.base_path}/search?q=#{CGI.escape(@last_query || "")}", + } + end + + private + + def calculate_max_results(llm) + max_results = options[:max_results].to_i + return [max_results, 100].min if max_results > 0 + + if llm.max_prompt_tokens > 30_000 + 60 + elsif llm.max_prompt_tokens > 10_000 + 40 + else + 20 + end + end + end + end + end +end diff --git a/lib/ai_bot/tools/search_settings.rb b/lib/ai_bot/tools/search_settings.rb new file mode 100644 index 00000000..504b7f0d --- /dev/null +++ b/lib/ai_bot/tools/search_settings.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class SearchSettings < Tool + INCLUDE_DESCRIPTIONS_MAX_LENGTH = 10 + MAX_RESULTS = 200 + + def self.signature + { + name: name, + description: "Will search through site settings and return top 20 results", + parameters: [ + { + name: "query", + description: + "comma delimited list of settings to search for (e.g. 'setting_1,setting_2')", + type: "string", + required: true, + }, + ], + } + end + + def self.name + "search_settings" + end + + def query + parameters[:query].to_s + end + + def invoke(_bot_user, _llm) + @last_num_results = 0 + + terms = query.split(",").map(&:strip).map(&:downcase).reject(&:blank?) + + found = + SiteSetting.all_settings.filter do |setting| + name = setting[:setting].to_s.downcase + description = setting[:description].to_s.downcase + plugin = setting[:plugin].to_s.downcase + + search_string = "#{name} #{description} #{plugin}" + + terms.any? { |term| search_string.include?(term) } + end + + if found.blank? + { + args: { + query: query, + }, + rows: [], + instruction: "no settings matched #{query}, expand your search", + } + else + include_descriptions = false + + if found.length > MAX_RESULTS + found = found[0..MAX_RESULTS] + elsif found.length < INCLUDE_DESCRIPTIONS_MAX_LENGTH + include_descriptions = true + end + + @last_num_results = found.length + + format_results(found, args: { query: query }) do |setting| + result = { name: setting[:setting] } + if include_descriptions + result[:description] = setting[:description] + result[:plugin] = setting[:plugin] + end + result + end + end + end + + protected + + def description_args + { count: @last_num_results || 0, query: parameters[:query].to_s } + end + end + end + end +end diff --git a/lib/ai_bot/tools/setting_context.rb b/lib/ai_bot/tools/setting_context.rb new file mode 100644 index 00000000..ef08d323 --- /dev/null +++ b/lib/ai_bot/tools/setting_context.rb @@ -0,0 +1,160 @@ +#frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class SettingContext < Tool + MAX_CONTEXT_TOKENS = 2000 + CODE_FILE_EXTENSIONS = "rb,js,gjs,hbs" + + class << self + def rg_installed? + if defined?(@rg_installed) + @rg_installed + else + @rg_installed = + begin + Discourse::Utils.execute_command("which", "rg") + true + rescue Discourse::Utils::CommandError + false + end + end + end + + def signature + { + name: name, + description: + "Will provide you with full context regarding a particular site setting in Discourse", + parameters: [ + { + name: "setting_name", + description: "The name of the site setting we need context for", + type: "string", + required: true, + }, + ], + } + end + + def name + "setting_context" + end + end + + def setting_name + parameters[:setting_name] + end + + def invoke(_bot_user, llm) + if !self.class.rg_installed? + return( + { + setting_name: setting_name, + context: + "This command requires the rg command line tool to be installed on the server", + } + ) + end + + if !SiteSetting.has_setting?(setting_name) + { setting_name: setting_name, context: "This setting does not exist" } + else + description = SiteSetting.description(setting_name) + result = +"# #{setting_name}\n#{description}\n\n" + + setting_info = + find_setting_info(setting_name, [Rails.root.join("config", "site_settings.yml").to_s]) + if !setting_info + setting_info = + find_setting_info(setting_name, Dir[Rails.root.join("plugins/**/settings.yml")]) + end + + result << setting_info + result << "\n\n" + + %w[lib app plugins].each do |dir| + path = Rails.root.join(dir).to_s + result << Discourse::Utils.execute_command( + "rg", + setting_name, + path, + "-g", + "!**/spec/**", + "-g", + "!**/dist/**", + "-g", + "*.{#{CODE_FILE_EXTENSIONS}}", + "-C", + "10", + "--color", + "never", + "--heading", + "--no-ignore", + chdir: path, + success_status_codes: [0, 1], + ) + end + + result.gsub!(/^#{Regexp.escape(Rails.root.to_s)}/, "") + + result = llm.tokenizer.truncate(result, MAX_CONTEXT_TOKENS) + + { setting_name: setting_name, context: result } + end + end + + private + + def find_setting_info(name, paths) + path, result = nil + + paths.each do |search_path| + result = + Discourse::Utils.execute_command( + "rg", + name, + search_path, + "-g", + "*.{#{CODE_FILE_EXTENSIONS}}", + "-A", + "10", + "--color", + "never", + "--heading", + success_status_codes: [0, 1], + ) + if !result.blank? + path = search_path + break + end + end + + if result.blank? + nil + else + rows = result.split("\n") + leading_spaces = rows[0].match(/^\s*/)[0].length + + filtered = [] + + rows.each do |row| + if !filtered.blank? + break if row.match(/^\s*/)[0].length <= leading_spaces + end + filtered << row + end + + filtered.unshift("#{path}") + filtered.join("\n") + end + end + + def description_args + parameters.slice(:setting_name) + end + end + end + end +end diff --git a/lib/ai_bot/tools/summarize.rb b/lib/ai_bot/tools/summarize.rb new file mode 100644 index 00000000..2108184f --- /dev/null +++ b/lib/ai_bot/tools/summarize.rb @@ -0,0 +1,183 @@ +#frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class Summarize < Tool + def self.signature + { + name: name, + description: "Will summarize a topic attempting to answer question in guidance", + parameters: [ + { + name: "topic_id", + description: "The discourse topic id to summarize", + type: "integer", + required: true, + }, + { + name: "guidance", + description: "Special guidance on how to summarize the topic", + type: "string", + }, + ], + } + end + + def self.name + "summary" + end + + def topic_id + parameters[:topic_id].to_i + end + + def guidance + parameters[:guidance] + end + + def chain_next_response? + false + end + + def standalone? + true + end + + def low_cost? + true + end + + def custom_raw + @last_summary || I18n.t("discourse_ai.ai_bot.topic_not_found") + end + + def invoke(bot_user, llm, &progress_blk) + topic = nil + if topic_id > 0 + topic = Topic.find_by(id: topic_id) + topic = nil if !topic || !Guardian.new.can_see?(topic) + end + + @last_summary = nil + + if topic + @last_topic_title = topic.title + + posts = + Post + .where(topic_id: topic.id) + .where("post_type in (?)", [Post.types[:regular], Post.types[:small_action]]) + .where("not hidden") + .order(:post_number) + + columns = ["posts.id", :post_number, :raw, :username] + + current_post_numbers = posts.limit(5).pluck(:post_number) + current_post_numbers += posts.reorder("posts.score desc").limit(50).pluck(:post_number) + current_post_numbers += posts.reorder("post_number desc").limit(5).pluck(:post_number) + + data = + Post + .where(topic_id: topic.id) + .joins(:user) + .where("post_number in (?)", current_post_numbers) + .order(:post_number) + .pluck(*columns) + + @last_summary = summarize(data, topic, guidance, bot_user, llm, &progress_blk) + end + + if !@last_summary + "Say: No topic found!" + else + "Topic summarized" + end + end + + protected + + def description_args + { url: "#{Discourse.base_path}/t/-/#{@last_topic_id}", title: @last_topic_title || "" } + end + + private + + def summarize(data, topic, guidance, bot_user, llm, &progress_blk) + text = +"" + data.each do |id, post_number, raw, username| + text << "(#{post_number} #{username} said: #{raw}" + end + + summaries = [] + current_section = +"" + split = [] + + text + .split(/\s+/) + .each_slice(20) do |slice| + current_section << " " + current_section << slice.join(" ") + + # somehow any more will get closer to limits + if llm.tokenizer.tokenize(current_section).length > 2500 + split << current_section + current_section = +"" + end + end + + split << current_section if current_section.present? + + split = split[0..3] + split[-3..-1] if split.length > 5 + + progress = +I18n.t("discourse_ai.ai_bot.summarizing") + progress_blk.call(progress) + + split.each do |section| + progress << "." + progress_blk.call(progress) + + prompt = section_prompt(topic, section, guidance) + + summary = llm.generate(prompt, temperature: 0.6, max_tokens: 400, user: bot_user) + + summaries << summary + end + + if summaries.length > 1 + progress << "." + progress_blk.call(progress) + + contatenation_prompt = { + insts: "You are a helpful bot", + input: + "concatenated the disjoint summaries, creating a cohesive narrative:\n#{summaries.join("\n")}}", + } + + llm.generate(contatenation_prompt, temperature: 0.6, max_tokens: 500, user: bot_user) + else + summaries.first + end + end + + def section_prompt(topic, text, guidance) + insts = <<~TEXT + You are a summarization bot. + You effectively summarise any text. + You condense it into a shorter version. + You understand and generate Discourse forum markdown. + Try generating links as well the format is #{topic.url}/POST_NUMBER. eg: [ref](#{topic.url}/77) + TEXT + + { insts: insts, input: <<~TEXT } + Guidance: #{guidance} + You are summarizing the topic: #{topic.title} + Summarize the following in 400 words: + + #{text} + TEXT + end + end + end + end +end diff --git a/lib/ai_bot/tools/time.rb b/lib/ai_bot/tools/time.rb new file mode 100644 index 00000000..563f2ceb --- /dev/null +++ b/lib/ai_bot/tools/time.rb @@ -0,0 +1,52 @@ +#frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class Time < Tool + def self.signature + { + name: name, + description: "Will generate the time in a timezone", + parameters: [ + { + name: "timezone", + description: "ALWAYS supply a Ruby compatible timezone", + type: "string", + required: true, + }, + ], + } + end + + def self.name + "time" + end + + def timezone + parameters[:timezone].to_s + end + + def invoke(_bot_user, _llm) + time = + begin + ::Time.now.in_time_zone(timezone) + rescue StandardError + nil + end + time = ::Time.now if !time + + @last_time = time.to_s + + { args: { timezone: timezone }, time: time.to_s } + end + + private + + def description_args + { timezone: timezone, time: @last_time } + end + end + end + end +end diff --git a/lib/ai_bot/tools/tool.rb b/lib/ai_bot/tools/tool.rb new file mode 100644 index 00000000..03a32417 --- /dev/null +++ b/lib/ai_bot/tools/tool.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class Tool + class << self + def signature + raise NotImplemented + end + + def name + raise NotImplemented + end + + def accepted_options + [] + end + + def option(name, type:) + Option.new(tool: self, name: name, type: type) + end + + def help + I18n.t("discourse_ai.ai_bot.command_help.#{signature[:name]}") + end + + def custom_system_message + nil + end + end + + attr_accessor :custom_raw + + def initialize(parameters, tool_call_id: "", persona_options: {}) + @parameters = parameters + @tool_call_id = tool_call_id + @persona_options = persona_options + end + + attr_reader :parameters, :tool_call_id + + def name + self.class.name + end + + def summary + I18n.t("discourse_ai.ai_bot.command_summary.#{name}") + end + + def details + I18n.t("discourse_ai.ai_bot.command_description.#{name}", description_args) + end + + def help + I18n.t("discourse_ai.ai_bot.command_help.#{name}") + end + + def options + self + .class + .accepted_options + .reduce(HashWithIndifferentAccess.new) do |memo, option| + val = @persona_options[option.name] + memo[option.name] = val if val + memo + end + end + + def chain_next_response? + true + end + + def standalone? + false + end + + def low_cost? + false + end + + protected + + def accepted_options + [] + end + + def option(name, type:) + Option.new(tool: self, name: name, type: type) + end + + def description_args + {} + end + + def format_results(rows, column_names = nil, args: nil) + rows = rows&.map { |row| yield row } if block_given? + + if !column_names + index = -1 + column_indexes = {} + + rows = + rows&.map do |data| + new_row = [] + data.each do |key, value| + found_index = column_indexes[key.to_s] ||= (index += 1) + new_row[found_index] = value + end + new_row + end + column_names = column_indexes.keys + end + + # this is not the most efficient format + # however this is needed cause GPT 3.5 / 4 was steered using JSON + result = { column_names: column_names, rows: rows } + result[:args] = args if args + result + end + end + end + end +end diff --git a/lib/completions/dialects/chat_gpt.rb b/lib/completions/dialects/chat_gpt.rb index 033f29ab..0335f53f 100644 --- a/lib/completions/dialects/chat_gpt.rb +++ b/lib/completions/dialects/chat_gpt.rb @@ -46,10 +46,9 @@ module DiscourseAi prompt[:tools].map do |t| tool = t.dup - if tool[:parameters] - tool[:parameters] = t[:parameters].reduce( - { type: "object", properties: {}, required: [] }, - ) do |memo, p| + tool[:parameters] = t[:parameters] + .to_a + .reduce({ type: "object", properties: {}, required: [] }) do |memo, p| name = p[:name] memo[:required] << name if p[:required] @@ -58,7 +57,6 @@ module DiscourseAi memo[:properties][name][:items] = { type: p[:item_type] } if p[:item_type] memo end - end { type: "function", function: tool } end @@ -71,9 +69,12 @@ module DiscourseAi trimmed_context.reverse.map do |context| if context[:type] == "tool_call" + function = JSON.parse(context[:content], symbolize_names: true) + function[:arguments] = function[:arguments].to_json + { role: "assistant", - tool_calls: [{ type: "function", function: context[:content], id: context[:name] }], + tool_calls: [{ type: "function", function: function, id: context[:name] }], } else translated = context.slice(:content) diff --git a/lib/completions/dialects/claude.rb b/lib/completions/dialects/claude.rb index 73f9d231..d7ecf0b0 100644 --- a/lib/completions/dialects/claude.rb +++ b/lib/completions/dialects/claude.rb @@ -39,12 +39,12 @@ module DiscourseAi def conversation_context return "" if prompt[:conversation_context].blank? - trimmed_context = trim_context(prompt[:conversation_context]) + clean_context = prompt[:conversation_context].select { |cc| cc[:type] != "tool_call" } + trimmed_context = trim_context(clean_context) trimmed_context .reverse .reduce(+"") do |memo, context| - next(memo) if context[:type] == "tool_call" memo << (context[:type] == "user" ? "Human:" : "Assistant:") if context[:type] == "tool" diff --git a/lib/completions/dialects/dialect.rb b/lib/completions/dialects/dialect.rb index 951db987..b0e6c6a3 100644 --- a/lib/completions/dialects/dialect.rb +++ b/lib/completions/dialects/dialect.rb @@ -97,6 +97,13 @@ module DiscourseAi message_tokens = calculate_message_token(dupped_context) + # Don't trim tool call metadata. + if context[:type] == "tool_call" + current_token_count += calculate_message_token(context) + per_message_overhead + memo << context + next(memo) + end + # Trimming content to make sure we respect token limit. while dupped_context[:content].present? && message_tokens + current_token_count + per_message_overhead > prompt_limit diff --git a/lib/completions/dialects/llama2_classic.rb b/lib/completions/dialects/llama2_classic.rb index 26e541a3..0a53c44b 100644 --- a/lib/completions/dialects/llama2_classic.rb +++ b/lib/completions/dialects/llama2_classic.rb @@ -39,12 +39,13 @@ module DiscourseAi def conversation_context return "" if prompt[:conversation_context].blank? - trimmed_context = trim_context(prompt[:conversation_context]) + clean_context = prompt[:conversation_context].select { |cc| cc[:type] != "tool_call" } + + trimmed_context = trim_context(clean_context) trimmed_context .reverse .reduce(+"") do |memo, context| - next(memo) if context[:type] == "tool_call" if context[:type] == "tool" memo << <<~TEXT [INST] diff --git a/lib/completions/dialects/mixtral.rb b/lib/completions/dialects/mixtral.rb index 464a1ac4..36a2fd43 100644 --- a/lib/completions/dialects/mixtral.rb +++ b/lib/completions/dialects/mixtral.rb @@ -39,12 +39,12 @@ module DiscourseAi def conversation_context return "" if prompt[:conversation_context].blank? - trimmed_context = trim_context(prompt[:conversation_context]) + clean_context = prompt[:conversation_context].select { |cc| cc[:type] != "tool_call" } + trimmed_context = trim_context(clean_context) trimmed_context .reverse .reduce(+"") do |memo, context| - next(memo) if context[:type] == "tool_call" memo << "[INST] " if context[:type] == "user" if context[:type] == "tool" diff --git a/lib/completions/dialects/orca_style.rb b/lib/completions/dialects/orca_style.rb index b89dca01..74a356f3 100644 --- a/lib/completions/dialects/orca_style.rb +++ b/lib/completions/dialects/orca_style.rb @@ -36,12 +36,12 @@ module DiscourseAi def conversation_context return "" if prompt[:conversation_context].blank? - trimmed_context = trim_context(prompt[:conversation_context]) + clean_context = prompt[:conversation_context].select { |cc| cc[:type] != "tool_call" } + trimmed_context = trim_context(clean_context) trimmed_context .reverse .reduce(+"") do |memo, context| - next(memo) if context[:type] == "tool_call" memo << (context[:type] == "user" ? "### User:" : "### Assistant:") if context[:type] == "tool" diff --git a/lib/completions/endpoints/anthropic.rb b/lib/completions/endpoints/anthropic.rb index d98990d8..e4d26a8e 100644 --- a/lib/completions/endpoints/anthropic.rb +++ b/lib/completions/endpoints/anthropic.rb @@ -23,7 +23,7 @@ module DiscourseAi def default_options { model: model, - max_tokens_to_sample: 2_000, + max_tokens_to_sample: 3_000, stop_sequences: ["\n\nHuman:", "
"], } end diff --git a/lib/completions/endpoints/aws_bedrock.rb b/lib/completions/endpoints/aws_bedrock.rb index 5d559b75..35c388c4 100644 --- a/lib/completions/endpoints/aws_bedrock.rb +++ b/lib/completions/endpoints/aws_bedrock.rb @@ -26,11 +26,7 @@ module DiscourseAi end def default_options - { - model: model, - max_tokens_to_sample: 2_000, - stop_sequences: ["\n\nHuman:", "
"], - } + { max_tokens_to_sample: 3_000, stop_sequences: ["\n\nHuman:", ""] } end def provider_id diff --git a/lib/completions/endpoints/base.rb b/lib/completions/endpoints/base.rb index a9768e47..72635e12 100644 --- a/lib/completions/endpoints/base.rb +++ b/lib/completions/endpoints/base.rb @@ -87,6 +87,8 @@ module DiscourseAi return response_data end + has_tool = false + begin cancelled = false cancel = lambda { cancelled = true } @@ -129,17 +131,19 @@ module DiscourseAi partial = extract_completion_from(raw_partial) next if response_data.empty? && partial.blank? next if partial.nil? + partials_raw << partial.to_s - # Skip yield for tools. We'll buffer and yield later. - if has_tool?(partials_raw) + # Stop streaming the response as soon as you find a tool. + # We'll buffer and yield it later. + has_tool = true if has_tool?(partials_raw) + + if has_tool function_buffer = add_to_buffer(function_buffer, partials_raw, partial) else response_data << partial yield partial, cancel if partial end - - partials_raw << partial.to_s rescue JSON::ParserError leftover = redo_chunk json_error = true @@ -158,7 +162,7 @@ module DiscourseAi end # Once we have the full response, try to return the tool as a XML doc. - if has_tool?(partials_raw) + if has_tool if function_buffer.at("tool_name").text.present? invocation = +function_buffer.at("function_calls").to_s invocation << "\n" @@ -264,7 +268,7 @@ module DiscourseAi read_function = Nokogiri::HTML5.fragment(raw_data) - if tool_name = read_function.at("tool_name").text + if tool_name = read_function.at("tool_name")&.text function_buffer.at("tool_name").inner_html = tool_name function_buffer.at("tool_id").inner_html = tool_name end @@ -272,7 +276,8 @@ module DiscourseAi _read_parameters = read_function .at("parameters") - .elements + &.elements + .to_a .each do |elem| if paramenter = function_buffer.at(elem.name)&.text function_buffer.at(elem.name).inner_html = paramenter diff --git a/lib/completions/llm.rb b/lib/completions/llm.rb index 462f0b89..621e9811 100644 --- a/lib/completions/llm.rb +++ b/lib/completions/llm.rb @@ -20,7 +20,10 @@ module DiscourseAi def self.with_prepared_responses(responses) @canned_response = DiscourseAi::Completions::Endpoints::CannedResponse.new(responses) - yield(@canned_response).tap { @canned_response = nil } + yield(@canned_response) + ensure + # Don't leak prepared response if there's an exception. + @canned_response = nil end def self.proxy(model_name) @@ -119,9 +122,15 @@ module DiscourseAi gateway.perform_completion!(dialect, user, model_params, &partial_read_blk) end + def max_prompt_tokens + dialect_klass.new({}, model_name).max_prompt_tokens + end + + attr_reader :model_name + private - attr_reader :dialect_klass, :gateway, :model_name + attr_reader :dialect_klass, :gateway end end end diff --git a/lib/inference/amazon_bedrock_inference.rb b/lib/inference/amazon_bedrock_inference.rb deleted file mode 100644 index 2ce1014f..00000000 --- a/lib/inference/amazon_bedrock_inference.rb +++ /dev/null @@ -1,168 +0,0 @@ -# frozen_string_literal: true - -require "base64" -require "json" -require "aws-eventstream" -require "aws-sigv4" - -module ::DiscourseAi - module Inference - class AmazonBedrockInference - CompletionFailed = Class.new(StandardError) - TIMEOUT = 60 - - def self.perform!( - prompt, - model = "anthropic.claude-v2", - temperature: nil, - top_p: nil, - top_k: nil, - max_tokens: 20_000, - user_id: nil, - stop_sequences: nil, - tokenizer: Tokenizer::AnthropicTokenizer - ) - raise CompletionFailed if model.blank? - raise CompletionFailed if !SiteSetting.ai_bedrock_access_key_id.present? - raise CompletionFailed if !SiteSetting.ai_bedrock_secret_access_key.present? - raise CompletionFailed if !SiteSetting.ai_bedrock_region.present? - - signer = - Aws::Sigv4::Signer.new( - access_key_id: SiteSetting.ai_bedrock_access_key_id, - region: SiteSetting.ai_bedrock_region, - secret_access_key: SiteSetting.ai_bedrock_secret_access_key, - service: "bedrock", - ) - - log = nil - response_data = +"" - response_raw = +"" - - url_api = - "https://bedrock-runtime.#{SiteSetting.ai_bedrock_region}.amazonaws.com/model/#{model}/" - if block_given? - url_api = url_api + "invoke-with-response-stream" - else - url_api = url_api + "invoke" - end - - url = URI(url_api) - headers = { "content-type" => "application/json", "Accept" => "*/*" } - - payload = { prompt: prompt } - - payload[:top_p] = top_p if top_p - payload[:top_k] = top_k if top_k - payload[:max_tokens_to_sample] = max_tokens || 2000 - payload[:temperature] = temperature if temperature - payload[:stop_sequences] = stop_sequences if stop_sequences - - Net::HTTP.start( - url.host, - url.port, - use_ssl: true, - read_timeout: TIMEOUT, - open_timeout: TIMEOUT, - write_timeout: TIMEOUT, - ) do |http| - request = Net::HTTP::Post.new(url) - request_body = payload.to_json - request.body = request_body - - signed_request = - signer.sign_request( - req: request, - http_method: request.method, - url: url, - body: request.body, - ) - - request.initialize_http_header(headers.merge!(signed_request.headers)) - - http.request(request) do |response| - if response.code.to_i != 200 - Rails.logger.error( - "BedRockInference: status: #{response.code.to_i} - body: #{response.body}", - ) - raise CompletionFailed - end - - log = - AiApiAuditLog.create!( - provider_id: AiApiAuditLog::Provider::Anthropic, - raw_request_payload: request_body, - user_id: user_id, - ) - - if !block_given? - response_body = response.read_body - parsed_response = JSON.parse(response_body, symbolize_names: true) - - log.update!( - raw_response_payload: response_body, - request_tokens: tokenizer.size(prompt), - response_tokens: tokenizer.size(parsed_response[:completion]), - ) - return parsed_response - end - - begin - cancelled = false - cancel = lambda { cancelled = true } - decoder = Aws::EventStream::Decoder.new - - response.read_body do |chunk| - if cancelled - http.finish - return - end - - response_raw << chunk - - begin - message = decoder.decode_chunk(chunk) - - partial = - message - .first - .payload - .string - .then { JSON.parse(_1) } - .dig("bytes") - .then { Base64.decode64(_1) } - .then { JSON.parse(_1, symbolize_names: true) } - - next if !partial - - response_data << partial[:completion].to_s - - yield partial, cancel if partial[:completion] - rescue JSON::ParserError, - Aws::EventStream::Errors::MessageChecksumError, - Aws::EventStream::Errors::PreludeChecksumError => e - Rails.logger.error("BedrockInference: #{e}") - end - rescue IOError - raise if !cancelled - end - end - - return response_data - end - ensure - if block_given? - log.update!( - raw_response_payload: response_data, - request_tokens: tokenizer.size(prompt), - response_tokens: tokenizer.size(response_data), - ) - end - if Rails.env.development? && log - puts "BedrockInference: request_tokens #{log.request_tokens} response_tokens #{log.response_tokens}" - end - end - end - end - end -end diff --git a/lib/inference/anthropic_completions.rb b/lib/inference/anthropic_completions.rb deleted file mode 100644 index 1c7c17ef..00000000 --- a/lib/inference/anthropic_completions.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class AnthropicCompletions - CompletionFailed = Class.new(StandardError) - TIMEOUT = 60 - - def self.perform!( - prompt, - model = "claude-2", - temperature: nil, - top_p: nil, - max_tokens: nil, - user_id: nil, - stop_sequences: nil, - post: nil, - &blk - ) - # HACK to get around the fact that they have different APIs - # we will introduce a proper LLM abstraction layer to handle this shenanigas later this year - if model == "claude-2" && SiteSetting.ai_bedrock_access_key_id.present? && - SiteSetting.ai_bedrock_secret_access_key.present? && - SiteSetting.ai_bedrock_region.present? - return( - AmazonBedrockInference.perform!( - prompt, - temperature: temperature, - top_p: top_p, - max_tokens: max_tokens, - user_id: user_id, - stop_sequences: stop_sequences, - &blk - ) - ) - end - - log = nil - response_data = +"" - response_raw = +"" - - url = URI("https://api.anthropic.com/v1/complete") - headers = { - "anthropic-version" => "2023-06-01", - "x-api-key" => SiteSetting.ai_anthropic_api_key, - "content-type" => "application/json", - } - - payload = { model: model, prompt: prompt } - - payload[:top_p] = top_p if top_p - payload[:max_tokens_to_sample] = max_tokens || 2000 - payload[:temperature] = temperature if temperature - payload[:stream] = true if block_given? - payload[:stop_sequences] = stop_sequences if stop_sequences - - Net::HTTP.start( - url.host, - url.port, - use_ssl: true, - read_timeout: TIMEOUT, - open_timeout: TIMEOUT, - write_timeout: TIMEOUT, - ) do |http| - request = Net::HTTP::Post.new(url, headers) - request_body = payload.to_json - request.body = request_body - - http.request(request) do |response| - if response.code.to_i != 200 - Rails.logger.error( - "AnthropicCompletions: status: #{response.code.to_i} - body: #{response.body}", - ) - raise CompletionFailed - end - - log = - AiApiAuditLog.create!( - provider_id: AiApiAuditLog::Provider::Anthropic, - raw_request_payload: request_body, - user_id: user_id, - post_id: post&.id, - topic_id: post&.topic_id, - ) - - if !block_given? - response_body = response.read_body - parsed_response = JSON.parse(response_body, symbolize_names: true) - - log.update!( - raw_response_payload: response_body, - request_tokens: DiscourseAi::Tokenizer::AnthropicTokenizer.size(prompt), - response_tokens: - DiscourseAi::Tokenizer::AnthropicTokenizer.size(parsed_response[:completion]), - ) - return parsed_response - end - - begin - cancelled = false - cancel = lambda { cancelled = true } - - response.read_body do |chunk| - if cancelled - http.finish - return - end - - response_raw << chunk - - chunk - .split("\n") - .each do |line| - data = line.split("data: ", 2)[1] - next if !data - - if !cancelled - begin - partial = JSON.parse(data, symbolize_names: true) - response_data << partial[:completion].to_s - - # ping has no data... do not yeild it - yield partial, cancel if partial[:completion] - rescue JSON::ParserError - nil - # TODO leftover chunk carry over to next - end - end - end - rescue IOError - raise if !cancelled - end - end - - return response_data - end - ensure - if block_given? - log.update!( - raw_response_payload: response_raw, - request_tokens: DiscourseAi::Tokenizer::AnthropicTokenizer.size(prompt), - response_tokens: DiscourseAi::Tokenizer::AnthropicTokenizer.size(response_data), - ) - end - if Rails.env.development? && log - puts "AnthropicCompletions: request_tokens #{log.request_tokens} response_tokens #{log.response_tokens}" - end - end - - def self.try_parse(data) - JSON.parse(data, symbolize_names: true) - rescue JSON::ParserError - nil - end - end - end - end -end diff --git a/lib/inference/function.rb b/lib/inference/function.rb deleted file mode 100644 index e19ae931..00000000 --- a/lib/inference/function.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class Function - attr_reader :name, :description, :parameters, :type - - def initialize(name:, description:, type: nil) - @name = name - @description = description - @type = type || "object" - @parameters = [] - end - - def add_parameter(parameter = nil, **kwargs) - if parameter - add_parameter_kwargs( - name: parameter.name, - type: parameter.type, - description: parameter.description, - required: parameter.required, - enum: parameter.enum, - item_type: parameter.item_type, - ) - else - add_parameter_kwargs(**kwargs) - end - end - - def add_parameter_kwargs( - name:, - type:, - description:, - enum: nil, - required: false, - item_type: nil - ) - param = { name: name, type: type, description: description, enum: enum, required: required } - param[:enum] = enum if enum - param[:item_type] = item_type if item_type - - @parameters << param - end - - def to_json(*args) - as_json.to_json(*args) - end - - def as_json - required_params = [] - - properties = {} - parameters.each do |parameter| - definition = { type: parameter[:type], description: parameter[:description] } - definition[:enum] = parameter[:enum] if parameter[:enum] - definition[:items] = { type: parameter[:item_type] } if parameter[:item_type] - required_params << parameter[:name] if parameter[:required] - properties[parameter[:name]] = definition - end - - params = { type: @type, properties: properties } - - params[:required] = required_params if required_params.present? - - { name: name, description: description, parameters: params } - end - end - end -end diff --git a/lib/inference/function_list.rb b/lib/inference/function_list.rb deleted file mode 100644 index 713565a7..00000000 --- a/lib/inference/function_list.rb +++ /dev/null @@ -1,119 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class FunctionList - def initialize - @functions = [] - end - - def <<(function) - @functions << function - end - - def parse_prompt(prompt) - xml = prompt.sub(%r{(.*)}m, '\1') - if xml.present? - parsed = [] - Nokogiri - .XML(xml) - .xpath("//invoke") - .each do |invoke_node| - function = { name: invoke_node.xpath("//tool_name").text, arguments: {} } - parsed << function - invoke_node - .xpath("//parameters") - .children - .each do |parameters_node| - if parameters_node.is_a?(Nokogiri::XML::Element) && name = parameters_node.name - function[:arguments][name.to_sym] = parameters_node.text - end - end - end - coerce_arguments!(parsed) - end - end - - def coerce_arguments!(parsed) - parsed.each do |function_call| - arguments = function_call[:arguments] - - function = @functions.find { |f| f.name == function_call[:name] } - next if !function - - arguments.each do |name, value| - parameter = function.parameters.find { |p| p[:name].to_s == name.to_s } - if !parameter - arguments.delete(name) - next - end - - type = parameter[:type] - if type == "array" - begin - arguments[name] = JSON.parse(value) - rescue JSON::ParserError - # maybe LLM chose a different shape for the array - arguments[name] = value.to_s.split("\n").map(&:strip).reject(&:blank?) - end - elsif type == "integer" - arguments[name] = value.to_i - elsif type == "float" - arguments[name] = value.to_f - end - end - end - parsed - end - - def system_prompt - tools = +"" - - @functions.each do |function| - parameters = +"" - if function.parameters.present? - parameters << "\n" - function.parameters.each do |parameter| - parameters << <<~PARAMETER - - #{parameter[:name]} - #{parameter[:type]} - #{parameter[:description]} - #{parameter[:required]} - PARAMETER - parameters << "#{parameter[:enum].join(",")}\n" if parameter[:enum] - parameters << "\n" - end - end - - tools << <<~TOOLS - - #{function.name} - #{function.description} - #{parameters} - - TOOLS - end - - <<~PROMPT - In this environment you have access to a set of tools you can use to answer the user's question. - You may call them like this. Only invoke one function at a time and wait for the results before invoking another function: - - - $TOOL_NAME - - <$PARAMETER_NAME>$PARAMETER_VALUE - ... - - - - - Here are the tools available: - - - #{tools} - PROMPT - end - end - end -end diff --git a/lib/inference/hugging_face_text_generation.rb b/lib/inference/hugging_face_text_generation.rb deleted file mode 100644 index 049bba81..00000000 --- a/lib/inference/hugging_face_text_generation.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class HuggingFaceTextGeneration - CompletionFailed = Class.new(StandardError) - TIMEOUT = 120 - - def self.perform!( - prompt, - model, - temperature: 0.7, - top_p: nil, - top_k: nil, - typical_p: nil, - max_tokens: 2000, - repetition_penalty: 1.1, - user_id: nil, - tokenizer: DiscourseAi::Tokenizer::Llama2Tokenizer, - token_limit: nil - ) - raise CompletionFailed if model.blank? - - url = URI(SiteSetting.ai_hugging_face_api_url) - headers = { "Content-Type" => "application/json" } - - if SiteSetting.ai_hugging_face_api_key.present? - headers["Authorization"] = "Bearer #{SiteSetting.ai_hugging_face_api_key}" - end - - token_limit = token_limit || SiteSetting.ai_hugging_face_token_limit - - parameters = {} - payload = { inputs: prompt, parameters: parameters } - prompt_size = tokenizer.size(prompt) - - parameters[:top_p] = top_p if top_p - parameters[:top_k] = top_k if top_k - parameters[:typical_p] = typical_p if typical_p - parameters[:max_new_tokens] = token_limit - prompt_size - parameters[:temperature] = temperature if temperature - parameters[:repetition_penalty] = repetition_penalty if repetition_penalty - parameters[:return_full_text] = false - - payload[:stream] = true if block_given? - - Net::HTTP.start( - url.host, - url.port, - use_ssl: url.scheme == "https", - read_timeout: TIMEOUT, - open_timeout: TIMEOUT, - write_timeout: TIMEOUT, - ) do |http| - request = Net::HTTP::Post.new(url, headers) - request_body = payload.to_json - request.body = request_body - - http.request(request) do |response| - if response.code.to_i != 200 - Rails.logger.error( - "HuggingFaceTextGeneration: status: #{response.code.to_i} - body: #{response.body}", - ) - raise CompletionFailed - end - - log = - AiApiAuditLog.create!( - provider_id: AiApiAuditLog::Provider::HuggingFaceTextGeneration, - raw_request_payload: request_body, - user_id: user_id, - ) - - if !block_given? - response_body = response.read_body - parsed_response = JSON.parse(response_body, symbolize_names: true) - - log.update!( - raw_response_payload: response_body, - request_tokens: tokenizer.size(prompt), - response_tokens: tokenizer.size(parsed_response.first[:generated_text]), - ) - return parsed_response - end - - response_data = +"" - - begin - cancelled = false - cancel = lambda { cancelled = true } - response_raw = +"" - - response.read_body do |chunk| - if cancelled - http.finish - return - end - - response_raw << chunk - - chunk - .split("\n") - .each do |line| - data = line.split("data:", 2)[1] - next if !data || data.squish == "[DONE]" - - if !cancelled - begin - # partial contains the entire payload till now - partial = JSON.parse(data, symbolize_names: true) - - # this is the last chunk and contains the full response - next if partial[:token][:special] == true - - response_data << partial[:token][:text].to_s - - yield partial, cancel - rescue JSON::ParserError - nil - end - end - end - rescue IOError - raise if !cancelled - ensure - log.update!( - raw_response_payload: response_raw, - request_tokens: tokenizer.size(prompt), - response_tokens: tokenizer.size(response_data), - ) - end - end - - return response_data - end - end - - def self.try_parse(data) - JSON.parse(data, symbolize_names: true) - rescue JSON::ParserError - nil - end - end - end - end -end diff --git a/lib/inference/open_ai_completions.rb b/lib/inference/open_ai_completions.rb deleted file mode 100644 index 71dd8bdc..00000000 --- a/lib/inference/open_ai_completions.rb +++ /dev/null @@ -1,194 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class OpenAiCompletions - TIMEOUT = 60 - DEFAULT_RETRIES = 3 - DEFAULT_RETRY_TIMEOUT_SECONDS = 3 - RETRY_TIMEOUT_BACKOFF_MULTIPLIER = 3 - - CompletionFailed = Class.new(StandardError) - - def self.perform!( - messages, - model, - temperature: nil, - top_p: nil, - max_tokens: nil, - functions: nil, - user_id: nil, - retries: DEFAULT_RETRIES, - retry_timeout: DEFAULT_RETRY_TIMEOUT_SECONDS, - post: nil, - &blk - ) - log = nil - response_data = +"" - response_raw = +"" - - url = - if model.include?("gpt-4") - if model.include?("turbo") || model.include?("1106-preview") - URI(SiteSetting.ai_openai_gpt4_turbo_url) - elsif model.include?("32k") - URI(SiteSetting.ai_openai_gpt4_32k_url) - else - URI(SiteSetting.ai_openai_gpt4_url) - end - else - if model.include?("16k") - URI(SiteSetting.ai_openai_gpt35_16k_url) - else - URI(SiteSetting.ai_openai_gpt35_url) - end - end - headers = { "Content-Type" => "application/json" } - - if url.host.include?("azure") - headers["api-key"] = SiteSetting.ai_openai_api_key - else - headers["Authorization"] = "Bearer #{SiteSetting.ai_openai_api_key}" - end - - if SiteSetting.ai_openai_organization.present? - headers["OpenAI-Organization"] = SiteSetting.ai_openai_organization - end - - payload = { model: model, messages: messages } - - payload[:temperature] = temperature if temperature - payload[:top_p] = top_p if top_p - payload[:max_tokens] = max_tokens if max_tokens - payload[:functions] = functions if functions - payload[:stream] = true if block_given? - - Net::HTTP.start( - url.host, - url.port, - use_ssl: true, - read_timeout: TIMEOUT, - open_timeout: TIMEOUT, - write_timeout: TIMEOUT, - ) do |http| - request = Net::HTTP::Post.new(url, headers) - request_body = payload.to_json - request.body = request_body - - http.request(request) do |response| - if retries > 0 && response.code.to_i == 429 - sleep(retry_timeout) - retries -= 1 - retry_timeout *= RETRY_TIMEOUT_BACKOFF_MULTIPLIER - return( - perform!( - messages, - model, - temperature: temperature, - top_p: top_p, - max_tokens: max_tokens, - functions: functions, - user_id: user_id, - retries: retries, - retry_timeout: retry_timeout, - &blk - ) - ) - elsif response.code.to_i != 200 - Rails.logger.error( - "OpenAiCompletions: status: #{response.code.to_i} - body: #{response.body}", - ) - raise CompletionFailed, "status: #{response.code.to_i} - body: #{response.body}" - end - - log = - AiApiAuditLog.create!( - provider_id: AiApiAuditLog::Provider::OpenAI, - raw_request_payload: request_body, - user_id: user_id, - post_id: post&.id, - topic_id: post&.topic_id, - ) - - if !blk - response_body = response.read_body - parsed_response = JSON.parse(response_body, symbolize_names: true) - - log.update!( - raw_response_payload: response_body, - request_tokens: parsed_response.dig(:usage, :prompt_tokens), - response_tokens: parsed_response.dig(:usage, :completion_tokens), - ) - return parsed_response - end - - begin - cancelled = false - cancel = lambda { cancelled = true } - - leftover = "" - - response.read_body do |chunk| - if cancelled - http.finish - break - end - - response_raw << chunk - - if (leftover + chunk).length < "data: [DONE]".length - leftover += chunk - next - end - - (leftover + chunk) - .split("\n") - .each do |line| - data = line.split("data: ", 2)[1] - next if !data || data == "[DONE]" - next if cancelled - - partial = nil - begin - partial = JSON.parse(data, symbolize_names: true) - leftover = "" - rescue JSON::ParserError - leftover = line - end - - if partial - response_data << partial.dig(:choices, 0, :delta, :content).to_s - response_data << partial.dig(:choices, 0, :delta, :function_call).to_s - - blk.call(partial, cancel) - end - end - rescue IOError - raise if !cancelled - end - end - - return response_data - end - end - ensure - if log && block_given? - request_tokens = DiscourseAi::Tokenizer::OpenAiTokenizer.size(extract_prompt(messages)) - response_tokens = DiscourseAi::Tokenizer::OpenAiTokenizer.size(response_data) - log.update!( - raw_response_payload: response_raw, - request_tokens: request_tokens, - response_tokens: response_tokens, - ) - end - if log && Rails.env.development? - puts "OpenAiCompletions: request_tokens #{log.request_tokens} response_tokens #{log.response_tokens}" - end - end - - def self.extract_prompt(messages) - messages.map { |message| message[:content] || message["content"] || "" }.join("\n") - end - end - end -end diff --git a/plugin.rb b/plugin.rb index 0872cb2f..385e4fbe 100644 --- a/plugin.rb +++ b/plugin.rb @@ -58,9 +58,7 @@ after_initialize do end if Rails.env.test? - require_relative "spec/support/openai_completions_inference_stubs" - require_relative "spec/support/anthropic_completion_stubs" - require_relative "spec/support/stable_diffusion_stubs" require_relative "spec/support/embeddings_generation_stubs" + require_relative "spec/support/stable_diffusion_stubs" end end diff --git a/spec/lib/completions/endpoints/anthropic_spec.rb b/spec/lib/completions/endpoints/anthropic_spec.rb index 4c39c2e7..9f74d377 100644 --- a/spec/lib/completions/endpoints/anthropic_spec.rb +++ b/spec/lib/completions/endpoints/anthropic_spec.rb @@ -64,7 +64,7 @@ RSpec.describe DiscourseAi::Completions::Endpoints::Anthropic do .to_return(status: 200, body: chunks) end - let(:tool_deltas) { [" get_weather diff --git a/spec/lib/modules/ai_bot/anthropic_bot_spec.rb b/spec/lib/modules/ai_bot/anthropic_bot_spec.rb deleted file mode 100644 index 1902f85c..00000000 --- a/spec/lib/modules/ai_bot/anthropic_bot_spec.rb +++ /dev/null @@ -1,183 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module AiBot - describe AnthropicBot do - def bot_user - User.find(EntryPoint::CLAUDE_V2_ID) - end - - before do - SiteSetting.ai_bot_enabled_chat_bots = "claude-2" - SiteSetting.ai_bot_enabled = true - end - - let(:bot) { described_class.new(bot_user) } - fab!(:post) - - describe "system message" do - it "includes the full command framework" do - prompt = bot.system_prompt(post, allow_commands: true) - - expect(prompt).to include("read") - expect(prompt).to include("search_query") - end - end - - it "does not include half parsed function calls in reply" do - completion1 = " - - search - - hello world - - - - junk - REPLY - - completion1 = { completion: completion1 }.to_json - completion2 = { completion: completion2 }.to_json - - completion3 = { completion: " should be stripped - expect(raw).to start_with("\n\n - - search - - hello world - 77 - - - " - - bot.populate_functions( - partial: nil, - reply: prompt, - functions: functions, - done: true, - current_delta: "", - ) - - expect(functions.found?).to eq(true) - - expect(functions.to_a.length).to eq(1) - - expect(functions.to_a).to eq( - [{ name: "search", arguments: "{\"search_query\":\"hello world\"}" }], - ) - end - end - - describe "#update_with_delta" do - describe "get_delta" do - it "can properly remove first leading space" do - context = {} - reply = +"" - - reply << bot.get_delta({ completion: " Hello" }, context) - reply << bot.get_delta({ completion: " World" }, context) - expect(reply).to eq("Hello World") - end - - it "can properly remove Assistant prefix" do - context = {} - reply = +"" - - reply << bot.get_delta({ completion: "Hello " }, context) - expect(reply).to eq("Hello ") - - reply << bot.get_delta({ completion: "world" }, context) - expect(reply).to eq("Hello world") - end - end - end - end - end -end diff --git a/spec/lib/modules/ai_bot/bot_spec.rb b/spec/lib/modules/ai_bot/bot_spec.rb index 216f0379..d40f3a47 100644 --- a/spec/lib/modules/ai_bot/bot_spec.rb +++ b/spec/lib/modules/ai_bot/bot_spec.rb @@ -1,116 +1,16 @@ # frozen_string_literal: true -class FakeBot < DiscourseAi::AiBot::Bot - class Tokenizer - def tokenize(text) - text.split(" ") - end - end +RSpec.describe DiscourseAi::AiBot::Bot do + subject(:bot) { described_class.as(bot_user) } - def tokenizer - Tokenizer.new - end - - def prompt_limit(allow_commands: false) - 10_000 - end - - def build_message(poster_username, content, system: false, function: nil) - role = poster_username == bot_user.username ? "Assistant" : "Human" - - "#{role}: #{content}" - end - - def submit_prompt(prompt, post: nil, prefer_low_cost: false) - rows = @responses.shift - rows.each { |data| yield data, lambda {} } - end - - def get_delta(partial, context) - partial - end - - def add_response(response) - @responses ||= [] - @responses << response - end -end - -describe FakeBot do before do SiteSetting.ai_bot_enabled_chat_bots = "gpt-4" SiteSetting.ai_bot_enabled = true end let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT4_ID) } - fab!(:post) { Fabricate(:post, raw: "hello world") } - it "can handle command truncation for long messages" do - bot = FakeBot.new(bot_user) - - tags_command = <<~TEXT - - - tags - - - TEXT - - bot.add_response(["hello this is a big test I am testing 123\n", "#{tags_command}\nabc"]) - bot.add_response(["this is the reply"]) - - bot.reply_to(post) - - reply = post.topic.posts.order(:post_number).last - - expect(reply.raw).not_to include("abc") - expect(reply.post_custom_prompt.custom_prompt.to_s).not_to include("abc") - expect(reply.post_custom_prompt.custom_prompt.length).to eq(3) - expect(reply.post_custom_prompt.custom_prompt[0][0]).to eq( - "hello this is a big test I am testing 123\n#{tags_command.strip}", - ) - end - - it "can handle command truncation for short bot messages" do - bot = FakeBot.new(bot_user) - - tags_command = <<~TEXT - _calls> - - tags - - - TEXT - - bot.add_response(["hello\n") - end -end - -describe DiscourseAi::AiBot::Bot do - before do - SiteSetting.ai_bot_enabled_chat_bots = "gpt-4" - SiteSetting.ai_bot_enabled = true - end - - let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT4_ID) } - let(:bot) { described_class.as(bot_user) } - - fab!(:user) { Fabricate(:user) } + let!(:user) { Fabricate(:user) } let!(:pm) do Fabricate( :private_message_topic, @@ -122,101 +22,39 @@ describe DiscourseAi::AiBot::Bot do ], ) end - let!(:first_post) { Fabricate(:post, topic: pm, user: user, raw: "This is a reply by the user") } - let!(:second_post) do - Fabricate(:post, topic: pm, user: user, raw: "This is a second reply by the user") - end + let!(:pm_post) { Fabricate(:post, topic: pm, user: user, raw: "Does my site has tags?") } - describe "#system_prompt" do - it "includes relevant context in system prompt" do - bot.system_prompt_style!(:standard) + let(:function_call) { <<~TEXT } + Let me try using a function to get more info: + + categories + + + TEXT - SiteSetting.title = "My Forum" - SiteSetting.site_description = "My Forum Description" + let(:response) { "As expected, your forum has multiple tags" } - system_prompt = bot.system_prompt(second_post, allow_commands: true) + let(:llm_responses) { [function_call, response] } - expect(system_prompt).to include(SiteSetting.title) - expect(system_prompt).to include(SiteSetting.site_description) + describe "#reply" 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({}) + partial_placeholder = +(<<~HTML) +
+ #{tool.summary} +

+
+ HTML - expect(system_prompt).to include(user.username) - end - end + context = {} - describe "#reply_to" do - it "can respond to a search command" do - bot.system_prompt_style!(:simple) - - expected_response = { - function_call: { - name: "search", - arguments: { query: "test search" }.to_json, - }, - } - - prompt = bot.bot_prompt_with_topic_context(second_post, allow_commands: true) - - req_opts = bot.reply_params.merge({ functions: bot.available_functions, stream: true }) - - OpenAiCompletionsInferenceStubs.stub_streamed_response( - prompt, - [expected_response], - model: bot.model_for, - req_opts: req_opts, - ) - - result = - DiscourseAi::AiBot::Commands::SearchCommand - .new(bot: nil, args: nil) - .process(query: "test search") - .to_json - - prompt << { role: "function", content: result, name: "search" } - - OpenAiCompletionsInferenceStubs.stub_streamed_response( - prompt, - [content: "I found nothing, sorry"], - model: bot.model_for, - req_opts: req_opts, - ) - - bot.reply_to(second_post) - - last = second_post.topic.posts.order("id desc").first - - expect(last.raw).to include("
") - expect(last.raw).to include("Search") - expect(last.raw).not_to include("translation missing") - expect(last.raw).to include("I found nothing") - - expect(last.post_custom_prompt.custom_prompt).to eq( - [[result, "search", "function"], ["I found nothing, sorry", bot_user.username]], - ) - log = AiApiAuditLog.find_by(post_id: second_post.id) - expect(log).to be_present - end - end - - describe "#update_pm_title" do - let(:expected_response) { "This is a suggested title" } - - before { SiteSetting.min_personal_message_post_length = 5 } - - it "updates the title using bot suggestions" do - OpenAiCompletionsInferenceStubs.stub_response( - bot.title_prompt(second_post), - expected_response, - model: bot.model_for, - req_opts: { - temperature: 0.7, - top_p: 0.9, - max_tokens: 40, - }, - ) - - bot.update_pm_title(second_post) - - expect(pm.reload.title).to eq(expected_response) + DiscourseAi::Completions::Llm.with_prepared_responses(llm_responses) do + bot.reply(context) do |_bot_reply_post, cancel, placeholder| + expect(placeholder).to eq(partial_placeholder) if placeholder + end + end + end end end end diff --git a/spec/lib/modules/ai_bot/commands/categories_command_spec.rb b/spec/lib/modules/ai_bot/commands/categories_command_spec.rb deleted file mode 100644 index e0eff204..00000000 --- a/spec/lib/modules/ai_bot/commands/categories_command_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::Commands::CategoriesCommand do - describe "#generate_categories_info" do - it "can generate correct info" do - Fabricate(:category, name: "america", posts_year: 999) - - info = DiscourseAi::AiBot::Commands::CategoriesCommand.new(bot: nil, args: nil).process - expect(info.to_s).to include("america") - expect(info.to_s).to include("999") - end - end -end diff --git a/spec/lib/modules/ai_bot/commands/command_spec.rb b/spec/lib/modules/ai_bot/commands/command_spec.rb deleted file mode 100644 index 1885037a..00000000 --- a/spec/lib/modules/ai_bot/commands/command_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::Commands::Command do - let(:command) { DiscourseAi::AiBot::Commands::GoogleCommand.new(bot: nil, args: nil) } - - before { SiteSetting.ai_bot_enabled = true } - - describe "#format_results" do - it "can generate efficient tables of data" do - rows = [1, 2, 3, 4, 5] - column_names = %w[first second third] - - formatted = - command.format_results(rows, column_names) { |row| ["row ¦ 1", row + 1, "a|b,\nc"] } - - expect(formatted[:column_names].length).to eq(3) - expect(formatted[:rows].length).to eq(5) - expect(formatted.to_s).to include("a|b,\\nc") - end - - it "can also generate results by returning hash per row" do - rows = [1, 2, 3, 4, 5] - column_names = %w[first second third] - - formatted = - command.format_results(rows, column_names) { |row| ["row ¦ 1", row + 1, "a|b,\nc"] } - - formatted2 = - command.format_results(rows) do |row| - { first: "row ¦ 1", second: row + 1, third: "a|b,\nc" } - end - - expect(formatted).to eq(formatted2) - end - end -end diff --git a/spec/lib/modules/ai_bot/commands/search_command_spec.rb b/spec/lib/modules/ai_bot/commands/search_command_spec.rb deleted file mode 100644 index 78b580e6..00000000 --- a/spec/lib/modules/ai_bot/commands/search_command_spec.rb +++ /dev/null @@ -1,207 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do - before { SearchIndexer.enable } - after { SearchIndexer.disable } - - let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } - fab!(:admin) - fab!(:parent_category) { Fabricate(:category, name: "animals") } - fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") } - fab!(:tag_funny) { Fabricate(:tag, name: "funny") } - fab!(:tag_sad) { Fabricate(:tag, name: "sad") } - fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") } - fab!(:staff_tag_group) do - tag_group = Fabricate.build(:tag_group, name: "Staff only", tag_names: ["hidden"]) - - tag_group.permissions = [ - [Group::AUTO_GROUPS[:staff], TagGroupPermission.permission_types[:full]], - ] - tag_group.save! - tag_group - end - fab!(:topic_with_tags) do - Fabricate(:topic, category: category, tags: [tag_funny, tag_sad, tag_hidden]) - end - - before { SiteSetting.ai_bot_enabled = true } - - it "can properly list options" do - options = described_class.options.sort_by(&:name) - expect(options.length).to eq(2) - expect(options.first.name.to_s).to eq("base_query") - expect(options.first.localized_name).not_to include("Translation missing:") - expect(options.first.localized_description).not_to include("Translation missing:") - - expect(options.second.name.to_s).to eq("max_results") - expect(options.second.localized_name).not_to include("Translation missing:") - expect(options.second.localized_description).not_to include("Translation missing:") - end - - describe "#process" do - it "can retreive options from persona correctly" do - persona = - Fabricate( - :ai_persona, - allowed_group_ids: [Group::AUTO_GROUPS[:admins]], - commands: [["SearchCommand", { "base_query" => "#funny" }]], - ) - Group.refresh_automatic_groups! - - bot = DiscourseAi::AiBot::Bot.as(bot_user, persona_id: persona.id, user: admin) - search_post = Fabricate(:post, topic: topic_with_tags) - - bot_post = Fabricate(:post) - - search = described_class.new(bot: bot, post: bot_post, args: nil) - - results = search.process(order: "latest") - expect(results[:rows].length).to eq(1) - - search_post.topic.tags = [] - search_post.topic.save! - - # no longer has the tag funny - results = search.process(order: "latest") - expect(results[:rows].length).to eq(0) - end - - it "can handle no results" do - post1 = Fabricate(:post, topic: topic_with_tags) - search = described_class.new(bot: nil, post: post1, args: nil) - - results = search.process(query: "order:fake ABDDCDCEDGDG") - - expect(results[:args]).to eq({ query: "order:fake ABDDCDCEDGDG" }) - expect(results[:rows]).to eq([]) - end - - describe "semantic search" do - let (:query) { - "this is an expanded search" - } - after { DiscourseAi::Embeddings::SemanticSearch.clear_cache_for(query) } - - it "supports semantic search when enabled" do - SiteSetting.ai_embeddings_semantic_search_enabled = true - SiteSetting.ai_embeddings_discourse_service_api_endpoint = "http://test.com" - - WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - status: 200, - body: JSON.dump(OpenAiCompletionsInferenceStubs.response(query)), - ) - - hyde_embedding = [0.049382, 0.9999] - EmbeddingsGenerationStubs.discourse_service( - SiteSetting.ai_embeddings_model, - query, - hyde_embedding, - ) - - post1 = Fabricate(:post, topic: topic_with_tags) - search = described_class.new(bot: nil, post: post1, args: nil) - - DiscourseAi::Embeddings::VectorRepresentations::AllMpnetBaseV2 - .any_instance - .expects(:asymmetric_topics_similarity_search) - .returns([post1.topic_id]) - - results = - DiscourseAi::Completions::Llm.with_prepared_responses(["#{query}"]) do - search.process(search_query: "hello world, sam", status: "public") - end - - expect(results[:args]).to eq({ search_query: "hello world, sam", status: "public" }) - expect(results[:rows].length).to eq(1) - end - end - - it "supports subfolder properly" do - Discourse.stubs(:base_path).returns("/subfolder") - - post1 = Fabricate(:post, topic: topic_with_tags) - - search = described_class.new(bot: nil, post: post1, args: nil) - - results = search.process(limit: 1, user: post1.user.username) - expect(results[:rows].to_s).to include("/subfolder" + post1.url) - end - - it "returns rich topic information" do - post1 = Fabricate(:post, like_count: 1, topic: topic_with_tags) - search = described_class.new(bot: nil, post: post1, args: nil) - post1.topic.update!(views: 100, posts_count: 2, like_count: 10) - - results = search.process(user: post1.user.username) - - row = results[:rows].first - category = row[results[:column_names].index("category")] - - expect(category).to eq("animals > amazing-cat") - - tags = row[results[:column_names].index("tags")] - expect(tags).to eq("funny, sad") - - likes = row[results[:column_names].index("likes")] - expect(likes).to eq(1) - - username = row[results[:column_names].index("username")] - expect(username).to eq(post1.user.username) - - likes = row[results[:column_names].index("topic_likes")] - expect(likes).to eq(10) - - views = row[results[:column_names].index("topic_views")] - expect(views).to eq(100) - - replies = row[results[:column_names].index("topic_replies")] - expect(replies).to eq(1) - end - - it "scales results to number of tokens" do - SiteSetting.ai_bot_enabled_chat_bots = "gpt-3.5-turbo|gpt-4|claude-2" - - post1 = Fabricate(:post) - - gpt_3_5_turbo = - DiscourseAi::AiBot::Bot.as(User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID)) - gpt4 = DiscourseAi::AiBot::Bot.as(User.find(DiscourseAi::AiBot::EntryPoint::GPT4_ID)) - claude = DiscourseAi::AiBot::Bot.as(User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID)) - - expect(described_class.new(bot: claude, post: post1, args: nil).max_results).to eq(60) - expect(described_class.new(bot: gpt_3_5_turbo, post: post1, args: nil).max_results).to eq(40) - expect(described_class.new(bot: gpt4, post: post1, args: nil).max_results).to eq(20) - - persona = - Fabricate( - :ai_persona, - commands: [["SearchCommand", { "max_results" => 6 }]], - enabled: true, - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - ) - - Group.refresh_automatic_groups! - - custom_bot = DiscourseAi::AiBot::Bot.as(bot_user, persona_id: persona.id, user: admin) - - expect(described_class.new(bot: custom_bot, post: post1, args: nil).max_results).to eq(6) - end - - it "can handle limits" do - post1 = Fabricate(:post, topic: topic_with_tags) - _post2 = Fabricate(:post, user: post1.user) - _post3 = Fabricate(:post, user: post1.user) - - # search has no built in support for limit: so handle it from the outside - search = described_class.new(bot: nil, post: post1, args: nil) - - results = search.process(limit: 2, user: post1.user.username) - - expect(results[:rows].length).to eq(2) - - # just searching for everything - results = search.process(order: "latest_topic") - expect(results[:rows].length).to be > 1 - end - end -end diff --git a/spec/lib/modules/ai_bot/commands/search_settings_command_spec.rb b/spec/lib/modules/ai_bot/commands/search_settings_command_spec.rb deleted file mode 100644 index c0752bcd..00000000 --- a/spec/lib/modules/ai_bot/commands/search_settings_command_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::Commands::SearchSettingsCommand do - let(:search) { described_class.new(bot: nil, args: nil) } - - describe "#process" do - it "can handle no results" do - results = search.process(query: "this will not exist frogs") - expect(results[:args]).to eq({ query: "this will not exist frogs" }) - expect(results[:rows]).to eq([]) - end - - it "can return more many settings with no descriptions if there are lots of hits" do - results = search.process(query: "a") - - expect(results[:rows].length).to be > 30 - expect(results[:rows][0].length).to eq(1) - end - - it "can return descriptions if there are few matches" do - results = - search.process(query: "this will not be found!@,default_locale,ai_bot_enabled_chat_bots") - - expect(results[:rows].length).to eq(2) - - expect(results[:rows][0][1]).not_to eq(nil) - end - end -end diff --git a/spec/lib/modules/ai_bot/commands/summarize_command_spec.rb b/spec/lib/modules/ai_bot/commands/summarize_command_spec.rb deleted file mode 100644 index 420d1578..00000000 --- a/spec/lib/modules/ai_bot/commands/summarize_command_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do - let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } - let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) } - - before { SiteSetting.ai_bot_enabled = true } - - describe "#process" do - it "can generate correct info" do - post = Fabricate(:post) - - WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - status: 200, - body: JSON.dump({ choices: [{ message: { content: "summary stuff" } }] }), - ) - - summarizer = described_class.new(bot: bot, args: nil, post: post) - info = summarizer.process(topic_id: post.topic_id, guidance: "why did it happen?") - - expect(info).to include("Topic summarized") - expect(summarizer.custom_raw).to include("summary stuff") - expect(summarizer.chain_next_response).to eq(false) - end - - it "protects hidden data" do - category = Fabricate(:category) - category.set_permissions({}) - category.save! - - topic = Fabricate(:topic, category_id: category.id) - post = Fabricate(:post, topic: topic) - - summarizer = described_class.new(bot: bot, post: post, args: nil) - info = summarizer.process(topic_id: post.topic_id, guidance: "why did it happen?") - - expect(info).not_to include(post.raw) - - expect(summarizer.custom_raw).to eq(I18n.t("discourse_ai.ai_bot.topic_not_found")) - end - end -end diff --git a/spec/lib/modules/ai_bot/commands/tags_command_spec.rb b/spec/lib/modules/ai_bot/commands/tags_command_spec.rb deleted file mode 100644 index df72b6d0..00000000 --- a/spec/lib/modules/ai_bot/commands/tags_command_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::Commands::TagsCommand do - describe "#process" do - it "can generate correct info" do - SiteSetting.tagging_enabled = true - - Fabricate(:tag, name: "america", public_topic_count: 100) - Fabricate(:tag, name: "not_here", public_topic_count: 0) - - info = DiscourseAi::AiBot::Commands::TagsCommand.new(bot: nil, args: nil).process - - expect(info.to_s).to include("america") - expect(info.to_s).not_to include("not_here") - end - end -end diff --git a/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb b/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb index 0f33fdf4..8fe58306 100644 --- a/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb +++ b/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true RSpec.describe Jobs::CreateAiReply do - before do - # got to do this cause we include times in system message - freeze_time - SiteSetting.ai_bot_enabled = true - end + before { SiteSetting.ai_bot_enabled = true } describe "#execute" do fab!(:topic) { Fabricate(:topic) } @@ -17,95 +13,15 @@ RSpec.describe Jobs::CreateAiReply do before { SiteSetting.min_personal_message_post_length = 5 } - context "when chatting with the Open AI bot" do - let(:deltas) { expected_response.split(" ").map { |w| { content: "#{w} " } } } - - before do - bot_user = User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) - bot = DiscourseAi::AiBot::Bot.as(bot_user) - - # time needs to be frozen so time in prompt does not drift - freeze_time - - OpenAiCompletionsInferenceStubs.stub_streamed_response( - DiscourseAi::AiBot::OpenAiBot.new(bot_user).bot_prompt_with_topic_context( - post, - allow_commands: true, - ), - deltas, - model: bot.model_for, - req_opts: { - temperature: 0.4, - top_p: 0.9, - max_tokens: 2500, - functions: bot.available_functions, - stream: true, - }, - ) - end - - it "adds a reply from the GPT bot" do + it "adds a reply from the bot" do + DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do subject.execute( post_id: topic.first_post.id, bot_user_id: DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID, ) - - expect(topic.posts.last.raw).to eq(expected_response) end - it "streams the reply on the fly to the client through MB" do - messages = - MessageBus.track_publish("discourse-ai/ai-bot/topic/#{topic.id}") do - subject.execute( - post_id: topic.first_post.id, - bot_user_id: DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID, - ) - end - - done_signal = messages.pop - - expect(messages.length).to eq(deltas.length) - - messages.each_with_index do |m, idx| - expect(m.data[:raw]).to eq(deltas[0..(idx + 1)].map { |d| d[:content] }.join) - end - - expect(done_signal.data[:done]).to eq(true) - end - end - - context "when chatting with Claude from Anthropic" do - let(:claude_response) { "#{expected_response}" } - let(:deltas) { claude_response.split(" ").map { |w| "#{w} " } } - - before do - SiteSetting.ai_bot_enabled_chat_bots = "claude-2" - bot_user = User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) - - AnthropicCompletionStubs.stub_streamed_response( - DiscourseAi::AiBot::AnthropicBot.new(bot_user).bot_prompt_with_topic_context( - post, - allow_commands: true, - ), - deltas, - model: "claude-2", - req_opts: { - max_tokens_to_sample: 3000, - temperature: 0.4, - stream: true, - stop_sequences: ["\n\nHuman:", ""], - }, - ) - end - - it "adds a reply from the Claude bot" do - subject.execute( - post_id: topic.first_post.id, - bot_user_id: DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID, - ) - - expect(topic.posts.last.raw).to eq(expected_response) - end + expect(topic.posts.last.raw).to eq(expected_response) end end end diff --git a/spec/lib/modules/ai_bot/jobs/regular/update_ai_bot_pm_title_spec.rb b/spec/lib/modules/ai_bot/jobs/regular/update_ai_bot_pm_title_spec.rb index 4477e7da..5bab003b 100644 --- a/spec/lib/modules/ai_bot/jobs/regular/update_ai_bot_pm_title_spec.rb +++ b/spec/lib/modules/ai_bot/jobs/regular/update_ai_bot_pm_title_spec.rb @@ -12,23 +12,6 @@ RSpec.describe Jobs::UpdateAiBotPmTitle do it "will properly update title on bot PMs" do SiteSetting.ai_bot_allowed_groups = Group::AUTO_GROUPS[:staff] - Jobs.run_immediately! - - WebMock - .stub_request(:post, "https://api.anthropic.com/v1/complete") - .with(body: /You are a helpful Discourse assistant/) - .to_return(status: 200, body: "data: {\"completion\": \"Hello back at you\"}", headers: {}) - - WebMock - .stub_request(:post, "https://api.anthropic.com/v1/complete") - .with(body: /Suggest a 7 word title/) - .to_return( - status: 200, - body: "{\"completion\": \"A great title would be:\n\nMy amazing title\n\n\"}", - headers: { - }, - ) - post = create_post( user: user, @@ -38,11 +21,20 @@ RSpec.describe Jobs::UpdateAiBotPmTitle do target_usernames: bot_user.username, ) - expect(post.reload.topic.title).to eq("My amazing title") + title_result = "A great title would be:\n\nMy amazing title\n\n" - WebMock.reset! + DiscourseAi::Completions::Llm.with_prepared_responses([title_result]) do + subject.execute(bot_user_id: bot_user.id, post_id: post.id) - Jobs::UpdateAiBotPmTitle.new.execute(bot_user_id: bot_user.id, post_id: post.id) - # should be a no op cause title is updated + expect(post.reload.topic.title).to eq("My amazing title") + end + + another_title = "I'm a different title" + + DiscourseAi::Completions::Llm.with_prepared_responses([another_title]) do + subject.execute(bot_user_id: bot_user.id, post_id: post.id) + + expect(post.reload.topic.title).to eq("My amazing title") + end end end diff --git a/spec/lib/modules/ai_bot/open_ai_bot_spec.rb b/spec/lib/modules/ai_bot/open_ai_bot_spec.rb deleted file mode 100644 index 5fa734a8..00000000 --- a/spec/lib/modules/ai_bot/open_ai_bot_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::OpenAiBot do - describe "#bot_prompt_with_topic_context" do - fab!(:topic) { Fabricate(:topic) } - - def post_body(post_number) - "This is post #{post_number}" - end - - def bot_user - User.find(DiscourseAi::AiBot::EntryPoint::GPT4_ID) - end - - subject { described_class.new(bot_user) } - - before do - SiteSetting.ai_bot_enabled_chat_bots = "gpt-4" - SiteSetting.ai_bot_enabled = true - end - - context "when cleaning usernames" do - it "can properly clean usernames so OpenAI allows it" do - expect(subject.clean_username("test test")).to eq("test_test") - expect(subject.clean_username("test.test")).to eq("test_test") - expect(subject.clean_username("test😀test")).to eq("test_test") - end - end - - context "when the topic has one post" do - fab!(:post_1) { Fabricate(:post, topic: topic, raw: post_body(1), post_number: 1) } - - it "includes it in the prompt" do - prompt_messages = subject.bot_prompt_with_topic_context(post_1, allow_commands: true) - - post_1_message = prompt_messages[-1] - - expect(post_1_message[:role]).to eq("user") - expect(post_1_message[:content]).to eq(post_body(1)) - expect(post_1_message[:name]).to eq(post_1.user.username) - end - end - - context "when prompt gets very long" do - fab!(:post_1) { Fabricate(:post, topic: topic, raw: "test " * 6000, post_number: 1) } - - it "trims the prompt" do - prompt_messages = subject.bot_prompt_with_topic_context(post_1, allow_commands: true) - - # trimming is tricky... it needs to account for system message as - # well... just make sure we trim for now - expect(prompt_messages[-1][:content].length).to be < post_1.raw.length - end - end - - context "when the topic has multiple posts" do - let!(:post_1) { Fabricate(:post, topic: topic, raw: post_body(1), post_number: 1) } - let!(:post_2) do - Fabricate(:post, topic: topic, user: bot_user, raw: post_body(2), post_number: 2) - end - let!(:post_3) { Fabricate(:post, topic: topic, raw: post_body(3), post_number: 3) } - - it "includes them in the prompt respecting the post number order" do - prompt_messages = subject.bot_prompt_with_topic_context(post_3, allow_commands: true) - - # negative cause we may have grounding prompts - expect(prompt_messages[-3][:role]).to eq("user") - expect(prompt_messages[-3][:content]).to eq(post_body(1)) - expect(prompt_messages[-3][:name]).to eq(post_1.username) - - expect(prompt_messages[-2][:role]).to eq("assistant") - expect(prompt_messages[-2][:content]).to eq(post_body(2)) - - expect(prompt_messages[-1][:role]).to eq("user") - expect(prompt_messages[-1][:content]).to eq(post_body(3)) - expect(prompt_messages[-1][:name]).to eq(post_3.username) - end - end - end -end diff --git a/spec/lib/modules/ai_bot/personas/persona_spec.rb b/spec/lib/modules/ai_bot/personas/persona_spec.rb index 3817f128..c09728f6 100644 --- a/spec/lib/modules/ai_bot/personas/persona_spec.rb +++ b/spec/lib/modules/ai_bot/personas/persona_spec.rb @@ -1,11 +1,11 @@ #frozen_string_literal: true class TestPersona < DiscourseAi::AiBot::Personas::Persona - def commands + def tools [ - DiscourseAi::AiBot::Commands::TagsCommand, - DiscourseAi::AiBot::Commands::SearchCommand, - DiscourseAi::AiBot::Commands::ImageCommand, + DiscourseAi::AiBot::Tools::ListTags, + DiscourseAi::AiBot::Tools::Search, + DiscourseAi::AiBot::Tools::Image, ] end @@ -37,41 +37,36 @@ module DiscourseAi::AiBot::Personas AiPersona.persona_cache.flush! end - fab!(:user) - - it "can disable commands" do - persona = TestPersona.new - - rendered = persona.render_system_prompt(topic: topic_with_users, allow_commands: false) - - expect(rendered).not_to include("!tags") - expect(rendered).not_to include("!search") + let(:context) do + { + site_url: Discourse.base_url, + site_title: "test site title", + site_description: "test site description", + time: Time.zone.now, + participants: topic_with_users.allowed_users.map(&:username).join(", "), + } end + fab!(:user) + it "renders the system prompt" do freeze_time - SiteSetting.title = "test site title" - SiteSetting.site_description = "test site description" + rendered = persona.craft_prompt(context) - rendered = - persona.render_system_prompt(topic: topic_with_users, render_function_instructions: true) + expect(rendered[:insts]).to include(Discourse.base_url) + expect(rendered[:insts]).to include("test site title") + expect(rendered[:insts]).to include("test site description") + expect(rendered[:insts]).to include("joe, jane") + expect(rendered[:insts]).to include(Time.zone.now.to_s) + + tools = rendered[:tools] + + expect(tools.find { |t| t[:name] == "search" }).to be_present + expect(tools.find { |t| t[:name] == "tags" }).to be_present - expect(rendered).to include(Discourse.base_url) - expect(rendered).to include("test site title") - expect(rendered).to include("test site description") - expect(rendered).to include("joe, jane") - expect(rendered).to include(Time.zone.now.to_s) - expect(rendered).to include("search") - expect(rendered).to include("tags") # needs to be configured so it is not available - expect(rendered).not_to include("image") - - rendered = - persona.render_system_prompt(topic: topic_with_users, render_function_instructions: false) - - expect(rendered).not_to include("search") - expect(rendered).not_to include("tags") + expect(tools.find { |t| t[:name] == "image" }).to be_nil end describe "custom personas" do @@ -88,31 +83,29 @@ module DiscourseAi::AiBot::Personas allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], ) - custom_persona = DiscourseAi::AiBot::Personas.all(user: user).last + custom_persona = DiscourseAi::AiBot::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.commands).to eq([DiscourseAi::AiBot::Commands::ImageCommand]) - expect(instance.render_system_prompt(render_function_instructions: true)).to eq( - "you are pun bot", - ) + expect(instance.tools).to eq([DiscourseAi::AiBot::Tools::Image]) + expect(instance.craft_prompt(context).dig(:insts)).to eq("you are pun bot\n\n") # should update persona.update!(name: "zzzpun_bot2") - custom_persona = DiscourseAi::AiBot::Personas.all(user: user).last + custom_persona = DiscourseAi::AiBot::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.all(user: user).last + last_persona = DiscourseAi::AiBot::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.all(user: user).last + last_persona = DiscourseAi::AiBot::Personas::Persona.all(user: user).last expect(last_persona.name).not_to eq("zzzpun_bot2") end end @@ -127,7 +120,7 @@ module DiscourseAi::AiBot::Personas SiteSetting.ai_google_custom_search_cx = "abc123" # should be ordered by priority and then alpha - expect(DiscourseAi::AiBot::Personas.all(user: user)).to eq( + expect(DiscourseAi::AiBot::Personas::Persona.all(user: user)).to eq( [General, Artist, Creative, Researcher, SettingsExplorer, SqlHelper], ) @@ -135,18 +128,18 @@ module DiscourseAi::AiBot::Personas SiteSetting.ai_stability_api_key = "" SiteSetting.ai_google_custom_search_api_key = "" - expect(DiscourseAi::AiBot::Personas.all(user: user)).to contain_exactly( + expect(DiscourseAi::AiBot::Personas::Persona.all(user: user)).to contain_exactly( General, SqlHelper, SettingsExplorer, Creative, ) - AiPersona.find(DiscourseAi::AiBot::Personas.system_personas[General]).update!( + AiPersona.find(DiscourseAi::AiBot::Personas::Persona.system_personas[General]).update!( enabled: false, ) - expect(DiscourseAi::AiBot::Personas.all(user: user)).to contain_exactly( + expect(DiscourseAi::AiBot::Personas::Persona.all(user: user)).to contain_exactly( SqlHelper, SettingsExplorer, Creative, diff --git a/spec/lib/modules/ai_bot/personas/researcher_spec.rb b/spec/lib/modules/ai_bot/personas/researcher_spec.rb index 9cc63c7c..e9cebc4e 100644 --- a/spec/lib/modules/ai_bot/personas/researcher_spec.rb +++ b/spec/lib/modules/ai_bot/personas/researcher_spec.rb @@ -6,6 +6,6 @@ RSpec.describe DiscourseAi::AiBot::Personas::Researcher do end it "renders schema" do - expect(researcher.commands).to eq([DiscourseAi::AiBot::Commands::GoogleCommand]) + expect(researcher.tools).to eq([DiscourseAi::AiBot::Tools::Google]) end end diff --git a/spec/lib/modules/ai_bot/personas/settings_explorer_spec.rb b/spec/lib/modules/ai_bot/personas/settings_explorer_spec.rb index 39ab3d12..d7a60cd1 100644 --- a/spec/lib/modules/ai_bot/personas/settings_explorer_spec.rb +++ b/spec/lib/modules/ai_bot/personas/settings_explorer_spec.rb @@ -6,18 +6,15 @@ RSpec.describe DiscourseAi::AiBot::Personas::SettingsExplorer do end it "renders schema" do - prompt = settings_explorer.render_system_prompt + prompt = settings_explorer.system_prompt # check we do not render plugin settings expect(prompt).not_to include("ai_bot_enabled_personas") expect(prompt).to include("site_description") - expect(settings_explorer.available_commands).to eq( - [ - DiscourseAi::AiBot::Commands::SettingContextCommand, - DiscourseAi::AiBot::Commands::SearchSettingsCommand, - ], + expect(settings_explorer.tools).to eq( + [DiscourseAi::AiBot::Tools::SettingContext, DiscourseAi::AiBot::Tools::SearchSettings], ) end end diff --git a/spec/lib/modules/ai_bot/personas/sql_helper_spec.rb b/spec/lib/modules/ai_bot/personas/sql_helper_spec.rb index 33173a09..37d645bc 100644 --- a/spec/lib/modules/ai_bot/personas/sql_helper_spec.rb +++ b/spec/lib/modules/ai_bot/personas/sql_helper_spec.rb @@ -6,12 +6,12 @@ RSpec.describe DiscourseAi::AiBot::Personas::SqlHelper do end it "renders schema" do - prompt = sql_helper.render_system_prompt + prompt = sql_helper.system_prompt expect(prompt).to include("posts(") expect(prompt).to include("topics(") expect(prompt).not_to include("translation_key") # not a priority table expect(prompt).to include("user_api_keys") # not a priority table - expect(sql_helper.available_commands).to eq([DiscourseAi::AiBot::Commands::DbSchemaCommand]) + expect(sql_helper.tools).to eq([DiscourseAi::AiBot::Tools::DbSchema]) end end diff --git a/spec/lib/modules/ai_bot/playground_spec.rb b/spec/lib/modules/ai_bot/playground_spec.rb new file mode 100644 index 00000000..0c86cbd5 --- /dev/null +++ b/spec/lib/modules/ai_bot/playground_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::AiBot::Playground do + subject(:playground) { described_class.new(bot) } + + before do + SiteSetting.ai_bot_enabled_chat_bots = "gpt-4" + SiteSetting.ai_bot_enabled = true + end + + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT4_ID) } + let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user) } + + fab!(:user) { Fabricate(:user) } + let!(:pm) do + Fabricate( + :private_message_topic, + title: "This is my special PM", + user: user, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: user), + Fabricate.build(:topic_allowed_user, user: bot_user), + ], + ) + end + let!(:first_post) do + Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user") + end + let!(:second_post) do + Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply") + end + let!(:third_post) do + Fabricate( + :post, + topic: pm, + user: user, + post_number: 3, + raw: "This is a second reply by the user", + ) + end + + describe "#title_playground" do + let(:expected_response) { "This is a suggested title" } + + before { SiteSetting.min_personal_message_post_length = 5 } + + it "updates the title using bot suggestions" do + DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do + playground.title_playground(third_post) + + expect(pm.reload.title).to eq(expected_response) + end + end + end + + describe "#reply_to" do + it "streams the bot reply through MB and create a new post in the PM with a cooked responses" do + expected_bot_response = + "Hello this is a bot and what you just said is an interesting question" + + DiscourseAi::Completions::Llm.with_prepared_responses([expected_bot_response]) do + messages = + MessageBus.track_publish("discourse-ai/ai-bot/topic/#{pm.id}") do + playground.reply_to(third_post) + end + + done_signal = messages.pop + expect(done_signal.data[:done]).to eq(true) + + messages.each_with_index do |m, idx| + expect(m.data[:raw]).to eq(expected_bot_response[0..idx]) + end + + expect(pm.reload.posts.last.cooked).to eq(PrettyText.cook(expected_bot_response)) + end + end + end + + describe "#conversation_context" do + it "includes previous posts ordered by post_number" do + context = playground.conversation_context(third_post) + + expect(context).to contain_exactly( + *[ + { type: "user", name: user.username, content: third_post.raw }, + { type: "assistant", content: second_post.raw }, + { type: "user", name: user.username, content: first_post.raw }, + ], + ) + end + + it "only include regular posts" do + first_post.update!(post_type: Post.types[:whisper]) + + context = playground.conversation_context(third_post) + + expect(context).to contain_exactly( + *[ + { type: "user", name: user.username, content: third_post.raw }, + { type: "assistant", content: second_post.raw }, + ], + ) + end + + context "with custom prompts" do + it "When post custom prompt is present, we use that instead of the post content" do + custom_prompt = [ + [ + { args: { timezone: "Buenos Aires" }, time: "2023-12-14 17:24:00 -0300" }.to_json, + "time", + "tool", + ], + [ + { name: "time", arguments: { name: "time", timezone: "Buenos Aires" } }.to_json, + "time", + "tool_call", + ], + ["I replied this thanks to the time command", bot_user.username], + ] + + PostCustomPrompt.create!(post: second_post, custom_prompt: custom_prompt) + + context = playground.conversation_context(third_post) + + expect(context).to contain_exactly( + *[ + { type: "user", name: user.username, content: third_post.raw }, + { type: "assistant", content: custom_prompt.third.first }, + { type: "tool_call", content: custom_prompt.second.first, name: "time" }, + { type: "tool", name: "time", content: custom_prompt.first.first }, + { type: "user", name: user.username, content: first_post.raw }, + ], + ) + end + end + + it "include replies generated from tools only once" do + custom_prompt = [ + [ + { args: { timezone: "Buenos Aires" }, time: "2023-12-14 17:24:00 -0300" }.to_json, + "time", + "tool", + ], + [ + { name: "time", arguments: { name: "time", timezone: "Buenos Aires" }.to_json }.to_json, + "time", + "tool_call", + ], + ["I replied this thanks to the time command", bot_user.username], + ] + PostCustomPrompt.create!(post: second_post, custom_prompt: custom_prompt) + PostCustomPrompt.create!(post: first_post, custom_prompt: custom_prompt) + + context = playground.conversation_context(third_post) + + expect(context).to contain_exactly( + *[ + { type: "user", name: user.username, content: third_post.raw }, + { type: "assistant", content: custom_prompt.third.first }, + { type: "tool_call", content: custom_prompt.second.first, name: "time" }, + { type: "tool", name: "time", content: custom_prompt.first.first }, + { type: "tool_call", content: custom_prompt.second.first, name: "time" }, + { type: "tool", name: "time", content: custom_prompt.first.first }, + ], + ) + end + end +end diff --git a/spec/lib/modules/ai_bot/commands/dall_e_command_spec.rb b/spec/lib/modules/ai_bot/tools/dall_e_spec.rb similarity index 69% rename from spec/lib/modules/ai_bot/commands/dall_e_command_spec.rb rename to spec/lib/modules/ai_bot/tools/dall_e_spec.rb index ac22c7a1..058692e9 100644 --- a/spec/lib/modules/ai_bot/commands/dall_e_command_spec.rb +++ b/spec/lib/modules/ai_bot/tools/dall_e_spec.rb @@ -1,8 +1,13 @@ #frozen_string_literal: true -RSpec.describe DiscourseAi::AiBot::Commands::DallECommand do +RSpec.describe DiscourseAi::AiBot::Tools::DallE do + subject(:dall_e) { described_class.new({ prompts: prompts }) } + + let(:prompts) { ["a pink cow", "a red cow"] } + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } - let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + let(:progress_blk) { Proc.new {} } before { SiteSetting.ai_bot_enabled = true } @@ -17,7 +22,6 @@ RSpec.describe DiscourseAi::AiBot::Commands::DallECommand do "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" data = [{ b64_json: image, revised_prompt: "a pink cow 1" }] - prompts = ["a pink cow", "a red cow"] WebMock .stub_request(:post, SiteSetting.ai_openai_dall_e_3_url) @@ -30,14 +34,12 @@ RSpec.describe DiscourseAi::AiBot::Commands::DallECommand do end .to_return(status: 200, body: { data: data }.to_json) - image = described_class.new(bot: bot, post: post, args: nil) - - info = image.process(prompts: prompts).to_json + info = dall_e.invoke(bot_user, llm, &progress_blk).to_json expect(JSON.parse(info)).to eq("prompts" => ["a pink cow 1", "a pink cow 1"]) - expect(image.custom_raw).to include("upload://") - expect(image.custom_raw).to include("[grid]") - expect(image.custom_raw).to include("a pink cow 1") + expect(subject.custom_raw).to include("upload://") + expect(subject.custom_raw).to include("[grid]") + expect(subject.custom_raw).to include("a pink cow 1") end it "can generate correct info" do @@ -49,7 +51,6 @@ RSpec.describe DiscourseAi::AiBot::Commands::DallECommand do "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" data = [{ b64_json: image, revised_prompt: "a pink cow 1" }] - prompts = ["a pink cow", "a red cow"] WebMock .stub_request(:post, "https://api.openai.com/v1/images/generations") @@ -60,14 +61,12 @@ RSpec.describe DiscourseAi::AiBot::Commands::DallECommand do end .to_return(status: 200, body: { data: data }.to_json) - image = described_class.new(bot: bot, post: post, args: nil) - - info = image.process(prompts: prompts).to_json + info = dall_e.invoke(bot_user, llm, &progress_blk).to_json expect(JSON.parse(info)).to eq("prompts" => ["a pink cow 1", "a pink cow 1"]) - expect(image.custom_raw).to include("upload://") - expect(image.custom_raw).to include("[grid]") - expect(image.custom_raw).to include("a pink cow 1") + expect(subject.custom_raw).to include("upload://") + expect(subject.custom_raw).to include("[grid]") + expect(subject.custom_raw).to include("a pink cow 1") end end end diff --git a/spec/lib/modules/ai_bot/commands/db_schema_command_spec.rb b/spec/lib/modules/ai_bot/tools/db_schema_spec.rb similarity index 54% rename from spec/lib/modules/ai_bot/commands/db_schema_command_spec.rb rename to spec/lib/modules/ai_bot/tools/db_schema_spec.rb index 9e1c9b70..83f4cb29 100644 --- a/spec/lib/modules/ai_bot/commands/db_schema_command_spec.rb +++ b/spec/lib/modules/ai_bot/tools/db_schema_spec.rb @@ -1,10 +1,14 @@ #frozen_string_literal: true -RSpec.describe DiscourseAi::AiBot::Commands::DbSchemaCommand do - let(:command) { DiscourseAi::AiBot::Commands::DbSchemaCommand.new(bot: nil, args: nil) } +RSpec.describe DiscourseAi::AiBot::Tools::DbSchema do + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + + before { SiteSetting.ai_bot_enabled = true } describe "#process" do it "returns rich schema for tables" do - result = command.process(tables: "posts,topics") + result = described_class.new({ tables: "posts,topics" }).invoke(bot_user, llm) + expect(result[:schema_info]).to include("raw text") expect(result[:schema_info]).to include("views integer") expect(result[:schema_info]).to include("posts") diff --git a/spec/lib/modules/ai_bot/commands/google_command_spec.rb b/spec/lib/modules/ai_bot/tools/google_spec.rb similarity index 73% rename from spec/lib/modules/ai_bot/commands/google_command_spec.rb rename to spec/lib/modules/ai_bot/tools/google_spec.rb index aa63966b..6a9a900a 100644 --- a/spec/lib/modules/ai_bot/commands/google_command_spec.rb +++ b/spec/lib/modules/ai_bot/tools/google_spec.rb @@ -1,8 +1,11 @@ #frozen_string_literal: true -RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do +RSpec.describe DiscourseAi::AiBot::Tools::Google do + subject(:search) { described_class.new({ query: "some search term" }) } + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } - let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + let(:progress_blk) { Proc.new {} } before { SiteSetting.ai_bot_enabled = true } @@ -20,10 +23,9 @@ RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do "https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=some%20search%20term", ).to_return(status: 200, body: json_text, headers: {}) - google = described_class.new(bot: nil, post: post, args: {}.to_json) - info = google.process(query: "some search term").to_json + info = search.invoke(bot_user, llm, &progress_blk).to_json - expect(google.description_args[:count]).to eq(0) + expect(search.results_count).to eq(0) expect(info).to_not include("oops") end @@ -61,23 +63,14 @@ RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do "https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=some%20search%20term", ).to_return(status: 200, body: json_text, headers: {}) - google = - described_class.new(bot: bot, post: post, args: { query: "some search term" }.to_json) + info = search.invoke(bot_user, llm, &progress_blk).to_json - info = google.process(query: "some search term").to_json - - expect(google.description_args[:count]).to eq(2) + expect(search.results_count).to eq(2) expect(info).to include("title1") expect(info).to include("snippet1") expect(info).to include("some+search+term") expect(info).to include("title2") expect(info).to_not include("oops") - - google.invoke! - - expect(post.reload.raw).to include("some search term") - - expect { google.invoke! }.to raise_error(StandardError) end end end diff --git a/spec/lib/modules/ai_bot/commands/image_command_spec.rb b/spec/lib/modules/ai_bot/tools/image_spec.rb similarity index 65% rename from spec/lib/modules/ai_bot/commands/image_command_spec.rb rename to spec/lib/modules/ai_bot/tools/image_spec.rb index 9804549b..9772a1f5 100644 --- a/spec/lib/modules/ai_bot/commands/image_command_spec.rb +++ b/spec/lib/modules/ai_bot/tools/image_spec.rb @@ -1,8 +1,14 @@ #frozen_string_literal: true -RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do +RSpec.describe DiscourseAi::AiBot::Tools::Image do + subject(:tool) { described_class.new({ prompts: prompts, seeds: [99, 32] }) } + + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + let(:progress_blk) { Proc.new {} } + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } - let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) } + + let(:prompts) { ["a pink cow", "a red cow"] } before { SiteSetting.ai_bot_enabled = true } @@ -17,7 +23,6 @@ RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" artifacts = [{ base64: image, seed: 99 }] - prompts = ["a pink cow", "a red cow"] WebMock .stub_request( @@ -31,15 +36,13 @@ RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do end .to_return(status: 200, body: { artifacts: artifacts }.to_json) - image = described_class.new(bot: bot, post: post, args: nil) - - info = image.process(prompts: prompts).to_json + info = tool.invoke(bot_user, llm, &progress_blk).to_json expect(JSON.parse(info)).to eq("prompts" => ["a pink cow", "a red cow"], "seeds" => [99, 99]) - expect(image.custom_raw).to include("upload://") - expect(image.custom_raw).to include("[grid]") - expect(image.custom_raw).to include("a pink cow") - expect(image.custom_raw).to include("a red cow") + expect(tool.custom_raw).to include("upload://") + expect(tool.custom_raw).to include("[grid]") + expect(tool.custom_raw).to include("a pink cow") + expect(tool.custom_raw).to include("a red cow") end end end diff --git a/spec/lib/modules/ai_bot/tools/list_categories_spec.rb b/spec/lib/modules/ai_bot/tools/list_categories_spec.rb new file mode 100644 index 00000000..7218442d --- /dev/null +++ b/spec/lib/modules/ai_bot/tools/list_categories_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::AiBot::Tools::ListCategories do + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + + before { SiteSetting.ai_bot_enabled = true } + + describe "#process" do + it "list available categories" do + Fabricate(:category, name: "america", posts_year: 999) + + info = described_class.new({}).invoke(bot_user, llm).to_s + + expect(info).to include("america") + expect(info).to include("999") + end + end +end diff --git a/spec/lib/modules/ai_bot/tools/list_tags_spec.rb b/spec/lib/modules/ai_bot/tools/list_tags_spec.rb new file mode 100644 index 00000000..e2278baf --- /dev/null +++ b/spec/lib/modules/ai_bot/tools/list_tags_spec.rb @@ -0,0 +1,23 @@ +#frozen_string_literal: true + +RSpec.describe DiscourseAi::AiBot::Tools::ListTags do + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + + before do + SiteSetting.ai_bot_enabled = true + SiteSetting.tagging_enabled = true + end + + describe "#process" do + it "can generate correct info" do + Fabricate(:tag, name: "america", public_topic_count: 100) + Fabricate(:tag, name: "not_here", public_topic_count: 0) + + info = described_class.new({}).invoke(bot_user, llm) + + expect(info.to_s).to include("america") + expect(info.to_s).not_to include("not_here") + end + end +end diff --git a/spec/lib/modules/ai_bot/commands/read_command_spec.rb b/spec/lib/modules/ai_bot/tools/read_spec.rb similarity index 81% rename from spec/lib/modules/ai_bot/commands/read_command_spec.rb rename to spec/lib/modules/ai_bot/tools/read_spec.rb index f3dc844e..c04bab2f 100644 --- a/spec/lib/modules/ai_bot/commands/read_command_spec.rb +++ b/spec/lib/modules/ai_bot/tools/read_spec.rb @@ -1,8 +1,10 @@ #frozen_string_literal: true -RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do +RSpec.describe DiscourseAi::AiBot::Tools::Read do + subject(:tool) { described_class.new({ topic_id: topic_with_tags.id }) } + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } - let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } fab!(:parent_category) { Fabricate(:category, name: "animals") } fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") } @@ -32,9 +34,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do Fabricate(:post, topic: topic_with_tags, raw: "hello there") Fabricate(:post, topic: topic_with_tags, raw: "mister sam") - read = described_class.new(bot: bot, args: nil) - - results = read.process(topic_id: topic_id) + results = tool.invoke(bot_user, llm) expect(results[:topic_id]).to eq(topic_id) expect(results[:content]).to include("hello") @@ -44,10 +44,8 @@ RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do expect(results[:content]).to include("sad") expect(results[:content]).to include("animals") expect(results[:content]).not_to include("hidden") - expect(read.description_args).to eq( - title: topic_with_tags.title, - url: topic_with_tags.relative_url, - ) + expect(tool.title).to eq(topic_with_tags.title) + expect(tool.url).to eq(topic_with_tags.relative_url) end end end diff --git a/spec/lib/modules/ai_bot/tools/search_settings_spec.rb b/spec/lib/modules/ai_bot/tools/search_settings_spec.rb new file mode 100644 index 00000000..59c68604 --- /dev/null +++ b/spec/lib/modules/ai_bot/tools/search_settings_spec.rb @@ -0,0 +1,39 @@ +#frozen_string_literal: true + +RSpec.describe DiscourseAi::AiBot::Tools::SearchSettings do + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + + before { SiteSetting.ai_bot_enabled = true } + + def search_settings(query) + described_class.new({ query: query }) + end + + describe "#process" do + it "can handle no results" do + results = search_settings("this will not exist frogs").invoke(bot_user, llm) + expect(results[:args]).to eq({ query: "this will not exist frogs" }) + expect(results[:rows]).to eq([]) + end + + it "can return more many settings with no descriptions if there are lots of hits" do + results = search_settings("a").invoke(bot_user, llm) + + expect(results[:rows].length).to be > 30 + expect(results[:rows][0].length).to eq(1) + end + + it "can return descriptions if there are few matches" do + results = + search_settings("this will not be found!@,default_locale,ai_bot_enabled_chat_bots").invoke( + bot_user, + llm, + ) + + expect(results[:rows].length).to eq(2) + + expect(results[:rows][0][1]).not_to eq(nil) + end + end +end diff --git a/spec/lib/modules/ai_bot/tools/search_spec.rb b/spec/lib/modules/ai_bot/tools/search_spec.rb new file mode 100644 index 00000000..a5136ea4 --- /dev/null +++ b/spec/lib/modules/ai_bot/tools/search_spec.rb @@ -0,0 +1,140 @@ +#frozen_string_literal: true + +RSpec.describe DiscourseAi::AiBot::Tools::Search do + before { SearchIndexer.enable } + after { SearchIndexer.disable } + + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + let(:progress_blk) { Proc.new {} } + + fab!(:admin) + fab!(:parent_category) { Fabricate(:category, name: "animals") } + fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") } + fab!(:tag_funny) { Fabricate(:tag, name: "funny") } + fab!(:tag_sad) { Fabricate(:tag, name: "sad") } + fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") } + fab!(:staff_tag_group) do + tag_group = Fabricate.build(:tag_group, name: "Staff only", tag_names: ["hidden"]) + + tag_group.permissions = [ + [Group::AUTO_GROUPS[:staff], TagGroupPermission.permission_types[:full]], + ] + tag_group.save! + tag_group + end + fab!(:topic_with_tags) do + Fabricate(:topic, category: category, tags: [tag_funny, tag_sad, tag_hidden]) + end + + before { SiteSetting.ai_bot_enabled = true } + + describe "#invoke" do + it "can retreive options from persona correctly" do + persona_options = { "base_query" => "#funny" } + + search_post = Fabricate(:post, topic: topic_with_tags) + + bot_post = Fabricate(:post) + + search = described_class.new({ order: "latest" }, persona_options: persona_options) + + results = search.invoke(bot_user, llm, &progress_blk) + expect(results[:rows].length).to eq(1) + + search_post.topic.tags = [] + search_post.topic.save! + + # no longer has the tag funny + results = search.invoke(bot_user, llm, &progress_blk) + expect(results[:rows].length).to eq(0) + end + + it "can handle no results" do + post1 = Fabricate(:post, topic: topic_with_tags) + search = described_class.new({ search_query: "ABDDCDCEDGDG", order: "fake" }) + + results = search.invoke(bot_user, llm, &progress_blk) + + expect(results[:args]).to eq({ search_query: "ABDDCDCEDGDG", order: "fake" }) + expect(results[:rows]).to eq([]) + end + + describe "semantic search" do + let (:query) { + "this is an expanded search" + } + after { DiscourseAi::Embeddings::SemanticSearch.clear_cache_for(query) } + + it "supports semantic search when enabled" do + SiteSetting.ai_embeddings_semantic_search_enabled = true + SiteSetting.ai_embeddings_discourse_service_api_endpoint = "http://test.com" + + hyde_embedding = [0.049382, 0.9999] + EmbeddingsGenerationStubs.discourse_service( + SiteSetting.ai_embeddings_model, + query, + hyde_embedding, + ) + + post1 = Fabricate(:post, topic: topic_with_tags) + search = described_class.new({ search_query: "hello world, sam", status: "public" }) + + DiscourseAi::Embeddings::VectorRepresentations::AllMpnetBaseV2 + .any_instance + .expects(:asymmetric_topics_similarity_search) + .returns([post1.topic_id]) + + results = + DiscourseAi::Completions::Llm.with_prepared_responses(["#{query}"]) do + search.invoke(bot_user, llm, &progress_blk) + end + + expect(results[:args]).to eq({ search_query: "hello world, sam", status: "public" }) + expect(results[:rows].length).to eq(1) + end + end + + it "supports subfolder properly" do + Discourse.stubs(:base_path).returns("/subfolder") + + post1 = Fabricate(:post, topic: topic_with_tags) + + search = described_class.new({ limit: 1, user: post1.user.username }) + + results = search.invoke(bot_user, llm, &progress_blk) + expect(results[:rows].to_s).to include("/subfolder" + post1.url) + end + + it "returns rich topic information" do + post1 = Fabricate(:post, like_count: 1, topic: topic_with_tags) + search = described_class.new({ user: post1.user.username }) + post1.topic.update!(views: 100, posts_count: 2, like_count: 10) + + results = search.invoke(bot_user, llm, &progress_blk) + + row = results[:rows].first + category = row[results[:column_names].index("category")] + + expect(category).to eq("animals > amazing-cat") + + tags = row[results[:column_names].index("tags")] + expect(tags).to eq("funny, sad") + + likes = row[results[:column_names].index("likes")] + expect(likes).to eq(1) + + username = row[results[:column_names].index("username")] + expect(username).to eq(post1.user.username) + + likes = row[results[:column_names].index("topic_likes")] + expect(likes).to eq(10) + + views = row[results[:column_names].index("topic_views")] + expect(views).to eq(100) + + replies = row[results[:column_names].index("topic_replies")] + expect(replies).to eq(1) + end + end +end diff --git a/spec/lib/modules/ai_bot/commands/setting_context_command.rb b/spec/lib/modules/ai_bot/tools/setting_context_spec.rb similarity index 55% rename from spec/lib/modules/ai_bot/commands/setting_context_command.rb rename to spec/lib/modules/ai_bot/tools/setting_context_spec.rb index dcf3620c..609422d0 100644 --- a/spec/lib/modules/ai_bot/commands/setting_context_command.rb +++ b/spec/lib/modules/ai_bot/tools/setting_context_spec.rb @@ -1,21 +1,26 @@ # frozen_string_literal: true -RSpec.describe DiscourseAi::AiBot::Commands::SettingContextCommand do - let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } - let(:command) { described_class.new(bot_user: bot_user, args: nil) } +def has_rg? + if defined?(@has_rg) + @has_rg + else + @has_rg |= system("which rg") + end +end - def has_rg? - if defined?(@has_rg) - @has_rg - else - @has_rg |= system("which rg") - end +RSpec.describe DiscourseAi::AiBot::Tools::SettingContext, if: has_rg? do + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + + before { SiteSetting.ai_bot_enabled = true } + + def setting_context(setting_name) + described_class.new({ setting_name: setting_name }) end describe "#execute" do - skip("rg is needed for these tests") if !has_rg? it "returns the context for core setting" do - result = command.process(setting_name: "moderators_view_emails") + result = setting_context("moderators_view_emails").invoke(bot_user, llm) expect(result[:setting_name]).to eq("moderators_view_emails") @@ -23,18 +28,17 @@ RSpec.describe DiscourseAi::AiBot::Commands::SettingContextCommand do expect(result[:context]).to include("moderators_view_emails") end - skip("rg is needed for these tests") if !has_rg? it "returns the context for plugin setting" do - result = command.process(setting_name: "ai_bot_enabled") + result = setting_context("ai_bot_enabled").invoke(bot_user, llm) expect(result[:setting_name]).to eq("ai_bot_enabled") expect(result[:context]).to include("ai_bot_enabled:") end context "when the setting does not exist" do - skip("rg is needed for these tests") if !has_rg? it "returns an error message" do - result = command.process(setting_name: "this_setting_does_not_exist") + result = setting_context("this_setting_does_not_exist").invoke(bot_user, llm) + expect(result[:context]).to eq("This setting does not exist") end end diff --git a/spec/lib/modules/ai_bot/tools/summarize_spec.rb b/spec/lib/modules/ai_bot/tools/summarize_spec.rb new file mode 100644 index 00000000..6a0795d8 --- /dev/null +++ b/spec/lib/modules/ai_bot/tools/summarize_spec.rb @@ -0,0 +1,46 @@ +#frozen_string_literal: true + +RSpec.describe DiscourseAi::AiBot::Tools::Summarize do + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + let(:progress_blk) { Proc.new {} } + + before { SiteSetting.ai_bot_enabled = true } + + let(:summary) { "summary stuff" } + + describe "#process" do + it "can generate correct info" do + post = Fabricate(:post) + + DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do + summarization = + described_class.new({ topic_id: post.topic_id, guidance: "why did it happen?" }) + info = summarization.invoke(bot_user, llm, &progress_blk) + + expect(info).to include("Topic summarized") + expect(summarization.custom_raw).to include(summary) + expect(summarization.chain_next_response?).to eq(false) + end + end + + it "protects hidden data" do + category = Fabricate(:category) + category.set_permissions({}) + category.save! + + topic = Fabricate(:topic, category_id: category.id) + post = Fabricate(:post, topic: topic) + + DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do + summarization = + described_class.new({ topic_id: post.topic_id, guidance: "why did it happen?" }) + info = summarization.invoke(bot_user, llm, &progress_blk) + + expect(info).not_to include(post.raw) + + expect(summarization.custom_raw).to eq(I18n.t("discourse_ai.ai_bot.topic_not_found")) + end + end + end +end diff --git a/spec/lib/modules/ai_bot/commands/time_command_spec.rb b/spec/lib/modules/ai_bot/tools/time_spec.rb similarity index 51% rename from spec/lib/modules/ai_bot/commands/time_command_spec.rb rename to spec/lib/modules/ai_bot/tools/time_spec.rb index 833c0390..90142698 100644 --- a/spec/lib/modules/ai_bot/commands/time_command_spec.rb +++ b/spec/lib/modules/ai_bot/tools/time_spec.rb @@ -1,12 +1,17 @@ #frozen_string_literal: true -RSpec.describe DiscourseAi::AiBot::Commands::TimeCommand do +RSpec.describe DiscourseAi::AiBot::Tools::Time do + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) } + let(:llm) { DiscourseAi::Completions::Llm.proxy("gpt-3.5-turbo") } + + before { SiteSetting.ai_bot_enabled = true } + describe "#process" do it "can generate correct info" do freeze_time args = { timezone: "America/Los_Angeles" } - info = DiscourseAi::AiBot::Commands::TimeCommand.new(bot: nil, args: nil).process(**args) + info = described_class.new(args).invoke(bot_user, llm) expect(info).to eq({ args: args, time: Time.now.in_time_zone("America/Los_Angeles").to_s }) expect(info.to_s).not_to include("not_here") diff --git a/spec/requests/admin/ai_personas_controller_spec.rb b/spec/requests/admin/ai_personas_controller_spec.rb index 1f75481f..2e116b4c 100644 --- a/spec/requests/admin/ai_personas_controller_spec.rb +++ b/spec/requests/admin/ai_personas_controller_spec.rb @@ -14,7 +14,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count) expect(response.parsed_body["meta"]["commands"].length).to eq( - DiscourseAi::AiBot::Personas::Persona.all_available_commands.length, + DiscourseAi::AiBot::Personas::Persona.all_available_tools.length, ) end @@ -34,7 +34,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do serializer_persona2 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona2.id } commands = response.parsed_body["meta"]["commands"] - search_command = commands.find { |c| c["id"] == "SearchCommand" } + search_command = commands.find { |c| c["id"] == "Search" } expect(search_command["help"]).to eq(I18n.t("discourse_ai.ai_bot.command_help.search")) @@ -71,7 +71,8 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do "Général Description", ) - id = DiscourseAi::AiBot::Personas.system_personas[DiscourseAi::AiBot::Personas::General] + id = + DiscourseAi::AiBot::Personas::Persona.system_personas[DiscourseAi::AiBot::Personas::General] name = I18n.t("discourse_ai.ai_bot.personas.general.name") description = I18n.t("discourse_ai.ai_bot.personas.general.description") persona = response.parsed_body["ai_personas"].find { |p| p["id"] == id } @@ -147,7 +148,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.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { system_prompt: "you are not a helpful bot", @@ -160,7 +161,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does not allow editing of commands" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { commands: %w[SearchCommand ImageCommand], @@ -173,7 +174,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.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { name: "bob", @@ -187,7 +188,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does allow some actions" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]], @@ -225,7 +226,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.system_personas.values.first}.json" + delete "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::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 diff --git a/spec/shared/inference/anthropic_completions_spec.rb b/spec/shared/inference/anthropic_completions_spec.rb deleted file mode 100644 index 8293414e..00000000 --- a/spec/shared/inference/anthropic_completions_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Inference::AnthropicCompletions do - before { SiteSetting.ai_anthropic_api_key = "abc-123" } - - it "can complete a trivial prompt" do - response_text = "1. Serenity\\n2. Laughter\\n3. Adventure" - prompt = "Human: write 3 words\n\n" - user_id = 183 - req_opts = { max_tokens_to_sample: 700, temperature: 0.5 } - - AnthropicCompletionStubs.stub_response(prompt, response_text, req_opts: req_opts) - - completions = - DiscourseAi::Inference::AnthropicCompletions.perform!( - prompt, - "claude-2", - temperature: req_opts[:temperature], - max_tokens: req_opts[:max_tokens_to_sample], - user_id: user_id, - ) - - expect(completions[:completion]).to eq(response_text) - - expect(AiApiAuditLog.count).to eq(1) - log = AiApiAuditLog.first - - request_body = { model: "claude-2", prompt: prompt }.merge(req_opts).to_json - response_body = AnthropicCompletionStubs.response(response_text).to_json - - expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) - expect(log.request_tokens).to eq(6) - expect(log.response_tokens).to eq(16) - expect(log.raw_request_payload).to eq(request_body) - expect(log.raw_response_payload).to eq(response_body) - end - - it "supports streaming mode" do - deltas = ["Mount", "ain", " ", "Tree ", "Frog"] - prompt = "Human: write 3 words\n\n" - req_opts = { max_tokens_to_sample: 300, stream: true } - content = +"" - - AnthropicCompletionStubs.stub_streamed_response(prompt, deltas, req_opts: req_opts) - - DiscourseAi::Inference::AnthropicCompletions.perform!( - prompt, - "claude-2", - max_tokens: req_opts[:max_tokens_to_sample], - ) do |partial, cancel| - data = partial[:completion] - content << data if data - cancel.call if content.split(" ").length == 2 - end - - expect(content).to eq("Mountain Tree ") - - expect(AiApiAuditLog.count).to eq(1) - log = AiApiAuditLog.first - - request_body = { model: "claude-2", prompt: prompt }.merge(req_opts).to_json - - expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) - expect(log.request_tokens).to eq(6) - expect(log.response_tokens).to eq(3) - expect(log.raw_request_payload).to eq(request_body) - expect(log.raw_response_payload).to be_present - end -end diff --git a/spec/shared/inference/function_list_spec.rb b/spec/shared/inference/function_list_spec.rb deleted file mode 100644 index 1bac1b5a..00000000 --- a/spec/shared/inference/function_list_spec.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true -require "rails_helper" - -module DiscourseAi::Inference - describe FunctionList do - let :function_list do - function = - Function.new(name: "get_weather", description: "Get the weather in a city (default to c)") - - function.add_parameter( - name: "location", - type: "string", - description: "the city name", - required: true, - ) - - function.add_parameter( - name: "unit", - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - required: false, - ) - - list = FunctionList.new - list << function - list - end - - let :image_function_list do - function = Function.new(name: "image", description: "generates an image") - - function.add_parameter( - name: "prompts", - type: "array", - item_type: "string", - required: true, - description: "the prompts", - ) - - list = FunctionList.new - list << function - list - end - - it "can handle function call parsing" do - raw_prompt = <<~PROMPT - - - image - - - [ - "an oil painting", - "a cute fluffy orange", - "3 apple's", - "a cat" - ] - - - - - PROMPT - parsed = image_function_list.parse_prompt(raw_prompt) - expect(parsed).to eq( - [ - { - name: "image", - arguments: { - prompts: ["an oil painting", "a cute fluffy orange", "3 apple's", "a cat"], - }, - }, - ], - ) - end - - it "can generate a general custom system prompt" do - prompt = function_list.system_prompt - - # this is fragile, by design, we need to test something here - # - expected = <<~PROMPT - - - get_weather - Get the weather in a city (default to c) - - - location - string - the city name - true - - - unit - string - the unit of measurement celcius c or fahrenheit f - false - c,f - - - - - PROMPT - expect(prompt).to include(expected) - end - end -end diff --git a/spec/shared/inference/openai_completions_spec.rb b/spec/shared/inference/openai_completions_spec.rb deleted file mode 100644 index 2c353d79..00000000 --- a/spec/shared/inference/openai_completions_spec.rb +++ /dev/null @@ -1,417 +0,0 @@ -# frozen_string_literal: true -require "rails_helper" - -describe DiscourseAi::Inference::OpenAiCompletions do - before { SiteSetting.ai_openai_api_key = "abc-123" } - - fab!(:user) - - it "supports sending an organization id" do - SiteSetting.ai_openai_organization = "org_123" - - stub_request(:post, "https://api.openai.com/v1/chat/completions").with( - body: - "{\"model\":\"gpt-3.5-turbo-0613\",\"messages\":[{\"role\":\"system\",\"content\":\"hello\"}]}", - headers: { - "Authorization" => "Bearer abc-123", - "Content-Type" => "application/json", - "Host" => "api.openai.com", - "User-Agent" => "Ruby", - "OpenAI-Organization" => "org_123", - }, - ).to_return( - status: 200, - body: { choices: [message: { content: "world" }] }.to_json, - headers: { - }, - ) - - result = - DiscourseAi::Inference::OpenAiCompletions.perform!( - [{ role: "system", content: "hello" }], - "gpt-3.5-turbo-0613", - ) - - expect(result.dig(:choices, 0, :message, :content)).to eq("world") - end - - context "when configured using Azure" do - it "Supports custom Azure endpoints for completions" do - gpt_url_base = - "https://company.openai.azure.com/openai/deployments/deployment/chat/completions?api-version=2023-03-15-preview" - key = "12345" - SiteSetting.ai_openai_api_key = key - - [ - { setting_name: "ai_openai_gpt35_url", model: "gpt-35-turbo" }, - { setting_name: "ai_openai_gpt35_16k_url", model: "gpt-35-16k-turbo" }, - { setting_name: "ai_openai_gpt4_url", model: "gpt-4" }, - { setting_name: "ai_openai_gpt4_32k_url", model: "gpt-4-32k" }, - { setting_name: "ai_openai_gpt4_turbo_url", model: "gpt-4-1106-preview" }, - ].each do |config| - gpt_url = "#{gpt_url_base}/#{config[:model]}" - setting_name = config[:setting_name] - model = config[:model] - - SiteSetting.public_send("#{setting_name}=".to_sym, gpt_url) - - expected = { - id: "chatcmpl-7TfPzOyBGW5K6dyWp3NPU0mYLGZRQ", - object: "chat.completion", - created: 1_687_305_079, - model: model, - choices: [ - { - index: 0, - finish_reason: "stop", - message: { - role: "assistant", - content: "Hi there! How can I assist you today?", - }, - }, - ], - usage: { - completion_tokens: 10, - prompt_tokens: 9, - total_tokens: 19, - }, - } - - stub_request(:post, gpt_url).with( - body: "{\"model\":\"#{model}\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}", - headers: { - "Api-Key" => "12345", - "Content-Type" => "application/json", - "Host" => "company.openai.azure.com", - }, - ).to_return(status: 200, body: expected.to_json, headers: {}) - - result = - DiscourseAi::Inference::OpenAiCompletions.perform!( - [role: "user", content: "hello"], - model, - ) - - expect(result).to eq(expected) - end - end - end - - it "supports function calling" do - prompt = [role: "system", content: "you are weatherbot"] - prompt << { role: "user", content: "what is the weather in sydney?" } - - functions = [] - - function = - DiscourseAi::Inference::Function.new( - name: "get_weather", - description: "Get the weather in a city", - ) - - function.add_parameter( - name: "location", - type: "string", - description: "the city name", - required: true, - ) - - function.add_parameter( - name: "unit", - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - required: true, - ) - - functions << function - - function_calls = [] - current_function_call = nil - - deltas = [ - { role: "assistant" }, - { function_call: { name: "get_weather", arguments: "" } }, - { function_call: { arguments: "{ \"location\": " } }, - { function_call: { arguments: "\"sydney\", \"unit\": \"c\" }" } }, - ] - - OpenAiCompletionsInferenceStubs.stub_streamed_response( - prompt, - deltas, - model: "gpt-3.5-turbo-0613", - req_opts: { - functions: functions, - stream: true, - }, - ) - - DiscourseAi::Inference::OpenAiCompletions.perform!( - prompt, - "gpt-3.5-turbo-0613", - functions: functions, - ) do |json, cancel| - fn = json.dig(:choices, 0, :delta, :function_call) - if fn && fn[:name] - current_function_call = { name: fn[:name], arguments: +fn[:arguments].to_s.dup } - function_calls << current_function_call - elsif fn && fn[:arguments] && current_function_call - current_function_call[:arguments] << fn[:arguments] - end - end - - expect(function_calls.length).to eq(1) - expect(function_calls[0][:name]).to eq("get_weather") - expect(JSON.parse(function_calls[0][:arguments])).to eq( - { "location" => "sydney", "unit" => "c" }, - ) - - prompt << { role: "function", name: "get_weather", content: 22.to_json } - - OpenAiCompletionsInferenceStubs.stub_response( - prompt, - "The current temperature in Sydney is 22 degrees Celsius.", - model: "gpt-3.5-turbo-0613", - req_opts: { - functions: functions, - }, - ) - - result = - DiscourseAi::Inference::OpenAiCompletions.perform!( - prompt, - "gpt-3.5-turbo-0613", - functions: functions, - ) - - expect(result.dig(:choices, 0, :message, :content)).to eq( - "The current temperature in Sydney is 22 degrees Celsius.", - ) - end - - it "supports rate limits" do - stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - [ - { status: 429, body: "", headers: {} }, - { status: 429, body: "", headers: {} }, - { status: 200, body: { choices: [message: { content: "ok" }] }.to_json, headers: {} }, - ], - ) - completions = - DiscourseAi::Inference::OpenAiCompletions.perform!( - [{ role: "user", content: "hello" }], - "gpt-3.5-turbo", - temperature: 0.5, - top_p: 0.8, - max_tokens: 700, - retries: 3, - retry_timeout: 0, - ) - - expect(completions.dig(:choices, 0, :message, :content)).to eq("ok") - end - - it "supports will raise once rate limit is met" do - stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - [ - { status: 429, body: "", headers: {} }, - { status: 429, body: "", headers: {} }, - { status: 429, body: "", headers: {} }, - ], - ) - - expect do - DiscourseAi::Inference::OpenAiCompletions.perform!( - [{ role: "user", content: "hello" }], - "gpt-3.5-turbo", - temperature: 0.5, - top_p: 0.8, - max_tokens: 700, - retries: 3, - retry_timeout: 0, - ) - end.to raise_error(DiscourseAi::Inference::OpenAiCompletions::CompletionFailed) - end - - it "can complete a trivial prompt" do - response_text = "1. Serenity\\n2. Laughter\\n3. Adventure" - prompt = [role: "user", content: "write 3 words"] - user_id = 183 - req_opts = { temperature: 0.5, top_p: 0.8, max_tokens: 700 } - - OpenAiCompletionsInferenceStubs.stub_response(prompt, response_text, req_opts: req_opts) - - completions = - DiscourseAi::Inference::OpenAiCompletions.perform!( - prompt, - "gpt-3.5-turbo", - temperature: 0.5, - top_p: 0.8, - max_tokens: 700, - user_id: user_id, - ) - - expect(completions.dig(:choices, 0, :message, :content)).to eq(response_text) - - expect(AiApiAuditLog.count).to eq(1) - log = AiApiAuditLog.first - - body = { model: "gpt-3.5-turbo", messages: prompt }.merge(req_opts).to_json - request_body = OpenAiCompletionsInferenceStubs.response(response_text).to_json - - expect(log.provider_id).to eq(AiApiAuditLog::Provider::OpenAI) - expect(log.request_tokens).to eq(337) - expect(log.response_tokens).to eq(162) - expect(log.raw_request_payload).to eq(body) - expect(log.raw_response_payload).to eq(request_body) - end - - context "when Webmock has streaming support" do - # See: https://github.com/bblimke/webmock/issues/629 - let(:mock_net_http) do - Class.new(Net::HTTP) do - def request(*) - super do |response| - response.instance_eval do - def read_body(*, &) - @body.each(&) - end - end - - yield response if block_given? - - response - end - end - end - end - - let(:remove_original_net_http) { Net.send(:remove_const, :HTTP) } - let(:original_http) { remove_original_net_http } - let(:stub_net_http) { Net.send(:const_set, :HTTP, mock_net_http) } - - let(:remove_stubbed_net_http) { Net.send(:remove_const, :HTTP) } - let(:restore_net_http) { Net.send(:const_set, :HTTP, original_http) } - - before do - mock_net_http - remove_original_net_http - stub_net_http - end - - after do - remove_stubbed_net_http - restore_net_http - end - - it "recovers from chunked payload issues" do - raw_data = <<~TEXT -da|ta: |{"choices":[{"delta":{"content"|:"test"}}]} - -data: {"choices":[{"delta":{"content":"test1"}}]} - -data: {"choices":[{"delta":{"conte|nt":"test2"}}]|} - -data: {"ch|oices":[{"delta|":{"content":"test3"}}]} - -data: [DONE] - TEXT - - chunks = raw_data.split("|") - - stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - status: 200, - body: chunks, - ) - - partials = [] - DiscourseAi::Inference::OpenAiCompletions.perform!([], "gpt-3.5-turbo") do |partial, cancel| - partials << partial - end - - expect(partials.length).to eq(4) - expect(partials).to eq( - [ - { choices: [{ delta: { content: "test" } }] }, - { choices: [{ delta: { content: "test1" } }] }, - { choices: [{ delta: { content: "test2" } }] }, - { choices: [{ delta: { content: "test3" } }] }, - ], - ) - end - - it "support extremely slow streaming" do - raw_data = <<~TEXT -data: {"choices":[{"delta":{"content":"test"}}]} - -data: {"choices":[{"delta":{"content":"test1"}}]} - -data: {"choices":[{"delta":{"content":"test2"}}]} - -data: [DONE] - TEXT - - chunks = raw_data.split("") - - stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - status: 200, - body: chunks, - ) - - partials = [] - DiscourseAi::Inference::OpenAiCompletions.perform!([], "gpt-3.5-turbo") do |partial, cancel| - partials << partial - end - - expect(partials.length).to eq(3) - expect(partials).to eq( - [ - { choices: [{ delta: { content: "test" } }] }, - { choices: [{ delta: { content: "test1" } }] }, - { choices: [{ delta: { content: "test2" } }] }, - ], - ) - end - end - - it "can operate in streaming mode" do - deltas = [ - { role: "assistant" }, - { content: "Mount" }, - { content: "ain" }, - { content: " " }, - { content: "Tree " }, - { content: "Frog" }, - ] - - prompt = [role: "user", content: "write 3 words"] - content = +"" - - OpenAiCompletionsInferenceStubs.stub_streamed_response( - prompt, - deltas, - req_opts: { - stream: true, - }, - ) - - DiscourseAi::Inference::OpenAiCompletions.perform!(prompt, "gpt-3.5-turbo") do |partial, cancel| - data = partial.dig(:choices, 0, :delta, :content) - content << data if data - cancel.call if content.split(" ").length == 2 - end - - expect(content).to eq("Mountain Tree ") - - expect(AiApiAuditLog.count).to eq(1) - log = AiApiAuditLog.first - - request_body = { model: "gpt-3.5-turbo", messages: prompt, stream: true }.to_json - - expect(log.provider_id).to eq(AiApiAuditLog::Provider::OpenAI) - expect(log.request_tokens).to eq(4) - expect(log.response_tokens).to eq(3) - expect(log.raw_request_payload).to eq(request_body) - expect(log.raw_response_payload).to be_present - end -end diff --git a/spec/support/anthropic_completion_stubs.rb b/spec/support/anthropic_completion_stubs.rb deleted file mode 100644 index d464e06b..00000000 --- a/spec/support/anthropic_completion_stubs.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -class AnthropicCompletionStubs - class << self - def response(content) - { - completion: content, - stop: "\n\nHuman:", - stop_reason: "stop_sequence", - truncated: false, - log_id: "12dcc7feafbee4a394e0de9dffde3ac5", - model: "claude-2", - exception: nil, - } - end - - def stub_response(prompt, response_text, req_opts: {}) - WebMock - .stub_request(:post, "https://api.anthropic.com/v1/complete") - .with( - body: { model: "claude-2", prompt: prompt, max_tokens_to_sample: 2000 }.merge( - req_opts, - ).to_json, - ) - .to_return(status: 200, body: JSON.dump(response(response_text))) - end - - def stream_line(delta, finish_reason: nil) - +"data: " << { - completion: delta, - stop: finish_reason ? "\n\nHuman:" : nil, - stop_reason: finish_reason, - truncated: false, - log_id: "12b029451c6d18094d868bc04ce83f63", - model: "claude-2", - exception: nil, - }.to_json - end - - def stub_streamed_response(prompt, deltas, model: nil, req_opts: {}) - chunks = - deltas.each_with_index.map do |_, index| - if index == (deltas.length - 1) - stream_line(deltas[index], finish_reason: "stop_sequence") - else - stream_line(deltas[index]) - end - end - - chunks = chunks.join("\n\n") - - WebMock - .stub_request(:post, "https://api.anthropic.com/v1/complete") - .with(body: { model: model || "claude-2", prompt: prompt }.merge(req_opts).to_json) - .to_return(status: 200, body: chunks) - end - end -end diff --git a/spec/support/openai_completions_inference_stubs.rb b/spec/support/openai_completions_inference_stubs.rb deleted file mode 100644 index 6a88aa6b..00000000 --- a/spec/support/openai_completions_inference_stubs.rb +++ /dev/null @@ -1,177 +0,0 @@ -# frozen_string_literal: true - -class OpenAiCompletionsInferenceStubs - TRANSLATE = "translate" - PROOFREAD = "proofread" - GENERATE_TITLES = "generate_titles" - CUSTOM_PROMPT = "custom_prompt" - EXPLAIN = "explain" - - class << self - def text_mode_to_id(mode) - case mode - when TRANSLATE - -1 - when PROOFREAD - -3 - when GENERATE_TITLES - -2 - when CUSTOM_PROMPT - -5 - when EXPLAIN - -4 - end - end - - def spanish_text - <<~STRING - Para que su horror sea perfecto, César, acosado al pie de la estatua por lo impacientes puñales de sus amigos, - descubre entre las caras y los aceros la de Marco Bruto, su protegido, acaso su hijo, - y ya no se defiende y exclama: ¡Tú también, hijo mío! Shakespeare y Quevedo recogen el patético grito. - - Al destino le agradan las repeticiones, las variantes, las simetrías; diecinueve siglos después, - en el sur de la provincia de Buenos Aires, un gaucho es agredido por otros gauchos y, al caer, - reconoce a un ahijado suyo y le dice con mansa reconvención y lenta sorpresa (estas palabras hay que oírlas, no leerlas): - ¡Pero, che! Lo matan y no sabe que muere para que se repita una escena. - STRING - end - - def custom_prompt_input - "Translate to French" - end - - def custom_prompt_response - <<~STRING - Le destin favorise les répétitions, les variantes, les symétries ; - dix-neuf siècles plus tard, dans la province du sud de Buenos Aires, - un gaucho est attaqué par d'autres gauchos et, en tombant, - reconnaît un filleul à lui et dit avec doux reproche et surprise lente (ces mots doivent être entendus, pas lus) - : 'Mais, mon ami!' Il est tué et ne sait pas qu'il meurt pour qu'une scène puisse être répétée. - STRING - end - - def translated_response - <<~STRING - "To perfect his horror, Caesar, surrounded at the base of the statue by the impatient daggers of his friends, - discovers among the faces and blades that of Marcus Brutus, his protege, perhaps his son, and he no longer - defends himself, but instead exclaims: 'You too, my son!' Shakespeare and Quevedo capture the pathetic cry. - - Destiny favors repetitions, variants, symmetries; nineteen centuries later, in the southern province of Buenos Aires, - a gaucho is attacked by other gauchos and, as he falls, recognizes a godson of his and says with gentle rebuke and - slow surprise (these words must be heard, not read): 'But, my friend!' He is killed and does not know that he - dies so that a scene may be repeated." - STRING - end - - def generated_titles - <<~STRING - 1. "The Life and Death of a Nameless Gaucho" - 2. "The Faith of Iron and Courage: A Gaucho's Legacy" - 3. "The Quiet Piece that Moves Literature: A Gaucho's Story" - 4. "The Unknown Hero: A Gaucho's Sacrifice for Country" - 5. "From Dust to Legacy: The Enduring Name of a Gaucho" - STRING - end - - def proofread_response - <<~STRING - "This excerpt explores the idea of repetition and symmetry in tragic events. The author highlights two instances - where someone is betrayed by a close friend or protege, uttering a similar phrase of surprise and disappointment - before their untimely death. The first example refers to Julius Caesar, who upon realizing that one of his own - friends and proteges, Marcus Brutus, is among his assassins, exclaims \"You too, my son!\" The second example - is of a gaucho in Buenos Aires, who recognizes his godson among his attackers and utters the words of rebuke - and surprise, \"But, my friend!\" before he is killed. The author suggests that these tragedies occur so that - a scene may be repeated, emphasizing the cyclical nature of history and the inevitability of certain events." - STRING - end - - def explain_response - <<~STRING - "In this context, \"pie\" refers to a baked dessert typically consisting of a pastry crust and filling. - The person states they enjoy eating pie, considering it a good dessert. They note that some people wastefully - throw pie at others, but the person themselves chooses to eat the pie rather than throwing it. Overall, \"pie\" - is being used to refer the the baked dessert food item." - STRING - end - - def response(content) - { - id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S", - object: "chat.completion", - created: 1_678_464_820, - model: "gpt-3.5-turbo-0301", - usage: { - prompt_tokens: 337, - completion_tokens: 162, - total_tokens: 499, - }, - choices: [ - { message: { role: "assistant", content: content }, finish_reason: "stop", index: 0 }, - ], - } - end - - def response_text_for(type) - case type - when TRANSLATE - translated_response - when PROOFREAD - proofread_response - when GENERATE_TITLES - generated_titles - when CUSTOM_PROMPT - custom_prompt_response - when EXPLAIN - explain_response - end - end - - def stub_prompt(type) - user_input = type == TRANSLATE ? spanish_text : translated_response - id = text_mode_to_id(type) - - if type == CUSTOM_PROMPT - user_input = { mode: id, text: translated_response, custom_prompt: "Translate to French" } - elsif type == TRANSLATE - user_input = { mode: id, text: spanish_text, custom_prompt: "" } - else - user_input = { mode: id, text: translated_response, custom_prompt: "" } - end - - prompt_messages = CompletionPrompt.find_by(id: id).messages_with_user_input(user_input) - - stub_response(prompt_messages, response_text_for(type)) - end - - def stub_response(messages, response_text, model: nil, req_opts: {}) - WebMock - .stub_request(:post, "https://api.openai.com/v1/chat/completions") - .with(body: { model: model || "gpt-3.5-turbo", messages: messages }.merge(req_opts).to_json) - .to_return(status: 200, body: JSON.dump(response(response_text))) - end - - def stream_line(finish_reason: nil, delta: {}) - +"data: " << { - id: "chatcmpl-#{SecureRandom.hex}", - object: "chat.completion.chunk", - created: 1_681_283_881, - model: "gpt-3.5-turbo-0301", - choices: [{ delta: delta }], - finish_reason: finish_reason, - index: 0, - }.to_json - end - - def stub_streamed_response(messages, deltas, model: nil, req_opts: {}) - chunks = deltas.map { |d| stream_line(delta: d) } - chunks << stream_line(finish_reason: "stop") - chunks << "[DONE]" - chunks = chunks.join("\n\n") - - WebMock - .stub_request(:post, "https://api.openai.com/v1/chat/completions") - .with(body: { model: model || "gpt-3.5-turbo", messages: messages }.merge(req_opts).to_json) - .to_return(status: 200, body: chunks) - end - end -end diff --git a/spec/system/ai_bot/persona_spec.rb b/spec/system/ai_bot/persona_spec.rb index 2ead5e62..b296c515 100644 --- a/spec/system/ai_bot/persona_spec.rb +++ b/spec/system/ai_bot/persona_spec.rb @@ -15,7 +15,7 @@ RSpec.describe "AI personas", type: :system, js: true do find(".d-header .ai-bot-button").click() persona_selector = PageObjects::Components::SelectKit.new(".persona-selector__dropdown") - id = DiscourseAi::AiBot::Personas.all(user: admin).first.id + id = DiscourseAi::AiBot::Personas::Persona.all(user: admin).first.id expect(persona_selector).to have_selected_value(id) @@ -38,7 +38,7 @@ RSpec.describe "AI personas", type: :system, js: true do command_selector = PageObjects::Components::SelectKit.new(".ai-persona-editor__commands") command_selector.expand - command_selector.select_row_by_value("ReadCommand") + command_selector.select_row_by_value("Read") find(".ai-persona-editor__save").click() @@ -50,11 +50,11 @@ RSpec.describe "AI personas", type: :system, js: true do expect(persona.name).to eq("Test Persona") expect(persona.description).to eq("I am a test persona") expect(persona.system_prompt).to eq("You are a helpful bot") - expect(persona.commands).to eq(["ReadCommand"]) + expect(persona.commands).to eq(["Read"]) end it "will not allow deletion or editing of system personas" do - visit "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}" + visit "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}" expect(page).not_to have_selector(".ai-persona-editor__delete") expect(find(".ai-persona-editor__system_prompt")).to be_disabled end