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.`
|
||||
}
|
||||
211
src/server/services/anonymization.ts
Normal file
211
src/server/services/anonymization.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Data Anonymization Service
|
||||
*
|
||||
* Strips PII (names, emails, etc.) from data before sending to AI services.
|
||||
* Returns ID mappings for de-anonymization of results.
|
||||
*/
|
||||
|
||||
export interface AnonymizedJuror {
|
||||
anonymousId: string
|
||||
expertiseTags: string[]
|
||||
currentAssignmentCount: number
|
||||
maxAssignments: number | null
|
||||
}
|
||||
|
||||
export interface AnonymizedProject {
|
||||
anonymousId: string
|
||||
title: string
|
||||
description: string | null
|
||||
tags: string[]
|
||||
teamName: string | null
|
||||
}
|
||||
|
||||
export interface JurorMapping {
|
||||
anonymousId: string
|
||||
realId: string
|
||||
}
|
||||
|
||||
export interface ProjectMapping {
|
||||
anonymousId: string
|
||||
realId: string
|
||||
}
|
||||
|
||||
export interface AnonymizationResult {
|
||||
jurors: AnonymizedJuror[]
|
||||
projects: AnonymizedProject[]
|
||||
jurorMappings: JurorMapping[]
|
||||
projectMappings: ProjectMapping[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Juror data from database
|
||||
*/
|
||||
interface JurorInput {
|
||||
id: string
|
||||
name?: string | null
|
||||
email: string
|
||||
expertiseTags: string[]
|
||||
maxAssignments?: number | null
|
||||
_count?: {
|
||||
assignments: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project data from database
|
||||
*/
|
||||
interface ProjectInput {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
tags: string[]
|
||||
teamName?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize juror and project data for AI processing
|
||||
*
|
||||
* This function:
|
||||
* 1. Strips all PII (names, emails) from juror data
|
||||
* 2. Replaces real IDs with sequential anonymous IDs
|
||||
* 3. Keeps only expertise tags and assignment counts
|
||||
* 4. Returns mappings for de-anonymization
|
||||
*/
|
||||
export function anonymizeForAI(
|
||||
jurors: JurorInput[],
|
||||
projects: ProjectInput[]
|
||||
): AnonymizationResult {
|
||||
const jurorMappings: JurorMapping[] = []
|
||||
const projectMappings: ProjectMapping[] = []
|
||||
|
||||
// Anonymize jurors
|
||||
const anonymizedJurors: AnonymizedJuror[] = jurors.map((juror, index) => {
|
||||
const anonymousId = `juror_${(index + 1).toString().padStart(3, '0')}`
|
||||
|
||||
jurorMappings.push({
|
||||
anonymousId,
|
||||
realId: juror.id,
|
||||
})
|
||||
|
||||
return {
|
||||
anonymousId,
|
||||
expertiseTags: juror.expertiseTags,
|
||||
currentAssignmentCount: juror._count?.assignments ?? 0,
|
||||
maxAssignments: juror.maxAssignments ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
// Anonymize projects (keep content but replace IDs)
|
||||
const anonymizedProjects: AnonymizedProject[] = projects.map(
|
||||
(project, index) => {
|
||||
const anonymousId = `project_${(index + 1).toString().padStart(3, '0')}`
|
||||
|
||||
projectMappings.push({
|
||||
anonymousId,
|
||||
realId: project.id,
|
||||
})
|
||||
|
||||
return {
|
||||
anonymousId,
|
||||
title: sanitizeText(project.title),
|
||||
description: project.description
|
||||
? sanitizeText(project.description)
|
||||
: null,
|
||||
tags: project.tags,
|
||||
// Replace specific team names with generic identifier
|
||||
teamName: project.teamName ? `Team ${index + 1}` : null,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
jurors: anonymizedJurors,
|
||||
projects: anonymizedProjects,
|
||||
jurorMappings,
|
||||
projectMappings,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* De-anonymize AI results back to real IDs
|
||||
*/
|
||||
export function deanonymizeResults<T extends { jurorId: string; projectId: string }>(
|
||||
results: T[],
|
||||
jurorMappings: JurorMapping[],
|
||||
projectMappings: ProjectMapping[]
|
||||
): (T & { realJurorId: string; realProjectId: string })[] {
|
||||
const jurorMap = new Map(
|
||||
jurorMappings.map((m) => [m.anonymousId, m.realId])
|
||||
)
|
||||
const projectMap = new Map(
|
||||
projectMappings.map((m) => [m.anonymousId, m.realId])
|
||||
)
|
||||
|
||||
return results.map((result) => ({
|
||||
...result,
|
||||
realJurorId: jurorMap.get(result.jurorId) || result.jurorId,
|
||||
realProjectId: projectMap.get(result.projectId) || result.projectId,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text to remove potential PII patterns
|
||||
* Removes emails, phone numbers, and URLs from text
|
||||
*/
|
||||
function sanitizeText(text: string): string {
|
||||
// Remove email addresses
|
||||
let sanitized = text.replace(
|
||||
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
||||
'[email removed]'
|
||||
)
|
||||
|
||||
// Remove phone numbers (various formats)
|
||||
sanitized = sanitized.replace(
|
||||
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g,
|
||||
'[phone removed]'
|
||||
)
|
||||
|
||||
// Remove URLs
|
||||
sanitized = sanitized.replace(
|
||||
/https?:\/\/[^\s]+/g,
|
||||
'[url removed]'
|
||||
)
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that data has been properly anonymized
|
||||
* Returns true if no PII patterns are detected
|
||||
*/
|
||||
export function validateAnonymization(data: AnonymizationResult): boolean {
|
||||
const piiPatterns = [
|
||||
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, // Email
|
||||
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/, // Phone
|
||||
]
|
||||
|
||||
const checkText = (text: string | null | undefined): boolean => {
|
||||
if (!text) return true
|
||||
return !piiPatterns.some((pattern) => pattern.test(text))
|
||||
}
|
||||
|
||||
// Check jurors (they should only have expertise tags)
|
||||
for (const juror of data.jurors) {
|
||||
// Jurors should not have any text fields that could contain PII
|
||||
// Only check expertiseTags
|
||||
for (const tag of juror.expertiseTags) {
|
||||
if (!checkText(tag)) return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check projects
|
||||
for (const project of data.projects) {
|
||||
if (!checkText(project.title)) return false
|
||||
if (!checkText(project.description)) return false
|
||||
for (const tag of project.tags) {
|
||||
if (!checkText(tag)) return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
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
|
||||
}
|
||||
321
src/server/services/notification.ts
Normal file
321
src/server/services/notification.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Unified Notification Service
|
||||
*
|
||||
* Handles sending notifications via multiple channels:
|
||||
* - Email (via nodemailer)
|
||||
* - WhatsApp (via Meta or Twilio)
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
sendMagicLinkEmail,
|
||||
sendJuryInvitationEmail,
|
||||
sendEvaluationReminderEmail,
|
||||
sendAnnouncementEmail,
|
||||
} from '@/lib/email'
|
||||
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
|
||||
import type { NotificationChannel } from '@prisma/client'
|
||||
|
||||
export type NotificationType =
|
||||
| 'MAGIC_LINK'
|
||||
| 'JURY_INVITATION'
|
||||
| 'EVALUATION_REMINDER'
|
||||
| 'ANNOUNCEMENT'
|
||||
|
||||
interface NotificationResult {
|
||||
success: boolean
|
||||
channels: {
|
||||
email?: { success: boolean; error?: string }
|
||||
whatsapp?: { success: boolean; messageId?: string; error?: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a user based on their preferences
|
||||
*/
|
||||
export async function sendNotification(
|
||||
userId: string,
|
||||
type: NotificationType,
|
||||
data: Record<string, string>
|
||||
): Promise<NotificationResult> {
|
||||
// Get user with notification preferences
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
whatsappOptIn: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
channels: {},
|
||||
}
|
||||
}
|
||||
|
||||
const result: NotificationResult = {
|
||||
success: true,
|
||||
channels: {},
|
||||
}
|
||||
|
||||
const preference = user.notificationPreference
|
||||
|
||||
// Determine which channels to use
|
||||
const sendEmail = preference === 'EMAIL' || preference === 'BOTH'
|
||||
const sendWhatsApp =
|
||||
(preference === 'WHATSAPP' || preference === 'BOTH') &&
|
||||
user.whatsappOptIn &&
|
||||
user.phoneNumber
|
||||
|
||||
// Send via email
|
||||
if (sendEmail) {
|
||||
const emailResult = await sendEmailNotification(user.email, user.name, type, data)
|
||||
result.channels.email = emailResult
|
||||
|
||||
// Log the notification
|
||||
await logNotification(user.id, 'EMAIL', 'SMTP', type, emailResult)
|
||||
}
|
||||
|
||||
// Send via WhatsApp
|
||||
if (sendWhatsApp && user.phoneNumber) {
|
||||
const whatsappResult = await sendWhatsAppNotification(
|
||||
user.phoneNumber,
|
||||
user.name,
|
||||
type,
|
||||
data
|
||||
)
|
||||
result.channels.whatsapp = whatsappResult
|
||||
|
||||
// Log the notification
|
||||
const providerType = await getWhatsAppProviderType()
|
||||
await logNotification(
|
||||
user.id,
|
||||
'WHATSAPP',
|
||||
providerType || 'UNKNOWN',
|
||||
type,
|
||||
whatsappResult
|
||||
)
|
||||
}
|
||||
|
||||
// Overall success if at least one channel succeeded
|
||||
result.success =
|
||||
(result.channels.email?.success ?? true) ||
|
||||
(result.channels.whatsapp?.success ?? true)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email notification
|
||||
*/
|
||||
async function sendEmailNotification(
|
||||
email: string,
|
||||
name: string | null,
|
||||
type: NotificationType,
|
||||
data: Record<string, string>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'MAGIC_LINK':
|
||||
await sendMagicLinkEmail(email, data.url)
|
||||
return { success: true }
|
||||
|
||||
case 'JURY_INVITATION':
|
||||
await sendJuryInvitationEmail(
|
||||
email,
|
||||
data.inviteUrl,
|
||||
data.programName,
|
||||
data.roundName
|
||||
)
|
||||
return { success: true }
|
||||
|
||||
case 'EVALUATION_REMINDER':
|
||||
await sendEvaluationReminderEmail(
|
||||
email,
|
||||
name,
|
||||
parseInt(data.pendingCount || '0'),
|
||||
data.roundName || 'Current Round',
|
||||
data.deadline || 'Soon',
|
||||
data.assignmentsUrl || `${process.env.NEXTAUTH_URL}/jury/assignments`
|
||||
)
|
||||
return { success: true }
|
||||
|
||||
case 'ANNOUNCEMENT':
|
||||
await sendAnnouncementEmail(
|
||||
email,
|
||||
name,
|
||||
data.title || 'Announcement',
|
||||
data.message || '',
|
||||
data.ctaText,
|
||||
data.ctaUrl
|
||||
)
|
||||
return { success: true }
|
||||
|
||||
default:
|
||||
return { success: false, error: `Unknown notification type: ${type}` }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Email send failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send WhatsApp notification
|
||||
*/
|
||||
async function sendWhatsAppNotification(
|
||||
phoneNumber: string,
|
||||
name: string | null,
|
||||
type: NotificationType,
|
||||
data: Record<string, string>
|
||||
): Promise<{ success: boolean; messageId?: string; error?: string }> {
|
||||
const provider = await getWhatsAppProvider()
|
||||
|
||||
if (!provider) {
|
||||
return { success: false, error: 'WhatsApp not configured' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Map notification types to templates
|
||||
const templateMap: Record<NotificationType, string> = {
|
||||
MAGIC_LINK: 'mopc_magic_link',
|
||||
JURY_INVITATION: 'mopc_jury_invitation',
|
||||
EVALUATION_REMINDER: 'mopc_evaluation_reminder',
|
||||
ANNOUNCEMENT: 'mopc_announcement',
|
||||
}
|
||||
|
||||
const template = templateMap[type]
|
||||
|
||||
// Build template params
|
||||
const params: Record<string, string> = {
|
||||
name: name || 'User',
|
||||
...data,
|
||||
}
|
||||
|
||||
const result = await provider.sendTemplate(phoneNumber, template, params)
|
||||
return result
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'WhatsApp send failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log notification to database
|
||||
*/
|
||||
async function logNotification(
|
||||
userId: string,
|
||||
channel: NotificationChannel,
|
||||
provider: string,
|
||||
type: NotificationType,
|
||||
result: { success: boolean; messageId?: string; error?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
userId,
|
||||
channel,
|
||||
provider,
|
||||
type,
|
||||
status: result.success ? 'SENT' : 'FAILED',
|
||||
externalId: result.messageId,
|
||||
errorMsg: result.error,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to log notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send bulk notifications to multiple users
|
||||
*/
|
||||
export async function sendBulkNotification(
|
||||
userIds: string[],
|
||||
type: NotificationType,
|
||||
data: Record<string, string>
|
||||
): Promise<{ sent: number; failed: number }> {
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
|
||||
for (const userId of userIds) {
|
||||
const result = await sendNotification(userId, type, data)
|
||||
if (result.success) {
|
||||
sent++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, failed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification statistics
|
||||
*/
|
||||
export async function getNotificationStats(options?: {
|
||||
userId?: string
|
||||
startDate?: Date
|
||||
endDate?: Date
|
||||
}): Promise<{
|
||||
total: number
|
||||
byChannel: Record<string, number>
|
||||
byStatus: Record<string, number>
|
||||
byType: Record<string, number>
|
||||
}> {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (options?.userId) {
|
||||
where.userId = options.userId
|
||||
}
|
||||
if (options?.startDate || options?.endDate) {
|
||||
where.createdAt = {}
|
||||
if (options.startDate) {
|
||||
(where.createdAt as Record<string, Date>).gte = options.startDate
|
||||
}
|
||||
if (options.endDate) {
|
||||
(where.createdAt as Record<string, Date>).lte = options.endDate
|
||||
}
|
||||
}
|
||||
|
||||
const [total, byChannel, byStatus, byType] = await Promise.all([
|
||||
prisma.notificationLog.count({ where }),
|
||||
prisma.notificationLog.groupBy({
|
||||
by: ['channel'],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
prisma.notificationLog.groupBy({
|
||||
by: ['status'],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
prisma.notificationLog.groupBy({
|
||||
by: ['type'],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
total,
|
||||
byChannel: Object.fromEntries(
|
||||
byChannel.map((r) => [r.channel, r._count])
|
||||
),
|
||||
byStatus: Object.fromEntries(
|
||||
byStatus.map((r) => [r.status, r._count])
|
||||
),
|
||||
byType: Object.fromEntries(
|
||||
byType.map((r) => [r.type, r._count])
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user