Remove dynamic form builder and complete RoundProject→roundId migration
Major cleanup and schema migration: - Remove unused dynamic form builder system (ApplicationForm, ApplicationFormField, etc.) - Complete migration from RoundProject junction table to direct Project.roundId - Add sortOrder and entryNotificationType fields to Round model - Add country field to User model for mentor matching - Enhance onboarding with profile photo and country selection steps - Fix all TypeScript errors related to roundProjects references - Remove unused libraries (@radix-ui/react-toast, embla-carousel-react, vaul) Files removed: - admin/forms/* pages and related components - admin/onboarding/* pages - applicationForm.ts and onboarding.ts routers - Dynamic form builder Prisma models and enums Schema changes: - Removed ApplicationForm, ApplicationFormField, OnboardingStep, ApplicationFormSubmission, SubmissionFile models - Removed FormFieldType and SpecialFieldType enums - Added Round.sortOrder, Round.entryNotificationType - Added User.country Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
541
src/server/services/ai-tagging.ts
Normal file
541
src/server/services/ai-tagging.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* AI-Powered Project Tagging Service
|
||||
*
|
||||
* Analyzes projects and assigns expertise tags automatically.
|
||||
*
|
||||
* Features:
|
||||
* - Single project tagging (on-submit or manual)
|
||||
* - Batch tagging for rounds
|
||||
* - Confidence scores for each tag
|
||||
* - Additive only - never removes existing tags
|
||||
*
|
||||
* GDPR Compliance:
|
||||
* - All project data is anonymized before AI processing
|
||||
* - Only necessary fields sent to OpenAI
|
||||
* - No personal identifiers in prompts or responses
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||
import {
|
||||
anonymizeProjectsForAI,
|
||||
validateAnonymizedProjects,
|
||||
type ProjectWithRelations,
|
||||
type AnonymizedProjectForAI,
|
||||
type ProjectAIMapping,
|
||||
} from './anonymization'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TagSuggestion {
|
||||
tagId: string
|
||||
tagName: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
export interface TaggingResult {
|
||||
projectId: string
|
||||
suggestions: TagSuggestion[]
|
||||
applied: TagSuggestion[]
|
||||
tokensUsed: number
|
||||
}
|
||||
|
||||
export interface BatchTaggingResult {
|
||||
processed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
results: TaggingResult[]
|
||||
}
|
||||
|
||||
interface AvailableTag {
|
||||
id: string
|
||||
name: string
|
||||
category: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 10
|
||||
const MAX_BATCH_SIZE = 25
|
||||
const CONFIDENCE_THRESHOLD = 0.5
|
||||
const DEFAULT_MAX_TAGS = 5
|
||||
|
||||
// System prompt optimized for tag suggestion
|
||||
const TAG_SUGGESTION_SYSTEM_PROMPT = `You are an expert at categorizing ocean conservation and sustainability projects.
|
||||
|
||||
Analyze the project and suggest the most relevant expertise tags from the provided list.
|
||||
Consider the project's focus areas, technology, methodology, and domain.
|
||||
|
||||
Return JSON with this format:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"tag_name": "exact tag name from list",
|
||||
"confidence": 0.0-1.0,
|
||||
"reasoning": "brief explanation why this tag fits"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Only suggest tags from the provided list (exact names)
|
||||
- Order by relevance (most relevant first)
|
||||
- Confidence should reflect how well the tag matches
|
||||
- Maximum 7 suggestions per project
|
||||
- Be conservative - only suggest tags that truly apply`
|
||||
|
||||
// ─── Helper Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get system settings for AI tagging
|
||||
*/
|
||||
async function getTaggingSettings(): Promise<{
|
||||
enabled: boolean
|
||||
maxTags: number
|
||||
}> {
|
||||
const settings = await prisma.systemSettings.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: ['ai_tagging_enabled', 'ai_tagging_max_tags'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const settingsMap = new Map(settings.map((s) => [s.key, s.value]))
|
||||
|
||||
return {
|
||||
enabled: settingsMap.get('ai_tagging_enabled') === 'true',
|
||||
maxTags: parseInt(settingsMap.get('ai_tagging_max_tags') || String(DEFAULT_MAX_TAGS)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active expertise tags
|
||||
*/
|
||||
async function getAvailableTags(): Promise<AvailableTag[]> {
|
||||
return prisma.expertiseTag.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
description: true,
|
||||
},
|
||||
orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert project to format for anonymization
|
||||
*/
|
||||
function toProjectWithRelations(project: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
competitionCategory?: string | null
|
||||
oceanIssue?: string | null
|
||||
country?: string | null
|
||||
geographicZone?: string | null
|
||||
institution?: string | null
|
||||
tags: string[]
|
||||
foundedAt?: Date | null
|
||||
wantsMentorship?: boolean
|
||||
submissionSource?: string
|
||||
submittedAt?: Date | null
|
||||
_count?: { teamMembers?: number; files?: number }
|
||||
files?: Array<{ fileType: string | null }>
|
||||
}): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as any,
|
||||
oceanIssue: project.oceanIssue as any,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: (project.submissionSource as any) ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? 0,
|
||||
},
|
||||
files: project.files?.map((f) => ({ fileType: (f.fileType as any) ?? null })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI Tagging Core ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call OpenAI to get tag suggestions for a project
|
||||
*/
|
||||
async function getAISuggestions(
|
||||
anonymizedProject: AnonymizedProjectForAI,
|
||||
availableTags: AvailableTag[],
|
||||
userId?: string
|
||||
): Promise<{ suggestions: TagSuggestion[]; tokensUsed: number }> {
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
console.warn('[AI Tagging] OpenAI not configured')
|
||||
return { suggestions: [], tokensUsed: 0 }
|
||||
}
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
|
||||
// Build tag list for prompt
|
||||
const tagList = availableTags.map((t) => ({
|
||||
name: t.name,
|
||||
category: t.category,
|
||||
description: t.description,
|
||||
}))
|
||||
|
||||
const userPrompt = `PROJECT:
|
||||
${JSON.stringify(anonymizedProject, null, 2)}
|
||||
|
||||
AVAILABLE TAGS:
|
||||
${JSON.stringify(tagList, null, 2)}
|
||||
|
||||
Suggest relevant tags for this project.`
|
||||
|
||||
try {
|
||||
const params = buildCompletionParams(model, {
|
||||
messages: [
|
||||
{ role: 'system', content: TAG_SUGGESTION_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
jsonMode: true,
|
||||
temperature: 0.3,
|
||||
maxTokens: 2000,
|
||||
})
|
||||
|
||||
const response = await openai.chat.completions.create(params)
|
||||
const usage = extractTokenUsage(response)
|
||||
|
||||
// Log usage
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'PROJECT_TAGGING',
|
||||
entityType: 'Project',
|
||||
entityId: anonymizedProject.project_id,
|
||||
model,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
batchSize: 1,
|
||||
itemsProcessed: 1,
|
||||
status: 'SUCCESS',
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (!content) {
|
||||
throw new Error('Empty response from AI')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as {
|
||||
suggestions: Array<{
|
||||
tag_name: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
}>
|
||||
}
|
||||
|
||||
// Map to TagSuggestion format, matching tag names to IDs
|
||||
const suggestions: TagSuggestion[] = []
|
||||
for (const s of parsed.suggestions || []) {
|
||||
const tag = availableTags.find(
|
||||
(t) => t.name.toLowerCase() === s.tag_name.toLowerCase()
|
||||
)
|
||||
if (tag) {
|
||||
suggestions.push({
|
||||
tagId: tag.id,
|
||||
tagName: tag.name,
|
||||
confidence: Math.max(0, Math.min(1, s.confidence)),
|
||||
reasoning: s.reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions, tokensUsed: usage.totalTokens }
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const parseError = createParseError(error.message)
|
||||
logAIError('Tagging', 'getAISuggestions', parseError)
|
||||
}
|
||||
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'PROJECT_TAGGING',
|
||||
entityType: 'Project',
|
||||
entityId: anonymizedProject.project_id,
|
||||
model,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
batchSize: 1,
|
||||
itemsProcessed: 0,
|
||||
status: 'ERROR',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tag a single project with AI-suggested expertise tags
|
||||
*
|
||||
* Behavior:
|
||||
* - Only applies tags with confidence >= 0.5
|
||||
* - Additive only - never removes existing tags
|
||||
* - Respects maxTags setting
|
||||
*/
|
||||
export async function tagProject(
|
||||
projectId: string,
|
||||
userId?: string
|
||||
): Promise<TaggingResult> {
|
||||
const settings = await getTaggingSettings()
|
||||
if (!settings.enabled) {
|
||||
return {
|
||||
projectId,
|
||||
suggestions: [],
|
||||
applied: [],
|
||||
tokensUsed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch project with needed fields
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
projectTags: true,
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
// Get available tags
|
||||
const availableTags = await getAvailableTags()
|
||||
if (availableTags.length === 0) {
|
||||
return {
|
||||
projectId,
|
||||
suggestions: [],
|
||||
applied: [],
|
||||
tokensUsed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymize project data
|
||||
const projectWithRelations = toProjectWithRelations(project)
|
||||
const { anonymized, mappings } = anonymizeProjectsForAI([projectWithRelations], 'FILTERING')
|
||||
|
||||
// Validate anonymization
|
||||
if (!validateAnonymizedProjects(anonymized)) {
|
||||
throw new Error('GDPR compliance check failed: PII detected in anonymized data')
|
||||
}
|
||||
|
||||
// Get AI suggestions
|
||||
const { suggestions, tokensUsed } = await getAISuggestions(
|
||||
anonymized[0],
|
||||
availableTags,
|
||||
userId
|
||||
)
|
||||
|
||||
// Filter by confidence threshold
|
||||
const validSuggestions = suggestions.filter(
|
||||
(s) => s.confidence >= CONFIDENCE_THRESHOLD
|
||||
)
|
||||
|
||||
// Get existing tag IDs to avoid duplicates
|
||||
const existingTagIds = new Set(project.projectTags.map((pt) => pt.tagId))
|
||||
|
||||
// Calculate how many more tags we can add
|
||||
const currentTagCount = project.projectTags.length
|
||||
const remainingSlots = Math.max(0, settings.maxTags - currentTagCount)
|
||||
|
||||
// Filter out existing tags and limit to remaining slots
|
||||
const newSuggestions = validSuggestions
|
||||
.filter((s) => !existingTagIds.has(s.tagId))
|
||||
.slice(0, remainingSlots)
|
||||
|
||||
// Apply new tags
|
||||
const applied: TagSuggestion[] = []
|
||||
for (const suggestion of newSuggestions) {
|
||||
try {
|
||||
await prisma.projectTag.create({
|
||||
data: {
|
||||
projectId,
|
||||
tagId: suggestion.tagId,
|
||||
confidence: suggestion.confidence,
|
||||
source: 'AI',
|
||||
},
|
||||
})
|
||||
applied.push(suggestion)
|
||||
} catch (error) {
|
||||
// Skip if tag already exists (race condition)
|
||||
console.warn(`[AI Tagging] Failed to apply tag ${suggestion.tagName}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
suggestions,
|
||||
applied,
|
||||
tokensUsed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch tag all untagged projects in a round
|
||||
*
|
||||
* Only processes projects with zero tags.
|
||||
*/
|
||||
export async function batchTagProjects(
|
||||
roundId: string,
|
||||
userId?: string,
|
||||
onProgress?: (processed: number, total: number) => void
|
||||
): Promise<BatchTaggingResult> {
|
||||
const settings = await getTaggingSettings()
|
||||
if (!settings.enabled) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: ['AI tagging is disabled'],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get untagged projects in round
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
roundId,
|
||||
projectTags: { none: {} }, // Only projects with no tags
|
||||
},
|
||||
include: {
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
const results: TaggingResult[] = []
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (let i = 0; i < projects.length; i++) {
|
||||
const project = projects[i]
|
||||
try {
|
||||
const result = await tagProject(project.id, userId)
|
||||
results.push(result)
|
||||
processed++
|
||||
} catch (error) {
|
||||
failed++
|
||||
errors.push(`${project.title}: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, projects.length)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processed,
|
||||
failed,
|
||||
skipped: 0,
|
||||
errors,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag suggestions for a project without applying them
|
||||
* Useful for preview/review before applying
|
||||
*/
|
||||
export async function getTagSuggestions(
|
||||
projectId: string,
|
||||
userId?: string
|
||||
): Promise<TagSuggestion[]> {
|
||||
// Fetch project
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
// Get available tags
|
||||
const availableTags = await getAvailableTags()
|
||||
if (availableTags.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Anonymize project data
|
||||
const projectWithRelations = toProjectWithRelations(project)
|
||||
const { anonymized } = anonymizeProjectsForAI([projectWithRelations], 'FILTERING')
|
||||
|
||||
// Validate anonymization
|
||||
if (!validateAnonymizedProjects(anonymized)) {
|
||||
throw new Error('GDPR compliance check failed')
|
||||
}
|
||||
|
||||
// Get AI suggestions
|
||||
const { suggestions } = await getAISuggestions(anonymized[0], availableTags, userId)
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually add a tag to a project
|
||||
*/
|
||||
export async function addProjectTag(
|
||||
projectId: string,
|
||||
tagId: string
|
||||
): Promise<void> {
|
||||
await prisma.projectTag.upsert({
|
||||
where: { projectId_tagId: { projectId, tagId } },
|
||||
create: { projectId, tagId, source: 'MANUAL', confidence: 1.0 },
|
||||
update: { source: 'MANUAL', confidence: 1.0 },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tag from a project
|
||||
*/
|
||||
export async function removeProjectTag(
|
||||
projectId: string,
|
||||
tagId: string
|
||||
): Promise<void> {
|
||||
await prisma.projectTag.deleteMany({
|
||||
where: { projectId, tagId },
|
||||
})
|
||||
}
|
||||
381
src/server/services/smart-assignment.ts
Normal file
381
src/server/services/smart-assignment.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Smart Assignment Scoring Service
|
||||
*
|
||||
* Calculates scores for jury/mentor-project matching based on:
|
||||
* - Tag overlap (expertise match)
|
||||
* - Workload balance
|
||||
* - Country match (mentors only)
|
||||
*
|
||||
* Score Breakdown (100 points max):
|
||||
* - Tag overlap: 0-50 points (weighted by confidence)
|
||||
* - Workload balance: 0-25 points
|
||||
* - Country match: 0-15 points (mentors only)
|
||||
* - Reserved: 0-10 points (future AI boost)
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
tagOverlap: number
|
||||
workloadBalance: number
|
||||
countryMatch: number
|
||||
aiBoost: number
|
||||
}
|
||||
|
||||
export interface AssignmentScore {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
score: number
|
||||
breakdown: ScoreBreakdown
|
||||
reasoning: string[]
|
||||
matchingTags: string[]
|
||||
}
|
||||
|
||||
export interface ProjectTagData {
|
||||
tagId: string
|
||||
tagName: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_TAG_OVERLAP_SCORE = 50
|
||||
const MAX_WORKLOAD_SCORE = 25
|
||||
const MAX_COUNTRY_SCORE = 15
|
||||
const POINTS_PER_TAG_MATCH = 10
|
||||
|
||||
// ─── Scoring Functions ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate tag overlap score between user expertise and project tags
|
||||
*/
|
||||
export function calculateTagOverlapScore(
|
||||
userTagNames: string[],
|
||||
projectTags: ProjectTagData[]
|
||||
): { score: number; matchingTags: string[] } {
|
||||
if (projectTags.length === 0 || userTagNames.length === 0) {
|
||||
return { score: 0, matchingTags: [] }
|
||||
}
|
||||
|
||||
const userTagSet = new Set(userTagNames.map((t) => t.toLowerCase()))
|
||||
const matchingTags: string[] = []
|
||||
let weightedScore = 0
|
||||
|
||||
for (const pt of projectTags) {
|
||||
if (userTagSet.has(pt.tagName.toLowerCase())) {
|
||||
matchingTags.push(pt.tagName)
|
||||
// Weight by confidence - higher confidence = more points
|
||||
weightedScore += POINTS_PER_TAG_MATCH * pt.confidence
|
||||
}
|
||||
}
|
||||
|
||||
// Cap at max score
|
||||
const score = Math.min(MAX_TAG_OVERLAP_SCORE, Math.round(weightedScore))
|
||||
return { score, matchingTags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate workload balance score
|
||||
* Full points if under target, decreasing as over target
|
||||
*/
|
||||
export function calculateWorkloadScore(
|
||||
currentAssignments: number,
|
||||
targetAssignments: number,
|
||||
maxAssignments?: number | null
|
||||
): number {
|
||||
// If user is at or over their personal max, return 0
|
||||
if (maxAssignments !== null && maxAssignments !== undefined) {
|
||||
if (currentAssignments >= maxAssignments) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// If under target, full points
|
||||
if (currentAssignments < targetAssignments) {
|
||||
return MAX_WORKLOAD_SCORE
|
||||
}
|
||||
|
||||
// Over target - decrease score
|
||||
const overload = currentAssignments - targetAssignments
|
||||
return Math.max(0, MAX_WORKLOAD_SCORE - overload * 5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate country match score (mentors only)
|
||||
* Same country = bonus points
|
||||
*/
|
||||
export function calculateCountryMatchScore(
|
||||
userCountry: string | null | undefined,
|
||||
projectCountry: string | null | undefined
|
||||
): number {
|
||||
if (!userCountry || !projectCountry) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Normalize for comparison
|
||||
const normalizedUser = userCountry.toLowerCase().trim()
|
||||
const normalizedProject = projectCountry.toLowerCase().trim()
|
||||
|
||||
if (normalizedUser === normalizedProject) {
|
||||
return MAX_COUNTRY_SCORE
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ─── Main Scoring Function ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get smart assignment suggestions for a round
|
||||
*/
|
||||
export async function getSmartSuggestions(options: {
|
||||
roundId: string
|
||||
type: 'jury' | 'mentor'
|
||||
limit?: number
|
||||
aiMaxPerJudge?: number
|
||||
}): Promise<AssignmentScore[]> {
|
||||
const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options
|
||||
|
||||
// Get projects in round with their tags
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
roundId,
|
||||
status: { not: 'REJECTED' },
|
||||
},
|
||||
include: {
|
||||
projectTags: {
|
||||
include: { tag: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get users of the appropriate role
|
||||
const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR'
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
role,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
assignments: {
|
||||
where: { roundId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (users.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get existing assignments to avoid duplicates
|
||||
const existingAssignments = await prisma.assignment.findMany({
|
||||
where: { roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const assignedPairs = new Set(
|
||||
existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
|
||||
)
|
||||
|
||||
// Calculate target assignments per user
|
||||
const targetPerUser = Math.ceil(projects.length / users.length)
|
||||
|
||||
// Calculate scores for all user-project pairs
|
||||
const suggestions: AssignmentScore[] = []
|
||||
|
||||
for (const user of users) {
|
||||
// Skip users at AI max (they won't appear in suggestions)
|
||||
const currentCount = user._count.assignments
|
||||
if (currentCount >= aiMaxPerJudge) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
// Skip if already assigned
|
||||
const pairKey = `${user.id}:${project.id}`
|
||||
if (assignedPairs.has(pairKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get project tags data
|
||||
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
|
||||
tagId: pt.tagId,
|
||||
tagName: pt.tag.name,
|
||||
confidence: pt.confidence,
|
||||
}))
|
||||
|
||||
// Calculate scores
|
||||
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
|
||||
user.expertiseTags,
|
||||
projectTags
|
||||
)
|
||||
|
||||
const workloadScore = calculateWorkloadScore(
|
||||
currentCount,
|
||||
targetPerUser,
|
||||
user.maxAssignments
|
||||
)
|
||||
|
||||
// Country match only for mentors
|
||||
const countryScore =
|
||||
type === 'mentor'
|
||||
? calculateCountryMatchScore(
|
||||
(user as any).country, // User might have country field
|
||||
project.country
|
||||
)
|
||||
: 0
|
||||
|
||||
const totalScore = tagScore + workloadScore + countryScore
|
||||
|
||||
// Build reasoning
|
||||
const reasoning: string[] = []
|
||||
if (matchingTags.length > 0) {
|
||||
reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`)
|
||||
}
|
||||
if (workloadScore === MAX_WORKLOAD_SCORE) {
|
||||
reasoning.push('Available capacity')
|
||||
} else if (workloadScore > 0) {
|
||||
reasoning.push('Moderate workload')
|
||||
}
|
||||
if (countryScore > 0) {
|
||||
reasoning.push('Same country')
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
userId: user.id,
|
||||
userName: user.name || 'Unknown',
|
||||
userEmail: user.email,
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
score: totalScore,
|
||||
breakdown: {
|
||||
tagOverlap: tagScore,
|
||||
workloadBalance: workloadScore,
|
||||
countryMatch: countryScore,
|
||||
aiBoost: 0,
|
||||
},
|
||||
reasoning,
|
||||
matchingTags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending and limit
|
||||
return suggestions.sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mentor suggestions for a specific project
|
||||
*/
|
||||
export async function getMentorSuggestionsForProject(
|
||||
projectId: string,
|
||||
limit: number = 10
|
||||
): Promise<AssignmentScore[]> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
projectTags: {
|
||||
include: { tag: true },
|
||||
},
|
||||
mentorAssignment: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
// Get all active mentors
|
||||
const mentors = await prisma.user.findMany({
|
||||
where: {
|
||||
role: 'MENTOR',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { mentorAssignments: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (mentors.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
|
||||
tagId: pt.tagId,
|
||||
tagName: pt.tag.name,
|
||||
confidence: pt.confidence,
|
||||
}))
|
||||
|
||||
const targetPerMentor = 5 // Target 5 projects per mentor
|
||||
|
||||
const suggestions: AssignmentScore[] = []
|
||||
|
||||
for (const mentor of mentors) {
|
||||
// Skip if already assigned to this project
|
||||
if (project.mentorAssignment?.mentorId === mentor.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
|
||||
mentor.expertiseTags,
|
||||
projectTags
|
||||
)
|
||||
|
||||
const workloadScore = calculateWorkloadScore(
|
||||
mentor._count.mentorAssignments,
|
||||
targetPerMentor,
|
||||
mentor.maxAssignments
|
||||
)
|
||||
|
||||
const countryScore = calculateCountryMatchScore(
|
||||
(mentor as any).country,
|
||||
project.country
|
||||
)
|
||||
|
||||
const totalScore = tagScore + workloadScore + countryScore
|
||||
|
||||
const reasoning: string[] = []
|
||||
if (matchingTags.length > 0) {
|
||||
reasoning.push(`${matchingTags.length} matching expertise tag(s)`)
|
||||
}
|
||||
if (countryScore > 0) {
|
||||
reasoning.push('Same country of origin')
|
||||
}
|
||||
if (workloadScore === MAX_WORKLOAD_SCORE) {
|
||||
reasoning.push('Available capacity')
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
userId: mentor.id,
|
||||
userName: mentor.name || 'Unknown',
|
||||
userEmail: mentor.email,
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
score: totalScore,
|
||||
breakdown: {
|
||||
tagOverlap: tagScore,
|
||||
workloadBalance: workloadScore,
|
||||
countryMatch: countryScore,
|
||||
aiBoost: 0,
|
||||
},
|
||||
reasoning,
|
||||
matchingTags,
|
||||
})
|
||||
}
|
||||
|
||||
return suggestions.sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
}
|
||||
Reference in New Issue
Block a user