Compare commits

...

4 Commits

Author SHA1 Message Date
Matt
771f35c695 Retroactive auto-PASS for projects with complete documents
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Wire batchCheckRequirementsAndTransition into round activation and reopen
so pre-existing projects that already have all required docs get auto-
passed. Also adds checkDocumentCompletion endpoint for manual sweeps on
already-active rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:29:57 +01:00
Matt
fbeec846a3 Pass tag confidence scores to AI assignment for weighted matching
The AI assignment path was receiving project tags as flat strings, losing
the confidence scores from AI tagging. Now both the GPT path and the
fallback algorithm weight tag matches by confidence — a 0.9 tag matters
more than a 0.5 one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:29:46 +01:00
Matt
cfeef9a601 Add auto-pass & advance for intake rounds (no manual marking needed)
For INTAKE, SUBMISSION, and MENTORING rounds, the Advance Projects dialog
now shows a simplified "Advance All" flow that auto-passes all pending
projects and advances them in one click. Backend accepts autoPassPending
flag to bulk-set PENDING→PASSED before advancing. Jury/evaluation rounds
keep the existing per-project selection workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:17:16 +01:00
Matt
fcee8761b9 Hide jury stat card in header for non-jury rounds (INTAKE, FILTERING, etc.)
The jury selector card in the stats bar was still visible on round types
where juries don't apply. Now conditionally rendered based on hasJury,
with the grid adjusting from 4 to 3 columns accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:17:15 +01:00
7 changed files with 271 additions and 95 deletions

View File

