Add per-round assignment constraints (min/max per judge)

- Add minAssignmentsPerJuror and maxAssignmentsPerJuror fields to Round model
- Update assignment router:
  - Calculate effective max from user override or round default
  - Add forceOverride parameter for manual assignment beyond limits
  - Update getSuggestions to use round constraints with min target bonus
  - Update getAISuggestions to pass constraints to AI service
- Update AI assignment service:
  - Add minAssignmentsPerJuror to constraints interface
  - Update fallback algorithm with under-min bonus scoring
  - New score weights: 50% expertise, 30% load, 20% under-min bonus
- Update round router:
  - Add new constraint fields to create/update schemas
  - Add validation for min <= max constraint
- Update admin UI:
  - Add min/max constraint fields to round edit page
  - Remove hardcoded maxPerJuror from assignments page
- Add migration files for production deployment:
  - User.bio field for judge/mentor profiles
  - Round assignment constraint fields

Constraint hierarchy:
1. User.maxAssignments (if set) overrides round default
2. Round.maxAssignmentsPerJuror is the default cap
3. Admin can force-override any limit with confirmation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 16:01:18 +01:00
parent ff26769ce1
commit 6d2537ec04
8 changed files with 209 additions and 50 deletions

View File

@@ -78,6 +78,7 @@ interface ProjectForAssignment {
interface AssignmentConstraints {
requiredReviewsPerProject: number
minAssignmentsPerJuror?: number
maxAssignmentsPerJuror?: number
existingAssignments: Array<{
jurorId: string
@@ -412,18 +413,22 @@ export function generateFallbackAssignments(
return true
})
.map((juror) => ({
juror,
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
loadScore: calculateLoadScore(
jurorAssignments.get(juror.id) || 0,
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10
),
}))
.map((juror) => {
const currentLoad = jurorAssignments.get(juror.id) || 0
const maxLoad = juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 20
const minTarget = constraints.minAssignmentsPerJuror ?? 5
return {
juror,
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
loadScore: calculateLoadScore(currentLoad, maxLoad),
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
}
})
.sort((a, b) => {
// Combined score: 60% expertise, 40% load balancing
const aTotal = a.score * 0.6 + a.loadScore * 0.4
const bTotal = b.score * 0.6 + b.loadScore * 0.4
// Combined score: 50% expertise, 30% load balancing, 20% under-min bonus
const aTotal = a.score * 0.5 + a.loadScore * 0.3 + a.underMinBonus * 0.2
const bTotal = b.score * 0.5 + b.loadScore * 0.3 + b.underMinBonus * 0.2
return bTotal - aTotal
})
@@ -494,6 +499,16 @@ function calculateLoadScore(currentLoad: number, maxLoad: number): number {
return Math.max(0, 1 - utilization)
}
/**
* Calculate bonus for jurors under their minimum target
* Returns 1.0 if under min, scaled down as approaching min
*/
function calculateUnderMinBonus(currentLoad: number, minTarget: number): number {
if (currentLoad >= minTarget) return 0
// Scale bonus based on how far under min (1.0 at 0 load, decreasing as approaching min)
return (minTarget - currentLoad) / minTarget
}
/**
* Generate reasoning for fallback assignments
*/