"""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 func, 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_question import StudyQuestion from models.user import User from services.study.card_normalize import compute_dedup_hash 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] = [] 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 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) ) await session.commit() return {"approved": result.rowcount or 0} @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 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) await session.commit()