Skip to content

Commit 2abc906

Browse files
authored
improvement(search): rank an overflowing Confluence all-terms match with relevance search (#9001)
* improvement(search): rank an overflowing Confluence all-terms match with relevance search A plain Confluence query runs an all-terms text ~ search. When more pages match every word than fit on one page, Confluence orders them by raw term matches, so on a large site the right page is often not on the page returned. That page is now fetched from siteSearch, the relevance search behind Confluence's own search box, which weights titles and rarer words. An all-terms match that fits on one page, date-sorted listings, and native CQL are unchanged; the underfilled case keeps its existing relevance fallback. A relevance-ranked page reports hasMore without a cursor, since a cursor would continue a different query. * fix(search): give Jira and Confluence site pages the same shape * fix(search): keep the all-terms Confluence page when relevance ranking fails A provider error on the optional relevance request (rate limit, refusal) now keeps the all-terms page and its cursor instead of failing the site, and with it the account's other sites. Cancellation and transport errors still propagate. Relevance-ranked and ranking-unavailable pages are reported as partial. * fix(search): keep the all-terms Confluence page on any non-cancellation ranking failure The client's own request timeout and transport failures surface as plain errors, so keeping the page only for provider errors still dropped it on a slow relevance request. Any failure other than cancellation (an AbortSignal reason) now keeps the all-terms page. * fix(search): honor a caller cancellation of any reason during Confluence relevance ranking * test(search): drive Confluence ranking failures through the test client The failure cases re-mocked the client with an optional call that could return undefined, which the test type check rejects. The helper now takes the relevance result or a throwing function, and the provider-refusal and timeout cases share one table test. * fix(search): give provider searches the session cancellation, whatever its reason A search stopped with a non-error abort reason (a string) surfaced from the HTTP client unchanged, so the relevance-ranking fallback treated it as a ranking failure and returned results after the stop. Provider searches now carry the account session's signal, and the fallback checks it instead of the error's type.
1 parent 0c3540a commit 2abc906

4 files changed

Lines changed: 165 additions & 31 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,11 @@ export async function openLiveAccountSession(
205205
client ? searchNativeProvider(provider, client, native) : searchMcp(native)
206206
)
207207
}
208+
/** Providers see the session's cancellation, whatever its reason, on every request. */
209+
const request = search.signal ? search : { ...search, signal }
208210
return client && requiresEveryTerm(provider)
209-
? searchWithAnyTermFallback(searchOnce, search, signal)
210-
: searchOnce(search)
211+
? searchWithAnyTermFallback(searchOnce, request, signal)
212+
: searchOnce(request)
211213
},
212214
async verify(document) {
213215
if (!(await boundary.verify(document))) return false

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,17 @@ describe('authorized live retrieval', () => {
697697
})
698698
})
699699

