Optimize AI system with batching, token tracking, and GDPR compliance
- Add AIUsageLog model for persistent token/cost tracking - Implement batched processing for all AI services: - Assignment: 15 projects/batch - Filtering: 20 projects/batch - Award eligibility: 20 projects/batch - Mentor matching: 15 projects/batch - Create unified error classification (ai-errors.ts) - Enhance anonymization with comprehensive project data - Add AI usage dashboard to Settings page - Add usage stats endpoints to settings router - Create AI system documentation (5 files) - Create GDPR compliance documentation (2 files) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure, superAdminProcedure, protectedProcedure } from '../trpc'
|
||||
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
|
||||
import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai'
|
||||
import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage'
|
||||
|
||||
/**
|
||||
* Categorize an OpenAI model for display
|
||||
*/
|
||||
function categorizeModel(modelId: string): string {
|
||||
const id = modelId.toLowerCase()
|
||||
if (id.startsWith('gpt-4o')) return 'gpt-4o'
|
||||
if (id.startsWith('gpt-4')) return 'gpt-4'
|
||||
if (id.startsWith('gpt-3.5')) return 'gpt-3.5'
|
||||
if (id.startsWith('o1') || id.startsWith('o3') || id.startsWith('o4')) return 'reasoning'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export const settingsRouter = router({
|
||||
/**
|
||||
@@ -177,33 +191,47 @@ export const settingsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Test AI connection
|
||||
* Test AI connection with the configured model
|
||||
*/
|
||||
testAIConnection: superAdminProcedure.mutation(async ({ ctx }) => {
|
||||
const apiKeySetting = await ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'openai_api_key' },
|
||||
})
|
||||
testAIConnection: superAdminProcedure.mutation(async () => {
|
||||
const result = await testOpenAIConnection()
|
||||
return result
|
||||
}),
|
||||
|
||||
if (!apiKeySetting?.value) {
|
||||
return { success: false, error: 'API key not configured' }
|
||||
/**
|
||||
* List available AI models from OpenAI
|
||||
*/
|
||||
listAIModels: superAdminProcedure.query(async () => {
|
||||
const result = await listAvailableModels()
|
||||
|
||||
if (!result.success || !result.models) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Failed to fetch models',
|
||||
models: [],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Test OpenAI connection with a minimal request
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKeySetting.value}`,
|
||||
},
|
||||
})
|
||||
// Categorize and annotate models
|
||||
const categorizedModels = result.models.map(model => ({
|
||||
id: model,
|
||||
name: model,
|
||||
isReasoning: isReasoningModel(model),
|
||||
category: categorizeModel(model),
|
||||
}))
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true }
|
||||
} else {
|
||||
const error = await response.json()
|
||||
return { success: false, error: error.error?.message || 'Unknown error' }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Connection failed' }
|
||||
// Sort: GPT-4o first, then other GPT-4, then GPT-3.5, then reasoning models
|
||||
const sorted = categorizedModels.sort((a, b) => {
|
||||
const order = ['gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning']
|
||||
const aOrder = order.findIndex(cat => a.category.startsWith(cat))
|
||||
const bOrder = order.findIndex(cat => b.category.startsWith(cat))
|
||||
if (aOrder !== bOrder) return aOrder - bOrder
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
models: sorted,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -373,4 +401,105 @@ export const settingsRouter = router({
|
||||
),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get AI usage statistics (admin only)
|
||||
*/
|
||||
getAIUsageStats: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const startDate = input.startDate ? new Date(input.startDate) : undefined
|
||||
const endDate = input.endDate ? new Date(input.endDate) : undefined
|
||||
|
||||
const stats = await getAIUsageStats(startDate, endDate)
|
||||
|
||||
return {
|
||||
totalTokens: stats.totalTokens,
|
||||
totalCost: stats.totalCost,
|
||||
totalCostFormatted: formatCost(stats.totalCost),
|
||||
byAction: Object.fromEntries(
|
||||
Object.entries(stats.byAction).map(([action, data]) => [
|
||||
action,
|
||||
{
|
||||
...data,
|
||||
costFormatted: formatCost(data.cost),
|
||||
},
|
||||
])
|
||||
),
|
||||
byModel: Object.fromEntries(
|
||||
Object.entries(stats.byModel).map(([model, data]) => [
|
||||
model,
|
||||
{
|
||||
...data,
|
||||
costFormatted: formatCost(data.cost),
|
||||
},
|
||||
])
|
||||
),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get current month AI usage cost (admin only)
|
||||
*/
|
||||
getAICurrentMonthCost: adminProcedure.query(async () => {
|
||||
const { cost, tokens, requestCount } = await getCurrentMonthCost()
|
||||
|
||||
return {
|
||||
cost,
|
||||
costFormatted: formatCost(cost),
|
||||
tokens,
|
||||
requestCount,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get AI usage history (last 30 days grouped by day)
|
||||
*/
|
||||
getAIUsageHistory: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
days: z.number().min(1).max(90).default(30),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - input.days)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
|
||||
const logs = await ctx.prisma.aIUsageLog.findMany({
|
||||
where: {
|
||||
createdAt: { gte: startDate },
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
totalTokens: true,
|
||||
estimatedCostUsd: true,
|
||||
action: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// Group by day
|
||||
const dailyData: Record<string, { date: string; tokens: number; cost: number; count: number }> = {}
|
||||
|
||||
for (const log of logs) {
|
||||
const dateKey = log.createdAt.toISOString().split('T')[0]
|
||||
if (!dailyData[dateKey]) {
|
||||
dailyData[dateKey] = { date: dateKey, tokens: 0, cost: 0, count: 0 }
|
||||
}
|
||||
dailyData[dateKey].tokens += log.totalTokens
|
||||
dailyData[dateKey].cost += log.estimatedCostUsd?.toNumber() ?? 0
|
||||
dailyData[dateKey].count += 1
|
||||
}
|
||||
|
||||
return Object.values(dailyData).map((day) => ({
|
||||
...day,
|
||||
costFormatted: formatCost(day.cost),
|
||||
}))
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user