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:
2026-02-03 11:58:12 +01:00
parent a72e815d3a
commit 928b1c65dc
19 changed files with 4103 additions and 601 deletions

View File

@@ -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),
}))
}),
})