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:
Matt
2026-04-02 13:03:22 -04:00
parent 7ead21114e
commit 3ccf9b0542
16 changed files with 359 additions and 89 deletions

View File

@@ -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[])

View File

@@ -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