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:
parent
93f4ad4b31
commit
80c9e35971
|
|
@ -62,6 +62,7 @@
|
|||
"nodemailer": "^7.0.7",
|
||||
"openai": "^6.16.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -83,6 +84,7 @@
|
|||
"@types/node": "^25.0.10",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"eslint": "^9.17.0",
|
||||
|
|
@ -1629,6 +1631,190 @@
|
|||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
|
|
@ -4589,6 +4775,16 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdf-parse": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
|
||||
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
|
|
@ -10826,6 +11022,38 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@
|
|||
"nodemailer": "^7.0.7",
|
||||
"openai": "^6.16.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -96,6 +97,7 @@
|
|||
"@types/node": "^25.0.10",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"eslint": "^9.17.0",
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ export default function CompetitionDetailPage() {
|
|||
return (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
href={`/admin/rounds/${round.id}` as Route}
|
||||
>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<CardContent className="pt-4 pb-3 space-y-3">
|
||||
|
|
@ -510,9 +510,6 @@ export default function CompetitionDetailPage() {
|
|||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{competition.notifyOnRoundAdvance && (
|
||||
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
|
||||
)}
|
||||
{competition.notifyOnDeadlineApproach && (
|
||||
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
|
||||
)}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -473,7 +474,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Rounds</p>
|
||||
<p className="text-xs text-muted-foreground">Manage competition rounds</p>
|
||||
<p className="text-xs text-muted-foreground">Manage rounds</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
|
||||
|
|
@ -513,7 +514,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
Rounds
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Competition rounds in {edition.name}
|
||||
Active rounds in {edition.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link
|
||||
|
|
@ -541,8 +542,9 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
|
||||
<div
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/rounds/${round.id}` as Route}
|
||||
className="block"
|
||||
>
|
||||
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||
|
|
@ -569,7 +571,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ type UploadState = {
|
|||
type UploadMap = Record<string, UploadState>
|
||||
|
||||
export default function BulkUploadPage() {
|
||||
const [windowId, setWindowId] = useState('')
|
||||
const [roundId, setRoundId] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
|
||||
|
|
@ -96,20 +96,20 @@ export default function BulkUploadPage() {
|
|||
}, [])
|
||||
|
||||
// Queries
|
||||
const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
|
||||
const { data: rounds, isLoading: roundsLoading } = trpc.file.listRoundsForBulkUpload.useQuery()
|
||||
|
||||
const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
|
||||
const { data, isLoading, refetch } = trpc.file.listProjectsByRoundRequirements.useQuery(
|
||||
{
|
||||
submissionWindowId: windowId,
|
||||
roundId,
|
||||
search: debouncedSearch || undefined,
|
||||
status: statusFilter,
|
||||
page,
|
||||
pageSize: perPage,
|
||||
},
|
||||
{ enabled: !!windowId }
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
|
||||
const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
|
||||
|
||||
// Upload a single file for a project requirement
|
||||
const uploadFileForRequirement = useCallback(
|
||||
|
|
@ -117,7 +117,7 @@ export default function BulkUploadPage() {
|
|||
projectId: string,
|
||||
requirementId: string,
|
||||
file: File,
|
||||
submissionWindowId: string
|
||||
targetRoundId: string
|
||||
) => {
|
||||
const key = `${projectId}:${requirementId}`
|
||||
setUploads((prev) => ({
|
||||
|
|
@ -131,8 +131,8 @@ export default function BulkUploadPage() {
|
|||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
submissionWindowId,
|
||||
submissionFileRequirementId: requirementId,
|
||||
roundId: targetRoundId,
|
||||
requirementId,
|
||||
})
|
||||
|
||||
// XHR upload with progress
|
||||
|
|
@ -186,18 +186,18 @@ export default function BulkUploadPage() {
|
|||
}
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file && windowId) {
|
||||
uploadFileForRequirement(projectId, requirementId, file, windowId)
|
||||
if (file && roundId) {
|
||||
uploadFileForRequirement(projectId, requirementId, file, roundId)
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
},
|
||||
[windowId, uploadFileForRequirement]
|
||||
[roundId, uploadFileForRequirement]
|
||||
)
|
||||
|
||||
// Handle bulk row upload
|
||||
const handleBulkUploadAll = useCallback(async () => {
|
||||
if (!bulkProject || !windowId) return
|
||||
if (!bulkProject || !roundId) return
|
||||
|
||||
const entries = Object.entries(bulkFiles).filter(
|
||||
([, file]) => file !== null
|
||||
|
|
@ -211,14 +211,14 @@ export default function BulkUploadPage() {
|
|||
// Upload all in parallel
|
||||
await Promise.allSettled(
|
||||
entries.map(([reqId, file]) =>
|
||||
uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
|
||||
uploadFileForRequirement(bulkProject.id, reqId, file, roundId)
|
||||
)
|
||||
)
|
||||
|
||||
setBulkProject(null)
|
||||
setBulkFiles({})
|
||||
toast.success('Bulk upload complete')
|
||||
}, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
|
||||
}, [bulkProject, bulkFiles, roundId, uploadFileForRequirement])
|
||||
|
||||
const progressPercent =
|
||||
data && data.totalProjects > 0
|
||||
|
|
@ -242,32 +242,37 @@ export default function BulkUploadPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Window Selector */}
|
||||
{/* Round Selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Submission Window</CardTitle>
|
||||
<CardTitle className="text-base">Round</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{windowsLoading ? (
|
||||
{roundsLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : !rounds || rounds.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>No rounds have file requirements configured. Add file requirements to a round first.</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={windowId}
|
||||
value={roundId}
|
||||
onValueChange={(v) => {
|
||||
setWindowId(v)
|
||||
setRoundId(v)
|
||||
setPage(1)
|
||||
setUploads({})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a submission window..." />
|
||||
<SelectValue placeholder="Select a round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{windows?.map((w) => (
|
||||
<SelectItem key={w.id} value={w.id}>
|
||||
{w.competition.program.name} {w.competition.program.year} — {w.name}{' '}
|
||||
({w.fileRequirements.length} requirement
|
||||
{w.fileRequirements.length !== 1 ? 's' : ''})
|
||||
{rounds.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.competition.program.name} {r.competition.program.year} — {r.name}{' '}
|
||||
({r.fileRequirements.length} requirement
|
||||
{r.fileRequirements.length !== 1 ? 's' : ''})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -276,8 +281,8 @@ export default function BulkUploadPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content (only if window selected) */}
|
||||
{windowId && data && (
|
||||
{/* Content (only if round selected) */}
|
||||
{roundId && data && (
|
||||
<>
|
||||
{/* Progress Summary */}
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ function ImportPageContent() {
|
|||
Create a competition with rounds before importing projects
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/competitions">View Competitions</Link>
|
||||
<Link href="/admin/rounds">View Rounds</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
|
@ -147,6 +148,11 @@ export default function RoundDetailPage() {
|
|||
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
|
||||
const [aiRecommendations, setAiRecommendations] = useState<{
|
||||
STARTUP: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
|
||||
BUSINESS_CONCEPT: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
|
||||
} | null>(null)
|
||||
const [shortlistDialogOpen, setShortlistDialogOpen] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
|
|
@ -243,6 +249,25 @@ export default function RoundDetailPage() {
|
|||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const shortlistMutation = trpc.round.generateAIRecommendations.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
setAiRecommendations(data.recommendations)
|
||||
toast.success(
|
||||
`AI recommendations generated: ${data.recommendations.STARTUP.length} startups, ${data.recommendations.BUSINESS_CONCEPT.length} concepts` +
|
||||
(data.tokensUsed ? ` (${data.tokensUsed} tokens)` : ''),
|
||||
)
|
||||
} else {
|
||||
toast.error(data.errors?.join('; ') || 'AI shortlist failed')
|
||||
}
|
||||
setShortlistDialogOpen(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
setShortlistDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
|
||||
|
||||
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
|
||||
|
|
@ -828,6 +853,25 @@ export default function RoundDetailPage() {
|
|||
</div>
|
||||
</button>
|
||||
|
||||
{/* AI Shortlist Recommendations */}
|
||||
{(isEvaluation || isFiltering) && projectCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShortlistDialogOpen(true)}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-purple-200 bg-purple-50/50"
|
||||
disabled={shortlistMutation.isPending}
|
||||
>
|
||||
<BarChart3 className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{shortlistMutation.isPending ? 'Generating...' : 'AI Recommendations'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Generate ranked shortlist per category using AI analysis
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Advance projects (shown when PASSED > 0) */}
|
||||
{passedCount > 0 && (
|
||||
<button
|
||||
|
|
@ -847,29 +891,62 @@ export default function RoundDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Advance Projects Confirmation Dialog */}
|
||||
<AlertDialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
|
||||
{/* Advance Projects Dialog */}
|
||||
<AdvanceProjectsDialog
|
||||
open={advanceDialogOpen}
|
||||
onOpenChange={setAdvanceDialogOpen}
|
||||
roundId={roundId}
|
||||
projectStates={projectStates}
|
||||
config={config}
|
||||
advanceMutation={advanceMutation}
|
||||
/>
|
||||
|
||||
{/* AI Shortlist Confirmation Dialog */}
|
||||
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Advance {passedCount} project(s)?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Generate AI Recommendations?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
All projects with PASSED status in this round will be moved to the next round.
|
||||
This action creates new entries in the next round and marks current entries as completed.
|
||||
The AI will analyze all project evaluations and generate a ranked shortlist
|
||||
for each category independently.
|
||||
{config.startupAdvanceCount ? (
|
||||
<span className="block mt-1">
|
||||
Startup target: top {String(config.startupAdvanceCount)}
|
||||
</span>
|
||||
) : null}
|
||||
{config.conceptAdvanceCount ? (
|
||||
<span className="block">
|
||||
Business Concept target: top {String(config.conceptAdvanceCount)}
|
||||
</span>
|
||||
) : null}
|
||||
{config.aiParseFiles ? (
|
||||
<span className="block mt-1 text-amber-600">
|
||||
Document parsing is enabled — the AI will read uploaded file contents.
|
||||
</span>
|
||||
) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => advanceMutation.mutate({ roundId })}
|
||||
disabled={advanceMutation.isPending}
|
||||
onClick={() => shortlistMutation.mutate({ roundId })}
|
||||
disabled={shortlistMutation.isPending}
|
||||
>
|
||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Advance Projects
|
||||
{shortlistMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Generate
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* AI Recommendations Display */}
|
||||
{aiRecommendations && (
|
||||
<AIRecommendationsDisplay
|
||||
recommendations={aiRecommendations}
|
||||
onClear={() => setAiRecommendations(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Round Info + Project Breakdown */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
|
|
@ -1054,7 +1131,7 @@ export default function RoundDetailPage() {
|
|||
<CardTitle className="text-base">General Settings</CardTitle>
|
||||
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
|
||||
|
|
@ -1072,6 +1149,85 @@ export default function RoundDetailPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
|
||||
Notify on advance
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send an email to project applicants when their project advances from this round to the next
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-on-advance"
|
||||
checked={!!config.notifyOnAdvance}
|
||||
onCheckedChange={(checked) => {
|
||||
handleConfigChange({ ...config, notifyOnAdvance: checked })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-parse-files" className="text-sm font-medium">
|
||||
AI document parsing
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow AI to read the contents of uploaded files (PDF/text) for deeper analysis during filtering and evaluation
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-parse-files"
|
||||
checked={!!config.aiParseFiles}
|
||||
onCheckedChange={(checked) => {
|
||||
handleConfigChange({ ...config, aiParseFiles: checked })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<Label className="text-sm font-medium">Advancement Targets</Label>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Target number of projects per category to advance from this round
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
|
||||
Startup Projects
|
||||
</Label>
|
||||
<Input
|
||||
id="startup-advance-count"
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-9"
|
||||
placeholder="No limit"
|
||||
value={(config.startupAdvanceCount as number) ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||
handleConfigChange({ ...config, startupAdvanceCount: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
|
||||
Concept Projects
|
||||
</Label>
|
||||
<Input
|
||||
id="concept-advance-count"
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-9"
|
||||
placeholder="No limit"
|
||||
value={(config.conceptAdvanceCount as number) ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||
handleConfigChange({ ...config, conceptAdvanceCount: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -1585,6 +1741,304 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
|||
|
||||
// ── Evaluation Criteria Editor ───────────────────────────────────────────
|
||||
|
||||
// ── Advance Projects Dialog ─────────────────────────────────────────────
|
||||
|
||||
function AdvanceProjectsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
roundId,
|
||||
projectStates,
|
||||
config,
|
||||
advanceMutation,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
roundId: string
|
||||
projectStates: any[] | undefined
|
||||
config: Record<string, unknown>
|
||||
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
|
||||
}) {
|
||||
const passedProjects = useMemo(() =>
|
||||
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
|
||||
[projectStates])
|
||||
|
||||
const startups = useMemo(() =>
|
||||
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
|
||||
[passedProjects])
|
||||
|
||||
const concepts = useMemo(() =>
|
||||
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
|
||||
[passedProjects])
|
||||
|
||||
const other = useMemo(() =>
|
||||
passedProjects.filter((ps: any) =>
|
||||
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
|
||||
),
|
||||
[passedProjects])
|
||||
|
||||
const startupCap = (config.startupAdvanceCount as number) || 0
|
||||
const conceptCap = (config.conceptAdvanceCount as number) || 0
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
// Reset selection when dialog opens
|
||||
if (open && selected.size === 0 && passedProjects.length > 0) {
|
||||
const initial = new Set<string>()
|
||||
// Auto-select all (or up to cap if configured)
|
||||
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
|
||||
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
|
||||
for (const ps of startupSlice) initial.add(ps.project?.id)
|
||||
for (const ps of conceptSlice) initial.add(ps.project?.id)
|
||||
for (const ps of other) initial.add(ps.project?.id)
|
||||
setSelected(initial)
|
||||
}
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) next.delete(projectId)
|
||||
else next.add(projectId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAll = (projects: any[], on: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const ps of projects) {
|
||||
if (on) next.add(ps.project?.id)
|
||||
else next.delete(ps.project?.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdvance = () => {
|
||||
const ids = Array.from(selected)
|
||||
if (ids.length === 0) return
|
||||
advanceMutation.mutate({ roundId, projectIds: ids })
|
||||
onOpenChange(false)
|
||||
setSelected(new Set())
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false)
|
||||
setSelected(new Set())
|
||||
}
|
||||
|
||||
const renderCategorySection = (
|
||||
label: string,
|
||||
projects: any[],
|
||||
cap: number,
|
||||
badgeColor: string,
|
||||
) => {
|
||||
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
|
||||
const overCap = cap > 0 && selectedInCategory > cap
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
|
||||
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
|
||||
/>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
|
||||
{selectedInCategory}/{projects.length}
|
||||
</Badge>
|
||||
{cap > 0 && (
|
||||
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
|
||||
(target: {cap})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
|
||||
) : (
|
||||
<div className="space-y-1 pl-7">
|
||||
{projects.map((ps: any) => (
|
||||
<label
|
||||
key={ps.project?.id}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(ps.project?.id)}
|
||||
onCheckedChange={() => toggleProject(ps.project?.id)}
|
||||
/>
|
||||
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
|
||||
{ps.project?.teamName && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Advance Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which passed projects to advance to the next round.
|
||||
{selected.size} of {passedProjects.length} selected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-2">
|
||||
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
||||
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
|
||||
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleAdvance}
|
||||
disabled={selected.size === 0 || advanceMutation.isPending}
|
||||
>
|
||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── AI Recommendations Display ──────────────────────────────────────────
|
||||
|
||||
type RecommendationItem = {
|
||||
projectId: string
|
||||
rank: number
|
||||
score: number
|
||||
category: string
|
||||
strengths: string[]
|
||||
concerns: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
function AIRecommendationsDisplay({
|
||||
recommendations,
|
||||
onClear,
|
||||
}: {
|
||||
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
|
||||
onClear: () => void
|
||||
}) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
|
||||
if (items.length === 0) return (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
No {label.toLowerCase()} projects evaluated
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const isExpanded = expandedId === `${item.category}-${item.projectId}`
|
||||
return (
|
||||
<div
|
||||
key={item.projectId}
|
||||
className="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
|
||||
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className={cn(
|
||||
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0',
|
||||
colorClass,
|
||||
)}>
|
||||
{item.rank}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.projectId}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-xs font-mono">
|
||||
{item.score}/100
|
||||
</Badge>
|
||||
<ChevronDown className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
|
||||
isExpanded && 'rotate-180',
|
||||
)} />
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
|
||||
<div className="pt-2">
|
||||
<p className="text-xs font-medium text-emerald-700 mb-1">Strengths</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
|
||||
{item.strengths.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
{item.concerns.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-amber-700 mb-1">Concerns</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
|
||||
{item.concerns.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-blue-700 mb-1">Recommendation</p>
|
||||
<p className="text-xs text-muted-foreground">{item.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
|
||||
<CardDescription>
|
||||
Ranked independently per category — {recommendations.STARTUP.length} startups, {recommendations.BUSINESS_CONCEPT.length} concepts
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClear}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
Startup ({recommendations.STARTUP.length})
|
||||
</h4>
|
||||
{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-purple-500" />
|
||||
Business Concept ({recommendations.BUSINESS_CONCEPT.length})
|
||||
</h4>
|
||||
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Evaluation Criteria Editor ───────────────────────────────────────────
|
||||
|
||||
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [criteria, setCriteria] = useState<Array<{
|
||||
|
|
|
|||
|
|
@ -169,7 +169,6 @@ export default function RoundsPage() {
|
|||
categoryMode: comp.categoryMode,
|
||||
startupFinalistCount: comp.startupFinalistCount,
|
||||
conceptFinalistCount: comp.conceptFinalistCount,
|
||||
notifyOnRoundAdvance: comp.notifyOnRoundAdvance,
|
||||
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
|
||||
})
|
||||
}
|
||||
|
|
@ -492,13 +491,6 @@ function CompetitionGroup({
|
|||
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
|
||||
/>
|
||||
<Label className="text-xs font-medium">Notify on Advance</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,9 @@ type RoundSummary = {
|
|||
}
|
||||
|
||||
export function CompetitionTimeline({
|
||||
competitionId,
|
||||
rounds,
|
||||
}: {
|
||||
competitionId: string
|
||||
competitionId?: string
|
||||
rounds: RoundSummary[]
|
||||
}) {
|
||||
if (rounds.length === 0) {
|
||||
|
|
@ -70,7 +69,7 @@ export function CompetitionTimeline({
|
|||
return (
|
||||
<div key={round.id} className="flex items-start">
|
||||
<Link
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
href={`/admin/rounds/${round.id}` as Route}
|
||||
className="group flex flex-col items-center text-center w-32 shrink-0"
|
||||
>
|
||||
<div className="relative">
|
||||
|
|
@ -116,7 +115,7 @@ export function CompetitionTimeline({
|
|||
return (
|
||||
<div key={round.id}>
|
||||
<Link
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
href={`/admin/rounds/${round.id}` as Route}
|
||||
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Search, UserPlus, Mail } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
interface AddMemberDialogProps {
|
||||
juryGroupId: string
|
||||
|
|
@ -30,10 +31,22 @@ interface AddMemberDialogProps {
|
|||
}
|
||||
|
||||
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
|
||||
const [tab, setTab] = useState<'search' | 'invite'>('search')
|
||||
|
||||
// Search existing user state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
||||
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
|
||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||
const [capMode, setCapMode] = useState<string>('')
|
||||
|
||||
// Invite new user state
|
||||
const [inviteName, setInviteName] = useState('')
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
|
||||
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
|
||||
const [inviteCapMode, setInviteCapMode] = useState<string>('')
|
||||
const [inviteExpertise, setInviteExpertise] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
|
|
@ -44,7 +57,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||
|
||||
const users = userResponse?.users || []
|
||||
|
||||
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
|
||||
const { mutate: addMember, isPending: isAdding } = trpc.juryGroup.addMember.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.getById.invalidate({ id: juryGroupId })
|
||||
toast.success('Member added successfully')
|
||||
|
|
@ -56,14 +69,49 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||
},
|
||||
})
|
||||
|
||||
const { mutate: createUser, isPending: isCreating } = trpc.user.create.useMutation({
|
||||
onSuccess: (newUser) => {
|
||||
// Immediately add the newly created user to the jury group
|
||||
addMember({
|
||||
juryGroupId,
|
||||
userId: newUser.id,
|
||||
role: inviteRole,
|
||||
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
|
||||
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||
})
|
||||
// Send invitation email
|
||||
sendInvitation({ userId: newUser.id, juryGroupId })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Invitation sent to ${result.email}`)
|
||||
},
|
||||
onError: (err) => {
|
||||
// Don't block — user was created and added, just invitation failed
|
||||
toast.error(`Member added but invitation email failed: ${err.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setSearchQuery('')
|
||||
setSelectedUserId('')
|
||||
setRole('MEMBER')
|
||||
setMaxAssignments('')
|
||||
setCapMode('')
|
||||
setInviteName('')
|
||||
setInviteEmail('')
|
||||
setInviteRole('MEMBER')
|
||||
setInviteMaxAssignments('')
|
||||
setInviteCapMode('')
|
||||
setInviteExpertise('')
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!selectedUserId) {
|
||||
|
|
@ -76,20 +124,59 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||
userId: selectedUserId,
|
||||
role,
|
||||
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
|
||||
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||
})
|
||||
}
|
||||
|
||||
const handleInviteSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!inviteEmail.trim()) {
|
||||
toast.error('Please enter an email address')
|
||||
return
|
||||
}
|
||||
|
||||
const expertiseTags = inviteExpertise
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
createUser({
|
||||
email: inviteEmail.trim(),
|
||||
name: inviteName.trim() || undefined,
|
||||
role: 'JURY_MEMBER',
|
||||
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
|
||||
maxAssignments: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const isPending = isAdding || isCreating
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Member to Jury Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for a user and assign them to this jury group
|
||||
Search for an existing user or invite a new juror to the platform
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as 'search' | 'invite')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="search" className="flex items-center gap-2">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Search Existing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="invite" className="flex items-center gap-2">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
Invite New
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Search existing user tab */}
|
||||
<TabsContent value="search">
|
||||
<form onSubmit={handleSearchSubmit} className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Search User</Label>
|
||||
<div className="relative">
|
||||
|
|
@ -127,9 +214,10 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select value={role} onValueChange={(val) => setRole(val as any)}>
|
||||
<Label htmlFor="role">Group Role</Label>
|
||||
<Select value={role} onValueChange={(val) => setRole(val as typeof role)}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -141,6 +229,22 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capMode">Cap Mode</Label>
|
||||
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
|
||||
<SelectTrigger id="capMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="DEFAULT">Group Default</SelectItem>
|
||||
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||
<SelectItem value="NONE">No Cap</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
|
||||
<Input
|
||||
|
|
@ -158,10 +262,115 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !selectedUserId}>
|
||||
{isPending ? 'Adding...' : 'Add Member'}
|
||||
{isAdding ? 'Adding...' : 'Add Member'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
{/* Invite new user tab */}
|
||||
<TabsContent value="invite">
|
||||
<form onSubmit={handleInviteSubmit} className="space-y-4 pt-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteName">Full Name</Label>
|
||||
<Input
|
||||
id="inviteName"
|
||||
placeholder="Jane Doe"
|
||||
value={inviteName}
|
||||
onChange={(e) => setInviteName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteEmail">
|
||||
Email <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="inviteEmail"
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
required
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteGroupRole">Group Role</Label>
|
||||
<Select value={inviteRole} onValueChange={(val) => setInviteRole(val as typeof inviteRole)}>
|
||||
<SelectTrigger id="inviteGroupRole">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="CHAIR">Chair</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteCapMode">Cap Mode</Label>
|
||||
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
|
||||
<SelectTrigger id="inviteCapMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="DEFAULT">Group Default</SelectItem>
|
||||
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||
<SelectItem value="NONE">No Cap</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteMaxAssignments">Max Assignments Override (optional)</Label>
|
||||
<Input
|
||||
id="inviteMaxAssignments"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave empty to use group default"
|
||||
value={inviteMaxAssignments}
|
||||
onChange={(e) => setInviteMaxAssignments(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
|
||||
<Input
|
||||
id="inviteExpertise"
|
||||
placeholder="marine biology, policy, finance"
|
||||
value={inviteExpertise}
|
||||
onChange={(e) => setInviteExpertise(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated tags for smart assignment matching
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
|
||||
<p className="text-xs text-blue-700">
|
||||
<UserPlus className="mr-1 inline h-3 w-3" />
|
||||
This will create a new user account and send an invitation email to join the platform as a jury member.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !inviteEmail.trim()}>
|
||||
{isCreating || isAdding ? 'Creating & Inviting...' : 'Create & Invite'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ interface JuryMember {
|
|||
email: string
|
||||
}
|
||||
maxAssignmentsOverride: number | null
|
||||
capModeOverride: string | null
|
||||
preferredStartupRatio: number | null
|
||||
}
|
||||
|
||||
|
|
@ -82,13 +83,14 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
|
|||
<TableHead>Email</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Role</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No members yet. Add members to get started.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -109,6 +111,15 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
|
|||
<TableCell className="hidden sm:table-cell">
|
||||
{member.maxAssignmentsOverride ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{member.capModeOverride ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{member.capModeOverride}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Group default</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
||||
title: 'AI Assignment Suggestions Ready',
|
||||
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||
linkUrl: `/admin/competitions/${round.competitionId}/assignments`,
|
||||
linkUrl: `/admin/rounds/${roundId}`,
|
||||
linkLabel: 'View Suggestions',
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
|
|
|
|||
|
|
@ -1206,4 +1206,285 @@ export const fileRouter = router({
|
|||
orderBy: [{ competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List rounds with their file requirement counts (for bulk upload round selector)
|
||||
*/
|
||||
listRoundsForBulkUpload: adminProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return ctx.prisma.round.findMany({
|
||||
where: {
|
||||
fileRequirements: { some: {} },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
roundType: true,
|
||||
sortOrder: true,
|
||||
competition: {
|
||||
select: { id: true, name: true, program: { select: { name: true, year: true } } },
|
||||
},
|
||||
fileRequirements: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ competition: { program: { year: 'desc' } } },
|
||||
{ sortOrder: 'asc' },
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List projects with upload status against a round's FileRequirements (for bulk upload)
|
||||
*/
|
||||
listProjectsByRoundRequirements: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
search: z.string().optional(),
|
||||
status: z.enum(['all', 'missing', 'complete']).default('all'),
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
include: {
|
||||
competition: { select: { id: true, programId: true, name: true } },
|
||||
fileRequirements: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
})
|
||||
|
||||
// Normalize requirements to a common shape
|
||||
const requirements = round.fileRequirements.map((req) => ({
|
||||
id: req.id,
|
||||
label: req.name,
|
||||
mimeTypes: req.acceptedMimeTypes,
|
||||
required: req.isRequired,
|
||||
maxSizeMb: req.maxSizeMB,
|
||||
description: req.description,
|
||||
}))
|
||||
|
||||
// Build project filter
|
||||
const projectWhere: Record<string, unknown> = {
|
||||
programId: round.competition.programId,
|
||||
}
|
||||
if (input.search) {
|
||||
projectWhere.OR = [
|
||||
{ title: { contains: input.search, mode: 'insensitive' } },
|
||||
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const allProjects = await ctx.prisma.project.findMany({
|
||||
where: projectWhere,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
submittedByUserId: true,
|
||||
submittedBy: { select: { id: true, name: true, email: true } },
|
||||
files: {
|
||||
where: { roundId: input.roundId, requirementId: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
createdAt: true,
|
||||
requirementId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { title: 'asc' },
|
||||
})
|
||||
|
||||
// Map projects with their requirement status
|
||||
const mapped = allProjects.map((project) => {
|
||||
const reqStatus = requirements.map((req) => {
|
||||
const file = project.files.find(
|
||||
(f) => f.requirementId === req.id
|
||||
)
|
||||
return {
|
||||
requirementId: req.id,
|
||||
label: req.label,
|
||||
mimeTypes: req.mimeTypes,
|
||||
required: req.required,
|
||||
file: file ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const totalRequired = reqStatus.filter((r) => r.required).length
|
||||
const filledRequired = reqStatus.filter(
|
||||
(r) => r.required && r.file
|
||||
).length
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
teamName: project.teamName,
|
||||
submittedBy: project.submittedBy,
|
||||
},
|
||||
requirements: reqStatus,
|
||||
isComplete: totalRequired > 0 ? filledRequired >= totalRequired : reqStatus.every((r) => r.file),
|
||||
filledCount: reqStatus.filter((r) => r.file).length,
|
||||
totalCount: reqStatus.length,
|
||||
}
|
||||
})
|
||||
|
||||
// Apply status filter
|
||||
const filtered =
|
||||
input.status === 'missing'
|
||||
? mapped.filter((p) => !p.isComplete)
|
||||
: input.status === 'complete'
|
||||
? mapped.filter((p) => p.isComplete)
|
||||
: mapped
|
||||
|
||||
// Paginate
|
||||
const total = filtered.length
|
||||
const totalPages = Math.ceil(total / input.pageSize)
|
||||
const page = Math.min(input.page, Math.max(totalPages, 1))
|
||||
const projects = filtered.slice(
|
||||
(page - 1) * input.pageSize,
|
||||
page * input.pageSize
|
||||
)
|
||||
|
||||
const completeCount = mapped.filter((p) => p.isComplete).length
|
||||
|
||||
return {
|
||||
projects,
|
||||
requirements,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
completeCount,
|
||||
totalProjects: mapped.length,
|
||||
competition: round.competition,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Upload a file for a round's FileRequirement (admin bulk upload)
|
||||
*/
|
||||
adminUploadForRoundRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().positive(),
|
||||
roundId: z.string(),
|
||||
requirementId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Block dangerous file extensions
|
||||
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi']
|
||||
const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.'))
|
||||
if (dangerousExtensions.includes(ext)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `File type "${ext}" is not allowed`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate requirement exists and belongs to the round
|
||||
const requirement = await ctx.prisma.fileRequirement.findFirst({
|
||||
where: {
|
||||
id: input.requirementId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
})
|
||||
if (!requirement) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Requirement not found for this round',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate MIME type if requirement specifies allowed types
|
||||
if (requirement.acceptedMimeTypes.length > 0) {
|
||||
const isAllowed = requirement.acceptedMimeTypes.some((allowed) => {
|
||||
if (allowed.endsWith('/*')) {
|
||||
return input.mimeType.startsWith(allowed.replace('/*', '/'))
|
||||
}
|
||||
return input.mimeType === allowed
|
||||
})
|
||||
if (!isAllowed) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `File type "${input.mimeType}" is not allowed for this requirement. Accepted: ${requirement.acceptedMimeTypes.join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Infer fileType from mimeType
|
||||
let fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' = 'OTHER'
|
||||
if (input.mimeType.startsWith('video/')) fileType = 'VIDEO'
|
||||
else if (input.mimeType === 'application/pdf') fileType = 'EXEC_SUMMARY'
|
||||
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
|
||||
fileType = 'PRESENTATION'
|
||||
|
||||
// Fetch project title and round name for storage path
|
||||
const [project, round] = await Promise.all([
|
||||
ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
select: { title: true },
|
||||
}),
|
||||
ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const bucket = BUCKET_NAME
|
||||
const objectKey = generateObjectKey(project.title, input.fileName, round.name)
|
||||
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
|
||||
|
||||
// Remove any existing file for this project+requirement combo (replace)
|
||||
await ctx.prisma.projectFile.deleteMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
roundId: input.roundId,
|
||||
requirementId: input.requirementId,
|
||||
},
|
||||
})
|
||||
|
||||
// Create file record
|
||||
const file = await ctx.prisma.projectFile.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
fileType,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
bucket,
|
||||
objectKey,
|
||||
roundId: input.roundId,
|
||||
requirementId: input.requirementId,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPLOAD_FILE',
|
||||
entityType: 'ProjectFile',
|
||||
entityId: file.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
roundId: input.roundId,
|
||||
requirementId: input.requirementId,
|
||||
bulkUpload: true,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { uploadUrl, file }
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
|||
orderBy: { priority: 'asc' },
|
||||
})
|
||||
|
||||
// Get current round with config
|
||||
const currentRound = await prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true, configJson: true },
|
||||
})
|
||||
const roundConfig = (currentRound.configJson as Record<string, unknown>) || {}
|
||||
const aiParseFiles = !!roundConfig.aiParseFiles
|
||||
|
||||
// Get projects in this round via ProjectRoundState
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: {
|
||||
|
|
@ -54,13 +62,67 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
|||
project: {
|
||||
include: {
|
||||
files: {
|
||||
select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileType: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
pageCount: true,
|
||||
objectKey: true,
|
||||
roundId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const projects = projectStates.map((pss: any) => pss.project).filter(Boolean)
|
||||
|
||||
// Get round names for file tagging
|
||||
const roundIds = new Set<string>()
|
||||
for (const pss of projectStates) {
|
||||
for (const f of (pss as any).project?.files || []) {
|
||||
if (f.roundId) roundIds.add(f.roundId)
|
||||
}
|
||||
}
|
||||
const roundNames = new Map<string, string>()
|
||||
if (roundIds.size > 0) {
|
||||
const rounds = await prisma.round.findMany({
|
||||
where: { id: { in: [...roundIds] } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
for (const r of rounds) roundNames.set(r.id, r.name)
|
||||
}
|
||||
|
||||
// Optionally extract file contents
|
||||
let fileContents: Map<string, string> | undefined
|
||||
if (aiParseFiles) {
|
||||
const { extractMultipleFileContents } = await import('@/server/services/file-content-extractor')
|
||||
const allFiles = projectStates.flatMap((pss: any) =>
|
||||
((pss.project?.files || []) as Array<{ id: string; fileName: string; mimeType: string; objectKey: string }>)
|
||||
)
|
||||
const extractions = await extractMultipleFileContents(allFiles)
|
||||
fileContents = new Map()
|
||||
for (const e of extractions) {
|
||||
if (e.content) fileContents.set(e.fileId, e.content)
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich projects with round-tagged file data
|
||||
const projects = projectStates.map((pss: any) => {
|
||||
const project = pss.project
|
||||
if (project?.files) {
|
||||
project.files = project.files.map((f: any) => ({
|
||||
...f,
|
||||
roundName: f.roundId ? (roundNames.get(f.roundId) || 'Unknown Round') : null,
|
||||
isCurrentRound: f.roundId === roundId,
|
||||
textContent: fileContents?.get(f.id) || undefined,
|
||||
}))
|
||||
}
|
||||
return project
|
||||
}).filter(Boolean)
|
||||
|
||||
// Calculate batch info
|
||||
const BATCH_SIZE = 20
|
||||
|
|
@ -149,10 +211,10 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
|||
},
|
||||
})
|
||||
|
||||
// Get round name and competitionId for notification
|
||||
// Get round name for notification
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { name: true, competitionId: true },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
// Notify admins that filtering is complete
|
||||
|
|
@ -160,7 +222,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
|||
type: NotificationTypes.FILTERING_COMPLETE,
|
||||
title: 'AI Filtering Complete',
|
||||
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
|
||||
linkUrl: `/admin/competitions/${round?.competitionId}/rounds/${roundId}`,
|
||||
linkUrl: `/admin/rounds/${roundId}`,
|
||||
linkLabel: 'View Results',
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
|
|
@ -183,16 +245,11 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
|||
},
|
||||
})
|
||||
|
||||
// Notify admins of failure - need to fetch round info for competitionId
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { competitionId: true },
|
||||
})
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.FILTERING_FAILED,
|
||||
title: 'AI Filtering Failed',
|
||||
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
linkUrl: round?.competitionId ? `/admin/competitions/${round.competitionId}/rounds/${roundId}` : `/admin/competitions`,
|
||||
linkUrl: `/admin/rounds/${roundId}`,
|
||||
linkLabel: 'View Details',
|
||||
priority: 'urgent',
|
||||
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client'
|
|||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||||
import { generateShortlist } from '../services/ai-shortlist'
|
||||
import {
|
||||
openWindow,
|
||||
closeWindow,
|
||||
|
|
@ -358,6 +359,74 @@ export const roundRouter = router({
|
|||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// AI Shortlist Recommendations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate AI-powered shortlist recommendations for a round.
|
||||
* Runs independently for STARTUP and BUSINESS_CONCEPT categories.
|
||||
* Uses per-round config for advancement targets and file parsing.
|
||||
*/
|
||||
generateAIRecommendations: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
rubric: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
competitionId: true,
|
||||
configJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||
const startupTopN = (config.startupAdvanceCount as number) || 10
|
||||
const conceptTopN = (config.conceptAdvanceCount as number) || 10
|
||||
const aiParseFiles = !!config.aiParseFiles
|
||||
|
||||
const result = await generateShortlist(
|
||||
{
|
||||
roundId: input.roundId,
|
||||
competitionId: round.competitionId,
|
||||
startupTopN,
|
||||
conceptTopN,
|
||||
rubric: input.rubric,
|
||||
aiParseFiles,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'AI_SHORTLIST',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
roundName: round.name,
|
||||
startupTopN,
|
||||
conceptTopN,
|
||||
aiParseFiles,
|
||||
success: result.success,
|
||||
startupCount: result.recommendations.STARTUP.length,
|
||||
conceptCount: result.recommendations.BUSINESS_CONCEPT.length,
|
||||
tokensUsed: result.tokensUsed,
|
||||
errors: result.errors,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Submission Window Management
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -112,7 +112,16 @@ interface ProjectForFiltering {
|
|||
institution?: string | null
|
||||
submissionSource?: SubmissionSource
|
||||
submittedAt?: Date | null
|
||||
files: Array<{ id: string; fileName: string; fileType?: FileType | null; size?: number; pageCount?: number | null }>
|
||||
files: Array<{
|
||||
id: string
|
||||
fileName: string
|
||||
fileType?: FileType | null
|
||||
size?: number
|
||||
pageCount?: number | null
|
||||
roundName?: string | null
|
||||
isCurrentRound?: boolean
|
||||
textContent?: string
|
||||
}>
|
||||
_count?: {
|
||||
teamMembers?: number
|
||||
files?: number
|
||||
|
|
@ -170,9 +179,10 @@ Return a JSON object with this exact structure:
|
|||
- founded_year: when the company/initiative was founded (use for age checks)
|
||||
- ocean_issue: the ocean conservation area
|
||||
- file_count, file_types: uploaded documents summary
|
||||
- files[]: per-file details with file_type, page_count (if known), and size_kb
|
||||
- files[]: per-file details with file_type, page_count (if known), size_kb, round_name (which round the file was submitted for), and is_current_round flag
|
||||
- description: project summary text
|
||||
- tags: topic tags
|
||||
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
|
||||
|
||||
## Guidelines
|
||||
- Evaluate ONLY against the provided criteria, not your own standards
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
* AI Shortlist Service
|
||||
*
|
||||
* Generates ranked recommendations at end of evaluation rounds.
|
||||
* Follows patterns from ai-filtering.ts and ai-evaluation-summary.ts.
|
||||
* Runs SEPARATELY for each category (STARTUP / BUSINESS_CONCEPT)
|
||||
* to produce independent rankings per the competition's advancement rules.
|
||||
*
|
||||
* GDPR Compliance:
|
||||
* - All project data is anonymized before AI processing
|
||||
|
|
@ -12,73 +13,156 @@
|
|||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||
import { classifyAIError, logAIError } from './ai-errors'
|
||||
import { extractMultipleFileContents } from './file-content-extractor'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ShortlistResult = {
|
||||
success: boolean
|
||||
recommendations: ShortlistRecommendation[]
|
||||
recommendations: CategoryRecommendations
|
||||
errors?: string[]
|
||||
tokensUsed?: number
|
||||
}
|
||||
|
||||
export type CategoryRecommendations = {
|
||||
STARTUP: ShortlistRecommendation[]
|
||||
BUSINESS_CONCEPT: ShortlistRecommendation[]
|
||||
}
|
||||
|
||||
export type ShortlistRecommendation = {
|
||||
projectId: string
|
||||
rank: number
|
||||
score: number
|
||||
category: string
|
||||
strengths: string[]
|
||||
concerns: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
// ─── Main Function ──────────────────────────────────────────────────────────
|
||||
// ─── Prompt Building ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate an AI shortlist for projects in a round.
|
||||
* Only runs if EvaluationConfig.generateAiShortlist is true.
|
||||
*/
|
||||
export async function generateShortlist(
|
||||
function buildShortlistPrompt(category: string, topN: number, rubric?: string): string {
|
||||
const categoryLabel = category === 'STARTUP' ? 'Startup' : 'Business Concept'
|
||||
|
||||
return `You are a senior jury advisor for the Monaco Ocean Protection Challenge.
|
||||
|
||||
## Your Role
|
||||
Analyze aggregated evaluation data to produce a ranked shortlist of the top ${topN} ${categoryLabel} projects.
|
||||
You are evaluating ONLY ${categoryLabel} projects in this batch — rank them against each other within this category.
|
||||
|
||||
## Ranking Criteria (Weighted)
|
||||
- Evaluation Scores (40%): Average scores across all jury evaluations
|
||||
- Innovation & Impact (25%): Novelty of approach and potential environmental impact
|
||||
- Feasibility (20%): Likelihood of successful implementation
|
||||
- Alignment (15%): Fit with ocean protection mission and competition goals
|
||||
|
||||
## Document Analysis
|
||||
If document content is provided (text_content field in files), use it for deeper qualitative analysis.
|
||||
Pay SPECIAL ATTENTION to files marked with is_current_round=true — these are the most recent submissions.
|
||||
Older documents provide context, but recent ones should carry more weight in your assessment.
|
||||
|
||||
${rubric ? `## Custom Evaluation Rubric\n${rubric}\n` : ''}
|
||||
## Output Format
|
||||
Return a JSON array:
|
||||
[
|
||||
{
|
||||
"anonymousId": "PROJECT_001",
|
||||
"rank": 1,
|
||||
"score": 0-100,
|
||||
"strengths": ["strength 1", "strength 2"],
|
||||
"concerns": ["concern 1"],
|
||||
"recommendation": "1-2 sentence recommendation"
|
||||
}
|
||||
]
|
||||
|
||||
## Guidelines
|
||||
- Only include the top ${topN} projects in your ranking
|
||||
- Score should reflect weighted combination of all criteria
|
||||
- Be specific in strengths and concerns — avoid generic statements
|
||||
- Consider feedback themes and evaluator consensus
|
||||
- Higher evaluator consensus should boost confidence in ranking
|
||||
- Do not include any personal identifiers`
|
||||
}
|
||||
|
||||
// ─── Single Category Processing ─────────────────────────────────────────────
|
||||
|
||||
async function generateCategoryShortlist(
|
||||
params: {
|
||||
roundId: string
|
||||
competitionId: string
|
||||
category?: string
|
||||
topN?: number
|
||||
category: string
|
||||
topN: number
|
||||
rubric?: string
|
||||
aiParseFiles: boolean
|
||||
},
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<ShortlistResult> {
|
||||
const { roundId, competitionId, category, topN = 10, rubric } = params
|
||||
|
||||
try {
|
||||
// Load projects with evaluations
|
||||
const where: Record<string, unknown> = {
|
||||
assignments: { some: { roundId } },
|
||||
}
|
||||
if (category) {
|
||||
where.competitionCategory = category
|
||||
}
|
||||
): 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,
|
||||
where: {
|
||||
competitionCategory: category,
|
||||
assignments: { some: { roundId } },
|
||||
},
|
||||
include: {
|
||||
assignments: {
|
||||
where: { roundId },
|
||||
include: {
|
||||
evaluation: true,
|
||||
},
|
||||
include: { evaluation: true },
|
||||
},
|
||||
projectTags: { include: { tag: true } },
|
||||
files: { select: { id: true, type: 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 {
|
||||
success: true,
|
||||
recommendations: [],
|
||||
errors: ['No projects found for this round'],
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,18 +179,25 @@ export async function generateShortlist(
|
|||
: 0
|
||||
|
||||
const feedbacks = evaluations
|
||||
.map((e: any) => e.feedbackGeneral)
|
||||
.map((e: any) => e.feedbackGeneral || e.feedbackText)
|
||||
.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
|
||||
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) } : {}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -114,8 +205,6 @@ export async function generateShortlist(
|
|||
const anonymized = projectSummaries.map((p: any, index: number) => ({
|
||||
anonymousId: `PROJECT_${String(index + 1).padStart(3, '0')}`,
|
||||
...p,
|
||||
// Strip identifying info
|
||||
title: undefined,
|
||||
id: undefined,
|
||||
}))
|
||||
|
||||
|
|
@ -125,62 +214,22 @@ export async function generateShortlist(
|
|||
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
|
||||
Analyze aggregated evaluation data to produce a ranked shortlist of top projects.
|
||||
|
||||
## Ranking Criteria (Weighted)
|
||||
- Evaluation Scores (40%): Average scores across all jury evaluations
|
||||
- Innovation & Impact (25%): Novelty of approach and potential environmental impact
|
||||
- Feasibility (20%): Likelihood of successful implementation
|
||||
- Alignment (15%): Fit with ocean protection mission and competition goals
|
||||
|
||||
## Output Format
|
||||
Return a JSON array:
|
||||
[
|
||||
{
|
||||
"anonymousId": "PROJECT_001",
|
||||
"rank": 1,
|
||||
"score": 0-100,
|
||||
"strengths": ["strength 1", "strength 2"],
|
||||
"concerns": ["concern 1"],
|
||||
"recommendation": "1-2 sentence recommendation",
|
||||
"criterionBreakdown": {
|
||||
"evaluationScores": 38,
|
||||
"innovationImpact": 22,
|
||||
"feasibility": 18,
|
||||
"alignment": 14
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
## Guidelines
|
||||
- Only include the requested number of top projects
|
||||
- Score should reflect weighted combination of all criteria
|
||||
- Be specific in strengths and concerns — avoid generic statements
|
||||
- Consider feedback themes and evaluator consensus
|
||||
- Higher evaluator consensus should boost confidence in ranking`
|
||||
|
||||
const userPrompt = `Analyze these anonymized project evaluations and produce a ranked shortlist of the top ${topN} projects.
|
||||
|
||||
${rubric ? `Evaluation rubric:\n${rubric}\n\n` : ''}Projects:
|
||||
${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.`
|
||||
|
||||
// Call AI
|
||||
const openai = await getOpenAI()
|
||||
const model = await getConfiguredModel()
|
||||
|
||||
if (!openai) {
|
||||
return {
|
||||
success: false,
|
||||
recommendations: [],
|
||||
errors: ['OpenAI client not configured'],
|
||||
}
|
||||
return { recommendations: [], tokensUsed: 0, errors: ['OpenAI client not configured'] }
|
||||
}
|
||||
|
||||
const systemPrompt = buildShortlistPrompt(category, topN, rubric)
|
||||
const userPrompt = `Analyze these anonymized ${category} project evaluations and produce a ranked shortlist of the top ${topN}.
|
||||
|
||||
Projects (${anonymized.length} total):
|
||||
${JSON.stringify(anonymized, null, 2)}
|
||||
|
||||
Return a JSON array following the format specified. Only include the top ${topN} projects. Rank by overall quality within this category.`
|
||||
|
||||
const MAX_PARSE_RETRIES = 2
|
||||
let parseAttempts = 0
|
||||
let response = await openai.chat.completions.create(
|
||||
|
|
@ -197,7 +246,7 @@ Return a JSON array following the format specified in your instructions. Only in
|
|||
let tokenUsage = extractTokenUsage(response)
|
||||
|
||||
await logAIUsage({
|
||||
action: 'FILTERING',
|
||||
action: 'SHORTLIST',
|
||||
model,
|
||||
promptTokens: tokenUsage.promptTokens,
|
||||
completionTokens: tokenUsage.completionTokens,
|
||||
|
|
@ -205,29 +254,20 @@ Return a JSON array following the format specified in your instructions. Only in
|
|||
status: 'SUCCESS',
|
||||
})
|
||||
|
||||
// Parse response with retry logic
|
||||
// Parse response
|
||||
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,
|
||||
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 ?? []
|
||||
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: [
|
||||
|
|
@ -242,33 +282,95 @@ Return a JSON array following the format specified in your instructions. Only in
|
|||
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
|
||||
.filter((item: any) => item.anonymousId && idMap.has(item.anonymousId))
|
||||
.map((item: any) => ({
|
||||
projectId: idMap.get(item.anonymousId)!,
|
||||
rank: item.rank ?? 0,
|
||||
score: item.score ?? 0,
|
||||
category,
|
||||
strengths: item.strengths ?? [],
|
||||
concerns: item.concerns ?? [],
|
||||
recommendation: item.recommendation ?? '',
|
||||
}))
|
||||
.sort((a: ShortlistRecommendation, b: ShortlistRecommendation) => a.rank - b.rank)
|
||||
|
||||
return { recommendations, tokensUsed: tokenUsage.totalTokens, errors: [] }
|
||||
}
|
||||
|
||||
// ─── Main Function ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate an AI shortlist for projects in a round, split by category.
|
||||
* Runs independently for STARTUP and BUSINESS_CONCEPT.
|
||||
*/
|
||||
export async function generateShortlist(
|
||||
params: {
|
||||
roundId: string
|
||||
competitionId: string
|
||||
category?: string // If provided, only run for this category
|
||||
topN?: number // Global fallback
|
||||
startupTopN?: number // Per-category override
|
||||
conceptTopN?: number // Per-category override
|
||||
rubric?: string
|
||||
aiParseFiles?: boolean
|
||||
},
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<ShortlistResult> {
|
||||
const {
|
||||
roundId,
|
||||
category,
|
||||
topN = 10,
|
||||
startupTopN,
|
||||
conceptTopN,
|
||||
rubric,
|
||||
aiParseFiles = false,
|
||||
} = params
|
||||
|
||||
try {
|
||||
const categories = category
|
||||
? [category]
|
||||
: ['STARTUP', 'BUSINESS_CONCEPT']
|
||||
|
||||
const allRecommendations: CategoryRecommendations = {
|
||||
STARTUP: [],
|
||||
BUSINESS_CONCEPT: [],
|
||||
}
|
||||
let totalTokens = 0
|
||||
const allErrors: string[] = []
|
||||
|
||||
// Run each category independently
|
||||
for (const cat of categories) {
|
||||
const catTopN = cat === 'STARTUP'
|
||||
? (startupTopN ?? topN)
|
||||
: (conceptTopN ?? topN)
|
||||
|
||||
console.log(`[AI Shortlist] Generating top-${catTopN} for ${cat}`)
|
||||
|
||||
const result = await generateCategoryShortlist(
|
||||
{ roundId, category: cat, topN: catTopN, rubric, aiParseFiles },
|
||||
prisma,
|
||||
)
|
||||
|
||||
if (cat === 'STARTUP') {
|
||||
allRecommendations.STARTUP = result.recommendations
|
||||
} else {
|
||||
allRecommendations.BUSINESS_CONCEPT = result.recommendations
|
||||
}
|
||||
totalTokens += result.tokensUsed
|
||||
allErrors.push(...result.errors)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
recommendations,
|
||||
tokensUsed: tokenUsage.totalTokens,
|
||||
recommendations: allRecommendations,
|
||||
tokensUsed: totalTokens,
|
||||
errors: allErrors.length > 0 ? allErrors : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
const classification = classifyAIError(error)
|
||||
|
|
@ -277,7 +379,7 @@ Return a JSON array following the format specified in your instructions. Only in
|
|||
|
||||
return {
|
||||
success: false,
|
||||
recommendations: [],
|
||||
recommendations: { STARTUP: [], BUSINESS_CONCEPT: [] },
|
||||
errors: [error instanceof Error ? error.message : 'AI shortlist generation failed'],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ export interface AnonymizedFileInfo {
|
|||
file_type: string // FileType enum value
|
||||
page_count: number | null // Number of pages if known
|
||||
size_kb: number // File size in KB
|
||||
round_name?: string | null // Which round the file was submitted for
|
||||
is_current_round?: boolean // Whether this file belongs to the current filtering/evaluation round
|
||||
text_content?: string // Extracted text content (when aiParseFiles is enabled)
|
||||
}
|
||||
|
||||
export interface AnonymizedProjectForAI {
|
||||
|
|
@ -299,10 +302,13 @@ export function anonymizeProjectForAI(
|
|||
file_types: project.files
|
||||
?.map((f) => f.fileType)
|
||||
.filter((ft): ft is FileType => ft !== null) ?? [],
|
||||
files: project.files?.map((f) => ({
|
||||
files: project.files?.map((f: any) => ({
|
||||
file_type: f.fileType ?? 'OTHER',
|
||||
page_count: f.pageCount ?? null,
|
||||
size_kb: Math.round((f.size ?? 0) / 1024),
|
||||
...(f.roundName ? { round_name: f.roundName } : {}),
|
||||
...(f.isCurrentRound !== undefined ? { is_current_round: f.isCurrentRound } : {}),
|
||||
...(f.textContent ? { text_content: f.textContent } : {}),
|
||||
})) ?? [],
|
||||
wants_mentorship: project.wantsMentorship ?? false,
|
||||
submission_source: project.submissionSource,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
)
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ export type AIAction =
|
|||
| 'PROJECT_TAGGING'
|
||||
| 'EVALUATION_SUMMARY'
|
||||
| 'ROUTING'
|
||||
| 'SHORTLIST'
|
||||
|
||||
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue