/** * OpenResponseModule - Free-form open response exercises with AI validation * Allows students to write free-text responses that are evaluated by AI * Implements DRSExerciseInterface for strict contract enforcement */ import DRSExerciseInterface from '../interfaces/DRSExerciseInterface.js'; class OpenResponseModule extends DRSExerciseInterface { constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) { super('OpenResponseModule'); // Validate dependencies if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) { throw new Error('OpenResponseModule requires all service dependencies'); } this.orchestrator = orchestrator; this.llmValidator = llmValidator; this.prerequisiteEngine = prerequisiteEngine; this.contextMemory = contextMemory; // Module state this.initialized = false; this.container = null; this.currentExerciseData = null; this.currentQuestion = null; this.userResponse = ''; this.validationInProgress = false; this.lastValidationResult = null; // Configuration this.config = { requiredProvider: 'openai', // Open response needs good comprehension model: 'gpt-4o-mini', temperature: 0.2, // Low for consistent evaluation maxTokens: 1000, timeout: 45000, minResponseLength: 10, // Minimum characters for response maxResponseLength: 2000, // Maximum characters for response allowMultipleAttempts: true, feedbackDepth: 'detailed' // detailed, brief, or minimal }; // Progress tracking this.progress = { questionsAnswered: 0, questionsCorrect: 0, averageScore: 0, totalAttempts: 0, timeSpent: 0, startTime: null }; } /** * Check if module can run with given content */ canRun(prerequisites, chapterContent) { // Can run with any content - will generate open questions return chapterContent && ( chapterContent.vocabulary || chapterContent.texts || chapterContent.phrases || chapterContent.grammar ); } /** * Present the open response exercise */ async present(container, exerciseData) { this.container = container; this.currentExerciseData = exerciseData; this.progress.startTime = Date.now(); console.log('📝 Starting Open Response exercise...'); // Generate or select question await this._generateQuestion(); // Create UI this._createExerciseInterface(); this.initialized = true; } /** * Validate user response using AI */ async validate(userInput, context) { if (this.validationInProgress) { return { score: 0, feedback: 'Validation already in progress', isCorrect: false }; } this.validationInProgress = true; this.userResponse = userInput.trim(); try { // Basic validation if (this.userResponse.length < this.config.minResponseLength) { return { score: 0, feedback: `Response too short. Please provide at least ${this.config.minResponseLength} characters.`, isCorrect: false, suggestions: ['Try to elaborate more on your answer', 'Provide more details and examples'] }; } if (this.userResponse.length > this.config.maxResponseLength) { return { score: 0, feedback: `Response too long. Please keep it under ${this.config.maxResponseLength} characters.`, isCorrect: false, suggestions: ['Try to be more concise', 'Focus on the main points'] }; } // AI validation const aiResult = await this._validateWithAI(); // Update progress this._updateProgress(aiResult); this.lastValidationResult = aiResult; return aiResult; } catch (error) { console.error('Open response validation error:', error); return { score: 0.5, feedback: 'Unable to validate response. Please try again.', isCorrect: false, error: error.message }; } finally { this.validationInProgress = false; } } /** * Get current progress */ getProgress() { const timeSpent = this.progress.startTime ? Date.now() - this.progress.startTime : 0; return { type: 'open-response', questionsAnswered: this.progress.questionsAnswered, questionsCorrect: this.progress.questionsCorrect, accuracy: this.progress.questionsAnswered > 0 ? this.progress.questionsCorrect / this.progress.questionsAnswered : 0, averageScore: this.progress.averageScore, timeSpent: timeSpent, currentQuestion: this.currentQuestion?.question, responseLength: this.userResponse.length }; } /** * Get module metadata */ getMetadata() { return { name: 'Open Response', type: 'open-response', difficulty: 'advanced', estimatedTime: 300, // 5 minutes per question capabilities: ['creative_writing', 'comprehension', 'analysis'], prerequisites: ['basic_vocabulary'] }; } /** * Cleanup module */ cleanup() { if (this.container) { this.container.innerHTML = ''; } this.initialized = false; this.validationInProgress = false; } // Private methods /** * Generate open response question */ async _generateQuestion() { const chapterContent = this.currentExerciseData.chapterContent || {}; // Question types based on available content const questionTypes = []; if (chapterContent.vocabulary) { questionTypes.push('vocabulary_usage', 'vocabulary_explanation'); } if (chapterContent.texts) { questionTypes.push('text_comprehension', 'text_analysis'); } if (chapterContent.phrases) { questionTypes.push('phrase_creation', 'situation_usage'); } if (chapterContent.grammar) { questionTypes.push('grammar_explanation', 'grammar_examples'); } // Fallback questions if (questionTypes.length === 0) { questionTypes.push('general_discussion', 'opinion_question'); } const selectedType = questionTypes[Math.floor(Math.random() * questionTypes.length)]; this.currentQuestion = await this._createQuestionByType(selectedType, chapterContent); } /** * Create question based on type */ async _createQuestionByType(type, content) { const questions = { vocabulary_usage: { question: this._createVocabularyUsageQuestion(content.vocabulary), type: 'vocabulary', expectedLength: 100, criteria: ['correct_word_usage', 'context_appropriateness', 'grammar'] }, vocabulary_explanation: { question: this._createVocabularyExplanationQuestion(content.vocabulary), type: 'vocabulary', expectedLength: 150, criteria: ['accuracy', 'clarity', 'examples'] }, text_comprehension: { question: this._createTextComprehensionQuestion(content.texts), type: 'comprehension', expectedLength: 200, criteria: ['understanding', 'details', 'inference'] }, phrase_creation: { question: this._createPhraseCreationQuestion(content.phrases), type: 'creative', expectedLength: 150, criteria: ['creativity', 'relevance', 'grammar'] }, grammar_explanation: { question: this._createGrammarExplanationQuestion(content.grammar), type: 'grammar', expectedLength: 180, criteria: ['accuracy', 'examples', 'clarity'] }, general_discussion: { question: this._createGeneralDiscussionQuestion(), type: 'discussion', expectedLength: 200, criteria: ['coherence', 'development', 'language_use'] } }; return questions[type] || questions.general_discussion; } _createVocabularyUsageQuestion(vocabulary) { if (!vocabulary || Object.keys(vocabulary).length === 0) { return "Describe your daily routine using as much detail as possible."; } const words = Object.keys(vocabulary); const selectedWords = words.slice(0, 3).join(', '); return `Write a short paragraph using these words: ${selectedWords}. Make sure to use each word correctly in context.`; } _createVocabularyExplanationQuestion(vocabulary) { if (!vocabulary || Object.keys(vocabulary).length === 0) { return "Explain the difference between 'house' and 'home' and give examples."; } const words = Object.keys(vocabulary); const selectedWord = words[Math.floor(Math.random() * words.length)]; return `Explain the meaning of "${selectedWord}" and provide at least two example sentences showing how to use it.`; } _createTextComprehensionQuestion(texts) { if (!texts || texts.length === 0) { return "Describe a memorable experience you had and explain why it was important to you."; } return "Based on the chapter content, what are the main themes discussed and how do they relate to everyday life?"; } _createPhraseCreationQuestion(phrases) { if (!phrases || Object.keys(phrases).length === 0) { return "Create a dialogue between two people meeting for the first time."; } return "Using the phrases from this chapter, write a short conversation that might happen in a real-life situation."; } _createGrammarExplanationQuestion(grammar) { if (!grammar || Object.keys(grammar).length === 0) { return "Explain when to use 'a' vs 'an' and give three examples of each."; } const concepts = Object.keys(grammar); const selectedConcept = concepts[Math.floor(Math.random() * concepts.length)]; return `Explain the grammar rule for "${selectedConcept}" and provide examples showing correct and incorrect usage.`; } _createGeneralDiscussionQuestion() { const questions = [ "What advice would you give to someone learning English for the first time?", "Describe your ideal weekend and explain why these activities appeal to you.", "What are the advantages and disadvantages of living in a big city?", "How has technology changed the way people communicate?", "What qualities make a good friend? Explain with examples." ]; return questions[Math.floor(Math.random() * questions.length)]; } /** * Validate response with AI */ async _validateWithAI() { const prompt = this._buildValidationPrompt(); try { const result = await this.llmValidator.validateAnswer( this.currentQuestion.question, this.userResponse, { provider: this.config.requiredProvider, model: this.config.model, temperature: this.config.temperature, maxTokens: this.config.maxTokens, timeout: this.config.timeout, context: prompt } ); return this._parseAIResponse(result); } catch (error) { console.error('AI validation failed:', error); throw error; } } _buildValidationPrompt() { return ` You are evaluating an open response answer from an English language learner. Question: "${this.currentQuestion.question}" Student Response: "${this.userResponse}" Evaluation Criteria: - ${this.currentQuestion.criteria.join('\n- ')} Expected Response Length: ~${this.currentQuestion.expectedLength} characters Actual Response Length: ${this.userResponse.length} characters Please evaluate the response and provide: 1. A score from 0.0 to 1.0 2. Detailed feedback on strengths and areas for improvement 3. Specific suggestions for enhancement 4. Whether the response adequately addresses the question Format your response as: [score]0.85 [feedback]Your response shows good understanding... [detailed feedback] [suggestions]Consider adding more examples... [specific suggestions] [correct]yes/no `.trim(); } _parseAIResponse(aiResult) { try { const response = aiResult.response || ''; // Extract score const scoreMatch = response.match(/\[score\]([\d.]+)/); const score = scoreMatch ? parseFloat(scoreMatch[1]) : 0.5; // Extract feedback const feedbackMatch = response.match(/\[feedback\](.*?)\[suggestions\]/s); const feedback = feedbackMatch ? feedbackMatch[1].trim() : 'Response evaluated'; // Extract suggestions const suggestionsMatch = response.match(/\[suggestions\](.*?)\[correct\]/s); const suggestions = suggestionsMatch ? suggestionsMatch[1].trim().split('\n') : []; // Extract correctness const correctMatch = response.match(/\[correct\](yes|no)/i); const isCorrect = correctMatch ? correctMatch[1].toLowerCase() === 'yes' : score >= 0.7; return { score, feedback, suggestions: suggestions.filter(s => s.trim().length > 0), isCorrect, criteria: this.currentQuestion.criteria, questionType: this.currentQuestion.type, aiProvider: this.config.requiredProvider }; } catch (error) { console.error('Error parsing AI response:', error); return { score: 0.5, feedback: 'Unable to parse evaluation. Please try again.', suggestions: [], isCorrect: false, error: 'parsing_error' }; } } /** * Create exercise interface */ _createExerciseInterface() { this.container.innerHTML = `

📝 Open Response

${this.currentQuestion.type} Target: ~${this.currentQuestion.expectedLength} chars
${this.currentQuestion.question}
Evaluation criteria:
    ${this.currentQuestion.criteria.map(c => `
  • ${c.replace(/_/g, ' ')}
  • `).join('')}
0 / ${this.config.maxResponseLength} Minimum: ${this.config.minResponseLength} characters
`; this._attachEventListeners(); } _attachEventListeners() { const textarea = document.getElementById('open-response-input'); const validateBtn = document.getElementById('validate-response'); const clearBtn = document.getElementById('clear-response'); const charCount = document.getElementById('character-count'); if (textarea) { textarea.addEventListener('input', (e) => { const length = e.target.value.length; charCount.textContent = `${length} / ${this.config.maxResponseLength}`; // Enable/disable submit button validateBtn.disabled = length < this.config.minResponseLength; // Update character count color if (length < this.config.minResponseLength) { charCount.style.color = '#dc3545'; } else if (length > this.config.maxResponseLength * 0.9) { charCount.style.color = '#ffc107'; } else { charCount.style.color = '#28a745'; } }); } if (validateBtn) { validateBtn.addEventListener('click', async () => { await this._handleValidation(); }); } if (clearBtn) { clearBtn.addEventListener('click', () => { textarea.value = ''; textarea.dispatchEvent(new Event('input')); document.getElementById('validation-result').style.display = 'none'; }); } } async _handleValidation() { const textarea = document.getElementById('open-response-input'); const validateBtn = document.getElementById('validate-response'); const resultDiv = document.getElementById('validation-result'); validateBtn.disabled = true; validateBtn.textContent = 'Validating...'; try { const result = await this.validate(textarea.value, {}); this._displayValidationResult(result); } catch (error) { console.error('Validation error:', error); this._displayValidationResult({ score: 0, feedback: 'Error validating response. Please try again.', isCorrect: false, suggestions: [] }); } finally { validateBtn.disabled = false; validateBtn.textContent = 'Submit Response'; } } _displayValidationResult(result) { const resultDiv = document.getElementById('validation-result'); const scorePercentage = Math.round(result.score * 100); const scoreClass = result.isCorrect ? 'success' : (result.score >= 0.5 ? 'warning' : 'error'); resultDiv.innerHTML = `
${scorePercentage}% ${result.isCorrect ? 'Good Response!' : 'Needs Improvement'}

Feedback:

${result.feedback}

${result.suggestions && result.suggestions.length > 0 ? `

Suggestions for improvement:

` : ''}
`; resultDiv.style.display = 'block'; // Attach action handlers document.getElementById('try-again')?.addEventListener('click', () => { resultDiv.style.display = 'none'; }); document.getElementById('next-question')?.addEventListener('click', () => { this._nextQuestion(); }); } async _nextQuestion() { await this._generateQuestion(); this._createExerciseInterface(); } _updateProgress(result) { this.progress.questionsAnswered++; this.progress.totalAttempts++; if (result.isCorrect) { this.progress.questionsCorrect++; } // Update average score const previousTotal = this.progress.averageScore * (this.progress.questionsAnswered - 1); this.progress.averageScore = (previousTotal + result.score) / this.progress.questionsAnswered; } // ======================================== // DRSExerciseInterface REQUIRED METHODS // ======================================== async init(config = {}, content = {}) { this.config = { ...this.config, ...config }; this.currentExerciseData = content; this.startTime = Date.now(); this.initialized = true; } async render(container) { if (!this.initialized) throw new Error('OpenResponseModule must be initialized before rendering'); await this.present(container, this.currentExerciseData); } async destroy() { this.cleanup?.(); this.container = null; this.initialized = false; } getResults() { const totalQuestions = this.questions ? this.questions.length : 0; const answeredQuestions = this.userResponses ? this.userResponses.length : 0; const score = this.progress ? Math.round(this.progress.averageScore * 100) : 0; return { score, attempts: answeredQuestions, timeSpent: this.startTime ? Date.now() - this.startTime : 0, completed: answeredQuestions >= totalQuestions, details: { totalQuestions, answeredQuestions, averageScore: this.progress?.averageScore || 0, responses: this.userResponses || [] } }; } handleUserInput(event, data) { if (event && event.type === 'input') this._handleInputChange?.(event); if (event && event.type === 'click' && event.target.id === 'submitButton') this._handleSubmit?.(event); } async markCompleted(results) { const { score, details } = results || this.getResults(); if (this.contextMemory) { this.contextMemory.recordInteraction({ type: 'open-response', subtype: 'completion', content: { questions: this.questions }, userResponse: this.userResponses, validation: { score, averageScore: details.averageScore }, context: { moduleType: 'open-response', totalQuestions: details.totalQuestions } }); } } getExerciseType() { return 'open-response'; } getExerciseConfig() { const questionCount = this.questions ? this.questions.length : this.config?.questionsPerExercise || 2; return { type: this.getExerciseType(), difficulty: this.currentExerciseData?.difficulty || 'medium', estimatedTime: questionCount * 3, // 3 min per question prerequisites: [], metadata: { ...this.config, questionCount, requiresAI: true } }; } } export default OpenResponseModule;