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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
Reference in New Issue
Block a user