diff --git a/frontend/src/lib/components/copilot/chat/script/core.test.ts b/frontend/src/lib/components/copilot/chat/script/core.test.ts index 6e665b064d..2e07925f70 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.test.ts @@ -20,25 +20,9 @@ import { editCodeToolWithDiff, searchResourceTypes } from './core' describe('searchResourceTypes', () => { const syncedNames = Array.from({ length: 30 }, (_, i) => `type_${i}`) - // The similarity index trails the table by up to a day, and builds without embeddings - // have none, so a type the query names must still be found. - it('finds a type the query names when the similarity search misses it', async () => { - vi.spyOn(ResourceService, 'queryResourceTypes').mockResolvedValue([]) - vi.spyOn(ResourceService, 'listResourceTypeNames').mockResolvedValue([...syncedNames, 'stripe']) - vi.spyOn(ResourceService, 'getResourceType').mockResolvedValue({ - name: 'stripe', - schema: { type: 'object' } - }) - - const { resourceTypes, note } = await searchResourceTypes('Stripe payments', 'ws', 5) - - expect(resourceTypes.map((rt) => rt.name)).toEqual(['stripe']) - expect(note).toBeUndefined() - }) - - // An empty result from a failed search says nothing about whether a type exists: read as - // "none", it sends the model to define a type the instance already has. - it('hands back the type names when the similarity search fails', async () => { + // An empty search says nothing about whether a type exists (stale index, or no embeddings): + // read as "none", it sends the model to define a type the instance already has. + it('lists the instance types when the similarity search finds nothing', async () => { vi.spyOn(ResourceService, 'queryResourceTypes').mockRejectedValue(new Error('500')) vi.spyOn(ResourceService, 'listResourceTypeNames').mockResolvedValue([ ...syncedNames, @@ -49,23 +33,21 @@ describe('searchResourceTypes', () => { expect(resourceTypes).toEqual([]) expect(note).toContain('postgresql') - expect(note).not.toContain('type_0') + expect(note).not.toContain('never synced') }) - it('keeps every type the query names ahead of the other matches', async () => { - vi.spyOn(ResourceService, 'queryResourceTypes').mockResolvedValue( - ['a', 'b', 'c', 'd', 'stripe'].map((name) => ({ name, score: 0.8 })) - ) - vi.spyOn(ResourceService, 'listResourceTypeNames').mockResolvedValue([ - ...syncedNames, - 'stripe', - 'github' - ]) - vi.spyOn(ResourceService, 'getResourceType').mockResolvedValue({ name: 'github', schema: {} }) + it('returns the schema of a type the query names', async () => { + vi.spyOn(ResourceService, 'queryResourceTypes').mockResolvedValue([]) + vi.spyOn(ResourceService, 'listResourceTypeNames').mockResolvedValue([...syncedNames, 'stripe']) + vi.spyOn(ResourceService, 'getResourceType').mockResolvedValue({ + name: 'stripe', + schema: { type: 'object' } + }) - const { resourceTypes } = await searchResourceTypes('stripe github', 'ws', 5) + const { resourceTypes, note } = await searchResourceTypes('stripe', 'ws', 5) - expect(resourceTypes.map((rt) => rt.name)).toEqual(['stripe', 'github', 'a', 'b', 'c']) + expect(resourceTypes.map((rt) => rt.name)).toEqual(['stripe']) + expect(note).toBeUndefined() }) it('notes an instance that never synced with the hub', async () => { diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 0bdb02b0f2..7ec1d960a7 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -73,64 +73,44 @@ export function formatResourceTypes( const UNSYNCED_RESOURCE_TYPE_COUNT = 20 /** - * Similarity matches, led by any resource type the query names exactly: the similarity index - * is rebuilt from the table once a day, and builds without embeddings have no index at all. + * When the similarity search finds nothing, the instance's type names come back instead: the + * index is rebuilt from the table once a day and builds without embeddings have none, so an + * empty search does not mean no type fits. A query that is a type's name gets its schema. */ export async function searchResourceTypes( query: string, workspace: string, limit: number ): Promise<{ resourceTypes: Pick[]; note?: string }> { - let similarityFailed = false - const [similar, names] = await Promise.all([ - ResourceService.queryResourceTypes({ workspace, text: query, limit }).catch(() => { - similarityFailed = true - return [] - }), - ResourceService.listResourceTypeNames({ workspace }) - .then((listed) => new Set(listed)) - .catch(() => undefined) - ]) - - const normalized = query.trim().toLowerCase() - const words = normalized.split(/[^a-z0-9_]+/).filter((word) => word.length > 0) - const candidates = new Set([normalized.replace(/[\s-]+/g, '_'), ...words]) - const exact = await Promise.all( - [...candidates] - .filter((name) => names?.has(name)) - .slice(0, limit) - .map( - (name) => - similar.find((rt) => rt.name === name) ?? - ResourceService.getResourceType({ workspace, path: name }).catch(() => undefined) - ) + const similar = await ResourceService.queryResourceTypes({ workspace, text: query, limit }).catch( + () => [] ) - const named = exact.flatMap((rt) => (rt ? [{ name: rt.name, schema: rt.schema }] : [])) - const resourceTypes = [ - ...named, - ...similar.filter((rt) => !named.some((n) => n.name === rt.name)) - ].slice(0, limit) + if (similar.length > 0) { + return { resourceTypes: similar } + } - // Without the similarity search, an empty result says nothing about whether a type exists, - // so the model gets the names close to its query rather than concluding there is none. - const related = - similarityFailed && names - ? [...names] - .filter((name) => - words.some((word) => word.length >= 3 && (name.includes(word) || word.includes(name))) - ) - .slice(0, 30) - : [] - const notes = [ - similarityFailed && names - ? `The similarity search is unavailable on this instance, so only a type named in the query can be found. ${related.length > 0 ? `Resource types whose names share a word with the query: ${related.join(', ')}.` : "Search again with the service's name."}` - : undefined, - names && names.size < UNSYNCED_RESOURCE_TYPE_COUNT - ? `Only ${names.size} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. ${HUB_SYNC_INSTRUCTIONS}` - : undefined - ].filter((note) => note !== undefined) + const names = [ + ...new Set(await ResourceService.listResourceTypeNames({ workspace }).catch(() => [])) + ] + const name = query.trim().toLowerCase() + const named = names.includes(name) + ? await ResourceService.getResourceType({ workspace, path: name }).catch(() => undefined) + : undefined + if (named) { + return { resourceTypes: [{ name: named.name, schema: named.schema }] } + } + if (names.length === 0) { + return { resourceTypes: [] } + } - return { resourceTypes, note: notes.length > 0 ? notes.join('\n\n') : undefined } + const unsynced = + names.length < UNSYNCED_RESOURCE_TYPE_COUNT + ? `\n\nOnly ${names.length} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. ${HUB_SYNC_INSTRUCTIONS}` + : '' + return { + resourceTypes: [], + note: `The resource types on this instance are: ${names.join(', ')}. Search again with one of these names to get its schema.${unsynced}` + } } export const SUPPORTED_CHAT_SCRIPT_LANGUAGES = [