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

@@ -37,7 +37,7 @@ import { dashboardRouter } from './dashboard'
// Round redesign Phase 2 routers
import { pipelineRouter } from './pipeline'
import { stageRouter } from './stage'
import { routingRouter } from './routing'
import { stageFilteringRouter } from './stageFiltering'
import { stageAssignmentRouter } from './stageAssignment'
import { cohortRouter } from './cohort'
@@ -87,8 +87,7 @@ export const appRouter = router({
// Round redesign Phase 2 routers
pipeline: pipelineRouter,
stage: stageRouter,
routing: routingRouter,
stageFiltering: stageFilteringRouter,
stageFiltering: stageFilteringRouter,
stageAssignment: stageAssignmentRouter,
cohort: cohortRouter,
live: liveRouter,

View File

@@ -14,7 +14,7 @@ export const awardRouter = router({
pipelineId: z.string(),
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
routingMode: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
routingMode: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
settingsJson: z.record(z.unknown()).optional(),
awardConfig: z.object({

View File

@@ -1,9 +1,9 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
export const pipelineRouter = router({
/**
@@ -186,10 +186,6 @@ export const pipelineRouter = router({
},
},
},
routingRules: {
where: { isActive: true },
orderBy: { priority: 'desc' },
},
},
})
}),
@@ -209,7 +205,7 @@ export const pipelineRouter = router({
_count: { select: { stages: true, projectStageStates: true } },
},
},
_count: { select: { tracks: true, routingRules: true } },
_count: { select: { tracks: true } },
},
})
@@ -244,7 +240,7 @@ export const pipelineRouter = router({
where: { programId: input.programId },
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { tracks: true, routingRules: true } },
_count: { select: { tracks: true } },
},
})
}),
@@ -327,7 +323,7 @@ export const pipelineRouter = router({
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
sortOrder: z.number().int().min(0),
routingModeDefault: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
routingModeDefault: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
stages: z.array(
z.object({
@@ -399,40 +395,40 @@ export const pipelineRouter = router({
},
})
// 3. Create stages for this track
const createdStages: Array<{ id: string; name: string; sortOrder: number }> = []
for (const stageInput of trackInput.stages) {
let parsedConfig: Prisma.InputJsonValue | undefined
if (stageInput.configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
stageInput.stageType,
stageInput.configJson,
{ strictUnknownKeys: true }
)
parsedConfig = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: `Invalid config for stage ${stageInput.name}`,
})
}
}
const stage = await tx.stage.create({
data: {
trackId: track.id,
name: stageInput.name,
slug: stageInput.slug,
stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder,
configJson: parsedConfig,
},
})
createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder })
// 3. Create stages for this track
const createdStages: Array<{ id: string; name: string; sortOrder: number }> = []
for (const stageInput of trackInput.stages) {
let parsedConfig: Prisma.InputJsonValue | undefined
if (stageInput.configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
stageInput.stageType,
stageInput.configJson,
{ strictUnknownKeys: true }
)
parsedConfig = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: `Invalid config for stage ${stageInput.name}`,
})
}
}
const stage = await tx.stage.create({
data: {
trackId: track.id,
name: stageInput.name,
slug: stageInput.slug,
stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder,
configJson: parsedConfig,
},
})
createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder })
}
// Create SpecialAward if AWARD kind
@@ -524,32 +520,25 @@ export const pipelineRouter = router({
},
},
},
specialAward: {
select: {
id: true,
name: true,
description: true,
criteriaText: true,
useAiEligibility: true,
scoringMode: true,
maxRankedPicks: true,
votingStartAt: true,
votingEndAt: true,
status: true,
},
},
specialAward: {
select: {
id: true,
name: true,
description: true,
criteriaText: true,
useAiEligibility: true,
scoringMode: true,
maxRankedPicks: true,
votingStartAt: true,
votingEndAt: true,
status: true,
},
},
_count: {
select: { projectStageStates: true },
},
},
},
routingRules: {
orderBy: { priority: 'desc' },
include: {
sourceTrack: { select: { id: true, name: true } },
destinationTrack: { select: { id: true, name: true } },
},
},
},
})
@@ -573,7 +562,7 @@ export const pipelineRouter = router({
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
sortOrder: z.number().int().min(0),
routingModeDefault: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
routingModeDefault: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
stages: z.array(
z.object({
@@ -738,52 +727,52 @@ export const pipelineRouter = router({
}
}
// Create or update stages
for (const stageInput of trackInput.stages) {
let parsedConfig: Prisma.InputJsonValue | undefined
if (stageInput.configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
stageInput.stageType,
stageInput.configJson,
{ strictUnknownKeys: true }
)
parsedConfig = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: `Invalid config for stage ${stageInput.name}`,
})
}
}
if (stageInput.id) {
await tx.stage.update({
where: { id: stageInput.id },
data: {
name: stageInput.name,
slug: stageInput.slug,
stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder,
configJson: parsedConfig,
},
})
allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId })
} else {
const newStage = await tx.stage.create({
data: {
trackId,
name: stageInput.name,
slug: stageInput.slug,
stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder,
configJson: parsedConfig,
},
})
allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId })
// Create or update stages
for (const stageInput of trackInput.stages) {
let parsedConfig: Prisma.InputJsonValue | undefined
if (stageInput.configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
stageInput.stageType,
stageInput.configJson,
{ strictUnknownKeys: true }
)
parsedConfig = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: `Invalid config for stage ${stageInput.name}`,
})
}
}
if (stageInput.id) {
await tx.stage.update({
where: { id: stageInput.id },
data: {
name: stageInput.name,
slug: stageInput.slug,
stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder,
configJson: parsedConfig,
},
})
allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId })
} else {
const newStage = await tx.stage.create({
data: {
trackId,
name: stageInput.name,
slug: stageInput.slug,
stageType: stageInput.stageType,
sortOrder: stageInput.sortOrder,
configJson: parsedConfig,
},
})
allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId })
}
}
}
@@ -866,10 +855,6 @@ export const pipelineRouter = router({
tracks: {
include: { stages: { orderBy: { sortOrder: 'asc' } } },
},
routingRules: {
where: { isActive: true },
orderBy: { priority: 'desc' },
},
},
})
@@ -895,26 +880,8 @@ export const pipelineRouter = router({
// Simulate: for each project, determine which track/stage it would land in
const mainTrack = pipeline.tracks.find((t) => t.kind === 'MAIN')
const simulations = projects.map((project) => {
// Default: route to first stage of MAIN track
let targetTrack = mainTrack
let targetStage = mainTrack?.stages[0] ?? null
// Check routing rules (highest priority first)
for (const rule of pipeline.routingRules) {
const predicate = rule.predicateJson as Record<string, unknown>
if (predicate && evaluateSimplePredicate(predicate, project)) {
const destTrack = pipeline.tracks.find(
(t) => t.id === rule.destinationTrackId
)
if (destTrack) {
targetTrack = destTrack
targetStage = rule.destinationStageId
? destTrack.stages.find((s) => s.id === rule.destinationStageId) ?? destTrack.stages[0]
: destTrack.stages[0]
break
}
}
}
const targetTrack = mainTrack
const targetStage = mainTrack?.stages[0] ?? null
return {
projectId: project.id,
@@ -1122,50 +1089,3 @@ export const pipelineRouter = router({
})
/**
* Simple predicate evaluator for simulation.
* Supports basic field matching on project data.
*/
function evaluateSimplePredicate(
predicate: Record<string, unknown>,
project: { tags: string[]; status: string; metadataJson: unknown }
): boolean {
const { field, operator, value } = predicate as {
field?: string
operator?: string
value?: unknown
}
if (!field || !operator) return false
let fieldValue: unknown
if (field === 'tags') {
fieldValue = project.tags
} else if (field === 'status') {
fieldValue = project.status
} else {
// Check metadataJson
const meta = (project.metadataJson as Record<string, unknown>) ?? {}
fieldValue = meta[field]
}
switch (operator) {
case 'equals':
return fieldValue === value
case 'contains':
if (Array.isArray(fieldValue)) return fieldValue.includes(value)
if (typeof fieldValue === 'string' && typeof value === 'string')
return fieldValue.includes(value)
return false
case 'in':
if (Array.isArray(value)) return value.includes(fieldValue)
return false
case 'hasAny':
if (Array.isArray(fieldValue) && Array.isArray(value))
return fieldValue.some((v) => value.includes(v))
return false
default:
return false
}
}

View File

@@ -1,519 +0,0 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import {
previewRouting,
evaluateRoutingRules,
executeRouting,
} from '@/server/services/routing-engine'
export const routingRouter = router({
/**
* Preview routing: show where projects would land without executing.
* Delegates to routing-engine service for proper predicate evaluation.
*/
preview: adminProcedure
.input(
z.object({
pipelineId: z.string(),
projectIds: z.array(z.string()).min(1).max(500),
})
)
.mutation(async ({ ctx, input }) => {
const results = await previewRouting(
input.projectIds,
input.pipelineId,
ctx.prisma
)
return {
pipelineId: input.pipelineId,
totalProjects: results.length,
results: results.map((r) => ({
projectId: r.projectId,
projectTitle: r.projectTitle,
matchedRuleId: r.matchedRule?.ruleId ?? null,
matchedRuleName: r.matchedRule?.ruleName ?? null,
targetTrackId: r.matchedRule?.destinationTrackId ?? null,
targetTrackName: null as string | null,
targetStageId: r.matchedRule?.destinationStageId ?? null,
targetStageName: null as string | null,
routingMode: r.matchedRule?.routingMode ?? null,
reason: r.reason,
})),
}
}),
/**
* Execute routing: evaluate rules and move projects into tracks/stages.
* Delegates to routing-engine service which enforces PARALLEL/EXCLUSIVE/POST_MAIN modes.
*/
execute: adminProcedure
.input(
z.object({
pipelineId: z.string(),
projectIds: z.array(z.string()).min(1).max(500),
})
)
.mutation(async ({ ctx, input }) => {
// Verify pipeline is ACTIVE
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
where: { id: input.pipelineId },
})
if (pipeline.status !== 'ACTIVE') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Pipeline must be ACTIVE to route projects',
})
}
// Load projects to get their current active stage states
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds } },
select: {
id: true,
title: true,
projectStageStates: {
where: { exitedAt: null },
select: { stageId: true },
take: 1,
},
},
})
if (projects.length === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No matching projects found',
})
}
let routedCount = 0
let skippedCount = 0
const errors: Array<{ projectId: string; error: string }> = []
for (const project of projects) {
const activePSS = project.projectStageStates[0]
if (!activePSS) {
skippedCount++
continue
}
// Evaluate routing rules using the service
const matchedRule = await evaluateRoutingRules(
project.id,
activePSS.stageId,
input.pipelineId,
ctx.prisma
)
if (!matchedRule) {
skippedCount++
continue
}
// Execute routing using the service (handles PARALLEL/EXCLUSIVE/POST_MAIN)
const result = await executeRouting(
project.id,
matchedRule,
ctx.user.id,
ctx.prisma
)
if (result.success) {
routedCount++
} else {
skippedCount++
if (result.errors?.length) {
errors.push({ projectId: project.id, error: result.errors[0] })
}
}
}
// Record batch-level audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ROUTING_EXECUTED',
entityType: 'Pipeline',
entityId: input.pipelineId,
detailsJson: {
projectCount: projects.length,
routedCount,
skippedCount,
errors: errors.length > 0 ? errors : undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { routedCount, skippedCount, totalProjects: projects.length }
}),
/**
* List routing rules for a pipeline
*/
listRules: adminProcedure
.input(z.object({ pipelineId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.routingRule.findMany({
where: { pipelineId: input.pipelineId },
orderBy: [{ isActive: 'desc' }, { priority: 'desc' }],
include: {
sourceTrack: { select: { id: true, name: true } },
destinationTrack: { select: { id: true, name: true } },
},
})
}),
/**
* Create or update a routing rule
*/
upsertRule: adminProcedure
.input(
z.object({
id: z.string().optional(), // If provided, update existing
pipelineId: z.string(),
name: z.string().min(1).max(255),
scope: z.enum(['global', 'track', 'stage']).default('global'),
sourceTrackId: z.string().optional().nullable(),
destinationTrackId: z.string(),
destinationStageId: z.string().optional().nullable(),
predicateJson: z.record(z.unknown()),
priority: z.number().int().min(0).max(1000).default(0),
isActive: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const { id, predicateJson, ...data } = input
// Verify destination track exists in this pipeline
const destTrack = await ctx.prisma.track.findFirst({
where: { id: input.destinationTrackId, pipelineId: input.pipelineId },
})
if (!destTrack) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Destination track must belong to the same pipeline',
})
}
if (id) {
// Update existing rule
const rule = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.routingRule.update({
where: { id },
data: {
...data,
predicateJson: predicateJson as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'RoutingRule',
entityId: id,
detailsJson: { name: input.name, priority: input.priority },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
return rule
} else {
// Create new rule
const rule = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.routingRule.create({
data: {
...data,
predicateJson: predicateJson as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'RoutingRule',
entityId: created.id,
detailsJson: { name: input.name, priority: input.priority },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return rule
}
}),
/**
* Delete a routing rule
*/
deleteRule: adminProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.routingRule.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, pipelineId: true },
})
await ctx.prisma.$transaction(async (tx) => {
await tx.routingRule.delete({
where: { id: input.id },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'RoutingRule',
entityId: input.id,
detailsJson: { name: existing.name, pipelineId: existing.pipelineId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Reorder routing rules by priority (highest first)
*/
reorderRules: adminProcedure
.input(
z.object({
pipelineId: z.string(),
orderedIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const rules = await ctx.prisma.routingRule.findMany({
where: { pipelineId: input.pipelineId },
select: { id: true },
})
const ruleIds = new Set(rules.map((rule) => rule.id))
for (const id of input.orderedIds) {
if (!ruleIds.has(id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Routing rule ${id} does not belong to this pipeline`,
})
}
}
await ctx.prisma.$transaction(async (tx) => {
const maxPriority = input.orderedIds.length
await Promise.all(
input.orderedIds.map((id, index) =>
tx.routingRule.update({
where: { id },
data: {
priority: maxPriority - index,
},
})
)
)
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Pipeline',
entityId: input.pipelineId,
detailsJson: {
action: 'ROUTING_RULES_REORDERED',
ruleCount: input.orderedIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Toggle a routing rule on/off
*/
toggleRule: adminProcedure
.input(
z.object({
id: z.string(),
isActive: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const rule = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.routingRule.update({
where: { id: input.id },
data: { isActive: input.isActive },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: input.isActive ? 'ROUTING_RULE_ENABLED' : 'ROUTING_RULE_DISABLED',
entityType: 'RoutingRule',
entityId: input.id,
detailsJson: { isActive: input.isActive, name: updated.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
return rule
}),
/**
* Parse natural language into a routing rule predicate using AI
*/
parseNaturalLanguageRule: adminProcedure
.input(
z.object({
text: z.string().min(1).max(500),
pipelineId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const openai = await getOpenAI()
if (!openai) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'OpenAI is not configured. Go to Settings to set up the API key.',
})
}
// Load pipeline tracks for context
const tracks = await ctx.prisma.track.findMany({
where: { pipelineId: input.pipelineId },
select: { id: true, name: true },
orderBy: { sortOrder: 'asc' },
})
const trackNames = tracks.map((t) => t.name).join(', ')
const model = await getConfiguredModel()
const systemPrompt = `You are a routing rule parser for a project management pipeline.
Convert the user's natural language description into a structured predicate JSON.
Available fields:
- competitionCategory: The project's competition category (string values like "STARTUP", "BUSINESS_CONCEPT")
- oceanIssue: The ocean issue the project addresses (string)
- country: The project's country of origin (string)
- geographicZone: The geographic zone (string)
- wantsMentorship: Whether the project wants mentorship (boolean: true/false)
- tags: Project tags (array of strings)
Available operators:
- eq: equals (exact match)
- neq: not equals
- in: value is in a list
- contains: string contains substring
- gt: greater than (numeric)
- lt: less than (numeric)
Predicate format:
- Simple condition: { "field": "<field>", "operator": "<op>", "value": "<value>" }
- Compound (AND): { "logic": "and", "conditions": [<condition>, ...] }
- Compound (OR): { "logic": "or", "conditions": [<condition>, ...] }
For boolean fields (wantsMentorship), use value: true or value: false (not strings).
For "in" operator, value should be an array: ["VALUE1", "VALUE2"].
Pipeline tracks: ${trackNames || 'None configured yet'}
Return a JSON object with two keys:
- "predicate": the predicate JSON object
- "explanation": a brief human-readable explanation of what the rule matches
Example input: "projects from France or Monaco that are startups"
Example output:
{
"predicate": {
"logic": "and",
"conditions": [
{ "field": "country", "operator": "in", "value": ["France", "Monaco"] },
{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }
]
},
"explanation": "Matches projects from France or Monaco with competition category STARTUP"
}`
const params = buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: input.text },
],
maxTokens: 1000,
temperature: 0.1,
jsonMode: true,
})
const response = await openai.chat.completions.create(params)
const content = response.choices[0]?.message?.content
if (!content) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI returned an empty response',
})
}
// Log AI usage
const tokenUsage = extractTokenUsage(response)
await logAIUsage({
userId: ctx.user.id,
action: 'ROUTING',
entityType: 'Pipeline',
entityId: input.pipelineId,
model,
...tokenUsage,
itemsProcessed: 1,
status: 'SUCCESS',
detailsJson: { input: input.text },
})
// Parse the response
let parsed: { predicate: Record<string, unknown>; explanation: string }
try {
parsed = JSON.parse(content) as { predicate: Record<string, unknown>; explanation: string }
} catch {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI returned invalid JSON. Try rephrasing your rule.',
})
}
if (!parsed.predicate || typeof parsed.predicate !== 'object') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI response missing predicate. Try rephrasing your rule.',
})
}
return {
predicateJson: parsed.predicate,
explanation: parsed.explanation || 'Parsed routing rule',
}
}),
})