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
+239
View File
@@ -0,0 +1,239 @@
"""study_cards API — 암기카드 검수 (공부 암기노트 Phase 1 검수 UI).
needs_review=true 카드를 '출처 문제별 그룹'으로 보고 채택(approve)/수정(edit)/폐기(delete).
별 라우터(prefix=/api/study-cards)라 /api/study-questions/{id} 와 경로 충돌 없음.
정적 경로(/needs-review/count, /approve-batch)는 /{card_id} 보다 먼저 정의.
결정(2026-06-07):
- 수정(cue/fact/cloze 편집) 시 dedup_hash 재계산 + needs_review=false(사용자 확정본). flagged 클리어.
- 전체 일괄승인 버튼 없음 — approve-batch 는 source_question_id 단위(그 문제의 카드만).
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.study_memo_card import StudyMemoCard, StudyMemoCardEvidence
from models.study_question import StudyQuestion
from models.user import User
from services.study.card_normalize import compute_dedup_hash
router = APIRouter()
class CardEvidence(BaseModel):
source_type: str
source_id: int | None = None
snippet: str | None = None
class CardItem(BaseModel):
id: int
format: str
cue: str
fact: str
cloze_text: str | None = None
needs_review: bool
flagged_by: str | None = None
evidence: list[CardEvidence] = []
class CardQuestionGroup(BaseModel):
source_question_id: int | None = None
question_text: str | None = None
correct_choice: int | None = None
cards: list[CardItem] = []
class CardUpdate(BaseModel):
needs_review: bool | None = None
cue: str | None = None
fact: str | None = None
cloze_text: str | None = None
class ApproveBatch(BaseModel):
source_question_id: int
def _verify_card(card: StudyMemoCard | None, user: User) -> StudyMemoCard:
if card is None or card.user_id != user.id or card.deleted_at is not None:
raise HTTPException(status_code=404, detail="카드를 찾을 수 없습니다")
return card
@router.get("/needs-review/count")
async def count_needs_review_cards(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""검수 대기 카드 수 (배지용)."""
n = (
await session.execute(
select(func.count())
.select_from(StudyMemoCard)
.where(
StudyMemoCard.user_id == user.id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review,
)
)
).scalar_one()
return {"count": n}
@router.get("", response_model=list[CardQuestionGroup])
async def list_cards(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
needs_review: Annotated[bool, Query()] = True,
format: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=2000)] = 600,
):
"""카드 목록 — 출처 문제별 그룹. 기본 needs_review=true 검수 큐."""
conds = [StudyMemoCard.user_id == user.id, StudyMemoCard.deleted_at.is_(None)]
if needs_review:
conds.append(StudyMemoCard.needs_review)
if format in ("qa", "cloze"):
conds.append(StudyMemoCard.format == format)
rows = (
await session.execute(
select(StudyMemoCard)
.where(*conds)
.order_by(StudyMemoCard.source_question_id.asc().nulls_last(), StudyMemoCard.id.asc())
.limit(limit)
)
).scalars().all()
if not rows:
return []
# evidence 일괄 조회
card_ids = [c.id for c in rows]
ev_rows = (
await session.execute(
select(StudyMemoCardEvidence).where(StudyMemoCardEvidence.card_id.in_(card_ids))
)
).scalars().all()
ev_by_card: dict[int, list[CardEvidence]] = {}
for e in ev_rows:
ev_by_card.setdefault(e.card_id, []).append(
CardEvidence(source_type=e.source_type, source_id=e.source_id, snippet=e.snippet)
)
# 출처 문제 메타 일괄 조회
qids = sorted({c.source_question_id for c in rows if c.source_question_id is not None})
q_meta: dict[int, tuple[str, int]] = {}
if qids:
q_rows = (
await session.execute(
select(StudyQuestion.id, StudyQuestion.question_text, StudyQuestion.correct_choice)
.where(StudyQuestion.id.in_(qids))
)
).all()
q_meta = {r.id: (r.question_text, r.correct_choice) for r in q_rows}
# 그룹핑 (출제순서=rows 순서 유지)
groups: dict[int | None, CardQuestionGroup] = {}
order: list[int | None] = []
for c in rows:
key = c.source_question_id
if key not in groups:
qt, cc = q_meta.get(key, (None, None)) if key is not None else (None, None)
groups[key] = CardQuestionGroup(source_question_id=key, question_text=qt, correct_choice=cc, cards=[])
order.append(key)
groups[key].cards.append(
CardItem(
id=c.id, 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_card.get(c.id, []),
)
)
return [groups[k] for k in order]
@router.post("/approve-batch")
async def approve_batch(
body: ApproveBatch,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""한 출처 문제의 검수 대기 카드를 일괄 승인(needs_review=false). 전체 일괄승인은 없음."""
result = await session.execute(
update(StudyMemoCard)
.where(
StudyMemoCard.user_id == user.id,
StudyMemoCard.source_question_id == body.source_question_id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review,
)
.values(needs_review=False, flagged_by=None, flagged_at=None)
)
await session.commit()
return {"approved": result.rowcount or 0}
@router.patch("/{card_id}", response_model=CardItem)
async def update_card(
card_id: int,
body: CardUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""승인(needs_review=false) 또는 수정(cue/fact/cloze). 내용 수정 시 dedup_hash 재계산 + 검수완료."""
card = await session.get(StudyMemoCard, card_id)
card = _verify_card(card, user)
fields_set = body.model_fields_set
content_changed = False
for fname in {"cue", "fact", "cloze_text"} & fields_set:
setattr(card, fname, getattr(body, fname))
content_changed = True
if content_changed:
# 정답 토큰(fact) 기준 dedup_hash 재계산 + 사용자 확정본 → 검수 완료.
card.dedup_hash = compute_dedup_hash(card.source_question_id, card.format, card.fact)
card.needs_review = False
card.flagged_by = None
card.flagged_at = None
elif "needs_review" in fields_set:
card.needs_review = bool(body.needs_review)
if card.needs_review:
card.flagged_by = "user"
card.flagged_at = datetime.now(timezone.utc)
else:
card.flagged_by = None
card.flagged_at = None
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise HTTPException(status_code=409, detail="같은 정답의 중복 카드가 이미 있습니다")
return CardItem(
id=card.id, format=card.format, cue=card.cue, fact=card.fact, cloze_text=card.cloze_text,
needs_review=card.needs_review, flagged_by=card.flagged_by, evidence=[],
)
@router.delete("/{card_id}", status_code=204)
async def delete_card(
card_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""저품질 카드 soft-delete. partial unique(WHERE deleted_at IS NULL)가 자연 정합."""
card = await session.get(StudyMemoCard, card_id)
card = _verify_card(card, user)
card.deleted_at = datetime.now(timezone.utc)
await session.commit()
+2
View File
@@ -28,6 +28,7 @@ from api.study_questions import router as study_questions_router
from api.study_sessions import router as study_sessions_router
from api.study_topics import router as study_topics_router
from api.study_reminders import router as study_reminders_router
from api.study_cards import router as study_cards_router
from api.video import router as video_router
from core.config import settings
from core.database import async_session, engine, init_db
@@ -167,6 +168,7 @@ app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study
# study_questions: 라우터 안에서 /study-topics/{id}/questions 와 /study-questions/{id} 두 줄기를 모두 정의하므로 prefix=/api 로 등록
app.include_router(study_questions_router, prefix="/api", tags=["study-questions"])
app.include_router(study_reminders_router, prefix="/api/study-reminders", tags=["study-reminders"])
app.include_router(study_cards_router, prefix="/api/study-cards", tags=["study-cards"])
# Phase 1: 학습 진행 상태 (review-complete + review-queue). prefix=/api/study-topics 안에 정의됨.
app.include_router(study_question_progress_router, prefix="/api", tags=["study-progress"])
+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>