feat(vocab): implement SRS pair fingerprinting and deduplicate course-wide SRS items
This commit is contained in:
@@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user