forked from wangziqi/gongxue-base
feat: add vocabulary review planning
This commit is contained in:
@@ -13,8 +13,10 @@ import {
|
||||
submitPracticeSessionRoute,
|
||||
toggleFavoriteQuestionRoute,
|
||||
toggleFavoriteWordRoute,
|
||||
reviewWordRoute,
|
||||
updateWordProgressRoute,
|
||||
wordProgressRoute,
|
||||
wordReviewPlanRoute,
|
||||
wordStatsRoute,
|
||||
wrongQuestionReviewPlanRoute,
|
||||
wrongQuestionsRoute,
|
||||
@@ -36,6 +38,8 @@ export const learningRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
|
||||
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
|
||||
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
|
||||
['GET', '/api/learning/vocabulary/review-plan', wordReviewPlanRoute],
|
||||
['POST', '/api/learning/vocabulary/review', reviewWordRoute],
|
||||
['GET', '/api/learning/vocabulary/favorites', favoriteWordsRoute],
|
||||
['POST', '/api/learning/vocabulary/favorites', toggleFavoriteWordRoute],
|
||||
['GET', '/api/learning/vocabulary/stats', wordStatsRoute],
|
||||
|
||||
@@ -145,6 +145,18 @@ interface SectionStat {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface WordProgressReviewRow {
|
||||
id: string;
|
||||
wordId: string;
|
||||
status: string;
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
reviewCount: number;
|
||||
correctStreak: number;
|
||||
easeFactor: string | number;
|
||||
nextReviewDate: string | null;
|
||||
}
|
||||
|
||||
interface PracticeAssembly {
|
||||
mode: string;
|
||||
targetType: string | null;
|
||||
@@ -1644,6 +1656,67 @@ function normalizeWordStatus(value: string) {
|
||||
return 'learning';
|
||||
}
|
||||
|
||||
function normalizeWordReviewResult(value: string) {
|
||||
if (['correct', 'known', 'mastered'].includes(value)) return 'known';
|
||||
if (['wrong', 'unknown', 'again'].includes(value)) return 'unknown';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function computeWordReviewSchedule(input: {
|
||||
result: 'known' | 'unknown';
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
reviewCount: number;
|
||||
correctStreak: number;
|
||||
easeFactor: number;
|
||||
}) {
|
||||
const known = input.result === 'known';
|
||||
const correctCount = input.correctCount + (known ? 1 : 0);
|
||||
const wrongCount = input.wrongCount + (known ? 0 : 1);
|
||||
const reviewCount = input.reviewCount + 1;
|
||||
const correctStreak = known ? input.correctStreak + 1 : 0;
|
||||
const easeFactor = Math.max(1.3, Math.min(3, input.easeFactor + (known ? 0.08 : -0.2)));
|
||||
const intervalDays = known
|
||||
? correctStreak <= 1
|
||||
? 1
|
||||
: correctStreak === 2
|
||||
? 3
|
||||
: correctStreak === 3
|
||||
? 7
|
||||
: Math.min(60, Math.round(7 * Math.pow(easeFactor, correctStreak - 3)))
|
||||
: 0;
|
||||
const status = known
|
||||
? correctStreak >= 4
|
||||
? 'mastered'
|
||||
: 'reviewing'
|
||||
: 'learning';
|
||||
const dueLevel = known
|
||||
? status === 'mastered'
|
||||
? 'mastered'
|
||||
: correctStreak <= 1
|
||||
? 'soon'
|
||||
: 'later'
|
||||
: 'again';
|
||||
const nextReviewDate = new Date();
|
||||
if (known) {
|
||||
nextReviewDate.setDate(nextReviewDate.getDate() + intervalDays);
|
||||
} else {
|
||||
nextReviewDate.setHours(nextReviewDate.getHours() + 6);
|
||||
}
|
||||
|
||||
return {
|
||||
correctCount,
|
||||
wrongCount,
|
||||
reviewCount,
|
||||
correctStreak,
|
||||
easeFactor: round2(easeFactor),
|
||||
intervalDays,
|
||||
status,
|
||||
dueLevel,
|
||||
nextReviewDate: nextReviewDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function wordProgressRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
@@ -1655,6 +1728,9 @@ export async function wordProgressRoute(ctx: RequestContext) {
|
||||
`
|
||||
select p.id, p.word_id as "wordId", p.status,
|
||||
p.correct_count as "correctCount", p.wrong_count as "wrongCount",
|
||||
p.review_count as "reviewCount", p.correct_streak as "correctStreak",
|
||||
p.ease_factor as "easeFactor", p.last_result as "lastResult",
|
||||
p.due_level as "dueLevel",
|
||||
p.last_review_date as "lastReviewDate", p.next_review_date as "nextReviewDate",
|
||||
p.created_at as "createdAt", p.updated_at as "updatedAt",
|
||||
w.unit_id as "unitId", w.word, w.phonetic, w.meaning
|
||||
@@ -1672,6 +1748,216 @@ export async function wordProgressRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function wordReviewPlanRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const reviewLimit = intParam(ctx, 'reviewLimit', 30, 200);
|
||||
const newLimit = intParam(ctx, 'newLimit', 20, 100);
|
||||
|
||||
const dueWords = await query(
|
||||
`
|
||||
select p.word_id as "wordId", p.status,
|
||||
p.correct_count as "correctCount", p.wrong_count as "wrongCount",
|
||||
p.review_count as "reviewCount", p.correct_streak as "correctStreak",
|
||||
p.ease_factor as "easeFactor", p.due_level as "dueLevel",
|
||||
p.last_review_date as "lastReviewDate", p.next_review_date as "nextReviewDate",
|
||||
w.unit_id as "unitId", w.word, w.phonetic, w.meaning,
|
||||
w.example, w.example_translation as "exampleTranslation",
|
||||
w.difficulty, w.tags
|
||||
from public.user_word_progress p
|
||||
join public.vocabulary_words w on w.id = p.word_id and w.tenant_id = p.tenant_id
|
||||
where p.tenant_id = $1
|
||||
and p.user_id = $2
|
||||
and w.is_active = true
|
||||
and ($3::uuid is null or w.unit_id = $3::uuid)
|
||||
and (
|
||||
p.next_review_date is null
|
||||
or p.next_review_date <= now()
|
||||
or p.due_level in ('again', 'new')
|
||||
)
|
||||
and p.status <> 'mastered'
|
||||
order by
|
||||
case p.due_level when 'again' then 0 when 'new' then 1 when 'soon' then 2 else 3 end,
|
||||
p.next_review_date asc nulls first,
|
||||
p.wrong_count desc,
|
||||
w.sort_order asc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, userId, unitId || null, reviewLimit],
|
||||
);
|
||||
|
||||
const newWords = await query(
|
||||
`
|
||||
select w.id as "wordId", 'new' as status,
|
||||
0 as "correctCount", 0 as "wrongCount", 0 as "reviewCount",
|
||||
0 as "correctStreak", 2.5 as "easeFactor", 'new' as "dueLevel",
|
||||
null::timestamptz as "lastReviewDate", null::timestamptz as "nextReviewDate",
|
||||
w.unit_id as "unitId", w.word, w.phonetic, w.meaning,
|
||||
w.example, w.example_translation as "exampleTranslation",
|
||||
w.difficulty, w.tags
|
||||
from public.vocabulary_words w
|
||||
where w.tenant_id = $1
|
||||
and w.is_active = true
|
||||
and ($2::uuid is null or w.unit_id = $2::uuid)
|
||||
and not exists (
|
||||
select 1 from public.user_word_progress p
|
||||
where p.tenant_id = w.tenant_id
|
||||
and p.user_id = $3
|
||||
and p.word_id = w.id
|
||||
)
|
||||
order by w.sort_order asc, w.created_at asc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, unitId || null, userId, newLimit],
|
||||
);
|
||||
|
||||
return {
|
||||
item: {
|
||||
unitId: unitId || null,
|
||||
reviewLimit,
|
||||
newLimit,
|
||||
dueCount: dueWords.length,
|
||||
newCount: newWords.length,
|
||||
totalPlanned: dueWords.length + newWords.length,
|
||||
dueWords,
|
||||
newWords,
|
||||
words: [...dueWords, ...newWords],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function reviewWordRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const wordId = requiredString(body, 'wordId');
|
||||
const result = normalizeWordReviewResult(optionalString(body, 'result') || optionalString(body, 'answerResult'));
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const word = await client.query<{ id: string }>(
|
||||
`
|
||||
select id from public.vocabulary_words
|
||||
where tenant_id = $1 and id = $2 and is_active = true
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, wordId],
|
||||
);
|
||||
if (!word.rows[0]) throw new HttpError(404, 'Vocabulary word not found', 'WORD_NOT_FOUND');
|
||||
|
||||
const existing = await client.query<WordProgressReviewRow>(
|
||||
`
|
||||
select id, word_id as "wordId", status,
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
review_count as "reviewCount", correct_streak as "correctStreak",
|
||||
ease_factor as "easeFactor", next_review_date as "nextReviewDate"
|
||||
from public.user_word_progress
|
||||
where tenant_id = $1 and user_id = $2 and word_id = $3
|
||||
for update
|
||||
`,
|
||||
[tenantId, userId, wordId],
|
||||
);
|
||||
const current = existing.rows[0] || {
|
||||
id: '',
|
||||
wordId,
|
||||
status: 'new',
|
||||
correctCount: 0,
|
||||
wrongCount: 0,
|
||||
reviewCount: 0,
|
||||
correctStreak: 0,
|
||||
easeFactor: 2.5,
|
||||
nextReviewDate: null,
|
||||
};
|
||||
const schedule = computeWordReviewSchedule({
|
||||
result,
|
||||
correctCount: Number(current.correctCount || 0),
|
||||
wrongCount: Number(current.wrongCount || 0),
|
||||
reviewCount: Number(current.reviewCount || 0),
|
||||
correctStreak: Number(current.correctStreak || 0),
|
||||
easeFactor: finiteNumber(current.easeFactor, 2.5),
|
||||
});
|
||||
|
||||
const progress = await client.query(
|
||||
`
|
||||
insert into public.user_word_progress (
|
||||
tenant_id, user_id, word_id, status, correct_count, wrong_count,
|
||||
review_count, correct_streak, ease_factor, last_result, due_level,
|
||||
last_review_date, next_review_date, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
now(), $12::timestamptz, $13::jsonb
|
||||
)
|
||||
on conflict (tenant_id, user_id, word_id)
|
||||
do update set status = excluded.status,
|
||||
correct_count = excluded.correct_count,
|
||||
wrong_count = excluded.wrong_count,
|
||||
review_count = excluded.review_count,
|
||||
correct_streak = excluded.correct_streak,
|
||||
ease_factor = excluded.ease_factor,
|
||||
last_result = excluded.last_result,
|
||||
due_level = excluded.due_level,
|
||||
last_review_date = now(),
|
||||
next_review_date = excluded.next_review_date,
|
||||
metadata = public.user_word_progress.metadata || excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, word_id as "wordId", status,
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
review_count as "reviewCount", correct_streak as "correctStreak",
|
||||
ease_factor as "easeFactor", last_result as "lastResult",
|
||||
due_level as "dueLevel",
|
||||
last_review_date as "lastReviewDate", next_review_date as "nextReviewDate",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
wordId,
|
||||
schedule.status,
|
||||
schedule.correctCount,
|
||||
schedule.wrongCount,
|
||||
schedule.reviewCount,
|
||||
schedule.correctStreak,
|
||||
schedule.easeFactor,
|
||||
result,
|
||||
schedule.dueLevel,
|
||||
schedule.nextReviewDate,
|
||||
JSON.stringify({
|
||||
source: 'review_word',
|
||||
result,
|
||||
intervalDays: schedule.intervalDays,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.student_profiles
|
||||
set mastered_words_count = (
|
||||
select count(*) from public.user_word_progress
|
||||
where tenant_id = $1 and user_id = $2 and status = 'mastered'
|
||||
),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
return {
|
||||
...progress.rows[0],
|
||||
review: {
|
||||
result,
|
||||
intervalDays: schedule.intervalDays,
|
||||
nextReviewDate: schedule.nextReviewDate,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function updateWordProgressRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
@@ -1681,6 +1967,8 @@ export async function updateWordProgressRoute(ctx: RequestContext) {
|
||||
const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0));
|
||||
const wrongDelta = Math.max(0, optionalInteger(body, 'wrongDelta', 0));
|
||||
const nextReviewDate = optionalString(body, 'nextReviewDate') || null;
|
||||
const lastResult = correctDelta > 0 ? 'known' : wrongDelta > 0 ? 'unknown' : null;
|
||||
const dueLevel = status === 'mastered' ? 'mastered' : wrongDelta > 0 ? 'again' : status === 'new' ? 'new' : 'soon';
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const word = await client.query<{ id: string }>(
|
||||
@@ -1697,22 +1985,30 @@ export async function updateWordProgressRoute(ctx: RequestContext) {
|
||||
`
|
||||
insert into public.user_word_progress (
|
||||
tenant_id, user_id, word_id, status, correct_count, wrong_count,
|
||||
review_count, correct_streak, last_result, due_level,
|
||||
last_review_date, next_review_date
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, now(), $7::timestamptz)
|
||||
values ($1, $2, $3, $4, $5, $6, case when $5::integer + $6::integer > 0 then 1 else 0 end, $8, $9, $10, now(), $7::timestamptz)
|
||||
on conflict (tenant_id, user_id, word_id)
|
||||
do update set status = excluded.status,
|
||||
correct_count = public.user_word_progress.correct_count + $5,
|
||||
wrong_count = public.user_word_progress.wrong_count + $6,
|
||||
review_count = public.user_word_progress.review_count + case when $5::integer + $6::integer > 0 then 1 else 0 end,
|
||||
correct_streak = case when $5 > 0 then public.user_word_progress.correct_streak + 1 when $6 > 0 then 0 else public.user_word_progress.correct_streak end,
|
||||
last_result = coalesce(excluded.last_result, public.user_word_progress.last_result),
|
||||
due_level = excluded.due_level,
|
||||
last_review_date = now(),
|
||||
next_review_date = coalesce(excluded.next_review_date, public.user_word_progress.next_review_date),
|
||||
updated_at = now()
|
||||
returning id, word_id as "wordId", status,
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
review_count as "reviewCount", correct_streak as "correctStreak",
|
||||
ease_factor as "easeFactor", last_result as "lastResult",
|
||||
due_level as "dueLevel",
|
||||
last_review_date as "lastReviewDate", next_review_date as "nextReviewDate",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[tenantId, userId, wordId, status, correctDelta, wrongDelta, nextReviewDate],
|
||||
[tenantId, userId, wordId, status, correctDelta, wrongDelta, nextReviewDate, correctDelta > 0 ? 1 : 0, lastResult, dueLevel],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
|
||||
Reference in New Issue
Block a user