Class_generator/src/DRS/exercise-modules/VocabularyModule.js
StillHammer 4714a4a1c6 Add TTS support and improve content compatibility system
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>
2025-10-18 02:49:48 +08:00

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;