Skip to content

Commit e1a4ca2

Browse files
committed
fix(search): count Jira half-term summaries by stemmed term, from a wider pool, most terms first
1 parent 1822130 commit e1a4ca2

3 files changed

Lines changed: 113 additions & 51 deletions

File tree

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

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -350,13 +350,21 @@ describe('Jira plain search ranking', () => {
350350
? { key: row, fields: { summary: row } }
351351
: { key: row.key, fields: { summary: row.summary } }
352352
)
353-
const json = vi.fn(async (path: string, options?: { body?: { jql?: string } }) => {
354-
if (path === '/oauth/token/accessible-resources') return [SITE]
355-
if (path !== search) throw new Error(`Unexpected request: ${path}`)
356-
const jql = options?.body?.jql ?? ''
357-
if (jql.includes('summary ~')) return { issues: issues(jql.includes(' OR ') ? half : titled) }
358-
return { issues: issues(exact.ids), ...(exact.more ? { nextPageToken: 'page-2' } : {}) }
359-
})
353+
const json = vi.fn(
354+
async (path: string, options?: { body?: { jql?: string; maxResults?: number } }) => {
355+
if (path === '/oauth/token/accessible-resources') return [SITE]
356+
if (path !== search) throw new Error(`Unexpected request: ${path}`)
357+
const jql = options?.body?.jql ?? ''
358+
if (jql.includes('summary ~'))
359+
return {
360+
issues: issues(jql.includes(' OR ') ? half : titled).slice(
361+
0,
362+
options?.body?.maxResults
363+
),
364+
}
365+
return { issues: issues(exact.ids), ...(exact.more ? { nextPageToken: 'page-2' } : {}) }
366+
}
367+
)
360368
return { json, text: vi.fn(), bytes: vi.fn() } satisfies NativeClient
361369
}
362370
const query = { query: 'merged results pipelines', limit: 3, scopes: [] }
@@ -430,8 +438,8 @@ describe('Jira plain search ranking', () => {
430438
overflowing,
431439
[],
432440
[
433-
{ key: 'HALF-1', summary: 'Merged results broke the pipelines' },
434441
{ key: 'LOOSE-1', summary: 'Pipelines dashboard' },
442+
{ key: 'HALF-1', summary: 'Merged results broke the dashboard' },
435443
]
436444
)
437445
const page = await searchAtlassian(api, 'jira', query)
@@ -454,4 +462,36 @@ describe('Jira plain search ranking', () => {
454462
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
455463
expect(page.nextCursor).toBe('page-2')
456464
})
465+
466+
it('counts a word and its plural as one term before allowing the half-the-terms fallback', async () => {
467+
const api = jira(overflowing, [], [{ key: 'ONE-1', summary: 'Rotation schedule' }])
468+
const page = await searchAtlassian(api, 'jira', { ...query, query: 'token tokens rotation' })
469+
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
470+
})
471+
472+
it('orders half-the-terms summaries by how many terms they hold', async () => {
473+
const api = jira(
474+
overflowing,
475+
[],
476+
[
477+
{ key: 'TWO-1', summary: 'Merged results dashboard' },
478+
{ key: 'THREE-1', summary: 'Merged results in release pipelines' },
479+
]
480+
)
481+
const page = await searchAtlassian(api, 'jira', {
482+
...query,
483+
query: 'merged results release pipelines',
484+
})
485+
expect(page.documents.map((document) => document.id)).toEqual(['THREE-1', 'TWO-1', 'NEW-3'])
486+
})
487+
488+
it('finds half-the-terms summaries beyond the first page of pair matches', async () => {
489+
const pairs = Array.from({ length: 10 }, (_, index) => ({
490+
key: `PAIR-${index}`,
491+
summary: 'Pipelines dashboard',
492+
}))
493+
const api = jira(overflowing, [], [...pairs, { key: 'HALF-9', summary: 'Merged pipelines' }])
494+
const page = await searchAtlassian(api, 'jira', query)
495+
expect(page.documents.map((document) => document.id)[0]).toBe('HALF-9')
496+
})
457497
})

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

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
confluenceTextClause,
1414
holdsHalfTheTerms,
1515
jiraTextClause,
16-
significantTerms,
16+
stemmedTerms,
1717
} from '@/lib/sim-search/live/text-match'
1818
import type {
1919
NativeClient,
@@ -117,6 +117,12 @@ async function sites(client: NativeClient) {
117117
return array(await client.json('/oauth/token/accessible-resources', { memo: true }))
118118
}
119119

120+
/**
121+
* Pair matches fetched for the half-the-terms summary fallback; the page is filtered to summaries
122+
* holding enough terms, so it starts from a wider pool than the caller's limit.
123+
*/
124+
const HALF_TERM_CANDIDATES = 50
125+
120126
/**
121127
* One Atlassian site's page; `ranking` marks a page whose order or completeness differs from the
122128
* all-terms search.
@@ -176,67 +182,75 @@ export async function searchAtlassian(
176182
const cloudId = string(site.id)
177183
const origin = string(site.url).replace(/\/$/, '')
178184
if (provider === 'jira') {
179-
/** One JQL page, keeping only issues whose summary and description pass `keep`. */
180-
const searchIssues = async (
181-
jql: string,
182-
cursor?: string,
183-
keep?: (summary: string, description: string) => boolean
184-
): Promise<SitePage> => {
185+
/** One JQL page, each issue with its summary and description. */
186+
const searchIssues = async (jql: string, cursor?: string, maxResults = input.limit) => {
185187
const data = object(
186188
await client.json(`/ex/jira/${segment(cloudId)}/rest/api/3/search/jql`, {
187189
body: {
188190
jql: scopeAtlassianQuery(jql, scope, order),
189-
maxResults: input.limit,
191+
maxResults,
190192
fields: ['summary', 'description', 'updated', 'creator', 'status'],
191193
...(cursor ? { nextPageToken: cursor } : {}),
192194
},
193195
signal: input.signal,
194196
})
195197
)
196198
return {
197-
documents: array(data.issues)
198-
.filter((row) => {
199-
const fields = object(row.fields)
200-
return !keep || keep(string(fields.summary), textContent(fields.description))
201-
})
202-
.map((row) => issue(row, cloudId, origin)),
203-
excluded: false,
199+
issues: array(data.issues).map((row) => {
200+
const fields = object(row.fields)
201+
return {
202+
document: issue(row, cloudId, origin),
203+
summary: string(fields.summary),
204+
description: textContent(fields.description),
205+
}
206+
}),
204207
next: string(data.nextPageToken) || undefined,
205208
}
206209
}
207210
const exact = await searchIssues(
208211
input.native?.query || (text ? jiraTextClause(text, input.textMatch) : ''),
209-
input.native?.cursor && single ? input.native.cursor : undefined,
210-
relevant && ((summary, description) => relevant(`${summary}\n${description}`))
212+
input.native?.cursor && single ? input.native.cursor : undefined
211213
)
214+
const exactPage: SitePage = {
215+
documents: exact.issues
216+
.filter(
217+
({ summary, description }) => !relevant || relevant(`${summary}\n${description}`)
218+
)
219+
.map(({ document }) => document),
220+
excluded: false,
221+
next: exact.next,
222+
}
212223
// JQL has no relevance order, so an all-terms match larger than a page comes back newest
213224
// first; issues whose summary holds every term, or else at least half of three or more
214-
// terms, lead it instead.
215-
if (!text || input.native || order || input.textMatch === 'any' || !exact.next) return exact
225+
// terms (most terms first), lead it instead.
226+
if (!text || input.native || order || input.textMatch === 'any' || !exact.next)
227+
return exactPage
228+
const terms = stemmedTerms(text)
216229
const [every, half] = await Promise.allSettled([
217230
searchIssues(jiraTextClause(text, 'all', 'summary')),
218-
significantTerms(text).length >= 3
219-
? searchIssues(
220-
jiraTextClause(text, 'any', 'summary'),
221-
undefined,
222-
holdsHalfTheTerms(text)
223-
)
231+
terms.count >= 3
232+
? searchIssues(jiraTextClause(text, 'any', 'summary'), undefined, HALF_TERM_CANDIDATES)
224233
: undefined,
225234
])
226235
// A cancelled search stops here; any other failure keeps the all-terms page.
227236
input.signal?.throwIfAborted()
237+
const required = Math.ceil(terms.count / 2)
228238
const titled =
229-
every.status === 'fulfilled' && every.value.documents.length
230-
? every.value.documents
231-
: half.status === 'fulfilled'
232-
? (half.value?.documents ?? [])
239+
every.status === 'fulfilled' && every.value.issues.length
240+
? every.value.issues.map(({ document }) => document)
241+
: half.status === 'fulfilled' && half.value
242+
? half.value.issues
243+
.map(({ document, summary }) => ({ document, held: terms.held(summary) }))
244+
.filter(({ held }) => held >= required)
245+
.sort((left, right) => right.held - left.held)
246+
.map(({ document }) => document)
233247
: []
234-
if (!titled.length) return exact
248+
if (!titled.length) return exactPage
235249
const leading = new Set(titled.map((document) => document.id))
236250
return {
237251
documents: [
238252
...titled,
239-
...exact.documents.filter((document) => !leading.has(document.id)),
253+
...exactPage.documents.filter((document) => !leading.has(document.id)),
240254
].slice(0, input.limit),
241255
excluded: false,
242256
ranking: 'relevance',
@@ -304,7 +318,7 @@ export async function searchAtlassian(
304318
: '') +
305319
(pages.some((result) => result.ranking === 'relevance')
306320
? provider === 'jira'
307-
? ' More issues contained every word than fit on one page, so issues whose summary holds the most query words come first; refine the query, or page with a native JQL query.'
321+
? ' More issues contained every word than fit on one page, so issues whose summary holds every word, or else most of them, come first; refine the query, or page with a native JQL query.'
308322
: ' 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.'
309323
: '') +
310324
(pages.some((result) => result.ranking === 'unavailable')

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -126,19 +126,27 @@ export function termCoverage(text: string, terms: readonly string[]): number {
126126
}
127127

128128
/**
129-
* A test for whether a text holds at least half the query's distinct terms. Query and text words
130-
* are compared by their singular key, so a word and its regular plural count as one term as a
131-
* stemmed search matches them; a term with punctuation inside it is matched as written.
129+
* The query's distinct terms as a stemmed search counts them, and how many of them a text holds.
130+
* Query and text words are compared by their singular key, so a word and its regular plural count
131+
* as one term; a term with punctuation inside it is matched as written.
132132
*/
133-
export function holdsHalfTheTerms(query: string): (text: string) => boolean {
133+
export function stemmedTerms(query: string): { count: number; held: (text: string) => number } {
134134
const terms = distinctTerms(query, singularKey)
135135
const keys = terms.map(singularKey)
136-
const required = Math.ceil(terms.length / 2)
137-
return (text) => {
138-
const words = new Set(textWords(text).map(singularKey))
139-
const present = terms.filter(
140-
(term, index) => words.has(keys[index]) || findTermMatches(text, [term]).length > 0
141-
)
142-
return present.length >= required
136+
return {
137+
count: terms.length,
138+
held: (text) => {
139+
const words = new Set(textWords(text).map(singularKey))
140+
return terms.filter(
141+
(term, index) => words.has(keys[index]) || findTermMatches(text, [term]).length > 0
142+
).length
143+
},
143144
}
144145
}
146+
147+
/** A test for whether a text holds at least half the query's distinct terms, as `stemmedTerms` counts them. */
148+
export function holdsHalfTheTerms(query: string): (text: string) => boolean {
149+
const { count, held } = stemmedTerms(query)
150+
const required = Math.ceil(count / 2)
151+
return (text) => held(text) >= required
152+
}

0 commit comments

Comments
 (0)