feat(study): 암기카드 검수 UI — 백엔드 카드 review API + SvelteKit /study/cards-review

577 카드(needs_review=true)를 보고 채택/수정/폐기하는 첫 검수 화면(학습 흐름 '마지막 한 칸' 1번).

- 백엔드 app/api/study_cards.py(prefix /api/study-cards): GET(출처 문제별 그룹, evidence 동반)·needs-review/count·PATCH(승인 needs_review=false / 수정 시 dedup_hash 재계산+검수완료)·DELETE(soft)·approve-batch(문제 단위, 전체 일괄승인 없음).
- 프론트 /study/cards-review: 반응형 그룹 목록(문제+카드) · 카드별 승인/수정(인라인)/삭제 · 문제 단위 일괄승인 · format 필터 · 세이지 토큰. study 허브에 진입 링크+대기 카운트 배지.
- 카피 drift 정정: 허브 '예정(Phase 2~)'이 가동 중인 퀴즈/SRS/통계를 잘못 표기 → 예정은 카드 SRS·모바일·알람으로 수정.

검증: 백엔드 부팅+라우트 등록 OK(4 route). 프론트 빌드는 배포 시 vite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-07 08:49:11 +09:00
parent 19f544fb5e
commit b9f2ade55e
4 changed files with 501 additions and 8 deletions
+30 -8
View File
@@ -1,8 +1,17 @@
<script>
// /study — 학습 hub.
// 자료 학습 (자료실 자료 + 회독 추적) / 필사 세션 (Apple Pencil) / Phase 2~ 퀴즈/SRS.
// 학습 워크스페이스(주제) — 필기·자료를 묶어 보는 1차 컨테이너.
import { BookOpen, PenLine, GraduationCap, FolderKanban } from 'lucide-svelte';
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers } from 'lucide-svelte';
let cardReviewCount = $state(0);
onMount(async () => {
try {
const r = await api('/study-cards/needs-review/count');
cardReviewCount = r?.count ?? 0;
} catch {}
});
</script>
<div class="p-4 md:p-6 max-w-5xl mx-auto">
@@ -10,7 +19,7 @@
<h1 class="text-xl font-semibold text-text flex items-center gap-2">
<GraduationCap size={22} /> 공부
</h1>
<p class="text-sm text-dim mt-1">학습 자료 회독 / 손글씨 필사 세션 / (예정) 퀴즈·복습.</p>
<p class="text-sm text-dim mt-1">주제별 퀴즈·복습(SRS)·통계 / 학습 자료 회독 / 손글씨 필사 세션.</p>
</header>
<a
@@ -46,14 +55,27 @@
</div>
<p class="text-xs text-dim">iPad + Apple Pencil 로 자격증 교재 / 어학 한자·단어를 손으로 필사. 세션 단위 stroke 보존.</p>
</a>
<a
href="/study/cards-review"
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">
<Layers size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">암기카드 검수</h2>
{#if cardReviewCount > 0}
<span class="ml-auto rounded-full bg-accent px-2 py-0.5 text-xs font-bold text-white">{cardReviewCount}</span>
{/if}
</div>
<p class="text-xs text-dim">푼 문제에서 AI가 추출한 암기카드(cloze 빈칸 / qa)를 확인하고 승인·수정·폐기. 승인된 카드만 학습에 쓰입니다.</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">예정 (Phase 2~)</div>
<div class="font-medium text-dim mb-1">예정</div>
<ul class="list-disc list-inside space-y-0.5">
<li>모바일 암기노트 / 카드 복습</li>
<li>AI 자료 기반 퀴즈 출제 + 정답률 분야별 통계</li>
<li>SRS (1·3·7·14일 복습 일정)</li>
<li>검수한 암기카드로 복습 (카드 SRS)</li>
<li>모바일 암기카드 복습 + 공부 알람</li>
</ul>
</div>
</div>
@@ -0,0 +1,230 @@
<script>
/**
* /study/cards-review — 암기카드 검수 (공부 암기노트 Phase 1).
*
* needs_review=true 카드를 '출처 문제별 그룹'으로 보고 채택(승인)/수정/폐기.
* backend: GET /study-cards?needs_review=true (그룹) · /study-cards/needs-review/count
* PATCH /study-cards/{id}(승인/수정) · DELETE /study-cards/{id} · POST /approve-batch
* 전체 일괄승인 없음 — 문제 단위 승인만(품질 관찰 우선).
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, Check, Pencil, Trash2, X, CheckCheck, FileText,
} 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';
let loading = $state(true);
let groups = $state([]); // [{ source_question_id, question_text, correct_choice, cards: [...] }]
let total = $state(0);
let fmtFilter = $state(''); // '' | 'cloze' | 'qa'
let editing = $state(null); // card id 편집 중
let draft = $state({ cue: '', fact: '', cloze_text: '' });
let shownGroups = $derived(
fmtFilter
? groups
.map((g) => ({ ...g, cards: g.cards.filter((c) => c.format === fmtFilter) }))
.filter((g) => g.cards.length > 0)
: groups,
);
async function load() {
loading = true;
try {
const q = fmtFilter ? `&format=${fmtFilter}` : '';
groups = (await api(`/study-cards?needs_review=true${q}`)) ?? [];
const c = await api('/study-cards/needs-review/count');
total = c?.count ?? 0;
} catch (err) {
addToast('error', err?.detail || '카드 조회 실패');
groups = [];
} finally {
loading = false;
}
}
// 로컬 상태에서 카드 제거 (승인/삭제 후 큐에서 사라짐)
function removeCard(qid, cardId) {
groups = groups
.map((g) =>
g.source_question_id === qid
? { ...g, cards: g.cards.filter((c) => c.id !== cardId) }
: g,
)
.filter((g) => g.cards.length > 0);
total = Math.max(0, total - 1);
}
function removeGroup(qid, n) {
groups = groups.filter((g) => g.source_question_id !== qid);
total = Math.max(0, total - n);
}
async function approve(qid, cardId) {
try {
await api(`/study-cards/${cardId}`, { method: 'PATCH', body: JSON.stringify({ needs_review: false }) });
removeCard(qid, cardId);
addToast('success', '승인됨');
} catch (err) {
addToast('error', err?.detail || '승인 실패');
}
}
async function remove(qid, cardId) {
if (!confirm('이 카드를 폐기할까요? (되돌릴 수 없음)')) return;
try {
await api(`/study-cards/${cardId}`, { method: 'DELETE' });
removeCard(qid, cardId);
addToast('success', '폐기됨');
} catch (err) {
addToast('error', err?.detail || '삭제 실패');
}
}
async function approveGroup(qid, n) {
try {
const r = await api('/study-cards/approve-batch', {
method: 'POST', body: JSON.stringify({ source_question_id: qid }),
});
removeGroup(qid, n);
addToast('success', `${r?.approved ?? n}장 승인됨`);
} catch (err) {
addToast('error', err?.detail || '일괄 승인 실패');
}
}
function startEdit(card) {
editing = card.id;
draft = { cue: card.cue, fact: card.fact, cloze_text: card.cloze_text ?? '' };
}
function cancelEdit() { editing = null; }
async function saveEdit(qid, cardId) {
try {
const body = { cue: draft.cue, fact: draft.fact, cloze_text: draft.cloze_text || null };
await api(`/study-cards/${cardId}`, { method: 'PATCH', body: JSON.stringify(body) });
editing = null;
// 수정 = 검수 완료 → 큐에서 제거
removeCard(qid, cardId);
addToast('success', '수정 후 승인됨');
} catch (err) {
addToast('error', err?.detail || '수정 실패');
}
}
function setFilter(f) {
if (fmtFilter === f) return;
fmtFilter = f;
}
onMount(load);
</script>
<svelte:head><title>암기카드 검수</title></svelte:head>
<div class="mx-auto max-w-3xl px-4 py-6">
<div class="mb-5 flex items-center gap-3">
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={() => goto('/study')}>공부</Button>
<h1 class="text-xl font-bold text-text">암기카드 검수</h1>
{#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">
<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>
</div>
</div>
<p class="mb-4 text-sm text-dim">
AI가 추출한 암기카드를 확인하고 <b class="text-text">승인 / 수정 / 폐기</b>합니다. 승인된 카드만 학습에 쓰입니다.
</p>
{#if loading}
<div class="space-y-3">{#each Array(4).fill(0) as _, i (i)}<Skeleton class="h-28 w-full" />{/each}</div>
{:else if shownGroups.length === 0}
<EmptyState title="검수할 카드가 없습니다" description="새 문제를 풀면 AI가 암기카드를 추출해 여기에 쌓입니다." icon={CheckCheck} />
{:else}
<div class="space-y-5">
{#each shownGroups as g (g.source_question_id)}
<div class="rounded-card border border-default bg-bg/40 p-3">
<!-- 출처 문제 -->
<div class="mb-3 flex items-start gap-2 rounded-lg border border-default bg-surface px-3 py-2">
<FileText size={15} class="mt-0.5 shrink-0 text-faint" />
<div class="min-w-0 flex-1">
<div class="text-xs font-bold uppercase tracking-wide text-faint">출처 문제</div>
<div class="text-sm text-text">{g.question_text}</div>
{#if g.correct_choice}<div class="mt-0.5 text-xs text-accent">사용자 정답: {g.correct_choice}</div>{/if}
</div>
{#if g.cards.length > 1}
<Button variant="secondary" size="sm" icon={CheckCheck} onclick={() => approveGroup(g.source_question_id, g.cards.length)}>{g.cards.length}장 승인</Button>
{/if}
</div>
<!-- 카드들 -->
<div class="space-y-2.5">
{#each g.cards as c (c.id)}
<div class="rounded-lg border border-default bg-surface p-3">
<div class="mb-2 flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-[10px] font-bold text-white {c.format === 'cloze' ? 'bg-accent' : 'bg-domain-engineering'}">{c.format}</span>
{#if c.flagged_by === 'source_changed' || c.flagged_by === 'source_deleted'}
<span class="text-[11px] text-warning">{c.flagged_by === 'source_changed' ? '문제 수정됨' : '문제 삭제됨'}</span>
{/if}
</div>
{#if editing === c.id}
<!-- 편집 모드 -->
<div class="space-y-2">
<label class="block text-[11px] font-semibold text-dim">앞 (단서/질문)
<textarea bind:value={draft.cue} rows="2" class="mt-1 w-full rounded-md border border-default bg-bg px-2 py-1.5 text-sm text-text"></textarea>
</label>
<label class="block text-[11px] font-semibold text-dim">정답 (fact)
<input bind:value={draft.fact} class="mt-1 w-full rounded-md border border-default bg-bg px-2 py-1.5 text-sm text-text" />
</label>
{#if c.format === 'cloze'}
<label class="block text-[11px] font-semibold text-dim">빈칸 문장 (cloze, [____] 포함)
<textarea bind:value={draft.cloze_text} rows="2" class="mt-1 w-full rounded-md border border-default bg-bg px-2 py-1.5 text-sm text-text"></textarea>
</label>
{/if}
<div class="flex gap-2">
<Button variant="primary" size="sm" icon={Check} onclick={() => saveEdit(g.source_question_id, c.id)}>저장 후 승인</Button>
<Button variant="ghost" size="sm" icon={X} onclick={cancelEdit}>취소</Button>
</div>
</div>
{:else}
<!-- 보기 모드 -->
<div class="rounded-md border border-default bg-surface-active px-3 py-2 text-sm">
<div class="text-[10px] font-bold uppercase tracking-wide text-faint"></div>{c.cue}
</div>
<div class="mt-1.5 rounded-md border border-accent-ring bg-bg px-3 py-2 text-sm">
{#if c.format === 'cloze' && c.cloze_text}
{c.cloze_text}
<div class="mt-1 text-xs text-accent">정답: <b>{c.fact}</b></div>
{:else}
<b class="text-accent">{c.fact}</b>
{/if}
</div>
{#if c.evidence?.length}
<div class="mt-2 text-[11px] text-dim">근거: {c.evidence[0].snippet}</div>
{:else}
<div class="mt-2 text-[11px] text-faint">근거: 확정 풀이(비정량 개념)</div>
{/if}
<div class="mt-2.5 flex gap-2">
<Button variant="primary" size="sm" icon={Check} onclick={() => approve(g.source_question_id, c.id)}>승인</Button>
<Button variant="ghost" size="sm" icon={Pencil} onclick={() => startEdit(c)}>수정</Button>
<Button variant="danger" size="sm" icon={Trash2} onclick={() => remove(g.source_question_id, c.id)}>삭제</Button>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>