FEATURE: Anthropic Claude for AIHelper and Summarization modules (#39)

This commit is contained in:
Rafael dos Santos Silva 2023-04-10 11:04:42 -03:00 committed by GitHub
parent f2e52f7f24
commit bb0b829634
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 314 additions and 94 deletions

View File

@ -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

View File

@ -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)
self.messages << { role: "user", content: user_input } if ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider == "openai"
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)
# #

View File

@ -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
); );
} }

View File

@ -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) {

View File

@ -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

View File

@ -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 = [

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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)

View File

@ -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