2026-02-03 11:58:12 +01:00
|
|
|
/**
|
|
|
|
|
* AI Usage Tracking Utility
|
|
|
|
|
*
|
|
|
|
|
* Logs AI API usage to the database for cost tracking and monitoring.
|
|
|
|
|
* Calculates estimated costs based on model pricing.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
|
import { Decimal } from '@prisma/client/runtime/library'
|
|
|
|
|
import type { Prisma } from '@prisma/client'
|
|
|
|
|
|
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export type AIAction =
|
|
|
|
|
| 'ASSIGNMENT'
|
|
|
|
|
| 'FILTERING'
|
|
|
|
|
| 'AWARD_ELIGIBILITY'
|
|
|
|
|
| 'MENTOR_MATCHING'
|
2026-02-04 14:15:06 +01:00
|
|
|
| 'PROJECT_TAGGING'
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
| 'EVALUATION_SUMMARY'
|
2026-02-14 20:10:24 +01:00
|
|
|
| 'ROUTING'
|
2026-02-03 11:58:12 +01:00
|
|
|
|
|
|
|
|
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
|
|
|
|
|
|
|
|
|
export interface LogAIUsageInput {
|
|
|
|
|
userId?: string
|
|
|
|
|
action: AIAction
|
|
|
|
|
entityType?: string
|
|
|
|
|
entityId?: string
|
|
|
|
|
model: string
|
|
|
|
|
promptTokens: number
|
|
|
|
|
completionTokens: number
|
|
|
|
|
totalTokens: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
itemsProcessed?: number
|
|
|
|
|
status: AIStatus
|
|
|
|
|
errorMessage?: string
|
|
|
|
|
detailsJson?: Record<string, unknown>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TokenUsageResult {
|
|
|
|
|
promptTokens: number
|
|
|
|
|
completionTokens: number
|
|
|
|
|
totalTokens: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Model Pricing (per 1M tokens) ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface ModelPricing {
|
|
|
|
|
input: number // $ per 1M input tokens
|
|
|
|
|
output: number // $ per 1M output tokens
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* OpenAI model pricing as of 2024/2025
|
|
|
|
|
* Prices in USD per 1 million tokens
|
|
|
|
|
*/
|
|
|
|
|
const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
|
|
|
// GPT-4o series
|
|
|
|
|
'gpt-4o': { input: 2.5, output: 10.0 },
|
|
|
|
|
'gpt-4o-2024-11-20': { input: 2.5, output: 10.0 },
|
|
|
|
|
'gpt-4o-2024-08-06': { input: 2.5, output: 10.0 },
|
|
|
|
|
'gpt-4o-2024-05-13': { input: 5.0, output: 15.0 },
|
|
|
|
|
'gpt-4o-mini': { input: 0.15, output: 0.6 },
|
|
|
|
|
'gpt-4o-mini-2024-07-18': { input: 0.15, output: 0.6 },
|
|
|
|
|
|
|
|
|
|
// GPT-4 Turbo series
|
|
|
|
|
'gpt-4-turbo': { input: 10.0, output: 30.0 },
|
|
|
|
|
'gpt-4-turbo-2024-04-09': { input: 10.0, output: 30.0 },
|
|
|
|
|
'gpt-4-turbo-preview': { input: 10.0, output: 30.0 },
|
|
|
|
|
'gpt-4-1106-preview': { input: 10.0, output: 30.0 },
|
|
|
|
|
'gpt-4-0125-preview': { input: 10.0, output: 30.0 },
|
|
|
|
|
|
|
|
|
|
// GPT-4 (base)
|
|
|
|
|
'gpt-4': { input: 30.0, output: 60.0 },
|
|
|
|
|
'gpt-4-0613': { input: 30.0, output: 60.0 },
|
|
|
|
|
'gpt-4-32k': { input: 60.0, output: 120.0 },
|
|
|
|
|
'gpt-4-32k-0613': { input: 60.0, output: 120.0 },
|
|
|
|
|
|
|
|
|
|
// GPT-3.5 Turbo series
|
|
|
|
|
'gpt-3.5-turbo': { input: 0.5, output: 1.5 },
|
|
|
|
|
'gpt-3.5-turbo-0125': { input: 0.5, output: 1.5 },
|
|
|
|
|
'gpt-3.5-turbo-1106': { input: 1.0, output: 2.0 },
|
|
|
|
|
'gpt-3.5-turbo-16k': { input: 3.0, output: 4.0 },
|
|
|
|
|
|
|
|
|
|
// o1 reasoning models
|
|
|
|
|
'o1': { input: 15.0, output: 60.0 },
|
|
|
|
|
'o1-2024-12-17': { input: 15.0, output: 60.0 },
|
|
|
|
|
'o1-preview': { input: 15.0, output: 60.0 },
|
|
|
|
|
'o1-preview-2024-09-12': { input: 15.0, output: 60.0 },
|
|
|
|
|
'o1-mini': { input: 3.0, output: 12.0 },
|
|
|
|
|
'o1-mini-2024-09-12': { input: 3.0, output: 12.0 },
|
|
|
|
|
|
|
|
|
|
// o3 reasoning models
|
|
|
|
|
'o3-mini': { input: 1.1, output: 4.4 },
|
|
|
|
|
'o3-mini-2025-01-31': { input: 1.1, output: 4.4 },
|
|
|
|
|
|
|
|
|
|
// o4 reasoning models (future-proofing)
|
|
|
|
|
'o4-mini': { input: 1.1, output: 4.4 },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default pricing for unknown models (conservative estimate)
|
|
|
|
|
const DEFAULT_PRICING: ModelPricing = { input: 5.0, output: 15.0 }
|
|
|
|
|
|
|
|
|
|
// ─── Cost Calculation ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get pricing for a model, with fallback for unknown models
|
|
|
|
|
*/
|
|
|
|
|
function getModelPricing(model: string): ModelPricing {
|
|
|
|
|
// Exact match
|
|
|
|
|
if (MODEL_PRICING[model]) {
|
|
|
|
|
return MODEL_PRICING[model]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to match by prefix
|
|
|
|
|
const modelLower = model.toLowerCase()
|
|
|
|
|
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
|
|
|
|
if (modelLower.startsWith(key.toLowerCase())) {
|
|
|
|
|
return pricing
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback based on model type
|
|
|
|
|
if (modelLower.startsWith('gpt-4o-mini')) {
|
|
|
|
|
return MODEL_PRICING['gpt-4o-mini']
|
|
|
|
|
}
|
|
|
|
|
if (modelLower.startsWith('gpt-4o')) {
|
|
|
|
|
return MODEL_PRICING['gpt-4o']
|
|
|
|
|
}
|
|
|
|
|
if (modelLower.startsWith('gpt-4')) {
|
|
|
|
|
return MODEL_PRICING['gpt-4-turbo']
|
|
|
|
|
}
|
|
|
|
|
if (modelLower.startsWith('gpt-3.5')) {
|
|
|
|
|
return MODEL_PRICING['gpt-3.5-turbo']
|
|
|
|
|
}
|
|
|
|
|
if (modelLower.startsWith('o1-mini')) {
|
|
|
|
|
return MODEL_PRICING['o1-mini']
|
|
|
|
|
}
|
|
|
|
|
if (modelLower.startsWith('o1')) {
|
|
|
|
|
return MODEL_PRICING['o1']
|
|
|
|
|
}
|
|
|
|
|
if (modelLower.startsWith('o3-mini')) {
|
|
|
|
|
return MODEL_PRICING['o3-mini']
|
|
|
|
|
}
|
|
|
|
|
if (modelLower.startsWith('o3')) {
|
|
|
|
|
return MODEL_PRICING['o3-mini'] // Conservative estimate
|
|
|
|
|
}
|
|
|
|
|
if (modelLower.startsWith('o4')) {
|
|
|
|
|
return MODEL_PRICING['o4-mini'] || DEFAULT_PRICING
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DEFAULT_PRICING
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculate estimated cost in USD for a given model and token usage
|
|
|
|
|
*/
|
|
|
|
|
export function calculateCost(
|
|
|
|
|
model: string,
|
|
|
|
|
promptTokens: number,
|
|
|
|
|
completionTokens: number
|
|
|
|
|
): number {
|
|
|
|
|
const pricing = getModelPricing(model)
|
|
|
|
|
|
|
|
|
|
const inputCost = (promptTokens / 1_000_000) * pricing.input
|
|
|
|
|
const outputCost = (completionTokens / 1_000_000) * pricing.output
|
|
|
|
|
|
|
|
|
|
return inputCost + outputCost
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format cost for display
|
|
|
|
|
*/
|
|
|
|
|
export function formatCost(costUsd: number): string {
|
|
|
|
|
if (costUsd < 0.01) {
|
|
|
|
|
return `$${(costUsd * 100).toFixed(3)}¢`
|
|
|
|
|
}
|
|
|
|
|
return `$${costUsd.toFixed(4)}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Logging ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Log AI usage to the database
|
|
|
|
|
*/
|
|
|
|
|
export async function logAIUsage(input: LogAIUsageInput): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const estimatedCost = calculateCost(
|
|
|
|
|
input.model,
|
|
|
|
|
input.promptTokens,
|
|
|
|
|
input.completionTokens
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await prisma.aIUsageLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
action: input.action,
|
|
|
|
|
entityType: input.entityType,
|
|
|
|
|
entityId: input.entityId,
|
|
|
|
|
model: input.model,
|
|
|
|
|
promptTokens: input.promptTokens,
|
|
|
|
|
completionTokens: input.completionTokens,
|
|
|
|
|
totalTokens: input.totalTokens,
|
|
|
|
|
estimatedCostUsd: new Decimal(estimatedCost),
|
|
|
|
|
batchSize: input.batchSize,
|
|
|
|
|
itemsProcessed: input.itemsProcessed,
|
|
|
|
|
status: input.status,
|
|
|
|
|
errorMessage: input.errorMessage,
|
|
|
|
|
detailsJson: input.detailsJson as Prisma.InputJsonValue | undefined,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Don't let logging failures break the main operation
|
|
|
|
|
console.error('[AI Usage] Failed to log usage:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract token usage from OpenAI API response
|
|
|
|
|
*/
|
|
|
|
|
export function extractTokenUsage(
|
|
|
|
|
response: { usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } }
|
|
|
|
|
): TokenUsageResult {
|
|
|
|
|
return {
|
|
|
|
|
promptTokens: response.usage?.prompt_tokens ?? 0,
|
|
|
|
|
completionTokens: response.usage?.completion_tokens ?? 0,
|
|
|
|
|
totalTokens: response.usage?.total_tokens ?? 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Statistics ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface AIUsageStats {
|
|
|
|
|
totalTokens: number
|
|
|
|
|
totalCost: number
|
|
|
|
|
byAction: Record<string, { tokens: number; cost: number; count: number }>
|
|
|
|
|
byModel: Record<string, { tokens: number; cost: number; count: number }>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get AI usage statistics for a date range
|
|
|
|
|
*/
|
|
|
|
|
export async function getAIUsageStats(
|
|
|
|
|
startDate?: Date,
|
|
|
|
|
endDate?: Date
|
|
|
|
|
): Promise<AIUsageStats> {
|
|
|
|
|
const where: { createdAt?: { gte?: Date; lte?: Date } } = {}
|
|
|
|
|
|
|
|
|
|
if (startDate || endDate) {
|
|
|
|
|
where.createdAt = {}
|
|
|
|
|
if (startDate) where.createdAt.gte = startDate
|
|
|
|
|
if (endDate) where.createdAt.lte = endDate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const logs = await prisma.aIUsageLog.findMany({
|
|
|
|
|
where,
|
|
|
|
|
select: {
|
|
|
|
|
action: true,
|
|
|
|
|
model: true,
|
|
|
|
|
totalTokens: true,
|
|
|
|
|
estimatedCostUsd: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const stats: AIUsageStats = {
|
|
|
|
|
totalTokens: 0,
|
|
|
|
|
totalCost: 0,
|
|
|
|
|
byAction: {},
|
|
|
|
|
byModel: {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const log of logs) {
|
|
|
|
|
const cost = log.estimatedCostUsd?.toNumber() ?? 0
|
|
|
|
|
|
|
|
|
|
stats.totalTokens += log.totalTokens
|
|
|
|
|
stats.totalCost += cost
|
|
|
|
|
|
|
|
|
|
// By action
|
|
|
|
|
if (!stats.byAction[log.action]) {
|
|
|
|
|
stats.byAction[log.action] = { tokens: 0, cost: 0, count: 0 }
|
|
|
|
|
}
|
|
|
|
|
stats.byAction[log.action].tokens += log.totalTokens
|
|
|
|
|
stats.byAction[log.action].cost += cost
|
|
|
|
|
stats.byAction[log.action].count += 1
|
|
|
|
|
|
|
|
|
|
// By model
|
|
|
|
|
if (!stats.byModel[log.model]) {
|
|
|
|
|
stats.byModel[log.model] = { tokens: 0, cost: 0, count: 0 }
|
|
|
|
|
}
|
|
|
|
|
stats.byModel[log.model].tokens += log.totalTokens
|
|
|
|
|
stats.byModel[log.model].cost += cost
|
|
|
|
|
stats.byModel[log.model].count += 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return stats
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current month's AI usage cost
|
|
|
|
|
*/
|
|
|
|
|
export async function getCurrentMonthCost(): Promise<{
|
|
|
|
|
cost: number
|
|
|
|
|
tokens: number
|
|
|
|
|
requestCount: number
|
|
|
|
|
}> {
|
|
|
|
|
const startOfMonth = new Date()
|
|
|
|
|
startOfMonth.setDate(1)
|
|
|
|
|
startOfMonth.setHours(0, 0, 0, 0)
|
|
|
|
|
|
|
|
|
|
const logs = await prisma.aIUsageLog.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
createdAt: { gte: startOfMonth },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
totalTokens: true,
|
|
|
|
|
estimatedCostUsd: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
cost: logs.reduce((sum, log) => sum + (log.estimatedCostUsd?.toNumber() ?? 0), 0),
|
|
|
|
|
tokens: logs.reduce((sum, log) => sum + log.totalTokens, 0),
|
|
|
|
|
requestCount: logs.length,
|
|
|
|
|
}
|
|
|
|
|
}
|