From c29410fd4e4eea84053f44875913a29e521fa662 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 14:50:50 +0200 Subject: [PATCH] =?UTF-8?q?refactor(mentor):=20extract=20computeExpertiseO?= =?UTF-8?q?verlap=20helper=20(=C2=A7C=20prep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/server/services/mentor-matching.ts | 112 ++++++++++++++----------- 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/src/server/services/mentor-matching.ts b/src/server/services/mentor-matching.ts index 8214d9a..cd7516f 100644 --- a/src/server/services/mentor-matching.ts +++ b/src/server/services/mentor-matching.ts @@ -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() + 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() + 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() - - 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() - 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)