feat(bisaya): enhance situational response exercises with keyword hints and minimum match requirements

This commit is contained in:
Torsten Schulz (local)
2026-08-25 11:56:50 +02:00
parent 20960e620c
commit 423fd49cdb
4 changed files with 75 additions and 5 deletions

View File

@@ -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.
}
};

View File

@@ -761,18 +761,24 @@ function buildSituationalExercise(lessonTitle, didactics, fallbackPattern) {
.split(' ') .split(' ')
.filter((token) => token.length >= 4) .filter((token) => token.length >= 4)
.slice(0, 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', { return withTypeName('situational_response', {
title: `${lessonTitle}: Situativ reagieren`, 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: { questionData: {
type: 'situational_response', type: 'situational_response',
question: speakingPrompt?.prompt || `Reagiere passend mit einem Ausdruck aus der Lektion "${lessonTitle}".`, question,
keywords keywords: requiredParts,
minKeywordMatches: requiredParts.length
}, },
answerData: { answerData: {
modelAnswer, modelAnswer,
keywords keywords: requiredParts,
minKeywordMatches: requiredParts.length
}, },
explanation: `Das Kernmuster "${modelAnswer}" passt natürlich zu dieser Situation.` explanation: `Das Kernmuster "${modelAnswer}" passt natürlich zu dieser Situation.`
}); });

View File

@@ -4593,7 +4593,17 @@ export default class VocabService {
const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || []; const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || [];
if (keywords.length > 0) { if (keywords.length > 0) {
const normalizedUser = this._normalizeTextAnswer(userAnswer); 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;
} }
} }

View File

@@ -784,6 +784,10 @@
<div v-else-if="getExerciseType(exercise) === 'situational_response'" class="situational-response-exercise"> <div v-else-if="getExerciseType(exercise) === 'situational_response'" class="situational-response-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p> <p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.keywords?.length" class="keywords-hint">
<strong>{{ $t('socialnetwork.vocab.courses.keywords') }}:</strong>
<span v-for="(keyword, idx) in getQuestionData(exercise).keywords" :key="idx" class="keyword-tag">{{ keyword }}</span>
</div>
<textarea <textarea
v-model="exerciseAnswers[exercise.id]" v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.situationalResponsePlaceholder')" :placeholder="$t('socialnetwork.vocab.courses.situationalResponsePlaceholder')"