feat(vocab): add hard status management for SRS items and update related logic
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user