AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow

- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-16 10:09:52 +01:00
parent 93f4ad4b31
commit 80c9e35971
21 changed files with 1886 additions and 1381 deletions

228
package-lock.json generated
View File

@ -62,6 +62,7 @@
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"pdf-parse": "^2.4.5",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
@ -83,6 +84,7 @@
"@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15",
"@types/pdf-parse": "^1.1.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"eslint": "^9.17.0",
@ -1629,6 +1631,190 @@
"react": ">=16.8.0"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -4589,6 +4775,16 @@
"@types/node": "*"
}
},
"node_modules/@types/pdf-parse": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@ -10826,6 +11022,38 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",

View File

@ -75,6 +75,7 @@
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"pdf-parse": "^2.4.5",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
@ -96,6 +97,7 @@
"@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15",
"@types/pdf-parse": "^1.1.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"eslint": "^9.17.0",

View File

@ -366,7 +366,7 @@ export default function CompetitionDetailPage() {
return (
<Link
key={round.id}
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
href={`/admin/rounds/${round.id}` as Route}
>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="pt-4 pb-3 space-y-3">
@ -510,9 +510,6 @@ export default function CompetitionDetailPage() {
<div>
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
<div className="flex flex-wrap gap-2 mt-1">
{competition.notifyOnRoundAdvance && (
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
)}
{competition.notifyOnDeadlineApproach && (
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
)}

View File

@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@ -473,7 +474,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
<div>
<p className="text-sm font-medium">Rounds</p>
<p className="text-xs text-muted-foreground">Manage competition rounds</p>
<p className="text-xs text-muted-foreground">Manage rounds</p>
</div>
</Link>
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
@ -513,7 +514,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
Rounds
</CardTitle>
<CardDescription>
Competition rounds in {edition.name}
Active rounds in {edition.name}
</CardDescription>
</div>
<Link
@ -541,8 +542,9 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
) : (
<div className="space-y-3">
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
<div
<Link
key={round.id}
href={`/admin/rounds/${round.id}` as Route}
className="block"
>
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
@ -569,7 +571,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
)}
</div>
</div>
</Link>
))}
</div>
)}

View File

