REFACTOR: Store prompts in a dedicated table. (#14)

This change makes it easier to add new prompts to our AI helper. We don't have a UI for it yet. You'll have to do it through a console.
This commit is contained in:
Roman Rizzi 2023-03-17 15:14:19 -03:00 committed by GitHub
parent edfdc6dfae
commit 320ac6e84b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 258 additions and 101 deletions

View File

@ -7,19 +7,27 @@ module DiscourseAi
requires_login requires_login
before_action :ensure_can_request_suggestions 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 def suggest
raise Discourse::InvalidParameters.new(:text) if params[:text].blank? raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
if !DiscourseAi::AiHelper::OpenAiPrompt::VALID_TYPES.include?(params[:mode]) prompt = CompletionPrompt.find_by(name: params[:mode])
raise Discourse::InvalidParameters.new(:mode) raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
end
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::OpenAiPrompt.new.generate_and_send_prompt(
params[:mode], prompt,
params[:text], params[:text],
), ),
status: 200 status: 200

View File

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

View File

@ -33,3 +33,20 @@ class ModelAccuracy < ActiveRecord::Base
flags_agreed + flags_disagreed flags_agreed + flags_disagreed
end end
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
#

View File

@ -147,3 +147,40 @@ class ReviewableAiChatMessage < Reviewable
end end
end 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
#

View File

@ -205,3 +205,40 @@ class ReviewableAiPost < Reviewable
end end
end 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
#

View File

