feat: add vocabulary review planning

This commit is contained in:
Codex
2026-06-28 23:55:01 +08:00
parent dfb383d281
commit fbcfa5127e
8 changed files with 406 additions and 7 deletions

View File

@@ -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],

View File

@@ -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(