@ -60,7 +60,7 @@ type UploadState = {
type UploadMap = Record<string, UploadState>
export default function BulkUploadPage() {
const [windowId, setWindowId] = useState('')
const [roundId, setRoundId] = useState('')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
@ -96,20 +96,20 @@ export default function BulkUploadPage() {
}, [])
// Queries
const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
const { data: rounds, isLoading: roundsLoading } = trpc.file.listRoundsForBulkUpload.useQuery()
const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
const { data, isLoading, refetch } = trpc.file.listProjectsByRoundRequirements.useQuery(
{
submissionWindowId: windowId,
roundId,
search: debouncedSearch || undefined,
status: statusFilter,
page,
pageSize: perPage,
},
{ enabled: !!windowId }
{ enabled: !!roundId }
)
const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
// Upload a single file for a project requirement
const uploadFileForRequirement = useCallback(
@ -117,7 +117,7 @@ export default function BulkUploadPage() {
projectId: string,
requirementId: string,
file: File,
submissionWindowId: string
targetRoundId: string
) => {
const key = `${projectId}:${requirementId}`
setUploads((prev) => ({
@ -131,8 +131,8 @@ export default function BulkUploadPage() {
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
submissionWindowId,
submissionFileRequirementId: requirementId,
roundId: targetRoundId,
requirementId,
})
// XHR upload with progress
@ -186,18 +186,18 @@ export default function BulkUploadPage() {
}
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file && windowId) {
uploadFileForRequirement(projectId, requirementId, file, windowId)
if (file && roundId) {
uploadFileForRequirement(projectId, requirementId, file, roundId)
}
}
input.click()
},
[windowId, uploadFileForRequirement]
[roundId, uploadFileForRequirement]
)
// Handle bulk row upload
const handleBulkUploadAll = useCallback(async () => {
if (!bulkProject || !windowId) return
if (!bulkProject || !roundId) return
const entries = Object.entries(bulkFiles).filter(
([, file]) => file !== null
@ -211,14 +211,14 @@ export default function BulkUploadPage() {
// Upload all in parallel
await Promise.allSettled(
entries.map(([reqId, file]) =>
uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
uploadFileForRequirement(bulkProject.id, reqId, file, roundId)
)
)
setBulkProject(null)
setBulkFiles({})
toast.success('Bulk upload complete')
}, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
}, [bulkProject, bulkFiles, roundId, uploadFileForRequirement])
const progressPercent =
data && data.totalProjects > 0
@ -242,32 +242,37 @@ export default function BulkUploadPage() {
</div>
</div>
{/* Window Selector */}
{/* Round Selector */}
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Window</CardTitle>
<CardTitle className="text-base">Round</CardTitle>
</CardHeader>
<CardContent>
{windowsLoading ? (
{roundsLoading ? (
<Skeleton className="h-10 w-full" />
) : !rounds || rounds.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
<span>No rounds have file requirements configured. Add file requirements to a round first.</span>
</div>
) : (
<Select
value={windowId}
value={roundId}
onValueChange={(v) => {
setWindowId(v)
setRoundId(v)
setPage(1)
setUploads({})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a submission window..." />
<SelectValue placeholder="Select a round..." />
</SelectTrigger>
<SelectContent>
{windows?.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.competition.program.name} {w.competition.program.year} &mdash; {w.name}{' '}
({w.fileRequirements.length} requirement
{w.fileRequirements.length !== 1 ? 's' : ''})
{rounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.competition.program.name} {r.competition.program.year} &mdash; {r.name}{' '}
({r.fileRequirements.length} requirement
{r.fileRequirements.length !== 1 ? 's' : ''})
</SelectItem>
))}
</SelectContent>
@ -276,8 +281,8 @@ export default function BulkUploadPage() {
</CardContent>
</Card>
{/* Content (only if window selected) */}
{windowId && data && (
{/* Content (only if round selected) */}
{roundId && data && (
<>
{/* Progress Summary */}
<Card>

View File

@ -92,7 +92,7 @@ function ImportPageContent() {
Create a competition with rounds before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/competitions">View Competitions</Link>
<Link href="/admin/rounds">View Rounds</Link>
</Button>
</div>
) : (

View File

@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
@ -147,6 +148,11 @@ export default function RoundDetailPage() {
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [aiRecommendations, setAiRecommendations] = useState<{
STARTUP: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
BUSINESS_CONCEPT: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
} | null>(null)
const [shortlistDialogOpen, setShortlistDialogOpen] = useState(false)
const utils = trpc.useUtils()
@ -243,6 +249,25 @@ export default function RoundDetailPage() {
onError: (err) => toast.error(err.message),
})
const shortlistMutation = trpc.round.generateAIRecommendations.useMutation({
onSuccess: (data) => {
if (data.success) {
setAiRecommendations(data.recommendations)
toast.success(
`AI recommendations generated: ${data.recommendations.STARTUP.length} startups, ${data.recommendations.BUSINESS_CONCEPT.length} concepts` +
(data.tokensUsed ? ` (${data.tokensUsed} tokens)` : ''),
)
} else {
toast.error(data.errors?.join('; ') || 'AI shortlist failed')
}
setShortlistDialogOpen(false)
},
onError: (err) => {
toast.error(err.message)
setShortlistDialogOpen(false)
},
})
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
@ -828,6 +853,25 @@ export default function RoundDetailPage() {
</div>
</button>
{/* AI Shortlist Recommendations */}
{(isEvaluation || isFiltering) && projectCount > 0 && (
<button
onClick={() => setShortlistDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-purple-200 bg-purple-50/50"
disabled={shortlistMutation.isPending}
>
<BarChart3 className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">
{shortlistMutation.isPending ? 'Generating...' : 'AI Recommendations'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Generate ranked shortlist per category using AI analysis
</p>
</div>
</button>
)}
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
<button
@ -847,29 +891,62 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
{/* Advance Projects Confirmation Dialog */}
<AlertDialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
{/* Advance Projects Dialog */}
<AdvanceProjectsDialog
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
/>
{/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Advance {passedCount} project(s)?</AlertDialogTitle>
<AlertDialogTitle>Generate AI Recommendations?</AlertDialogTitle>
<AlertDialogDescription>
All projects with PASSED status in this round will be moved to the next round.
This action creates new entries in the next round and marks current entries as completed.
The AI will analyze all project evaluations and generate a ranked shortlist
for each category independently.
{config.startupAdvanceCount ? (
<span className="block mt-1">
Startup target: top {String(config.startupAdvanceCount)}
</span>
) : null}
{config.conceptAdvanceCount ? (
<span className="block">
Business Concept target: top {String(config.conceptAdvanceCount)}
</span>
) : null}
{config.aiParseFiles ? (
<span className="block mt-1 text-amber-600">
Document parsing is enabled the AI will read uploaded file contents.
</span>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => advanceMutation.mutate({ roundId })}
disabled={advanceMutation.isPending}
onClick={() => shortlistMutation.mutate({ roundId })}
disabled={shortlistMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance Projects
{shortlistMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Generate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* AI Recommendations Display */}
{aiRecommendations && (
<AIRecommendationsDisplay
recommendations={aiRecommendations}
onClear={() => setAiRecommendations(null)}
/>
)}
{/* Round Info + Project Breakdown */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
@ -1054,7 +1131,7 @@ export default function RoundDetailPage() {
<CardTitle className="text-base">General Settings</CardTitle>
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
@ -1072,6 +1149,85 @@ export default function RoundDetailPage() {
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
</Label>
<p className="text-xs text-muted-foreground">
Send an email to project applicants when their project advances from this round to the next
</p>
</div>
<Switch
id="notify-on-advance"
checked={!!config.notifyOnAdvance}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnAdvance: checked })
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="ai-parse-files" className="text-sm font-medium">
AI document parsing
</Label>
<p className="text-xs text-muted-foreground">
Allow AI to read the contents of uploaded files (PDF/text) for deeper analysis during filtering and evaluation
</p>
</div>
<Switch
id="ai-parse-files"
checked={!!config.aiParseFiles}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, aiParseFiles: checked })
}}
/>
</div>
<div className="border-t pt-4">
<Label className="text-sm font-medium">Advancement Targets</Label>
<p className="text-xs text-muted-foreground mb-3">
Target number of projects per category to advance from this round
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
Startup Projects
</Label>
<Input
id="startup-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.startupAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, startupAdvanceCount: val })
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
Concept Projects
</Label>
<Input
id="concept-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.conceptAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, conceptAdvanceCount: val })
}}
/>
</div>
</div>
</div>
</CardContent>
</Card>
@ -1585,6 +1741,304 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
// ── Evaluation Criteria Editor ───────────────────────────────────────────
// ── Advance Projects Dialog ─────────────────────────────────────────────
function AdvanceProjectsDialog({
open,
onOpenChange,
roundId,
projectStates,
config,
advanceMutation,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
projectStates: any[] | undefined
config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
}) {
const passedProjects = useMemo(() =>
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
[projectStates])
const startups = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
[passedProjects])
const concepts = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
[passedProjects])
const other = useMemo(() =>
passedProjects.filter((ps: any) =>
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
),
[passedProjects])
const startupCap = (config.startupAdvanceCount as number) || 0
const conceptCap = (config.conceptAdvanceCount as number) || 0
const [selected, setSelected] = useState<Set<string>>(new Set())
// Reset selection when dialog opens
if (open && selected.size === 0 && passedProjects.length > 0) {
const initial = new Set<string>()
// Auto-select all (or up to cap if configured)
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
for (const ps of startupSlice) initial.add(ps.project?.id)
for (const ps of conceptSlice) initial.add(ps.project?.id)
for (const ps of other) initial.add(ps.project?.id)
setSelected(initial)
}
const toggleProject = (projectId: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(projectId)) next.delete(projectId)
else next.add(projectId)
return next
})
}
const toggleAll = (projects: any[], on: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
for (const ps of projects) {
if (on) next.add(ps.project?.id)
else next.delete(ps.project?.id)
}
return next
})
}
const handleAdvance = () => {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({ roundId, projectIds: ids })
onOpenChange(false)
setSelected(new Set())
}
const handleClose = () => {
onOpenChange(false)
setSelected(new Set())
}
const renderCategorySection = (
label: string,
projects: any[],
cap: number,
badgeColor: string,
) => {
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
const overCap = cap > 0 && selectedInCategory > cap
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
/>
<span className="text-sm font-medium">{label}</span>
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
{selectedInCategory}/{projects.length}
</Badge>
{cap > 0 && (
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
(target: {cap})
</span>
)}
</div>
</div>
{projects.length === 0 ? (
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
) : (
<div className="space-y-1 pl-7">
{projects.map((ps: any) => (
<label
key={ps.project?.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
>
<Checkbox
checked={selected.has(ps.project?.id)}
onCheckedChange={() => toggleProject(ps.project?.id)}
/>
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
{ps.project?.teamName && (
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
)}
</label>
))}
</div>
)}
</div>
)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select which passed projects to advance to the next round.
{selected.size} of {passedProjects.length} selected.
</DialogDescription>
</DialogHeader>
<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>
<Button variant="outline" onClick={handleClose}>Cancel</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>
</DialogContent>
</Dialog>
)
}
// ── AI Recommendations Display ──────────────────────────────────────────
type RecommendationItem = {
projectId: string
rank: number
score: number
category: string
strengths: string[]
concerns: string[]
recommendation: string
}
function AIRecommendationsDisplay({
recommendations,
onClear,
}: {
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
onClear: () => void
}) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
if (items.length === 0) return (
<div className="text-center py-4 text-muted-foreground text-sm">
No {label.toLowerCase()} projects evaluated
</div>
)
return (
<div className="space-y-2">
{items.map((item) => {
const isExpanded = expandedId === `${item.category}-${item.projectId}`
return (
<div
key={item.projectId}
className="border rounded-lg overflow-hidden"
>
<button
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/30 transition-colors"
>
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0',
colorClass,
)}>
{item.rank}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.projectId}</p>
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
</div>
<Badge variant="outline" className="shrink-0 text-xs font-mono">
{item.score}/100
</Badge>
<ChevronDown className={cn(
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
isExpanded && 'rotate-180',
)} />
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
<div className="pt-2">
<p className="text-xs font-medium text-emerald-700 mb-1">Strengths</p>
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
{item.strengths.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
{item.concerns.length > 0 && (
<div>
<p className="text-xs font-medium text-amber-700 mb-1">Concerns</p>
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
{item.concerns.map((c, i) => <li key={i}>{c}</li>)}
</ul>
</div>
)}
<div>
<p className="text-xs font-medium text-blue-700 mb-1">Recommendation</p>
<p className="text-xs text-muted-foreground">{item.recommendation}</p>
</div>
</div>
)}
</div>
)
})}
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
<CardDescription>
Ranked independently per category {recommendations.STARTUP.length} startups, {recommendations.BUSINESS_CONCEPT.length} concepts
</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={onClear}>
<X className="h-4 w-4 mr-1" />
Dismiss
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-6 lg:grid-cols-2">
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
Startup ({recommendations.STARTUP.length})
</h4>
{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}
</div>
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-purple-500" />
Business Concept ({recommendations.BUSINESS_CONCEPT.length})
</h4>
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
</div>
</div>
</CardContent>
</Card>
)
}
// ── Evaluation Criteria Editor ───────────────────────────────────────────
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
const [editing, setEditing] = useState(false)
const [criteria, setCriteria] = useState<Array<{

View File

@ -169,7 +169,6 @@ export default function RoundsPage() {
categoryMode: comp.categoryMode,
startupFinalistCount: comp.startupFinalistCount,
conceptFinalistCount: comp.conceptFinalistCount,
notifyOnRoundAdvance: comp.notifyOnRoundAdvance,
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
})
}
@ -492,13 +491,6 @@ function CompetitionGroup({
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
/>
</div>
<div className="flex items-center gap-3 pt-6">
<Switch
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
/>
<Label className="text-xs font-medium">Notify on Advance</Label>
</div>
<div className="flex items-center gap-3 pt-6">
<Switch
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}

View File

@ -37,10 +37,9 @@ type RoundSummary = {
}
export function CompetitionTimeline({
competitionId,
rounds,
}: {
competitionId: string
competitionId?: string
rounds: RoundSummary[]
}) {
if (rounds.length === 0) {
@ -70,7 +69,7 @@ export function CompetitionTimeline({
return (
<div key={round.id} className="flex items-start">
<Link
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
href={`/admin/rounds/${round.id}` as Route}
className="group flex flex-col items-center text-center w-32 shrink-0"
>
<div className="relative">
@ -116,7 +115,7 @@ export function CompetitionTimeline({
return (
<div key={round.id}>
<Link
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
href={`/admin/rounds/${round.id}` as Route}
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
>
<div className="flex flex-col items-center shrink-0">

View File

@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Search } from 'lucide-react'
import { Search, UserPlus, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@ -22,6 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface AddMemberDialogProps {
juryGroupId: string
@ -30,10 +31,22 @@ interface AddMemberDialogProps {
}
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
const [tab, setTab] = useState<'search' | 'invite'>('search')
// Search existing user state
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string>('')
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [maxAssignments, setMaxAssignments] = useState<string>('')
const [capMode, setCapMode] = useState<string>('')
// Invite new user state
const [inviteName, setInviteName] = useState('')
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
const [inviteCapMode, setInviteCapMode] = useState<string>('')
const [inviteExpertise, setInviteExpertise] = useState('')
const utils = trpc.useUtils()
@ -44,7 +57,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
const users = userResponse?.users || []
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
const { mutate: addMember, isPending: isAdding } = trpc.juryGroup.addMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member added successfully')
@ -56,14 +69,49 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
},
})
const { mutate: createUser, isPending: isCreating } = trpc.user.create.useMutation({
onSuccess: (newUser) => {
// Immediately add the newly created user to the jury group
addMember({
juryGroupId,
userId: newUser.id,
role: inviteRole,
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
})
// Send invitation email
sendInvitation({ userId: newUser.id, juryGroupId })
},
onError: (err) => {
toast.error(err.message)
},
})
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
onSuccess: (result) => {
toast.success(`Invitation sent to ${result.email}`)
},
onError: (err) => {
// Don't block — user was created and added, just invitation failed
toast.error(`Member added but invitation email failed: ${err.message}`)
},
})
const resetForm = () => {
setSearchQuery('')
setSelectedUserId('')
setRole('MEMBER')
setMaxAssignments('')
setCapMode('')
setInviteName('')
setInviteEmail('')
setInviteRole('MEMBER')
setInviteMaxAssignments('')
setInviteCapMode('')
setInviteExpertise('')
}
const handleSubmit = (e: React.FormEvent) => {
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedUserId) {
@ -76,92 +124,253 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
userId: selectedUserId,
role,
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
})
}
const handleInviteSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!inviteEmail.trim()) {
toast.error('Please enter an email address')
return
}
const expertiseTags = inviteExpertise
.split(',')
.map((t) => t.trim())
.filter(Boolean)
createUser({
email: inviteEmail.trim(),
name: inviteName.trim() || undefined,
role: 'JURY_MEMBER',
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
maxAssignments: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : undefined,
})
}
const isPending = isAdding || isCreating
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add Member to Jury Group</DialogTitle>
<DialogDescription>
Search for a user and assign them to this jury group
Search for an existing user or invite a new juror to the platform
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="search">Search User</Label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Search by name or email..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isSearching && (
<p className="text-sm text-muted-foreground">Searching...</p>
)}
{users && users.length > 0 && (
<div className="max-h-40 overflow-y-auto border rounded-md">
{users.map((user) => (
<button
key={user.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent ${
selectedUserId === user.id ? 'bg-accent' : ''
}`}
onClick={() => {
setSelectedUserId(user.id)
setSearchQuery(user.email)
}}
>
<div className="font-medium">{user.name || 'Unnamed User'}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</button>
))}
<Tabs value={tab} onValueChange={(v) => setTab(v as 'search' | 'invite')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="search" className="flex items-center gap-2">
<Search className="h-3.5 w-3.5" />
Search Existing
</TabsTrigger>
<TabsTrigger value="invite" className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5" />
Invite New
</TabsTrigger>
</TabsList>
{/* Search existing user tab */}
<TabsContent value="search">
<form onSubmit={handleSearchSubmit} className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="search">Search User</Label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Search by name or email..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isSearching && (
<p className="text-sm text-muted-foreground">Searching...</p>
)}
{users && users.length > 0 && (
<div className="max-h-40 overflow-y-auto border rounded-md">
{users.map((user) => (
<button
key={user.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent ${
selectedUserId === user.id ? 'bg-accent' : ''
}`}
onClick={() => {
setSelectedUserId(user.id)
setSearchQuery(user.email)
}}
>
<div className="font-medium">{user.name || 'Unnamed User'}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</button>
))}
</div>
)}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(val) => setRole(val as any)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="role">Group Role</Label>
<Select value={role} onValueChange={(val) => setRole(val as typeof role)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
<Input
id="maxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="capMode">Cap Mode</Label>
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
<SelectTrigger id="capMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DEFAULT">Group Default</SelectItem>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !selectedUserId}>
{isPending ? 'Adding...' : 'Add Member'}
</Button>
</DialogFooter>
</form>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
<Input
id="maxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !selectedUserId}>
{isAdding ? 'Adding...' : 'Add Member'}
</Button>
</DialogFooter>
</form>
</TabsContent>
{/* Invite new user tab */}
<TabsContent value="invite">
<form onSubmit={handleInviteSubmit} className="space-y-4 pt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteName">Full Name</Label>
<Input
id="inviteName"
placeholder="Jane Doe"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inviteEmail">
Email <span className="text-destructive">*</span>
</Label>
<Input
id="inviteEmail"
type="email"
placeholder="jane@example.com"
required
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteGroupRole">Group Role</Label>
<Select value={inviteRole} onValueChange={(val) => setInviteRole(val as typeof inviteRole)}>
<SelectTrigger id="inviteGroupRole">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="inviteCapMode">Cap Mode</Label>
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
<SelectTrigger id="inviteCapMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DEFAULT">Group Default</SelectItem>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="inviteMaxAssignments">Max Assignments Override (optional)</Label>
<Input
id="inviteMaxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={inviteMaxAssignments}
onChange={(e) => setInviteMaxAssignments(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
<Input
id="inviteExpertise"
placeholder="marine biology, policy, finance"
value={inviteExpertise}
onChange={(e) => setInviteExpertise(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Comma-separated tags for smart assignment matching
</p>
</div>
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
<UserPlus className="mr-1 inline h-3 w-3" />
This will create a new user account and send an invitation email to join the platform as a jury member.
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !inviteEmail.trim()}>
{isCreating || isAdding ? 'Creating & Inviting...' : 'Create & Invite'}
</Button>
</DialogFooter>
</form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)

View File

@ -36,6 +36,7 @@ interface JuryMember {
email: string
}
maxAssignmentsOverride: number | null
capModeOverride: string | null
preferredStartupRatio: number | null
}
@ -82,13 +83,14 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableHead>Email</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
<TableCell colSpan={6} className="text-center text-muted-foreground">
No members yet. Add members to get started.
</TableCell>
</TableRow>
@ -109,6 +111,15 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableCell className="hidden sm:table-cell">
{member.maxAssignmentsOverride ?? '—'}
</TableCell>
<TableCell className="hidden lg:table-cell">
{member.capModeOverride ? (
<Badge variant="outline" className="text-[10px]">
{member.capModeOverride}
</Badge>
) : (
<span className="text-muted-foreground text-xs">Group default</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"

View File

@ -159,7 +159,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
type: NotificationTypes.AI_SUGGESTIONS_READY,
title: 'AI Assignment Suggestions Ready',
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
linkUrl: `/admin/competitions/${round.competitionId}/assignments`,
linkUrl: `/admin/rounds/${roundId}`,
linkLabel: 'View Suggestions',
priority: 'high',
metadata: {

View File

@ -1206,4 +1206,285 @@ export const fileRouter = router({
orderBy: [{ competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }],
})
}),
/**
* List rounds with their file requirement counts (for bulk upload round selector)
*/
listRoundsForBulkUpload: adminProcedure
.query(async ({ ctx }) => {
return ctx.prisma.round.findMany({
where: {
fileRequirements: { some: {} },
},
select: {
id: true,
name: true,
roundType: true,
sortOrder: true,
competition: {
select: { id: true, name: true, program: { select: { name: true, year: true } } },
},
fileRequirements: {
select: { id: true },
},
},
orderBy: [
{ competition: { program: { year: 'desc' } } },
{ sortOrder: 'asc' },
],
})
}),
/**
* List projects with upload status against a round's FileRequirements (for bulk upload)
*/
listProjectsByRoundRequirements: adminProcedure
.input(
z.object({
roundId: z.string(),
search: z.string().optional(),
status: z.enum(['all', 'missing', 'complete']).default('all'),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
include: {
competition: { select: { id: true, programId: true, name: true } },
fileRequirements: { orderBy: { sortOrder: 'asc' } },
},
})
// Normalize requirements to a common shape
const requirements = round.fileRequirements.map((req) => ({
id: req.id,
label: req.name,
mimeTypes: req.acceptedMimeTypes,
required: req.isRequired,
maxSizeMb: req.maxSizeMB,
description: req.description,
}))
// Build project filter
const projectWhere: Record<string, unknown> = {
programId: round.competition.programId,
}
if (input.search) {
projectWhere.OR = [
{ title: { contains: input.search, mode: 'insensitive' } },
{ teamName: { contains: input.search, mode: 'insensitive' } },
]
}
const allProjects = await ctx.prisma.project.findMany({
where: projectWhere,
select: {
id: true,
title: true,
teamName: true,
submittedByUserId: true,
submittedBy: { select: { id: true, name: true, email: true } },
files: {
where: { roundId: input.roundId, requirementId: { not: null } },
select: {
id: true,
fileName: true,
mimeType: true,
size: true,
createdAt: true,
requirementId: true,
},
},
},
orderBy: { title: 'asc' },
})
// Map projects with their requirement status
const mapped = allProjects.map((project) => {
const reqStatus = requirements.map((req) => {
const file = project.files.find(
(f) => f.requirementId === req.id
)
return {
requirementId: req.id,
label: req.label,
mimeTypes: req.mimeTypes,
required: req.required,
file: file ?? null,
}
})
const totalRequired = reqStatus.filter((r) => r.required).length
const filledRequired = reqStatus.filter(
(r) => r.required && r.file
).length
return {
project: {
id: project.id,
title: project.title,
teamName: project.teamName,
submittedBy: project.submittedBy,
},
requirements: reqStatus,
isComplete: totalRequired > 0 ? filledRequired >= totalRequired : reqStatus.every((r) => r.file),
filledCount: reqStatus.filter((r) => r.file).length,
totalCount: reqStatus.length,
}
})
// Apply status filter
const filtered =
input.status === 'missing'
? mapped.filter((p) => !p.isComplete)
: input.status === 'complete'
? mapped.filter((p) => p.isComplete)
: mapped
// Paginate
const total = filtered.length
const totalPages = Math.ceil(total / input.pageSize)
const page = Math.min(input.page, Math.max(totalPages, 1))
const projects = filtered.slice(
(page - 1) * input.pageSize,
page * input.pageSize
)
const completeCount = mapped.filter((p) => p.isComplete).length
return {
projects,
requirements,
total,
page,
totalPages,
completeCount,
totalProjects: mapped.length,
competition: round.competition,
}
}),
/**
* Upload a file for a round's FileRequirement (admin bulk upload)
*/
adminUploadForRoundRequirement: adminProcedure
.input(
z.object({
projectId: z.string(),
fileName: z.string(),
mimeType: z.string(),
size: z.number().int().positive(),
roundId: z.string(),
requirementId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Block dangerous file extensions
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi']
const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.'))
if (dangerousExtensions.includes(ext)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `File type "${ext}" is not allowed`,
})
}
// Validate requirement exists and belongs to the round
const requirement = await ctx.prisma.fileRequirement.findFirst({
where: {
id: input.requirementId,
roundId: input.roundId,
},
})
if (!requirement) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Requirement not found for this round',
})
}
// Validate MIME type if requirement specifies allowed types
if (requirement.acceptedMimeTypes.length > 0) {
const isAllowed = requirement.acceptedMimeTypes.some((allowed) => {
if (allowed.endsWith('/*')) {
return input.mimeType.startsWith(allowed.replace('/*', '/'))
}
return input.mimeType === allowed
})
if (!isAllowed) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `File type "${input.mimeType}" is not allowed for this requirement. Accepted: ${requirement.acceptedMimeTypes.join(', ')}`,
})
}
}
// Infer fileType from mimeType
let fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' = 'OTHER'
if (input.mimeType.startsWith('video/')) fileType = 'VIDEO'
else if (input.mimeType === 'application/pdf') fileType = 'EXEC_SUMMARY'
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
fileType = 'PRESENTATION'
// Fetch project title and round name for storage path
const [project, round] = await Promise.all([
ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: { title: true },
}),
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { name: true },
}),
])
const bucket = BUCKET_NAME
const objectKey = generateObjectKey(project.title, input.fileName, round.name)
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
// Remove any existing file for this project+requirement combo (replace)
await ctx.prisma.projectFile.deleteMany({
where: {
projectId: input.projectId,
roundId: input.roundId,
requirementId: input.requirementId,
},
})
// Create file record
const file = await ctx.prisma.projectFile.create({
data: {
projectId: input.projectId,
fileType,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
bucket,
objectKey,
roundId: input.roundId,
requirementId: input.requirementId,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPLOAD_FILE',
entityType: 'ProjectFile',
entityId: file.id,
detailsJson: {
projectId: input.projectId,
fileName: input.fileName,
roundId: input.roundId,
requirementId: input.requirementId,
bulkUpload: true,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { uploadUrl, file }
}),
})

