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.
294 lines
8.7 KiB
Ruby
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
|