export interface AlphaUsageRow { campaign_id: string | null job_id: string | null request_kind: string input_tokens: number output_tokens: number cost_usd: number | string latency_ms: number | null created_at: string } export interface AlphaRoundRow { id: string campaign_id: string number: number status: string queued_at: string | null resolved_at: string | null error: string | null created_at: string } const roundRequestKinds = new Set(['round_plan', 'round_resolution', 'story_summary']) function average(values: number[]): number { return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0 } function percentile(values: number[], percentileValue: number): number { if (!values.length) return 0 const sorted = [...values].sort((left, right) => left - right) return sorted[Math.max(0, Math.ceil(sorted.length * percentileValue) - 1)] ?? 0 } export function summarizeAlphaUsage(rows: AlphaUsageRow[]) { const latencies = rows.flatMap(row => row.latency_ms === null ? [] : [Number(row.latency_ms)]) return { requests: rows.length, inputTokens: rows.reduce((sum, row) => sum + Number(row.input_tokens), 0), outputTokens: rows.reduce((sum, row) => sum + Number(row.output_tokens), 0), costUsd: rows.reduce((sum, row) => sum + Number(row.cost_usd), 0), averageLatencyMs: Math.round(average(latencies)), } } export function summarizeRoundObservability( usageRows: AlphaUsageRow[], roundRows: AlphaRoundRow[], campaignNames: ReadonlyMap, ) { const jobs = new Map() for (const row of usageRows) { if (!row.job_id || !roundRequestKinds.has(row.request_kind)) continue const current = jobs.get(row.job_id) ?? { costUsd: 0, contextTokens: 0 } current.costUsd += Number(row.cost_usd) // The planning request is the direct serialization of RoundContext. Keep // the largest attempt so retries increase true cost without inflating the // reported size of the context itself. if (row.request_kind === 'round_plan') { current.contextTokens = Math.max(current.contextTokens, Number(row.input_tokens)) } jobs.set(row.job_id, current) } const completed = roundRows.filter(round => round.status === 'resolved') const failed = roundRows.filter(round => round.status === 'failed') const endToEndLatencies = completed.flatMap(round => { if (!round.queued_at || !round.resolved_at) return [] const elapsed = Date.parse(round.resolved_at) - Date.parse(round.queued_at) return Number.isFinite(elapsed) && elapsed >= 0 ? [elapsed] : [] }) const costs = [...jobs.values()].map(job => job.costUsd) const contextSizes = [...jobs.values()].flatMap(job => job.contextTokens > 0 ? [job.contextTokens] : []) const observedOutcomes = completed.length + failed.length return { completed: completed.length, failed: failed.length, failureRate: observedOutcomes ? failed.length / observedOutcomes : 0, averageCostUsd: average(costs), averageLatencyMs: Math.round(average(endToEndLatencies)), p95LatencyMs: Math.round(percentile(endToEndLatencies, 0.95)), averageContextTokens: Math.round(average(contextSizes)), latestFailures: failed .slice() .sort((left, right) => right.created_at.localeCompare(left.created_at)) .slice(0, 3) .map(round => ({ id: round.id, campaignId: round.campaign_id, campaign: campaignNames.get(round.campaign_id) ?? 'Private campaign', round: Number(round.number), message: round.error?.trim() || 'The round stopped before narration was committed.', occurredAt: round.created_at, })), } }