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