feat(study): 암기카드 검수 UI — 백엔드 카드 review API + SvelteKit /study/cards-review
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>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"""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()
|
||||
@@ -28,6 +28,7 @@ from api.study_questions import router as study_questions_router
|
||||
from api.study_sessions import router as study_sessions_router
|
||||
from api.study_topics import router as study_topics_router
|
||||
from api.study_reminders import router as study_reminders_router
|
||||
from api.study_cards import router as study_cards_router
|
||||
from api.video import router as video_router
|
||||
from core.config import settings
|
||||
from core.database import async_session, engine, init_db
|
||||
@@ -167,6 +168,7 @@ app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study
|
||||
# study_questions: 라우터 안에서 /study-topics/{id}/questions 와 /study-questions/{id} 두 줄기를 모두 정의하므로 prefix=/api 로 등록
|
||||
app.include_router(study_questions_router, prefix="/api", tags=["study-questions"])
|
||||
app.include_router(study_reminders_router, prefix="/api/study-reminders", tags=["study-reminders"])
|
||||
app.include_router(study_cards_router, prefix="/api/study-cards", tags=["study-cards"])
|
||||
# Phase 1: 학습 진행 상태 (review-complete + review-queue). prefix=/api/study-topics 안에 정의됨.
|
||||
app.include_router(study_question_progress_router, prefix="/api", tags=["study-progress"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user