"""study_cards API — 암기카드 검수 (공부 암기노트 Phase 1 검수 UI). needs_review=true 카드를 '출처 문제별 그룹'으로 보고 채택(approve)/수정(edit)/폐기(delete). 별 라우터(prefix=/api/study-cards)라 /api/study-questions/{id} 와 경로 충돌 없음. 정적 경로(/needs-review/count, /approve-batch)는 /{card_id} 보다 먼저 정의. 결정(2026-06-07): - 수정(cue/fact/cloze 편집) 시 dedup_hash 재계산 + needs_review=false(사용자 확정본). flagged 클리어. - 전체 일괄승인 버튼 없음 — approve-batch 는 source_question_id 단위(그 문제의 카드만). """ from __future__ import annotations from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import and_, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from core.config import settings from core.database import get_session 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 from services.study.publish_enqueue import enqueue_card_progress_publish, enqueue_card_publish router = APIRouter() class CardEvidence(BaseModel): source_type: str source_id: int | None = None snippet: str | None = None class CardItem(BaseModel): id: int source_kind: str = "question" format: str cue: str fact: str cloze_text: str | None = None 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): source_question_id: int | None = None question_text: str | None = None correct_choice: int | None = None cards: list[CardItem] = [] class CardUpdate(BaseModel): needs_review: bool | None = None cue: str | None = None fact: str | None = None cloze_text: str | None = None 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="카드를 찾을 수 없습니다") return card @router.get("/needs-review/count") async def count_needs_review_cards( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """검수 대기 카드 수 (배지용).""" n = ( await session.execute( select(func.count()) .select_from(StudyMemoCard) .where( StudyMemoCard.user_id == user.id, StudyMemoCard.deleted_at.is_(None), StudyMemoCard.needs_review, ) ) ).scalar_one() return {"count": n} @router.get("", response_model=list[CardQuestionGroup]) async def list_cards( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], needs_review: Annotated[bool, Query()] = True, format: Annotated[str | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=2000)] = 600, ): """카드 목록 — 출처 문제별 그룹. 기본 needs_review=true 검수 큐.""" conds = [StudyMemoCard.user_id == user.id, StudyMemoCard.deleted_at.is_(None)] if needs_review: conds.append(StudyMemoCard.needs_review) if format in ("qa", "cloze"): conds.append(StudyMemoCard.format == format) rows = ( await session.execute( select(StudyMemoCard) .where(*conds) .order_by(StudyMemoCard.source_question_id.asc().nulls_last(), StudyMemoCard.id.asc()) .limit(limit) ) ).scalars().all() if not rows: return [] # evidence 일괄 조회 card_ids = [c.id for c in rows] ev_rows = ( await session.execute( select(StudyMemoCardEvidence).where(StudyMemoCardEvidence.card_id.in_(card_ids)) ) ).scalars().all() ev_by_card: dict[int, list[CardEvidence]] = {} for e in ev_rows: ev_by_card.setdefault(e.card_id, []).append( CardEvidence(source_type=e.source_type, source_id=e.source_id, snippet=e.snippet) ) # 출처 문제 메타 일괄 조회 qids = sorted({c.source_question_id for c in rows if c.source_question_id is not None}) q_meta: dict[int, tuple[str, int]] = {} if qids: q_rows = ( await session.execute( select(StudyQuestion.id, StudyQuestion.question_text, StudyQuestion.correct_choice) .where(StudyQuestion.id.in_(qids)) ) ).all() q_meta = {r.id: (r.question_text, r.correct_choice) for r in q_rows} # 그룹핑 (출제순서=rows 순서 유지). question 카드는 출처 문제별, # manual(직접 추가) 카드는 extra.material 별로 묶는다. groups: dict[str, CardQuestionGroup] = {} order: list[str] = [] for c in rows: if c.source_question_id is not None: gkey = f"q:{c.source_question_id}" else: material = c.extra.get("material") if isinstance(c.extra, dict) else None gkey = f"m:{material or '직접 추가'}" if gkey not in groups: if c.source_question_id is not None: qt, cc = q_meta.get(c.source_question_id, (None, None)) groups[gkey] = CardQuestionGroup( source_question_id=c.source_question_id, question_text=qt, correct_choice=cc, cards=[] ) else: material = c.extra.get("material") if isinstance(c.extra, dict) else None groups[gkey] = CardQuestionGroup( source_question_id=None, question_text=(f"[자료] {material}" if material else "직접 추가 카드"), correct_choice=None, cards=[], ) order.append(gkey) groups[gkey].cards.append( 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_card.get(c.id, []), ) ) return [groups[k] for k in order] @router.post("/approve-batch") async def approve_batch( body: ApproveBatch, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """한 출처 문제의 검수 대기 카드를 일괄 승인(needs_review=false). 전체 일괄승인은 없음.""" result = await session.execute( update(StudyMemoCard) .where( StudyMemoCard.user_id == user.id, StudyMemoCard.source_question_id == body.source_question_id, StudyMemoCard.deleted_at.is_(None), StudyMemoCard.needs_review, ) .values(needs_review=False, flagged_by=None, flagged_at=None) .returning(StudyMemoCard.id) ) approved_ids = list(result.scalars().all()) # 방금 검수완료된 카드 발행(같은 tx, flag off 면 no-op). S-2. if settings.study_publish_enabled and approved_ids: cards = ( await session.execute(select(StudyMemoCard).where(StudyMemoCard.id.in_(approved_ids))) ).scalars().all() for c in cards: await enqueue_card_publish(session, c) await session.commit() return {"approved": len(approved_ids)} # ─── 복습(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, ): """오늘 복습할 카드 (검수 통과만). 두 부류: - 신규 승인 카드(progress 없음=첫 회상 전) — SR 큐 진입 경로(첫 회상). '암'이면 due 안 박고 종료('큐 폭발 방지'), 애매/모름이면 평가 즉시 due(내일)로 입고. - 예정 due 카드(due_at<=now, stage<4). progress 는 user+card UNIQUE 라 outer join 으로 최대 1행. 예정 due 먼저, 신규(due NULL) 뒤로.""" now = datetime.now(timezone.utc) P = StudyMemoCardProgress rows = ( await session.execute( select(StudyMemoCard, P.review_stage) .outerjoin(P, and_(P.card_id == StudyMemoCard.id, P.user_id == user.id)) .where( StudyMemoCard.user_id == user.id, StudyMemoCard.deleted_at.is_(None), StudyMemoCard.needs_review.is_(False), or_( P.id.is_(None), # 신규(첫 회상 전) — progress 미생성 and_( P.due_at.is_not(None), P.due_at <= now, or_(P.review_stage.is_(None), P.review_stage < 4), ), ), ) .order_by(P.due_at.asc().nulls_last(), StudyMemoCard.id.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)) # 카드 SR 상태 발행(같은 tx, flag off=no-op) — ALL row(sentinel/terminal 포함). S-4. if settings.study_publish_enabled: await enqueue_card_progress_publish(session, progress) 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, body: CardUpdate, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """승인(needs_review=false) 또는 수정(cue/fact/cloze). 내용 수정 시 dedup_hash 재계산 + 검수완료.""" card = await session.get(StudyMemoCard, card_id) card = _verify_card(card, user) fields_set = body.model_fields_set content_changed = False for fname in {"cue", "fact", "cloze_text"} & fields_set: setattr(card, fname, getattr(body, fname)) content_changed = True if content_changed: # 정답 토큰(fact) 기준 dedup_hash 재계산 + 사용자 확정본 → 검수 완료. card.dedup_hash = compute_dedup_hash(card.source_question_id, card.format, card.fact) card.needs_review = False card.flagged_by = None card.flagged_at = None elif "needs_review" in fields_set: card.needs_review = bool(body.needs_review) if card.needs_review: card.flagged_by = "user" card.flagged_at = datetime.now(timezone.utc) else: card.flagged_by = None card.flagged_at = None # 발행 재투영/tombstone(같은 tx) — 검수완료=발행·검수대기복귀=tombstone(상태 기반). S-2. if settings.study_publish_enabled: await enqueue_card_publish(session, card) try: await session.commit() except IntegrityError: await session.rollback() raise HTTPException(status_code=409, detail="같은 정답의 중복 카드가 이미 있습니다") return CardItem( id=card.id, source_kind=card.source_kind, format=card.format, cue=card.cue, fact=card.fact, cloze_text=card.cloze_text, needs_review=card.needs_review, flagged_by=card.flagged_by, evidence=[], ) @router.delete("/{card_id}", status_code=204) async def delete_card( card_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """저품질 카드 soft-delete. partial unique(WHERE deleted_at IS NULL)가 자연 정합.""" card = await session.get(StudyMemoCard, card_id) card = _verify_card(card, user) card.deleted_at = datetime.now(timezone.utc) # 발행 tombstone(같은 tx) — 삭제는 feed 1급 이벤트. S-2. if settings.study_publish_enabled: await enqueue_card_publish(session, card) await session.commit()