/** * AI-Powered Award Eligibility Service * * Determines project eligibility for special awards using * AI interpretation of plain-language criteria. * * GDPR Compliance: * - All project data is anonymized before AI processing * - IDs replaced with sequential identifiers * - No personal information sent to OpenAI */ import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai' import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage' import { classifyAIError, createParseError, logAIError } from './ai-errors' import { sanitizeUserInput } from '@/server/services/ai-prompt-guard' import { anonymizeProjectsForAI, validateAnonymizedProjects, toProjectWithRelations, type AnonymizedProjectForAI, type ProjectAIMapping, } from './anonymization' import type { SubmissionSource } from '@prisma/client' // ─── Constants ─────────────────────────────────────────────────────────────── const BATCH_SIZE = 20 // Structured system prompt for award eligibility const AI_ELIGIBILITY_SYSTEM_PROMPT = `You are an award eligibility evaluator for an ocean conservation competition. ## Your Role Determine whether each project meets the criteria for a specific award category. ## Evaluation Dimensions - Geographic Relevance: Does the project's location/focus match the award's geographic requirements? - Category Fit: Does the project category align with the award criteria? - Topic Alignment: Does the project's ocean issue focus match the award's thematic area? - Maturity Level: Is the project at the right stage for this award? ## Output Format Return a JSON object: { "evaluations": [ { "project_id": "PROJECT_001", "eligible": true/false, "confidence": 0.0-1.0, "quality_score": 0-100, "reasoning": "2-3 sentence explanation covering key dimensions", "dimensionScores": { "geographic": 0.0-1.0, "category": 0.0-1.0, "topic": 0.0-1.0, "maturity": 0.0-1.0 } } ] } quality_score is a 0-100 integer measuring how well the project fits the award criteria (used for ranking shortlists). 100 = perfect fit, 0 = no fit. Even ineligible projects should receive a score for reference. ## Guidelines - Base evaluation only on provided data — do not infer missing information - Be inclusive: if the project reasonably fits the criteria, mark eligible=true - eligible=true when the project's location, category, or topic aligns with the award criteria - Do not require perfection across all dimensions — strong fit in the primary criterion (e.g. geographic) is sufficient - confidence reflects how clearly the data supports the determination - No personal identifiers in reasoning` // ─── Types ────────────────────────────────────────────────────────────────── export interface EligibilityResult { projectId: string eligible: boolean confidence: number qualityScore: number reasoning: string method: 'AUTO' | 'AI' } interface ProjectForEligibility { id: string title: string description?: string | null competitionCategory?: string | null country?: string | null geographicZone?: string | null tags: string[] oceanIssue?: string | null institution?: string | null foundedAt?: Date | null wantsMentorship?: boolean submissionSource?: SubmissionSource | string submittedAt?: Date | null _count?: { teamMembers?: number files?: number } files?: Array<{ fileType: string | null }> } // ─── AI Criteria Interpretation ───────────────────────────────────────────── /** * Process a batch for AI eligibility evaluation */ async function processEligibilityBatch( openai: NonNullable>>, model: string, criteriaText: string, anonymized: AnonymizedProjectForAI[], mappings: ProjectAIMapping[], userId?: string, entityId?: string ): Promise<{ results: EligibilityResult[] tokensUsed: number }> { const results: EligibilityResult[] = [] let tokensUsed = 0 // Sanitize user-supplied criteria const { sanitized: safeCriteria } = sanitizeUserInput(criteriaText) const userPrompt = `CRITERIA: ${safeCriteria} PROJECTS: ${JSON.stringify(anonymized)} Evaluate eligibility for each project.` const MAX_PARSE_RETRIES = 2 let parseAttempts = 0 let response: Awaited> try { const params = buildCompletionParams(model, { messages: [ { role: 'system', content: AI_ELIGIBILITY_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], jsonMode: true, temperature: 0.1, maxTokens: 4000, }) response = await openai.chat.completions.create(params) const usage = extractTokenUsage(response) tokensUsed = usage.totalTokens // Log usage await logAIUsage({ userId, action: 'AWARD_ELIGIBILITY', entityType: 'Award', entityId, model, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, totalTokens: usage.totalTokens, batchSize: anonymized.length, itemsProcessed: anonymized.length, status: 'SUCCESS', }) // Parse with retry logic let parsed: { evaluations: Array<{ project_id: string eligible: boolean confidence: number quality_score?: number reasoning: string }> } while (true) { try { const content = response.choices[0]?.message?.content if (!content) { throw new Error('Empty response from AI') } parsed = JSON.parse(content) break } catch (parseError) { if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) { parseAttempts++ console.warn(`[AI Eligibility] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`) // Retry the API call with hint const retryParams = buildCompletionParams(model, { messages: [ { role: 'system', content: AI_ELIGIBILITY_SYSTEM_PROMPT }, { role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' }, ], jsonMode: true, temperature: 0.1, maxTokens: 4000, }) response = await openai.chat.completions.create(retryParams) const retryUsage = extractTokenUsage(response) tokensUsed += retryUsage.totalTokens continue } throw parseError } } // Map results back to real IDs for (const eval_ of parsed.evaluations || []) { const mapping = mappings.find((m) => m.anonymousId === eval_.project_id) if (mapping) { results.push({ projectId: mapping.realId, eligible: eval_.eligible, confidence: eval_.confidence, qualityScore: Math.max(0, Math.min(100, eval_.quality_score ?? 0)), reasoning: eval_.reasoning, method: 'AI', }) } } } catch (error) { if (error instanceof SyntaxError) { const parseError = createParseError(error.message) logAIError('AwardEligibility', 'batch processing', parseError) await logAIUsage({ userId, action: 'AWARD_ELIGIBILITY', entityType: 'Award', entityId, model, promptTokens: 0, completionTokens: 0, totalTokens: tokensUsed, batchSize: anonymized.length, itemsProcessed: 0, status: 'ERROR', errorMessage: parseError.message, }) // Flag all for manual review for (const mapping of mappings) { results.push({ projectId: mapping.realId, eligible: false, confidence: 0, qualityScore: 0, reasoning: 'AI response parse error — requires manual review', method: 'AI', }) } } else { throw error } } return { results, tokensUsed } } export async function aiInterpretCriteria( criteriaText: string, projects: ProjectForEligibility[], userId?: string, awardId?: string ): Promise { const results: EligibilityResult[] = [] try { const openai = await getOpenAI() if (!openai) { console.warn('[AI Eligibility] OpenAI not configured') return projects.map((p) => ({ projectId: p.id, eligible: false, confidence: 0, qualityScore: 0, reasoning: 'AI unavailable — requires manual eligibility review', method: 'AI' as const, })) } const model = await getConfiguredModel() console.log(`[AI Eligibility] Using model: ${model} for ${projects.length} projects`) // Convert and anonymize projects const projectsWithRelations = projects.map(toProjectWithRelations) const { anonymized, mappings } = anonymizeProjectsForAI(projectsWithRelations, 'ELIGIBILITY') // Validate anonymization if (!validateAnonymizedProjects(anonymized)) { console.error('[AI Eligibility] Anonymization validation failed') throw new Error('GDPR compliance check failed: PII detected in anonymized data') } let totalTokens = 0 // Process in batches for (let i = 0; i < anonymized.length; i += BATCH_SIZE) { const batchAnon = anonymized.slice(i, i + BATCH_SIZE) const batchMappings = mappings.slice(i, i + BATCH_SIZE) console.log(`[AI Eligibility] Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(anonymized.length / BATCH_SIZE)}`) const { results: batchResults, tokensUsed } = await processEligibilityBatch( openai, model, criteriaText, batchAnon, batchMappings, userId, awardId ) results.push(...batchResults) totalTokens += tokensUsed } console.log(`[AI Eligibility] Completed. Total tokens: ${totalTokens}`) } catch (error) { const classified = classifyAIError(error) logAIError('AwardEligibility', 'aiInterpretCriteria', classified) // Log failed attempt await logAIUsage({ userId, action: 'AWARD_ELIGIBILITY', entityType: 'Award', entityId: awardId, model: 'unknown', promptTokens: 0, completionTokens: 0, totalTokens: 0, batchSize: projects.length, itemsProcessed: 0, status: 'ERROR', errorMessage: classified.message, }) // Return all as needing manual review return projects.map((p) => ({ projectId: p.id, eligible: false, confidence: 0, qualityScore: 0, reasoning: `AI error: ${classified.message}`, method: 'AI' as const, })) } return results }