* DEV: refactor bot internals This introduces a proper object for bot context, this makes it simpler to improve context management as we go cause we have a nice object to work with Starts refactoring allowing for a single message to have multiple uploads throughout * transplant method to message builder * chipping away at inline uploads * image support is improved but not fully fixed yet partially working in anthropic, still got quite a few dialects to go * open ai and claude are now working * Gemini is now working as well * fix nova * more dialects... * fix ollama * fix specs * update artifact fixed * more tests * spam scanner * pass more specs * bunch of specs improved * more bug fixes. * all the rest of the tests are working * improve tests coverage and ensure custom tools are aware of new context object * tests are working, but we need more tests * resolve merge conflict * new preamble and expanded specs on ai tool * remove concept of "standalone tools" This is no longer needed, we can set custom raw, tool details are injected into tool calls
296 lines
8.9 KiB
Ruby
296 lines
8.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module DiscourseAi
|
|
module AiHelper
|
|
class Assistant
|
|
IMAGE_CAPTION_MAX_WORDS = 50
|
|
|
|
def self.prompt_cache
|
|
@prompt_cache ||= ::DiscourseAi::MultisiteHash.new("prompt_cache")
|
|
end
|
|
|
|
def self.clear_prompt_cache!
|
|
prompt_cache.flush!
|
|
end
|
|
|
|
def initialize(helper_llm: nil, image_caption_llm: nil)
|
|
@helper_llm = helper_llm
|
|
@image_caption_llm = image_caption_llm
|
|
end
|
|
|
|
def helper_llm
|
|
@helper_llm || DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model)
|
|
end
|
|
|
|
def image_caption_llm
|
|
@image_caption_llm ||
|
|
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_image_caption_model)
|
|
end
|
|
|
|
def available_prompts(user)
|
|
key = "prompt_cache_#{I18n.locale}"
|
|
self
|
|
.class
|
|
.prompt_cache
|
|
.fetch(key) do
|
|
prompts = CompletionPrompt.where(enabled: true)
|
|
|
|
# Hide illustrate_post if disabled
|
|
prompts =
|
|
prompts.where.not(
|
|
name: "illustrate_post",
|
|
) if SiteSetting.ai_helper_illustrate_post_model == "disabled"
|
|
|
|
prompts =
|
|
prompts.map do |prompt|
|
|
if prompt.name == "translate"
|
|
locale = user.effective_locale
|
|
locale_hash =
|
|
LocaleSiteSetting.language_names[locale] ||
|
|
LocaleSiteSetting.language_names[locale.split("_")[0]]
|
|
translation =
|
|
I18n.t(
|
|
"discourse_ai.ai_helper.prompts.translate",
|
|
language: locale_hash["nativeName"],
|
|
) || prompt.translated_name || prompt.name
|
|
else
|
|
translation =
|
|
I18n.t("discourse_ai.ai_helper.prompts.#{prompt.name}", default: nil) ||
|
|
prompt.translated_name || prompt.name
|
|
end
|
|
|
|
{
|
|
id: prompt.id,
|
|
name: prompt.name,
|
|
translated_name: translation,
|
|
prompt_type: prompt.prompt_type,
|
|
icon: icon_map(prompt.name),
|
|
location: location_map(prompt.name),
|
|
}
|
|
end
|
|
prompts
|
|
end
|
|
end
|
|
|
|
def custom_locale_instructions(user = nil, force_default_locale)
|
|
locale = SiteSetting.default_locale
|
|
locale = user.effective_locale if !force_default_locale && user
|
|
locale_hash = LocaleSiteSetting.language_names[locale]
|
|
|
|
if locale != "en" && locale_hash
|
|
locale_description = "#{locale_hash["name"]} (#{locale_hash["nativeName"]})"
|
|
"It is imperative that you write your answer in #{locale_description}, you are interacting with a #{locale_description} speaking user. Leave tag names in English."
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def localize_prompt!(prompt, user = nil, force_default_locale = false)
|
|
locale_instructions = custom_locale_instructions(user, force_default_locale)
|
|
if locale_instructions
|
|
prompt.messages[0][:content] = prompt.messages[0][:content] + locale_instructions
|
|
end
|
|
|
|
if prompt.messages[0][:content].include?("%LANGUAGE%")
|
|
locale = SiteSetting.default_locale
|
|
|
|
locale = user.effective_locale if user && !force_default_locale
|
|
|
|
locale_hash = LocaleSiteSetting.language_names[locale]
|
|
|
|
prompt.messages[0][:content] = prompt.messages[0][:content].gsub(
|
|
"%LANGUAGE%",
|
|
"#{locale_hash["name"]}",
|
|
)
|
|
end
|
|
|
|
if user && prompt.messages[0][:content].include?("{{temporal_context}}")
|
|
timezone = user.user_option.timezone || "UTC"
|
|
current_time = Time.now.in_time_zone(timezone)
|
|
|
|
temporal_context = {
|
|
utc_date_time: current_time.iso8601,
|
|
local_time: current_time.strftime("%H:%M"),
|
|
user: {
|
|
timezone: timezone,
|
|
weekday: current_time.strftime("%A"),
|
|
},
|
|
}
|
|
|
|
prompt.messages[0][:content] = prompt.messages[0][:content].gsub(
|
|
"{{temporal_context}}",
|
|
temporal_context.to_json,
|
|
)
|
|
|
|
prompt.messages.each do |message|
|
|
message[:content] = DateFormatter.process_date_placeholders(message[:content], user)
|
|
end
|
|
end
|
|
end
|
|
|
|
def generate_prompt(completion_prompt, input, user, force_default_locale = false, &block)
|
|
llm = helper_llm
|
|
prompt = completion_prompt.messages_with_input(input)
|
|
localize_prompt!(prompt, user, force_default_locale)
|
|
|
|
llm.generate(
|
|
prompt,
|
|
user: user,
|
|
temperature: completion_prompt.temperature,
|
|
stop_sequences: completion_prompt.stop_sequences,
|
|
feature_name: "ai_helper",
|
|
&block
|
|
)
|
|
end
|
|
|
|
def generate_and_send_prompt(completion_prompt, input, user, force_default_locale = false)
|
|
completion_result = generate_prompt(completion_prompt, input, user, force_default_locale)
|
|
result = { type: completion_prompt.prompt_type }
|
|
|
|
result[:suggestions] = (
|
|
if completion_prompt.list?
|
|
parse_list(completion_result).map { |suggestion| sanitize_result(suggestion) }
|
|
else
|
|
sanitized = sanitize_result(completion_result)
|
|
result[:diff] = parse_diff(input, sanitized) if completion_prompt.diff?
|
|
[sanitized]
|
|
end
|
|
)
|
|
|
|
result
|
|
end
|
|
|
|
def stream_prompt(completion_prompt, input, user, channel)
|
|
streamed_result = +""
|
|
start = Time.now
|
|
|
|
generate_prompt(completion_prompt, input, user) do |partial_response, cancel_function|
|
|
streamed_result << partial_response
|
|
|
|
# Throttle the updates
|
|
if (Time.now - start > 0.5) || Rails.env.test?
|
|
payload = { result: sanitize_result(streamed_result), done: false }
|
|
publish_update(channel, payload, user)
|
|
start = Time.now
|
|
end
|
|
end
|
|
|
|
sanitized_result = sanitize_result(streamed_result)
|
|
if sanitized_result.present?
|
|
publish_update(channel, { result: sanitized_result, done: true }, user)
|
|
end
|
|
end
|
|
|
|
def generate_image_caption(upload, user)
|
|
prompt =
|
|
DiscourseAi::Completions::Prompt.new(
|
|
"You are a bot specializing in image captioning.",
|
|
messages: [
|
|
{
|
|
type: :user,
|
|
content: [
|
|
"Describe this image in a single sentence#{custom_locale_instructions(user)}",
|
|
{ upload_id: upload.id },
|
|
],
|
|
},
|
|
],
|
|
)
|
|
|
|
raw_caption =
|
|
image_caption_llm.generate(
|
|
prompt,
|
|
user: user,
|
|
max_tokens: 1024,
|
|
feature_name: "image_caption",
|
|
)
|
|
|
|
raw_caption.delete("|").squish.truncate_words(IMAGE_CAPTION_MAX_WORDS)
|
|
end
|
|
|
|
private
|
|
|
|
SANITIZE_REGEX_STR =
|
|
%w[term context topic replyTo input output result]
|
|
.map { |tag| "<#{tag}>\\n?|\\n?</#{tag}>" }
|
|
.join("|")
|
|
|
|
SANITIZE_REGEX = Regexp.new(SANITIZE_REGEX_STR, Regexp::IGNORECASE | Regexp::MULTILINE)
|
|
|
|
def sanitize_result(result)
|
|
result.gsub(SANITIZE_REGEX, "")
|
|
end
|
|
|
|
def publish_update(channel, payload, user)
|
|
MessageBus.publish(channel, payload, user_ids: [user.id])
|
|
end
|
|
|
|
def icon_map(name)
|
|
case name
|
|
when "translate"
|
|
"language"
|
|
when "generate_titles"
|
|
"heading"
|
|
when "proofread"
|
|
"spell-check"
|
|
when "markdown_table"
|
|
"table"
|
|
when "tone"
|
|
"microphone"
|
|
when "custom_prompt"
|
|
"comment"
|
|
when "rewrite"
|
|
"pen"
|
|
when "explain"
|
|
"question"
|
|
when "illustrate_post"
|
|
"images"
|
|
when "replace_dates"
|
|
"calendar-days"
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def location_map(name)
|
|
case name
|
|
when "translate"
|
|
%w[composer post]
|
|
when "generate_titles"
|
|
%w[composer]
|
|
when "proofread"
|
|
%w[composer post]
|
|
when "markdown_table"
|
|
%w[composer]
|
|
when "tone"
|
|
%w[composer]
|
|
when "custom_prompt"
|
|
%w[composer post]
|
|
when "rewrite"
|
|
%w[composer]
|
|
when "explain"
|
|
%w[post]
|
|
when "summarize"
|
|
%w[post]
|
|
when "illustrate_post"
|
|
%w[composer]
|
|
when "replace_dates"
|
|
%w[composer]
|
|
else
|
|
%w[]
|
|
end
|
|
end
|
|
|
|
def parse_diff(text, suggestion)
|
|
cooked_text = PrettyText.cook(text)
|
|
cooked_suggestion = PrettyText.cook(suggestion)
|
|
|
|
DiscourseDiff.new(cooked_text, cooked_suggestion).inline_html
|
|
end
|
|
|
|
def parse_list(list)
|
|
Nokogiri::HTML5.fragment(list).css("item").map(&:text)
|
|
end
|
|
end
|
|
end
|
|
end
|