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:
318
src/server/services/ai-errors.ts
Normal file
318
src/server/services/ai-errors.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* AI Error Classification Service
|
||||
*
|
||||
* Provides unified error handling and classification for all AI services.
|
||||
* Converts technical API errors into user-friendly messages.
|
||||
*/
|
||||
|
||||
// ─── Error Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type AIErrorType =
|
||||
| 'rate_limit'
|
||||
| 'quota_exceeded'
|
||||
| 'model_not_found'
|
||||
| 'invalid_api_key'
|
||||
| 'context_length'
|
||||
| 'parse_error'
|
||||
| 'timeout'
|
||||
| 'network_error'
|
||||
| 'content_filter'
|
||||
| 'server_error'
|
||||
| 'unknown'
|
||||
|
||||
export interface ClassifiedError {
|
||||
type: AIErrorType
|
||||
message: string
|
||||
originalMessage: string
|
||||
retryable: boolean
|
||||
suggestedAction?: string
|
||||
}
|
||||
|
||||
// ─── Error Patterns ──────────────────────────────────────────────────────────
|
||||
|
||||
interface ErrorPattern {
|
||||
type: AIErrorType
|
||||
patterns: Array<string | RegExp>
|
||||
retryable: boolean
|
||||
userMessage: string
|
||||
suggestedAction?: string
|
||||
}
|
||||
|
||||
const ERROR_PATTERNS: ErrorPattern[] = [
|
||||
{
|
||||
type: 'rate_limit',
|
||||
patterns: [
|
||||
'rate_limit',
|
||||
'rate limit',
|
||||
'too many requests',
|
||||
'429',
|
||||
'quota exceeded',
|
||||
'Rate limit reached',
|
||||
],
|
||||
retryable: true,
|
||||
userMessage: 'Rate limit exceeded. Please wait a few minutes and try again.',
|
||||
suggestedAction: 'Wait 1-2 minutes before retrying, or reduce batch size.',
|
||||
},
|
||||
{
|
||||
type: 'quota_exceeded',
|
||||
patterns: [
|
||||
'insufficient_quota',
|
||||
'billing',
|
||||
'exceeded your current quota',
|
||||
'payment required',
|
||||
'account deactivated',
|
||||
],
|
||||
retryable: false,
|
||||
userMessage: 'API quota exceeded. Please check your OpenAI billing settings.',
|
||||
suggestedAction: 'Add payment method or increase spending limit in OpenAI dashboard.',
|
||||
},
|
||||
{
|
||||
type: 'model_not_found',
|
||||
patterns: [
|
||||
'model_not_found',
|
||||
'does not exist',
|
||||
'The model',
|
||||
'invalid model',
|
||||
'model not available',
|
||||
],
|
||||
retryable: false,
|
||||
userMessage: 'The selected AI model is not available. Please check your settings.',
|
||||
suggestedAction: 'Go to Settings → AI and select a different model.',
|
||||
},
|
||||
{
|
||||
type: 'invalid_api_key',
|
||||
patterns: [
|
||||
'invalid_api_key',
|
||||
'Incorrect API key',
|
||||
'authentication',
|
||||
'unauthorized',
|
||||
'401',
|
||||
'invalid api key',
|
||||
],
|
||||
retryable: false,
|
||||
userMessage: 'Invalid API key. Please check your OpenAI API key in settings.',
|
||||
suggestedAction: 'Go to Settings → AI and enter a valid API key.',
|
||||
},
|
||||
{
|
||||
type: 'context_length',
|
||||
patterns: [
|
||||
'context_length',
|
||||
'maximum context length',
|
||||
'tokens',
|
||||
'too long',
|
||||
'reduce the length',
|
||||
'max_tokens',
|
||||
],
|
||||
retryable: true,
|
||||
userMessage: 'Request too large. Try processing fewer items at once.',
|
||||
suggestedAction: 'Process items in smaller batches.',
|
||||
},
|
||||
{
|
||||
type: 'content_filter',
|
||||
patterns: [
|
||||
'content_filter',
|
||||
'content policy',
|
||||
'flagged',
|
||||
'inappropriate',
|
||||
'safety system',
|
||||
],
|
||||
retryable: false,
|
||||
userMessage: 'Content was flagged by the AI safety system. Please review the input data.',
|
||||
suggestedAction: 'Check project descriptions for potentially sensitive content.',
|
||||
},
|
||||
{
|
||||
type: 'timeout',
|
||||
patterns: [
|
||||
'timeout',
|
||||
'timed out',
|
||||
'ETIMEDOUT',
|
||||
'ECONNABORTED',
|
||||
'deadline exceeded',
|
||||
],
|
||||
retryable: true,
|
||||
userMessage: 'Request timed out. Please try again.',
|
||||
suggestedAction: 'Try again or process fewer items at once.',
|
||||
},
|
||||
{
|
||||
type: 'network_error',
|
||||
patterns: [
|
||||
'ENOTFOUND',
|
||||
'ECONNREFUSED',
|
||||
'network',
|
||||
'connection',
|
||||
'DNS',
|
||||
'getaddrinfo',
|
||||
],
|
||||
retryable: true,
|
||||
userMessage: 'Network error. Please check your connection and try again.',
|
||||
suggestedAction: 'Check network connectivity and firewall settings.',
|
||||
},
|
||||
{
|
||||
type: 'server_error',
|
||||
patterns: [
|
||||
'500',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
'internal error',
|
||||
'server error',
|
||||
'service unavailable',
|
||||
],
|
||||
retryable: true,
|
||||
userMessage: 'OpenAI service temporarily unavailable. Please try again later.',
|
||||
suggestedAction: 'Wait a few minutes and retry. Check status.openai.com for outages.',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Error Classification ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classify an error from the OpenAI API
|
||||
*/
|
||||
export function classifyAIError(error: Error | unknown): ClassifiedError {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorString = errorMessage.toLowerCase()
|
||||
|
||||
// Check against known patterns
|
||||
for (const pattern of ERROR_PATTERNS) {
|
||||
for (const matcher of pattern.patterns) {
|
||||
const matches =
|
||||
typeof matcher === 'string'
|
||||
? errorString.includes(matcher.toLowerCase())
|
||||
: matcher.test(errorString)
|
||||
|
||||
if (matches) {
|
||||
return {
|
||||
type: pattern.type,
|
||||
message: pattern.userMessage,
|
||||
originalMessage: errorMessage,
|
||||
retryable: pattern.retryable,
|
||||
suggestedAction: pattern.suggestedAction,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
originalMessage: errorMessage,
|
||||
retryable: true,
|
||||
suggestedAction: 'If the problem persists, check the AI settings or contact support.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a JSON parse error
|
||||
*/
|
||||
export function isParseError(error: Error | unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return (
|
||||
message.includes('JSON') ||
|
||||
message.includes('parse') ||
|
||||
message.includes('Unexpected token') ||
|
||||
message.includes('SyntaxError')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a classified parse error
|
||||
*/
|
||||
export function createParseError(originalMessage: string): ClassifiedError {
|
||||
return {
|
||||
type: 'parse_error',
|
||||
message: 'AI returned an invalid response. Items flagged for manual review.',
|
||||
originalMessage,
|
||||
retryable: true,
|
||||
suggestedAction: 'Review flagged items manually. Consider using a different model.',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User-Friendly Messages ──────────────────────────────────────────────────
|
||||
|
||||
const USER_FRIENDLY_MESSAGES: Record<AIErrorType, string> = {
|
||||
rate_limit: 'Rate limit exceeded. Please wait a few minutes and try again.',
|
||||
quota_exceeded: 'API quota exceeded. Please check your OpenAI billing settings.',
|
||||
model_not_found: 'Selected AI model is not available. Please check your settings.',
|
||||
invalid_api_key: 'Invalid API key. Please verify your OpenAI API key.',
|
||||
context_length: 'Request too large. Please try with fewer items.',
|
||||
parse_error: 'AI response could not be processed. Items flagged for review.',
|
||||
timeout: 'Request timed out. Please try again.',
|
||||
network_error: 'Network connection error. Please check your connection.',
|
||||
content_filter: 'Content flagged by AI safety system. Please review input data.',
|
||||
server_error: 'AI service temporarily unavailable. Please try again later.',
|
||||
unknown: 'An unexpected error occurred. Please try again.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for an error type
|
||||
*/
|
||||
export function getUserFriendlyMessage(errorType: AIErrorType): string {
|
||||
return USER_FRIENDLY_MESSAGES[errorType]
|
||||
}
|
||||
|
||||
// ─── Error Handling Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wrap an async function with standardized AI error handling
|
||||
*/
|
||||
export async function withAIErrorHandling<T>(
|
||||
fn: () => Promise<T>,
|
||||
fallback: T
|
||||
): Promise<{ result: T; error?: ClassifiedError }> {
|
||||
try {
|
||||
const result = await fn()
|
||||
return { result }
|
||||
} catch (error) {
|
||||
const classified = classifyAIError(error)
|
||||
console.error(`[AI Error] ${classified.type}:`, classified.originalMessage)
|
||||
return { result: fallback, error: classified }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an AI error with context
|
||||
*/
|
||||
export function logAIError(
|
||||
service: string,
|
||||
operation: string,
|
||||
error: ClassifiedError,
|
||||
context?: Record<string, unknown>
|
||||
): void {
|
||||
console.error(
|
||||
`[AI ${service}] ${operation} failed:`,
|
||||
JSON.stringify({
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
originalMessage: error.originalMessage,
|
||||
retryable: error.retryable,
|
||||
...context,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Retry Logic ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine if an operation should be retried based on error type
|
||||
*/
|
||||
export function shouldRetry(error: ClassifiedError, attempt: number, maxAttempts: number = 3): boolean {
|
||||
if (!error.retryable) return false
|
||||
if (attempt >= maxAttempts) return false
|
||||
|
||||
// Rate limits need longer delays
|
||||
if (error.type === 'rate_limit') {
|
||||
return attempt < 2 // Only retry once for rate limits
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delay before retry (exponential backoff)
|
||||
*/
|
||||
export function getRetryDelay(error: ClassifiedError, attempt: number): number {
|
||||
const baseDelay = error.type === 'rate_limit' ? 30000 : 1000 // 30s for rate limit, 1s otherwise
|
||||
return baseDelay * Math.pow(2, attempt)
|
||||
}
|
||||
Reference in New Issue
Block a user