All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
569 lines
18 KiB
TypeScript
569 lines
18 KiB
TypeScript
/**
|
|
* 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)
|
|
}
|