All checks were successful
Build and Push Docker Image / build (push) Successful in 8m50s
- Fix bug: AI assignment router read non-existent `(m as any).maxAssignments` instead of the actual schema field `m.maxAssignmentsOverride` - Wire `jurorLimits` record into AI assignment constraints so per-juror caps are respected during both AI scoring and algorithmic assignment - Add inline editable cap in jury members table (click to edit, blur/enter to save, empty = no cap / use group default) - Add inline editable cap badges on round page member list so admins can set caps right from the assignment workflow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import { z } from 'zod'
|
|
import { TRPCError } from '@trpc/server'
|
|
import { router, adminProcedure, protectedProcedure, juryProcedure } from '../trpc'
|
|
import {
|
|
previewRoundAssignment,
|
|
executeRoundAssignment,
|
|
getRoundCoverageReport,
|
|
getUnassignedQueue,
|
|
} from '../services/round-assignment'
|
|
import { generateAIAssignments } from '../services/ai-assignment'
|
|
|
|
export const roundAssignmentRouter = router({
|
|
/**
|
|
* AI-powered assignment preview using GPT with enriched project/juror data
|
|
*/
|
|
aiPreview: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
requiredReviews: z.number().int().min(1).max(20).default(3),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Load round with jury group
|
|
const round = await ctx.prisma.round.findUnique({
|
|
where: { id: input.roundId },
|
|
include: {
|
|
juryGroup: {
|
|
include: {
|
|
members: {
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true, name: true, email: true,
|
|
bio: true, expertiseTags: true, country: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!round) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
|
}
|
|
|
|
if (!round.juryGroup) {
|
|
return {
|
|
assignments: [],
|
|
warnings: ['Round has no linked jury group'],
|
|
stats: { totalProjects: 0, totalJurors: 0, assignmentsGenerated: 0, unassignedProjects: 0 },
|
|
fallbackUsed: false,
|
|
tokensUsed: 0,
|
|
}
|
|
}
|
|
|
|
// Load projects with rich data (descriptions, tags, files, team members, etc.)
|
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
|
where: { roundId: input.roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
|
include: {
|
|
project: {
|
|
include: {
|
|
projectTags: { include: { tag: true } },
|
|
files: { select: { fileType: true, size: true, pageCount: true } },
|
|
_count: { select: { teamMembers: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (projectStates.length === 0) {
|
|
return {
|
|
assignments: [],
|
|
warnings: ['No active projects in this round'],
|
|
stats: { totalProjects: 0, totalJurors: round.juryGroup.members.length, assignmentsGenerated: 0, unassignedProjects: 0 },
|
|
fallbackUsed: false,
|
|
tokensUsed: 0,
|
|
}
|
|
}
|
|
|
|
// Load existing assignments
|
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
|
|
// Load COI records to exclude
|
|
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
|
|
where: { assignment: { roundId: input.roundId }, hasConflict: true },
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
const coiPairs = new Set(coiRecords.map((c: { userId: string; projectId: string }) => `${c.userId}:${c.projectId}`))
|
|
|
|
// Build enriched juror data for AI
|
|
const jurors = round.juryGroup.members.map((m) => ({
|
|
id: m.user.id,
|
|
name: m.user.name,
|
|
email: m.user.email,
|
|
expertiseTags: (m.user.expertiseTags as string[]) ?? [],
|
|
bio: m.user.bio as string | null,
|
|
country: m.user.country as string | null,
|
|
maxAssignments: m.maxAssignmentsOverride ?? null,
|
|
_count: {
|
|
assignments: existingAssignments.filter((a) => a.userId === m.user.id).length,
|
|
},
|
|
}))
|
|
|
|
// Build enriched project data for AI
|
|
const projects = projectStates.map((ps) => {
|
|
const p = ps.project as any
|
|
return {
|
|
id: p.id as string,
|
|
title: p.title as string,
|
|
description: p.description as string | null,
|
|
tags: (p.projectTags?.map((pt: any) => pt.tag?.name).filter(Boolean) ?? p.tags ?? []) as string[],
|
|
tagConfidences: p.projectTags?.map((pt: any) => ({
|
|
name: pt.tag?.name as string,
|
|
confidence: (pt.confidence as number) ?? 1.0,
|
|
})) as Array<{ name: string; confidence: number }> | undefined,
|
|
teamName: p.teamName as string | null,
|
|
competitionCategory: p.competitionCategory as string | null,
|
|
oceanIssue: p.oceanIssue as string | null,
|
|
country: p.country as string | null,
|
|
institution: p.institution as string | null,
|
|
teamSize: (p._count?.teamMembers as number) ?? 0,
|
|
fileTypes: (p.files?.map((f: any) => f.fileType).filter(Boolean) ?? []) as string[],
|
|
_count: {
|
|
assignments: existingAssignments.filter((a) => a.projectId === p.id).length,
|
|
},
|
|
}
|
|
})
|
|
|
|
// Build constraints
|
|
const configJson = round.configJson as Record<string, unknown> | null
|
|
const configuredMax = (configJson?.maxAssignmentsPerJuror as number) ?? undefined
|
|
|
|
// If no explicit cap, calculate a balanced one: ceil(total_needed / juror_count) + 2 buffer
|
|
const totalNeeded = projectStates.length * input.requiredReviews
|
|
const jurorCount = round.juryGroup.members.length
|
|
const calculatedMax = Math.ceil(totalNeeded / jurorCount) + 2
|
|
const maxPerJuror = configuredMax ?? calculatedMax
|
|
|
|
// Build per-juror cap overrides
|
|
const jurorLimits: Record<string, number> = {}
|
|
for (const m of round.juryGroup.members) {
|
|
if (m.maxAssignmentsOverride != null) {
|
|
jurorLimits[m.user.id] = m.maxAssignmentsOverride
|
|
}
|
|
}
|
|
|
|
const constraints = {
|
|
requiredReviewsPerProject: input.requiredReviews,
|
|
maxAssignmentsPerJuror: maxPerJuror,
|
|
jurorLimits,
|
|
existingAssignments: existingAssignments.map((a) => ({
|
|
jurorId: a.userId,
|
|
projectId: a.projectId,
|
|
})),
|
|
}
|
|
|
|
// Call AI service
|
|
console.log(`[AI Assignment Router] Starting for ${projects.length} projects, ${jurors.length} jurors, ${input.requiredReviews} reviews/project, max ${maxPerJuror}/juror`)
|
|
const result = await generateAIAssignments(
|
|
jurors,
|
|
projects,
|
|
constraints,
|
|
ctx.user.id,
|
|
input.roundId,
|
|
)
|
|
console.log(`[AI Assignment Router] Got ${result.suggestions.length} suggestions, success=${result.success}, fallback=${result.fallbackUsed}`)
|
|
|
|
// Filter out COI pairs and already-assigned pairs
|
|
const existingPairSet = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
|
const filteredSuggestions = result.suggestions.filter((s) =>
|
|
!coiPairs.has(`${s.jurorId}:${s.projectId}`) &&
|
|
!existingPairSet.has(`${s.jurorId}:${s.projectId}`)
|
|
)
|
|
|
|
// Map to common AssignmentPreview format
|
|
const jurorNameMap = new Map(jurors.map((j) => [j.id, j.name ?? 'Unknown']))
|
|
const projectTitleMap = new Map(projects.map((p) => [p.id, p.title]))
|
|
|
|
const assignments = filteredSuggestions.map((s) => ({
|
|
userId: s.jurorId,
|
|
userName: jurorNameMap.get(s.jurorId) ?? 'Unknown',
|
|
projectId: s.projectId,
|
|
projectTitle: projectTitleMap.get(s.projectId) ?? 'Unknown',
|
|
score: Math.round(s.confidenceScore * 100),
|
|
breakdown: {
|
|
tagOverlap: Math.round(s.expertiseMatchScore * 100),
|
|
bioMatch: 0,
|
|
workloadBalance: 0,
|
|
countryMatch: 0,
|
|
geoDiversityPenalty: 0,
|
|
previousRoundFamiliarity: 0,
|
|
coiPenalty: 0,
|
|
availabilityPenalty: 0,
|
|
categoryQuotaPenalty: 0,
|
|
},
|
|
reasoning: [s.reasoning],
|
|
matchingTags: [] as string[],
|
|
policyViolations: [] as string[],
|
|
fromIntent: false,
|
|
}))
|
|
|
|
const assignedProjectIds = new Set(assignments.map((a) => a.projectId))
|
|
|
|
// Warn about jurors without profile data
|
|
const warnings: string[] = result.error ? [result.error] : []
|
|
const incompleteJurors = jurors.filter(
|
|
(j) => (!j.expertiseTags || j.expertiseTags.length === 0) && !j.bio
|
|
)
|
|
if (incompleteJurors.length > 0) {
|
|
const names = incompleteJurors.map((j) => j.name || 'Unknown').join(', ')
|
|
warnings.push(
|
|
`${incompleteJurors.length} juror(s) have no expertise tags or bio (${names}). Their assignments are based on workload balance only — consider asking them to complete their profile first.`
|
|
)
|
|
}
|
|
|
|
return {
|
|
assignments,
|
|
warnings,
|
|
stats: {
|
|
totalProjects: projects.length,
|
|
totalJurors: jurors.length,
|
|
assignmentsGenerated: assignments.length,
|
|
unassignedProjects: projects.length - assignedProjectIds.size,
|
|
},
|
|
fallbackUsed: result.fallbackUsed ?? false,
|
|
tokensUsed: result.tokensUsed ?? 0,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Preview round assignments without committing (algorithmic)
|
|
*/
|
|
preview: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
honorIntents: z.boolean().default(true),
|
|
requiredReviews: z.number().int().min(1).max(20).default(3),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
return previewRoundAssignment(
|
|
input.roundId,
|
|
{
|
|
honorIntents: input.honorIntents,
|
|
requiredReviews: input.requiredReviews,
|
|
},
|
|
ctx.prisma,
|
|
)
|
|
}),
|
|
|
|
/**
|
|
* Execute round assignments (create Assignment records)
|
|
*/
|
|
execute: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
assignments: z.array(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
})
|
|
).min(1),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const result = await executeRoundAssignment(
|
|
input.roundId,
|
|
input.assignments,
|
|
ctx.user.id,
|
|
ctx.prisma,
|
|
)
|
|
|
|
if (result.errors.length > 0 && result.created === 0) {
|
|
throw new TRPCError({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
message: result.errors.join('; '),
|
|
})
|
|
}
|
|
|
|
return result
|
|
}),
|
|
|
|
/**
|
|
* Get coverage report for a round
|
|
*/
|
|
coverageReport: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
requiredReviews: z.number().int().min(1).max(20).default(3),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
return getRoundCoverageReport(input.roundId, input.requiredReviews, ctx.prisma)
|
|
}),
|
|
|
|
/**
|
|
* Get projects below required reviews threshold
|
|
*/
|
|
unassignedQueue: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
requiredReviews: z.number().int().min(1).max(20).default(3),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
return getUnassignedQueue(input.roundId, input.requiredReviews, ctx.prisma)
|
|
}),
|
|
|
|
/**
|
|
* Get assignments for the current jury member in a specific round
|
|
*/
|
|
getMyAssignments: juryProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.assignment.findMany({
|
|
where: {
|
|
roundId: input.roundId,
|
|
userId: ctx.user.id,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: { id: true, title: true, competitionCategory: true },
|
|
},
|
|
evaluation: {
|
|
select: { id: true, status: true, globalScore: true },
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
}),
|
|
})
|