Files
MOPC-Portal/src/server/services/ai-assignment.ts
Matt 735b841f4a
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
Rewrite AI assignment to hybrid approach: single AI call + algorithm
Instead of 10 sequential GPT calls (which timeout with GPT-5.1 on 99
projects), use a two-phase approach:

Phase 1 - AI Scoring: ONE API call asks GPT to score each juror's
affinity for all projects, returning a compact preference matrix with
expertise match scores and reasoning.

Phase 2 - Algorithm: Uses AI scores as the preference input to a
balanced assignment algorithm that assigns N reviewers per project,
enforcing even workload distribution, respecting per-juror caps, and
filling coverage gaps.

Benefits:
- Single API call eliminates timeout issues
- AI provides expertise-aware scoring, algorithm ensures balance
- Truncated response handling (JSON repair) for resilience
- Falls back to tag-based algorithm if AI fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:49:41 +01:00

832 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AI-Powered Assignment Service (Hybrid Approach)
*
* Phase 1 — AI Scoring: ONE API call asks GPT to score each juror's affinity
* for each project (expertise match, reasoning). Returns a preference matrix.
* Phase 2 — Algorithm: Uses the AI scores to assign N reviewers per project
* with even workload distribution, respecting caps and COI constraints.
*
* GDPR Compliance:
* - All data anonymized before AI processing
* - IDs replaced with sequential identifiers
* - No personal information sent to OpenAI
*/
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import {
anonymizeForAI,
deanonymizeResults,
validateAnonymization,
DESCRIPTION_LIMITS,
truncateAndSanitize,
type AnonymizationResult,
} from './anonymization'
// ─── Types ───────────────────────────────────────────────────────────────────
export interface AIAssignmentSuggestion {
jurorId: string
projectId: string
confidenceScore: number // 0-1
reasoning: string
expertiseMatchScore: number // 0-1
}
export interface AIAssignmentResult {
success: boolean
suggestions: AIAssignmentSuggestion[]
error?: string
tokensUsed?: number
fallbackUsed?: boolean
}
interface JurorForAssignment {
id: string
name?: string | null
email: string
expertiseTags: string[]
bio?: string | null
country?: string | null
maxAssignments?: number | null
_count?: {
assignments: number
}
}
interface ProjectForAssignment {
id: string
title: string
description?: string | null
tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null
competitionCategory?: string | null
oceanIssue?: string | null
country?: string | null
institution?: string | null
teamSize?: number
fileTypes?: string[]
_count?: {
assignments: number
}
}
interface AssignmentConstraints {
requiredReviewsPerProject: number
minAssignmentsPerJuror?: number
maxAssignmentsPerJuror?: number
jurorLimits?: Record<string, number> // userId -> personal max assignments
existingAssignments: Array<{
jurorId: string
projectId: string
}>
/** Ideal target assignments per juror (for balanced distribution hint) */
_targetPerJuror?: number
}
export interface AssignmentProgressCallback {
(progress: {
currentBatch: number
totalBatches: number
processedCount: number
totalProjects: number
}): Promise<void>
}
/** Per-juror ranking from AI: which projects they should review */
interface JurorAffinityRow {
jurorId: string // anonymous ID
rankings: Array<{
projectId: string // anonymous ID
score: number // 0-100
reasoning: string
}>
}
// ─── System Prompt ──────────────────────────────────────────────────────────
const AFFINITY_SYSTEM_PROMPT = `You are an expert jury assignment optimizer for an ocean conservation competition.
## Your Task
Score how well each juror matches each project. Return a compact affinity matrix.
## Scoring Criteria (100-point scale)
- **Expertise Match (60 pts)**: Tag overlap, bio background relevance, ocean issue alignment
- **Diversity Benefit (25 pts)**: Different country from project, different expertise angle from other jurors
- **Category Fit (15 pts)**: Experience with startup vs concept evaluation, institutional familiarity
## Output Format
Return JSON with this exact structure:
{
"affinities": [
{
"juror_id": "JUROR_001",
"rankings": [
{"project_id": "PROJECT_001", "score": 85, "reason": "Strong coral reef expertise matches project focus"},
{"project_id": "PROJECT_005", "score": 72, "reason": "Marine biology background relevant to biodiversity project"}
]
}
]
}
## Rules
- For each juror, list their TOP project matches (at least the top 50% of projects, more is better)
- Scores must be integers 0-100
- Keep "reason" to one short sentence (under 20 words)
- A juror with no matching expertise should still get scores (based on general competence), just lower ones (30-50 range)
- Do NOT include projects that a juror has zero relevance for (score would be under 20)
- Return VALID JSON only`
// ─── AI Scoring Phase ───────────────────────────────────────────────────────
/**
* Build the user prompt for the single AI affinity call
*/
function buildAffinityPrompt(
anonymizedData: AnonymizationResult,
existingPairs: Set<string>,
): string {
// Compact juror representation
const jurorLines = anonymizedData.jurors.map((j) => {
const parts = [j.anonymousId]
if (j.expertiseTags.length > 0) parts.push(`tags:[${j.expertiseTags.join(',')}]`)
if (j.bio) parts.push(`bio:"${j.bio.slice(0, 150)}"`)
if (j.country) parts.push(`country:${j.country}`)
return parts.join(' | ')
})
// Compact project representation
const projectLines = anonymizedData.projects.map((p) => {
const parts = [p.anonymousId, `"${p.title}"`]
if (p.tags.length > 0) parts.push(`tags:[${p.tags.map((t) => t.name).join(',')}]`)
if (p.category) parts.push(`cat:${p.category}`)
if (p.oceanIssue) parts.push(`issue:${p.oceanIssue}`)
if (p.country) parts.push(`country:${p.country}`)
if (p.description) parts.push(`desc:"${p.description.slice(0, 100)}"`)
return parts.join(' | ')
})
// Note existing assignments to avoid
let existingNote = ''
if (existingPairs.size > 0) {
existingNote = `\nALREADY_ASSIGNED (do NOT score these pairs): ${[...existingPairs].join(', ')}`
}
return `## JURORS (${jurorLines.length})
${jurorLines.join('\n')}
## PROJECTS (${projectLines.length})
${projectLines.join('\n')}
${existingNote}
Score each juror's affinity for the projects. For each juror, return their top project matches with scores (0-100) and a short reason.`
}
/**
* Call AI once to get the full affinity matrix
*/
async function getAIAffinityMatrix(
openai: NonNullable<Awaited<ReturnType<typeof getOpenAI>>>,
model: string,
anonymizedData: AnonymizationResult,
existingPairs: Set<string>,
userId?: string,
entityId?: string,
): Promise<{
affinities: JurorAffinityRow[]
tokensUsed: number
}> {
const userPrompt = buildAffinityPrompt(anonymizedData, existingPairs)
// Estimate tokens: ~50 tokens per juror-project score entry
// For 15 jurors × 99 projects top 60% = ~890 entries × 50 = ~44500 output tokens
// Cap at a reasonable limit
const estimatedEntries = anonymizedData.jurors.length * Math.ceil(anonymizedData.projects.length * 0.6)
const estimatedTokens = Math.min(64000, Math.max(8000, estimatedEntries * 50 + 500))
console.log(`[AI Assignment] Affinity call: ${anonymizedData.jurors.length} jurors × ${anonymizedData.projects.length} projects, est. ${estimatedEntries} entries, maxTokens=${estimatedTokens}`)
const params = buildCompletionParams(model, {
messages: [
{ role: 'system', content: AFFINITY_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
jsonMode: true,
temperature: 0.1,
maxTokens: estimatedTokens,
})
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
try {
response = await openai.chat.completions.create(params)
} catch (apiError) {
const errorMsg = apiError instanceof Error ? apiError.message : String(apiError)
if (errorMsg.includes('model') || errorMsg.includes('does not exist')) {
throw new Error(`Invalid AI model "${model}". Please check the model name in Settings > AI Configuration.`)
}
throw apiError
}
const usage = extractTokenUsage(response)
await logAIUsage({
userId,
action: 'ASSIGNMENT',
entityType: 'Round',
entityId,
model,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
batchSize: anonymizedData.projects.length,
itemsProcessed: anonymizedData.projects.length,
status: 'SUCCESS',
})
// Parse response
const content = response.choices[0]?.message?.content
if (!content) {
const finishReason = response.choices[0]?.finish_reason
if (finishReason === 'content_filter') {
throw new Error('AI response was filtered. Try a different model or simplify the project descriptions.')
}
if (finishReason === 'length') {
console.warn('[AI Assignment] Response truncated (hit token limit). Will proceed with partial data + algorithm gap-fill.')
} else {
throw new Error(`Empty response from AI model "${model}".`)
}
}
let parsed: {
affinities: Array<{
juror_id: string
rankings: Array<{
project_id: string
score: number
reason: string
}>
}>
}
try {
// Handle potentially truncated JSON by attempting repair
let jsonStr = content || '{}'
// If truncated, try to close the JSON structure
if (!jsonStr.trim().endsWith('}')) {
console.warn('[AI Assignment] Response appears truncated, attempting JSON repair')
jsonStr = repairTruncatedJSON(jsonStr)
}
parsed = JSON.parse(jsonStr)
} catch (parseError) {
// Try extracting JSON from markdown code blocks
const jsonMatch = (content || '').match(/```(?:json)?\s*([\s\S]*?)```/)
if (jsonMatch) {
try {
parsed = JSON.parse(jsonMatch[1])
} catch {
throw createParseError(`Failed to parse AI affinity response: ${(parseError as Error).message}`)
}
} else {
throw createParseError(`Failed to parse AI affinity response: ${(parseError as Error).message}`)
}
}
// Normalize to our internal format
const affinities: JurorAffinityRow[] = (parsed.affinities || []).map((a) => ({
jurorId: a.juror_id,
rankings: (a.rankings || []).map((r) => ({
projectId: r.project_id,
score: Math.min(100, Math.max(0, r.score)),
reasoning: r.reason || '',
})),
}))
console.log(`[AI Assignment] Got affinities for ${affinities.length} jurors, total entries: ${affinities.reduce((sum, a) => sum + a.rankings.length, 0)}`)
return { affinities, tokensUsed: usage.totalTokens }
}
/**
* Attempt to repair truncated JSON by closing open structures
*/
function repairTruncatedJSON(json: string): string {
let s = json.trim()
// Remove any trailing incomplete entry (cut mid-object)
const lastCompleteEntry = s.lastIndexOf('}')
if (lastCompleteEntry > 0) {
s = s.slice(0, lastCompleteEntry + 1)
}
// Count open/close brackets
let openBrackets = 0
let openBraces = 0
for (const ch of s) {
if (ch === '[') openBrackets++
else if (ch === ']') openBrackets--
else if (ch === '{') openBraces++
else if (ch === '}') openBraces--
}
// Close everything
while (openBrackets > 0) { s += ']'; openBrackets-- }
while (openBraces > 0) { s += '}'; openBraces-- }
return s
}
// ─── Algorithm Phase ────────────────────────────────────────────────────────
/**
* Build a full score matrix from AI affinities, filling gaps with fallback scores
*/
function buildScoreMatrix(
affinities: JurorAffinityRow[],
jurors: JurorForAssignment[],
projects: ProjectForAssignment[],
anonymizedData: AnonymizationResult,
): Map<string, Map<string, { score: number; reasoning: string }>> {
// Create reverse mapping: anonymous ID → real ID
const jurorAnonToReal = new Map(anonymizedData.jurorMappings.map((m) => [m.anonymousId, m.realId]))
const projectAnonToReal = new Map(anonymizedData.projectMappings.map((m) => [m.anonymousId, m.realId]))
// Matrix: realJurorId → realProjectId → { score, reasoning }
const matrix = new Map<string, Map<string, { score: number; reasoning: string }>>()
// Initialize with AI scores
for (const row of affinities) {
const realJurorId = jurorAnonToReal.get(row.jurorId)
if (!realJurorId) continue
const jurorScores = new Map<string, { score: number; reasoning: string }>()
for (const r of row.rankings) {
const realProjectId = projectAnonToReal.get(r.projectId)
if (!realProjectId) continue
jurorScores.set(realProjectId, {
score: r.score / 100, // normalize to 0-1
reasoning: r.reasoning,
})
}
matrix.set(realJurorId, jurorScores)
}
// Fill gaps: for juror-project pairs not scored by AI, use tag-based fallback
for (const juror of jurors) {
if (!matrix.has(juror.id)) {
matrix.set(juror.id, new Map())
}
const jurorScores = matrix.get(juror.id)!
for (const project of projects) {
if (!jurorScores.has(project.id)) {
const tagScore = calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences)
jurorScores.set(project.id, {
score: tagScore * 0.5, // Scale down fallback scores
reasoning: generateFallbackReasoning(juror.expertiseTags, project.tags, tagScore),
})
}
}
}
return matrix
}
/**
* Balanced assignment algorithm using AI affinity scores.
*
* Strategy: iteratively assign the best available juror to each under-covered
* project, always preferring the least-loaded juror among those with decent scores.
*/
function assignFromScores(
scoreMatrix: Map<string, Map<string, { score: number; reasoning: string }>>,
jurors: JurorForAssignment[],
projects: ProjectForAssignment[],
constraints: AssignmentConstraints,
maxCap: number,
): AIAssignmentSuggestion[] {
const result: AIAssignmentSuggestion[] = []
// Track state
const assignedPairs = new Set<string>()
const jurorLoad = new Map<string, number>() // total load (existing + new)
const projectCoverage = new Map<string, number>() // how many reviewers assigned
// Initialize from existing assignments
for (const ea of constraints.existingAssignments) {
assignedPairs.add(`${ea.jurorId}:${ea.projectId}`)
jurorLoad.set(ea.jurorId, (jurorLoad.get(ea.jurorId) || 0) + 1)
projectCoverage.set(ea.projectId, (projectCoverage.get(ea.projectId) || 0) + 1)
}
// Also count existing DB assignments from _count
for (const j of jurors) {
const dbCount = j._count?.assignments || 0
jurorLoad.set(j.id, Math.max(jurorLoad.get(j.id) || 0, dbCount))
}
for (const p of projects) {
const dbCount = p._count?.assignments || 0
projectCoverage.set(p.id, Math.max(projectCoverage.get(p.id) || 0, dbCount))
}
const getEffectiveCap = (jurorId: string) => {
if (constraints.jurorLimits?.[jurorId]) return constraints.jurorLimits[jurorId]
const juror = jurors.find((j) => j.id === jurorId)
return juror?.maxAssignments ?? maxCap
}
// Ideal target: distribute evenly
const totalNeeded = projects.reduce((sum, p) => {
const current = projectCoverage.get(p.id) || 0
return sum + Math.max(0, constraints.requiredReviewsPerProject - current)
}, 0)
const idealPerJuror = Math.ceil(totalNeeded / jurors.length)
console.log(`[AI Assignment] Algorithm: ${totalNeeded} slots to fill, ideal ${idealPerJuror}/juror, cap ${maxCap}/juror`)
// Iterative assignment: repeat until all projects are covered or no more capacity
for (let pass = 0; pass < constraints.requiredReviewsPerProject; pass++) {
// Sort projects by coverage gap (most under-covered first)
const projectsByNeed = [...projects]
.map((p) => ({
project: p,
current: projectCoverage.get(p.id) || 0,
needed: constraints.requiredReviewsPerProject,
}))
.filter((pp) => pp.current < pp.needed)
.sort((a, b) => (a.current - a.needed) - (b.current - b.needed))
if (projectsByNeed.length === 0) break
for (const { project } of projectsByNeed) {
const currentCoverage = projectCoverage.get(project.id) || 0
if (currentCoverage >= constraints.requiredReviewsPerProject) continue
// Find best available juror: weighted by AI score AND workload balance
const candidates = jurors
.filter((j) => {
const pairKey = `${j.id}:${project.id}`
if (assignedPairs.has(pairKey)) return false
const load = jurorLoad.get(j.id) || 0
return load < getEffectiveCap(j.id)
})
.map((j) => {
const load = jurorLoad.get(j.id) || 0
const aiData = scoreMatrix.get(j.id)?.get(project.id)
const aiScore = aiData?.score ?? 0.3
const reasoning = aiData?.reasoning ?? 'Assigned for coverage'
// Workload penalty: heavily penalize jurors above ideal target
// This ensures even distribution
const loadRatio = load / Math.max(1, idealPerJuror)
const loadPenalty = loadRatio > 1
? 0.3 * Math.pow(0.5, loadRatio - 1) // Steep drop-off above ideal
: 1 - (loadRatio * 0.4) // Gentle linear decrease up to ideal
// Combined score: 55% AI score, 45% workload balance
const combinedScore = aiScore * 0.55 + loadPenalty * 0.45
return { juror: j, aiScore, combinedScore, reasoning, load }
})
.sort((a, b) => b.combinedScore - a.combinedScore)
if (candidates.length === 0) continue
const best = candidates[0]
result.push({
jurorId: best.juror.id,
projectId: project.id,
confidenceScore: best.aiScore,
expertiseMatchScore: best.aiScore,
reasoning: best.reasoning,
})
assignedPairs.add(`${best.juror.id}:${project.id}`)
jurorLoad.set(best.juror.id, (best.load) + 1)
projectCoverage.set(project.id, (currentCoverage) + 1)
}
}
// Log final distribution
const newAssignmentsPerJuror = new Map<string, number>()
for (const s of result) {
newAssignmentsPerJuror.set(s.jurorId, (newAssignmentsPerJuror.get(s.jurorId) || 0) + 1)
}
const loads = [...newAssignmentsPerJuror.values()]
if (loads.length > 0) {
console.log(`[AI Assignment] Distribution: min=${Math.min(...loads)}, max=${Math.max(...loads)}, avg=${(loads.reduce((a, b) => a + b, 0) / loads.length).toFixed(1)}`)
}
const uncovered = projects.filter((p) => (projectCoverage.get(p.id) || 0) < constraints.requiredReviewsPerProject)
if (uncovered.length > 0) {
console.warn(`[AI Assignment] ${uncovered.length} projects still under-covered after assignment`)
}
return result
}
// ─── Main Entry Point ───────────────────────────────────────────────────────
/**
* Generate AI-powered assignment suggestions (hybrid approach).
*
* 1. ONE AI call: get affinity scores for all juror-project pairs
* 2. Algorithm: assign N reviewers per project using AI scores + workload balancing
*/
export async function generateAIAssignments(
jurors: JurorForAssignment[],
projects: ProjectForAssignment[],
constraints: AssignmentConstraints,
userId?: string,
entityId?: string,
_onProgress?: AssignmentProgressCallback
): Promise<AIAssignmentResult> {
// Truncate descriptions before anonymization
const truncatedProjects = projects.map((p) => ({
...p,
description: truncateAndSanitize(p.description, DESCRIPTION_LIMITS.ASSIGNMENT),
}))
// Anonymize data before sending to AI
const anonymizedData = anonymizeForAI(jurors, truncatedProjects)
// Validate anonymization
if (!validateAnonymization(anonymizedData)) {
console.error('[AI Assignment] Anonymization validation failed, falling back to algorithm')
return generateFallbackAssignments(jurors, projects, constraints)
}
// Build existing pair set for AI (anonymous IDs)
const jurorRealToAnon = new Map(anonymizedData.jurorMappings.map((m) => [m.realId, m.anonymousId]))
const projectRealToAnon = new Map(anonymizedData.projectMappings.map((m) => [m.realId, m.anonymousId]))
const existingAnonPairs = new Set<string>()
for (const ea of constraints.existingAssignments) {
const aJ = jurorRealToAnon.get(ea.jurorId)
const aP = projectRealToAnon.get(ea.projectId)
if (aJ && aP) existingAnonPairs.add(`${aJ}:${aP}`)
}
// Calculate caps
const totalNeeded = projects.length * constraints.requiredReviewsPerProject
const maxCap = constraints.maxAssignmentsPerJuror ?? Math.ceil(totalNeeded / jurors.length) + 2
try {
const openai = await getOpenAI()
if (!openai) {
console.log('[AI Assignment] OpenAI not configured, using fallback algorithm')
return generateFallbackAssignments(jurors, projects, constraints)
}
const model = await getConfiguredModel()
console.log(`[AI Assignment] Hybrid approach: ${projects.length} projects, ${jurors.length} jurors, ${constraints.requiredReviewsPerProject} reviews/project, model: ${model}`)
// ── Phase 1: AI Scoring (single call) ──
console.log('[AI Assignment] Phase 1: Getting AI affinity scores...')
const { affinities, tokensUsed } = await getAIAffinityMatrix(
openai,
model,
anonymizedData,
existingAnonPairs,
userId,
entityId,
)
// ── Phase 2: Build score matrix and run algorithm ──
console.log('[AI Assignment] Phase 2: Running balanced assignment algorithm...')
const scoreMatrix = buildScoreMatrix(affinities, jurors, projects, anonymizedData)
const suggestions = assignFromScores(scoreMatrix, jurors, projects, constraints, maxCap)
console.log(`[AI Assignment] Complete: ${suggestions.length} assignments, ${tokensUsed} tokens used`)
return {
success: true,
suggestions,
tokensUsed,
fallbackUsed: false,
}
} catch (error) {
const classified = classifyAIError(error)
logAIError('Assignment', 'generateAIAssignments', classified)
await logAIUsage({
userId,
action: 'ASSIGNMENT',
entityType: 'Round',
entityId,
model: 'unknown',
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
batchSize: projects.length,
itemsProcessed: 0,
status: 'ERROR',
errorMessage: classified.message,
})
console.error('[AI Assignment] AI failed, falling back to algorithm:', classified.message)
const fallback = generateFallbackAssignments(jurors, projects, constraints)
return {
...fallback,
error: `AI scoring failed (${classified.message}). Used algorithmic fallback.`,
}
}
}
// ─── Fallback Algorithm ──────────────────────────────────────────────────────
/**
* Fallback algorithm-based assignment when AI is unavailable
*/
export function generateFallbackAssignments(
jurors: JurorForAssignment[],
projects: ProjectForAssignment[],
constraints: AssignmentConstraints
): AIAssignmentResult {
const suggestions: AIAssignmentSuggestion[] = []
const existingSet = new Set(
constraints.existingAssignments.map((a) => `${a.jurorId}:${a.projectId}`)
)
// Track assignments per juror and project
const jurorAssignments = new Map<string, number>()
const projectAssignments = new Map<string, number>()
// Initialize counts from existing assignments
for (const assignment of constraints.existingAssignments) {
jurorAssignments.set(
assignment.jurorId,
(jurorAssignments.get(assignment.jurorId) || 0) + 1
)
projectAssignments.set(
assignment.projectId,
(projectAssignments.get(assignment.projectId) || 0) + 1
)
}
// Also include current assignment counts
for (const juror of jurors) {
const current = juror._count?.assignments || 0
jurorAssignments.set(
juror.id,
Math.max(jurorAssignments.get(juror.id) || 0, current)
)
}
for (const project of projects) {
const current = project._count?.assignments || 0
projectAssignments.set(
project.id,
Math.max(projectAssignments.get(project.id) || 0, current)
)
}
const totalNeeded = projects.length * constraints.requiredReviewsPerProject
const maxCap = constraints.maxAssignmentsPerJuror ?? Math.ceil(totalNeeded / jurors.length) + 2
const idealPerJuror = Math.ceil(totalNeeded / jurors.length)
// Iterative: for each pass, assign one more reviewer per under-covered project
for (let pass = 0; pass < constraints.requiredReviewsPerProject; pass++) {
// Sort projects by need (fewest assignments first)
const sortedProjects = [...projects].sort((a, b) => {
const aCount = projectAssignments.get(a.id) || 0
const bCount = projectAssignments.get(b.id) || 0
return aCount - bCount
})
for (const project of sortedProjects) {
const currentProjectAssignments = projectAssignments.get(project.id) || 0
if (currentProjectAssignments >= constraints.requiredReviewsPerProject) continue
// Score jurors with heavy workload emphasis
const scoredJurors = jurors
.filter((juror) => {
if (existingSet.has(`${juror.id}:${project.id}`)) return false
const currentLoad = jurorAssignments.get(juror.id) || 0
const cap = juror.maxAssignments ?? maxCap
return currentLoad < cap
})
.map((juror) => {
const currentLoad = jurorAssignments.get(juror.id) || 0
const expertiseScore = calculateExpertiseScore(
juror.expertiseTags,
project.tags,
project.tagConfidences,
)
// Heavy workload balance weight
const loadRatio = currentLoad / Math.max(1, idealPerJuror)
const loadPenalty = loadRatio > 1
? 0.3 * Math.pow(0.5, loadRatio - 1)
: 1 - (loadRatio * 0.4)
return {
juror,
expertiseScore,
combinedScore: expertiseScore * 0.45 + loadPenalty * 0.55,
}
})
.sort((a, b) => b.combinedScore - a.combinedScore)
if (scoredJurors.length === 0) continue
const { juror, expertiseScore } = scoredJurors[0]
suggestions.push({
jurorId: juror.id,
projectId: project.id,
confidenceScore: expertiseScore,
expertiseMatchScore: expertiseScore,
reasoning: generateFallbackReasoning(juror.expertiseTags, project.tags, expertiseScore),
})
existingSet.add(`${juror.id}:${project.id}`)
jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1)
projectAssignments.set(project.id, currentProjectAssignments + 1)
}
}
return {
success: true,
suggestions,
fallbackUsed: true,
}
}
// ─── Scoring Helpers ────────────────────────────────────────────────────────
/**
* Calculate expertise match score based on tag overlap
* When tagConfidences are available, weights matches by confidence
*/
function calculateExpertiseScore(
jurorTags: string[],
projectTags: string[],
tagConfidences?: Array<{ name: string; confidence: number }>
): number {
if (jurorTags.length === 0 || projectTags.length === 0) {
return 0.5 // Neutral score if no tags
}
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
// If we have confidence data, use weighted scoring
if (tagConfidences && tagConfidences.length > 0) {
let weightedMatches = 0
let totalWeight = 0
for (const tc of tagConfidences) {
totalWeight += tc.confidence
if (jurorTagsLower.has(tc.name.toLowerCase())) {
weightedMatches += tc.confidence
}
}
if (totalWeight === 0) return 0.5
const weightedRatio = weightedMatches / totalWeight
const hasExpertise = weightedMatches > 0 ? 0.2 : 0
return Math.min(1, weightedRatio * 0.8 + hasExpertise)
}
// Fallback: unweighted matching using flat tags
const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase())
)
const matchRatio = matchingTags.length / projectTags.length
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
return Math.min(1, matchRatio * 0.8 + hasExpertise)
}
/**
* Generate reasoning for fallback assignments
*/
function generateFallbackReasoning(
jurorTags: string[],
projectTags: string[],
score: number
): string {
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase())
)
if (matchingTags.length > 0) {
return `Expertise match: ${matchingTags.join(', ')}. Match score: ${(score * 100).toFixed(0)}%.`
}
if (score >= 0.5) {
return `Assigned for workload balance. No direct expertise match but available capacity.`
}
return `Assigned to ensure project coverage.`
}