From c12c04a9b175166cb0ae419029e46f3a165eef11 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 11:45:07 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20=EB=B3=B5=EC=8A=B5=20=ED=81=90=20?= =?UTF-8?q?cold-start=20=E2=80=94=20/due=20=EC=97=90=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EC=B9=B4=EB=93=9C=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?(=EC=B2=AB=20=ED=9A=8C=EC=83=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/api/study_cards.py | 25 ++++++++++++------- .../src/routes/study/cards-study/+page.svelte | 4 ++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/app/api/study_cards.py b/app/api/study_cards.py index 96bb65d..37f9aa1 100644 --- a/app/api/study_cards.py +++ b/app/api/study_cards.py @@ -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() diff --git a/frontend/src/routes/study/cards-study/+page.svelte b/frontend/src/routes/study/cards-study/+page.svelte index 8639d21..95bd3aa 100644 --- a/frontend/src/routes/study/cards-study/+page.svelte +++ b/frontend/src/routes/study/cards-study/+page.svelte @@ -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]}일`; }