From b9f2ade55e931e4fe4454b5db5e006c0f1b45751 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 08:49:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20=EC=95=94=EA=B8=B0=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B2=80=EC=88=98=20UI=20=E2=80=94=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=B9=B4=EB=93=9C=20review=20API=20+=20Sv?= =?UTF-8?q?elteKit=20/study/cards-review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/api/study_cards.py | 239 ++++++++++++++++++ app/main.py | 2 + frontend/src/routes/study/+page.svelte | 38 ++- .../routes/study/cards-review/+page.svelte | 230 +++++++++++++++++ 4 files changed, 501 insertions(+), 8 deletions(-) create mode 100644 app/api/study_cards.py create mode 100644 frontend/src/routes/study/cards-review/+page.svelte 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 @@
-
예정 (Phase 2~)
+
예정
    -
  • 모바일 암기노트 / 카드 복습
  • -
  • AI 자료 기반 퀴즈 출제 + 정답률 분야별 통계
  • -
  • SRS (1·3·7·14일 복습 일정)
  • +
  • 검수한 암기카드로 복습 (카드 SRS)
  • +
  • 모바일 암기카드 복습 + 공부 알람
diff --git a/frontend/src/routes/study/cards-review/+page.svelte b/frontend/src/routes/study/cards-review/+page.svelte new file mode 100644 index 0000000..240fd02 --- /dev/null +++ b/frontend/src/routes/study/cards-review/+page.svelte @@ -0,0 +1,230 @@ + + +암기카드 검수 + +
+
+ +

암기카드 검수

+ {#if total > 0} + 대기 {total} + {/if} +
+ + + +
+
+ +

+ AI가 추출한 암기카드를 확인하고 승인 / 수정 / 폐기합니다. 승인된 카드만 학습에 쓰입니다. +

+ + {#if loading} +
{#each Array(4).fill(0) as _, i (i)}{/each}
+ {:else if shownGroups.length === 0} + + {:else} +
+ {#each shownGroups as g (g.source_question_id)} +
+ +
+ +
+
출처 문제
+
{g.question_text}
+ {#if g.correct_choice}
사용자 정답: {g.correct_choice}번
{/if} +
+ {#if g.cards.length > 1} + + {/if} +
+ + +
+ {#each g.cards as c (c.id)} +
+
+ {c.format} + {#if c.flagged_by === 'source_changed' || c.flagged_by === 'source_deleted'} + {c.flagged_by === 'source_changed' ? '문제 수정됨' : '문제 삭제됨'} + {/if} +
+ + {#if editing === c.id} + +
+ + + {#if c.format === 'cloze'} + + {/if} +
+ + +
+
+ {:else} + +
+
{c.cue} +
+
+ {#if c.format === 'cloze' && c.cloze_text} + {c.cloze_text} +
정답: {c.fact}
+ {:else} + {c.fact} + {/if} +
+ {#if c.evidence?.length} +
근거: {c.evidence[0].snippet}
+ {:else} +
근거: 확정 풀이(비정량 개념)
+ {/if} +
+ + + +
+ {/if} +
+ {/each} +
+
+ {/each} +
+ {/if} +