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' 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): 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 { 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 => { 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 = Deno.env.get('ANTHROPIC_API_KEY')?.trim() ?? '' 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' }) } })