FEATURE: Additional AI suggestion options (#176)
This commit is contained in:
parent
181113159b
commit
43e485cbd9
|
@ -39,6 +39,51 @@ module DiscourseAi
|
||||||
status: 502
|
status: 502
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def suggest_title
|
||||||
|
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
|
||||||
|
|
||||||
|
llm_prompt =
|
||||||
|
DiscourseAi::AiHelper::LlmPrompt
|
||||||
|
.new
|
||||||
|
.available_prompts(name_filter: "generate_titles")
|
||||||
|
.first
|
||||||
|
prompt = CompletionPrompt.find_by(id: llm_prompt[:id])
|
||||||
|
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::LlmPrompt.new.generate_and_send_prompt(
|
||||||
|
prompt,
|
||||||
|
params[:text],
|
||||||
|
),
|
||||||
|
status: 200
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|
||||||
|
def suggest_category
|
||||||
|
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
|
||||||
|
|
||||||
|
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||||
|
|
||||||
|
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(params[:text]).categories,
|
||||||
|
status: 200
|
||||||
|
end
|
||||||
|
|
||||||
|
def suggest_tags
|
||||||
|
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
|
||||||
|
|
||||||
|
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||||
|
|
||||||
|
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(params[:text]).tags, status: 200
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_can_request_suggestions
|
def ensure_can_request_suggestions
|
||||||
|
|
|
@ -0,0 +1,184 @@
|
||||||
|
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 { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||||
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import I18n from "I18n";
|
||||||
|
|
||||||
|
export default class AISuggestionDropdown extends Component {
|
||||||
|
<template>
|
||||||
|
<DButton
|
||||||
|
@class="suggestion-button {{if this.loading 'is-loading'}}"
|
||||||
|
@icon={{this.suggestIcon}}
|
||||||
|
@title="discourse_ai.ai_helper.suggest"
|
||||||
|
@action={{this.performSuggestion}}
|
||||||
|
@disabled={{this.disableSuggestionButton}}
|
||||||
|
...attributes
|
||||||
|
/>
|
||||||
|
{{#if this.showMenu}}
|
||||||
|
{{! template-lint-disable modifier-name-case }}
|
||||||
|
<ul class="popup-menu ai-suggestions-menu" {{didInsert this.handleClickOutside}}>
|
||||||
|
{{#if this.showErrors}}
|
||||||
|
<li class="ai-suggestions-menu__errors">{{this.error}}</li>
|
||||||
|
{{/if}}
|
||||||
|
{{#each this.generatedSuggestions as |suggestion index|}}
|
||||||
|
<li data-name={{suggestion}} data-value={{index}}>
|
||||||
|
<DButton
|
||||||
|
@class="popup-menu-btn"
|
||||||
|
@translatedLabel={{suggestion}}
|
||||||
|
@action={{this.applySuggestion}}
|
||||||
|
@actionParam={{suggestion}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
@service dialog;
|
||||||
|
@service site;
|
||||||
|
@service siteSettings;
|
||||||
|
@tracked loading = false;
|
||||||
|
@tracked showMenu = false;
|
||||||
|
@tracked generatedSuggestions = [];
|
||||||
|
@tracked suggestIcon = "discourse-sparkles";
|
||||||
|
@tracked showErrors = false;
|
||||||
|
@tracked error = "";
|
||||||
|
SUGGESTION_TYPES = {
|
||||||
|
title: "suggest_title",
|
||||||
|
category: "suggest_category",
|
||||||
|
tag: "suggest_tags",
|
||||||
|
};
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
document.removeEventListener("click", this.onClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
get composerInput() {
|
||||||
|
return document.querySelector(".d-editor-input")?.value || this.args.composer.reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
get disableSuggestionButton() {
|
||||||
|
return this.loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMenu() {
|
||||||
|
this.suggestIcon = "discourse-sparkles";
|
||||||
|
this.showMenu = false;
|
||||||
|
this.showErrors = false;
|
||||||
|
this.errors = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
onClickOutside(event) {
|
||||||
|
const menu = document.querySelector(".ai-title-suggestions-menu");
|
||||||
|
|
||||||
|
if (event.target === menu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleClickOutside() {
|
||||||
|
document.addEventListener("click", this.onClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
applySuggestion(suggestion) {
|
||||||
|
if (!this.args.mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composer = this.args?.composer;
|
||||||
|
if (!composer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (this.args.mode === this.SUGGESTION_TYPES.title) {
|
||||||
|
composer.set("title", suggestion);
|
||||||
|
return this.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.args.mode === this.SUGGESTION_TYPES.category) {
|
||||||
|
const selectedCategoryId = this.site.categories.find((c) => c.slug === suggestion).id;
|
||||||
|
composer.set("categoryId", selectedCategoryId);
|
||||||
|
return this.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.args.mode === this.SUGGESTION_TYPES.tag) {
|
||||||
|
this.updateTags(suggestion, composer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTags(suggestion, composer) {
|
||||||
|
const maxTags = this.siteSettings.max_tags_per_topic;
|
||||||
|
|
||||||
|
if (!composer.tags) {
|
||||||
|
composer.set("tags", [suggestion]);
|
||||||
|
// remove tag from the list of suggestions once added
|
||||||
|
this.generatedSuggestions = this.generatedSuggestions.filter((s) => s !== suggestion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tags = composer.tags;
|
||||||
|
|
||||||
|
if (tags?.length >= maxTags) {
|
||||||
|
// Show error if trying to add more tags than allowed
|
||||||
|
this.showErrors = true;
|
||||||
|
this.error = I18n.t("select_kit.max_content_reached", { count: maxTags});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(suggestion);
|
||||||
|
composer.set("tags", [...tags]);
|
||||||
|
// remove tag from the list of suggestions once added
|
||||||
|
return this.generatedSuggestions = this.generatedSuggestions.filter((s) => s !== suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async performSuggestion() {
|
||||||
|
if (!this.args.mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.composerInput?.length === 0) {
|
||||||
|
return this.dialog.alert(I18n.t("discourse_ai.ai_helper.missing_content"));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.suggestIcon = "spinner";
|
||||||
|
|
||||||
|
return ajax(`/discourse-ai/ai-helper/${this.args.mode}`, {
|
||||||
|
method: "POST",
|
||||||
|
data: { text: this.composerInput },
|
||||||
|
}).then((data) => {
|
||||||
|
if (this.args.mode === this.SUGGESTION_TYPES.title) {
|
||||||
|
this.generatedSuggestions = data.suggestions;
|
||||||
|
} else {
|
||||||
|
const suggestions = data.assistant.map((s) => s.name);
|
||||||
|
if (this.SUGGESTION_TYPES.tag) {
|
||||||
|
if (this.args.composer?.tags && this.args.composer?.tags.length > 0) {
|
||||||
|
// Filter out tags if they are already selected in the tag input
|
||||||
|
this.generatedSuggestions = suggestions.filter((t) => !this.args.composer.tags.includes(t));
|
||||||
|
} else {
|
||||||
|
this.generatedSuggestions = suggestions;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.generatedSuggestions = suggestions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(popupAjaxError).finally(() => {
|
||||||
|
this.loading = false;
|
||||||
|
this.suggestIcon = "sync-alt";
|
||||||
|
this.showMenu = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
|
||||||
|
export default class AICategorySuggestion extends Component {
|
||||||
|
<template>
|
||||||
|
{{#if this.siteSettings.ai_embeddings_enabled}}
|
||||||
|
<AISuggestionDropdown @mode="suggest_category" @composer={{@outletArgs.composer}} class="suggest-category-button"/>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
@service siteSettings;
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
|
||||||
|
|
||||||
|
export default class AITagSuggestion extends Component {
|
||||||
|
<template>
|
||||||
|
{{#if this.siteSettings.ai_embeddings_enabled}}
|
||||||
|
<AISuggestionDropdown @mode="suggest_tags" @composer={{@outletArgs.composer}} class="suggest-tags-button"/>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
@service siteSettings;
|
||||||
|
}
|
|
@ -1,116 +0,0 @@
|
||||||
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 { popupAjaxError } from "discourse/lib/ajax-error";
|
|
||||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
|
||||||
import { bind } from "discourse-common/utils/decorators";
|
|
||||||
import { inject as service } from "@ember/service";
|
|
||||||
import I18n from "I18n";
|
|
||||||
|
|
||||||
|
|
||||||
export default class AITitleSuggester extends Component {
|
|
||||||
<template>
|
|
||||||
<DButton
|
|
||||||
@class="suggest-titles-button {{if this.loading 'is-loading'}}"
|
|
||||||
@icon={{this.suggestTitleIcon}}
|
|
||||||
@title="discourse_ai.ai_helper.suggest_titles"
|
|
||||||
@action={{this.suggestTitles}}
|
|
||||||
@disabled={{this.disableSuggestionButton}}
|
|
||||||
/>
|
|
||||||
{{#if this.showMenu}}
|
|
||||||
{{! template-lint-disable modifier-name-case }}
|
|
||||||
<ul class="popup-menu ai-title-suggestions-menu" {{didInsert this.handleClickOutside}}>
|
|
||||||
{{#each this.generatedTitleSuggestions as |suggestion index|}}
|
|
||||||
<li data-name={{suggestion}} data-value={{index}}>
|
|
||||||
<DButton
|
|
||||||
@class="popup-menu-btn"
|
|
||||||
@translatedLabel={{suggestion}}
|
|
||||||
@action={{this.updateTopicTitle}}
|
|
||||||
@actionParam={{suggestion}}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
{{/if}}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
@service dialog;
|
|
||||||
@tracked loading = false;
|
|
||||||
@tracked showMenu = false;
|
|
||||||
@tracked generatedTitleSuggestions = [];
|
|
||||||
@tracked suggestTitleIcon = "discourse-sparkles";
|
|
||||||
mode = {
|
|
||||||
id: -2,
|
|
||||||
name: "generate_titles",
|
|
||||||
translated_name: "Suggest topic titles",
|
|
||||||
prompt_type: "list"
|
|
||||||
};
|
|
||||||
|
|
||||||
willDestroy() {
|
|
||||||
super.willDestroy(...arguments);
|
|
||||||
document.removeEventListener("click", this.onClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
get composerInput() {
|
|
||||||
return document.querySelector(".d-editor-input")?.value || this.args.outletArgs.composer.reply;
|
|
||||||
}
|
|
||||||
|
|
||||||
get disableSuggestionButton() {
|
|
||||||
return this.loading;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeMenu() {
|
|
||||||
this.suggestTitleIcon = "discourse-sparkles";
|
|
||||||
this.showMenu = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
onClickOutside(event) {
|
|
||||||
const menu = document.querySelector(".ai-title-suggestions-menu");
|
|
||||||
|
|
||||||
if (event.target === menu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.closeMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
handleClickOutside() {
|
|
||||||
document.addEventListener("click", this.onClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
updateTopicTitle(title) {
|
|
||||||
const composer = this.args.outletArgs?.composer;
|
|
||||||
|
|
||||||
if (composer) {
|
|
||||||
composer.set("title", title);
|
|
||||||
this.closeMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
async suggestTitles() {
|
|
||||||
if (this.composerInput?.length === 0) {
|
|
||||||
return this.dialog.alert(I18n.t("discourse_ai.ai_helper.missing_content"));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
this.suggestTitleIcon = "spinner";
|
|
||||||
|
|
||||||
return ajax("/discourse-ai/ai-helper/suggest", {
|
|
||||||
method: "POST",
|
|
||||||
data: { mode: this.mode.id, text: this.composerInput },
|
|
||||||
}).then((data) => {
|
|
||||||
this.generatedTitleSuggestions = data.suggestions;
|
|
||||||
}).catch(popupAjaxError).finally(() => {
|
|
||||||
this.loading = false;
|
|
||||||
this.suggestTitleIcon = "sync-alt";
|
|
||||||
this.showMenu = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
|
|
||||||
|
export default class AITitleSuggestion extends Component {
|
||||||
|
<template>
|
||||||
|
<AISuggestionDropdown @mode="suggest_title" @composer={{@outletArgs.composer}} class="suggest-titles-button" />
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -218,13 +218,24 @@
|
||||||
right: 1px;
|
right: 1px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-button {
|
||||||
.d-icon-spinner {
|
.d-icon-spinner {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-title-suggestions-menu {
|
.suggest-tags-button,
|
||||||
|
.suggest-category-button {
|
||||||
|
display: block;
|
||||||
|
align-self: baseline;
|
||||||
|
border: 1px solid var(--primary-medium);
|
||||||
|
border-left: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-menu {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -233,6 +244,20 @@
|
||||||
max-width: 25rem;
|
max-width: 25rem;
|
||||||
width: unset;
|
width: unset;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
|
&__errors {
|
||||||
|
background: var(--danger);
|
||||||
|
padding: 0.25rem 1em;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-input:has(.ai-suggestions-menu) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-tags-button + .ai-suggestions-menu {
|
||||||
|
top: 4.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
|
|
|
@ -16,7 +16,7 @@ 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."
|
||||||
suggest_titles: "Suggest titles with AI"
|
suggest: "Suggest with AI"
|
||||||
missing_content: "Please enter some content to generate suggestions."
|
missing_content: "Please enter some content to generate suggestions."
|
||||||
context_menu:
|
context_menu:
|
||||||
trigger: "AI"
|
trigger: "AI"
|
||||||
|
|
|
@ -4,6 +4,9 @@ DiscourseAi::Engine.routes.draw do
|
||||||
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"
|
get "prompts" => "assistant#prompts"
|
||||||
post "suggest" => "assistant#suggest"
|
post "suggest" => "assistant#suggest"
|
||||||
|
post "suggest_title" => "assistant#suggest_title"
|
||||||
|
post "suggest_category" => "assistant#suggest_category"
|
||||||
|
post "suggest_tags" => "assistant#suggest_tags"
|
||||||
end
|
end
|
||||||
|
|
||||||
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
|
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
CompletionPrompt.seed do |cp|
|
CompletionPrompt.seed do |cp|
|
||||||
cp.id = -101
|
cp.id = -101
|
||||||
cp.provider = "anthropic"
|
cp.provider = "anthropic"
|
||||||
cp.name = "Traslate to English"
|
cp.name = "translate"
|
||||||
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
||||||
cp.messages = [{ role: "Human", content: <<~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
|
I want you to act as an English translator, spelling corrector and improver. I will speak to you
|
||||||
|
@ -17,7 +17,7 @@ end
|
||||||
CompletionPrompt.seed do |cp|
|
CompletionPrompt.seed do |cp|
|
||||||
cp.id = -102
|
cp.id = -102
|
||||||
cp.provider = "anthropic"
|
cp.provider = "anthropic"
|
||||||
cp.name = "Suggest topic titles"
|
cp.name = "generate_titles"
|
||||||
cp.prompt_type = CompletionPrompt.prompt_types[:list]
|
cp.prompt_type = CompletionPrompt.prompt_types[:list]
|
||||||
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
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,
|
I want you to act as a title generator for written pieces. I will provide you with a text inside <input> tags,
|
||||||
|
@ -30,7 +30,7 @@ end
|
||||||
CompletionPrompt.seed do |cp|
|
CompletionPrompt.seed do |cp|
|
||||||
cp.id = -103
|
cp.id = -103
|
||||||
cp.provider = "anthropic"
|
cp.provider = "anthropic"
|
||||||
cp.name = "Proofread"
|
cp.name = "proofread"
|
||||||
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
||||||
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
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 are a markdown proofreader. You correct egregious typos and phrasing issues but keep the user's original voice.
|
||||||
|
@ -46,7 +46,7 @@ end
|
||||||
CompletionPrompt.seed do |cp|
|
CompletionPrompt.seed do |cp|
|
||||||
cp.id = -104
|
cp.id = -104
|
||||||
cp.provider = "anthropic"
|
cp.provider = "anthropic"
|
||||||
cp.name = "Convert to table"
|
cp.name = "markdown_table"
|
||||||
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
||||||
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
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.
|
You are a markdown table formatter, I will provide you text and you will format it into a markdown table.
|
||||||
|
|
|
@ -4,6 +4,7 @@ module DiscourseAi
|
||||||
class EntryPoint
|
class EntryPoint
|
||||||
def load_files
|
def load_files
|
||||||
require_relative "llm_prompt"
|
require_relative "llm_prompt"
|
||||||
|
require_relative "semantic_categorizer"
|
||||||
end
|
end
|
||||||
|
|
||||||
def inject_into(plugin)
|
def inject_into(plugin)
|
||||||
|
|
|
@ -3,8 +3,10 @@
|
||||||
module DiscourseAi
|
module DiscourseAi
|
||||||
module AiHelper
|
module AiHelper
|
||||||
class LlmPrompt
|
class LlmPrompt
|
||||||
def available_prompts
|
def available_prompts(name_filter: nil)
|
||||||
CompletionPrompt
|
cp = CompletionPrompt
|
||||||
|
cp = cp.where(name: name_filter) if name_filter.present?
|
||||||
|
cp
|
||||||
.where(provider: enabled_provider)
|
.where(provider: enabled_provider)
|
||||||
.where(enabled: true)
|
.where(enabled: true)
|
||||||
.map do |prompt|
|
.map do |prompt|
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
module DiscourseAi
|
||||||
|
module AiHelper
|
||||||
|
class SemanticCategorizer
|
||||||
|
def initialize(text)
|
||||||
|
@text = text
|
||||||
|
end
|
||||||
|
|
||||||
|
def categories
|
||||||
|
return [] if @text.blank?
|
||||||
|
return [] unless SiteSetting.ai_embeddings_enabled
|
||||||
|
|
||||||
|
candidates =
|
||||||
|
::DiscourseAi::Embeddings::SemanticSearch.new(nil).asymmetric_semantic_search(
|
||||||
|
@text,
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
return_distance: true,
|
||||||
|
)
|
||||||
|
candidate_ids = candidates.map(&:first)
|
||||||
|
|
||||||
|
::Topic
|
||||||
|
.joins(:category)
|
||||||
|
.where(id: candidate_ids)
|
||||||
|
.order("array_position(ARRAY#{candidate_ids}, topics.id)")
|
||||||
|
.pluck("categories.slug")
|
||||||
|
.map
|
||||||
|
.with_index { |category, index| { name: category, score: candidates[index].last } }
|
||||||
|
.map do |c|
|
||||||
|
c[:score] = 1 / (c[:score] + 1) # inverse of the distance
|
||||||
|
c
|
||||||
|
end
|
||||||
|
.group_by { |c| c[:name] }
|
||||||
|
.map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } }
|
||||||
|
.sort_by { |c| -c[:score] }
|
||||||
|
.take(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tags
|
||||||
|
return [] if @text.blank?
|
||||||
|
return [] unless SiteSetting.ai_embeddings_enabled
|
||||||
|
|
||||||
|
candidates =
|
||||||
|
::DiscourseAi::Embeddings::SemanticSearch.new(nil).asymmetric_semantic_search(
|
||||||
|
@text,
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
return_distance: true,
|
||||||
|
)
|
||||||
|
candidate_ids = candidates.map(&:first)
|
||||||
|
|
||||||
|
::Topic
|
||||||
|
.joins(:topic_tags, :tags)
|
||||||
|
.where(id: candidate_ids)
|
||||||
|
.group("topics.id")
|
||||||
|
.order("array_position(ARRAY#{candidate_ids}, topics.id)")
|
||||||
|
.pluck("array_agg(tags.name)")
|
||||||
|
.map(&:uniq)
|
||||||
|
.map
|
||||||
|
.with_index { |tag_list, index| { tags: tag_list, score: candidates[index].last } }
|
||||||
|
.flat_map { |c| c[:tags].map { |t| { name: t, score: c[:score] } } }
|
||||||
|
.map do |c|
|
||||||
|
c[:score] = 1 / (c[:score] + 1) # inverse of the distance
|
||||||
|
c
|
||||||
|
end
|
||||||
|
.group_by { |c| c[:name] }
|
||||||
|
.map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } }
|
||||||
|
.sort_by { |c| -c[:score] }
|
||||||
|
.take(5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -23,15 +23,15 @@ module DiscourseAi
|
||||||
.order("array_position(ARRAY#{candidate_ids}, topic_id)")
|
.order("array_position(ARRAY#{candidate_ids}, topic_id)")
|
||||||
end
|
end
|
||||||
|
|
||||||
def asymmetric_semantic_search(query, limit, offset)
|
def asymmetric_semantic_search(query, limit, offset, return_distance: false)
|
||||||
embedding = model.generate_embeddings(query)
|
embedding = model.generate_embeddings(query)
|
||||||
table = @manager.topic_embeddings_table
|
table = @manager.topic_embeddings_table
|
||||||
|
|
||||||
begin
|
begin
|
||||||
candidate_ids =
|
candidate_ids = DB.query(<<~SQL, query_embedding: embedding, limit: limit, offset: offset)
|
||||||
DB.query(<<~SQL, query_embedding: embedding, limit: limit, offset: offset).map(
|
|
||||||
SELECT
|
SELECT
|
||||||
topic_id
|
topic_id,
|
||||||
|
embeddings #{@model.pg_function} '[:query_embedding]' AS distance
|
||||||
FROM
|
FROM
|
||||||
#{table}
|
#{table}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
@ -39,8 +39,6 @@ module DiscourseAi
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
OFFSET :offset
|
OFFSET :offset
|
||||||
SQL
|
SQL
|
||||||
&:topic_id
|
|
||||||
)
|
|
||||||
rescue PG::Error => e
|
rescue PG::Error => e
|
||||||
Rails.logger.error(
|
Rails.logger.error(
|
||||||
"Error #{e} querying embeddings for model #{model.name} and search #{query}",
|
"Error #{e} querying embeddings for model #{model.name} and search #{query}",
|
||||||
|
@ -48,7 +46,11 @@ module DiscourseAi
|
||||||
raise MissingEmbeddingError
|
raise MissingEmbeddingError
|
||||||
end
|
end
|
||||||
|
|
||||||
candidate_ids
|
if return_distance
|
||||||
|
candidate_ids.map { |c| [c.topic_id, c.distance] }
|
||||||
|
else
|
||||||
|
candidate_ids.map(&:topic_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -15,7 +15,13 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
let(:ai_helper_context_menu) { PageObjects::Components::AIHelperContextMenu.new }
|
let(:ai_helper_context_menu) { PageObjects::Components::AIHelperContextMenu.new }
|
||||||
let(:ai_helper_modal) { PageObjects::Modals::AiHelper.new }
|
let(:ai_helper_modal) { PageObjects::Modals::AiHelper.new }
|
||||||
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
||||||
let(:ai_title_suggester) { PageObjects::Components::AITitleSuggester.new }
|
let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
|
||||||
|
fab!(:category) { Fabricate(:category) }
|
||||||
|
fab!(:video) { Fabricate(:tag) }
|
||||||
|
fab!(:music) { Fabricate(:tag) }
|
||||||
|
fab!(:cloud) { Fabricate(:tag) }
|
||||||
|
fab!(:feedback) { Fabricate(:tag) }
|
||||||
|
fab!(:review) { Fabricate(:tag) }
|
||||||
|
|
||||||
context "when using the translation mode" do
|
context "when using the translation mode" do
|
||||||
let(:mode) { OpenAiCompletionsInferenceStubs::TRANSLATE }
|
let(:mode) { OpenAiCompletionsInferenceStubs::TRANSLATE }
|
||||||
|
@ -296,22 +302,22 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
visit("/latest")
|
visit("/latest")
|
||||||
page.find("#create-topic").click
|
page.find("#create-topic").click
|
||||||
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
||||||
ai_title_suggester.click_suggest_titles_button
|
ai_suggestion_dropdown.click_suggest_titles_button
|
||||||
|
|
||||||
wait_for { ai_title_suggester.has_dropdown? }
|
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||||||
|
|
||||||
expect(ai_title_suggester).to have_dropdown
|
expect(ai_suggestion_dropdown).to have_dropdown
|
||||||
end
|
end
|
||||||
|
|
||||||
it "replaces the topic title with the selected title" do
|
it "replaces the topic title with the selected title" do
|
||||||
visit("/latest")
|
visit("/latest")
|
||||||
page.find("#create-topic").click
|
page.find("#create-topic").click
|
||||||
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
||||||
ai_title_suggester.click_suggest_titles_button
|
ai_suggestion_dropdown.click_suggest_titles_button
|
||||||
|
|
||||||
wait_for { ai_title_suggester.has_dropdown? }
|
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||||||
|
|
||||||
ai_title_suggester.select_title_suggestion(2)
|
ai_suggestion_dropdown.select_suggestion(2)
|
||||||
expected_title = "The Quiet Piece that Moves Literature: A Gaucho's Story"
|
expected_title = "The Quiet Piece that Moves Literature: A Gaucho's Story"
|
||||||
|
|
||||||
expect(find("#reply-title").value).to eq(expected_title)
|
expect(find("#reply-title").value).to eq(expected_title)
|
||||||
|
@ -321,13 +327,66 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
visit("/latest")
|
visit("/latest")
|
||||||
page.find("#create-topic").click
|
page.find("#create-topic").click
|
||||||
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
||||||
ai_title_suggester.click_suggest_titles_button
|
ai_suggestion_dropdown.click_suggest_titles_button
|
||||||
|
|
||||||
wait_for { ai_title_suggester.has_dropdown? }
|
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||||||
|
|
||||||
find(".d-editor-preview").click
|
find(".d-editor-preview").click
|
||||||
|
|
||||||
expect(ai_title_suggester).to have_no_dropdown
|
expect(ai_suggestion_dropdown).to have_no_dropdown
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when suggesting the category with AI category suggester" do
|
||||||
|
before { SiteSetting.ai_embeddings_enabled = true }
|
||||||
|
|
||||||
|
it "updates the category with the suggested category" do
|
||||||
|
response =
|
||||||
|
Category
|
||||||
|
.take(3)
|
||||||
|
.pluck(:slug)
|
||||||
|
.map { |s| { name: s, score: rand(0.0...45.0) } }
|
||||||
|
.sort { |h| h[:score] }
|
||||||
|
DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:categories).returns(response)
|
||||||
|
visit("/latest")
|
||||||
|
page.find("#create-topic").click
|
||||||
|
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
||||||
|
ai_suggestion_dropdown.click_suggest_category_button
|
||||||
|
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||||||
|
|
||||||
|
suggestion = ai_suggestion_dropdown.suggestion_name(0)
|
||||||
|
ai_suggestion_dropdown.select_suggestion(0)
|
||||||
|
category_selector = page.find(".category-chooser summary")
|
||||||
|
|
||||||
|
expect(category_selector["data-name"].downcase.gsub(" ", "-")).to eq(suggestion)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when suggesting the tags with AI tag suggester" do
|
||||||
|
before { SiteSetting.ai_embeddings_enabled = true }
|
||||||
|
|
||||||
|
it "updates the tag with the suggested tag" do
|
||||||
|
response =
|
||||||
|
Tag
|
||||||
|
.take(5)
|
||||||
|
.pluck(:name)
|
||||||
|
.map { |s| { name: s, score: rand(0.0...45.0) } }
|
||||||
|
.sort { |h| h[:score] }
|
||||||
|
DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:tags).returns(response)
|
||||||
|
|
||||||
|
visit("/latest")
|
||||||
|
page.find("#create-topic").click
|
||||||
|
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
||||||
|
|
||||||
|
ai_suggestion_dropdown.click_suggest_tags_button
|
||||||
|
|
||||||
|
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||||||
|
|
||||||
|
suggestion = ai_suggestion_dropdown.suggestion_name(0)
|
||||||
|
ai_suggestion_dropdown.select_suggestion(0)
|
||||||
|
tag_selector = page.find(".mini-tag-chooser summary")
|
||||||
|
|
||||||
|
expect(tag_selector["data-name"]).to eq(suggestion)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PageObjects
|
||||||
|
module Components
|
||||||
|
class AISuggestionDropdown < PageObjects::Components::Base
|
||||||
|
TITLE_BUTTON_SELECTOR = ".suggestion-button.suggest-titles-button"
|
||||||
|
CATEGORY_BUTTON_SELECTOR = ".suggestion-button.suggest-category-button"
|
||||||
|
TAG_BUTTON_SELECTOR = ".suggestion-button.suggest-tags-button"
|
||||||
|
MENU_SELECTOR = ".ai-suggestions-menu"
|
||||||
|
|
||||||
|
def click_suggest_titles_button
|
||||||
|
find(TITLE_BUTTON_SELECTOR, visible: :all).click
|
||||||
|
end
|
||||||
|
|
||||||
|
def click_suggest_category_button
|
||||||
|
find(CATEGORY_BUTTON_SELECTOR, visible: :all).click
|
||||||
|
end
|
||||||
|
|
||||||
|
def click_suggest_tags_button
|
||||||
|
find(TAG_BUTTON_SELECTOR, visible: :all).click
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_suggestion(index)
|
||||||
|
find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]").click
|
||||||
|
end
|
||||||
|
|
||||||
|
def suggestion_name(index)
|
||||||
|
suggestion = find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]")
|
||||||
|
suggestion["data-name"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_dropdown?
|
||||||
|
has_css?(MENU_SELECTOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_no_dropdown?
|
||||||
|
has_no_css?(MENU_SELECTOR)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,26 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module PageObjects
|
|
||||||
module Components
|
|
||||||
class AITitleSuggester < PageObjects::Components::Base
|
|
||||||
BUTTON_SELECTOR = ".suggest-titles-button"
|
|
||||||
MENU_SELECTOR = ".ai-title-suggestions-menu"
|
|
||||||
|
|
||||||
def click_suggest_titles_button
|
|
||||||
find(BUTTON_SELECTOR, visible: :all).click
|
|
||||||
end
|
|
||||||
|
|
||||||
def select_title_suggestion(index)
|
|
||||||
find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]").click
|
|
||||||
end
|
|
||||||
|
|
||||||
def has_dropdown?
|
|
||||||
has_css?(MENU_SELECTOR)
|
|
||||||
end
|
|
||||||
|
|
||||||
def has_no_dropdown?
|
|
||||||
has_no_css?(MENU_SELECTOR)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
Loading…
Reference in New Issue