Files
discourse-ai/lib/modules/ai_bot/bot.rb
T
Sam 840968630e FEATURE: disable smart commands on Claude and GPT 3.5 (#84)
For the time being smart commands only work consistently on GPT 4.
Avoid using any smart commands on the earlier models.

Additionally adds better error handling to Claude which sometimes streams
partial json and slightly tunes the search command.
2023-06-01 09:10:33 +10:00

294 lines
8.7 KiB
Ruby

# frozen_string_literal: true
module DiscourseAi
module AiBot
class Bot
attr_reader :bot_user
BOT_NOT_FOUND = Class.new(StandardError)
MAX_COMPLETIONS = 3
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
def update_pm_title(post)
prompt = [title_prompt(post)]
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
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
redis_stream_key = nil
reply = +(bot_reply_post ? bot_reply_post.raw.dup : "")
start = Time.now
setup_cancel = false
context = {}
submit_prompt(prompt, prefer_low_cost: prefer_low_cost) do |partial, cancel|
reply << get_delta(partial, context)
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,
)
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 bot_reply_post
publish_update(bot_reply_post, done: true)
bot_reply_post.revise(
bot_user,
{ raw: reply },
skip_validations: true,
skip_revision: true,
)
cmd_text = reply.split("\n").detect { |l| l[0] == "!" }
if cmd_text
command_name, args = cmd_text[1..-1].strip.split(" ", 2)
if command_klass = available_commands.detect { |cmd| cmd.invoked?(command_name) }
command = command_klass.new(bot_user, args)
chain = command.invoke_and_attach_result_to(bot_reply_post)
if chain
reply_to(
bot_reply_post,
total_completions: total_completions + 1,
bot_reply_post: bot_reply_post,
prefer_low_cost: command.low_cost?,
standalone: command.standalone?,
)
end
end
elsif post_custom_prompt = bot_reply_post.post_custom_prompt
prompt = post_custom_prompt.custom_prompt
prompt << [reply, bot_user.username]
post_custom_prompt.update!(custom_prompt: prompt)
end
end
rescue => e
raise e if Rails.env.test?
Discourse.warn_exception(e, message: "ai-bot: Reply failed")
end
def bot_prompt_with_topic_context(post, prompt: "topic")
messages = []
conversation = conversation_context(post)
rendered_system_prompt = system_prompt(post)
total_prompt_tokens = tokenize(rendered_system_prompt).length
messages =
conversation.reduce([]) do |memo, (raw, username)|
break(memo) if total_prompt_tokens >= prompt_limit
tokens = tokenize(raw)
while !raw.blank? && tokens.length + total_prompt_tokens > prompt_limit
raw = raw[0..-100] || ""
tokens = tokenize(raw)
end
next(memo) if raw.blank?
total_prompt_tokens += tokens.length
memo.unshift(build_message(username, raw))
end
# we need this to ground the model (especially GPT-3.5)
messages.unshift(build_message(bot_user.username, "!echo 1"))
messages.unshift(build_message("user", "please echo 1"))
messages.unshift(build_message(bot_user.username, rendered_system_prompt, system: true))
messages
end
def prompt_limit
raise NotImplemented
end
def title_prompt(post)
build_message(bot_user.username, <<~TEXT)
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
def available_commands
# by default assume bots have no access to commands
# for now we need GPT 4 to properly work with them
[]
end
def system_prompt_style!(style)
@style = style
end
def system_prompt(post)
return "You are a helpful Bot" if @style == :simple
command_text = ""
command_text = <<~TEXT if available_commands.present?
You can complete some tasks using !commands.
NEVER ask user to issue !commands, they have no access, only you do.
#{available_commands.map(&:desc).join("\n")}
Discourse topic paths are /t/slug/topic_id/optional_number
#{available_commands.map(&:extra_context).compact_blank.join("\n")}
Commands should be issued in single assistant message.
Example sessions:
User: echo the text 'test'
GPT: !echo test
User: THING GPT DOES NOT KNOW ABOUT
GPT: !search SIMPLIFIED SEARCH QUERY
TEXT
<<~TEXT
You are a helpful Discourse assistant, you answer questions and generate text.
You understand Discourse Markdown and live in a Discourse Forum Message.
You are provided with the context of previous discussions.
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.
#{command_text}
TEXT
end
def tokenize(text)
raise NotImplemented
end
def submit_prompt(prompt, prefer_low_cost: false, &blk)
raise NotImplemented
end
def get_delta(partial, context)
raise NotImplemented
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
else
result << custom_prompt.first
end
else
result << [raw, username]
end
end
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,
)
end
end
end
end