Bisaya kurs korrekturen
All checks were successful
Deploy to production / deploy (push) Successful in 2m41s

This commit is contained in:
Torsten Schulz (local)
2026-06-06 13:16:04 +02:00
parent bfec885a1f
commit 973fcaaf9e
5 changed files with 443 additions and 19 deletions

View File

@@ -0,0 +1,302 @@
#!/usr/bin/env node
/**
* Repairs stale Bisaya number pairs where the Bisaya side is correct, but the
* German answer side contains a currency phrase such as "20 peso".
*
* Default: dry-run. Use --apply to write changes.
*/
import crypto from 'crypto';
import { Op } from 'sequelize';
import { sequelize } from '../utils/sequelize.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import VocabSrsItem from '../models/community/vocab_srs_item.js';
const BISAYA_TO_GERMAN_DIGIT = new Map([
['usa', '1'],
['duha', '2'],
['tulo', '3'],
['upat', '4'],
['lima', '5'],
['unom', '6'],
['pito', '7'],
['walo', '8'],
['siyam', '9'],
['napulo', '10'],
['onse', '11'],
['dose', '12'],
['trese', '13'],
['katorse', '14'],
['kinse', '15'],
['disisais', '16'],
['disisiete', '17'],
['disiotso', '18'],
['disinuybe', '19'],
['baynte', '20'],
['kawhaan', '20'],
]);
const CURRENCY_WORDS = new Set(['peso', 'pesos', 'piso', 'pisos']);
const GERMAN_NUMBER_TO_DIGIT = new Map([
['ein', '1'],
['eins', '1'],
['zwei', '2'],
['drei', '3'],
['vier', '4'],
['fuenf', '5'],
['funf', '5'],
['fünf', '5'],
['sechs', '6'],
['sieben', '7'],
['acht', '8'],
['neun', '9'],
['zehn', '10'],
['elf', '11'],
['zwoelf', '12'],
['zwolf', '12'],
['zwölf', '12'],
['dreizehn', '13'],
['vierzehn', '14'],
['fuenfzehn', '15'],
['funfzehn', '15'],
['fünfzehn', '15'],
['sechzehn', '16'],
['siebzehn', '17'],
['achtzehn', '18'],
['neunzehn', '19'],
['zwanzig', '20'],
]);
function parseArgs(argv) {
return {
apply: argv.includes('--apply'),
};
}
function normalizeText(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function compact(value) {
return normalizeText(value).replace(/\s+/g, '');
}
function getBisayaDigit(value) {
return BISAYA_TO_GERMAN_DIGIT.get(compact(value)) || '';
}
function getCurrencyNumber(value) {
const text = normalizeText(value);
if (!text) return '';
const parts = text.split(/\s+/).filter(Boolean);
if (parts.length < 2) return '';
const last = parts[parts.length - 1];
if (!CURRENCY_WORDS.has(last)) return '';
const numberPart = parts.slice(0, -1).join('');
if (/^\d+$/.test(numberPart)) return String(Number(numberPart));
return GERMAN_NUMBER_TO_DIGIT.get(numberPart) || '';
}
function fixPair(left, right) {
const leftBisayaDigit = getBisayaDigit(left);
const rightCurrencyDigit = getCurrencyNumber(right);
if (leftBisayaDigit && rightCurrencyDigit && leftBisayaDigit === rightCurrencyDigit) {
return { left, right: leftBisayaDigit, changed: right !== leftBisayaDigit };
}
const rightBisayaDigit = getBisayaDigit(right);
const leftCurrencyDigit = getCurrencyNumber(left);
if (rightBisayaDigit && leftCurrencyDigit && rightBisayaDigit === leftCurrencyDigit) {
return { left: rightBisayaDigit, right, changed: left !== rightBisayaDigit };
}
return { left, right, changed: false };
}
function patchPattern(entry) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return { value: entry, changed: false };
}
const next = { ...entry };
let changed = false;
const pairKeys = [
['target', 'gloss'],
['learning', 'reference'],
['bisaya', 'native'],
];
pairKeys.forEach(([leftKey, rightKey]) => {
if (!(leftKey in next) || !(rightKey in next)) return;
const pair = fixPair(next[leftKey], next[rightKey]);
if (!pair.changed) return;
next[leftKey] = pair.left;
next[rightKey] = pair.right;
changed = true;
});
return { value: next, changed };
}
function patchJson(value) {
if (Array.isArray(value)) {
let changed = false;
const next = value.map((entry) => {
const patched = patchJson(entry);
changed = changed || patched.changed;
return patched.value;
});
return { value: next, changed };
}
if (value && typeof value === 'object') {
const direct = patchPattern(value);
let next = direct.value;
let changed = direct.changed;
Object.entries(next).forEach(([key, child]) => {
if (!child || typeof child !== 'object') return;
const patched = patchJson(child);
if (!patched.changed) return;
next = { ...next, [key]: patched.value };
changed = true;
});
return { value: next, changed };
}
return { value, changed: false };
}
function normalizeSrsText(value) {
return normalizeText(value);
}
function buildSrsItemKey({ courseId, lessonId = null, learning, reference, direction = 'BOTH' }) {
const raw = [
Number(courseId) || 0,
lessonId == null ? 'course' : Number(lessonId) || 0,
String(direction || 'BOTH').toUpperCase(),
normalizeSrsText(learning),
normalizeSrsText(reference),
].join('|');
return crypto.createHash('sha1').update(raw).digest('hex');
}
async function repairLessons({ apply }) {
const lessons = await VocabCourseLesson.findAll({
where: {
corePatterns: {
[Op.ne]: null,
},
},
attributes: ['id', 'courseId', 'lessonNumber', 'title', 'corePatterns'],
order: [['courseId', 'ASC'], ['lessonNumber', 'ASC']],
});
let changedLessons = 0;
for (const lesson of lessons) {
const patched = patchJson(lesson.corePatterns);
if (!patched.changed) continue;
changedLessons += 1;
console.log(
`Lesson ${lesson.id} (course ${lesson.courseId}, #${lesson.lessonNumber}, ${lesson.title}): corePatterns korrigiert`
);
if (apply) {
await lesson.update({ corePatterns: patched.value });
}
}
return changedLessons;
}
async function repairSrsItems({ apply }) {
const items = await VocabSrsItem.findAll({
where: {
[Op.or]: [
{ learning: { [Op.iLike]: '%peso%' } },
{ reference: { [Op.iLike]: '%peso%' } },
{ learning: { [Op.iLike]: '%piso%' } },
{ reference: { [Op.iLike]: '%piso%' } },
],
},
order: [['id', 'ASC']],
});
let changedItems = 0;
let keyConflicts = 0;
for (const item of items) {
const pair = fixPair(item.learning, item.reference);
if (!pair.changed) continue;
changedItems += 1;
const nextKey = buildSrsItemKey({
courseId: item.courseId,
lessonId: item.lessonId,
learning: pair.left,
reference: pair.right,
direction: item.direction,
});
console.log(
`SRS ${item.id}: "${item.learning}" | "${item.reference}" -> "${pair.left}" | "${pair.right}"`
);
if (!apply) continue;
try {
await item.update({
learning: pair.left,
reference: pair.right,
itemKey: nextKey,
});
} catch (error) {
keyConflicts += 1;
console.warn(` item_key-Konflikt bei SRS ${item.id}; aktualisiere Textwerte mit altem Key.`);
await item.update({
learning: pair.left,
reference: pair.right,
});
}
}
return { changedItems, keyConflicts };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
console.log(`Modus: ${args.apply ? 'APPLY (schreibt Änderungen)' : 'DRY-RUN (keine Änderungen)'}`);
await sequelize.authenticate();
const changedLessons = await repairLessons(args);
const { changedItems, keyConflicts } = await repairSrsItems(args);
console.log('');
console.log(`Betroffene Lektionen: ${changedLessons}`);
console.log(`Betroffene SRS-Items: ${changedItems}`);
if (keyConflicts > 0) {
console.log(`SRS-Items mit behaltenem altem item_key wegen Unique-Konflikt: ${keyConflicts}`);
}
if (!args.apply) {
console.log('Zum Anwenden erneut mit --apply ausführen.');
}
}
main()
.then(async () => {
await sequelize.close();
})
.catch(async (error) => {
console.error('Fehler beim Reparieren der Bisaya-Zahlenpaare:', error);
await sequelize.close();
process.exit(1);
});

