feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,240 @@
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' })
}
})