@@ -326,7 +326,10 @@ export default function RoundDetailPage() {
onSuccess: (data) => { onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId }) utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`) const msg = data.autoPassedCount
? `Passed ${data.autoPassedCount} and advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
: `Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
toast.success(msg)
setAdvanceDialogOpen(false) setAdvanceDialogOpen(false)
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
@@ -378,6 +381,7 @@ export default function RoundDetailPage() {
const isEvaluation = round?.roundType === 'EVALUATION' const isEvaluation = round?.roundType === 'EVALUATION'
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '') const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = hasJury const hasAwards = hasJury
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
@@ -582,7 +586,7 @@ export default function RoundDetailPage() {
</motion.div> </motion.div>
{/* ===== STATS BAR — Accent-bordered cards ===== */} {/* ===== STATS BAR — Accent-bordered cards ===== */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4"> <div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
{/* Projects */} {/* Projects */}
<AnimatedCard index={0}> <AnimatedCard index={0}>
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow"> <Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
@@ -605,56 +609,58 @@ export default function RoundDetailPage() {
</Card> </Card>
</AnimatedCard> </AnimatedCard>
{/* Jury (with inline group selector) */} {/* Jury (with inline group selector) — only for jury-relevant rounds */}
<AnimatedCard index={1}> {hasJury && (
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow"> <AnimatedCard index={1}>
<CardContent className="pt-4 pb-3"> <Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<div className="flex items-center gap-2.5 mb-1" data-jury-select> <CardContent className="pt-4 pb-3">
<div className="rounded-full bg-purple-50 p-1.5"> <div className="flex items-center gap-2.5 mb-1" data-jury-select>
<Users className="h-4 w-4 text-purple-500" /> <div className="rounded-full bg-purple-50 p-1.5">
<Users className="h-4 w-4 text-purple-500" />
</div>
<span className="text-sm font-medium text-muted-foreground">Jury</span>
</div> </div>
<span className="text-sm font-medium text-muted-foreground">Jury</span> {juryGroups && juryGroups.length > 0 ? (
</div> <Select
{juryGroups && juryGroups.length > 0 ? ( value={round.juryGroupId ?? '__none__'}
<Select onValueChange={(value) => {
value={round.juryGroupId ?? '__none__'} assignJuryMutation.mutate({
onValueChange={(value) => { id: roundId,
assignJuryMutation.mutate({ juryGroupId: value === '__none__' ? null : value,
id: roundId, })
juryGroupId: value === '__none__' ? null : value, }}
}) disabled={assignJuryMutation.isPending}
}} >
disabled={assignJuryMutation.isPending} <SelectTrigger className="h-8 text-xs mt-1">
> <SelectValue placeholder="Select jury group..." />
<SelectTrigger className="h-8 text-xs mt-1"> </SelectTrigger>
<SelectValue placeholder="Select jury group..." /> <SelectContent>
</SelectTrigger> <SelectItem value="__none__">No jury assigned</SelectItem>
<SelectContent> {juryGroups.map((jg: any) => (
<SelectItem value="__none__">No jury assigned</SelectItem> <SelectItem key={jg.id} value={jg.id}>
{juryGroups.map((jg: any) => ( {jg.name} ({jg._count?.members ?? 0} members)
<SelectItem key={jg.id} value={jg.id}> </SelectItem>
{jg.name} ({jg._count?.members ?? 0} members) ))}
</SelectItem> </SelectContent>
))} </Select>
</SelectContent> ) : juryGroup ? (
</Select> <>
) : juryGroup ? ( <p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
<> <p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p> </>
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p> ) : (
</> <>
) : ( <p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<> <p className="text-xs text-muted-foreground">No jury groups yet</p>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p> </>
<p className="text-xs text-muted-foreground">No jury groups yet</p> )}
</> </CardContent>
)} </Card>
</CardContent> </AnimatedCard>
</Card> )}
</AnimatedCard>
{/* Window */} {/* Window */}
<AnimatedCard index={2}> <AnimatedCard index={hasJury ? 2 : 1}>
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow"> <Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3"> <CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -687,7 +693,7 @@ export default function RoundDetailPage() {
</AnimatedCard> </AnimatedCard>
{/* Advancement */} {/* Advancement */}
<AnimatedCard index={3}> <AnimatedCard index={hasJury ? 3 : 2}>
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow"> <Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3"> <CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -967,28 +973,28 @@ export default function RoundDetailPage() {
{/* Advance projects (always visible when projects exist) */} {/* Advance projects (always visible when projects exist) */}
{projectCount > 0 && ( {projectCount > 0 && (
<button <button
onClick={() => passedCount > 0 onClick={() => (isSimpleAdvance || passedCount > 0)
? setAdvanceDialogOpen(true) ? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')} : toast.info('Mark projects as "Passed" first in the Projects tab')}
className={cn( className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left', 'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
passedCount > 0 (isSimpleAdvance || passedCount > 0)
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30' ? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60', : 'border-dashed opacity-60',
)} )}
> >
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', passedCount > 0 ? 'text-emerald-600' : 'text-muted-foreground')} /> <ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<div> <div>
<p className="text-sm font-medium">Advance Projects</p> <p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{passedCount > 0 {isSimpleAdvance
? `Move ${passedCount} passed project(s) to the next round` ? `Advance all ${projectCount} project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'} : passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p> </p>
</div> </div>
{passedCount > 0 && ( <Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
)}
</button> </button>
)} )}
@@ -1096,6 +1102,7 @@ export default function RoundDetailPage() {
open={advanceDialogOpen} open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen} onOpenChange={setAdvanceDialogOpen}
roundId={roundId} roundId={roundId}
roundType={round?.roundType}
projectStates={projectStates} projectStates={projectStates}
config={config} config={config}
advanceMutation={advanceMutation} advanceMutation={advanceMutation}
@@ -2500,6 +2507,7 @@ function AdvanceProjectsDialog({
open, open,
onOpenChange, onOpenChange,
roundId, roundId,
roundType,
projectStates, projectStates,
config, config,
advanceMutation, advanceMutation,
@@ -2509,12 +2517,15 @@ function AdvanceProjectsDialog({
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
roundId: string roundId: string
roundType?: string
projectStates: any[] | undefined projectStates: any[] | undefined
config: Record<string, unknown> config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string }) => void; isPending: boolean } advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }> competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
currentSortOrder?: number currentSortOrder?: number
}) { }) {
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
// Target round selector // Target round selector
const availableTargets = useMemo(() => const availableTargets = useMemo(() =>
(competitionRounds ?? []) (competitionRounds ?? [])
@@ -2528,9 +2539,11 @@ function AdvanceProjectsDialog({
if (open && !targetRoundId && availableTargets.length > 0) { if (open && !targetRoundId && availableTargets.length > 0) {
setTargetRoundId(availableTargets[0].id) setTargetRoundId(availableTargets[0].id)
} }
const allProjects = projectStates ?? []
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
const passedProjects = useMemo(() => const passedProjects = useMemo(() =>
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'), allProjects.filter((ps: any) => ps.state === 'PASSED'),
[projectStates]) [allProjects])
const startups = useMemo(() => const startups = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'), passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
@@ -2583,14 +2596,23 @@ function AdvanceProjectsDialog({
}) })
} }
const handleAdvance = () => { const handleAdvance = (autoPass?: boolean) => {
const ids = Array.from(selected) if (autoPass) {
if (ids.length === 0) return // Auto-pass all pending then advance all
advanceMutation.mutate({ advanceMutation.mutate({
roundId, roundId,
projectIds: ids, autoPassPending: true,
...(targetRoundId ? { targetRoundId } : {}), ...(targetRoundId ? { targetRoundId } : {}),
}) })
} else {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({
roundId,
projectIds: ids,
...(targetRoundId ? { targetRoundId } : {}),
})
}
onOpenChange(false) onOpenChange(false)
setSelected(new Set()) setSelected(new Set())
setTargetRoundId('') setTargetRoundId('')
@@ -2655,14 +2677,18 @@ function AdvanceProjectsDialog({
) )
} }
const totalProjectCount = allProjects.length
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col"> <DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>Advance Projects</DialogTitle> <DialogTitle>Advance Projects</DialogTitle>
<DialogDescription> <DialogDescription>
Select which passed projects to advance. {isSimpleAdvance
{selected.size} of {passedProjects.length} selected. ? `Move all ${totalProjectCount} projects to the next round.`
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -2690,21 +2716,50 @@ function AdvanceProjectsDialog({
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto space-y-4 py-2"> {isSimpleAdvance ? (
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')} /* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')} <div className="py-4 space-y-3">
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')} <div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
</div> <p className="text-3xl font-bold">{totalProjectCount}</p>
<p className="text-sm text-muted-foreground">projects will be advanced</p>
</div>
{pendingCount > 0 && (
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
</p>
</div>
)}
</div>
) : (
/* Detailed mode for jury/evaluation rounds — per-project selection */
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
</div>
)}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancel</Button> <Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button {isSimpleAdvance ? (
onClick={handleAdvance} <Button
disabled={selected.size === 0 || advanceMutation.isPending} onClick={() => handleAdvance(true)}
> disabled={totalProjectCount === 0 || advanceMutation.isPending}
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />} >
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''} {advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
</Button> Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
</Button>
) : (
<Button
onClick={() => handleAdvance()}
disabled={selected.size === 0 || advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
</Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -74,10 +74,22 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
description: true, description: true,
tags: true, tags: true,
teamName: true, teamName: true,
projectTags: {
select: { tag: { select: { name: true } }, confidence: true },
},
_count: { select: { assignments: { where: { roundId } } } }, _count: { select: { assignments: { where: { roundId } } } },
}, },
}) })
// Enrich projects with tag confidence data for AI matching
const projectsWithConfidence = projects.map((p) => ({
...p,
tagConfidences: p.projectTags.map((pt) => ({
name: pt.tag.name,
confidence: pt.confidence,
})),
}))
const existingAssignments = await prisma.assignment.findMany({ const existingAssignments = await prisma.assignment.findMany({
where: { roundId }, where: { roundId },
select: { userId: true, projectId: true }, select: { userId: true, projectId: true },
@@ -124,7 +136,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
const result = await generateAIAssignments( const result = await generateAIAssignments(
jurors, jurors,
projects, projectsWithConfidence,
constraints, constraints,
userId, userId,
roundId, roundId,

View File

@@ -243,10 +243,11 @@ export const roundRouter = router({
roundId: z.string(), roundId: z.string(),
targetRoundId: z.string().optional(), targetRoundId: z.string().optional(),
projectIds: z.array(z.string()).optional(), projectIds: z.array(z.string()).optional(),
autoPassPending: z.boolean().optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, projectIds } = input const { roundId, targetRoundId, projectIds, autoPassPending } = input
// Get current round with competition context // Get current round with competition context
const currentRound = await ctx.prisma.round.findUniqueOrThrow({ const currentRound = await ctx.prisma.round.findUniqueOrThrow({
@@ -280,6 +281,16 @@ export const roundRouter = router({
targetRound = nextRound targetRound = nextRound
} }
// Auto-pass all PENDING projects first (for intake/bulk workflows)
let autoPassedCount = 0
if (autoPassPending) {
const result = await ctx.prisma.projectRoundState.updateMany({
where: { roundId, state: 'PENDING' },
data: { state: 'PASSED' },
})
autoPassedCount = result.count
}
// Determine which projects to advance // Determine which projects to advance
let idsToAdvance: string[] let idsToAdvance: string[]
if (projectIds && projectIds.length > 0) { if (projectIds && projectIds.length > 0) {
@@ -346,6 +357,7 @@ export const roundRouter = router({
toRound: targetRound.name, toRound: targetRound.name,
targetRoundId: targetRound.id, targetRoundId: targetRound.id,
projectCount: idsToAdvance.length, projectCount: idsToAdvance.length,
autoPassedCount,
projectIds: idsToAdvance, projectIds: idsToAdvance,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
@@ -354,6 +366,7 @@ export const roundRouter = router({
return { return {
advancedCount: idsToAdvance.length, advancedCount: idsToAdvance.length,
autoPassedCount,
targetRoundId: targetRound.id, targetRoundId: targetRound.id,
targetRoundName: targetRound.name, targetRoundName: targetRound.name,
} }

View File

@@ -263,4 +263,41 @@ export const roundEngineRouter = router({
return { success: true, removedCount: deleted.count } return { success: true, removedCount: deleted.count }
}), }),
/**
* Retroactive document check: auto-PASS any PENDING/IN_PROGRESS projects
* that already have all required documents uploaded for this round.
* Useful for rounds activated before the auto-transition feature was deployed.
*/
checkDocumentCompletion: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { batchCheckRequirementsAndTransition } = await import('../services/round-engine')
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: {
roundId: input.roundId,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
select: { projectId: true },
})
if (projectStates.length === 0) {
return { transitionedCount: 0, checkedCount: 0, projectIds: [] }
}
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(
input.roundId,
projectIds,
ctx.user.id,
ctx.prisma,
)
return {
transitionedCount: result.transitionedCount,
checkedCount: projectIds.length,
projectIds: result.projectIds,
}
}),
}) })

View File

@@ -38,7 +38,7 @@ const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer fo
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements. Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
## Matching Criteria (Weighted) ## Matching Criteria (Weighted)
- Expertise Match (50%): How well juror tags/expertise align with project topics - 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 - Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count - Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
@@ -99,6 +99,7 @@ interface ProjectForAssignment {
title: string title: string
description?: string | null description?: string | null
tags: string[] tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null teamName?: string | null
_count?: { _count?: {
assignments: number assignments: number
@@ -539,7 +540,7 @@ export function generateFallbackAssignments(
return { return {
juror, juror,
score: calculateExpertiseScore(juror.expertiseTags, project.tags), score: calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences),
loadScore: calculateLoadScore(currentLoad, maxLoad), loadScore: calculateLoadScore(currentLoad, maxLoad),
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget), underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
} }
@@ -586,24 +587,44 @@ export function generateFallbackAssignments(
/** /**
* Calculate expertise match score based on tag overlap * Calculate expertise match score based on tag overlap
* When tagConfidences are available, weights matches by confidence
*/ */
function calculateExpertiseScore( function calculateExpertiseScore(
jurorTags: string[], jurorTags: string[],
projectTags: string[] projectTags: string[],
tagConfidences?: Array<{ name: string; confidence: number }>
): number { ): number {
if (jurorTags.length === 0 || projectTags.length === 0) { if (jurorTags.length === 0 || projectTags.length === 0) {
return 0.5 // Neutral score if no tags return 0.5 // Neutral score if no tags
} }
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase())) const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
// If we have confidence data, use weighted scoring
if (tagConfidences && tagConfidences.length > 0) {
let weightedMatches = 0
let totalWeight = 0
for (const tc of tagConfidences) {
totalWeight += tc.confidence
if (jurorTagsLower.has(tc.name.toLowerCase())) {
weightedMatches += tc.confidence
}
}
if (totalWeight === 0) return 0.5
const weightedRatio = weightedMatches / totalWeight
const hasExpertise = weightedMatches > 0 ? 0.2 : 0
return Math.min(1, weightedRatio * 0.8 + hasExpertise)
}
// Fallback: unweighted matching using flat tags
const matchingTags = projectTags.filter((t) => const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase()) jurorTagsLower.has(t.toLowerCase())
) )
// Score based on percentage of project tags matched
const matchRatio = matchingTags.length / projectTags.length const matchRatio = matchingTags.length / projectTags.length
// Boost for having expertise, even if not all match
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0 const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
return Math.min(1, matchRatio * 0.8 + hasExpertise) return Math.min(1, matchRatio * 0.8 + hasExpertise)

View File

@@ -52,7 +52,7 @@ export interface AnonymizedProject {
anonymousId: string anonymousId: string
title: string title: string
description: string | null description: string | null
tags: string[] tags: Array<{ name: string; confidence: number }>
teamName: string | null teamName: string | null
} }
@@ -209,6 +209,7 @@ interface ProjectInput {
title: string title: string
description?: string | null description?: string | null
tags: string[] tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null teamName?: string | null
} }
@@ -253,7 +254,9 @@ export function anonymizeForAI(
description: project.description description: project.description
? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT) ? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT)
: null, : null,
tags: project.tags, tags: project.tagConfidences && project.tagConfidences.length > 0
? project.tagConfidences
: project.tags.map((t) => ({ name: t, confidence: 1.0 })),
teamName: project.teamName ? `Team ${index + 1}` : null, teamName: project.teamName ? `Team ${index + 1}` : null,
} }
} }
@@ -524,7 +527,7 @@ export function validateAnonymization(data: AnonymizationResult): boolean {
if (!checkText(project.title)) return false if (!checkText(project.title)) return false
if (!checkText(project.description)) return false if (!checkText(project.description)) return false
for (const tag of project.tags) { for (const tag of project.tags) {
if (!checkText(tag)) return false if (!checkText(typeof tag === 'string' ? tag : tag.name)) return false
} }
} }

View File

@@ -143,6 +143,24 @@ export async function activateRound(
detailsJson: { name: round.name, roundType: round.roundType }, detailsJson: { name: round.name, roundType: round.roundType },
}) })
// Retroactive check: auto-PASS any projects that already have all required docs uploaded
// Non-fatal — runs after activation so it never blocks the transition
try {
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
select: { projectId: true },
})
if (projectStates.length > 0) {
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
if (result.transitionedCount > 0) {
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
}
return { return {
success: true, success: true,
round: { id: updated.id, status: updated.status }, round: { id: updated.id, status: updated.status },
@@ -429,6 +447,23 @@ export async function reopenRound(
}, },
}) })
// Retroactive check: auto-PASS any projects that already have all required docs
try {
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
select: { projectId: true },
})
if (projectStates.length > 0) {
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
if (batchResult.transitionedCount > 0) {
console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError)
}
return { return {
success: true, success: true,
round: { id: result.updated.id, status: result.updated.status }, round: { id: result.updated.id, status: result.updated.status },