Class_generator/src/DRS/exercise-modules/OpenResponseModule.js
StillHammer 194d65cd76 Implement strict DRS interface system for all 11 exercise modules
MAJOR ARCHITECTURE UPDATE - C++ Style Interface Enforcement

🔒 **Strict Interface System**:
- Created DRSExerciseInterface (10 required methods)
- Created ProgressSystemInterface (17 required methods)
- Updated ImplementationValidator with 3-phase validation
- Red screen errors for missing implementations

📚 **11/11 Exercise Modules Implemented**:
 VocabularyModule - Local flashcard validation
 TextAnalysisModule - AI text comprehension
 GrammarAnalysisModule - AI grammar correction
 TranslationModule - AI translation validation
 OpenResponseModule - AI open-ended responses
 PhraseModule - Phrase comprehension
 AudioModule - Audio listening exercises
 ImageModule - Visual comprehension
 GrammarModule - Grammar exercises
 TextModule - Reading comprehension
 WordDiscoveryModule - Vocabulary introduction

🎯 **Required Methods (All Modules)**:
- Lifecycle: init(), render(), destroy()
- Exercise: validate(), getResults(), handleUserInput()
- Progress: markCompleted(), getProgress()
- Metadata: getExerciseType(), getExerciseConfig()

📋 **Documentation**:
- Updated CLAUDE.md with complete interface hierarchy
- Created DRS_IMPLEMENTATION_PLAN.md (roadmap)
- Documented enforcement rules and patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 13:43:25 +08:00

688 lines
24 KiB
JavaScript

/**
* 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 = `
<div class="open-response-exercise">
<div class="exercise-header">
<h2>📝 Open Response</h2>
<div class="exercise-meta">
<span class="question-type">${this.currentQuestion.type}</span>
<span class="expected-length">Target: ~${this.currentQuestion.expectedLength} chars</span>
</div>
</div>
<div class="question-section">
<div class="question-text">
${this.currentQuestion.question}
</div>
<div class="criteria-info">
<strong>Evaluation criteria:</strong>
<ul>
${this.currentQuestion.criteria.map(c => `<li>${c.replace(/_/g, ' ')}</li>`).join('')}
</ul>
</div>
</div>
<div class="response-section">
<textarea
id="open-response-input"
placeholder="Write your response here..."
maxlength="${this.config.maxResponseLength}"
></textarea>
<div class="response-meta">
<span id="character-count">0 / ${this.config.maxResponseLength}</span>
<span class="min-length">Minimum: ${this.config.minResponseLength} characters</span>
</div>
</div>
<div class="action-section">
<button id="validate-response" class="btn btn-primary" disabled>
Submit Response
</button>
<button id="clear-response" class="btn btn-secondary">
Clear
</button>
</div>
<div id="validation-result" class="validation-result" style="display: none;">
<!-- Validation results will appear here -->
</div>
</div>
`;
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 = `
<div class="result-header ${scoreClass}">
<div class="score-display">
<span class="score-value">${scorePercentage}%</span>
<span class="score-label">${result.isCorrect ? 'Good Response!' : 'Needs Improvement'}</span>
</div>
</div>
<div class="feedback-section">
<h4>Feedback:</h4>
<p>${result.feedback}</p>
</div>
${result.suggestions && result.suggestions.length > 0 ? `
<div class="suggestions-section">
<h4>Suggestions for improvement:</h4>
<ul>
${result.suggestions.map(s => `<li>${s}</li>`).join('')}
</ul>
</div>
` : ''}
<div class="result-actions">
<button id="try-again" class="btn btn-secondary">Try Again</button>
<button id="next-question" class="btn btn-primary">Next Question</button>
</div>
`;
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;