diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 9a9830af57..35dbf6a155 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -171,7 +171,7 @@ const AdvancedNodeSearchDialog = ({ discourseNodes.map((node) => [node.type, node]), ); - const results = useAdvancedNodeSearchResults({ + const { results, isSearching } = useAdvancedNodeSearchResults({ debouncedSearchTerm, selectedNodeTypeIds, sort, @@ -291,7 +291,7 @@ const AdvancedNodeSearchDialog = ({ const contentState = indexError ? "error" - : isIndexLoading + : isIndexLoading || isSearching ? "indexing" : !debouncedSearchTerm && selectedNodeTypeIds.length === 0 ? "initial" diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index 463e98e211..bd48b7f05b 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -226,7 +226,7 @@ export const AdvancedSearchSidebarPanel = ({ }; }, [discourseNodes]); - const results = useAdvancedNodeSearchResults({ + const { results, isSearching } = useAdvancedNodeSearchResults({ debouncedSearchTerm, selectedNodeTypeIds, sort, @@ -306,11 +306,14 @@ export const AdvancedSearchSidebarPanel = ({ results={results} /> )} - {debouncedSearchTerm && !results.length && !isIndexLoading && ( -

- No matches. Try another keyword. -

- )} + {debouncedSearchTerm && + !results.length && + !isIndexLoading && + !isSearching && ( +

+ No matches. Try another keyword. +

+ )} )} diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts index d3a6828646..9de2a6adee 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts @@ -1,8 +1,11 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import MiniSearch from "minisearch"; +import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import { searchDiscourseNodes } from "~/utils/searchDiscourseNodes"; import { - searchIndexedNodes, + searchDiscourseNodesWithMiniSearch, sortSearchResults, + type ScoredSearchResult, type SearchResult, type SortConfig, } from "./utils"; @@ -32,37 +35,110 @@ export const useAdvancedNodeSearchResults = ({ searchIndex, dockedQuery, dockedResults, -}: UseAdvancedNodeSearchResultsArgs): SearchResult[] => - useMemo(() => { - if (!debouncedSearchTerm) return []; - - const isDockedQuery = +}: UseAdvancedNodeSearchResultsArgs): { + results: SearchResult[]; + isSearching: boolean; +} => { + // An empty persisted result set is not a usable cache: the docked panel also + // persists the transient [] published while an async search is in flight. + const hasUsableDockedResults = useMemo( + () => dockedQuery !== undefined && - debouncedSearchTerm.trim() === dockedQuery.trim(); + debouncedSearchTerm.trim() === dockedQuery.trim() && + !!dockedResults && + dockedResults.length > 0, + [debouncedSearchTerm, dockedQuery, dockedResults], + ); + + const [unsortedScoredResults, setUnsortedScoredResults] = useState< + ScoredSearchResult[] + >([]); + const [isSearching, setIsSearching] = useState(false); - if (isDockedQuery && dockedResults) { - return dockedResults; + useEffect(() => { + if (hasUsableDockedResults) { + setUnsortedScoredResults([]); + setIsSearching(false); + return; + } + + if (!debouncedSearchTerm) { + setUnsortedScoredResults([]); + setIsSearching(false); + return; } if (isIndexLoading || indexError || !searchIndex) { - return []; + setUnsortedScoredResults([]); + setIsSearching(false); + return; } - const scoredHits = searchIndexedNodes({ - miniSearch: searchIndex.miniSearch, - allResults: searchIndex.allResults, - searchTerm: debouncedSearchTerm, - typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined, - }); + setUnsortedScoredResults([]); + setIsSearching(true); + let cancelled = false; + const typeFilter = selectedNodeTypeIds.length + ? selectedNodeTypeIds + : undefined; + const discourseNodes = getDiscourseNodes().filter( + (node) => + node.backedBy === "user" && + (!typeFilter || typeFilter.includes(node.type)), + ); + const resultsByUid = new Map( + searchIndex.allResults.map((result) => [result.uid, result]), + ); + + const runMiniSearch = (): ScoredSearchResult[] => + searchDiscourseNodesWithMiniSearch({ + miniSearch: searchIndex.miniSearch, + allResults: searchIndex.allResults, + searchTerm: debouncedSearchTerm, + typeFilter, + }); - return sortSearchResults({ hits: scoredHits, sort }); + void searchDiscourseNodes({ + nodeTypes: discourseNodes, + query: debouncedSearchTerm, + resultsByUid, + runMiniSearch, + }) + .then((results) => { + if (cancelled) return; + setUnsortedScoredResults(results); + }) + .catch(() => { + if (cancelled) return; + try { + setUnsortedScoredResults(runMiniSearch()); + } catch { + setUnsortedScoredResults([]); + } + }) + .finally(() => { + if (!cancelled) setIsSearching(false); + }); + + return () => { + cancelled = true; + }; }, [ debouncedSearchTerm, - dockedQuery, - dockedResults, + hasUsableDockedResults, indexError, isIndexLoading, searchIndex, selectedNodeTypeIds, - sort, ]); + + const results = useMemo( + () => sortSearchResults({ scoredResults: unsortedScoredResults, sort }), + [unsortedScoredResults, sort], + ); + + if (hasUsableDockedResults && dockedResults) { + return { results: dockedResults, isSearching: false }; + } + + return { results, isSearching }; +}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts index c3040eadf9..82c7a4be13 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts @@ -9,9 +9,19 @@ import { getPulledDiscourseNodeUid, queryDiscourseNodesByFormat, } from "~/utils/discourseNodeSearch"; +import { + MAX_RESULTS, + type ScoredSearchResult, + type SearchResult, +} from "~/utils/discourseNodeSearchTypes"; + +export type { + ScoredSearchResult, + SearchResult, +} from "~/utils/discourseNodeSearchTypes"; export const DEBOUNCE_MS = 250; -export const MAX_RESULTS = 50; +export { MAX_RESULTS }; export const getSearchKeywords = (searchTerm: string): string[] => searchTerm.split(/\s+/).filter(Boolean); @@ -36,17 +46,6 @@ export const SORT_FIELD_LABELS: Record = { author: "Author", }; -export type SearchResult = { - uid: string; - title: string; - type: string; - nodeTypeLabel: string; - excerpt: string; - createdAt: string; - lastModified: string; - authorName: string; -}; - export type DockedSearchState = { query: string; results: SearchResult[]; @@ -56,11 +55,6 @@ export type DockedSearchState = { dgSearchId?: string; }; -export type ScoredSearchHit = { - result: SearchResult; - score: number; -}; - type MiniSearchDocument = SearchResult & { id: string; }; @@ -214,23 +208,28 @@ export const isNonDefaultSort = (sort: SortConfig): boolean => sort.direction !== DEFAULT_SORT_CONFIG.direction; export const sortSearchResults = ({ - hits, + scoredResults, sort, }: { - hits: ScoredSearchHit[]; + scoredResults: ScoredSearchResult[]; sort: SortConfig; }): SearchResult[] => { - const sorted = [...hits]; + // Semantic and MiniSearch results already arrive in their provider's relevance + // order, so relevance sorting only has to reverse it for ascending. + if (sort.field === "relevance") { + const ordered = + sort.direction === "asc" ? [...scoredResults].reverse() : scoredResults; + return ordered.map((entry) => entry.result); + } - sorted.sort((aHit, bHit) => { - const a = aHit.result; - const b = bHit.result; + const sorted = [...scoredResults]; + + sorted.sort((aEntry, bEntry) => { + const a = aEntry.result; + const b = bEntry.result; let comparison = 0; switch (sort.field) { - case "relevance": - comparison = compareNumbers(aHit.score, bHit.score, sort.direction); - break; case "alphabetical": comparison = compareStrings( getSortableTitle(a), @@ -259,10 +258,10 @@ export const sortSearchResults = ({ return comparison || a.uid.localeCompare(b.uid); }); - return sorted.map((hit) => hit.result); + return sorted.map((entry) => entry.result); }; -export const searchIndexedNodes = ({ +export const searchDiscourseNodesWithMiniSearch = ({ miniSearch, allResults, searchTerm, @@ -272,7 +271,7 @@ export const searchIndexedNodes = ({ allResults: SearchResult[]; searchTerm: string; typeFilter?: string[]; -}): ScoredSearchHit[] => { +}): ScoredSearchResult[] => { const resultsByUid = new Map( allResults.map((result) => [result.uid, result]), ); @@ -291,7 +290,11 @@ export const searchIndexedNodes = ({ .map((result) => { const searchResult = resultsByUid.get(String(result.id)); if (!searchResult) return null; - return { result: searchResult, score: result.score }; + return { + result: searchResult, + score: result.score, + source: "miniSearch", + }; }) - .filter((hit): hit is ScoredSearchHit => !!hit); + .filter((entry): entry is ScoredSearchResult => !!entry); }; diff --git a/apps/roam/src/utils/__tests__/discourseNodeSearchTypes.test.ts b/apps/roam/src/utils/__tests__/discourseNodeSearchTypes.test.ts new file mode 100644 index 0000000000..3e4df750d0 --- /dev/null +++ b/apps/roam/src/utils/__tests__/discourseNodeSearchTypes.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { sortSearchResults } from "~/components/AdvancedNodeSearchDialog/utils"; +import { + combineSemanticAndMiniSearchResults, + MAX_RESULTS, + type DiscourseNodeSearchSource, + type ScoredSearchResult, +} from "~/utils/discourseNodeSearchTypes"; + +const makeEntry = ({ + uid, + score, + source, +}: { + uid: string; + score: number; + source: DiscourseNodeSearchSource; +}): ScoredSearchResult => ({ + result: { + uid, + title: `[[CLM]] - ${uid}`, + type: "clm", + nodeTypeLabel: "Claim", + excerpt: "", + createdAt: "", + lastModified: "", + authorName: "", + }, + score, + source, +}); + +const relevanceOrder = ( + scoredResults: ScoredSearchResult[], + direction: "asc" | "desc" = "desc", +): string[] => + sortSearchResults({ + scoredResults, + sort: { field: "relevance", direction }, + }).map((result) => result.uid); + +describe("combineSemanticAndMiniSearchResults", () => { + it("drops duplicate uids from both sources", () => { + const combined = combineSemanticAndMiniSearchResults({ + semantic: [ + makeEntry({ uid: "a", score: 0.9, source: "semantic" }), + makeEntry({ uid: "a", score: 0.7, source: "semantic" }), + makeEntry({ uid: "b", score: 0.5, source: "semantic" }), + ], + miniSearch: [ + makeEntry({ uid: "b", score: 10, source: "miniSearch" }), + makeEntry({ uid: "c", score: 8, source: "miniSearch" }), + ], + }); + + expect(combined.map((entry) => entry.result.uid)).toEqual(["a", "b", "c"]); + expect(combined[0]?.score).toBe(0.9); + }); +}); + +describe("sortSearchResults relevance", () => { + it("preserves the order the providers returned, ignoring score", () => { + const scoredResults = [ + makeEntry({ uid: "roamFirst", score: 0.12, source: "semantic" }), + makeEntry({ uid: "roamSecond", score: 0.91, source: "semantic" }), + makeEntry({ uid: "miniFirst", score: 9, source: "miniSearch" }), + makeEntry({ uid: "miniSecond", score: 2, source: "miniSearch" }), + ]; + + expect(relevanceOrder(scoredResults)).toEqual([ + "roamFirst", + "roamSecond", + "miniFirst", + "miniSecond", + ]); + }); + + it("does not reorder tied scores by uid", () => { + const scoredResults = [ + makeEntry({ uid: "zzz", score: 0, source: "semantic" }), + makeEntry({ uid: "aaa", score: 0, source: "semantic" }), + ]; + + expect(relevanceOrder(scoredResults)).toEqual(["zzz", "aaa"]); + }); + + it("reverses the provider order when sorting ascending", () => { + const scoredResults = [ + makeEntry({ uid: "roamFirst", score: 0.12, source: "semantic" }), + makeEntry({ uid: "miniFirst", score: 9, source: "miniSearch" }), + ]; + + expect(relevanceOrder(scoredResults, "asc")).toEqual([ + "miniFirst", + "roamFirst", + ]); + }); + + it("does not mutate the input array", () => { + const scoredResults = [ + makeEntry({ uid: "a", score: 1, source: "semantic" }), + makeEntry({ uid: "b", score: 2, source: "miniSearch" }), + ]; + + relevanceOrder(scoredResults, "asc"); + + expect(scoredResults.map((entry) => entry.result.uid)).toEqual(["a", "b"]); + }); +}); + +describe("combined semantic + MiniSearch ordering", () => { + it("keeps every semantic result, in order, ahead of MiniSearch results in order", () => { + const semantic = ["s1", "s2", "s3"].map((uid, i) => + makeEntry({ uid, score: i, source: "semantic" }), + ); + const miniSearch = ["m1", "m2"].map((uid, i) => + makeEntry({ uid, score: 100 - i, source: "miniSearch" }), + ); + + const combined = combineSemanticAndMiniSearchResults({ + semantic, + miniSearch, + }); + + expect(relevanceOrder(combined)).toEqual(["s1", "s2", "s3", "m1", "m2"]); + }); + + it("does not cap, so callers stay responsible for MAX_RESULTS", () => { + const semantic = Array.from({ length: 200 }, (_, i) => + makeEntry({ uid: `s${i}`, score: 0, source: "semantic" }), + ); + + const combined = combineSemanticAndMiniSearchResults({ + semantic, + miniSearch: [makeEntry({ uid: "m1", score: 9, source: "miniSearch" })], + }); + + // searchDiscourseNodes applies the cap; see searchDiscourseNodes.test.ts. + expect(combined).toHaveLength(201); + expect(combined.length).toBeGreaterThan(MAX_RESULTS); + }); +}); diff --git a/apps/roam/src/utils/__tests__/searchDiscourseNodes.test.ts b/apps/roam/src/utils/__tests__/searchDiscourseNodes.test.ts new file mode 100644 index 0000000000..9163b806e2 --- /dev/null +++ b/apps/roam/src/utils/__tests__/searchDiscourseNodes.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type SemanticProviderPayload = { + filteredResults: { uid: string; text: string }[]; + filteredResultCount: number; +}; + +const runRoamSemanticSearch = + vi.fn<(args: unknown) => Promise>(); +vi.mock("~/utils/discourseNodeSearchProviders", () => ({ + runRoamSemanticSearch: (args: unknown) => runRoamSemanticSearch(args), +})); + +import { searchDiscourseNodes } from "~/utils/searchDiscourseNodes"; +import { + MAX_RESULTS, + type ScoredSearchResult, + type SearchResult, +} from "~/utils/discourseNodeSearchTypes"; + +const originalWindow = (globalThis as { window?: unknown }).window; + +const setSemanticSearchEnabled = (enabled: boolean): void => { + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { data: { semanticSearchEnabled: () => enabled } }, + }; +}; + +// Roam returns hits without a score, so semantic entries carry score 0. +const semanticHits = (count: number): { uid: string; text: string }[] => + Array.from({ length: count }, (_, i) => ({ + uid: `s${i}`, + text: `[[CLM]] - semantic ${i}`, + })); + +const miniSearchEntry = (uid: string): ScoredSearchResult => ({ + result: { + uid, + title: `[[CLM]] - ${uid}`, + type: "clm", + nodeTypeLabel: "Claim", + excerpt: "", + createdAt: "", + lastModified: "", + authorName: "", + }, + score: 9, + source: "miniSearch", +}); + +const search = ( + runMiniSearch: () => ScoredSearchResult[], +): Promise => + searchDiscourseNodes({ + nodeTypes: [], + query: "governance", + resultsByUid: new Map(), + runMiniSearch, + }); + +beforeEach(() => { + runRoamSemanticSearch.mockReset(); + setSemanticSearchEnabled(true); +}); + +afterEach(() => { + (globalThis as { window?: unknown }).window = originalWindow; +}); + +describe("searchDiscourseNodes", () => { + it("caps the semantic path at MAX_RESULTS", async () => { + const filteredResults = semanticHits(200); + runRoamSemanticSearch.mockResolvedValue({ + filteredResults, + filteredResultCount: filteredResults.length, + }); + + const results = await search(() => []); + + expect(results).toHaveLength(MAX_RESULTS); + expect(results[0]?.result.uid).toBe("s0"); + }); + + it("skips MiniSearch once semantic returns enough discourse nodes", async () => { + const filteredResults = semanticHits(5); + runRoamSemanticSearch.mockResolvedValue({ + filteredResults, + filteredResultCount: filteredResults.length, + }); + const runMiniSearch = vi.fn(() => [miniSearchEntry("m1")]); + + const results = await search(runMiniSearch); + + expect(runMiniSearch).not.toHaveBeenCalled(); + expect(results.map((r) => r.result.uid)).toEqual([ + "s0", + "s1", + "s2", + "s3", + "s4", + ]); + }); + + it("appends MiniSearch results after sparse semantic results", async () => { + const filteredResults = semanticHits(2); + runRoamSemanticSearch.mockResolvedValue({ + filteredResults, + filteredResultCount: filteredResults.length, + }); + + const results = await search(() => [ + miniSearchEntry("m1"), + miniSearchEntry("m2"), + ]); + + expect(results.map((r) => r.result.uid)).toEqual(["s0", "s1", "m1", "m2"]); + }); + + it("falls back to MiniSearch when the semantic provider throws", async () => { + runRoamSemanticSearch.mockRejectedValue(new Error("semantic unavailable")); + + const results = await search(() => [miniSearchEntry("m1")]); + + expect(results.map((r) => r.result.uid)).toEqual(["m1"]); + }); + + it("degrades to an empty list when MiniSearch itself throws", async () => { + setSemanticSearchEnabled(false); + + await expect( + search(() => { + throw new Error("minisearch exploded"); + }), + ).resolves.toEqual([]); + }); + + it("returns nothing for a blank query without calling either provider", async () => { + const runMiniSearch = vi.fn(() => [miniSearchEntry("m1")]); + + const results = await searchDiscourseNodes({ + nodeTypes: [], + query: " ", + resultsByUid: new Map(), + runMiniSearch, + }); + + expect(results).toEqual([]); + expect(runMiniSearch).not.toHaveBeenCalled(); + expect(runRoamSemanticSearch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/discourseNodeSearchProviders.ts b/apps/roam/src/utils/discourseNodeSearchProviders.ts index a34d1be34f..24f7595412 100644 --- a/apps/roam/src/utils/discourseNodeSearchProviders.ts +++ b/apps/roam/src/utils/discourseNodeSearchProviders.ts @@ -249,7 +249,7 @@ export const searchSemanticNodeTitles = async ({ ); }; -const runRoamSemanticSearch = async ({ +export const runRoamSemanticSearch = async ({ nodeTypes, query, }: { diff --git a/apps/roam/src/utils/discourseNodeSearchTypes.ts b/apps/roam/src/utils/discourseNodeSearchTypes.ts new file mode 100644 index 0000000000..516eee4721 --- /dev/null +++ b/apps/roam/src/utils/discourseNodeSearchTypes.ts @@ -0,0 +1,78 @@ +export type DiscourseNodeSearchSource = "semantic" | "miniSearch"; + +// Caps what any search path returns, semantic included. +export const MAX_RESULTS = 50; + +export type SearchResult = { + uid: string; + title: string; + type: string; + nodeTypeLabel: string; + excerpt: string; + createdAt: string; + lastModified: string; + authorName: string; +}; + +// score and source are carried for diagnostics and future rank fusion; ordering +// reuses each provider's returned order instead of re-deriving it from score. +export type ScoredSearchResult = { + result: SearchResult; + score: number; + source: DiscourseNodeSearchSource; +}; + +export const combineSemanticAndMiniSearchResults = ({ + semantic, + miniSearch, +}: { + semantic: ScoredSearchResult[]; + miniSearch: ScoredSearchResult[]; +}): ScoredSearchResult[] => { + const seenUids = new Set(); + const combined: ScoredSearchResult[] = []; + + [...semantic, ...miniSearch].forEach((entry) => { + if (seenUids.has(entry.result.uid)) return; + seenUids.add(entry.result.uid); + combined.push(entry); + }); + + return combined; +}; + +export const toScoredSearchResultFromSemantic = ({ + uid, + title, + type, + nodeTypeLabel, + score, + resultsByUid, +}: { + uid: string; + title: string; + type?: string; + nodeTypeLabel?: string; + score: number; + resultsByUid: Map; +}): ScoredSearchResult => { + const indexedResult = resultsByUid.get(uid); + if (indexedResult) { + return { result: indexedResult, score, source: "semantic" }; + } + + return { + result: { + uid, + title, + type: type || "", + nodeTypeLabel: nodeTypeLabel || "", + excerpt: "", + createdAt: "", + lastModified: "", + authorName: "Unknown", + }, + score, + source: "semantic", + }; +}; diff --git a/apps/roam/src/utils/searchDiscourseNodes.ts b/apps/roam/src/utils/searchDiscourseNodes.ts new file mode 100644 index 0000000000..9be5bacd15 --- /dev/null +++ b/apps/roam/src/utils/searchDiscourseNodes.ts @@ -0,0 +1,81 @@ +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; +import { + combineSemanticAndMiniSearchResults, + MAX_RESULTS, + toScoredSearchResultFromSemantic, + type ScoredSearchResult, + type SearchResult, +} from "~/utils/discourseNodeSearchTypes"; +import { runRoamSemanticSearch } from "~/utils/discourseNodeSearchProviders"; + +export const SEMANTIC_SEARCH_MIN_DISCOURSE_RESULTS = 5; + +export type { + DiscourseNodeSearchSource, + ScoredSearchResult, + SearchResult, +} from "~/utils/discourseNodeSearchTypes"; + +export const isRoamSemanticSearchEnabled = (): boolean => + window.roamAlphaAPI.data.semanticSearchEnabled(); + +const runMiniSearchSafely = ( + runMiniSearch: () => ScoredSearchResult[], +): ScoredSearchResult[] => { + try { + return runMiniSearch(); + } catch { + return []; + } +}; + +export const searchDiscourseNodes = async ({ + nodeTypes, + query, + resultsByUid, + runMiniSearch, +}: { + nodeTypes: DiscourseNode[]; + query: string; + resultsByUid: Map; + runMiniSearch: () => ScoredSearchResult[]; +}): Promise => { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + + let results: ScoredSearchResult[]; + + if (!isRoamSemanticSearchEnabled()) { + results = runMiniSearchSafely(runMiniSearch); + } else { + try { + const providerResult = await runRoamSemanticSearch({ + nodeTypes, + query: trimmedQuery, + }); + const semanticResults = providerResult.filteredResults.map((item) => + toScoredSearchResultFromSemantic({ + uid: item.uid, + title: item.text, + type: item.type, + nodeTypeLabel: item.nodeTypeLabel, + score: item.score ?? 0, + resultsByUid, + }), + ); + + results = + providerResult.filteredResultCount >= + SEMANTIC_SEARCH_MIN_DISCOURSE_RESULTS + ? semanticResults + : combineSemanticAndMiniSearchResults({ + semantic: semanticResults, + miniSearch: runMiniSearchSafely(runMiniSearch), + }); + } catch { + results = runMiniSearchSafely(runMiniSearch); + } + } + + return results.slice(0, MAX_RESULTS); +};