Files
MOPC-Portal/src/server/services/ai-assignment.ts

832 lines
29 KiB
TypeScript
Raw Normal View History

/**
* 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.`
}