Files
MOPC-Portal/src/server/services/mentor-matching.ts

333 lines
8.5 KiB
TypeScript
Raw Normal View History

import { PrismaClient, OceanIssue, CompetitionCategory } from '@prisma/client'
import OpenAI from 'openai'
// Lazy initialization to avoid errors when API key is not set
let openaiClient: OpenAI | null = null
function getOpenAIClient(): OpenAI | null {
if (!process.env.OPENAI_API_KEY) {
return null
}
if (!openaiClient) {
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
}
return openaiClient
}
interface ProjectInfo {
id: string
title: string
description: string | null
oceanIssue: OceanIssue | null
competitionCategory: CompetitionCategory | null
tags: string[]
}
interface MentorInfo {
id: string
name: string | null
email: string
expertiseTags: string[]
currentAssignments: number
maxAssignments: number | null
}
interface MentorMatch {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
}
/**
* Get AI-suggested mentor matches for a project
*/
export async function getAIMentorSuggestions(
prisma: PrismaClient,
projectId: string,
limit: number = 5
): Promise<MentorMatch[]> {
// Get project details
const project = await prisma.project.findUniqueOrThrow({
where: { id: projectId },
select: {
id: true,
title: true,
description: true,
oceanIssue: true,
competitionCategory: true,
tags: true,
},
})
// Get available mentors (users with expertise tags)
// In a full implementation, you'd have a MENTOR role
// For now, we use users with expertiseTags and consider them potential mentors
const mentors = await prisma.user.findMany({
where: {
OR: [
{ expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' }, // Jury members can also be mentors
],
status: 'ACTIVE',
},
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
maxAssignments: true,
mentorAssignments: {
select: { id: true },
},
},
})
// Filter mentors who haven't reached max assignments
const availableMentors: MentorInfo[] = mentors
.filter((m) => {
const currentAssignments = m.mentorAssignments.length
return !m.maxAssignments || currentAssignments < m.maxAssignments
})
.map((m) => ({
id: m.id,
name: m.name,
email: m.email,
expertiseTags: m.expertiseTags,
currentAssignments: m.mentorAssignments.length,
maxAssignments: m.maxAssignments,
}))
if (availableMentors.length === 0) {
return []
}
// Try AI matching if API key is configured
if (process.env.OPENAI_API_KEY) {
try {
return await getAIMatches(project, availableMentors, limit)
} catch (error) {
console.error('AI mentor matching failed, falling back to algorithm:', error)
}
}
// Fallback to algorithmic matching
return getAlgorithmicMatches(project, availableMentors, limit)
}
/**
* Use OpenAI to match mentors to projects
*/
async function getAIMatches(
project: ProjectInfo,
mentors: MentorInfo[],
limit: number
): Promise<MentorMatch[]> {
// Anonymize data before sending to AI
const anonymizedProject = {
description: project.description?.slice(0, 500) || 'No description',
category: project.competitionCategory,
oceanIssue: project.oceanIssue,
tags: project.tags,
}
const anonymizedMentors = mentors.map((m, index) => ({
index,
expertise: m.expertiseTags,
availability: m.maxAssignments
? `${m.currentAssignments}/${m.maxAssignments}`
: 'unlimited',
}))
const prompt = `You are matching mentors to an ocean protection project.
PROJECT:
- Category: ${anonymizedProject.category || 'Not specified'}
- Ocean Issue: ${anonymizedProject.oceanIssue || 'Not specified'}
- Tags: ${anonymizedProject.tags.join(', ') || 'None'}
- Description: ${anonymizedProject.description}
AVAILABLE MENTORS:
${anonymizedMentors.map((m) => `${m.index}: Expertise: [${m.expertise.join(', ')}], Availability: ${m.availability}`).join('\n')}
Rank the top ${limit} mentors by suitability. For each, provide:
1. Mentor index (0-based)
2. Confidence score (0-1)
3. Expertise match score (0-1)
4. Brief reasoning (1-2 sentences)
Respond in JSON format:
{
"matches": [
{
"mentorIndex": 0,
"confidenceScore": 0.85,
"expertiseMatchScore": 0.9,
"reasoning": "Strong expertise alignment..."
}
]
}`
const openai = getOpenAIClient()
if (!openai) {
throw new Error('OpenAI client not available')
}
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: 'You are an expert at matching mentors to projects based on expertise alignment. Always respond with valid JSON.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
max_tokens: 1000,
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('No response from AI')
}
const parsed = JSON.parse(content) as {
matches: Array<{
mentorIndex: number
confidenceScore: number
expertiseMatchScore: number
reasoning: string
}>
}
return parsed.matches
.filter((m) => m.mentorIndex >= 0 && m.mentorIndex < mentors.length)
.map((m) => ({
mentorId: mentors[m.mentorIndex].id,
confidenceScore: m.confidenceScore,
expertiseMatchScore: m.expertiseMatchScore,
reasoning: m.reasoning,
}))
}
/**
* Algorithmic fallback for mentor matching
*/
function getAlgorithmicMatches(
project: ProjectInfo,
mentors: MentorInfo[],
limit: number
): MentorMatch[] {
// Build keyword set from project
const projectKeywords = new Set<string>()
if (project.oceanIssue) {
projectKeywords.add(project.oceanIssue.toLowerCase().replace(/_/g, ' '))
}
if (project.competitionCategory) {
projectKeywords.add(project.competitionCategory.toLowerCase().replace(/_/g, ' '))
}
project.tags.forEach((tag) => {
tag.toLowerCase().split(/\s+/).forEach((word) => {
if (word.length > 3) projectKeywords.add(word)
})
})
if (project.description) {
// Extract key words from description
const words = project.description.toLowerCase().split(/\s+/)
words.forEach((word) => {
if (word.length > 4) projectKeywords.add(word.replace(/[^a-z]/g, ''))
})
}
// Score each mentor
const scored = mentors.map((mentor) => {
const mentorKeywords = new Set<string>()
mentor.expertiseTags.forEach((tag) => {
tag.toLowerCase().split(/\s+/).forEach((word) => {
if (word.length > 2) mentorKeywords.add(word)
})
})
// Calculate overlap
let matchCount = 0
projectKeywords.forEach((keyword) => {
mentorKeywords.forEach((mentorKeyword) => {
if (keyword.includes(mentorKeyword) || mentorKeyword.includes(keyword)) {
matchCount++
}
})
})
const expertiseMatchScore = mentorKeywords.size > 0
? Math.min(1, matchCount / mentorKeywords.size)
: 0
// Factor in availability
const availabilityScore = mentor.maxAssignments
? 1 - (mentor.currentAssignments / mentor.maxAssignments)
: 1
const confidenceScore = (expertiseMatchScore * 0.7 + availabilityScore * 0.3)
return {
mentorId: mentor.id,
confidenceScore: Math.round(confidenceScore * 100) / 100,
expertiseMatchScore: Math.round(expertiseMatchScore * 100) / 100,
reasoning: `Matched ${matchCount} keyword(s) with mentor expertise. Availability: ${availabilityScore > 0.5 ? 'Good' : 'Limited'}.`,
}
})
// Sort by confidence and return top matches
return scored
.sort((a, b) => b.confidenceScore - a.confidenceScore)
.slice(0, limit)
}
/**
* Round-robin assignment for load balancing
*/
export async function getRoundRobinMentor(
prisma: PrismaClient,
excludeMentorIds: string[] = []
): Promise<string | null> {
const mentors = await prisma.user.findMany({
where: {
OR: [
{ expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' },
],
status: 'ACTIVE',
id: { notIn: excludeMentorIds },
},
select: {
id: true,
maxAssignments: true,
mentorAssignments: {
select: { id: true },
},
},
orderBy: {
mentorAssignments: {
_count: 'asc',
},
},
})
// Find mentor with fewest assignments who hasn't reached max
for (const mentor of mentors) {
const currentCount = mentor.mentorAssignments.length
if (!mentor.maxAssignments || currentCount < mentor.maxAssignments) {
return mentor.id
}
}
return null
}