Skip to content

Commit 190dac4

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

8 files changed

Lines changed: 54 additions & 12 deletions

File tree

‎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 (connect an account, or drop the source filter) 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/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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('text match clauses', () => {
6666
expect(confluenceTextClause(String.raw`C:\builds\out`, 'any')).toBe(
6767
'siteSearch ~ "C builds out"'
6868
)
69-
expect(confluenceTextClause(String.raw` :\ `, 'all')).toBe('')
69+
expect(confluenceTextClause(String.raw` :\ `, 'all')).toBeUndefined()
7070
})
7171

7272
it('falls back to the all-terms clause when fewer than two terms are distinctive', () => {

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,15 @@ export function jiraTextClause(text: string, match: TextMatch = 'all'): string {
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.
153153
*
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.
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.
156157
*/
157-
export function confluenceTextClause(text: string, match: TextMatch = 'all'): string {
158+
export function confluenceTextClause(text: string, match: TextMatch = 'all'): string | undefined {
158159
const searchable = text.replace(/[:\\]/g, ' ').trim()
159-
if (!searchable) return ''
160-
return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}`
160+
return searchable
161+
? `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}`
162+
: undefined
161163
}
162164

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

‎apps/sim/scripts/test-organization-search-e2e.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,15 @@ try {
445445
const body = toRecord(response.request().postDataJSON())
446446
assert.equal(body.organizationId, organizationId)
447447
assert.equal(body.query, 'Launch notes')
448-
await expect(page.getByText('Search found no results.', { exact: true })).toBeVisible()
448+
// The seeded member has connected no account, so nothing was searched.
449+
await expect(
450+
page.getByText('Connect your apps to search them.', { exact: true })
451+
).toBeVisible()
452+
await expect(page.getByText('Search found no results.', { exact: true })).toHaveCount(0)
453+
await expect(page.getByRole('link', { name: 'Connect apps' })).toHaveAttribute(
454+
'href',
455+
`${root}/integrations`
456+
)
449457
await expect(page).toHaveURL(
450458
(url) => url.pathname === `${root}/search` && url.searchParams.get('q') === 'Launch notes'
451459
)

0 commit comments

Comments
 (0)