feat(vocab): implement SRS pair fingerprinting and deduplicate course-wide SRS items
This commit is contained in:
@@ -123,6 +123,15 @@ export default class VocabService {
|
||||
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 } = {}) {
|
||||
return (Array.isArray(vocabs) ? vocabs : [])
|
||||
.map((entry) => {
|
||||
@@ -255,20 +264,24 @@ export default class VocabService {
|
||||
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({
|
||||
where: {
|
||||
userId,
|
||||
itemKey: {
|
||||
[Op.in]: decorated.map((entry) => entry.itemKey)
|
||||
}
|
||||
}
|
||||
where: { userId, courseId: Number(courseId) },
|
||||
order: [['correctCount', 'DESC'], ['lastReviewedAt', 'DESC'], ['id', 'ASC']]
|
||||
});
|
||||
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 createdItems = [];
|
||||
for (const entry of decorated) {
|
||||
if (existingByKey.has(entry.itemKey)) {
|
||||
const fingerprint = this._buildSrsPairFingerprint(entry);
|
||||
if (existingByPair.has(fingerprint)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -295,11 +308,11 @@ export default class VocabService {
|
||||
nextDueAt: safeNextDue
|
||||
});
|
||||
createdItems.push(created);
|
||||
existingByKey.set(entry.itemKey, created);
|
||||
existingByPair.set(fingerprint, created);
|
||||
}
|
||||
|
||||
return decorated.map((entry) => {
|
||||
const item = existingByKey.get(entry.itemKey);
|
||||
const item = existingByPair.get(this._buildSrsPairFingerprint(entry));
|
||||
return {
|
||||
...entry,
|
||||
srs: item ? {
|
||||
@@ -2045,21 +2058,21 @@ export default class VocabService {
|
||||
]
|
||||
});
|
||||
const validPool = await this.getCompletedLessonVocabPool(hashedUserId, course.id);
|
||||
const validKeys = new Set(
|
||||
const validPairs = new Set(
|
||||
(Array.isArray(validPool?.vocabs) ? validPool.vocabs : [])
|
||||
.map((entry) => String(entry?.itemKey || '').trim())
|
||||
.map((entry) => this._buildSrsPairFingerprint(entry))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const validDueRows = scheduledRows.filter((item) =>
|
||||
this._isTrainableSrsPair(item)
|
||||
&& (!validKeys.size || validKeys.has(String(item.itemKey || '').trim()))
|
||||
&& (!validPairs.size || validPairs.has(this._buildSrsPairFingerprint(item)))
|
||||
&& this._getSrsScheduleDateKey(item.nextDueAt) <= todayKey
|
||||
);
|
||||
const upcomingByDate = new Map();
|
||||
scheduledRows
|
||||
.filter((item) => (
|
||||
this._isTrainableSrsPair(item)
|
||||
&& (!validKeys.size || validKeys.has(String(item.itemKey || '').trim()))
|
||||
&& (!validPairs.size || validPairs.has(this._buildSrsPairFingerprint(item)))
|
||||
&& this._getSrsScheduleDateKey(item.nextDueAt) > todayKey
|
||||
))
|
||||
.forEach((item) => {
|
||||
|
||||
Reference in New Issue
Block a user