View File

@ -43,6 +43,14 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
orderBy: { priority: 'asc' },
})
// Get current round with config
const currentRound = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true, configJson: true },
})
const roundConfig = (currentRound.configJson as Record<string, unknown>) || {}
const aiParseFiles = !!roundConfig.aiParseFiles
// Get projects in this round via ProjectRoundState
const projectStates = await prisma.projectRoundState.findMany({
where: {
@ -54,13 +62,67 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
project: {
include: {
files: {
select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
pageCount: true,
objectKey: true,
roundId: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
},
},
},
},
})
const projects = projectStates.map((pss: any) => pss.project).filter(Boolean)
// Get round names for file tagging
const roundIds = new Set<string>()
for (const pss of projectStates) {
for (const f of (pss as any).project?.files || []) {
if (f.roundId) roundIds.add(f.roundId)
}
}
const roundNames = new Map<string, string>()
if (roundIds.size > 0) {
const rounds = await prisma.round.findMany({
where: { id: { in: [...roundIds] } },
select: { id: true, name: true },
})
for (const r of rounds) roundNames.set(r.id, r.name)
}
// Optionally extract file contents
let fileContents: Map<string, string> | undefined
if (aiParseFiles) {
const { extractMultipleFileContents } = await import('@/server/services/file-content-extractor')
const allFiles = projectStates.flatMap((pss: any) =>
((pss.project?.files || []) as Array<{ id: string; fileName: string; mimeType: string; objectKey: string }>)
)
const extractions = await extractMultipleFileContents(allFiles)
fileContents = new Map()
for (const e of extractions) {
if (e.content) fileContents.set(e.fileId, e.content)
}
}
// Enrich projects with round-tagged file data
const projects = projectStates.map((pss: any) => {
const project = pss.project
if (project?.files) {
project.files = project.files.map((f: any) => ({
...f,
roundName: f.roundId ? (roundNames.get(f.roundId) || 'Unknown Round') : null,
isCurrentRound: f.roundId === roundId,
textContent: fileContents?.get(f.id) || undefined,
}))
}
return project
}).filter(Boolean)
// Calculate batch info
const BATCH_SIZE = 20
@ -149,10 +211,10 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
},
})
// Get round name and competitionId for notification
// Get round name for notification
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { name: true, competitionId: true },
select: { name: true },
})
// Notify admins that filtering is complete
@ -160,7 +222,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
type: NotificationTypes.FILTERING_COMPLETE,
title: 'AI Filtering Complete',
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
linkUrl: `/admin/competitions/${round?.competitionId}/rounds/${roundId}`,
linkUrl: `/admin/rounds/${roundId}`,
linkLabel: 'View Results',
priority: 'high',
metadata: {
@ -183,16 +245,11 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
},
})
// Notify admins of failure - need to fetch round info for competitionId
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { competitionId: true },
})
await notifyAdmins({
type: NotificationTypes.FILTERING_FAILED,
title: 'AI Filtering Failed',
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
linkUrl: round?.competitionId ? `/admin/competitions/${round.competitionId}/rounds/${roundId}` : `/admin/competitions`,
linkUrl: `/admin/rounds/${roundId}`,
linkLabel: 'View Details',
priority: 'urgent',
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },

View File

@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
import { generateShortlist } from '../services/ai-shortlist'
import {
openWindow,
closeWindow,
@ -358,6 +359,74 @@ export const roundRouter = router({
}
}),
// =========================================================================
// AI Shortlist Recommendations
// =========================================================================
/**
* Generate AI-powered shortlist recommendations for a round.
* Runs independently for STARTUP and BUSINESS_CONCEPT categories.
* Uses per-round config for advancement targets and file parsing.
*/
generateAIRecommendations: adminProcedure
.input(
z.object({
roundId: z.string(),
rubric: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
id: true,
name: true,
competitionId: true,
configJson: true,
},
})
const config = (round.configJson as Record<string, unknown>) ?? {}
const startupTopN = (config.startupAdvanceCount as number) || 10
const conceptTopN = (config.conceptAdvanceCount as number) || 10
const aiParseFiles = !!config.aiParseFiles
const result = await generateShortlist(
{
roundId: input.roundId,
competitionId: round.competitionId,
startupTopN,
conceptTopN,
rubric: input.rubric,
aiParseFiles,
},
ctx.prisma,
)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'AI_SHORTLIST',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
roundName: round.name,
startupTopN,
conceptTopN,
aiParseFiles,
success: result.success,
startupCount: result.recommendations.STARTUP.length,
conceptCount: result.recommendations.BUSINESS_CONCEPT.length,
tokensUsed: result.tokensUsed,
errors: result.errors,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
}),
// =========================================================================
// Submission Window Management
// =========================================================================

