FEATURE: Additional AI suggestion options (#176)

This commit is contained in:
Rafael dos Santos Silva 2023-09-01 21:10:58 -03:00 committed by GitHub
parent 181113159b
commit 43e485cbd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 495 additions and 167 deletions

View File

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

View File

@ -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;
});
}
}

View File

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

View File

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

View File

@ -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;
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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