@ -1,13 +1,12 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action, computed } from "@ember/object"; import { action, computed } from "@ember/object";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
const TRANSLATE = "translate"; const LIST = "list";
const GENERATE_TITLES = "generate_titles"; const TEXT = "text";
const PROOFREAD = "proofread"; const DIFF = "diff";
export default class AiHelper extends Component { export default class AiHelper extends Component {
@tracked selected = null; @tracked selected = null;
@ -21,20 +20,29 @@ export default class AiHelper extends Component {
@tracked proofreadDiff = null; @tracked proofreadDiff = null;
helperOptions = [ @tracked helperOptions = [];
{ promptTypes = {};
name: I18n.t("discourse_ai.ai_helper.modes.translate"),
value: TRANSLATE, constructor() {
}, super(...arguments);
{ this.loadPrompts();
name: I18n.t("discourse_ai.ai_helper.modes.generate_titles"), }
value: GENERATE_TITLES,
}, async loadPrompts() {
{ const prompts = await ajax("/discourse-ai/ai-helper/prompts");
name: I18n.t("discourse_ai.ai_helper.modes.proofreader"),
value: PROOFREAD, 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() { get composedMessage() {
const editor = this.args.editor; const editor = this.args.editor;
@ -45,7 +53,7 @@ export default class AiHelper extends Component {
@computed("selected", "selectedTitle", "translatingText", "proofreadingText") @computed("selected", "selectedTitle", "translatingText", "proofreadingText")
get canSave() { get canSave() {
return ( return (
(this.selected === GENERATE_TITLES && this.selectedTitle) || (this.promptTypes[this.selected] === LIST && this.selectedTitle) ||
this.translatingText || this.translatingText ||
this.proofreadingText this.proofreadingText
); );
@ -53,31 +61,33 @@ export default class AiHelper extends Component {
@computed("selected", "translatedSuggestion") @computed("selected", "translatedSuggestion")
get translatingText() { get translatingText() {
return this.selected === TRANSLATE && this.translatedSuggestion; return (
this.promptTypes[this.selected] === TEXT && this.translatedSuggestion
);
} }
@computed("selected", "proofReadSuggestion") @computed("selected", "proofReadSuggestion")
get proofreadingText() { get proofreadingText() {
return this.selected === PROOFREAD && this.proofReadSuggestion; return this.promptTypes[this.selected] === DIFF && this.proofReadSuggestion;
} }
@computed("selected", "generatedTitlesSuggestions") @computed("selected", "generatedTitlesSuggestions")
get selectingTopicTitle() { get selectingTopicTitle() {
return ( return (
this.selected === GENERATE_TITLES && this.promptTypes[this.selected] === LIST &&
this.generatedTitlesSuggestions.length > 0 this.generatedTitlesSuggestions.length > 0
); );
} }
_updateSuggestedByAI(value, data) { _updateSuggestedByAI(data) {
switch (value) { switch (data.type) {
case GENERATE_TITLES: case LIST:
this.generatedTitlesSuggestions = data.suggestions; this.generatedTitlesSuggestions = data.suggestions;
break; break;
case TRANSLATE: case TEXT:
this.translatedSuggestion = data.suggestions[0]; this.translatedSuggestion = data.suggestions[0];
break; break;
case PROOFREAD: case DIFF:
this.proofReadSuggestion = data.suggestions[0]; this.proofReadSuggestion = data.suggestions[0];
this.proofreadDiff = data.diff; this.proofreadDiff = data.diff;
break; break;
@ -89,7 +99,7 @@ export default class AiHelper extends Component {
this.loading = true; this.loading = true;
this.selected = value; this.selected = value;
if (value === GENERATE_TITLES) { if (value === LIST) {
this.selectedTitle = null; this.selectedTitle = null;
} }
@ -101,7 +111,7 @@ export default class AiHelper extends Component {
data: { mode: this.selected, text: this.composedMessage }, data: { mode: this.selected, text: this.composedMessage },
}) })
.then((data) => { .then((data) => {
this._updateSuggestedByAI(value, data); this._updateSuggestedByAI(data);
}) })
.catch(popupAjaxError) .catch(popupAjaxError)
.finally(() => (this.loading = false)); .finally(() => (this.loading = false));

View File

@ -5,10 +5,6 @@ en:
title: "Suggest changes using AI" title: "Suggest changes using AI"
description: "Choose one of the options below, and the AI will suggest you a new version of the text." 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." 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: reviewables:
model_used: "Model used:" model_used: "Model used:"
accuracy: "Accuracy:" accuracy: "Accuracy:"

View File

@ -50,3 +50,11 @@ en:
reasons: reasons:
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic. 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. 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

View File

@ -3,6 +3,7 @@
DiscourseAi::Engine.routes.draw do DiscourseAi::Engine.routes.draw do
# AI-helper routes # AI-helper routes
scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do
get "prompts" => "assistant#prompts"
post "suggest" => "assistant#suggest" post "suggest" => "assistant#suggest"
end end
end end

View File

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

View File

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

View File

@ -7,6 +7,9 @@ module DiscourseAi
end end
def inject_into(plugin) def inject_into(plugin)
plugin.register_seedfu_fixtures(
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai-helper"),
)
plugin.register_svg_icon("magic") plugin.register_svg_icon("magic")
end end
end end

View File

@ -8,45 +8,37 @@ module DiscourseAi
PROOFREAD = "proofread" PROOFREAD = "proofread"
VALID_TYPES = [TRANSLATE, GENERATE_TITLES, PROOFREAD] VALID_TYPES = [TRANSLATE, GENERATE_TITLES, PROOFREAD]
def get_prompt_for(prompt_type) def available_prompts
case prompt_type CompletionPrompt
when TRANSLATE .where(enabled: true)
translate_prompt .map do |prompt|
when GENERATE_TITLES translation =
generate_titles_prompt I18n.t("discourse_ai.ai_helper.prompts.#{prompt.name}", default: nil) ||
when PROOFREAD prompt.translated_name
proofread_prompt
{ name: prompt.name, translated_name: translation, prompt_type: prompt.prompt_type }
end end
end end
def generate_and_send_prompt(prompt_type, text) def generate_and_send_prompt(prompt, text)
result = {} result = { type: prompt.prompt_type }
prompt = [ ai_messages = [{ role: "system", content: prompt.value }, { role: "user", content: text }]
{ role: "system", content: get_prompt_for(prompt_type) },
{ role: "user", content: text },
]
result[:suggestions] = DiscourseAi::Inference::OpenAiCompletions result[:suggestions] = DiscourseAi::Inference::OpenAiCompletions
.perform!(prompt) .perform!(ai_messages)
.dig(:choices) .dig(:choices)
.to_a .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 .compact_blank
result[:diff] = generate_diff(text, result[:suggestions].first) if proofreading?( result[:diff] = generate_diff(text, result[:suggestions].first) if prompt.diff?
prompt_type,
)
result result
end end
private private
def proofreading?(prompt_type)
prompt_type == PROOFREAD
end
def generate_diff(text, suggestion) def generate_diff(text, suggestion)
cooked_text = PrettyText.cook(text) cooked_text = PrettyText.cook(text)
cooked_suggestion = PrettyText.cook(suggestion) cooked_suggestion = PrettyText.cook(suggestion)
@ -54,39 +46,12 @@ module DiscourseAi
DiscourseDiff.new(cooked_text, cooked_suggestion).inline_html DiscourseDiff.new(cooked_text, cooked_suggestion).inline_html
end end
def parse_content(type, content) def parse_content(prompt, content)
return "" if content.blank? 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) content.gsub("\"", "").gsub(/\d./, "").split("\n").map(&:strip)
end 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 end
end end

View File

@ -3,15 +3,17 @@
require_relative "../../../support/openai_completions_inference_stubs" require_relative "../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do
let(:prompt) { CompletionPrompt.find_by(name: mode) }
describe "#generate_and_send_prompt" do describe "#generate_and_send_prompt" do
context "when using the translate mode" do context "when using the translate mode" do
let(:mode) { described_class::TRANSLATE } let(:mode) { "translate" }
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
it "Sends the prompt to chatGPT and returns the response" do it "Sends the prompt to chatGPT and returns the response" do
response = 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( expect(response[:suggestions]).to contain_exactly(
OpenAiCompletionsInferenceStubs.translated_response.strip, OpenAiCompletionsInferenceStubs.translated_response.strip,
@ -20,14 +22,14 @@ RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do
end end
context "when using the proofread mode" do context "when using the proofread mode" do
let(:mode) { described_class::PROOFREAD } let(:mode) { "proofread" }
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
it "Sends the prompt to chatGPT and returns the response" do it "Sends the prompt to chatGPT and returns the response" do
response = response =
subject.generate_and_send_prompt( subject.generate_and_send_prompt(
mode, prompt,
OpenAiCompletionsInferenceStubs.translated_response, OpenAiCompletionsInferenceStubs.translated_response,
) )
@ -38,7 +40,7 @@ RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do
end end
context "when generating titles" do context "when generating titles" do
let(:mode) { described_class::GENERATE_TITLES } let(:mode) { "generate_titles" }
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
@ -53,7 +55,7 @@ RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do
response = response =
subject.generate_and_send_prompt( subject.generate_and_send_prompt(
mode, prompt,
OpenAiCompletionsInferenceStubs.translated_response, OpenAiCompletionsInferenceStubs.translated_response,
) )

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) { DiscourseAi::AiHelper::OpenAiPrompt::PROOFREAD } let(:mode) { "proofread" }
context "when not logged in" do context "when not logged in" do
it "returns a 403 response" do it "returns a 403 response" do

View File

@ -82,10 +82,9 @@ class OpenAiCompletionsInferenceStubs
prompt_builder = DiscourseAi::AiHelper::OpenAiPrompt.new prompt_builder = DiscourseAi::AiHelper::OpenAiPrompt.new
text = text =
type == DiscourseAi::AiHelper::OpenAiPrompt::TRANSLATE ? spanish_text : translated_response type == DiscourseAi::AiHelper::OpenAiPrompt::TRANSLATE ? spanish_text : translated_response
prompt = [
{ role: "system", content: prompt_builder.get_prompt_for(type) }, used_prompt = CompletionPrompt.find_by(name: type)
{ role: "user", content: text }, prompt = [{ role: "system", content: used_prompt.value }, { role: "user", content: text }]
]
WebMock WebMock
.stub_request(:post, "https://api.openai.com/v1/chat/completions") .stub_request(:post, "https://api.openai.com/v1/chat/completions")