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

@@ -81,7 +81,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
const { data: suggestions, isLoading: loadingSuggestions, refetch: refetchSuggestions } = trpc.assignment.getSuggestions.useQuery(
{ roundId, maxPerJuror: 10, minPerProject: 3 },
{ roundId },
{ enabled: !!round }
)

View File

@@ -59,6 +59,8 @@ const updateRoundSchema = z
.object({
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
minAssignmentsPerJuror: z.number().int().min(1).max(50),
maxAssignmentsPerJuror: z.number().int().min(1).max(100),
votingStartAt: z.date().nullable().optional(),
votingEndAt: z.date().nullable().optional(),
})
@@ -74,6 +76,13 @@ const updateRoundSchema = z
path: ['votingEndAt'],
}
)
.refine(
(data) => data.minAssignmentsPerJuror <= data.maxAssignmentsPerJuror,
{
message: 'Min must be less than or equal to max',
path: ['minAssignmentsPerJuror'],
}
)
type UpdateRoundForm = z.infer<typeof updateRoundSchema>
@@ -121,6 +130,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
defaultValues: {
name: '',
requiredReviews: 3,
minAssignmentsPerJuror: 5,
maxAssignmentsPerJuror: 20,
votingStartAt: null,
votingEndAt: null,
},
@@ -132,6 +143,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
form.reset({
name: round.name,
requiredReviews: round.requiredReviews,
minAssignmentsPerJuror: round.minAssignmentsPerJuror,
maxAssignmentsPerJuror: round.maxAssignmentsPerJuror,
votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null,
votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
})
@@ -161,6 +174,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
id: roundId,
name: data.name,
requiredReviews: data.requiredReviews,
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
roundType,
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? null,
@@ -277,6 +292,58 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</FormItem>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="minAssignmentsPerJuror"
render={({ field }) => (
<FormItem>
<FormLabel>Min Projects per Judge</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={50}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
</FormControl>
<FormDescription>
Target minimum projects each judge should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxAssignmentsPerJuror"
render={({ field }) => (
<FormItem>
<FormLabel>Max Projects per Judge</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={100}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
</FormControl>
<FormDescription>
Maximum projects a judge can be assigned
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>