Files
hyungi 08c5213168 feat(publish): S-4 pub_card_progress 발행 — 카드 SR 상태 read model (study→viewer)
DS 가 가진 카드 SR progress row 를 발행(kind=study_card_progress) = read model.
viewer C-4 복습큐/미확인 set-difference 재료. plan study-viewer-port S-4.
- projection: KIND_CARD_PROGRESS + project_card_progress(card_id·topic_id·last_outcome·
  last_reviewed_at·due_at·review_stage). ★ALL row(due_at NULL sentinel=암-on-new·terminal
  포함) — due-only 발행 금지(sentinel 누락→viewer 미확인 오분류).
- enqueue: enqueue_card_progress_publish + backfill_publish_card_progress(필터 없음).
- 훅: /study-cards/{id}/rate 의 rate_card 직후(같은 tx·flag 게이트). 단일 write 사이트.
  SR 계산=DS(sr_schedule 무변경), 발행=결과만.
- 카드 삭제 시 progress tombstone 안 함 = DS SR 보존(재승인 복원), orphan 은 viewer C-4 가 로컬 드롭.
- scripts/backfill_publish_card_progress.py.

py_compile PASS · project_card_progress 단위검증(sentinel due_at=None 보존).

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

438 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_progress_publish, 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))
# 카드 SR 상태 발행(같은 tx, flag off=no-op) — ALL row(sentinel/terminal 포함). S-4.
if settings.study_publish_enabled:
await enqueue_card_progress_publish(session, progress)
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()