AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- 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:
2026-02-16 10:09:52 +01:00
parent 93f4ad4b31
commit 80c9e35971
21 changed files with 1886 additions and 1381 deletions

View File

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

View File

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

View File

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

View File

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