Add bio field and enhance smart assignment with bio matching

- Add bio field to User model for judge/mentor profile descriptions
- Add bio step to onboarding wizard (optional step with 500 char limit)
- Enhance smart assignment to match judge bio against project description
  - Uses keyword extraction and Jaccard-like similarity scoring
  - Only applies if judge has a bio (no penalty for empty bio)
  - Max 15 points for bio match on top of existing scoring
- Fix geographic distribution query to use round relation for programId
- Update score breakdown: tags (40), bio (15), workload (25), country (15)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 15:27:28 +01:00
parent 3a7177c652
commit ff26769ce1
5 changed files with 205 additions and 25 deletions

View File

@@ -3,14 +3,16 @@
*
* Calculates scores for jury/mentor-project matching based on:
* - Tag overlap (expertise match)
* - Bio/description match (text similarity)
* - Workload balance
* - Country match (mentors only)
*
* Score Breakdown (100 points max):
* - Tag overlap: 0-50 points (weighted by confidence)
* - Tag overlap: 0-40 points (weighted by confidence)
* - Bio match: 0-15 points (if bio exists)
* - Workload balance: 0-25 points
* - Country match: 0-15 points (mentors only)
* - Reserved: 0-10 points (future AI boost)
* - Reserved: 0-5 points (future AI boost)
*/
import { prisma } from '@/lib/prisma'
@@ -19,6 +21,7 @@ import { prisma } from '@/lib/prisma'
export interface ScoreBreakdown {
tagOverlap: number
bioMatch: number
workloadBalance: number
countryMatch: number
aiBoost: number
@@ -44,13 +47,93 @@ export interface ProjectTagData {
// ─── Constants ───────────────────────────────────────────────────────────────
const MAX_TAG_OVERLAP_SCORE = 50
const MAX_TAG_OVERLAP_SCORE = 40
const MAX_BIO_MATCH_SCORE = 15
const MAX_WORKLOAD_SCORE = 25
const MAX_COUNTRY_SCORE = 15
const POINTS_PER_TAG_MATCH = 10
const POINTS_PER_TAG_MATCH = 8
// Common words to exclude from bio matching
const STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with',
'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been', 'be', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must',
'that', 'which', 'who', 'whom', 'this', 'these', 'those', 'it', 'its', 'i', 'we',
'you', 'he', 'she', 'they', 'them', 'their', 'our', 'my', 'your', 'his', 'her',
'am', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below',
'between', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
'where', 'why', 'how', 'all', 'each', 'few', 'more', 'most', 'other', 'some',
'such', 'no', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'can',
'just', 'being', 'over', 'both', 'up', 'down', 'out', 'also', 'new', 'any',
])
// ─── Scoring Functions ───────────────────────────────────────────────────────
/**
* Extract meaningful keywords from text
*/
function extractKeywords(text: string | null | undefined): Set<string> {
if (!text) return new Set()
// Tokenize, lowercase, and filter
const words = text
.toLowerCase()
.replace(/[^\w\s]/g, ' ') // Remove punctuation
.split(/\s+/)
.filter((word) => word.length >= 3 && !STOP_WORDS.has(word))
return new Set(words)
}
/**
* Calculate bio match score between user bio and project description
* Only applies if user has a bio
*/
export function calculateBioMatchScore(
userBio: string | null | undefined,
projectDescription: string | null | undefined
): { score: number; matchingKeywords: string[] } {
// If no bio, return 0 (not penalized, just no bonus)
if (!userBio || userBio.trim().length === 0) {
return { score: 0, matchingKeywords: [] }
}
// If no project description, can't match
if (!projectDescription || projectDescription.trim().length === 0) {
return { score: 0, matchingKeywords: [] }
}
const bioKeywords = extractKeywords(userBio)
const projectKeywords = extractKeywords(projectDescription)
if (bioKeywords.size === 0 || projectKeywords.size === 0) {
return { score: 0, matchingKeywords: [] }
}
// Find matching keywords
const matchingKeywords: string[] = []
for (const keyword of bioKeywords) {
if (projectKeywords.has(keyword)) {
matchingKeywords.push(keyword)
}
}
if (matchingKeywords.length === 0) {
return { score: 0, matchingKeywords: [] }
}
// Calculate score based on match ratio
// Use Jaccard-like similarity: matches / (bio keywords + project keywords - matches)
const unionSize = bioKeywords.size + projectKeywords.size - matchingKeywords.length
const similarity = matchingKeywords.length / unionSize
// Scale to max score (15 points)
// A good match (20%+ overlap) should get near max
const score = Math.min(MAX_BIO_MATCH_SCORE, Math.round(similarity * 100))
return { score, matchingKeywords }
}
/**
* Calculate tag overlap score between user expertise and project tags
*/
@@ -141,13 +224,19 @@ export async function getSmartSuggestions(options: {
}): Promise<AssignmentScore[]> {
const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options
// Get projects in round with their tags
// Get projects in round with their tags and description
const projects = await prisma.project.findMany({
where: {
roundId,
status: { not: 'REJECTED' },
},
include: {
select: {
id: true,
title: true,
teamName: true,
description: true,
country: true,
status: true,
projectTags: {
include: { tag: true },
},
@@ -158,14 +247,21 @@ export async function getSmartSuggestions(options: {
return []
}
// Get users of the appropriate role
// Get users of the appropriate role with bio for matching
const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR'
const users = await prisma.user.findMany({
where: {
role,
status: 'ACTIVE',
},
include: {
select: {
id: true,
name: true,
email: true,
bio: true,
expertiseTags: true,
maxAssignments: true,
country: true,
_count: {
select: {
assignments: {
@@ -222,6 +318,12 @@ export async function getSmartSuggestions(options: {
projectTags
)
// Bio match (only if user has a bio)
const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
user.bio,
project.description
)
const workloadScore = calculateWorkloadScore(
currentCount,
targetPerUser,
@@ -231,19 +333,19 @@ export async function getSmartSuggestions(options: {
// Country match only for mentors
const countryScore =
type === 'mentor'
? calculateCountryMatchScore(
(user as any).country, // User might have country field
project.country
)
? calculateCountryMatchScore(user.country, project.country)
: 0
const totalScore = tagScore + workloadScore + countryScore
const totalScore = tagScore + bioScore + workloadScore + countryScore
// Build reasoning
const reasoning: string[] = []
if (matchingTags.length > 0) {
reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`)
}
if (bioScore > 0) {
reasoning.push(`Bio match: ${matchingKeywords.length} keyword(s)`)
}
if (workloadScore === MAX_WORKLOAD_SCORE) {
reasoning.push('Available capacity')
} else if (workloadScore > 0) {
@@ -262,6 +364,7 @@ export async function getSmartSuggestions(options: {
score: totalScore,
breakdown: {
tagOverlap: tagScore,
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,
@@ -297,13 +400,20 @@ export async function getMentorSuggestionsForProject(
throw new Error(`Project not found: ${projectId}`)
}
// Get all active mentors
// Get all active mentors with bio for matching
const mentors = await prisma.user.findMany({
where: {
role: 'MENTOR',
status: 'ACTIVE',
},
include: {
select: {
id: true,
name: true,
email: true,
bio: true,
expertiseTags: true,
maxAssignments: true,
country: true,
_count: {
select: { mentorAssignments: true },
},
@@ -335,6 +445,12 @@ export async function getMentorSuggestionsForProject(
projectTags
)
// Bio match (only if mentor has a bio)
const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
mentor.bio,
project.description
)
const workloadScore = calculateWorkloadScore(
mentor._count.mentorAssignments,
targetPerMentor,
@@ -342,16 +458,19 @@ export async function getMentorSuggestionsForProject(
)
const countryScore = calculateCountryMatchScore(
(mentor as any).country,
mentor.country,
project.country
)
const totalScore = tagScore + workloadScore + countryScore
const totalScore = tagScore + bioScore + workloadScore + countryScore
const reasoning: string[] = []
if (matchingTags.length > 0) {
reasoning.push(`${matchingTags.length} matching expertise tag(s)`)
}
if (bioScore > 0) {
reasoning.push(`Bio match: ${matchingKeywords.length} keyword(s)`)
}
if (countryScore > 0) {
reasoning.push('Same country of origin')
}
@@ -368,6 +487,7 @@ export async function getMentorSuggestionsForProject(
score: totalScore,
breakdown: {
tagOverlap: tagScore,
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,