150 lines
5 KiB
TypeScript
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' })
|
|
}
|
|
})
|