mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-02-16 08:24:45 +00:00
FEATURE: Additional AI suggestion options (#176)
This commit is contained in:
parent
181113159b
commit
43e485cbd9
@ -39,6 +39,51 @@ module DiscourseAi
|
||||
status: 502
|
||||
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
|
||||
|
||||
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;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.suggestion-button {
|
||||
.d-icon-spinner {
|
||||
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;
|
||||
margin-left: 0;
|
||||
position: absolute;
|
||||
@ -233,6 +244,20 @@
|
||||
max-width: 25rem;
|
||||
width: unset;
|
||||
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 {
|
||||
|
@ -16,7 +16,7 @@ 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."
|
||||
suggest_titles: "Suggest titles with AI"
|
||||
suggest: "Suggest with AI"
|
||||
missing_content: "Please enter some content to generate suggestions."
|
||||
context_menu:
|
||||
trigger: "AI"
|
||||
|
@ -4,6 +4,9 @@ DiscourseAi::Engine.routes.draw do
|
||||
scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do
|
||||
get "prompts" => "assistant#prompts"
|
||||
post "suggest" => "assistant#suggest"
|
||||
post "suggest_title" => "assistant#suggest_title"
|
||||
post "suggest_category" => "assistant#suggest_category"
|
||||
post "suggest_tags" => "assistant#suggest_tags"
|
||||
end
|
||||
|
||||
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
|
||||
|
@ -2,7 +2,7 @@
|
||||
CompletionPrompt.seed do |cp|
|
||||
cp.id = -101
|
||||
cp.provider = "anthropic"
|
||||
cp.name = "Traslate to English"
|
||||
cp.name = "translate"
|
||||
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
||||
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
||||
I want you to act as an English translator, spelling corrector and improver. I will speak to you
|
||||
@ -17,7 +17,7 @@ end
|
||||
CompletionPrompt.seed do |cp|
|
||||
cp.id = -102
|
||||
cp.provider = "anthropic"
|
||||
cp.name = "Suggest topic titles"
|
||||
cp.name = "generate_titles"
|
||||
cp.prompt_type = CompletionPrompt.prompt_types[:list]
|
||||
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
||||
I want you to act as a title generator for written pieces. I will provide you with a text inside <input> tags,
|
||||
@ -30,7 +30,7 @@ end
|
||||
CompletionPrompt.seed do |cp|
|
||||
cp.id = -103
|
||||
cp.provider = "anthropic"
|
||||
cp.name = "Proofread"
|
||||
cp.name = "proofread"
|
||||
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
||||
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
||||
You are a markdown proofreader. You correct egregious typos and phrasing issues but keep the user's original voice.
|
||||
@ -46,7 +46,7 @@ end
|
||||
CompletionPrompt.seed do |cp|
|
||||
cp.id = -104
|
||||
cp.provider = "anthropic"
|
||||
cp.name = "Convert to table"
|
||||
cp.name = "markdown_table"
|
||||
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
|
||||
cp.messages = [{ role: "Human", content: <<~TEXT }]
|
||||
You are a markdown table formatter, I will provide you text and you will format it into a markdown table.
|
||||
|
@ -4,6 +4,7 @@ module DiscourseAi
|
||||
class EntryPoint
|
||||
def load_files
|
||||
require_relative "llm_prompt"
|
||||
require_relative "semantic_categorizer"
|
||||
end
|
||||
|
||||
def inject_into(plugin)
|
||||
|
@ -3,8 +3,10 @@
|
||||
module DiscourseAi
|
||||
module AiHelper
|
||||
class LlmPrompt
|
||||
def available_prompts
|
||||
CompletionPrompt
|
||||
def available_prompts(name_filter: nil)
|
||||
cp = CompletionPrompt
|
||||
cp = cp.where(name: name_filter) if name_filter.present?
|
||||
cp
|
||||
.where(provider: enabled_provider)
|
||||
.where(enabled: true)
|
||||
.map do |prompt|
|
||||
|
73
lib/modules/ai_helper/semantic_categorizer.rb
Normal file
73
lib/modules/ai_helper/semantic_categorizer.rb
Normal file
@ -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)")
|
||||
end
|
||||
|
||||
def asymmetric_semantic_search(query, limit, offset)
|
||||
def asymmetric_semantic_search(query, limit, offset, return_distance: false)
|
||||
embedding = model.generate_embeddings(query)
|
||||
table = @manager.topic_embeddings_table
|
||||
|
||||
begin
|
||||
candidate_ids =
|
||||
DB.query(<<~SQL, query_embedding: embedding, limit: limit, offset: offset).map(
|
||||
candidate_ids = DB.query(<<~SQL, query_embedding: embedding, limit: limit, offset: offset)
|
||||
SELECT
|
||||
topic_id
|
||||
topic_id,
|
||||
embeddings #{@model.pg_function} '[:query_embedding]' AS distance
|
||||
FROM
|
||||
#{table}
|
||||
ORDER BY
|
||||
@ -39,8 +39,6 @@ module DiscourseAi
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
SQL
|
||||
&:topic_id
|
||||
)
|
||||
rescue PG::Error => e
|
||||
Rails.logger.error(
|
||||
"Error #{e} querying embeddings for model #{model.name} and search #{query}",
|
||||
@ -48,7 +46,11 @@ module DiscourseAi
|
||||
raise MissingEmbeddingError
|
||||
end
|
||||
|
||||
candidate_ids
|
||||
if return_distance
|
||||
candidate_ids.map { |c| [c.topic_id, c.distance] }
|
||||
else
|
||||
candidate_ids.map(&:topic_id)
|
||||
end
|
||||
end
|
||||
|
||||
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_modal) { PageObjects::Modals::AiHelper.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
|
||||
let(:mode) { OpenAiCompletionsInferenceStubs::TRANSLATE }
|
||||
@ -296,22 +302,22 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||
visit("/latest")
|
||||
page.find("#create-topic").click
|
||||
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
|
||||
|
||||
it "replaces the topic title with the selected title" do
|
||||
visit("/latest")
|
||||
page.find("#create-topic").click
|
||||
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"
|
||||
|
||||
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")
|
||||
page.find("#create-topic").click
|
||||
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
|
||||
|
||||
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
|
||||
|
@ -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…
x
Reference in New Issue
Block a user