All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
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>
832 lines
29 KiB
TypeScript
832 lines
29 KiB
TypeScript
/**
|
||
* 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.`
|
||
}
|