240 lines
8.6 KiB
TypeScript
240 lines
8.6 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'
|
|
|
|
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 = 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' })
|
|
}
|
|
})
|