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

- 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:
Matt
2026-02-14 20:10:24 +01:00
parent c634982835
commit 382570cebd
17 changed files with 2577 additions and 1095 deletions

View File

@@ -35,6 +35,7 @@ export interface ScoreBreakdown {
previousRoundFamiliarity: number
coiPenalty: number
availabilityPenalty: number
categoryQuotaPenalty: number
}
export interface AssignmentScore {
@@ -69,6 +70,8 @@ const GEO_DIVERSITY_PENALTY_PER_EXCESS = -15
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
// COI jurors are skipped entirely rather than penalized (effectively -Infinity)
const AVAILABILITY_PENALTY = -30 // Heavy penalty for unavailable jurors
const CATEGORY_QUOTA_PENALTY = -25 // Heavy penalty when juror exceeds category max
const CATEGORY_QUOTA_BONUS = 10 // Bonus when juror is below category min
// Common words to exclude from bio matching
const STOP_WORDS = new Set([
@@ -267,6 +270,50 @@ export function calculateAvailabilityPenalty(
return AVAILABILITY_PENALTY
}
/**
* Calculate category quota penalty/bonus for a juror-project pair.
* - If the juror's count for the project's category >= max quota, apply heavy penalty (-25)
* - If the juror's count is below min and other categories are above their min, apply bonus (+10)
* - Otherwise return 0
*/
export function calculateCategoryQuotaPenalty(
categoryQuotas: Record<string, { min: number; max: number }>,
jurorCategoryCounts: Record<string, number>,
projectCategory: string | null | undefined
): number {
if (!projectCategory) return 0
const normalizedCategory = projectCategory.toLowerCase().trim()
const quota = Object.entries(categoryQuotas).find(
([key]) => key.toLowerCase().trim() === normalizedCategory
)
if (!quota) return 0
const [, { min, max }] = quota
const currentCount = jurorCategoryCounts[normalizedCategory] || 0
// If at or over max, heavy penalty
if (currentCount >= max) {
return CATEGORY_QUOTA_PENALTY
}
// If below min and other categories are above their min, give bonus
if (currentCount < min) {
const otherCategoriesAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
if (key.toLowerCase().trim() === normalizedCategory) return false
const count = jurorCategoryCounts[key.toLowerCase().trim()] || 0
return count >= q.min
})
if (otherCategoriesAboveMin) {
return CATEGORY_QUOTA_BONUS
}
}
return 0
}
// ─── Main Scoring Function ───────────────────────────────────────────────────
/**
@@ -277,8 +324,9 @@ export async function getSmartSuggestions(options: {
type: 'jury' | 'mentor'
limit?: number
aiMaxPerJudge?: number
categoryQuotas?: Record<string, { min: number; max: number }>
}): Promise<AssignmentScore[]> {
const { stageId, type, limit = 50, aiMaxPerJudge = 20 } = options
const { stageId, type, limit = 50, aiMaxPerJudge = 20, categoryQuotas } = options
const projectStageStates = await prisma.projectStageState.findMany({
where: { stageId },
@@ -297,6 +345,7 @@ export async function getSmartSuggestions(options: {
teamName: true,
description: true,
country: true,
competitionCategory: true,
status: true,
projectTags: {
include: { tag: true },
@@ -372,6 +421,28 @@ export async function getSmartSuggestions(options: {
countryMap.set(country, (countryMap.get(country) || 0) + 1)
}
// Build map: userId -> { category -> count } for category quota scoring
const userCategoryDistribution = new Map<string, Record<string, number>>()
if (categoryQuotas) {
const assignmentsWithCategory = await prisma.assignment.findMany({
where: { stageId },
select: {
userId: true,
project: { select: { competitionCategory: true } },
},
})
for (const a of assignmentsWithCategory) {
const category = a.project.competitionCategory?.toLowerCase().trim()
if (!category) continue
let categoryMap = userCategoryDistribution.get(a.userId)
if (!categoryMap) {
categoryMap = {}
userCategoryDistribution.set(a.userId, categoryMap)
}
categoryMap[category] = (categoryMap[category] || 0) + 1
}
}
const currentStage = await prisma.stage.findUnique({
where: { id: stageId },
select: { trackId: true, sortOrder: true },
@@ -485,6 +556,17 @@ export async function getSmartSuggestions(options: {
// ── New scoring factors ─────────────────────────────────────────────
// Category quota penalty/bonus
let categoryQuotaPenalty = 0
if (categoryQuotas) {
const jurorCategoryCounts = userCategoryDistribution.get(user.id) || {}
categoryQuotaPenalty = calculateCategoryQuotaPenalty(
categoryQuotas,
jurorCategoryCounts,
project.competitionCategory
)
}
// Geographic diversity penalty
let geoDiversityPenalty = 0
const projectCountry = project.country?.toLowerCase().trim()
@@ -510,7 +592,8 @@ export async function getSmartSuggestions(options: {
countryScore +
geoDiversityPenalty +
previousRoundFamiliarity +
availabilityPenalty
availabilityPenalty +
categoryQuotaPenalty
// Build reasoning
const reasoning: string[] = []
@@ -540,6 +623,11 @@ export async function getSmartSuggestions(options: {
if (availabilityPenalty < 0) {
reasoning.push(`Unavailable during voting window (${availabilityPenalty})`)
}
if (categoryQuotaPenalty < 0) {
reasoning.push(`Category quota exceeded (${categoryQuotaPenalty})`)
} else if (categoryQuotaPenalty > 0) {
reasoning.push(`Category quota bonus (+${categoryQuotaPenalty})`)
}
suggestions.push({
userId: user.id,
@@ -557,6 +645,7 @@ export async function getSmartSuggestions(options: {
previousRoundFamiliarity,
coiPenalty: 0, // COI jurors are skipped entirely
availabilityPenalty,
categoryQuotaPenalty,
},
reasoning,
matchingTags,
@@ -690,6 +779,7 @@ export async function getMentorSuggestionsForProject(
previousRoundFamiliarity: 0,
coiPenalty: 0,
availabilityPenalty: 0,
categoryQuotaPenalty: 0,
},
reasoning,
matchingTags,