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", "nodemailer": "^7.0.7",
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pdf-parse": "^2.4.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -83,6 +84,7 @@
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15", "@types/papaparse": "^5.3.15",
"@types/pdf-parse": "^1.1.5",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
@ -1629,6 +1631,190 @@
"react": ">=16.8.0" "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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -4589,6 +4775,16 @@
"@types/node": "*" "@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": { "node_modules/@types/raf": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@ -10826,6 +11022,38 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",

View File

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

View File

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

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@ -473,7 +474,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div> </div>
<div> <div>
<p className="text-sm font-medium">Rounds</p> <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> </div>
</Link> </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"> <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 Rounds
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Competition rounds in {edition.name} Active rounds in {edition.name}
</CardDescription> </CardDescription>
</div> </div>
<Link <Link
@ -541,8 +542,9 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => ( {roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
<div <Link
key={round.id} key={round.id}
href={`/admin/rounds/${round.id}` as Route}
className="block" className="block"
> >
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md"> <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 /> <Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
)} )}
</div> </div>
</div> </Link>
))} ))}
</div> </div>
)} )}

View File

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

View File

@ -92,7 +92,7 @@ function ImportPageContent() {
Create a competition with rounds before importing projects Create a competition with rounds before importing projects
</p> </p>
<Button asChild className="mt-4"> <Button asChild className="mt-4">
<Link href="/admin/competitions">View Competitions</Link> <Link href="/admin/rounds">View Rounds</Link>
</Button> </Button>
</div> </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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@ -147,6 +148,11 @@ export default function RoundDetailPage() {
const [previewSheetOpen, setPreviewSheetOpen] = useState(false) const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false)
const [advanceDialogOpen, setAdvanceDialogOpen] = 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() const utils = trpc.useUtils()
@ -243,6 +249,25 @@ export default function RoundDetailPage() {
onError: (err) => toast.error(err.message), 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 isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => { const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
@ -828,6 +853,25 @@ export default function RoundDetailPage() {
</div> </div>
</button> </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) */} {/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && ( {passedCount > 0 && (
<button <button
@ -847,29 +891,62 @@ export default function RoundDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Advance Projects Confirmation Dialog */} {/* Advance Projects Dialog */}
<AlertDialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}> <AdvanceProjectsDialog
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
/>
{/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Advance {passedCount} project(s)?</AlertDialogTitle> <AlertDialogTitle>Generate AI Recommendations?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
All projects with PASSED status in this round will be moved to the next round. The AI will analyze all project evaluations and generate a ranked shortlist
This action creates new entries in the next round and marks current entries as completed. 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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => advanceMutation.mutate({ roundId })} onClick={() => shortlistMutation.mutate({ roundId })}
disabled={advanceMutation.isPending} disabled={shortlistMutation.isPending}
> >
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />} {shortlistMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance Projects Generate
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* AI Recommendations Display */}
{aiRecommendations && (
<AIRecommendationsDisplay
recommendations={aiRecommendations}
onClear={() => setAiRecommendations(null)}
/>
)}
{/* Round Info + Project Breakdown */} {/* Round Info + Project Breakdown */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Card> <Card>
@ -1054,7 +1131,7 @@ export default function RoundDetailPage() {
<CardTitle className="text-base">General Settings</CardTitle> <CardTitle className="text-base">General Settings</CardTitle>
<CardDescription>Settings that apply to this round regardless of type</CardDescription> <CardDescription>Settings that apply to this round regardless of type</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium"> <Label htmlFor="notify-on-entry" className="text-sm font-medium">
@ -1072,6 +1149,85 @@ export default function RoundDetailPage() {
}} }}
/> />
</div> </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> </CardContent>
</Card> </Card>
@ -1585,6 +1741,304 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
// ── Evaluation Criteria Editor ─────────────────────────────────────────── // ── 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 }) { function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [criteria, setCriteria] = useState<Array<{ const [criteria, setCriteria] = useState<Array<{

View File

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

View File

@ -37,10 +37,9 @@ type RoundSummary = {
} }
export function CompetitionTimeline({ export function CompetitionTimeline({
competitionId,
rounds, rounds,
}: { }: {
competitionId: string competitionId?: string
rounds: RoundSummary[] rounds: RoundSummary[]
}) { }) {
if (rounds.length === 0) { if (rounds.length === 0) {
@ -70,7 +69,7 @@ export function CompetitionTimeline({
return ( return (
<div key={round.id} className="flex items-start"> <div key={round.id} className="flex items-start">
<Link <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" className="group flex flex-col items-center text-center w-32 shrink-0"
> >
<div className="relative"> <div className="relative">
@ -116,7 +115,7 @@ export function CompetitionTimeline({
return ( return (
<div key={round.id}> <div key={round.id}>
<Link <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" 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"> <div className="flex flex-col items-center shrink-0">

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { Search } from 'lucide-react' import { Search, UserPlus, Mail } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -22,6 +22,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface AddMemberDialogProps { interface AddMemberDialogProps {
juryGroupId: string juryGroupId: string
@ -30,10 +31,22 @@ interface AddMemberDialogProps {
} }
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) { export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
const [tab, setTab] = useState<'search' | 'invite'>('search')
// Search existing user state
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string>('') const [selectedUserId, setSelectedUserId] = useState<string>('')
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER') const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [maxAssignments, setMaxAssignments] = useState<string>('') 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() const utils = trpc.useUtils()
@ -44,7 +57,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
const users = userResponse?.users || [] const users = userResponse?.users || []
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({ const { mutate: addMember, isPending: isAdding } = trpc.juryGroup.addMember.useMutation({
onSuccess: () => { onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId }) utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member added successfully') 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 = () => { const resetForm = () => {
setSearchQuery('') setSearchQuery('')
setSelectedUserId('') setSelectedUserId('')
setRole('MEMBER') setRole('MEMBER')
setMaxAssignments('') setMaxAssignments('')
setCapMode('')
setInviteName('')
setInviteEmail('')
setInviteRole('MEMBER')
setInviteMaxAssignments('')
setInviteCapMode('')
setInviteExpertise('')
} }
const handleSubmit = (e: React.FormEvent) => { const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!selectedUserId) { if (!selectedUserId) {
@ -76,92 +124,253 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
userId: selectedUserId, userId: selectedUserId,
role, role,
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null, 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Member to Jury Group</DialogTitle> <DialogTitle>Add Member to Jury Group</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <Tabs value={tab} onValueChange={(v) => setTab(v as 'search' | 'invite')}>
<div className="space-y-2"> <TabsList className="grid w-full grid-cols-2">
<Label htmlFor="search">Search User</Label> <TabsTrigger value="search" className="flex items-center gap-2">
<div className="relative"> <Search className="h-3.5 w-3.5" />
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> Search Existing
<Input </TabsTrigger>
id="search" <TabsTrigger value="invite" className="flex items-center gap-2">
placeholder="Search by name or email..." <Mail className="h-3.5 w-3.5" />
className="pl-9" Invite New
value={searchQuery} </TabsTrigger>
onChange={(e) => setSearchQuery(e.target.value)} </TabsList>
/>
</div> {/* Search existing user tab */}
{isSearching && ( <TabsContent value="search">
<p className="text-sm text-muted-foreground">Searching...</p> <form onSubmit={handleSearchSubmit} className="space-y-4 pt-2">
)} <div className="space-y-2">
{users && users.length > 0 && ( <Label htmlFor="search">Search User</Label>
<div className="max-h-40 overflow-y-auto border rounded-md"> <div className="relative">
{users.map((user) => ( <Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<button <Input
key={user.id} id="search"
type="button" placeholder="Search by name or email..."
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent ${ className="pl-9"
selectedUserId === user.id ? 'bg-accent' : '' value={searchQuery}
}`} onChange={(e) => setSearchQuery(e.target.value)}
onClick={() => { />
setSelectedUserId(user.id) </div>
setSearchQuery(user.email) {isSearching && (
}} <p className="text-sm text-muted-foreground">Searching...</p>
> )}
<div className="font-medium">{user.name || 'Unnamed User'}</div> {users && users.length > 0 && (
<div className="text-muted-foreground text-xs">{user.email}</div> <div className="max-h-40 overflow-y-auto border rounded-md">
</button> {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>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-3">
<Label htmlFor="role">Role</Label> <div className="space-y-2">
<Select value={role} onValueChange={(val) => setRole(val as any)}> <Label htmlFor="role">Group Role</Label>
<SelectTrigger id="role"> <Select value={role} onValueChange={(val) => setRole(val as typeof role)}>
<SelectValue /> <SelectTrigger id="role">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem value="MEMBER">Member</SelectItem> <SelectContent>
<SelectItem value="CHAIR">Chair</SelectItem> <SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem> <SelectItem value="CHAIR">Chair</SelectItem>
</SelectContent> <SelectItem value="OBSERVER">Observer</SelectItem>
</Select> </SelectContent>
</div> </Select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label> <Label htmlFor="capMode">Cap Mode</Label>
<Input <Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
id="maxAssignments" <SelectTrigger id="capMode">
type="number" <SelectValue />
min="1" </SelectTrigger>
placeholder="Leave empty to use group default" <SelectContent>
value={maxAssignments} <SelectItem value="DEFAULT">Group Default</SelectItem>
onChange={(e) => setMaxAssignments(e.target.value)} <SelectItem value="HARD">Hard Cap</SelectItem>
/> <SelectItem value="SOFT">Soft Cap</SelectItem>
</div> <SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter> <div className="space-y-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
Cancel <Input
</Button> id="maxAssignments"
<Button type="submit" disabled={isPending || !selectedUserId}> type="number"
{isPending ? 'Adding...' : 'Add Member'} min="1"
</Button> placeholder="Leave empty to use group default"
</DialogFooter> value={maxAssignments}
</form> 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> </DialogContent>
</Dialog> </Dialog>
) )

View File

@ -36,6 +36,7 @@ interface JuryMember {
email: string email: string
} }
maxAssignmentsOverride: number | null maxAssignmentsOverride: number | null
capModeOverride: string | null
preferredStartupRatio: number | null preferredStartupRatio: number | null
} }
@ -82,13 +83,14 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead> <TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead> <TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{members.length === 0 ? ( {members.length === 0 ? (
<TableRow> <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. No members yet. Add members to get started.
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -109,6 +111,15 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableCell className="hidden sm:table-cell"> <TableCell className="hidden sm:table-cell">
{member.maxAssignmentsOverride ?? '—'} {member.maxAssignmentsOverride ?? '—'}
</TableCell> </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> <TableCell>
<Button <Button
variant="ghost" variant="ghost"

View File

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

View File

@ -1206,4 +1206,285 @@ export const fileRouter = router({
orderBy: [{ competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }], 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' }, 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 // Get projects in this round via ProjectRoundState
const projectStates = await prisma.projectRoundState.findMany({ const projectStates = await prisma.projectRoundState.findMany({
where: { where: {
@ -54,13 +62,67 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
project: { project: {
include: { include: {
files: { 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 // Calculate batch info
const BATCH_SIZE = 20 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({ const round = await prisma.round.findUnique({
where: { id: roundId }, where: { id: roundId },
select: { name: true, competitionId: true }, select: { name: true },
}) })
// Notify admins that filtering is complete // Notify admins that filtering is complete
@ -160,7 +222,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
type: NotificationTypes.FILTERING_COMPLETE, type: NotificationTypes.FILTERING_COMPLETE,
title: 'AI Filtering Complete', title: 'AI Filtering Complete',
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`, 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', linkLabel: 'View Results',
priority: 'high', priority: 'high',
metadata: { 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({ await notifyAdmins({
type: NotificationTypes.FILTERING_FAILED, type: NotificationTypes.FILTERING_FAILED,
title: 'AI Filtering Failed', title: 'AI Filtering Failed',
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 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', linkLabel: 'View Details',
priority: 'urgent', priority: 'urgent',
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' }, 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 { router, adminProcedure, protectedProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs' import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
import { generateShortlist } from '../services/ai-shortlist'
import { import {
openWindow, openWindow,
closeWindow, 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 // Submission Window Management
// ========================================================================= // =========================================================================

View File

@ -112,7 +112,16 @@ interface ProjectForFiltering {
institution?: string | null institution?: string | null
submissionSource?: SubmissionSource submissionSource?: SubmissionSource
submittedAt?: Date | null 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?: { _count?: {
teamMembers?: number teamMembers?: number
files?: 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) - founded_year: when the company/initiative was founded (use for age checks)
- ocean_issue: the ocean conservation area - ocean_issue: the ocean conservation area
- file_count, file_types: uploaded documents summary - 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 - description: project summary text
- tags: topic tags - 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 ## Guidelines
- Evaluate ONLY against the provided criteria, not your own standards - Evaluate ONLY against the provided criteria, not your own standards

View File

@ -2,7 +2,8 @@
* AI Shortlist Service * AI Shortlist Service
* *
* Generates ranked recommendations at end of evaluation rounds. * 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: * GDPR Compliance:
* - All project data is anonymized before AI processing * - All project data is anonymized before AI processing
@ -12,124 +13,43 @@
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai' import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage' import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, logAIError } from './ai-errors' import { classifyAIError, logAIError } from './ai-errors'
import { extractMultipleFileContents } from './file-content-extractor'
import type { PrismaClient } from '@prisma/client' import type { PrismaClient } from '@prisma/client'
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
export type ShortlistResult = { export type ShortlistResult = {
success: boolean success: boolean
recommendations: ShortlistRecommendation[] recommendations: CategoryRecommendations
errors?: string[] errors?: string[]
tokensUsed?: number tokensUsed?: number
} }
export type CategoryRecommendations = {
STARTUP: ShortlistRecommendation[]
BUSINESS_CONCEPT: ShortlistRecommendation[]
}
export type ShortlistRecommendation = { export type ShortlistRecommendation = {
projectId: string projectId: string
rank: number rank: number
score: number score: number
category: string
strengths: string[] strengths: string[]
concerns: string[] concerns: string[]
recommendation: string recommendation: string
} }
// ─── Main Function ────────────────────────────────────────────────────────── // ─── Prompt Building ────────────────────────────────────────────────────────
/** function buildShortlistPrompt(category: string, topN: number, rubric?: string): string {
* Generate an AI shortlist for projects in a round. const categoryLabel = category === 'STARTUP' ? 'Startup' : 'Business Concept'
* 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
try { return `You are a senior jury advisor for the Monaco Ocean Protection Challenge.
// 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.
## Your Role ## 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) ## Ranking Criteria (Weighted)
- Evaluation Scores (40%): Average scores across all jury evaluations - 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 - Feasibility (20%): Likelihood of successful implementation
- Alignment (15%): Fit with ocean protection mission and competition goals - 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 ## Output Format
Return a JSON array: Return a JSON array:
[ [
@ -146,129 +72,305 @@ Return a JSON array:
"score": 0-100, "score": 0-100,
"strengths": ["strength 1", "strength 2"], "strengths": ["strength 1", "strength 2"],
"concerns": ["concern 1"], "concerns": ["concern 1"],
"recommendation": "1-2 sentence recommendation", "recommendation": "1-2 sentence recommendation"
"criterionBreakdown": {
"evaluationScores": 38,
"innovationImpact": 22,
"feasibility": 18,
"alignment": 14
}
} }
] ]
## Guidelines ## 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 - Score should reflect weighted combination of all criteria
- Be specific in strengths and concerns avoid generic statements - Be specific in strengths and concerns avoid generic statements
- Consider feedback themes and evaluator consensus - 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)} ${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 MAX_PARSE_RETRIES = 2
const model = await getConfiguredModel() 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) { let tokenUsage = extractTokenUsage(response)
return {
success: false, await logAIUsage({
recommendations: [], action: 'SHORTLIST',
errors: ['OpenAI client not configured'], 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 json = JSON.parse(content)
parsed = Array.isArray(json) ? json : json.rankings ?? json.projects ?? json.shortlist ?? []
const MAX_PARSE_RETRIES = 2 break
let parseAttempts = 0 } catch (parseError) {
let response = await openai.chat.completions.create( if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
buildCompletionParams(model, { parseAttempts++
messages: [ response = await openai.chat.completions.create(
{ role: 'system', content: systemPrompt }, buildCompletionParams(model, {
{ role: 'user', content: userPrompt }, messages: [
], { role: 'system', content: systemPrompt },
temperature: 0.1, { role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
jsonMode: true, ],
}), temperature: 0.1,
) jsonMode: true,
}),
let tokenUsage = extractTokenUsage(response) )
const retryUsage = extractTokenUsage(response)
await logAIUsage({ tokenUsage.totalTokens += retryUsage.totalTokens
action: 'FILTERING', continue
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,
}
} }
return { recommendations: [], tokensUsed: tokenUsage.totalTokens, errors: ['Failed to parse AI response'] }
} }
}
// De-anonymize and build recommendations // De-anonymize
const recommendations: ShortlistRecommendation[] = parsed const recommendations: ShortlistRecommendation[] = parsed
.filter((item: any) => item.anonymousId && idMap.has(item.anonymousId)) .filter((item: any) => item.anonymousId && idMap.has(item.anonymousId))
.map((item: any) => ({ .map((item: any) => ({
projectId: idMap.get(item.anonymousId)!, projectId: idMap.get(item.anonymousId)!,
rank: item.rank ?? 0, rank: item.rank ?? 0,
score: item.score ?? 0, score: item.score ?? 0,
strengths: item.strengths ?? [], category,
concerns: item.concerns ?? [], strengths: item.strengths ?? [],
recommendation: item.recommendation ?? '', concerns: item.concerns ?? [],
})) recommendation: item.recommendation ?? '',
.sort((a: ShortlistRecommendation, b: ShortlistRecommendation) => a.rank - b.rank) }))
.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 { return {
success: true, success: true,
recommendations, recommendations: allRecommendations,
tokensUsed: tokenUsage.totalTokens, tokensUsed: totalTokens,
errors: allErrors.length > 0 ? allErrors : undefined,
} }
} catch (error) { } catch (error) {
const classification = classifyAIError(error) const classification = classifyAIError(error)
@ -277,7 +379,7 @@ Return a JSON array following the format specified in your instructions. Only in
return { return {
success: false, success: false,
recommendations: [], recommendations: { STARTUP: [], BUSINESS_CONCEPT: [] },
errors: [error instanceof Error ? error.message : 'AI shortlist generation failed'], 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 file_type: string // FileType enum value
page_count: number | null // Number of pages if known page_count: number | null // Number of pages if known
size_kb: number // File size in KB 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 { export interface AnonymizedProjectForAI {
@ -299,10 +302,13 @@ export function anonymizeProjectForAI(
file_types: project.files file_types: project.files
?.map((f) => f.fileType) ?.map((f) => f.fileType)
.filter((ft): ft is FileType => ft !== null) ?? [], .filter((ft): ft is FileType => ft !== null) ?? [],
files: project.files?.map((f) => ({ files: project.files?.map((f: any) => ({
file_type: f.fileType ?? 'OTHER', file_type: f.fileType ?? 'OTHER',
page_count: f.pageCount ?? null, page_count: f.pageCount ?? null,
size_kb: Math.round((f.size ?? 0) / 1024), 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, wants_mentorship: project.wantsMentorship ?? false,
submission_source: project.submissionSource, 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' | 'PROJECT_TAGGING'
| 'EVALUATION_SUMMARY' | 'EVALUATION_SUMMARY'
| 'ROUTING' | 'ROUTING'
| 'SHORTLIST'
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR' export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'