diff --git a/app/api/study_cards.py b/app/api/study_cards.py index 5c4914e..96bb65d 100644 --- a/app/api/study_cards.py +++ b/app/api/study_cards.py @@ -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, diff --git a/app/api/study_question_progress.py b/app/api/study_question_progress.py index 7b0a4ac..fb85269 100644 --- a/app/api/study_question_progress.py +++ b/app/api/study_question_progress.py @@ -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: diff --git a/app/models/study_memo_card.py b/app/models/study_memo_card.py index 0551a63..15a19b6 100644 --- a/app/models/study_memo_card.py +++ b/app/models/study_memo_card.py @@ -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, *, diff --git a/app/models/study_memo_card_progress.py b/app/models/study_memo_card_progress.py new file mode 100644 index 0000000..1e0980c --- /dev/null +++ b/app/models/study_memo_card_progress.py @@ -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 diff --git a/app/services/study/session_finalize.py b/app/services/study/session_finalize.py index 4cb8854..fb10a16 100644 --- a/app/services/study/session_finalize.py +++ b/app/services/study/session_finalize.py @@ -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 한 번 diff --git a/app/services/study/sr_schedule.py b/app/services/study/sr_schedule.py new file mode 100644 index 0000000..518acd3 --- /dev/null +++ b/app/services/study/sr_schedule.py @@ -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 0→1→2→3 = 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) 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일)으로 자기평가하거나, 그냥 공부로 덜 본 카드를 휙휙 훑어봅니다.
+검수 완료한 암기카드를 학습합니다. 두 가지 방법 중 선택하세요.
+애매·모름 카드는 내일 복습 큐에 다시 올라옵니다. 암 카드는 간격만큼 쉬어요.
+ {:else} +'봤다'로 기록한 카드는 다음에 덜 본 순서에서 뒤로 갑니다.
+ {/if} +