feat(vocab): add hard status management for SRS items and update related logic

This commit is contained in:
Torsten Schulz (local)
2026-08-20 15:17:11 +02:00
parent 2c0944ae2c
commit 3a0945734f
10 changed files with 181 additions and 263 deletions

View File

@@ -43,10 +43,16 @@ class VocabController {
this.getCourseSrsDue = this._wrapWithUser((userId, req) =>
this.service.getCourseSrsDue(userId, req.params.courseId, req.query)
);
this.getCourseHardSrsItems = this._wrapWithUser((userId, req) =>
this.service.getCourseHardSrsItems(userId, req.params.courseId)
);
this.reviewSrsItem = this._wrapWithUser((userId, req) =>
this.service.reviewSrsItem(userId, req.body),
{ successStatus: 201 }
);
this.clearSrsItemHardStatus = this._wrapWithUser((userId, req) =>
this.service.clearSrsItemHardStatus(userId, req.body)
);
this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode));
this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body));
this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId));

View File

@@ -0,0 +1,24 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE community.vocab_srs_item
ADD COLUMN IF NOT EXISTS is_hard BOOLEAN NOT NULL DEFAULT FALSE;
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS idx_vocab_srs_item_hard
ON community.vocab_srs_item (user_id, course_id, is_hard, next_due_at);
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`
DROP INDEX IF EXISTS community.idx_vocab_srs_item_hard;
`);
await queryInterface.sequelize.query(`
ALTER TABLE community.vocab_srs_item
DROP COLUMN IF EXISTS is_hard;
`);
}
};

View File

