initial commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Base class for all lesson modules
|
||||
* All lesson modules should extend this class
|
||||
*/
|
||||
class LessonModule {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.lessonKey = config.lessonKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an answer for a specific question
|
||||
* @param {string} questionId - The question identifier
|
||||
* @param {any} answer - The participant's answer
|
||||
* @returns {Object} { isCorrect, pointsAwarded, feedback }
|
||||
*/
|
||||
async validateAnswer(questionId, answer) {
|
||||
const step = this.config.steps.find(s => s.id === questionId);
|
||||
if (!step || step.type !== 'question') {
|
||||
throw new Error(`Question ${questionId} not found`);
|
||||
}
|
||||
|
||||
return this._validateQuestionType(step, answer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal validation based on question type
|
||||
*/
|
||||
_validateQuestionType(step, answer) {
|
||||
switch (step.questionType) {
|
||||
case 'single_choice':
|
||||
return this._validateSingleChoice(step, answer);
|
||||
case 'multiple_choice':
|
||||
return this._validateMultipleChoice(step, answer);
|
||||
case 'free_text':
|
||||
return this._validateFreeText(step, answer);
|
||||
default:
|
||||
throw new Error(`Unknown question type: ${step.questionType}`);
|
||||
}
|
||||
}
|
||||
|
||||
_validateSingleChoice(step, answer) {
|
||||
const selectedOption = step.options.find(opt => opt.id === answer);
|
||||
if (!selectedOption) {
|
||||
return {
|
||||
isCorrect: false,
|
||||
pointsAwarded: 0,
|
||||
feedback: step.feedback?.incorrect || 'Incorrect answer'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isCorrect: selectedOption.isCorrect,
|
||||
pointsAwarded: selectedOption.isCorrect ? selectedOption.points : 0,
|
||||
feedback: selectedOption.isCorrect
|
||||
? (step.feedback?.correct || 'Correct!')
|
||||
: (step.feedback?.incorrect || 'Incorrect answer')
|
||||
};
|
||||
}
|
||||
|
||||
_validateMultipleChoice(step, answers) {
|
||||
// answers should be an array of option IDs
|
||||
if (!Array.isArray(answers)) {
|
||||
return {
|
||||
isCorrect: false,
|
||||
pointsAwarded: 0,
|
||||
feedback: step.feedback?.incorrect || 'Invalid answer format'
|
||||
};
|
||||
}
|
||||
|
||||
const correctOptions = step.options.filter(opt => opt.isCorrect).map(opt => opt.id);
|
||||
const selectedCorrect = answers.filter(a => correctOptions.includes(a));
|
||||
const selectedIncorrect = answers.filter(a => !correctOptions.includes(a));
|
||||
|
||||
// Calculate points
|
||||
const pointsAwarded = selectedCorrect.reduce((sum, id) => {
|
||||
const option = step.options.find(opt => opt.id === id);
|
||||
return sum + (option?.points || 0);
|
||||
}, 0);
|
||||
|
||||
const isFullyCorrect = selectedCorrect.length === correctOptions.length &&
|
||||
selectedIncorrect.length === 0;
|
||||
const isPartiallyCorrect = selectedCorrect.length > 0 && !isFullyCorrect;
|
||||
|
||||
let feedback = step.feedback?.incorrect || 'Incorrect answer';
|
||||
if (isFullyCorrect) {
|
||||
feedback = step.feedback?.correct || 'Correct!';
|
||||
} else if (isPartiallyCorrect) {
|
||||
feedback = step.feedback?.partial || step.feedback?.correct || 'Partially correct';
|
||||
}
|
||||
|
||||
return {
|
||||
isCorrect: isFullyCorrect,
|
||||
isPartial: isPartiallyCorrect,
|
||||
pointsAwarded,
|
||||
feedback
|
||||
};
|
||||
}
|
||||
|
||||
_validateFreeText(step, answer) {
|
||||
if (!answer || typeof answer !== 'string') {
|
||||
return {
|
||||
isCorrect: false,
|
||||
pointsAwarded: 0,
|
||||
feedback: step.feedback?.incorrect || 'Answer is required'
|
||||
};
|
||||
}
|
||||
|
||||
if (!step.validationRules || step.validationRules.length === 0) {
|
||||
// No validation rules, accept any non-empty answer
|
||||
const points = answer.trim().length > 0 ? step.maxPoints : 0;
|
||||
return {
|
||||
isCorrect: points > 0,
|
||||
pointsAwarded: points,
|
||||
feedback: points > 0
|
||||
? (step.feedback?.correct || 'Answer received')
|
||||
: (step.feedback?.incorrect || 'Answer is too short')
|
||||
};
|
||||
}
|
||||
|
||||
let passedRules = 0;
|
||||
const totalRules = step.validationRules.length;
|
||||
|
||||
for (const rule of step.validationRules) {
|
||||
if (this._checkValidationRule(rule, answer)) {
|
||||
passedRules++;
|
||||
}
|
||||
}
|
||||
|
||||
const scorePercentage = passedRules / totalRules;
|
||||
const pointsAwarded = Math.round(step.maxPoints * scorePercentage);
|
||||
const isCorrect = scorePercentage >= 0.7; // 70% threshold
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
pointsAwarded,
|
||||
feedback: isCorrect
|
||||
? (step.feedback?.correct || 'Good answer!')
|
||||
: (step.feedback?.incorrect || 'Please review your answer')
|
||||
};
|
||||
}
|
||||
|
||||
_checkValidationRule(rule, answer) {
|
||||
const lowerAnswer = (answer || '').toLowerCase();
|
||||
|
||||
switch (rule.type) {
|
||||
case 'contains_keywords':
|
||||
const matches = rule.keywords.filter(keyword =>
|
||||
lowerAnswer.includes(keyword.toLowerCase())
|
||||
).length;
|
||||
return matches >= (rule.minMatches || 1);
|
||||
|
||||
case 'min_length':
|
||||
return answer.length >= rule.value;
|
||||
|
||||
case 'max_length':
|
||||
return answer.length <= rule.value;
|
||||
|
||||
case 'regex':
|
||||
return new RegExp(rule.pattern, rule.flags || 'i').test(answer);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interactive component data for a step
|
||||
* Can be overridden by subclasses for dynamic content
|
||||
*/
|
||||
async getInteractiveData(stepId) {
|
||||
const step = this.config.steps.find(s => s.id === stepId);
|
||||
if (!step || step.type !== 'interactive') {
|
||||
throw new Error(`Interactive step ${stepId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
component: step.interactiveComponent,
|
||||
props: step.componentProps || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lesson content for rendering (without answers)
|
||||
*/
|
||||
getContent() {
|
||||
return {
|
||||
lessonKey: this.lessonKey,
|
||||
title: this.config.title,
|
||||
description: this.config.description,
|
||||
difficultyLevel: this.config.difficultyLevel,
|
||||
estimatedDuration: this.config.estimatedDuration,
|
||||
steps: this.config.steps.map(step => ({
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
title: step.title,
|
||||
content: step.content,
|
||||
// For question steps, don't send correct answers
|
||||
...(step.type === 'question' && {
|
||||
questionType: step.questionType,
|
||||
question: step.question,
|
||||
maxPoints: step.maxPoints,
|
||||
options: step.options?.map(opt => ({
|
||||
id: opt.id,
|
||||
text: opt.text
|
||||
// isCorrect and points are intentionally omitted
|
||||
}))
|
||||
}),
|
||||
// For interactive steps, send component info
|
||||
...(step.type === 'interactive' && {
|
||||
interactiveComponent: step.interactiveComponent,
|
||||
componentProps: step.componentProps
|
||||
})
|
||||
})),
|
||||
scoring: {
|
||||
maxTotalPoints: this.config.scoring?.maxTotalPoints || 100,
|
||||
passingScore: this.config.scoring?.passingScore || 70
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full configuration (for debugging/admin)
|
||||
*/
|
||||
getFullConfig() {
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LessonModule;
|
||||
Reference in New Issue
Block a user