881 lines
27 KiB
TypeScript
881 lines
27 KiB
TypeScript
|
|
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,
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
})
|