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:
@@ -351,6 +351,61 @@ export async function getAIMentorSuggestions(
|
|||||||
|
|
||||||
// ─── Algorithmic Fallback ────────────────────────────────────────────────────
|
// ─── 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
|
* Algorithmic fallback for multiple projects
|
||||||
*/
|
*/
|
||||||
@@ -376,69 +431,26 @@ function getAlgorithmicMatches(
|
|||||||
mentors: MentorInfo[],
|
mentors: MentorInfo[],
|
||||||
limit: number
|
limit: number
|
||||||
): MentorMatch[] {
|
): 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 scored = mentors.map((mentor) => {
|
||||||
const mentorKeywords = new Set<string>()
|
const { score: expertiseMatchScore, matchedCount } = computeExpertiseOverlap(
|
||||||
mentor.expertiseTags.forEach((tag) => {
|
project,
|
||||||
tag.toLowerCase().split(/\s+/).forEach((word) => {
|
mentor.expertiseTags
|
||||||
if (word.length > 2) mentorKeywords.add(word)
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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
|
const availabilityScore = mentor.maxAssignments
|
||||||
? 1 - (mentor.currentAssignments / mentor.maxAssignments)
|
? 1 - (mentor.currentAssignments / mentor.maxAssignments)
|
||||||
: 1
|
: 1
|
||||||
|
|
||||||
const confidenceScore = (expertiseMatchScore * 0.7 + availabilityScore * 0.3)
|
const confidenceScore = expertiseMatchScore * 0.7 + availabilityScore * 0.3
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mentorId: mentor.id,
|
mentorId: mentor.id,
|
||||||
confidenceScore: Math.round(confidenceScore * 100) / 100,
|
confidenceScore: Math.round(confidenceScore * 100) / 100,
|
||||||
expertiseMatchScore: Math.round(expertiseMatchScore * 100) / 100,
|
expertiseMatchScore,
|
||||||
reasoning: `Matched ${matchCount} keyword(s). Availability: ${availabilityScore > 0.5 ? 'Good' : 'Limited'}.`,
|
reasoning: `Matched ${matchedCount} keyword(s). Availability: ${availabilityScore > 0.5 ? 'Good' : 'Limited'}.`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by confidence and return top matches
|
|
||||||
return scored
|
return scored
|
||||||
.sort((a, b) => b.confidenceScore - a.confidenceScore)
|
.sort((a, b) => b.confidenceScore - a.confidenceScore)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
|
|||||||
Reference in New Issue
Block a user