FEATURE: AI helper on posts (#244)

Adds an AI Helper function when selecting text while viewing a topic.

---------

Co-authored-by: Keegan George <kgeorge13@gmail.com>
Co-authored-by: Roman Rizzi <roman@discourse.org>
This commit is contained in:
Rafael dos Santos Silva 2023-10-23 11:41:36 -03:00 committed by GitHub
parent cda5cb6e9c
commit 0e5764617a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 578 additions and 22 deletions

View File

@ -96,12 +96,37 @@ module DiscourseAi
end
end
def explain
post_id = get_post_param!
text = get_text_param!
post = Post.find_by(id: post_id)
raise Discourse::InvalidParameters.new(:post_id) unless post
render json:
DiscourseAi::AiHelper::TopicHelper.new(
{ text: text },
current_user,
post: post,
).explain,
status: 200
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502
end
private
def get_text_param!
params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? }
end
def get_post_param!
params[:post_id].tap { |t| raise Discourse::InvalidParameters.new(:post_id) if t.blank? }
end
def rate_limiter_performed!
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
end

View File

@ -10,6 +10,8 @@ class CompletionPrompt < ActiveRecord::Base
validate :each_message_length
def messages_with_user_input(user_input)
return messages unless user_input.present?
if user_input[:custom_prompt].present?
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
when "huggingface"

View File

