Files
MOPC-Portal/src/server/routers/roundAssignment.ts

313 lines
9.8 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure, juryProcedure } from '../trpc'
import {
previewRoundAssignment,
executeRoundAssignment,
getRoundCoverageReport,
getUnassignedQueue,
} from '../services/round-assignment'
import { generateAIAssignments } from '../services/ai-assignment'
export const roundAssignmentRouter = router({
/**
* AI-powered assignment preview using GPT with enriched project/juror data
*/
aiPreview: adminProcedure
.input(
z.object({
roundId: z.string(),
requiredReviews: z.number().int().min(1).max(20).default(3),
})
)
.mutation(async ({ ctx, input }) => {
// Load round with jury group
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
include: {
juryGroup: {
include: {
members: {
include: {
user: {
select: {
id: true, name: true, email: true,
bio: true, expertiseTags: true, country: true,
},
},
},
},
},
},
},
})
if (!round) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
}
if (!round.juryGroup) {
return {
assignments: [],
warnings: ['Round has no linked jury group'],
stats: { totalProjects: 0, totalJurors: 0, assignmentsGenerated: 0, unassignedProjects: 0 },
fallbackUsed: false,
tokensUsed: 0,
}
}
// Load projects with rich data (descriptions, tags, files, team members, etc.)
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
include: {
project: {
include: {
projectTags: { include: { tag: true } },
files: { select: { fileType: true, size: true, pageCount: true } },
_count: { select: { teamMembers: true } },
},
},
},
})
if (projectStates.length === 0) {
return {
assignments: [],
warnings: ['No active projects in this round'],
stats: { totalProjects: 0, totalJurors: round.juryGroup.members.length, assignmentsGenerated: 0, unassignedProjects: 0 },
fallbackUsed: false,
tokensUsed: 0,
}
}
// Load existing assignments
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
// Load COI records to exclude
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: { assignment: { roundId: input.roundId }, hasConflict: true },
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c: { userId: string; projectId: string }) => `${c.userId}:${c.projectId}`))
// Build enriched juror data for AI
const jurors = round.juryGroup.members.map((m) => ({
id: m.user.id,
name: m.user.name,
email: m.user.email,
expertiseTags: (m.user.expertiseTags as string[]) ?? [],
bio: m.user.bio as string | null,
country: m.user.country as string | null,
maxAssignments: (m as any).maxAssignments as number | null ?? null,
_count: {
assignments: existingAssignments.filter((a) => a.userId === m.user.id).length,
},
}))
// Build enriched project data for AI
const projects = projectStates.map((ps) => {
const p = ps.project as any
return {
id: p.id as string,
title: p.title as string,
description: p.description as string | null,
tags: (p.projectTags?.map((pt: any) => pt.tag?.name).filter(Boolean) ?? p.tags ?? []) as string[],
tagConfidences: p.projectTags?.map((pt: any) => ({
name: pt.tag?.name as string,
confidence: (pt.confidence as number) ?? 1.0,
})) as Array<{ name: string; confidence: number }> | undefined,
teamName: p.teamName as string | null,
competitionCategory: p.competitionCategory as string | null,
oceanIssue: p.oceanIssue as string | null,
country: p.country as string | null,
institution: p.institution as string | null,
teamSize: (p._count?.teamMembers as number) ?? 0,
fileTypes: (p.files?.map((f: any) => f.fileType).filter(Boolean) ?? []) as string[],
_count: {
assignments: existingAssignments.filter((a) => a.projectId === p.id).length,
},
}
})
// Build constraints
const configJson = round.configJson as Record<string, unknown> | null
const maxPerJuror = (configJson?.maxAssignmentsPerJuror as number) ?? undefined
const constraints = {
requiredReviewsPerProject: input.requiredReviews,
maxAssignmentsPerJuror: maxPerJuror,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,
})),
}
// Call AI service
const result = await generateAIAssignments(
jurors,
projects,
constraints,
ctx.user.id,
input.roundId,
)
// Filter out COI pairs and already-assigned pairs
const existingPairSet = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const filteredSuggestions = result.suggestions.filter((s) =>
!coiPairs.has(`${s.jurorId}:${s.projectId}`) &&
!existingPairSet.has(`${s.jurorId}:${s.projectId}`)
)
// Map to common AssignmentPreview format
const jurorNameMap = new Map(jurors.map((j) => [j.id, j.name ?? 'Unknown']))
const projectTitleMap = new Map(projects.map((p) => [p.id, p.title]))
const assignments = filteredSuggestions.map((s) => ({
userId: s.jurorId,
userName: jurorNameMap.get(s.jurorId) ?? 'Unknown',
projectId: s.projectId,
projectTitle: projectTitleMap.get(s.projectId) ?? 'Unknown',
score: Math.round(s.confidenceScore * 100),
breakdown: {
tagOverlap: Math.round(s.expertiseMatchScore * 100),
bioMatch: 0,
workloadBalance: 0,
countryMatch: 0,
geoDiversityPenalty: 0,
previousRoundFamiliarity: 0,
coiPenalty: 0,
availabilityPenalty: 0,
categoryQuotaPenalty: 0,
},
reasoning: [s.reasoning],
matchingTags: [] as string[],
policyViolations: [] as string[],
fromIntent: false,
}))
const assignedProjectIds = new Set(assignments.map((a) => a.projectId))
return {
assignments,
warnings: result.error ? [result.error] : [],
stats: {
totalProjects: projects.length,
totalJurors: jurors.length,
assignmentsGenerated: assignments.length,
unassignedProjects: projects.length - assignedProjectIds.size,
},
fallbackUsed: result.fallbackUsed ?? false,
tokensUsed: result.tokensUsed ?? 0,
}
}),
/**
* Preview round assignments without committing (algorithmic)
*/
preview: adminProcedure
.input(
z.object({
roundId: z.string(),
honorIntents: z.boolean().default(true),
requiredReviews: z.number().int().min(1).max(20).default(3),
})
)
.query(async ({ ctx, input }) => {
return previewRoundAssignment(
input.roundId,
{
honorIntents: input.honorIntents,
requiredReviews: input.requiredReviews,
},
ctx.prisma,
)
}),
/**
* Execute round assignments (create Assignment records)
*/
execute: adminProcedure
.input(
z.object({
roundId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
projectId: z.string(),
})
).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const result = await executeRoundAssignment(
input.roundId,
input.assignments,
ctx.user.id,
ctx.prisma,
)
if (result.errors.length > 0 && result.created === 0) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: result.errors.join('; '),
})
}
return result
}),
/**
* Get coverage report for a round
*/
coverageReport: protectedProcedure
.input(
z.object({
roundId: z.string(),
requiredReviews: z.number().int().min(1).max(20).default(3),
})
)
.query(async ({ ctx, input }) => {
return getRoundCoverageReport(input.roundId, input.requiredReviews, ctx.prisma)
}),
/**
* Get projects below required reviews threshold
*/
unassignedQueue: protectedProcedure
.input(
z.object({
roundId: z.string(),
requiredReviews: z.number().int().min(1).max(20).default(3),
})
)
.query(async ({ ctx, input }) => {
return getUnassignedQueue(input.roundId, input.requiredReviews, ctx.prisma)
}),
/**
* Get assignments for the current jury member in a specific round
*/
getMyAssignments: juryProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.assignment.findMany({
where: {
roundId: input.roundId,
userId: ctx.user.id,
},
include: {
project: {
select: { id: true, title: true, competitionCategory: true },
},
evaluation: {
select: { id: true, status: true, globalScore: true },
},
},
orderBy: { createdAt: 'asc' },
})
}),
})