424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
/**
|
|
* AI-Powered Assignment Service
|
|
*
|
|
* Uses GPT to analyze juror expertise and project requirements
|
|
* to generate optimal assignment suggestions.
|
|
*/
|
|
|
|
import { getOpenAI, getConfiguredModel } from '@/lib/openai'
|
|
import {
|
|
anonymizeForAI,
|
|
deanonymizeResults,
|
|
validateAnonymization,
|
|
type AnonymizationResult,
|
|
} from './anonymization'
|
|
|
|
// Types for AI assignment
|
|
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[]
|
|
maxAssignments?: number | null
|
|
_count?: {
|
|
assignments: number
|
|
}
|
|
}
|
|
|
|
interface ProjectForAssignment {
|
|
id: string
|
|
title: string
|
|
description?: string | null
|
|
tags: string[]
|
|
teamName?: string | null
|
|
_count?: {
|
|
assignments: number
|
|
}
|
|
}
|
|
|
|
interface AssignmentConstraints {
|
|
requiredReviewsPerProject: number
|
|
maxAssignmentsPerJuror?: number
|
|
existingAssignments: Array<{
|
|
jurorId: string
|
|
projectId: string
|
|
}>
|
|
}
|
|
|
|
/**
|
|
* System prompt for AI assignment
|
|
*/
|
|
const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert at matching jury members to projects based on expertise alignment.
|
|
|
|
Your task is to suggest optimal juror-project assignments that:
|
|
1. Match juror expertise tags with project tags and content
|
|
2. Distribute workload fairly among jurors
|
|
3. Ensure each project gets the required number of reviews
|
|
4. Avoid assigning jurors who are already at their limit
|
|
|
|
For each suggestion, provide:
|
|
- A confidence score (0-1) based on how well the juror's expertise matches the project
|
|
- An expertise match score (0-1) based purely on tag/content alignment
|
|
- A brief reasoning explaining why this is a good match
|
|
|
|
Return your response as a JSON array of assignments.`
|
|
|
|
/**
|
|
* Generate AI-powered assignment suggestions
|
|
*/
|
|
export async function generateAIAssignments(
|
|
jurors: JurorForAssignment[],
|
|
projects: ProjectForAssignment[],
|
|
constraints: AssignmentConstraints
|
|
): Promise<AIAssignmentResult> {
|
|
// Anonymize data before sending to AI
|
|
const anonymizedData = anonymizeForAI(jurors, projects)
|
|
|
|
// Validate anonymization
|
|
if (!validateAnonymization(anonymizedData)) {
|
|
console.error('Anonymization validation failed, falling back to algorithm')
|
|
return generateFallbackAssignments(jurors, projects, constraints)
|
|
}
|
|
|
|
try {
|
|
const openai = await getOpenAI()
|
|
|
|
if (!openai) {
|
|
console.log('OpenAI not configured, using fallback algorithm')
|
|
return generateFallbackAssignments(jurors, projects, constraints)
|
|
}
|
|
|
|
const suggestions = await callAIForAssignments(
|
|
openai,
|
|
anonymizedData,
|
|
constraints
|
|
)
|
|
|
|
// De-anonymize results
|
|
const deanonymizedSuggestions = deanonymizeResults(
|
|
suggestions.map((s) => ({
|
|
...s,
|
|
jurorId: s.jurorId,
|
|
projectId: s.projectId,
|
|
})),
|
|
anonymizedData.jurorMappings,
|
|
anonymizedData.projectMappings
|
|
).map((s) => ({
|
|
jurorId: s.realJurorId,
|
|
projectId: s.realProjectId,
|
|
confidenceScore: s.confidenceScore,
|
|
reasoning: s.reasoning,
|
|
expertiseMatchScore: s.expertiseMatchScore,
|
|
}))
|
|
|
|
return {
|
|
success: true,
|
|
suggestions: deanonymizedSuggestions,
|
|
fallbackUsed: false,
|
|
}
|
|
} catch (error) {
|
|
console.error('AI assignment failed, using fallback:', error)
|
|
return generateFallbackAssignments(jurors, projects, constraints)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call OpenAI API for assignment suggestions
|
|
*/
|
|
async function callAIForAssignments(
|
|
openai: Awaited<ReturnType<typeof getOpenAI>>,
|
|
anonymizedData: AnonymizationResult,
|
|
constraints: AssignmentConstraints
|
|
): Promise<AIAssignmentSuggestion[]> {
|
|
if (!openai) {
|
|
throw new Error('OpenAI client not available')
|
|
}
|
|
|
|
// Build the user prompt
|
|
const userPrompt = buildAssignmentPrompt(anonymizedData, constraints)
|
|
|
|
const model = await getConfiguredModel()
|
|
|
|
const response = await openai.chat.completions.create({
|
|
model,
|
|
messages: [
|
|
{ role: 'system', content: ASSIGNMENT_SYSTEM_PROMPT },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
response_format: { type: 'json_object' },
|
|
temperature: 0.3, // Lower temperature for more consistent results
|
|
max_tokens: 4000,
|
|
})
|
|
|
|
const content = response.choices[0]?.message?.content
|
|
|
|
if (!content) {
|
|
throw new Error('No response from AI')
|
|
}
|
|
|
|
// Parse the response
|
|
const parsed = JSON.parse(content) as {
|
|
assignments: Array<{
|
|
juror_id: string
|
|
project_id: string
|
|
confidence_score: number
|
|
expertise_match_score: number
|
|
reasoning: string
|
|
}>
|
|
}
|
|
|
|
return (parsed.assignments || []).map((a) => ({
|
|
jurorId: a.juror_id,
|
|
projectId: a.project_id,
|
|
confidenceScore: Math.min(1, Math.max(0, a.confidence_score)),
|
|
expertiseMatchScore: Math.min(1, Math.max(0, a.expertise_match_score)),
|
|
reasoning: a.reasoning,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Build the prompt for AI assignment
|
|
*/
|
|
function buildAssignmentPrompt(
|
|
data: AnonymizationResult,
|
|
constraints: AssignmentConstraints
|
|
): string {
|
|
const { jurors, projects } = data
|
|
|
|
// Map existing assignments to anonymous IDs
|
|
const jurorIdMap = new Map(
|
|
data.jurorMappings.map((m) => [m.realId, m.anonymousId])
|
|
)
|
|
const projectIdMap = new Map(
|
|
data.projectMappings.map((m) => [m.realId, m.anonymousId])
|
|
)
|
|
|
|
const anonymousExisting = constraints.existingAssignments
|
|
.map((a) => ({
|
|
jurorId: jurorIdMap.get(a.jurorId),
|
|
projectId: projectIdMap.get(a.projectId),
|
|
}))
|
|
.filter((a) => a.jurorId && a.projectId)
|
|
|
|
return `## Jurors Available
|
|
${JSON.stringify(jurors, null, 2)}
|
|
|
|
## Projects to Assign
|
|
${JSON.stringify(projects, null, 2)}
|
|
|
|
## Constraints
|
|
- Each project needs ${constraints.requiredReviewsPerProject} reviews
|
|
- Maximum assignments per juror: ${constraints.maxAssignmentsPerJuror || 'No limit'}
|
|
- Existing assignments to avoid duplicating:
|
|
${JSON.stringify(anonymousExisting, null, 2)}
|
|
|
|
## Instructions
|
|
Generate optimal juror-project assignments. Return a JSON object with an "assignments" array where each assignment has:
|
|
- juror_id: The anonymous juror ID
|
|
- project_id: The anonymous project ID
|
|
- confidence_score: 0-1 confidence in this match
|
|
- expertise_match_score: 0-1 expertise alignment score
|
|
- reasoning: Brief explanation (1-2 sentences)
|
|
|
|
Focus on matching expertise tags with project tags and descriptions. Distribute assignments fairly.`
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
)
|
|
}
|
|
|
|
// 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 each project, find best matching jurors
|
|
for (const project of sortedProjects) {
|
|
const currentProjectAssignments = projectAssignments.get(project.id) || 0
|
|
const neededReviews = Math.max(
|
|
0,
|
|
constraints.requiredReviewsPerProject - currentProjectAssignments
|
|
)
|
|
|
|
if (neededReviews === 0) continue
|
|
|
|
// Score all available jurors
|
|
const scoredJurors = jurors
|
|
.filter((juror) => {
|
|
// Check not already assigned
|
|
if (existingSet.has(`${juror.id}:${project.id}`)) return false
|
|
|
|
// Check not at limit
|
|
const currentAssignments = jurorAssignments.get(juror.id) || 0
|
|
const maxAssignments =
|
|
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? Infinity
|
|
if (currentAssignments >= maxAssignments) return false
|
|
|
|
return true
|
|
})
|
|
.map((juror) => ({
|
|
juror,
|
|
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
|
|
loadScore: calculateLoadScore(
|
|
jurorAssignments.get(juror.id) || 0,
|
|
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10
|
|
),
|
|
}))
|
|
.sort((a, b) => {
|
|
// Combined score: 60% expertise, 40% load balancing
|
|
const aTotal = a.score * 0.6 + a.loadScore * 0.4
|
|
const bTotal = b.score * 0.6 + b.loadScore * 0.4
|
|
return bTotal - aTotal
|
|
})
|
|
|
|
// Assign top jurors
|
|
for (let i = 0; i < Math.min(neededReviews, scoredJurors.length); i++) {
|
|
const { juror, score } = scoredJurors[i]
|
|
|
|
suggestions.push({
|
|
jurorId: juror.id,
|
|
projectId: project.id,
|
|
confidenceScore: score,
|
|
expertiseMatchScore: score,
|
|
reasoning: generateFallbackReasoning(
|
|
juror.expertiseTags,
|
|
project.tags,
|
|
score
|
|
),
|
|
})
|
|
|
|
// Update tracking
|
|
existingSet.add(`${juror.id}:${project.id}`)
|
|
jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1)
|
|
projectAssignments.set(
|
|
project.id,
|
|
(projectAssignments.get(project.id) || 0) + 1
|
|
)
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
suggestions,
|
|
fallbackUsed: true,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate expertise match score based on tag overlap
|
|
*/
|
|
function calculateExpertiseScore(
|
|
jurorTags: string[],
|
|
projectTags: string[]
|
|
): 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()))
|
|
const matchingTags = projectTags.filter((t) =>
|
|
jurorTagsLower.has(t.toLowerCase())
|
|
)
|
|
|
|
// Score based on percentage of project tags matched
|
|
const matchRatio = matchingTags.length / projectTags.length
|
|
|
|
// Boost for having expertise, even if not all match
|
|
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
|
|
|
|
return Math.min(1, matchRatio * 0.8 + hasExpertise)
|
|
}
|
|
|
|
/**
|
|
* Calculate load balancing score (higher score = less loaded)
|
|
*/
|
|
function calculateLoadScore(currentLoad: number, maxLoad: number): number {
|
|
if (maxLoad === 0) return 0
|
|
const utilization = currentLoad / maxLoad
|
|
return Math.max(0, 1 - utilization)
|
|
}
|
|
|
|
/**
|
|
* 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.`
|
|
}
|