Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
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'
2026-02-17 14:45:57 +01:00
import { generateAIAssignments } from '../services/ai-assignment'
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
export const roundAssignmentRouter = router ( {
/ * *
2026-02-17 14:45:57 +01:00
* 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
2026-02-18 16:16:55 +01:00
const configuredMax = ( configJson ? . maxAssignmentsPerJuror as number ) ? ? undefined
// If no explicit cap, calculate a balanced one: ceil(total_needed / juror_count) + 2 buffer
const totalNeeded = projectStates . length * input . requiredReviews
const jurorCount = round . juryGroup . members . length
const calculatedMax = Math . ceil ( totalNeeded / jurorCount ) + 2
const maxPerJuror = configuredMax ? ? calculatedMax
2026-02-17 14:45:57 +01:00
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 ) )
2026-02-18 15:16:22 +01:00
// Warn about jurors without profile data
const warnings : string [ ] = result . error ? [ result . error ] : [ ]
const incompleteJurors = jurors . filter (
( j ) = > ( ! j . expertiseTags || j . expertiseTags . length === 0 ) && ! j . bio
)
if ( incompleteJurors . length > 0 ) {
const names = incompleteJurors . map ( ( j ) = > j . name || 'Unknown' ) . join ( ', ' )
warnings . push (
` ${ incompleteJurors . length } juror(s) have no expertise tags or bio ( ${ names } ). Their assignments are based on workload balance only — consider asking them to complete their profile first. `
)
}
2026-02-17 14:45:57 +01:00
return {
assignments ,
2026-02-18 15:16:22 +01:00
warnings ,
2026-02-17 14:45:57 +01:00
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 )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
* /
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' } ,
} )
} ) ,
} )