Simplify routing to award assignment, seed all CSV entries, fix category mapping
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m3s

- Remove RoutingRule model and routing engine (replaced by direct award assignment)
- Simplify RoutingMode enum: PARALLEL/POST_MAIN → SHARED, keep EXCLUSIVE
- Remove routing router, routing-rules-editor, and related tests
- Update pipeline, award, and notification code to remove routing references
- Seed: include all CSV entries (no filtering/dedup), AI screening handles duplicates
- Seed: fix non-breaking space (U+00A0) bug in category/issue mapping
- Stage filtering: add duplicate detection that flags projects for admin review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 14:25:05 +01:00
parent 382570cebd
commit 9ab4717f96
23 changed files with 249 additions and 2449 deletions

View File

@@ -1,505 +0,0 @@
/**
* Routing Engine Service
*
* Evaluates routing rules against projects and executes routing decisions
* to move projects between tracks in a pipeline. Supports three routing modes:
*
* - PARALLEL: Keep the project in the current track AND add it to the destination track
* - EXCLUSIVE: Exit the project from the current track and move to the destination track
* - POST_MAIN: Route to destination only after the main track gate is passed
*
* Predicate evaluation supports operators: eq, neq, in, contains, gt, lt
* Compound predicates: and, or
*/
import type { PrismaClient, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
interface PredicateLeaf {
field: string
operator: 'eq' | 'neq' | 'in' | 'contains' | 'gt' | 'lt'
value: unknown
}
interface PredicateCompound {
logic: 'and' | 'or'
conditions: PredicateNode[]
}
type PredicateNode = PredicateLeaf | PredicateCompound
export interface MatchedRule {
ruleId: string
ruleName: string
destinationTrackId: string
destinationStageId: string | null
routingMode: string
priority: number
}
export interface RoutingPreviewItem {
projectId: string
projectTitle: string
matchedRule: MatchedRule | null
reason: string
}
export interface RoutingExecutionResult {
success: boolean
projectStageStateId: string | null
errors?: string[]
}
// ─── Predicate Evaluation ───────────────────────────────────────────────────
function isCompoundPredicate(node: unknown): node is PredicateCompound {
return (
typeof node === 'object' &&
node !== null &&
'logic' in node &&
'conditions' in node
)
}
function isLeafPredicate(node: unknown): node is PredicateLeaf {
return (
typeof node === 'object' &&
node !== null &&
'field' in node &&
'operator' in node
)
}
function evaluateLeaf(
leaf: PredicateLeaf,
context: Record<string, unknown>
): boolean {
const fieldValue = resolveField(context, leaf.field)
switch (leaf.operator) {
case 'eq':
return fieldValue === leaf.value
case 'neq':
return fieldValue !== leaf.value
case 'in': {
if (!Array.isArray(leaf.value)) return false
return leaf.value.includes(fieldValue)
}
case 'contains': {
if (typeof fieldValue === 'string' && typeof leaf.value === 'string') {
return fieldValue.toLowerCase().includes(leaf.value.toLowerCase())
}
if (Array.isArray(fieldValue)) {
return fieldValue.includes(leaf.value)
}
return false
}
case 'gt':
return Number(fieldValue) > Number(leaf.value)
case 'lt':
return Number(fieldValue) < Number(leaf.value)
default:
return false
}
}
/**
* Resolve a dot-notation field path from a context object.
* E.g. "project.country" resolves context.project.country
*/
function resolveField(
context: Record<string, unknown>,
fieldPath: string
): unknown {
const parts = fieldPath.split('.')
let current: unknown = context
for (const part of parts) {
if (current === null || current === undefined) return undefined
if (typeof current !== 'object') return undefined
current = (current as Record<string, unknown>)[part]
}
return current
}
function evaluatePredicate(
node: PredicateNode,
context: Record<string, unknown>
): boolean {
if (isCompoundPredicate(node)) {
const results = node.conditions.map((child) =>
evaluatePredicate(child, context)
)
return node.logic === 'and'
? results.every(Boolean)
: results.some(Boolean)
}
if (isLeafPredicate(node)) {
return evaluateLeaf(node, context)
}
// Unknown node type, fail closed
return false
}
// ─── Build Project Context ──────────────────────────────────────────────────
async function buildProjectContext(
projectId: string,
currentStageId: string,
prisma: PrismaClient | any
): Promise<Record<string, unknown>> {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: { select: { fileType: true, fileName: true } },
projectTags: { include: { tag: true } },
filteringResults: {
where: { stageId: currentStageId },
take: 1,
orderBy: { createdAt: 'desc' as const },
},
assignments: {
where: { stageId: currentStageId },
include: { evaluation: true },
},
projectStageStates: {
where: { stageId: currentStageId, exitedAt: null },
take: 1,
},
},
})
if (!project) return {}
const evaluations = project.assignments
.map((a: any) => a.evaluation)
.filter(Boolean)
const submittedEvals = evaluations.filter(
(e: any) => e.status === 'SUBMITTED'
)
const avgScore =
submittedEvals.length > 0
? submittedEvals.reduce(
(sum: number, e: any) => sum + (e.globalScore ?? 0),
0
) / submittedEvals.length
: 0
const filteringResult = project.filteringResults[0] ?? null
const currentPSS = project.projectStageStates[0] ?? null
return {
project: {
id: project.id,
title: project.title,
status: project.status,
country: project.country,
competitionCategory: project.competitionCategory,
oceanIssue: project.oceanIssue,
tags: project.tags,
wantsMentorship: project.wantsMentorship,
fileCount: project.files.length,
},
tags: project.projectTags.map((pt: any) => pt.tag.name),
evaluation: {
count: evaluations.length,
submittedCount: submittedEvals.length,
averageScore: avgScore,
},
filtering: {
outcome: filteringResult?.outcome ?? null,
finalOutcome: filteringResult?.finalOutcome ?? null,
},
state: currentPSS?.state ?? null,
}
}
// ─── Evaluate Routing Rules ─────────────────────────────────────────────────
/**
* Load active routing rules for a pipeline, evaluate predicates against a
* project's context, and return the first matching rule (by priority, lowest first).
*/
export async function evaluateRoutingRules(
projectId: string,
currentStageId: string,
pipelineId: string,
prisma: PrismaClient | any
): Promise<MatchedRule | null> {
const rules = await prisma.routingRule.findMany({
where: {
pipelineId,
isActive: true,
},
include: {
destinationTrack: true,
sourceTrack: true,
},
orderBy: { priority: 'asc' as const },
})
if (rules.length === 0) return null
const context = await buildProjectContext(projectId, currentStageId, prisma)
for (const rule of rules) {
// If rule has a sourceTrackId, check that the project is in that track
if (rule.sourceTrackId) {
const inSourceTrack = await prisma.projectStageState.findFirst({
where: {
projectId,
trackId: rule.sourceTrackId,
exitedAt: null,
},
})
if (!inSourceTrack) continue
}
const predicateJson = rule.predicateJson as unknown as PredicateNode
if (evaluatePredicate(predicateJson, context)) {
return {
ruleId: rule.id,
ruleName: rule.name,
destinationTrackId: rule.destinationTrackId,
destinationStageId: rule.destinationStageId ?? null,
routingMode: rule.destinationTrack.routingMode ?? 'EXCLUSIVE',
priority: rule.priority,
}
}
}
return null
}
// ─── Execute Routing ────────────────────────────────────────────────────────
/**
* Execute a routing decision for a project based on the matched rule.
*
* PARALLEL mode: Keep the project in its current track, add a new PSS in the
* destination track's first stage (or specified destination stage).
* EXCLUSIVE mode: Exit the current PSS and create a new PSS in the destination.
* POST_MAIN mode: Validate that the project PASSED the main track gate before routing.
*/
export async function executeRouting(
projectId: string,
matchedRule: MatchedRule,
actorId: string,
prisma: PrismaClient | any
): Promise<RoutingExecutionResult> {
try {
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
// Determine destination stage
let destinationStageId = matchedRule.destinationStageId
if (!destinationStageId) {
// Find the first stage in the destination track (by sortOrder)
const firstStage = await tx.stage.findFirst({
where: { trackId: matchedRule.destinationTrackId },
orderBy: { sortOrder: 'asc' as const },
})
if (!firstStage) {
throw new Error(
`No stages found in destination track ${matchedRule.destinationTrackId}`
)
}
destinationStageId = firstStage.id
}
// Mode-specific logic
if (matchedRule.routingMode === 'POST_MAIN') {
// Validate that the project has passed the main track gate
const mainTrack = await tx.track.findFirst({
where: {
pipeline: {
tracks: {
some: { id: matchedRule.destinationTrackId },
},
},
kind: 'MAIN',
},
})
if (mainTrack) {
const mainPSS = await tx.projectStageState.findFirst({
where: {
projectId,
trackId: mainTrack.id,
state: { in: ['PASSED', 'COMPLETED'] },
},
orderBy: { exitedAt: 'desc' as const },
})
if (!mainPSS) {
throw new Error(
'POST_MAIN routing requires the project to have passed the main track gate'
)
}
}
}
if (matchedRule.routingMode === 'EXCLUSIVE') {
// Exit all active PSS for this project in any track of the same pipeline
const activePSSRecords = await tx.projectStageState.findMany({
where: {
projectId,
exitedAt: null,
},
})
for (const pss of activePSSRecords) {
await tx.projectStageState.update({
where: { id: pss.id },
data: {
exitedAt: now,
state: 'ROUTED',
},
})
}
}
// Create PSS in destination track/stage
const destPSS = await tx.projectStageState.upsert({
where: {
projectId_trackId_stageId: {
projectId,
trackId: matchedRule.destinationTrackId,
stageId: destinationStageId,
},
},
create: {
projectId,
trackId: matchedRule.destinationTrackId,
stageId: destinationStageId,
state: 'PENDING',
enteredAt: now,
},
update: {
state: 'PENDING',
enteredAt: now,
exitedAt: null,
},
})
// Log DecisionAuditLog
await tx.decisionAuditLog.create({
data: {
eventType: 'routing.executed',
entityType: 'ProjectStageState',
entityId: destPSS.id,
actorId,
detailsJson: {
projectId,
ruleId: matchedRule.ruleId,
ruleName: matchedRule.ruleName,
routingMode: matchedRule.routingMode,
destinationTrackId: matchedRule.destinationTrackId,
destinationStageId,
},
snapshotJson: {
destPSSId: destPSS.id,
timestamp: now.toISOString(),
},
},
})
// AuditLog
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUTING_EXECUTED',
entityType: 'RoutingRule',
entityId: matchedRule.ruleId,
detailsJson: {
projectId,
routingMode: matchedRule.routingMode,
destinationTrackId: matchedRule.destinationTrackId,
destinationStageId,
},
})
return destPSS
})
return {
success: true,
projectStageStateId: result.id,
}
} catch (error) {
console.error('[RoutingEngine] Routing execution failed:', error)
return {
success: false,
projectStageStateId: null,
errors: [
error instanceof Error
? error.message
: 'Unknown error during routing execution',
],
}
}
}
// ─── Preview Routing ────────────────────────────────────────────────────────
/**
* Dry-run evaluation of routing rules for a batch of projects.
* Does not modify any data.
*/
export async function previewRouting(
projectIds: string[],
pipelineId: string,
prisma: PrismaClient | any
): Promise<RoutingPreviewItem[]> {
const preview: RoutingPreviewItem[] = []
// Load projects with their current stage states
const projects = await prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
projectStageStates: {
where: { exitedAt: null },
select: { stageId: true, trackId: true, state: true },
},
},
})
for (const project of projects) {
const activePSS = project.projectStageStates[0]
if (!activePSS) {
preview.push({
projectId: project.id,
projectTitle: project.title,
matchedRule: null,
reason: 'No active stage state found',
})
continue
}
const matchedRule = await evaluateRoutingRules(
project.id,
activePSS.stageId,
pipelineId,
prisma
)
preview.push({
projectId: project.id,
projectTitle: project.title,
matchedRule,
reason: matchedRule
? `Matched rule "${matchedRule.ruleName}" (priority ${matchedRule.priority})`
: 'No routing rules matched',
})
}
return preview
}