View File

@@ -156,6 +156,7 @@
<script> <script>
import DialogWidget from '@/components/DialogWidget.vue'; import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { normalizeComparableWithNumberWords } from '@/utils/numberAnswerVariants.js';
const PRACTICE_MIN_EXPOSURES = 3; const PRACTICE_MIN_EXPOSURES = 3;
const SRS_SESSION_STORAGE_VERSION = 2; const SRS_SESSION_STORAGE_VERSION = 2;
@@ -630,7 +631,8 @@ export default {
} }
}, },
normalize(s) { normalize(s) {
const normalized = String(s || '') const baseNormalize = (value) => {
const normalized = String(value || '')
.trim() .trim()
.toLowerCase() .toLowerCase()
.normalize('NFKC') .normalize('NFKC')
@@ -638,6 +640,8 @@ export default {
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
return normalized.replace(/\s+/g, ''); return normalized.replace(/\s+/g, '');
};
return normalizeComparableWithNumberWords(s, baseNormalize);
}, },
isInstructionLikeText(value) { isInstructionLikeText(value) {
const text = String(value || '').trim(); const text = String(value || '').trim();

View File

@@ -0,0 +1,110 @@
const GERMAN_NUMBER_WORDS = new Map([
['null', '0'],
['ein', '1'],
['eins', '1'],
['eine', '1'],
['einen', '1'],
['einem', '1'],
['einer', '1'],
['zwei', '2'],
['drei', '3'],
['vier', '4'],
['fuenf', '5'],
['funf', '5'],
['fünf', '5'],
['sechs', '6'],
['sieben', '7'],
['acht', '8'],
['neun', '9'],
['zehn', '10'],
['elf', '11'],
['zwoelf', '12'],
['zwolf', '12'],
['zwölf', '12'],
['dreizehn', '13'],
['vierzehn', '14'],
['fuenfzehn', '15'],
['funfzehn', '15'],
['fünfzehn', '15'],
['sechzehn', '16'],
['siebzehn', '17'],
['achtzehn', '18'],
['neunzehn', '19'],
['zwanzig', '20'],
['dreissig', '30'],
['dreißig', '30'],
['vierzig', '40'],
['fuenfzig', '50'],
['funfzig', '50'],
['fünfzig', '50'],
['sechzig', '60'],
['siebzig', '70'],
['achtzig', '80'],
['neunzig', '90'],
['hundert', '100'],
['einhundert', '100'],
['tausend', '1000'],
['eintausend', '1000'],
['zweitausend', '2000']
]);
const GERMAN_ONES = [
['', ''],
['ein', 'eins'],
['zwei'],
['drei'],
['vier'],
['fuenf', 'funf', 'fünf'],
['sechs'],
['sieben'],
['acht'],
['neun']
];
const GERMAN_TENS = {
20: ['zwanzig'],
30: ['dreissig', 'dreißig'],
40: ['vierzig'],
50: ['fuenfzig', 'funfzig', 'fünfzig'],
60: ['sechzig'],
70: ['siebzig'],
80: ['achtzig'],
90: ['neunzig']
};
for (const [tensValue, tensWords] of Object.entries(GERMAN_TENS)) {
const tens = Number(tensValue);
for (let one = 1; one <= 9; one += 1) {
for (const oneWord of GERMAN_ONES[one]) {
for (const tenWord of tensWords) {
GERMAN_NUMBER_WORDS.set(`${oneWord}und${tenWord}`, String(tens + one));
}
}
}
}
function stripGermanNumberSeparators(value) {
return String(value || '')
.replace(/[\s.-]+/g, '');
}
export function canonicalizeNumberAnswer(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) return '';
const compact = stripGermanNumberSeparators(raw);
if (/^\d+$/.test(compact)) {
return String(Number(compact));
}
return GERMAN_NUMBER_WORDS.get(compact) || '';
}
export function normalizeComparableWithNumberWords(value, baseNormalizer) {
const rawCanonical = canonicalizeNumberAnswer(value);
if (rawCanonical) return rawCanonical;
const normalized = baseNormalizer(String(value || ''));
const normalizedCanonical = canonicalizeNumberAnswer(normalized);
return normalizedCanonical || normalized;
}

View File

@@ -75,6 +75,7 @@
<script> <script>
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { normalizeComparableWithNumberWords } from '@/utils/numberAnswerVariants.js';
export default { export default {
name: 'VocabLessonReviewView', name: 'VocabLessonReviewView',
@@ -119,9 +120,12 @@ export default {
}, },
methods: { methods: {
normalize(s) { normalize(s) {
const base = String(s || '').trim().toLowerCase(); const baseNormalize = (value) => {
const base = String(value || '').trim().toLowerCase();
// Satzzeichen am Ende ignorieren (Punkt, Fragezeichen, Ausrufezeichen, Komma, Strichpunkt, Doppelpunkt) // Satzzeichen am Ende ignorieren (Punkt, Fragezeichen, Ausrufezeichen, Komma, Strichpunkt, Doppelpunkt)
return base.replace(/[.,!?;:]+$/g, '').replace(/\s+/g, ' '); return base.replace(/[.,!?;:]+$/g, '').replace(/\s+/g, ' ');
};
return normalizeComparableWithNumberWords(s, baseNormalize);
}, },
getItemKey(item) { getItemKey(item) {
return `${String(item?.gloss || '').trim()}|${String(item?.target || '').trim()}`; return `${String(item?.gloss || '').trim()}|${String(item?.target || '').trim()}`;

View File

@@ -1087,6 +1087,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { normalizeComparableWithNumberWords } from '@/utils/numberAnswerVariants.js';
const debugLog = () => {}; const debugLog = () => {};
const LESSON_STATE_VERSION = 1; const LESSON_STATE_VERSION = 1;
@@ -4271,7 +4272,8 @@ export default {
} }
}, },
normalizeComparableText(value) { normalizeComparableText(value) {
const normalized = String(value || '') const baseNormalize = (source) => {
const normalized = String(source || '')
.trim() .trim()
.toLowerCase() .toLowerCase()
.normalize('NFKC') .normalize('NFKC')
@@ -4279,6 +4281,8 @@ export default {
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
return normalized.replace(/\s+/g, ''); return normalized.replace(/\s+/g, '');
};
return normalizeComparableWithNumberWords(value, baseNormalize);
}, },
stripTrailingParentheticalNotes(value) { stripTrailingParentheticalNotes(value) {
let text = String(value || '').trim(); let text = String(value || '').trim();