/** * AI-Powered Assignment Service (Hybrid Approach) * * Phase 1 — AI Scoring: ONE API call asks GPT to score each juror's affinity * for each project (expertise match, reasoning). Returns a preference matrix. * Phase 2 — Algorithm: Uses the AI scores to assign N reviewers per project * with even workload distribution, respecting caps and COI constraints. * * 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' // ─── 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 } /** Per-juror ranking from AI: which projects they should review */ interface JurorAffinityRow { jurorId: string // anonymous ID rankings: Array<{ projectId: string // anonymous ID score: number // 0-100 reasoning: string }> } // ─── System Prompt ────────────────────────────────────────────────────────── const AFFINITY_SYSTEM_PROMPT = `You are an expert jury assignment optimizer for an ocean conservation competition. ## Your Task Score how well each juror matches each project. Return a compact affinity matrix. ## Scoring Criteria (100-point scale) - **Expertise Match (60 pts)**: Tag overlap, bio background relevance, ocean issue alignment - **Diversity Benefit (25 pts)**: Different country from project, different expertise angle from other jurors - **Category Fit (15 pts)**: Experience with startup vs concept evaluation, institutional familiarity ## Output Format Return JSON with this exact structure: { "affinities": [ { "juror_id": "JUROR_001", "rankings": [ {"project_id": "PROJECT_001", "score": 85, "reason": "Strong coral reef expertise matches project focus"}, {"project_id": "PROJECT_005", "score": 72, "reason": "Marine biology background relevant to biodiversity project"} ] } ] } ## Rules - For each juror, list their TOP project matches (at least the top 50% of projects, more is better) - Scores must be integers 0-100 - Keep "reason" to one short sentence (under 20 words) - A juror with no matching expertise should still get scores (based on general competence), just lower ones (30-50 range) - Do NOT include projects that a juror has zero relevance for (score would be under 20) - Return VALID JSON only` // ─── AI Scoring Phase ─────────────────────────────────────────────────────── /** * Build the user prompt for the single AI affinity call */ function buildAffinityPrompt( anonymizedData: AnonymizationResult, existingPairs: Set, ): string { // Compact juror representation const jurorLines = anonymizedData.jurors.map((j) => { const parts = [j.anonymousId] if (j.expertiseTags.length > 0) parts.push(`tags:[${j.expertiseTags.join(',')}]`) if (j.bio) parts.push(`bio:"${j.bio.slice(0, 150)}"`) if (j.country) parts.push(`country:${j.country}`) return parts.join(' | ') }) // Compact project representation const projectLines = anonymizedData.projects.map((p) => { const parts = [p.anonymousId, `"${p.title}"`] if (p.tags.length > 0) parts.push(`tags:[${p.tags.map((t) => t.name).join(',')}]`) if (p.category) parts.push(`cat:${p.category}`) if (p.oceanIssue) parts.push(`issue:${p.oceanIssue}`) if (p.country) parts.push(`country:${p.country}`) if (p.description) parts.push(`desc:"${p.description.slice(0, 100)}"`) return parts.join(' | ') }) // Note existing assignments to avoid let existingNote = '' if (existingPairs.size > 0) { existingNote = `\nALREADY_ASSIGNED (do NOT score these pairs): ${[...existingPairs].join(', ')}` } return `## JURORS (${jurorLines.length}) ${jurorLines.join('\n')} ## PROJECTS (${projectLines.length}) ${projectLines.join('\n')} ${existingNote} Score each juror's affinity for the projects. For each juror, return their top project matches with scores (0-100) and a short reason.` } /** * Call AI once to get the full affinity matrix */ async function getAIAffinityMatrix( openai: NonNullable>>, model: string, anonymizedData: AnonymizationResult, existingPairs: Set, userId?: string, entityId?: string, ): Promise<{ affinities: JurorAffinityRow[] tokensUsed: number }> { const userPrompt = buildAffinityPrompt(anonymizedData, existingPairs) // Estimate tokens: ~50 tokens per juror-project score entry // For 15 jurors × 99 projects top 60% = ~890 entries × 50 = ~44500 output tokens // Cap at a reasonable limit const estimatedEntries = anonymizedData.jurors.length * Math.ceil(anonymizedData.projects.length * 0.6) const estimatedTokens = Math.min(64000, Math.max(8000, estimatedEntries * 50 + 500)) console.log(`[AI Assignment] Affinity call: ${anonymizedData.jurors.length} jurors × ${anonymizedData.projects.length} projects, est. ${estimatedEntries} entries, maxTokens=${estimatedTokens}`) const params = buildCompletionParams(model, { messages: [ { role: 'system', content: AFFINITY_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], jsonMode: true, temperature: 0.1, maxTokens: estimatedTokens, }) let response: Awaited> try { response = await openai.chat.completions.create(params) } catch (apiError) { 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) await logAIUsage({ userId, action: 'ASSIGNMENT', entityType: 'Round', entityId, model, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, totalTokens: usage.totalTokens, batchSize: anonymizedData.projects.length, itemsProcessed: anonymizedData.projects.length, status: 'SUCCESS', }) // Parse response const content = response.choices[0]?.message?.content if (!content) { 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 (finishReason === 'length') { console.warn('[AI Assignment] Response truncated (hit token limit). Will proceed with partial data + algorithm gap-fill.') } else { throw new Error(`Empty response from AI model "${model}".`) } } let parsed: { affinities: Array<{ juror_id: string rankings: Array<{ project_id: string score: number reason: string }> }> } try { // Handle potentially truncated JSON by attempting repair let jsonStr = content || '{}' // If truncated, try to close the JSON structure if (!jsonStr.trim().endsWith('}')) { console.warn('[AI Assignment] Response appears truncated, attempting JSON repair') jsonStr = repairTruncatedJSON(jsonStr) } parsed = JSON.parse(jsonStr) } catch (parseError) { // Try extracting JSON from markdown code blocks const jsonMatch = (content || '').match(/```(?:json)?\s*([\s\S]*?)```/) if (jsonMatch) { try { parsed = JSON.parse(jsonMatch[1]) } catch { throw createParseError(`Failed to parse AI affinity response: ${(parseError as Error).message}`) } } else { throw createParseError(`Failed to parse AI affinity response: ${(parseError as Error).message}`) } } // Normalize to our internal format const affinities: JurorAffinityRow[] = (parsed.affinities || []).map((a) => ({ jurorId: a.juror_id, rankings: (a.rankings || []).map((r) => ({ projectId: r.project_id, score: Math.min(100, Math.max(0, r.score)), reasoning: r.reason || '', })), })) console.log(`[AI Assignment] Got affinities for ${affinities.length} jurors, total entries: ${affinities.reduce((sum, a) => sum + a.rankings.length, 0)}`) return { affinities, tokensUsed: usage.totalTokens } } /** * Attempt to repair truncated JSON by closing open structures */ function repairTruncatedJSON(json: string): string { let s = json.trim() // Remove any trailing incomplete entry (cut mid-object) const lastCompleteEntry = s.lastIndexOf('}') if (lastCompleteEntry > 0) { s = s.slice(0, lastCompleteEntry + 1) } // Count open/close brackets let openBrackets = 0 let openBraces = 0 for (const ch of s) { if (ch === '[') openBrackets++ else if (ch === ']') openBrackets-- else if (ch === '{') openBraces++ else if (ch === '}') openBraces-- } // Close everything while (openBrackets > 0) { s += ']'; openBrackets-- } while (openBraces > 0) { s += '}'; openBraces-- } return s } // ─── Algorithm Phase ──────────────────────────────────────────────────────── /** * Build a full score matrix from AI affinities, filling gaps with fallback scores */ function buildScoreMatrix( affinities: JurorAffinityRow[], jurors: JurorForAssignment[], projects: ProjectForAssignment[], anonymizedData: AnonymizationResult, ): Map> { // Create reverse mapping: anonymous ID → real ID const jurorAnonToReal = new Map(anonymizedData.jurorMappings.map((m) => [m.anonymousId, m.realId])) const projectAnonToReal = new Map(anonymizedData.projectMappings.map((m) => [m.anonymousId, m.realId])) // Matrix: realJurorId → realProjectId → { score, reasoning } const matrix = new Map>() // Initialize with AI scores for (const row of affinities) { const realJurorId = jurorAnonToReal.get(row.jurorId) if (!realJurorId) continue const jurorScores = new Map() for (const r of row.rankings) { const realProjectId = projectAnonToReal.get(r.projectId) if (!realProjectId) continue jurorScores.set(realProjectId, { score: r.score / 100, // normalize to 0-1 reasoning: r.reasoning, }) } matrix.set(realJurorId, jurorScores) } // Fill gaps: for juror-project pairs not scored by AI, use tag-based fallback for (const juror of jurors) { if (!matrix.has(juror.id)) { matrix.set(juror.id, new Map()) } const jurorScores = matrix.get(juror.id)! for (const project of projects) { if (!jurorScores.has(project.id)) { const tagScore = calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences) jurorScores.set(project.id, { score: tagScore * 0.5, // Scale down fallback scores reasoning: generateFallbackReasoning(juror.expertiseTags, project.tags, tagScore), }) } } } return matrix } /** * Balanced assignment algorithm using AI affinity scores. * * Strategy: iteratively assign the best available juror to each under-covered * project, always preferring the least-loaded juror among those with decent scores. */ function assignFromScores( scoreMatrix: Map>, jurors: JurorForAssignment[], projects: ProjectForAssignment[], constraints: AssignmentConstraints, maxCap: number, ): AIAssignmentSuggestion[] { const result: AIAssignmentSuggestion[] = [] // Track state const assignedPairs = new Set() const jurorLoad = new Map() // total load (existing + new) const projectCoverage = new Map() // how many reviewers assigned // Initialize from existing 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) } // Also count existing DB assignments from _count for (const j of jurors) { const dbCount = j._count?.assignments || 0 jurorLoad.set(j.id, Math.max(jurorLoad.get(j.id) || 0, dbCount)) } for (const p of projects) { const dbCount = p._count?.assignments || 0 projectCoverage.set(p.id, Math.max(projectCoverage.get(p.id) || 0, dbCount)) } const getEffectiveCap = (jurorId: string) => { if (constraints.jurorLimits?.[jurorId]) return constraints.jurorLimits[jurorId] const juror = jurors.find((j) => j.id === jurorId) return juror?.maxAssignments ?? maxCap } // Ideal target: distribute evenly const totalNeeded = projects.reduce((sum, p) => { const current = projectCoverage.get(p.id) || 0 return sum + Math.max(0, constraints.requiredReviewsPerProject - current) }, 0) const idealPerJuror = Math.ceil(totalNeeded / jurors.length) console.log(`[AI Assignment] Algorithm: ${totalNeeded} slots to fill, ideal ${idealPerJuror}/juror, cap ${maxCap}/juror`) // Iterative assignment: repeat until all projects are covered or no more capacity for (let pass = 0; pass < constraints.requiredReviewsPerProject; pass++) { // Sort projects by coverage gap (most under-covered first) const projectsByNeed = [...projects] .map((p) => ({ project: p, current: projectCoverage.get(p.id) || 0, needed: constraints.requiredReviewsPerProject, })) .filter((pp) => pp.current < pp.needed) .sort((a, b) => (a.current - a.needed) - (b.current - b.needed)) if (projectsByNeed.length === 0) break for (const { project } of projectsByNeed) { const currentCoverage = projectCoverage.get(project.id) || 0 if (currentCoverage >= constraints.requiredReviewsPerProject) continue // Find best available juror: weighted by AI score AND workload balance 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) }) .map((j) => { const load = jurorLoad.get(j.id) || 0 const aiData = scoreMatrix.get(j.id)?.get(project.id) const aiScore = aiData?.score ?? 0.3 const reasoning = aiData?.reasoning ?? 'Assigned for coverage' // Workload penalty: heavily penalize jurors above ideal target // This ensures even distribution const loadRatio = load / Math.max(1, idealPerJuror) const loadPenalty = loadRatio > 1 ? 0.3 * Math.pow(0.5, loadRatio - 1) // Steep drop-off above ideal : 1 - (loadRatio * 0.4) // Gentle linear decrease up to ideal // Combined score: 55% AI score, 45% workload balance const combinedScore = aiScore * 0.55 + loadPenalty * 0.45 return { juror: j, aiScore, combinedScore, reasoning, load } }) .sort((a, b) => b.combinedScore - a.combinedScore) if (candidates.length === 0) continue const best = candidates[0] result.push({ jurorId: best.juror.id, projectId: project.id, confidenceScore: best.aiScore, expertiseMatchScore: best.aiScore, reasoning: best.reasoning, }) assignedPairs.add(`${best.juror.id}:${project.id}`) jurorLoad.set(best.juror.id, (best.load) + 1) projectCoverage.set(project.id, (currentCoverage) + 1) } } // Log final distribution const newAssignmentsPerJuror = new Map() for (const s of result) { newAssignmentsPerJuror.set(s.jurorId, (newAssignmentsPerJuror.get(s.jurorId) || 0) + 1) } const loads = [...newAssignmentsPerJuror.values()] if (loads.length > 0) { console.log(`[AI Assignment] Distribution: min=${Math.min(...loads)}, max=${Math.max(...loads)}, avg=${(loads.reduce((a, b) => a + b, 0) / loads.length).toFixed(1)}`) } const uncovered = projects.filter((p) => (projectCoverage.get(p.id) || 0) < constraints.requiredReviewsPerProject) if (uncovered.length > 0) { console.warn(`[AI Assignment] ${uncovered.length} projects still under-covered after assignment`) } return result } // ─── Main Entry Point ─────────────────────────────────────────────────────── /** * Generate AI-powered assignment suggestions (hybrid approach). * * 1. ONE AI call: get affinity scores for all juror-project pairs * 2. Algorithm: assign N reviewers per project using AI scores + workload balancing */ 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) } // Build existing pair set for AI (anonymous IDs) const jurorRealToAnon = new Map(anonymizedData.jurorMappings.map((m) => [m.realId, m.anonymousId])) const projectRealToAnon = new Map(anonymizedData.projectMappings.map((m) => [m.realId, m.anonymousId])) const existingAnonPairs = new Set() for (const ea of constraints.existingAssignments) { const aJ = jurorRealToAnon.get(ea.jurorId) const aP = projectRealToAnon.get(ea.projectId) if (aJ && aP) existingAnonPairs.add(`${aJ}:${aP}`) } // Calculate caps const totalNeeded = projects.length * constraints.requiredReviewsPerProject const maxCap = constraints.maxAssignmentsPerJuror ?? Math.ceil(totalNeeded / jurors.length) + 2 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] Hybrid approach: ${projects.length} projects, ${jurors.length} jurors, ${constraints.requiredReviewsPerProject} reviews/project, model: ${model}`) // ── Phase 1: AI Scoring (single call) ── console.log('[AI Assignment] Phase 1: Getting AI affinity scores...') const { affinities, tokensUsed } = await getAIAffinityMatrix( openai, model, anonymizedData, existingAnonPairs, userId, entityId, ) // ── Phase 2: Build score matrix and run algorithm ── console.log('[AI Assignment] Phase 2: Running balanced assignment algorithm...') const scoreMatrix = buildScoreMatrix(affinities, jurors, projects, anonymizedData) const suggestions = assignFromScores(scoreMatrix, jurors, projects, constraints, maxCap) console.log(`[AI Assignment] Complete: ${suggestions.length} assignments, ${tokensUsed} tokens used`) return { success: true, suggestions, tokensUsed, fallbackUsed: false, } } catch (error) { const classified = classifyAIError(error) logAIError('Assignment', 'generateAIAssignments', classified) 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 failed, falling back to algorithm:', classified.message) const fallback = generateFallbackAssignments(jurors, projects, constraints) return { ...fallback, error: `AI scoring failed (${classified.message}). Used algorithmic fallback.`, } } } // ─── 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) ) } const totalNeeded = projects.length * constraints.requiredReviewsPerProject const maxCap = constraints.maxAssignmentsPerJuror ?? Math.ceil(totalNeeded / jurors.length) + 2 const idealPerJuror = Math.ceil(totalNeeded / jurors.length) // Iterative: for each pass, assign one more reviewer per under-covered project for (let pass = 0; pass < constraints.requiredReviewsPerProject; pass++) { // 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 (const project of sortedProjects) { const currentProjectAssignments = projectAssignments.get(project.id) || 0 if (currentProjectAssignments >= constraints.requiredReviewsPerProject) continue // Score jurors with heavy workload emphasis const scoredJurors = jurors .filter((juror) => { if (existingSet.has(`${juror.id}:${project.id}`)) return false const currentLoad = jurorAssignments.get(juror.id) || 0 const cap = juror.maxAssignments ?? maxCap return currentLoad < cap }) .map((juror) => { const currentLoad = jurorAssignments.get(juror.id) || 0 const expertiseScore = calculateExpertiseScore( juror.expertiseTags, project.tags, project.tagConfidences, ) // Heavy workload balance weight const loadRatio = currentLoad / Math.max(1, idealPerJuror) const loadPenalty = loadRatio > 1 ? 0.3 * Math.pow(0.5, loadRatio - 1) : 1 - (loadRatio * 0.4) return { juror, expertiseScore, combinedScore: expertiseScore * 0.45 + loadPenalty * 0.55, } }) .sort((a, b) => b.combinedScore - a.combinedScore) if (scoredJurors.length === 0) continue const { juror, expertiseScore } = scoredJurors[0] suggestions.push({ jurorId: juror.id, projectId: project.id, confidenceScore: expertiseScore, expertiseMatchScore: expertiseScore, reasoning: generateFallbackReasoning(juror.expertiseTags, project.tags, expertiseScore), }) existingSet.add(`${juror.id}:${project.id}`) jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1) projectAssignments.set(project.id, currentProjectAssignments + 1) } } return { success: true, suggestions, fallbackUsed: true, } } // ─── Scoring Helpers ──────────────────────────────────────────────────────── /** * 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) } /** * 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.` }