/** * Smart Assignment Scoring Service * * Calculates scores for jury/mentor-project matching based on: * - Tag overlap (expertise match) * - Bio/description match (text similarity) * - Workload balance * - Country match (mentors only) * * Score Breakdown (100 points max): * - Tag overlap: 0-40 points (weighted by confidence) * - Bio match: 0-15 points (if bio exists) * - Workload balance: 0-25 points * - Country match: 0-15 points (mentors only) * - Reserved: 0-5 points (future AI boost) */ import { prisma } from '@/lib/prisma' // ─── Types ────────────────────────────────────────────────────────────────── export interface ScoreBreakdown { tagOverlap: number bioMatch: number workloadBalance: number countryMatch: 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 = 40 const MAX_BIO_MATCH_SCORE = 15 const MAX_WORKLOAD_SCORE = 25 const MAX_COUNTRY_SCORE = 15 const POINTS_PER_TAG_MATCH = 8 // Common words to exclude from bio matching const STOP_WORDS = new Set([ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been', 'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'that', 'which', 'who', 'whom', 'this', 'these', 'those', 'it', 'its', 'i', 'we', 'you', 'he', 'she', 'they', 'them', 'their', 'our', 'my', 'your', 'his', 'her', 'am', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'can', 'just', 'being', 'over', 'both', 'up', 'down', 'out', 'also', 'new', 'any', ]) // ─── Scoring Functions ─────────────────────────────────────────────────────── /** * Extract meaningful keywords from text */ function extractKeywords(text: string | null | undefined): Set { if (!text) return new Set() // Tokenize, lowercase, and filter const words = text .toLowerCase() .replace(/[^\w\s]/g, ' ') // Remove punctuation .split(/\s+/) .filter((word) => word.length >= 3 && !STOP_WORDS.has(word)) return new Set(words) } /** * Calculate bio match score between user bio and project description * Only applies if user has a bio */ export function calculateBioMatchScore( userBio: string | null | undefined, projectDescription: string | null | undefined ): { score: number; matchingKeywords: string[] } { // If no bio, return 0 (not penalized, just no bonus) if (!userBio || userBio.trim().length === 0) { return { score: 0, matchingKeywords: [] } } // If no project description, can't match if (!projectDescription || projectDescription.trim().length === 0) { return { score: 0, matchingKeywords: [] } } const bioKeywords = extractKeywords(userBio) const projectKeywords = extractKeywords(projectDescription) if (bioKeywords.size === 0 || projectKeywords.size === 0) { return { score: 0, matchingKeywords: [] } } // Find matching keywords const matchingKeywords: string[] = [] for (const keyword of bioKeywords) { if (projectKeywords.has(keyword)) { matchingKeywords.push(keyword) } } if (matchingKeywords.length === 0) { return { score: 0, matchingKeywords: [] } } // Calculate score based on match ratio // Use Jaccard-like similarity: matches / (bio keywords + project keywords - matches) const unionSize = bioKeywords.size + projectKeywords.size - matchingKeywords.length const similarity = matchingKeywords.length / unionSize // Scale to max score (15 points) // A good match (20%+ overlap) should get near max const score = Math.min(MAX_BIO_MATCH_SCORE, Math.round(similarity * 100)) return { score, matchingKeywords } } /** * 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 and description const projects = await prisma.project.findMany({ where: { roundId, status: { not: 'REJECTED' }, }, select: { id: true, title: true, teamName: true, description: true, country: true, status: true, projectTags: { include: { tag: true }, }, }, }) if (projects.length === 0) { return [] } // Get users of the appropriate role with bio for matching const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR' const users = await prisma.user.findMany({ where: { role, status: 'ACTIVE', }, select: { id: true, name: true, email: true, bio: true, expertiseTags: true, maxAssignments: true, country: true, _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 ) // Bio match (only if user has a bio) const { score: bioScore, matchingKeywords } = calculateBioMatchScore( user.bio, project.description ) const workloadScore = calculateWorkloadScore( currentCount, targetPerUser, user.maxAssignments ) // Country match only for mentors const countryScore = type === 'mentor' ? calculateCountryMatchScore(user.country, project.country) : 0 const totalScore = tagScore + bioScore + workloadScore + countryScore // Build reasoning const reasoning: string[] = [] if (matchingTags.length > 0) { reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`) } if (bioScore > 0) { reasoning.push(`Bio match: ${matchingKeywords.length} keyword(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, bioMatch: bioScore, workloadBalance: workloadScore, countryMatch: countryScore, }, 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 with bio for matching const mentors = await prisma.user.findMany({ where: { role: 'MENTOR', status: 'ACTIVE', }, select: { id: true, name: true, email: true, bio: true, expertiseTags: true, maxAssignments: true, country: true, _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 ) // Bio match (only if mentor has a bio) const { score: bioScore, matchingKeywords } = calculateBioMatchScore( mentor.bio, project.description ) const workloadScore = calculateWorkloadScore( mentor._count.mentorAssignments, targetPerMentor, mentor.maxAssignments ) const countryScore = calculateCountryMatchScore( mentor.country, project.country ) const totalScore = tagScore + bioScore + workloadScore + countryScore const reasoning: string[] = [] if (matchingTags.length > 0) { reasoning.push(`${matchingTags.length} matching expertise tag(s)`) } if (bioScore > 0) { reasoning.push(`Bio match: ${matchingKeywords.length} keyword(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, bioMatch: bioScore, workloadBalance: workloadScore, countryMatch: countryScore, }, reasoning, matchingTags, }) } return suggestions.sort((a, b) => b.score - a.score).slice(0, limit) }