mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-02-16 16:34:45 +00:00
DEV: Mix semantic search results with normal results (#278)
This commit is contained in:
parent
d1f21c78f1
commit
f7277d244e
@ -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>
|
||||||
|
}
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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}}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
9
assets/javascripts/initializers/ai-semantic-search.js
Normal file
9
assets/javascripts/initializers/ai-semantic-search.js
Normal 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"],
|
||||||
|
});
|
||||||
|
});
|
@ -8,13 +8,26 @@
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|
||||||
.semantic-search {
|
.semantic-search {
|
||||||
|
&__searching {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.in-progress {
|
||||||
|
.semantic-search__searching-text {
|
||||||
|
color: var(--primary-medium);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__searching-text {
|
&__searching-text {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__indicator-wave {
|
&__indicator-wave {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
color: var(--primary-medium);
|
||||||
}
|
}
|
||||||
&__indicator-dot {
|
&__indicator-dot {
|
||||||
display: inline-block;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -80,7 +80,8 @@ en:
|
|||||||
semantic_search: "Topics (Semantic)"
|
semantic_search: "Topics (Semantic)"
|
||||||
semantic_search_loading: "Searching for more results using AI"
|
semantic_search_loading: "Searching for more results using AI"
|
||||||
semantic_search_results:
|
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."
|
none: "Sorry, our AI search found no matching topics."
|
||||||
|
|
||||||
ai_bot:
|
ai_bot:
|
||||||
|
53
spec/system/embeddings/semantic_search_spec.rb
Normal file
53
spec/system/embeddings/semantic_search_spec.rb
Normal 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
|
Loading…
x
Reference in New Issue
Block a user