91 lines
3.6 KiB
TypeScript
91 lines
3.6 KiB
TypeScript
// server/supabase/functions/search-knowledge/handler.ts
|
|
// Use case: embed a search query and return the caller's matching chunks.
|
|
// IO is injected through ports so the quota guard is testable offline.
|
|
|
|
import { handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { errorResponse, jsonResponse as json } from '../_shared/json-response.ts'
|
|
import { type EmbeddingProvider, isEmbedding } from '../_shared/openai-embeddings.ts'
|
|
import {
|
|
EMBEDDING_LIMITS,
|
|
type EmbeddingReservation,
|
|
type EmbeddingUsageStore,
|
|
embeddingUnitsFor,
|
|
quotaExceededBody,
|
|
refundEmbeddingUnits,
|
|
reserveEmbeddingUnits,
|
|
} from '../_shared/embedding-quota.ts'
|
|
|
|
export const DEFAULT_MATCH_COUNT = 5
|
|
export const MAX_MATCH_COUNT = 20
|
|
export const SIMILARITY_THRESHOLD = 0.5
|
|
|
|
export type ChunkSearchOutcome =
|
|
| { ok: true; results: unknown[] }
|
|
| { ok: false; reason: 'unavailable' | 'failed' }
|
|
|
|
/** Port: similarity search run with the caller's own JWT (RLS scopes the rows). */
|
|
export type ChunkSearcher = (req: Request, embedding: number[], count: number) => Promise<ChunkSearchOutcome>
|
|
|
|
export interface SearchKnowledgeDeps {
|
|
authenticate(req: Request): Promise<{ id: string }>
|
|
/** null when the provider key is not configured. */
|
|
embeddingProvider(): EmbeddingProvider | null
|
|
usageStore(): EmbeddingUsageStore
|
|
searchChunks: ChunkSearcher
|
|
now?(): Date
|
|
}
|
|
|
|
export function createSearchKnowledgeHandler(deps: SearchKnowledgeDeps): (req: Request) => Promise<Response> {
|
|
const now = () => deps.now?.() ?? new Date()
|
|
|
|
return async (req: Request): Promise<Response> => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) return preflight
|
|
if (req.method !== 'POST') return json(405, { error: 'method_not_allowed' })
|
|
|
|
try {
|
|
const user = await deps.authenticate(req)
|
|
const body = await req.json().catch(() => null) as {
|
|
query?: unknown
|
|
count?: unknown
|
|
} | null
|
|
const query = typeof body?.query === 'string' ? body.query.trim() : ''
|
|
const count = body?.count === undefined ? DEFAULT_MATCH_COUNT : body.count
|
|
if (!query || query.length > EMBEDDING_LIMITS.maxQueryChars) return json(400, { error: 'invalid_query' })
|
|
if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > MAX_MATCH_COUNT) {
|
|
return json(400, { error: 'invalid_count' })
|
|
}
|
|
|
|
const provider = deps.embeddingProvider()
|
|
if (!provider) return json(503, { error: 'embedding_provider_unavailable' })
|
|
|
|
const usage = deps.usageStore()
|
|
const units = embeddingUnitsFor(query)
|
|
let reservation: EmbeddingReservation
|
|
try {
|
|
reservation = await reserveEmbeddingUnits(usage, user.id, units, now())
|
|
} catch {
|
|
return json(503, { error: 'quota_unavailable' })
|
|
}
|
|
if (!reservation.allowed) return json(429, quotaExceededBody(reservation, units))
|
|
|
|
const outcome = await provider.embed(query)
|
|
if (!outcome.ok && outcome.reason === 'upstream') {
|
|
await refundEmbeddingUnits(usage, user.id, reservation, units, now())
|
|
return json(502, { error: 'embedding_upstream_failed' })
|
|
}
|
|
const queryEmbedding = outcome.ok ? outcome.data[0]?.embedding : undefined
|
|
if (!isEmbedding(queryEmbedding)) return json(502, { error: 'embedding_response_invalid' })
|
|
|
|
const result = await deps.searchChunks(req, queryEmbedding, count)
|
|
if (!result.ok) {
|
|
return result.reason === 'unavailable'
|
|
? json(503, { error: 'knowledge_storage_unavailable' })
|
|
: json(500, { error: 'knowledge_search_failed' })
|
|
}
|
|
return json(200, { results: result.results })
|
|
} catch (error) {
|
|
return errorResponse(error)
|
|
}
|
|
}
|
|
}
|