From 6743119c4d8f8000f2bf1e512431d2712bc16876 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 17 Feb 2026 14:45:57 +0100 Subject: [PATCH] AI-powered assignment generation with enriched data and streaming UI - 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 --- .../[competitionId]/assignments/page.tsx | 38 ++- .../(admin)/admin/rounds/[roundId]/page.tsx | 117 +++++++- .../assignment/assignment-preview-sheet.tsx | 259 +++++++++++++++--- src/server/routers/roundAssignment.ts | 197 ++++++++++++- src/server/services/ai-assignment.ts | 32 ++- src/server/services/anonymization.ts | 26 +- src/server/services/round-assignment.ts | 44 ++- 7 files changed, 640 insertions(+), 73 deletions(-) diff --git a/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx index 4991a1f..b3edd71 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx @@ -2,7 +2,8 @@ import { useState } from 'react' import { useParams, useRouter } from 'next/navigation' -import { ArrowLeft, PlayCircle } from 'lucide-react' +import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react' +import { toast } from 'sonner' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -26,6 +27,16 @@ export default function AssignmentsDashboardPage() { const [selectedRoundId, setSelectedRoundId] = useState('') const [previewSheetOpen, setPreviewSheetOpen] = useState(false) + const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({ + onSuccess: () => { + toast.success('AI assignments ready!', { + action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) }, + duration: 10000, + }) + }, + onError: (err) => toast.error(`AI generation failed: ${err.message}`), + }) + const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({ id: competitionId, }) @@ -104,11 +115,24 @@ export default function AssignmentsDashboardPage() { {selectedRoundId && (
-
- + {aiAssignmentMutation.data && ( + + )}
@@ -170,6 +194,10 @@ export default function AssignmentsDashboardPage() { open={previewSheetOpen} onOpenChange={setPreviewSheetOpen} requiredReviews={requiredReviews} + aiResult={aiAssignmentMutation.data ?? null} + isAIGenerating={aiAssignmentMutation.isPending} + onGenerateAI={() => aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })} + onResetAI={() => aiAssignmentMutation.reset()} />
)} diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 9c58348..43327ec 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -170,6 +170,22 @@ export default function RoundDetailPage() { const pendingSaveRef = useRef(false) const [activeTab, setActiveTab] = useState('overview') const [previewSheetOpen, setPreviewSheetOpen] = useState(false) + + // AI assignment generation (lifted to page level so it persists when sheet closes) + const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({ + onSuccess: () => { + toast.success('AI assignments ready!', { + action: { + label: 'Review', + onClick: () => setPreviewSheetOpen(true), + }, + duration: 10000, + }) + }, + onError: (err) => { + toast.error(`AI generation failed: ${err.message}`) + }, + }) const [exportOpen, setExportOpen] = useState(false) const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false) const [aiRecommendations, setAiRecommendations] = useState<{ @@ -1514,23 +1530,62 @@ export default function RoundDetailPage() { {/* Generate Assignments */} - +
- Assignment Generation + + Assignment Generation + {aiAssignmentMutation.isPending && ( + + + AI generating... + + )} + {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( + + + {aiAssignmentMutation.data.stats.assignmentsGenerated} ready + + )} + AI-suggested jury-to-project assignments based on expertise and workload
- +
+ {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( + + )} + +
@@ -1546,12 +1601,41 @@ export default function RoundDetailPage() { Add projects to this round first. )} - {juryGroup && projectCount > 0 && ( + {juryGroup && projectCount > 0 && !aiAssignmentMutation.isPending && !aiAssignmentMutation.data && (

- Click "Generate Assignments" to preview AI-suggested assignments. - You can review and execute them from the preview sheet. + Click "Generate with AI" to create assignments using GPT analysis of juror expertise, project descriptions, and documents. Or open the preview to use the algorithm instead.

)} + {aiAssignmentMutation.isPending && ( +
+
+
+
+
+

AI is analyzing projects and jurors...

+

+ Matching expertise, reviewing bios, and balancing workloads +

+
+
+ )} + {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( +
+ +
+

+ {aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated +

+

+ {aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects + {aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'} +

+
+ +
+ )} @@ -1582,6 +1666,13 @@ export default function RoundDetailPage() { open={previewSheetOpen} onOpenChange={setPreviewSheetOpen} requiredReviews={(config.requiredReviewsPerProject as number) || 3} + aiResult={aiAssignmentMutation.data ?? null} + isAIGenerating={aiAssignmentMutation.isPending} + onGenerateAI={() => aiAssignmentMutation.mutate({ + roundId, + requiredReviews: (config.requiredReviewsPerProject as number) || 3, + })} + onResetAI={() => aiAssignmentMutation.reset()} /> {/* CSV Export Dialog */} diff --git a/src/components/admin/assignment/assignment-preview-sheet.tsx b/src/components/admin/assignment/assignment-preview-sheet.tsx index d0734c6..b0f4ffc 100644 --- a/src/components/admin/assignment/assignment-preview-sheet.tsx +++ b/src/components/admin/assignment/assignment-preview-sheet.tsx @@ -1,18 +1,19 @@ 'use client' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { AlertTriangle, - Bot, CheckCircle2, ChevronDown, ChevronRight, + Cpu, Loader2, Plus, Sparkles, Tag, User, X, + Zap, } from 'lucide-react' import { toast } from 'sonner' import { trpc } from '@/lib/trpc/client' @@ -49,6 +50,8 @@ import { } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +type AssignmentMode = 'ai' | 'algorithm' + type EditableAssignment = { localId: string userId: string @@ -63,11 +66,27 @@ type EditableAssignment = { isManual: boolean } +type AIPreviewData = { + assignments: any[] + warnings: string[] + stats: { totalProjects: number; totalJurors: number; assignmentsGenerated: number; unassignedProjects: number } + fallbackUsed?: boolean + tokensUsed?: number +} + type AssignmentPreviewSheetProps = { roundId: string open: boolean onOpenChange: (open: boolean) => void requiredReviews?: number + /** AI preview result from parent (lifted mutation) */ + aiResult?: AIPreviewData | null + /** Whether AI is currently generating */ + isAIGenerating?: boolean + /** Trigger AI generation from parent */ + onGenerateAI?: () => void + /** Reset AI results */ + onResetAI?: () => void } export function AssignmentPreviewSheet({ @@ -75,25 +94,38 @@ export function AssignmentPreviewSheet({ open, onOpenChange, requiredReviews = 3, + aiResult, + isAIGenerating = false, + onGenerateAI, + onResetAI, }: AssignmentPreviewSheetProps) { const utils = trpc.useUtils() + const [mode, setMode] = useState('ai') const [assignments, setAssignments] = useState([]) const [initialized, setInitialized] = useState(false) const [expandedJurors, setExpandedJurors] = useState>(new Set()) const [addJurorId, setAddJurorId] = useState('') const [addProjectId, setAddProjectId] = useState('') + // Track staggered reveal for streaming effect + const [visibleCount, setVisibleCount] = useState(0) + const staggerTimerRef = useRef | null>(null) // ── Queries ────────────────────────────────────────────────────────────── + + // Algorithm mode query (only runs when algorithm mode is selected) const { - data: preview, - isLoading, - refetch, + data: algoPreview, + isLoading: isAlgoLoading, } = trpc.roundAssignment.preview.useQuery( { roundId, honorIntents: true, requiredReviews }, - { enabled: open }, + { enabled: open && mode === 'algorithm' }, ) + // Active preview data based on mode + const preview = mode === 'ai' ? aiResult : algoPreview + const isLoading = mode === 'ai' ? isAIGenerating : isAlgoLoading + // Fetch round data for jury group members const { data: round } = trpc.round.getById.useQuery( { id: roundId }, @@ -127,12 +159,12 @@ export function AssignmentPreviewSheet({ }, }) - // ── Initialize local state from preview ────────────────────────────────── + // ── Initialize local state from preview with staggered reveal ────────── useEffect(() => { if (preview && !initialized) { const mapped: EditableAssignment[] = preview.assignments.map( (a: any, idx: number) => ({ - localId: `ai-${idx}`, + localId: `${mode}-${idx}`, userId: a.userId, userName: a.userName, projectId: a.projectId, @@ -146,14 +178,36 @@ export function AssignmentPreviewSheet({ }), ) setAssignments(mapped) + // Auto-expand all jurors const jurorIds = new Set(mapped.map((a) => a.userId)) setExpandedJurors(jurorIds) setInitialized(true) - } - }, [preview, initialized]) - // Reset when sheet closes + // Staggered reveal: show assignments one by one + setVisibleCount(0) + const totalGroups = new Set(mapped.map((a) => a.userId)).size + let count = 0 + const reveal = () => { + count++ + setVisibleCount(count) + if (count < totalGroups) { + staggerTimerRef.current = setTimeout(reveal, 150) + } + } + // Start after a small delay for visual effect + staggerTimerRef.current = setTimeout(reveal, 100) + } + }, [preview, initialized, mode]) + + // Cleanup stagger timer + useEffect(() => { + return () => { + if (staggerTimerRef.current) clearTimeout(staggerTimerRef.current) + } + }, []) + + // Reset when sheet closes (but preserve AI results in parent) useEffect(() => { if (!open) { setInitialized(false) @@ -161,9 +215,21 @@ export function AssignmentPreviewSheet({ setExpandedJurors(new Set()) setAddJurorId('') setAddProjectId('') + setVisibleCount(0) + if (staggerTimerRef.current) clearTimeout(staggerTimerRef.current) } }, [open]) + // Reset assignments when mode changes + const handleModeChange = (newMode: AssignmentMode) => { + if (newMode === mode) return + setMode(newMode) + setInitialized(false) + setAssignments([]) + setExpandedJurors(new Set()) + setVisibleCount(0) + } + // ── Derived data ───────────────────────────────────────────────────────── const juryMembers = useMemo(() => { if (!round?.juryGroup?.members) return [] @@ -271,8 +337,9 @@ export function AssignmentPreviewSheet({ }, ]) - // Expand the juror's section + // Expand the juror's section and make visible setExpandedJurors((prev) => new Set([...prev, addJurorId])) + setVisibleCount((prev) => Math.max(prev, groupedByJuror.length + 1)) setAddProjectId('') } @@ -329,25 +396,139 @@ export function AssignmentPreviewSheet({ Assignment Preview - - - - AI Suggested - - Review and fine-tune before executing. + + Review and fine-tune assignments before executing. + + {/* Mode toggle */} +
+ + +
+ {/* AI mode: show generate button if no results yet */} + {mode === 'ai' && !aiResult && !isAIGenerating && ( + + +
+ +
+
+

AI-Powered Assignments

+

+ Uses GPT to analyze juror expertise, bios, project descriptions, + and documents to generate optimal assignments. +

+
+ +
+
+ )} + + {/* Loading state */} {isLoading ? (
+ {mode === 'ai' && ( + + +
+
+
+
+

Generating AI assignments...

+

+ Analyzing juror expertise and project data with GPT +

+
+ + + )} {[1, 2, 3, 4].map((i) => ( ))}
) : preview ? ( <> + {/* AI metadata */} + {mode === 'ai' && aiResult && ( +
+ + + AI Generated + + {aiResult.fallbackUsed && ( + + + Fallback algorithm used + + )} + {(aiResult.tokensUsed ?? 0) > 0 && ( + + {aiResult.tokensUsed?.toLocaleString()} tokens used + + )} + +
+ )} + + {mode === 'algorithm' && ( +
+ + + Algorithm + + + Tag overlap + bio match + workload balancing + +
+ )} + {/* ── Summary stats ── */}
{[ @@ -402,7 +583,7 @@ export function AssignmentPreviewSheet({ )} - {/* ── Juror groups ── */} + {/* ── Juror groups with staggered reveal ── */}

Assignments by Juror @@ -414,19 +595,28 @@ export function AssignmentPreviewSheet({ ) : ( - groupedByJuror.map((group) => ( - ( +
toggleJuror(group.userId)} - onRemove={removeAssignment} - onAddProject={(projectId) => - addProjectToJuror(group.userId, projectId) - } - availableProjects={getAvailableProjectsForJuror(group.userId)} - requiredReviews={requiredReviews} - /> + className={cn( + 'transition-all duration-300', + index < visibleCount + ? 'opacity-100 translate-y-0' + : 'opacity-0 translate-y-2 pointer-events-none h-0 overflow-hidden', + )} + > + toggleJuror(group.userId)} + onRemove={removeAssignment} + onAddProject={(projectId) => + addProjectToJuror(group.userId, projectId) + } + availableProjects={getAvailableProjectsForJuror(group.userId)} + requiredReviews={requiredReviews} + /> +
)) )}

@@ -504,11 +694,11 @@ export function AssignmentPreviewSheet({ - ) : ( + ) : mode === 'algorithm' ? (

No preview data available

- )} + ) : null}
@@ -561,7 +751,6 @@ function JurorGroup({ onRemove, onAddProject, availableProjects, - requiredReviews, }: JurorGroupProps) { const [inlineProjectId, setInlineProjectId] = useState('') @@ -571,8 +760,6 @@ function JurorGroup({ setInlineProjectId('') } - const aiCount = group.assignments.filter((a) => !a.isManual).length - const manualCount = group.assignments.filter((a) => a.isManual).length const avgScore = group.assignments.length > 0 ? Math.round( diff --git a/src/server/routers/roundAssignment.ts b/src/server/routers/roundAssignment.ts index be0dddf..6286e27 100644 --- a/src/server/routers/roundAssignment.ts +++ b/src/server/routers/roundAssignment.ts @@ -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 | 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( diff --git a/src/server/services/ai-assignment.ts b/src/server/services/ai-assignment.ts index 17e162b..aa39e4f 100644 --- a/src/server/services/ai-assignment.ts +++ b/src/server/services/ai-assignment.ts @@ -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 } diff --git a/src/server/services/anonymization.ts b/src/server/services/anonymization.ts index 5228418..60a96be 100644 --- a/src/server/services/anonymization.ts +++ b/src/server/services/anonymization.ts @@ -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, } } ) diff --git a/src/server/services/round-assignment.ts b/src/server/services/round-assignment.ts index af06889..d094341 100644 --- a/src/server/services/round-assignment.ts +++ b/src/server/services/round-assignment.ts @@ -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() + + 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(