refactor(help): simplify to binary help system + add seed script CLI
Help System Cleanup: - Simplify HelpLevel from 0|1|2|3 to binary 0|1 (matching actual usage) - Change BKT help weight from 0.8 to 0.5 for helped answers - Delete unused PracticeHelpPanel.tsx (~540 lines) - Delete unused usePracticeHelp.ts (~400 lines) - Delete unused reinforcement-config.ts - Remove reinforcement tracking (BKT handles skill weakness detection) - Update all journey simulator profiles to use binary help arrays - Update documentation (BKT_DESIGN_SPEC.md, decomposition README) Seed Script Improvements: - Add CLI argument parsing with node:util parseArgs - Add --help, --list, --name, --category, --dry-run flags - Add new "Forgotten Weaknesses" test profile (weak + stale skill mix) - Enable seeding individual students or categories 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b9d4bc552a
commit
446678799c
|
|
@ -251,7 +251,10 @@
|
|||
"Bash(\"apps/web/src/lib/curriculum/placement-test.ts\" )",
|
||||
"Bash(\"apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts\")",
|
||||
"Bash(mcp__sqlite__read_query:*)",
|
||||
"Bash(mcp__sqlite__describe_table:*)"
|
||||
"Bash(mcp__sqlite__describe_table:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(npx tsx:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export interface SlotResult {
|
|||
timestamp: number;
|
||||
responseTimeMs: number;
|
||||
userAnswer: number | null;
|
||||
helpLevel: 0 | 1 | 2 | 3;
|
||||
helpLevel: 0 | 1; // Boolean: 0 = no help, 1 = used help
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -220,20 +220,16 @@ export function updateOnIncorrect(
|
|||
// src/lib/curriculum/bkt/evidence-quality.ts
|
||||
|
||||
/**
|
||||
* Adjust observation weight based on help level.
|
||||
* More help = less confident the student really knows it.
|
||||
* Adjust observation weight based on whether help was used.
|
||||
* Using help = less confident the student really knows it.
|
||||
*
|
||||
* Note: Help is binary (0 = no help, 1 = used help).
|
||||
* We can't determine which skill needed help for multi-skill problems,
|
||||
* so we apply the discount uniformly and let conjunctive BKT identify
|
||||
* weak skills from aggregated evidence.
|
||||
*/
|
||||
export function helpLevelWeight(helpLevel: 0 | 1 | 2 | 3): number {
|
||||
switch (helpLevel) {
|
||||
case 0:
|
||||
return 1.0; // No help - full evidence
|
||||
case 1:
|
||||
return 0.8; // Minor hint - slight reduction
|
||||
case 2:
|
||||
return 0.5; // Significant help - halve evidence
|
||||
case 3:
|
||||
return 0.5; // Full help - halve evidence
|
||||
}
|
||||
export function helpLevelWeight(helpLevel: 0 | 1): number {
|
||||
return helpLevel === 0 ? 1.0 : 0.5; // 50% weight for helped answers
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -57,8 +57,56 @@
|
|||
* ⚖️ Multi-Weak Remediation - Many weak skills needing remediation
|
||||
* 🕰️ Stale Skills Test - Skills at various staleness levels
|
||||
* 💥 NaN Stress Test - Stress tests BKT NaN handling
|
||||
* 🧊 Forgotten Weaknesses - Weak skills that are also stale
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
// =============================================================================
|
||||
// CLI Argument Parsing
|
||||
// =============================================================================
|
||||
|
||||
const { values: cliArgs } = parseArgs({
|
||||
options: {
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
list: { type: 'boolean', short: 'l', default: false },
|
||||
name: { type: 'string', short: 'n', multiple: true, default: [] },
|
||||
category: { type: 'string', short: 'c', multiple: true, default: [] },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
})
|
||||
|
||||
function showHelp(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
npm run seed:test-students [options]
|
||||
|
||||
Options:
|
||||
--help, -h Show this help message
|
||||
--list, -l List all available students and categories
|
||||
--name, -n <name> Seed specific student(s) by name (can use multiple times)
|
||||
--category, -c <cat> Seed all students in a category (can use multiple times)
|
||||
--dry-run Show what would be seeded without creating students
|
||||
|
||||
Categories:
|
||||
bkt Core BKT scenarios (deficient, blocker, progressing, etc.)
|
||||
session Session mode tests (remediation, progression, maintenance)
|
||||
edge Edge cases (empty, single skill, high volume, NaN stress test)
|
||||
|
||||
Examples:
|
||||
npm run seed:test-students # Seed all students
|
||||
npm run seed:test-students -- --list # List available options
|
||||
npm run seed:test-students -- -n "💥 NaN Stress Test"
|
||||
npm run seed:test-students -- -c edge # Seed all edge case students
|
||||
npm run seed:test-students -- -c bkt -c session
|
||||
npm run seed:test-students -- -n "🔴 Multi-Skill Deficient" -n "🟢 Progressing Nicely"
|
||||
`)
|
||||
}
|
||||
|
||||
// Note: listProfiles is defined after TEST_PROFILES (below)
|
||||
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import { db, schema } from '../src/db'
|
||||
|
|
@ -1167,8 +1215,142 @@ Use this profile to verify:
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '🧊 Forgotten Weaknesses',
|
||||
emoji: '🧊',
|
||||
color: '#3b82f6', // blue-500
|
||||
category: 'edge',
|
||||
description: 'EDGE CASE - Weak skills that are also stale (urgent remediation needed)',
|
||||
currentPhaseId: 'L1.add.+2.five',
|
||||
practicingSkills: [
|
||||
'basic.directAddition',
|
||||
'basic.heavenBead',
|
||||
'basic.simpleCombinations',
|
||||
'fiveComplements.4=5-1',
|
||||
'fiveComplements.3=5-2',
|
||||
'fiveComplements.2=5-3',
|
||||
],
|
||||
tutorialCompletedSkills: [
|
||||
'basic.directAddition',
|
||||
'basic.heavenBead',
|
||||
'basic.simpleCombinations',
|
||||
'fiveComplements.4=5-1',
|
||||
'fiveComplements.3=5-2',
|
||||
'fiveComplements.2=5-3',
|
||||
],
|
||||
intentionNotes: `INTENTION: Forgotten Weaknesses
|
||||
|
||||
This student has a realistic mix of weak and stale skills - NOT the same set.
|
||||
|
||||
Session Mode: Should trigger REMEDIATION.
|
||||
|
||||
Skill breakdown:
|
||||
• 1 skill STRONG + recent (healthy baseline)
|
||||
• 1 skill STRONG + stale 20 days (stale-only, should refresh easily)
|
||||
• 1 skill WEAK + recent (weak-only, actively struggling)
|
||||
• 1 skill WEAK + stale 14 days (overlap: weak AND stale)
|
||||
• 1 skill WEAK + stale 35 days (overlap: urgent forgotten weakness)
|
||||
• 1 skill DEVELOPING + stale 25 days (borderline, needs attention)
|
||||
|
||||
This tests:
|
||||
• Different combinations of weak/stale indicators
|
||||
• UI distinguishing "stale but strong" from "stale AND weak"
|
||||
• Session planning prioritizing weak+stale over strong+stale
|
||||
• BKT decay effects on skills at different mastery levels
|
||||
|
||||
Real-world scenario: Student has been practicing inconsistently. Some skills
|
||||
are rusty from neglect (stale), others they just can't get (weak), and some
|
||||
are both - the forgotten weaknesses that need urgent attention.`,
|
||||
skillHistory: [
|
||||
// STRONG + recent (healthy baseline)
|
||||
{ skillId: 'basic.directAddition', targetAccuracy: 0.92, problems: 20, ageDays: 1 },
|
||||
// STRONG + stale 20 days (stale-only - "Getting rusty" but should be fine)
|
||||
{ skillId: 'basic.heavenBead', targetAccuracy: 0.88, problems: 18, ageDays: 20 },
|
||||
// WEAK + recent (weak-only - actively struggling with this)
|
||||
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.28, problems: 15, ageDays: 2 },
|
||||
// WEAK + stale 14 days (overlap: weak AND "Not practiced recently")
|
||||
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.32, problems: 14, ageDays: 14 },
|
||||
// WEAK + stale 35 days (overlap: urgent - weak AND "Very stale")
|
||||
{ skillId: 'fiveComplements.3=5-2', targetAccuracy: 0.22, problems: 18, ageDays: 35 },
|
||||
// DEVELOPING + stale 25 days (borderline - needs practice)
|
||||
{ skillId: 'fiveComplements.2=5-3', targetAccuracy: 0.55, problems: 16, ageDays: 25 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// CLI Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
function listProfiles(): void {
|
||||
console.log('\n📋 Available Test Students:\n')
|
||||
|
||||
const categories: Record<ProfileCategory, TestStudentProfile[]> = {
|
||||
bkt: [],
|
||||
session: [],
|
||||
edge: [],
|
||||
}
|
||||
|
||||
for (const profile of TEST_PROFILES) {
|
||||
categories[profile.category].push(profile)
|
||||
}
|
||||
|
||||
console.log('BKT Scenarios (--category bkt):')
|
||||
for (const p of categories.bkt) {
|
||||
console.log(` ${p.name}`)
|
||||
console.log(` ${p.description}`)
|
||||
}
|
||||
|
||||
console.log('\nSession Mode Tests (--category session):')
|
||||
for (const p of categories.session) {
|
||||
console.log(` ${p.name}`)
|
||||
console.log(` ${p.description}`)
|
||||
}
|
||||
|
||||
console.log('\nEdge Cases (--category edge):')
|
||||
for (const p of categories.edge) {
|
||||
console.log(` ${p.name}`)
|
||||
console.log(` ${p.description}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal: ${TEST_PROFILES.length} students\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter profiles based on CLI args (name and category filters)
|
||||
*/
|
||||
function filterProfiles(profiles: TestStudentProfile[]): TestStudentProfile[] {
|
||||
const names = cliArgs.name as string[]
|
||||
const categories = cliArgs.category as string[]
|
||||
|
||||
// If no filters, return all
|
||||
if (names.length === 0 && categories.length === 0) {
|
||||
return profiles
|
||||
}
|
||||
|
||||
return profiles.filter((profile) => {
|
||||
// Check name filter (partial match, case-insensitive)
|
||||
const matchesName =
|
||||
names.length === 0 ||
|
||||
names.some(
|
||||
(n) =>
|
||||
profile.name.toLowerCase().includes(n.toLowerCase()) ||
|
||||
n.toLowerCase().includes(profile.name.toLowerCase())
|
||||
)
|
||||
|
||||
// Check category filter
|
||||
const matchesCategory = categories.length === 0 || categories.includes(profile.category)
|
||||
|
||||
// If both filters specified, must match at least one
|
||||
if (names.length > 0 && categories.length > 0) {
|
||||
return matchesName || matchesCategory
|
||||
}
|
||||
|
||||
// If only one filter type, must match that one
|
||||
return matchesName && matchesCategory
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
|
@ -1727,8 +1909,50 @@ async function createTestStudentWithTuning(
|
|||
// =============================================================================
|
||||
|
||||
async function main() {
|
||||
// Handle --help
|
||||
if (cliArgs.help) {
|
||||
showHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Handle --list
|
||||
if (cliArgs.list) {
|
||||
listProfiles()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Filter profiles based on CLI args
|
||||
const profilesToSeed = filterProfiles(TEST_PROFILES)
|
||||
|
||||
if (profilesToSeed.length === 0) {
|
||||
console.log('❌ No students match the specified filters.')
|
||||
console.log(' Use --list to see available students.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Handle --dry-run
|
||||
if (cliArgs['dry-run']) {
|
||||
console.log('🧪 DRY RUN - Would seed the following students:\n')
|
||||
for (const profile of profilesToSeed) {
|
||||
console.log(` ${profile.name} [${profile.category}]`)
|
||||
console.log(` ${profile.description}`)
|
||||
}
|
||||
console.log(`\nTotal: ${profilesToSeed.length} students`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log('🧪 Seeding Test Students for BKT Testing...\n')
|
||||
|
||||
// Show filter info if applicable
|
||||
const names = cliArgs.name as string[]
|
||||
const categories = cliArgs.category as string[]
|
||||
if (names.length > 0 || categories.length > 0) {
|
||||
console.log(` Filtering: ${profilesToSeed.length} of ${TEST_PROFILES.length} students`)
|
||||
if (names.length > 0) console.log(` Names: ${names.join(', ')}`)
|
||||
if (categories.length > 0) console.log(` Categories: ${categories.join(', ')}`)
|
||||
console.log('')
|
||||
}
|
||||
|
||||
// Find the most recent browser session by looking at recent session activity
|
||||
// This is more reliable than player creation time
|
||||
console.log('1. Finding most recent browser session...')
|
||||
|
|
@ -1800,7 +2024,7 @@ async function main() {
|
|||
// Create each test profile with iterative tuning (up to 3 rounds)
|
||||
console.log('\n2. Creating test students (with up to 2 tuning rounds if needed)...\n')
|
||||
|
||||
for (const profile of TEST_PROFILES) {
|
||||
for (const profile of profilesToSeed) {
|
||||
const { playerId, classifications, tuningHistory } = await createTestStudentWithTuning(
|
||||
profile,
|
||||
userId,
|
||||
|
|
@ -1808,7 +2032,7 @@ async function main() {
|
|||
)
|
||||
const { weak, developing, strong } = classifications
|
||||
|
||||
console.log(` ${profile.emoji} ${profile.name}`)
|
||||
console.log(` ${profile.name}`)
|
||||
console.log(` ${profile.description}`)
|
||||
console.log(` Phase: ${profile.currentPhaseId}`)
|
||||
console.log(` Practicing: ${profile.practicingSkills.length} skills`)
|
||||
|
|
|
|||
|
|
@ -159,14 +159,14 @@ function TutorialPlayer({ step, currentMultiStep }) {
|
|||
}
|
||||
```
|
||||
|
||||
### Practice Help Panel
|
||||
### With Help Mode
|
||||
|
||||
```typescript
|
||||
import { DecompositionProvider, DecompositionDisplay } from '@/components/decomposition'
|
||||
|
||||
function PracticeHelpPanel({ currentValue, targetValue, helpLevel }) {
|
||||
// Show decomposition at help level 2+
|
||||
if (helpLevel < 2) return null
|
||||
function HelpOverlay({ currentValue, targetValue, showHelp }) {
|
||||
// Show decomposition when help is requested
|
||||
if (!showHelp) return null
|
||||
|
||||
return (
|
||||
<DecompositionProvider
|
||||
|
|
|
|||
|
|
@ -1,540 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { HelpLevel } from '@/db/schema/session-plans'
|
||||
import type { PracticeHelpState } from '@/hooks/usePracticeHelp'
|
||||
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
|
||||
import { HelpAbacus } from './HelpAbacus'
|
||||
|
||||
/**
|
||||
* Generate a dynamic coach hint based on the current step
|
||||
*/
|
||||
function generateDynamicCoachHint(
|
||||
sequence: ReturnType<typeof generateUnifiedInstructionSequence> | null,
|
||||
currentStepIndex: number,
|
||||
abacusValue: number,
|
||||
targetValue: number
|
||||
): string {
|
||||
if (!sequence || sequence.steps.length === 0) {
|
||||
return 'Take your time and think through each step.'
|
||||
}
|
||||
|
||||
// Check if we're done
|
||||
if (abacusValue === targetValue) {
|
||||
return 'You did it! Move on to the next step.'
|
||||
}
|
||||
|
||||
// Get the current step
|
||||
const currentStep = sequence.steps[currentStepIndex]
|
||||
if (!currentStep) {
|
||||
return 'Take your time and think through each step.'
|
||||
}
|
||||
|
||||
// Find the segment this step belongs to
|
||||
const segment = sequence.segments.find((s) => s.id === currentStep.segmentId)
|
||||
|
||||
// Use the segment's readable summary if available
|
||||
if (segment?.readable?.summary) {
|
||||
return segment.readable.summary
|
||||
}
|
||||
|
||||
// Fall back to generating from the rule
|
||||
if (segment) {
|
||||
const rule = segment.plan[0]?.rule
|
||||
switch (rule) {
|
||||
case 'Direct':
|
||||
return `Add ${segment.digit} directly to the ${getPlaceName(segment.place)} column.`
|
||||
case 'FiveComplement':
|
||||
return `Think about friends of 5. What plus ${5 - segment.digit} makes 5?`
|
||||
case 'TenComplement':
|
||||
return `Think about friends of 10. What plus ${10 - segment.digit} makes 10?`
|
||||
case 'Cascade':
|
||||
return 'This will carry through multiple columns. Start from the left.'
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to english instruction from the step
|
||||
if (currentStep.englishInstruction) {
|
||||
return currentStep.englishInstruction
|
||||
}
|
||||
|
||||
return 'Think about which beads need to move.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get place name from place value
|
||||
*/
|
||||
function getPlaceName(place: number): string {
|
||||
switch (place) {
|
||||
case 0:
|
||||
return 'ones'
|
||||
case 1:
|
||||
return 'tens'
|
||||
case 2:
|
||||
return 'hundreds'
|
||||
case 3:
|
||||
return 'thousands'
|
||||
default:
|
||||
return `10^${place}`
|
||||
}
|
||||
}
|
||||
|
||||
interface PracticeHelpPanelProps {
|
||||
/** Current help state from usePracticeHelp hook */
|
||||
helpState: PracticeHelpState
|
||||
/** Request help at a specific level */
|
||||
onRequestHelp: (level?: HelpLevel) => void
|
||||
/** Dismiss help (return to L0) */
|
||||
onDismissHelp: () => void
|
||||
/** Whether this is the abacus part (enables bead arrows at L3) */
|
||||
isAbacusPart?: boolean
|
||||
/** Current value on the abacus (for bead arrows at L3) */
|
||||
currentValue?: number
|
||||
/** Target value to reach (for bead arrows at L3) */
|
||||
targetValue?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Help level labels for display
|
||||
*/
|
||||
const HELP_LEVEL_LABELS: Record<HelpLevel, string> = {
|
||||
0: 'No Help',
|
||||
1: 'Hint',
|
||||
2: 'Show Steps',
|
||||
3: 'Show Beads',
|
||||
}
|
||||
|
||||
/**
|
||||
* Help level icons
|
||||
*/
|
||||
const HELP_LEVEL_ICONS: Record<HelpLevel, string> = {
|
||||
0: '💡',
|
||||
1: '💬',
|
||||
2: '📝',
|
||||
3: '🧮',
|
||||
}
|
||||
|
||||
/**
|
||||
* PracticeHelpPanel - Progressive help display for practice sessions
|
||||
*
|
||||
* Shows escalating levels of help:
|
||||
* - L0: Just the "Need Help?" button
|
||||
* - L1: Coach hint (verbal guidance)
|
||||
* - L2: Mathematical decomposition with explanations
|
||||
* - L3: Bead movement arrows (for abacus part)
|
||||
*/
|
||||
export function PracticeHelpPanel({
|
||||
helpState,
|
||||
onRequestHelp,
|
||||
onDismissHelp,
|
||||
isAbacusPart = false,
|
||||
currentValue,
|
||||
targetValue,
|
||||
}: PracticeHelpPanelProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const { currentLevel, content, isAvailable, maxLevelUsed } = helpState
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// Track current abacus value for step synchronization
|
||||
const [abacusValue, setAbacusValue] = useState(currentValue ?? 0)
|
||||
|
||||
// Generate the decomposition steps to determine current step from abacus value
|
||||
const sequence = useMemo(() => {
|
||||
if (currentValue === undefined || targetValue === undefined) return null
|
||||
return generateUnifiedInstructionSequence(currentValue, targetValue)
|
||||
}, [currentValue, targetValue])
|
||||
|
||||
// Calculate which step the user is on based on abacus value
|
||||
// Find the highest step index where expectedValue <= abacusValue
|
||||
const currentStepIndex = useMemo(() => {
|
||||
if (!sequence || sequence.steps.length === 0) return 0
|
||||
if (currentValue === undefined) return 0
|
||||
|
||||
// Start value is the value before any steps
|
||||
const startVal = currentValue
|
||||
|
||||
// If abacus is still at start value, we're at step 0
|
||||
if (abacusValue === startVal) return 0
|
||||
|
||||
// Find which step we're on by checking expected values
|
||||
// The step index to highlight is the one we're working toward (next incomplete step)
|
||||
for (let i = 0; i < sequence.steps.length; i++) {
|
||||
const step = sequence.steps[i]
|
||||
// If abacus value is less than this step's expected value, we're working on this step
|
||||
if (abacusValue < step.expectedValue) {
|
||||
return i
|
||||
}
|
||||
// If we've reached or passed this step's expected value, check next step
|
||||
if (abacusValue === step.expectedValue) {
|
||||
// We've completed this step, move to next
|
||||
return Math.min(i + 1, sequence.steps.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// At or past target - show last step as complete
|
||||
return sequence.steps.length - 1
|
||||
}, [sequence, abacusValue, currentValue])
|
||||
|
||||
// Generate dynamic coach hint based on current step
|
||||
const dynamicCoachHint = useMemo(() => {
|
||||
return generateDynamicCoachHint(sequence, currentStepIndex, abacusValue, targetValue ?? 0)
|
||||
}, [sequence, currentStepIndex, abacusValue, targetValue])
|
||||
|
||||
// Handle abacus value changes
|
||||
const handleAbacusValueChange = useCallback((newValue: number) => {
|
||||
setAbacusValue(newValue)
|
||||
}, [])
|
||||
|
||||
// Calculate effective level here so handleRequestHelp can use it
|
||||
// (effectiveLevel treats L0 as L1 since we auto-show help on prefix sum detection)
|
||||
const effectiveLevel = currentLevel === 0 ? 1 : currentLevel
|
||||
|
||||
const handleRequestHelp = useCallback(() => {
|
||||
// Always request the next level above effectiveLevel
|
||||
if (effectiveLevel < 3) {
|
||||
onRequestHelp((effectiveLevel + 1) as HelpLevel)
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [effectiveLevel, onRequestHelp])
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
onDismissHelp()
|
||||
setIsExpanded(false)
|
||||
}, [onDismissHelp])
|
||||
|
||||
// Don't render if help is not available (e.g., sequence generation failed)
|
||||
if (!isAvailable) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Levels 1-3: Show the help content (effectiveLevel is calculated above)
|
||||
return (
|
||||
<div
|
||||
data-component="practice-help-panel"
|
||||
data-level={effectiveLevel}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'blue.900' : 'blue.50',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'blue.700' : 'blue.200',
|
||||
})}
|
||||
>
|
||||
{/* Header with level indicator */}
|
||||
<div
|
||||
data-element="help-header"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.25rem' })}>{HELP_LEVEL_ICONS[effectiveLevel]}</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
})}
|
||||
>
|
||||
{HELP_LEVEL_LABELS[effectiveLevel]}
|
||||
</span>
|
||||
{/* Help level indicator dots */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
marginLeft: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{[1, 2, 3].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={css({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
level <= effectiveLevel ? 'blue.500' : isDark ? 'blue.700' : 'blue.200',
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="dismiss-help"
|
||||
onClick={handleDismiss}
|
||||
className={css({
|
||||
padding: '0.25rem 0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✕ Hide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Level 1: Coach hint - uses dynamic hint that updates with abacus progress */}
|
||||
{effectiveLevel >= 1 && dynamicCoachHint && (
|
||||
<div
|
||||
data-element="coach-hint"
|
||||
data-step-index={currentStepIndex}
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.800' : 'blue.100',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
{dynamicCoachHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Level 2: Decomposition */}
|
||||
{effectiveLevel >= 2 &&
|
||||
content?.decomposition &&
|
||||
content.decomposition.isMeaningful &&
|
||||
currentValue !== undefined &&
|
||||
targetValue !== undefined && (
|
||||
<div
|
||||
data-element="decomposition-container"
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.800' : 'blue.100',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'blue.300' : 'blue.600',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
})}
|
||||
>
|
||||
Step-by-Step
|
||||
</div>
|
||||
<div
|
||||
data-element="decomposition-display"
|
||||
className={css({
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '1.125rem',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
wordBreak: 'break-word',
|
||||
})}
|
||||
>
|
||||
<DecompositionProvider
|
||||
startValue={currentValue}
|
||||
targetValue={targetValue}
|
||||
currentStepIndex={currentStepIndex}
|
||||
abacusColumns={3}
|
||||
>
|
||||
<DecompositionDisplay />
|
||||
</DecompositionProvider>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Level 3: Visual abacus with bead arrows */}
|
||||
{effectiveLevel >= 3 && currentValue !== undefined && targetValue !== undefined && (
|
||||
<div
|
||||
data-element="help-abacus"
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'purple.700' : 'purple.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'purple.300' : 'purple.600',
|
||||
marginBottom: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
🧮 Follow the Arrows
|
||||
</div>
|
||||
|
||||
<HelpAbacus
|
||||
currentValue={currentValue}
|
||||
targetValue={targetValue}
|
||||
columns={3}
|
||||
scaleFactor={1.0}
|
||||
interactive={true}
|
||||
onValueChange={handleAbacusValueChange}
|
||||
/>
|
||||
|
||||
{isAbacusPart && (
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: isDark ? 'purple.900' : 'purple.50',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'purple.200' : 'purple.700',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Try following these movements on your physical abacus
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: Text bead steps if abacus values not provided */}
|
||||
{effectiveLevel >= 3 &&
|
||||
(currentValue === undefined || targetValue === undefined) &&
|
||||
content?.beadSteps &&
|
||||
content.beadSteps.length > 0 && (
|
||||
<div
|
||||
data-element="bead-steps-text"
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.800' : 'blue.100',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'purple.300' : 'purple.600',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
})}
|
||||
>
|
||||
Bead Movements
|
||||
</div>
|
||||
<ol
|
||||
className={css({
|
||||
listStyle: 'decimal',
|
||||
paddingLeft: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{content.beadSteps.map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'purple.300' : 'purple.700',
|
||||
})}
|
||||
>
|
||||
{step.mathematicalTerm}
|
||||
</span>
|
||||
{step.englishInstruction && (
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{' '}
|
||||
— {step.englishInstruction}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* More help button (if not at max level) */}
|
||||
{effectiveLevel < 3 && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="escalate-help"
|
||||
onClick={handleRequestHelp}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'blue.300' : 'blue.600',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.700' : 'blue.200',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'blue.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{HELP_LEVEL_ICONS[(effectiveLevel + 1) as HelpLevel]}</span>
|
||||
<span>More Help: {HELP_LEVEL_LABELS[(effectiveLevel + 1) as HelpLevel]}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Max level indicator */}
|
||||
{maxLevelUsed > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Help used: Level {maxLevelUsed}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PracticeHelpPanel
|
||||
|
|
@ -277,47 +277,6 @@ export const DifferentStudents: Story = {
|
|||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* With Focus Areas - showing skills needing reinforcement
|
||||
*/
|
||||
export const WithFocusAreas: Story = {
|
||||
render: () => (
|
||||
<DashboardWrapper>
|
||||
<ProgressDashboard
|
||||
student={sampleStudent}
|
||||
currentPhase={intermediatePhase}
|
||||
focusAreas={[
|
||||
{
|
||||
skillId: 'fiveComplements.3=5-2',
|
||||
skillName: '+3 Five Complement',
|
||||
bktClassification: 'developing',
|
||||
attempts: 15,
|
||||
correct: 10,
|
||||
consecutiveCorrect: 1,
|
||||
needsReinforcement: true,
|
||||
lastHelpLevel: 2,
|
||||
reinforcementStreak: 1,
|
||||
},
|
||||
{
|
||||
skillId: 'tenComplements.8=10-2',
|
||||
skillName: '+8 Ten Complement',
|
||||
bktClassification: 'weak',
|
||||
attempts: 8,
|
||||
correct: 4,
|
||||
consecutiveCorrect: 0,
|
||||
needsReinforcement: true,
|
||||
lastHelpLevel: 3,
|
||||
reinforcementStreak: 0,
|
||||
},
|
||||
]}
|
||||
onClearReinforcement={(skillId) => alert(`Clear reinforcement for ${skillId}`)}
|
||||
onClearAllReinforcement={() => alert('Clear all reinforcement')}
|
||||
{...handlers}
|
||||
/>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
}
|
||||
|
||||
// Note: Active session resume/start functionality has been moved to the
|
||||
// SessionModeBanner system (see ActiveSessionBanner.tsx and ProjectingBanner.tsx)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,12 +18,8 @@ export interface SkillProgress {
|
|||
attempts: number
|
||||
correct: number
|
||||
consecutiveCorrect: number
|
||||
/** Whether this skill needs reinforcement (used heavy help recently) */
|
||||
needsReinforcement?: boolean
|
||||
/** Last help level used on this skill (0-3) */
|
||||
/** Last help level used on this skill (0 or 1) */
|
||||
lastHelpLevel?: number
|
||||
/** Progress toward clearing reinforcement (0-3) */
|
||||
reinforcementStreak?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,14 +72,8 @@ interface ProgressDashboardProps {
|
|||
currentPhase?: CurrentPhaseInfo
|
||||
/** BKT-based skill health summary */
|
||||
skillHealth?: SkillHealthSummary
|
||||
/** Skills that need extra practice (used heavy help recently) */
|
||||
focusAreas?: SkillProgress[]
|
||||
/** Callback when no active session - start new practice */
|
||||
onStartPractice: () => void
|
||||
/** Callback to clear reinforcement for a skill (teacher only) */
|
||||
onClearReinforcement?: (skillId: string) => void
|
||||
/** Callback to clear all reinforcement flags (teacher only) */
|
||||
onClearAllReinforcement?: () => void
|
||||
}
|
||||
|
||||
// Helper: Compute progress percent based on mode
|
||||
|
|
@ -150,10 +140,7 @@ export function ProgressDashboard({
|
|||
student,
|
||||
currentPhase,
|
||||
skillHealth,
|
||||
focusAreas = [],
|
||||
onStartPractice,
|
||||
onClearReinforcement,
|
||||
onClearAllReinforcement,
|
||||
}: ProgressDashboardProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
|
@ -425,146 +412,6 @@ export function ProgressDashboard({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Focus Areas - Skills needing extra practice */}
|
||||
{focusAreas.length > 0 && (
|
||||
<div
|
||||
data-section="focus-areas"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'orange.900' : 'orange.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'orange.700' : 'orange.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'orange.200' : 'orange.700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span>🎯</span>
|
||||
Focus Areas
|
||||
</h3>
|
||||
{onClearAllReinforcement && focusAreas.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="clear-all-reinforcement"
|
||||
onClick={onClearAllReinforcement}
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'orange.300' : 'orange.600',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
These skills need extra practice:
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{focusAreas.map((skill) => (
|
||||
<div
|
||||
key={skill.skillId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.5rem 0.75rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'orange.800' : 'orange.100',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
>
|
||||
{skill.skillName}
|
||||
</span>
|
||||
{skill.reinforcementStreak !== undefined && skill.reinforcementStreak > 0 && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
})}
|
||||
title={`${skill.reinforcementStreak} correct answers toward clearing`}
|
||||
>
|
||||
({skill.reinforcementStreak}/3)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{onClearReinforcement && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="clear-reinforcement"
|
||||
onClick={() => onClearReinforcement(skill.skillId)}
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
_hover: {
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
},
|
||||
})}
|
||||
title="Mark as mastered (teacher only)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ interface SkillPerformanceAnalysis {
|
|||
fastSkills: SkillPerformance[]
|
||||
slowSkills: SkillPerformance[]
|
||||
lowAccuracySkills: SkillPerformance[]
|
||||
reinforcementSkills: SkillPerformance[]
|
||||
}
|
||||
|
||||
interface SkillPerformanceReportsProps {
|
||||
|
|
@ -291,7 +290,6 @@ export function SkillPerformanceReports({
|
|||
const hasTimingData = analysis.skills.some((s) => s.responseTimeCount > 0)
|
||||
const hasSlowSkills = analysis.slowSkills.length > 0
|
||||
const hasLowAccuracySkills = analysis.lowAccuracySkills.length > 0
|
||||
const hasReinforcementSkills = analysis.reinforcementSkills.length > 0
|
||||
|
||||
// No data yet
|
||||
if (analysis.skills.length === 0) {
|
||||
|
|
@ -376,7 +374,7 @@ export function SkillPerformanceReports({
|
|||
)}
|
||||
|
||||
{/* Skills appearing frequently in errors or slow responses */}
|
||||
{(hasSlowSkills || hasLowAccuracySkills || hasReinforcementSkills) && (
|
||||
{(hasSlowSkills || hasLowAccuracySkills) && (
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<h4
|
||||
className={css({
|
||||
|
|
@ -424,20 +422,6 @@ export function SkillPerformanceReports({
|
|||
overallAvgMs={analysis.overallAvgResponseTimeMs}
|
||||
/>
|
||||
))}
|
||||
{analysis.reinforcementSkills
|
||||
.filter(
|
||||
(s) =>
|
||||
!analysis.slowSkills.some((slow) => slow.skillId === s.skillId) &&
|
||||
!analysis.lowAccuracySkills.some((low) => low.skillId === s.skillId)
|
||||
)
|
||||
.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.skillId}
|
||||
skill={skill}
|
||||
isDark={isDark}
|
||||
overallAvgMs={analysis.overallAvgResponseTimeMs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -104,8 +104,8 @@ export function filterProblemsNeedingAttention(
|
|||
reasons.push('slow')
|
||||
}
|
||||
|
||||
// Check if used significant help
|
||||
if (problem.result.helpLevelUsed >= 3) {
|
||||
// Check if used help (helpLevelUsed is binary: 0 = no help, 1 = help used)
|
||||
if (problem.result.helpLevelUsed >= 1) {
|
||||
reasons.push('help-used')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,32 +56,10 @@ export const playerSkillMastery = sqliteTable(
|
|||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
|
||||
// ---- Reinforcement Tracking (for help system feedback loop) ----
|
||||
|
||||
/**
|
||||
* Whether this skill needs reinforcement
|
||||
* Set to true when student uses heavy help (level 2+) or has multiple incorrect attempts
|
||||
* Cleared after N consecutive correct answers without help
|
||||
*/
|
||||
needsReinforcement: integer('needs_reinforcement', { mode: 'boolean' })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
/**
|
||||
* Last help level used on this skill (0-3)
|
||||
* Used to track struggling patterns
|
||||
* Last help level used on this skill (0 = no help, 1 = used help)
|
||||
*/
|
||||
lastHelpLevel: integer('last_help_level').notNull().default(0),
|
||||
|
||||
/**
|
||||
* Consecutive correct answers without heavy help since reinforcement was flagged
|
||||
* Resets to 0 when reinforcement is cleared or when help level 2+ is used
|
||||
*/
|
||||
reinforcementStreak: integer('reinforcement_streak').notNull().default(0),
|
||||
|
||||
// NOTE: totalResponseTimeMs, responseTimeCount columns REMOVED
|
||||
// These are now computed on-the-fly from session results (single source of truth)
|
||||
// See: getRecentSessionResults() in session-planner.ts
|
||||
},
|
||||
(table) => ({
|
||||
/** Index for fast lookups by playerId */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { users } from './users'
|
|||
|
||||
/**
|
||||
* Help mode for practice sessions
|
||||
* - 'auto': Help automatically appears after time thresholds
|
||||
* - 'auto': Help automatically appears after timeout
|
||||
* - 'manual': Help only appears when student clicks for it
|
||||
* - 'teacher-approved': Student can request help, but teacher must approve
|
||||
*/
|
||||
|
|
@ -12,22 +12,18 @@ export type HelpMode = 'auto' | 'manual' | 'teacher-approved'
|
|||
|
||||
/**
|
||||
* Settings that control help behavior during practice sessions
|
||||
*
|
||||
* Note: Help is now boolean (used or not used). BKT uses 0.5x evidence weight
|
||||
* for problems where help was used.
|
||||
*/
|
||||
export interface StudentHelpSettings {
|
||||
/** How help is triggered */
|
||||
helpMode: HelpMode
|
||||
|
||||
/** For 'auto' mode: milliseconds before each help level appears */
|
||||
autoEscalationTimingMs: {
|
||||
level1: number // Default: 30000 (30s)
|
||||
level2: number // Default: 60000 (60s)
|
||||
level3: number // Default: 90000 (90s)
|
||||
}
|
||||
|
||||
/** For beginners: unlimited L1-L2 help without mastery penalty */
|
||||
/** For beginners: help doesn't count against mastery */
|
||||
beginnerFreeHelp: boolean
|
||||
|
||||
/** For advanced: L2+ help requires teacher approval */
|
||||
/** For advanced students: help requires teacher approval */
|
||||
advancedRequiresApproval: boolean
|
||||
}
|
||||
|
||||
|
|
@ -36,11 +32,6 @@ export interface StudentHelpSettings {
|
|||
*/
|
||||
export const DEFAULT_HELP_SETTINGS: StudentHelpSettings = {
|
||||
helpMode: 'auto',
|
||||
autoEscalationTimingMs: {
|
||||
level1: 30000,
|
||||
level2: 60000,
|
||||
level3: 90000,
|
||||
},
|
||||
beginnerFreeHelp: true,
|
||||
advancedRequiresApproval: false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,13 +214,14 @@ export interface SessionAdjustment {
|
|||
}
|
||||
|
||||
/**
|
||||
* Help level used during a problem
|
||||
* Help level used during a problem (boolean)
|
||||
* - 0: No help requested
|
||||
* - 1: Coach hint only (e.g., "Add the tens digit first")
|
||||
* - 2: Decomposition shown (e.g., "45 + 27 = 45 + 20 + 7")
|
||||
* - 3: Bead highlighting (arrows showing which beads to move)
|
||||
* - 1: Help was used (interactive abacus overlay shown)
|
||||
*
|
||||
* Note: The system previously defined levels 0-3, but only 0/1 are ever recorded.
|
||||
* BKT uses conjunctive blame attribution to identify weak skills.
|
||||
*/
|
||||
export type HelpLevel = 0 | 1 | 2 | 3
|
||||
export type HelpLevel = 0 | 1
|
||||
|
||||
/**
|
||||
* Result of a single problem slot
|
||||
|
|
|
|||
|
|
@ -1,396 +0,0 @@
|
|||
/**
|
||||
* Practice Help Hook
|
||||
*
|
||||
* Manages progressive help during practice sessions.
|
||||
* Integrates with the tutorial system to provide escalating levels of assistance.
|
||||
*
|
||||
* Help Levels:
|
||||
* - L0: No help - student is working independently
|
||||
* - L1: Coach hint - verbal encouragement ("Think about what makes 10")
|
||||
* - L2: Decomposition - show the mathematical breakdown
|
||||
* - L3: Bead arrows - highlight specific bead movements
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { HelpLevel } from '@/db/schema/session-plans'
|
||||
import type { StudentHelpSettings } from '@/db/schema/players'
|
||||
import {
|
||||
generateUnifiedInstructionSequence,
|
||||
type PedagogicalSegment,
|
||||
type UnifiedInstructionSequence,
|
||||
type UnifiedStepData,
|
||||
} from '@/utils/unifiedStepGenerator'
|
||||
import { extractSkillsFromSequence, type ExtractedSkill } from '@/utils/skillExtraction'
|
||||
|
||||
/**
|
||||
* Current term context for help
|
||||
*/
|
||||
export interface TermContext {
|
||||
/** Current abacus value before this term */
|
||||
currentValue: number
|
||||
/** Target value after adding this term */
|
||||
targetValue: number
|
||||
/** The term being added (difference) */
|
||||
term: number
|
||||
/** Index of this term in the problem */
|
||||
termIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Help content at different levels
|
||||
*/
|
||||
export interface HelpContent {
|
||||
/** Level 1: Coach hint - short verbal guidance */
|
||||
coachHint: string
|
||||
/** Level 2: Decomposition string and explanation */
|
||||
decomposition: {
|
||||
/** Full decomposition string (e.g., "3 + 7 = 3 + (10 - 3) = 10") */
|
||||
fullDecomposition: string
|
||||
/** Whether this decomposition is pedagogically meaningful */
|
||||
isMeaningful: boolean
|
||||
/** Segments with readable explanations */
|
||||
segments: PedagogicalSegment[]
|
||||
}
|
||||
/** Level 3: Step-by-step bead movements */
|
||||
beadSteps: UnifiedStepData[]
|
||||
/** Skills exercised in this term */
|
||||
skills: ExtractedSkill[]
|
||||
/** The underlying instruction sequence */
|
||||
sequence: UnifiedInstructionSequence | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Help state returned by the hook
|
||||
*/
|
||||
export interface PracticeHelpState {
|
||||
/** Current help level (0-3) */
|
||||
currentLevel: HelpLevel
|
||||
/** Help content for the current term */
|
||||
content: HelpContent | null
|
||||
/** Maximum help level used so far for this term */
|
||||
maxLevelUsed: HelpLevel
|
||||
/** Whether help is available (sequence generated successfully) */
|
||||
isAvailable: boolean
|
||||
/** Time since help became available (for auto-escalation) */
|
||||
elapsedTimeMs: number
|
||||
/** How help was triggered */
|
||||
trigger: 'none' | 'manual' | 'auto-time' | 'auto-errors'
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for managing help
|
||||
*/
|
||||
export interface PracticeHelpActions {
|
||||
/** Request help at a specific level (or next level if not specified) */
|
||||
requestHelp: (level?: HelpLevel) => void
|
||||
/** Escalate to the next help level */
|
||||
escalateHelp: () => void
|
||||
/** Reset help state for a new term */
|
||||
resetForNewTerm: (context: TermContext) => void
|
||||
/** Dismiss current help (return to L0) */
|
||||
dismissHelp: () => void
|
||||
/** Mark that an error occurred (for auto-escalation) */
|
||||
recordError: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for help behavior
|
||||
*/
|
||||
export interface PracticeHelpConfig {
|
||||
/** Student's help settings */
|
||||
settings: StudentHelpSettings
|
||||
/** Whether this student is a beginner (free help without penalty) */
|
||||
isBeginnerMode: boolean
|
||||
/** BKT-based skill classification of the student (affects auto-escalation) */
|
||||
studentBktClassification?: 'strong' | 'developing' | 'weak' | null
|
||||
/** Callback when help level changes (for tracking) */
|
||||
onHelpLevelChange?: (level: HelpLevel, trigger: PracticeHelpState['trigger']) => void
|
||||
/** Callback when max help level updates */
|
||||
onMaxLevelUpdate?: (maxLevel: HelpLevel) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: StudentHelpSettings = {
|
||||
helpMode: 'auto',
|
||||
autoEscalationTimingMs: {
|
||||
level1: 30000,
|
||||
level2: 60000,
|
||||
level3: 90000,
|
||||
},
|
||||
beginnerFreeHelp: true,
|
||||
advancedRequiresApproval: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate coach hint based on the pedagogical segment
|
||||
*/
|
||||
function generateCoachHint(segment: PedagogicalSegment | undefined): string {
|
||||
if (!segment) {
|
||||
return 'Take your time and think through each step.'
|
||||
}
|
||||
|
||||
const rule = segment.plan[0]?.rule
|
||||
const readable = segment.readable
|
||||
|
||||
switch (rule) {
|
||||
case 'Direct':
|
||||
return (
|
||||
readable.summary ||
|
||||
`You can add ${segment.digit} directly to the ${readable.title.split(' — ')[1] || 'column'}.`
|
||||
)
|
||||
case 'FiveComplement':
|
||||
return `Think about friends of 5. What plus ${5 - segment.digit} makes 5?`
|
||||
case 'TenComplement':
|
||||
return `Think about friends of 10. What plus ${10 - segment.digit} makes 10?`
|
||||
case 'Cascade':
|
||||
return 'This will carry through multiple columns. Start from the left.'
|
||||
default:
|
||||
return 'Think about which beads need to move.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing progressive help during practice
|
||||
*/
|
||||
export function usePracticeHelp(
|
||||
config: PracticeHelpConfig
|
||||
): [PracticeHelpState, PracticeHelpActions] {
|
||||
const {
|
||||
settings = DEFAULT_SETTINGS,
|
||||
isBeginnerMode,
|
||||
onHelpLevelChange,
|
||||
onMaxLevelUpdate,
|
||||
} = config
|
||||
|
||||
// Current term context
|
||||
const [termContext, setTermContext] = useState<TermContext | null>(null)
|
||||
|
||||
// Help state
|
||||
const [currentLevel, setCurrentLevel] = useState<HelpLevel>(0)
|
||||
const [maxLevelUsed, setMaxLevelUsed] = useState<HelpLevel>(0)
|
||||
const [trigger, setTrigger] = useState<PracticeHelpState['trigger']>('none')
|
||||
const [errorCount, setErrorCount] = useState(0)
|
||||
|
||||
// Timer for auto-escalation
|
||||
const [startTime, setStartTime] = useState<number | null>(null)
|
||||
const [elapsedTimeMs, setElapsedTimeMs] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Generate instruction sequence for current term
|
||||
const sequence = useMemo<UnifiedInstructionSequence | null>(() => {
|
||||
if (!termContext) return null
|
||||
try {
|
||||
return generateUnifiedInstructionSequence(termContext.currentValue, termContext.targetValue)
|
||||
} catch {
|
||||
// Subtraction or other unsupported operations
|
||||
return null
|
||||
}
|
||||
}, [termContext])
|
||||
|
||||
// Extract skills from sequence
|
||||
const skills = useMemo<ExtractedSkill[]>(() => {
|
||||
if (!sequence) return []
|
||||
return extractSkillsFromSequence(sequence)
|
||||
}, [sequence])
|
||||
|
||||
// Build help content
|
||||
const content = useMemo<HelpContent | null>(() => {
|
||||
if (!sequence) return null
|
||||
|
||||
const firstSegment = sequence.segments[0]
|
||||
|
||||
return {
|
||||
coachHint: generateCoachHint(firstSegment),
|
||||
decomposition: {
|
||||
fullDecomposition: sequence.fullDecomposition,
|
||||
isMeaningful: sequence.isMeaningfulDecomposition,
|
||||
segments: sequence.segments,
|
||||
},
|
||||
beadSteps: sequence.steps,
|
||||
skills,
|
||||
sequence,
|
||||
}
|
||||
}, [sequence, skills])
|
||||
|
||||
// Check if help is available
|
||||
const isAvailable = sequence !== null
|
||||
|
||||
// Start/stop timer for elapsed time tracking
|
||||
useEffect(() => {
|
||||
if (termContext && startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
|
||||
// Update elapsed time every second
|
||||
if (startTime !== null) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsedTimeMs(Date.now() - startTime)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [termContext, startTime])
|
||||
|
||||
// Auto-escalation based on time (only in 'auto' mode)
|
||||
useEffect(() => {
|
||||
if (settings.helpMode !== 'auto' || !isAvailable) return
|
||||
|
||||
const { autoEscalationTimingMs } = settings
|
||||
|
||||
// Check if we should auto-escalate
|
||||
if (currentLevel === 0 && elapsedTimeMs >= autoEscalationTimingMs.level1) {
|
||||
setCurrentLevel(1)
|
||||
setTrigger('auto-time')
|
||||
if (1 > maxLevelUsed) {
|
||||
setMaxLevelUsed(1)
|
||||
onMaxLevelUpdate?.(1)
|
||||
}
|
||||
onHelpLevelChange?.(1, 'auto-time')
|
||||
} else if (currentLevel === 1 && elapsedTimeMs >= autoEscalationTimingMs.level2) {
|
||||
setCurrentLevel(2)
|
||||
setTrigger('auto-time')
|
||||
if (2 > maxLevelUsed) {
|
||||
setMaxLevelUsed(2)
|
||||
onMaxLevelUpdate?.(2)
|
||||
}
|
||||
onHelpLevelChange?.(2, 'auto-time')
|
||||
} else if (currentLevel === 2 && elapsedTimeMs >= autoEscalationTimingMs.level3) {
|
||||
setCurrentLevel(3)
|
||||
setTrigger('auto-time')
|
||||
if (3 > maxLevelUsed) {
|
||||
setMaxLevelUsed(3)
|
||||
onMaxLevelUpdate?.(3)
|
||||
}
|
||||
onHelpLevelChange?.(3, 'auto-time')
|
||||
}
|
||||
}, [
|
||||
elapsedTimeMs,
|
||||
currentLevel,
|
||||
settings.helpMode,
|
||||
settings.autoEscalationTimingMs,
|
||||
isAvailable,
|
||||
maxLevelUsed,
|
||||
onHelpLevelChange,
|
||||
onMaxLevelUpdate,
|
||||
])
|
||||
|
||||
// Auto-escalation based on errors
|
||||
useEffect(() => {
|
||||
if (settings.helpMode !== 'auto' || !isAvailable) return
|
||||
|
||||
// After 2 errors, escalate to L1
|
||||
// After 3 errors, escalate to L2
|
||||
// After 4 errors, escalate to L3
|
||||
let targetLevel: HelpLevel = 0
|
||||
if (errorCount >= 4) {
|
||||
targetLevel = 3
|
||||
} else if (errorCount >= 3) {
|
||||
targetLevel = 2
|
||||
} else if (errorCount >= 2) {
|
||||
targetLevel = 1
|
||||
}
|
||||
|
||||
if (targetLevel > currentLevel) {
|
||||
setCurrentLevel(targetLevel)
|
||||
setTrigger('auto-errors')
|
||||
if (targetLevel > maxLevelUsed) {
|
||||
setMaxLevelUsed(targetLevel)
|
||||
onMaxLevelUpdate?.(targetLevel)
|
||||
}
|
||||
onHelpLevelChange?.(targetLevel, 'auto-errors')
|
||||
}
|
||||
}, [
|
||||
errorCount,
|
||||
currentLevel,
|
||||
settings.helpMode,
|
||||
isAvailable,
|
||||
maxLevelUsed,
|
||||
onHelpLevelChange,
|
||||
onMaxLevelUpdate,
|
||||
])
|
||||
|
||||
// Actions
|
||||
const requestHelp = useCallback(
|
||||
(level?: HelpLevel) => {
|
||||
if (!isAvailable) return
|
||||
|
||||
const targetLevel = level ?? (Math.min(currentLevel + 1, 3) as HelpLevel)
|
||||
|
||||
// Check if advanced help requires approval
|
||||
if (!isBeginnerMode && settings.advancedRequiresApproval && targetLevel >= 2) {
|
||||
// In teacher-approved mode, this would trigger an approval request
|
||||
// For now, we just don't escalate past L1 automatically
|
||||
if (settings.helpMode === 'teacher-approved' && targetLevel > 1) {
|
||||
// TODO: Trigger approval request
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentLevel(targetLevel)
|
||||
setTrigger('manual')
|
||||
if (targetLevel > maxLevelUsed) {
|
||||
setMaxLevelUsed(targetLevel)
|
||||
onMaxLevelUpdate?.(targetLevel)
|
||||
}
|
||||
onHelpLevelChange?.(targetLevel, 'manual')
|
||||
},
|
||||
[
|
||||
currentLevel,
|
||||
isAvailable,
|
||||
isBeginnerMode,
|
||||
settings,
|
||||
maxLevelUsed,
|
||||
onHelpLevelChange,
|
||||
onMaxLevelUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
const escalateHelp = useCallback(() => {
|
||||
if (currentLevel < 3) {
|
||||
requestHelp((currentLevel + 1) as HelpLevel)
|
||||
}
|
||||
}, [currentLevel, requestHelp])
|
||||
|
||||
const resetForNewTerm = useCallback((context: TermContext) => {
|
||||
setTermContext(context)
|
||||
setCurrentLevel(0)
|
||||
setMaxLevelUsed(0)
|
||||
setTrigger('none')
|
||||
setErrorCount(0)
|
||||
setStartTime(null)
|
||||
setElapsedTimeMs(0)
|
||||
}, [])
|
||||
|
||||
const dismissHelp = useCallback(() => {
|
||||
setCurrentLevel(0)
|
||||
setTrigger('none')
|
||||
}, [])
|
||||
|
||||
const recordError = useCallback(() => {
|
||||
setErrorCount((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
const state: PracticeHelpState = {
|
||||
currentLevel,
|
||||
content,
|
||||
maxLevelUsed,
|
||||
isAvailable,
|
||||
elapsedTimeMs,
|
||||
trigger,
|
||||
}
|
||||
|
||||
const actions: PracticeHelpActions = {
|
||||
requestHelp,
|
||||
escalateHelp,
|
||||
resetForNewTerm,
|
||||
dismissHelp,
|
||||
recordError,
|
||||
}
|
||||
|
||||
return [state, actions]
|
||||
}
|
||||
|
||||
export default usePracticeHelp
|
||||
|
|
@ -3,31 +3,26 @@
|
|||
*
|
||||
* Not all observations are equally informative. We adjust the weight
|
||||
* of evidence based on:
|
||||
* - Help level: More help = less confident the student really knows it
|
||||
* - Help level: Using help = less confident the student really knows it
|
||||
* - Response time: Fast correct = strong mastery, slow correct = struggled
|
||||
*/
|
||||
|
||||
import type { HelpLevel } from '@/db/schema/session-plans'
|
||||
|
||||
/**
|
||||
* Adjust observation weight based on help level.
|
||||
* More help = less confident the student really knows it.
|
||||
* Adjust observation weight based on whether help was used.
|
||||
* Using help = less confident the student really knows it.
|
||||
*
|
||||
* @param helpLevel - Amount of help provided (0 = none, 3 = full solution)
|
||||
* @returns Weight multiplier [0, 1]
|
||||
* @param helpLevel - 0 = no help, 1 = help used
|
||||
* @returns Weight multiplier [0.5, 1.0]
|
||||
*/
|
||||
export function helpLevelWeight(helpLevel: 0 | 1 | 2 | 3): number {
|
||||
switch (helpLevel) {
|
||||
case 0:
|
||||
return 1.0 // No help - full evidence
|
||||
case 1:
|
||||
return 0.8 // Minor hint - slight reduction
|
||||
case 2:
|
||||
return 0.5 // Significant help - halve evidence
|
||||
case 3:
|
||||
return 0.5 // Full help - halve evidence
|
||||
default:
|
||||
// Guard against unexpected values (e.g., null, undefined, or invalid numbers from JSON parsing)
|
||||
return 1.0
|
||||
export function helpLevelWeight(helpLevel: HelpLevel): number {
|
||||
// Guard against unexpected values (legacy data, JSON parsing issues)
|
||||
if (helpLevel !== 0 && helpLevel !== 1) {
|
||||
return 1.0
|
||||
}
|
||||
// 0 = no help (full evidence), 1 = used help (50% evidence)
|
||||
return helpLevel === 0 ? 1.0 : 0.5
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -75,7 +70,7 @@ export function responseTimeWeight(
|
|||
* Combined evidence weight from help and response time.
|
||||
*/
|
||||
export function combinedEvidenceWeight(
|
||||
helpLevel: 0 | 1 | 2 | 3,
|
||||
helpLevel: HelpLevel,
|
||||
responseTimeMs: number,
|
||||
isCorrect: boolean,
|
||||
expectedTimeMs: number = 5000
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
* - slot-distribution.ts - Problem distribution across purposes and parts
|
||||
* - complexity-budgets.ts - Cognitive load budget system
|
||||
* - skill-costs.ts - Base skill complexity and rotation multipliers
|
||||
* - reinforcement-config.ts - Help system feedback loop rules
|
||||
* - bkt-integration.ts - Bayesian Knowledge Tracing integration
|
||||
*/
|
||||
|
||||
|
|
@ -49,12 +48,6 @@ export {
|
|||
ROTATION_MULTIPLIERS,
|
||||
} from './skill-costs'
|
||||
|
||||
// Reinforcement System (help feedback loop)
|
||||
export {
|
||||
REINFORCEMENT_CONFIG,
|
||||
type ReinforcementConfig,
|
||||
} from './reinforcement-config'
|
||||
|
||||
// BKT Integration
|
||||
export {
|
||||
// Unified thresholds (preferred)
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
/**
|
||||
* Reinforcement System Configuration
|
||||
*
|
||||
* When a student struggles with a skill (needs heavy help or makes errors),
|
||||
* the skill is flagged for reinforcement. This config controls:
|
||||
* - What triggers reinforcement
|
||||
* - How reinforcement is cleared
|
||||
* - How help levels affect mastery credit
|
||||
*/
|
||||
export const REINFORCEMENT_CONFIG = {
|
||||
/**
|
||||
* Help level threshold that triggers reinforcement flag.
|
||||
* Level 2+ (decomposition or bead arrows) indicates significant help needed.
|
||||
*/
|
||||
helpLevelThreshold: 2,
|
||||
|
||||
/**
|
||||
* Number of consecutive correct answers without heavy help to clear reinforcement.
|
||||
*/
|
||||
streakToClear: 3,
|
||||
|
||||
/**
|
||||
* Maximum help level that still counts toward clearing reinforcement.
|
||||
* Level 1 (hints) is OK, but Level 2+ resets the streak.
|
||||
*/
|
||||
maxHelpLevelToCount: 1,
|
||||
|
||||
/**
|
||||
* Mastery credit multipliers based on help level.
|
||||
* Used when updating skill mastery after a correct answer.
|
||||
*
|
||||
* - 0 (no help): 1.0 = full credit
|
||||
* - 1 (hint): 1.0 = full credit (hints don't reduce credit)
|
||||
* - 2 (decomposition): 0.5 = half credit
|
||||
* - 3 (bead arrows): 0.25 = quarter credit
|
||||
*/
|
||||
creditMultipliers: {
|
||||
0: 1.0,
|
||||
1: 1.0,
|
||||
2: 0.5,
|
||||
3: 0.25,
|
||||
} as Record<0 | 1 | 2 | 3, number>,
|
||||
} as const
|
||||
|
||||
export type ReinforcementConfig = typeof REINFORCEMENT_CONFIG
|
||||
|
|
@ -7,7 +7,6 @@ import { and, desc, eq, inArray } from 'drizzle-orm'
|
|||
import { db, schema } from '@/db'
|
||||
import type { NewPlayerCurriculum, PlayerCurriculum } from '@/db/schema/player-curriculum'
|
||||
import type { NewPlayerSkillMastery, PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
|
||||
import { REINFORCEMENT_CONFIG } from '@/lib/curriculum/config'
|
||||
import type { PracticeSession } from '@/db/schema/practice-sessions'
|
||||
import type { HelpLevel } from '@/db/schema/session-plans'
|
||||
import {
|
||||
|
|
@ -264,58 +263,30 @@ export async function recordSkillAttempt(
|
|||
/**
|
||||
* Record a skill attempt with help level tracking
|
||||
*
|
||||
* Reinforcement logic:
|
||||
* - If help level >= threshold, mark skill as needing reinforcement
|
||||
* - If correct answer without heavy help, increment reinforcement streak
|
||||
* - After N consecutive correct answers, clear reinforcement flag
|
||||
* Updates the lastPracticedAt timestamp and tracks whether help was used.
|
||||
* BKT handles mastery estimation via evidence weighting (helped answers get 0.5x weight).
|
||||
*
|
||||
* NOTE: Attempt/correct statistics and response times are now computed on-the-fly
|
||||
* from session results. This function only updates metadata and reinforcement tracking.
|
||||
* NOTE: The old reinforcement system (based on help levels 2+) has been removed.
|
||||
* Only boolean help (0 or 1) is recorded. BKT's conjunctive blame attribution
|
||||
* identifies weak skills from multi-skill problems.
|
||||
*/
|
||||
export async function recordSkillAttemptWithHelp(
|
||||
playerId: string,
|
||||
skillId: string,
|
||||
isCorrect: boolean,
|
||||
_isCorrect: boolean,
|
||||
helpLevel: HelpLevel,
|
||||
_responseTimeMs?: number
|
||||
): Promise<PlayerSkillMastery> {
|
||||
const existing = await getSkillMastery(playerId, skillId)
|
||||
const now = new Date()
|
||||
|
||||
// Determine if this help level triggers reinforcement tracking
|
||||
const isHeavyHelp = helpLevel >= REINFORCEMENT_CONFIG.helpLevelThreshold
|
||||
|
||||
if (existing) {
|
||||
// Reinforcement tracking
|
||||
let needsReinforcement = existing.needsReinforcement
|
||||
let reinforcementStreak = existing.reinforcementStreak
|
||||
|
||||
if (isHeavyHelp) {
|
||||
// Heavy help triggers reinforcement flag
|
||||
needsReinforcement = true
|
||||
reinforcementStreak = 0
|
||||
} else if (isCorrect && existing.needsReinforcement) {
|
||||
// Correct answer without heavy help - increment streak toward clearing
|
||||
reinforcementStreak = existing.reinforcementStreak + 1
|
||||
|
||||
// Clear reinforcement if streak reaches threshold
|
||||
if (reinforcementStreak >= REINFORCEMENT_CONFIG.streakToClear) {
|
||||
needsReinforcement = false
|
||||
reinforcementStreak = 0
|
||||
}
|
||||
} else if (!isCorrect) {
|
||||
// Incorrect answer resets reinforcement streak
|
||||
reinforcementStreak = 0
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.playerSkillMastery)
|
||||
.set({
|
||||
lastPracticedAt: now,
|
||||
updatedAt: now,
|
||||
needsReinforcement,
|
||||
lastHelpLevel: helpLevel,
|
||||
reinforcementStreak,
|
||||
})
|
||||
.where(eq(schema.playerSkillMastery.id, existing.id))
|
||||
|
||||
|
|
@ -328,9 +299,7 @@ export async function recordSkillAttemptWithHelp(
|
|||
skillId,
|
||||
isPracticing: true, // skill is being practiced
|
||||
lastPracticedAt: now,
|
||||
needsReinforcement: isHeavyHelp,
|
||||
lastHelpLevel: helpLevel,
|
||||
reinforcementStreak: 0,
|
||||
}
|
||||
|
||||
await db.insert(schema.playerSkillMastery).values(newRecord)
|
||||
|
|
@ -364,54 +333,6 @@ export async function recordSkillAttemptsWithHelp(
|
|||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skills that need reinforcement for a player
|
||||
*/
|
||||
export async function getSkillsNeedingReinforcement(
|
||||
playerId: string
|
||||
): Promise<PlayerSkillMastery[]> {
|
||||
return db.query.playerSkillMastery.findMany({
|
||||
where: and(
|
||||
eq(schema.playerSkillMastery.playerId, playerId),
|
||||
eq(schema.playerSkillMastery.needsReinforcement, true)
|
||||
),
|
||||
orderBy: desc(schema.playerSkillMastery.lastPracticedAt),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear reinforcement for a specific skill (teacher override)
|
||||
*/
|
||||
export async function clearSkillReinforcement(playerId: string, skillId: string): Promise<void> {
|
||||
await db
|
||||
.update(schema.playerSkillMastery)
|
||||
.set({
|
||||
needsReinforcement: false,
|
||||
reinforcementStreak: 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playerSkillMastery.playerId, playerId),
|
||||
eq(schema.playerSkillMastery.skillId, skillId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all reinforcement flags for a player (teacher override)
|
||||
*/
|
||||
export async function clearAllReinforcement(playerId: string): Promise<void> {
|
||||
await db
|
||||
.update(schema.playerSkillMastery)
|
||||
.set({
|
||||
needsReinforcement: false,
|
||||
reinforcementStreak: 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.playerSkillMastery.playerId, playerId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Record multiple skill attempts at once (for batch updates after a problem)
|
||||
*/
|
||||
|
|
@ -613,8 +534,6 @@ export interface SkillPerformanceAnalysis {
|
|||
slowSkills: SkillPerformance[]
|
||||
/** Skills with low accuracy that may need intervention */
|
||||
lowAccuracySkills: SkillPerformance[]
|
||||
/** Skills needing reinforcement (from help system) */
|
||||
reinforcementSkills: SkillPerformance[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -729,18 +648,12 @@ export async function analyzeSkillPerformance(playerId: string): Promise<SkillPe
|
|||
s.accuracy < PERFORMANCE_THRESHOLDS.minAccuracyThreshold
|
||||
)
|
||||
|
||||
// Get skills needing reinforcement
|
||||
const reinforcementRecords = await getSkillsNeedingReinforcement(playerId)
|
||||
const reinforcementSkillIds = new Set(reinforcementRecords.map((r) => r.skillId))
|
||||
const reinforcementSkills = skills.filter((s) => reinforcementSkillIds.has(s.skillId))
|
||||
|
||||
return {
|
||||
skills,
|
||||
overallAvgResponseTimeMs,
|
||||
fastSkills,
|
||||
slowSkills,
|
||||
lowAccuracySkills,
|
||||
reinforcementSkills,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ function createTestProfile(overrides: Partial<StudentProfile> = {}): StudentProf
|
|||
halfMaxExposure: 10, // K = 10: 50% at 10 exposures
|
||||
hillCoefficient: 2, // n = 2: x² curve shape
|
||||
initialExposures: {},
|
||||
helpUsageProbabilities: [1.0, 0, 0, 0], // Always no help for deterministic tests
|
||||
helpBonuses: [0, 0.1, 0.2, 0.3],
|
||||
helpUsageProbabilities: [1.0, 0], // Always no help for deterministic tests
|
||||
helpBonuses: [0, 0],
|
||||
baseResponseTimeMs: 5000,
|
||||
responseTimeVariance: 0,
|
||||
...overrides,
|
||||
|
|
@ -384,8 +384,8 @@ describe('SimulatedStudent Hill Function', () => {
|
|||
it('should clamp probability to valid range', () => {
|
||||
// Even with 0 skills and max help, probability shouldn't exceed 0.98
|
||||
const profile = createTestProfile({
|
||||
helpUsageProbabilities: [0, 0, 0, 1.0], // Always max help
|
||||
helpBonuses: [0, 0.1, 0.2, 0.5], // Large bonus
|
||||
helpUsageProbabilities: [0, 1.0], // Always uses help
|
||||
helpBonuses: [0, 0.5], // Large bonus
|
||||
})
|
||||
const student = new SimulatedStudent(profile, new SeededRandom(12345))
|
||||
|
||||
|
|
|
|||
|
|
@ -174,21 +174,21 @@ export class SimulatedStudent {
|
|||
this.skillExposures.set(skillId, current + 1)
|
||||
}
|
||||
|
||||
// Determine help level (probabilistic based on profile)
|
||||
const helpLevel = this.selectHelpLevel()
|
||||
// Determine if student uses help (binary)
|
||||
const helpLevelUsed = this.selectHelpLevel()
|
||||
|
||||
// Calculate answer probability using Hill function + conjunctive model
|
||||
const answerProbability = this.calculateAnswerProbability(skillsChallenged, helpLevel)
|
||||
const answerProbability = this.calculateAnswerProbability(skillsChallenged, helpLevelUsed)
|
||||
|
||||
const isCorrect = this.rng.chance(answerProbability)
|
||||
|
||||
// Calculate response time
|
||||
const responseTimeMs = this.calculateResponseTime(skillsChallenged, helpLevel, isCorrect)
|
||||
const responseTimeMs = this.calculateResponseTime(skillsChallenged, helpLevelUsed, isCorrect)
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
responseTimeMs,
|
||||
helpLevelUsed: helpLevel,
|
||||
helpLevelUsed,
|
||||
skillsChallenged,
|
||||
fatigue,
|
||||
}
|
||||
|
|
@ -200,7 +200,7 @@ export class SimulatedStudent {
|
|||
* For multi-skill problems, each skill must be applied correctly:
|
||||
* P(correct) = P(skill_A) × P(skill_B) × P(skill_C) × ...
|
||||
*
|
||||
* Help bonuses are additive (applied after the product).
|
||||
* Help bonus is additive (applied after the product).
|
||||
*/
|
||||
private calculateAnswerProbability(skillIds: string[], helpLevel: HelpLevel): number {
|
||||
if (skillIds.length === 0) {
|
||||
|
|
@ -229,17 +229,12 @@ export class SimulatedStudent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Select a help level based on the student's profile probabilities.
|
||||
* Select whether student uses help (binary).
|
||||
* Based on profile's helpUsageProbabilities [P(no help), P(help)].
|
||||
*/
|
||||
private selectHelpLevel(): HelpLevel {
|
||||
const [p0, p1, p2, p3] = this.profile.helpUsageProbabilities
|
||||
const roll = this.rng.next()
|
||||
|
||||
// Cumulative probability check
|
||||
if (roll < p0) return 0
|
||||
if (roll < p0 + p1) return 1
|
||||
if (roll < p0 + p1 + p2) return 2
|
||||
return 3
|
||||
const [pNoHelp] = this.profile.helpUsageProbabilities
|
||||
return this.rng.next() < pNoHelp ? 0 : 1
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -64,11 +64,11 @@ export const averageLearnerProfile: StudentProfile = {
|
|||
|
||||
initialExposures,
|
||||
|
||||
// Moderate help usage: 55% no help, 25% hint, 15% decomp, 5% full
|
||||
helpUsageProbabilities: [0.55, 0.25, 0.15, 0.05],
|
||||
// Moderate help usage: 55% no help, 45% uses help
|
||||
helpUsageProbabilities: [0.55, 0.45],
|
||||
|
||||
// Standard help bonuses
|
||||
helpBonuses: [0, 0.06, 0.15, 0.3],
|
||||
// Help bonus: 15% additive when help is used
|
||||
helpBonuses: [0, 0.15],
|
||||
|
||||
// Average response time
|
||||
baseResponseTimeMs: 5500,
|
||||
|
|
|
|||
|
|
@ -63,11 +63,11 @@ export const fastLearnerProfile: StudentProfile = {
|
|||
|
||||
initialExposures,
|
||||
|
||||
// Rarely needs help: 70% no help, 20% hint, 8% decomp, 2% full
|
||||
helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02],
|
||||
// Rarely needs help: 70% no help, 30% uses help
|
||||
helpUsageProbabilities: [0.7, 0.3],
|
||||
|
||||
// Help bonuses (additive to probability)
|
||||
helpBonuses: [0, 0.05, 0.12, 0.25],
|
||||
// Help bonus: 12% additive when help is used
|
||||
helpBonuses: [0, 0.12],
|
||||
|
||||
// Relatively fast responses
|
||||
baseResponseTimeMs: 4000,
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ export const LEARNER_TYPES = {
|
|||
* - 75 exposures → 90% (mastered)
|
||||
*/
|
||||
masteredExposure: 75,
|
||||
helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02] as [number, number, number, number],
|
||||
helpBonuses: [0, 0.05, 0.12, 0.25] as [number, number, number, number],
|
||||
helpUsageProbabilities: [0.7, 0.3] as [number, number],
|
||||
helpBonuses: [0, 0.12] as [number, number],
|
||||
baseResponseTimeMs: 4000,
|
||||
responseTimeVariance: 0.25,
|
||||
},
|
||||
|
|
@ -108,8 +108,8 @@ export const LEARNER_TYPES = {
|
|||
* - 120 exposures → 94% (mastered)
|
||||
*/
|
||||
masteredExposure: 120,
|
||||
helpUsageProbabilities: [0.55, 0.25, 0.15, 0.05] as [number, number, number, number],
|
||||
helpBonuses: [0, 0.06, 0.15, 0.3] as [number, number, number, number],
|
||||
helpUsageProbabilities: [0.55, 0.45] as [number, number],
|
||||
helpBonuses: [0, 0.15] as [number, number],
|
||||
baseResponseTimeMs: 5500,
|
||||
responseTimeVariance: 0.35,
|
||||
},
|
||||
|
|
@ -126,8 +126,8 @@ export const LEARNER_TYPES = {
|
|||
* - 150 exposures → 94% (mastered)
|
||||
*/
|
||||
masteredExposure: 150,
|
||||
helpUsageProbabilities: [0.4, 0.3, 0.2, 0.1] as [number, number, number, number],
|
||||
helpBonuses: [0, 0.08, 0.18, 0.35] as [number, number, number, number],
|
||||
helpUsageProbabilities: [0.4, 0.6] as [number, number],
|
||||
helpBonuses: [0, 0.2] as [number, number],
|
||||
baseResponseTimeMs: 7000,
|
||||
responseTimeVariance: 0.4,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,11 +69,11 @@ export const slowLearnerProfile: StudentProfile = {
|
|||
|
||||
initialExposures,
|
||||
|
||||
// Uses help often: 40% no help, 30% hint, 20% decomp, 10% full
|
||||
helpUsageProbabilities: [0.4, 0.3, 0.2, 0.1],
|
||||
// Uses help often: 40% no help, 60% uses help
|
||||
helpUsageProbabilities: [0.4, 0.6],
|
||||
|
||||
// Higher help bonuses (helps more when used)
|
||||
helpBonuses: [0, 0.08, 0.18, 0.35],
|
||||
// Help bonus: 20% additive when help is used
|
||||
helpBonuses: [0, 0.2],
|
||||
|
||||
// Takes longer to respond
|
||||
baseResponseTimeMs: 8000,
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ export const starkContrastProfile: StudentProfile = {
|
|||
initialExposures,
|
||||
|
||||
// Uses less help to make weakness more apparent
|
||||
// 70% no help, 20% hint, 8% decomp, 2% full
|
||||
helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02],
|
||||
// 70% no help, 30% uses help
|
||||
helpUsageProbabilities: [0.7, 0.3],
|
||||
|
||||
// Lower help bonuses (weakness is more visible)
|
||||
helpBonuses: [0, 0.05, 0.12, 0.25],
|
||||
// Help bonus: 12% additive when help is used
|
||||
helpBonuses: [0, 0.12],
|
||||
|
||||
// Average response time
|
||||
baseResponseTimeMs: 5000,
|
||||
|
|
|
|||
|
|
@ -58,11 +58,11 @@ export const unevenSkillsProfile: StudentProfile = {
|
|||
|
||||
initialExposures,
|
||||
|
||||
// Moderate help usage: 55% no help, 25% hint, 15% decomp, 5% full
|
||||
helpUsageProbabilities: [0.55, 0.25, 0.15, 0.05],
|
||||
// Moderate help usage: 55% no help, 45% uses help
|
||||
helpUsageProbabilities: [0.55, 0.45],
|
||||
|
||||
// Standard help bonuses
|
||||
helpBonuses: [0, 0.06, 0.15, 0.3],
|
||||
// Help bonus: 15% additive when help is used
|
||||
helpBonuses: [0, 0.15],
|
||||
|
||||
// Average response time
|
||||
baseResponseTimeMs: 5500,
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import * as schema from '@/db/schema'
|
|||
import {
|
||||
createEphemeralDatabase,
|
||||
createTestStudent,
|
||||
type EphemeralDbResult,
|
||||
getCurrentEphemeralDb,
|
||||
setCurrentEphemeralDb,
|
||||
type EphemeralDbResult,
|
||||
} from './EphemeralDatabase'
|
||||
import { JourneyRunner } from './JourneyRunner'
|
||||
import { SeededRandom } from './SeededRandom'
|
||||
import { SimulatedStudent, getTrueMultiplier } from './SimulatedStudent'
|
||||
import { getTrueMultiplier, SimulatedStudent } from './SimulatedStudent'
|
||||
import type { JourneyConfig, JourneyResult, StudentProfile } from './types'
|
||||
|
||||
// Mock the @/db module to use our ephemeral database
|
||||
|
|
@ -39,8 +39,8 @@ const STANDARD_PROFILE: StudentProfile = {
|
|||
halfMaxExposure: 10, // Base K=10, multiplied by skill difficulty
|
||||
hillCoefficient: 2.0, // Standard curve shape
|
||||
initialExposures: {}, // Start from scratch
|
||||
helpUsageProbabilities: [1.0, 0, 0, 0], // No help for clean measurements
|
||||
helpBonuses: [0, 0, 0, 0],
|
||||
helpUsageProbabilities: [1.0, 0], // No help for clean measurements
|
||||
helpBonuses: [0, 0],
|
||||
baseResponseTimeMs: 5000,
|
||||
responseTimeVariance: 0.3,
|
||||
}
|
||||
|
|
@ -460,8 +460,8 @@ describe('A/B Mastery Trajectories', () => {
|
|||
.filter((s) => s !== deficientSkillId)
|
||||
.map((s) => [s, 25]) // 25 exposures = ~86% mastery for basic, ~73% for five-comp
|
||||
),
|
||||
helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02],
|
||||
helpBonuses: [0, 0.05, 0.12, 0.25],
|
||||
helpUsageProbabilities: [0.7, 0.3], // 70% no help, 30% uses help
|
||||
helpBonuses: [0, 0.25], // Help bonus when used
|
||||
baseResponseTimeMs: 5000,
|
||||
responseTimeVariance: 0.3,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -55,17 +55,17 @@ export interface StudentProfile {
|
|||
initialExposures: Record<string, number>
|
||||
|
||||
/**
|
||||
* Probability distribution for help level usage [none, hint, decomp, highlight]
|
||||
* Probability of using help: [P(no help), P(help)]
|
||||
* Must sum to 1.0
|
||||
* Example: [0.7, 0.3] means 70% no help, 30% uses help
|
||||
*/
|
||||
helpUsageProbabilities: [number, number, number, number]
|
||||
helpUsageProbabilities: [number, number]
|
||||
|
||||
/**
|
||||
* Additive bonus to P(correct) per help level [none, hint, decomp, highlight]
|
||||
* These are additive to the base probability, not multiplicative.
|
||||
* Example: [0, 0.05, 0.12, 0.25] means full help adds 25% to success chance
|
||||
* Additive bonus to P(correct) when help is used: [no help bonus, help bonus]
|
||||
* Example: [0, 0.15] means using help adds 15% to success chance
|
||||
*/
|
||||
helpBonuses: [number, number, number, number]
|
||||
helpBonuses: [number, number]
|
||||
|
||||
/** Base response time in milliseconds */
|
||||
baseResponseTimeMs: number
|
||||
|
|
@ -114,7 +114,7 @@ export interface SimulatedAnswer {
|
|||
isCorrect: boolean
|
||||
/** Time taken to answer in milliseconds */
|
||||
responseTimeMs: number
|
||||
/** Help level used (0 = none, 3 = full) */
|
||||
/** Help level used (0 = no help, 1 = used help) */
|
||||
helpLevelUsed: HelpLevel
|
||||
/** Skills that were actually challenged by this problem */
|
||||
skillsChallenged: string[]
|
||||
|
|
|
|||
Loading…
Reference in New Issue