d3ro-voice/server/supabase/functions/search-knowledge/index.ts
Yun Chan 0273c6abaa
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
fix(edge): stop short clips waiting a minute on the NAS and make the OpenAI STT fallback usable
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.
2026-09-27 18:02:14 +09:00

100 lines
3.7 KiB
TypeScript

// deno-lint-ignore no-import-prefix
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { readProviderKey } from '../_shared/provider-key.ts'
const EMBEDDING_DIMENSIONS = 1536
const PROVIDER_TIMEOUT_MS = 45_000
function json(status: number, body: Record<string, unknown>): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
function isEmbedding(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 {
await requireUser(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 ? 5 : body.count
if (!query || query.length > 4_000) return json(400, { error: 'invalid_query' })
if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > 20) {
return json(400, { error: 'invalid_count' })
}
const openaiKey = readProviderKey('OPENAI_API_KEY')
if (!openaiKey) return json(503, { error: 'embedding_provider_unavailable' })
let embeddingResponse: Response
try {
embeddingResponse = 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: query,
dimensions: EMBEDDING_DIMENSIONS,
}),
signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
})
} catch {
return json(502, { error: 'embedding_upstream_failed' })
}
if (!embeddingResponse.ok) return json(502, { error: 'embedding_upstream_failed' })
const embeddingPayload = await embeddingResponse.json().catch(() => null) as {
data?: Array<{ embedding?: unknown }>
} | null
const queryEmbedding = embeddingPayload?.data?.[0]?.embedding
if (!isEmbedding(queryEmbedding)) return json(502, { error: 'embedding_response_invalid' })
const authHeader = req.headers.get('Authorization') ?? ''
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
const anonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''
if (!supabaseUrl || !anonKey) return json(503, { error: 'knowledge_storage_unavailable' })
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
auth: { persistSession: false, autoRefreshToken: false },
})
const result = await userClient.rpc('match_knowledge_chunks', {
query_embedding: queryEmbedding,
match_count: count,
similarity_threshold: 0.5,
})
if (result.error) return json(500, { error: 'knowledge_search_failed' })
return json(200, { results: result.data ?? [] })
} 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' })
}
})