Skip to content

Commit 10a9d8a

Browse files
authored
fix(search): keep Confluence searchable for colon queries and say when nothing was searched (#9694)
* fix(search): keep Confluence searchable for colon queries and say when nothing was searched Confluence rejects a CQL text query holding a colon or backslash with HTTP 400, even escaped, which dropped it from those searches; both now go as word breaks. A plain live search that reaches no connected account is reported as partial with guidance naming where to connect one, and the results view offers the connection instead of claiming no results. * fix(search): search nothing for a Confluence query with no words and stop the Assistant retrying unsearchable searches A query left with no words after the colon and backslash become spaces would list every Confluence page, so it now searches nothing on both the member and service-account paths. The Assistant search tool reports a search no account served as nothing searched instead of advising a narrower retry, and the organization search E2E asserts the connect prompt the unconnected member sees. * fix(search): let the guidance alone say how to recover from a search nothing served
1 parent ff5744c commit 10a9d8a

11 files changed

Lines changed: 141 additions & 15 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
'use client'
22

33
import { useEffect, useMemo, useState } from 'react'
4-
import { Chip } from '@sim/emcn'
4+
import { Chip, ChipLink } from '@sim/emcn'
55
import { useQueryStates } from 'nuqs'
66
import { ActivityStatus } from '@/components/ui/activity-status'
77
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
88
import { useSession } from '@/lib/auth/client'
99
import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope'
10+
import { organizationRoutes } from '@/lib/navigation/paths'
1011
import { SearchFilters } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters'
1112
import {
1213
groupResultsByDocument,
@@ -150,6 +151,25 @@ function LiveSearchResults({
150151
Search found no results.
151152
</p>
152153
)}
154+
{!isFetching && data?.live && accounts.length === 0 && (
155+
<div className='flex items-center gap-2 px-2 py-2'>
156+
<p role='status' className='text-[var(--text-muted)] text-caption'>
157+
{filters.source
158+
? 'You haven’t connected this app.'
159+
: 'Connect your apps to search them.'}
160+
</p>
161+
<ChipLink
162+
variant='border'
163+
href={
164+
scope.kind === 'organization'
165+
? organizationRoutes(scope.organizationId).integrations
166+
: `/workspace/${scope.workspaceId}/integrations`
167+
}
168+
>
169+
Connect apps
170+
</ChipLink>
171+
</div>
172+
)}
153173
{accounts.some((account) =>
154174
['unavailable', 'timeout', 'rate_limited', 'reconnect'].includes(account.status)
155175
) && (

‎apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,19 @@ describe('Assistant retrieval tools', () => {
246246
)
247247
expect(JSON.stringify(result)).not.toContain(secret)
248248
})
249+
it('tells the model a search no account served searched nothing, not to narrow and retry', async () => {
250+
mocks.search.mockResolvedValueOnce({
251+
query: 'roadmap',
252+
retrieval: { status: 'partial', timedOutLegs: [] },
253+
results: [],
254+
live: { backend: 'live', accounts: [], guidance: 'Nothing was searched: connect one.' },
255+
})
256+
const result = await searchWorkspaceServerTool.execute({ query: 'roadmap' }, context)
257+
expect(result).toMatchObject({
258+
success: true,
259+
message: expect.stringMatching(/^Nothing was searched: (?!.*more specific query)/),
260+
})
261+
})
249262
it('rejects untrusted contexts, incompatible sources and out-of-scope document reads', async () => {
250263
expect(
251264
await searchWorkspaceServerTool.execute(

‎apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,13 @@ export const searchWorkspaceServerTool: BaseServerTool = {
8585
...liveInput,
8686
workspaceId: scope.workspaceId,
8787
})
88+
/** Every searched account reports a status, so none means no account could serve it. */
89+
const nothingSearched = data.live?.accounts.length === 0
8890
return {
8991
success: true,
90-
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}`,
92+
message: nothingSearched
93+
? '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.'
94+
: `${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}`,
9195
data: {
9296
...data,
9397
results: data.results.map((item) => ({

‎apps/sim/lib/sim-search/live/application.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,28 @@ describe('authorized live retrieval', () => {
666666
expect(result.live?.accounts.map((status) => status.queryIndex)).toEqual([0, 1])
667667
expect(result.live?.guidance).toBe('Live coverage: slack')
668668
})
669+
it('reports a search that reached no account as nothing searched, not as no results', async () => {
670+
mocks.accounts.mockResolvedValue([])
671+
const result = await searchLiveKnowledge.execute({ principal, input })
672+
expect(mocks.search).not.toHaveBeenCalled()
673+
expect(result.results).toEqual([])
674+
expect(result.retrieval.status).toBe('partial')
675+
expect(result.live?.accounts).toEqual([])
676+
expect(result.live?.guidance).toMatch(
677+
/^Nothing was searched: you have no connected sources here\. Connect your accounts at \S*\/workspace\/workspace\/integrations,/
678+
)
679+
})
680+
it('names the filtered source when only other sources are connected', async () => {
681+
const result = await searchLiveKnowledge.execute({
682+
principal,
683+
input: { ...input, filters: { source: 'slack' } },
684+
})
685+
expect(mocks.search).not.toHaveBeenCalled()
686+
expect(result.retrieval.status).toBe('partial')
687+
expect(result.live?.guidance).toMatch(
688+
/^Nothing was searched: you have no connected slack account here\. .* or search without the source filter\./
689+
)
690+
})
669691
describe('result previews', () => {
670692
const excerpts = ['a', 'b', 'c', 'd'].map((id) => ({
671693
...document,

‎apps/sim/lib/sim-search/live/application.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
mapWithConcurrency,
3333
waitWithAbort,
3434
} from '@/lib/core/utils/concurrency'
35+
import { getBaseUrl } from '@/lib/core/utils/urls'
3536
import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors'
3637
import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability'
3738
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
@@ -49,6 +50,7 @@ import {
4950
import { RRF_K } from '@/lib/knowledge/search/recency'
5051
import { matchPassage } from '@/lib/knowledge/search/snippet'
5152
import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url'
53+
import { organizationRoutes } from '@/lib/navigation/paths'
5254
import { connectorDisplayName } from '@/lib/sim-search/connectors'
5355
import {
5456
type LiveAccountSession,
@@ -440,6 +442,24 @@ function compareAccounts(left: LiveAccount, right: LiveAccount): number {
440442
)
441443
}
442444

445+
/**
446+
* Why a plain search that reached no account returned nothing, and where the caller connects one;
447+
* an empty result would otherwise read as a search that found nothing.
448+
*/
449+
function nothingSearchedGuidance(
450+
owner: ResourceOwner,
451+
source: string | undefined,
452+
hasOtherAccounts: boolean
453+
): string {
454+
const path = owner.organizationId
455+
? organizationRoutes(owner.organizationId).integrations
456+
: `/workspace/${owner.workspaceId}/integrations`
457+
const connect = new URL(path, getBaseUrl()).href
458+
return source && hasOtherAccounts
459+
? `Nothing was searched: you have no connected ${source} account here. Connect one at ${connect}, or search without the source filter.`
460+
: `Nothing was searched: you have no connected sources here. Connect your accounts at ${connect}, then search again.`
461+
}
462+
443463
const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
444464
operation: knowledgeOperations.search,
445465
resolveContext: ({ input }: { input: LiveSearchInput }) => resolveKnowledgeOwnerContext(input),
@@ -503,6 +523,7 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
503523
nativesFor(account).length > 0
504524
)
505525
.sort(compareAccounts)
526+
const nothingSearched = !queries && eligible.length === 0
506527
const selected = eligible.slice(0, MAX_ACCOUNTS)
507528
const openingAccount = createConcurrencyLimit(ACCOUNT_OPEN_CONCURRENCY)
508529
annotateSearchDiagnostics({
@@ -909,9 +930,14 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
909930
}
910931
})
911932
/** Live coverage guides the providers this call searched or tried to search. */
912-
const guidance = liveSearchGuidance(
913-
accounts.filter((account) => account.status !== 'reconnect').map(({ provider }) => provider)
914-
)
933+
const guidance = [
934+
...(nothingSearched
935+
? [nothingSearchedGuidance(input, filters?.source, allAccounts.length > 0)]
936+
: []),
937+
liveSearchGuidance(
938+
accounts.filter((account) => account.status !== 'reconnect').map(({ provider }) => provider)
939+
),
940+
].join('\n')
915941
for (const account of eligible.slice(MAX_ACCOUNTS)) {
916942
for (const { queryIndex } of nativesFor(account))
917943
accounts.push({
@@ -934,9 +960,10 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
934960
message: 'No connection with this provider is configured and approved in this scope.',
935961
})
936962
}
937-
const retrievalStatus = accounts.some((account) => account.status !== 'ok')
938-
? 'partial'
939-
: 'complete'
963+
const retrievalStatus =
964+
nothingSearched || accounts.some((account) => account.status !== 'ok')
965+
? 'partial'
966+
: 'complete'
940967
annotateSearchDiagnostics({
941968
resultCount: Math.min(ranked.length, input.topK),
942969
retrievalStatus,

‎apps/sim/lib/sim-search/live/atlassian.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,13 @@ describe('Jira any-term search', () => {
244244
const page = await searchAtlassian(api, 'confluence', { ...query, textMatch: 'any' })
245245
expect(page.documents.map((document) => document.id)).toEqual(['5'])
246246
})
247+
248+
it('searches nothing for a Confluence query with no word left, which would list every page', async () => {
249+
const api = client({})
250+
const page = await searchAtlassian(api, 'confluence', { ...query, query: String.raw` :\ ` })
251+
expect(page.documents).toEqual([])
252+
expect(api.json).not.toHaveBeenCalled()
253+
})
247254
})
248255

249256
describe('Confluence plain search ranking', () => {

‎apps/sim/lib/sim-search/live/atlassian.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ export async function searchAtlassian(
132132
provider: 'jira' | 'confluence',
133133
input: NativeSearchInput
134134
): Promise<NativePage> {
135+
const text = nativeText(input)
136+
const confluenceClause =
137+
provider === 'confluence' && text && !input.native?.query
138+
? confluenceTextClause(text, input.textMatch)
139+
: ''
140+
if (confluenceClause === undefined) return { documents: [] }
135141
const allSites = (await sites(client)).filter(
136142
(site) =>
137143
!input.policy?.sites.length ||
@@ -166,7 +172,6 @@ export async function searchAtlassian(
166172
.join(' AND ') || undefined
167173
const direction = dateSortDirection(input.filters)
168174
const order = direction ? `${field} ${direction.toUpperCase()}` : undefined
169-
const text = nativeText(input)
170175
// Jira's any-term JQL matches issues sharing two common words; like minimum-should-match,
171176
// keep only issues holding at least half the terms.
172177
const relevant = input.textMatch === 'any' && text ? holdsHalfTheTerms(text) : undefined
@@ -234,13 +239,15 @@ export async function searchAtlassian(
234239
}
235240
}
236241
const exact = await searchPages(
237-
text ? confluenceTextClause(text, input.textMatch) : '',
242+
confluenceClause,
238243
input.native?.cursor && single ? input.native.cursor : undefined
239244
)
245+
const relevance = confluenceTextClause(text, 'any')
240246
// An all-terms match larger than a page comes back by raw term matches; rank it by relevance.
241-
if (!text || input.native || order || input.textMatch === 'any' || !exact.next) return exact
247+
if (!relevance || input.native || order || input.textMatch === 'any' || !exact.next)
248+
return exact
242249
try {
243-
const ranked = await searchPages(confluenceTextClause(text, 'any'))
250+
const ranked = await searchPages(relevance)
244251
return { ...ranked, next: undefined, ranking: 'relevance' }
245252
} catch {
246253
// A cancelled search stops here; any other failure keeps the all-terms page.

‎apps/sim/lib/sim-search/live/service-session.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export async function createLiveServiceSession(input: {
146146
const query =
147147
search.native?.query ??
148148
(search.query ? confluenceTextClause(search.query, search.textMatch) : '')
149+
if (query === undefined) return null
149150
return {
150151
...search,
151152
native: { provider, ...search.native, query: scopeAtlassianQuery(query, scope) },

‎apps/sim/lib/sim-search/live/text-match.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ describe('text match clauses', () => {
5959
)
6060
})
6161

62+
it('sends a colon or backslash to Confluence as a word break, which it otherwise rejects', () => {
63+
expect(confluenceTextClause('Security: Weekly Report', 'all')).toBe(
64+
'text ~ "Security Weekly Report"'
65+
)
66+
expect(confluenceTextClause(String.raw`C:\builds\out`, 'any')).toBe(
67+
'siteSearch ~ "C builds out"'
68+
)
69+
expect(confluenceTextClause(String.raw` :\ `, 'all')).toBeUndefined()
70+
})
71+
6272
it('falls back to the all-terms clause when fewer than two terms are distinctive', () => {
6373
expect(jiraTextClause('how does it work', 'any')).toBe('text ~ "how does it work"')
6474
})

‎apps/sim/lib/sim-search/live/text-match.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,16 @@ export function jiraTextClause(text: string, match: TextMatch = 'all'): string {
150150
* The text clause for a plain query in Confluence CQL. `any` uses `siteSearch`, Confluence's own
151151
* relevance search, which ranks a page with the query's rarer words above the many pages sharing
152152
* one common word, where a `text ~` OR query ranks them the other way round.
153+
*
154+
* Confluence answers HTTP 400 to the escaped colon or backslash this phrase would carry, and its
155+
* analyzer splits words on both, so they go as spaces. `undefined` means nothing is left to match:
156+
* Confluence would list every page for such a phrase, so callers search nothing instead.
153157
*/
154-
export function confluenceTextClause(text: string, match: TextMatch = 'all'): string {
155-
return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(text)}`
158+
export function confluenceTextClause(text: string, match: TextMatch = 'all'): string | undefined {
159+
const searchable = text.replace(/[:\\]/g, ' ').trim()
160+
return searchable
161+
? `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}`
162+
: undefined
156163
}
157164

158165
/** How many distinct terms occur as whole words in the text. */

0 commit comments

Comments
 (0)