Skip to content

Commit 37af475

Browse files
authored
improvement(search): require half the query's terms in Jira partial matches (#8987)
* improvement(search): keep a Jira partial match only when it holds half the query's terms The any-term retry ORs pairs of terms, so on a large Jira site it returns tickets sharing only a couple of common words, which then ranked alongside every other source's best results. Jira partial matches must now contain at least half the query's distinct terms in their title or description, a word and its regular plural counting as one as Jira's stemmed search matches them (minimum-should-match over the relaxed tier). Confluence's relevance-ranked site search is unchanged. When no partial match qualifies, the all-terms page is returned as it was before the retry existed. * improvement(search): one provider setting for the any-term retry Replace the parallel requiresEveryTerm and partialMatchesNeedHalfTheTerms flags and their two lookups with a single anyTermRetry setting that carries the fallback's options. * fix(search): compare query and ticket words by the same singular key in the Jira floor Rebuilding plural forms from a word's key missed singular words whose plural adds -es (release, database, license): their key drops the final e, so neither the word nor its plural was found and the floor dropped valid partial matches. Both sides now go through the same key, as an analyzer does for query and document, with a term containing punctuation matched as written. * refactor(search): apply the Jira half-the-terms floor in the Jira adapter The floor tightens Jira's own any-term query, so it now sits beside that query in the Jira branch of searchAtlassian. The shared any-term fallback and the provider registry are back to their previous shape: no option, flag, or exported type. The term test is built once per search from the query, splits words with the shared WORD_CHARACTER definition, and is covered directly in text-match tests, with the adapter tests covering the wiring and Confluence staying untouched. * chore(search): terse floor comment, guard the query-side plural fold * fix(search): measure Jira partial-match coverage on the summary and description only The issue mapper appends a Status line, so a query containing 'status' gained a term from the label alone. The floor now reads the raw summary and description fields before mapping.
1 parent 1dc6f18 commit 37af475

5 files changed

Lines changed: 121 additions & 4 deletions

File tree

‎apps/sim/lib/knowledge/search/snippet.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const HEADER_LINE = /^[A-Z][A-Za-z-]{1,15}: .*$/
1616
*/
1717
const WORD_CHARACTER =
1818
/(?![\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}\p{sc=Thai}])[\p{L}\p{N}_]/u
19+
const WORD_RUN = new RegExp(`(?:${WORD_CHARACTER.source})+`, 'gu')
20+
21+
/** The words of a text, split where a run of `WORD_CHARACTER`s ends. */
22+
export function textWords(text: string): string[] {
23+
return text.match(WORD_RUN) ?? []
24+
}
1925

