feat(study): 카드 SR writer + 두 트랙 API (B2 — 복습/그냥공부)
검토 완료 카드를 학습하는 백엔드. 복습(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) <noreply@anthropic.com>
This commit is contained in:
+146
-2
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user