Merge remote-tracking branch 'origin/feat/study-memo-card-p1' into feat/email-pkm-folder

This commit is contained in:
hyungi
2026-06-07 02:38:39 +00:00
12 changed files with 809 additions and 25 deletions
+157 -2
View File
@@ -16,13 +16,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy import func, or_, 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_memo_card import StudyMemoCard, StudyMemoCardEvidence, record_card_view
from models.study_memo_card_progress import StudyMemoCardProgress, rate_card
from models.study_question import StudyQuestion
from models.user import User
from services.study.card_normalize import compute_dedup_hash
@@ -46,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):
@@ -66,6 +70,57 @@ class ApproveBatch(BaseModel):
source_question_id: int
class RateBody(BaseModel):
outcome: str # 암/애매/모름 또는 correct/unsure/wrong
class RateResult(BaseModel):
card_id: int
outcome: str
review_stage: int | None = None
due_at: datetime | None = None
# 자기평가 read-time 매핑 (신규 enum 0 — last_outcome 어휘는 기존 4종 재사용)
_RATE_MAP = {
"": "correct", "애매": "unsure", "모름": "wrong",
"correct": "correct", "unsure": "unsure", "wrong": "wrong",
}
async def _build_card_items(
session: AsyncSession,
cards: list[StudyMemoCard],
stages: dict[int, int | None] | None = None,
) -> list[CardItem]:
"""카드 목록 → 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(
select(StudyMemoCardEvidence).where(StudyMemoCardEvidence.card_id.in_(ids))
)
).scalars().all()
ev_by: dict[int, list[CardEvidence]] = {}
for e in ev_rows:
ev_by.setdefault(e.card_id, []).append(
CardEvidence(source_type=e.source_type, source_id=e.source_id, snippet=e.snippet)
)
return [
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, []), review_stage=stages.get(c.id),
)
for c in cards
]
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="카드를 찾을 수 없습니다")
@@ -198,6 +253,106 @@ async def approve_batch(
return {"approved": result.rowcount or 0}
# ─── 복습(SR) 트랙 ───
@router.get("/due", response_model=list[CardItem])
async def due_cards(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
limit: Annotated[int, Query(ge=1, le=200)] = 30,
):
"""오늘 복습할 카드 (due_at<=now, stage<4, 검수 통과만). due_at 오름차순."""
now = datetime.now(timezone.utc)
rows = (
await session.execute(
select(StudyMemoCard, StudyMemoCardProgress.review_stage)
.join(StudyMemoCardProgress, StudyMemoCardProgress.card_id == StudyMemoCard.id)
.where(
StudyMemoCard.user_id == user.id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review.is_(False),
StudyMemoCardProgress.due_at.is_not(None),
StudyMemoCardProgress.due_at <= now,
or_(
StudyMemoCardProgress.review_stage.is_(None),
StudyMemoCardProgress.review_stage < 4,
),
)
.order_by(StudyMemoCardProgress.due_at.asc())
.limit(limit)
)
).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)
async def rate(
card_id: int,
body: RateBody,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""카드 자기평가(암/애매/모름) → SR 즉시 자동 입고."""
card = await session.get(StudyMemoCard, card_id)
card = _verify_card(card, user)
if card.needs_review:
raise HTTPException(status_code=400, detail="검수 안 된 카드는 복습(SR) 대상이 아닙니다")
outcome = _RATE_MAP.get((body.outcome or "").strip())
if outcome is None:
raise HTTPException(status_code=422, detail=f"invalid outcome: {body.outcome!r}")
progress = await rate_card(session, card=card, outcome=outcome, now=datetime.now(timezone.utc))
await session.commit()
return RateResult(
card_id=card.id, outcome=outcome, review_stage=progress.review_stage, due_at=progress.due_at
)
# ─── 그냥 공부(cram) 트랙 — 봤다 기록, SR 무관 ───
@router.get("/deck", response_model=list[CardItem])
async def deck(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
material: Annotated[str | None, Query()] = None,
format: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
"""'그냥 공부'(cram) 덱 — 검수 통과 카드를 덜 본 순서로. material/format 필터. SR 무관."""
conds = [
StudyMemoCard.user_id == user.id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review.is_(False),
]
if format in ("qa", "cloze"):
conds.append(StudyMemoCard.format == format)
if material:
conds.append(StudyMemoCard.extra["material"].astext == material)
rows = (
await session.execute(
select(StudyMemoCard)
.where(*conds)
.order_by(StudyMemoCard.last_viewed_at.asc().nulls_first(), StudyMemoCard.id.asc())
.limit(limit)
)
).scalars().all()
return await _build_card_items(session, list(rows))
@router.post("/{card_id}/view", status_code=204)
async def view_card(
card_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""'그냥 공부' 봤다 기록 (view_count++, SR 무관)."""
ok = await record_card_view(session, user_id=user.id, card_id=card_id)
await session.commit()
if not ok:
raise HTTPException(status_code=404, detail="카드를 찾을 수 없습니다")
@router.patch("/{card_id}", response_model=CardItem)
async def update_card(
card_id: int,
+2 -2
View File
@@ -26,8 +26,8 @@ from models.user import User
router = APIRouter(prefix="/study-topics", tags=["study-progress"])
# 1차 due_at 부여 시 디폴트 1일 뒤
DEFAULT_FIRST_DUE_DAYS = 1
# 1차 due_at 부여 시 디폴트 1일 뒤 — SR 상수는 sr_schedule.py 단일 source (재-export).
from services.study.sr_schedule import DEFAULT_FIRST_DUE_DAYS # noqa: E402,F401
def _verify_topic_owner(topic: StudyTopic | None, user: User) -> None:
+24
View File
@@ -67,6 +67,9 @@ class StudyMemoCard(Base):
model: Mapped[str | None] = mapped_column(String(120))
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# '그냥 공부'(cram) 봤다 기록 (SR 무관, migration 300)
view_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_viewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
@@ -187,6 +190,27 @@ async def append_card_evidence(
return len(rows)
async def record_card_view(
session: AsyncSession, *, user_id: int, card_id: int
) -> bool:
"""'그냥 공부'(cram) 봤다 기록 — view_count++ + last_viewed_at. SR(progress) 무관.
needs_review 무관(검수 안 된 카드도 가볍게 둘러볼 수 있음), 본인·미삭제 카드만.
Returns: 기록됨 여부.
"""
stmt = (
update(StudyMemoCard)
.where(
StudyMemoCard.id == card_id,
StudyMemoCard.user_id == user_id,
StudyMemoCard.deleted_at.is_(None),
)
.values(view_count=StudyMemoCard.view_count + 1, last_viewed_at=func.now())
)
result = await session.execute(stmt)
return (result.rowcount or 0) > 0
async def flag_cards_for_source(
session: AsyncSession,
*,
+88
View File
@@ -0,0 +1,88 @@
"""study_memo_card_progress ORM — 카드 SR(간격반복) 상태 (문제 progress '분리 미러').
migration 294. 226 골격 축소: SR 4컬럼(last_outcome/last_reviewed_at/due_at/review_stage)만,
pattern 분류 컬럼은 미보유(카드 복습함은 due/미확인/완료 3탭). UNIQUE(user_id, card_id).
간격 산술은 sr_schedule.py 단일 source.
입고 정책(결정 2026-06-07): '평가 즉시 자동 입고' — 애매/모름 카드는 평가 즉시 due 부여
(문제 SR의 [학습완료] 수동 게이트와 달리 자동). 암(correct) 카드는 due 안 박음(큐 폭발 방지).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, SmallInteger, String, UniqueConstraint, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
from models.study_memo_card import StudyMemoCard
from services.study import sr_schedule
class StudyMemoCardProgress(Base):
__tablename__ = "study_memo_card_progress"
__table_args__ = (UniqueConstraint("user_id", "card_id", name="uq_card_progress_user_card"),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
)
card_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_memo_cards.id", ondelete="CASCADE"), nullable=False
)
last_outcome: Mapped[str | None] = mapped_column(String(20))
last_reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
review_stage: Mapped[int | None] = mapped_column(SmallInteger)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
async def rate_card(
session: AsyncSession, *, card: StudyMemoCard, outcome: str, now: datetime
) -> StudyMemoCardProgress:
"""카드 자기평가 1건 처리 (SR 즉시 자동 입고). outcome ∈ correct/wrong/unsure.
- progress 없으면 생성. last_outcome/last_reviewed_at 갱신.
- 이미 due(복습 큐)면 sr_schedule.advance(전진/리셋/졸업).
- due 없으면 애매/모름만 first_due 부여(즉시 입고), 암은 due 안 박음.
caller 가 commit.
"""
progress = (
await session.execute(
select(StudyMemoCardProgress).where(
StudyMemoCardProgress.user_id == card.user_id,
StudyMemoCardProgress.card_id == card.id,
)
)
).scalar_one_or_none()
if progress is None:
progress = StudyMemoCardProgress(
user_id=card.user_id, study_topic_id=card.study_topic_id, card_id=card.id
)
session.add(progress)
progress.last_outcome = outcome
progress.last_reviewed_at = now
if progress.due_at is not None:
result = sr_schedule.advance(progress.review_stage, outcome, now)
if result is not None: # skipped 는 None → 불변
progress.review_stage, progress.due_at = result
elif outcome in ("wrong", "unsure"):
# 즉시 자동 입고: 애매·모름은 평가 즉시 복습 큐로 (stage0 + 내일)
progress.review_stage, progress.due_at = sr_schedule.first_due(now)
# outcome == 'correct' 이고 due 없음 → due 안 박음(큐 폭발 방지)
return progress
+11 -16
View File
@@ -40,10 +40,13 @@ from services.study.learning_pattern import (
compute_pattern_state,
)
# review_stage 별 다음 due_at interval (days)
REVIEW_INTERVAL_DAYS = {1: 3, 2: 7, 3: 14}
REVIEW_STAGE_MASTERED = 4
DEFAULT_FIRST_DUE_DAYS = 1
# SR 산술은 sr_schedule.py 단일 source (문제 SR + 카드 SR 공용). 상수는 재-export 유지.
from services.study.sr_schedule import ( # noqa: E402
DEFAULT_FIRST_DUE_DAYS, # noqa: F401
REVIEW_INTERVAL_DAYS, # noqa: F401
REVIEW_STAGE_MASTERED, # noqa: F401
advance as sr_advance,
)
@dataclass
@@ -185,19 +188,11 @@ async def finalize_session(
progress.pattern_updated_at = now
progress.pattern_window_attempts = window_size
# 복습 stage 갱신 — 이미 due_at 박힌 문제만
# 복습 stage 갱신 — 이미 due_at 박힌 문제만 (산술은 sr_schedule 공용)
if progress.due_at is not None:
if outcome == "correct":
progress.review_stage = (progress.review_stage or 0) + 1
if progress.review_stage >= REVIEW_STAGE_MASTERED:
progress.due_at = None # 학습완료
else:
days = REVIEW_INTERVAL_DAYS[progress.review_stage]
progress.due_at = now + timedelta(days=days)
elif outcome in ("wrong", "unsure"):
progress.review_stage = 0
progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)
# skipped 는 due_at 그대로 (큐 유지, stage 변경 안 함)
result = sr_advance(progress.review_stage, outcome, now)
if result is not None: # skipped 는 None → due_at/stage 불변
progress.review_stage, progress.due_at = result
# progress.due_at IS NULL 일반 풀이 → stage 건드리지 않음
# 4. 바로 할 일 카운트 (요약 응답용) — finalize 직후 progress 상태 기준 SQL 한 번
+48
View File
@@ -0,0 +1,48 @@
"""SR(간격반복) 산술 단일 source — 문제 SR + 카드 SR 공용.
session_finalize.py(문제 SR) study_memo_card writer(카드 SR) 같은 상수·산술을 참조하도록
순수함수로 추출. 진입 게이트(due_at IS NOT NULL 행만 갱신 / 최초 due 부여 / skipped 불변)
호출부에 남긴다 finalize review-complete 정책이 미묘히 달라 통합 회귀 위험.
정본 간격(실측): review_stage 0123 = 1·3·7·14, stage4 = 졸업(due_at=NULL),
오답/모호 리셋 = 내일(stage 0).
"""
from __future__ import annotations
from datetime import datetime, timedelta
# review_stage 별 '다음 due_at' interval (days). stage 1→3일, 2→7일, 3→14일.
REVIEW_INTERVAL_DAYS = {1: 3, 2: 7, 3: 14}
# 이 stage 도달 시 졸업 (due_at=NULL, 복습 큐에서 제거)
REVIEW_STAGE_MASTERED = 4
# 최초 due 부여 / 오답 리셋 = 내일
DEFAULT_FIRST_DUE_DAYS = 1
def advance(
review_stage: int | None, outcome: str, now: datetime
) -> tuple[int, datetime | None] | None:
"""이미 복습 큐(due_at IS NOT NULL)에 있는 항목의 SR 갱신 산술.
호출부가 'due_at IS NOT NULL' 가드 호출한다.
반환:
(new_stage, new_due_at) correct/wrong/unsure. 졸업이면 new_due_at=None.
None skipped/기타(변경 없음, 호출부가 무시).
"""
if outcome == "correct":
new_stage = (review_stage or 0) + 1
if new_stage >= REVIEW_STAGE_MASTERED:
return new_stage, None # 학습완료(졸업)
return new_stage, now + timedelta(days=REVIEW_INTERVAL_DAYS[new_stage])
if outcome in ("wrong", "unsure"):
return 0, now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)
return None # skipped — due_at/stage 불변
def first_due(now: datetime) -> tuple[int, datetime]:
"""복습 큐 최초 진입(오답/모호 + due_at IS NULL) 시 부여값.
문제 review-complete / 카드 회상 공용. 반환: (review_stage=0, due_at=내일).
"""
return 0, now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)
+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>
@@ -0,0 +1,7 @@
-- 299_study_memo_card_progress_due_idx.sql
-- 카드 SR 복습 큐 due 조회 가속 (227_progress_due_idx 미러).
-- 사용자별 due_at 오름차순. due_at IS NULL(미입고/졸업)은 색인 제외.
CREATE INDEX IF NOT EXISTS idx_card_progress_due
ON study_memo_card_progress (user_id, due_at)
WHERE due_at IS NOT NULL;
@@ -0,0 +1,8 @@
-- 300_study_memo_cards_view_cols.sql
-- '그냥 공부'(cram) 트랙 — 봤다 기록. SR(study_memo_card_progress)과 무관.
-- view_count = 누적 열람 수, last_viewed_at = 마지막 열람. 미니게임형 가벼운 학습 기록용.
-- 시스템 학습(SR due/stage)에는 영향 0 — cram 은 progress 를 쓰지 않는다.
ALTER TABLE study_memo_cards
ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS last_viewed_at TIMESTAMPTZ;
+93
View File
@@ -0,0 +1,93 @@
"""sr_schedule 공용추출 회귀 테스트 (B1).
문제 SR 동작이 추출 전과 동일함을 보장 advance() session_finalize 분기 로직과
바이트 동등(전진 1·3·7·14 / 졸업 / 오답 리셋 / skipped 불변 / 상수값)인지 검증.
"""
from __future__ import annotations
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "app"))
from services.study import sr_schedule as sr # noqa: E402
NOW = datetime(2026, 6, 7, 12, 0, 0, tzinfo=timezone.utc)
def _old_logic(review_stage, outcome, now):
"""추출 전 session_finalize.py:188-201 의 산술을 그대로 재현 (동등성 기준)."""
if outcome == "correct":
new_stage = (review_stage or 0) + 1
if new_stage >= 4:
return new_stage, None
return new_stage, now + timedelta(days={1: 3, 2: 7, 3: 14}[new_stage])
elif outcome in ("wrong", "unsure"):
return 0, now + timedelta(days=1)
return None # skipped
def test_constants():
assert sr.REVIEW_INTERVAL_DAYS == {1: 3, 2: 7, 3: 14}
assert sr.REVIEW_STAGE_MASTERED == 4
assert sr.DEFAULT_FIRST_DUE_DAYS == 1
def test_advance_correct_progression():
assert sr.advance(None, "correct", NOW) == (1, NOW + timedelta(days=3))
assert sr.advance(0, "correct", NOW) == (1, NOW + timedelta(days=3))
assert sr.advance(1, "correct", NOW) == (2, NOW + timedelta(days=7))
assert sr.advance(2, "correct", NOW) == (3, NOW + timedelta(days=14))
def test_advance_graduation():
# stage 3 → correct → stage 4 = 졸업(due_at=None)
assert sr.advance(3, "correct", NOW) == (4, None)
assert sr.advance(4, "correct", NOW) == (5, None)
def test_advance_reset():
assert sr.advance(0, "wrong", NOW) == (0, NOW + timedelta(days=1))
assert sr.advance(2, "wrong", NOW) == (0, NOW + timedelta(days=1))
assert sr.advance(2, "unsure", NOW) == (0, NOW + timedelta(days=1))
def test_advance_skipped_no_change():
assert sr.advance(1, "skipped", NOW) is None
assert sr.advance(3, "skipped", NOW) is None
def test_first_due():
assert sr.first_due(NOW) == (0, NOW + timedelta(days=1))
def test_equivalence_with_old_logic():
# 전 stage × 전 outcome 조합에서 추출 함수 == 구 로직.
for stage in (None, 0, 1, 2, 3, 4):
for outcome in ("correct", "wrong", "unsure", "skipped"):
assert sr.advance(stage, outcome, NOW) == _old_logic(stage, outcome, NOW), \
f"mismatch stage={stage} outcome={outcome}"
def test_reexport_preserved():
# 기존 import 경로 (session_finalize / study_question_progress) 가 상수를 재-export.
from services.study import session_finalize as sf
assert sf.REVIEW_INTERVAL_DAYS == sr.REVIEW_INTERVAL_DAYS
assert sf.REVIEW_STAGE_MASTERED == sr.REVIEW_STAGE_MASTERED
assert sf.DEFAULT_FIRST_DUE_DAYS == sr.DEFAULT_FIRST_DUE_DAYS
_TESTS = [v for k, v in dict(globals()).items() if k.startswith("test_")]
if __name__ == "__main__":
# session_finalize import 는 무거운 의존(ai 등) 가능 — reexport 테스트만 조건부.
ran = 0
for t in _TESTS:
if t.__name__ == "test_reexport_preserved":
continue
t()
ran += 1
print(f"OK ({ran} pure tests; reexport는 pytest에서)")