diff --git a/app/api/study_cards.py b/app/api/study_cards.py new file mode 100644 index 0000000..c5047ba --- /dev/null +++ b/app/api/study_cards.py @@ -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() diff --git a/app/main.py b/app/main.py index 4159145..0e72a24 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/frontend/src/routes/study/+page.svelte b/frontend/src/routes/study/+page.svelte index 84644a2..fa43a65 100644 --- a/frontend/src/routes/study/+page.svelte +++ b/frontend/src/routes/study/+page.svelte @@ -1,8 +1,17 @@
학습 자료 회독 / 손글씨 필사 세션 / (예정) 퀴즈·복습.
+주제별 퀴즈·복습(SRS)·통계 / 학습 자료 회독 / 손글씨 필사 세션.
iPad + Apple Pencil 로 자격증 교재 / 어학 한자·단어를 손으로 필사. 세션 단위 stroke 보존.
+ + +푼 문제에서 AI가 추출한 암기카드(cloze 빈칸 / qa)를 확인하고 승인·수정·폐기. 승인된 카드만 학습에 쓰입니다.
++ AI가 추출한 암기카드를 확인하고 승인 / 수정 / 폐기합니다. 승인된 카드만 학습에 쓰입니다. +
+ + {#if loading} +