Files
hyungi_document_server/app/api/study_cards.py
T
hyungi e9a95934ef feat(study): 카드 검수 그룹핑 — manual(직접 추가) 카드를 자료(material)별 묶음 + source_kind 노출
직접 추가 자료 카드(source_kind='manual', 출처 문제 없음)가 검수 UI에서 null 한 덩어리로
뭉치지 않도록 extra.material 별 그룹("[자료] ...") + CardItem.source_kind 노출(프론트 '직접 추가 자료' 라벨).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:41:13 +09:00

256 lines
9.2 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
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()