b9f2ade55e
577 카드(needs_review=true)를 보고 채택/수정/폐기하는 첫 검수 화면(학습 흐름 '마지막 한 칸' 1번). - 백엔드 app/api/study_cards.py(prefix /api/study-cards): GET(출처 문제별 그룹, evidence 동반)·needs-review/count·PATCH(승인 needs_review=false / 수정 시 dedup_hash 재계산+검수완료)·DELETE(soft)·approve-batch(문제 단위, 전체 일괄승인 없음). - 프론트 /study/cards-review: 반응형 그룹 목록(문제+카드) · 카드별 승인/수정(인라인)/삭제 · 문제 단위 일괄승인 · format 필터 · 세이지 토큰. study 허브에 진입 링크+대기 카운트 배지. - 카피 drift 정정: 허브 '예정(Phase 2~)'이 가동 중인 퀴즈/SRS/통계를 잘못 표기 → 예정은 카드 SRS·모바일·알람으로 수정. 검증: 백엔드 부팅+라우트 등록 OK(4 route). 프론트 빌드는 배포 시 vite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
8.3 KiB
Python
240 lines
8.3 KiB
Python
"""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
|
|
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 순서 유지)
|
|
groups: dict[int | None, CardQuestionGroup] = {}
|
|
order: list[int | None] = []
|
|
for c in rows:
|
|
key = c.source_question_id
|
|
if key not in groups:
|
|
qt, cc = q_meta.get(key, (None, None)) if key is not None else (None, None)
|
|
groups[key] = CardQuestionGroup(source_question_id=key, question_text=qt, correct_choice=cc, cards=[])
|
|
order.append(key)
|
|
groups[key].cards.append(
|
|
CardItem(
|
|
id=c.id, 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, 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()
|