diff --git a/prisma/migrations/20260205220000_add_suggestions_to_assignment_job/migration.sql b/prisma/migrations/20260205220000_add_suggestions_to_assignment_job/migration.sql new file mode 100644 index 0000000..e2caa7a --- /dev/null +++ b/prisma/migrations/20260205220000_add_suggestions_to_assignment_job/migration.sql @@ -0,0 +1,11 @@ +-- Add suggestionsJson column to AssignmentJob to store AI-generated suggestions + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'AssignmentJob' AND column_name = 'suggestionsJson' + ) THEN + ALTER TABLE "AssignmentJob" ADD COLUMN "suggestionsJson" JSONB; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4617155..393f17c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1105,6 +1105,7 @@ model AssignmentJob { currentBatch Int @default(0) processedCount Int @default(0) suggestionsCount Int @default(0) + suggestionsJson Json? @db.JsonB // Stores the AI-generated suggestions errorMessage String? @db.Text startedAt DateTime? completedAt DateTime? diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index f1e9d38..d3ff99e 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -112,7 +112,18 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string onProgress ) - // Mark job as completed + // Enrich suggestions with names for storage + const enrichedSuggestions = result.suggestions.map((s) => { + const juror = jurors.find((j) => j.id === s.jurorId) + const project = projects.find((p) => p.id === s.projectId) + return { + ...s, + jurorName: juror?.name || juror?.email || 'Unknown', + projectTitle: project?.title || 'Unknown', + } + }) + + // Mark job as completed and store suggestions await prisma.assignmentJob.update({ where: { id: jobId }, data: { @@ -120,6 +131,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string completedAt: new Date(), processedCount: projects.length, suggestionsCount: result.suggestions.length, + suggestionsJson: enrichedSuggestions, fallbackUsed: result.fallbackUsed ?? false, }, }) @@ -730,7 +742,7 @@ export const assignmentRouter = router({ }), /** - * Get AI-powered assignment suggestions + * Get AI-powered assignment suggestions (retrieves from completed job) */ getAISuggestions: adminProcedure .input( @@ -740,88 +752,61 @@ export const assignmentRouter = router({ }) ) .query(async ({ ctx, input }) => { - // Get round constraints - const round = await ctx.prisma.round.findUniqueOrThrow({ - where: { id: input.roundId }, + // Find the latest completed job for this round + const completedJob = await ctx.prisma.assignmentJob.findFirst({ + where: { + roundId: input.roundId, + status: 'COMPLETED', + }, + orderBy: { completedAt: 'desc' }, select: { - requiredReviews: true, - minAssignmentsPerJuror: true, - maxAssignmentsPerJuror: true, + suggestionsJson: true, + fallbackUsed: true, + completedAt: true, }, }) - // Get all active jury members with their expertise and current load - const jurors = await ctx.prisma.user.findMany({ - where: { role: 'JURY_MEMBER', status: 'ACTIVE' }, - select: { - id: true, - name: true, - email: true, - expertiseTags: true, - maxAssignments: true, - _count: { - select: { - assignments: { where: { roundId: input.roundId } }, - }, - }, - }, - }) + // If we have stored suggestions, return them + if (completedJob?.suggestionsJson) { + const suggestions = completedJob.suggestionsJson as Array<{ + jurorId: string + jurorName: string + projectId: string + projectTitle: string + confidenceScore: number + expertiseMatchScore: number + reasoning: string + }> - // Get all projects in the round - const projects = await ctx.prisma.project.findMany({ - where: { roundId: input.roundId }, - select: { - id: true, - title: true, - description: true, - tags: true, - teamName: true, - _count: { select: { assignments: true } }, - }, - }) - - // Get existing assignments - const existingAssignments = await ctx.prisma.assignment.findMany({ - where: { roundId: input.roundId }, - select: { userId: true, projectId: true }, - }) - - const constraints = { - requiredReviewsPerProject: round.requiredReviews, - minAssignmentsPerJuror: round.minAssignmentsPerJuror, - maxAssignmentsPerJuror: round.maxAssignmentsPerJuror, - existingAssignments: existingAssignments.map((a) => ({ - jurorId: a.userId, - projectId: a.projectId, - })), - } - - // Use AI or fallback based on input and availability - let result - if (input.useAI) { - result = await generateAIAssignments(jurors, projects, constraints) - } else { - result = generateFallbackAssignments(jurors, projects, constraints) - } - - // Enrich suggestions with user and project names for display - const enrichedSuggestions = await Promise.all( - result.suggestions.map(async (s) => { - const juror = jurors.find((j) => j.id === s.jurorId) - const project = projects.find((p) => p.id === s.projectId) - return { - ...s, - jurorName: juror?.name || juror?.email || 'Unknown', - projectTitle: project?.title || 'Unknown', - } + // Filter out suggestions for assignments that already exist + const existingAssignments = await ctx.prisma.assignment.findMany({ + where: { roundId: input.roundId }, + select: { userId: true, projectId: true }, }) - ) + const assignmentSet = new Set( + existingAssignments.map((a) => `${a.userId}-${a.projectId}`) + ) + const filteredSuggestions = suggestions.filter( + (s) => !assignmentSet.has(`${s.jurorId}-${s.projectId}`) + ) + + return { + success: true, + suggestions: filteredSuggestions, + fallbackUsed: completedJob.fallbackUsed, + error: null, + generatedAt: completedJob.completedAt, + } + } + + // No completed job with suggestions - return empty return { - success: result.success, - suggestions: enrichedSuggestions, - fallbackUsed: result.fallbackUsed, - error: result.error, + success: true, + suggestions: [], + fallbackUsed: false, + error: null, + generatedAt: null, } }),