feat: per-category evaluation criteria (startup vs business concept)
Add ability to define completely different evaluation criteria for each competition category. Admins toggle "Separate Criteria per Category" in round config, then configure criteria independently via tabbed editor. - Schema: add nullable `category` to EvaluationForm with updated constraints - Config: add `perCategoryCriteria` boolean to EvaluationConfigSchema - Helper: new `findActiveForm()` with category-aware resolution + fallback - Backend: getForm, upsertForm, getStageForm, startStage all category-aware - AI services: use project category for form lookup in summaries + ranking - Export/ranking: merge criteria from all active forms for cross-category reports - Admin UI: toggle switch + tabbed criteria editor with per-category builders - Jury UI: auto-selects correct form based on project category (invisible to juror) - Fully backwards compatible: toggle defaults OFF, existing forms unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
|
||||
|
||||
/**
|
||||
@@ -1298,11 +1299,9 @@ export const evaluationRouter = router({
|
||||
* Get active evaluation form for a round (admin view with full details)
|
||||
*/
|
||||
getForm: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
})
|
||||
const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
|
||||
|
||||
if (!form) return null
|
||||
|
||||
@@ -1333,6 +1332,7 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish(),
|
||||
criteria: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
@@ -1364,7 +1364,7 @@ export const evaluationRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, criteria } = input
|
||||
const { roundId, category, criteria } = input
|
||||
|
||||
// Enforce max one advance criterion per form
|
||||
const advanceCount = criteria.filter((c) => c.type === 'advance').length
|
||||
@@ -1378,9 +1378,9 @@ export const evaluationRouter = router({
|
||||
// Verify round exists
|
||||
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||
|
||||
// Get current max version for this round
|
||||
// Get current max version for this round + category
|
||||
const latestForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId },
|
||||
where: { roundId, category: category ?? null },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true },
|
||||
})
|
||||
@@ -1440,10 +1440,10 @@ export const evaluationRouter = router({
|
||||
scalesJson[scale] = { min, max }
|
||||
}
|
||||
|
||||
// Transaction: deactivate old → create new
|
||||
// Transaction: deactivate old → create new (scoped to category)
|
||||
const form = await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.evaluationForm.updateMany({
|
||||
where: { roundId, isActive: true },
|
||||
where: { roundId, isActive: true, category: category ?? null },
|
||||
data: { isActive: false },
|
||||
})
|
||||
|
||||
@@ -1454,6 +1454,7 @@ export const evaluationRouter = router({
|
||||
criteriaJson,
|
||||
scalesJson,
|
||||
isActive: true,
|
||||
category: category ?? null,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1553,10 +1554,14 @@ export const evaluationRouter = router({
|
||||
})
|
||||
if (existing) return existing
|
||||
|
||||
// Get active evaluation form for this stage
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
// Fetch project's competition category for category-aware form lookup
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: assignment.projectId },
|
||||
select: { competitionCategory: true },
|
||||
})
|
||||
|
||||
// Get active evaluation form for this stage (category-aware)
|
||||
const form = await findActiveForm(ctx.prisma, input.roundId, project.competitionCategory)
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -1577,11 +1582,9 @@ export const evaluationRouter = router({
|
||||
* Get the active evaluation form for a stage
|
||||
*/
|
||||
getStageForm: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
})
|
||||
const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
|
||||
|
||||
if (!form) {
|
||||
return null
|
||||
@@ -1846,7 +1849,7 @@ export const evaluationRouter = router({
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { userId: input.userId },
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
project: { select: { id: true, title: true, competitionCategory: true } },
|
||||
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
|
||||
evaluation: {
|
||||
select: {
|
||||
|
||||
@@ -103,14 +103,23 @@ export const exportRouter = router({
|
||||
projectScores: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Fetch evaluation form to get criteria labels
|
||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
// Fetch all active evaluation forms for this round (shared + category-specific)
|
||||
const activeForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
select: { criteriaJson: true },
|
||||
})
|
||||
const criteria = (evalForm?.criteriaJson as Array<{
|
||||
id: string; label: string; type?: string
|
||||
}> | null) ?? []
|
||||
// Merge criteria across all forms, deduplicating by criterion id
|
||||
const seenCriterionIds = new Set<string>()
|
||||
const criteria: Array<{ id: string; label: string; type?: string }> = []
|
||||
for (const f of activeForms) {
|
||||
const fc = (f.criteriaJson as Array<{ id: string; label: string; type?: string }> | null) ?? []
|
||||
for (const c of fc) {
|
||||
if (!seenCriterionIds.has(c.id)) {
|
||||
seenCriterionIds.add(c.id)
|
||||
criteria.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
@@ -632,12 +641,24 @@ export const exportRouter = router({
|
||||
|
||||
// Criteria breakdown
|
||||
if (includeSection('criteriaBreakdown')) {
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
// Fetch all active forms (shared + category-specific) and merge criteria
|
||||
const allForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
})
|
||||
|
||||
if (form?.criteriaJson) {
|
||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
|
||||
const seenIds = new Set<string>()
|
||||
const allCriteria: Array<{ id: string; label: string }> = []
|
||||
for (const f of allForms) {
|
||||
const fc = (f.criteriaJson as Array<{ id: string; label: string }> | null) ?? []
|
||||
for (const c of fc) {
|
||||
if (!seenIds.has(c.id)) {
|
||||
seenIds.add(c.id)
|
||||
allCriteria.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allCriteria.length > 0) {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
@@ -646,7 +667,7 @@ export const exportRouter = router({
|
||||
select: { criterionScoresJson: true },
|
||||
})
|
||||
|
||||
result.criteriaBreakdown = criteria.map((c) => {
|
||||
result.criteriaBreakdown = allCriteria.map((c) => {
|
||||
const scores: number[] = []
|
||||
evaluations.forEach((e) => {
|
||||
const cs = e.criterionScoresJson as Record<string, number> | null
|
||||
|
||||
@@ -443,17 +443,24 @@ export const rankingRouter = router({
|
||||
roundEvaluationScores: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Find the boolean criterion ID from the EvaluationForm (not round configJson)
|
||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
// Find the boolean criterion ID across all active forms (shared + category-specific)
|
||||
const activeForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
select: { criteriaJson: true },
|
||||
})
|
||||
const formCriteria = (evalForm?.criteriaJson as Array<{
|
||||
id: string; label: string; type?: string
|
||||
}> | null) ?? []
|
||||
const boolCriterionId = formCriteria.find(
|
||||
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
||||
)?.id ?? null
|
||||
let boolCriterionId: string | null = null
|
||||
for (const f of activeForms) {
|
||||
const fc = (f.criteriaJson as Array<{
|
||||
id: string; label: string; type?: string
|
||||
}> | null) ?? []
|
||||
const found = fc.find(
|
||||
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
||||
)
|
||||
if (found) {
|
||||
boolCriterionId = found.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||
import { sanitizeText } from './anonymization'
|
||||
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -290,6 +291,7 @@ export async function generateSummary({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
competitionCategory: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -329,11 +331,8 @@ export async function generateSummary({
|
||||
})
|
||||
}
|
||||
|
||||
// Get evaluation form criteria for this round
|
||||
const form = await prisma.evaluationForm.findFirst({
|
||||
where: { roundId, isActive: true },
|
||||
select: { criteriaJson: true },
|
||||
})
|
||||
// Get evaluation form criteria for this round (category-aware)
|
||||
const form = await findActiveForm(prisma, roundId, project.competitionCategory)
|
||||
|
||||
const criteria: CriterionDef[] = form?.criteriaJson
|
||||
? (form.criteriaJson as unknown as CriterionDef[])
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||
import { classifyAIError, logAIError } from './ai-errors'
|
||||
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
||||
@@ -625,16 +626,13 @@ async function fetchCategoryProjects(
|
||||
roundId: string,
|
||||
prisma: PrismaClient,
|
||||
): Promise<CategoryProjectData> {
|
||||
// Fetch the round config and evaluation form in parallel
|
||||
// Fetch the round config and evaluation form in parallel (category-aware)
|
||||
const [round, evalForm] = await Promise.all([
|
||||
prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { configJson: true },
|
||||
}),
|
||||
prisma.evaluationForm.findFirst({
|
||||
where: { roundId, isActive: true },
|
||||
select: { criteriaJson: true },
|
||||
}),
|
||||
findActiveForm(prisma, roundId, category),
|
||||
])
|
||||
|
||||
const roundConfig = round.configJson as Record<string, unknown> | null
|
||||
|
||||
26
src/server/utils/evaluation-form-lookup.ts
Normal file
26
src/server/utils/evaluation-form-lookup.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { PrismaClient, CompetitionCategory, EvaluationForm } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Find the active EvaluationForm for a round, with category-aware resolution.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If `category` is provided, try the category-specific active form first.
|
||||
* 2. Fall back to the shared form (category = null).
|
||||
* 3. If no `category` provided, return the shared form directly.
|
||||
*/
|
||||
export async function findActiveForm(
|
||||
prisma: PrismaClient | Pick<PrismaClient, 'evaluationForm'>,
|
||||
roundId: string,
|
||||
category?: CompetitionCategory | null,
|
||||
): Promise<EvaluationForm | null> {
|
||||
if (category) {
|
||||
const specific = await prisma.evaluationForm.findFirst({
|
||||
where: { roundId, isActive: true, category },
|
||||
})
|
||||
if (specific) return specific
|
||||
}
|
||||
// Fallback to shared form (category = null)
|
||||
return prisma.evaluationForm.findFirst({
|
||||
where: { roundId, isActive: true, category: null },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user