MOPC-App/src/server/services/smart-assignment.ts

382 lines
9.9 KiB
TypeScript
Raw Normal View History

/**
* 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<AssignmentScore[]> {
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<AssignmentScore[]> {
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)
}