/** * 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 = 15 // 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. **HARD CAP**: NEVER assign a juror more than their maxAssignments. Check currentAssignmentCount + new assignments in this batch. If currentAssignmentCount >= maxAssignments, do NOT assign that juror at all. 2. **EVEN DISTRIBUTION**: Assignments must be spread as evenly as possible. Before assigning a project, always pick the juror with the FEWEST total assignments (currentAssignmentCount + assignments already given in this batch). Only deviate from this if the least-loaded juror has zero expertise relevance AND another juror with only 1-2 more assignments has strong expertise. 3. **TARGET_PER_JUROR**: A target per juror is provided. Stay within ±1 of this target for every juror. ## Matching Criteria (Weighted) - Workload Balance (50%): Prefer the least-loaded juror. A juror with 2 fewer assignments than another should ALWAYS be preferred unless they have zero relevance. - Expertise & Domain Match (35%): Tag overlap, bio background, ocean issue alignment. Use bio text for deeper matching. - Geographic/Disciplinary Diversity (15%): Avoid same-country matches; mix expertise backgrounds per project. ## Output Format Return a JSON object: { "assignments": [ { "juror_id": "JUROR_001", "project_id": "PROJECT_001", "confidence_score": 0.0-1.0, "expertise_match_score": 0.0-1.0, "reasoning": "1-2 sentence justification" } ] } ## Guidelines - Each project MUST receive the required number of reviews - NEVER exceed a juror's maxAssignments cap — this is a hard constraint - Spread assignments evenly — the difference between the most-loaded and least-loaded juror should be at most 2 - confidence_score reflects overall assignment quality; expertise_match_score reflects expertise overlap` // ─── 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 { const params = buildCompletionParams(model, { messages: [ { role: 'system', content: ASSIGNMENT_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], jsonMode: true, temperature: 0.1, maxTokens: 4000, }) 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)` : '' return `JURORS: ${JSON.stringify(jurors)} PROJECTS: ${JSON.stringify(projects)} CONSTRAINTS: ${constraints.requiredReviewsPerProject} reviews/project, max ${constraints.maxAssignmentsPerJuror || 'unlimited'}/juror (HARD LIMIT — never exceed)${jurorLimitsStr}${targetStr} EXISTING: ${JSON.stringify(anonymousExisting)} IMPORTANT: Check each juror's currentAssignmentCount + existing assignments before assigning. If at or over max, skip them entirely. Distribute evenly — pick the least-loaded juror 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) return { success: true, suggestions: balanced, 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 } // ─── 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.` }