fix(study): 복습 큐 cold-start — /due 에 신규 승인 카드 포함(첫 회상)

B2 /due 가 due_at<=now(progress 보유) 카드만 반환 → progress 는 rate_card(=/rate)로만 생기고 /rate 는 /due 카드만 평가 → 신규 승인 카드가 SR 큐에 영영 못 들어가는 순환 갭. 복습 트랙이 절대 안 채워짐.

- /due 를 outerjoin 으로 재작성: 신규(progress 없음=첫 회상 전) OR 예정 due(due_at<=now, stage<4). 예정 due 먼저, 신규(due NULL) 뒤로. '첫 회상 후 due' 규칙·시안('오늘 복습'에 stage0 신규 포함)과 일치.
- 신규 카드 '암'은 백엔드가 due 안 박음(외움→큐 제외, 큐 폭발 방지)이라 correctLabel(null)='안 나옴'으로 정합(기존 '+3일'은 거짓 라벨). 큐 stage0 '암'은 그대로 '+3일'.

검증: py_compile OK. 신규 암→progress(due null, 재출제 X) / 애매·모름→due 내일 입고 / 큐 stage 전진 불변.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-07 11:45:07 +09:00
parent 861db96305
commit c12c04a9b1
2 changed files with 19 additions and 10 deletions
+16 -9
View File
@@ -16,7 +16,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, or_, select, update
from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -261,24 +261,31 @@ async def due_cards(
session: Annotated[AsyncSession, Depends(get_session)],
limit: Annotated[int, Query(ge=1, le=200)] = 30,
):
"""오늘 복습할 카드 (due_at<=now, stage<4, 검수 통과만). due_at 오름차순."""
"""오늘 복습할 카드 (검수 통과만). 두 부류:
- 신규 승인 카드(progress 없음=첫 회상 전) — SR 큐 진입 경로(첫 회상). ''이면 due 안
박고 종료('큐 폭발 방지'), 애매/모름이면 평가 즉시 due(내일)로 입고.
- 예정 due 카드(due_at<=now, stage<4).
progress 는 user+card UNIQUE 라 outer join 으로 최대 1행. 예정 due 먼저, 신규(due NULL) 뒤로."""
now = datetime.now(timezone.utc)
P = StudyMemoCardProgress
rows = (
await session.execute(
select(StudyMemoCard, StudyMemoCardProgress.review_stage)
.join(StudyMemoCardProgress, StudyMemoCardProgress.card_id == StudyMemoCard.id)
select(StudyMemoCard, P.review_stage)
.outerjoin(P, and_(P.card_id == StudyMemoCard.id, P.user_id == user.id))
.where(
StudyMemoCard.user_id == user.id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review.is_(False),
StudyMemoCardProgress.due_at.is_not(None),
StudyMemoCardProgress.due_at <= now,
or_(
StudyMemoCardProgress.review_stage.is_(None),
StudyMemoCardProgress.review_stage < 4,
P.id.is_(None), # 신규(첫 회상 전) — progress 미생성
and_(
P.due_at.is_not(None),
P.due_at <= now,
or_(P.review_stage.is_(None), P.review_stage < 4),
),
),
)
.order_by(StudyMemoCardProgress.due_at.asc())
.order_by(P.due_at.asc().nulls_last(), StudyMemoCard.id.asc())
.limit(limit)
)
).all()
@@ -22,9 +22,11 @@
import EmptyState from '$lib/components/ui/EmptyState.svelte';
// sr_schedule.py 단일 source 미러 — '암'(correct) 다음 복습일 라벨 전용(실제 스케줄은 백엔드).
// stage===null = 신규 카드(progress 없음): '암'이면 백엔드가 due 안 박음(외움→큐 제외)이라 '안 나옴'.
const REVIEW_INTERVAL_DAYS = { 1: 3, 2: 7, 3: 14 };
function correctLabel(stage) {
const ns = (stage ?? 0) + 1;
if (stage === null || stage === undefined) return '안 나옴';
const ns = stage + 1;
if (ns >= 4) return '졸업';
return `+${REVIEW_INTERVAL_DAYS[ns]}일`;
}