From f7277d244ed015649fc7dae07c3fde25f5c39965 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Fri, 17 Nov 2023 12:46:59 -0800 Subject: [PATCH] DEV: Mix semantic search results with normal results (#278) --- .../search-result-decoration.gjs | 10 ++ .../semantic-search.gjs | 148 ++++++++++++++++++ .../semantic-search.hbs | 51 ------ .../semantic-search.js | 86 ---------- .../initializers/ai-semantic-search.js | 9 ++ .../embeddings/common/semantic-search.scss | 43 +++++ config/locales/client.en.yml | 3 +- .../system/embeddings/semantic_search_spec.rb | 53 +++++++ 8 files changed, 265 insertions(+), 138 deletions(-) create mode 100644 assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs create mode 100644 assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.gjs delete mode 100644 assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.hbs delete mode 100644 assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.js create mode 100644 assets/javascripts/initializers/ai-semantic-search.js create mode 100644 spec/system/embeddings/semantic_search_spec.rb diff --git a/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs b/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs new file mode 100644 index 00000000..2c6e2020 --- /dev/null +++ b/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs @@ -0,0 +1,10 @@ +import Component from '@glimmer/component'; +import icon from "discourse-common/helpers/d-icon"; + +export default class SearchResultDecoration extends Component { + +} \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.gjs b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.gjs new file mode 100644 index 00000000..80deea56 --- /dev/null +++ b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.gjs @@ -0,0 +1,148 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import I18n from "I18n"; +import { tracked } from "@glimmer/tracking"; +import { ajax } from "discourse/lib/ajax"; +import { isValidSearchTerm, translateResults } from "discourse/lib/search"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; +import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search"; +import DToggleSwitch from "discourse/components/d-toggle-switch"; +import { on } from "@ember/modifier"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; +import icon from "discourse-common/helpers/d-icon"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { withPluginApi } from "discourse/lib/plugin-api"; + +export default class SemanticSearch extends Component { + + + static shouldRender(_args, { siteSettings }) { + return siteSettings.ai_embeddings_semantic_search_enabled; + } + + @service appEvents; + @service siteSettings; + + @tracked searching = false; + @tracked AIResults = []; + @tracked showingAIResults = false; + + get searchStateText() { + if (this.searching) { + return I18n.t("discourse_ai.embeddings.semantic_search_loading"); + } + + if (this.AIResults.length === 0) { + return I18n.t("discourse_ai.embeddings.semantic_search_results.none"); + } + + if (this.AIResults.length > 0) { + if (this.showingAIResults) { + return I18n.t( + "discourse_ai.embeddings.semantic_search_results.toggle", + { + count: this.AIResults.length, + } + ); + } else { + return I18n.t( + "discourse_ai.embeddings.semantic_search_results.toggle_hidden", + { + count: this.AIResults.length, + } + ); + } + } + } + + get searchTerm() { + return this.args.outletArgs.search; + } + + get searchEnabled() { + return ( + this.args.outletArgs.type === SEARCH_TYPE_DEFAULT && + isValidSearchTerm(this.searchTerm, this.siteSettings) + ); + } + + @action + toggleAIResults() { + document.body.classList.toggle("showing-ai-results"); + this.showingAIResults = !this.showingAIResults; + } + + @action + resetAIResults() { + this.AIResults = []; + this.showingAIResults = false; + document.body.classList.remove("showing-ai-results"); + } + + @action + performHyDESearch() { + if (!this.searchEnabled) { + return; + } + + withPluginApi("1.15.0", (api) => { + api.onAppEvent("full-page-search:trigger-search", () => { + this.searching = true; + this.resetAIResults(); + + ajax("/discourse-ai/embeddings/semantic-search", { + data: { q: this.searchTerm }, + }) + .then(async (results) => { + const model = (await translateResults(results)) || {}; + const AIResults = model.posts.map(function (post) { + return Object.assign({}, post, { generatedByAI: true }); + }); + + this.args.outletArgs.addSearchResults(AIResults, "topic_id"); + this.AIResults = AIResults; + }) + .catch(popupAjaxError) + .finally(() => (this.searching = false)); + }); + }); + } +} diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.hbs b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.hbs deleted file mode 100644 index 9b71af92..00000000 --- a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.hbs +++ /dev/null @@ -1,51 +0,0 @@ -{{#if this.searchEnabled}} -
-
- {{#if this.searching}} -
-
- {{i18n "discourse_ai.embeddings.semantic_search_loading"}} -
- - . - . - . - -
- {{else}} - {{#if this.results.length}} -
- -
- - {{#unless this.collapsedResults}} -
- -
- {{/unless}} - {{else}} -
- {{i18n "discourse_ai.embeddings.semantic_search_results.none"}} -
- {{/if}} - {{/if}} -
-
-{{/if}} \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.js b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.js deleted file mode 100644 index 5bc0e0c3..00000000 --- a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.js +++ /dev/null @@ -1,86 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action, computed } from "@ember/object"; -import { inject as service } from "@ember/service"; -import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search"; -import { ajax } from "discourse/lib/ajax"; -import { isValidSearchTerm, translateResults } from "discourse/lib/search"; -import discourseDebounce from "discourse-common/lib/debounce"; -import { bind } from "discourse-common/utils/decorators"; -import I18n from "I18n"; - -export default class extends Component { - static shouldRender(_args, { siteSettings }) { - return siteSettings.ai_embeddings_semantic_search_enabled; - } - - @service appEvents; - @service siteSettings; - - @tracked searching = true; - @tracked collapsedResults = true; - @tracked results = []; - - @computed("args.outletArgs.search") - get searchTerm() { - return this.args.outletArgs.search; - } - - @computed("args.outletArgs.type", "searchTerm") - get searchEnabled() { - return ( - this.args.outletArgs.type === SEARCH_TYPE_DEFAULT && - isValidSearchTerm(this.searchTerm, this.siteSettings) - ); - } - - @computed("results") - get collapsedResultsTitle() { - return I18n.t("discourse_ai.embeddings.semantic_search_results.toggle", { - count: this.results.length, - }); - } - - @action - setup() { - this.appEvents.on( - "full-page-search:trigger-search", - this, - "debouncedSearch" - ); - } - - @action - teardown() { - this.appEvents.off( - "full-page-search:trigger-search", - this, - "debouncedSearch" - ); - } - - @bind - performHyDESearch() { - if (!this.searchEnabled) { - return; - } - - this.searching = true; - this.collapsedResults = true; - this.results = []; - - ajax("/discourse-ai/embeddings/semantic-search", { - data: { q: this.searchTerm }, - }) - .then(async (results) => { - const model = (await translateResults(results)) || {}; - this.results = model.posts; - }) - .finally(() => (this.searching = false)); - } - - @action - debouncedSearch() { - discourseDebounce(this, this.performHyDESearch, 500); - } -} diff --git a/assets/javascripts/initializers/ai-semantic-search.js b/assets/javascripts/initializers/ai-semantic-search.js new file mode 100644 index 00000000..070b80b6 --- /dev/null +++ b/assets/javascripts/initializers/ai-semantic-search.js @@ -0,0 +1,9 @@ +import { apiInitializer } from "discourse/lib/api"; + +export default apiInitializer("1.15.0", (api) => { + api.modifyClass("component:search-result-entry", { + pluginId: "discourse-ai", + + classNameBindings: ["bulkSelectEnabled", "post.generatedByAI:ai-result"], + }); +}); diff --git a/assets/stylesheets/modules/embeddings/common/semantic-search.scss b/assets/stylesheets/modules/embeddings/common/semantic-search.scss index fcf09cb6..e5940c46 100644 --- a/assets/stylesheets/modules/embeddings/common/semantic-search.scss +++ b/assets/stylesheets/modules/embeddings/common/semantic-search.scss @@ -8,13 +8,26 @@ align-items: baseline; .semantic-search { + &__searching { + display: flex; + align-items: center; + + &.in-progress { + .semantic-search__searching-text { + color: var(--primary-medium); + } + } + } + &__searching-text { display: inline-block; margin-left: 3px; } + &__indicator-wave { flex: 0 0 auto; display: inline-flex; + color: var(--primary-medium); } &__indicator-dot { display: inline-block; @@ -37,3 +50,33 @@ } } } + +.search-results { + .fps-result { + padding: 0.5rem; + + .ai-result__icon { + display: none; + } + } + + .ai-result { + display: none; + background: var(--tertiary-very-low); + border-radius: var(--d-border-radius); + + .ai-result__icon { + display: inline; + margin-right: 0.5rem; + margin-left: auto; + font-size: var(--font-up-2); + color: var(--tertiary); + } + } +} + +.showing-ai-results { + .ai-result { + display: flex; + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 33f40e99..bdbd1d4c 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -80,7 +80,8 @@ en: semantic_search: "Topics (Semantic)" semantic_search_loading: "Searching for more results using AI" semantic_search_results: - toggle: "Found %{count} results using AI" + toggle: "Showing %{count} results found using AI" + toggle_hidden: "Hiding %{count} results found using AI" none: "Sorry, our AI search found no matching topics." ai_bot: diff --git a/spec/system/embeddings/semantic_search_spec.rb b/spec/system/embeddings/semantic_search_spec.rb new file mode 100644 index 00000000..962acf41 --- /dev/null +++ b/spec/system/embeddings/semantic_search_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "../../support/embeddings_generation_stubs" +require_relative "../../support/openai_completions_inference_stubs" + +RSpec.describe "AI Composer helper", type: :system, js: true do + let(:search_page) { PageObjects::Pages::Search.new } + let(:query) { "apple_pie" } + let(:hypothetical_post) { "This is an hypothetical post generated from the keyword apple_pie" } + + fab!(:user) { Fabricate(:admin) } + fab!(:topic) { Fabricate(:topic) } + fab!(:post) { Fabricate(:post, topic: topic, raw: "Apple pie is a delicious dessert to eat") } + + before do + SiteSetting.ai_embeddings_discourse_service_api_endpoint = "http://test.com" + prompt = DiscourseAi::Embeddings::HydeGenerators::OpenAi.new.prompt(query) + OpenAiCompletionsInferenceStubs.stub_response( + prompt, + hypothetical_post, + req_opts: { + max_tokens: 400, + }, + ) + + hyde_embedding = [0.049382, 0.9999] + EmbeddingsGenerationStubs.discourse_service( + SiteSetting.ai_embeddings_model, + hypothetical_post, + hyde_embedding, + ) + + SearchIndexer.enable + SearchIndexer.index(topic, force: true) + SiteSetting.ai_embeddings_semantic_search_enabled = true + sign_in(user) + end + + after do + described_class.clear_cache_for(query) + SearchIndexer.disable + end + + describe "when performing a search in the full page search page" do + skip "TODO: Implement test after doing LLM abrstraction" do + it "performs AI search in the background and hides results by default" do + visit("/search?expanded=true") + search_page.type_in_search("apple pie") + search_page.click_search_button + end + end + end +end