/** * AI-Powered Assignment Service * * Uses GPT to analyze juror expertise and project requirements * to generate optimal assignment suggestions. */ import { getOpenAI, getConfiguredModel } from '@/lib/openai' import { anonymizeForAI, deanonymizeResults, validateAnonymization, type AnonymizationResult, } from './anonymization' // Types for AI assignment export interface AIAssignmentSuggestion { jurorId: string projectId: string confidenceScore: number // 0-1 reasoning: string expertiseMatchScore: number // 0-1 } export interface AIAssignmentResult { success: boolean suggestions: AIAssignmentSuggestion[] error?: string tokensUsed?: number fallbackUsed?: boolean } interface JurorForAssignment { id: string name?: string | null email: string expertiseTags: string[] maxAssignments?: number | null _count?: { assignments: number } } interface ProjectForAssignment { id: string title: string description?: string | null tags: string[] teamName?: string | null _count?: { assignments: number } } interface AssignmentConstraints { requiredReviewsPerProject: number maxAssignmentsPerJuror?: number existingAssignments: Array<{ jurorId: string projectId: string }> } /** * System prompt for AI assignment */ const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert at matching jury members to projects based on expertise alignment. Your task is to suggest optimal juror-project assignments that: 1. Match juror expertise tags with project tags and content 2. Distribute workload fairly among jurors 3. Ensure each project gets the required number of reviews 4. Avoid assigning jurors who are already at their limit For each suggestion, provide: - A confidence score (0-1) based on how well the juror's expertise matches the project - An expertise match score (0-1) based purely on tag/content alignment - A brief reasoning explaining why this is a good match Return your response as a JSON array of assignments.` /** * Generate AI-powered assignment suggestions */ export async function generateAIAssignments( jurors: JurorForAssignment[], projects: ProjectForAssignment[], constraints: AssignmentConstraints ): Promise { // Anonymize data before sending to AI const anonymizedData = anonymizeForAI(jurors, projects) // Validate anonymization if (!validateAnonymization(anonymizedData)) { console.error('Anonymization validation failed, falling back to algorithm') return generateFallbackAssignments(jurors, projects, constraints) } try { const openai = await getOpenAI() if (!openai) { console.log('OpenAI not configured, using fallback algorithm') return generateFallbackAssignments(jurors, projects, constraints) } const suggestions = await callAIForAssignments( openai, anonymizedData, constraints ) // De-anonymize results const deanonymizedSuggestions = deanonymizeResults( suggestions.map((s) => ({ ...s, jurorId: s.jurorId, projectId: s.projectId, })), anonymizedData.jurorMappings, anonymizedData.projectMappings ).map((s) => ({ jurorId: s.realJurorId, projectId: s.realProjectId, confidenceScore: s.confidenceScore, reasoning: s.reasoning, expertiseMatchScore: s.expertiseMatchScore, })) return { success: true, suggestions: deanonymizedSuggestions, fallbackUsed: false, } } catch (error) { console.error('AI assignment failed, using fallback:', error) return generateFallbackAssignments(jurors, projects, constraints) } } /** * Call OpenAI API for assignment suggestions */ async function callAIForAssignments( openai: Awaited>, anonymizedData: AnonymizationResult, constraints: AssignmentConstraints ): Promise { if (!openai) { throw new Error('OpenAI client not available') } // Build the user prompt const userPrompt = buildAssignmentPrompt(anonymizedData, constraints) const model = await getConfiguredModel() const response = await openai.chat.completions.create({ model, messages: [ { role: 'system', content: ASSIGNMENT_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], response_format: { type: 'json_object' }, temperature: 0.3, // Lower temperature for more consistent results max_tokens: 4000, }) const content = response.choices[0]?.message?.content if (!content) { throw new Error('No response from AI') } // Parse the response const parsed = JSON.parse(content) as { assignments: Array<{ juror_id: string project_id: string confidence_score: number expertise_match_score: number reasoning: string }> } return (parsed.assignments || []).map((a) => ({ jurorId: a.juror_id, projectId: a.project_id, confidenceScore: Math.min(1, Math.max(0, a.confidence_score)), expertiseMatchScore: Math.min(1, Math.max(0, a.expertise_match_score)), reasoning: a.reasoning, })) } /** * Build the prompt for AI assignment */ function buildAssignmentPrompt( data: AnonymizationResult, constraints: AssignmentConstraints ): string { const { jurors, projects } = data // Map existing assignments to anonymous IDs const jurorIdMap = new Map( data.jurorMappings.map((m) => [m.realId, m.anonymousId]) ) const projectIdMap = new Map( data.projectMappings.map((m) => [m.realId, m.anonymousId]) ) const anonymousExisting = constraints.existingAssignments .map((a) => ({ jurorId: jurorIdMap.get(a.jurorId), projectId: projectIdMap.get(a.projectId), })) .filter((a) => a.jurorId && a.projectId) return `## Jurors Available ${JSON.stringify(jurors, null, 2)} ## Projects to Assign ${JSON.stringify(projects, null, 2)} ## Constraints - Each project needs ${constraints.requiredReviewsPerProject} reviews - Maximum assignments per juror: ${constraints.maxAssignmentsPerJuror || 'No limit'} - Existing assignments to avoid duplicating: ${JSON.stringify(anonymousExisting, null, 2)} ## Instructions Generate optimal juror-project assignments. Return a JSON object with an "assignments" array where each assignment has: - juror_id: The anonymous juror ID - project_id: The anonymous project ID - confidence_score: 0-1 confidence in this match - expertise_match_score: 0-1 expertise alignment score - reasoning: Brief explanation (1-2 sentences) Focus on matching expertise tags with project tags and descriptions. Distribute assignments fairly.` } /** * Fallback algorithm-based assignment when AI is unavailable */ export function generateFallbackAssignments( jurors: JurorForAssignment[], projects: ProjectForAssignment[], constraints: AssignmentConstraints ): AIAssignmentResult { const suggestions: AIAssignmentSuggestion[] = [] const existingSet = new Set( constraints.existingAssignments.map((a) => `${a.jurorId}:${a.projectId}`) ) // Track assignments per juror and project const jurorAssignments = new Map() const projectAssignments = new Map() // Initialize counts from existing assignments for (const assignment of constraints.existingAssignments) { jurorAssignments.set( assignment.jurorId, (jurorAssignments.get(assignment.jurorId) || 0) + 1 ) projectAssignments.set( assignment.projectId, (projectAssignments.get(assignment.projectId) || 0) + 1 ) } // Also include current assignment counts for (const juror of jurors) { const current = juror._count?.assignments || 0 jurorAssignments.set( juror.id, Math.max(jurorAssignments.get(juror.id) || 0, current) ) } for (const project of projects) { const current = project._count?.assignments || 0 projectAssignments.set( project.id, Math.max(projectAssignments.get(project.id) || 0, current) ) } // Sort projects by need (fewest assignments first) const sortedProjects = [...projects].sort((a, b) => { const aCount = projectAssignments.get(a.id) || 0 const bCount = projectAssignments.get(b.id) || 0 return aCount - bCount }) // For each project, find best matching jurors for (const project of sortedProjects) { const currentProjectAssignments = projectAssignments.get(project.id) || 0 const neededReviews = Math.max( 0, constraints.requiredReviewsPerProject - currentProjectAssignments ) if (neededReviews === 0) continue // Score all available jurors const scoredJurors = jurors .filter((juror) => { // Check not already assigned if (existingSet.has(`${juror.id}:${project.id}`)) return false // Check not at limit const currentAssignments = jurorAssignments.get(juror.id) || 0 const maxAssignments = juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? Infinity if (currentAssignments >= maxAssignments) return false return true }) .map((juror) => ({ juror, score: calculateExpertiseScore(juror.expertiseTags, project.tags), loadScore: calculateLoadScore( jurorAssignments.get(juror.id) || 0, juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10 ), })) .sort((a, b) => { // Combined score: 60% expertise, 40% load balancing const aTotal = a.score * 0.6 + a.loadScore * 0.4 const bTotal = b.score * 0.6 + b.loadScore * 0.4 return bTotal - aTotal }) // Assign top jurors for (let i = 0; i < Math.min(neededReviews, scoredJurors.length); i++) { const { juror, score } = scoredJurors[i] suggestions.push({ jurorId: juror.id, projectId: project.id, confidenceScore: score, expertiseMatchScore: score, reasoning: generateFallbackReasoning( juror.expertiseTags, project.tags, score ), }) // Update tracking existingSet.add(`${juror.id}:${project.id}`) jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1) projectAssignments.set( project.id, (projectAssignments.get(project.id) || 0) + 1 ) } } return { success: true, suggestions, fallbackUsed: true, } } /** * Calculate expertise match score based on tag overlap */ function calculateExpertiseScore( jurorTags: string[], projectTags: string[] ): number { if (jurorTags.length === 0 || projectTags.length === 0) { return 0.5 // Neutral score if no tags } const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase())) const matchingTags = projectTags.filter((t) => jurorTagsLower.has(t.toLowerCase()) ) // Score based on percentage of project tags matched const matchRatio = matchingTags.length / projectTags.length // Boost for having expertise, even if not all match const hasExpertise = matchingTags.length > 0 ? 0.2 : 0 return Math.min(1, matchRatio * 0.8 + hasExpertise) } /** * Calculate load balancing score (higher score = less loaded) */ function calculateLoadScore(currentLoad: number, maxLoad: number): number { if (maxLoad === 0) return 0 const utilization = currentLoad / maxLoad return Math.max(0, 1 - utilization) } /** * Generate reasoning for fallback assignments */ function generateFallbackReasoning( jurorTags: string[], projectTags: string[], score: number ): string { const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase())) const matchingTags = projectTags.filter((t) => jurorTagsLower.has(t.toLowerCase()) ) if (matchingTags.length > 0) { return `Expertise match: ${matchingTags.join(', ')}. Match score: ${(score * 100).toFixed(0)}%.` } if (score >= 0.5) { return `Assigned for workload balance. No direct expertise match but available capacity.` } return `Assigned to ensure project coverage.` }