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:
332
src/server/services/mentor-matching.ts
Normal file
332
src/server/services/mentor-matching.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user