Pipeline UX: clickable cards, wizard edit, routing rules redesign, category quotas
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s
- Simplify pipeline list cards: whole card is clickable, remove clutter - Add wizard edit page for existing pipelines with full state pre-population - Extract toWizardTrackConfig to shared utility for reuse - Rewrite predicate builder with 3 modes: Simple (sentence-style), AI (NLP), Advanced (JSON) - Fix routing operators to match backend (eq/neq/in/contains/gt/lt) - Rewrite routing rules editor with collapsible cards and natural language summaries - Add parseNaturalLanguageRule AI procedure for routing rules - Add per-category quotas to SelectionConfig and EvaluationConfig - Add category quota UI toggles to selection and assignment sections - Add category breakdown display to selection panel - Add category-aware scoring to smart assignment (penalty/bonus) - Add category-aware filtering targets with excess demotion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,14 +34,14 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
|
||||
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
||||
const minAssignmentsPerJuror =
|
||||
(config.minLoadPerJuror as number) ??
|
||||
(config.minAssignmentsPerJuror as number) ??
|
||||
1
|
||||
const maxAssignmentsPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
const minAssignmentsPerJuror =
|
||||
(config.minLoadPerJuror as number) ??
|
||||
(config.minAssignmentsPerJuror as number) ??
|
||||
1
|
||||
const maxAssignmentsPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
const jurors = await prisma.user.findMany({
|
||||
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
||||
@@ -339,10 +339,10 @@ export const assignmentRouter = router({
|
||||
])
|
||||
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
const maxAssignmentsPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
const maxAssignmentsPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
|
||||
|
||||
const currentCount = await ctx.prisma.assignment.count({
|
||||
@@ -461,10 +461,10 @@ export const assignmentRouter = router({
|
||||
select: { configJson: true, name: true, windowCloseAt: true },
|
||||
})
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
const stageMaxPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
const stageMaxPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Track running counts to handle multiple assignments to the same juror in one batch
|
||||
const runningCounts = new Map<string, number>()
|
||||
@@ -680,14 +680,20 @@ export const assignmentRouter = router({
|
||||
})
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
||||
const minAssignmentsPerJuror =
|
||||
(config.minLoadPerJuror as number) ??
|
||||
(config.minAssignmentsPerJuror as number) ??
|
||||
1
|
||||
const maxAssignmentsPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
const minAssignmentsPerJuror =
|
||||
(config.minLoadPerJuror as number) ??
|
||||
(config.minAssignmentsPerJuror as number) ??
|
||||
1
|
||||
const maxAssignmentsPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Extract category quotas if enabled
|
||||
const categoryQuotasEnabled = config.categoryQuotasEnabled === true
|
||||
const categoryQuotas = categoryQuotasEnabled
|
||||
? (config.categoryQuotas as Record<string, { min: number; max: number }> | undefined)
|
||||
: undefined
|
||||
|
||||
const jurors = await ctx.prisma.user.findMany({
|
||||
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
||||
@@ -717,6 +723,7 @@ export const assignmentRouter = router({
|
||||
id: true,
|
||||
title: true,
|
||||
tags: true,
|
||||
competitionCategory: true,
|
||||
projectTags: {
|
||||
include: { tag: { select: { name: true } } },
|
||||
},
|
||||
@@ -732,6 +739,28 @@ export const assignmentRouter = router({
|
||||
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
||||
)
|
||||
|
||||
// Build per-juror category distribution for quota scoring
|
||||
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
|
||||
if (categoryQuotas) {
|
||||
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
select: {
|
||||
userId: true,
|
||||
project: { select: { competitionCategory: true } },
|
||||
},
|
||||
})
|
||||
for (const a of assignmentsWithCategory) {
|
||||
const cat = a.project.competitionCategory?.toLowerCase().trim()
|
||||
if (!cat) continue
|
||||
let catMap = jurorCategoryDistribution.get(a.userId)
|
||||
if (!catMap) {
|
||||
catMap = {}
|
||||
jurorCategoryDistribution.set(a.userId, catMap)
|
||||
}
|
||||
catMap[cat] = (catMap[cat] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
const suggestions: Array<{
|
||||
userId: string
|
||||
jurorName: string
|
||||
@@ -796,6 +825,34 @@ export const assignmentRouter = router({
|
||||
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
|
||||
)
|
||||
|
||||
// Category quota scoring
|
||||
if (categoryQuotas) {
|
||||
const jurorCategoryCounts = jurorCategoryDistribution.get(juror.id) || {}
|
||||
const normalizedCat = project.competitionCategory?.toLowerCase().trim()
|
||||
if (normalizedCat) {
|
||||
const quota = Object.entries(categoryQuotas).find(
|
||||
([key]) => key.toLowerCase().trim() === normalizedCat
|
||||
)
|
||||
if (quota) {
|
||||
const [, { min, max }] = quota
|
||||
const currentCount = jurorCategoryCounts[normalizedCat] || 0
|
||||
if (currentCount >= max) {
|
||||
score -= 25
|
||||
reasoning.push(`Category quota exceeded (-25)`)
|
||||
} else if (currentCount < min) {
|
||||
const otherAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
|
||||
if (key.toLowerCase().trim() === normalizedCat) return false
|
||||
return (jurorCategoryCounts[key.toLowerCase().trim()] || 0) >= q.min
|
||||
})
|
||||
if (otherAboveMin) {
|
||||
score += 10
|
||||
reasoning.push(`Category quota bonus (+10)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userId: juror.id,
|
||||
jurorName: juror.name || juror.email || 'Unknown',
|
||||
@@ -932,10 +989,10 @@ export const assignmentRouter = router({
|
||||
select: { configJson: true },
|
||||
})
|
||||
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||
const stageMaxPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
const stageMaxPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
const runningCounts = new Map<string, number>()
|
||||
for (const u of users) {
|
||||
@@ -1087,10 +1144,10 @@ export const assignmentRouter = router({
|
||||
select: { configJson: true },
|
||||
})
|
||||
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||
const stageMaxPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
const stageMaxPerJuror =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
const runningCounts = new Map<string, number>()
|
||||
for (const u of users) {
|
||||
|
||||
Reference in New Issue
Block a user