d3ro-voice/server/supabase/functions/generate-meeting-document/index.ts

150 lines
5 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,
} from '../_shared/meeting-document-contract.ts'
import { readProviderKey } from '../_shared/provider-key.ts'
import {
generateMeetingDocument,
type GenerationLogger,
type MeetingDocumentProvider,
type MeetingDocumentStore,
type ProviderOutcome,
} from './generation.ts'
type ServiceClient = ReturnType<typeof createServiceRoleClient>
const PROVIDER_TIMEOUT_MS = 60_000
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',
},
})
}
const logError: GenerationLogger = (message, context) => {
if (context === undefined) console.error(message)
else console.error(message, context)
}
/** Supabase adapter for the persistence port (service-role RPCs). */
function createSupabaseStore(client: ServiceClient): MeetingDocumentStore {
return {
async claim(actorId, request) {
const { data, error } = await client.rpc('claim_meeting_document_generation_v1', {
p_actor_id: actorId,
p_idempotency_key: request.idempotencyKey,
p_meeting_id: request.meetingId,
p_template_id: request.templateId,
p_title: request.title,
p_model: request.model,
})
return { data, error }
},
async markFailed(actorId, idempotencyKey, code) {
const { error } = await client.rpc('fail_meeting_document_generation_v1', {
p_actor_id: actorId,
p_idempotency_key: idempotencyKey,
p_error_code: code,
})
return error
},
async commit(actorId, idempotencyKey, providerResult, latencyMs) {
const { data, error } = await client.rpc('commit_meeting_document_generation_v1', {
p_actor_id: actorId,
p_idempotency_key: idempotencyKey,
p_content: providerResult.content,
p_latency_ms: latencyMs,
p_input_tokens: providerResult.inputTokens,
p_output_tokens: providerResult.outputTokens,
})
return { data, error }
},
async findDocument(actorId, documentId) {
const { data, error } = await client
.from('meeting_documents')
.select('*')
.eq('id', documentId)
.eq('user_id', actorId)
.maybeSingle()
return { data, error }
},
}
}
/** Anthropic Messages API adapter for the provider port. */
function createAnthropicProvider(apiKey: string): MeetingDocumentProvider {
return {
async generate(payload): Promise<ProviderOutcome> {
let response: Response
try {
response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
})
} catch (error) {
const timedOut = error instanceof DOMException && error.name === 'TimeoutError'
return timedOut ? { kind: 'timeout' } : { kind: 'network_error' }
}
if (!response.ok) return { kind: 'http_error', status: response.status }
try {
return { kind: 'ok', body: await response.json() }
} catch {
return { kind: 'ok', body: null }
}
},
}
}
Deno.serve(async (request: Request) => {
const preflight = handleCorsPreflightRequest(request)
if (preflight) return preflight
if (request.method !== 'POST') return json(405, { error: 'method_not_allowed' })
try {
const actor = await requireUser(request)
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
throw new MeetingDocumentRequestError('Invalid JSON body')
}
const body = parseGenerateMeetingDocumentRequest(rawBody)
const providerKey = readProviderKey('ANTHROPIC_API_KEY')
if (providerKey.length === 0) {
return json(503, { error: 'provider_unavailable' })
}
const outcome = await generateMeetingDocument(actor.id, body, {
store: createSupabaseStore(createServiceRoleClient()),
provider: createAnthropicProvider(providerKey),
now: () => Date.now(),
log: logError,
})
return json(outcome.status, outcome.body)
} 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' })
}
})