From 7818644bdfdbb883893abc4f79f2c394a193d623 Mon Sep 17 00:00:00 2001 From: sujini kudipudi Date: Mon, 6 Jul 2026 11:33:46 +0530 Subject: [PATCH] fix(#314): enforce maximum difficulty cap in recommendation fallback paths --- src/app/actions/recommendations.ts | 28 ++++++++++++++++++++++++---- src/lib/pipeline/recommend.test.ts | 20 +++++++++++++++++--- src/lib/pipeline/recommend.ts | 7 ++++++- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index cd8253f6..92a1dbbe 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -228,10 +228,19 @@ export async function skipRecommendation( // Insert a replacement pick. Same difficulty if possible. Excludes // anything the user has already seen (any status). + const { data: profile } = await service + .from('profiles') + .select('level') + .eq('id', user.id) + .single(); + + const userLevel = profile?.level ?? 0; + const replacement = await pickReplacement({ service, userId: user.id, preferDifficulty: data.difficulty as 'E' | 'M' | 'H', + level: userLevel, }); await cacheDel(`recs:${user.id}`); @@ -242,8 +251,9 @@ async function pickReplacement(args: { service: NonNullable>; userId: string; preferDifficulty: 'E' | 'M' | 'H'; + level: number; }): Promise { - const { service, userId, preferDifficulty } = args; + const { service, userId, preferDifficulty, level } = args; const { data: seen } = await service .from('recommendations') @@ -251,8 +261,13 @@ async function pickReplacement(args: { .eq('user_id', userId); const excludeIds = new Set((seen ?? []).map((r) => r.issue_id)); - // Try same tier first, then any tier. Health >= 40 filter mirrors filterAndRank. - for (const where of [{ difficulty: preferDifficulty }, {} as Record]) { + const allowedDifficulties = new Set(); + if (level >= 0) allowedDifficulties.add('E'); + if (level >= 1) allowedDifficulties.add('M'); + if (level >= 2) allowedDifficulties.add('H'); + + // Try same tier first, then any allowed tier + for (const fallback of [false, true]) { let q = service .from('issues') .select('id, repo_full_name, github_issue_number, title, difficulty, xp_reward, url') @@ -260,7 +275,12 @@ async function pickReplacement(args: { .gte('repo_health_score', 40) .order('scored_at', { ascending: false }) .limit(50); - if (where.difficulty) q = q.eq('difficulty', where.difficulty); + + if (!fallback) { + q = q.eq('difficulty', preferDifficulty); + } else { + q = q.in('difficulty', Array.from(allowedDifficulties)); + } const { data: pool } = await q; const pick = (pool ?? []).find((i) => !excludeIds.has(i.id)); if (!pick) continue; diff --git a/src/lib/pipeline/recommend.test.ts b/src/lib/pipeline/recommend.test.ts index fca51e2c..2929d3f8 100644 --- a/src/lib/pipeline/recommend.test.ts +++ b/src/lib/pipeline/recommend.test.ts @@ -104,15 +104,29 @@ describe('filterAndRank', () => { expect(result).toEqual([]); }); - it('falls back to easier tier when target tier is empty', () => { + it('falls back to other allowed tiers but respects max difficulty cap', () => { + const issues = [ + issue({ id: 1, difficulty: 'H' }), + issue({ id: 2, difficulty: 'M' }), + issue({ id: 3, difficulty: 'E' }), + ]; + const result = filterAndRank(issues, { + level: 1, + excludeIssueIds: new Set(), + allowFallback: true, + }); + expect(result).toHaveLength(2); + expect(result.map((r) => r.difficulty).sort()).toEqual(['E', 'M']); + }); + + it('does not fallback to higher difficulty tiers', () => { const issues = [issue({ id: 1, difficulty: 'M' }), issue({ id: 2, difficulty: 'M' })]; - // L0 wants 3 E but pool has none. Soft fallback: take M's so user has something. const result = filterAndRank(issues, { level: 0, excludeIssueIds: new Set(), allowFallback: true, }); - expect(result).toHaveLength(2); + expect(result).toEqual([]); }); it('without fallback returns empty when tier is missing', () => { diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index 2ec0891e..b9d9adbe 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -65,8 +65,13 @@ export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptio // Fallback: if any tier came up empty, optionally borrow from adjacent (only easier). if (opts.allowFallback && result.length < totalDesired(mix)) { const seen = new Set(result.map((r) => r.id)); + const allowedDifficulties = new Set(); + if (opts.level >= 0) allowedDifficulties.add('E'); + if (opts.level >= 1) allowedDifficulties.add('M'); + if (opts.level >= 2) allowedDifficulties.add('H'); + const extras = eligible - .filter((i) => !seen.has(i.id)) + .filter((i) => !seen.has(i.id) && allowedDifficulties.has(i.difficulty)) .sort((a, b) => rankScore(b) - rankScore(a)); const needed = totalDesired(mix) - result.length; result.push(...extras.slice(0, needed));