Skip to content

Commit 331f83d

Browse files
committed
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.
1 parent 550fe32 commit 331f83d

5 files changed

Lines changed: 92 additions & 8 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/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/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')).toBe('')
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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,14 @@ 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 a text query holding a colon or backslash, even escaped, and its
155+
* analyzer splits words on both, so they go as spaces. Nothing left to match means no clause.
153156
*/
154157
export function confluenceTextClause(text: string, match: TextMatch = 'all'): string {
155-
return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(text)}`
158+
const searchable = text.replace(/[:\\]/g, ' ').trim()
159+
if (!searchable) return ''
160+
return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}`
156161
}
157162

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

0 commit comments

Comments
 (0)