diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index c95228c963c..3047b9a79de 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -1,12 +1,13 @@ 'use client' import { useEffect, useMemo, useState } from 'react' -import { Chip } from '@sim/emcn' +import { Chip, ChipLink } from '@sim/emcn' import { useQueryStates } from 'nuqs' import { ActivityStatus } from '@/components/ui/activity-status' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' import { useSession } from '@/lib/auth/client' import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope' +import { organizationRoutes } from '@/lib/navigation/paths' import { SearchFilters } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters' import { groupResultsByDocument, @@ -150,6 +151,25 @@ function LiveSearchResults({ Search found no results.

)} + {!isFetching && data?.live && accounts.length === 0 && ( +
+

+ {filters.source + ? 'You haven’t connected this app.' + : 'Connect your apps to search them.'} +

+ + Connect apps + +
+ )} {accounts.some((account) => ['unavailable', 'timeout', 'rate_limited', 'reconnect'].includes(account.status) ) && ( diff --git a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts index 8ae1042bd73..ae675215799 100644 --- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts @@ -246,6 +246,19 @@ describe('Assistant retrieval tools', () => { ) expect(JSON.stringify(result)).not.toContain(secret) }) + it('tells the model a search no account served searched nothing, not to narrow and retry', async () => { + mocks.search.mockResolvedValueOnce({ + query: 'roadmap', + retrieval: { status: 'partial', timedOutLegs: [] }, + results: [], + live: { backend: 'live', accounts: [], guidance: 'Nothing was searched: connect one.' }, + }) + const result = await searchWorkspaceServerTool.execute({ query: 'roadmap' }, context) + expect(result).toMatchObject({ + success: true, + message: expect.stringMatching(/^Nothing was searched: (?!.*more specific query)/), + }) + }) it('rejects untrusted contexts, incompatible sources and out-of-scope document reads', async () => { expect( await searchWorkspaceServerTool.execute( diff --git a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts index 3b04bcb713c..4da4825ad9d 100644 --- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts @@ -85,9 +85,13 @@ export const searchWorkspaceServerTool: BaseServerTool = { ...liveInput, workspaceId: scope.workspaceId, }) + /** Every searched account reports a status, so none means no account could serve it. */ + const nothingSearched = data.live?.accounts.length === 0 return { success: true, - message: `${data.retrieval.status === 'partial' ? 'Search coverage is incomplete. Continue with a more specific query or source filter; these results cannot establish absence or completeness. ' : ''}Found ${data.results.length} live results. Read a documentId when its passage does not answer the question or more context is needed. ${CITATION_INSTRUCTION}`, + message: nothingSearched + ? 'Nothing was searched: no connected account can serve this search, so it says nothing about what exists. Follow the first line of live.guidance instead of retrying the same search.' + : `${data.retrieval.status === 'partial' ? 'Search coverage is incomplete. Continue with a more specific query or source filter; these results cannot establish absence or completeness. ' : ''}Found ${data.results.length} live results. Read a documentId when its passage does not answer the question or more context is needed. ${CITATION_INSTRUCTION}`, data: { ...data, results: data.results.map((item) => ({ diff --git a/apps/sim/lib/sim-search/live/application.test.ts b/apps/sim/lib/sim-search/live/application.test.ts index b69d53ff555..5bed7b39f05 100644 --- a/apps/sim/lib/sim-search/live/application.test.ts +++ b/apps/sim/lib/sim-search/live/application.test.ts @@ -666,6 +666,28 @@ describe('authorized live retrieval', () => { expect(result.live?.accounts.map((status) => status.queryIndex)).toEqual([0, 1]) expect(result.live?.guidance).toBe('Live coverage: slack') }) + it('reports a search that reached no account as nothing searched, not as no results', async () => { + mocks.accounts.mockResolvedValue([]) + const result = await searchLiveKnowledge.execute({ principal, input }) + expect(mocks.search).not.toHaveBeenCalled() + expect(result.results).toEqual([]) + expect(result.retrieval.status).toBe('partial') + expect(result.live?.accounts).toEqual([]) + expect(result.live?.guidance).toMatch( + /^Nothing was searched: you have no connected sources here\. Connect your accounts at \S*\/workspace\/workspace\/integrations,/ + ) + }) + it('names the filtered source when only other sources are connected', async () => { + const result = await searchLiveKnowledge.execute({ + principal, + input: { ...input, filters: { source: 'slack' } }, + }) + expect(mocks.search).not.toHaveBeenCalled() + expect(result.retrieval.status).toBe('partial') + expect(result.live?.guidance).toMatch( + /^Nothing was searched: you have no connected slack account here\. .* or search without the source filter\./ + ) + }) describe('result previews', () => { const excerpts = ['a', 'b', 'c', 'd'].map((id) => ({ ...document, diff --git a/apps/sim/lib/sim-search/live/application.ts b/apps/sim/lib/sim-search/live/application.ts index 78e9817b53f..309c92ddb54 100644 --- a/apps/sim/lib/sim-search/live/application.ts +++ b/apps/sim/lib/sim-search/live/application.ts @@ -32,6 +32,7 @@ import { mapWithConcurrency, waitWithAbort, } from '@/lib/core/utils/concurrency' +import { getBaseUrl } from '@/lib/core/utils/urls' import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' @@ -49,6 +50,7 @@ import { import { RRF_K } from '@/lib/knowledge/search/recency' import { matchPassage } from '@/lib/knowledge/search/snippet' import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url' +import { organizationRoutes } from '@/lib/navigation/paths' import { connectorDisplayName } from '@/lib/sim-search/connectors' import { type LiveAccountSession, @@ -440,6 +442,24 @@ function compareAccounts(left: LiveAccount, right: LiveAccount): number { ) } +/** + * Why a plain search that reached no account returned nothing, and where the caller connects one; + * an empty result would otherwise read as a search that found nothing. + */ +function nothingSearchedGuidance( + owner: ResourceOwner, + source: string | undefined, + hasOtherAccounts: boolean +): string { + const path = owner.organizationId + ? organizationRoutes(owner.organizationId).integrations + : `/workspace/${owner.workspaceId}/integrations` + const connect = new URL(path, getBaseUrl()).href + return source && hasOtherAccounts + ? `Nothing was searched: you have no connected ${source} account here. Connect one at ${connect}, or search without the source filter.` + : `Nothing was searched: you have no connected sources here. Connect your accounts at ${connect}, then search again.` +} + const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.search, resolveContext: ({ input }: { input: LiveSearchInput }) => resolveKnowledgeOwnerContext(input), @@ -503,6 +523,7 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ nativesFor(account).length > 0 ) .sort(compareAccounts) + const nothingSearched = !queries && eligible.length === 0 const selected = eligible.slice(0, MAX_ACCOUNTS) const openingAccount = createConcurrencyLimit(ACCOUNT_OPEN_CONCURRENCY) annotateSearchDiagnostics({ @@ -909,9 +930,14 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ } }) /** Live coverage guides the providers this call searched or tried to search. */ - const guidance = liveSearchGuidance( - accounts.filter((account) => account.status !== 'reconnect').map(({ provider }) => provider) - ) + const guidance = [ + ...(nothingSearched + ? [nothingSearchedGuidance(input, filters?.source, allAccounts.length > 0)] + : []), + liveSearchGuidance( + accounts.filter((account) => account.status !== 'reconnect').map(({ provider }) => provider) + ), + ].join('\n') for (const account of eligible.slice(MAX_ACCOUNTS)) { for (const { queryIndex } of nativesFor(account)) accounts.push({ @@ -934,9 +960,10 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ message: 'No connection with this provider is configured and approved in this scope.', }) } - const retrievalStatus = accounts.some((account) => account.status !== 'ok') - ? 'partial' - : 'complete' + const retrievalStatus = + nothingSearched || accounts.some((account) => account.status !== 'ok') + ? 'partial' + : 'complete' annotateSearchDiagnostics({ resultCount: Math.min(ranked.length, input.topK), retrievalStatus, diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index 7a6fe15f8a3..df145b026e7 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -244,6 +244,13 @@ describe('Jira any-term search', () => { const page = await searchAtlassian(api, 'confluence', { ...query, textMatch: 'any' }) expect(page.documents.map((document) => document.id)).toEqual(['5']) }) + + it('searches nothing for a Confluence query with no word left, which would list every page', async () => { + const api = client({}) + const page = await searchAtlassian(api, 'confluence', { ...query, query: String.raw` :\ ` }) + expect(page.documents).toEqual([]) + expect(api.json).not.toHaveBeenCalled() + }) }) describe('Confluence plain search ranking', () => { diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index 4283b3eddf2..aae29c76799 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -132,6 +132,12 @@ export async function searchAtlassian( provider: 'jira' | 'confluence', input: NativeSearchInput ): Promise { + const text = nativeText(input) + const confluenceClause = + provider === 'confluence' && text && !input.native?.query + ? confluenceTextClause(text, input.textMatch) + : '' + if (confluenceClause === undefined) return { documents: [] } const allSites = (await sites(client)).filter( (site) => !input.policy?.sites.length || @@ -166,7 +172,6 @@ export async function searchAtlassian( .join(' AND ') || undefined const direction = dateSortDirection(input.filters) const order = direction ? `${field} ${direction.toUpperCase()}` : undefined - const text = nativeText(input) // Jira's any-term JQL matches issues sharing two common words; like minimum-should-match, // keep only issues holding at least half the terms. const relevant = input.textMatch === 'any' && text ? holdsHalfTheTerms(text) : undefined @@ -234,13 +239,15 @@ export async function searchAtlassian( } } const exact = await searchPages( - text ? confluenceTextClause(text, input.textMatch) : '', + confluenceClause, input.native?.cursor && single ? input.native.cursor : undefined ) + const relevance = confluenceTextClause(text, 'any') // An all-terms match larger than a page comes back by raw term matches; rank it by relevance. - if (!text || input.native || order || input.textMatch === 'any' || !exact.next) return exact + if (!relevance || input.native || order || input.textMatch === 'any' || !exact.next) + return exact try { - const ranked = await searchPages(confluenceTextClause(text, 'any')) + const ranked = await searchPages(relevance) return { ...ranked, next: undefined, ranking: 'relevance' } } catch { // A cancelled search stops here; any other failure keeps the all-terms page. diff --git a/apps/sim/lib/sim-search/live/service-session.ts b/apps/sim/lib/sim-search/live/service-session.ts index df68caa9a5a..b30ce33124b 100644 --- a/apps/sim/lib/sim-search/live/service-session.ts +++ b/apps/sim/lib/sim-search/live/service-session.ts @@ -146,6 +146,7 @@ export async function createLiveServiceSession(input: { const query = search.native?.query ?? (search.query ? confluenceTextClause(search.query, search.textMatch) : '') + if (query === undefined) return null return { ...search, native: { provider, ...search.native, query: scopeAtlassianQuery(query, scope) }, diff --git a/apps/sim/lib/sim-search/live/text-match.test.ts b/apps/sim/lib/sim-search/live/text-match.test.ts index bd05d59e34c..82ac1b41003 100644 --- a/apps/sim/lib/sim-search/live/text-match.test.ts +++ b/apps/sim/lib/sim-search/live/text-match.test.ts @@ -59,6 +59,16 @@ describe('text match clauses', () => { ) }) + it('sends a colon or backslash to Confluence as a word break, which it otherwise rejects', () => { + expect(confluenceTextClause('Security: Weekly Report', 'all')).toBe( + 'text ~ "Security Weekly Report"' + ) + expect(confluenceTextClause(String.raw`C:\builds\out`, 'any')).toBe( + 'siteSearch ~ "C builds out"' + ) + expect(confluenceTextClause(String.raw` :\ `, 'all')).toBeUndefined() + }) + it('falls back to the all-terms clause when fewer than two terms are distinctive', () => { expect(jiraTextClause('how does it work', 'any')).toBe('text ~ "how does it work"') }) diff --git a/apps/sim/lib/sim-search/live/text-match.ts b/apps/sim/lib/sim-search/live/text-match.ts index c5dd8958455..c8a40699012 100644 --- a/apps/sim/lib/sim-search/live/text-match.ts +++ b/apps/sim/lib/sim-search/live/text-match.ts @@ -150,9 +150,16 @@ export function jiraTextClause(text: string, match: TextMatch = 'all'): string { * The text clause for a plain query in Confluence CQL. `any` uses `siteSearch`, Confluence's own * relevance search, which ranks a page with the query's rarer words above the many pages sharing * one common word, where a `text ~` OR query ranks them the other way round. + * + * Confluence answers HTTP 400 to the escaped colon or backslash this phrase would carry, and its + * analyzer splits words on both, so they go as spaces. `undefined` means nothing is left to match: + * Confluence would list every page for such a phrase, so callers search nothing instead. */ -export function confluenceTextClause(text: string, match: TextMatch = 'all'): string { - return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(text)}` +export function confluenceTextClause(text: string, match: TextMatch = 'all'): string | undefined { + const searchable = text.replace(/[:\\]/g, ' ').trim() + return searchable + ? `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}` + : undefined } /** How many distinct terms occur as whole words in the text. */ diff --git a/apps/sim/scripts/test-organization-search-e2e.ts b/apps/sim/scripts/test-organization-search-e2e.ts index a4a6b7bbe53..67f8350052e 100644 --- a/apps/sim/scripts/test-organization-search-e2e.ts +++ b/apps/sim/scripts/test-organization-search-e2e.ts @@ -445,7 +445,15 @@ try { const body = toRecord(response.request().postDataJSON()) assert.equal(body.organizationId, organizationId) assert.equal(body.query, 'Launch notes') - await expect(page.getByText('Search found no results.', { exact: true })).toBeVisible() + // The seeded member has connected no account, so nothing was searched. + await expect( + page.getByText('Connect your apps to search them.', { exact: true }) + ).toBeVisible() + await expect(page.getByText('Search found no results.', { exact: true })).toHaveCount(0) + await expect(page.getByRole('link', { name: 'Connect apps' })).toHaveAttribute( + 'href', + `${root}/integrations` + ) await expect(page).toHaveURL( (url) => url.pathname === `${root}/search` && url.searchParams.get('q') === 'Launch notes' )