fix: match integration suggestions on slug words instead of substrings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-24 16:39:34 +02:00
parent 97ff901176
commit 3d47bf8caa
2 changed files with 96 additions and 5 deletions
@@ -1453,6 +1453,75 @@ describe('createSearchHubScriptsTool', () => {
expect(JSON.parse(raw)).toEqual({ results: [], suggested_integrations: ['stripe'] })
})
// Matching a query word anywhere inside a slug turns every short English word into
// a hit — `for` in salesforce, `the` in basis_theory — so a task that names no
// integration came back with a confident-looking list of them.
it('suggests nothing for a request that names no integration', async () => {
const { ScriptService, IntegrationService } = await import('$lib/gen')
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) })
Object.assign(IntegrationService, {
listHubIntegrations: vi.fn(async () => [
{ name: 'salesforce' },
{ name: 'basis_theory' },
{ name: 'hackernews' },
{ name: 's3' }
])
})
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
clearHubIntegrationsCache()
const raw = await createSearchHubScriptsTool().fn({
args: { query: 'list all the invoices for the new month' },
toolId: 't1',
toolCallbacks: { setToolStatus: vi.fn() }
} as any)
expect(JSON.parse(raw).suggested_integrations).toEqual([])
})
// Google's integrations are all a compressed `g` plus the product word, so the
// word a user actually says starts one character into the slug.
it('reaches an integration whose slug compresses the vendor name', async () => {
const { ScriptService, IntegrationService } = await import('$lib/gen')
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) })
Object.assign(IntegrationService, {
listHubIntegrations: vi.fn(async () => [
{ name: 'gsheets' },
{ name: 'gdrive' },
{ name: 'smartsheet' }
])
})
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
clearHubIntegrationsCache()
const raw = await createSearchHubScriptsTool().fn({
args: { query: 'add a row to a google sheet' },
toolId: 't1',
toolCallbacks: { setToolStatus: vi.fn() }
} as any)
expect(JSON.parse(raw).suggested_integrations).toEqual(['gsheets'])
})
// A slug shorter than the token floor is only reachable by an exact match.
it('still reaches a two-character integration slug', async () => {
const { ScriptService, IntegrationService } = await import('$lib/gen')
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) })
Object.assign(IntegrationService, {
listHubIntegrations: vi.fn(async () => [{ name: 's3' }, { name: 'salesforce' }])
})
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
clearHubIntegrationsCache()
const raw = await createSearchHubScriptsTool().fn({
args: { query: 'upload a file to s3' },
toolId: 't1',
toolCallbacks: { setToolStatus: vi.fn() }
} as any)
expect(JSON.parse(raw).suggested_integrations).toEqual(['s3'])
})
})
describe('getHubIntegrationTool', () => {
@@ -1271,15 +1271,37 @@ async function suggestHubIntegrations(query: string): Promise<string[]> {
const tokens = query
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((t) => t.length > 2)
.filter((t) => t.length >= 2)
return hubIntegrationsCache
.filter((name) => {
const slug = name.toLowerCase()
return tokens.some((t) => slug.includes(t) || (slug.length > 2 && t.includes(slug)))
})
.filter((name) => tokens.some((t) => tokenMatchesSlug(t, name.toLowerCase())))
.slice(0, MAX_SUGGESTED_INTEGRATIONS)
}
/** Matches a query word against a slug on word boundaries rather than by bare
* substring. A substring test reads every three-letter English word as a hit —
* `for` in sales*for*ce, `the` in basis_*the*ory — so a request that names no
* integration still came back with five confident-looking ones. Short tokens must
* equal a slug or one of its parts, which is also what reaches the two-character
* slugs (`s3`, `wiz`) that a length filter alone hides. */
function tokenMatchesSlug(token: string, slug: string): boolean {
const parts = slug.split(/[_-]/).filter(Boolean)
if (slug === token || parts.includes(token)) {
return true
}
if (token.length < 4) {
return false
}
const extends_ = (a: string, b: string) => a.startsWith(b) || b.startsWith(a)
return (
parts.some((p) => p.length >= 4 && extends_(p, token)) ||
(slug.length >= 4 && extends_(slug, token)) ||
// A whole family of slugs compresses the vendor to one letter in front of the
// product word, so the word the user actually says starts one character in:
// "google sheet" has to reach `gsheets`, "google drive" `gdrive`.
parts.some((p) => p.length >= 5 && extends_(p.slice(1), token))
)
}
export const clearHubIntegrationsCache = () => {
hubIntegrationsCache = undefined
}