FEATURE: Anthropic Claude for AIHelper and Summarization modules (#39)
This commit is contained in:
parent
f2e52f7f24
commit
bb0b829634
|
@ -10,7 +10,7 @@ module DiscourseAi
|
||||||
def prompts
|
def prompts
|
||||||
render json:
|
render json:
|
||||||
ActiveModel::ArraySerializer.new(
|
ActiveModel::ArraySerializer.new(
|
||||||
DiscourseAi::AiHelper::OpenAiPrompt.new.available_prompts,
|
DiscourseAi::AiHelper::LlmPrompt.new.available_prompts,
|
||||||
root: false,
|
root: false,
|
||||||
),
|
),
|
||||||
status: 200
|
status: 200
|
||||||
|
@ -19,20 +19,21 @@ module DiscourseAi
|
||||||
def suggest
|
def suggest
|
||||||
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
|
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
|
||||||
|
|
||||||
prompt = CompletionPrompt.find_by(name: params[:mode])
|
prompt = CompletionPrompt.find_by(id: params[:mode])
|
||||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
||||||
|
|
||||||
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||||
|
|
||||||
hijack do
|
hijack do
|
||||||
render json:
|
render json:
|
||||||
DiscourseAi::AiHelper::OpenAiPrompt.new.generate_and_send_prompt(
|
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
|
||||||
prompt,
|
prompt,
|
||||||
params[:text],
|
params[:text],
|
||||||
),
|
),
|
||||||
status: 200
|
status: 200
|
||||||
end
|
end
|
||||||
rescue DiscourseAi::Inference::OpenAiCompletions::CompletionFailed
|
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||||
|
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||||
status: 502
|
status: 502
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,16 +4,17 @@ class CompletionPrompt < ActiveRecord::Base
|
||||||
# TODO(roman): Remove sept 2023.
|
# TODO(roman): Remove sept 2023.
|
||||||
self.ignored_columns = ["value"]
|
self.ignored_columns = ["value"]
|
||||||
|
|
||||||
VALID_ROLES = %w[system user assistant]
|
|
||||||
|
|
||||||
enum :prompt_type, { text: 0, list: 1, diff: 2 }
|
enum :prompt_type, { text: 0, list: 1, diff: 2 }
|
||||||
|
|
||||||
validates :messages, length: { maximum: 20 }
|
validates :messages, length: { maximum: 20 }
|
||||||
validate :each_message_length
|
validate :each_message_length
|
||||||
validate :each_message_role
|
|
||||||
|
|
||||||
def messages_with_user_input(user_input)
|
def messages_with_user_input(user_input)
|
||||||
|
if ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider == "openai"
|
||||||
self.messages << { role: "user", content: user_input }
|
self.messages << { role: "user", content: user_input }
|
||||||
|
else
|
||||||
|
self.messages << { "role" => "Input", "content" => "<input>#{user_input}</input>" }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -25,14 +26,6 @@ class CompletionPrompt < ActiveRecord::Base
|
||||||
errors.add(:messages, I18n.t("errors.prompt_message_length", idx: idx + 1))
|
errors.add(:messages, I18n.t("errors.prompt_message_length", idx: idx + 1))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def each_message_role
|
|
||||||
messages.each_with_index do |msg, idx|
|
|
||||||
next if VALID_ROLES.include?(msg["role"])
|
|
||||||
|
|
||||||
errors.add(:messages, I18n.t("errors.invalid_prompt_role", idx: idx + 1))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
@ -46,9 +39,10 @@ end
|
||||||
# enabled :boolean default(TRUE), not null
|
# enabled :boolean default(TRUE), not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# messages :jsonb not null
|
# messages :jsonb
|
||||||
|
# provider :text
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_completion_prompts_on_name (name) UNIQUE
|
# index_completion_prompts_on_name (name)
|
||||||
#
|
#
|
||||||
|
|
|
@ -21,6 +21,7 @@ export default class AiHelper extends Component {
|
||||||
@tracked proofreadDiff = null;
|
@tracked proofreadDiff = null;
|
||||||
|
|
||||||
@tracked helperOptions = [];
|
@tracked helperOptions = [];
|
||||||
|
prompts = [];
|
||||||
promptTypes = {};
|
promptTypes = {};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -29,7 +30,11 @@ export default class AiHelper extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPrompts() {
|
async loadPrompts() {
|
||||||
const prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
||||||
|
|
||||||
|
prompts.map((p) => {
|
||||||
|
this.prompts[p.id] = p;
|
||||||
|
});
|
||||||
|
|
||||||
this.promptTypes = prompts.reduce((memo, p) => {
|
this.promptTypes = prompts.reduce((memo, p) => {
|
||||||
memo[p.name] = p.prompt_type;
|
memo[p.name] = p.prompt_type;
|
||||||
|
@ -39,7 +44,7 @@ export default class AiHelper extends Component {
|
||||||
this.helperOptions = prompts.map((p) => {
|
this.helperOptions = prompts.map((p) => {
|
||||||
return {
|
return {
|
||||||
name: p.translated_name,
|
name: p.translated_name,
|
||||||
value: p.name,
|
value: p.id,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -53,7 +58,9 @@ export default class AiHelper extends Component {
|
||||||
@computed("selected", "selectedTitle", "translatingText", "proofreadingText")
|
@computed("selected", "selectedTitle", "translatingText", "proofreadingText")
|
||||||
get canSave() {
|
get canSave() {
|
||||||
return (
|
return (
|
||||||
(this.promptTypes[this.selected] === LIST && this.selectedTitle) ||
|
(this.selected &&
|
||||||
|
this.prompts[this.selected].prompt_type === LIST &&
|
||||||
|
this.selectedTitle) ||
|
||||||
this.translatingText ||
|
this.translatingText ||
|
||||||
this.proofreadingText
|
this.proofreadingText
|
||||||
);
|
);
|
||||||
|
@ -62,19 +69,26 @@ export default class AiHelper extends Component {
|
||||||
@computed("selected", "translatedSuggestion")
|
@computed("selected", "translatedSuggestion")
|
||||||
get translatingText() {
|
get translatingText() {
|
||||||
return (
|
return (
|
||||||
this.promptTypes[this.selected] === TEXT && this.translatedSuggestion
|
this.selected &&
|
||||||
|
this.prompts[this.selected].prompt_type === TEXT &&
|
||||||
|
this.translatedSuggestion
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed("selected", "proofReadSuggestion")
|
@computed("selected", "proofReadSuggestion")
|
||||||
get proofreadingText() {
|
get proofreadingText() {
|
||||||
return this.promptTypes[this.selected] === DIFF && this.proofReadSuggestion;
|
return (
|
||||||
|
this.selected &&
|
||||||
|
this.prompts[this.selected].prompt_type === DIFF &&
|
||||||
|
this.proofReadSuggestion
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed("selected", "generatedTitlesSuggestions")
|
@computed("selected", "generatedTitlesSuggestions")
|
||||||
get selectingTopicTitle() {
|
get selectingTopicTitle() {
|
||||||
return (
|
return (
|
||||||
this.promptTypes[this.selected] === LIST &&
|
this.selected &&
|
||||||
|
this.prompts[this.selected].prompt_type === LIST &&
|
||||||
this.generatedTitlesSuggestions.length > 0
|
this.generatedTitlesSuggestions.length > 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,7 +60,7 @@ export default {
|
||||||
const allowedGroups = settings.ai_helper_allowed_groups
|
const allowedGroups = settings.ai_helper_allowed_groups
|
||||||
.split("|")
|
.split("|")
|
||||||
.map(parseInt);
|
.map(parseInt);
|
||||||
const canUseAssistant =
|
let canUseAssistant =
|
||||||
user && user.groups.some((g) => allowedGroups.includes(g.id));
|
user && user.groups.some((g) => allowedGroups.includes(g.id));
|
||||||
|
|
||||||
if (helperEnabled && canUseAssistant) {
|
if (helperEnabled && canUseAssistant) {
|
||||||
|
|
|
@ -87,6 +87,8 @@ plugins:
|
||||||
|
|
||||||
ai_openai_api_key:
|
ai_openai_api_key:
|
||||||
default: ""
|
default: ""
|
||||||
|
ai_anthropic_api_key:
|
||||||
|
default: ""
|
||||||
|
|
||||||
composer_ai_helper_enabled:
|
composer_ai_helper_enabled:
|
||||||
default: false
|
default: false
|
||||||
|
@ -107,6 +109,7 @@ plugins:
|
||||||
choices:
|
choices:
|
||||||
- gpt-3.5-turbo
|
- gpt-3.5-turbo
|
||||||
- gpt-4
|
- gpt-4
|
||||||
|
- claude-v1
|
||||||
|
|
||||||
ai_embeddings_enabled:
|
ai_embeddings_enabled:
|
||||||
default: false
|
default: false
|
||||||
|
@ -165,4 +168,5 @@ plugins:
|
||||||
- long-t5-tglobal-base-16384-book-summary
|
- long-t5-tglobal-base-16384-book-summary
|
||||||
- gpt-3.5-turbo
|
- gpt-3.5-turbo
|
||||||
- gpt-4
|
- gpt-4
|
||||||
|
- claude-v1
|
||||||
ai_summarization_rate_limit_minutes: 10
|
ai_summarization_rate_limit_minutes: 10
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
CompletionPrompt.seed do |cp|
|
CompletionPrompt.seed do |cp|
|
||||||
cp.id = -1
|
cp.id = -1
|
||||||
|
cp.provider = "openai"
|
||||||
cp.name = "translate"
|
cp.name = "translate"
|
||||||
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
||||||
cp.messages = [{ role: "system", content: <<~TEXT }]
|
cp.messages = [{ role: "system", content: <<~TEXT }]
|
||||||
|
@ -15,6 +16,7 @@ end
|
||||||
|
|
||||||
CompletionPrompt.seed do |cp|
|
CompletionPrompt.seed do |cp|
|
||||||
cp.id = -2
|
cp.id = -2
|
||||||
|
cp.provider = "openai"
|
||||||
cp.name = "generate_titles"
|
cp.name = "generate_titles"
|
||||||
cp.prompt_type = CompletionPrompt.prompt_types[:list]
|
cp.prompt_type = CompletionPrompt.prompt_types[:list]
|
||||||
cp.messages = [{ role: "system", content: <<~TEXT }]
|
cp.messages = [{ role: "system", content: <<~TEXT }]
|
||||||
|
@ -27,6 +29,7 @@ end
|
||||||
|
|
||||||
CompletionPrompt.seed do |cp|
|
CompletionPrompt.seed do |cp|
|
||||||
cp.id = -3
|
cp.id = -3
|
||||||
|
cp.provider = "openai"
|
||||||
cp.name = "proofread"
|
cp.name = "proofread"
|
||||||
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
||||||
cp.messages = [
|
cp.messages = [
|
||||||
|
@ -83,6 +86,7 @@ end
|
||||||
|
|
||||||
CompletionPrompt.seed do |cp|
|
CompletionPrompt.seed do |cp|
|
||||||
cp.id = -4
|
cp.id = -4
|
||||||
|
cp.provider = "openai"
|
||||||
cp.name = "markdown_table"
|
cp.name = "markdown_table"
|
||||||
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
||||||
cp.messages = [
|
cp.messages = [
|
|
@ -0,0 +1,56 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
CompletionPrompt.seed do |cp|
|
||||||
|
cp.id = -101
|
||||||
|
cp.provider = "anthropic"
|
||||||
|
cp.name = "Traslate to English"
|
||||||
|
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
||||||
|
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
||||||
|
I want you to act as an English translator, spelling corrector and improver. I will speak to you
|
||||||
|
in any language and you will detect the language, translate it and answer in the corrected and
|
||||||
|
improved version of my text, in English. I want you to replace my simplified A0-level words and
|
||||||
|
sentences with more beautiful and elegant, upper level English words and sentences.
|
||||||
|
Keep the meaning same, but make them more literary. I will provide you with a text inside <input> tags,
|
||||||
|
please put the translation between <ai></ai> tags.
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
|
||||||
|
CompletionPrompt.seed do |cp|
|
||||||
|
cp.id = -102
|
||||||
|
cp.provider = "anthropic"
|
||||||
|
cp.name = "Suggest topic titles"
|
||||||
|
cp.prompt_type = CompletionPrompt.prompt_types[:list]
|
||||||
|
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
||||||
|
I want you to act as a title generator for written pieces. I will provide you with a text inside <input> tags,
|
||||||
|
and you will generate five attention-grabbing titles. Please keep the title concise and under 20 words,
|
||||||
|
and ensure that the meaning is maintained. Replies will utilize the language type of the topic.
|
||||||
|
Please put each suggestion between <ai></ai> tags.
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
|
||||||
|
CompletionPrompt.seed do |cp|
|
||||||
|
cp.id = -103
|
||||||
|
cp.provider = "anthropic"
|
||||||
|
cp.name = "Proofread"
|
||||||
|
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
||||||
|
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
||||||
|
You are a markdown proofreader. You correct egregious typos and phrasing issues but keep the user's original voice.
|
||||||
|
You do not touch code blocks. I will provide you with text to proofread. If nothing needs fixing, then you will echo the text back.
|
||||||
|
|
||||||
|
Optionally, a user can specify intensity. Intensity 10 is a pedantic English teacher correcting the text.
|
||||||
|
Intensity 1 is a minimal proofreader. By default, you operate at intensity 1.
|
||||||
|
I will provide you with a text inside <input> tags,
|
||||||
|
please reply with the corrected text between <ai></ai> tags.
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
|
||||||
|
CompletionPrompt.seed do |cp|
|
||||||
|
cp.id = -104
|
||||||
|
cp.provider = "anthropic"
|
||||||
|
cp.name = "Convert to table"
|
||||||
|
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
||||||
|
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
||||||
|
You are a markdown table formatter, I will provide you text and you will format it into a markdown table.
|
||||||
|
I will provide you with a text inside <input> tags,
|
||||||
|
please reply with the corrected text between <ai></ai> tags.
|
||||||
|
TEXT
|
||||||
|
end
|
|
@ -0,0 +1,22 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddProviderToCompletionPrompts < ActiveRecord::Migration[7.0]
|
||||||
|
def up
|
||||||
|
remove_index :completion_prompts, name: "index_completion_prompts_on_name"
|
||||||
|
add_column :completion_prompts, :provider, :text
|
||||||
|
add_index :completion_prompts, %i[name], unique: false
|
||||||
|
|
||||||
|
# set provider for existing prompts
|
||||||
|
DB.exec <<~SQL
|
||||||
|
UPDATE completion_prompts
|
||||||
|
SET provider = 'openai'
|
||||||
|
WHERE provider IS NULL;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :completion_prompts, :provider
|
||||||
|
remove_index :completion_prompts, name: "index_completion_prompts_on_name"
|
||||||
|
add_index :completion_prompts, %i[name], unique: true
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,7 +3,7 @@ module DiscourseAi
|
||||||
module AiHelper
|
module AiHelper
|
||||||
class EntryPoint
|
class EntryPoint
|
||||||
def load_files
|
def load_files
|
||||||
require_relative "open_ai_prompt"
|
require_relative "llm_prompt"
|
||||||
end
|
end
|
||||||
|
|
||||||
def inject_into(plugin)
|
def inject_into(plugin)
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module AiHelper
|
||||||
|
class LlmPrompt
|
||||||
|
def available_prompts
|
||||||
|
CompletionPrompt
|
||||||
|
.where(provider: enabled_provider)
|
||||||
|
.where(enabled: true)
|
||||||
|
.map do |prompt|
|
||||||
|
translation =
|
||||||
|
I18n.t("discourse_ai.ai_helper.prompts.#{prompt.name}", default: nil) ||
|
||||||
|
prompt.translated_name || prompt.name
|
||||||
|
|
||||||
|
{
|
||||||
|
id: prompt.id,
|
||||||
|
name: prompt.name,
|
||||||
|
translated_name: translation,
|
||||||
|
prompt_type: prompt.prompt_type,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_and_send_prompt(prompt, text)
|
||||||
|
if enabled_provider == "openai"
|
||||||
|
openai_call(prompt, text)
|
||||||
|
else
|
||||||
|
anthropic_call(prompt, text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled_provider
|
||||||
|
if SiteSetting.ai_helper_model.start_with?("gpt")
|
||||||
|
"openai"
|
||||||
|
else
|
||||||
|
"anthropic"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_diff(text, suggestion)
|
||||||
|
cooked_text = PrettyText.cook(text)
|
||||||
|
cooked_suggestion = PrettyText.cook(suggestion)
|
||||||
|
|
||||||
|
DiscourseDiff.new(cooked_text, cooked_suggestion).inline_html
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_content(prompt, content)
|
||||||
|
return "" if content.blank?
|
||||||
|
|
||||||
|
if enabled_provider == "openai"
|
||||||
|
return content.strip if !prompt.list?
|
||||||
|
|
||||||
|
content.gsub("\"", "").gsub(/\d./, "").split("\n").map(&:strip)
|
||||||
|
else
|
||||||
|
parse_antropic_content(prompt, content)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def openai_call(prompt, text)
|
||||||
|
result = { type: prompt.prompt_type }
|
||||||
|
|
||||||
|
messages = prompt.messages_with_user_input(text)
|
||||||
|
|
||||||
|
result[:suggestions] = DiscourseAi::Inference::OpenAiCompletions
|
||||||
|
.perform!(messages)
|
||||||
|
.dig(:choices)
|
||||||
|
.to_a
|
||||||
|
.flat_map { |choice| parse_content(prompt, choice.dig(:message, :content).to_s) }
|
||||||
|
.compact_blank
|
||||||
|
|
||||||
|
result[:diff] = generate_diff(text, result[:suggestions].first) if prompt.diff?
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def anthropic_call(prompt, text)
|
||||||
|
result = { type: prompt.prompt_type }
|
||||||
|
|
||||||
|
filled_message = prompt.messages_with_user_input(text)
|
||||||
|
|
||||||
|
message =
|
||||||
|
filled_message.map { |msg| "#{msg["role"]}: #{msg["content"]}" }.join("\n\n") +
|
||||||
|
"Assistant:"
|
||||||
|
|
||||||
|
response = DiscourseAi::Inference::AnthropicCompletions.perform!(message)
|
||||||
|
|
||||||
|
result[:suggestions] = parse_content(prompt, response.dig(:completion))
|
||||||
|
|
||||||
|
result[:diff] = generate_diff(text, result[:suggestions].first) if prompt.diff?
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_antropic_content(prompt, content)
|
||||||
|
if prompt.list?
|
||||||
|
suggestions = Nokogiri::HTML5.fragment(content).search("ai").map(&:text)
|
||||||
|
|
||||||
|
if suggestions.length > 1
|
||||||
|
suggestions
|
||||||
|
else
|
||||||
|
suggestions.split("\n").map(&:strip)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
[Nokogiri::HTML5.fragment(content).at("ai").text]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,52 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module DiscourseAi
|
|
||||||
module AiHelper
|
|
||||||
class OpenAiPrompt
|
|
||||||
def available_prompts
|
|
||||||
CompletionPrompt
|
|
||||||
.where(enabled: true)
|
|
||||||
.map do |prompt|
|
|
||||||
translation =
|
|
||||||
I18n.t("discourse_ai.ai_helper.prompts.#{prompt.name}", default: nil) ||
|
|
||||||
prompt.translated_name
|
|
||||||
|
|
||||||
{ name: prompt.name, translated_name: translation, prompt_type: prompt.prompt_type }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_and_send_prompt(prompt, text)
|
|
||||||
result = { type: prompt.prompt_type }
|
|
||||||
|
|
||||||
messages = prompt.messages_with_user_input(text)
|
|
||||||
|
|
||||||
result[:suggestions] = DiscourseAi::Inference::OpenAiCompletions
|
|
||||||
.perform!(messages)
|
|
||||||
.dig(:choices)
|
|
||||||
.to_a
|
|
||||||
.flat_map { |choice| parse_content(prompt, choice.dig(:message, :content).to_s) }
|
|
||||||
.compact_blank
|
|
||||||
|
|
||||||
result[:diff] = generate_diff(text, result[:suggestions].first) if prompt.diff?
|
|
||||||
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_diff(text, suggestion)
|
|
||||||
cooked_text = PrettyText.cook(text)
|
|
||||||
cooked_suggestion = PrettyText.cook(suggestion)
|
|
||||||
|
|
||||||
DiscourseDiff.new(cooked_text, cooked_suggestion).inline_html
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_content(prompt, content)
|
|
||||||
return "" if content.blank?
|
|
||||||
return content.strip if !prompt.list?
|
|
||||||
|
|
||||||
content.gsub("\"", "").gsub(/\d./, "").split("\n").map(&:strip)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -18,7 +18,16 @@ module DiscourseAi
|
||||||
attr_reader :target
|
attr_reader :target
|
||||||
|
|
||||||
def summarization_provider
|
def summarization_provider
|
||||||
model.starts_with?("gpt") ? "openai" : "discourse"
|
case model
|
||||||
|
in "gpt-3.5-turbo"
|
||||||
|
"openai"
|
||||||
|
in "gpt-4"
|
||||||
|
"openai"
|
||||||
|
in "claude-v1"
|
||||||
|
"anthropic"
|
||||||
|
else
|
||||||
|
"discourse"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_content(content_since)
|
def get_content(content_since)
|
||||||
|
@ -63,6 +72,23 @@ module DiscourseAi
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def anthropic_summarization(content)
|
||||||
|
messages =
|
||||||
|
"Human: Summarize the following article that is inside <input> tags.
|
||||||
|
Plese include only the summary inside <ai> tags.
|
||||||
|
|
||||||
|
<input>##{content}</input>
|
||||||
|
|
||||||
|
|
||||||
|
Assistant:
|
||||||
|
"
|
||||||
|
|
||||||
|
response =
|
||||||
|
::DiscourseAi::Inference::AnthropicCompletions.perform!(messages).dig(:completion)
|
||||||
|
|
||||||
|
Nokogiri::HTML5.fragment(response).at("ai").text
|
||||||
|
end
|
||||||
|
|
||||||
def model
|
def model
|
||||||
SiteSetting.ai_summarization_model
|
SiteSetting.ai_summarization_model
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module ::DiscourseAi
|
||||||
|
module Inference
|
||||||
|
class AnthropicCompletions
|
||||||
|
CompletionFailed = Class.new(StandardError)
|
||||||
|
|
||||||
|
def self.perform!(prompt)
|
||||||
|
headers = {
|
||||||
|
"x-api-key" => SiteSetting.ai_anthropic_api_key,
|
||||||
|
"Content-Type" => "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
model = "claude-v1"
|
||||||
|
|
||||||
|
connection_opts = { request: { write_timeout: 60, read_timeout: 60, open_timeout: 60 } }
|
||||||
|
|
||||||
|
response =
|
||||||
|
Faraday.new(nil, connection_opts).post(
|
||||||
|
"https://api.anthropic.com/v1/complete",
|
||||||
|
{ model: model, prompt: prompt, max_tokens_to_sample: 300 }.to_json,
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status != 200
|
||||||
|
Rails.logger.error(
|
||||||
|
"AnthropicCompletions: status: #{response.status} - body: #{response.body}",
|
||||||
|
)
|
||||||
|
raise CompletionFailed
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.parse(response.body, symbolize_names: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -23,6 +23,7 @@ after_initialize do
|
||||||
require_relative "lib/shared/inference/discourse_reranker"
|
require_relative "lib/shared/inference/discourse_reranker"
|
||||||
require_relative "lib/shared/inference/openai_completions"
|
require_relative "lib/shared/inference/openai_completions"
|
||||||
require_relative "lib/shared/inference/openai_embeddings"
|
require_relative "lib/shared/inference/openai_embeddings"
|
||||||
|
require_relative "lib/shared/inference/anthropic_completions"
|
||||||
|
|
||||||
require_relative "lib/shared/classificator"
|
require_relative "lib/shared/classificator"
|
||||||
require_relative "lib/shared/post_classificator"
|
require_relative "lib/shared/post_classificator"
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
require_relative "../../../support/openai_completions_inference_stubs"
|
require_relative "../../../support/openai_completions_inference_stubs"
|
||||||
|
|
||||||
RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do
|
RSpec.describe DiscourseAi::AiHelper::LlmPrompt do
|
||||||
let(:prompt) { CompletionPrompt.find_by(name: mode) }
|
let(:prompt) { CompletionPrompt.find_by(name: mode) }
|
||||||
|
|
||||||
describe "#generate_and_send_prompt" do
|
describe "#generate_and_send_prompt" do
|
|
@ -17,13 +17,5 @@ RSpec.describe CompletionPrompt do
|
||||||
expect(prompt.valid?).to eq(false)
|
expect(prompt.valid?).to eq(false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when the message has invalid roles" do
|
|
||||||
it "doesn't accept messages when the role is invalid" do
|
|
||||||
prompt = described_class.new(messages: [{ role: "invalid", content: "a" }])
|
|
||||||
|
|
||||||
expect(prompt.valid?).to eq(false)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,7 @@ require_relative "../../support/openai_completions_inference_stubs"
|
||||||
RSpec.describe DiscourseAi::AiHelper::AssistantController do
|
RSpec.describe DiscourseAi::AiHelper::AssistantController do
|
||||||
describe "#suggest" do
|
describe "#suggest" do
|
||||||
let(:text) { OpenAiCompletionsInferenceStubs.translated_response }
|
let(:text) { OpenAiCompletionsInferenceStubs.translated_response }
|
||||||
let(:mode) { "proofread" }
|
let(:mode) { "-3" }
|
||||||
|
|
||||||
context "when not logged in" do
|
context "when not logged in" do
|
||||||
it "returns a 403 response" do
|
it "returns a 403 response" do
|
||||||
|
@ -64,7 +64,7 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns a suggestion" do
|
it "returns a suggestion" do
|
||||||
OpenAiCompletionsInferenceStubs.stub_prompt(mode)
|
OpenAiCompletionsInferenceStubs.stub_prompt("proofread")
|
||||||
|
|
||||||
post "/discourse-ai/ai-helper/suggest", params: { mode: mode, text: text }
|
post "/discourse-ai/ai-helper/suggest", params: { mode: mode, text: text }
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,17 @@ class OpenAiCompletionsInferenceStubs
|
||||||
GENERATE_TITLES = "generate_titles"
|
GENERATE_TITLES = "generate_titles"
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
def text_mode_to_id(mode)
|
||||||
|
case mode
|
||||||
|
when TRANSLATE
|
||||||
|
-1
|
||||||
|
when PROOFREAD
|
||||||
|
-3
|
||||||
|
when GENERATE_TITLES
|
||||||
|
-2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def spanish_text
|
def spanish_text
|
||||||
<<~STRING
|
<<~STRING
|
||||||
Para que su horror sea perfecto, César, acosado al pie de la estatua por lo impacientes puñales de sus amigos,
|
Para que su horror sea perfecto, César, acosado al pie de la estatua por lo impacientes puñales de sus amigos,
|
||||||
|
@ -83,7 +94,7 @@ class OpenAiCompletionsInferenceStubs
|
||||||
end
|
end
|
||||||
|
|
||||||
def stub_prompt(type)
|
def stub_prompt(type)
|
||||||
prompt_builder = DiscourseAi::AiHelper::OpenAiPrompt.new
|
prompt_builder = DiscourseAi::AiHelper::LlmPrompt.new
|
||||||
text = type == TRANSLATE ? spanish_text : translated_response
|
text = type == TRANSLATE ? spanish_text : translated_response
|
||||||
|
|
||||||
prompt_messages = CompletionPrompt.find_by(name: type).messages_with_user_input(text)
|
prompt_messages = CompletionPrompt.find_by(name: type).messages_with_user_input(text)
|
||||||
|
|
|
@ -28,7 +28,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
|
|
||||||
expect(ai_helper_modal).to be_visible
|
expect(ai_helper_modal).to be_visible
|
||||||
|
|
||||||
ai_helper_modal.select_helper_model(mode)
|
ai_helper_modal.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode))
|
||||||
ai_helper_modal.save_changes
|
ai_helper_modal.save_changes
|
||||||
|
|
||||||
expect(composer.composer_input.value).to eq(
|
expect(composer.composer_input.value).to eq(
|
||||||
|
@ -51,7 +51,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
|
|
||||||
expect(ai_helper_modal).to be_visible
|
expect(ai_helper_modal).to be_visible
|
||||||
|
|
||||||
ai_helper_modal.select_helper_model(mode)
|
ai_helper_modal.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode))
|
||||||
ai_helper_modal.save_changes
|
ai_helper_modal.save_changes
|
||||||
|
|
||||||
expect(composer.composer_input.value).to eq(
|
expect(composer.composer_input.value).to eq(
|
||||||
|
@ -74,7 +74,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
|
|
||||||
expect(ai_helper_modal).to be_visible
|
expect(ai_helper_modal).to be_visible
|
||||||
|
|
||||||
ai_helper_modal.select_helper_model(mode)
|
ai_helper_modal.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode))
|
||||||
ai_helper_modal.select_title_suggestion(2)
|
ai_helper_modal.select_title_suggestion(2)
|
||||||
ai_helper_modal.save_changes
|
ai_helper_modal.save_changes
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue