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)
+13 -3
View File
@@ -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 @@
</div>
<p class="text-xs text-dim">푼 문제에서 AI가 추출한 암기카드(cloze 빈칸 / qa)를 확인하고 승인·수정·폐기. 승인된 카드만 학습에 쓰입니다.</p>
</a>
<a
href="/study/cards-study"
class="block p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
>
<div class="flex items-center gap-2 mb-2">
<Repeat size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">암기카드 학습</h2>
</div>
<p class="text-xs text-dim">검수한 암기카드를 모바일에서 학습. <b>복습(간격반복 1·3·7·14일)</b>으로 자기평가하거나, <b>그냥 공부</b>로 덜 본 카드를 휙휙 훑어봅니다.</p>
</a>
</div>
<div class="mt-6 p-4 rounded-lg border border-dashed border-default/60 text-xs text-dim">
<div class="font-medium text-dim mb-1">예정</div>
<ul class="list-disc list-inside space-y-0.5">
<li>검수한 암기카드로 복습 (카드 SRS)</li>
<li>모바일 암기카드 복습 + 공부 알람</li>
<li>애플워치 빠른복습 + 공부 알람(push)</li>
</ul>
</div>
</div>
@@ -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}
<span class="rounded-full bg-accent px-2.5 py-0.5 text-xs font-bold text-white">대기 {total}</span>
{/if}
<div class="ml-auto flex gap-1.5">
<div class="ml-auto flex items-center gap-1.5">
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === '' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('')}>전체</button>
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === 'cloze' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('cloze')}>cloze</button>
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === 'qa' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('qa')}>qa</button>
<Button href="/study/cards-study" variant="secondary" size="sm" icon={Repeat}>학습</Button>
</div>
</div>
@@ -0,0 +1,355 @@
<script>
/**
* /study/cards-study — 암기카드 학습 (공부 암기노트 B3, 모바일 우선·아이폰 15 Pro Max 430×932).
*
* 두 트랙:
* 1) 복습(SR) — due 카드를 앞면 회상 → 탭 reveal → 3단 자기평가(모름/애매/암).
* backend: GET /study-cards/due · POST /study-cards/{id}/rate (암/애매/모름 → correct/unsure/wrong)
* '암'(correct) 버튼은 stage별 다음 복습일을 미리보기(+3/7/14일·졸업), 모름·애매는 내일.
* 2) 그냥 공부(cram) — 검수 통과 카드를 덜 본 순서로 휙휙, '봤다'만 기록(SR 무관).
* backend: GET /study-cards/deck (format 필터) · POST /study-cards/{id}/view
*
* 검수(needs_review=false) 카드만 복습 큐 입고(백엔드 양층 게이트). 카드 format = qa/cloze 2종.
* 데스크탑 키보드: Space=정답 보기 / 복습 J·K·L=모름·애매·암 / 그냥공부 Enter=봤다.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Repeat, Layers, Eye, BookOpen } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
// sr_schedule.py 단일 source 미러 — '암'(correct) 다음 복습일 라벨 전용(실제 스케줄은 백엔드).
const REVIEW_INTERVAL_DAYS = { 1: 3, 2: 7, 3: 14 };
function correctLabel(stage) {
const ns = (stage ?? 0) + 1;
if (ns >= 4) return '졸업';
return `+${REVIEW_INTERVAL_DAYS[ns]}일`;
}
let mode = $state('landing'); // 'landing' | 'review' | 'cram'
let loading = $state(false);
let busy = $state(false); // rate/view 진행 중 더블탭 방지
let cards = $state([]);
let idx = $state(0);
let revealed = $state(false);
let done = $state(false);
let fmtFilter = $state(''); // '' | 'qa' | 'cloze' (cram)
let tally = $state({ correct: 0, unsure: 0, wrong: 0 }); // 복습 결과 집계
let seen = $state(0); // 그냥공부 본 카드 수
let dueCount = $state(null); // landing 배지
let current = $derived(cards[idx] ?? null);
let total = $derived(cards.length);
let pct = $derived(total ? Math.round((idx / total) * 100) : 0);
let _dueCache = null; // landing prefetch 한 due 카드 재사용
async function fetchDue() {
const d = (await api('/study-cards/due?limit=50')) ?? [];
_dueCache = d;
dueCount = d.length;
return d;
}
async function startReview() {
mode = 'review';
loading = true;
done = false;
idx = 0;
revealed = false;
tally = { correct: 0, unsure: 0, wrong: 0 };
try {
cards = _dueCache ?? (await fetchDue());
_dueCache = null; // 소비
} catch (err) {
addToast('error', err?.detail || '복습 카드 조회 실패');
cards = [];
} finally {
loading = false;
}
}
async function startCram() {
mode = 'cram';
loading = true;
done = false;
idx = 0;
revealed = false;
seen = 0;
try {
const q = fmtFilter ? `?format=${fmtFilter}&limit=40` : '?limit=40';
cards = (await api(`/study-cards/deck${q}`)) ?? [];
} catch (err) {
addToast('error', err?.detail || '학습 덱 조회 실패');
cards = [];
} finally {
loading = false;
}
}
function backToLanding() {
mode = 'landing';
cards = [];
idx = 0;
revealed = false;
done = false;
fetchDue().catch(() => { dueCount = null; });
}
function advance() {
revealed = false;
if (idx + 1 >= cards.length) done = true;
else idx += 1;
}
async function rate(label) {
if (!current || busy) return;
busy = true;
const c = current;
try {
await api(`/study-cards/${c.id}/rate`, {
method: 'POST',
body: JSON.stringify({ outcome: label }),
});
const key = label === '암' ? 'correct' : label === '애매' ? 'unsure' : 'wrong';
tally = { ...tally, [key]: tally[key] + 1 };
advance();
} catch (err) {
addToast('error', err?.detail || '평가 저장 실패');
} finally {
busy = false;
}
}
async function markSeen() {
if (!current || busy) return;
busy = true;
try {
await api(`/study-cards/${current.id}/view`, { method: 'POST' });
seen += 1;
advance();
} catch (err) {
addToast('error', err?.detail || '기록 실패');
} finally {
busy = false;
}
}
function setCramFilter(f) {
if (fmtFilter === f) return;
fmtFilter = f;
startCram();
}
// 카드 앞면 텍스트 (cloze=빈칸 문장 / qa=질문)
function frontText(c) {
if (c.format === 'cloze' && c.cloze_text) return c.cloze_text;
return c.cue;
}
function onKey(e) {
if (mode === 'landing' || loading || done || !current) return;
if (e.key === ' ') {
e.preventDefault();
if (!revealed) revealed = true;
return;
}
if (!revealed) return;
if (mode === 'review') {
if (e.key === 'j' || e.key === 'J') rate('모름');
else if (e.key === 'k' || e.key === 'K') rate('애매');
else if (e.key === 'l' || e.key === 'L') rate('암');
} else if (mode === 'cram' && e.key === 'Enter') {
markSeen();
}
}
onMount(() => {
window.addEventListener('keydown', onKey);
const m = new URLSearchParams(window.location.search).get('mode');
if (m === 'review') startReview();
else if (m === 'cram') startCram();
else fetchDue().catch(() => { dueCount = null; });
return () => window.removeEventListener('keydown', onKey);
});
</script>
<svelte:head><title>암기카드 학습</title></svelte:head>
<div class="mx-auto flex min-h-[100dvh] max-w-md flex-col px-4 py-4 sm:py-6">
<!-- 헤더 -->
<div class="mb-4 flex items-center gap-2">
{#if mode === 'landing'}
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={() => goto('/study')}>공부</Button>
<h1 class="text-lg font-bold text-text">암기카드 학습</h1>
{:else}
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={backToLanding}>나가기</Button>
{#if !done && total > 0}
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-default">
<div class="h-full rounded-full bg-accent transition-all" style="width:{pct}%"></div>
</div>
<span class="shrink-0 text-xs tabular-nums text-dim">{Math.min(idx + 1, total)} / {total}</span>
{:else}
<h1 class="text-lg font-bold text-text">{mode === 'review' ? '복습' : '그냥 공부'}</h1>
{/if}
{/if}
</div>
{#if mode === 'cram' && !loading && !done}
<!-- 그냥 공부 format 필터 (전환 시 덱 재시작) -->
<div class="mb-3 flex gap-1.5">
{#each [['', '전체'], ['cloze', 'cloze'], ['qa', 'qa']] as [val, label] (val)}
<button
class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === val ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}"
onclick={() => setCramFilter(val)}
>{label}</button>
{/each}
</div>
{/if}
{#if mode === 'landing'}
<!-- 트랙 선택 -->
<p class="mb-4 text-sm text-dim">검수 완료한 암기카드를 학습합니다. 두 가지 방법 중 선택하세요.</p>
<div class="space-y-3">
<button
onclick={startReview}
class="block w-full rounded-card border border-default bg-surface p-5 text-left transition-colors hover:border-accent hover:bg-accent/5"
>
<div class="mb-1.5 flex items-center gap-2">
<Repeat size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">복습 (간격반복)</h2>
{#if dueCount !== null && dueCount > 0}
<span class="ml-auto rounded-full bg-accent px-2 py-0.5 text-xs font-bold text-white">오늘 {dueCount}</span>
{/if}
</div>
<p class="text-xs text-dim">
오늘 복습할 카드를 앞면만 보고 떠올린 뒤 <b class="text-text">모름·애매·암</b>으로 자기평가합니다.
암이면 복습 간격이 늘고(1·3·7·14일), 애매·모름은 내일 다시 나옵니다.
</p>
</button>
<button
onclick={startCram}
class="block w-full rounded-card border border-default bg-surface p-5 text-left transition-colors hover:border-accent hover:bg-accent/5"
>
<div class="mb-1.5 flex items-center gap-2">
<Layers size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">그냥 공부 (휙휙)</h2>
</div>
<p class="text-xs text-dim">
덜 본 카드부터 빠르게 넘겨보며 <b class="text-text">봤다</b>만 기록합니다. 간격반복(SR)과 무관 — 가볍게 훑을 때.
</p>
</button>
</div>
{:else if loading}
<div class="flex-1 space-y-3"><Skeleton class="h-64 w-full" /><Skeleton class="h-12 w-full" /></div>
{:else if done}
<!-- 결과 화면 -->
<div class="flex flex-1 flex-col items-center justify-center text-center">
{#if mode === 'review'}
<div class="text-lg font-bold text-text">오늘 카드 복습 완료</div>
<div class="my-6 flex gap-9">
<div><div class="text-3xl font-extrabold text-success">{tally.correct}</div><div class="text-xs text-dim"></div></div>
<div><div class="text-3xl font-extrabold text-warning">{tally.unsure}</div><div class="text-xs text-dim">애매</div></div>
<div><div class="text-3xl font-extrabold text-error">{tally.wrong}</div><div class="text-xs text-dim">모름</div></div>
</div>
<p class="text-xs text-dim">애매·모름 카드는 내일 복습 큐에 다시 올라옵니다. 암 카드는 간격만큼 쉬어요.</p>
{:else}
<div class="text-lg font-bold text-text">훑어보기 완료</div>
<div class="my-6 text-3xl font-extrabold text-accent">{seen}<span class="ml-1 text-sm font-medium text-dim"></span></div>
<p class="text-xs text-dim">'봤다'로 기록한 카드는 다음에 덜 본 순서에서 뒤로 갑니다.</p>
{/if}
<div class="mt-7 flex gap-2">
<Button variant="secondary" onclick={backToLanding}>다시 고르기</Button>
<Button variant="primary" onclick={() => goto('/study')}>공부 허브로</Button>
</div>
</div>
{:else if total === 0}
<!-- 빈 큐 -->
<div class="flex flex-1 items-center justify-center">
{#if mode === 'review'}
<EmptyState
title="오늘 복습할 카드가 없습니다"
description="자기평가에서 애매·모름으로 표시한 카드가 복습일이 되면 여기에 나타납니다. '그냥 공부'로 가볍게 훑어볼 수 있어요."
icon={Repeat}
/>
{:else}
<EmptyState
title="학습할 카드가 없습니다"
description="문제를 풀면 AI가 암기카드를 추출하고, 검수를 마친 카드가 여기에 쌓입니다."
icon={BookOpen}
/>
{/if}
</div>
{:else if current}
<!-- 카드 -->
<div class="flex flex-1 flex-col">
<div class="flex flex-1 flex-col rounded-card border border-default bg-surface p-5">
<span
class="self-start rounded-full px-2.5 py-0.5 text-[10px] font-bold text-white {current.format === 'cloze' ? 'bg-accent' : 'bg-domain-engineering'}"
>{current.format}</span>
<div class="mt-3 text-[10px] font-bold uppercase tracking-wide text-faint">
앞 — {current.format === 'qa' ? '질문' : '회상'}
</div>
<div class="mt-1 text-lg font-semibold leading-relaxed text-text">{frontText(current)}</div>
{#if revealed}
<div class="mt-4 rounded-lg border border-accent-ring bg-bg px-4 py-3">
<div class="text-[10px] font-bold uppercase tracking-wide text-faint">정답</div>
<div class="mt-0.5 text-xl font-bold text-accent">{current.fact}</div>
{#if current.evidence?.length && current.evidence[0].snippet}
<div class="mt-2 text-[11px] leading-relaxed text-dim">근거: {current.evidence[0].snippet}</div>
{/if}
</div>
{/if}
{#if !revealed}
<button
onclick={() => (revealed = true)}
class="mt-auto flex items-center justify-center gap-2 rounded-md border border-dashed border-accent-ring bg-surface-hover py-3 text-sm font-medium text-accent transition-colors hover:bg-accent/5"
>
<Eye size={16} /> 탭하여 정답 보기 <span class="text-faint">(Space)</span>
</button>
{/if}
</div>
<!-- 평가/액션 (reveal 후) -->
{#if revealed}
{#if mode === 'review'}
<div class="mt-3 grid grid-cols-3 gap-2">
<button
onclick={() => rate('모름')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-error py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>모름<span class="mt-0.5 text-[10px] font-medium opacity-85">내일</span></button>
<button
onclick={() => rate('애매')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-warning py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>애매<span class="mt-0.5 text-[10px] font-medium opacity-85">내일</span></button>
<button
onclick={() => rate('암')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-success py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>암<span class="mt-0.5 text-[10px] font-medium opacity-85">{correctLabel(current.review_stage)}</span></button>
</div>
<p class="mt-2 hidden text-center text-[11px] text-faint sm:block">키보드: J 모름 · K 애매 · L 암</p>
{:else}
<button
onclick={markSeen}
disabled={busy}
class="mt-3 w-full rounded-lg bg-accent py-3.5 text-sm font-bold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>봤다 — 다음 <span class="text-xs font-medium opacity-85">(Enter)</span></button>
{/if}
{/if}
</div>
{/if}
</div>