Files
MOPC-Portal/src/server/routers/ranking.ts

350 lines
12 KiB
TypeScript
Raw Normal View History

import { router, adminProcedure } from '../trpc'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
import {
parseRankingCriteria,
executeAIRanking,
quickRank as aiQuickRank,
fetchAndRankCategory,
type ParsedRankingRule,
} from '../services/ai-ranking'
import { logAudit } from '../utils/audit'
import type { EvaluationConfig } from '@/types/competition-configs'
// ─── Zod Schemas ──────────────────────────────────────────────────────────────
const ParsedRuleSchema = z.object({
step: z.number().int(),
type: z.enum(['filter', 'sort', 'limit']),
description: z.string(),
field: z.enum(['pass_rate', 'avg_score', 'evaluator_count']).nullable(),
operator: z.enum(['gte', 'lte', 'eq', 'top_n']).nullable(),
value: z.number().nullable(),
dataAvailable: z.boolean(),
})
// ─── Router ───────────────────────────────────────────────────────────────────
export const rankingRouter = router({
/**
* Parse natural-language criteria into structured rules (preview mode).
* RANK-01, RANK-02, RANK-03 admin reviews parsed rules before executing.
*/
parseRankingCriteria: adminProcedure
.input(
z.object({
roundId: z.string(),
criteriaText: z.string().min(1).max(5000),
}),
)
.mutation(async ({ ctx, input }): Promise<ParsedRankingRule[]> => {
const rules = await parseRankingCriteria(input.criteriaText, ctx.user.id, input.roundId)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'RANKING_CRITERIA_PARSED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { ruleCount: rules.length },
})
return rules
}),
/**
* Execute ranking using pre-parsed rules (confirmed mode).
* Fetches both categories in parallel, persists a RankingSnapshot.
* RANK-05, RANK-06, RANK-08.
*/
executeRanking: adminProcedure
.input(
z.object({
roundId: z.string(),
criteriaText: z.string(),
parsedRules: z.array(ParsedRuleSchema),
}),
)
.mutation(async ({ ctx, input }) => {
// Cast to service type — validated by Zod above
const rules = input.parsedRules as ParsedRankingRule[]
// Fetch and rank both categories in parallel
const [startup, concept] = await Promise.all([
fetchAndRankCategory('STARTUP', rules, input.roundId, ctx.prisma, ctx.user.id),
fetchAndRankCategory('BUSINESS_CONCEPT', rules, input.roundId, ctx.prisma, ctx.user.id),
])
// Persist snapshot
const snapshot = await ctx.prisma.rankingSnapshot.create({
data: {
roundId: input.roundId,
triggeredById: ctx.user.id,
triggerType: 'MANUAL',
mode: 'CONFIRMED',
status: 'COMPLETED',
criteriaText: input.criteriaText,
parsedRulesJson: rules as unknown as Prisma.InputJsonValue,
startupRankingJson: startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: concept.rankedProjects as unknown as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'RANKING_EXECUTED',
entityType: 'RankingSnapshot',
entityId: snapshot.id,
detailsJson: {
roundId: input.roundId,
startupCount: startup.rankedProjects.length,
conceptCount: concept.rankedProjects.length,
},
})
return { snapshotId: snapshot.id, startup, concept }
}),
/**
* Quick rank: parse criteria and execute in one step (RANK-04).
* Persists a RankingSnapshot with mode=QUICK.
*/
quickRank: adminProcedure
.input(
z.object({
roundId: z.string(),
criteriaText: z.string().min(1).max(5000),
}),
)
.mutation(async ({ ctx, input }) => {
const { startup, concept, parsedRules } = await aiQuickRank(
input.criteriaText,
input.roundId,
ctx.prisma,
ctx.user.id,
)
// Persist snapshot
const snapshot = await ctx.prisma.rankingSnapshot.create({
data: {
roundId: input.roundId,
triggeredById: ctx.user.id,
triggerType: 'QUICK',
mode: 'QUICK',
status: 'COMPLETED',
criteriaText: input.criteriaText,
parsedRulesJson: parsedRules as unknown as Prisma.InputJsonValue,
startupRankingJson: startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: concept.rankedProjects as unknown as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'RANKING_QUICK_EXECUTED',
entityType: 'RankingSnapshot',
entityId: snapshot.id,
detailsJson: {
roundId: input.roundId,
startupCount: startup.rankedProjects.length,
conceptCount: concept.rankedProjects.length,
},
})
return { snapshotId: snapshot.id, startup, concept, parsedRules }
}),
/**
* List all ranking snapshots for a round (Phase 2 dashboard prep).
* Ordered by createdAt desc newest first.
*/
listSnapshots: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.rankingSnapshot.findMany({
where: { roundId: input.roundId },
orderBy: { createdAt: 'desc' },
select: {
id: true,
triggeredById: true,
triggerType: true,
mode: true,
status: true,
criteriaText: true,
tokensUsed: true,
createdAt: true,
triggeredBy: {
select: { name: true },
},
},
})
}),
/**
* Get a single ranking snapshot by ID (Phase 2 dashboard prep).
* Returns full snapshot including ranking results.
*/
getSnapshot: adminProcedure
.input(z.object({ snapshotId: z.string() }))
.query(async ({ ctx, input }) => {
const snapshot = await ctx.prisma.rankingSnapshot.findUnique({
where: { id: input.snapshotId },
})
if (!snapshot) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Ranking snapshot ${input.snapshotId} not found`,
})
}
return snapshot
}),
/**
* RANK-09 Manual trigger for auto-rank (admin button on round detail page).
* Reads ranking criteria from round configJson and executes quickRank.
*/
triggerAutoRank: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { roundId } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true, configJson: true, competition: { select: { id: true } } },
})
const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig)
const criteriaText = config?.rankingCriteria ?? null
if (!criteriaText) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No ranking criteria configured for this round. Add criteria in round settings first.',
})
}
const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id)
const snapshot = await ctx.prisma.rankingSnapshot.create({
data: {
roundId,
triggeredById: ctx.user.id,
triggerType: 'MANUAL',
criteriaText,
parsedRulesJson: result.parsedRules as unknown as Prisma.InputJsonValue,
startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue,
mode: 'QUICK',
status: 'COMPLETED',
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'RANKING_MANUAL_TRIGGERED',
entityType: 'RankingSnapshot',
entityId: snapshot.id,
detailsJson: { roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { snapshotId: snapshot.id, startup: result.startup, concept: result.concept }
}),
/**
* RANK-10 Retroactive scan: finds all active/closed rounds with autoRankOnComplete
* configured but no RETROACTIVE snapshot yet, then executes ranking for each.
* Runs sequentially to avoid hammering OpenAI.
*/
retroactiveScan: adminProcedure
.input(z.object({}))
.mutation(async ({ ctx }) => {
const rounds = await ctx.prisma.round.findMany({
where: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
select: { id: true, name: true, configJson: true },
})
const results: Array<{ roundId: string; triggered: boolean; reason?: string }> = []
for (const round of rounds) {
const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig)
const autoRankEnabled = config?.autoRankOnComplete ?? false
const criteriaText = config?.rankingCriteria ?? null
if (!autoRankEnabled || !criteriaText) {
results.push({ roundId: round.id, triggered: false, reason: 'auto-rank not configured' })
continue
}
// Check if fully evaluated
const [total, completed] = await Promise.all([
ctx.prisma.assignment.count({ where: { roundId: round.id, isRequired: true } }),
ctx.prisma.assignment.count({ where: { roundId: round.id, isRequired: true, isCompleted: true } }),
])
if (total === 0 || total !== completed) {
results.push({
roundId: round.id,
triggered: false,
reason: `${completed}/${total} assignments complete`,
})
continue
}
// Check if a RETROACTIVE snapshot already exists
const existing = await ctx.prisma.rankingSnapshot.findFirst({
where: { roundId: round.id, triggerType: 'RETROACTIVE' },
select: { id: true },
})
if (existing) {
results.push({ roundId: round.id, triggered: false, reason: 'retroactive snapshot already exists' })
continue
}
// Execute ranking sequentially to avoid rate limits
try {
const result = await aiQuickRank(criteriaText, round.id, ctx.prisma, ctx.user.id)
await ctx.prisma.rankingSnapshot.create({
data: {
roundId: round.id,
triggeredById: ctx.user.id,
triggerType: 'RETROACTIVE',
criteriaText,
parsedRulesJson: result.parsedRules as unknown as Prisma.InputJsonValue,
startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue,
mode: 'QUICK',
status: 'COMPLETED',
},
})
results.push({ roundId: round.id, triggered: true })
} catch (err) {
results.push({ roundId: round.id, triggered: false, reason: String(err) })
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'RANKING_RETROACTIVE_SCAN',
entityType: 'Round',
detailsJson: { results },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
results,
total: results.length,
triggered: results.filter((r) => r.triggered).length,
}
}),
})