700+
it('gives provider searches the account session cancellation signal', async () => {
701+
const signals: (AbortSignal | undefined)[] = []
702+
mocks.search.mockImplementation(async (_provider, _client, search) => {
703+
signals.push(search.signal)
704+
return { documents: [document] }
705+
})
706+
await searchLiveKnowledge.execute({ principal, input: { ...input, query: 'launch checklist' } })
707+
expect(signals.length).toBeGreaterThan(0)
708+
for (const signal of signals) expect(signal).toBeInstanceOf(AbortSignal)
709+
})
710+
700711
it('retries an underfilled search with any term for providers that require every term', async () => {
701712
mocks.requiresEveryTerm.mockReturnValue(true)
702713
const loose = { ...document, id: 'loose', url: 'https://docs.google.com/document/d/loose/edit' }

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,89 @@ describe('Jira any-term search', () => {
245245
expect(page.documents.map((document) => document.id)).toEqual(['5'])
246246
})
247247
})
248+
249+
describe('Confluence plain search ranking', () => {
250+
const search = '/ex/confluence/cloud/wiki/rest/api/search'
251+
/** Answers the all-terms query with `exact` and the relevance query with `ranked`, or its failure. */
252+
function confluence(exact: { ids: string[]; more: boolean }, ranked: string[] | (() => never)) {
253+
const results = (ids: string[]) =>
254+
ids.map((id) => ({ content: { id, type: 'page', title: id } }))
255+
const json = vi.fn(async (path: string, options?: { query?: Record<string, string> }) => {
256+
if (path === '/oauth/token/accessible-resources') return [SITE]
257+
if (path !== search) throw new Error(`Unexpected request: ${path}`)
258+
if (options?.query?.cql?.includes('siteSearch ~'))
259+
return { results: results(typeof ranked === 'function' ? ranked() : ranked), _links: {} }
260+
return {
261+
results: results(exact.ids),
262+
_links: exact.more ? { next: `${search}?cursor=page-2` } : {},
263+
}
264+
})
265+
return { json, text: vi.fn(), bytes: vi.fn() } satisfies NativeClient
266+
}
267+
const query = { query: 'merge train service', limit: 2, scopes: [] }
268+
const overflowing = { ids: ['old-1', 'old-2'], more: true }
269+
270+
it('ranks an all-terms match too large for one page with relevance search', async () => {
271+
const api = confluence(overflowing, ['best', 'next'])
272+
const page = await searchAtlassian(api, 'confluence', query)
273+
expect(page.documents.map((document) => document.id)).toEqual(['best', 'next'])
274+
expect(page.hasMore).toBe(true)
275+
expect(page.partial).toBe(true)
276+
expect(page.nextCursor).toBeUndefined()
277+
})
278+
279+
it.each([
280+
['a provider refusal', new NativeSearchError('rate_limited', 'Rate limited.', 30)],
281+
['a request timeout', new Error('Request timed out after 10000ms')],
282+
])(
283+
'keeps the all-terms page and its cursor when relevance ranking fails on %s',
284+
async (_, failure) => {
285+
const api = confluence(overflowing, () => {
286+
throw failure
287+
})
288+
const page = await searchAtlassian(api, 'confluence', query)
289+
expect(page.documents.map((document) => document.id)).toEqual(['old-1', 'old-2'])
290+
expect(page.nextCursor).toBe('page-2')
291+
expect(page.partial).toBe(true)
292+
}
293+
)
294+
295+
it.each([
296+
['an Error', new Error('Caller stopped')],
297+
['a string', 'AbortReason.UserStop'],
298+
['a DOMException', new DOMException('Search cancelled', 'AbortError')],
299+
])('propagates a cancellation whose reason is %s', async (_, reason) => {
300+
const caller = new AbortController()
301+
const api = confluence(overflowing, () => {
302+
caller.abort(reason)
303+
throw reason
304+
})
305+
await expect(
306+
searchAtlassian(api, 'confluence', { ...query, signal: caller.signal })
307+
).rejects.toBe(reason)
308+
})
309+
310+
it('keeps an all-terms match that fits on one page', async () => {
311+
const api = confluence({ ids: ['exact'], more: false }, ['loose'])
312+
const page = await searchAtlassian(api, 'confluence', query)
313+
expect(page.documents.map((document) => document.id)).toEqual(['exact'])
314+
expect(api.json).toHaveBeenCalledTimes(2)
315+
})
316+
317+
it('keeps date order and native queries on the all-terms page', async () => {
318+
const dated = confluence({ ids: ['newest', 'older'], more: true }, ['best'])
319+
const byDate = await searchAtlassian(dated, 'confluence', {
320+
...query,
321+
filters: { sortBy: 'newest' },
322+
})
323+
expect(byDate.documents.map((document) => document.id)).toEqual(['newest', 'older'])
324+
expect(byDate.nextCursor).toBe('page-2')
325+
const native = confluence({ ids: ['a', 'b'], more: true }, ['best'])
326+
const own = await searchAtlassian(native, 'confluence', {
327+
...query,
328+
native: { provider: 'confluence', query: 'text ~ "merge train"' },
329+
})
330+
expect(own.documents.map((document) => document.id)).toEqual(['a', 'b'])
331+
expect(own.nextCursor).toBe('page-2')
332+
})
333+
})

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

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,17 @@ async function sites(client: NativeClient) {
116116
return array(await client.json('/oauth/token/accessible-resources', { memo: true }))
117117
}
118118

