From 43e485cbd915f5d86623234f2f22bb7cfe275853 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Fri, 1 Sep 2023 21:10:58 -0300 Subject: [PATCH] FEATURE: Additional AI suggestion options (#176) --- .../ai_helper/assistant_controller.rb | 45 +++++ .../components/ai-suggestion-dropdown.gjs | 184 ++++++++++++++++++ .../ai-category-suggestion.gjs | 13 ++ .../ai-tag-suggestion.gjs | 14 ++ .../ai-title-suggester.gjs | 116 ----------- .../ai-title-suggestion.gjs | 8 + .../modules/ai-helper/common/ai-helper.scss | 27 ++- config/locales/client.en.yml | 2 +- config/routes.rb | 3 + .../601_anthropic_completion_prompts.rb | 8 +- lib/modules/ai_helper/entry_point.rb | 1 + lib/modules/ai_helper/llm_prompt.rb | 6 +- lib/modules/ai_helper/semantic_categorizer.rb | 73 +++++++ lib/modules/embeddings/semantic_search.rb | 16 +- .../ai_helper/ai_composer_helper_spec.rb | 79 +++++++- .../components/ai_suggestion_dropdown.rb | 41 ++++ .../components/ai_title_suggester.rb | 26 --- 17 files changed, 495 insertions(+), 167 deletions(-) create mode 100644 assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs create mode 100644 assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs create mode 100644 assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggester.gjs create mode 100644 assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs create mode 100644 lib/modules/ai_helper/semantic_categorizer.rb create mode 100644 spec/system/page_objects/components/ai_suggestion_dropdown.rb delete mode 100644 spec/system/page_objects/components/ai_title_suggester.rb diff --git a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb index 3f2f0db1..52b61115 100644 --- a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb +++ b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb @@ -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 diff --git a/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs b/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs new file mode 100644 index 00000000..edca3919 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs @@ -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 { + + + @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; + }); + + } +} \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs new file mode 100644 index 00000000..c7fed1d7 --- /dev/null +++ b/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs @@ -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 { + + + @service siteSettings; +} \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs new file mode 100644 index 00000000..a90d0978 --- /dev/null +++ b/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs @@ -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 { + + + @service siteSettings; +} \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggester.gjs b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggester.gjs deleted file mode 100644 index 5817bcc6..00000000 --- a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggester.gjs +++ /dev/null @@ -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 { - - - @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; - }); - - } -} \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs new file mode 100644 index 00000000..be8fd945 --- /dev/null +++ b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs @@ -0,0 +1,8 @@ +import Component from '@glimmer/component'; +import AISuggestionDropdown from "../../components/ai-suggestion-dropdown"; + +export default class AITitleSuggestion extends Component { + +} \ No newline at end of file diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index d4efa827..22fefd81 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -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 { diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6077c311..f026b06a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index 41a2a294..51b7d506 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb b/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb index aa40a18d..c33157c8 100644 --- a/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb +++ b/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb @@ -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 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. diff --git a/lib/modules/ai_helper/entry_point.rb b/lib/modules/ai_helper/entry_point.rb index cdafab20..14dbf29c 100644 --- a/lib/modules/ai_helper/entry_point.rb +++ b/lib/modules/ai_helper/entry_point.rb @@ -4,6 +4,7 @@ module DiscourseAi class EntryPoint def load_files require_relative "llm_prompt" + require_relative "semantic_categorizer" end def inject_into(plugin) diff --git a/lib/modules/ai_helper/llm_prompt.rb b/lib/modules/ai_helper/llm_prompt.rb index 4eeab5eb..145e9007 100644 --- a/lib/modules/ai_helper/llm_prompt.rb +++ b/lib/modules/ai_helper/llm_prompt.rb @@ -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| diff --git a/lib/modules/ai_helper/semantic_categorizer.rb b/lib/modules/ai_helper/semantic_categorizer.rb new file mode 100644 index 00000000..34528ab9 --- /dev/null +++ b/lib/modules/ai_helper/semantic_categorizer.rb @@ -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 diff --git a/lib/modules/embeddings/semantic_search.rb b/lib/modules/embeddings/semantic_search.rb index 91b3fba1..e35fdc56 100644 --- a/lib/modules/embeddings/semantic_search.rb +++ b/lib/modules/embeddings/semantic_search.rb @@ -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 diff --git a/spec/system/ai_helper/ai_composer_helper_spec.rb b/spec/system/ai_helper/ai_composer_helper_spec.rb index 8e40ba52..01674890 100644 --- a/spec/system/ai_helper/ai_composer_helper_spec.rb +++ b/spec/system/ai_helper/ai_composer_helper_spec.rb @@ -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 diff --git a/spec/system/page_objects/components/ai_suggestion_dropdown.rb b/spec/system/page_objects/components/ai_suggestion_dropdown.rb new file mode 100644 index 00000000..ed64e69d --- /dev/null +++ b/spec/system/page_objects/components/ai_suggestion_dropdown.rb @@ -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 diff --git a/spec/system/page_objects/components/ai_title_suggester.rb b/spec/system/page_objects/components/ai_title_suggester.rb deleted file mode 100644 index 2cb4650e..00000000 --- a/spec/system/page_objects/components/ai_title_suggester.rb +++ /dev/null @@ -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