import { type NextRequest, NextResponse } from 'next/server' import { v1KnowledgeSearchContract } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' import { generateSearchEmbedding, getDocumentMetadataByIds, getQueryStrategy, handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch, type SearchResult, } from '@/app/api/knowledge/search/utils' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { handleError } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 /** POST /api/v1/knowledge/search — Vector search across knowledge bases. */ export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await authenticateRequest(request, 'knowledge-search') if (auth instanceof NextResponse) return auth const { requestId, userId, rateLimit } = auth try { const parsed = await parseRequest(v1KnowledgeSearchContract, request, {}) if (!parsed.success) return parsed.response const { workspaceId, topK, query, tagFilters } = parsed.data.body const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) if (accessError) return accessError // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's // usage and frozen status before spending. Tag-only search is free, so skip it. if (query && query.trim().length > 0) { const usage = await checkActorUsageLimits(userId, workspaceId) if (usage.isExceeded) { return NextResponse.json( { error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' }, { status: 402 } ) } } const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) ? parsed.data.body.knowledgeBaseIds : [parsed.data.body.knowledgeBaseIds] const accessChecks = await Promise.all( knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) ) const accessibleKbs = accessChecks .filter( (ac): ac is KnowledgeBaseAccessResult => ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId ) .map((ac) => ac.knowledgeBase) const accessibleKbIds = accessibleKbs.map((kb) => kb.id) if (accessibleKbIds.length === 0) { return NextResponse.json( { error: 'Knowledge base not found or access denied' }, { status: 404 } ) } const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) if (inaccessibleKbIds.length > 0) { return NextResponse.json( { error: `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` }, { status: 404 } ) } let structuredFilters: StructuredFilter[] = [] const tagDefsCache = new Map>>() if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { return NextResponse.json( { error: 'Tag filters are only supported when searching a single knowledge base' }, { status: 400 } ) } if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { const kbId = accessibleKbIds[0] const tagDefs = await getDocumentTagDefinitions(kbId) tagDefsCache.set(kbId, tagDefs) const displayNameToTagDef: Record = {} tagDefs.forEach((def) => { displayNameToTagDef[def.displayName] = { tagSlot: def.tagSlot, fieldType: def.fieldType, } }) const undefinedTags: string[] = [] const typeErrors: string[] = [] for (const filter of tagFilters) { const tagDef = displayNameToTagDef[filter.tagName] if (!tagDef) { undefinedTags.push(filter.tagName) continue } const validationError = validateTagValue( filter.tagName, String(filter.value), tagDef.fieldType ) if (validationError) { typeErrors.push(validationError) } } if (undefinedTags.length > 0 || typeErrors.length > 0) { const errorParts: string[] = [] if (undefinedTags.length > 0) { errorParts.push(buildUndefinedTagsError(undefinedTags)) } if (typeErrors.length > 0) { errorParts.push(...typeErrors) } return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 }) } structuredFilters = tagFilters.map((filter) => { const tagDef = displayNameToTagDef[filter.tagName]! return { tagSlot: tagDef.tagSlot, fieldType: tagDef.fieldType, operator: filter.operator, value: filter.value, valueTo: filter.valueTo, } }) } const hasQuery = query && query.trim().length > 0 const hasFilters = structuredFilters.length > 0 const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) if (hasQuery && embeddingModels.length > 1) { return NextResponse.json( { error: 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.', }, { status: 400 } ) } const queryEmbeddingModel = embeddingModels[0] let results: SearchResult[] let queryEmbeddingIsBYOK: boolean | null = null if (!hasQuery && hasFilters) { results = await handleTagOnlySearch({ knowledgeBaseIds: accessibleKbIds, topK, structuredFilters, }) } else if (hasQuery && hasFilters) { const strategy = getQueryStrategy(accessibleKbIds.length, topK) const queryEmbeddingResult = await generateSearchEmbedding( query!, queryEmbeddingModel, workspaceId ) queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK const queryVector = JSON.stringify(queryEmbeddingResult.embedding) results = await handleTagAndVectorSearch({ knowledgeBaseIds: accessibleKbIds, topK, structuredFilters, queryVector, distanceThreshold: strategy.distanceThreshold, }) } else if (hasQuery) { const strategy = getQueryStrategy(accessibleKbIds.length, topK) const queryEmbeddingResult = await generateSearchEmbedding( query!, queryEmbeddingModel, workspaceId ) queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK const queryVector = JSON.stringify(queryEmbeddingResult.embedding) results = await handleVectorOnlySearch({ knowledgeBaseIds: accessibleKbIds, topK, queryVector, distanceThreshold: strategy.distanceThreshold, }) } else { return NextResponse.json( { error: 'Either query or tagFilters must be provided' }, { status: 400 } ) } if (queryEmbeddingIsBYOK !== null) { await recordSearchEmbeddingUsage({ userId, workspaceId, embeddingModel: queryEmbeddingModel, query: query!, isBYOK: queryEmbeddingIsBYOK, sourceReference: `v1-kb-search:${requestId}`, }) } const tagDefsResults = await Promise.all( accessibleKbIds.map(async (kbId) => { try { const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) const map: Record = {} tagDefs.forEach((def) => { map[def.tagSlot] = def.displayName }) return { kbId, map } } catch { return { kbId, map: {} as Record } } }) ) const tagDefinitionsMap: Record> = {} tagDefsResults.forEach(({ kbId, map }) => { tagDefinitionsMap[kbId] = map }) const documentIds = results.map((r) => r.documentId) const documentMetadataMap = await getDocumentMetadataByIds(documentIds) return NextResponse.json({ success: true, data: { results: results.map((result) => { const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} const tags: Record = {} ALL_TAG_SLOTS.forEach((slot) => { const tagValue = result[slot as keyof SearchResult] if (tagValue !== null && tagValue !== undefined) { const displayName = kbTagMap[slot] || slot tags[displayName] = tagValue as string | number | boolean | Date | null } }) const docMeta = documentMetadataMap[result.documentId] return { documentId: result.documentId, documentName: docMeta?.filename || undefined, sourceUrl: docMeta?.sourceUrl ?? null, content: result.content, chunkIndex: result.chunkIndex, metadata: tags, similarity: hasQuery ? 1 - result.distance : 1, } }), query: query || '', knowledgeBaseIds: accessibleKbIds, topK, totalResults: results.length, }, }) } catch (error) { return handleError(requestId, error, 'Failed to perform search') } })