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,
} 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: {

View File

@ -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,

View File

@ -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<SessionPlan> {
const res = await api(`curriculum/${playerId}/sessions/plans`, {
method: 'POST',
@ -78,6 +82,7 @@ async function generateSessionPlan({
enabledParts,
problemGenerationMode,
confidenceThreshold,
sessionMode,
}),
})
if (!res.ok) {

View File

@ -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'

View File

@ -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)}%`)