Major improvements: - Add TTSHelper utility for text-to-speech functionality - Enhance content compatibility scoring across all games - Improve sentence extraction from multiple content sources - Update all game modules to support diverse content formats - Refine MarioEducational physics and rendering - Polish UI styles and remove unused CSS Games updated: AdventureReader, FillTheBlank, FlashcardLearning, GrammarDiscovery, MarioEducational, QuizGame, RiverRun, WhackAMole, WhackAMoleHard, WizardSpellCaster, WordDiscovery, WordStorm 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1474 lines
52 KiB
JavaScript
1474 lines
52 KiB
JavaScript
/**
|
|
* VocabularyModule - Groups of 5 vocabulary exercise implementation
|
|
* Implements DRSExerciseInterface for strict contract enforcement
|
|
*/
|
|
|
|
import DRSExerciseInterface from '../interfaces/DRSExerciseInterface.js';
|
|
|
|
class VocabularyModule extends DRSExerciseInterface {
|
|
constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) {
|
|
super('VocabularyModule');
|
|
|
|
// Validate dependencies (llmValidator can be null since we use local validation)
|
|
if (!orchestrator || !prerequisiteEngine || !contextMemory) {
|
|
throw new Error('VocabularyModule requires orchestrator, prerequisiteEngine, and contextMemory');
|
|
}
|
|
|
|
this.orchestrator = orchestrator;
|
|
this.llmValidator = llmValidator;
|
|
this.prerequisiteEngine = prerequisiteEngine;
|
|
this.contextMemory = contextMemory;
|
|
|
|
// Module state
|
|
this.initialized = false;
|
|
this.container = null;
|
|
this.currentExerciseData = null;
|
|
this.currentVocabularyGroup = [];
|
|
this.currentWordIndex = 0;
|
|
this.groupResults = [];
|
|
this.isRevealed = false;
|
|
|
|
// Configuration
|
|
this.config = {
|
|
groupSize: 5,
|
|
masteryThreshold: 80, // 80% correct to consider mastered
|
|
maxAttempts: 3,
|
|
showPronunciation: true,
|
|
randomizeOrder: true
|
|
};
|
|
|
|
// Bind methods
|
|
this._handleNextWord = this._handleNextWord.bind(this);
|
|
this._handleRevealAnswer = this._handleRevealAnswer.bind(this);
|
|
this._handleUserInput = this._handleUserInput.bind(this);
|
|
this._handleDifficultySelection = this._handleDifficultySelection.bind(this);
|
|
}
|
|
|
|
/**
|
|
* Initialize the exercise module
|
|
* @param {Object} config - Exercise configuration
|
|
* @param {Object} content - Exercise content data
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async init(config = {}, content = {}) {
|
|
if (this.initialized) return;
|
|
|
|
console.log('📚 Initializing VocabularyModule...');
|
|
|
|
// Merge provided config with defaults
|
|
this.config = {
|
|
...this.config,
|
|
...config
|
|
};
|
|
|
|
// Store content for later use
|
|
this.currentExerciseData = content;
|
|
this.startTime = Date.now();
|
|
|
|
this.initialized = true;
|
|
console.log('✅ VocabularyModule initialized');
|
|
}
|
|
|
|
/**
|
|
* Render the exercise UI
|
|
* @param {HTMLElement} container - Container element to render into
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async render(container) {
|
|
if (!this.initialized) {
|
|
throw new Error('VocabularyModule must be initialized before rendering');
|
|
}
|
|
|
|
// Use existing present() logic
|
|
await this.present(container, this.currentExerciseData);
|
|
}
|
|
|
|
/**
|
|
* Clean up and destroy the exercise
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async destroy() {
|
|
console.log('🧹 Destroying VocabularyModule...');
|
|
|
|
// Remove event listeners
|
|
if (this.container) {
|
|
const input = this.container.querySelector('.vocabulary-input');
|
|
const submitBtn = this.container.querySelector('.btn-submit');
|
|
const revealBtn = this.container.querySelector('.btn-reveal');
|
|
const nextBtn = this.container.querySelector('.btn-next');
|
|
const difficultyButtons = this.container.querySelectorAll('.difficulty-btn');
|
|
|
|
if (input) input.removeEventListener('input', this._handleUserInput);
|
|
if (submitBtn) submitBtn.removeEventListener('click', this._handleUserInput);
|
|
if (revealBtn) revealBtn.removeEventListener('click', this._handleRevealAnswer);
|
|
if (nextBtn) nextBtn.removeEventListener('click', this._handleNextWord);
|
|
difficultyButtons.forEach(btn => {
|
|
btn.removeEventListener('click', this._handleDifficultySelection);
|
|
});
|
|
|
|
// Clear container
|
|
this.container.innerHTML = '';
|
|
this.container = null;
|
|
}
|
|
|
|
// Reset state
|
|
this.currentVocabularyGroup = [];
|
|
this.currentWordIndex = 0;
|
|
this.groupResults = [];
|
|
this.isRevealed = false;
|
|
this.currentExerciseData = null;
|
|
this.initialized = false;
|
|
|
|
console.log('✅ VocabularyModule destroyed');
|
|
}
|
|
|
|
/**
|
|
* Check if module can run with current prerequisites
|
|
* @param {Array} prerequisites - List of learned vocabulary/concepts
|
|
* @param {Object} chapterContent - Full chapter content
|
|
* @returns {boolean} - True if module can run
|
|
*/
|
|
canRun(prerequisites, chapterContent) {
|
|
// Vocabulary module can always run if there's vocabulary in the chapter
|
|
const hasVocabulary = chapterContent && chapterContent.vocabulary &&
|
|
Object.keys(chapterContent.vocabulary).length > 0;
|
|
|
|
return hasVocabulary;
|
|
}
|
|
|
|
/**
|
|
* Present exercise UI and content
|
|
* @param {HTMLElement} container - DOM container to render into
|
|
* @param {Object} exerciseData - Specific exercise data to present
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async present(container, exerciseData) {
|
|
if (!this.initialized) {
|
|
throw new Error('VocabularyModule must be initialized before use');
|
|
}
|
|
|
|
this.container = container;
|
|
this.currentExerciseData = exerciseData;
|
|
|
|
// Extract and process all vocabulary
|
|
const allVocabulary = exerciseData.vocabulary || [];
|
|
|
|
// Pre-process all vocabulary items to extract clean translations
|
|
allVocabulary.forEach(word => {
|
|
let translation = word.translation;
|
|
if (typeof translation === 'object' && translation !== null) {
|
|
// Try different field names that might contain the translation
|
|
translation = translation.user_language ||
|
|
translation.target_language ||
|
|
translation.translation ||
|
|
translation.meaning ||
|
|
translation.fr ||
|
|
translation.definition ||
|
|
Object.values(translation).find(val => typeof val === 'string' && val !== word.word) ||
|
|
JSON.stringify(translation);
|
|
}
|
|
word.cleanTranslation = translation;
|
|
});
|
|
|
|
// Filter out already mastered words using PrerequisiteEngine
|
|
const unmastedVocabulary = allVocabulary.filter(word => {
|
|
if (!this.prerequisiteEngine || !this.prerequisiteEngine.isInitialized) {
|
|
return true; // Include all words if PrerequisiteEngine not available
|
|
}
|
|
const isMastered = this.prerequisiteEngine.isMastered(word.word);
|
|
if (isMastered) {
|
|
console.log(`🎯 Skipping already mastered word: ${word.word}`);
|
|
}
|
|
return !isMastered; // Only include non-mastered words
|
|
});
|
|
|
|
console.log(`📚 Filtered vocabulary: ${allVocabulary.length} total → ${unmastedVocabulary.length} unmastered words`);
|
|
|
|
// Check if all words are already mastered
|
|
if (unmastedVocabulary.length === 0) {
|
|
this.container.innerHTML = `
|
|
<div class="vocabulary-exercise-complete">
|
|
<div class="completion-icon">🎉</div>
|
|
<h2>Vocabulary Mastered!</h2>
|
|
<p>All vocabulary words in this chapter have been mastered.</p>
|
|
<div class="completion-stats">
|
|
<div class="stat">
|
|
<span class="stat-number">${allVocabulary.length}</span>
|
|
<span class="stat-label">Words Mastered</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary" onclick="window.drsDebug?.instance?.completeExercise?.()">
|
|
<span class="btn-icon">✅</span>
|
|
<span class="btn-text">Continue</span>
|
|
</button>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// DYNAMIC MODE: Pick 5 random words from discovered words (no pre-calculation)
|
|
const discoveredWords = unmastedVocabulary.filter(word => {
|
|
return this.prerequisiteEngine.isDiscovered(word.word);
|
|
});
|
|
|
|
console.log(`📚 Found ${discoveredWords.length} discovered words (out of ${unmastedVocabulary.length} unmastered)`);
|
|
|
|
// Take up to 5 random discovered words
|
|
const selectedWords = this._selectRandomWords(discoveredWords, this.config.groupSize);
|
|
|
|
this.currentVocabularyGroup = selectedWords;
|
|
this.currentWordIndex = 0;
|
|
this.groupResults = [];
|
|
this.isRevealed = false;
|
|
|
|
console.log(`📚 Selected ${selectedWords.length} random words for this session:`, selectedWords.map(w => w.word));
|
|
|
|
if (this.config.randomizeOrder) {
|
|
this._shuffleArray(this.currentVocabularyGroup);
|
|
}
|
|
|
|
console.log(`📚 Presenting vocabulary group (${this.currentVocabularyGroup.length} words)`);
|
|
|
|
// Render initial UI
|
|
await this._renderVocabularyExercise();
|
|
|
|
// Start with first word
|
|
this._presentCurrentWord();
|
|
}
|
|
|
|
/**
|
|
* Validate user input with simple string matching (NO AI)
|
|
* @param {string} userInput - User's response
|
|
* @param {Object} context - Exercise context and expected answer
|
|
* @returns {Promise<ValidationResult>} - Validation result with score and feedback
|
|
*/
|
|
async validate(userInput, context) {
|
|
if (!userInput || !userInput.trim()) {
|
|
return {
|
|
score: 0,
|
|
correct: false,
|
|
feedback: "Please provide an answer.",
|
|
timestamp: new Date().toISOString(),
|
|
provider: 'local'
|
|
};
|
|
}
|
|
|
|
const currentWord = this.currentVocabularyGroup[this.currentWordIndex];
|
|
const expectedTranslation = currentWord.cleanTranslation || currentWord.translation;
|
|
const userAnswer = userInput.trim();
|
|
|
|
// Simple string matching validation (NO AI)
|
|
const isCorrect = this._checkTranslation(userAnswer, expectedTranslation);
|
|
|
|
const result = {
|
|
score: isCorrect ? 100 : 0,
|
|
correct: isCorrect,
|
|
feedback: isCorrect
|
|
? "Correct! Well done."
|
|
: `Incorrect. The correct answer is: ${expectedTranslation}`,
|
|
timestamp: new Date().toISOString(),
|
|
provider: 'local',
|
|
expectedAnswer: expectedTranslation,
|
|
userAnswer: userAnswer
|
|
};
|
|
|
|
// Record interaction in context memory
|
|
this.contextMemory.recordInteraction({
|
|
type: 'vocabulary',
|
|
subtype: 'translation',
|
|
content: {
|
|
vocabulary: [currentWord],
|
|
word: currentWord.word,
|
|
expectedTranslation
|
|
},
|
|
userResponse: userAnswer,
|
|
validation: result,
|
|
context: { validationType: 'simple_string_match' }
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get current progress data
|
|
* @returns {ProgressData} - Progress information for this module
|
|
*/
|
|
getProgress() {
|
|
const totalWords = this.currentVocabularyGroup.length;
|
|
const completedWords = this.groupResults.length;
|
|
const correctWords = this.groupResults.filter(result => result.correct).length;
|
|
|
|
return {
|
|
type: 'vocabulary',
|
|
totalWords,
|
|
completedWords,
|
|
correctWords,
|
|
currentWordIndex: this.currentWordIndex,
|
|
groupResults: this.groupResults,
|
|
progressPercentage: totalWords > 0 ? Math.round((completedWords / totalWords) * 100) : 0,
|
|
accuracyPercentage: completedWords > 0 ? Math.round((correctWords / completedWords) * 100) : 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clean up and prepare for unloading
|
|
*/
|
|
cleanup() {
|
|
console.log('🧹 Cleaning up VocabularyModule...');
|
|
|
|
// Remove event listeners
|
|
if (this.container) {
|
|
this.container.innerHTML = '';
|
|
}
|
|
|
|
// Reset state
|
|
this.container = null;
|
|
this.currentExerciseData = null;
|
|
this.currentVocabularyGroup = [];
|
|
this.currentWordIndex = 0;
|
|
this.groupResults = [];
|
|
this.isRevealed = false;
|
|
|
|
console.log('✅ VocabularyModule cleaned up');
|
|
}
|
|
|
|
/**
|
|
* Get module metadata
|
|
* @returns {Object} - Module information
|
|
*/
|
|
getMetadata() {
|
|
return {
|
|
name: 'VocabularyModule',
|
|
type: 'vocabulary',
|
|
version: '1.0.0',
|
|
description: 'Groups of 5 vocabulary exercises with LLM validation',
|
|
capabilities: ['translation', 'pronunciation', 'spaced_repetition'],
|
|
config: this.config
|
|
};
|
|
}
|
|
|
|
// Private Methods
|
|
|
|
/**
|
|
* Check translation with simple string matching and fuzzy logic
|
|
* @param {string} userAnswer - User's answer
|
|
* @param {string} expectedTranslation - Expected correct answer
|
|
* @returns {boolean} - True if answer is acceptable
|
|
* @private
|
|
*/
|
|
_checkTranslation(userAnswer, expectedTranslation) {
|
|
if (!userAnswer || !expectedTranslation) return false;
|
|
|
|
// Normalize both strings
|
|
const normalizeString = (str) => {
|
|
return str.toLowerCase()
|
|
.trim()
|
|
.replace(/[.,!?;:"'()]/g, '') // Remove punctuation
|
|
.replace(/\s+/g, ' '); // Normalize whitespace
|
|
};
|
|
|
|
const normalizedUser = normalizeString(userAnswer);
|
|
const normalizedExpected = normalizeString(expectedTranslation);
|
|
|
|
// Exact match after normalization
|
|
if (normalizedUser === normalizedExpected) {
|
|
return true;
|
|
}
|
|
|
|
// Split expected answer into alternatives (e.g., "shirt, t-shirt" or "shirt / t-shirt")
|
|
const alternatives = normalizedExpected.split(/[,/|;]/).map(alt => alt.trim());
|
|
|
|
// Check if user answer matches any alternative
|
|
for (const alternative of alternatives) {
|
|
if (normalizedUser === alternative) {
|
|
return true;
|
|
}
|
|
|
|
// Allow partial matches for single words if they're very close
|
|
if (alternative.split(' ').length === 1 && normalizedUser.split(' ').length === 1) {
|
|
const similarity = this._calculateSimilarity(normalizedUser, alternative);
|
|
if (similarity > 0.85) { // 85% similarity threshold
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Calculate string similarity using simple character comparison
|
|
* @param {string} str1 - First string
|
|
* @param {string} str2 - Second string
|
|
* @returns {number} - Similarity score between 0 and 1
|
|
* @private
|
|
*/
|
|
_calculateSimilarity(str1, str2) {
|
|
if (str1 === str2) return 1.0;
|
|
if (str1.length === 0 || str2.length === 0) return 0.0;
|
|
|
|
// Simple character-based similarity
|
|
const longer = str1.length > str2.length ? str1 : str2;
|
|
const shorter = str1.length > str2.length ? str2 : str1;
|
|
|
|
if (longer.length === 0) return 1.0;
|
|
|
|
const editDistance = this._levenshteinDistance(str1, str2);
|
|
return (longer.length - editDistance) / longer.length;
|
|
}
|
|
|
|
/**
|
|
* Calculate Levenshtein distance between two strings
|
|
* @param {string} str1 - First string
|
|
* @param {string} str2 - Second string
|
|
* @returns {number} - Edit distance
|
|
* @private
|
|
*/
|
|
_levenshteinDistance(str1, str2) {
|
|
const matrix = [];
|
|
|
|
for (let i = 0; i <= str2.length; i++) {
|
|
matrix[i] = [i];
|
|
}
|
|
|
|
for (let j = 0; j <= str1.length; j++) {
|
|
matrix[0][j] = j;
|
|
}
|
|
|
|
for (let i = 1; i <= str2.length; i++) {
|
|
for (let j = 1; j <= str1.length; j++) {
|
|
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
|
matrix[i][j] = matrix[i - 1][j - 1];
|
|
} else {
|
|
matrix[i][j] = Math.min(
|
|
matrix[i - 1][j - 1] + 1,
|
|
matrix[i][j - 1] + 1,
|
|
matrix[i - 1][j] + 1
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return matrix[str2.length][str1.length];
|
|
}
|
|
|
|
async _renderVocabularyExercise() {
|
|
if (!this.container) return;
|
|
|
|
const totalWords = this.currentVocabularyGroup.length;
|
|
const progressPercentage = totalWords > 0 ?
|
|
Math.round((this.currentWordIndex / totalWords) * 100) : 0;
|
|
|
|
this.container.innerHTML = `
|
|
<div class="vocabulary-exercise">
|
|
<div class="exercise-header">
|
|
<h2>📚 Vocabulary Practice</h2>
|
|
<div class="progress-info">
|
|
<span class="progress-text" id="progress-text">
|
|
Word ${this.currentWordIndex + 1} of ${totalWords}
|
|
</span>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" id="progress-fill" style="width: ${progressPercentage}%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="vocabulary-card" id="vocabulary-card">
|
|
<!-- Card content will be populated by _presentCurrentWord -->
|
|
</div>
|
|
|
|
<div class="exercise-controls" id="exercise-controls">
|
|
<!-- Controls will be populated dynamically -->
|
|
</div>
|
|
|
|
<div class="group-results" id="group-results" style="display: none;">
|
|
<!-- Results will be shown when group is complete -->
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add CSS styles
|
|
this._addStyles();
|
|
}
|
|
|
|
_updateProgressDisplay() {
|
|
const progressText = document.getElementById('progress-text');
|
|
const progressFill = document.getElementById('progress-fill');
|
|
|
|
if (progressText && progressFill) {
|
|
const totalWords = this.currentVocabularyGroup.length;
|
|
const progressPercentage = totalWords > 0 ?
|
|
Math.round((this.currentWordIndex / totalWords) * 100) : 0;
|
|
|
|
// Update text (no group numbers in dynamic mode)
|
|
progressText.textContent = `Word ${this.currentWordIndex + 1} of ${totalWords}`;
|
|
|
|
// Update progress bar
|
|
progressFill.style.width = `${progressPercentage}%`;
|
|
}
|
|
}
|
|
|
|
_presentCurrentWord() {
|
|
if (this.currentWordIndex >= this.currentVocabularyGroup.length) {
|
|
this._showGroupResults();
|
|
return;
|
|
}
|
|
|
|
// Update progress display
|
|
this._updateProgressDisplay();
|
|
|
|
const currentWord = this.currentVocabularyGroup[this.currentWordIndex];
|
|
const card = document.getElementById('vocabulary-card');
|
|
const controls = document.getElementById('exercise-controls');
|
|
|
|
if (!card || !controls) return;
|
|
|
|
this.isRevealed = false;
|
|
|
|
card.innerHTML = `
|
|
<div class="word-card">
|
|
<div class="word-display">
|
|
<h3 class="target-word clickable" id="target-word-tts" title="Click to hear pronunciation">
|
|
${currentWord.word}
|
|
</h3>
|
|
${this.config.showPronunciation && currentWord.pronunciation ?
|
|
`<div class="pronunciation" id="pronunciation-display">[${currentWord.pronunciation}]</div>` : ''}
|
|
<div class="word-type">${currentWord.type || 'word'}</div>
|
|
</div>
|
|
|
|
<div class="answer-section" id="answer-section">
|
|
<div class="translation-input">
|
|
<label for="translation-input">Translation:</label>
|
|
<input type="text"
|
|
id="translation-input"
|
|
placeholder="Enter the translation..."
|
|
autocomplete="off">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="revealed-answer" id="revealed-answer" style="display: none;">
|
|
<div class="correct-translation clickable" id="answer-tts" title="Click to hear pronunciation">
|
|
<strong>Correct Answer:</strong> ${currentWord.cleanTranslation}
|
|
</div>
|
|
${this.config.showPronunciation && currentWord.pronunciation ?
|
|
`<div class="pronunciation-text" id="pronunciation-reveal">[${currentWord.pronunciation}]</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
controls.innerHTML = `
|
|
<div class="control-buttons">
|
|
<button id="tts-btn" class="btn btn-secondary">🔊 Listen</button>
|
|
<button id="reveal-btn" class="btn btn-secondary">Reveal Answer</button>
|
|
<button id="submit-btn" class="btn btn-primary">Submit</button>
|
|
</div>
|
|
`;
|
|
|
|
// Add event listeners
|
|
document.getElementById('tts-btn').onclick = () => this._handleTTS();
|
|
document.getElementById('reveal-btn').onclick = this._handleRevealAnswer;
|
|
document.getElementById('submit-btn').onclick = this._handleUserInput;
|
|
|
|
// Add click listener on the word itself for TTS
|
|
const targetWord = document.getElementById('target-word-tts');
|
|
if (targetWord) {
|
|
targetWord.onclick = () => {
|
|
this._handleTTS();
|
|
this._highlightPronunciation();
|
|
};
|
|
}
|
|
|
|
// Allow Enter key to submit
|
|
const input = document.getElementById('translation-input');
|
|
input.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
this._handleUserInput();
|
|
}
|
|
});
|
|
|
|
// Focus on input
|
|
input.focus();
|
|
}
|
|
|
|
async _handleUserInput() {
|
|
const input = document.getElementById('translation-input');
|
|
const userInput = input ? input.value.trim() : '';
|
|
|
|
if (!userInput) {
|
|
this._showFeedback('Please enter a translation.', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Disable input during validation
|
|
this._setInputEnabled(false);
|
|
this._showFeedback('Checking answer...', 'info');
|
|
|
|
try {
|
|
const validationResult = await this.validate(userInput, {});
|
|
|
|
// Store result
|
|
this.groupResults[this.currentWordIndex] = {
|
|
word: this.currentVocabularyGroup[this.currentWordIndex].word,
|
|
userAnswer: userInput,
|
|
correct: validationResult.correct,
|
|
score: validationResult.score,
|
|
feedback: validationResult.feedback,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
// Show result and difficulty selection
|
|
this._showValidationResult(validationResult);
|
|
|
|
} catch (error) {
|
|
console.error('Validation error:', error);
|
|
this._showFeedback('Error validating answer. Please try again.', 'error');
|
|
this._setInputEnabled(true);
|
|
}
|
|
}
|
|
|
|
_handleRevealAnswer() {
|
|
const revealedSection = document.getElementById('revealed-answer');
|
|
const answerSection = document.getElementById('answer-section');
|
|
|
|
if (revealedSection && answerSection) {
|
|
revealedSection.style.display = 'block';
|
|
answerSection.style.display = 'none';
|
|
this.isRevealed = true;
|
|
|
|
// Add click listener on revealed answer for TTS
|
|
const answerTTS = document.getElementById('answer-tts');
|
|
if (answerTTS) {
|
|
answerTTS.onclick = () => {
|
|
this._handleTTS();
|
|
this._highlightPronunciation();
|
|
};
|
|
}
|
|
|
|
// Auto-play TTS when answer is revealed
|
|
setTimeout(() => {
|
|
this._handleTTS();
|
|
this._highlightPronunciation();
|
|
}, 100); // Quick delay to let the answer appear
|
|
|
|
// Don't mark as incorrect yet - wait for user self-assessment
|
|
// The difficulty selection will determine the actual result
|
|
|
|
this._showDifficultySelection();
|
|
}
|
|
}
|
|
|
|
_showValidationResult(validationResult) {
|
|
const feedbackClass = validationResult.correct ? 'success' : 'error';
|
|
this._showFeedback(validationResult.feedback, feedbackClass);
|
|
|
|
// Show correct answer if incorrect
|
|
if (!validationResult.correct) {
|
|
const revealedSection = document.getElementById('revealed-answer');
|
|
if (revealedSection) {
|
|
revealedSection.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Show difficulty selection
|
|
setTimeout(() => {
|
|
this._showDifficultySelection();
|
|
}, 2000);
|
|
}
|
|
|
|
_showDifficultySelection() {
|
|
const controls = document.getElementById('exercise-controls');
|
|
if (!controls) return;
|
|
|
|
controls.innerHTML = `
|
|
<div class="difficulty-selection">
|
|
<div class="difficulty-buttons">
|
|
<button class="difficulty-btn btn-error" data-difficulty="again">
|
|
Again (< 1 min)
|
|
</button>
|
|
<button class="difficulty-btn btn-warning" data-difficulty="hard">
|
|
Hard (< 6 min)
|
|
</button>
|
|
<button class="difficulty-btn btn-primary" data-difficulty="good">
|
|
Good (< 10 min)
|
|
</button>
|
|
<button class="difficulty-btn btn-success" data-difficulty="easy">
|
|
Easy (< 4 days)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add event listeners for difficulty buttons
|
|
document.querySelectorAll('.difficulty-btn').forEach(btn => {
|
|
btn.onclick = async (e) => await this._handleDifficultySelection(e.target.dataset.difficulty);
|
|
});
|
|
}
|
|
|
|
async _handleDifficultySelection(difficulty) {
|
|
const currentWord = this.currentVocabularyGroup[this.currentWordIndex];
|
|
|
|
// Create or update result based on user self-assessment
|
|
const userAnswer = this.isRevealed ? '[revealed]' :
|
|
(this.groupResults[this.currentWordIndex]?.userAnswer || '[self-assessed]');
|
|
|
|
// Convert difficulty to success/score based on spaced repetition logic
|
|
const difficultyMapping = {
|
|
'again': { correct: false, score: 0 }, // Failed - need to see again soon
|
|
'hard': { correct: true, score: 60 }, // Passed but difficult
|
|
'good': { correct: true, score: 80 }, // Good understanding
|
|
'easy': { correct: true, score: 100 } // Perfect understanding
|
|
};
|
|
|
|
const assessment = difficultyMapping[difficulty] || { correct: false, score: 0 };
|
|
|
|
// Create/update the result entry
|
|
this.groupResults[this.currentWordIndex] = {
|
|
word: currentWord.word,
|
|
userAnswer: userAnswer,
|
|
correct: assessment.correct,
|
|
score: assessment.score,
|
|
difficulty: difficulty,
|
|
feedback: `Self-assessed as: ${difficulty}`,
|
|
timestamp: new Date().toISOString(),
|
|
wasRevealed: this.isRevealed
|
|
};
|
|
|
|
// ALWAYS mark word as discovered (seen/introduced) - Enhanced with persistence
|
|
const discoveryMetadata = {
|
|
difficulty: difficulty,
|
|
sessionId: this.orchestrator?.sessionId || 'unknown',
|
|
moduleType: 'vocabulary',
|
|
timestamp: new Date().toISOString(),
|
|
wasRevealed: this.isRevealed,
|
|
responseTime: Date.now() - (this.wordStartTime || Date.now())
|
|
};
|
|
|
|
try {
|
|
await this.prerequisiteEngine.markWordDiscovered(currentWord.word, discoveryMetadata);
|
|
console.log('📚 Enhanced persistence: Word discovered:', currentWord.word);
|
|
} catch (error) {
|
|
console.error('❌ Error marking word discovered:', error);
|
|
}
|
|
|
|
// Mark word as mastered ONLY if good or easy - Enhanced with persistence
|
|
if (['good', 'easy'].includes(difficulty)) {
|
|
const masteryMetadata = {
|
|
difficulty: difficulty,
|
|
sessionId: this.orchestrator?.sessionId || 'unknown',
|
|
moduleType: 'vocabulary',
|
|
attempts: 1, // Single attempt with self-assessment
|
|
correct: assessment.correct,
|
|
scores: [assessment.score],
|
|
masteryLevel: difficulty === 'easy' ? 2 : 1
|
|
};
|
|
|
|
try {
|
|
await this.prerequisiteEngine.markWordMastered(currentWord.word, masteryMetadata);
|
|
console.log('🏆 Enhanced persistence: Word mastered:', currentWord.word);
|
|
} catch (error) {
|
|
console.error('❌ Error marking word mastered:', error);
|
|
}
|
|
|
|
// Legacy persistent storage removed - now using enhanced PrerequisiteEngine persistence
|
|
}
|
|
|
|
console.log(`Word "${currentWord.word}" marked as ${difficulty}`);
|
|
|
|
// Move to next word
|
|
this.currentWordIndex++;
|
|
this._presentCurrentWord();
|
|
}
|
|
|
|
_handleNextWord() {
|
|
this.currentWordIndex++;
|
|
this._presentCurrentWord();
|
|
}
|
|
|
|
_handleTTS() {
|
|
const currentWord = this.currentVocabularyGroup[this.currentWordIndex];
|
|
if (currentWord && currentWord.word) {
|
|
this._speakWord(currentWord.word);
|
|
}
|
|
}
|
|
|
|
async _speakWord(text, options = {}) {
|
|
// Check if browser supports Speech Synthesis
|
|
if ('speechSynthesis' in window) {
|
|
try {
|
|
// Cancel any ongoing speech
|
|
window.speechSynthesis.cancel();
|
|
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
|
|
// Get language from chapter data, fallback to options or en-US
|
|
const chapterLanguage = this.currentExerciseData?.language || 'en-US';
|
|
utterance.lang = options.lang || chapterLanguage;
|
|
utterance.rate = options.rate || 0.8;
|
|
utterance.pitch = options.pitch || 1;
|
|
utterance.volume = options.volume || 1;
|
|
|
|
// Wait for voices to be loaded before selecting one
|
|
const voices = await this._getVoices();
|
|
if (voices.length > 0) {
|
|
// Find voice matching the chapter language
|
|
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
|
const matchingVoice = voices.find(voice =>
|
|
voice.lang.startsWith(langPrefix) && voice.default
|
|
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
|
|
|
if (matchingVoice) {
|
|
utterance.voice = matchingVoice;
|
|
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
|
} else {
|
|
console.warn(`🔊 No voice found for language: ${chapterLanguage}, available:`, voices.map(v => v.lang));
|
|
}
|
|
}
|
|
|
|
// Add event handlers
|
|
utterance.onstart = () => {
|
|
console.log('🔊 TTS started for:', text);
|
|
this._updateTTSButton(true);
|
|
};
|
|
|
|
utterance.onend = () => {
|
|
console.log('🔊 TTS finished for:', text);
|
|
this._updateTTSButton(false);
|
|
};
|
|
|
|
utterance.onerror = (event) => {
|
|
console.warn('🔊 TTS error:', event.error);
|
|
this._updateTTSButton(false);
|
|
};
|
|
|
|
// Speak the text
|
|
window.speechSynthesis.speak(utterance);
|
|
|
|
} catch (error) {
|
|
console.warn('🔊 TTS failed:', error);
|
|
this._fallbackTTS(text);
|
|
}
|
|
} else {
|
|
console.warn('🔊 Speech Synthesis not supported in this browser');
|
|
this._fallbackTTS(text);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
|
* @private
|
|
*/
|
|
_getVoices() {
|
|
return new Promise((resolve) => {
|
|
let voices = window.speechSynthesis.getVoices();
|
|
|
|
// If voices are already loaded, return them immediately
|
|
if (voices.length > 0) {
|
|
resolve(voices);
|
|
return;
|
|
}
|
|
|
|
// Otherwise, wait for voiceschanged event
|
|
const voicesChangedHandler = () => {
|
|
voices = window.speechSynthesis.getVoices();
|
|
if (voices.length > 0) {
|
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
|
resolve(voices);
|
|
}
|
|
};
|
|
|
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
|
|
|
// Fallback timeout in case voices never load
|
|
setTimeout(() => {
|
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
|
resolve(window.speechSynthesis.getVoices());
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
_updateTTSButton(isPlaying) {
|
|
// Update main TTS button
|
|
const ttsBtn = document.getElementById('tts-btn');
|
|
if (ttsBtn) {
|
|
if (isPlaying) {
|
|
ttsBtn.innerHTML = '🔄 Speaking...';
|
|
ttsBtn.disabled = true;
|
|
} else {
|
|
ttsBtn.innerHTML = '🔊 Listen';
|
|
ttsBtn.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
_fallbackTTS(text) {
|
|
// Fallback when TTS fails - show pronunciation if available
|
|
const currentWord = this.currentVocabularyGroup[this.currentWordIndex];
|
|
if (currentWord && currentWord.pronunciation) {
|
|
alert(`Pronunciation: /${currentWord.pronunciation}/`);
|
|
} else {
|
|
alert(`Word: ${text}\n(Text-to-speech not available)`);
|
|
}
|
|
}
|
|
|
|
_highlightPronunciation() {
|
|
// Highlight pronunciation when TTS is played
|
|
const pronunciation = document.getElementById('pronunciation-display') ||
|
|
document.getElementById('pronunciation-reveal');
|
|
|
|
if (pronunciation) {
|
|
// Add highlight class
|
|
pronunciation.classList.add('pronunciation-highlight');
|
|
|
|
// Remove highlight after animation
|
|
setTimeout(() => {
|
|
pronunciation.classList.remove('pronunciation-highlight');
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
_showGroupResults() {
|
|
const resultsContainer = document.getElementById('group-results');
|
|
const card = document.getElementById('vocabulary-card');
|
|
const controls = document.getElementById('exercise-controls');
|
|
|
|
if (!resultsContainer) return;
|
|
|
|
const correctCount = this.groupResults.filter(result => result.correct).length;
|
|
const totalCount = this.groupResults.length;
|
|
const accuracy = totalCount > 0 ? Math.round((correctCount / totalCount) * 100) : 0;
|
|
|
|
let resultClass = 'results-poor';
|
|
if (accuracy >= 80) resultClass = 'results-excellent';
|
|
else if (accuracy >= 60) resultClass = 'results-good';
|
|
|
|
// DYNAMIC MODE: Single "Continue" button - orchestrator decides next exercise
|
|
const resultsHTML = `
|
|
<div class="group-results-content ${resultClass}">
|
|
<h3>📊 Session Results</h3>
|
|
<div class="results-summary">
|
|
<div class="accuracy-display">
|
|
<span class="accuracy-number">${accuracy}%</span>
|
|
<span class="accuracy-label">Accuracy</span>
|
|
</div>
|
|
<div class="count-display">
|
|
${correctCount} / ${totalCount} correct
|
|
</div>
|
|
</div>
|
|
|
|
<div class="results-actions">
|
|
<button id="continue-btn" class="btn btn-primary">Continue →</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
resultsContainer.innerHTML = resultsHTML;
|
|
resultsContainer.style.display = 'block';
|
|
|
|
// Hide other sections
|
|
if (card) card.style.display = 'none';
|
|
if (controls) controls.style.display = 'none';
|
|
|
|
// Add button listener
|
|
const continueBtn = document.getElementById('continue-btn');
|
|
|
|
if (continueBtn) {
|
|
continueBtn.onclick = () => {
|
|
// Complete exercise and let orchestrator decide next step
|
|
if (this.orchestrator && this.orchestrator.completeExercise) {
|
|
this.orchestrator.completeExercise({
|
|
moduleType: 'vocabulary',
|
|
results: this.groupResults,
|
|
progress: this.getProgress()
|
|
});
|
|
} else {
|
|
console.log('✅ Vocabulary session completed, orchestrator will decide next exercise');
|
|
// Fallback: use drsDebug if available
|
|
if (window.drsDebug?.instance?.completeExercise) {
|
|
window.drsDebug.instance.completeExercise();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
_setInputEnabled(enabled) {
|
|
const input = document.getElementById('translation-input');
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
const revealBtn = document.getElementById('reveal-btn');
|
|
|
|
if (input) input.disabled = !enabled;
|
|
if (submitBtn) submitBtn.disabled = !enabled;
|
|
if (revealBtn) revealBtn.disabled = !enabled;
|
|
}
|
|
|
|
_showFeedback(message, type = 'info') {
|
|
// Create or update feedback element
|
|
let feedback = document.getElementById('feedback-message');
|
|
if (!feedback) {
|
|
feedback = document.createElement('div');
|
|
feedback.id = 'feedback-message';
|
|
feedback.className = 'feedback-message';
|
|
|
|
const card = document.getElementById('vocabulary-card');
|
|
if (card) {
|
|
card.appendChild(feedback);
|
|
}
|
|
}
|
|
|
|
feedback.className = `feedback-message feedback-${type}`;
|
|
feedback.textContent = message;
|
|
feedback.style.display = 'block';
|
|
|
|
// Auto-hide info messages
|
|
if (type === 'info') {
|
|
setTimeout(() => {
|
|
if (feedback) {
|
|
feedback.style.display = 'none';
|
|
}
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select N random words from an array
|
|
* @param {Array} words - Array of word objects
|
|
* @param {number} count - Number of words to select
|
|
* @returns {Array} - Random selection of words
|
|
*/
|
|
_selectRandomWords(words, count) {
|
|
if (words.length <= count) {
|
|
return [...words]; // Return all if not enough words
|
|
}
|
|
|
|
// Fisher-Yates shuffle and take first N
|
|
const shuffled = [...words];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
|
|
return shuffled.slice(0, count);
|
|
}
|
|
|
|
_shuffleArray(array) {
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
}
|
|
|
|
_addStyles() {
|
|
if (document.getElementById('vocabulary-module-styles')) return;
|
|
|
|
const styles = document.createElement('style');
|
|
styles.id = 'vocabulary-module-styles';
|
|
styles.textContent = `
|
|
.vocabulary-exercise {
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.exercise-header {
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.progress-info {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background-color: #e0e0e0;
|
|
border-radius: 4px;
|
|
margin-top: 5px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.vocabulary-card {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.word-display {
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.target-word {
|
|
font-size: 2.5em;
|
|
color: #333;
|
|
margin-bottom: 10px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.target-word.clickable {
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.target-word.clickable:hover {
|
|
color: #667eea;
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.pronunciation {
|
|
font-style: italic;
|
|
color: #666;
|
|
margin-bottom: 5px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.pronunciation-highlight {
|
|
color: #667eea !important;
|
|
font-weight: bold;
|
|
font-size: 1.2em;
|
|
animation: pulse 0.5s ease-in-out;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.1); }
|
|
}
|
|
|
|
.word-type {
|
|
color: #888;
|
|
font-size: 0.9em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.translation-input {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.translation-input label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: bold;
|
|
color: #555;
|
|
}
|
|
|
|
.translation-input input {
|
|
width: 100%;
|
|
padding: 12px;
|
|
font-size: 1.1em;
|
|
border: 2px solid #ddd;
|
|
border-radius: 8px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.translation-input input:focus {
|
|
border-color: #667eea;
|
|
outline: none;
|
|
}
|
|
|
|
.revealed-answer {
|
|
background-color: #f8f9fa;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.correct-translation {
|
|
font-size: 1.2em;
|
|
color: #28a745;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.correct-translation.clickable {
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
padding: 5px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.correct-translation.clickable:hover {
|
|
background-color: #d4edda;
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.pronunciation-text {
|
|
font-style: italic;
|
|
color: #666;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.exercise-controls {
|
|
text-align: center;
|
|
}
|
|
|
|
.control-buttons {
|
|
display: flex;
|
|
gap: 15px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.difficulty-selection {
|
|
text-align: center;
|
|
}
|
|
|
|
.difficulty-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: center;
|
|
flex-wrap: nowrap;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.difficulty-btn {
|
|
padding: 8px 12px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 0.85em;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.group-results-content {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
}
|
|
|
|
.results-summary {
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.accuracy-display {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.accuracy-number {
|
|
font-size: 3em;
|
|
font-weight: bold;
|
|
display: block;
|
|
}
|
|
|
|
.results-excellent .accuracy-number { color: #28a745; }
|
|
.results-good .accuracy-number { color: #ffc107; }
|
|
.results-poor .accuracy-number { color: #dc3545; }
|
|
|
|
.word-results {
|
|
text-align: left;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.word-result {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 10px;
|
|
margin-bottom: 5px;
|
|
border-radius: 8px;
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.word-result.correct { background-color: #d4edda; }
|
|
.word-result.incorrect { background-color: #f8d7da; }
|
|
|
|
.feedback-message {
|
|
padding: 10px;
|
|
border-radius: 8px;
|
|
margin-top: 15px;
|
|
text-align: center;
|
|
}
|
|
|
|
.feedback-info { background-color: #d1ecf1; color: #0c5460; }
|
|
.feedback-success { background-color: #d4edda; color: #155724; }
|
|
.feedback-warning { background-color: #fff3cd; color: #856404; }
|
|
.feedback-error { background-color: #f8d7da; color: #721c24; }
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
color: white;
|
|
border: none;
|
|
padding: 12px 24px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #6c757d;
|
|
color: white;
|
|
border: none;
|
|
padding: 12px 24px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #5a6268;
|
|
}
|
|
|
|
.btn-success { background-color: #28a745; color: white; }
|
|
.btn-warning { background-color: #ffc107; color: #212529; }
|
|
.btn-error { background-color: #dc3545; color: white; }
|
|
|
|
button:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
`;
|
|
|
|
document.head.appendChild(styles);
|
|
}
|
|
|
|
// ========================================
|
|
// DRSExerciseInterface REQUIRED METHODS
|
|
// ========================================
|
|
|
|
/**
|
|
* Get exercise results and statistics
|
|
* @returns {Object} - Results data
|
|
*/
|
|
getResults() {
|
|
const correctWords = this.groupResults.filter(result => result.correct).length;
|
|
const totalWords = this.groupResults.length;
|
|
const score = totalWords > 0 ? Math.round((correctWords / totalWords) * 100) : 0;
|
|
const timeSpent = this.startTime ? Date.now() - this.startTime : 0;
|
|
|
|
return {
|
|
score,
|
|
attempts: totalWords,
|
|
timeSpent,
|
|
completed: this.currentWordIndex >= this.currentVocabularyGroup.length,
|
|
details: {
|
|
correctWords,
|
|
totalWords,
|
|
groupResults: this.groupResults,
|
|
masteryPercentage: score
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle user input during exercise
|
|
* @param {Event} event - User input event
|
|
* @param {*} data - Input data
|
|
* @returns {void}
|
|
*/
|
|
handleUserInput(event, data) {
|
|
// This method delegates to existing handlers
|
|
// Already implemented through _handleUserInput, _handleDifficultySelection, etc.
|
|
if (event && event.type) {
|
|
switch (event.type) {
|
|
case 'input':
|
|
case 'change':
|
|
this._handleUserInput(event);
|
|
break;
|
|
case 'click':
|
|
if (event.target.classList.contains('difficulty-btn')) {
|
|
this._handleDifficultySelection(event);
|
|
} else if (event.target.classList.contains('btn-next')) {
|
|
this._handleNextWord(event);
|
|
} else if (event.target.classList.contains('btn-reveal')) {
|
|
this._handleRevealAnswer(event);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark exercise as completed and save progress
|
|
* @param {Object} results - Exercise results
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async markCompleted(results) {
|
|
console.log('💾 Marking VocabularyModule as completed...');
|
|
|
|
// Mark all words in current group as mastered if score is high enough
|
|
const { score, details } = results || this.getResults();
|
|
|
|
if (score >= this.config.masteryThreshold) {
|
|
// Mark all words in group as mastered
|
|
for (const word of this.currentVocabularyGroup) {
|
|
if (this.prerequisiteEngine && this.prerequisiteEngine.isInitialized) {
|
|
await this.prerequisiteEngine.markWordMastered(word.word, {
|
|
score,
|
|
timestamp: new Date().toISOString(),
|
|
moduleType: 'vocabulary',
|
|
attempts: details.totalWords
|
|
});
|
|
}
|
|
}
|
|
console.log(`✅ Marked ${this.currentVocabularyGroup.length} words as mastered`);
|
|
} else {
|
|
console.log(`⚠️ Score ${score}% below mastery threshold ${this.config.masteryThreshold}%`);
|
|
}
|
|
|
|
// Save completion metadata
|
|
if (this.contextMemory) {
|
|
this.contextMemory.recordInteraction({
|
|
type: 'vocabulary',
|
|
subtype: 'completion',
|
|
content: {
|
|
vocabulary: this.currentVocabularyGroup
|
|
},
|
|
validation: results,
|
|
context: {
|
|
moduleType: 'vocabulary',
|
|
dynamicMode: true
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('✅ VocabularyModule completion saved');
|
|
}
|
|
|
|
/**
|
|
* Get exercise type identifier
|
|
* @returns {string} - Exercise type
|
|
*/
|
|
getExerciseType() {
|
|
return 'vocabulary';
|
|
}
|
|
|
|
/**
|
|
* Get exercise configuration
|
|
* @returns {Object} - Configuration object
|
|
*/
|
|
getExerciseConfig() {
|
|
const wordCount = this.currentVocabularyGroup ? this.currentVocabularyGroup.length : 0;
|
|
const estimatedTimePerWord = 0.5; // 30 seconds per word
|
|
|
|
return {
|
|
type: this.getExerciseType(),
|
|
difficulty: wordCount <= 3 ? 'easy' : (wordCount <= 7 ? 'medium' : 'hard'),
|
|
estimatedTime: Math.ceil(wordCount * estimatedTimePerWord), // in minutes
|
|
prerequisites: [], // Vocabulary has no prerequisites
|
|
metadata: {
|
|
...this.config,
|
|
groupSize: this.config.groupSize,
|
|
masteryThreshold: this.config.masteryThreshold,
|
|
wordCount,
|
|
dynamicMode: true // Flag to indicate dynamic word selection
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
export default VocabularyModule; |