diff --git a/backend/migrations-active/20260825000000-repair-lesson-39-situational-response.cjs b/backend/migrations-active/20260825000000-repair-lesson-39-situational-response.cjs new file mode 100644 index 0000000..7c9e577 --- /dev/null +++ b/backend/migrations-active/20260825000000-repair-lesson-39-situational-response.cjs @@ -0,0 +1,50 @@ +'use strict'; + +const OLD_QUESTION = 'Erzähle einen Tagesplan mit drei Schritten und einem Wunsch.'; +const REPAIRED_QUESTION = 'Schreibe vier kurze Sätze auf Bisaya. Verwende dabei alle diese Bausteine: Una, Pagkahuman, Unya und Gusto ko nga.'; +const KEYWORDS = ['una', 'pagkahuman', 'unya', 'gusto ko nga']; +const MODEL_ANSWER = 'Una, moadto ko sa merkado. Pagkahuman, kuhaon nako ang bata. Unya, mouli mi sa balay. Gusto ko nga magkita ta ugma.'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + // This was an open-ended production prompt, but the checker required four + // hidden words. Make both the expected language and the four criteria + // explicit, while still accepting every answer that fulfils them. + await queryInterface.sequelize.query(` + UPDATE community.vocab_grammar_exercise AS exercise + SET question_data = JSONB_SET( + JSONB_SET( + JSONB_SET(exercise.question_data, '{question}', TO_JSONB(CAST(:repairedQuestion AS text))), + '{keywords}', CAST(:keywords AS jsonb) + ), + '{minKeywordMatches}', '4'::jsonb + ), + answer_data = JSONB_SET( + JSONB_SET( + JSONB_SET(COALESCE(exercise.answer_data, '{}'::jsonb), '{modelAnswer}', TO_JSONB(CAST(:modelAnswer AS text))), + '{keywords}', CAST(:keywords AS jsonb) + ), + '{minKeywordMatches}', '4'::jsonb + ) + FROM community.vocab_course_lesson AS lesson + WHERE exercise.lesson_id = lesson.id + AND (lesson.id = 39 OR (lesson.course_id = 1 AND lesson.lesson_number = 39)) + AND exercise.question_data->>'type' = 'situational_response' + AND exercise.question_data->>'question' = :oldQuestion; + `, { + replacements: { + oldQuestion: OLD_QUESTION, + repairedQuestion: REPAIRED_QUESTION, + keywords: JSON.stringify(KEYWORDS), + modelAnswer: MODEL_ANSWER + }, + transaction + }); + }); + }, + + async down() { + // Keep the unambiguous exercise wording once deployed. + } +}; diff --git a/backend/scripts/create-bisaya-course-content.js b/backend/scripts/create-bisaya-course-content.js index 6d3e83f..2e079ec 100755 --- a/backend/scripts/create-bisaya-course-content.js +++ b/backend/scripts/create-bisaya-course-content.js @@ -761,18 +761,24 @@ function buildSituationalExercise(lessonTitle, didactics, fallbackPattern) { .split(' ') .filter((token) => token.length >= 4) .slice(0, 4); + const requiredParts = keywords.length > 0 ? keywords : [modelAnswer]; + const question = speakingPrompt?.prompt + ? `${speakingPrompt.prompt} Schreibe auf Bisaya und verwende diese Bausteine: ${requiredParts.join(', ')}.` + : `Reagiere passend mit einem Ausdruck aus der Lektion „${lessonTitle}“. Verwende: ${requiredParts.join(', ')}.`; return withTypeName('situational_response', { title: `${lessonTitle}: Situativ reagieren`, - instruction: 'Antworte kurz und passend auf die Situation.', + instruction: 'Antworte auf Bisaya. Die angezeigten Bausteine müssen in deiner Antwort vorkommen.', questionData: { type: 'situational_response', - question: speakingPrompt?.prompt || `Reagiere passend mit einem Ausdruck aus der Lektion "${lessonTitle}".`, - keywords + question, + keywords: requiredParts, + minKeywordMatches: requiredParts.length }, answerData: { modelAnswer, - keywords + keywords: requiredParts, + minKeywordMatches: requiredParts.length }, explanation: `Das Kernmuster "${modelAnswer}" passt natürlich zu dieser Situation.` }); diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 6eb3dc6..80b7c30 100755 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -4593,7 +4593,17 @@ export default class VocabService { const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || []; if (keywords.length > 0) { const normalizedUser = this._normalizeTextAnswer(userAnswer); - return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword))); + const matches = keywords.filter((keyword) => ( + normalizedUser.includes(this._normalizeTextAnswer(keyword)) + )).length; + const configuredMinimum = Number( + parsedQuestionData.minKeywordMatches ?? parsedAnswerData.minKeywordMatches + ); + const requiredMatches = Number.isInteger(configuredMinimum) + && configuredMinimum > 0 + ? Math.min(configuredMinimum, keywords.length) + : keywords.length; + return matches >= requiredMatches; } } diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index b961cd5..02cc342 100755 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -784,6 +784,10 @@

{{ getVisibleQuestionText(exercise) }}

+
+ {{ $t('socialnetwork.vocab.courses.keywords') }}: + {{ keyword }} +