feat(vocab): implement SRS pair fingerprinting and deduplicate course-wide SRS items

This commit is contained in:
Torsten Schulz (local)
2026-08-28 09:05:06 +02:00
parent 13aaf8c0f2
commit fe08433747
2 changed files with 77 additions and 14 deletions

View File

@@ -0,0 +1,50 @@
'use strict';
/**
* Legacy SRS keys included lesson_id while the course pool used NULL. The same
* word pair could therefore receive parallel schedules. Keep the most advanced
* record, preserve its earliest due time, and remove only exact course-wide
* duplicates.
*/
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
WITH grouped AS (
SELECT
id,
FIRST_VALUE(id) OVER bucket AS keeper_id,
MIN(next_due_at) OVER bucket AS earliest_due_at,
MAX(correct_count) OVER bucket AS max_correct_count,
MAX(wrong_count) OVER bucket AS max_wrong_count,
MAX(lapse_count) OVER bucket AS max_lapse_count,
BOOL_OR(is_hard) OVER bucket AS any_hard,
ROW_NUMBER() OVER bucket AS row_number
FROM community.vocab_srs_item
WINDOW bucket AS (
PARTITION BY user_id, course_id, UPPER(direction),
LOWER(REGEXP_REPLACE(learning, '[[:punct:][:space:]]+', ' ', 'g')),
LOWER(REGEXP_REPLACE(reference, '[[:punct:][:space:]]+', ' ', 'g'))
ORDER BY correct_count DESC, last_reviewed_at DESC NULLS LAST, id ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
), updated AS (
UPDATE community.vocab_srs_item AS item
SET next_due_at = grouped.earliest_due_at,
correct_count = grouped.max_correct_count,
wrong_count = grouped.max_wrong_count,
lapse_count = grouped.max_lapse_count,
is_hard = grouped.any_hard
FROM grouped
WHERE item.id = grouped.keeper_id
RETURNING item.id
)
DELETE FROM community.vocab_srs_item AS item
USING grouped
WHERE item.id = grouped.id
AND grouped.row_number > 1;
`);
},
async down() {
// Duplicate rows are intentionally not recreated.
}
};

View File

@@ -123,6 +123,15 @@ export default class VocabService {
return crypto.createHash('sha1').update(raw).digest('hex'); return crypto.createHash('sha1').update(raw).digest('hex');
} }
_buildSrsPairFingerprint({ courseId, learning, reference, direction = 'BOTH' }) {
return [
Number(courseId) || 0,
String(direction || 'BOTH').toUpperCase(),
this._normalizeSrsText(learning),
this._normalizeSrsText(reference)
].join('|');
}
_decorateSrsVocabs(vocabs = [], { courseId, lessonId = null } = {}) { _decorateSrsVocabs(vocabs = [], { courseId, lessonId = null } = {}) {
return (Array.isArray(vocabs) ? vocabs : []) return (Array.isArray(vocabs) ? vocabs : [])
.map((entry) => { .map((entry) => {
@@ -255,20 +264,24 @@ export default class VocabService {
return []; return [];
} }
// SRS is course-wide. A pair may originate from a lesson pool or a course
// pool, but must still remain one scheduling item. Matching only itemKey
// used to create duplicates because that key includes lessonId.
const existing = await VocabSrsItem.findAll({ const existing = await VocabSrsItem.findAll({
where: { where: { userId, courseId: Number(courseId) },
userId, order: [['correctCount', 'DESC'], ['lastReviewedAt', 'DESC'], ['id', 'ASC']]
itemKey: { });
[Op.in]: decorated.map((entry) => entry.itemKey) const existingByPair = new Map();
} existing.forEach((entry) => {
} const fingerprint = this._buildSrsPairFingerprint(entry);
if (!existingByPair.has(fingerprint)) existingByPair.set(fingerprint, entry);
}); });
const existingByKey = new Map(existing.map((entry) => [entry.itemKey, entry]));
const now = new Date(); const now = new Date();
const createdItems = []; const createdItems = [];
for (const entry of decorated) { for (const entry of decorated) {
if (existingByKey.has(entry.itemKey)) { const fingerprint = this._buildSrsPairFingerprint(entry);
if (existingByPair.has(fingerprint)) {
continue; continue;
} }
@@ -295,11 +308,11 @@ export default class VocabService {
nextDueAt: safeNextDue nextDueAt: safeNextDue
}); });
createdItems.push(created); createdItems.push(created);
existingByKey.set(entry.itemKey, created); existingByPair.set(fingerprint, created);
} }
return decorated.map((entry) => { return decorated.map((entry) => {
const item = existingByKey.get(entry.itemKey); const item = existingByPair.get(this._buildSrsPairFingerprint(entry));
return { return {
...entry, ...entry,
srs: item ? { srs: item ? {
@@ -2045,21 +2058,21 @@ export default class VocabService {
] ]
}); });
const validPool = await this.getCompletedLessonVocabPool(hashedUserId, course.id); const validPool = await this.getCompletedLessonVocabPool(hashedUserId, course.id);
const validKeys = new Set( const validPairs = new Set(
(Array.isArray(validPool?.vocabs) ? validPool.vocabs : []) (Array.isArray(validPool?.vocabs) ? validPool.vocabs : [])
.map((entry) => String(entry?.itemKey || '').trim()) .map((entry) => this._buildSrsPairFingerprint(entry))
.filter(Boolean) .filter(Boolean)
); );
const validDueRows = scheduledRows.filter((item) => const validDueRows = scheduledRows.filter((item) =>
this._isTrainableSrsPair(item) this._isTrainableSrsPair(item)
&& (!validKeys.size || validKeys.has(String(item.itemKey || '').trim())) && (!validPairs.size || validPairs.has(this._buildSrsPairFingerprint(item)))
&& this._getSrsScheduleDateKey(item.nextDueAt) <= todayKey && this._getSrsScheduleDateKey(item.nextDueAt) <= todayKey
); );
const upcomingByDate = new Map(); const upcomingByDate = new Map();
scheduledRows scheduledRows
.filter((item) => ( .filter((item) => (
this._isTrainableSrsPair(item) this._isTrainableSrsPair(item)
&& (!validKeys.size || validKeys.has(String(item.itemKey || '').trim())) && (!validPairs.size || validPairs.has(this._buildSrsPairFingerprint(item)))
&& this._getSrsScheduleDateKey(item.nextDueAt) > todayKey && this._getSrsScheduleDateKey(item.nextDueAt) > todayKey
)) ))
.forEach((item) => { .forEach((item) => {