Add bio field and enhance smart assignment with bio matching
Build and Push Docker Image / build (push) Successful in 9m13s
Details
Build and Push Docker Image / build (push) Successful in 9m13s
Details
- 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:
parent
3a7177c652
commit
ff26769ce1
|
|
@ -199,7 +199,8 @@ model User {
|
||||||
country String? // User's home country (for mentor matching)
|
country String? // User's home country (for mentor matching)
|
||||||
metadataJson Json? @db.JsonB
|
metadataJson Json? @db.JsonB
|
||||||
|
|
||||||
// Profile image
|
// Profile
|
||||||
|
bio String? // User bio for matching with project descriptions
|
||||||
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
|
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
|
||||||
profileImageProvider String? // Storage provider used: 's3' or 'local'
|
profileImageProvider String? // Storage provider used: 's3' or 'local'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ExpertiseSelect } from '@/components/shared/expertise-select'
|
import { ExpertiseSelect } from '@/components/shared/expertise-select'
|
||||||
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Phone,
|
Phone,
|
||||||
|
|
@ -36,9 +37,10 @@ import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Camera,
|
Camera,
|
||||||
Globe,
|
Globe,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
type Step = 'name' | 'photo' | 'country' | 'phone' | 'tags' | 'preferences' | 'complete'
|
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete'
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -48,6 +50,7 @@ export default function OnboardingPage() {
|
||||||
// Form state
|
// Form state
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [country, setCountry] = useState('')
|
const [country, setCountry] = useState('')
|
||||||
|
const [bio, setBio] = useState('')
|
||||||
const [phoneNumber, setPhoneNumber] = useState('')
|
const [phoneNumber, setPhoneNumber] = useState('')
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||||
const [lockedTags, setLockedTags] = useState<string[]>([])
|
const [lockedTags, setLockedTags] = useState<string[]>([])
|
||||||
|
|
@ -70,6 +73,10 @@ export default function OnboardingPage() {
|
||||||
if (userData.country) {
|
if (userData.country) {
|
||||||
setCountry(userData.country)
|
setCountry(userData.country)
|
||||||
}
|
}
|
||||||
|
// Pre-fill bio if available
|
||||||
|
if (userData.bio) {
|
||||||
|
setBio(userData.bio)
|
||||||
|
}
|
||||||
// Pre-fill phone if available
|
// Pre-fill phone if available
|
||||||
if (userData.phoneNumber) {
|
if (userData.phoneNumber) {
|
||||||
setPhoneNumber(userData.phoneNumber)
|
setPhoneNumber(userData.phoneNumber)
|
||||||
|
|
@ -96,10 +103,10 @@ export default function OnboardingPage() {
|
||||||
// Dynamic steps based on WhatsApp availability
|
// Dynamic steps based on WhatsApp availability
|
||||||
const steps: Step[] = useMemo(() => {
|
const steps: Step[] = useMemo(() => {
|
||||||
if (whatsappEnabled) {
|
if (whatsappEnabled) {
|
||||||
return ['name', 'photo', 'country', 'phone', 'tags', 'preferences', 'complete']
|
return ['name', 'photo', 'country', 'bio', 'phone', 'tags', 'preferences', 'complete']
|
||||||
}
|
}
|
||||||
// Skip phone step if WhatsApp is disabled
|
// Skip phone step if WhatsApp is disabled
|
||||||
return ['name', 'photo', 'country', 'tags', 'preferences', 'complete']
|
return ['name', 'photo', 'country', 'bio', 'tags', 'preferences', 'complete']
|
||||||
}, [whatsappEnabled])
|
}, [whatsappEnabled])
|
||||||
|
|
||||||
const currentIndex = steps.indexOf(step)
|
const currentIndex = steps.indexOf(step)
|
||||||
|
|
@ -128,6 +135,7 @@ export default function OnboardingPage() {
|
||||||
await completeOnboarding.mutateAsync({
|
await completeOnboarding.mutateAsync({
|
||||||
name,
|
name,
|
||||||
country: country || undefined,
|
country: country || undefined,
|
||||||
|
bio: bio || undefined,
|
||||||
phoneNumber: phoneNumber || undefined,
|
phoneNumber: phoneNumber || undefined,
|
||||||
expertiseTags,
|
expertiseTags,
|
||||||
notificationPreference,
|
notificationPreference,
|
||||||
|
|
@ -302,7 +310,49 @@ export default function OnboardingPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Phone (only if WhatsApp enabled) */}
|
{/* Step 4: Bio */}
|
||||||
|
{step === 'bio' && (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-primary" />
|
||||||
|
About You
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tell us a bit about yourself and your expertise. This helps us match you with relevant projects. (Optional)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bio">Bio</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
placeholder="e.g., Marine biologist with 10 years experience in coral reef conservation and ocean acidification research..."
|
||||||
|
rows={4}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{bio.length}/500 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goNext} className="flex-1">
|
||||||
|
{bio ? 'Continue' : 'Skip for now'}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Phone (only if WhatsApp enabled) */}
|
||||||
{step === 'phone' && whatsappEnabled && (
|
{step === 'phone' && whatsappEnabled && (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -343,7 +393,7 @@ export default function OnboardingPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 5: Tags */}
|
{/* Step 6: Tags */}
|
||||||
{step === 'tags' && (
|
{step === 'tags' && (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -377,7 +427,7 @@ export default function OnboardingPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 6: Preferences */}
|
{/* Step 7: Preferences */}
|
||||||
{step === 'preferences' && (
|
{step === 'preferences' && (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -434,6 +484,12 @@ export default function OnboardingPage() {
|
||||||
<span className="text-muted-foreground">Country:</span> {country}
|
<span className="text-muted-foreground">Country:</span> {country}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{bio && (
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Bio:</span>{' '}
|
||||||
|
{bio.length > 50 ? `${bio.substring(0, 50)}...` : bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{whatsappEnabled && phoneNumber && (
|
{whatsappEnabled && phoneNumber && (
|
||||||
<p>
|
<p>
|
||||||
<span className="text-muted-foreground">Phone:</span>{' '}
|
<span className="text-muted-foreground">Phone:</span>{' '}
|
||||||
|
|
|
||||||
|
|
@ -349,7 +349,7 @@ export const analyticsRouter = router({
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where = input.roundId
|
const where = input.roundId
|
||||||
? { roundId: input.roundId }
|
? { roundId: input.roundId }
|
||||||
: { programId: input.programId }
|
: { round: { programId: input.programId } }
|
||||||
|
|
||||||
const distribution = await ctx.prisma.project.groupBy({
|
const distribution = await ctx.prisma.project.groupBy({
|
||||||
by: ['country'],
|
by: ['country'],
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export const userRouter = router({
|
||||||
metadataJson: true,
|
metadataJson: true,
|
||||||
phoneNumber: true,
|
phoneNumber: true,
|
||||||
country: true,
|
country: true,
|
||||||
|
bio: true,
|
||||||
notificationPreference: true,
|
notificationPreference: true,
|
||||||
profileImageKey: true,
|
profileImageKey: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
|
@ -789,6 +790,7 @@ export const userRouter = router({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
phoneNumber: z.string().optional(),
|
phoneNumber: z.string().optional(),
|
||||||
country: z.string().optional(),
|
country: z.string().optional(),
|
||||||
|
bio: z.string().max(500).optional(),
|
||||||
expertiseTags: z.array(z.string()).optional(),
|
expertiseTags: z.array(z.string()).optional(),
|
||||||
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
||||||
})
|
})
|
||||||
|
|
@ -811,6 +813,7 @@ export const userRouter = router({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
phoneNumber: input.phoneNumber,
|
phoneNumber: input.phoneNumber,
|
||||||
country: input.country,
|
country: input.country,
|
||||||
|
bio: input.bio,
|
||||||
expertiseTags: mergedTags,
|
expertiseTags: mergedTags,
|
||||||
notificationPreference: input.notificationPreference || 'EMAIL',
|
notificationPreference: input.notificationPreference || 'EMAIL',
|
||||||
onboardingCompletedAt: new Date(),
|
onboardingCompletedAt: new Date(),
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@
|
||||||
*
|
*
|
||||||
* Calculates scores for jury/mentor-project matching based on:
|
* Calculates scores for jury/mentor-project matching based on:
|
||||||
* - Tag overlap (expertise match)
|
* - Tag overlap (expertise match)
|
||||||
|
* - Bio/description match (text similarity)
|
||||||
* - Workload balance
|
* - Workload balance
|
||||||
* - Country match (mentors only)
|
* - Country match (mentors only)
|
||||||
*
|
*
|
||||||
* Score Breakdown (100 points max):
|
* 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
|
* - Workload balance: 0-25 points
|
||||||
* - Country match: 0-15 points (mentors only)
|
* - 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'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
@ -19,6 +21,7 @@ import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
export interface ScoreBreakdown {
|
export interface ScoreBreakdown {
|
||||||
tagOverlap: number
|
tagOverlap: number
|
||||||
|
bioMatch: number
|
||||||
workloadBalance: number
|
workloadBalance: number
|
||||||
countryMatch: number
|
countryMatch: number
|
||||||
aiBoost: number
|
aiBoost: number
|
||||||
|
|
@ -44,13 +47,93 @@ export interface ProjectTagData {
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── 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_WORKLOAD_SCORE = 25
|
||||||
const MAX_COUNTRY_SCORE = 15
|
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 ───────────────────────────────────────────────────────
|
// ─── 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
|
* Calculate tag overlap score between user expertise and project tags
|
||||||
*/
|
*/
|
||||||
|
|
@ -141,13 +224,19 @@ export async function getSmartSuggestions(options: {
|
||||||
}): Promise<AssignmentScore[]> {
|
}): Promise<AssignmentScore[]> {
|
||||||
const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options
|
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({
|
const projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
roundId,
|
roundId,
|
||||||
status: { not: 'REJECTED' },
|
status: { not: 'REJECTED' },
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
teamName: true,
|
||||||
|
description: true,
|
||||||
|
country: true,
|
||||||
|
status: true,
|
||||||
projectTags: {
|
projectTags: {
|
||||||
include: { tag: true },
|
include: { tag: true },
|
||||||
},
|
},
|
||||||
|
|
@ -158,14 +247,21 @@ export async function getSmartSuggestions(options: {
|
||||||
return []
|
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 role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR'
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role,
|
role,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
bio: true,
|
||||||
|
expertiseTags: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
country: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
assignments: {
|
assignments: {
|
||||||
|
|
@ -222,6 +318,12 @@ export async function getSmartSuggestions(options: {
|
||||||
projectTags
|
projectTags
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Bio match (only if user has a bio)
|
||||||
|
const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
|
||||||
|
user.bio,
|
||||||
|
project.description
|
||||||
|
)
|
||||||
|
|
||||||
const workloadScore = calculateWorkloadScore(
|
const workloadScore = calculateWorkloadScore(
|
||||||
currentCount,
|
currentCount,
|
||||||
targetPerUser,
|
targetPerUser,
|
||||||
|
|
@ -231,19 +333,19 @@ export async function getSmartSuggestions(options: {
|
||||||
// Country match only for mentors
|
// Country match only for mentors
|
||||||
const countryScore =
|
const countryScore =
|
||||||
type === 'mentor'
|
type === 'mentor'
|
||||||
? calculateCountryMatchScore(
|
? calculateCountryMatchScore(user.country, project.country)
|
||||||
(user as any).country, // User might have country field
|
|
||||||
project.country
|
|
||||||
)
|
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const totalScore = tagScore + workloadScore + countryScore
|
const totalScore = tagScore + bioScore + workloadScore + countryScore
|
||||||
|
|
||||||
// Build reasoning
|
// Build reasoning
|
||||||
const reasoning: string[] = []
|
const reasoning: string[] = []
|
||||||
if (matchingTags.length > 0) {
|
if (matchingTags.length > 0) {
|
||||||
reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`)
|
reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`)
|
||||||
}
|
}
|
||||||
|
if (bioScore > 0) {
|
||||||
|
reasoning.push(`Bio match: ${matchingKeywords.length} keyword(s)`)
|
||||||
|
}
|
||||||
if (workloadScore === MAX_WORKLOAD_SCORE) {
|
if (workloadScore === MAX_WORKLOAD_SCORE) {
|
||||||
reasoning.push('Available capacity')
|
reasoning.push('Available capacity')
|
||||||
} else if (workloadScore > 0) {
|
} else if (workloadScore > 0) {
|
||||||
|
|
@ -262,6 +364,7 @@ export async function getSmartSuggestions(options: {
|
||||||
score: totalScore,
|
score: totalScore,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
tagOverlap: tagScore,
|
tagOverlap: tagScore,
|
||||||
|
bioMatch: bioScore,
|
||||||
workloadBalance: workloadScore,
|
workloadBalance: workloadScore,
|
||||||
countryMatch: countryScore,
|
countryMatch: countryScore,
|
||||||
aiBoost: 0,
|
aiBoost: 0,
|
||||||
|
|
@ -297,13 +400,20 @@ export async function getMentorSuggestionsForProject(
|
||||||
throw new Error(`Project not found: ${projectId}`)
|
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({
|
const mentors = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: 'MENTOR',
|
role: 'MENTOR',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
bio: true,
|
||||||
|
expertiseTags: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
country: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { mentorAssignments: true },
|
select: { mentorAssignments: true },
|
||||||
},
|
},
|
||||||
|
|
@ -335,6 +445,12 @@ export async function getMentorSuggestionsForProject(
|
||||||
projectTags
|
projectTags
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Bio match (only if mentor has a bio)
|
||||||
|
const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
|
||||||
|
mentor.bio,
|
||||||
|
project.description
|
||||||
|
)
|
||||||
|
|
||||||
const workloadScore = calculateWorkloadScore(
|
const workloadScore = calculateWorkloadScore(
|
||||||
mentor._count.mentorAssignments,
|
mentor._count.mentorAssignments,
|
||||||
targetPerMentor,
|
targetPerMentor,
|
||||||
|
|
@ -342,16 +458,19 @@ export async function getMentorSuggestionsForProject(
|
||||||
)
|
)
|
||||||
|
|
||||||
const countryScore = calculateCountryMatchScore(
|
const countryScore = calculateCountryMatchScore(
|
||||||
(mentor as any).country,
|
mentor.country,
|
||||||
project.country
|
project.country
|
||||||
)
|
)
|
||||||
|
|
||||||
const totalScore = tagScore + workloadScore + countryScore
|
const totalScore = tagScore + bioScore + workloadScore + countryScore
|
||||||
|
|
||||||
const reasoning: string[] = []
|
const reasoning: string[] = []
|
||||||
if (matchingTags.length > 0) {
|
if (matchingTags.length > 0) {
|
||||||
reasoning.push(`${matchingTags.length} matching expertise tag(s)`)
|
reasoning.push(`${matchingTags.length} matching expertise tag(s)`)
|
||||||
}
|
}
|
||||||
|
if (bioScore > 0) {
|
||||||
|
reasoning.push(`Bio match: ${matchingKeywords.length} keyword(s)`)
|
||||||
|
}
|
||||||
if (countryScore > 0) {
|
if (countryScore > 0) {
|
||||||
reasoning.push('Same country of origin')
|
reasoning.push('Same country of origin')
|
||||||
}
|
}
|
||||||
|
|
@ -368,6 +487,7 @@ export async function getMentorSuggestionsForProject(
|
||||||
score: totalScore,
|
score: totalScore,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
tagOverlap: tagScore,
|
tagOverlap: tagScore,
|
||||||
|
bioMatch: bioScore,
|
||||||
workloadBalance: workloadScore,
|
workloadBalance: workloadScore,
|
||||||
countryMatch: countryScore,
|
countryMatch: countryScore,
|
||||||
aiBoost: 0,
|
aiBoost: 0,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue