af5640ef49
검수완료(needs_review=false)·미삭제 study_memo_card 만 발행(kind=study_card, 뷰어 pubstudy.ts getCards 계약 일치). plan study-viewer-port S-2. - projection: KIND_CARD + project_card(format·cue·fact·cloze_text·source_question_id·source_generated_at). - enqueue: enqueue_card_publish = 카드 상태 기반 publish/tombstone 단일화(경로별 가드 기억 회피) + backfill_publish_cards. - 저작훅(study_publish_enabled 게이트): approve-batch(검수완료→발행)·update(수정=재투영/ 검수대기복귀=tombstone)·delete(tombstone). - 발행자격 상실 경로 tombstone(viewer stale 잔류 0): 워커 supersede(재추출 retire)· flag_cards_for_source(소스문제 정정/삭제). 두 fn 은 '발행 중이던'(needs_review=false) id 만 선캡처 반환 → 미발행 카드 스푸리어스 tombstone 회피. - scripts/backfill_publish_cards.py. py_compile PASS · project_card payload 단위검증(getCards 계약 일치). 워커·/published/feed kind-generic 무변경. flag on 환경 배포 시 주제처럼 카드 발행 시작. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
16 KiB
Python
435 lines
16 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 and_, func, or_, select, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from core.config import settings
|
|
from core.database import get_session
|
|
from models.study_memo_card import StudyMemoCard, StudyMemoCardEvidence, record_card_view
|
|
from models.study_memo_card_progress import StudyMemoCardProgress, rate_card
|
|
from models.study_question import StudyQuestion
|
|
from models.user import User
|
|
from services.study.card_normalize import compute_dedup_hash
|
|
from services.study.publish_enqueue import enqueue_card_publish
|
|
|
|
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] = []
|
|
# 복습(SR) 큐에서만 채움 — 정답('암') 시 다음 복습일 미리보기 라벨 계산용
|
|
# (stage별 동적: +3/7/14일·졸업). deck/검수 응답에선 None.
|
|
review_stage: int | None = None
|
|
|
|
|
|
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
|
|
|
|
|
|
class RateBody(BaseModel):
|
|
outcome: str # 암/애매/모름 또는 correct/unsure/wrong
|
|
|
|
|
|
class RateResult(BaseModel):
|
|
card_id: int
|
|
outcome: str
|
|
review_stage: int | None = None
|
|
due_at: datetime | None = None
|
|
|
|
|
|
# 자기평가 read-time 매핑 (신규 enum 0 — last_outcome 어휘는 기존 4종 재사용)
|
|
_RATE_MAP = {
|
|
"암": "correct", "애매": "unsure", "모름": "wrong",
|
|
"correct": "correct", "unsure": "unsure", "wrong": "wrong",
|
|
}
|
|
|
|
|
|
async def _build_card_items(
|
|
session: AsyncSession,
|
|
cards: list[StudyMemoCard],
|
|
stages: dict[int, int | None] | None = None,
|
|
) -> list[CardItem]:
|
|
"""카드 목록 → CardItem(evidence 동반). due/deck 학습 flow 공용.
|
|
|
|
stages: card_id → review_stage (복습 큐에서만 전달, 동적 라벨 미리보기용).
|
|
"""
|
|
if not cards:
|
|
return []
|
|
stages = stages or {}
|
|
ids = [c.id for c in cards]
|
|
ev_rows = (
|
|
await session.execute(
|
|
select(StudyMemoCardEvidence).where(StudyMemoCardEvidence.card_id.in_(ids))
|
|
)
|
|
).scalars().all()
|
|
ev_by: dict[int, list[CardEvidence]] = {}
|
|
for e in ev_rows:
|
|
ev_by.setdefault(e.card_id, []).append(
|
|
CardEvidence(source_type=e.source_type, source_id=e.source_id, snippet=e.snippet)
|
|
)
|
|
return [
|
|
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.get(c.id, []), review_stage=stages.get(c.id),
|
|
)
|
|
for c in cards
|
|
]
|
|
|
|
|
|
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)
|
|
.returning(StudyMemoCard.id)
|
|
)
|
|
approved_ids = list(result.scalars().all())
|
|
# 방금 검수완료된 카드 발행(같은 tx, flag off 면 no-op). S-2.
|
|
if settings.study_publish_enabled and approved_ids:
|
|
cards = (
|
|
await session.execute(select(StudyMemoCard).where(StudyMemoCard.id.in_(approved_ids)))
|
|
).scalars().all()
|
|
for c in cards:
|
|
await enqueue_card_publish(session, c)
|
|
await session.commit()
|
|
return {"approved": len(approved_ids)}
|
|
|
|
|
|
# ─── 복습(SR) 트랙 ───
|
|
|
|
@router.get("/due", response_model=list[CardItem])
|
|
async def due_cards(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
limit: Annotated[int, Query(ge=1, le=200)] = 30,
|
|
):
|
|
"""오늘 복습할 카드 (검수 통과만). 두 부류:
|
|
- 신규 승인 카드(progress 없음=첫 회상 전) — SR 큐 진입 경로(첫 회상). '암'이면 due 안
|
|
박고 종료('큐 폭발 방지'), 애매/모름이면 평가 즉시 due(내일)로 입고.
|
|
- 예정 due 카드(due_at<=now, stage<4).
|
|
progress 는 user+card UNIQUE 라 outer join 으로 최대 1행. 예정 due 먼저, 신규(due NULL) 뒤로."""
|
|
now = datetime.now(timezone.utc)
|
|
P = StudyMemoCardProgress
|
|
rows = (
|
|
await session.execute(
|
|
select(StudyMemoCard, P.review_stage)
|
|
.outerjoin(P, and_(P.card_id == StudyMemoCard.id, P.user_id == user.id))
|
|
.where(
|
|
StudyMemoCard.user_id == user.id,
|
|
StudyMemoCard.deleted_at.is_(None),
|
|
StudyMemoCard.needs_review.is_(False),
|
|
or_(
|
|
P.id.is_(None), # 신규(첫 회상 전) — progress 미생성
|
|
and_(
|
|
P.due_at.is_not(None),
|
|
P.due_at <= now,
|
|
or_(P.review_stage.is_(None), P.review_stage < 4),
|
|
),
|
|
),
|
|
)
|
|
.order_by(P.due_at.asc().nulls_last(), StudyMemoCard.id.asc())
|
|
.limit(limit)
|
|
)
|
|
).all()
|
|
cards = [r[0] for r in rows]
|
|
stages = {r[0].id: r[1] for r in rows}
|
|
return await _build_card_items(session, cards, stages)
|
|
|
|
|
|
@router.post("/{card_id}/rate", response_model=RateResult)
|
|
async def rate(
|
|
card_id: int,
|
|
body: RateBody,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""카드 자기평가(암/애매/모름) → SR 즉시 자동 입고."""
|
|
card = await session.get(StudyMemoCard, card_id)
|
|
card = _verify_card(card, user)
|
|
if card.needs_review:
|
|
raise HTTPException(status_code=400, detail="검수 안 된 카드는 복습(SR) 대상이 아닙니다")
|
|
outcome = _RATE_MAP.get((body.outcome or "").strip())
|
|
if outcome is None:
|
|
raise HTTPException(status_code=422, detail=f"invalid outcome: {body.outcome!r}")
|
|
progress = await rate_card(session, card=card, outcome=outcome, now=datetime.now(timezone.utc))
|
|
await session.commit()
|
|
return RateResult(
|
|
card_id=card.id, outcome=outcome, review_stage=progress.review_stage, due_at=progress.due_at
|
|
)
|
|
|
|
|
|
# ─── 그냥 공부(cram) 트랙 — 봤다 기록, SR 무관 ───
|
|
|
|
@router.get("/deck", response_model=list[CardItem])
|
|
async def deck(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
material: Annotated[str | None, Query()] = None,
|
|
format: Annotated[str | None, Query()] = None,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
):
|
|
"""'그냥 공부'(cram) 덱 — 검수 통과 카드를 덜 본 순서로. material/format 필터. SR 무관."""
|
|
conds = [
|
|
StudyMemoCard.user_id == user.id,
|
|
StudyMemoCard.deleted_at.is_(None),
|
|
StudyMemoCard.needs_review.is_(False),
|
|
]
|
|
if format in ("qa", "cloze"):
|
|
conds.append(StudyMemoCard.format == format)
|
|
if material:
|
|
conds.append(StudyMemoCard.extra["material"].astext == material)
|
|
rows = (
|
|
await session.execute(
|
|
select(StudyMemoCard)
|
|
.where(*conds)
|
|
.order_by(StudyMemoCard.last_viewed_at.asc().nulls_first(), StudyMemoCard.id.asc())
|
|
.limit(limit)
|
|
)
|
|
).scalars().all()
|
|
return await _build_card_items(session, list(rows))
|
|
|
|
|
|
@router.post("/{card_id}/view", status_code=204)
|
|
async def view_card(
|
|
card_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""'그냥 공부' 봤다 기록 (view_count++, SR 무관)."""
|
|
ok = await record_card_view(session, user_id=user.id, card_id=card_id)
|
|
await session.commit()
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="카드를 찾을 수 없습니다")
|
|
|
|
|
|
@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
|
|
|
|
# 발행 재투영/tombstone(같은 tx) — 검수완료=발행·검수대기복귀=tombstone(상태 기반). S-2.
|
|
if settings.study_publish_enabled:
|
|
await enqueue_card_publish(session, card)
|
|
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)
|
|
# 발행 tombstone(같은 tx) — 삭제는 feed 1급 이벤트. S-2.
|
|
if settings.study_publish_enabled:
|
|
await enqueue_card_publish(session, card)
|
|
await session.commit()
|