From e1da984e083da1c3aab602b983f318a0f4edad07 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 10:11:38 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(study):=20SR=20=EC=82=B0=EC=88=A0?= =?UTF-8?q?=20sr=5Fschedule.py=20=EA=B3=B5=EC=9A=A9=EC=B6=94=EC=B6=9C=20(B?= =?UTF-8?q?1=20=E2=80=94=20=EC=B9=B4=EB=93=9C=20SR=20=ED=86=A0=EB=8C=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 SR과 카드 SR이 같은 간격 상수·산술을 참조하도록 순수함수 추출. 운영 동작 무변경. - app/services/study/sr_schedule.py: REVIEW_INTERVAL_DAYS{1:3,2:7,3:14}/MASTERED=4/FIRST_DUE=1 + advance(stage,outcome,now)→(new_stage,new_due) | None(skipped) + first_due(now). 진입 게이트(due_at IS NOT NULL/최초 due/skipped 불변)는 호출부 잔류(finalize vs review-complete 정책 차이). - session_finalize.py: 상수·advance 분기 → sr_schedule import + sr_advance() (re-export 유지). - study_question_progress.py: DEFAULT_FIRST_DUE_DAYS → sr_schedule import. - 회귀 테스트 7/7: 전진 1·3·7·14·졸업·리셋·skipped불변·상수 + 전 stage×outcome 구 로직 바이트 동등. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/study_question_progress.py | 4 +- app/services/study/session_finalize.py | 27 +++----- app/services/study/sr_schedule.py | 48 +++++++++++++ tests/test_sr_schedule.py | 93 ++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 app/services/study/sr_schedule.py create mode 100644 tests/test_sr_schedule.py 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/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/tests/test_sr_schedule.py b/tests/test_sr_schedule.py new file mode 100644 index 0000000..77dd1cf --- /dev/null +++ b/tests/test_sr_schedule.py @@ -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에서)") From 0d274cc5fea406101751ce9f782a34dd3e0eae9e Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 10:18:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(study):=20=EC=B9=B4=EB=93=9C=20SR=20wr?= =?UTF-8?q?iter=20+=20=EB=91=90=20=ED=8A=B8=EB=9E=99=20API=20(B2=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=B5=EC=8A=B5/=EA=B7=B8=EB=83=A5=EA=B3=B5?= =?UTF-8?q?=EB=B6=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검토 완료 카드를 학습하는 백엔드. 복습(SR)=즉시 자동 입고 / 그냥공부(cram)=봤다 기록, SR 무관. - migrations 299(idx_card_progress_due partial) + 300(study_memo_cards view_count/last_viewed_at). - StudyMemoCardProgress 모델(294 미러, UNIQUE user+card) + rate_card(get-or-create → sr_schedule.advance/first_due, 즉시 자동 입고: 애매/모름 평가 즉시 due, 암은 due 안 박음). - StudyMemoCard view_count/last_viewed_at + record_card_view 헬퍼(cram, SR 무관). - API: GET /study-cards/due(복습 큐, 검수통과만) · POST /{id}/rate(자기평가 read-time 매핑) · GET /deck(cram, 덜 본 순) · POST /{id}/view(봤다 기록). 검증: 부팅+8라우트 등록 · 287~300 ephemeral 적용(인덱스·컬럼 확인) · sr_schedule 회귀 7/7(B1). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/study_cards.py | 148 +++++++++++++++++- app/models/study_memo_card.py | 24 +++ app/models/study_memo_card_progress.py | 88 +++++++++++ .../299_study_memo_card_progress_due_idx.sql | 7 + migrations/300_study_memo_cards_view_cols.sql | 8 + 5 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 app/models/study_memo_card_progress.py create mode 100644 migrations/299_study_memo_card_progress_due_idx.sql create mode 100644 migrations/300_study_memo_cards_view_cols.sql diff --git a/app/api/study_cards.py b/app/api/study_cards.py index 5c4914e..38ef817 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 @@ -66,6 +67,51 @@ 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] +) -> list[CardItem]: + """카드 목록 → CardItem(evidence 동반). due/deck 학습 flow 공용.""" + if not cards: + return [] + 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, []), + ) + 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 +244,104 @@ 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) + .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) + ) + ).scalars().all() + return await _build_card_items(session, list(rows)) + + +@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/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/migrations/299_study_memo_card_progress_due_idx.sql b/migrations/299_study_memo_card_progress_due_idx.sql new file mode 100644 index 0000000..ab21ecb --- /dev/null +++ b/migrations/299_study_memo_card_progress_due_idx.sql @@ -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; diff --git a/migrations/300_study_memo_cards_view_cols.sql b/migrations/300_study_memo_cards_view_cols.sql new file mode 100644 index 0000000..1051d77 --- /dev/null +++ b/migrations/300_study_memo_cards_view_cols.sql @@ -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; From 861db9630587be77b0dc9e9971c6cc8fa0a560a3 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 11:37:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(study):=20=EC=B9=B4=EB=93=9C=20SR=20?= =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=95=99=EC=8A=B5=20UI=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=B5=EC=8A=B5/=EA=B7=B8=EB=83=A5=EA=B3=B5?= =?UTF-8?q?=EB=B6=80=202=ED=8A=B8=EB=9E=99=20(B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검수 완료 카드를 모바일에서 학습하는 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) --- app/api/study_cards.py | 23 +- frontend/src/routes/study/+page.svelte | 16 +- .../routes/study/cards-review/+page.svelte | 5 +- .../src/routes/study/cards-study/+page.svelte | 355 ++++++++++++++++++ 4 files changed, 388 insertions(+), 11 deletions(-) create mode 100644 frontend/src/routes/study/cards-study/+page.svelte diff --git a/app/api/study_cards.py b/app/api/study_cards.py index 38ef817..96bb65d 100644 --- a/app/api/study_cards.py +++ b/app/api/study_cards.py @@ -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) 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일)으로 자기평가하거나, 그냥 공부로 덜 본 카드를 휙휙 훑어봅니다.

+
예정
    -
  • 검수한 암기카드로 복습 (카드 SRS)
  • -
  • 모바일 암기카드 복습 + 공부 알람
  • +
  • 애플워치 빠른복습 + 공부 알람(push)
diff --git a/frontend/src/routes/study/cards-review/+page.svelte b/frontend/src/routes/study/cards-review/+page.svelte index ed828ba..99565cb 100644 --- a/frontend/src/routes/study/cards-review/+page.svelte +++ b/frontend/src/routes/study/cards-review/+page.svelte @@ -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} 대기 {total} {/if} -
+
+
diff --git a/frontend/src/routes/study/cards-study/+page.svelte b/frontend/src/routes/study/cards-study/+page.svelte new file mode 100644 index 0000000..8639d21 --- /dev/null +++ b/frontend/src/routes/study/cards-study/+page.svelte @@ -0,0 +1,355 @@ + + +암기카드 학습 + +
+ +
+ {#if mode === 'landing'} + +

암기카드 학습

+ {:else} + + {#if !done && total > 0} +
+
+
+ {Math.min(idx + 1, total)} / {total} + {:else} +

{mode === 'review' ? '복습' : '그냥 공부'}

+ {/if} + {/if} +
+ + {#if mode === 'cram' && !loading && !done} + +
+ {#each [['', '전체'], ['cloze', 'cloze'], ['qa', 'qa']] as [val, label] (val)} + + {/each} +
+ {/if} + + {#if mode === 'landing'} + +

검수 완료한 암기카드를 학습합니다. 두 가지 방법 중 선택하세요.

+
+ + + +
+ + {:else if loading} +
+ + {:else if done} + +
+ {#if mode === 'review'} +
오늘 카드 복습 완료
+
+
{tally.correct}
+
{tally.unsure}
애매
+
{tally.wrong}
모름
+
+

애매·모름 카드는 내일 복습 큐에 다시 올라옵니다. 암 카드는 간격만큼 쉬어요.

+ {:else} +
훑어보기 완료
+
{seen}
+

'봤다'로 기록한 카드는 다음에 덜 본 순서에서 뒤로 갑니다.

+ {/if} +
+ + +
+
+ + {:else if total === 0} + +
+ {#if mode === 'review'} + + {:else} + + {/if} +
+ + {:else if current} + +
+
+ {current.format} + +
+ 앞 — {current.format === 'qa' ? '질문' : '회상'} +
+
{frontText(current)}
+ + {#if revealed} +
+
정답
+
{current.fact}
+ {#if current.evidence?.length && current.evidence[0].snippet} +
근거: {current.evidence[0].snippet}
+ {/if} +
+ {/if} + + {#if !revealed} + + {/if} +
+ + + {#if revealed} + {#if mode === 'review'} +
+ + + +
+ + {:else} + + {/if} + {/if} +
+ {/if} +