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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user