119+
/**
120+
* One Atlassian site's page; `ranking` marks a page whose order or completeness differs from the
121+
* all-terms search.
122+
*/
123+
interface SitePage {
124+
documents: NativeDocument[]
125+
excluded: boolean
126+
next?: string
127+
ranking?: 'relevance' | 'unavailable'
128+
}
129+
119130
export async function searchAtlassian(
120131
client: NativeClient,
121132
provider: 'jira' | 'confluence',
@@ -160,7 +171,7 @@ export async function searchAtlassian(
160171
// keep only issues holding at least half the terms.
161172
const relevant = input.textMatch === 'any' && text ? holdsHalfTheTerms(text) : undefined
162173
const pages = await Promise.all(
163-
selected.map(async (site) => {
174+
selected.map(async (site): Promise<SitePage> => {
164175
const cloudId = string(site.id)
165176
const origin = string(site.url).replace(/\/$/, '')
166177
if (provider === 'jira') {
@@ -193,47 +204,71 @@ export async function searchAtlassian(
193204
next: string(data.nextPageToken) || undefined,
194205
}
195206
}
196-
const data = object(
197-
await client.json(`/ex/confluence/${segment(cloudId)}/wiki/rest/api/search`, {
198-
query: {
199-
cql: scopeAtlassianQuery(
200-
input.native?.query ||
201-
`type IN (page, blogpost)${text ? ` AND ${confluenceTextClause(text, input.textMatch)}` : ''}`,
202-
scope,
203-
order
204-
),
205-
limit: String(input.limit),
206-
expand: 'content.version,content.space',
207-
...(input.native?.cursor && single ? { cursor: input.native.cursor } : {}),
208-
},
209-
signal: input.signal,
210-
})
207+
const searchPages = async (clause: string, cursor?: string) => {
208+
const data = object(
209+
await client.json(`/ex/confluence/${segment(cloudId)}/wiki/rest/api/search`, {
210+
query: {
211+
cql: scopeAtlassianQuery(
212+
input.native?.query || `type IN (page, blogpost)${clause ? ` AND ${clause}` : ''}`,
213+
scope,
214+
order
215+
),
216+
limit: String(input.limit),
217+
expand: 'content.version,content.space',
218+
...(cursor ? { cursor } : {}),
219+
},
220+
signal: input.signal,
221+
})
222+
)
223+
const next = string(object(data._links).next)
224+
const rows = array(data.results)
225+
const documents = rows
226+
.map((row) => page(row, cloudId, origin))
227+
.filter((document) => document !== undefined)
228+
return {
229+
documents,
230+
excluded: documents.length < rows.length,
231+
next: next
232+
? (new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined)
233+
: undefined,
234+
}
235+
}
236+
const exact = await searchPages(
237+
text ? confluenceTextClause(text, input.textMatch) : '',
238+
input.native?.cursor && single ? input.native.cursor : undefined
211239
)
212-
const next = string(object(data._links).next)
213-
const rows = array(data.results)
214-
const documents = rows
215-
.map((row) => page(row, cloudId, origin))
216-
.filter((document) => document !== undefined)
217-
return {
218-
documents,
219-
excluded: documents.length < rows.length,
220-
next: next
221-
? (new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined)
222-
: undefined,
240+
// 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
242+
try {
243+
const ranked = await searchPages(confluenceTextClause(text, 'any'))
244+
return { ...ranked, next: undefined, ranking: 'relevance' }
245+
} catch {
246+
// A cancelled search stops here; any other failure keeps the all-terms page.
247+
input.signal?.throwIfAborted()
248+
return { ...exact, ranking: 'unavailable' }
223249
}
224250
})
225251
)
226252
const documents = interleaveByRank(pages.map((result) => result.documents))
227253
const excluded = pages.some((result) => result.excluded)
228254
return {
229255
documents,
230-
partial: excluded || (!input.native?.project && allSites.length > selected.length),
231-
hasMore: pages.some((result) => Boolean(result.next)),
256+
partial:
257+
excluded ||
258+
pages.some((result) => result.ranking) ||
259+
(!input.native?.project && allSites.length > selected.length),
260+
hasMore: pages.some((result) => Boolean(result.next) || result.ranking === 'relevance'),
232261
nextCursor: single ? pages[0]?.next : undefined,
233262
message:
234263
'Searches up to four accessible Atlassian sites. For a specific site and pagination, set project to its cloud ID.' +
235264
(excluded
236265
? ' Matches other than pages, blog posts, and spaces (attachments, comments, whiteboards, folders, databases) were excluded because they cannot be read.'
266+
: '') +
267+
(pages.some((result) => result.ranking === 'relevance')
268+
? ' More pages contained every word than fit on one page, so this page is ordered by Confluence relevance search; refine the query, or page with a native CQL query.'
269+
: '') +
270+
(pages.some((result) => result.ranking === 'unavailable')
271+
? ' Relevance ranking was unavailable, so this page keeps the order of the all-terms search.'
237272
: ''),
238273
}
239274
}

0 commit comments

Comments
 (0)