2026-02-14 15:26:42 +01:00
|
|
|
|
/**
|
2026-02-18 17:49:41 +01:00
|
|
|
|
* AI-Powered Assignment Service (Hybrid Approach)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
*
|
2026-02-18 17:49:41 +01:00
|
|
|
|
* 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.
|
2026-02-14 15:26:42 +01:00
|
|
|
|
*
|
|
|
|
|
|
* 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[]
|
2026-02-17 14:45:57 +01:00
|
|
|
|
bio?: string | null
|
|
|
|
|
|
country?: string | null
|
2026-02-14 15:26:42 +01:00
|
|
|
|
maxAssignments?: number | null
|
|
|
|
|
|
_count?: {
|
|
|
|
|
|
assignments: number
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ProjectForAssignment {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
title: string
|
|
|
|
|
|
description?: string | null
|
|
|
|
|
|
tags: string[]
|
2026-02-17 09:29:46 +01:00
|
|
|
|
tagConfidences?: Array<{ name: string; confidence: number }>
|
2026-02-14 15:26:42 +01:00
|
|
|
|
teamName?: string | null
|
2026-02-17 14:45:57 +01:00
|
|
|
|
competitionCategory?: string | null
|
|
|
|
|
|
oceanIssue?: string | null
|
|
|
|
|
|
country?: string | null
|
|
|
|
|
|
institution?: string | null
|
|
|
|
|
|
teamSize?: number
|
|
|
|
|
|
fileTypes?: string[]
|
2026-02-14 15:26:42 +01:00
|
|
|
|
_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
|
|
|
|
|
|
}>
|
2026-02-18 16:16:55 +01:00
|
|
|
|
/** Ideal target assignments per juror (for balanced distribution hint) */
|
|
|
|
|
|
_targetPerJuror?: number
|
2026-02-14 15:26:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface AssignmentProgressCallback {
|
|
|
|
|
|
(progress: {
|
|
|
|
|
|
currentBatch: number
|
|
|
|
|
|
totalBatches: number
|
|
|
|
|
|
processedCount: number
|
|
|
|
|
|
totalProjects: number
|
|
|
|
|
|
}): Promise<void>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
/** 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.`
|
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-18 17:49:41 +01:00
|
|
|
|
* Call AI once to get the full affinity matrix
|
2026-02-14 15:26:42 +01:00
|
|
|
|
*/
|
2026-02-18 17:49:41 +01:00
|
|
|
|
async function getAIAffinityMatrix(
|
2026-02-14 15:26:42 +01:00
|
|
|
|
openai: NonNullable<Awaited<ReturnType<typeof getOpenAI>>>,
|
|
|
|
|
|
model: string,
|
|
|
|
|
|
anonymizedData: AnonymizationResult,
|
2026-02-18 17:49:41 +01:00
|
|
|
|
existingPairs: Set<string>,
|
2026-02-14 15:26:42 +01:00
|
|
|
|
userId?: string,
|
2026-02-18 17:49:41 +01:00
|
|
|
|
entityId?: string,
|
2026-02-14 15:26:42 +01:00
|
|
|
|
): Promise<{
|
2026-02-18 17:49:41 +01:00
|
|
|
|
affinities: JurorAffinityRow[]
|
2026-02-14 15:26:42 +01:00
|
|
|
|
tokensUsed: number
|
|
|
|
|
|
}> {
|
2026-02-18 17:49:41 +01:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
|
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
|
try {
|
2026-02-18 17:49:41 +01:00
|
|
|
|
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.`)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
}
|
2026-02-18 17:49:41 +01:00
|
|
|
|
throw apiError
|
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
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',
|
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// 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}".`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
let parsed: {
|
|
|
|
|
|
affinities: Array<{
|
|
|
|
|
|
juror_id: string
|
|
|
|
|
|
rankings: Array<{
|
2026-02-14 15:26:42 +01:00
|
|
|
|
project_id: string
|
2026-02-18 17:49:41 +01:00
|
|
|
|
score: number
|
|
|
|
|
|
reason: string
|
2026-02-14 15:26:42 +01:00
|
|
|
|
}>
|
2026-02-18 17:49:41 +01:00
|
|
|
|
}>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
parsed = JSON.parse(jsonStr)
|
|
|
|
|
|
} catch (parseError) {
|
|
|
|
|
|
// Try extracting JSON from markdown code blocks
|
|
|
|
|
|
const jsonMatch = (content || '').match(/```(?:json)?\s*([\s\S]*?)```/)
|
|
|
|
|
|
if (jsonMatch) {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
try {
|
2026-02-18 17:49:41 +01:00
|
|
|
|
parsed = JSON.parse(jsonMatch[1])
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
throw createParseError(`Failed to parse AI affinity response: ${(parseError as Error).message}`)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
}
|
2026-02-18 17:49:41 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
throw createParseError(`Failed to parse AI affinity response: ${(parseError as Error).message}`)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
}
|
2026-02-18 17:49:41 +01:00
|
|
|
|
}
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// 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 || '',
|
|
|
|
|
|
})),
|
|
|
|
|
|
}))
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
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,
|
2026-02-14 15:26:42 +01:00
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-02-18 17:49:41 +01:00
|
|
|
|
matrix.set(realJurorId, jurorScores)
|
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// 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),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
return matrix
|
2026-02-14 15:26:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-18 17:49:41 +01:00
|
|
|
|
* 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.
|
2026-02-14 15:26:42 +01:00
|
|
|
|
*/
|
2026-02-18 17:49:41 +01:00
|
|
|
|
function assignFromScores(
|
|
|
|
|
|
scoreMatrix: Map<string, Map<string, { score: number; reasoning: string }>>,
|
|
|
|
|
|
jurors: JurorForAssignment[],
|
|
|
|
|
|
projects: ProjectForAssignment[],
|
2026-02-14 15:26:42 +01:00
|
|
|
|
constraints: AssignmentConstraints,
|
2026-02-18 17:49:41 +01:00
|
|
|
|
maxCap: number,
|
|
|
|
|
|
): AIAssignmentSuggestion[] {
|
|
|
|
|
|
const result: AIAssignmentSuggestion[] = []
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// 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
|
2026-02-18 16:16:55 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-02-18 16:48:06 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// 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))
|
2026-02-18 17:24:16 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
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)
|
2026-02-18 17:24:16 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// 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
|
2026-02-14 15:26:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// ─── Main Entry Point ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
|
/**
|
2026-02-18 17:49:41 +01:00
|
|
|
|
* 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
|
2026-02-14 15:26:42 +01:00
|
|
|
|
*/
|
|
|
|
|
|
export async function generateAIAssignments(
|
|
|
|
|
|
jurors: JurorForAssignment[],
|
|
|
|
|
|
projects: ProjectForAssignment[],
|
|
|
|
|
|
constraints: AssignmentConstraints,
|
|
|
|
|
|
userId?: string,
|
|
|
|
|
|
entityId?: string,
|
2026-02-18 17:49:41 +01:00
|
|
|
|
_onProgress?: AssignmentProgressCallback
|
2026-02-14 15:26:42 +01:00
|
|
|
|
): 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
|
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()
|
2026-02-18 17:49:41 +01:00
|
|
|
|
console.log(`[AI Assignment] Hybrid approach: ${projects.length} projects, ${jurors.length} jurors, ${constraints.requiredReviewsPerProject} reviews/project, model: ${model}`)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// ── 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,
|
|
|
|
|
|
)
|
2026-02-18 16:16:55 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// ── 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)
|
2026-02-18 16:48:06 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
console.log(`[AI Assignment] Complete: ${suggestions.length} assignments, ${tokensUsed} tokens used`)
|
2026-02-18 16:48:06 +01:00
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
2026-02-18 17:49:41 +01:00
|
|
|
|
suggestions,
|
|
|
|
|
|
tokensUsed,
|
2026-02-14 15:26:42 +01:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
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.`,
|
2026-02-18 16:48:06 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
|
// ─── 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)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
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
|
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
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)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
if (scoredJurors.length === 0) continue
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
const { juror, expertiseScore } = scoredJurors[0]
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
|
|
suggestions.push({
|
|
|
|
|
|
jurorId: juror.id,
|
|
|
|
|
|
projectId: project.id,
|
2026-02-18 17:49:41 +01:00
|
|
|
|
confidenceScore: expertiseScore,
|
|
|
|
|
|
expertiseMatchScore: expertiseScore,
|
|
|
|
|
|
reasoning: generateFallbackReasoning(juror.expertiseTags, project.tags, expertiseScore),
|
2026-02-14 15:26:42 +01:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
existingSet.add(`${juror.id}:${project.id}`)
|
|
|
|
|
|
jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1)
|
2026-02-18 17:49:41 +01:00
|
|
|
|
projectAssignments.set(project.id, currentProjectAssignments + 1)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
suggestions,
|
|
|
|
|
|
fallbackUsed: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 17:49:41 +01:00
|
|
|
|
// ─── Scoring Helpers ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Calculate expertise match score based on tag overlap
|
2026-02-17 09:29:46 +01:00
|
|
|
|
* When tagConfidences are available, weights matches by confidence
|
2026-02-14 15:26:42 +01:00
|
|
|
|
*/
|
|
|
|
|
|
function calculateExpertiseScore(
|
|
|
|
|
|
jurorTags: string[],
|
2026-02-17 09:29:46 +01:00
|
|
|
|
projectTags: string[],
|
|
|
|
|
|
tagConfidences?: Array<{ name: string; confidence: number }>
|
2026-02-14 15:26:42 +01:00
|
|
|
|
): 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()))
|
2026-02-17 09:29:46 +01:00
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-02-14 15:26:42 +01:00
|
|
|
|
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.`
|
|
|
|
|
|
}
|