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 { // 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 { // 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() 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() 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 { 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 }