From b98835d2778b981148aa19fd8025ff268a5bded2 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 18 Aug 2026 15:11:43 +0200 Subject: [PATCH] feat(vocab): add support for answer alternatives in core patterns and update related logic --- ...000-add-bisaya-rest-answer-alternative.cjs | 142 ++++++++++++++++++ .../scripts/apply-bisaya-course-refresh.js | 2 +- backend/scripts/bisaya-course-phase1.js | 2 +- backend/scripts/create-bisaya-course.js | 2 +- backend/services/vocabService.js | 14 +- 5 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 backend/migrations-active/20260818000000-add-bisaya-rest-answer-alternative.cjs diff --git a/backend/migrations-active/20260818000000-add-bisaya-rest-answer-alternative.cjs b/backend/migrations-active/20260818000000-add-bisaya-rest-answer-alternative.cjs new file mode 100644 index 0000000..7470627 --- /dev/null +++ b/backend/migrations-active/20260818000000-add-bisaya-rest-answer-alternative.cjs @@ -0,0 +1,142 @@ +'use strict'; + +const SHORT_ANSWER = 'Ich ruhe mich später kurz aus.'; +const ALTERNATIVE_ANSWER = 'Ich ruhe mich später aus.'; +const COMBINED_ANSWER = `${SHORT_ANSWER} / ${ALTERNATIVE_ANSWER}`; +const TARGET = 'Magpahuway ko gamay unya.'; +const NORMALIZED_COMBINED_ANSWER = 'ich ruhe mich später kurz aus ich ruhe mich später aus'; +const NORMALIZED_TARGET = 'magpahuway ko gamay unya'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query(` + UPDATE community.vocab_course_lesson + SET core_patterns = ( + SELECT JSONB_AGG( + CASE + WHEN pattern.value->>'target' = :target AND pattern.value->>'gloss' = :shortAnswer + THEN pattern.value || JSONB_BUILD_OBJECT('alternatives', JSONB_BUILD_ARRAY(:alternativeAnswer)) + ELSE pattern.value + END + ) + FROM JSONB_ARRAY_ELEMENTS(core_patterns) AS pattern(value) + ) + WHERE JSONB_TYPEOF(core_patterns) = 'array' + AND core_patterns @> JSONB_BUILD_ARRAY(JSONB_BUILD_OBJECT('target', :target, 'gloss', :shortAnswer)); + `, { + replacements: { target: TARGET, shortAnswer: SHORT_ANSWER, alternativeAnswer: ALTERNATIVE_ANSWER }, + transaction + }); + + await queryInterface.sequelize.query(` + WITH stale AS ( + SELECT id, user_id, course_id, lesson_id, direction, correct_count, wrong_count, lapse_count, + stage, interval_days, last_reviewed_at, next_due_at + FROM community.vocab_srs_item + WHERE learning = :shortAnswer + AND reference = :target + ) + UPDATE community.vocab_srs_item AS canonical + SET correct_count = canonical.correct_count + stale.correct_count, + wrong_count = canonical.wrong_count + stale.wrong_count, + lapse_count = canonical.lapse_count + stale.lapse_count, + stage = GREATEST(canonical.stage, stale.stage), + interval_days = GREATEST(canonical.interval_days, stale.interval_days), + last_reviewed_at = GREATEST(canonical.last_reviewed_at, stale.last_reviewed_at), + next_due_at = LEAST(canonical.next_due_at, stale.next_due_at), + updated_at = NOW() + FROM stale + WHERE canonical.user_id = stale.user_id + AND canonical.id <> stale.id + AND canonical.item_key = ENCODE(DIGEST(CONCAT_WS( + '|', + stale.course_id::text, + COALESCE(stale.lesson_id::text, 'course'), + UPPER(stale.direction), + :normalizedCombinedAnswer, + :normalizedTarget + ), 'sha1'), 'hex'); + `, { + replacements: { + target: TARGET, + shortAnswer: SHORT_ANSWER, + normalizedCombinedAnswer: NORMALIZED_COMBINED_ANSWER, + normalizedTarget: NORMALIZED_TARGET + }, + transaction + }); + + await queryInterface.sequelize.query(` + DELETE FROM community.vocab_srs_item AS stale + WHERE stale.learning = :shortAnswer + AND stale.reference = :target + AND EXISTS ( + SELECT 1 + FROM community.vocab_srs_item canonical + WHERE canonical.user_id = stale.user_id + AND canonical.id <> stale.id + AND canonical.item_key = ENCODE(DIGEST(CONCAT_WS( + '|', + stale.course_id::text, + COALESCE(stale.lesson_id::text, 'course'), + UPPER(stale.direction), + :normalizedCombinedAnswer, + :normalizedTarget + ), 'sha1'), 'hex') + ); + `, { + replacements: { + target: TARGET, + shortAnswer: SHORT_ANSWER, + normalizedCombinedAnswer: NORMALIZED_COMBINED_ANSWER, + normalizedTarget: NORMALIZED_TARGET + }, + transaction + }); + + await queryInterface.sequelize.query(` + UPDATE community.vocab_srs_item + SET learning = :combinedAnswer, + item_key = ENCODE(DIGEST(CONCAT_WS( + '|', + course_id::text, + COALESCE(lesson_id::text, 'course'), + UPPER(direction), + :normalizedCombinedAnswer, + :normalizedTarget + ), 'sha1'), 'hex'), + updated_at = NOW() + WHERE learning = :shortAnswer + AND reference = :target + AND NOT EXISTS ( + SELECT 1 + FROM community.vocab_srs_item duplicate + WHERE duplicate.user_id = vocab_srs_item.user_id + AND duplicate.item_key = ENCODE(DIGEST(CONCAT_WS( + '|', + vocab_srs_item.course_id::text, + COALESCE(vocab_srs_item.lesson_id::text, 'course'), + UPPER(vocab_srs_item.direction), + :normalizedCombinedAnswer, + :normalizedTarget + ), 'sha1'), 'hex') + AND duplicate.id <> vocab_srs_item.id + ); + `, { + replacements: { + target: TARGET, + shortAnswer: SHORT_ANSWER, + combinedAnswer: COMBINED_ANSWER, + normalizedCombinedAnswer: NORMALIZED_COMBINED_ANSWER, + normalizedTarget: NORMALIZED_TARGET + }, + transaction + }); + }); + }, + + async down() { + // The alternative is valid course content and should not be removed. + }, +}; diff --git a/backend/scripts/apply-bisaya-course-refresh.js b/backend/scripts/apply-bisaya-course-refresh.js index c7049ab..6430aee 100755 --- a/backend/scripts/apply-bisaya-course-refresh.js +++ b/backend/scripts/apply-bisaya-course-refresh.js @@ -242,7 +242,7 @@ const LESSON_DIDACTICS = { { target: 'Human na ka sa trabaho?', gloss: 'Bist du mit der Arbeit fertig?' }, { target: 'Dali lang ko mubalik.', gloss: 'Ich komme gleich wieder.' }, { target: 'Naa koy lakaw karong hapon.', gloss: 'Ich habe heute Nachmittag etwas zu erledigen.' }, - { target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.' }, + { target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] }, { target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' } ], speakingPrompts: [ diff --git a/backend/scripts/bisaya-course-phase1.js b/backend/scripts/bisaya-course-phase1.js index e151413..848bbe3 100755 --- a/backend/scripts/bisaya-course-phase1.js +++ b/backend/scripts/bisaya-course-phase1.js @@ -41,7 +41,7 @@ export const BISAYA_DIDACTICS_FRAGMENTS = { { target: 'Human na ka sa trabaho?', gloss: 'Bist du mit der Arbeit fertig?' }, { target: 'Dali lang ko mubalik.', gloss: 'Ich komme gleich wieder.' }, { target: 'Naa koy lakaw karong hapon.', gloss: 'Ich habe heute Nachmittag etwas zu erledigen.' }, - { target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.' }, + { target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] }, { target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' } ], speakingPrompts: [ diff --git a/backend/scripts/create-bisaya-course.js b/backend/scripts/create-bisaya-course.js index f380c17..92977ef 100755 --- a/backend/scripts/create-bisaya-course.js +++ b/backend/scripts/create-bisaya-course.js @@ -397,7 +397,7 @@ const LESSON_DIDACTICS = { { target: 'Human na ka sa trabaho?', gloss: 'Bist du mit der Arbeit fertig?' }, { target: 'Dali lang ko mubalik.', gloss: 'Ich komme gleich wieder.' }, { target: 'Naa koy lakaw karong hapon.', gloss: 'Ich habe heute Nachmittag etwas zu erledigen.' }, - { target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.' }, + { target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] }, { target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' } ], speakingPrompts: [ diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 7b52020..41ec97b 100755 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -984,7 +984,10 @@ export default class VocabService { corePatterns.forEach((entry) => { const pattern = this._normalizeCorePatternEntry(entry); const reference = String(pattern?.target || '').trim(); - const learning = String(pattern?.gloss || '').trim(); + const answers = [pattern?.gloss, ...(pattern?.alternatives || [])] + .map((answer) => String(answer || '').trim()) + .filter(Boolean); + const learning = [...new Set(answers)].join(' / '); if (!this._isTrainableSrsPair({ learning, reference })) return; vocabMap.set(`${learning}-${reference}`, { learning, reference }); }); @@ -1009,8 +1012,9 @@ export default class VocabService { } /** - * Kernmuster: Zielsprachen-Phrase + optionale Glossierung (z. B. Deutsch). - * Unterstützt Legacy-Strings, "Phrase|Gloss" und Objekte { target, gloss } / { ceb, de }. + * Kernmuster: Zielsprachen-Phrase + optionale Glossierung und Antwortalternativen. + * Unterstützt Legacy-Strings, "Phrase|Gloss" und Objekte + * { target, gloss, alternatives } / { ceb, de }. */ _normalizeCorePatternEntry(entry) { if (entry === null || entry === undefined || entry === '') { @@ -1020,7 +1024,9 @@ export default class VocabService { const target = String(entry.target ?? entry.ceb ?? entry.phrase ?? '').trim(); const gloss = String(entry.gloss ?? entry.de ?? entry.translation ?? '').trim(); if (!target) return null; - return { target, gloss }; + const alternatives = this._normalizeStringList(entry.alternatives ?? entry.glossAlternatives) + .filter((alternative) => alternative !== gloss); + return { target, gloss, alternatives }; } const s = String(entry).trim(); if (!s) return null;