feat(01-04): add auto-trigger hook + triggerAutoRank + retroactiveScan

- evaluation.ts: add triggerAutoRankIfComplete (module-level, not exported)
  - Checks total/completed required assignments for round
  - Reads autoRankOnComplete + rankingCriteria from round configJson
  - 5-minute cooldown guard on AUTO snapshots
  - Fire-and-forget via void call after isCompleted=true (never awaited)
  - Notifies admins via AI_RANKING_COMPLETE / AI_RANKING_FAILED
- ranking.ts: add triggerAutoRank procedure (RANK-09)
  - Admin manual trigger reading criteria from round configJson
  - Creates MANUAL snapshot with QUICK mode
- ranking.ts: add retroactiveScan procedure (RANK-10)
  - Scans ROUND_ACTIVE / ROUND_CLOSED rounds for auto-rank configured
  - Skips rounds with existing RETROACTIVE snapshots
  - Runs sequentially to avoid rate limits
- ranking.ts: router now has 7 total procedures
This commit is contained in:
2026-02-27 01:05:10 +01:00
parent d1d64cb6f7
commit c310631480
4 changed files with 251 additions and 9 deletions

View File

@@ -6,6 +6,99 @@ import { notifyAdmins, NotificationTypes } from '../services/in-app-notification
import { reassignAfterCOI } from './assignment'
import { sendManualReminders } from '../services/evaluation-reminders'
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'
/**
* Auto-trigger AI ranking if all required assignments for the round are complete.
* MUST be called fire-and-forget (void). Never awaited from submission mutation.
* Implements RANK-09 cooldown guard (5-minute window).
*/
async function triggerAutoRankIfComplete(
roundId: string,
prisma: PrismaClient,
userId: string,
): Promise<void> {
try {
// 1. Check if round is fully evaluated
const [total, completed] = await Promise.all([
prisma.assignment.count({ where: { roundId, isRequired: true } }),
prisma.assignment.count({ where: { roundId, isRequired: true, isCompleted: true } }),
])
if (total === 0 || total !== completed) return
// 2. Check round config for auto-ranking settings
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { id: true, name: true, configJson: true, competition: { select: { id: true, name: true } } },
})
if (!round) return
const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig)
const criteriaText = config?.rankingCriteria ?? null
const autoRankEnabled = config?.autoRankOnComplete ?? false
if (!autoRankEnabled || !criteriaText) {
// Auto-ranking not configured for this round — skip silently
return
}
// 3. Cooldown check: skip if AUTO snapshot created in last 5 minutes
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
const recentSnapshot = await prisma.rankingSnapshot.findFirst({
where: { roundId, triggerType: 'AUTO', createdAt: { gte: fiveMinutesAgo } },
select: { id: true },
})
if (recentSnapshot) {
// Duplicate auto-trigger within cooldown window — skip
return
}
// 4. Execute ranking (takes 10-30s, do not await from submission)
const result = await aiQuickRank(criteriaText, roundId, prisma, userId)
// 5. Save snapshot
await prisma.rankingSnapshot.create({
data: {
roundId,
triggeredById: null, // auto-triggered, no specific user
triggerType: 'AUTO',
criteriaText,
parsedRulesJson: result.parsedRules as unknown as import('@prisma/client').Prisma.InputJsonValue,
startupRankingJson: result.startup.rankedProjects as unknown as import('@prisma/client').Prisma.InputJsonValue,
conceptRankingJson: result.concept.rankedProjects as unknown as import('@prisma/client').Prisma.InputJsonValue,
mode: 'QUICK',
status: 'COMPLETED',
},
})
// 6. Notify admins of success
const startupCount = result.startup.rankedProjects.length
const conceptCount = result.concept.rankedProjects.length
await notifyAdmins({
type: NotificationTypes.AI_RANKING_COMPLETE,
title: 'AI Ranking Complete',
message: `Rankings generated for "${round.name}" — ${startupCount} STARTUP, ${conceptCount} BUSINESS_CONCEPT projects ranked.`,
linkUrl: `/admin/competitions/${round.competition.id}/rounds/${roundId}?tab=ranking`,
linkLabel: 'View Rankings',
priority: 'normal',
})
} catch (error) {
// Auto-trigger must never crash the calling mutation
try {
await notifyAdmins({
type: NotificationTypes.AI_RANKING_FAILED,
title: 'AI Ranking Failed',
message: `Auto-ranking failed for round (ID: ${roundId}). Please trigger manually.`,
priority: 'high',
})
} catch {
// Even notification failure must not propagate
}
console.error('[auto-rank] triggerAutoRankIfComplete failed:', error)
}
}
export const evaluationRouter = router({
/**
@@ -281,6 +374,9 @@ export const evaluationRouter = router({
}),
])
// Auto-trigger ranking if all assignments complete (fire-and-forget, never awaited)
void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id)
// Audit log
await logAudit({
prisma: ctx.prisma,

View File

@@ -10,6 +10,7 @@ import {
type ParsedRankingRule,
} from '../services/ai-ranking'
import { logAudit } from '../utils/audit'
import type { EvaluationConfig } from '@/types/competition-configs'
// ─── Zod Schemas ──────────────────────────────────────────────────────────────
@@ -202,4 +203,147 @@ export const rankingRouter = router({
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,
}
}),
})