180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
|
|
const EMBEDDING_DIMENSIONS = 1536
|
|
const PROVIDER_TIMEOUT_MS = 45_000
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
|
|
interface OpenAIEmbeddingResponse {
|
|
data?: Array<{ embedding?: unknown; index?: unknown }>
|
|
}
|
|
|
|
function json(status: number, body: Record<string, unknown>): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
function validEmbedding(value: unknown): value is number[] {
|
|
return Array.isArray(value)
|
|
&& value.length === EMBEDDING_DIMENSIONS
|
|
&& value.every((entry) => typeof entry === 'number' && Number.isFinite(entry))
|
|
}
|
|
|
|
Deno.serve(async (req: Request) => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) return preflight
|
|
if (req.method !== 'POST') return json(405, { error: 'method_not_allowed' })
|
|
|
|
try {
|
|
const user = await requireUser(req)
|
|
const body = await req.json().catch(() => null) as { document_id?: unknown } | null
|
|
if (!body || typeof body.document_id !== 'string' || !UUID_PATTERN.test(body.document_id)) {
|
|
return json(400, { error: 'invalid_document_id' })
|
|
}
|
|
|
|
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
|
if (!openaiKey) return json(503, { error: 'embedding_provider_unavailable' })
|
|
|
|
const serviceClient = createServiceRoleClient()
|
|
const documentResult = await serviceClient
|
|
.from('knowledge_documents')
|
|
.select('id,user_id')
|
|
.eq('id', body.document_id)
|
|
.maybeSingle()
|
|
if (documentResult.error) return json(500, { error: 'knowledge_storage_failed' })
|
|
if (!documentResult.data || documentResult.data.user_id !== user.id) {
|
|
return json(404, { error: 'knowledge_document_not_found' })
|
|
}
|
|
|
|
const totalResult = await serviceClient
|
|
.from('knowledge_chunks')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('document_id', body.document_id)
|
|
if (totalResult.error) return json(500, { error: 'knowledge_storage_failed' })
|
|
const total = totalResult.count ?? 0
|
|
if (total === 0) {
|
|
await serviceClient
|
|
.from('knowledge_documents')
|
|
.update({ indexed: false, indexed_at: null })
|
|
.eq('id', body.document_id)
|
|
return json(409, { error: 'knowledge_document_empty' })
|
|
}
|
|
|
|
const pendingResult = await serviceClient
|
|
.from('knowledge_chunks')
|
|
.select('id,content')
|
|
.eq('document_id', body.document_id)
|
|
.is('embedding', null)
|
|
.order('chunk_index', { ascending: true })
|
|
if (pendingResult.error) return json(500, { error: 'knowledge_storage_failed' })
|
|
|
|
const chunks = (pendingResult.data ?? []) as Array<{ id: string; content: string }>
|
|
let embedded = 0
|
|
let failed = 0
|
|
|
|
for (let offset = 0; offset < chunks.length; offset += 100) {
|
|
const batch = chunks.slice(offset, offset + 100)
|
|
try {
|
|
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${openaiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'text-embedding-3-small',
|
|
input: batch.map((chunk) => chunk.content),
|
|
dimensions: EMBEDDING_DIMENSIONS,
|
|
}),
|
|
signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
|
|
})
|
|
if (!response.ok) {
|
|
failed += batch.length
|
|
continue
|
|
}
|
|
|
|
const payload = await response.json().catch(() => null) as OpenAIEmbeddingResponse | null
|
|
if (!payload || !Array.isArray(payload.data) || payload.data.length !== batch.length) {
|
|
failed += batch.length
|
|
continue
|
|
}
|
|
|
|
const received = new Set<number>()
|
|
let batchSuccess = 0
|
|
for (const item of payload.data) {
|
|
if (
|
|
typeof item.index !== 'number'
|
|
|| !Number.isInteger(item.index)
|
|
|| item.index < 0
|
|
|| item.index >= batch.length
|
|
|| received.has(item.index)
|
|
|| !validEmbedding(item.embedding)
|
|
) {
|
|
continue
|
|
}
|
|
received.add(item.index)
|
|
const update = await serviceClient
|
|
.from('knowledge_chunks')
|
|
.update({ embedding: item.embedding })
|
|
.eq('id', batch[item.index].id)
|
|
.eq('document_id', body.document_id)
|
|
if (!update.error) {
|
|
embedded += 1
|
|
batchSuccess += 1
|
|
}
|
|
}
|
|
failed += batch.length - batchSuccess
|
|
} catch {
|
|
failed += batch.length
|
|
}
|
|
}
|
|
|
|
const remainingResult = await serviceClient
|
|
.from('knowledge_chunks')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('document_id', body.document_id)
|
|
.is('embedding', null)
|
|
if (remainingResult.error) return json(500, { error: 'knowledge_storage_failed' })
|
|
|
|
const remaining = remainingResult.count ?? total
|
|
if (failed > 0 || remaining > 0) {
|
|
const rollback = await serviceClient
|
|
.from('knowledge_documents')
|
|
.update({ indexed: false, indexed_at: null })
|
|
.eq('id', body.document_id)
|
|
.eq('user_id', user.id)
|
|
if (rollback.error) return json(500, { error: 'knowledge_storage_failed' })
|
|
return json(502, {
|
|
error: 'embedding_failed',
|
|
embedded,
|
|
remaining,
|
|
})
|
|
}
|
|
|
|
const indexed = await serviceClient
|
|
.from('knowledge_documents')
|
|
.update({ indexed: true, indexed_at: new Date().toISOString() })
|
|
.eq('id', body.document_id)
|
|
.eq('user_id', user.id)
|
|
.select('id')
|
|
.maybeSingle()
|
|
if (indexed.error || !indexed.data) return json(500, { error: 'knowledge_storage_failed' })
|
|
|
|
return json(200, { embedded, total, indexed: true })
|
|
} catch (error) {
|
|
if (
|
|
error
|
|
&& typeof error === 'object'
|
|
&& 'status' in error
|
|
&& (error.status === 401 || error.status === 403)
|
|
&& 'message' in error
|
|
&& typeof error.message === 'string'
|
|
) {
|
|
return authErrorResponse(error as AuthError, corsHeaders)
|
|
}
|
|
return json(500, { error: 'internal_error' })
|
|
}
|
|
})
|