Skip to content

Commit b70fc9e

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
Add knowledge tag ID canonical input
1 parent 6d48444 commit b70fc9e

25 files changed

Lines changed: 1470 additions & 105 deletions

File tree

apps/sim/app/api/knowledge/search/route.test.ts

Lines changed: 264 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -700,8 +700,8 @@ describe('Knowledge Search API Route', () => {
700700

701701
describe('Optional Query Search', () => {
702702
const mockTagDefinitions = [
703-
{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
704-
{ tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' },
703+
{ id: 'tag-category', tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
704+
{ id: 'tag-priority', tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' },
705705
]
706706

707707
const mockTaggedResults = [
@@ -772,6 +772,268 @@ describe('Knowledge Search API Route', () => {
772772
})
773773
})
774774

775+
it('should resolve tag IDs and derive each filter type and slot server-side', async () => {
776+
const tagOnlyData = {
777+
knowledgeBaseIds: 'kb-123',
778+
tagFilters: [
779+
{ tagId: 'tag-text', value: 'api', fieldType: 'boolean', operator: 'contains' },
780+
{
781+
tagId: 'tag-number',
782+
value: 10,
783+
valueTo: 20,
784+
fieldType: 'text',
785+
operator: 'between',
786+
},
787+
{ tagId: 'tag-date', value: '2026-07-27', fieldType: 'text', operator: 'lte' },
788+
{ tagId: 'tag-boolean', value: true, fieldType: 'number', operator: 'eq' },
789+
],
790+
topK: 10,
791+
}
792+
const tagDefinitions = [
793+
{ id: 'tag-text', tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
794+
{ id: 'tag-number', tagSlot: 'number1', displayName: 'score', fieldType: 'number' },
795+
{ id: 'tag-date', tagSlot: 'date1', displayName: 'published', fieldType: 'date' },
796+
{ id: 'tag-boolean', tagSlot: 'boolean1', displayName: 'verified', fieldType: 'boolean' },
797+
]
798+
799+
mockCheckKnowledgeBaseAccess.mockResolvedValue({
800+
hasAccess: true,
801+
knowledgeBase: {
802+
id: 'kb-123',
803+
userId: 'user-123',
804+
name: 'Test KB',
805+
deletedAt: null,
806+
embeddingModel: 'text-embedding-3-small',
807+
},
808+
})
809+
mockGetDocumentTagDefinitions.mockResolvedValue(tagDefinitions)
810+
mockHandleTagOnlySearch.mockResolvedValue([])
811+
812+
const req = createMockRequest('POST', tagOnlyData)
813+
const response = await POST(req)
814+
815+
expect(response.status).toBe(200)
816+
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
817+
knowledgeBaseIds: ['kb-123'],
818+
topK: 10,
819+
structuredFilters: [
820+
{
821+
tagSlot: 'tag1',
822+
fieldType: 'text',
823+
operator: 'contains',
824+
value: 'api',
825+
valueTo: undefined,
826+
},
827+
{
828+
tagSlot: 'number1',
829+
fieldType: 'number',
830+
operator: 'between',
831+
value: 10,
832+
valueTo: 20,
833+
},
834+
{
835+
tagSlot: 'date1',
836+
fieldType: 'date',
837+
operator: 'lte',
838+
value: '2026-07-27',
839+
valueTo: undefined,
840+
},
841+
{
842+
tagSlot: 'boolean1',
843+
fieldType: 'boolean',
844+
operator: 'eq',
845+
value: true,
846+
valueTo: undefined,
847+
},
848+
],
849+
})
850+
})
851+
852+
it('should reject a tag ID that does not belong to the selected knowledge base', async () => {
853+
mockCheckKnowledgeBaseAccess.mockResolvedValue({
854+
hasAccess: true,
855+
knowledgeBase: {
856+
id: 'kb-123',
857+
userId: 'user-123',
858+
name: 'Test KB',
859+
deletedAt: null,
860+
embeddingModel: 'text-embedding-3-small',
861+
},
862+
})
863+
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
864+
865+
const req = createMockRequest('POST', {
866+
knowledgeBaseIds: 'kb-123',
867+
tagFilters: [{ tagId: 'tag-from-another-kb', value: 'api', operator: 'eq' }],
868+
})
869+
const response = await POST(req)
870+
const data = await response.json()
871+
872+
expect(response.status).toBe(400)
873+
expect(data.error).toContain(
874+
'Tag IDs not found in the selected knowledge base: tag-from-another-kb'
875+
)
876+
expect(mockHandleTagOnlySearch).not.toHaveBeenCalled()
877+
})
878+
879+
it('should reject ambiguous filters containing both tag name and tag ID', async () => {
880+
const req = createMockRequest('POST', {
881+
knowledgeBaseIds: 'kb-123',
882+
tagFilters: [
883+
{
884+
tagName: 'category',
885+
tagId: 'tag-category',
886+
value: 'api',
887+
operator: 'eq',
888+
},
889+
],
890+
})
891+
const response = await POST(req)
892+
const data = await response.json()
893+
894+
expect(response.status).toBe(400)
895+
expect(data.error).toBe('Validation error')
896+
expect(data.details).toEqual(
897+
expect.arrayContaining([
898+
expect.objectContaining({
899+
message: 'Each tag filter must include exactly one of tagName or tagId',
900+
}),
901+
])
902+
)
903+
})
904+
905+
it('should reject a configured filter whose dynamic tag ID resolved empty', async () => {
906+
const req = createMockRequest('POST', {
907+
knowledgeBaseIds: 'kb-123',
908+
query: 'api',
909+
tagFilters: [{ value: 'api', operator: 'eq' }],
910+
})
911+
const response = await POST(req)
912+
const data = await response.json()
913+
914+
expect(response.status).toBe(400)
915+
expect(data.error).toBe('Validation error')
916+
expect(data.details).toEqual(
917+
expect.arrayContaining([
918+
expect.objectContaining({
919+
message: 'Each tag filter must include exactly one of tagName or tagId',
920+
}),
921+
])
922+
)
923+
})
924+
925+
it('should reject tag ID filters across multiple knowledge bases', async () => {
926+
mockCheckKnowledgeBaseAccess.mockImplementation(async (knowledgeBaseId: string) => ({
927+
hasAccess: true,
928+
knowledgeBase: {
929+
id: knowledgeBaseId,
930+
userId: 'user-123',
931+
name: 'Test KB',
932+
deletedAt: null,
933+
embeddingModel: 'text-embedding-3-small',
934+
},
935+
}))
936+
937+
const req = createMockRequest('POST', {
938+
knowledgeBaseIds: ['kb-123', 'kb-456'],
939+
tagFilters: [{ tagId: 'tag-category', value: 'api', operator: 'eq' }],
940+
})
941+
const response = await POST(req)
942+
const data = await response.json()
943+
944+
expect(response.status).toBe(400)
945+
expect(data.error).toContain('Tag ID filters can only search one knowledge base at a time')
946+
expect(mockGetDocumentTagDefinitions).not.toHaveBeenCalled()
947+
})
948+
949+
it('should validate the operator against the tag type resolved from the ID', async () => {
950+
mockCheckKnowledgeBaseAccess.mockResolvedValue({
951+
hasAccess: true,
952+
knowledgeBase: {
953+
id: 'kb-123',
954+
userId: 'user-123',
955+
name: 'Test KB',
956+
deletedAt: null,
957+
embeddingModel: 'text-embedding-3-small',
958+
},
959+
})
960+
mockGetDocumentTagDefinitions.mockResolvedValue([
961+
{ id: 'tag-enabled', tagSlot: 'tag7', displayName: 'enabled', fieldType: 'boolean' },
962+
])
963+
964+
const req = createMockRequest('POST', {
965+
knowledgeBaseIds: 'kb-123',
966+
tagFilters: [{ tagId: 'tag-enabled', value: true, operator: 'contains' }],
967+
})
968+
const response = await POST(req)
969+
const data = await response.json()
970+
971+
expect(response.status).toBe(400)
972+
expect(data.error).toContain('Operator "contains" is not valid for boolean tag "enabled"')
973+
expect(mockHandleTagOnlySearch).not.toHaveBeenCalled()
974+
})
975+
976+
it.each([
977+
{
978+
label: 'missing second value',
979+
fieldType: 'number',
980+
valueTo: undefined,
981+
expectedError: 'requires a second value for the "between" operator',
982+
},
983+
{
984+
label: 'invalid number',
985+
fieldType: 'number',
986+
valueTo: 'not-a-number',
987+
expectedError: 'Invalid second value for "between"',
988+
},
989+
{
990+
label: 'invalid date',
991+
fieldType: 'date',
992+
valueTo: '2026-02-31',
993+
expectedError: 'Invalid second value for "between"',
994+
},
995+
])(
996+
'should reject $label in an ID-resolved between filter',
997+
async ({ fieldType, valueTo, expectedError }) => {
998+
mockCheckKnowledgeBaseAccess.mockResolvedValue({
999+
hasAccess: true,
1000+
knowledgeBase: {
1001+
id: 'kb-123',
1002+
userId: 'user-123',
1003+
name: 'Test KB',
1004+
deletedAt: null,
1005+
embeddingModel: 'text-embedding-3-small',
1006+
},
1007+
})
1008+
mockGetDocumentTagDefinitions.mockResolvedValue([
1009+
{
1010+
id: 'tag-range',
1011+
tagSlot: fieldType === 'date' ? 'date1' : 'number1',
1012+
displayName: 'range',
1013+
fieldType,
1014+
},
1015+
])
1016+
1017+
const req = createMockRequest('POST', {
1018+
knowledgeBaseIds: 'kb-123',
1019+
tagFilters: [
1020+
{
1021+
tagId: 'tag-range',
1022+
value: fieldType === 'date' ? '2026-01-01' : 1,
1023+
valueTo,
1024+
operator: 'between',
1025+
},
1026+
],
1027+
})
1028+
const response = await POST(req)
1029+
const data = await response.json()
1030+
1031+
expect(response.status).toBe(400)
1032+
expect(data.error).toContain(expectedError)
1033+
expect(mockHandleTagOnlySearch).not.toHaveBeenCalled()
1034+
}
1035+
)
1036+
7751037
it('should perform query + tag combination search', async () => {
7761038
const combinedData = {
7771039
knowledgeBaseIds: 'kb-123',

0 commit comments

Comments
 (0)