@ -1,7 +1,7 @@
import Component from '@glimmer/component';
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import { inject as service } from "@ember/service";
import showAIHelper from "../../lib/show-ai-helper";
import { showComposerAIHelper } from "../../lib/show-ai-helper";
export default class AICategorySuggestion extends Component {
@ -12,7 +12,7 @@ export default class AICategorySuggestion extends Component {
</template>
static shouldRender(outletArgs, helper) {
return showAIHelper(outletArgs, helper);
return showComposerAIHelper(outletArgs, helper);
}
@service siteSettings;

View File

@ -1,7 +1,7 @@
import Component from '@glimmer/component';
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import { inject as service } from "@ember/service";
import showAIHelper from "../../lib/show-ai-helper";
import { showComposerAIHelper } from "../../lib/show-ai-helper";
export default class AITagSuggestion extends Component {
@ -12,7 +12,7 @@ export default class AITagSuggestion extends Component {
</template>
static shouldRender(outletArgs, helper) {
return showAIHelper(outletArgs, helper);
return showComposerAIHelper(outletArgs, helper);
}
@service siteSettings;

View File

@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import showAIHelper from "../../lib/show-ai-helper";
import { showComposerAIHelper } from "../../lib/show-ai-helper";
export default class AITitleSuggestion extends Component {
<template>
@ -8,6 +8,6 @@ export default class AITitleSuggestion extends Component {
</template>
static shouldRender(outletArgs, helper) {
return showAIHelper(outletArgs, helper);
return showComposerAIHelper(outletArgs, helper);
}
}

View File

@ -8,11 +8,11 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { createPopper } from "@popperjs/core";
import { caretPosition, getCaretPosition } from "discourse/lib/utilities";
import { inject as service } from "@ember/service";
import showAIHelper from "../../lib/show-ai-helper";
import { showComposerAIHelper } from "../../lib/show-ai-helper";
export default class AiHelperContextMenu extends Component {
static shouldRender(outletArgs, helper) {
return showAIHelper(outletArgs, helper);
return showComposerAIHelper(outletArgs, helper);
}
@service currentUser;
@ -79,7 +79,9 @@ export default class AiHelperContextMenu extends Component {
async loadPrompts() {
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
prompts = prompts.filter((p) => p.name !== "generate_titles");
prompts = prompts
.filter((p) => p.location.includes("composer"))
.filter((p) => p.name !== "generate_titles");
// Find the custom_prompt object and move it to the beginning of the array
const customPromptIndex = prompts.findIndex(

View File

@ -0,0 +1,148 @@
import Component from '@glimmer/component';
import DButton from "discourse/components/d-button";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { showPostAIHelper } from "../../lib/show-ai-helper";
import eq from "truth-helpers/helpers/eq";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
const i18n = I18n.t.bind(I18n);
export default class AIHelperOptionsMenu extends Component {
<template>
{{#if this.showMainButtons}}
{{yield}}
{{/if}}
<div class="ai-post-helper">
{{#if (eq this.menuState this.MENU_STATES.triggers)}}
<DButton
@class="btn-flat ai-post-helper__trigger"
@icon="discourse-sparkles"
@label="discourse_ai.ai_helper.post_options_menu.trigger"
@action={{this.showAIHelperOptions}}
/>
{{else if (eq this.menuState this.MENU_STATES.options)}}
<div class="ai-post-helper__options">
{{#each this.helperOptions as |option|}}
<DButton
@class="btn-flat"
@icon={{option.icon}}
@translatedLabel={{option.name}}
@action={{this.performAISuggestion}}
@actionParam={{option}}
data-name={{option.name}}
data-value={{option.value}}
/>
{{/each}}
</div>
{{else if (eq this.menuState this.MENU_STATES.loading)}}
<div class="ai-helper-context-menu__loading">
<div class="dot-falling"></div>
<span>
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
</span>
<DButton
@icon="times"
@title="discourse_ai.ai_helper.context_menu.cancel"
@action={{this.cancelAIAction}}
class="btn-flat cancel-request"
/>
</div>
{{else if (eq this.menuState this.MENU_STATES.result)}}
<div class="ai-post-helper__suggestion">{{this.suggestion}}</div>
{{/if}}
</div>
</template>
static shouldRender(outletArgs, helper) {
return showPostAIHelper(outletArgs, helper);
}
@tracked helperOptions = [];
@tracked menuState = this.MENU_STATES.triggers;
@tracked loading = false;
@tracked suggestion = "";
@tracked showMainButtons = true;
MENU_STATES = {
triggers: "TRIGGERS",
options: "OPTIONS",
loading: "LOADING",
result: "RESULT"
};
@tracked _activeAIRequest = null;
constructor() {
super(...arguments);
if (this.helperOptions.length === 0) {
this.loadPrompts();
}
}
@action
async showAIHelperOptions() {
this.showMainButtons = false;
this.menuState = this.MENU_STATES.options;
}
@action
async performAISuggestion(option) {
this.menuState = this.MENU_STATES.loading;
if (option.name === "Explain") {
this._activeAIRequest = ajax("/discourse-ai/ai-helper/explain", {
method: "POST",
data: {
mode: option.value,
text: this.args.outletArgs.data.quoteState.buffer,
post_id: this.args.outletArgs.data.quoteState.postId
}
});
} else {
this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: option.value,
text: this.args.outletArgs.data.quoteState.buffer,
custom_prompt: "",
}
});
}
this._activeAIRequest.then(({ suggestions }) => {
this.suggestion = suggestions[0];
}).catch(popupAjaxError).finally(() => {
this.loading = false;
this.menuState = this.MENU_STATES.result;
});
return this._activeAIRequest;
}
@action
cancelAIAction() {
if (this._activeAIRequest) {
this._activeAIRequest.abort();
this._activeAIRequest = null;
this.loading = false;
this.menuState = this.MENU_STATES.options;
}
}
async loadPrompts() {
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
this.helperOptions = prompts.filter(item => item.location.includes("post")).map((p) => {
return {
name: p.translated_name,
value: p.id,
icon: p.icon,
};
});
}
}

View File

@ -1,20 +1,38 @@
export default function showAIHelper(outletArgs, helper) {
const helperEnabled =
helper.siteSettings.discourse_ai_enabled &&
helper.siteSettings.composer_ai_helper_enabled;
const allowedGroups = helper.siteSettings.ai_helper_allowed_groups
.split("|")
.map((id) => parseInt(id, 10));
const canUseAssistant = helper.currentUser?.groups.some((g) =>
allowedGroups.includes(g.id)
export function showComposerAIHelper(outletArgs, helper) {
const enableHelper = _helperEnabled(helper.siteSettings);
const enableAssistant = _canUseAssistant(
helper.currentUser,
_findAllowedGroups(helper.siteSettings.ai_helper_allowed_groups)
);
const canShowInPM = helper.siteSettings.ai_helper_allowed_in_pm;
if (outletArgs?.composer?.privateMessage) {
return helperEnabled && canUseAssistant && canShowInPM;
return enableHelper && enableAssistant && canShowInPM;
}
return helperEnabled && canUseAssistant;
return enableHelper && enableAssistant;
}
export function showPostAIHelper(outletArgs, helper) {
return (
_helperEnabled(helper.siteSettings) &&
_canUseAssistant(
helper.currentUser,
_findAllowedGroups(helper.siteSettings.post_ai_helper_allowed_groups)
)
);
}
function _helperEnabled(siteSettings) {
return (
siteSettings.discourse_ai_enabled && siteSettings.composer_ai_helper_enabled
);
}
function _findAllowedGroups(setting) {
return setting.split("|").map((id) => parseInt(id, 10));
}
function _canUseAssistant(user, allowedGroups) {
return user?.groups.some((g) => allowedGroups.includes(g.id));
}

View File

@ -319,3 +319,17 @@
transform: rotate(359deg);
}
}
.ai-post-helper {
&__options {
display: flex;
flex-flow: column nowrap;
align-items: flex-start;
gap: 0.25rem;
justify-content: flex-start;
}
&__suggestion {
padding: 1rem;
}
}

View File

@ -66,6 +66,10 @@ en:
title: "Custom Prompt"
placeholder: "Enter a custom prompt..."
submit: "Send Prompt"
post_options_menu:
trigger: "Ask AI"
loading: "AI is generating"
close: "Close"
reviewables:
model_used: "Model used:"
accuracy: "Accuracy:"

View File

@ -108,6 +108,7 @@ en:
proofread: Proofread text
markdown_table: Generate Markdown table
custom_prompt: "Custom Prompt"
explain: "Explain"
ai_bot:
personas:

View File

@ -8,6 +8,7 @@ DiscourseAi::Engine.routes.draw do
post "suggest_category" => "assistant#suggest_category"
post "suggest_tags" => "assistant#suggest_tags"
post "suggest_thumbnails" => "assistant#suggest_thumbnails"
post "explain" => "assistant#explain"
end
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do

View File

@ -169,6 +169,13 @@ discourse_ai:
default: "3" # 3: @staff
allow_any: false
refresh: true
post_ai_helper_allowed_groups:
client: true
type: group_list
list_type: compact
default: "3|14" # 3: @staff, 14: @trust_level_4
allow_any: false
refresh: true
ai_embeddings_enabled:
default: false

View File

@ -134,3 +134,25 @@ CompletionPrompt.seed do |cp|
you will {{custom_prompt}} and you will reply with the result.
TEXT
end
CompletionPrompt.seed do |cp|
cp.id = -6
cp.provider = "openai"
cp.name = "explain"
cp.prompt_type = CompletionPrompt.prompt_types[:text]
cp.messages = [{ role: "Human", content: <<~TEXT }, { role: "Assistant", content: "" }]
You are a helpful assistant. Act as a tutor explaining terms to a student in a specific
context. Reply with a paragraph with a brief explanation about what the term means in the
content provided, format the response using markdown. Reply only with the explanation and
nothing more.
Term to explain:
{{search}}
Context where it was used:
{{context}}
Title of the conversation where it was used:
{{topic}}
TEXT
end

View File

@ -65,3 +65,29 @@ CompletionPrompt.seed do |cp|
you will {{custom_prompt}} and you will reply with the result between <ai></ai> tags.
TEXT
end
CompletionPrompt.seed do |cp|
cp.id = -106
cp.provider = "anthropic"
cp.name = "explain"
cp.prompt_type = CompletionPrompt.prompt_types[:text]
cp.messages = [{ role: "Human", content: <<~TEXT }, { role: "Assistant", content: "" }]
You are a helpful assistant, I will provide you with a term inside <input> tags,
and the context where it was used inside <context> tags, the title of the topic
where it was used between <topic> tags, optionally the post it was written
in response to in <post> tags and you will reply with an explanation of what the
term means in this context between <ai></ai> tags.
<input>
{{search}}
</input>
<context>
{{context}}
</context>
<topic>
{{topic}}
</topic>
TEXT
end

View File

@ -126,3 +126,29 @@ CompletionPrompt.seed do |cp|
### Assistant:
TEXT
end
CompletionPrompt.seed do |cp|
cp.id = -206
cp.provider = "huggingface"
cp.name = "explain"
cp.prompt_type = CompletionPrompt.prompt_types[:text]
cp.messages = [<<~TEXT]
### System:
You are a helpful assistant. Act as a tutor explaining terms to a student in a specific
context. Reply with a paragraph with a brief explanation about what the term means in the
content provided, format the response using markdown. Reply only with the explanation and
nothing more.
### User:
Term to explain:
{{search}}
Context where it was used:
{{context}}
Title of the conversation where it was used:
{{topic}}
### Assistant:
TEXT
end

View File

@ -6,6 +6,7 @@ module DiscourseAi
require_relative "llm_prompt"
require_relative "semantic_categorizer"
require_relative "painter"
require_relative "topic_helper"
end
def inject_into(plugin)

View File

@ -20,6 +20,7 @@ module DiscourseAi
translated_name: translation,
prompt_type: prompt.prompt_type,
icon: icon_map(prompt.name),
location: location_map(prompt.name),
}
end
end
@ -64,11 +65,38 @@ module DiscourseAi
"comment"
when "rewrite"
"pen"
when "explain"
"question"
else
nil
end
end
def location_map(name)
case name
when "translate"
%w[composer post]
when "generate_titles"
%w[composer]
when "proofread"
%w[composer]
when "markdown_table"
%w[composer]
when "tone"
%w[composer]
when "custom_prompt"
%w[composer]
when "rewrite"
%w[composer]
when "explain"
%w[post]
when "summarize"
%w[post]
else
%w[composer post]
end
end
def generate_diff(text, suggestion)
cooked_text = PrettyText.cook(text)
cooked_suggestion = PrettyText.cook(suggestion)

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
module DiscourseAi
module AiHelper
class TopicHelper
def initialize(input, user, params = {})
@user = user
@text = input[:text]
@params = params
end
def explain
return nil if @text.blank?
return nil unless post = Post.find_by(id: @params[:post])
reply_to = post.topic.first_post
topic = reply_to.topic
llm_prompt =
DiscourseAi::AiHelper::LlmPrompt.new.available_prompts(name_filter: "explain").first
prompt = CompletionPrompt.find_by(id: llm_prompt[:id])
prompt.messages.first["content"].gsub!("{{search}}", @text)
prompt.messages.first["content"].gsub!("{{context}}", post.raw)
prompt.messages.first["content"].gsub!("{{topic}}", topic.title)
# TODO inject this conditionally
#prompt.messages.first["content"].gsub!("{{post}}", reply_to.raw)
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(prompt, nil)
end
end
end
end

View File

@ -5,6 +5,7 @@ class OpenAiCompletionsInferenceStubs
PROOFREAD = "proofread"
GENERATE_TITLES = "generate_titles"
CUSTOM_PROMPT = "custom_prompt"
EXPLAIN = "explain"
class << self
def text_mode_to_id(mode)
@ -17,6 +18,8 @@ class OpenAiCompletionsInferenceStubs
-2
when CUSTOM_PROMPT
-5
when EXPLAIN
-4
end
end
@ -82,6 +85,15 @@ class OpenAiCompletionsInferenceStubs
STRING
end
def explain_response
<<~STRING
"In this context, \"pie\" refers to a baked dessert typically consisting of a pastry crust and filling.
The person states they enjoy eating pie, considering it a good dessert. They note that some people wastefully
throw pie at others, but the person themselves chooses to eat the pie rather than throwing it. Overall, \"pie\"
is being used to refer the the baked dessert food item."
STRING
end
def response(content)
{
id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S",
@ -109,6 +121,8 @@ class OpenAiCompletionsInferenceStubs
generated_titles
when CUSTOM_PROMPT
custom_prompt_response
when EXPLAIN
explain_response
end
end

View File

@ -0,0 +1,120 @@
# frozen_string_literal: true
require_relative "../../support/openai_completions_inference_stubs"
RSpec.describe "AI Composer helper", type: :system, js: true do
fab!(:user) { Fabricate(:admin) }
fab!(:non_member_group) { Fabricate(:group) }
fab!(:topic) { Fabricate(:topic) }
fab!(:post) do
Fabricate(
:post,
topic: topic,
raw:
"I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.",
)
end
fab!(:post_2) do
Fabricate(:post, topic: topic, raw: OpenAiCompletionsInferenceStubs.spanish_text)
end
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:post_ai_helper) { PageObjects::Components::AIHelperPostOptions.new }
before do
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
SiteSetting.composer_ai_helper_enabled = true
sign_in(user)
end
def select_post_text(selected_post)
topic_page.visit_topic(topic)
page.execute_script(
"var element = document.querySelector('#{topic_page.post_by_number_selector(selected_post.post_number)} .cooked p'); " +
"var range = document.createRange(); " + "range.selectNodeContents(element); " +
"var selection = window.getSelection(); " + "selection.removeAllRanges(); " +
"selection.addRange(range);",
)
end
context "when triggering AI helper in post" do
it "shows the Ask AI button in the post selection toolbar" do
select_post_text(post)
expect(post_ai_helper).to have_post_selection_toolbar
expect(post_ai_helper).to have_post_ai_helper
end
it "shows AI helper options after clicking the AI button" do
select_post_text(post)
post_ai_helper.click_ai_button
expect(post_ai_helper).to have_no_post_selection_primary_buttons
expect(post_ai_helper).to have_post_ai_helper_options
end
context "when using explain mode" do
skip "TODO: Fix explain mode option not appearing in spec" do
let(:mode) { OpenAiCompletionsInferenceStubs::EXPLAIN }
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
it "shows an explanation of the selected text" do
select_post_text(post)
post_ai_helper.click_ai_button
post_ai_helper.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode))
wait_for do
post_ai_helper.suggestion_value ==
OpenAiCompletionsInferenceStubs.explain_response.strip
end
expect(post_ai_helper.suggestion_value).to eq(
OpenAiCompletionsInferenceStubs.explain_response.strip,
)
end
end
end
context "when using translate mode" do
skip "TODO: Fix WebMock request for translate mode not working" do
let(:mode) { OpenAiCompletionsInferenceStubs::TRANSLATE }
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
it "shows a translation of the selected text" do
select_post_text(post_2)
post_ai_helper.click_ai_button
post_ai_helper.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode))
wait_for do
post_ai_helper.suggestion_value ==
OpenAiCompletionsInferenceStubs.translated_response.strip
end
expect(post_ai_helper.suggestion_value).to eq(
OpenAiCompletionsInferenceStubs.translated_response.strip,
)
end
end
end
end
context "when AI helper is disabled" do
before { SiteSetting.composer_ai_helper_enabled = false }
it "does not show the Ask AI button in the post selection toolbar" do
select_post_text(post)
expect(post_ai_helper).to have_post_selection_toolbar
expect(post_ai_helper).to have_no_post_ai_helper
end
end
context "when user is not a member of the post AI helper allowed group" do
before do
SiteSetting.composer_ai_helper_enabled = true
SiteSetting.post_ai_helper_allowed_groups = non_member_group.id.to_s
end
it "does not show the Ask AI button in the post selection toolbar" do
select_post_text(post)
expect(post_ai_helper).to have_post_selection_toolbar
expect(post_ai_helper).to have_no_post_ai_helper
end
end
end

View File

@ -0,0 +1,64 @@
# frozen_string_literal: true
module PageObjects
module Components
class AIHelperPostOptions < PageObjects::Components::Base
POST_SELECTION_TOOLBAR_SELECTOR = ".quote-button"
QUOTE_SELECTOR = ".insert-quote"
EDIT_SELECTOR = ".quote-edit-label"
SHARE_SELECTOR = ".quote-sharing"
AI_HELPER_SELECTOR = ".ai-post-helper"
TRIGGER_SELECTOR = "#{AI_HELPER_SELECTOR}__trigger"
OPTIONS_SELECTOR = "#{AI_HELPER_SELECTOR}__options"
LOADING_SELECTOR = ".ai-helper-context-menu__loading"
SUGGESTION_SELECTOR = "#{AI_HELPER_SELECTOR}__suggestion"
def click_ai_button
find(TRIGGER_SELECTOR).click
end
def select_helper_model(mode)
find("#{OPTIONS_SELECTOR} .btn[data-value=\"#{mode}\"]").click
end
def suggestion_value
find(SUGGESTION_SELECTOR).text
end
def has_post_ai_helper?
page.has_css?(AI_HELPER_SELECTOR)
end
def has_no_post_ai_helper?
page.has_no_css?(AI_HELPER_SELECTOR)
end
def has_post_ai_helper_options?
page.has_css?(OPTIONS_SELECTOR)
end
def has_no_post_ai_helper_options?
page.has_no_css?(OPTIONS_SELECTOR)
end
def has_post_selection_toolbar?
page.has_css?(POST_SELECTION_TOOLBAR_SELECTOR)
end
def has_no_post_selection_toolbar?
page.has_no_css?(POST_SELECTION_TOOLBAR_SELECTOR)
end
def has_post_selection_primary_buttons?
page.has_css?(QUOTE_SELECTOR) || page.has_css?(EDIT_SELECTOR) ||
page.has_css?(SHARE_SELECTOR)
end
def has_no_post_selection_primary_buttons?
page.has_no_css?(QUOTE_SELECTOR) || page.has_no_css?(EDIT_SELECTOR) ||
page.has_no_css?(SHARE_SELECTOR)
end
end
end
end