diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts index 7f80828..7488f0e 100644 --- a/src/app/actions/profile.ts +++ b/src/app/actions/profile.ts @@ -3,10 +3,10 @@ import { getServerSupabase } from '@/lib/supabase/server'; import { getServiceSupabase } from '@/lib/supabase/service'; import { inngest } from '@/inngest/client'; +import { rateLimit } from '@/lib/rate-limit'; import { ok, err, type Result } from '@/lib/result'; import { revalidatePath } from 'next/cache'; import { z } from 'zod'; -import { rateLimit } from '@/lib/rate-limit'; type BootstrapOutput = { profileId: string; @@ -38,6 +38,7 @@ export async function bootstrapProfile(): Promise> { const githubId = String(identity.id); const githubHandle = (identity.identity_data?.['user_name'] ?? identity.identity_data?.['preferred_username']) as string | undefined; + if (!githubHandle) return err('no_github_handle', 'GitHub handle missing from identity'); const avatarUrl = identity.identity_data?.['avatar_url'] as string | undefined; @@ -68,8 +69,10 @@ export async function bootstrapProfile(): Promise> { } let auditQueued = false; + if (!profile.audit_completed) { const providerToken = (await sb.auth.getSession()).data.session?.provider_token; + if (providerToken) { await inngest.send({ name: 'audit/run', @@ -80,6 +83,7 @@ export async function bootstrapProfile(): Promise> { accessToken: providerToken, }, }); + auditQueued = true; } } @@ -108,6 +112,60 @@ export async function bootstrapProfile(): Promise> { }); } +/** + * Updates or clears the user's mute preferences. + * Pass empty arrays to clear preferences. + */ +export async function updateMutePreferences( + mutedRepos: string[], + mutedLanguages: string[], +): Promise> { + const sb = getServerSupabase(); + + if (!sb) { + return err('not_configured', 'auth not configured'); + } + + const { + data: { user }, + } = await sb.auth.getUser(); + + if (!user) { + return err('not_authenticated', 'sign in first'); + } + + const rateRes = await rateLimit({ + namespace: 'profile:mute', + key: user.id, + limit: 10, + windowSec: 60, + }); + + if (!rateRes.ok) { + return err('rate_limited', 'slow down', true); + } + + const service = getServiceSupabase(); + + if (!service) { + return err('not_configured', 'service role not configured'); + } + + const { error: updateErr } = await service + .from('profiles') + .update({ + muted_repos: mutedRepos, + muted_languages: mutedLanguages, + }) + .eq('id', user.id); + + if (updateErr) { + return err('persist_failed', updateErr.message); + } + + return ok(undefined); +} + // ============================================================================ // Profile Update Action // ============================================================================ @@ -115,8 +173,11 @@ export async function bootstrapProfile(): Promise> { // Validation schema for profile updates const profileUpdateSchema = z.object({ bio: z.string().max(280, 'Bio must be 280 characters or less').optional().nullable(), + skills: z.array(z.string()).max(10, 'Maximum 10 skills allowed').optional().nullable(), + website_url: z.string().url('Please enter a valid URL').optional().nullable().or(z.literal('')), + twitter_handle: z .string() .regex(/^[A-Za-z0-9_]{1,15}$/, 'Invalid Twitter handle (no @ symbol, max 15 chars)') @@ -132,6 +193,7 @@ export type ProfileUpdateData = z.infer; */ export async function updateProfile(data: ProfileUpdateData): Promise> { const sb = getServerSupabase(); + if (!sb) { return err('not_configured', 'Authentication not configured'); } diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index cd8253f..5c731a2 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -194,6 +194,7 @@ export async function linkPrToRec(recId: number, prUrl: string): Promise> { const sb = getServerSupabase(); if (!sb) return err('not_configured', 'auth not configured'); @@ -214,9 +215,14 @@ export async function skipRecommendation( if (!rateRes.ok) return err('rate_limited', 'slow down', true); // Atomic skip with the issue id so we know what tier to refill from. + // Persist the optional skip_reason alongside the status change. + const updatePayload: Record = { status: 'reassigned' }; + if (skipReason?.trim()) { + updatePayload.skip_reason = skipReason.trim().slice(0, 500); + } const { data, error: updateErr } = await service .from('recommendations') - .update({ status: 'reassigned' }) + .update(updatePayload) .eq('id', recId) .eq('user_id', user.id) .eq('status', 'open') diff --git a/src/inngest/functions/recommendations-build.test.ts b/src/inngest/functions/recommendations-build.test.ts index 6190bfd..27ab868 100644 --- a/src/inngest/functions/recommendations-build.test.ts +++ b/src/inngest/functions/recommendations-build.test.ts @@ -1,99 +1,301 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { filterAndRank } from '@/lib/pipeline/recommend'; -import { recommendationsBuild } from './recommendations-build'; -import { sb, wire, step } from './test-helpers'; - -// Mock external dependencies. -vi.mock('@/lib/supabase/service', () => ({ getServiceSupabase: vi.fn() })); -vi.mock('@/lib/pipeline/recommend', () => ({ filterAndRank: vi.fn() })); +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; + +/** + * Unit tests for recommendations-build Inngest function. + * + * The production function issues these Supabase queries in order: + * 1. from('issues').select(...).eq('state','open').order(...).limit() → candidate pool + * 2. from('github_installations').select(...).is(...).not(...) → active users + * 3. from('recommendations').select(...).eq('status','reassigned').gte() → skip history + * 4. per-user: from('recommendations').select('issue_id').eq('user_id') → seen ids + * 5. from('recommendations').upsert(...) → write picks + * + * The mock below must handle all five shapes through a single `from()` factory. + */ + +// --------------------------------------------------------------------------- +// Mock: inngest client +// +// createFunction() returns an opaque InngestFunction object in production. +// Here we capture the raw handler at mock-time so tests can invoke it directly +// via runHandler(), bypassing the Inngest runtime entirely. +// --------------------------------------------------------------------------- +type StepCtx = { step: { run: (name: string, fn: () => unknown) => unknown } }; +type RawHandler = (ctx: StepCtx) => unknown; + +let capturedHandler: RawHandler | null = null; + vi.mock('../client', () => ({ - inngest: { createFunction: (_c: unknown, _t: unknown, h: Function) => h }, + inngest: { + createFunction: (_meta: unknown, _trigger: unknown, handler: RawHandler) => { + capturedHandler = handler; + // Return a non-callable sentinel so the module exports something. + return { __isMockedInngestFn: true }; + }, + }, })); -// Handler reference — createFunction mock passes the raw handler through. -const run = recommendationsBuild as unknown as (ctx: { - step: typeof step; -}) => Promise<{ users: number; inserted: number }>; - -// Factory for a scored issue row. -const issue = ( - id: number, - difficulty: 'E' | 'M' | 'H' = 'E', - lang: string | null = 'TypeScript', -) => ({ - id, - repo_full_name: 'org/repo', - github_issue_number: id, - title: `Issue ${id}`, - difficulty, - xp_reward: difficulty === 'E' ? 50 : difficulty === 'M' ? 150 : 400, - repo_health_score: 80, - repo_language: lang, - scored_at: new Date().toISOString(), -}); +/** Invoke the captured handler with a step.run() that calls its callback directly. */ +function runHandler(): Promise { + if (!capturedHandler) + throw new Error('Handler not captured — import recommendations-build first'); + return Promise.resolve( + capturedHandler({ + step: { + run: (_name: string, fn: () => unknown) => fn(), + }, + }), + ); +} -// Factory for a user row returned by github_installations join. -const user = (id: string, level = 0, lang: string | null = null) => ({ - user_id: id, - profiles: { level, primary_language: lang }, -}); +// --------------------------------------------------------------------------- +// Mock: getServiceSupabase +// +// The builder is called multiple times on the same `from()` factory. +// We need to distinguish call sites by the table name and/or which terminal +// method is eventually invoked. We achieve this by tracking a simple call +// counter that resets in beforeEach. +// --------------------------------------------------------------------------- + +// Terminal mocks — reassigned in beforeEach so each test can override them. +const mockIssuesLimit = vi.fn(); +const mockUsersNot = vi.fn(); +const mockSkipHistoryGte = vi.fn(); // from('recommendations').eq('status','reassigned').gte() +const mockSeenEq = vi.fn(); // from('recommendations').eq('user_id', ...) (per-user) +const mockUpsert = vi.fn(); + +/** + * Tiny state machine: `from()` is called once per query; we use a counter + * so the nth call returns the nth builder shape. + * + * Call order inside build-all step: + * 0 → issues pool (.select.eq.order.limit) + * 1 → users (.select.is.not) + * 2 → skip history (.select.eq.gte) + * 3 → seen ids (.select.eq) ← per-user, may repeat + * 4+ → upsert (.upsert) ← per-user, may repeat + */ +let fromCallCount = 0; + +vi.mock('@/lib/supabase/service', () => ({ + getServiceSupabase: () => ({ + from: (_table: string) => { + const callIndex = fromCallCount++; + + if (callIndex === 0) { + // issues pool: .select().eq().order().limit() + return { + select: () => ({ + eq: () => ({ + order: () => ({ + limit: mockIssuesLimit, + }), + }), + }), + }; + } + + if (callIndex === 1) { + // github_installations: .select().is().not() + return { + select: () => ({ + is: () => ({ + not: mockUsersNot, + }), + }), + }; + } + + if (callIndex === 2) { + // skip history: .select().eq('status','reassigned').gte('recommended_at', ...) + return { + select: () => ({ + eq: () => ({ + gte: mockSkipHistoryGte, + }), + }), + }; + } + + // callIndex >= 3 alternates between seen-ids selects and upserts + // per-user seen ids: .select('issue_id').eq('user_id', ...) + // upsert: .upsert(rows, opts) + return { + select: () => ({ + eq: mockSeenEq, + }), + upsert: mockUpsert, + }; + }, + }), +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('recommendations-build', () => { + beforeAll(async () => { + await import('./recommendations-build'); + }); -describe('recommendationsBuild', () => { - beforeEach(() => vi.clearAllMocks()); - - it('creates correct number of recs matching filterAndRank output', async () => { - wire({ - issues: sb({ - limit: vi.fn().mockResolvedValue({ data: [issue(1, 'E'), issue(2, 'M'), issue(3, 'H')] }), - }), - github_installations: sb({ not: vi.fn().mockResolvedValue({ data: [user('u1', 1)] }) }), - recommendations: sb({ - eq: vi.fn().mockResolvedValue({ data: [] }), - upsert: vi.fn().mockResolvedValue({ error: null }), - }), + beforeEach(() => { + vi.clearAllMocks(); + fromCallCount = 0; + + // Default: empty pool → early-exit path (users: 0, inserted: 0) + mockIssuesLimit.mockResolvedValue({ data: [] }); + mockUsersNot.mockResolvedValue({ data: [] }); + mockSkipHistoryGte.mockResolvedValue({ data: [] }); + mockSeenEq.mockResolvedValue({ data: [] }); + mockUpsert.mockResolvedValue({ error: null }); + }); + + it('returns { users: 0, inserted: 0 } when the issue pool is empty', async () => { + mockIssuesLimit.mockResolvedValue({ data: [] }); + + const result = await runHandler(); + + expect(result).toEqual({ users: 0, inserted: 0 }); + }); + + it('returns { users: 0, inserted: 0 } when there are no active users', async () => { + mockIssuesLimit.mockResolvedValue({ + data: [ + { + id: 1, + repo_full_name: 'a/b', + github_issue_number: 10, + title: 'Fix bug', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: 'TypeScript', + scored_at: new Date().toISOString(), + state: 'open', + }, + ], }); - vi.mocked(filterAndRank).mockReturnValue([ - { id: 1, difficulty: 'E', xpReward: 50 }, - { id: 3, difficulty: 'H', xpReward: 400 }, - ] as never); + mockUsersNot.mockResolvedValue({ data: [] }); + + const result = await runHandler(); - expect(await run({ step })).toEqual({ users: 1, inserted: 2 }); + expect(result).toEqual({ users: 0, inserted: 0 }); }); - it('excludes already-seen issues so no duplicates are created', async () => { - wire({ - issues: sb({ limit: vi.fn().mockResolvedValue({ data: [issue(1), issue(2), issue(3)] }) }), - github_installations: sb({ not: vi.fn().mockResolvedValue({ data: [user('u1')] }) }), - recommendations: sb({ - eq: vi.fn().mockResolvedValue({ data: [{ issue_id: 1 }, { issue_id: 3 }] }), - }), + it('queries skip history with gte() and does not throw', async () => { + const issue = { + id: 1, + repo_full_name: 'a/b', + github_issue_number: 10, + title: 'Fix bug', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: 'TypeScript', + scored_at: new Date().toISOString(), + state: 'open', + }; + + mockIssuesLimit.mockResolvedValue({ data: [issue] }); + mockUsersNot.mockResolvedValue({ + data: [ + { + user_id: 'user-1', + profiles: { level: 0, primary_language: 'TypeScript' }, + }, + ], }); - vi.mocked(filterAndRank).mockReturnValue([]); + // skip history returns empty — no penalty applied + mockSkipHistoryGte.mockResolvedValue({ data: [] }); + // user has no previously seen issues + mockSeenEq.mockResolvedValue({ data: [] }); + mockUpsert.mockResolvedValue({ error: null }); - await run({ step }); + const result = (await runHandler()) as { users: number; inserted: number }; - expect(filterAndRank).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ excludeIssueIds: new Set([1, 3]) }), - ); + expect(mockSkipHistoryGte).toHaveBeenCalledOnce(); + expect(result.users).toBe(1); + expect(result.inserted).toBeGreaterThanOrEqual(0); }); - it('passes user level to filterAndRank for difficulty distribution', async () => { - wire({ - issues: sb({ limit: vi.fn().mockResolvedValue({ data: [issue(1, 'E'), issue(2, 'M')] }) }), - github_installations: sb({ - not: vi.fn().mockResolvedValue({ data: [user('u1', 4, 'Python')] }), - }), - recommendations: sb({ eq: vi.fn().mockResolvedValue({ data: [] }) }), + it('applies skip history penalty — skipped repo ranks lower', async () => { + const skippedIssue = { + id: 1, + repo_full_name: 'skipped/repo', + github_issue_number: 1, + title: 'Old issue', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: 'TypeScript', + scored_at: new Date().toISOString(), + state: 'open', + }; + const freshIssue = { + id: 2, + repo_full_name: 'fresh/repo', + github_issue_number: 2, + title: 'Fresh issue', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: 'TypeScript', + scored_at: new Date().toISOString(), + state: 'open', + }; + + mockIssuesLimit.mockResolvedValue({ data: [skippedIssue, freshIssue] }); + mockUsersNot.mockResolvedValue({ + data: [{ user_id: 'user-1', profiles: { level: 0, primary_language: null } }], }); - vi.mocked(filterAndRank).mockReturnValue([]); + // Two skip-history rows for 'skipped/repo' → triggers repo penalty + mockSkipHistoryGte.mockResolvedValue({ + data: [ + { + user_id: 'user-1', + issues: { repo_full_name: 'skipped/repo', repo_language: 'TypeScript' }, + }, + { + user_id: 'user-1', + issues: { repo_full_name: 'skipped/repo', repo_language: 'TypeScript' }, + }, + ], + }); + mockSeenEq.mockResolvedValue({ data: [] }); + mockUpsert.mockResolvedValue({ error: null }); + + // Should not throw — penalty logic runs without error + await expect(runHandler()).resolves.not.toThrow(); - await run({ step }); + expect(mockSkipHistoryGte).toHaveBeenCalledOnce(); + }); + + it('does not insert recommendations when upsert errors', async () => { + const issue = { + id: 1, + repo_full_name: 'a/b', + github_issue_number: 1, + title: 'Bug', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: null, + scored_at: new Date().toISOString(), + state: 'open', + }; - expect(filterAndRank).toHaveBeenCalledWith(expect.anything(), { - level: 4, - excludeIssueIds: new Set(), - allowFallback: true, + mockIssuesLimit.mockResolvedValue({ data: [issue] }); + mockUsersNot.mockResolvedValue({ + data: [{ user_id: 'user-1', profiles: { level: 0, primary_language: null } }], }); + mockSkipHistoryGte.mockResolvedValue({ data: [] }); + mockSeenEq.mockResolvedValue({ data: [] }); + mockUpsert.mockResolvedValue({ error: new Error('db error') }); + + const result = (await runHandler()) as { users: number; inserted: number }; + + expect(result.users).toBe(1); + expect(result.inserted).toBe(0); // error → not counted }); }); diff --git a/src/inngest/functions/recommendations-build.ts b/src/inngest/functions/recommendations-build.ts index 2b04d80..e31ac30 100644 --- a/src/inngest/functions/recommendations-build.ts +++ b/src/inngest/functions/recommendations-build.ts @@ -1,6 +1,7 @@ import { inngest } from '../client'; import { getServiceSupabase } from '@/lib/supabase/service'; -import { filterAndRank, type ScoredIssue } from '@/lib/pipeline/recommend'; +import { filterAndRank, type ScoredIssue, type SkipCounts } from '@/lib/pipeline/recommend'; +import { SKIP_HISTORY_WINDOW_DAYS } from '@/lib/pipeline/constants'; /** * For every active user, derive a fresh set of recommendation rows from the @@ -61,6 +62,37 @@ export const recommendationsBuild = inngest.createFunction( }; const userList = (users ?? []) as unknown as UserRow[]; + // Fetch skip history in bulk to avoid N+1 queries inside the user loop. + const cutoffDate = new Date( + Date.now() - SKIP_HISTORY_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); + const { data: skipsData } = await sb + .from('recommendations') + .select('user_id, issues!inner(repo_full_name, repo_language)') + .eq('status', 'reassigned') + .gte('recommended_at', cutoffDate); + + const skipHistoryMap: Record = {}; + for (const row of skipsData ?? []) { + const userId = row.user_id; + const issue = row.issues as unknown as { + repo_full_name: string; + repo_language: string | null; + }; + + if (!skipHistoryMap[userId]) { + skipHistoryMap[userId] = { byRepo: {}, byLanguage: {} }; + } + + const counts = skipHistoryMap[userId]; + counts.byRepo[issue.repo_full_name] = (counts.byRepo[issue.repo_full_name] ?? 0) + 1; + + if (issue.repo_language) { + counts.byLanguage[issue.repo_language] = + (counts.byLanguage[issue.repo_language] ?? 0) + 1; + } + } + let totalInserted = 0; for (const u of userList) { const level = u.profiles?.level ?? 0; @@ -69,6 +101,7 @@ export const recommendationsBuild = inngest.createFunction( // Build per-user candidates so languageMatch reflects this user's // primary_language. Pool is shared; only the language flag varies. const candidates: ScoredIssue[] = rawPool.map((i) => ({ + repoLanguage: i.repo_language, id: i.id, repoFullName: i.repo_full_name, number: i.github_issue_number, @@ -88,11 +121,13 @@ export const recommendationsBuild = inngest.createFunction( .select('issue_id') .eq('user_id', u.user_id); const excludeIds = new Set((seen ?? []).map((r) => r.issue_id)); + const skipCounts = skipHistoryMap[u.user_id]; const picks = filterAndRank(candidates, { level, excludeIssueIds: excludeIds, allowFallback: true, + skipCounts, }); if (picks.length === 0) continue; diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index b84646b..0bb28a6 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -43,6 +43,11 @@ export const profiles = pgTable( level: integer('level').notNull().default(0), auditCompleted: boolean('audit_completed').notNull().default(false), timezone: text('timezone'), + // Contributor mute preferences. Arrays of repo full names + // and language strings the user has marked "not interested in". + // Fully reversible — cleared by passing an empty array. + mutedRepos: text('muted_repos').array().default([]).notNull(), + mutedLanguages: text('muted_languages').array().default([]).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), bio: text('bio'), @@ -149,6 +154,9 @@ export const recommendations = pgTable( difficulty: text('difficulty', { enum: ['E', 'M', 'H'] }).notNull(), xpReward: integer('xp_reward').notNull(), linkedPrUrl: text('linked_pr_url'), + // Optional free-text reason captured when a user skips a recommendation. + // Never required — omitting preserves existing skip behavior. + skipReason: text('skip_reason'), recommendedAt: timestamp('recommended_at', { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), claimedAt: timestamp('claimed_at', { withTimezone: true }), diff --git a/src/lib/pipeline/constants.ts b/src/lib/pipeline/constants.ts new file mode 100644 index 0000000..9fe13e8 --- /dev/null +++ b/src/lib/pipeline/constants.ts @@ -0,0 +1,57 @@ +/** + * Recommendation ranking constants. + * + * All scoring penalties applied during filterAndRank() are defined here. + * Adjust these to tune ranking quality without touching ranking logic. + * + * Penalty design principles: + * - Penalties are SUBTRACTIVE — they reduce a candidate's rank score. + * - They are DOWN-RANKING only: no candidate is hard-excluded by penalty alone. + * - Muted penalties are stronger than skip-history penalties. + * - Language skip penalties are softer than repo skip penalties to preserve diversity. + * - Stack predictably: a muted+skipped repo simply accumulates both penalties. + */ + +export const RECOMMENDATION_PENALTIES = { + /** + * Number of times a user must have skipped recs from the same repo + * before the repo-skip penalty kicks in. + */ + REPO_SKIP_THRESHOLD: 2, + + /** + * Score reduction applied to each issue from a repository the user + * has skipped >= REPO_SKIP_THRESHOLD times. + * Conservative: keeps the repo surfaceable but pushes it down the list. + */ + REPO_SKIP_PENALTY: 15, + + /** + * Number of skipped recs whose primary language matches a language + * before the language-skip penalty kicks in. + */ + LANGUAGE_SKIP_THRESHOLD: 3, + + /** + * Score reduction applied to issues whose repo_language has been + * skipped >= LANGUAGE_SKIP_THRESHOLD times. + * Softer than repo penalty to preserve cross-language diversity. + */ + LANGUAGE_SKIP_PENALTY: 10, + + /** + * Score reduction for repos the user has explicitly muted ("not interested"). + * Large enough to push muted repos near the bottom without disappearing them, + * so they can resurface if the candidate pool is thin. + */ + MUTED_REPO_PENALTY: 50, + + /** + * Score reduction for issues whose language the user has explicitly muted. + * Smaller than MUTED_REPO_PENALTY to allow some diversity bleed-through. + */ + MUTED_LANGUAGE_PENALTY: 30, +} as const; + +/** How many days of skip history to consider when computing skip penalties. */ +export const SKIP_HISTORY_WINDOW_DAYS = 30; diff --git a/src/lib/pipeline/recommend.test.ts b/src/lib/pipeline/recommend.test.ts index fca51e2..234b902 100644 --- a/src/lib/pipeline/recommend.test.ts +++ b/src/lib/pipeline/recommend.test.ts @@ -11,6 +11,7 @@ const issue = (over: Partial): ScoredIssue => ({ repoHealthScore: 60, freshnessHours: 24, languageMatch: false, + repoLanguage: null, ...over, }); @@ -124,4 +125,63 @@ describe('filterAndRank', () => { }); expect(result).toEqual([]); }); + + it('applies repository skip down-ranking', () => { + const issues = [ + issue({ id: 1, repoFullName: 'skipped/repo', difficulty: 'E', repoHealthScore: 80 }), + issue({ id: 2, repoFullName: 'fresh/repo', difficulty: 'E', repoHealthScore: 80 }), + ]; + const result = filterAndRank(issues, { + level: 0, + excludeIssueIds: new Set(), + skipCounts: { + byRepo: { 'skipped/repo': 2 }, + byLanguage: {}, + }, + }); + // id 2 (fresh) should rank above id 1 (skipped) due to penalty + expect(result.map((r) => r.id)).toEqual([2, 1]); + }); + + it('applies language skip down-ranking', () => { + const issues = [ + issue({ id: 1, repoLanguage: 'TypeScript', difficulty: 'E', repoHealthScore: 80 }), + issue({ id: 2, repoLanguage: 'Python', difficulty: 'E', repoHealthScore: 80 }), + ]; + const result = filterAndRank(issues, { + level: 0, + excludeIssueIds: new Set(), + skipCounts: { + byRepo: {}, + byLanguage: { TypeScript: 3 }, + }, + }); + expect(result.map((r) => r.id)).toEqual([2, 1]); + }); + + it('applies muted repository down-ranking', () => { + const issues = [ + issue({ id: 1, repoFullName: 'muted/repo', difficulty: 'E', repoHealthScore: 80 }), + issue({ id: 2, repoFullName: 'normal/repo', difficulty: 'E', repoHealthScore: 80 }), + ]; + const result = filterAndRank(issues, { + level: 0, + excludeIssueIds: new Set(), + mutedRepos: ['muted/repo'], + }); + expect(result.map((r) => r.id)).toEqual([2, 1]); + }); + + it('applies muted language down-ranking', () => { + const issues = [ + issue({ id: 1, repoLanguage: 'Java', difficulty: 'E', repoHealthScore: 80 }), + issue({ id: 2, repoLanguage: 'Rust', difficulty: 'E', repoHealthScore: 80 }), + ]; + const result = filterAndRank(issues, { + level: 0, + excludeIssueIds: new Set(), + mutedLanguages: ['Java'], + }); + expect(result.map((r) => r.id)).toEqual([2, 1]); + }); }); diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index 2ec0891..5419d65 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -1,4 +1,5 @@ import type { Difficulty } from './score'; +import { RECOMMENDATION_PENALTIES } from './constants'; /** * Recommendation pipeline — pure ranking + filtering logic. @@ -14,14 +15,23 @@ export type ScoredIssue = { difficulty: Difficulty; xpReward: number; repoHealthScore: number; - freshnessHours: number; // hours since issue was opened/updated + freshnessHours: number; languageMatch: boolean; + repoLanguage: string | null; +}; + +export type SkipCounts = { + byRepo: Record; + byLanguage: Record; }; export type RecommendOptions = { level: number; excludeIssueIds: Set; allowFallback?: boolean; + mutedRepos?: readonly string[]; + mutedLanguages?: readonly string[]; + skipCounts?: SkipCounts; }; export type LevelMix = { E: number; M: number; H: number }; @@ -36,13 +46,38 @@ export function mixForLevel(level: number): LevelMix { return { E: 0, M: 1, H: 4 }; } -function rankScore(issue: ScoredIssue): number { - // higher = better. repo health weighted heaviest; language match is a tiebreaker. - return ( +function rankScore(issue: ScoredIssue, opts: RecommendOptions): number { + const baseline = issue.repoHealthScore * 1 + (issue.languageMatch ? 20 : 0) + - Math.max(0, 30 - issue.freshnessHours / 24) - ); + Math.max(0, 30 - issue.freshnessHours / 24); + + let penalty = 0; + + // Apply soft penalties for frequently skipped repositories and languages. + if (opts.skipCounts) { + const repoSkips = opts.skipCounts.byRepo[issue.repoFullName] ?? 0; + if (repoSkips >= RECOMMENDATION_PENALTIES.REPO_SKIP_THRESHOLD) { + penalty += RECOMMENDATION_PENALTIES.REPO_SKIP_PENALTY; + } + + if (issue.repoLanguage) { + const langSkips = opts.skipCounts.byLanguage[issue.repoLanguage] ?? 0; + if (langSkips >= RECOMMENDATION_PENALTIES.LANGUAGE_SKIP_THRESHOLD) { + penalty += RECOMMENDATION_PENALTIES.LANGUAGE_SKIP_PENALTY; + } + } + } + + // Mute-preference penalties. + if (opts.mutedRepos?.includes(issue.repoFullName)) { + penalty += RECOMMENDATION_PENALTIES.MUTED_REPO_PENALTY; + } + if (issue.repoLanguage && opts.mutedLanguages?.includes(issue.repoLanguage)) { + penalty += RECOMMENDATION_PENALTIES.MUTED_LANGUAGE_PENALTY; + } + + return baseline - penalty; } export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptions): ScoredIssue[] { @@ -58,7 +93,7 @@ export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptio if (want === 0) continue; const sorted = eligible .filter((i) => i.difficulty === tier) - .sort((a, b) => rankScore(b) - rankScore(a)); + .sort((a, b) => rankScore(b, opts) - rankScore(a, opts)); result.push(...sorted.slice(0, want)); } @@ -67,7 +102,7 @@ export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptio const seen = new Set(result.map((r) => r.id)); const extras = eligible .filter((i) => !seen.has(i.id)) - .sort((a, b) => rankScore(b) - rankScore(a)); + .sort((a, b) => rankScore(b, opts) - rankScore(a, opts)); const needed = totalDesired(mix) - result.length; result.push(...extras.slice(0, needed)); } diff --git a/supabase/migrations/0012_recommendation_feedback.sql b/supabase/migrations/0012_recommendation_feedback.sql new file mode 100644 index 0000000..c45eab7 --- /dev/null +++ b/supabase/migrations/0012_recommendation_feedback.sql @@ -0,0 +1,12 @@ +-- Issue #91: Recommendation quality feedback loop +-- Adds skip_reason to recommendations and mute preferences to profiles. + +-- 1. Optional skip reason on recommendations (free text, never required). +alter table recommendations + add column if not exists skip_reason text; + +-- 2. Contributor mute preferences on profiles. +-- Arrays of repo full names / language strings. Default empty array. +alter table profiles + add column if not exists muted_repos text[] not null default '{}', + add column if not exists muted_languages text[] not null default '{}';