From 6f6d5ef501bfdabc5d688fc0b98652211c6961a6 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Feb 2026 17:40:26 +0100 Subject: [PATCH] Add visual progress indicator for AI assignment batches - Add AssignmentJob model to track AI assignment progress - Create startAIAssignmentJob mutation for background processing - Add getAIAssignmentJobStatus query for polling progress - Update AI assignment service with progress callback support - Add progress bar UI showing batch/project processing status - Add toast notifications for job completion/failure - Add AI_SUGGESTIONS_READY notification type Co-Authored-By: Claude Opus 4.5 --- prisma/schema.prisma | 33 +++ .../admin/rounds/[id]/assignments/page.tsx | 158 ++++++++++-- src/server/routers/assignment.ts | 240 ++++++++++++++++++ src/server/services/ai-assignment.ts | 28 +- src/server/services/in-app-notification.ts | 1 + 5 files changed, 439 insertions(+), 21 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3035ed5..5023294 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -374,6 +374,7 @@ model Round { filteringRules FilteringRule[] filteringResults FilteringResult[] filteringJobs FilteringJob[] + assignmentJobs AssignmentJob[] @@index([programId]) @@index([status]) @@ -1092,6 +1093,38 @@ enum FilteringJobStatus { FAILED } +// Tracks progress of long-running AI assignment jobs +model AssignmentJob { + id String @id @default(cuid()) + roundId String + status AssignmentJobStatus @default(PENDING) + totalProjects Int @default(0) + totalBatches Int @default(0) + currentBatch Int @default(0) + processedCount Int @default(0) + suggestionsCount Int @default(0) + errorMessage String? @db.Text + startedAt DateTime? + completedAt DateTime? + fallbackUsed Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + + @@index([roundId]) + @@index([status]) +} + +enum AssignmentJobStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + // ============================================================================= // SPECIAL AWARDS SYSTEM // ============================================================================= diff --git a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx index 97b543e..608d62f 100644 --- a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, use, useState } from 'react' +import { Suspense, use, useState, useEffect } from 'react' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { @@ -77,23 +77,47 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { const [selectedJuror, setSelectedJuror] = useState('') const [selectedProject, setSelectedProject] = useState('') const [useAI, setUseAI] = useState(false) + const [activeJobId, setActiveJobId] = useState(null) const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId }) const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId }) const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId }) const { data: isAIAvailable } = trpc.assignment.isAIAvailable.useQuery() + // AI Assignment job queries + const { data: latestJob, refetch: refetchLatestJob } = trpc.assignment.getLatestAIAssignmentJob.useQuery( + { roundId }, + { enabled: useAI } + ) + + // Poll for job status when there's an active job + const { data: jobStatus } = trpc.assignment.getAIAssignmentJobStatus.useQuery( + { jobId: activeJobId! }, + { + enabled: !!activeJobId, + refetchInterval: activeJobId ? 2000 : false, + } + ) + + // Start AI assignment job mutation + const startAIJob = trpc.assignment.startAIAssignmentJob.useMutation() + + const isAIJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING' + const aiJobProgressPercent = jobStatus?.totalBatches + ? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100) + : 0 + // Algorithmic suggestions (default) const { data: algorithmicSuggestions, isLoading: loadingAlgorithmic, refetch: refetchAlgorithmic } = trpc.assignment.getSuggestions.useQuery( { roundId }, { enabled: !!round && !useAI } ) - // AI-powered suggestions (expensive - disable auto refetch) + // AI-powered suggestions (expensive - only used after job completes) const { data: aiSuggestionsRaw, isLoading: loadingAI, refetch: refetchAI } = trpc.assignment.getAISuggestions.useQuery( { roundId, useAI: true }, { - enabled: !!round && useAI, + enabled: !!round && useAI && !isAIJobRunning, staleTime: Infinity, // Never consider stale (only refetch manually) refetchOnWindowFocus: false, refetchOnReconnect: false, @@ -101,6 +125,41 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { } ) + // Set active job from latest job on load + useEffect(() => { + if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) { + setActiveJobId(latestJob.id) + } + }, [latestJob]) + + // Handle job completion + useEffect(() => { + if (jobStatus?.status === 'COMPLETED') { + toast.success( + `AI Assignment complete: ${jobStatus.suggestionsCount} suggestions generated${jobStatus.fallbackUsed ? ' (using fallback algorithm)' : ''}` + ) + setActiveJobId(null) + refetchLatestJob() + refetchAI() + } else if (jobStatus?.status === 'FAILED') { + toast.error(`AI Assignment failed: ${jobStatus.errorMessage || 'Unknown error'}`) + setActiveJobId(null) + refetchLatestJob() + } + }, [jobStatus?.status, jobStatus?.suggestionsCount, jobStatus?.fallbackUsed, jobStatus?.errorMessage, refetchLatestJob, refetchAI]) + + const handleStartAIJob = async () => { + try { + const result = await startAIJob.mutateAsync({ roundId }) + setActiveJobId(result.jobId) + toast.info('AI Assignment job started. Progress will update automatically.') + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to start AI assignment' + ) + } + } + // Normalize AI suggestions to match algorithmic format const aiSuggestions = aiSuggestionsRaw?.suggestions?.map((s) => ({ userId: s.jurorId, @@ -113,7 +172,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { // Use the appropriate suggestions based on mode const suggestions = useAI ? aiSuggestions : (algorithmicSuggestions ?? []) - const loadingSuggestions = useAI ? loadingAI : loadingAlgorithmic + const loadingSuggestions = useAI ? (loadingAI || isAIJobRunning) : loadingAlgorithmic const refetchSuggestions = useAI ? refetchAI : refetchAlgorithmic // Get available jurors for manual assignment @@ -483,31 +542,92 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { variant={useAI ? 'default' : 'outline'} size="sm" onClick={() => { - setUseAI(!useAI) - setSelectedSuggestions(new Set()) + if (!useAI) { + setUseAI(true) + setSelectedSuggestions(new Set()) + // Start AI job if no suggestions yet + if (!aiSuggestionsRaw?.suggestions?.length && !isAIJobRunning) { + handleStartAIJob() + } + } else { + setUseAI(false) + setSelectedSuggestions(new Set()) + } }} - disabled={!isAIAvailable && !useAI} + disabled={(!isAIAvailable && !useAI) || isAIJobRunning} title={!isAIAvailable ? 'OpenAI API key not configured' : undefined} > {useAI ? 'AI Mode' : 'Use AI'} - + {useAI && !isAIJobRunning && ( + + )} + {!useAI && ( + + )} - {loadingSuggestions ? ( + {/* AI Job Progress Indicator */} + {isAIJobRunning && jobStatus && ( +
+
+
+ +
+

+ AI Assignment Analysis in Progress +

+

+ Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches +

+
+ + + Batch {jobStatus.currentBatch} of {jobStatus.totalBatches} + +
+
+
+ + {jobStatus.processedCount} of {jobStatus.totalProjects} projects processed + + + {aiJobProgressPercent}% + +
+ +
+
+
+ )} + + {loadingSuggestions && !isAIJobRunning ? (
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 638e1ac..d32d3ae 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -5,14 +5,157 @@ import { getUserAvatarUrl } from '../utils/avatar-url' import { generateAIAssignments, generateFallbackAssignments, + type AssignmentProgressCallback, } from '../services/ai-assignment' import { isOpenAIConfigured } from '@/lib/openai' +import { prisma } from '@/lib/prisma' import { createNotification, createBulkNotifications, + notifyAdmins, NotificationTypes, } from '../services/in-app-notification' +// Background job execution function +async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) { + try { + // Update job to running + await prisma.assignmentJob.update({ + where: { id: jobId }, + data: { status: 'RUNNING', startedAt: new Date() }, + }) + + // Get round constraints + const round = await prisma.round.findUniqueOrThrow({ + where: { id: roundId }, + select: { + name: true, + requiredReviews: true, + minAssignmentsPerJuror: true, + maxAssignmentsPerJuror: true, + }, + }) + + // Get all active jury members with their expertise and current load + const jurors = await 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 } }, + }, + }, + }, + }) + + // Get all projects in the round + const projects = await prisma.project.findMany({ + where: { roundId }, + select: { + id: true, + title: true, + description: true, + tags: true, + teamName: true, + _count: { select: { assignments: true } }, + }, + }) + + // Get existing assignments + const existingAssignments = await prisma.assignment.findMany({ + where: { roundId }, + select: { userId: true, projectId: true }, + }) + + // Calculate batch info + const BATCH_SIZE = 15 + const totalBatches = Math.ceil(projects.length / BATCH_SIZE) + + await prisma.assignmentJob.update({ + where: { id: jobId }, + data: { totalProjects: projects.length, totalBatches }, + }) + + // Progress callback + const onProgress: AssignmentProgressCallback = async (progress) => { + await prisma.assignmentJob.update({ + where: { id: jobId }, + data: { + currentBatch: progress.currentBatch, + processedCount: progress.processedCount, + }, + }) + } + + const constraints = { + requiredReviewsPerProject: round.requiredReviews, + minAssignmentsPerJuror: round.minAssignmentsPerJuror, + maxAssignmentsPerJuror: round.maxAssignmentsPerJuror, + existingAssignments: existingAssignments.map((a) => ({ + jurorId: a.userId, + projectId: a.projectId, + })), + } + + // Execute AI assignment with progress callback + const result = await generateAIAssignments( + jurors, + projects, + constraints, + userId, + roundId, + onProgress + ) + + // Mark job as completed + await prisma.assignmentJob.update({ + where: { id: jobId }, + data: { + status: 'COMPLETED', + completedAt: new Date(), + processedCount: projects.length, + suggestionsCount: result.suggestions.length, + fallbackUsed: result.fallbackUsed ?? false, + }, + }) + + // Notify admins that AI assignment is complete + await notifyAdmins({ + type: NotificationTypes.AI_SUGGESTIONS_READY, + title: 'AI Assignment Suggestions Ready', + message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`, + linkUrl: `/admin/rounds/${roundId}/assignments`, + linkLabel: 'View Suggestions', + priority: 'high', + metadata: { + roundId, + jobId, + projectCount: projects.length, + suggestionsCount: result.suggestions.length, + fallbackUsed: result.fallbackUsed, + }, + }) + + } catch (error) { + console.error('[AI Assignment Job] Error:', error) + + // Mark job as failed + await prisma.assignmentJob.update({ + where: { id: jobId }, + data: { + status: 'FAILED', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + completedAt: new Date(), + }, + }) + } +} + export const assignmentRouter = router({ /** * List assignments for a round (admin only) @@ -851,4 +994,101 @@ export const assignmentRouter = router({ return { created: created.count } }), + + /** + * Start an AI assignment job (background processing) + */ + startAIAssignmentJob: adminProcedure + .input(z.object({ roundId: z.string() })) + .mutation(async ({ ctx, input }) => { + // Check for existing running job + const existingJob = await ctx.prisma.assignmentJob.findFirst({ + where: { + roundId: input.roundId, + status: { in: ['PENDING', 'RUNNING'] }, + }, + }) + + if (existingJob) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'An AI assignment job is already running for this round', + }) + } + + // Verify AI is available + if (!isOpenAIConfigured()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'OpenAI API is not configured', + }) + } + + // Create job record + const job = await ctx.prisma.assignmentJob.create({ + data: { + roundId: input.roundId, + status: 'PENDING', + }, + }) + + // Start background job (non-blocking) + runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error) + + return { jobId: job.id } + }), + + /** + * Get AI assignment job status (for polling) + */ + getAIAssignmentJobStatus: protectedProcedure + .input(z.object({ jobId: z.string() })) + .query(async ({ ctx, input }) => { + const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({ + where: { id: input.jobId }, + }) + + return { + id: job.id, + status: job.status, + totalProjects: job.totalProjects, + totalBatches: job.totalBatches, + currentBatch: job.currentBatch, + processedCount: job.processedCount, + suggestionsCount: job.suggestionsCount, + fallbackUsed: job.fallbackUsed, + errorMessage: job.errorMessage, + startedAt: job.startedAt, + completedAt: job.completedAt, + } + }), + + /** + * Get the latest AI assignment job for a round + */ + getLatestAIAssignmentJob: adminProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const job = await ctx.prisma.assignmentJob.findFirst({ + where: { roundId: input.roundId }, + orderBy: { createdAt: 'desc' }, + }) + + if (!job) return null + + return { + id: job.id, + status: job.status, + totalProjects: job.totalProjects, + totalBatches: job.totalBatches, + currentBatch: job.currentBatch, + processedCount: job.processedCount, + suggestionsCount: job.suggestionsCount, + fallbackUsed: job.fallbackUsed, + errorMessage: job.errorMessage, + startedAt: job.startedAt, + completedAt: job.completedAt, + createdAt: job.createdAt, + } + }), }) diff --git a/src/server/services/ai-assignment.ts b/src/server/services/ai-assignment.ts index 555f32f..be1ec9d 100644 --- a/src/server/services/ai-assignment.ts +++ b/src/server/services/ai-assignment.ts @@ -86,6 +86,15 @@ interface AssignmentConstraints { }> } +export interface AssignmentProgressCallback { + (progress: { + currentBatch: number + totalBatches: number + processedCount: number + totalProjects: number + }): Promise +} + // ─── AI Processing ─────────────────────────────────────────────────────────── /** @@ -247,7 +256,8 @@ export async function generateAIAssignments( projects: ProjectForAssignment[], constraints: AssignmentConstraints, userId?: string, - entityId?: string + entityId?: string, + onProgress?: AssignmentProgressCallback ): Promise { // Truncate descriptions before anonymization const truncatedProjects = projects.map((p) => ({ @@ -279,11 +289,14 @@ export async function generateAIAssignments( let totalTokens = 0 // Process projects in batches + const totalBatches = Math.ceil(anonymizedData.projects.length / ASSIGNMENT_BATCH_SIZE) + for (let i = 0; i < anonymizedData.projects.length; i += ASSIGNMENT_BATCH_SIZE) { const batchProjects = anonymizedData.projects.slice(i, i + ASSIGNMENT_BATCH_SIZE) const batchMappings = anonymizedData.projectMappings.slice(i, i + ASSIGNMENT_BATCH_SIZE) + const currentBatch = Math.floor(i / ASSIGNMENT_BATCH_SIZE) + 1 - console.log(`[AI Assignment] Processing batch ${Math.floor(i / ASSIGNMENT_BATCH_SIZE) + 1}/${Math.ceil(anonymizedData.projects.length / ASSIGNMENT_BATCH_SIZE)}`) + console.log(`[AI Assignment] Processing batch ${currentBatch}/${totalBatches}`) const { suggestions, tokensUsed } = await processAssignmentBatch( openai, @@ -298,6 +311,17 @@ export async function generateAIAssignments( allSuggestions.push(...suggestions) totalTokens += tokensUsed + + // Report progress after each batch + if (onProgress) { + const processedCount = Math.min((currentBatch) * ASSIGNMENT_BATCH_SIZE, projects.length) + await onProgress({ + currentBatch, + totalBatches, + processedCount, + totalProjects: projects.length, + }) + } } console.log(`[AI Assignment] Completed. Total suggestions: ${allSuggestions.length}, Total tokens: ${totalTokens}`) diff --git a/src/server/services/in-app-notification.ts b/src/server/services/in-app-notification.ts index 3fc4c0a..71ae51e 100644 --- a/src/server/services/in-app-notification.ts +++ b/src/server/services/in-app-notification.ts @@ -16,6 +16,7 @@ export const NotificationTypes = { // Admin notifications FILTERING_COMPLETE: 'FILTERING_COMPLETE', FILTERING_FAILED: 'FILTERING_FAILED', + AI_SUGGESTIONS_READY: 'AI_SUGGESTIONS_READY', NEW_APPLICATION: 'NEW_APPLICATION', BULK_APPLICATIONS: 'BULK_APPLICATIONS', DOCUMENTS_UPLOADED: 'DOCUMENTS_UPLOADED',