Files
MOPC-Portal/src/server/services/round-assignment.ts

569 lines
18 KiB
TypeScript
Raw Normal View History

/**
* Enhanced Assignment Service (Round-Aware)
*
* Builds on existing smart-assignment scoring and integrates with the
* Phase 2 policy engine for cap/mode/bias resolution.
*/
import type { PrismaClient, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
import { resolveCompetitionContext, resolveMemberContext } from './competition-context'
import { evaluateAssignmentPolicy } from './assignment-policy'
import {
calculateTagOverlapScore,
calculateBioMatchScore,
calculateWorkloadScore,
calculateAvailabilityPenalty,
calculateCategoryQuotaPenalty,
type ProjectTagData,
type ScoreBreakdown,
} from './smart-assignment'
// ─── Types ──────────────────────────────────────────────────────────────────
export type AssignmentPreview = {
assignments: AssignmentSuggestion[]
warnings: string[]
stats: {
totalProjects: number
totalJurors: number
assignmentsGenerated: number
unassignedProjects: number
}
}
export type AssignmentSuggestion = {
userId: string
userName: string
projectId: string
projectTitle: string
score: number
breakdown: ScoreBreakdown
reasoning: string[]
matchingTags: string[]
policyViolations: string[]
fromIntent: boolean
}
export type CoverageReport = {
totalProjects: number
fullyAssigned: number
partiallyAssigned: number
unassigned: number
avgReviewsPerProject: number
requiredReviews: number
byCategory: Record<string, { total: number; assigned: number; coverage: number }>
}
// ─── Constants ──────────────────────────────────────────────────────────────
const GEO_DIVERSITY_THRESHOLD = 2
const GEO_DIVERSITY_PENALTY = -15
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
// ─── Preview Assignment ─────────────────────────────────────────────────────
/**
* Preview round assignments without committing them.
* 1. Load round + juryGroup + members via competition-context
* 2. Evaluate policy for each member via evaluateAssignmentPolicy()
* 3. Honor pending AssignmentIntents first
* 4. Run scoring algorithm
* 5. Enforce hard/soft caps per member
* 6. Return preview with policy violation warnings
*/
export async function previewRoundAssignment(
roundId: string,
config?: { honorIntents?: boolean; requiredReviews?: number },
prisma?: PrismaClient | any,
): Promise<AssignmentPreview> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const honorIntents = config?.honorIntents ?? true
const requiredReviews = config?.requiredReviews ?? 3
const ctx = await resolveCompetitionContext(roundId)
const warnings: string[] = []
if (!ctx.juryGroup) {
return {
assignments: [],
warnings: ['Round has no linked jury group'],
stats: { totalProjects: 0, totalJurors: 0, assignmentsGenerated: 0, unassignedProjects: 0 },
}
}
// Load jury group members
const members = await db.juryGroupMember.findMany({
where: { juryGroupId: ctx.juryGroup.id },
include: {
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
},
})
// Load projects in this round (with active ProjectRoundState)
const projectStates = await db.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
include: {
project: {
select: {
id: true,
title: true,
description: true,
country: true,
competitionCategory: true,
projectTags: { include: { tag: true } },
},
},
},
})
const projects = projectStates.map((ps: any) => ps.project)
if (projects.length === 0) {
return {
assignments: [],
warnings: ['No active projects in this round'],
stats: { totalProjects: 0, totalJurors: members.length, assignmentsGenerated: 0, unassignedProjects: 0 },
}
}
// Load existing assignments for this round
const existingAssignments = await db.assignment.findMany({
where: { roundId },
select: { userId: true, projectId: true },
})
const assignedPairs = new Set(existingAssignments.map((a: any) => `${a.userId}:${a.projectId}`))
// Track assignment counts per juror for policy evaluation
const jurorAssignmentCounts = new Map<string, number>()
for (const a of existingAssignments) {
jurorAssignmentCounts.set(a.userId, (jurorAssignmentCounts.get(a.userId) ?? 0) + 1)
}
// Load pending intents
let pendingIntents: any[] = []
if (honorIntents) {
pendingIntents = await db.assignmentIntent.findMany({
where: { roundId, status: 'INTENT_PENDING' },
include: {
juryGroupMember: { include: { user: { select: { id: true } } } },
},
})
}
// Load COI records
const coiRecords = await db.conflictOfInterest.findMany({
where: {
assignment: { roundId },
hasConflict: true,
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c: any) => `${c.userId}:${c.projectId}`))
// Build assignment suggestions
const suggestions: AssignmentSuggestion[] = []
const projectAssignmentCounts = new Map<string, number>()
// Count existing coverage
for (const a of existingAssignments) {
projectAssignmentCounts.set(a.projectId, (projectAssignmentCounts.get(a.projectId) ?? 0) + 1)
}
// First: honor pending intents
const intentAssignments = new Set<string>()
for (const intent of pendingIntents) {
const userId = intent.juryGroupMember.user.id
const projectId = intent.projectId
const pairKey = `${userId}:${projectId}`
if (assignedPairs.has(pairKey) || coiPairs.has(pairKey)) continue
const member = members.find((m: any) => m.userId === userId)
if (!member) continue
const project = projects.find((p: any) => p.id === projectId)
if (!project) continue
suggestions.push({
userId,
userName: member.user.name ?? 'Unknown',
projectId,
projectTitle: project.title,
score: 100, // Intent-based assignments get max priority
breakdown: { tagOverlap: 0, bioMatch: 0, workloadBalance: 0, countryMatch: 0, geoDiversityPenalty: 0, previousRoundFamiliarity: 0, coiPenalty: 0, availabilityPenalty: 0, categoryQuotaPenalty: 0 },
reasoning: ['Honoring assignment intent'],
matchingTags: [],
policyViolations: [],
fromIntent: true,
})
intentAssignments.add(pairKey)
}
// Then: algorithmic matching for remaining needs
for (const member of members) {
const userId = member.userId
const currentCount = (jurorAssignmentCounts.get(userId) ?? 0) +
suggestions.filter((s) => s.userId === userId).length
// Build a minimal member context for policy evaluation
const memberCtx = {
...ctx,
member: member as any,
user: member.user,
currentAssignmentCount: currentCount,
assignmentsByCategory: {},
pendingIntents: [],
}
const policy = evaluateAssignmentPolicy(memberCtx)
if (!policy.canAssignMore) continue
for (const project of projects) {
const pairKey = `${userId}:${project.id}`
if (assignedPairs.has(pairKey) || intentAssignments.has(pairKey) || coiPairs.has(pairKey)) continue
// Check project needs more reviews
const currentProjectReviews = (projectAssignmentCounts.get(project.id) ?? 0) +
suggestions.filter((s) => s.projectId === project.id).length
if (currentProjectReviews >= requiredReviews) continue
// Calculate score
const projectTags: ProjectTagData[] = project.projectTags.map((pt: any) => ({
tagId: pt.tagId,
tagName: pt.tag.name,
confidence: pt.confidence,
}))
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
member.user.expertiseTags ?? [],
projectTags,
)
const { score: bioScore } = calculateBioMatchScore(
member.user.bio,
project.description,
)
const workloadScore = calculateWorkloadScore(
currentCount,
policy.effectiveCap.value,
)
const availabilityPenalty = calculateAvailabilityPenalty(
member.user.availabilityJson,
ctx.round.windowOpenAt,
ctx.round.windowCloseAt,
)
const categoryQuotaPenalty = policy.categoryBias.value
? calculateCategoryQuotaPenalty(
Object.fromEntries(
Object.entries(policy.categoryBias.value).map(([k, v]) => [k, { min: 0, max: Math.round(v * policy.effectiveCap.value) }]),
),
{},
project.competitionCategory,
)
: 0
const totalScore = tagScore + bioScore + workloadScore + availabilityPenalty + categoryQuotaPenalty
const policyViolations: string[] = []
if (policy.isOverCap) {
policyViolations.push(`Over cap by ${policy.overCapBy}`)
}
const reasoning: string[] = []
if (matchingTags.length > 0) reasoning.push(`${matchingTags.length} matching tag(s)`)
if (bioScore > 0) reasoning.push('Bio match')
if (availabilityPenalty < 0) reasoning.push('Availability concern')
suggestions.push({
userId,
userName: member.user.name ?? 'Unknown',
projectId: project.id,
projectTitle: project.title,
score: totalScore,
breakdown: {
tagOverlap: tagScore,
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: 0,
geoDiversityPenalty: 0,
previousRoundFamiliarity: 0,
coiPenalty: 0,
availabilityPenalty,
categoryQuotaPenalty,
},
reasoning,
matchingTags,
policyViolations,
fromIntent: false,
})
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score)
// Greedy assignment: pick top suggestions respecting caps
const finalAssignments: AssignmentSuggestion[] = []
const finalJurorCounts = new Map(jurorAssignmentCounts)
const finalProjectCounts = new Map(projectAssignmentCounts)
for (const suggestion of suggestions) {
const jurorCount = finalJurorCounts.get(suggestion.userId) ?? 0
const projectCount = finalProjectCounts.get(suggestion.projectId) ?? 0
if (projectCount >= requiredReviews) continue
// Re-check cap
const member = members.find((m: any) => m.userId === suggestion.userId)
if (member) {
const memberCtx = {
...ctx,
member: member as any,
user: member.user,
currentAssignmentCount: jurorCount,
assignmentsByCategory: {},
pendingIntents: [],
}
const policy = evaluateAssignmentPolicy(memberCtx)
if (!policy.canAssignMore && !suggestion.fromIntent) continue
}
finalAssignments.push(suggestion)
finalJurorCounts.set(suggestion.userId, jurorCount + 1)
finalProjectCounts.set(suggestion.projectId, projectCount + 1)
}
const unassignedProjects = projects.filter(
(p: any) => (finalProjectCounts.get(p.id) ?? 0) < requiredReviews,
).length
return {
assignments: finalAssignments,
warnings,
stats: {
totalProjects: projects.length,
totalJurors: members.length,
assignmentsGenerated: finalAssignments.length,
unassignedProjects,
},
}
}
// ─── Execute Assignment ─────────────────────────────────────────────────────
/**
* Execute round assignments by creating Assignment records.
*/
export async function executeRoundAssignment(
roundId: string,
assignments: Array<{ userId: string; projectId: string }>,
actorId: string,
prisma: PrismaClient | any,
): Promise<{ created: number; errors: string[] }> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const errors: string[] = []
let created = 0
for (const assignment of assignments) {
try {
await db.$transaction(async (tx: any) => {
// Create assignment record
await tx.assignment.create({
data: {
projectId: assignment.projectId,
userId: assignment.userId,
roundId,
method: 'ALGORITHM',
},
})
// Create or update ProjectRoundState to IN_PROGRESS
await tx.projectRoundState.upsert({
where: {
projectId_roundId: {
projectId: assignment.projectId,
roundId,
},
},
create: {
projectId: assignment.projectId,
roundId,
state: 'IN_PROGRESS',
enteredAt: new Date(),
},
update: {
state: 'IN_PROGRESS',
},
})
// Honor matching intent if exists
const intent = await tx.assignmentIntent.findFirst({
where: {
roundId,
projectId: assignment.projectId,
juryGroupMember: { userId: assignment.userId },
status: 'INTENT_PENDING',
},
})
if (intent) {
await tx.assignmentIntent.update({
where: { id: intent.id },
data: { status: 'HONORED' },
})
}
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_ASSIGNMENT_CREATE',
entityType: 'Assignment',
entityId: `${assignment.userId}:${assignment.projectId}`,
detailsJson: { roundId, userId: assignment.userId, projectId: assignment.projectId },
})
created++
})
} catch (error) {
errors.push(
`Failed to assign ${assignment.userId} to ${assignment.projectId}: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
}
// Overall audit
await logAudit({
prisma: db,
userId: actorId,
action: 'ROUND_ASSIGNMENT_BATCH',
entityType: 'Round',
entityId: roundId,
detailsJson: { created, failed: errors.length },
})
return { created, errors }
}
// ─── Coverage Report ────────────────────────────────────────────────────────
/**
* Get coverage report for a round's assignments.
*/
export async function getRoundCoverageReport(
roundId: string,
requiredReviews: number = 3,
prisma?: PrismaClient | any,
): Promise<CoverageReport> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const projectStates = await db.projectRoundState.findMany({
where: { roundId },
include: {
project: { select: { id: true, competitionCategory: true } },
},
})
const assignments = await db.assignment.findMany({
where: { roundId },
select: { projectId: true },
})
const projectAssignmentCounts = new Map<string, number>()
for (const a of assignments) {
projectAssignmentCounts.set(a.projectId, (projectAssignmentCounts.get(a.projectId) ?? 0) + 1)
}
let fullyAssigned = 0
let partiallyAssigned = 0
let unassigned = 0
const byCategory: Record<string, { total: number; assigned: number; coverage: number }> = {}
for (const ps of projectStates) {
const count = projectAssignmentCounts.get(ps.project.id) ?? 0
const cat = ps.project.competitionCategory ?? 'UNCATEGORIZED'
if (!byCategory[cat]) {
byCategory[cat] = { total: 0, assigned: 0, coverage: 0 }
}
byCategory[cat].total++
if (count >= requiredReviews) {
fullyAssigned++
byCategory[cat].assigned++
} else if (count > 0) {
partiallyAssigned++
byCategory[cat].assigned++
} else {
unassigned++
}
}
// Calculate coverage percentages
for (const cat of Object.keys(byCategory)) {
byCategory[cat].coverage = byCategory[cat].total > 0
? Math.round((byCategory[cat].assigned / byCategory[cat].total) * 100)
: 0
}
const totalReviews = assignments.length
const avgReviews = projectStates.length > 0 ? totalReviews / projectStates.length : 0
return {
totalProjects: projectStates.length,
fullyAssigned,
partiallyAssigned,
unassigned,
avgReviewsPerProject: Math.round(avgReviews * 10) / 10,
requiredReviews,
byCategory,
}
}
// ─── Unassigned Queue ───────────────────────────────────────────────────────
/**
* Get projects below requiredReviews threshold.
*/
export async function getUnassignedQueue(
roundId: string,
requiredReviews: number = 3,
prisma?: PrismaClient | any,
) {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const projectStates = await db.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
_count: {
select: {
assignments: { where: { roundId } },
},
},
},
},
},
})
return projectStates
.filter((ps: any) => ps.project._count.assignments < requiredReviews)
.map((ps: any) => ({
projectId: ps.project.id,
title: ps.project.title,
teamName: ps.project.teamName,
category: ps.project.competitionCategory,
currentReviews: ps.project._count.assignments,
needed: requiredReviews - ps.project._count.assignments,
state: ps.state,
}))
.sort((a: any, b: any) => a.currentReviews - b.currentReviews)
}