From 0d274cc5fea406101751ce9f782a34dd3e0eae9e Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 10:18:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20=EC=B9=B4=EB=93=9C=20SR=20writer?= =?UTF-8?q?=20+=20=EB=91=90=20=ED=8A=B8=EB=9E=99=20API=20(B2=20=E2=80=94?= =?UTF-8?q?=20=EB=B3=B5=EC=8A=B5/=EA=B7=B8=EB=83=A5=EA=B3=B5=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;