feat(session-planner): integrate SessionMode for single source of truth targeting

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-18 14:06:42 -06:00
parent b345baf3c4
commit 9851c01026
5 changed files with 52 additions and 7 deletions

View File

@ -9,6 +9,7 @@ import {
NoSkillsEnabledError, NoSkillsEnabledError,
} from '@/lib/curriculum' } from '@/lib/curriculum'
import type { ProblemGenerationMode } from '@/lib/curriculum/config' import type { ProblemGenerationMode } from '@/lib/curriculum/config'
import type { SessionMode } from '@/lib/curriculum/session-mode'
interface RouteParams { interface RouteParams {
params: Promise<{ playerId: string }> params: Promise<{ playerId: string }>
@ -74,6 +75,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
enabledParts, enabledParts,
problemGenerationMode, problemGenerationMode,
confidenceThreshold, confidenceThreshold,
sessionMode,
} = body } = body
if (!durationMinutes || typeof durationMinutes !== 'number') { if (!durationMinutes || typeof durationMinutes !== 'number') {
@ -102,6 +104,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// Pass BKT confidence threshold if specified // Pass BKT confidence threshold if specified
confidenceThreshold: confidenceThreshold:
typeof confidenceThreshold === 'number' ? confidenceThreshold : undefined, 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 // Pass config overrides if abacusTermCount is specified
...(abacusTermCount && { ...(abacusTermCount && {
config: { config: {

View File

@ -211,6 +211,8 @@ export function StartPracticeModal({
abacusTermCount: { min: 3, max: abacusMaxTerms }, abacusTermCount: { min: 3, max: abacusMaxTerms },
enabledParts, enabledParts,
problemGenerationMode: 'adaptive-bkt', problemGenerationMode: 'adaptive-bkt',
// Pass sessionMode for single source of truth targeting (no rug-pulling)
sessionMode,
}) })
} catch (err) { } catch (err) {
if (err instanceof ActiveSessionExistsClientError) { if (err instanceof ActiveSessionExistsClientError) {
@ -235,6 +237,7 @@ export function StartPracticeModal({
abacusMaxTerms, abacusMaxTerms,
enabledParts, enabledParts,
existingPlan, existingPlan,
sessionMode,
generatePlan, generatePlan,
approvePlan, approvePlan,
startPlan, startPlan,

View File

@ -3,6 +3,7 @@
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans' import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
import type { ProblemGenerationMode } from '@/lib/curriculum/config' import type { ProblemGenerationMode } from '@/lib/curriculum/config'
import type { SessionMode } from '@/lib/curriculum/session-mode'
import { api } from '@/lib/queryClient' import { api } from '@/lib/queryClient'
import { sessionPlanKeys } from '@/lib/queryKeys' import { sessionPlanKeys } from '@/lib/queryKeys'
@ -57,6 +58,7 @@ async function generateSessionPlan({
enabledParts, enabledParts,
problemGenerationMode, problemGenerationMode,
confidenceThreshold, confidenceThreshold,
sessionMode,
}: { }: {
playerId: string playerId: string
durationMinutes: number durationMinutes: number
@ -68,6 +70,8 @@ async function generateSessionPlan({
problemGenerationMode?: ProblemGenerationMode problemGenerationMode?: ProblemGenerationMode
/** BKT confidence threshold for identifying struggling skills */ /** BKT confidence threshold for identifying struggling skills */
confidenceThreshold?: number confidenceThreshold?: number
/** Pre-computed session mode for targeting consistency */
sessionMode?: SessionMode
}): Promise<SessionPlan> { }): Promise<SessionPlan> {
const res = await api(`curriculum/${playerId}/sessions/plans`, { const res = await api(`curriculum/${playerId}/sessions/plans`, {
method: 'POST', method: 'POST',
@ -78,6 +82,7 @@ async function generateSessionPlan({
enabledParts, enabledParts,
problemGenerationMode, problemGenerationMode,
confidenceThreshold, confidenceThreshold,
sessionMode,
}), }),
}) })
if (!res.ok) { if (!res.ok) {

View File

@ -36,18 +36,14 @@ export {
// Skill mastery // Skill mastery
calculateMasteryPercent, calculateMasteryPercent,
// Practice sessions // Practice sessions
completePracticeSession,
getAllSkillMastery, getAllSkillMastery,
getPlayerCurriculum, getPlayerCurriculum,
getPlayerProgressSummary, getPlayerProgressSummary,
getRecentSessions, getRecentSessions,
getSessionsForPhase,
getSkillMastery, getSkillMastery,
initializeStudent, initializeStudent,
recordSkillAttempt, recordSkillAttempt,
recordSkillAttempts, recordSkillAttempts,
startPracticeSession,
updatePracticeSession,
upsertPlayerCurriculum, upsertPlayerCurriculum,
} from './progress-manager' } from './progress-manager'
@ -66,3 +62,18 @@ export {
recordSlotResult, recordSlotResult,
startSessionPlan, startSessionPlan,
} from './session-planner' } 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'

View File

@ -58,6 +58,7 @@ import {
getRecentSessions, getRecentSessions,
recordSkillAttemptsWithHelp, recordSkillAttemptsWithHelp,
} from './progress-manager' } from './progress-manager'
import { getWeakSkillIds, type SessionMode } from './session-mode'
// ============================================================================ // ============================================================================
// Plan Generation // Plan Generation
@ -90,6 +91,12 @@ export interface GenerateSessionPlanOptions {
* - 'classic': Fluency-based discrete states * - 'classic': Fluency-based discrete states
*/ */
problemGenerationMode?: ProblemGenerationMode 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, config: configOverrides,
enabledParts, enabledParts,
problemGenerationMode = DEFAULT_PROBLEM_GENERATION_MODE, problemGenerationMode = DEFAULT_PROBLEM_GENERATION_MODE,
sessionMode,
} = options } = options
const config = { ...DEFAULT_PLAN_CONFIG, ...configOverrides } const config = { ...DEFAULT_PLAN_CONFIG, ...configOverrides }
@ -248,11 +256,25 @@ export async function generateSessionPlan(
const struggling = findStrugglingSkills(skillMastery) const struggling = findStrugglingSkills(skillMastery)
const needsReview = findSkillsNeedingReview(skillMastery, config.reviewIntervalDays) const needsReview = findSkillsNeedingReview(skillMastery, config.reviewIntervalDays)
// Identify weak skills from BKT for targeting (adaptive modes only) // Identify weak skills for targeting
const weakSkills = usesBktTargeting ? identifyWeakSkills(bktResults) : [] // 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) { 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) { for (const skillId of weakSkills) {
const bkt = bktResults?.get(skillId) const bkt = bktResults?.get(skillId)
console.log(` ${skillId}: pKnown=${(bkt?.pKnown ?? 0 * 100).toFixed(0)}%`) console.log(` ${skillId}: pKnown=${(bkt?.pKnown ?? 0 * 100).toFixed(0)}%`)