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:
2026-02-04 14:15:06 +01:00
parent 7bcd2ce6ca
commit 29827268b2
71 changed files with 2139 additions and 6609 deletions

View 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 },
})
}

View 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)
}