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 | 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 = {} 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' }, }) }), })