View File

@ -112,7 +112,16 @@ interface ProjectForFiltering {
institution?: string | null
submissionSource?: SubmissionSource
submittedAt?: Date | null
files: Array<{ id: string; fileName: string; fileType?: FileType | null; size?: number; pageCount?: number | null }>
files: Array<{
id: string
fileName: string
fileType?: FileType | null
size?: number
pageCount?: number | null
roundName?: string | null
isCurrentRound?: boolean
textContent?: string
}>
_count?: {
teamMembers?: number
files?: number
@ -170,9 +179,10 @@ Return a JSON object with this exact structure:
- founded_year: when the company/initiative was founded (use for age checks)
- ocean_issue: the ocean conservation area
- file_count, file_types: uploaded documents summary
- files[]: per-file details with file_type, page_count (if known), and size_kb
- files[]: per-file details with file_type, page_count (if known), size_kb, round_name (which round the file was submitted for), and is_current_round flag
- description: project summary text
- tags: topic tags
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
## Guidelines
- Evaluate ONLY against the provided criteria, not your own standards

View File

@ -2,7 +2,8 @@
* AI Shortlist Service
*
* Generates ranked recommendations at end of evaluation rounds.
* Follows patterns from ai-filtering.ts and ai-evaluation-summary.ts.
* Runs SEPARATELY for each category (STARTUP / BUSINESS_CONCEPT)
* to produce independent rankings per the competition's advancement rules.
*
* GDPR Compliance:
* - All project data is anonymized before AI processing
@ -12,124 +13,43 @@
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, logAIError } from './ai-errors'
import { extractMultipleFileContents } from './file-content-extractor'
import type { PrismaClient } from '@prisma/client'
// ─── Types ──────────────────────────────────────────────────────────────────
export type ShortlistResult = {
success: boolean
recommendations: ShortlistRecommendation[]
recommendations: CategoryRecommendations
errors?: string[]
tokensUsed?: number
}
export type CategoryRecommendations = {
STARTUP: ShortlistRecommendation[]
BUSINESS_CONCEPT: ShortlistRecommendation[]
}
export type ShortlistRecommendation = {
projectId: string
rank: number
score: number
category: string
strengths: string[]
concerns: string[]
recommendation: string
}
// ─── Main Function ──────────────────────────────────────────────────────────
// ─── Prompt Building ────────────────────────────────────────────────────────
/**
* Generate an AI shortlist for projects in a round.
* Only runs if EvaluationConfig.generateAiShortlist is true.
*/
export async function generateShortlist(
params: {
roundId: string
competitionId: string
category?: string
topN?: number
rubric?: string
},
prisma: PrismaClient | any,
): Promise<ShortlistResult> {
const { roundId, competitionId, category, topN = 10, rubric } = params
function buildShortlistPrompt(category: string, topN: number, rubric?: string): string {
const categoryLabel = category === 'STARTUP' ? 'Startup' : 'Business Concept'
try {
// Load projects with evaluations
const where: Record<string, unknown> = {
assignments: { some: { roundId } },
}
if (category) {
where.competitionCategory = category
}
const projects = await prisma.project.findMany({
where,
include: {
assignments: {
where: { roundId },
include: {
evaluation: true,
},
},
projectTags: { include: { tag: true } },
files: { select: { id: true, type: true } },
teamMembers: { select: { user: { select: { name: true } } } },
},
})
if (projects.length === 0) {
return {
success: true,
recommendations: [],
errors: ['No projects found for this round'],
}
}
// Aggregate scores per project
const projectSummaries = projects.map((project: any) => {
const evaluations = project.assignments
.map((a: any) => a.evaluation)
.filter(Boolean)
.filter((e: any) => e.status === 'SUBMITTED')
const scores = evaluations.map((e: any) => e.globalScore ?? 0)
const avgScore = scores.length > 0
? scores.reduce((sum: number, s: number) => sum + s, 0) / scores.length
: 0
const feedbacks = evaluations
.map((e: any) => e.feedbackGeneral)
.filter(Boolean)
return {
id: project.id,
title: project.title,
description: project.description,
category: project.competitionCategory,
tags: project.projectTags.map((pt: any) => pt.tag.name),
avgScore,
evaluationCount: evaluations.length,
feedbackSamples: feedbacks.slice(0, 3), // Max 3 feedback samples
}
})
// Anonymize for AI
const anonymized = projectSummaries.map((p: any, index: number) => ({
anonymousId: `PROJECT_${String(index + 1).padStart(3, '0')}`,
...p,
// Strip identifying info
title: undefined,
id: undefined,
}))
// Build idMap for de-anonymization
const idMap = new Map<string, string>()
projectSummaries.forEach((p: any, index: number) => {
idMap.set(`PROJECT_${String(index + 1).padStart(3, '0')}`, p.id)
})
// Build prompt
const systemPrompt = `You are a senior jury advisor for the Monaco Ocean Protection Challenge.
return `You are a senior jury advisor for the Monaco Ocean Protection Challenge.
## Your Role
Analyze aggregated evaluation data to produce a ranked shortlist of top projects.
Analyze aggregated evaluation data to produce a ranked shortlist of the top ${topN} ${categoryLabel} projects.
You are evaluating ONLY ${categoryLabel} projects in this batch rank them against each other within this category.
## Ranking Criteria (Weighted)
- Evaluation Scores (40%): Average scores across all jury evaluations
@ -137,6 +57,12 @@ Analyze aggregated evaluation data to produce a ranked shortlist of top projects
- Feasibility (20%): Likelihood of successful implementation
- Alignment (15%): Fit with ocean protection mission and competition goals
## Document Analysis
If document content is provided (text_content field in files), use it for deeper qualitative analysis.
Pay SPECIAL ATTENTION to files marked with is_current_round=true these are the most recent submissions.
Older documents provide context, but recent ones should carry more weight in your assessment.
${rubric ? `## Custom Evaluation Rubric\n${rubric}\n` : ''}
## Output Format
Return a JSON array:
[
@ -146,129 +72,305 @@ Return a JSON array:
"score": 0-100,
"strengths": ["strength 1", "strength 2"],
"concerns": ["concern 1"],
"recommendation": "1-2 sentence recommendation",
"criterionBreakdown": {
"evaluationScores": 38,
"innovationImpact": 22,
"feasibility": 18,
"alignment": 14
}
"recommendation": "1-2 sentence recommendation"
}
]
## Guidelines
- Only include the requested number of top projects
- Only include the top ${topN} projects in your ranking
- Score should reflect weighted combination of all criteria
- Be specific in strengths and concerns avoid generic statements
- Consider feedback themes and evaluator consensus
- Higher evaluator consensus should boost confidence in ranking`
- Higher evaluator consensus should boost confidence in ranking
- Do not include any personal identifiers`
}
const userPrompt = `Analyze these anonymized project evaluations and produce a ranked shortlist of the top ${topN} projects.
// ─── Single Category Processing ─────────────────────────────────────────────
${rubric ? `Evaluation rubric:\n${rubric}\n\n` : ''}Projects:
async function generateCategoryShortlist(
params: {
roundId: string
category: string
topN: number
rubric?: string
aiParseFiles: boolean
},
prisma: PrismaClient | any,
): Promise<{ recommendations: ShortlistRecommendation[]; tokensUsed: number; errors: string[] }> {
const { roundId, category, topN, rubric, aiParseFiles } = params
// Load projects with evaluations for this category
const projects = await prisma.project.findMany({
where: {
competitionCategory: category,
assignments: { some: { roundId } },
},
include: {
assignments: {
where: { roundId },
include: { evaluation: true },
},
projectTags: { include: { tag: true } },
files: {
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
pageCount: true,
objectKey: true,
roundId: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' as const },
},
teamMembers: { select: { user: { select: { name: true } } } },
},
})
if (projects.length === 0) {
return {
recommendations: [],
tokensUsed: 0,
errors: [`No ${category} projects found for this round`],
}
}
// Get round names for file tagging
const roundIds = new Set<string>()
for (const p of projects) {
for (const f of (p as any).files || []) {
if (f.roundId) roundIds.add(f.roundId)
}
}
const roundNames = new Map<string, string>()
if (roundIds.size > 0) {
const rounds = await prisma.round.findMany({
where: { id: { in: [...roundIds] } },
select: { id: true, name: true },
})
for (const r of rounds) roundNames.set(r.id, r.name)
}
// Optionally extract file contents
let fileContents: Map<string, string> | undefined
if (aiParseFiles) {
const allFiles = projects.flatMap((p: any) =>
((p.files || []) as Array<{ id: string; fileName: string; mimeType: string; objectKey: string }>)
)
const extractions = await extractMultipleFileContents(allFiles)
fileContents = new Map()
for (const e of extractions) {
if (e.content) fileContents.set(e.fileId, e.content)
}
}
// Aggregate scores per project
const projectSummaries = projects.map((project: any) => {
const evaluations = project.assignments
.map((a: any) => a.evaluation)
.filter(Boolean)
.filter((e: any) => e.status === 'SUBMITTED')
const scores = evaluations.map((e: any) => e.globalScore ?? 0)
const avgScore = scores.length > 0
? scores.reduce((sum: number, s: number) => sum + s, 0) / scores.length
: 0
const feedbacks = evaluations
.map((e: any) => e.feedbackGeneral || e.feedbackText)
.filter(Boolean)
return {
id: project.id,
description: project.description,
category: project.competitionCategory,
tags: project.projectTags.map((pt: any) => pt.tag.name),
avgScore,
evaluationCount: evaluations.length,
feedbackSamples: feedbacks.slice(0, 3),
files: (project.files || []).map((f: any) => ({
file_type: f.fileType ?? 'OTHER',
page_count: f.pageCount ?? null,
size_kb: Math.round((f.size ?? 0) / 1024),
round_name: f.roundId ? (roundNames.get(f.roundId) || null) : null,
is_current_round: f.roundId === roundId,
...(fileContents?.get(f.id) ? { text_content: fileContents.get(f.id) } : {}),
})),
}
})
// Anonymize for AI
const anonymized = projectSummaries.map((p: any, index: number) => ({
anonymousId: `PROJECT_${String(index + 1).padStart(3, '0')}`,
...p,
id: undefined,
}))
// Build idMap for de-anonymization
const idMap = new Map<string, string>()
projectSummaries.forEach((p: any, index: number) => {
idMap.set(`PROJECT_${String(index + 1).padStart(3, '0')}`, p.id)
})
// Call AI
const openai = await getOpenAI()
const model = await getConfiguredModel()
if (!openai) {
return { recommendations: [], tokensUsed: 0, errors: ['OpenAI client not configured'] }
}
const systemPrompt = buildShortlistPrompt(category, topN, rubric)
const userPrompt = `Analyze these anonymized ${category} project evaluations and produce a ranked shortlist of the top ${topN}.
Projects (${anonymized.length} total):
${JSON.stringify(anonymized, null, 2)}
Return a JSON array following the format specified in your instructions. Only include the top ${topN} projects. Rank by overall quality considering scores and feedback.`
Return a JSON array following the format specified. Only include the top ${topN} projects. Rank by overall quality within this category.`
const openai = await getOpenAI()
const model = await getConfiguredModel()
const MAX_PARSE_RETRIES = 2
let parseAttempts = 0
let response = await openai.chat.completions.create(
buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.1,
jsonMode: true,
}),
)
if (!openai) {
return {
success: false,
recommendations: [],
errors: ['OpenAI client not configured'],
let tokenUsage = extractTokenUsage(response)
await logAIUsage({
action: 'SHORTLIST',
model,
promptTokens: tokenUsage.promptTokens,
completionTokens: tokenUsage.completionTokens,
totalTokens: tokenUsage.totalTokens,
status: 'SUCCESS',
})
// Parse response
let parsed: any[]
while (true) {
try {
const content = response.choices[0]?.message?.content
if (!content) {
return { recommendations: [], tokensUsed: tokenUsage.totalTokens, errors: ['Empty AI response'] }
}
}
const MAX_PARSE_RETRIES = 2
let parseAttempts = 0
let response = await openai.chat.completions.create(
buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.1,
jsonMode: true,
}),
)
let tokenUsage = extractTokenUsage(response)
await logAIUsage({
action: 'FILTERING',
model,
promptTokens: tokenUsage.promptTokens,
completionTokens: tokenUsage.completionTokens,
totalTokens: tokenUsage.totalTokens,
status: 'SUCCESS',
})
// Parse response with retry logic
let parsed: any[]
while (true) {
try {
const content = response.choices[0]?.message?.content
if (!content) {
return {
success: false,
recommendations: [],
errors: ['Empty AI response'],
tokensUsed: tokenUsage.totalTokens,
}
}
const json = JSON.parse(content)
parsed = Array.isArray(json) ? json : json.rankings ?? json.projects ?? json.shortlist ?? []
break
} catch (parseError) {
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
parseAttempts++
console.warn(`[AI Shortlist] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
// Retry the API call with hint
response = await openai.chat.completions.create(
buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
],
temperature: 0.1,
jsonMode: true,
}),
)
const retryUsage = extractTokenUsage(response)
tokenUsage.totalTokens += retryUsage.totalTokens
continue
}
return {
success: false,
recommendations: [],
errors: ['Failed to parse AI response as JSON'],
tokensUsed: tokenUsage.totalTokens,
}
const json = JSON.parse(content)
parsed = Array.isArray(json) ? json : json.rankings ?? json.projects ?? json.shortlist ?? []
break
} catch (parseError) {
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
parseAttempts++
response = await openai.chat.completions.create(
buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
],
temperature: 0.1,
jsonMode: true,
}),
)
const retryUsage = extractTokenUsage(response)
tokenUsage.totalTokens += retryUsage.totalTokens
continue
}
return { recommendations: [], tokensUsed: tokenUsage.totalTokens, errors: ['Failed to parse AI response'] }
}
}
// De-anonymize and build recommendations
const recommendations: ShortlistRecommendation[] = parsed
.filter((item: any) => item.anonymousId && idMap.has(item.anonymousId))
.map((item: any) => ({
projectId: idMap.get(item.anonymousId)!,
rank: item.rank ?? 0,
score: item.score ?? 0,
strengths: item.strengths ?? [],
concerns: item.concerns ?? [],
recommendation: item.recommendation ?? '',
}))
.sort((a: ShortlistRecommendation, b: ShortlistRecommendation) => a.rank - b.rank)
// De-anonymize
const recommendations: ShortlistRecommendation[] = parsed
.filter((item: any) => item.anonymousId && idMap.has(item.anonymousId))
.map((item: any) => ({
projectId: idMap.get(item.anonymousId)!,
rank: item.rank ?? 0,
score: item.score ?? 0,
category,
strengths: item.strengths ?? [],
concerns: item.concerns ?? [],
recommendation: item.recommendation ?? '',
}))
.sort((a: ShortlistRecommendation, b: ShortlistRecommendation) => a.rank - b.rank)
return { recommendations, tokensUsed: tokenUsage.totalTokens, errors: [] }
}
// ─── Main Function ──────────────────────────────────────────────────────────
/**
* Generate an AI shortlist for projects in a round, split by category.
* Runs independently for STARTUP and BUSINESS_CONCEPT.
*/
export async function generateShortlist(
params: {
roundId: string
competitionId: string
category?: string // If provided, only run for this category
topN?: number // Global fallback
startupTopN?: number // Per-category override
conceptTopN?: number // Per-category override
rubric?: string
aiParseFiles?: boolean
},
prisma: PrismaClient | any,
): Promise<ShortlistResult> {
const {
roundId,
category,
topN = 10,
startupTopN,
conceptTopN,
rubric,
aiParseFiles = false,
} = params
try {
const categories = category
? [category]
: ['STARTUP', 'BUSINESS_CONCEPT']
const allRecommendations: CategoryRecommendations = {
STARTUP: [],
BUSINESS_CONCEPT: [],
}
let totalTokens = 0
const allErrors: string[] = []
// Run each category independently
for (const cat of categories) {
const catTopN = cat === 'STARTUP'
? (startupTopN ?? topN)
: (conceptTopN ?? topN)
console.log(`[AI Shortlist] Generating top-${catTopN} for ${cat}`)
const result = await generateCategoryShortlist(
{ roundId, category: cat, topN: catTopN, rubric, aiParseFiles },
prisma,
)
if (cat === 'STARTUP') {
allRecommendations.STARTUP = result.recommendations
} else {
allRecommendations.BUSINESS_CONCEPT = result.recommendations
}
totalTokens += result.tokensUsed
allErrors.push(...result.errors)
}
return {
success: true,
recommendations,
tokensUsed: tokenUsage.totalTokens,
recommendations: allRecommendations,
tokensUsed: totalTokens,
errors: allErrors.length > 0 ? allErrors : undefined,
}
} catch (error) {
const classification = classifyAIError(error)
@ -277,7 +379,7 @@ Return a JSON array following the format specified in your instructions. Only in
return {
success: false,
recommendations: [],
recommendations: { STARTUP: [], BUSINESS_CONCEPT: [] },
errors: [error instanceof Error ? error.message : 'AI shortlist generation failed'],
}
}

View File

@ -83,6 +83,9 @@ export interface AnonymizedFileInfo {
file_type: string // FileType enum value
page_count: number | null // Number of pages if known
size_kb: number // File size in KB
round_name?: string | null // Which round the file was submitted for
is_current_round?: boolean // Whether this file belongs to the current filtering/evaluation round
text_content?: string // Extracted text content (when aiParseFiles is enabled)
}
export interface AnonymizedProjectForAI {
@ -299,10 +302,13 @@ export function anonymizeProjectForAI(
file_types: project.files
?.map((f) => f.fileType)
.filter((ft): ft is FileType => ft !== null) ?? [],
files: project.files?.map((f) => ({
files: project.files?.map((f: any) => ({
file_type: f.fileType ?? 'OTHER',
page_count: f.pageCount ?? null,
size_kb: Math.round((f.size ?? 0) / 1024),
...(f.roundName ? { round_name: f.roundName } : {}),
...(f.isCurrentRound !== undefined ? { is_current_round: f.isCurrentRound } : {}),
...(f.textContent ? { text_content: f.textContent } : {}),
})) ?? [],
wants_mentorship: project.wantsMentorship ?? false,
submission_source: project.submissionSource,

View File

@ -0,0 +1,112 @@
/**
* File Content Extractor
*
* Downloads files from storage and extracts text content for AI analysis.
* Supports PDF and plain text files. Used when round config has aiParseFiles=true.
*
* Limits:
* - Max 50KB of extracted text per file (to stay within AI token limits)
* - Only PDF and text-based files are parsed
* - Extraction failures are non-fatal (file is skipped)
*/
import { getStorageProvider } from '@/lib/storage'
const MAX_TEXT_PER_FILE = 50_000 // ~50KB of text per file
const PARSEABLE_MIME_TYPES = [
'application/pdf',
'text/plain',
'text/csv',
'text/markdown',
'text/html',
'application/rtf',
]
export type ExtractedFileContent = {
fileId: string
fileName: string
content: string | null
error?: string
}
/**
* Check if a file's mime type supports content extraction
*/
export function isParseableMimeType(mimeType: string): boolean {
return PARSEABLE_MIME_TYPES.some((t) => mimeType.startsWith(t))
}
/**
* Extract text content from a single file stored in MinIO/S3.
* Returns null content if file type is unsupported or extraction fails.
*/
export async function extractFileContent(
objectKey: string,
mimeType: string,
fileName: string,
fileId: string,
): Promise<ExtractedFileContent> {
if (!isParseableMimeType(mimeType)) {
return { fileId, fileName, content: null, error: 'Unsupported mime type' }
}
try {
const storage = await getStorageProvider()
const buffer = await storage.getObject(objectKey)
let text: string
if (mimeType === 'application/pdf') {
// Dynamic import to avoid loading pdf-parse when not needed
const pdfParseModule = await import('pdf-parse')
const pdfParse = typeof pdfParseModule === 'function' ? pdfParseModule : (pdfParseModule as any).default ?? pdfParseModule
const pdf = await pdfParse(buffer)
text = pdf.text
} else {
// Text-based files
text = buffer.toString('utf-8')
}
// Truncate to limit
if (text.length > MAX_TEXT_PER_FILE) {
text = text.slice(0, MAX_TEXT_PER_FILE) + '\n[... content truncated ...]'
}
return { fileId, fileName, content: text }
} catch (error) {
console.warn(`[FileExtractor] Failed to extract content from ${fileName}:`, error)
return {
fileId,
fileName,
content: null,
error: error instanceof Error ? error.message : 'Extraction failed',
}
}
}
/**
* Extract content from multiple files in parallel.
* Non-fatal: files that fail extraction are returned with null content.
*/
export async function extractMultipleFileContents(
files: Array<{
id: string
fileName: string
mimeType: string
objectKey: string
}>,
): Promise<ExtractedFileContent[]> {
const parseableFiles = files.filter((f) => isParseableMimeType(f.mimeType))
if (parseableFiles.length === 0) return []
const results = await Promise.allSettled(
parseableFiles.map((f) => extractFileContent(f.objectKey, f.mimeType, f.fileName, f.id)),
)
return results.map((r, i) =>
r.status === 'fulfilled'
? r.value
: { fileId: parseableFiles[i].id, fileName: parseableFiles[i].fileName, content: null, error: 'Promise rejected' },
)
}

View File

@ -19,6 +19,7 @@ export type AIAction =
| 'PROJECT_TAGGING'
| 'EVALUATION_SUMMARY'
| 'ROUTING'
| 'SHORTLIST'
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'