2023-05-11 09:03:03 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
module DiscourseAi
|
|
|
|
module AiBot
|
|
|
|
class Bot
|
2023-08-22 17:49:36 -04:00
|
|
|
class FunctionCalls
|
2023-06-19 18:45:31 -04:00
|
|
|
def initialize
|
|
|
|
@functions = []
|
|
|
|
@current_function = nil
|
2023-08-22 17:49:36 -04:00
|
|
|
@found = false
|
|
|
|
end
|
|
|
|
|
|
|
|
def found?
|
|
|
|
!@functions.empty? || @found
|
|
|
|
end
|
|
|
|
|
|
|
|
def found!
|
|
|
|
@found = true
|
2023-06-19 18:45:31 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def add_function(name)
|
|
|
|
@current_function = { name: name, arguments: +"" }
|
2023-08-22 17:49:36 -04:00
|
|
|
@functions << @current_function
|
2023-06-19 18:45:31 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def add_argument_fragment(fragment)
|
|
|
|
@current_function[:arguments] << fragment
|
|
|
|
end
|
2023-08-22 17:49:36 -04:00
|
|
|
|
|
|
|
def length
|
|
|
|
@functions.length
|
|
|
|
end
|
|
|
|
|
|
|
|
def each
|
|
|
|
@functions.each { |function| yield function }
|
|
|
|
end
|
|
|
|
|
|
|
|
def to_a
|
|
|
|
@functions
|
|
|
|
end
|
2023-06-19 18:45:31 -04:00
|
|
|
end
|
|
|
|
|
2023-05-23 09:08:17 -04:00
|
|
|
attr_reader :bot_user
|
|
|
|
|
2023-05-11 09:03:03 -04:00
|
|
|
BOT_NOT_FOUND = Class.new(StandardError)
|
2023-06-20 01:44:03 -04:00
|
|
|
MAX_COMPLETIONS = 6
|
2023-05-11 09:03:03 -04:00
|
|
|
|
|
|
|
def self.as(bot_user)
|
|
|
|
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
|
|
|
|
|
|
|
|
bot.new(bot_user)
|
|
|
|
end
|
|
|
|
|
|
|
|
def initialize(bot_user)
|
|
|
|
@bot_user = bot_user
|
|
|
|
end
|
|
|
|
|
2023-05-16 13:38:21 -04:00
|
|
|
def update_pm_title(post)
|
2023-06-19 18:45:31 -04:00
|
|
|
prompt = title_prompt(post)
|
2023-05-16 13:38:21 -04:00
|
|
|
|
|
|
|
new_title = get_updated_title(prompt)
|
|
|
|
|
|
|
|
PostRevisor.new(post.topic.first_post, post.topic).revise!(
|
|
|
|
bot_user,
|
|
|
|
title: new_title.sub(/\A"/, "").sub(/"\Z/, ""),
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2023-06-05 17:09:33 -04:00
|
|
|
def max_commands_per_reply=(val)
|
|
|
|
@max_commands_per_reply = val
|
|
|
|
end
|
|
|
|
|
|
|
|
def max_commands_per_reply
|
|
|
|
@max_commands_per_reply || 5
|
|
|
|
end
|
|
|
|
|
2023-05-20 03:45:54 -04:00
|
|
|
def reply_to(
|
|
|
|
post,
|
|
|
|
total_completions: 0,
|
|
|
|
bot_reply_post: nil,
|
|
|
|
prefer_low_cost: false,
|
|
|
|
standalone: false
|
|
|
|
)
|
|
|
|
return if 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)
|
|
|
|
end
|
2023-05-11 09:03:03 -04:00
|
|
|
|
|
|
|
redis_stream_key = nil
|
2023-06-20 01:44:03 -04:00
|
|
|
partial_reply = +""
|
2023-05-23 09:08:17 -04:00
|
|
|
reply = +(bot_reply_post ? bot_reply_post.raw.dup : "")
|
2023-05-11 09:03:03 -04:00
|
|
|
start = Time.now
|
|
|
|
|
2023-05-20 03:45:54 -04:00
|
|
|
setup_cancel = false
|
2023-05-23 09:08:17 -04:00
|
|
|
context = {}
|
2023-08-22 17:49:36 -04:00
|
|
|
functions = FunctionCalls.new
|
2023-05-20 03:45:54 -04:00
|
|
|
|
2023-05-21 22:09:14 -04:00
|
|
|
submit_prompt(prompt, prefer_low_cost: prefer_low_cost) do |partial, cancel|
|
2023-06-20 01:44:03 -04:00
|
|
|
current_delta = get_delta(partial, context)
|
|
|
|
partial_reply << current_delta
|
2023-08-22 17:49:36 -04:00
|
|
|
|
|
|
|
if !available_functions.empty?
|
|
|
|
populate_functions(
|
|
|
|
partial: partial,
|
|
|
|
reply: partial_reply,
|
|
|
|
functions: functions,
|
|
|
|
done: false,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
reply << current_delta if !functions.found?
|
2023-05-11 09:03:03 -04:00
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
next if reply.length < SiteSetting.min_personal_message_post_length
|
|
|
|
# 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: false,
|
|
|
|
)
|
2023-05-20 03:45:54 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
if !setup_cancel && bot_reply_post
|
2023-05-11 09:03:03 -04:00
|
|
|
redis_stream_key = "gpt_cancel:#{bot_reply_post.id}"
|
|
|
|
Discourse.redis.setex(redis_stream_key, 60, 1)
|
2023-05-20 03:45:54 -04:00
|
|
|
setup_cancel = true
|
2023-05-11 09:03:03 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
if bot_reply_post
|
|
|
|
publish_update(bot_reply_post, done: true)
|
2023-05-23 09:08:17 -04:00
|
|
|
|
2023-05-11 09:03:03 -04:00
|
|
|
bot_reply_post.revise(
|
|
|
|
bot_user,
|
|
|
|
{ raw: reply },
|
|
|
|
skip_validations: true,
|
|
|
|
skip_revision: true,
|
|
|
|
)
|
2023-05-20 03:45:54 -04:00
|
|
|
|
2023-06-19 18:45:31 -04:00
|
|
|
bot_reply_post.post_custom_prompt ||= post.build_post_custom_prompt(custom_prompt: [])
|
|
|
|
prompt = post.post_custom_prompt.custom_prompt || []
|
|
|
|
|
2023-06-20 01:44:03 -04:00
|
|
|
prompt << [partial_reply, bot_user.username]
|
|
|
|
|
2023-06-19 18:45:31 -04:00
|
|
|
post.post_custom_prompt.update!(custom_prompt: prompt)
|
|
|
|
end
|
2023-06-05 17:09:33 -04:00
|
|
|
|
2023-08-22 17:49:36 -04:00
|
|
|
if !available_functions.empty?
|
|
|
|
populate_functions(partial: nil, reply: partial_reply, functions: functions, done: true)
|
|
|
|
end
|
|
|
|
|
|
|
|
if functions.length > 0
|
2023-06-05 17:09:33 -04:00
|
|
|
chain = false
|
|
|
|
standalone = false
|
2023-05-20 03:45:54 -04:00
|
|
|
|
2023-08-22 17:49:36 -04:00
|
|
|
functions.each do |function|
|
2023-06-19 18:45:31 -04:00
|
|
|
name, args = function[:name], function[:arguments]
|
2023-05-20 03:45:54 -04:00
|
|
|
|
2023-06-19 18:45:31 -04:00
|
|
|
if command_klass = available_commands.detect { |cmd| cmd.invoked?(name) }
|
2023-08-14 02:30:12 -04:00
|
|
|
command =
|
|
|
|
command_klass.new(
|
|
|
|
bot_user: bot_user,
|
|
|
|
args: args,
|
|
|
|
post: bot_reply_post,
|
|
|
|
parent_post: post,
|
|
|
|
)
|
|
|
|
chain_intermediate, bot_reply_post = command.invoke!
|
2023-06-05 17:09:33 -04:00
|
|
|
chain ||= chain_intermediate
|
|
|
|
standalone ||= command.standalone?
|
2023-05-20 03:45:54 -04:00
|
|
|
end
|
2023-06-05 17:09:33 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
if chain
|
|
|
|
reply_to(
|
|
|
|
bot_reply_post,
|
|
|
|
total_completions: total_completions + 1,
|
|
|
|
bot_reply_post: bot_reply_post,
|
|
|
|
standalone: standalone,
|
|
|
|
)
|
|
|
|
end
|
2023-05-11 09:03:03 -04:00
|
|
|
end
|
|
|
|
rescue => e
|
2023-06-19 18:45:31 -04:00
|
|
|
if Rails.env.development?
|
|
|
|
p e
|
|
|
|
puts e.backtrace
|
|
|
|
end
|
2023-05-20 03:45:54 -04:00
|
|
|
raise e if Rails.env.test?
|
2023-05-11 09:03:03 -04:00
|
|
|
Discourse.warn_exception(e, message: "ai-bot: Reply failed")
|
|
|
|
end
|
|
|
|
|
2023-08-21 18:36:41 -04:00
|
|
|
def extra_tokens_per_message
|
|
|
|
0
|
|
|
|
end
|
|
|
|
|
2023-05-16 13:38:21 -04:00
|
|
|
def bot_prompt_with_topic_context(post, prompt: "topic")
|
2023-05-11 09:03:03 -04:00
|
|
|
messages = []
|
|
|
|
conversation = conversation_context(post)
|
|
|
|
|
2023-05-20 03:45:54 -04:00
|
|
|
rendered_system_prompt = system_prompt(post)
|
|
|
|
|
2023-08-21 18:36:41 -04:00
|
|
|
total_prompt_tokens = tokenize(rendered_system_prompt).length + extra_tokens_per_message
|
2023-05-21 22:09:14 -04:00
|
|
|
|
2023-05-11 09:03:03 -04:00
|
|
|
messages =
|
2023-06-19 18:45:31 -04:00
|
|
|
conversation.reduce([]) do |memo, (raw, username, function)|
|
2023-05-11 09:03:03 -04:00
|
|
|
break(memo) if total_prompt_tokens >= prompt_limit
|
|
|
|
|
2023-06-19 18:45:31 -04:00
|
|
|
tokens = tokenize(raw.to_s)
|
2023-05-11 09:03:03 -04:00
|
|
|
|
2023-08-21 18:36:41 -04:00
|
|
|
while !raw.blank? &&
|
|
|
|
tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit
|
2023-05-20 03:45:54 -04:00
|
|
|
raw = raw[0..-100] || ""
|
2023-06-19 18:45:31 -04:00
|
|
|
tokens = tokenize(raw.to_s)
|
2023-05-11 09:03:03 -04:00
|
|
|
end
|
|
|
|
|
2023-05-20 03:45:54 -04:00
|
|
|
next(memo) if raw.blank?
|
2023-05-11 09:03:03 -04:00
|
|
|
|
2023-08-21 18:36:41 -04:00
|
|
|
total_prompt_tokens += tokens.length + extra_tokens_per_message
|
2023-06-19 18:45:31 -04:00
|
|
|
memo.unshift(build_message(username, raw, function: !!function))
|
2023-05-11 09:03:03 -04:00
|
|
|
end
|
|
|
|
|
2023-05-20 03:45:54 -04:00
|
|
|
messages.unshift(build_message(bot_user.username, rendered_system_prompt, system: true))
|
2023-08-21 18:36:41 -04:00
|
|
|
|
2023-05-11 09:03:03 -04:00
|
|
|
messages
|
|
|
|
end
|
|
|
|
|
|
|
|
def prompt_limit
|
|
|
|
raise NotImplemented
|
|
|
|
end
|
|
|
|
|
2023-05-16 13:38:21 -04:00
|
|
|
def title_prompt(post)
|
2023-06-19 18:45:31 -04:00
|
|
|
[build_message(bot_user.username, <<~TEXT)]
|
2023-05-16 13:38:21 -04:00
|
|
|
Suggest a 7 word title for the following topic without quoting any of it:
|
|
|
|
|
|
|
|
#{post.topic.posts[1..-1].map(&:raw).join("\n\n")[0..prompt_limit]}
|
|
|
|
TEXT
|
|
|
|
end
|
|
|
|
|
2023-05-20 03:45:54 -04:00
|
|
|
def available_commands
|
2023-08-22 17:49:36 -04:00
|
|
|
return @cmds if @cmds
|
|
|
|
|
|
|
|
all_commands =
|
|
|
|
[
|
|
|
|
Commands::CategoriesCommand,
|
|
|
|
Commands::TimeCommand,
|
|
|
|
Commands::SearchCommand,
|
|
|
|
Commands::SummarizeCommand,
|
|
|
|
Commands::ReadCommand,
|
|
|
|
].tap do |cmds|
|
|
|
|
cmds << Commands::TagsCommand if SiteSetting.tagging_enabled
|
|
|
|
cmds << Commands::ImageCommand if SiteSetting.ai_stability_api_key.present?
|
|
|
|
if SiteSetting.ai_google_custom_search_api_key.present? &&
|
|
|
|
SiteSetting.ai_google_custom_search_cx.present?
|
|
|
|
cmds << Commands::GoogleCommand
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
allowed_commands = SiteSetting.ai_bot_enabled_chat_commands.split("|")
|
|
|
|
@cmds = all_commands.filter { |klass| allowed_commands.include?(klass.name) }
|
2023-05-20 03:45:54 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def system_prompt_style!(style)
|
|
|
|
@style = style
|
|
|
|
end
|
|
|
|
|
|
|
|
def system_prompt(post)
|
|
|
|
return "You are a helpful Bot" if @style == :simple
|
2023-08-22 17:49:36 -04:00
|
|
|
|
|
|
|
prompt = +<<~TEXT
|
2023-06-19 18:45:31 -04:00
|
|
|
You are a helpful Discourse assistant.
|
|
|
|
You understand and generate Discourse Markdown.
|
|
|
|
You live in a Discourse Forum Message.
|
2023-05-20 03:45:54 -04:00
|
|
|
|
|
|
|
You live in the forum with the URL: #{Discourse.base_url}
|
|
|
|
The title of your site: #{SiteSetting.title}
|
|
|
|
The description is: #{SiteSetting.site_description}
|
|
|
|
The participants in this conversation are: #{post.topic.allowed_users.map(&:username).join(", ")}
|
|
|
|
The date now is: #{Time.zone.now}, much has changed since you were trained.
|
|
|
|
TEXT
|
2023-08-22 17:49:36 -04:00
|
|
|
|
|
|
|
if include_function_instructions_in_system_prompt?
|
|
|
|
prompt << "\n"
|
|
|
|
prompt << function_list.system_prompt
|
|
|
|
prompt << "\n"
|
|
|
|
end
|
|
|
|
|
|
|
|
prompt << available_commands.map(&:custom_system_message).compact.join("\n")
|
|
|
|
prompt
|
|
|
|
end
|
|
|
|
|
|
|
|
def include_function_instructions_in_system_prompt?
|
|
|
|
true
|
|
|
|
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
|
2023-05-20 03:45:54 -04:00
|
|
|
end
|
|
|
|
|
2023-05-21 22:09:14 -04:00
|
|
|
def tokenize(text)
|
|
|
|
raise NotImplemented
|
|
|
|
end
|
|
|
|
|
|
|
|
def submit_prompt(prompt, prefer_low_cost: false, &blk)
|
|
|
|
raise NotImplemented
|
|
|
|
end
|
|
|
|
|
2023-05-23 09:08:17 -04:00
|
|
|
def get_delta(partial, context)
|
|
|
|
raise NotImplemented
|
|
|
|
end
|
2023-05-11 09:03:03 -04:00
|
|
|
|
2023-08-22 17:49:36 -04:00
|
|
|
def populate_functions(partial:, reply:, functions:, done:)
|
|
|
|
if !done
|
|
|
|
functions.found! if reply.match?(/^!/i)
|
|
|
|
else
|
|
|
|
reply
|
|
|
|
.scan(/^!.*$/i)
|
|
|
|
.each do |line|
|
|
|
|
function_list
|
|
|
|
.parse_prompt(line)
|
|
|
|
.each do |function|
|
|
|
|
functions.add_function(function[:name])
|
|
|
|
functions.add_argument_fragment(function[:arguments].to_json)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def available_functions
|
|
|
|
# note if defined? can be a problem in test
|
|
|
|
# this can never be nil so it is safe
|
|
|
|
return @available_functions if @available_functions
|
|
|
|
|
|
|
|
functions = []
|
|
|
|
|
|
|
|
functions =
|
|
|
|
available_commands.map do |command|
|
|
|
|
function =
|
|
|
|
DiscourseAi::Inference::Function.new(name: command.name, description: command.desc)
|
|
|
|
command.parameters.each do |parameter|
|
|
|
|
function.add_parameter(
|
|
|
|
name: parameter.name,
|
|
|
|
type: parameter.type,
|
|
|
|
description: parameter.description,
|
|
|
|
required: parameter.required,
|
|
|
|
enum: parameter.enum,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
function
|
|
|
|
end
|
|
|
|
|
|
|
|
@available_functions = functions
|
2023-06-19 18:45:31 -04:00
|
|
|
end
|
|
|
|
|
2023-05-23 09:08:17 -04:00
|
|
|
protected
|
2023-05-11 09:03:03 -04:00
|
|
|
|
2023-05-16 13:38:21 -04:00
|
|
|
def get_updated_title(prompt)
|
|
|
|
raise NotImplemented
|
|
|
|
end
|
|
|
|
|
2023-05-11 09:03:03 -04:00
|
|
|
def model_for(bot)
|
|
|
|
raise NotImplemented
|
|
|
|
end
|
|
|
|
|
|
|
|
def conversation_context(post)
|
2023-05-20 03:45:54 -04:00
|
|
|
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 = []
|
|
|
|
|
2023-05-21 22:09:14 -04:00
|
|
|
first = true
|
2023-05-20 03:45:54 -04:00
|
|
|
context.each do |raw, username, custom_prompt|
|
|
|
|
if custom_prompt.present?
|
2023-05-21 22:09:14 -04:00
|
|
|
if first
|
|
|
|
custom_prompt.reverse_each { |message| result << message }
|
|
|
|
first = false
|
|
|
|
else
|
|
|
|
result << custom_prompt.first
|
|
|
|
end
|
2023-05-20 03:45:54 -04:00
|
|
|
else
|
|
|
|
result << [raw, username]
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
result
|
2023-05-11 09:03:03 -04:00
|
|
|
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
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|