AI-powered assignment generation with enriched data and streaming UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s

- Add aiPreview mutation with full project/juror data (bios, descriptions,
  documents, categories, ocean issues, countries, team sizes)
- Increase AI description limit from 300 to 2000 chars for richer context
- Update GPT system prompt to use all available data fields
- Add mode toggle (AI default / Algorithm fallback) in assignment preview
- Lift AI mutation to parent page for background generation persistence
- Show visual indicator on page while AI generates (spinner + progress card)
- Toast notification with "Review" action when AI completes
- Staggered reveal animation for assignment results (streaming feel)
- Fix assignment balance with dynamic penalty (25pts per existing assignment)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-17 14:45:57 +01:00
parent a7b6031f4d
commit 6743119c4d
7 changed files with 640 additions and 73 deletions

View File

@@ -7,10 +7,205 @@ import {
getRoundCoverageReport,
getUnassignedQueue,
} from '../services/round-assignment'
import { generateAIAssignments } from '../services/ai-assignment'
export const roundAssignmentRouter = router({
/**
* Preview round assignments without committing
* 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(

View File

@@ -35,11 +35,15 @@ const ASSIGNMENT_BATCH_SIZE = 15
const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer for an ocean conservation competition.
## Your Role
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
Match jurors to projects based on expertise alignment, workload balance, geographic diversity, and coverage requirements. You have access to rich data about both jurors and projects — use ALL available information to make optimal assignments.
## Available Data
- **Jurors**: expertiseTags (areas of expertise), bio (background description with deeper domain knowledge), country, currentAssignmentCount, maxAssignments
- **Projects**: title, description (detailed project overview), tags (with confidence 0-1), category (e.g. STARTUP, BUSINESS_CONCEPT), oceanIssue (focus area like CORAL_REEFS, POLLUTION), country, institution, teamSize, fileTypes (submitted document types)
## Matching Criteria (Weighted)
- Expertise Match (50%): How well juror tags/expertise align with project topics. Project tags include a confidence score (0-1) — weight higher-confidence tags more heavily as they are more reliably assigned. A tag with confidence 0.9 is a strong signal; one with 0.5 is uncertain.
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
- Expertise & Domain Match (50%): How well juror tags, bio, and background align with project topics, category, ocean issue, and description. Use bio text to identify deeper domain expertise beyond explicit tags — e.g., a bio mentioning "20 years of coral research" matches coral-related projects even without explicit tags. Weight higher-confidence tags more heavily.
- Workload Balance (30%): Distribute assignments as evenly as possible; strongly prefer jurors below capacity. Never let one juror get significantly more assignments than another.
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
## Output Format
@@ -51,18 +55,20 @@ Return a JSON object:
"project_id": "PROJECT_001",
"confidence_score": 0.0-1.0,
"expertise_match_score": 0.0-1.0,
"reasoning": "1-2 sentence justification"
"reasoning": "1-2 sentence justification referencing specific expertise matches"
}
]
}
## Guidelines
- Each project should receive the required number of reviews
- Each project MUST receive the required number of reviews — ensure full coverage
- Distribute assignments as evenly as possible across all jurors
- Do not assign jurors who are at or above their capacity
- Favor geographic and disciplinary diversity in assignments
- confidence_score reflects overall assignment quality; expertise_match_score reflects tag overlap only
- A strong match: shared expertise tags + available capacity + under minimum target
- An acceptable match: related domain + available capacity
- Favor geographic diversity: avoid assigning jurors from the same country as the project when possible
- Consider disciplinary diversity: mix different expertise backgrounds per project
- confidence_score reflects overall assignment quality; expertise_match_score reflects tag/expertise overlap
- A strong match: shared expertise tags + relevant bio background + available capacity
- An acceptable match: related domain/ocean issue + available capacity
- A poor match: no expertise overlap, only assigned for coverage`
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -88,6 +94,8 @@ interface JurorForAssignment {
name?: string | null
email: string
expertiseTags: string[]
bio?: string | null
country?: string | null
maxAssignments?: number | null
_count?: {
assignments: number
@@ -101,6 +109,12 @@ interface ProjectForAssignment {
tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null
competitionCategory?: string | null
oceanIssue?: string | null
country?: string | null
institution?: string | null
teamSize?: number
fileTypes?: string[]
_count?: {
assignments: number
}

View File

@@ -21,7 +21,7 @@ import type {
// ─── Description Limits ──────────────────────────────────────────────────────
export const DESCRIPTION_LIMITS = {
ASSIGNMENT: 300,
ASSIGNMENT: 2000,
FILTERING: 500,
ELIGIBILITY: 400,
MENTOR: 350,
@@ -46,6 +46,8 @@ export interface AnonymizedJuror {
expertiseTags: string[]
currentAssignmentCount: number
maxAssignments: number | null
bio?: string | null
country?: string | null
}
export interface AnonymizedProject {
@@ -54,6 +56,12 @@ export interface AnonymizedProject {
description: string | null
tags: Array<{ name: string; confidence: number }>
teamName: string | null
category?: string | null
oceanIssue?: string | null
country?: string | null
institution?: string | null
teamSize?: number
fileTypes?: string[]
}
export interface JurorMapping {
@@ -200,6 +208,8 @@ interface JurorInput {
name?: string | null
email: string
expertiseTags: string[]
bio?: string | null
country?: string | null
maxAssignments?: number | null
_count?: {
assignments: number
@@ -213,6 +223,12 @@ interface ProjectInput {
tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null
competitionCategory?: string | null
oceanIssue?: string | null
country?: string | null
institution?: string | null
teamSize?: number
fileTypes?: string[]
}
/**
@@ -238,6 +254,8 @@ export function anonymizeForAI(
expertiseTags: juror.expertiseTags,
currentAssignmentCount: juror._count?.assignments ?? 0,
maxAssignments: juror.maxAssignments ?? null,
bio: juror.bio ? truncateAndSanitize(juror.bio, 500) : null,
country: juror.country ?? null,
}
})
@@ -260,6 +278,12 @@ export function anonymizeForAI(
? project.tagConfidences
: project.tags.map((t) => ({ name: t, confidence: 1.0 })),
teamName: project.teamName ? `Team ${index + 1}` : null,
category: project.competitionCategory ?? null,
oceanIssue: project.oceanIssue ?? null,
country: project.country ?? null,
institution: project.institution ? sanitizeText(project.institution) : null,
teamSize: project.teamSize,
fileTypes: project.fileTypes,
}
}
)

View File

@@ -303,19 +303,47 @@ export async function previewRoundAssignment(
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score)
// Balance-aware greedy assignment: at each step, pick the best suggestion
// while dynamically penalizing jurors who already have more assignments
// to distribute work as evenly as possible.
const BALANCE_PENALTY_PER_ASSIGNMENT = 25
// Greedy assignment: pick top suggestions respecting caps
const finalAssignments: AssignmentSuggestion[] = []
const finalJurorCounts = new Map(jurorAssignmentCounts)
const finalProjectCounts = new Map(projectAssignmentCounts)
const consumed = new Set<number>()
while (consumed.size < suggestions.length) {
let bestIdx = -1
let bestAdjustedScore = -Infinity
for (let i = 0; i < suggestions.length; i++) {
if (consumed.has(i)) continue
const s = suggestions[i]
const projectCount = finalProjectCounts.get(s.projectId) ?? 0
if (projectCount >= requiredReviews) {
consumed.add(i)
continue
}
const jurorCount = finalJurorCounts.get(s.userId) ?? 0
// Dynamic balance penalty: penalize jurors who already have more assignments
const balancePenalty = jurorCount * BALANCE_PENALTY_PER_ASSIGNMENT
const adjustedScore = s.score - balancePenalty
if (adjustedScore > bestAdjustedScore) {
bestAdjustedScore = adjustedScore
bestIdx = i
}
}
if (bestIdx === -1) break
const suggestion = suggestions[bestIdx]
consumed.add(bestIdx)
for (const suggestion of suggestions) {
const jurorCount = finalJurorCounts.get(suggestion.userId) ?? 0
const projectCount = finalProjectCounts.get(suggestion.projectId) ?? 0
if (projectCount >= requiredReviews) continue
// Re-check cap
const member = members.find((m: any) => m.userId === suggestion.userId)
@@ -334,7 +362,7 @@ export async function previewRoundAssignment(
finalAssignments.push(suggestion)
finalJurorCounts.set(suggestion.userId, jurorCount + 1)
finalProjectCounts.set(suggestion.projectId, projectCount + 1)
finalProjectCounts.set(suggestion.projectId, (finalProjectCounts.get(suggestion.projectId) ?? 0) + 1)
}
const unassignedProjects = projects.filter(