medienkompetenz-lernplattform/backend/lessons/modules/base/LessonModule.js
2026-02-05 22:42:30 +01:00

231 lines
6.7 KiB
JavaScript

/**
* 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;