AI-powered assignment generation with enriched data and streaming UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user