2026
/**
2127
* The document text without the header block some connectors prefix (the

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,51 @@ describe('Confluence live documents', () => {
197197
expect(await previewConfluence(page('<p>Rollout steps</p>'), document)).toBe('Rollout steps')
198198
})
199199
})
200+
201+
describe('Jira any-term search', () => {
202+
const issues = {
203+
'/ex/jira/cloud/rest/api/3/search/jql': {
204+
issues: [
205+
{ key: 'ENG-1', fields: { summary: 'Token creator role request' } },
206+
{ key: 'ENG-2', fields: { summary: 'Role request form' } },
207+
],
208+
},
209+
}
210+
const query = { query: 'glean token creator role request', limit: 25, scopes: [] }
211+
212+
it('keeps only issues holding at least half the query terms', async () => {
213+
const page = await searchAtlassian(client(issues), 'jira', { ...query, textMatch: 'any' })
214+
expect(page.documents.map((document) => document.id)).toEqual(['ENG-1'])
215+
})
216+
217+
it('counts only the summary and description, not the added status line', async () => {
218+
const api = client({
219+
'/ex/jira/cloud/rest/api/3/search/jql': {
220+
issues: [{ key: 'ENG-3', fields: { summary: 'Release update', status: { name: 'Open' } } }],
221+
},
222+
})
223+
const page = await searchAtlassian(api, 'jira', {
224+
query: 'release status update owner timeline',
225+
limit: 25,
226+
scopes: [],
227+
textMatch: 'any',
228+
})
229+
expect(page.documents).toEqual([])
230+
})
231+
232+
it('keeps every issue an all-terms search returns', async () => {
233+
const page = await searchAtlassian(client(issues), 'jira', query)
234+
expect(page.documents.map((document) => document.id)).toEqual(['ENG-1', 'ENG-2'])
235+
})
236+
237+
it('leaves a Confluence any-term search to its relevance ranking', async () => {
238+
const api = client({
239+
'/ex/confluence/cloud/wiki/rest/api/search': {
240+
results: [{ content: { id: '5', type: 'page', title: 'Role request form' } }],
241+
_links: {},
242+
},
243+
})
244+
const page = await searchAtlassian(api, 'confluence', { ...query, textMatch: 'any' })
245+
expect(page.documents.map((document) => document.id)).toEqual(['5'])
246+
})
247+
})

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
} from '@/lib/sim-search/live/http'
1010
import { interleaveByRank } from '@/lib/sim-search/live/pages'
1111
import { providerText } from '@/lib/sim-search/live/text'
12-
import { confluenceTextClause, jiraTextClause } from '@/lib/sim-search/live/text-match'
12+
import {
13+
confluenceTextClause,
14+
holdsHalfTheTerms,
15+
jiraTextClause,
16+
} from '@/lib/sim-search/live/text-match'
1317
import type {
1418
NativeClient,
1519
NativeDocument,
@@ -152,6 +156,9 @@ export async function searchAtlassian(
152156
const direction = dateSortDirection(input.filters)
153157
const order = direction ? `${field} ${direction.toUpperCase()}` : undefined
154158
const text = nativeText(input)
159+
// Jira's any-term JQL matches issues sharing two common words; like minimum-should-match,
160+
// keep only issues holding at least half the terms.
161+
const relevant = input.textMatch === 'any' && text ? holdsHalfTheTerms(text) : undefined
155162
const pages = await Promise.all(
156163
selected.map(async (site) => {
157164
const cloudId = string(site.id)
@@ -173,7 +180,15 @@ export async function searchAtlassian(
173180
})
174181
)
175182
return {
176-
documents: array(data.issues).map((row) => issue(row, cloudId, origin)),
183+
documents: array(data.issues)
184+
.filter((row) => {
185+
const fields = object(row.fields)
186+
return (
187+
!relevant ||
188+
relevant(`${string(fields.summary)}\n${textContent(fields.description)}`)
189+
)
190+
})
191+
.map((row) => issue(row, cloudId, origin)),
177192
excluded: false,
178193
next: string(data.nextPageToken) || undefined,
179194
}

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest'
2-
import { confluenceTextClause, jiraTextClause } from '@/lib/sim-search/live/text-match'
2+
import {
3+
confluenceTextClause,
4+
holdsHalfTheTerms,
5+
jiraTextClause,
6+
} from '@/lib/sim-search/live/text-match'
37

48
describe('text match clauses', () => {
59
it('matches either term of a two-term query, leaving out stopwords', () => {
@@ -57,4 +61,30 @@ describe('text match clauses', () => {
5761
it('falls back to the all-terms clause when fewer than two terms are distinctive', () => {
5862
expect(jiraTextClause('how does it work', 'any')).toBe('text ~ "how does it work"')
5963
})
64+
65+
describe('holdsHalfTheTerms', () => {
66+
it('needs at least half the distinct terms, rounding up', () => {
67+
const holds = holdsHalfTheTerms('glean token creator role request')
68+
expect(holds('Role request form')).toBe(false)
69+
expect(holds('Token creator role')).toBe(true)
70+
})
71+
72+
it('counts a word and its plural as one term in either direction', () => {
73+
expect(holdsHalfTheTerms('glean token creator role request')('Glean tokens, creators')).toBe(
74+
true
75+
)
76+
expect(
77+
holdsHalfTheTerms('database release checklist owner')('Databases and weekly releases')
78+
).toBe(true)
79+
expect(holdsHalfTheTerms('databases releases checklist owner')('Database release')).toBe(true)
80+
})
81+
82+
it('matches a term with punctuation inside it as written', () => {
83+
expect(holdsHalfTheTerms('ci/cd pipeline failure owner')('CI/CD pipeline')).toBe(true)
84+
})
85+
86+
it('accepts one matching term for a two-term query', () => {
87+
expect(holdsHalfTheTerms('contractor offboarding')('Contractor directory')).toBe(true)
88+
})
89+
})
6090
})

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { findTermMatches, queryTerms } from '@/lib/knowledge/search/snippet'
1+
import { findTermMatches, queryTerms, textWords } from '@/lib/knowledge/search/snippet'
22

33
/**
44
* How a plain query's words must match a document. Confluence and Jira text search require every
@@ -115,3 +115,21 @@ export function confluenceTextClause(text: string, match: TextMatch = 'all'): st
115115
export function termCoverage(text: string, terms: readonly string[]): number {
116116
return terms.filter((term) => findTermMatches(text, [term]).length > 0).length
117117
}
118+
119+
/**
120+
* A test for whether a text holds at least half the query's distinct terms. Query and text words
121+
* are compared by their singular key, so a word and its regular plural count as one term as a
122+
* stemmed search matches them; a term with punctuation inside it is matched as written.
123+
*/
124+
export function holdsHalfTheTerms(query: string): (text: string) => boolean {
125+
const terms = distinctTerms(query, singularKey)
126+
const keys = terms.map(singularKey)
127+
const required = Math.ceil(terms.length / 2)
128+
return (text) => {
129+
const words = new Set(textWords(text).map(singularKey))
130+
const present = terms.filter(
131+
(term, index) => words.has(keys[index]) || findTermMatches(text, [term]).length > 0
132+
)
133+
return present.length >= required
134+
}
135+
}

0 commit comments

Comments
 (0)