View File

@@ -261,6 +261,34 @@ export async function runStageFiltering(
)
const aiRules = rules.filter((r: any) => r.ruleType === 'AI_SCREENING')
// ── Built-in: Duplicate submission detection ──────────────────────────────
// Group projects by submitter email to detect duplicate submissions.
// Duplicates are ALWAYS flagged for admin review (never auto-rejected).
const duplicateProjectIds = new Set<string>()
const emailToProjects = new Map<string, Array<{ id: string; title: string }>>()
for (const project of projects) {
const email = (project.submittedByEmail ?? '').toLowerCase().trim()
if (!email) continue
if (!emailToProjects.has(email)) emailToProjects.set(email, [])
emailToProjects.get(email)!.push({ id: project.id, title: project.title })
}
const duplicateGroups: Map<string, string[]> = new Map() // projectId → sibling ids
emailToProjects.forEach((group, _email) => {
if (group.length <= 1) return
const ids = group.map((p) => p.id)
for (const p of group) {
duplicateProjectIds.add(p.id)
duplicateGroups.set(p.id, ids.filter((id) => id !== p.id))
}
})
if (duplicateProjectIds.size > 0) {
console.log(`[Stage Filtering] Detected ${duplicateProjectIds.size} projects in duplicate groups`)
}
// ── End duplicate detection ───────────────────────────────────────────────
let passed = 0
let rejected = 0
let manualQueue = 0
@@ -271,6 +299,20 @@ export async function runStageFiltering(
let deterministicPassed = true
let deterministicOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = 'PASSED'
// 0. Check for duplicate submissions (always FLAG, never auto-reject)
if (duplicateProjectIds.has(project.id)) {
const siblingIds = duplicateGroups.get(project.id) ?? []
ruleResults.push({
ruleId: '__duplicate_check',
ruleName: 'Duplicate Submission Check',
ruleType: 'DUPLICATE_CHECK',
passed: false,
action: 'FLAG',
reasoning: `Duplicate submission detected: same applicant email submitted ${siblingIds.length + 1} project(s). Sibling project IDs: ${siblingIds.join(', ')}. Admin must review and decide which to keep.`,
})
deterministicOutcome = 'FLAGGED'
}
// 1. Run deterministic rules
for (const rule of deterministicRules) {
const config = rule.configJson as unknown as RuleConfig
@@ -312,11 +354,12 @@ export async function runStageFiltering(
}
}
// 2. AI screening (only if deterministic passed)
// 2. AI screening (run if deterministic passed, OR if duplicate—so AI can recommend which to keep)
const isDuplicate = duplicateProjectIds.has(project.id)
let aiScreeningJson: Record<string, unknown> | null = null
let finalOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = deterministicOutcome
if (deterministicPassed && aiRules.length > 0) {
if ((deterministicPassed || isDuplicate) && aiRules.length > 0) {
// Build a simplified AI screening result using the existing AI criteria
// In production this would call OpenAI via the ai-filtering service
const aiRule = aiRules[0]
@@ -337,12 +380,25 @@ export async function runStageFiltering(
: 'Insufficient project data for AI screening',
}
// Attach duplicate metadata so admin can see sibling projects
if (isDuplicate) {
const siblingIds = duplicateGroups.get(project.id) ?? []
aiScreeningJson.isDuplicate = true
aiScreeningJson.siblingProjectIds = siblingIds
aiScreeningJson.duplicateNote =
`This project shares a submitter email with ${siblingIds.length} other project(s). ` +
'AI screening should compare these and recommend which to keep.'
}
const banded = bandByConfidence({
confidence,
meetsAllCriteria: hasMinimalData,
})
finalOutcome = banded.outcome
// For non-duplicate projects, use AI banding; for duplicates, keep FLAGGED
if (!isDuplicate) {
finalOutcome = banded.outcome
}
ruleResults.push({
ruleId: aiRule.id,
@@ -354,6 +410,12 @@ export async function runStageFiltering(
})
}
// Duplicate submissions must ALWAYS be flagged for admin review,
// even if other rules would auto-reject them.
if (duplicateProjectIds.has(project.id) && finalOutcome === 'FILTERED_OUT') {
finalOutcome = 'FLAGGED'
}
await prisma.filteringResult.upsert({
where: {
stageId_projectId: {

View File

@@ -8,7 +8,7 @@
*
* Event types follow a dotted convention:
* stage.transitioned, filtering.completed, assignment.generated,
* routing.executed, live.cursor_updated, decision.overridden
* live.cursor_updated, decision.overridden
*/
import type { PrismaClient, Prisma } from '@prisma/client'
@@ -32,7 +32,6 @@ const EVENT_TYPES = {
STAGE_TRANSITIONED: 'stage.transitioned',
FILTERING_COMPLETED: 'filtering.completed',
ASSIGNMENT_GENERATED: 'assignment.generated',
ROUTING_EXECUTED: 'routing.executed',
CURSOR_UPDATED: 'live.cursor_updated',
DECISION_OVERRIDDEN: 'decision.overridden',
} as const
@@ -41,7 +40,6 @@ const EVENT_TITLES: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'Stage Transition',
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filtering Complete',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'Assignments Generated',
[EVENT_TYPES.ROUTING_EXECUTED]: 'Routing Executed',
[EVENT_TYPES.CURSOR_UPDATED]: 'Live Cursor Updated',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'Decision Overridden',
}
@@ -50,7 +48,6 @@ const EVENT_ICONS: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'ArrowRight',
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filter',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'ClipboardList',
[EVENT_TYPES.ROUTING_EXECUTED]: 'GitBranch',
[EVENT_TYPES.CURSOR_UPDATED]: 'Play',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'ShieldAlert',
}
@@ -59,7 +56,6 @@ const EVENT_PRIORITIES: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'normal',
[EVENT_TYPES.FILTERING_COMPLETED]: 'high',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'high',
[EVENT_TYPES.ROUTING_EXECUTED]: 'normal',
[EVENT_TYPES.CURSOR_UPDATED]: 'low',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'high',
}
@@ -220,7 +216,6 @@ async function resolveRecipients(
case EVENT_TYPES.STAGE_TRANSITIONED:
case EVENT_TYPES.FILTERING_COMPLETED:
case EVENT_TYPES.ASSIGNMENT_GENERATED:
case EVENT_TYPES.ROUTING_EXECUTED:
case EVENT_TYPES.DECISION_OVERRIDDEN: {
// Notify admins
const admins = await prisma.user.findMany({
@@ -311,12 +306,6 @@ function buildNotificationMessage(
return `${count ?? 0} assignments were generated for the stage.`
}
case EVENT_TYPES.ROUTING_EXECUTED: {
const ruleName = details.ruleName as string | undefined
const routingMode = details.routingMode as string | undefined
return `Routing rule "${ruleName ?? 'unknown'}" executed in ${routingMode ?? 'unknown'} mode.`
}
case EVENT_TYPES.CURSOR_UPDATED: {
const projectId = details.projectId as string | undefined
const action = details.action as string | undefined
@@ -419,34 +408,6 @@ export async function onAssignmentGenerated(
)
}
/**
* Emit a routing.executed event when a project is routed to a new track.
* Called from routing-engine.ts after executeRouting.
*/
export async function onRoutingExecuted(
ruleId: string,
projectId: string,
ruleName: string,
routingMode: string,
destinationTrackId: string,
actorId: string,
prisma: PrismaClient | any
): Promise<void> {
await emitStageEvent(
EVENT_TYPES.ROUTING_EXECUTED,
'RoutingRule',
ruleId,
actorId,
{
projectId,
ruleName,
routingMode,
destinationTrackId,
},
prisma
)
}
/**
* Emit a live.cursor_updated event when the live cursor position changes.
* Called from live-control.ts after setActiveProject or jumpToProject.