feat(vocab): add vocab distractor pool functionality
All checks were successful
Deploy to production / deploy (push) Successful in 3m9s
All checks were successful
Deploy to production / deploy (push) Successful in 3m9s
- Implemented a new endpoint to retrieve a pool of distractors for vocabulary exercises based on prior lessons. - Updated the VocabController and VocabRouter to include the new getVocabDistractorPool method. - Enhanced VocabService to classify questions and gather distractors from previous lessons. - Modified VocabLessonView to fetch and utilize the distractor pool for multiple-choice exercises, improving the learning experience.
This commit is contained in:
@@ -27,6 +27,9 @@ class VocabController {
|
|||||||
this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 });
|
this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 });
|
||||||
this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query));
|
this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query));
|
||||||
this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId));
|
this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId));
|
||||||
|
this.getVocabDistractorPool = this._wrapWithUser((userId, req) =>
|
||||||
|
this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId)
|
||||||
|
);
|
||||||
this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode));
|
this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode));
|
||||||
this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body));
|
this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body));
|
||||||
this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId));
|
this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId));
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ router.post('/courses', vocabController.createCourse);
|
|||||||
router.get('/courses', vocabController.getCourses);
|
router.get('/courses', vocabController.getCourses);
|
||||||
router.get('/courses/my', vocabController.getMyCourses);
|
router.get('/courses/my', vocabController.getMyCourses);
|
||||||
router.post('/courses/find-by-code', vocabController.getCourseByShareCode);
|
router.post('/courses/find-by-code', vocabController.getCourseByShareCode);
|
||||||
|
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
|
||||||
router.get('/courses/:courseId', vocabController.getCourse);
|
router.get('/courses/:courseId', vocabController.getCourse);
|
||||||
router.put('/courses/:courseId', vocabController.updateCourse);
|
router.put('/courses/:courseId', vocabController.updateCourse);
|
||||||
router.delete('/courses/:courseId', vocabController.deleteCourse);
|
router.delete('/courses/:courseId', vocabController.deleteCourse);
|
||||||
|
|||||||
@@ -107,13 +107,14 @@ const BISAYA_EXERCISES = {
|
|||||||
questionData: {
|
questionData: {
|
||||||
type: 'multiple_choice',
|
type: 'multiple_choice',
|
||||||
question: 'Was bedeutet "Babay"?',
|
question: 'Was bedeutet "Babay"?',
|
||||||
options: ['Tschüss / Auf Wiedersehen', 'Wie geht es dir?', 'Guten Tag', 'Ich bin müde']
|
options: ['Tschüss', 'Auf Wiedersehen', 'Wie geht es dir?', 'Guten Tag']
|
||||||
},
|
},
|
||||||
answerData: {
|
answerData: {
|
||||||
type: 'multiple_choice',
|
type: 'multiple_choice',
|
||||||
correctAnswer: 0
|
// Beide gelten als richtig (Lehnwort von „bye-bye“)
|
||||||
|
correctAnswer: [0, 1]
|
||||||
},
|
},
|
||||||
explanation: '"Babay" ist eine einfache alltägliche Verabschiedung.'
|
explanation: '"Babay" ist eine einfache Verabschiedung — vergleichbar mit „Tschüss“ oder „Auf Wiedersehen“.'
|
||||||
},
|
},
|
||||||
withTypeName('dialog_completion', {
|
withTypeName('dialog_completion', {
|
||||||
title: 'Begrüßungsdialog ergänzen',
|
title: 'Begrüßungsdialog ergänzen',
|
||||||
|
|||||||
@@ -1933,8 +1933,21 @@ export default class VocabService {
|
|||||||
|
|
||||||
if (correctIndices.length === 0) return false;
|
if (correctIndices.length === 0) return false;
|
||||||
|
|
||||||
// userAnswer ist der Index (0, 1, 2, ...)
|
const correctTexts = correctIndices
|
||||||
|
.map((i) => options[i])
|
||||||
|
.filter((opt) => opt !== undefined && opt !== null);
|
||||||
|
const norm = (s) => String(s).trim().toLowerCase();
|
||||||
|
|
||||||
|
// Nach zufälligen Distraktoren: Client sendet gewählten Optionstext statt Index
|
||||||
|
if (typeof userAnswer === 'string') {
|
||||||
|
const u = norm(userAnswer);
|
||||||
|
if (!u) return false;
|
||||||
|
return correctTexts.some((t) => norm(t) === u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: Index in die gespeicherten (nicht gemischten) Optionen
|
||||||
const userIndex = Number(userAnswer);
|
const userIndex = Number(userAnswer);
|
||||||
|
if (Number.isNaN(userIndex)) return false;
|
||||||
return correctIndices.includes(userIndex);
|
return correctIndices.includes(userIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2083,4 +2096,88 @@ export default class VocabService {
|
|||||||
await exercise.destroy();
|
await exercise.destroy();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnet eine Multiple-Choice-Frage der Zielsprache (zu lernen) oder Muttersprache (Erklärung) zu,
|
||||||
|
* damit Distraktoren aus dem passenden Wortpool gewählt werden können.
|
||||||
|
* @returns {'target'|'native'|'unknown'}
|
||||||
|
*/
|
||||||
|
_classifyMcQuestionSide(question) {
|
||||||
|
const q = String(question || '');
|
||||||
|
if (/Wie sagt man\s/i.test(q) || /Übersetze/i.test(q)) return 'target';
|
||||||
|
if (/Was bedeutet/i.test(q)) return 'native';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelt Vokabeln aus allen Multiple-Choice-Übungen von Lektionen **vor** der angegebenen Lektion
|
||||||
|
* (gleicher Kurs), getrennt nach Ziel- vs. Muttersprache anhand der Frageformulierung.
|
||||||
|
*/
|
||||||
|
async getVocabDistractorPool(hashedUserId, courseId, beforeLessonId) {
|
||||||
|
if (!beforeLessonId) {
|
||||||
|
const err = new Error('beforeLessonId is required');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const enrollment = await VocabCourseEnrollment.findOne({
|
||||||
|
where: { userId: user.id, courseId: Number(courseId) },
|
||||||
|
});
|
||||||
|
if (!enrollment) {
|
||||||
|
const err = new Error('Not enrolled in this course');
|
||||||
|
err.status = 403;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLesson = await VocabCourseLesson.findByPk(beforeLessonId);
|
||||||
|
if (!currentLesson || currentLesson.courseId !== Number(courseId)) {
|
||||||
|
const err = new Error('Lesson not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorLessons = await VocabCourseLesson.findAll({
|
||||||
|
where: {
|
||||||
|
courseId: Number(courseId),
|
||||||
|
lessonNumber: { [Op.lt]: currentLesson.lessonNumber },
|
||||||
|
},
|
||||||
|
attributes: ['id'],
|
||||||
|
order: [['lessonNumber', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const lessonIds = priorLessons.map((l) => l.id);
|
||||||
|
if (lessonIds.length === 0) {
|
||||||
|
return { target: [], native: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const exercises = await VocabGrammarExercise.findAll({
|
||||||
|
where: {
|
||||||
|
lessonId: { [Op.in]: lessonIds },
|
||||||
|
exerciseTypeId: 2,
|
||||||
|
},
|
||||||
|
attributes: ['questionData'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = new Set();
|
||||||
|
const native = new Set();
|
||||||
|
|
||||||
|
for (const ex of exercises) {
|
||||||
|
const qd =
|
||||||
|
typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData;
|
||||||
|
const question = qd?.question || '';
|
||||||
|
const opts = qd?.options;
|
||||||
|
if (!Array.isArray(opts)) continue;
|
||||||
|
const side = this._classifyMcQuestionSide(question);
|
||||||
|
if (side === 'target') {
|
||||||
|
opts.forEach((o) => target.add(String(o).trim()));
|
||||||
|
} else if (side === 'native') {
|
||||||
|
opts.forEach((o) => native.add(String(o).trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
target: [...target],
|
||||||
|
native: [...native],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
backend/sql/update-bisaya-babay-multiple-choice.sql
Normal file
33
backend/sql/update-bisaya-babay-multiple-choice.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Bisaya-Kurs: Übung „Abschiedsform erkennen“ (Was bedeutet „Babay“?)
|
||||||
|
-- Statt einer kombinierten Option „Tschüss / Auf Wiedersehen“: zwei gültige Antworten
|
||||||
|
-- (correctAnswer [0, 1]), passend zu backend/services/vocabService.js (multiple_choice).
|
||||||
|
--
|
||||||
|
-- Nach dem Einspielen: Frontend deployen, falls der Vokabeltrainer aus Übungen
|
||||||
|
-- mehrere korrekte MC-Indizes verarbeiten soll (VocabLessonView importantVocab).
|
||||||
|
|
||||||
|
UPDATE community.vocab_grammar_exercise AS e
|
||||||
|
SET
|
||||||
|
question_data = jsonb_build_object(
|
||||||
|
'type', 'multiple_choice',
|
||||||
|
'question', 'Was bedeutet "Babay"?',
|
||||||
|
'options', jsonb_build_array(
|
||||||
|
'Tschüss',
|
||||||
|
'Auf Wiedersehen',
|
||||||
|
'Wie geht es dir?',
|
||||||
|
'Guten Tag'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
answer_data = jsonb_build_object(
|
||||||
|
'type', 'multiple_choice',
|
||||||
|
'correctAnswer', jsonb_build_array(0, 1)
|
||||||
|
),
|
||||||
|
explanation = '"Babay" ist eine einfache Verabschiedung — vergleichbar mit „Tschüss“ oder „Auf Wiedersehen“.'
|
||||||
|
WHERE e.exercise_type_id = 2
|
||||||
|
AND e.title = 'Abschiedsform erkennen'
|
||||||
|
AND (
|
||||||
|
e.question_data->>'question' = 'Was bedeutet "Babay"?'
|
||||||
|
OR (e.question_data->'options'->>0) = 'Tschüss / Auf Wiedersehen'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Erwartet: mindestens eine Zeile pro betroffener Übung (pro Kurs/Lektion).
|
||||||
|
-- SELECT COUNT(*) vorher/nachher bei Bedarf prüfen.
|
||||||
@@ -735,7 +735,11 @@ export default {
|
|||||||
nextLessonId: null,
|
nextLessonId: null,
|
||||||
showCompletionDialog: false,
|
showCompletionDialog: false,
|
||||||
showErrorDialog: false,
|
showErrorDialog: false,
|
||||||
errorMessage: ''
|
errorMessage: '',
|
||||||
|
/** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */
|
||||||
|
distractorPool: { target: [], native: [] },
|
||||||
|
/** { [exerciseId]: { options: string[], useTextAnswer: boolean } } */
|
||||||
|
mcRandomizedOptions: {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1091,6 +1095,8 @@ export default {
|
|||||||
this.vocabTrainerPhase = 'current';
|
this.vocabTrainerPhase = 'current';
|
||||||
this.vocabTrainerCurrentAttempts = 0;
|
this.vocabTrainerCurrentAttempts = 0;
|
||||||
this.vocabTrainerReviewAttempts = 0;
|
this.vocabTrainerReviewAttempts = 0;
|
||||||
|
this.distractorPool = { target: [], native: [] };
|
||||||
|
this.mcRandomizedOptions = {};
|
||||||
// Reset Flags
|
// Reset Flags
|
||||||
this.isCheckingLessonCompletion = false;
|
this.isCheckingLessonCompletion = false;
|
||||||
this.isNavigatingToNext = false;
|
this.isNavigatingToNext = false;
|
||||||
@@ -1116,17 +1122,27 @@ export default {
|
|||||||
this.focusAssistantCard();
|
this.focusAssistantCard();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Initialisiere mit effectiveExercises (für Review: reviewVocabExercises, sonst: grammarExercises)
|
try {
|
||||||
this.$nextTick(async () => {
|
const poolRes = await apiClient.get(`/api/vocab/courses/${this.courseId}/distractor-pool`, {
|
||||||
const exercises = this.effectiveExercises;
|
params: { beforeLessonId: this.lessonId }
|
||||||
|
});
|
||||||
|
this.distractorPool = poolRes.data || { target: [], native: [] };
|
||||||
|
} catch (poolErr) {
|
||||||
|
console.warn('[VocabLessonView] Distraktor-Pool nicht geladen:', poolErr);
|
||||||
|
this.distractorPool = { target: [], native: [] };
|
||||||
|
}
|
||||||
|
await this.$nextTick();
|
||||||
|
let exercises = this.effectiveExercises;
|
||||||
|
if (!exercises || exercises.length === 0) {
|
||||||
|
debugLog('[VocabLessonView] Lade Übungen separat...');
|
||||||
|
await this.loadGrammarExercises();
|
||||||
|
exercises = this.effectiveExercises;
|
||||||
|
}
|
||||||
if (exercises && exercises.length > 0) {
|
if (exercises && exercises.length > 0) {
|
||||||
debugLog('[VocabLessonView] Übungen für Kapitel-Prüfung:', exercises.length);
|
debugLog('[VocabLessonView] Übungen für Kapitel-Prüfung:', exercises.length);
|
||||||
this.initializeExercises(exercises);
|
this.initializeExercises(exercises);
|
||||||
} else {
|
this.buildMcRandomizedOptions();
|
||||||
debugLog('[VocabLessonView] Lade Übungen separat...');
|
|
||||||
await this.loadGrammarExercises();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
debugLog('[VocabLessonView] loadLesson abgeschlossen');
|
debugLog('[VocabLessonView] loadLesson abgeschlossen');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[VocabLessonView] Fehler beim Laden der Lektion:', e);
|
console.error('[VocabLessonView] Fehler beim Laden der Lektion:', e);
|
||||||
@@ -1224,7 +1240,6 @@ export default {
|
|||||||
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/grammar-exercises`);
|
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/grammar-exercises`);
|
||||||
const exercises = res.data || [];
|
const exercises = res.data || [];
|
||||||
this.lesson.grammarExercises = exercises;
|
this.lesson.grammarExercises = exercises;
|
||||||
this.initializeExercises(exercises);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Konnte Grammatik-Übungen nicht laden:', e);
|
console.error('Konnte Grammatik-Übungen nicht laden:', e);
|
||||||
this.lesson.grammarExercises = [];
|
this.lesson.grammarExercises = [];
|
||||||
@@ -1284,6 +1299,74 @@ export default {
|
|||||||
? JSON.parse(exercise.answerData)
|
? JSON.parse(exercise.answerData)
|
||||||
: exercise.answerData;
|
: exercise.answerData;
|
||||||
},
|
},
|
||||||
|
_shuffleArray(arr) {
|
||||||
|
const a = [...arr];
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
},
|
||||||
|
/** Muss zu backend/services/vocabService _classifyMcQuestionSide passen */
|
||||||
|
_classifyMcQuestionSide(question) {
|
||||||
|
const q = String(question || '');
|
||||||
|
if (/Wie sagt man\s/i.test(q) || /Übersetze/i.test(q)) return 'target';
|
||||||
|
if (/Was bedeutet/i.test(q)) return 'native';
|
||||||
|
return 'unknown';
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Zufällige Distraktoren aus gelernten Vokabeln (vorherige Lektionen), gleiche Sprache wie die richtigen Optionen.
|
||||||
|
* Deaktivierbar pro Übung: questionData.randomizeDistractors === false
|
||||||
|
*/
|
||||||
|
randomizeMcOptionsIfPossible(exercise) {
|
||||||
|
const q = this.getQuestionData(exercise);
|
||||||
|
const a = this.getAnswerData(exercise);
|
||||||
|
if (!q || !a || q.type !== 'multiple_choice' || q.randomizeDistractors === false) return null;
|
||||||
|
const qtext = q.question || '';
|
||||||
|
const side = this._classifyMcQuestionSide(qtext);
|
||||||
|
if (side === 'unknown') return null;
|
||||||
|
const options = q.options || [];
|
||||||
|
let correctIndices = [];
|
||||||
|
if (a.correctAnswer !== undefined) {
|
||||||
|
correctIndices = Array.isArray(a.correctAnswer) ? a.correctAnswer.map(Number) : [Number(a.correctAnswer)];
|
||||||
|
} else if (a.correct !== undefined) {
|
||||||
|
correctIndices = Array.isArray(a.correct) ? a.correct.map(Number) : [Number(a.correct)];
|
||||||
|
}
|
||||||
|
const correctTexts = correctIndices.map((i) => options[i]).filter((t) => t !== undefined && t !== null);
|
||||||
|
if (!correctTexts.length) return null;
|
||||||
|
const totalSlots = options.length;
|
||||||
|
const need = totalSlots - correctTexts.length;
|
||||||
|
if (need <= 0) return null;
|
||||||
|
const norm = (s) => String(s).trim().toLowerCase();
|
||||||
|
const correctSet = new Set(correctTexts.map(norm));
|
||||||
|
const poolRaw = side === 'target' ? this.distractorPool.target : this.distractorPool.native;
|
||||||
|
let poolFiltered = poolRaw.filter((w) => w && !correctSet.has(norm(w)));
|
||||||
|
poolFiltered = this._shuffleArray(poolFiltered);
|
||||||
|
let picked = poolFiltered.slice(0, need);
|
||||||
|
if (picked.length < need) {
|
||||||
|
const wrongFromDb = options.filter((_, i) => !correctIndices.includes(i));
|
||||||
|
const shuffledWrong = this._shuffleArray(wrongFromDb);
|
||||||
|
for (const w of shuffledWrong) {
|
||||||
|
if (picked.length >= need) break;
|
||||||
|
if (!picked.some((p) => norm(p) === norm(w))) picked.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (picked.length < need) return null;
|
||||||
|
const all = this._shuffleArray([...correctTexts, ...picked]);
|
||||||
|
return { options: all, useTextAnswer: true };
|
||||||
|
},
|
||||||
|
buildMcRandomizedOptions() {
|
||||||
|
this.mcRandomizedOptions = {};
|
||||||
|
const exercises = this.effectiveExercises;
|
||||||
|
if (!exercises) return;
|
||||||
|
exercises.forEach((ex) => {
|
||||||
|
if (this.getExerciseType(ex) !== 'multiple_choice') return;
|
||||||
|
const built = this.randomizeMcOptionsIfPossible(ex);
|
||||||
|
if (built) {
|
||||||
|
this.mcRandomizedOptions[ex.id] = built;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
getQuestionText(exercise) {
|
getQuestionText(exercise) {
|
||||||
const qData = this.getQuestionData(exercise);
|
const qData = this.getQuestionData(exercise);
|
||||||
if (!qData) return exercise.title;
|
if (!qData) return exercise.title;
|
||||||
@@ -1301,6 +1384,10 @@ export default {
|
|||||||
return exercise.title;
|
return exercise.title;
|
||||||
},
|
},
|
||||||
getOptions(exercise) {
|
getOptions(exercise) {
|
||||||
|
const custom = this.mcRandomizedOptions[exercise.id];
|
||||||
|
if (custom && Array.isArray(custom.options) && custom.options.length > 0) {
|
||||||
|
return custom.options;
|
||||||
|
}
|
||||||
const qData = this.getQuestionData(exercise);
|
const qData = this.getQuestionData(exercise);
|
||||||
return qData?.options || [];
|
return qData?.options || [];
|
||||||
},
|
},
|
||||||
@@ -1340,8 +1427,15 @@ export default {
|
|||||||
answer = [answer];
|
answer = [answer];
|
||||||
}
|
}
|
||||||
} else if (exerciseType === 'multiple_choice') {
|
} else if (exerciseType === 'multiple_choice') {
|
||||||
// Multiple Choice: Index als Zahl
|
const ro = this.mcRandomizedOptions[exercise.id];
|
||||||
|
if (ro && ro.useTextAnswer) {
|
||||||
|
const opts = this.getOptions(exercise);
|
||||||
|
const idx = Number(answer);
|
||||||
|
const pick = opts[idx];
|
||||||
|
answer = pick !== undefined && pick !== null ? String(pick) : '';
|
||||||
|
} else {
|
||||||
answer = Number(answer);
|
answer = Number(answer);
|
||||||
|
}
|
||||||
} else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') {
|
} else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') {
|
||||||
// Transformation: String
|
// Transformation: String
|
||||||
answer = String(answer || '').trim();
|
answer = String(answer || '').trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user