All checks were successful
ci / 정본·보안·린트·타입·테스트 (push) Successful in 52s
ci / 모바일 린트·타입·Jest (push) Successful in 41s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 22s
ci / .NET API 서버 테스트 (push) Successful in 14s
deploy-site / deploy (push) Successful in 41s
ci / 워크스페이스 빌드 검증 (push) Successful in 36s
A five-second phone recording took over a minute: stt-proxy waited up to 60 s for the self-hosted gateway, whose GPU endpoint was off and whose NAS CPU Whisper needs 30-90 s per clip. With a direct provider configured the gateway now gets 5 s plus the clip length (30 s cap). The direct OpenAI fallback never produced a result. The production key held characters that are not valid in an HTTP header, so every request threw while being built; provider keys are now stripped of BOM/zero-width characters and a still-invalid key counts as not configured. whisper-1 verbose_json reports the language by name, which the result contract rejected; names now map to codes. Fail-closed responses list each provider's failure (status or error class, no secrets) so an outage can be diagnosed without log access.
181 lines
6.3 KiB
TypeScript
181 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'
|
|
import { readProviderKey } from '../_shared/provider-key.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 = readProviderKey('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' })
|
|
}
|
|
})
|