Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
421
src/server/services/ai-assignment.ts
Normal file
421
src/server/services/ai-assignment.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* AI-Powered Assignment Service
|
||||
*
|
||||
* Uses GPT to analyze juror expertise and project requirements
|
||||
* to generate optimal assignment suggestions.
|
||||
*/
|
||||
|
||||
import { getOpenAI, AI_MODELS } 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 response = await openai.chat.completions.create({
|
||||
model: AI_MODELS.ASSIGNMENT,
|
||||
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.`
|
||||
}
|
||||
Reference in New Issue
Block a user