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.
241 lines
8.7 KiB
TypeScript
241 lines
8.7 KiB
TypeScript
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { authErrorResponse, requireUser, type AuthError } from '../_shared/auth.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
import {
|
|
MeetingDocumentRequestError,
|
|
parseGenerateMeetingDocumentRequest,
|
|
parseProviderDocumentResult,
|
|
} from '../_shared/meeting-document-contract.ts'
|
|
import { buildMeetingDocumentSystemPrompt } from '../_shared/generative-ai-safety.ts'
|
|
import { readProviderKey } from '../_shared/provider-key.ts'
|
|
|
|
interface ClaimResult {
|
|
claimed: boolean
|
|
status: 'processing' | 'succeeded' | 'failed'
|
|
documentId: string | null
|
|
meetingTitle: string
|
|
documentTitle: string
|
|
templateType: string
|
|
systemPrompt: string | null
|
|
transcript: string | null
|
|
model: string
|
|
}
|
|
|
|
function json(status: number, body: Record<string, unknown>): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
})
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function parseClaim(value: unknown): ClaimResult | null {
|
|
if (!isRecord(value)) return null
|
|
if (
|
|
typeof value.claimed !== 'boolean'
|
|
|| !['processing', 'succeeded', 'failed'].includes(String(value.status))
|
|
|| (value.documentId !== null && typeof value.documentId !== 'string')
|
|
|| typeof value.meetingTitle !== 'string'
|
|
|| typeof value.documentTitle !== 'string'
|
|
|| typeof value.templateType !== 'string'
|
|
|| (value.systemPrompt !== null && typeof value.systemPrompt !== 'string')
|
|
|| (value.transcript !== null && typeof value.transcript !== 'string')
|
|
|| typeof value.model !== 'string'
|
|
) {
|
|
return null
|
|
}
|
|
return value as unknown as ClaimResult
|
|
}
|
|
|
|
function databaseErrorResponse(error: { code?: string; message?: string } | null): Response {
|
|
const code = error?.code ?? ''
|
|
const message = error?.message ?? ''
|
|
if (code === '42501') return json(403, { error: 'forbidden' })
|
|
if (code === 'P0002') return json(404, { error: 'not_found' })
|
|
if (code === '22023') return json(400, { error: 'invalid_request' })
|
|
if (message.includes('generation_quota_exceeded')) {
|
|
return json(429, { error: 'quota_exceeded' })
|
|
}
|
|
console.error('meeting document database operation failed', { code: code || 'unknown' })
|
|
return json(500, { error: 'internal_error' })
|
|
}
|
|
|
|
Deno.serve(async (request: Request) => {
|
|
const preflight = handleCorsPreflightRequest(request)
|
|
if (preflight) return preflight
|
|
if (request.method !== 'POST') return json(405, { error: 'method_not_allowed' })
|
|
|
|
let actorId: string | null = null
|
|
let idempotencyKey: string | null = null
|
|
const serviceClient = createServiceRoleClient()
|
|
|
|
const markFailed = async (errorCode:
|
|
| 'provider_timeout'
|
|
| 'provider_request_failed'
|
|
| 'provider_invalid_response'
|
|
| 'quota_exceeded'
|
|
| 'commit_failed'): Promise<void> => {
|
|
if (actorId === null || idempotencyKey === null) return
|
|
const { error } = await serviceClient.rpc('fail_meeting_document_generation_v1', {
|
|
p_actor_id: actorId,
|
|
p_idempotency_key: idempotencyKey,
|
|
p_error_code: errorCode,
|
|
})
|
|
if (error !== null) {
|
|
console.error('meeting document failure marker failed', { code: error.code ?? 'unknown' })
|
|
}
|
|
}
|
|
|
|
try {
|
|
const actor = await requireUser(request)
|
|
actorId = actor.id
|
|
|
|
let rawBody: unknown
|
|
try {
|
|
rawBody = await request.json()
|
|
} catch {
|
|
throw new MeetingDocumentRequestError('Invalid JSON body')
|
|
}
|
|
const body = parseGenerateMeetingDocumentRequest(rawBody)
|
|
idempotencyKey = body.idempotencyKey
|
|
|
|
const providerKey = readProviderKey('ANTHROPIC_API_KEY')
|
|
if (providerKey.length === 0) {
|
|
return json(503, { error: 'provider_unavailable' })
|
|
}
|
|
|
|
const { data: claimData, error: claimError } = await serviceClient.rpc(
|
|
'claim_meeting_document_generation_v1',
|
|
{
|
|
p_actor_id: actor.id,
|
|
p_idempotency_key: body.idempotencyKey,
|
|
p_meeting_id: body.meetingId,
|
|
p_template_id: body.templateId,
|
|
p_title: body.title,
|
|
p_model: body.model,
|
|
},
|
|
)
|
|
if (claimError !== null) return databaseErrorResponse(claimError)
|
|
|
|
const claim = parseClaim(claimData)
|
|
if (claim === null) {
|
|
console.error('meeting document claim returned an invalid shape')
|
|
return json(500, { error: 'internal_error' })
|
|
}
|
|
if (!claim.claimed) {
|
|
if (claim.status === 'succeeded' && claim.documentId !== null) {
|
|
const { data: document, error } = await serviceClient
|
|
.from('meeting_documents')
|
|
.select('*')
|
|
.eq('id', claim.documentId)
|
|
.eq('user_id', actor.id)
|
|
.maybeSingle()
|
|
if (error !== null || document === null) {
|
|
console.error('idempotent meeting document lookup failed', { code: error?.code ?? 'missing' })
|
|
return json(500, { error: 'internal_error' })
|
|
}
|
|
return json(200, { document, idempotent: true })
|
|
}
|
|
return json(409, {
|
|
error: claim.status === 'processing' ? 'generation_in_progress' : 'generation_failed',
|
|
})
|
|
}
|
|
if (claim.systemPrompt === null || claim.transcript === null) {
|
|
await markFailed('commit_failed')
|
|
return json(500, { error: 'internal_error' })
|
|
}
|
|
|
|
const startedAt = Date.now()
|
|
let providerResponse: Response
|
|
try {
|
|
providerResponse = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': providerKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
body: JSON.stringify({
|
|
model: claim.model,
|
|
max_tokens: 4096,
|
|
system: buildMeetingDocumentSystemPrompt(claim.systemPrompt),
|
|
messages: [{
|
|
role: 'user',
|
|
content: `회의 제목: ${claim.meetingTitle}\n\n전사록:\n${claim.transcript}`,
|
|
}],
|
|
stream: false,
|
|
}),
|
|
signal: AbortSignal.timeout(60_000),
|
|
})
|
|
} catch (error) {
|
|
const timedOut = error instanceof DOMException && error.name === 'TimeoutError'
|
|
await markFailed(timedOut ? 'provider_timeout' : 'provider_request_failed')
|
|
return json(timedOut ? 504 : 502, {
|
|
error: timedOut ? 'provider_timeout' : 'provider_request_failed',
|
|
})
|
|
}
|
|
|
|
if (!providerResponse.ok) {
|
|
console.error('meeting document provider request failed', { status: providerResponse.status })
|
|
await markFailed('provider_request_failed')
|
|
return json(502, { error: 'provider_request_failed' })
|
|
}
|
|
|
|
let providerBody: unknown
|
|
try {
|
|
providerBody = await providerResponse.json()
|
|
} catch {
|
|
providerBody = null
|
|
}
|
|
const providerResult = parseProviderDocumentResult(providerBody)
|
|
if (providerResult === null) {
|
|
console.error('meeting document provider returned an invalid response shape')
|
|
await markFailed('provider_invalid_response')
|
|
return json(502, { error: 'provider_invalid_response' })
|
|
}
|
|
|
|
const { data: commitData, error: commitError } = await serviceClient.rpc(
|
|
'commit_meeting_document_generation_v1',
|
|
{
|
|
p_actor_id: actor.id,
|
|
p_idempotency_key: body.idempotencyKey,
|
|
p_content: providerResult.content,
|
|
p_latency_ms: Math.max(0, Date.now() - startedAt),
|
|
p_input_tokens: providerResult.inputTokens,
|
|
p_output_tokens: providerResult.outputTokens,
|
|
},
|
|
)
|
|
if (commitError !== null) {
|
|
const quotaExceeded = commitError.message?.includes('generation_quota_exceeded') === true
|
|
await markFailed(quotaExceeded ? 'quota_exceeded' : 'commit_failed')
|
|
if (quotaExceeded) return json(429, { error: 'quota_exceeded' })
|
|
console.error('meeting document atomic commit failed', { code: commitError.code ?? 'unknown' })
|
|
return json(500, { error: 'commit_failed' })
|
|
}
|
|
if (!isRecord(commitData) || !isRecord(commitData.document)) {
|
|
console.error('meeting document commit returned an invalid shape')
|
|
return json(500, { error: 'internal_error' })
|
|
}
|
|
|
|
return json(200, {
|
|
document: commitData.document,
|
|
idempotent: commitData.idempotent === true,
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof MeetingDocumentRequestError) {
|
|
return json(error.status, { error: 'invalid_request', message: error.message })
|
|
}
|
|
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
|
return authErrorResponse(error as AuthError, corsHeaders)
|
|
}
|
|
console.error('generate-meeting-document failed', { kind: 'internal_error' })
|
|
return json(500, { error: 'internal_error' })
|
|
}
|
|
})
|