/** * Smart Assignment Scoring Service * * Calculates scores for jury/mentor-project matching based on: * - Tag overlap (expertise match) * - Workload balance * - Country match (mentors only) * * Score Breakdown (100 points max): * - Tag overlap: 0-50 points (weighted by confidence) * - Workload balance: 0-25 points * - Country match: 0-15 points (mentors only) * - Reserved: 0-10 points (future AI boost) */ import { prisma } from '@/lib/prisma' // ─── Types ────────────────────────────────────────────────────────────────── export interface ScoreBreakdown { tagOverlap: number workloadBalance: number countryMatch: number aiBoost: number } export interface AssignmentScore { userId: string userName: string userEmail: string projectId: string projectTitle: string score: number breakdown: ScoreBreakdown reasoning: string[] matchingTags: string[] } export interface ProjectTagData { tagId: string tagName: string confidence: number } // ─── Constants ─────────────────────────────────────────────────────────────── const MAX_TAG_OVERLAP_SCORE = 50 const MAX_WORKLOAD_SCORE = 25 const MAX_COUNTRY_SCORE = 15 const POINTS_PER_TAG_MATCH = 10 // ─── Scoring Functions ─────────────────────────────────────────────────────── /** * Calculate tag overlap score between user expertise and project tags */ export function calculateTagOverlapScore( userTagNames: string[], projectTags: ProjectTagData[] ): { score: number; matchingTags: string[] } { if (projectTags.length === 0 || userTagNames.length === 0) { return { score: 0, matchingTags: [] } } const userTagSet = new Set(userTagNames.map((t) => t.toLowerCase())) const matchingTags: string[] = [] let weightedScore = 0 for (const pt of projectTags) { if (userTagSet.has(pt.tagName.toLowerCase())) { matchingTags.push(pt.tagName) // Weight by confidence - higher confidence = more points weightedScore += POINTS_PER_TAG_MATCH * pt.confidence } } // Cap at max score const score = Math.min(MAX_TAG_OVERLAP_SCORE, Math.round(weightedScore)) return { score, matchingTags } } /** * Calculate workload balance score * Full points if under target, decreasing as over target */ export function calculateWorkloadScore( currentAssignments: number, targetAssignments: number, maxAssignments?: number | null ): number { // If user is at or over their personal max, return 0 if (maxAssignments !== null && maxAssignments !== undefined) { if (currentAssignments >= maxAssignments) { return 0 } } // If under target, full points if (currentAssignments < targetAssignments) { return MAX_WORKLOAD_SCORE } // Over target - decrease score const overload = currentAssignments - targetAssignments return Math.max(0, MAX_WORKLOAD_SCORE - overload * 5) } /** * Calculate country match score (mentors only) * Same country = bonus points */ export function calculateCountryMatchScore( userCountry: string | null | undefined, projectCountry: string | null | undefined ): number { if (!userCountry || !projectCountry) { return 0 } // Normalize for comparison const normalizedUser = userCountry.toLowerCase().trim() const normalizedProject = projectCountry.toLowerCase().trim() if (normalizedUser === normalizedProject) { return MAX_COUNTRY_SCORE } return 0 } // ─── Main Scoring Function ─────────────────────────────────────────────────── /** * Get smart assignment suggestions for a round */ export async function getSmartSuggestions(options: { roundId: string type: 'jury' | 'mentor' limit?: number aiMaxPerJudge?: number }): Promise { const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options // Get projects in round with their tags const projects = await prisma.project.findMany({ where: { roundId, status: { not: 'REJECTED' }, }, include: { projectTags: { include: { tag: true }, }, }, }) if (projects.length === 0) { return [] } // Get users of the appropriate role const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR' const users = await prisma.user.findMany({ where: { role, status: 'ACTIVE', }, include: { _count: { select: { assignments: { where: { roundId }, }, }, }, }, }) if (users.length === 0) { return [] } // Get existing assignments to avoid duplicates const existingAssignments = await prisma.assignment.findMany({ where: { roundId }, select: { userId: true, projectId: true }, }) const assignedPairs = new Set( existingAssignments.map((a) => `${a.userId}:${a.projectId}`) ) // Calculate target assignments per user const targetPerUser = Math.ceil(projects.length / users.length) // Calculate scores for all user-project pairs const suggestions: AssignmentScore[] = [] for (const user of users) { // Skip users at AI max (they won't appear in suggestions) const currentCount = user._count.assignments if (currentCount >= aiMaxPerJudge) { continue } for (const project of projects) { // Skip if already assigned const pairKey = `${user.id}:${project.id}` if (assignedPairs.has(pairKey)) { continue } // Get project tags data const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({ tagId: pt.tagId, tagName: pt.tag.name, confidence: pt.confidence, })) // Calculate scores const { score: tagScore, matchingTags } = calculateTagOverlapScore( user.expertiseTags, projectTags ) const workloadScore = calculateWorkloadScore( currentCount, targetPerUser, user.maxAssignments ) // Country match only for mentors const countryScore = type === 'mentor' ? calculateCountryMatchScore( (user as any).country, // User might have country field project.country ) : 0 const totalScore = tagScore + workloadScore + countryScore // Build reasoning const reasoning: string[] = [] if (matchingTags.length > 0) { reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`) } if (workloadScore === MAX_WORKLOAD_SCORE) { reasoning.push('Available capacity') } else if (workloadScore > 0) { reasoning.push('Moderate workload') } if (countryScore > 0) { reasoning.push('Same country') } suggestions.push({ userId: user.id, userName: user.name || 'Unknown', userEmail: user.email, projectId: project.id, projectTitle: project.title, score: totalScore, breakdown: { tagOverlap: tagScore, workloadBalance: workloadScore, countryMatch: countryScore, aiBoost: 0, }, reasoning, matchingTags, }) } } // Sort by score descending and limit return suggestions.sort((a, b) => b.score - a.score).slice(0, limit) } /** * Get mentor suggestions for a specific project */ export async function getMentorSuggestionsForProject( projectId: string, limit: number = 10 ): Promise { const project = await prisma.project.findUnique({ where: { id: projectId }, include: { projectTags: { include: { tag: true }, }, mentorAssignment: true, }, }) if (!project) { throw new Error(`Project not found: ${projectId}`) } // Get all active mentors const mentors = await prisma.user.findMany({ where: { role: 'MENTOR', status: 'ACTIVE', }, include: { _count: { select: { mentorAssignments: true }, }, }, }) if (mentors.length === 0) { return [] } const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({ tagId: pt.tagId, tagName: pt.tag.name, confidence: pt.confidence, })) const targetPerMentor = 5 // Target 5 projects per mentor const suggestions: AssignmentScore[] = [] for (const mentor of mentors) { // Skip if already assigned to this project if (project.mentorAssignment?.mentorId === mentor.id) { continue } const { score: tagScore, matchingTags } = calculateTagOverlapScore( mentor.expertiseTags, projectTags ) const workloadScore = calculateWorkloadScore( mentor._count.mentorAssignments, targetPerMentor, mentor.maxAssignments ) const countryScore = calculateCountryMatchScore( (mentor as any).country, project.country ) const totalScore = tagScore + workloadScore + countryScore const reasoning: string[] = [] if (matchingTags.length > 0) { reasoning.push(`${matchingTags.length} matching expertise tag(s)`) } if (countryScore > 0) { reasoning.push('Same country of origin') } if (workloadScore === MAX_WORKLOAD_SCORE) { reasoning.push('Available capacity') } suggestions.push({ userId: mentor.id, userName: mentor.name || 'Unknown', userEmail: mentor.email, projectId: project.id, projectTitle: project.title, score: totalScore, breakdown: { tagOverlap: tagScore, workloadBalance: workloadScore, countryMatch: countryScore, aiBoost: 0, }, reasoning, matchingTags, }) } return suggestions.sort((a, b) => b.score - a.score).slice(0, limit) }