refactor(mentor): extract computeExpertiseOverlap helper (§C prep)

Pure function reused by upcoming mentor.getCandidates + AI fallback path.
Refactors getAlgorithmicMatches to call it. No behavior change.

Plan: docs/superpowers/plans/2026-04-28-pr4-mentor-assignment-ux.md
This commit is contained in:
Matt
2026-04-28 14:50:50 +02:00
parent b867c45114
commit c29410fd4e

View File

@@ -351,6 +351,61 @@ export async function getAIMentorSuggestions(
// ─── Algorithmic Fallback ────────────────────────────────────────────────────
/**
* Compute expertise-overlap score [0..1] between a project and a mentor.
* Pure function — no availability/load mixed in. Reused by mentor.getCandidates
* and the algorithmic fallback below to keep ranking consistent.
*
* Score = matched-keyword-count / max(mentor-keyword-count, 1).
* Project keywords: ocean issue + category + tags + description words >4 chars.
* Mentor keywords: expertiseTags split on whitespace, words >2 chars.
* Substring-matched both directions.
*/
export function computeExpertiseOverlap(
project: {
oceanIssue: OceanIssue | null
competitionCategory: CompetitionCategory | null
tags: string[]
description: string | null
},
mentorExpertiseTags: string[],
): { score: number; matchedCount: number } {
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((w) => {
if (w.length > 3) projectKeywords.add(w)
})
})
if (project.description) {
project.description.toLowerCase().split(/\s+/).forEach((w) => {
if (w.length > 4) projectKeywords.add(w.replace(/[^a-z]/g, ''))
})
}
const mentorKeywords = new Set<string>()
mentorExpertiseTags.forEach((tag) => {
tag.toLowerCase().split(/\s+/).forEach((w) => {
if (w.length > 2) mentorKeywords.add(w)
})
})
let matchCount = 0
projectKeywords.forEach((pk) => {
mentorKeywords.forEach((mk) => {
if (pk.includes(mk) || mk.includes(pk)) matchCount++
})
})
const score = mentorKeywords.size > 0 ? Math.min(1, matchCount / mentorKeywords.size) : 0
return { score: Math.round(score * 100) / 100, matchedCount: matchCount }
}
/**
* Algorithmic fallback for multiple projects
*/
@@ -376,69 +431,26 @@ function getAlgorithmicMatches(
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) {
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)
})
})
const { score: expertiseMatchScore, matchedCount } = computeExpertiseOverlap(
project,
mentor.expertiseTags
)
// 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)
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). Availability: ${availabilityScore > 0.5 ? 'Good' : 'Limited'}.`,
expertiseMatchScore,
reasoning: `Matched ${matchedCount} keyword(s). 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)