From 9851c0102698c5386902e891488f2568ecdc1630 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 18 Dec 2025 14:06:42 -0600 Subject: [PATCH] feat(session-planner): integrate SessionMode for single source of truth targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session planner now accepts an optional sessionMode parameter that: - Uses pre-computed weak skills from SessionMode (remediation mode) - Eliminates duplicate BKT computation between UI and problem generation - Ensures "no rug-pulling" - what the modal shows is what configures problems Changes: - session-planner.ts: Accept sessionMode, use getWeakSkillIds() when provided - useSessionPlan.ts: Accept sessionMode in generateSessionPlan function - plans/route.ts: Pass sessionMode from request body to planner - StartPracticeModal.tsx: Pass sessionMode when generating plan - index.ts: Export SessionMode types from curriculum module 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../[playerId]/sessions/plans/route.ts | 4 +++ .../practice/StartPracticeModal.tsx | 3 ++ apps/web/src/hooks/useSessionPlan.ts | 5 ++++ apps/web/src/lib/curriculum/index.ts | 19 ++++++++++--- .../web/src/lib/curriculum/session-planner.ts | 28 +++++++++++++++++-- 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts b/apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts index 4cc77996..f13d3e08 100644 --- a/apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts +++ b/apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts @@ -9,6 +9,7 @@ import { NoSkillsEnabledError, } from '@/lib/curriculum' import type { ProblemGenerationMode } from '@/lib/curriculum/config' +import type { SessionMode } from '@/lib/curriculum/session-mode' interface RouteParams { params: Promise<{ playerId: string }> @@ -74,6 +75,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { enabledParts, problemGenerationMode, confidenceThreshold, + sessionMode, } = body if (!durationMinutes || typeof durationMinutes !== 'number') { @@ -102,6 +104,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // Pass BKT confidence threshold if specified confidenceThreshold: typeof confidenceThreshold === 'number' ? confidenceThreshold : undefined, + // Pass session mode for single source of truth targeting + sessionMode: sessionMode as SessionMode | undefined, // Pass config overrides if abacusTermCount is specified ...(abacusTermCount && { config: { diff --git a/apps/web/src/components/practice/StartPracticeModal.tsx b/apps/web/src/components/practice/StartPracticeModal.tsx index a0e7ec13..df998ee7 100644 --- a/apps/web/src/components/practice/StartPracticeModal.tsx +++ b/apps/web/src/components/practice/StartPracticeModal.tsx @@ -211,6 +211,8 @@ export function StartPracticeModal({ abacusTermCount: { min: 3, max: abacusMaxTerms }, enabledParts, problemGenerationMode: 'adaptive-bkt', + // Pass sessionMode for single source of truth targeting (no rug-pulling) + sessionMode, }) } catch (err) { if (err instanceof ActiveSessionExistsClientError) { @@ -235,6 +237,7 @@ export function StartPracticeModal({ abacusMaxTerms, enabledParts, existingPlan, + sessionMode, generatePlan, approvePlan, startPlan, diff --git a/apps/web/src/hooks/useSessionPlan.ts b/apps/web/src/hooks/useSessionPlan.ts index 62fb3c0e..7a63f84b 100644 --- a/apps/web/src/hooks/useSessionPlan.ts +++ b/apps/web/src/hooks/useSessionPlan.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import type { SessionPlan, SlotResult } from '@/db/schema/session-plans' import type { ProblemGenerationMode } from '@/lib/curriculum/config' +import type { SessionMode } from '@/lib/curriculum/session-mode' import { api } from '@/lib/queryClient' import { sessionPlanKeys } from '@/lib/queryKeys' @@ -57,6 +58,7 @@ async function generateSessionPlan({ enabledParts, problemGenerationMode, confidenceThreshold, + sessionMode, }: { playerId: string durationMinutes: number @@ -68,6 +70,8 @@ async function generateSessionPlan({ problemGenerationMode?: ProblemGenerationMode /** BKT confidence threshold for identifying struggling skills */ confidenceThreshold?: number + /** Pre-computed session mode for targeting consistency */ + sessionMode?: SessionMode }): Promise { const res = await api(`curriculum/${playerId}/sessions/plans`, { method: 'POST', @@ -78,6 +82,7 @@ async function generateSessionPlan({ enabledParts, problemGenerationMode, confidenceThreshold, + sessionMode, }), }) if (!res.ok) { diff --git a/apps/web/src/lib/curriculum/index.ts b/apps/web/src/lib/curriculum/index.ts index 7a1d45f1..85615b72 100644 --- a/apps/web/src/lib/curriculum/index.ts +++ b/apps/web/src/lib/curriculum/index.ts @@ -36,18 +36,14 @@ export { // Skill mastery calculateMasteryPercent, // Practice sessions - completePracticeSession, getAllSkillMastery, getPlayerCurriculum, getPlayerProgressSummary, getRecentSessions, - getSessionsForPhase, getSkillMastery, initializeStudent, recordSkillAttempt, recordSkillAttempts, - startPracticeSession, - updatePracticeSession, upsertPlayerCurriculum, } from './progress-manager' @@ -66,3 +62,18 @@ export { recordSlotResult, startSessionPlan, } from './session-planner' + +// Session mode - unified session state computation +export { + getSessionMode, + getWeakSkillIds, + isMaintenanceMode, + isProgressionMode, + isRemediationMode, + type BlockedPromotion, + type MaintenanceMode, + type ProgressionMode, + type RemediationMode, + type SessionMode, + type SkillInfo, +} from './session-mode' diff --git a/apps/web/src/lib/curriculum/session-planner.ts b/apps/web/src/lib/curriculum/session-planner.ts index 6259b5e3..1ac8bb0b 100644 --- a/apps/web/src/lib/curriculum/session-planner.ts +++ b/apps/web/src/lib/curriculum/session-planner.ts @@ -58,6 +58,7 @@ import { getRecentSessions, recordSkillAttemptsWithHelp, } from './progress-manager' +import { getWeakSkillIds, type SessionMode } from './session-mode' // ============================================================================ // Plan Generation @@ -90,6 +91,12 @@ export interface GenerateSessionPlanOptions { * - 'classic': Fluency-based discrete states */ problemGenerationMode?: ProblemGenerationMode + /** + * Pre-computed session mode from getSessionMode(). + * When provided, skips duplicate BKT computation and uses the mode's weak skills directly. + * This ensures the session targets exactly what was shown in the UI (no "rug-pulling"). + */ + sessionMode?: SessionMode } /** @@ -136,6 +143,7 @@ export async function generateSessionPlan( config: configOverrides, enabledParts, problemGenerationMode = DEFAULT_PROBLEM_GENERATION_MODE, + sessionMode, } = options const config = { ...DEFAULT_PLAN_CONFIG, ...configOverrides } @@ -248,11 +256,25 @@ export async function generateSessionPlan( const struggling = findStrugglingSkills(skillMastery) const needsReview = findSkillsNeedingReview(skillMastery, config.reviewIntervalDays) - // Identify weak skills from BKT for targeting (adaptive modes only) - const weakSkills = usesBktTargeting ? identifyWeakSkills(bktResults) : [] + // Identify weak skills for targeting + // When sessionMode is provided, use its pre-computed weak skills (single source of truth) + // This ensures the session targets exactly what was shown in the UI (no "rug-pulling") + let weakSkills: string[] + if (sessionMode) { + // Use pre-computed weak skills from sessionMode + weakSkills = getWeakSkillIds(sessionMode) + if (process.env.DEBUG_SESSION_PLANNER === 'true') { + console.log( + `[SessionPlanner] Using weak skills from sessionMode (${sessionMode.type}): ${weakSkills.length}` + ) + } + } else { + // Fallback: compute locally (for backwards compatibility) + weakSkills = usesBktTargeting ? identifyWeakSkills(bktResults) : [] + } if (process.env.DEBUG_SESSION_PLANNER === 'true' && weakSkills.length > 0) { - console.log(`[SessionPlanner] Identified ${weakSkills.length} weak skills for targeting:`) + console.log(`[SessionPlanner] Targeting ${weakSkills.length} weak skills:`) for (const skillId of weakSkills) { const bkt = bktResults?.get(skillId) console.log(` ${skillId}: pKnown=${(bkt?.pKnown ?? 0 * 100).toFixed(0)}%`)