From 861db9630587be77b0dc9e9971c6cc8fa0a560a3 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 11:37:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20=EC=B9=B4=EB=93=9C=20SR=20?= =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=95=99=EC=8A=B5=20UI=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=B5=EC=8A=B5/=EA=B7=B8=EB=83=A5=EA=B3=B5?= =?UTF-8?q?=EB=B6=80=202=ED=8A=B8=EB=9E=99=20(B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검수 완료 카드를 모바일에서 학습하는 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) --- app/api/study_cards.py | 23 +- frontend/src/routes/study/+page.svelte | 16 +- .../routes/study/cards-review/+page.svelte | 5 +- .../src/routes/study/cards-study/+page.svelte | 355 ++++++++++++++++++ 4 files changed, 388 insertions(+), 11 deletions(-) create mode 100644 frontend/src/routes/study/cards-study/+page.svelte diff --git a/app/api/study_cards.py b/app/api/study_cards.py index 38ef817..96bb65d 100644 --- a/app/api/study_cards.py +++ b/app/api/study_cards.py @@ -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) diff --git a/frontend/src/routes/study/+page.svelte b/frontend/src/routes/study/+page.svelte index fa43a65..463137c 100644 --- a/frontend/src/routes/study/+page.svelte +++ b/frontend/src/routes/study/+page.svelte @@ -3,7 +3,7 @@ // 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수. import { onMount } from 'svelte'; import { api } from '$lib/api'; - import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers } from 'lucide-svelte'; + import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat } from 'lucide-svelte'; let cardReviewCount = $state(0); onMount(async () => { @@ -69,13 +69,23 @@

푼 문제에서 AI가 추출한 암기카드(cloze 빈칸 / qa)를 확인하고 승인·수정·폐기. 승인된 카드만 학습에 쓰입니다.

+ + +
+ +

암기카드 학습

+
+

검수한 암기카드를 모바일에서 학습. 복습(간격반복 1·3·7·14일)으로 자기평가하거나, 그냥 공부로 덜 본 카드를 휙휙 훑어봅니다.

+
예정
    -
  • 검수한 암기카드로 복습 (카드 SRS)
  • -
  • 모바일 암기카드 복습 + 공부 알람
  • +
  • 애플워치 빠른복습 + 공부 알람(push)
diff --git a/frontend/src/routes/study/cards-review/+page.svelte b/frontend/src/routes/study/cards-review/+page.svelte index ed828ba..99565cb 100644 --- a/frontend/src/routes/study/cards-review/+page.svelte +++ b/frontend/src/routes/study/cards-review/+page.svelte @@ -12,7 +12,7 @@ import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; import { - ArrowLeft, Check, Pencil, Trash2, X, CheckCheck, FileText, + ArrowLeft, Check, Pencil, Trash2, X, CheckCheck, FileText, Repeat, } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte'; @@ -134,10 +134,11 @@ {#if total > 0} 대기 {total} {/if} -
+
+
diff --git a/frontend/src/routes/study/cards-study/+page.svelte b/frontend/src/routes/study/cards-study/+page.svelte new file mode 100644 index 0000000..8639d21 --- /dev/null +++ b/frontend/src/routes/study/cards-study/+page.svelte @@ -0,0 +1,355 @@ + + +암기카드 학습 + +
+ +
+ {#if mode === 'landing'} + +

암기카드 학습

+ {:else} + + {#if !done && total > 0} +
+
+
+ {Math.min(idx + 1, total)} / {total} + {:else} +

{mode === 'review' ? '복습' : '그냥 공부'}

+ {/if} + {/if} +
+ + {#if mode === 'cram' && !loading && !done} + +
+ {#each [['', '전체'], ['cloze', 'cloze'], ['qa', 'qa']] as [val, label] (val)} + + {/each} +
+ {/if} + + {#if mode === 'landing'} + +

검수 완료한 암기카드를 학습합니다. 두 가지 방법 중 선택하세요.

+
+ + + +
+ + {:else if loading} +
+ + {:else if done} + +
+ {#if mode === 'review'} +
오늘 카드 복습 완료
+
+
{tally.correct}
+
{tally.unsure}
애매
+
{tally.wrong}
모름
+
+

애매·모름 카드는 내일 복습 큐에 다시 올라옵니다. 암 카드는 간격만큼 쉬어요.

+ {:else} +
훑어보기 완료
+
{seen}
+

'봤다'로 기록한 카드는 다음에 덜 본 순서에서 뒤로 갑니다.

+ {/if} +
+ + +
+
+ + {:else if total === 0} + +
+ {#if mode === 'review'} + + {:else} + + {/if} +
+ + {:else if current} + +
+
+ {current.format} + +
+ 앞 — {current.format === 'qa' ? '질문' : '회상'} +
+
{frontText(current)}
+ + {#if revealed} +
+
정답
+
{current.fact}
+ {#if current.evidence?.length && current.evidence[0].snippet} +
근거: {current.evidence[0].snippet}
+ {/if} +
+ {/if} + + {#if !revealed} + + {/if} +
+ + + {#if revealed} + {#if mode === 'review'} +
+ + + +
+ + {:else} + + {/if} + {/if} +
+ {/if} +