mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-08-04 12:13:48 +00:00
Previous to this changeset we used a custom system for tools/command support for Anthropic. We defined commands by using !command as a signal to execute it Following Anthropic Claude 2.1, there is an official supported syntax (beta) for tools execution. eg: ``` + <function_calls> + <invoke> + <tool_name>image</tool_name> + <parameters> + <prompts> + [ + "an oil painting", + "a cute fluffy orange", + "3 apple's", + "a cat" + ] + </prompts> + </parameters> + </invoke> + </function_calls> ``` This implements the spec per Anthropic, it should be stable enough to also work on other LLMs. Keep in mind that OpenAI is not impacted here at all, as it has its own custom system for function calls. Additionally: - Fixes the title system prompt so it works with latest Anthropic - Uses new spec for "system" messages by Anthropic - Tweak forum helper persona to guide Anthropic a tiny be better Overall results are pretty awesome and Anthropic Claude performs really well now on Discourse
222 lines
5.6 KiB
Ruby
222 lines
5.6 KiB
Ruby
#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
|
|
|
|
class Command
|
|
CARET = "<!-- caret -->"
|
|
PROGRESS_CARET = "<!-- progress -->"
|
|
|
|
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
|
|
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
|
|
<details>
|
|
<summary>#{I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")}</summary>
|
|
<p>
|
|
#{CARET}
|
|
</p>
|
|
</details>
|
|
#{PROGRESS_CARET}
|
|
HTML
|
|
|
|
@invoked = false
|
|
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
|
|
<function_results>
|
|
<result>
|
|
<tool_name>#{self.class.name}</tool_name>
|
|
<json>
|
|
#{function_results}
|
|
</json>
|
|
</result>
|
|
</function_results>
|
|
XML
|
|
prompt << [function_results, self.class.name, "function"]
|
|
@post.post_custom_prompt.update!(custom_prompt: prompt)
|
|
|
|
raw = +(<<~HTML)
|
|
<details>
|
|
<summary>#{I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")}</summary>
|
|
<p>
|
|
#{localized_description}
|
|
</p>
|
|
</details>
|
|
|
|
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
|