DEV: Mix semantic search results with normal results (#278)

This commit is contained in:
Keegan George 2023-11-17 12:46:59 -08:00 committed by GitHub
parent d1f21c78f1
commit f7277d244e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 265 additions and 138 deletions

View File

@ -0,0 +1,10 @@
import Component from '@glimmer/component';
import icon from "discourse-common/helpers/d-icon";
export default class SearchResultDecoration extends Component {
<template>
<div class="ai-result__icon">
{{icon "discourse-sparkles"}}
</div>
</template>
}

View File

@ -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 {
<template>
{{#if this.searchEnabled}}
<div class="semantic-search__container search-results" role="region">
<div
class="semantic-search__results"
{{didInsert this.performHyDESearch}}
>
<div
class="semantic-search__searching
{{if this.searching 'in-progress'}}"
>
<DToggleSwitch
disabled={{this.searching}}
@state={{this.showingAIResults}}
title="AI search results hidden"
class="semantic-search__results-toggle"
{{on "click" this.toggleAIResults}}
/>
<div class="semantic-search__searching-text">
{{icon "discourse-sparkles"}}
{{this.searchStateText}}
</div>
{{#if this.searching}}
<span class="semantic-search__indicator-wave">
<span class="semantic-search__indicator-dot">.</span>
<span class="semantic-search__indicator-dot">.</span>
<span class="semantic-search__indicator-dot">.</span>
</span>
{{/if}}
</div>
</div>
</div>
{{/if}}
</template>
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));
});
});
}
}

View File

@ -1,51 +0,0 @@
{{#if this.searchEnabled}}
<div class="semantic-search__container search-results" role="region">
<div
class="semantic-search__results"
{{did-insert this.setup}}
{{did-insert this.debouncedSearch}}
{{will-destroy this.teardown}}
>
{{#if this.searching}}
<div class="semantic-search__searching">
<div class="semantic-search__searching-text">
{{i18n "discourse_ai.embeddings.semantic_search_loading"}}
</div>
<span class="semantic-search__indicator-wave">
<span class="semantic-search__indicator-dot">.</span>
<span class="semantic-search__indicator-dot">.</span>
<span class="semantic-search__indicator-dot">.</span>
</span>
</div>
{{else}}
{{#if this.results.length}}
<div class="semantic-search__toggle-button-container">
<DButton
@translatedTitle={{this.collapsedResultsTitle}}
@translatedLabel={{this.collapsedResultsTitle}}
@action={{fn
(mut this.collapsedResults)
(not this.collapsedResults)
}}
@class="btn-flat"
@icon={{if this.collapsedResults "chevron-right" "chevron-down"}}
/>
</div>
{{#unless this.collapsedResults}}
<div class="semantic-search__entries">
<SearchResultEntries
@posts={{this.results}}
@highlightQuery={{this.highlightQuery}}
/>
</div>
{{/unless}}
{{else}}
<div class="semantic-search__searching">
{{i18n "discourse_ai.embeddings.semantic_search_results.none"}}
</div>
{{/if}}
{{/if}}
</div>
</div>
{{/if}}

View File

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

View File

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

View File

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

View File

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

View File

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