feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,114 +1,99 @@
|
|||
// server/supabase/functions/search-knowledge/index.ts
|
||||
// 쿼리 텍스트 → OpenAI 임베딩 → match_knowledge_chunks RPC → 상위 청크 반환.
|
||||
//
|
||||
// 요청:
|
||||
// POST { query: "...", count?: number }
|
||||
// 응답:
|
||||
// { results: Array<{ id, document_id, chunk_index, content, similarity }> }
|
||||
|
||||
// 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'
|
||||
|
||||
interface SearchRequest {
|
||||
query: string
|
||||
count?: number
|
||||
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' },
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.103.0'
|
||||
function isEmbedding(value: unknown): value is number[] {
|
||||
return Array.isArray(value)
|
||||
&& value.length === EMBEDDING_DIMENSIONS
|
||||
&& value.every((entry) => typeof entry === 'number' && Number.isFinite(entry))
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||||
status: 405,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
if (req.method !== 'POST') return json(405, { error: 'method_not_allowed' })
|
||||
|
||||
try {
|
||||
await requireUser(req) // 인증만 확인. RPC는 auth.uid() 기반 RLS
|
||||
|
||||
const body = (await req.json()) as SearchRequest
|
||||
if (!body.query || body.query.trim().length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'query 필요' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
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' })
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno.env
|
||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
||||
if (!openaiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'openai_not_configured' }),
|
||||
{ status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
if (!openaiKey) return json(503, { error: 'embedding_provider_unavailable' })
|
||||
|
||||
// 1) 쿼리 임베딩
|
||||
const embedResp = 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: body.query,
|
||||
dimensions: 1536
|
||||
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),
|
||||
})
|
||||
})
|
||||
|
||||
if (!embedResp.ok) {
|
||||
const errText = await embedResp.text()
|
||||
throw new Error(`임베딩 실패: ${errText.slice(0, 200)}`)
|
||||
} catch {
|
||||
return json(502, { error: 'embedding_upstream_failed' })
|
||||
}
|
||||
if (!embeddingResponse.ok) return json(502, { error: 'embedding_upstream_failed' })
|
||||
|
||||
const embedData = (await embedResp.json()) as {
|
||||
data: Array<{ embedding: number[] }>
|
||||
}
|
||||
const queryEmbedding = embedData.data[0]?.embedding
|
||||
if (!queryEmbedding) {
|
||||
throw new Error('임베딩 응답이 비어있음')
|
||||
}
|
||||
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' })
|
||||
|
||||
// 2) RLS 컨텍스트에서 RPC 호출 (authenticated 유저 토큰 사용)
|
||||
const authHeader = req.headers.get('Authorization') ?? ''
|
||||
// @ts-expect-error — Deno.env
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
|
||||
// @ts-expect-error — Deno.env
|
||||
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 } }
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
})
|
||||
|
||||
const { data: matches, error: rpcErr } = await userClient.rpc('match_knowledge_chunks', {
|
||||
const result = await userClient.rpc('match_knowledge_chunks', {
|
||||
query_embedding: queryEmbedding,
|
||||
match_count: body.count ?? 5,
|
||||
similarity_threshold: 0.5
|
||||
match_count: count,
|
||||
similarity_threshold: 0.5,
|
||||
})
|
||||
if (result.error) return json(500, { error: 'knowledge_search_failed' })
|
||||
|
||||
if (rpcErr) {
|
||||
throw new Error(`RPC 실패: ${rpcErr.message}`)
|
||||
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 new Response(JSON.stringify({ results: matches ?? [] }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
return json(500, { error: 'internal_error' })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue