Files
MOPC-Portal/src/server/routers/assignment/assignment-suggestions.ts

881 lines
27 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, withAIRateLimit } from '../../trpc'
import {
generateAIAssignments,
type AssignmentProgressCallback,
} from '../../services/ai-assignment'
import { isOpenAIConfigured } from '@/lib/openai'
import { prisma } from '@/lib/prisma'
import { notifyAdmins, NotificationTypes } from '../../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
import { buildBatchNotifications } from './shared'
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
try {
await prisma.assignmentJob.update({
where: { id: jobId },
data: { status: 'RUNNING', startedAt: new Date() },
})
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: {
name: true,
configJson: true,
competitionId: true,
juryGroupId: true,
},
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
1
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Scope jurors to jury group if the round has one assigned
let scopedJurorIds: string[] | undefined
if (round.juryGroupId) {
const groupMembers = await prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
select: { userId: true },
})
scopedJurorIds = groupMembers.map((m) => m.userId)
}
const jurors = await prisma.user.findMany({
where: {
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId } },
},
},
},
})
const projectRoundStates = await prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectRoundStates.map((prs) => prs.projectId)
const projects = await prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
description: true,
tags: true,
teamName: true,
projectTags: {
select: { tag: { select: { name: true } }, confidence: true },
},
_count: { select: { assignments: { where: { roundId } } } },
},
})
// Enrich projects with tag confidence data for AI matching
const projectsWithConfidence = projects.map((p) => ({
...p,
tagConfidences: p.projectTags.map((pt) => ({
name: pt.tag.name,
confidence: pt.confidence,
})),
}))
const existingAssignments = await prisma.assignment.findMany({
where: { roundId },
select: { userId: true, projectId: true },
})
// Query COI records for this round to exclude conflicted juror-project pairs
const coiRecords = await prisma.conflictOfInterest.findMany({
where: {
assignment: { roundId },
hasConflict: true,
},
select: { userId: true, projectId: true },
})
const coiExclusions = new Set(
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
)
// 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,
},
})
}
// Build per-juror limits map for jurors with personal maxAssignments
const jurorLimits: Record<string, number> = {}
for (const juror of jurors) {
if (juror.maxAssignments !== null && juror.maxAssignments !== undefined) {
jurorLimits[juror.id] = juror.maxAssignments
}
}
const constraints = {
requiredReviewsPerProject: requiredReviews,
minAssignmentsPerJuror,
maxAssignmentsPerJuror,
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,
})),
}
const result = await generateAIAssignments(
jurors,
projectsWithConfidence,
constraints,
userId,
roundId,
onProgress
)
// Filter out suggestions that conflict with COI declarations
const filteredSuggestions = coiExclusions.size > 0
? result.suggestions.filter((s) => !coiExclusions.has(`${s.jurorId}:${s.projectId}`))
: result.suggestions
// Enrich suggestions with names for storage
const enrichedSuggestions = filteredSuggestions.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: {
status: 'COMPLETED',
completedAt: new Date(),
processedCount: projects.length,
suggestionsCount: filteredSuggestions.length,
suggestionsJson: enrichedSuggestions,
fallbackUsed: result.fallbackUsed ?? false,
},
})
await notifyAdmins({
type: NotificationTypes.AI_SUGGESTIONS_READY,
title: 'AI Assignment Suggestions Ready',
message: `AI generated ${filteredSuggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
linkUrl: `/admin/rounds/${roundId}`,
linkLabel: 'View Suggestions',
priority: 'high',
metadata: {
roundId,
jobId,
projectCount: projects.length,
suggestionsCount: filteredSuggestions.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 assignmentSuggestionsRouter = router({
/**
* Get smart assignment suggestions using algorithm
*/
getSuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const stage = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true, juryGroupId: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
1
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Extract category quotas if enabled
const categoryQuotasEnabled = config.categoryQuotasEnabled === true
const categoryQuotas = categoryQuotasEnabled
? (config.categoryQuotas as Record<string, { min: number; max: number }> | undefined)
: undefined
// Scope jurors to jury group if the round has one assigned
let scopedJurorIds: string[] | undefined
if (stage.juryGroupId) {
const groupMembers = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: stage.juryGroupId },
select: { userId: true },
})
scopedJurorIds = groupMembers.map((m) => m.userId)
}
const jurors = await ctx.prisma.user.findMany({
where: {
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId: input.roundId } },
},
},
},
})
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: { projectId: true },
})
const projectIds = projectRoundStates.map((pss) => pss.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
tags: true,
competitionCategory: true,
projectTags: {
include: { tag: { select: { name: true } } },
},
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
},
})
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}`)
)
// Build per-juror category distribution for quota scoring
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
if (categoryQuotas) {
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: {
userId: true,
project: { select: { competitionCategory: true } },
},
})
for (const a of assignmentsWithCategory) {
const cat = a.project.competitionCategory?.toLowerCase().trim()
if (!cat) continue
let catMap = jurorCategoryDistribution.get(a.userId)
if (!catMap) {
catMap = {}
jurorCategoryDistribution.set(a.userId, catMap)
}
catMap[cat] = (catMap[cat] || 0) + 1
}
}
const suggestions: Array<{
userId: string
jurorName: string
projectId: string
projectTitle: string
score: number
reasoning: string[]
}> = []
for (const project of projects) {
if (project._count.assignments >= requiredReviews) continue
const neededAssignments = requiredReviews - project._count.assignments
const jurorScores = jurors
.filter((j) => {
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
if (j._count.assignments >= effectiveMax) return false
return true
})
.map((juror) => {
const reasoning: string[] = []
let score = 0
const projectTagNames = project.projectTags.map((pt) => pt.tag.name.toLowerCase())
const matchingTags = projectTagNames.length > 0
? juror.expertiseTags.filter((tag) =>
projectTagNames.includes(tag.toLowerCase())
)
: juror.expertiseTags.filter((tag) =>
project.tags.map((t) => t.toLowerCase()).includes(tag.toLowerCase())
)
const totalTags = projectTagNames.length > 0 ? projectTagNames.length : project.tags.length
const expertiseScore =
matchingTags.length > 0
? matchingTags.length / Math.max(totalTags, 1)
: 0
score += expertiseScore * 35
if (matchingTags.length > 0) {
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
}
const effectiveMax = juror.maxAssignments ?? maxAssignmentsPerJuror
const loadScore = 1 - juror._count.assignments / effectiveMax
score += loadScore * 20
const underMinBonus =
juror._count.assignments < minAssignmentsPerJuror
? (minAssignmentsPerJuror - juror._count.assignments) * 3
: 0
score += Math.min(15, underMinBonus)
if (juror._count.assignments < minAssignmentsPerJuror) {
reasoning.push(
`Under target: ${juror._count.assignments}/${minAssignmentsPerJuror} min`
)
}
reasoning.push(
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
)
// Category quota scoring
if (categoryQuotas) {
const jurorCategoryCounts = jurorCategoryDistribution.get(juror.id) || {}
const normalizedCat = project.competitionCategory?.toLowerCase().trim()
if (normalizedCat) {
const quota = Object.entries(categoryQuotas).find(
([key]) => key.toLowerCase().trim() === normalizedCat
)
if (quota) {
const [, { min, max }] = quota
const currentCount = jurorCategoryCounts[normalizedCat] || 0
if (currentCount >= max) {
score -= 25
reasoning.push(`Category quota exceeded (-25)`)
} else if (currentCount < min) {
const otherAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
if (key.toLowerCase().trim() === normalizedCat) return false
return (jurorCategoryCounts[key.toLowerCase().trim()] || 0) >= q.min
})
if (otherAboveMin) {
score += 10
reasoning.push(`Category quota bonus (+10)`)
}
}
}
}
}
return {
userId: juror.id,
jurorName: juror.name || juror.email || 'Unknown',
projectId: project.id,
projectTitle: project.title || 'Unknown',
score,
reasoning,
}
})
.sort((a, b) => b.score - a.score)
.slice(0, neededAssignments)
suggestions.push(...jurorScores)
}
return suggestions.sort((a, b) => b.score - a.score)
}),
/**
* Check if AI assignment is available
*/
isAIAvailable: adminProcedure.query(async () => {
return isOpenAIConfigured()
}),
/**
* Get AI-powered assignment suggestions (retrieves from completed job)
*/
getAISuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
useAI: z.boolean().default(true),
})
)
.query(async ({ ctx, input }) => {
const completedJob = await ctx.prisma.assignmentJob.findFirst({
where: {
roundId: input.roundId,
status: 'COMPLETED',
},
orderBy: { completedAt: 'desc' },
select: {
suggestionsJson: true,
fallbackUsed: true,
completedAt: true,
},
})
if (completedJob?.suggestionsJson) {
const suggestions = completedJob.suggestionsJson as Array<{
jurorId: string
jurorName: string
projectId: string
projectTitle: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
}>
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,
}
}
return {
success: true,
suggestions: [],
fallbackUsed: false,
error: null,
generatedAt: null,
}
}),
/**
* Apply AI-suggested assignments
*/
applyAISuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
projectId: z.string(),
confidenceScore: z.number().optional(),
expertiseMatchScore: z.number().optional(),
reasoning: z.string().optional(),
})
),
usedAI: z.boolean().default(false),
forceOverride: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
let assignmentsToCreate = input.assignments
let skippedDueToCapacity = 0
// Capacity check (unless forceOverride)
if (!input.forceOverride) {
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
const users = await ctx.prisma.user.findMany({
where: { id: { in: uniqueUserIds } },
select: {
id: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId: input.roundId } },
},
},
},
})
const userMap = new Map(users.map((u) => [u.id, u]))
const stageData = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
})
const config = (stageData.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const runningCounts = new Map<string, number>()
for (const u of users) {
runningCounts.set(u.id, u._count.assignments)
}
assignmentsToCreate = input.assignments.filter((a) => {
const user = userMap.get(a.userId)
if (!user) return true
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
const currentCount = runningCounts.get(a.userId) ?? 0
if (currentCount >= effectiveMax) {
skippedDueToCapacity++
return false
}
runningCounts.set(a.userId, currentCount + 1)
return true
})
}
const created = await ctx.prisma.assignment.createMany({
data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
roundId: input.roundId,
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
aiConfidenceScore: a.confidenceScore,
expertiseMatchScore: a.expertiseMatchScore,
aiReasoning: a.reasoning,
createdBy: ctx.user.id,
})),
skipDuplicates: true,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
roundId: input.roundId,
count: created.count,
usedAI: input.usedAI,
forceOverride: input.forceOverride,
skippedDueToCapacity,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
if (created.count > 0) {
const userAssignmentCounts = assignmentsToCreate.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
},
{} as Record<string, number>
)
const stage = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, windowCloseAt: true },
})
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
await buildBatchNotifications(userAssignmentCounts, stage?.name, deadline)
}
return {
created: created.count,
requested: input.assignments.length,
skippedDueToCapacity,
}
}),
/**
* Apply suggested assignments
*/
applySuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
projectId: z.string(),
reasoning: z.string().optional(),
})
),
forceOverride: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
let assignmentsToCreate = input.assignments
let skippedDueToCapacity = 0
// Capacity check (unless forceOverride)
if (!input.forceOverride) {
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
const users = await ctx.prisma.user.findMany({
where: { id: { in: uniqueUserIds } },
select: {
id: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId: input.roundId } },
},
},
},
})
const userMap = new Map(users.map((u) => [u.id, u]))
const stageData = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
})
const config = (stageData.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const runningCounts = new Map<string, number>()
for (const u of users) {
runningCounts.set(u.id, u._count.assignments)
}
assignmentsToCreate = input.assignments.filter((a) => {
const user = userMap.get(a.userId)
if (!user) return true
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
const currentCount = runningCounts.get(a.userId) ?? 0
if (currentCount >= effectiveMax) {
skippedDueToCapacity++
return false
}
runningCounts.set(a.userId, currentCount + 1)
return true
})
}
const created = await ctx.prisma.assignment.createMany({
data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
roundId: input.roundId,
method: 'ALGORITHM',
aiReasoning: a.reasoning,
createdBy: ctx.user.id,
})),
skipDuplicates: true,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
roundId: input.roundId,
count: created.count,
forceOverride: input.forceOverride,
skippedDueToCapacity,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
if (created.count > 0) {
const userAssignmentCounts = assignmentsToCreate.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
},
{} as Record<string, number>
)
const stage = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, windowCloseAt: true },
})
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
await buildBatchNotifications(userAssignmentCounts, stage?.name, deadline)
}
return {
created: created.count,
requested: input.assignments.length,
skippedDueToCapacity,
}
}),
/**
* Start an AI assignment job (background processing)
*/
startAIAssignmentJob: adminProcedure
.use(withAIRateLimit)
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
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 stage',
})
}
if (!isOpenAIConfigured()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'OpenAI API is not configured',
})
}
const job = await ctx.prisma.assignmentJob.create({
data: {
roundId: input.roundId,
status: 'PENDING',
},
})
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
return { jobId: job.id }
}),
/**
* Get AI assignment job status (for polling)
*/
getAIAssignmentJobStatus: adminProcedure
.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,
}
}),
})