/** * AI-Powered Assignment Service * * Uses GPT to analyze juror expertise and project requirements * to generate optimal assignment suggestions. * * Optimization: * - Batched processing (15 projects per batch) * - Description truncation (300 chars) * - Token tracking and cost logging * * GDPR Compliance: * - All data 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 { anonymizeForAI, deanonymizeResults, validateAnonymization, DESCRIPTION_LIMITS, truncateAndSanitize, type AnonymizationResult, } from './anonymization' // ─── Constants ─────────────────────────────────────────────────────────────── const ASSIGNMENT_BATCH_SIZE = 10 // Structured system prompt for assignment const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer for an ocean conservation competition. ## Your Role Match jurors to projects based on BALANCED workload distribution and expertise alignment. Even distribution is the TOP PRIORITY. ## Available Data - **Jurors**: expertiseTags, bio, country, currentAssignmentCount, maxAssignments - **Projects**: title, description, tags (with confidence 0-1), category, oceanIssue, country, institution, teamSize, fileTypes ## CRITICAL RULES (Must Follow) 1. **MULTIPLE REVIEWERS PER PROJECT**: Each project MUST be assigned to EXACTLY the number of DIFFERENT jurors specified in REVIEWS_PER_PROJECT. For example, if REVIEWS_PER_PROJECT is 3, every project needs 3 separate assignment objects with 3 different juror_ids. This is the most important rule. 2. **HARD CAP**: NEVER assign a juror more than their maxAssignments. Check currentAssignmentCount + new assignments in this batch. If a juror is at capacity, skip them. 3. **EVEN DISTRIBUTION**: Spread assignments evenly. Always prefer the juror with the FEWEST total assignments (currentAssignmentCount + assignments in this batch). Only deviate if the least-loaded juror has zero relevance and another with 1-2 more has strong expertise. ## Matching Criteria (Weighted) - Workload Balance (50%): Prefer least-loaded jurors. - Expertise Match (35%): Tag overlap, bio background, ocean issue alignment. - Diversity (15%): Avoid same-country; mix expertise per project. ## Output Format Return a JSON object. For N reviews/project, each project appears N times with DIFFERENT juror_ids: { "assignments": [ { "juror_id": "JUROR_001", "project_id": "PROJECT_001", "confidence_score": 0.85, "expertise_match_score": 0.7, "reasoning": "justification" }, { "juror_id": "JUROR_003", "project_id": "PROJECT_001", "confidence_score": 0.72, "expertise_match_score": 0.5, "reasoning": "justification" }, { "juror_id": "JUROR_005", "project_id": "PROJECT_001", "confidence_score": 0.65, "expertise_match_score": 0.3, "reasoning": "justification" } ] } ## Guidelines - Total assignments in output should be approximately: number_of_projects × REVIEWS_PER_PROJECT - NEVER exceed a juror's maxAssignments cap - Spread evenly — max-loaded minus min-loaded juror should differ by at most 2` // ─── Types ─────────────────────────────────────────────────────────────────── 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[] bio?: string | null country?: string | null maxAssignments?: number | null _count?: { assignments: number } } interface ProjectForAssignment { id: string title: string description?: string | null tags: string[] tagConfidences?: Array<{ name: string; confidence: number }> teamName?: string | null competitionCategory?: string | null oceanIssue?: string | null country?: string | null institution?: string | null teamSize?: number fileTypes?: string[] _count?: { assignments: number } } interface AssignmentConstraints { requiredReviewsPerProject: number minAssignmentsPerJuror?: number maxAssignmentsPerJuror?: number jurorLimits?: Record // userId -> personal max assignments existingAssignments: Array<{ jurorId: string projectId: string }> /** Ideal target assignments per juror (for balanced distribution hint) */ _targetPerJuror?: number } export interface AssignmentProgressCallback { (progress: { currentBatch: number totalBatches: number processedCount: number totalProjects: number }): Promise } // ─── AI Processing ─────────────────────────────────────────────────────────── /** * Process a batch of projects for assignment suggestions */ async function processAssignmentBatch( openai: NonNullable>>, model: string, anonymizedData: AnonymizationResult, batchProjects: typeof anonymizedData.projects, batchMappings: typeof anonymizedData.projectMappings, constraints: AssignmentConstraints, userId?: string, entityId?: string ): Promise<{ suggestions: AIAssignmentSuggestion[] tokensUsed: number }> { const suggestions: AIAssignmentSuggestion[] = [] let tokensUsed = 0 // Build prompt with batch-specific data const userPrompt = buildBatchPrompt( anonymizedData.jurors, batchProjects, constraints, anonymizedData.jurorMappings, batchMappings ) const MAX_PARSE_RETRIES = 2 let parseAttempts = 0 let response: Awaited> try { // Calculate maxTokens based on expected assignments // ~150 tokens per assignment JSON object, capped at 12000 const expectedAssignments = batchProjects.length * constraints.requiredReviewsPerProject const estimatedTokens = Math.min(12000, Math.max(4000, expectedAssignments * 200 + 500)) const params = buildCompletionParams(model, { messages: [ { role: 'system', content: ASSIGNMENT_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], jsonMode: true, temperature: 0.1, maxTokens: estimatedTokens, }) try { response = await openai.chat.completions.create(params) } catch (apiError) { // Provide clearer error for model-related issues const errorMsg = apiError instanceof Error ? apiError.message : String(apiError) if (errorMsg.includes('model') || errorMsg.includes('does not exist')) { throw new Error(`Invalid AI model "${model}". Please check the model name in Settings > AI Configuration.`) } throw apiError } const usage = extractTokenUsage(response) tokensUsed = usage.totalTokens // Log batch usage await logAIUsage({ userId, action: 'ASSIGNMENT', entityType: 'Round', entityId, model, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, totalTokens: usage.totalTokens, batchSize: batchProjects.length, itemsProcessed: batchProjects.length, status: 'SUCCESS', }) // Parse with retry logic let parsed: { assignments: Array<{ juror_id: string project_id: string confidence_score: number expertise_match_score: number reasoning: string }> } while (true) { try { const content = response.choices[0]?.message?.content if (!content) { // Check if response indicates an issue const finishReason = response.choices[0]?.finish_reason if (finishReason === 'content_filter') { throw new Error('AI response was filtered. Try a different model or simplify the project descriptions.') } if (!response.choices || response.choices.length === 0) { throw new Error(`No response from model "${model}". This model may not exist or may not be available. Please verify the model name.`) } throw new Error(`Empty response from AI model "${model}". The model may not support this type of request.`) } parsed = JSON.parse(content) break } catch (parseError) { if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) { parseAttempts++ console.warn(`[AI Assignment] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`) // Retry the API call with hint const retryParams = buildCompletionParams(model, { messages: [ { role: 'system', content: ASSIGNMENT_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 } } // De-anonymize and add to suggestions const deanonymized = deanonymizeResults( (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, })), anonymizedData.jurorMappings, batchMappings ) for (const item of deanonymized) { suggestions.push({ jurorId: item.realJurorId, projectId: item.realProjectId, confidenceScore: item.confidenceScore, reasoning: item.reasoning, expertiseMatchScore: item.expertiseMatchScore, }) } } catch (error) { if (error instanceof SyntaxError) { const parseError = createParseError(error.message) logAIError('Assignment', 'batch processing', parseError) await logAIUsage({ userId, action: 'ASSIGNMENT', entityType: 'Round', entityId, model, promptTokens: 0, completionTokens: 0, totalTokens: tokensUsed, batchSize: batchProjects.length, itemsProcessed: 0, status: 'ERROR', errorMessage: parseError.message, }) } else { throw error } } return { suggestions, tokensUsed } } /** * Build prompt for a batch of projects */ function buildBatchPrompt( jurors: AnonymizationResult['jurors'], projects: AnonymizationResult['projects'], constraints: AssignmentConstraints, jurorMappings: AnonymizationResult['jurorMappings'], projectMappings: AnonymizationResult['projectMappings'] ): string { // Map existing assignments to anonymous IDs const jurorIdMap = new Map(jurorMappings.map((m) => [m.realId, m.anonymousId])) const projectIdMap = new Map(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) // Build per-juror limits mapped to anonymous IDs let jurorLimitsStr = '' if (constraints.jurorLimits && Object.keys(constraints.jurorLimits).length > 0) { const anonymousLimits: Record = {} for (const [realId, limit] of Object.entries(constraints.jurorLimits)) { const anonId = jurorIdMap.get(realId) if (anonId) { anonymousLimits[anonId] = limit } } if (Object.keys(anonymousLimits).length > 0) { jurorLimitsStr = `\nJUROR_LIMITS: ${JSON.stringify(anonymousLimits)} (per-juror max assignments, override global max)` } } const targetStr = constraints._targetPerJuror ? `\nTARGET_PER_JUROR: ${constraints._targetPerJuror} (aim for this many total assignments per juror, ±1)` : '' const expectedTotal = projects.length * constraints.requiredReviewsPerProject // Instead of full existing assignment list, send per-juror current load counts // This keeps the prompt shorter as batches accumulate const jurorCurrentLoad: Record = {} for (const a of constraints.existingAssignments) { const anonId = jurorIdMap.get(a.jurorId) if (anonId) jurorCurrentLoad[anonId] = (jurorCurrentLoad[anonId] || 0) + 1 } // Also track which projects in this batch already have assignments const projectExistingReviewers: Record = {} for (const a of constraints.existingAssignments) { const anonProjectId = projectIdMap.get(a.projectId) const anonJurorId = jurorIdMap.get(a.jurorId) if (anonProjectId && anonJurorId) { if (!projectExistingReviewers[anonProjectId]) projectExistingReviewers[anonProjectId] = [] projectExistingReviewers[anonProjectId].push(anonJurorId) } } return `JURORS: ${JSON.stringify(jurors)} PROJECTS: ${JSON.stringify(projects)} REVIEWS_PER_PROJECT: ${constraints.requiredReviewsPerProject} (each project MUST get exactly ${constraints.requiredReviewsPerProject} different jurors) MAX_PER_JUROR: ${constraints.maxAssignmentsPerJuror || 'unlimited'} (HARD LIMIT — never exceed)${jurorLimitsStr}${targetStr} CURRENT_JUROR_LOAD: ${JSON.stringify(jurorCurrentLoad)} (add these to currentAssignmentCount to get true total) ALREADY_ASSIGNED: ${JSON.stringify(projectExistingReviewers)} (do NOT assign these juror-project pairs again) EXPECTED_OUTPUT: ${expectedTotal} assignment objects (${projects.length} projects × ${constraints.requiredReviewsPerProject} reviewers) IMPORTANT: Every project must appear ${constraints.requiredReviewsPerProject} times with ${constraints.requiredReviewsPerProject} DIFFERENT juror_ids. Pick the least-loaded jurors first. Return JSON: {"assignments": [...]}` } /** * Generate AI-powered assignment suggestions with batching */ export async function generateAIAssignments( jurors: JurorForAssignment[], projects: ProjectForAssignment[], constraints: AssignmentConstraints, userId?: string, entityId?: string, onProgress?: AssignmentProgressCallback ): Promise { // Truncate descriptions before anonymization const truncatedProjects = projects.map((p) => ({ ...p, description: truncateAndSanitize(p.description, DESCRIPTION_LIMITS.ASSIGNMENT), })) // Anonymize data before sending to AI const anonymizedData = anonymizeForAI(jurors, truncatedProjects) // Validate anonymization if (!validateAnonymization(anonymizedData)) { console.error('[AI Assignment] Anonymization validation failed, falling back to algorithm') return generateFallbackAssignments(jurors, projects, constraints) } try { const openai = await getOpenAI() if (!openai) { console.log('[AI Assignment] OpenAI not configured, using fallback algorithm') return generateFallbackAssignments(jurors, projects, constraints) } const model = await getConfiguredModel() console.log(`[AI Assignment] Using model: ${model} for ${projects.length} projects in batches of ${ASSIGNMENT_BATCH_SIZE}`) const allSuggestions: AIAssignmentSuggestion[] = [] let totalTokens = 0 // Calculate ideal distribution for the prompt const totalNeededAssignments = projects.length * constraints.requiredReviewsPerProject const maxCap = constraints.maxAssignmentsPerJuror ?? Math.ceil(totalNeededAssignments / jurors.length) + 2 const idealPerJuror = Math.ceil(totalNeededAssignments / jurors.length) // Track cumulative assignments across batches (real IDs) const cumulativeAssignments: Array<{ jurorId: string; projectId: string }> = [ ...constraints.existingAssignments, ] // Process projects in batches const totalBatches = Math.ceil(anonymizedData.projects.length / ASSIGNMENT_BATCH_SIZE) for (let i = 0; i < anonymizedData.projects.length; i += ASSIGNMENT_BATCH_SIZE) { const batchProjects = anonymizedData.projects.slice(i, i + ASSIGNMENT_BATCH_SIZE) const batchMappings = anonymizedData.projectMappings.slice(i, i + ASSIGNMENT_BATCH_SIZE) const currentBatch = Math.floor(i / ASSIGNMENT_BATCH_SIZE) + 1 console.log(`[AI Assignment] Processing batch ${currentBatch}/${totalBatches}`) // Pass cumulative assignments so GPT knows about previous batch results const batchConstraints: AssignmentConstraints = { ...constraints, maxAssignmentsPerJuror: maxCap, existingAssignments: cumulativeAssignments, _targetPerJuror: idealPerJuror, } const { suggestions, tokensUsed } = await processAssignmentBatch( openai, model, anonymizedData, batchProjects, batchMappings, batchConstraints, userId, entityId ) // Add this batch's results to cumulative tracking for (const s of suggestions) { cumulativeAssignments.push({ jurorId: s.jurorId, projectId: s.projectId }) } allSuggestions.push(...suggestions) totalTokens += tokensUsed // Report progress after each batch if (onProgress) { const processedCount = Math.min((currentBatch) * ASSIGNMENT_BATCH_SIZE, projects.length) await onProgress({ currentBatch, totalBatches, processedCount, totalProjects: projects.length, }) } } console.log(`[AI Assignment] Completed. Total suggestions: ${allSuggestions.length}, Total tokens: ${totalTokens}`) // Post-process: enforce hard cap and rebalance const balanced = rebalanceAssignments(allSuggestions, jurors, constraints, maxCap) // Fill coverage gaps: if any project has fewer than requiredReviewsPerProject, use fallback const gapFilled = fillCoverageGaps(balanced, jurors, projects, constraints, maxCap) console.log(`[AI Assignment] After gap-fill: ${gapFilled.length} total (${gapFilled.length - balanced.length} added for coverage)`) return { success: true, suggestions: gapFilled, tokensUsed: totalTokens, fallbackUsed: false, } } catch (error) { const classified = classifyAIError(error) logAIError('Assignment', 'generateAIAssignments', classified) // Log failed attempt await logAIUsage({ userId, action: 'ASSIGNMENT', entityType: 'Round', entityId, model: 'unknown', promptTokens: 0, completionTokens: 0, totalTokens: 0, batchSize: projects.length, itemsProcessed: 0, status: 'ERROR', errorMessage: classified.message, }) console.error('[AI Assignment] AI assignment failed, using fallback:', classified.message) return generateFallbackAssignments(jurors, projects, constraints) } } // ─── Post-Processing Rebalancer ───────────────────────────────────────────── /** * Enforce hard caps and rebalance assignments from overloaded jurors. * Moves excess assignments from over-cap jurors to under-loaded jurors * that haven't been assigned that project yet. */ function rebalanceAssignments( suggestions: AIAssignmentSuggestion[], jurors: JurorForAssignment[], constraints: AssignmentConstraints, maxCap: number, ): AIAssignmentSuggestion[] { // Build juror load tracking (existing DB assignments + new AI suggestions) const jurorLoad = new Map() const jurorSet = new Set(jurors.map((j) => j.id)) // Count existing assignments from DB for (const ea of constraints.existingAssignments) { if (jurorSet.has(ea.jurorId)) { jurorLoad.set(ea.jurorId, (jurorLoad.get(ea.jurorId) || 0) + 1) } } // Count new suggestions per juror const newLoadPerJuror = new Map() for (const s of suggestions) { newLoadPerJuror.set(s.jurorId, (newLoadPerJuror.get(s.jurorId) || 0) + 1) } // Effective cap: per-juror personal cap or global cap const getEffectiveCap = (jurorId: string) => { if (constraints.jurorLimits?.[jurorId]) return constraints.jurorLimits[jurorId] const juror = jurors.find((j) => j.id === jurorId) return juror?.maxAssignments ?? maxCap } // Calculate max new assignments allowed per juror const maxNewForJuror = (jurorId: string) => { const existing = jurorLoad.get(jurorId) || 0 const cap = getEffectiveCap(jurorId) return Math.max(0, cap - existing) } // Sort suggestions by confidence (keep best matches when trimming) const sorted = [...suggestions].sort((a, b) => b.confidenceScore - a.confidenceScore) // Phase 1: Accept assignments up to each juror's cap const accepted: AIAssignmentSuggestion[] = [] const rejected: AIAssignmentSuggestion[] = [] const acceptedPerJuror = new Map() const acceptedPairs = new Set() const projectCoverage = new Map() // projectId → accepted reviewers for (const s of sorted) { const currentNew = acceptedPerJuror.get(s.jurorId) || 0 const allowed = maxNewForJuror(s.jurorId) const pairKey = `${s.jurorId}:${s.projectId}` if (currentNew < allowed && !acceptedPairs.has(pairKey)) { accepted.push(s) acceptedPerJuror.set(s.jurorId, currentNew + 1) acceptedPairs.add(pairKey) projectCoverage.set(s.projectId, (projectCoverage.get(s.projectId) || 0) + 1) } else { rejected.push(s) } } // Phase 2: Reassign rejected items to least-loaded jurors that aren't at cap for (const r of rejected) { const currentCoverage = projectCoverage.get(r.projectId) || 0 if (currentCoverage >= constraints.requiredReviewsPerProject) continue // project is covered // Find the least-loaded juror who can take this project const candidates = jurors .filter((j) => { const pairKey = `${j.id}:${r.projectId}` if (acceptedPairs.has(pairKey)) return false // already assigned // Check existing DB pairs too if (constraints.existingAssignments.some( (ea) => ea.jurorId === j.id && ea.projectId === r.projectId )) return false const currentNew = acceptedPerJuror.get(j.id) || 0 return currentNew < maxNewForJuror(j.id) }) .sort((a, b) => { const aTotal = (jurorLoad.get(a.id) || 0) + (acceptedPerJuror.get(a.id) || 0) const bTotal = (jurorLoad.get(b.id) || 0) + (acceptedPerJuror.get(b.id) || 0) return aTotal - bTotal // least loaded first }) if (candidates.length > 0) { const picked = candidates[0] accepted.push({ jurorId: picked.id, projectId: r.projectId, confidenceScore: r.confidenceScore * 0.8, // slightly lower confidence for reassigned expertiseMatchScore: r.expertiseMatchScore * 0.5, reasoning: `Reassigned for workload balance (originally suggested for another juror at capacity).`, }) acceptedPerJuror.set(picked.id, (acceptedPerJuror.get(picked.id) || 0) + 1) acceptedPairs.add(`${picked.id}:${r.projectId}`) projectCoverage.set(r.projectId, (projectCoverage.get(r.projectId) || 0) + 1) } } // Log rebalancing stats const rebalanced = accepted.length - (suggestions.length - rejected.length) if (rejected.length > 0) { console.log( `[AI Assignment] Rebalanced: ${rejected.length} over-cap assignments redistributed, ` + `${rebalanced} successfully reassigned` ) } return accepted } /** * Fill coverage gaps — ensure every project has requiredReviewsPerProject assignments. * Uses a simple least-loaded-juror algorithm to fill missing slots. */ function fillCoverageGaps( suggestions: AIAssignmentSuggestion[], jurors: JurorForAssignment[], projects: ProjectForAssignment[], constraints: AssignmentConstraints, maxCap: number, ): AIAssignmentSuggestion[] { const result = [...suggestions] // Track current state const assignedPairs = new Set() const jurorLoad = new Map() const projectCoverage = new Map() // Count existing DB assignments for (const ea of constraints.existingAssignments) { assignedPairs.add(`${ea.jurorId}:${ea.projectId}`) jurorLoad.set(ea.jurorId, (jurorLoad.get(ea.jurorId) || 0) + 1) projectCoverage.set(ea.projectId, (projectCoverage.get(ea.projectId) || 0) + 1) } // Count new AI suggestions for (const s of suggestions) { assignedPairs.add(`${s.jurorId}:${s.projectId}`) jurorLoad.set(s.jurorId, (jurorLoad.get(s.jurorId) || 0) + 1) projectCoverage.set(s.projectId, (projectCoverage.get(s.projectId) || 0) + 1) } const getEffectiveCap = (jurorId: string) => { if (constraints.jurorLimits?.[jurorId]) return constraints.jurorLimits[jurorId] const juror = jurors.find((j) => j.id === jurorId) return juror?.maxAssignments ?? maxCap } let gapsFilled = 0 // For each project, check if it needs more reviewers for (const project of projects) { const current = projectCoverage.get(project.id) || 0 const needed = constraints.requiredReviewsPerProject - current if (needed <= 0) continue // Find available jurors sorted by load (least loaded first) const candidates = jurors .filter((j) => { const pairKey = `${j.id}:${project.id}` if (assignedPairs.has(pairKey)) return false const load = jurorLoad.get(j.id) || 0 return load < getEffectiveCap(j.id) }) .sort((a, b) => { const aLoad = jurorLoad.get(a.id) || 0 const bLoad = jurorLoad.get(b.id) || 0 return aLoad - bLoad }) for (let i = 0; i < Math.min(needed, candidates.length); i++) { const juror = candidates[i] const expertiseScore = calculateExpertiseScore( juror.expertiseTags, project.tags, project.tagConfidences, ) result.push({ jurorId: juror.id, projectId: project.id, confidenceScore: expertiseScore * 0.7, // slightly lower confidence for gap-fill expertiseMatchScore: expertiseScore, reasoning: generateFallbackReasoning(juror.expertiseTags, project.tags, expertiseScore) + ' (Added to meet coverage requirement)', }) assignedPairs.add(`${juror.id}:${project.id}`) jurorLoad.set(juror.id, (jurorLoad.get(juror.id) || 0) + 1) projectCoverage.set(project.id, (projectCoverage.get(project.id) || 0) + 1) gapsFilled++ } } if (gapsFilled > 0) { console.log(`[AI Assignment] Gap-filled ${gapsFilled} assignment(s) to meet coverage requirements`) } return result } // ─── Fallback Algorithm ────────────────────────────────────────────────────── /** * 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) => { const currentLoad = jurorAssignments.get(juror.id) || 0 const maxLoad = juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 20 const minTarget = constraints.minAssignmentsPerJuror ?? 5 return { juror, score: calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences), loadScore: calculateLoadScore(currentLoad, maxLoad), underMinBonus: calculateUnderMinBonus(currentLoad, minTarget), } }) .sort((a, b) => { // Combined score: 50% expertise, 30% load balancing, 20% under-min bonus const aTotal = a.score * 0.5 + a.loadScore * 0.3 + a.underMinBonus * 0.2 const bTotal = b.score * 0.5 + b.loadScore * 0.3 + b.underMinBonus * 0.2 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 * When tagConfidences are available, weights matches by confidence */ function calculateExpertiseScore( jurorTags: string[], projectTags: string[], tagConfidences?: Array<{ name: string; confidence: number }> ): 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())) // If we have confidence data, use weighted scoring if (tagConfidences && tagConfidences.length > 0) { let weightedMatches = 0 let totalWeight = 0 for (const tc of tagConfidences) { totalWeight += tc.confidence if (jurorTagsLower.has(tc.name.toLowerCase())) { weightedMatches += tc.confidence } } if (totalWeight === 0) return 0.5 const weightedRatio = weightedMatches / totalWeight const hasExpertise = weightedMatches > 0 ? 0.2 : 0 return Math.min(1, weightedRatio * 0.8 + hasExpertise) } // Fallback: unweighted matching using flat tags const matchingTags = projectTags.filter((t) => jurorTagsLower.has(t.toLowerCase()) ) const matchRatio = matchingTags.length / projectTags.length 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) } /** * Calculate bonus for jurors under their minimum target * Returns 1.0 if under min, scaled down as approaching min */ function calculateUnderMinBonus(currentLoad: number, minTarget: number): number { if (currentLoad >= minTarget) return 0 // Scale bonus based on how far under min (1.0 at 0 load, decreasing as approaching min) return (minTarget - currentLoad) / minTarget } /** * 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.` }