diff --git a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb index d9863e67..4b8dbf6e 100644 --- a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb +++ b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb @@ -7,19 +7,27 @@ module DiscourseAi requires_login before_action :ensure_can_request_suggestions + def prompts + render json: + ActiveModel::ArraySerializer.new( + DiscourseAi::AiHelper::OpenAiPrompt.new.available_prompts, + root: false, + ), + status: 200 + end + def suggest raise Discourse::InvalidParameters.new(:text) if params[:text].blank? - if !DiscourseAi::AiHelper::OpenAiPrompt::VALID_TYPES.include?(params[:mode]) - raise Discourse::InvalidParameters.new(:mode) - end + prompt = CompletionPrompt.find_by(name: params[:mode]) + raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled? RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed! hijack do render json: DiscourseAi::AiHelper::OpenAiPrompt.new.generate_and_send_prompt( - params[:mode], + prompt, params[:text], ), status: 200 diff --git a/app/models/completion_prompt.rb b/app/models/completion_prompt.rb new file mode 100644 index 00000000..e1b3636c --- /dev/null +++ b/app/models/completion_prompt.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CompletionPrompt < ActiveRecord::Base + enum :prompt_type, { text: 0, list: 1, diff: 2 } +end + +# == Schema Information +# +# Table name: completion_prompts +# +# id :bigint not null, primary key +# name :string not null +# translated_name :string +# prompt_type :integer default("text"), not null +# value :text not null +# enabled :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_completion_prompts_on_name (name) UNIQUE +# diff --git a/app/models/model_accuracy.rb b/app/models/model_accuracy.rb index bbf32415..933653a7 100644 --- a/app/models/model_accuracy.rb +++ b/app/models/model_accuracy.rb @@ -33,3 +33,20 @@ class ModelAccuracy < ActiveRecord::Base flags_agreed + flags_disagreed end end + +# == Schema Information +# +# Table name: model_accuracies +# +# id :bigint not null, primary key +# model :string not null +# classification_type :string not null +# flags_agreed :integer default(0), not null +# flags_disagreed :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_model_accuracies_on_model (model) UNIQUE +# diff --git a/app/models/reviewable_ai_chat_message.rb b/app/models/reviewable_ai_chat_message.rb index 2639b3b4..52075f78 100644 --- a/app/models/reviewable_ai_chat_message.rb +++ b/app/models/reviewable_ai_chat_message.rb @@ -147,3 +147,40 @@ class ReviewableAiChatMessage < Reviewable end end end + +# == Schema Information +# +# Table name: reviewables +# +# id :bigint not null, primary key +# type :string not null +# status :integer default("pending"), not null +# created_by_id :integer not null +# reviewable_by_moderator :boolean default(FALSE), not null +# reviewable_by_group_id :integer +# category_id :integer +# topic_id :integer +# score :float default(0.0), not null +# potential_spam :boolean default(FALSE), not null +# target_id :integer +# target_type :string +# target_created_by_id :integer +# payload :json +# version :integer default(0), not null +# latest_score :datetime +# created_at :datetime not null +# updated_at :datetime not null +# force_review :boolean default(FALSE), not null +# reject_reason :text +# +# Indexes +# +# idx_reviewables_score_desc_created_at_desc (score,created_at) +# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id) +# index_reviewables_on_status_and_created_at (status,created_at) +# index_reviewables_on_status_and_score (status,score) +# index_reviewables_on_status_and_type (status,type) +# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text) +# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id) +# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE +# diff --git a/app/models/reviewable_ai_post.rb b/app/models/reviewable_ai_post.rb index 3f65b71a..a20273bf 100644 --- a/app/models/reviewable_ai_post.rb +++ b/app/models/reviewable_ai_post.rb @@ -205,3 +205,40 @@ class ReviewableAiPost < Reviewable end end end + +# == Schema Information +# +# Table name: reviewables +# +# id :bigint not null, primary key +# type :string not null +# status :integer default("pending"), not null +# created_by_id :integer not null +# reviewable_by_moderator :boolean default(FALSE), not null +# reviewable_by_group_id :integer +# category_id :integer +# topic_id :integer +# score :float default(0.0), not null +# potential_spam :boolean default(FALSE), not null +# target_id :integer +# target_type :string +# target_created_by_id :integer +# payload :json +# version :integer default(0), not null +# latest_score :datetime +# created_at :datetime not null +# updated_at :datetime not null +# force_review :boolean default(FALSE), not null +# reject_reason :text +# +# Indexes +# +# idx_reviewables_score_desc_created_at_desc (score,created_at) +# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id) +# index_reviewables_on_status_and_created_at (status,created_at) +# index_reviewables_on_status_and_score (status,score) +# index_reviewables_on_status_and_type (status,type) +# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text) +# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id) +# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE +# diff --git a/assets/javascripts/discourse/components/ai-helper.js b/assets/javascripts/discourse/components/ai-helper.js index 17bb75d6..2f3a59af 100644 --- a/assets/javascripts/discourse/components/ai-helper.js +++ b/assets/javascripts/discourse/components/ai-helper.js @@ -1,13 +1,12 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { action, computed } from "@ember/object"; -import I18n from "I18n"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -const TRANSLATE = "translate"; -const GENERATE_TITLES = "generate_titles"; -const PROOFREAD = "proofread"; +const LIST = "list"; +const TEXT = "text"; +const DIFF = "diff"; export default class AiHelper extends Component { @tracked selected = null; @@ -21,20 +20,29 @@ export default class AiHelper extends Component { @tracked proofreadDiff = null; - helperOptions = [ - { - name: I18n.t("discourse_ai.ai_helper.modes.translate"), - value: TRANSLATE, - }, - { - name: I18n.t("discourse_ai.ai_helper.modes.generate_titles"), - value: GENERATE_TITLES, - }, - { - name: I18n.t("discourse_ai.ai_helper.modes.proofreader"), - value: PROOFREAD, - }, - ]; + @tracked helperOptions = []; + promptTypes = {}; + + constructor() { + super(...arguments); + this.loadPrompts(); + } + + async loadPrompts() { + const prompts = await ajax("/discourse-ai/ai-helper/prompts"); + + this.promptTypes = prompts.reduce((memo, p) => { + memo[p.name] = p.prompt_type; + return memo; + }, {}); + + this.helperOptions = prompts.map((p) => { + return { + name: p.translated_name, + value: p.name, + }; + }); + } get composedMessage() { const editor = this.args.editor; @@ -45,7 +53,7 @@ export default class AiHelper extends Component { @computed("selected", "selectedTitle", "translatingText", "proofreadingText") get canSave() { return ( - (this.selected === GENERATE_TITLES && this.selectedTitle) || + (this.promptTypes[this.selected] === LIST && this.selectedTitle) || this.translatingText || this.proofreadingText ); @@ -53,31 +61,33 @@ export default class AiHelper extends Component { @computed("selected", "translatedSuggestion") get translatingText() { - return this.selected === TRANSLATE && this.translatedSuggestion; + return ( + this.promptTypes[this.selected] === TEXT && this.translatedSuggestion + ); } @computed("selected", "proofReadSuggestion") get proofreadingText() { - return this.selected === PROOFREAD && this.proofReadSuggestion; + return this.promptTypes[this.selected] === DIFF && this.proofReadSuggestion; } @computed("selected", "generatedTitlesSuggestions") get selectingTopicTitle() { return ( - this.selected === GENERATE_TITLES && + this.promptTypes[this.selected] === LIST && this.generatedTitlesSuggestions.length > 0 ); } - _updateSuggestedByAI(value, data) { - switch (value) { - case GENERATE_TITLES: + _updateSuggestedByAI(data) { + switch (data.type) { + case LIST: this.generatedTitlesSuggestions = data.suggestions; break; - case TRANSLATE: + case TEXT: this.translatedSuggestion = data.suggestions[0]; break; - case PROOFREAD: + case DIFF: this.proofReadSuggestion = data.suggestions[0]; this.proofreadDiff = data.diff; break; @@ -89,7 +99,7 @@ export default class AiHelper extends Component { this.loading = true; this.selected = value; - if (value === GENERATE_TITLES) { + if (value === LIST) { this.selectedTitle = null; } @@ -101,7 +111,7 @@ export default class AiHelper extends Component { data: { mode: this.selected, text: this.composedMessage }, }) .then((data) => { - this._updateSuggestedByAI(value, data); + this._updateSuggestedByAI(data); }) .catch(popupAjaxError) .finally(() => (this.loading = false)); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index cb6217a8..08134399 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -5,10 +5,6 @@ en: title: "Suggest changes using AI" description: "Choose one of the options below, and the AI will suggest you a new version of the text." selection_hint: "Hint: You can also select a portion of the text before opening the helper to rewrite only that." - modes: - translate: Translate to English - generate_titles: Suggest topic titles - proofreader: Proofread text reviewables: model_used: "Model used:" accuracy: "Accuracy:" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 0b63278c..0c32972f 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -50,3 +50,11 @@ en: reasons: flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic. flagged_by_nsfw: The AI plugin flagged this after classifying at least one of the attached images as NSFW. + + + discourse_ai: + ai_helper: + prompts: + translate: Translate to English + generate_titles: Suggest topic titles + proofread: Proofread text diff --git a/config/routes.rb b/config/routes.rb index 462f9afc..0c9fcf47 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ DiscourseAi::Engine.routes.draw do # AI-helper routes scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do + get "prompts" => "assistant#prompts" post "suggest" => "assistant#suggest" end end diff --git a/db/fixtures/ai-helper/600_completion_prompts.rb b/db/fixtures/ai-helper/600_completion_prompts.rb new file mode 100644 index 00000000..b486617a --- /dev/null +++ b/db/fixtures/ai-helper/600_completion_prompts.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +CompletionPrompt.seed do |cp| + cp.id = 1 + cp.name = "translate" + cp.prompt_type = CompletionPrompt.prompt_types[:text] + cp.value = <<~STRING + 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 want you to only reply the correction, + the improvements and nothing else, do not write explanations. + STRING +end + +CompletionPrompt.seed do |cp| + cp.id = 2 + cp.name = "generate_titles" + cp.prompt_type = CompletionPrompt.prompt_types[:list] + cp.value = <<~STRING + I want you to act as a title generator for written pieces. I will provide you with a text, + 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. + STRING +end + +CompletionPrompt.seed do |cp| + cp.id = 3 + cp.name = "proofread" + cp.prompt_type = CompletionPrompt.prompt_types[:diff] + cp.value = <<~STRING + I want you act as a proofreader. I will provide you with a text and I want you to review them for any spelling, + grammar, or punctuation errors. Once you have finished reviewing the text, provide me with any necessary + corrections or suggestions for improve the text. + STRING +end diff --git a/db/migrate/20230316160714_create_completion_prompt_table.rb b/db/migrate/20230316160714_create_completion_prompt_table.rb new file mode 100644 index 00000000..a89c521d --- /dev/null +++ b/db/migrate/20230316160714_create_completion_prompt_table.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +class CreateCompletionPromptTable < ActiveRecord::Migration[7.0] + def change + create_table :completion_prompts do |t| + t.string :name, null: false + t.string :translated_name + t.integer :prompt_type, null: false, default: 0 + t.text :value, null: false + t.boolean :enabled, null: false, default: true + t.timestamps + end + + add_index :completion_prompts, %i[name], unique: true + end +end diff --git a/lib/modules/ai_helper/entry_point.rb b/lib/modules/ai_helper/entry_point.rb index e07aba21..8f353611 100644 --- a/lib/modules/ai_helper/entry_point.rb +++ b/lib/modules/ai_helper/entry_point.rb @@ -7,6 +7,9 @@ module DiscourseAi end def inject_into(plugin) + plugin.register_seedfu_fixtures( + Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai-helper"), + ) plugin.register_svg_icon("magic") end end diff --git a/lib/modules/ai_helper/open_ai_prompt.rb b/lib/modules/ai_helper/open_ai_prompt.rb index dddec2c9..7b5476d6 100644 --- a/lib/modules/ai_helper/open_ai_prompt.rb +++ b/lib/modules/ai_helper/open_ai_prompt.rb @@ -8,45 +8,37 @@ module DiscourseAi PROOFREAD = "proofread" VALID_TYPES = [TRANSLATE, GENERATE_TITLES, PROOFREAD] - def get_prompt_for(prompt_type) - case prompt_type - when TRANSLATE - translate_prompt - when GENERATE_TITLES - generate_titles_prompt - when PROOFREAD - proofread_prompt - end + 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_type, text) - result = {} + def generate_and_send_prompt(prompt, text) + result = { type: prompt.prompt_type } - prompt = [ - { role: "system", content: get_prompt_for(prompt_type) }, - { role: "user", content: text }, - ] + ai_messages = [{ role: "system", content: prompt.value }, { role: "user", content: text }] result[:suggestions] = DiscourseAi::Inference::OpenAiCompletions - .perform!(prompt) + .perform!(ai_messages) .dig(:choices) .to_a - .flat_map { |choice| parse_content(prompt_type, choice.dig(:message, :content).to_s) } + .flat_map { |choice| parse_content(prompt, choice.dig(:message, :content).to_s) } .compact_blank - result[:diff] = generate_diff(text, result[:suggestions].first) if proofreading?( - prompt_type, - ) + result[:diff] = generate_diff(text, result[:suggestions].first) if prompt.diff? result end private - def proofreading?(prompt_type) - prompt_type == PROOFREAD - end - def generate_diff(text, suggestion) cooked_text = PrettyText.cook(text) cooked_suggestion = PrettyText.cook(suggestion) @@ -54,39 +46,12 @@ module DiscourseAi DiscourseDiff.new(cooked_text, cooked_suggestion).inline_html end - def parse_content(type, content) + def parse_content(prompt, content) return "" if content.blank? - return content.strip if type != GENERATE_TITLES + return content.strip if !prompt.list? content.gsub("\"", "").gsub(/\d./, "").split("\n").map(&:strip) end - - def translate_prompt - <<~STRING - 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 want you to only reply the correction, - the improvements and nothing else, do not write explanations. - STRING - end - - def generate_titles_prompt - <<~STRING - I want you to act as a title generator for written pieces. I will provide you with a text, - 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. - STRING - end - - def proofread_prompt - <<~STRING - I want you act as a proofreader. I will provide you with a text and I want you to review them for any spelling, - grammar, or punctuation errors. Once you have finished reviewing the text, provide me with any necessary - corrections or suggestions for improve the text. - STRING - end end end end diff --git a/spec/lib/modules/ai_helper/open_ai_prompt_spec.rb b/spec/lib/modules/ai_helper/open_ai_prompt_spec.rb index 5df3c08c..944accbc 100644 --- a/spec/lib/modules/ai_helper/open_ai_prompt_spec.rb +++ b/spec/lib/modules/ai_helper/open_ai_prompt_spec.rb @@ -3,15 +3,17 @@ require_relative "../../../support/openai_completions_inference_stubs" RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do + let(:prompt) { CompletionPrompt.find_by(name: mode) } + describe "#generate_and_send_prompt" do context "when using the translate mode" do - let(:mode) { described_class::TRANSLATE } + let(:mode) { "translate" } before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } it "Sends the prompt to chatGPT and returns the response" do response = - subject.generate_and_send_prompt(mode, OpenAiCompletionsInferenceStubs.spanish_text) + subject.generate_and_send_prompt(prompt, OpenAiCompletionsInferenceStubs.spanish_text) expect(response[:suggestions]).to contain_exactly( OpenAiCompletionsInferenceStubs.translated_response.strip, @@ -20,14 +22,14 @@ RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do end context "when using the proofread mode" do - let(:mode) { described_class::PROOFREAD } + let(:mode) { "proofread" } before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } it "Sends the prompt to chatGPT and returns the response" do response = subject.generate_and_send_prompt( - mode, + prompt, OpenAiCompletionsInferenceStubs.translated_response, ) @@ -38,7 +40,7 @@ RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do end context "when generating titles" do - let(:mode) { described_class::GENERATE_TITLES } + let(:mode) { "generate_titles" } before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } @@ -53,7 +55,7 @@ RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do response = subject.generate_and_send_prompt( - mode, + prompt, OpenAiCompletionsInferenceStubs.translated_response, ) diff --git a/spec/requests/ai_helper/assistant_controller_spec.rb b/spec/requests/ai_helper/assistant_controller_spec.rb index 39f58718..2bd11c73 100644 --- a/spec/requests/ai_helper/assistant_controller_spec.rb +++ b/spec/requests/ai_helper/assistant_controller_spec.rb @@ -5,7 +5,7 @@ require_relative "../../support/openai_completions_inference_stubs" RSpec.describe DiscourseAi::AiHelper::AssistantController do describe "#suggest" do let(:text) { OpenAiCompletionsInferenceStubs.translated_response } - let(:mode) { DiscourseAi::AiHelper::OpenAiPrompt::PROOFREAD } + let(:mode) { "proofread" } context "when not logged in" do it "returns a 403 response" do diff --git a/spec/support/openai_completions_inference_stubs.rb b/spec/support/openai_completions_inference_stubs.rb index d69eeeef..886e48d9 100644 --- a/spec/support/openai_completions_inference_stubs.rb +++ b/spec/support/openai_completions_inference_stubs.rb @@ -82,10 +82,9 @@ class OpenAiCompletionsInferenceStubs prompt_builder = DiscourseAi::AiHelper::OpenAiPrompt.new text = type == DiscourseAi::AiHelper::OpenAiPrompt::TRANSLATE ? spanish_text : translated_response - prompt = [ - { role: "system", content: prompt_builder.get_prompt_for(type) }, - { role: "user", content: text }, - ] + + used_prompt = CompletionPrompt.find_by(name: type) + prompt = [{ role: "system", content: used_prompt.value }, { role: "user", content: text }] WebMock .stub_request(:post, "https://api.openai.com/v1/chat/completions")