feat(study): 카드 SR 모바일 학습 UI — 복습/그냥공부 2트랙 (B3)

검수 완료 카드를 모바일에서 학습하는 UI. 복습(SR)=앞면 회상→reveal→3단 자기평가(모름/애매/암) / 그냥공부(cram)=덜 본 순 휙휙+봤다(SR 무관).

- 새 페이지 /study/cards-study(+page.svelte): landing 트랙선택·진행바·결과(세션 tally)·빈/로딩 상태·cram format 필터·키보드(Space reveal·복습 J/K/L·cram Enter). 아이폰15PM 우선, 세이지 토큰.
- '암'(correct) 버튼 stage별 동적 라벨(+3/7/14일·졸업), 모름/애매=내일. correctLabel은 sr_schedule REVIEW_INTERVAL_DAYS 미러(라벨 전용, 산술 정본은 백엔드).
- API: /study-cards/due CardItem에 review_stage 추가(복습 큐에서만 채움, 동적 라벨용). _build_card_items(session,cards,stages) 확장, /due는 select(card, progress.review_stage)로 변경.
- 진입: 허브 '암기카드 학습' 카드+예정목록 갱신 / 검수 UI 헤더 '학습' 버튼.

검증: py_compile OK · 4차원 적대검토(runes·API계약·SR규칙·UX) 통과(확정 조치 0, 지적 2건 거짓양성). 로컬 vite 빌드 불가(node_modules 부재)→배포가 컴파일 게이트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-07 11:37:19 +09:00
parent 0d274cc5fe
commit 861db96305
4 changed files with 388 additions and 11 deletions
+17 -6
View File
@@ -47,6 +47,9 @@ class CardItem(BaseModel):
needs_review: bool
flagged_by: str | None = None
evidence: list[CardEvidence] = []
# 복습(SR) 큐에서만 채움 — 정답('암') 시 다음 복습일 미리보기 라벨 계산용
# (stage별 동적: +3/7/14일·졸업). deck/검수 응답에선 None.
review_stage: int | None = None
class CardQuestionGroup(BaseModel):
@@ -86,11 +89,17 @@ _RATE_MAP = {
async def _build_card_items(
session: AsyncSession, cards: list[StudyMemoCard]
session: AsyncSession,
cards: list[StudyMemoCard],
stages: dict[int, int | None] | None = None,
) -> list[CardItem]:
"""카드 목록 → CardItem(evidence 동반). due/deck 학습 flow 공용."""
"""카드 목록 → CardItem(evidence 동반). due/deck 학습 flow 공용.
stages: card_id → review_stage (복습 큐에서만 전달, 동적 라벨 미리보기용).
"""
if not cards:
return []
stages = stages or {}
ids = [c.id for c in cards]
ev_rows = (
await session.execute(
@@ -106,7 +115,7 @@ async def _build_card_items(
CardItem(
id=c.id, source_kind=c.source_kind, format=c.format, cue=c.cue, fact=c.fact,
cloze_text=c.cloze_text, needs_review=c.needs_review, flagged_by=c.flagged_by,
evidence=ev_by.get(c.id, []),
evidence=ev_by.get(c.id, []), review_stage=stages.get(c.id),
)
for c in cards
]
@@ -256,7 +265,7 @@ async def due_cards(
now = datetime.now(timezone.utc)
rows = (
await session.execute(
select(StudyMemoCard)
select(StudyMemoCard, StudyMemoCardProgress.review_stage)
.join(StudyMemoCardProgress, StudyMemoCardProgress.card_id == StudyMemoCard.id)
.where(
StudyMemoCard.user_id == user.id,
@@ -272,8 +281,10 @@ async def due_cards(
.order_by(StudyMemoCardProgress.due_at.asc())
.limit(limit)
)
).scalars().all()
return await _build_card_items(session, list(rows))
).all()
cards = [r[0] for r in rows]
stages = {r[0].id: r[1] for r in rows}
return await _build_card_items(session, cards, stages)
@router.post("/{card_id}/rate", response_model=RateResult)