Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
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>
This commit is contained in:
568
src/server/services/round-assignment.ts
Normal file
568
src/server/services/round-assignment.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user