feat: implement sequential addition problem generation with skill-aware logic
- Create intelligent skill analysis that simulates step-by-step addition process - Generate 3-5 number sequences where each step uses only allowed skills - Analyze required skills during sequential addition rather than final sum - Support five complements, ten complements, and basic abacus operations - Include comprehensive test coverage for skill detection and generation - Problems ensure students only need skills they've learned during the sequence 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
234
apps/web/src/utils/__tests__/problemGenerator.test.ts
Normal file
234
apps/web/src/utils/__tests__/problemGenerator.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
analyzeRequiredSkills,
|
||||
problemMatchesSkills,
|
||||
generateSingleProblem,
|
||||
generateProblems,
|
||||
validatePracticeStepConfiguration
|
||||
} from '../problemGenerator'
|
||||
import { createBasicSkillSet, createEmptySkillSet, PracticeStep } from '../../types/tutorial'
|
||||
|
||||
describe('Problem Generator', () => {
|
||||
describe('analyzeRequiredSkills', () => {
|
||||
it('should identify basic direct addition in sequence', () => {
|
||||
const skills = analyzeRequiredSkills([1, 2], 3) // 0 + 1 = 1, then 1 + 2 = 3
|
||||
expect(skills).toContain('basic.directAddition')
|
||||
})
|
||||
|
||||
it('should identify heaven bead usage in sequence', () => {
|
||||
const skills = analyzeRequiredSkills([3, 2], 5) // 0 + 3 = 3, then 3 + 2 = 5 (needs five complement)
|
||||
expect(skills).toContain('fiveComplements.2=5-3')
|
||||
expect(skills).toContain('basic.heavenBead')
|
||||
})
|
||||
|
||||
it('should identify simple combinations', () => {
|
||||
const skills = analyzeRequiredSkills([5, 1], 6) // 0 + 5 = 5, then 5 + 1 = 6
|
||||
expect(skills).toContain('basic.heavenBead')
|
||||
expect(skills).toContain('basic.simpleCombinations')
|
||||
})
|
||||
|
||||
it('should identify five complements in sequence', () => {
|
||||
const skills = analyzeRequiredSkills([3, 4], 7) // 0 + 3 = 3, then 3 + 4 = 7 (needs 4=5-1)
|
||||
expect(skills).toContain('fiveComplements.4=5-1')
|
||||
})
|
||||
|
||||
it('should identify ten complements in sequence', () => {
|
||||
const skills = analyzeRequiredSkills([7, 5], 12) // 0 + 7 = 7, then 7 + 5 = 12 (needs ten complement)
|
||||
expect(skills).toContain('tenComplements.5=10-5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('problemMatchesSkills', () => {
|
||||
const basicSkills = createBasicSkillSet()
|
||||
basicSkills.basic.directAddition = true
|
||||
|
||||
const problem = {
|
||||
id: 'test',
|
||||
terms: [1, 2],
|
||||
answer: 3,
|
||||
requiredSkills: ['basic.directAddition'],
|
||||
difficulty: 'easy' as const
|
||||
}
|
||||
|
||||
it('should match when problem uses required skills', () => {
|
||||
const matches = problemMatchesSkills(problem, basicSkills)
|
||||
expect(matches).toBe(true)
|
||||
})
|
||||
|
||||
it('should not match when problem uses forbidden skills', () => {
|
||||
const forbiddenSkills = createEmptySkillSet()
|
||||
forbiddenSkills.basic.directAddition = true
|
||||
|
||||
const matches = problemMatchesSkills(problem, basicSkills, undefined, forbiddenSkills)
|
||||
expect(matches).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSingleProblem', () => {
|
||||
it('should generate a sequential problem within constraints', () => {
|
||||
const constraints = {
|
||||
numberRange: { min: 1, max: 3 },
|
||||
maxSum: 8,
|
||||
maxTerms: 5,
|
||||
problemCount: 1
|
||||
}
|
||||
|
||||
const skills = createBasicSkillSet()
|
||||
skills.basic.directAddition = true
|
||||
|
||||
const problem = generateSingleProblem(constraints, skills)
|
||||
|
||||
expect(problem).toBeTruthy()
|
||||
if (problem) {
|
||||
expect(problem.terms.length).toBeGreaterThanOrEqual(3) // Now 3-5 terms
|
||||
expect(problem.terms.length).toBeLessThanOrEqual(5)
|
||||
expect(problem.answer).toBeLessThanOrEqual(8)
|
||||
expect(problem.terms.every(term => term >= 1 && term <= 3)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('should return null when constraints are impossible', () => {
|
||||
const constraints = {
|
||||
numberRange: { min: 10, max: 10 },
|
||||
maxSum: 5, // Impossible: even one term of 10 exceeds maxSum of 5
|
||||
maxTerms: 5,
|
||||
problemCount: 1
|
||||
}
|
||||
|
||||
const skills = createBasicSkillSet()
|
||||
skills.basic.directAddition = true
|
||||
|
||||
const problem = generateSingleProblem(constraints, skills, undefined, undefined, 10)
|
||||
expect(problem).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateProblems', () => {
|
||||
const practiceStep: PracticeStep = {
|
||||
id: 'test-practice',
|
||||
type: 'practice',
|
||||
title: 'Test Practice',
|
||||
description: 'Test practice step',
|
||||
problemCount: 3,
|
||||
maxTerms: 5,
|
||||
requiredSkills: createBasicSkillSet(),
|
||||
numberRange: { min: 1, max: 3 },
|
||||
sumConstraints: { maxSum: 8 }
|
||||
}
|
||||
|
||||
// Enable basic direct addition
|
||||
practiceStep.requiredSkills.basic.directAddition = true
|
||||
|
||||
it('should generate the requested number of problems', () => {
|
||||
const problems = generateProblems(practiceStep)
|
||||
expect(problems).toHaveLength(3)
|
||||
// Each problem should have 3-5 terms
|
||||
problems.forEach(problem => {
|
||||
expect(problem.terms.length).toBeGreaterThanOrEqual(3)
|
||||
expect(problem.terms.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
})
|
||||
|
||||
it('should generate unique problems', () => {
|
||||
const problems = generateProblems(practiceStep)
|
||||
const problemSignatures = problems.map(p => p.terms.join('-'))
|
||||
const uniqueSignatures = [...new Set(problemSignatures)]
|
||||
expect(uniqueSignatures.length).toBe(problemSignatures.length)
|
||||
})
|
||||
|
||||
it('should handle duplicate detection with large problem counts', () => {
|
||||
const largePracticeStep: PracticeStep = {
|
||||
...practiceStep,
|
||||
problemCount: 10,
|
||||
numberRange: { min: 1, max: 2 }, // Very restrictive to force potential duplicates
|
||||
maxTerms: 3
|
||||
}
|
||||
|
||||
const problems = generateProblems(largePracticeStep)
|
||||
const problemSignatures = problems.map(p => p.terms.join('-'))
|
||||
const uniqueSignatures = [...new Set(problemSignatures)]
|
||||
|
||||
// Should still generate unique problems even with restrictive constraints
|
||||
expect(uniqueSignatures.length).toBe(problemSignatures.length)
|
||||
expect(problems.length).toBe(10)
|
||||
})
|
||||
|
||||
it('should create fallback problems when primary generation fails', () => {
|
||||
const impossibleStep: PracticeStep = {
|
||||
id: 'impossible',
|
||||
type: 'practice',
|
||||
title: 'Impossible',
|
||||
description: 'Should fall back to simple problems',
|
||||
problemCount: 3,
|
||||
maxTerms: 5,
|
||||
requiredSkills: createEmptySkillSet(), // No skills enabled
|
||||
numberRange: { min: 1, max: 1 }, // Only one possible number
|
||||
sumConstraints: { maxSum: 2 } // Very restrictive
|
||||
}
|
||||
|
||||
const problems = generateProblems(impossibleStep)
|
||||
expect(problems.length).toBe(3)
|
||||
|
||||
// Should fall back to basic addition problems
|
||||
problems.forEach(problem => {
|
||||
expect(problem.requiredSkills).toContain('basic.directAddition')
|
||||
expect(problem.difficulty).toBe('easy')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePracticeStepConfiguration', () => {
|
||||
it('should validate a good configuration', () => {
|
||||
const practiceStep: PracticeStep = {
|
||||
id: 'test',
|
||||
type: 'practice',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
problemCount: 5,
|
||||
maxTerms: 5,
|
||||
requiredSkills: createBasicSkillSet(),
|
||||
numberRange: { min: 1, max: 9 },
|
||||
sumConstraints: { maxSum: 15 }
|
||||
}
|
||||
|
||||
practiceStep.requiredSkills.basic.directAddition = true
|
||||
|
||||
const result = validatePracticeStepConfiguration(practiceStep)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.warnings).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should warn about no required skills', () => {
|
||||
const practiceStep: PracticeStep = {
|
||||
id: 'test',
|
||||
type: 'practice',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
problemCount: 5,
|
||||
maxTerms: 3,
|
||||
requiredSkills: createEmptySkillSet(),
|
||||
numberRange: { min: 1, max: 9 }
|
||||
}
|
||||
|
||||
const result = validatePracticeStepConfiguration(practiceStep)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.warnings.some(w => w.includes('No required skills'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should warn about impossible sum constraints', () => {
|
||||
const practiceStep: PracticeStep = {
|
||||
id: 'test',
|
||||
type: 'practice',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
problemCount: 5,
|
||||
maxTerms: 5,
|
||||
requiredSkills: createBasicSkillSet(),
|
||||
numberRange: { min: 1, max: 5 },
|
||||
sumConstraints: { maxSum: 30 } // Higher than possible (5 terms * 5 max = 25)
|
||||
}
|
||||
|
||||
const result = validatePracticeStepConfiguration(practiceStep)
|
||||
expect(result.warnings.some(w => w.includes('Maximum sum constraint'))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
643
apps/web/src/utils/problemGenerator.ts
Normal file
643
apps/web/src/utils/problemGenerator.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import { SkillSet, PracticeStep } from '../types/tutorial'
|
||||
|
||||
export interface GeneratedProblem {
|
||||
id: string
|
||||
terms: number[]
|
||||
answer: number
|
||||
requiredSkills: string[]
|
||||
difficulty: 'easy' | 'medium' | 'hard'
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
export interface ProblemConstraints {
|
||||
numberRange: { min: number; max: number }
|
||||
maxSum?: number
|
||||
minSum?: number
|
||||
maxTerms: number
|
||||
problemCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes which skills are required during the sequential addition process
|
||||
* This simulates adding each term one by one to the abacus
|
||||
*/
|
||||
export function analyzeRequiredSkills(terms: number[], finalSum: number): string[] {
|
||||
const skills: string[] = []
|
||||
let currentValue = 0
|
||||
|
||||
// Simulate adding each term sequentially
|
||||
for (const term of terms) {
|
||||
const newValue = currentValue + term
|
||||
const requiredSkillsForStep = analyzeStepSkills(currentValue, term, newValue)
|
||||
skills.push(...requiredSkillsForStep)
|
||||
currentValue = newValue
|
||||
}
|
||||
|
||||
return [...new Set(skills)] // Remove duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes skills needed for a single addition step: currentValue + term = newValue
|
||||
*/
|
||||
function analyzeStepSkills(currentValue: number, term: number, newValue: number): string[] {
|
||||
const skills: string[] = []
|
||||
|
||||
// Work column by column from right to left
|
||||
const currentDigits = getDigits(currentValue)
|
||||
const termDigits = getDigits(term)
|
||||
const newDigits = getDigits(newValue)
|
||||
|
||||
const maxColumns = Math.max(currentDigits.length, termDigits.length, newDigits.length)
|
||||
|
||||
for (let column = 0; column < maxColumns; column++) {
|
||||
const currentDigit = currentDigits[column] || 0
|
||||
const termDigit = termDigits[column] || 0
|
||||
const newDigit = newDigits[column] || 0
|
||||
|
||||
if (termDigit === 0) continue // No addition in this column
|
||||
|
||||
// Analyze what happens in this column
|
||||
const columnSkills = analyzeColumnAddition(currentDigit, termDigit, newDigit, column)
|
||||
skills.push(...columnSkills)
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes skills needed for addition in a single column
|
||||
*/
|
||||
function analyzeColumnAddition(currentDigit: number, termDigit: number, resultDigit: number, column: number): string[] {
|
||||
const skills: string[] = []
|
||||
|
||||
// Direct addition (1-4)
|
||||
if (termDigit >= 1 && termDigit <= 4) {
|
||||
if (currentDigit + termDigit <= 4) {
|
||||
skills.push('basic.directAddition')
|
||||
} else if (currentDigit + termDigit === 5) {
|
||||
// Adding to make exactly 5 - could be direct or complement
|
||||
if (currentDigit === 0) {
|
||||
skills.push('basic.heavenBead') // Direct 5
|
||||
} else {
|
||||
// Five complement: need to use 5 - complement
|
||||
skills.push(`fiveComplements.${termDigit}=5-${5-termDigit}`)
|
||||
skills.push('basic.heavenBead')
|
||||
}
|
||||
} else if (currentDigit + termDigit > 5 && currentDigit + termDigit <= 9) {
|
||||
// Results in 6-9: use five complement + simple combination
|
||||
skills.push(`fiveComplements.${termDigit}=5-${5-termDigit}`)
|
||||
skills.push('basic.heavenBead')
|
||||
skills.push('basic.simpleCombinations')
|
||||
} else if (currentDigit + termDigit >= 10) {
|
||||
// Ten complement needed
|
||||
const complement = 10 - termDigit
|
||||
skills.push(`tenComplements.${termDigit}=10-${complement}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Direct heaven bead (5)
|
||||
else if (termDigit === 5) {
|
||||
if (currentDigit === 0) {
|
||||
skills.push('basic.heavenBead')
|
||||
} else if (currentDigit + 5 <= 9) {
|
||||
skills.push('basic.heavenBead')
|
||||
skills.push('basic.simpleCombinations')
|
||||
} else {
|
||||
// Ten complement
|
||||
skills.push(`tenComplements.5=10-5`)
|
||||
}
|
||||
}
|
||||
|
||||
// Simple combinations (6-9)
|
||||
else if (termDigit >= 6 && termDigit <= 9) {
|
||||
if (currentDigit === 0) {
|
||||
skills.push('basic.heavenBead')
|
||||
skills.push('basic.simpleCombinations')
|
||||
} else if (currentDigit + termDigit <= 9) {
|
||||
skills.push('basic.heavenBead')
|
||||
skills.push('basic.simpleCombinations')
|
||||
} else {
|
||||
// Ten complement
|
||||
const complement = 10 - termDigit
|
||||
skills.push(`tenComplements.${termDigit}=10-${complement}`)
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a number to array of digits (ones, tens, hundreds, etc.)
|
||||
*/
|
||||
function getDigits(num: number): number[] {
|
||||
if (num === 0) return [0]
|
||||
|
||||
const digits: number[] = []
|
||||
while (num > 0) {
|
||||
digits.push(num % 10)
|
||||
num = Math.floor(num / 10)
|
||||
}
|
||||
return digits
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a problem matches the required skills
|
||||
*/
|
||||
export function problemMatchesSkills(
|
||||
problem: GeneratedProblem,
|
||||
requiredSkills: SkillSet,
|
||||
targetSkills?: Partial<SkillSet>,
|
||||
forbiddenSkills?: Partial<SkillSet>
|
||||
): boolean {
|
||||
// Check required skills - problem must use at least one enabled required skill
|
||||
const hasRequiredSkill = problem.requiredSkills.some(skillPath => {
|
||||
const [category, skill] = skillPath.split('.')
|
||||
if (category === 'basic') {
|
||||
return requiredSkills.basic[skill as keyof typeof requiredSkills.basic]
|
||||
} else if (category === 'fiveComplements') {
|
||||
return requiredSkills.fiveComplements[skill as keyof typeof requiredSkills.fiveComplements]
|
||||
} else if (category === 'tenComplements') {
|
||||
return requiredSkills.tenComplements[skill as keyof typeof requiredSkills.tenComplements]
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (!hasRequiredSkill) return false
|
||||
|
||||
// Check forbidden skills - problem must not use any forbidden skills
|
||||
if (forbiddenSkills) {
|
||||
const usesForbiddenSkill = problem.requiredSkills.some(skillPath => {
|
||||
const [category, skill] = skillPath.split('.')
|
||||
if (category === 'basic' && forbiddenSkills.basic) {
|
||||
return forbiddenSkills.basic[skill as keyof typeof forbiddenSkills.basic]
|
||||
} else if (category === 'fiveComplements' && forbiddenSkills.fiveComplements) {
|
||||
return forbiddenSkills.fiveComplements[skill as keyof typeof forbiddenSkills.fiveComplements]
|
||||
} else if (category === 'tenComplements' && forbiddenSkills.tenComplements) {
|
||||
return forbiddenSkills.tenComplements[skill as keyof typeof forbiddenSkills.tenComplements]
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (usesForbiddenSkill) return false
|
||||
}
|
||||
|
||||
// Check target skills - if specified, problem should use at least one target skill
|
||||
if (targetSkills) {
|
||||
const hasTargetSkill = problem.requiredSkills.some(skillPath => {
|
||||
const [category, skill] = skillPath.split('.')
|
||||
if (category === 'basic' && targetSkills.basic) {
|
||||
return targetSkills.basic[skill as keyof typeof targetSkills.basic]
|
||||
} else if (category === 'fiveComplements' && targetSkills.fiveComplements) {
|
||||
return targetSkills.fiveComplements[skill as keyof typeof targetSkills.fiveComplements]
|
||||
} else if (category === 'tenComplements' && targetSkills.tenComplements) {
|
||||
return targetSkills.tenComplements[skill as keyof typeof targetSkills.tenComplements]
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// If target skills are specified but none match, reject
|
||||
const hasAnyTargetSkill = Object.values(targetSkills.basic || {}).some(Boolean) ||
|
||||
Object.values(targetSkills.fiveComplements || {}).some(Boolean) ||
|
||||
Object.values(targetSkills.tenComplements || {}).some(Boolean)
|
||||
|
||||
if (hasAnyTargetSkill && !hasTargetSkill) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a single sequential addition problem that matches the given constraints and skills
|
||||
*/
|
||||
export function generateSingleProblem(
|
||||
constraints: ProblemConstraints,
|
||||
requiredSkills: SkillSet,
|
||||
targetSkills?: Partial<SkillSet>,
|
||||
forbiddenSkills?: Partial<SkillSet>,
|
||||
attempts: number = 100
|
||||
): GeneratedProblem | null {
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
// Generate random number of terms (3 to 5 as specified)
|
||||
const termCount = Math.floor(Math.random() * 3) + 3 // 3-5 terms
|
||||
|
||||
// Generate the sequence of numbers to add
|
||||
const terms = generateSequence(constraints, termCount, requiredSkills, targetSkills, forbiddenSkills)
|
||||
|
||||
if (!terms) continue // Failed to generate valid sequence
|
||||
|
||||
const sum = terms.reduce((acc, term) => acc + term, 0)
|
||||
|
||||
// Check sum constraints
|
||||
if (constraints.maxSum && sum > constraints.maxSum) continue
|
||||
if (constraints.minSum && sum < constraints.minSum) continue
|
||||
|
||||
// Analyze what skills this sequential addition requires
|
||||
const problemSkills = analyzeRequiredSkills(terms, sum)
|
||||
|
||||
// Determine difficulty based on skills required
|
||||
let difficulty: 'easy' | 'medium' | 'hard' = 'easy'
|
||||
if (problemSkills.some(skill => skill.startsWith('tenComplements'))) {
|
||||
difficulty = 'hard'
|
||||
} else if (problemSkills.some(skill => skill.startsWith('fiveComplements'))) {
|
||||
difficulty = 'medium'
|
||||
}
|
||||
|
||||
const problem: GeneratedProblem = {
|
||||
id: `problem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
terms,
|
||||
answer: sum,
|
||||
requiredSkills: problemSkills,
|
||||
difficulty,
|
||||
explanation: generateSequentialExplanation(terms, sum, problemSkills)
|
||||
}
|
||||
|
||||
// Check if problem matches skill requirements
|
||||
if (problemMatchesSkills(problem, requiredSkills, targetSkills, forbiddenSkills)) {
|
||||
return problem
|
||||
}
|
||||
}
|
||||
|
||||
return null // Failed to generate a suitable problem
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sequence of numbers that can be added using only the specified skills
|
||||
*/
|
||||
function generateSequence(
|
||||
constraints: ProblemConstraints,
|
||||
termCount: number,
|
||||
requiredSkills: SkillSet,
|
||||
targetSkills?: Partial<SkillSet>,
|
||||
forbiddenSkills?: Partial<SkillSet>
|
||||
): number[] | null {
|
||||
const terms: number[] = []
|
||||
let currentValue = 0
|
||||
|
||||
for (let i = 0; i < termCount; i++) {
|
||||
// Try to find a valid next term
|
||||
const validTerm = findValidNextTerm(
|
||||
currentValue,
|
||||
constraints,
|
||||
requiredSkills,
|
||||
targetSkills,
|
||||
forbiddenSkills,
|
||||
i === termCount - 1 // isLastTerm
|
||||
)
|
||||
|
||||
if (validTerm === null) return null // Couldn't find valid term
|
||||
|
||||
terms.push(validTerm)
|
||||
currentValue += validTerm
|
||||
}
|
||||
|
||||
return terms
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a valid next term in the sequence
|
||||
*/
|
||||
function findValidNextTerm(
|
||||
currentValue: number,
|
||||
constraints: ProblemConstraints,
|
||||
requiredSkills: SkillSet,
|
||||
targetSkills?: Partial<SkillSet>,
|
||||
forbiddenSkills?: Partial<SkillSet>,
|
||||
isLastTerm: boolean = false
|
||||
): number | null {
|
||||
const { min, max } = constraints.numberRange
|
||||
const candidates: number[] = []
|
||||
|
||||
// Try each possible term value
|
||||
for (let term = min; term <= max; term++) {
|
||||
const newValue = currentValue + term
|
||||
|
||||
// Check if this addition step is valid
|
||||
const stepSkills = analyzeStepSkills(currentValue, term, newValue)
|
||||
|
||||
// Check if the step uses only allowed skills
|
||||
const usesValidSkills = stepSkills.every(skillPath => {
|
||||
const [category, skill] = skillPath.split('.')
|
||||
|
||||
// Must use only required skills
|
||||
let hasSkill = false
|
||||
if (category === 'basic') {
|
||||
hasSkill = requiredSkills.basic[skill as keyof typeof requiredSkills.basic]
|
||||
} else if (category === 'fiveComplements') {
|
||||
hasSkill = requiredSkills.fiveComplements[skill as keyof typeof requiredSkills.fiveComplements]
|
||||
} else if (category === 'tenComplements') {
|
||||
hasSkill = requiredSkills.tenComplements[skill as keyof typeof requiredSkills.tenComplements]
|
||||
}
|
||||
|
||||
if (!hasSkill) return false
|
||||
|
||||
// Must not use forbidden skills
|
||||
if (forbiddenSkills) {
|
||||
let isForbidden = false
|
||||
if (category === 'basic' && forbiddenSkills.basic) {
|
||||
isForbidden = forbiddenSkills.basic[skill as keyof typeof forbiddenSkills.basic] || false
|
||||
} else if (category === 'fiveComplements' && forbiddenSkills.fiveComplements) {
|
||||
isForbidden = forbiddenSkills.fiveComplements[skill as keyof typeof forbiddenSkills.fiveComplements] || false
|
||||
} else if (category === 'tenComplements' && forbiddenSkills.tenComplements) {
|
||||
isForbidden = forbiddenSkills.tenComplements[skill as keyof typeof forbiddenSkills.tenComplements] || false
|
||||
}
|
||||
if (isForbidden) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (usesValidSkills) {
|
||||
candidates.push(term)
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
// If we have target skills and this is not the last term, try to pick a term that uses target skills
|
||||
if (targetSkills && !isLastTerm) {
|
||||
const targetCandidates = candidates.filter(term => {
|
||||
const newValue = currentValue + term
|
||||
const stepSkills = analyzeStepSkills(currentValue, term, newValue)
|
||||
|
||||
return stepSkills.some(skillPath => {
|
||||
const [category, skill] = skillPath.split('.')
|
||||
if (category === 'basic' && targetSkills.basic) {
|
||||
return targetSkills.basic[skill as keyof typeof targetSkills.basic]
|
||||
} else if (category === 'fiveComplements' && targetSkills.fiveComplements) {
|
||||
return targetSkills.fiveComplements[skill as keyof typeof targetSkills.fiveComplements]
|
||||
} else if (category === 'tenComplements' && targetSkills.tenComplements) {
|
||||
return targetSkills.tenComplements[skill as keyof typeof targetSkills.tenComplements]
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
if (targetCandidates.length > 0) {
|
||||
return targetCandidates[Math.floor(Math.random() * targetCandidates.length)]
|
||||
}
|
||||
}
|
||||
|
||||
// Return random valid candidate
|
||||
return candidates[Math.floor(Math.random() * candidates.length)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an explanation for how to solve the sequential addition problem
|
||||
*/
|
||||
function generateSequentialExplanation(terms: number[], sum: number, skills: string[]): string {
|
||||
const explanations: string[] = []
|
||||
|
||||
// Create vertical display format for explanation
|
||||
const verticalDisplay = terms.map(term => ` ${term}`).join('\n') + `\n---\n ${sum}`
|
||||
|
||||
explanations.push(`Calculate this problem by adding each number in sequence:\n${verticalDisplay}`)
|
||||
|
||||
// Skill-specific explanations
|
||||
if (skills.includes('basic.directAddition')) {
|
||||
explanations.push('Use direct addition for numbers 1-4.')
|
||||
}
|
||||
|
||||
if (skills.includes('basic.heavenBead')) {
|
||||
explanations.push('Use the heaven bead when working with 5 or making totals involving 5.')
|
||||
}
|
||||
|
||||
if (skills.includes('basic.simpleCombinations')) {
|
||||
explanations.push('Use combinations of heaven and earth beads for 6-9.')
|
||||
}
|
||||
|
||||
if (skills.some(skill => skill.startsWith('fiveComplements'))) {
|
||||
const complements = skills.filter(skill => skill.startsWith('fiveComplements'))
|
||||
explanations.push(`Apply five complements: ${complements.map(s => s.split('.')[1]).join(', ')}.`)
|
||||
}
|
||||
|
||||
if (skills.some(skill => skill.startsWith('tenComplements'))) {
|
||||
const complements = skills.filter(skill => skill.startsWith('tenComplements'))
|
||||
explanations.push(`Apply ten complements: ${complements.map(s => s.split('.')[1]).join(', ')}.`)
|
||||
}
|
||||
|
||||
return explanations.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique signature for a problem to detect duplicates
|
||||
*/
|
||||
function getProblemSignature(terms: number[]): string {
|
||||
return terms.join('-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a problem is a duplicate of any existing problems
|
||||
*/
|
||||
function isDuplicateProblem(problem: GeneratedProblem, existingProblems: GeneratedProblem[]): boolean {
|
||||
const signature = getProblemSignature(problem.terms)
|
||||
return existingProblems.some(existing => getProblemSignature(existing.terms) === signature)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates multiple unique problems for a practice step
|
||||
*/
|
||||
export function generateProblems(practiceStep: PracticeStep): GeneratedProblem[] {
|
||||
const constraints: ProblemConstraints = {
|
||||
numberRange: practiceStep.numberRange || { min: 1, max: 9 },
|
||||
maxSum: practiceStep.sumConstraints?.maxSum,
|
||||
minSum: practiceStep.sumConstraints?.minSum,
|
||||
maxTerms: practiceStep.maxTerms,
|
||||
problemCount: practiceStep.problemCount
|
||||
}
|
||||
|
||||
const problems: GeneratedProblem[] = []
|
||||
const problemSignatures = new Set<string>()
|
||||
const maxAttempts = practiceStep.problemCount * 50 // Increased attempts for better uniqueness
|
||||
let attempts = 0
|
||||
let consecutiveFailures = 0
|
||||
|
||||
while (problems.length < practiceStep.problemCount && attempts < maxAttempts) {
|
||||
attempts++
|
||||
|
||||
const problem = generateSingleProblem(
|
||||
constraints,
|
||||
practiceStep.requiredSkills,
|
||||
practiceStep.targetSkills,
|
||||
practiceStep.forbiddenSkills,
|
||||
150 // More attempts per problem for uniqueness
|
||||
)
|
||||
|
||||
if (problem) {
|
||||
const signature = getProblemSignature(problem.terms)
|
||||
|
||||
// Check for duplicates using both the signature set and existing problems
|
||||
if (!problemSignatures.has(signature) && !isDuplicateProblem(problem, problems)) {
|
||||
problems.push(problem)
|
||||
problemSignatures.add(signature)
|
||||
consecutiveFailures = 0
|
||||
} else {
|
||||
consecutiveFailures++
|
||||
|
||||
// If we're getting too many duplicates, the constraints might be too restrictive
|
||||
if (consecutiveFailures > practiceStep.problemCount * 5) {
|
||||
console.warn('Too many duplicate problems generated. Constraints may be too restrictive.')
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consecutiveFailures++
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't generate enough unique problems, fill with fallback problems
|
||||
// but ensure even fallbacks are unique
|
||||
let fallbackIndex = 0
|
||||
while (problems.length < practiceStep.problemCount) {
|
||||
let fallbackProblem
|
||||
let fallbackAttempts = 0
|
||||
|
||||
do {
|
||||
fallbackProblem = generateFallbackProblem(constraints, fallbackIndex++)
|
||||
fallbackAttempts++
|
||||
} while (
|
||||
fallbackAttempts < 20 &&
|
||||
isDuplicateProblem(fallbackProblem, problems)
|
||||
)
|
||||
|
||||
// Only add if it's unique or we've exhausted attempts
|
||||
if (!isDuplicateProblem(fallbackProblem, problems)) {
|
||||
problems.push(fallbackProblem)
|
||||
problemSignatures.add(getProblemSignature(fallbackProblem.terms))
|
||||
} else {
|
||||
// Last resort: modify the last term slightly to create uniqueness
|
||||
const modifiedProblem = createModifiedUniqueProblem(fallbackProblem, problems, constraints)
|
||||
if (modifiedProblem) {
|
||||
problems.push(modifiedProblem)
|
||||
problemSignatures.add(getProblemSignature(modifiedProblem.terms))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a simple fallback problem when constraints are too restrictive
|
||||
*/
|
||||
function generateFallbackProblem(constraints: ProblemConstraints, index: number): GeneratedProblem {
|
||||
const { min, max } = constraints.numberRange
|
||||
const termCount = 3 // Generate 3-term problems as fallback
|
||||
|
||||
// Use the seed index to create variation
|
||||
const seed = index * 7 + 3 // Prime numbers for better distribution
|
||||
|
||||
const terms: number[] = []
|
||||
for (let i = 0; i < termCount; i++) {
|
||||
// Create pseudo-random but deterministic terms based on index
|
||||
const term = ((seed + i * 5) % (max - min + 1)) + min
|
||||
terms.push(Math.max(min, Math.min(max, term)))
|
||||
}
|
||||
|
||||
const sum = terms.reduce((acc, term) => acc + term, 0)
|
||||
|
||||
return {
|
||||
id: `fallback_${index}_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`,
|
||||
terms,
|
||||
answer: sum,
|
||||
requiredSkills: ['basic.directAddition'],
|
||||
difficulty: 'easy',
|
||||
explanation: generateSequentialExplanation(terms, sum, ['basic.directAddition'])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a modified version of a problem to ensure uniqueness
|
||||
*/
|
||||
function createModifiedUniqueProblem(
|
||||
baseProblem: GeneratedProblem,
|
||||
existingProblems: GeneratedProblem[],
|
||||
constraints: ProblemConstraints
|
||||
): GeneratedProblem | null {
|
||||
const { min, max } = constraints.numberRange
|
||||
|
||||
// Try modifying the last term to create uniqueness
|
||||
for (let modifier = 1; modifier <= 3; modifier++) {
|
||||
for (const direction of [1, -1]) {
|
||||
const newTerms = [...baseProblem.terms]
|
||||
const lastIndex = newTerms.length - 1
|
||||
const newLastTerm = newTerms[lastIndex] + (modifier * direction)
|
||||
|
||||
// Check if the new term is within constraints
|
||||
if (newLastTerm >= min && newLastTerm <= max) {
|
||||
newTerms[lastIndex] = newLastTerm
|
||||
const newSum = newTerms.reduce((acc, term) => acc + term, 0)
|
||||
|
||||
// Check sum constraints
|
||||
if ((!constraints.maxSum || newSum <= constraints.maxSum) &&
|
||||
(!constraints.minSum || newSum >= constraints.minSum)) {
|
||||
|
||||
const modifiedProblem: GeneratedProblem = {
|
||||
id: `modified_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`,
|
||||
terms: newTerms,
|
||||
answer: newSum,
|
||||
requiredSkills: baseProblem.requiredSkills,
|
||||
difficulty: baseProblem.difficulty,
|
||||
explanation: generateSequentialExplanation(newTerms, newSum, baseProblem.requiredSkills)
|
||||
}
|
||||
|
||||
// Check if this modification creates a unique problem
|
||||
if (!isDuplicateProblem(modifiedProblem, existingProblems)) {
|
||||
return modifiedProblem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null // Could not create a unique modification
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a practice step configuration can generate problems
|
||||
*/
|
||||
export function validatePracticeStepConfiguration(practiceStep: PracticeStep): {
|
||||
isValid: boolean
|
||||
warnings: string[]
|
||||
suggestions: string[]
|
||||
} {
|
||||
const warnings: string[] = []
|
||||
const suggestions: string[] = []
|
||||
|
||||
// Check if any required skills are enabled
|
||||
const hasAnyRequiredSkill = Object.values(practiceStep.requiredSkills.basic).some(Boolean) ||
|
||||
Object.values(practiceStep.requiredSkills.fiveComplements).some(Boolean) ||
|
||||
Object.values(practiceStep.requiredSkills.tenComplements).some(Boolean)
|
||||
|
||||
if (!hasAnyRequiredSkill) {
|
||||
warnings.push('No required skills are enabled. Problems may be very basic.')
|
||||
suggestions.push('Enable at least one skill in the "Required Skills" section.')
|
||||
}
|
||||
|
||||
// Check number range vs sum constraints
|
||||
const maxPossibleSum = practiceStep.numberRange?.max ?
|
||||
practiceStep.numberRange.max * practiceStep.maxTerms :
|
||||
9 * practiceStep.maxTerms
|
||||
|
||||
if (practiceStep.sumConstraints?.maxSum && practiceStep.sumConstraints.maxSum > maxPossibleSum) {
|
||||
warnings.push('Maximum sum constraint is higher than what the number range allows.')
|
||||
suggestions.push('Either increase the number range maximum or decrease the sum constraint.')
|
||||
}
|
||||
|
||||
// Check if constraints are too restrictive
|
||||
if (practiceStep.sumConstraints?.maxSum && practiceStep.sumConstraints.maxSum < 5) {
|
||||
warnings.push('Very low sum constraint may limit problem variety.')
|
||||
suggestions.push('Consider increasing the maximum sum to allow more diverse problems.')
|
||||
}
|
||||
|
||||
// Check problem count
|
||||
if (practiceStep.problemCount > 20) {
|
||||
warnings.push('High problem count may take a long time to generate and complete.')
|
||||
suggestions.push('Consider reducing the problem count for better user experience.')
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: warnings.length === 0,
|
||||
warnings,
|
||||
suggestions
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user