Remove dynamic form builder and complete RoundProject→roundId migration
Major cleanup and schema migration: - Remove unused dynamic form builder system (ApplicationForm, ApplicationFormField, etc.) - Complete migration from RoundProject junction table to direct Project.roundId - Add sortOrder and entryNotificationType fields to Round model - Add country field to User model for mentor matching - Enhance onboarding with profile photo and country selection steps - Fix all TypeScript errors related to roundProjects references - Remove unused libraries (@radix-ui/react-toast, embla-carousel-react, vaul) Files removed: - admin/forms/* pages and related components - admin/onboarding/* pages - applicationForm.ts and onboarding.ts routers - Dynamic form builder Prisma models and enums Schema changes: - Removed ApplicationForm, ApplicationFormField, OnboardingStep, ApplicationFormSubmission, SubmissionFile models - Removed FormFieldType and SpecialFieldType enums - Added Round.sortOrder, Round.entryNotificationType - Added User.country Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
381
src/server/services/smart-assignment.ts
Normal file
381
src/server/services/smart-assignment.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user