@@ -81,6 +81,12 @@ VocabSrsItem.init({
allowNull: false,
defaultValue: 0,
field: 'lapse_count'
},
isHard: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_hard'
}
}, {
sequelize,

View File

@@ -36,10 +36,12 @@ router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getComp
router.get('/courses/:courseId/dictionary', vocabController.getCourseDictionary);
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
router.get('/courses/:courseId/srs/due', vocabController.getCourseSrsDue);
router.get('/courses/:courseId/srs/hard', vocabController.getCourseHardSrsItems);
router.get('/courses/:courseId', vocabController.getCourse);
router.put('/courses/:courseId', vocabController.updateCourse);
router.delete('/courses/:courseId', vocabController.deleteCourse);
router.post('/srs/review', vocabController.reviewSrsItem);
router.patch('/srs/hard', vocabController.clearSrsItemHardStatus);
// Lessons
router.post('/courses/:courseId/lessons', vocabController.addLessonToCourse);

View File

@@ -15,6 +15,8 @@ import { notifyUser } from '../utils/socket.js';
import { Op } from 'sequelize';
import { BISAYA_PHASE1_DIDACTICS, BISAYA_DIDACTICS_FRAGMENTS } from '../scripts/bisaya-course-phase1.js';
const DAILY_SRS_LIMIT = 50;
export default class VocabService {
_stripGermanNumberSeparators(value) {
return String(value || '').replace(/[\s.-]+/g, '');
@@ -202,35 +204,23 @@ export default class VocabService {
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
}
_calculateSrsSchedule(item, { correct, rating = null } = {}) {
_calculateSrsSchedule(item, { correct } = {}) {
const now = new Date();
const previousStage = Math.max(0, Number(item?.stage) || 0);
const previousInterval = Math.max(0, Number(item?.intervalDays) || 0);
const normalizedRating = String(rating || '').toLowerCase();
const isCorrect = Boolean(correct) && normalizedRating !== 'again';
if (!isCorrect) {
if (!correct) {
return {
stage: Math.max(0, previousStage - 1),
stage: 0,
intervalDays: 0,
nextDueAt: new Date(now.getTime() + 10 * 60 * 1000),
lapseDelta: 1
};
}
// Neue einfache Policy:
// - 'easy' -> 7 Tage
// - 'good'/'normal' -> 4 Tage
// - 'hard' -> 1 Tag
// Außerdem: nextDueAt darf nicht mehr am gleichen Kalendertag liegen.
let intervalDays;
if (normalizedRating === 'easy') {
intervalDays = 7;
} else if (normalizedRating === 'hard') {
intervalDays = 1;
} else {
// default / 'good' / unspecified
intervalDays = 4;
}
// Intervals are based only on successful answers in the daily SRS review.
// There is deliberately no user-selected difficulty rating: each successful
// recall moves the term to the next, less frequent interval.
const intervals = [1, 3, 7, 14, 30, 60, 120];
const previousCorrectCount = Math.max(0, Number(item?.correctCount) || 0);
const intervalDays = intervals[Math.min(previousCorrectCount, intervals.length - 1)];
// Bestimme nextDueAt als Start des Tages (00:00) nach intervalDays
const nextDueAt = new Date(now);
@@ -239,8 +229,7 @@ export default class VocabService {
// Gehe vorwärts: morgen + (intervalDays - 1)
nextDueAt.setDate(nextDueAt.getDate() + 1 + Math.max(0, intervalDays - 1));
// Stage-Logik: einfache Fortschrittsstufe basierend auf intervalDays
let nextStage = Math.min(8, Math.max(0, Math.floor(Math.log2(intervalDays + 1))));
const nextStage = Math.min(intervals.length, previousCorrectCount + 1);
return {
stage: nextStage,
@@ -2012,7 +2001,10 @@ export default class VocabService {
throw err;
}
const limit = this._clampInteger(query?.limit, { min: 1, max: 100, fallback: 30 });
// A daily SRS batch is intentionally capped. Excess due items remain due
// and are shown on the following day rather than silently overloading today.
const requestedLimit = this._clampInteger(query?.limit, { min: 1, max: DAILY_SRS_LIMIT, fallback: DAILY_SRS_LIMIT });
const limit = Math.min(DAILY_SRS_LIMIT, requestedLimit);
const now = new Date();
const dueWhere = {
userId: user.id,
@@ -2052,6 +2044,7 @@ export default class VocabService {
dueAt: now.toISOString(),
count: rows.length,
totalDueCount,
dailyLimit: DAILY_SRS_LIMIT,
limit,
items: rows.map((item) => ({
itemKey: item.itemKey,
@@ -2066,7 +2059,42 @@ export default class VocabService {
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
correctCount: item.correctCount,
wrongCount: item.wrongCount,
lapseCount: item.lapseCount
lapseCount: item.lapseCount,
isHard: item.isHard
}))
};
}
async getCourseHardSrsItems(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const items = await VocabSrsItem.findAll({
where: { userId: user.id, courseId: Number(course.id), isHard: true },
order: [['nextDueAt', 'ASC'], ['wrongCount', 'DESC']]
});
return {
courseId: course.id,
items: items.filter((item) => this._isTrainableSrsPair(item)).map((item) => ({
itemKey: item.itemKey,
courseId: item.courseId,
lessonId: item.lessonId,
learning: item.learning,
reference: item.reference,
direction: item.direction,
correctCount: item.correctCount,
wrongCount: item.wrongCount,
isHard: true
}))
};
}
@@ -2143,8 +2171,7 @@ export default class VocabService {
const correct = Boolean(payload?.correct);
const schedule = this._calculateSrsSchedule(item, {
correct,
rating: payload?.rating
correct
});
item.stage = schedule.stage;
@@ -2156,6 +2183,7 @@ export default class VocabService {
} else {
item.wrongCount += 1;
item.lapseCount += schedule.lapseDelta;
item.isHard = true;
}
await item.save();
@@ -2180,10 +2208,31 @@ export default class VocabService {
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
correctCount: item.correctCount,
wrongCount: item.wrongCount,
lapseCount: item.lapseCount
lapseCount: item.lapseCount,
isHard: item.isHard
};
}
async clearSrsItemHardStatus(hashedUserId, payload = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const courseId = this._clampInteger(payload?.courseId, { min: 1, max: 1_000_000, fallback: 0 });
const itemKey = this._sanitizeShortString(payload?.itemKey, 80);
if (!courseId || !itemKey) {
const err = new Error('Missing SRS item');
err.status = 400;
throw err;
}
const item = await VocabSrsItem.findOne({ where: { userId: user.id, courseId, itemKey } });
if (!item) {
const err = new Error('SRS item not found');
err.status = 404;
throw err;
}
item.isHard = false;
await item.save();
return { itemKey: item.itemKey, isHard: false };
}
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);