MOPC-App/src/server/services/ai-assignment.ts

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