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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user