d39882c38e
신규 라우트 /study/topics/[id]/stats — backend 단일 endpoint 호출로 6 섹션:
진척도 / 학습 상태 분포 / 복습 큐 / 세션 추이 / 일별 풀이량 / 과목별 약점.
차트는 SVG 직접 렌더 (의존성 0).
Backend (app/api/study_question_progress.py):
- GET /study-topics/{tid}/stats — 6~7 쿼리 묶음
· 문제 진척도 (study_questions count + progress count)
· pattern_state 분포 (NULL → unattempted + 토픽 미시도분 합산)
· review_stage 분포 (0/1/2/3/mastered≥4)
· due 분류 (today / this_week / later / mastered) — datetime 비교 + filter
· 최근 done 세션 추이 (Phase 2-B 4 컬럼 활용, limit 20)
· 일별 풀이량 30일 (cast Date + group)
· 과목별 약점 (subject 별 attempted/correct/pending_review/chronic)
Frontend (/study/topics/[id]/stats):
- Card grid 6개. 진행률 바 + stacked horizontal bar + SVG sparkline + bar chart.
- 패턴 분포: 7색 stacked bar + 범례 grid.
- 복습 큐: 4 카운트 박스 + stage 분포 inline.
- 세션 추이: SVG sparkline (50% baseline) + 최근 5세션 표 (회복/퇴행/새로 맞힘 인라인).
- 일별 풀이량: SVG bar (max 동적) + title tooltip + start/end 날짜 라벨.
- 과목별: 정답률 진행률 바 + 미확인/반복 오답 인라인.
진입: 토픽 페이지 헤더 [통계] 버튼.
Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-D)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
535 lines
19 KiB
Python
535 lines
19 KiB
Python
"""학습 진행 상태 (progress) API — review-complete + review-queue + stats.
|
|
|
|
review-complete: 사용자가 오답/모르겠음 문제를 검토했음을 표시. due_at 최초 부여.
|
|
review-queue: 5 탭 (due_today / pending_review / chronic / regressed / mastered) 으로 progress 조회.
|
|
stats (Phase 2-D): 통계 대시보드 — 진척도 / 패턴 분포 / 복습 큐 / 세션 추이 / 일별 풀이량 / 과목별.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import and_, case, cast, func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.types import Date as SQLDate
|
|
|
|
from core.auth import get_current_user
|
|
from core.database import get_session
|
|
from models.study_question import StudyQuestion, StudyQuestionAttempt
|
|
from models.study_question_progress import StudyQuestionProgress
|
|
from models.study_quiz_session import StudyQuizSession
|
|
from models.study_topic import StudyTopic
|
|
from models.user import User
|
|
|
|
router = APIRouter(prefix="/study-topics", tags=["study-progress"])
|
|
|
|
# 1차 due_at 부여 시 디폴트 1일 뒤
|
|
DEFAULT_FIRST_DUE_DAYS = 1
|
|
|
|
|
|
def _verify_topic_owner(topic: StudyTopic | None, user: User) -> None:
|
|
if topic is None or topic.deleted_at is not None or topic.user_id != user.id:
|
|
raise HTTPException(status_code=404, detail="주제를 찾을 수 없습니다")
|
|
|
|
|
|
@router.post("/{topic_id}/questions/{question_id}/review-complete", status_code=204)
|
|
async def review_complete(
|
|
topic_id: int,
|
|
question_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""확인완료 처리 — last_reviewed_at + (wrong/unsure 인 경우) due_at 최초 부여.
|
|
|
|
이미 due_at 박힌 문제면 due_at 그대로 유지 (큐 위치 보존).
|
|
정답 맞춘 문제면 due_at 박지 않음 (큐 폭발 방지).
|
|
"""
|
|
topic = await session.get(StudyTopic, topic_id)
|
|
_verify_topic_owner(topic, user)
|
|
|
|
q = await session.get(StudyQuestion, question_id)
|
|
if q is None or q.deleted_at is not None or q.user_id != user.id or q.study_topic_id != topic_id:
|
|
raise HTTPException(status_code=404, detail="문제를 찾을 수 없습니다")
|
|
|
|
progress = (
|
|
await session.execute(
|
|
select(StudyQuestionProgress).where(
|
|
StudyQuestionProgress.user_id == user.id,
|
|
StudyQuestionProgress.study_topic_id == topic_id,
|
|
StudyQuestionProgress.study_question_id == question_id,
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if progress is None:
|
|
# attempt 없는데 review-complete 시도. 진척 상태가 없어 의미 없음.
|
|
raise HTTPException(status_code=409, detail="아직 시도한 적이 없는 문제입니다")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
progress.last_reviewed_at = now
|
|
|
|
# due_at 최초 부여는 wrong/unsure 일 때만. 이미 박혀있으면 유지.
|
|
if progress.last_outcome in ("wrong", "unsure") and progress.due_at is None:
|
|
progress.review_stage = 0
|
|
progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)
|
|
|
|
await session.commit()
|
|
|
|
|
|
# ─── review-queue ───
|
|
|
|
|
|
class ReviewQueueItem(BaseModel):
|
|
question_id: int
|
|
question_text: str
|
|
subject: str | None
|
|
scope: str | None
|
|
exam_round: str | None
|
|
exam_question_number: int | None
|
|
last_outcome: str | None
|
|
last_attempted_at: datetime | None
|
|
last_reviewed_at: datetime | None
|
|
due_at: datetime | None
|
|
review_stage: int | None
|
|
pattern_state: str | None
|
|
|
|
|
|
class ReviewQueueResponse(BaseModel):
|
|
tab: str
|
|
total: int
|
|
items: list[ReviewQueueItem]
|
|
page: int
|
|
page_size: int
|
|
|
|
|
|
def _truncate(text: str, n: int = 80) -> str:
|
|
if not text:
|
|
return ""
|
|
s = text.strip()
|
|
return s if len(s) <= n else s[:n].rstrip() + "…"
|
|
|
|
|
|
@router.get("/{topic_id}/review-queue", response_model=ReviewQueueResponse)
|
|
async def review_queue(
|
|
topic_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
tab: str = Query(..., pattern="^(due_today|pending_review|chronic|regressed|mastered)$"),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
):
|
|
"""5 탭 진행 상태 조회.
|
|
|
|
- due_today: progress.due_at <= now() AND review_stage < 4
|
|
- pending_review: last_outcome IN (wrong, unsure)
|
|
AND (last_reviewed_at IS NULL OR last_reviewed_at < last_attempted_at)
|
|
- chronic: pattern_state = 'chronic_wrong'
|
|
- regressed: pattern_state = 'regressed'
|
|
- mastered: review_stage >= 4
|
|
"""
|
|
topic = await session.get(StudyTopic, topic_id)
|
|
_verify_topic_owner(topic, user)
|
|
|
|
base = (
|
|
select(StudyQuestionProgress, StudyQuestion)
|
|
.join(StudyQuestion, StudyQuestion.id == StudyQuestionProgress.study_question_id)
|
|
.where(
|
|
StudyQuestionProgress.user_id == user.id,
|
|
StudyQuestionProgress.study_topic_id == topic_id,
|
|
StudyQuestion.deleted_at.is_(None),
|
|
)
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
if tab == "due_today":
|
|
base = base.where(
|
|
StudyQuestionProgress.due_at.is_not(None),
|
|
StudyQuestionProgress.due_at <= now,
|
|
or_(
|
|
StudyQuestionProgress.review_stage.is_(None),
|
|
StudyQuestionProgress.review_stage < 4,
|
|
),
|
|
).order_by(StudyQuestionProgress.due_at.asc())
|
|
elif tab == "pending_review":
|
|
base = base.where(
|
|
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
|
|
or_(
|
|
StudyQuestionProgress.last_reviewed_at.is_(None),
|
|
and_(
|
|
StudyQuestionProgress.last_reviewed_at.is_not(None),
|
|
StudyQuestionProgress.last_attempted_at.is_not(None),
|
|
StudyQuestionProgress.last_reviewed_at
|
|
< StudyQuestionProgress.last_attempted_at,
|
|
),
|
|
),
|
|
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
|
|
elif tab == "chronic":
|
|
base = base.where(
|
|
StudyQuestionProgress.pattern_state == "chronic_wrong",
|
|
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
|
|
elif tab == "regressed":
|
|
base = base.where(
|
|
StudyQuestionProgress.pattern_state == "regressed",
|
|
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
|
|
elif tab == "mastered":
|
|
base = base.where(
|
|
StudyQuestionProgress.review_stage.is_not(None),
|
|
StudyQuestionProgress.review_stage >= 4,
|
|
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
|
|
|
|
# total
|
|
total_row = await session.execute(
|
|
select(func.count()).select_from(base.subquery())
|
|
)
|
|
total = int(total_row.scalar() or 0)
|
|
|
|
# paged
|
|
rows = (
|
|
await session.execute(
|
|
base.offset((page - 1) * page_size).limit(page_size)
|
|
)
|
|
).all()
|
|
|
|
items = [
|
|
ReviewQueueItem(
|
|
question_id=q.id,
|
|
question_text=_truncate(q.question_text, 80),
|
|
subject=q.subject,
|
|
scope=q.scope,
|
|
exam_round=q.exam_round,
|
|
exam_question_number=q.exam_question_number,
|
|
last_outcome=p.last_outcome,
|
|
last_attempted_at=p.last_attempted_at,
|
|
last_reviewed_at=p.last_reviewed_at,
|
|
due_at=p.due_at,
|
|
review_stage=p.review_stage,
|
|
pattern_state=p.pattern_state,
|
|
)
|
|
for (p, q) in rows
|
|
]
|
|
|
|
return ReviewQueueResponse(
|
|
tab=tab, total=total, items=items, page=page, page_size=page_size,
|
|
)
|
|
|
|
|
|
# ─── stats (Phase 2-D 통계 대시보드) ───
|
|
|
|
|
|
class StatsQuestions(BaseModel):
|
|
total: int
|
|
attempted: int
|
|
unattempted: int
|
|
|
|
|
|
class StatsDue(BaseModel):
|
|
today: int
|
|
this_week: int
|
|
later: int
|
|
mastered: int
|
|
|
|
|
|
class StatsSessionTrendItem(BaseModel):
|
|
id: int
|
|
finished_at: datetime
|
|
total: int
|
|
correct_count: int
|
|
wrong_count: int
|
|
unsure_count: int
|
|
accuracy: int # 0~100
|
|
newly_correct_count: int
|
|
relapsed_count: int
|
|
recovered_count: int
|
|
|
|
|
|
class StatsDailyAttempt(BaseModel):
|
|
date: date
|
|
count: int
|
|
|
|
|
|
class StatsSubjectBreakdown(BaseModel):
|
|
subject: str
|
|
total: int
|
|
attempted: int
|
|
last_correct: int
|
|
accuracy: int # 0~100
|
|
pending_review: int
|
|
chronic: int
|
|
|
|
|
|
class StatsResponse(BaseModel):
|
|
questions: StatsQuestions
|
|
pattern_distribution: dict # state(or "unattempted") → count
|
|
review_stage_distribution: dict # "0"/"1"/"2"/"3"/"mastered" → count
|
|
due: StatsDue
|
|
session_trend: list[StatsSessionTrendItem] # 최근 done 세션 newest→oldest
|
|
daily_attempts_30d: list[StatsDailyAttempt]
|
|
subject_breakdown: list[StatsSubjectBreakdown]
|
|
|
|
|
|
@router.get("/{topic_id}/stats", response_model=StatsResponse)
|
|
async def topic_stats(
|
|
topic_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
session_trend_limit: int = Query(20, ge=1, le=100),
|
|
):
|
|
"""통계 대시보드 — progress + quiz_sessions + attempts 한 번에 집계.
|
|
|
|
가벼운 쿼리 6~7 묶음. 1인 운영 + 토픽당 progress 수천 행 가정 — 추가 인덱스 없이 OK.
|
|
"""
|
|
topic = await session.get(StudyTopic, topic_id)
|
|
_verify_topic_owner(topic, user)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# 1. 문제 진척도 — 토픽의 question 총수 + progress 행 수 (attempted)
|
|
total_q_row = await session.execute(
|
|
select(func.count())
|
|
.select_from(StudyQuestion)
|
|
.where(
|
|
StudyQuestion.user_id == user.id,
|
|
StudyQuestion.study_topic_id == topic_id,
|
|
StudyQuestion.deleted_at.is_(None),
|
|
)
|
|
)
|
|
total_q = int(total_q_row.scalar() or 0)
|
|
|
|
attempted_row = await session.execute(
|
|
select(func.count())
|
|
.select_from(StudyQuestionProgress)
|
|
.where(
|
|
StudyQuestionProgress.user_id == user.id,
|
|
StudyQuestionProgress.study_topic_id == topic_id,
|
|
StudyQuestionProgress.last_outcome.is_not(None),
|
|
)
|
|
)
|
|
attempted = int(attempted_row.scalar() or 0)
|
|
unattempted = max(0, total_q - attempted)
|
|
|
|
# 2. pattern_state 분포 (NULL 은 "unattempted" 로)
|
|
pattern_rows = (
|
|
await session.execute(
|
|
select(
|
|
func.coalesce(StudyQuestionProgress.pattern_state, "unattempted").label("state"),
|
|
func.count().label("cnt"),
|
|
)
|
|
.where(
|
|
StudyQuestionProgress.user_id == user.id,
|
|
StudyQuestionProgress.study_topic_id == topic_id,
|
|
)
|
|
.group_by("state")
|
|
)
|
|
).all()
|
|
pattern_distribution = {r.state: int(r.cnt) for r in pattern_rows}
|
|
# 모든 키 default 0 채우기 (UI 가 빈 키 처리 안 해도 되게)
|
|
for k in ("stable", "unstable", "unsure", "regressed", "recovered", "chronic_wrong", "unattempted"):
|
|
pattern_distribution.setdefault(k, 0)
|
|
# 한 번도 시도 안 한 (progress 행 자체 없음) 분량을 unattempted 에 합산
|
|
pattern_distribution["unattempted"] += unattempted
|
|
|
|
# 3. review_stage 분포 — 0/1/2/3/mastered (>=4)
|
|
stage_rows = (
|
|
await session.execute(
|
|
select(
|
|
StudyQuestionProgress.review_stage.label("stage"),
|
|
func.count().label("cnt"),
|
|
)
|
|
.where(
|
|
StudyQuestionProgress.user_id == user.id,
|
|
StudyQuestionProgress.study_topic_id == topic_id,
|
|
StudyQuestionProgress.review_stage.is_not(None),
|
|
)
|
|
.group_by(StudyQuestionProgress.review_stage)
|
|
)
|
|
).all()
|
|
review_stage_distribution = {"0": 0, "1": 0, "2": 0, "3": 0, "mastered": 0}
|
|
for r in stage_rows:
|
|
st = int(r.stage)
|
|
if st >= 4:
|
|
review_stage_distribution["mastered"] += int(r.cnt)
|
|
elif 0 <= st <= 3:
|
|
review_stage_distribution[str(st)] += int(r.cnt)
|
|
|
|
# 4. due 분류 — today / this_week / later / mastered
|
|
end_today = now.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
end_week = end_today + timedelta(days=7)
|
|
due_rows = (
|
|
await session.execute(
|
|
select(
|
|
func.count().filter(
|
|
and_(
|
|
StudyQuestionProgress.due_at.is_not(None),
|
|
StudyQuestionProgress.due_at <= end_today,
|
|
or_(
|
|
StudyQuestionProgress.review_stage.is_(None),
|
|
StudyQuestionProgress.review_stage < 4,
|
|
),
|
|
)
|
|
).label("today"),
|
|
func.count().filter(
|
|
and_(
|
|
StudyQuestionProgress.due_at.is_not(None),
|
|
StudyQuestionProgress.due_at > end_today,
|
|
StudyQuestionProgress.due_at <= end_week,
|
|
or_(
|
|
StudyQuestionProgress.review_stage.is_(None),
|
|
StudyQuestionProgress.review_stage < 4,
|
|
),
|
|
)
|
|
).label("this_week"),
|
|
func.count().filter(
|
|
and_(
|
|
StudyQuestionProgress.due_at.is_not(None),
|
|
StudyQuestionProgress.due_at > end_week,
|
|
or_(
|
|
StudyQuestionProgress.review_stage.is_(None),
|
|
StudyQuestionProgress.review_stage < 4,
|
|
),
|
|
)
|
|
).label("later"),
|
|
func.count().filter(
|
|
and_(
|
|
StudyQuestionProgress.review_stage.is_not(None),
|
|
StudyQuestionProgress.review_stage >= 4,
|
|
)
|
|
).label("mastered"),
|
|
)
|
|
.where(
|
|
StudyQuestionProgress.user_id == user.id,
|
|
StudyQuestionProgress.study_topic_id == topic_id,
|
|
)
|
|
)
|
|
).first()
|
|
due = StatsDue(
|
|
today=int(due_rows.today or 0),
|
|
this_week=int(due_rows.this_week or 0),
|
|
later=int(due_rows.later or 0),
|
|
mastered=int(due_rows.mastered or 0),
|
|
)
|
|
|
|
# 5. 최근 done 세션 추이 (Phase 2-B 4 컬럼 활용)
|
|
sess_rows = (
|
|
await session.execute(
|
|
select(StudyQuizSession)
|
|
.where(
|
|
StudyQuizSession.user_id == user.id,
|
|
StudyQuizSession.study_topic_id == topic_id,
|
|
StudyQuizSession.status == "done",
|
|
StudyQuizSession.finished_at.is_not(None),
|
|
)
|
|
.order_by(StudyQuizSession.finished_at.desc())
|
|
.limit(session_trend_limit)
|
|
)
|
|
).scalars().all()
|
|
session_trend: list[StatsSessionTrendItem] = []
|
|
for s in sess_rows:
|
|
total_n = len(s.question_ids or [])
|
|
acc = round((s.correct_count / total_n) * 100) if total_n > 0 else 0
|
|
session_trend.append(StatsSessionTrendItem(
|
|
id=s.id,
|
|
finished_at=s.finished_at,
|
|
total=total_n,
|
|
correct_count=s.correct_count,
|
|
wrong_count=s.wrong_count,
|
|
unsure_count=s.unsure_count,
|
|
accuracy=acc,
|
|
newly_correct_count=s.newly_correct_count,
|
|
relapsed_count=s.relapsed_count,
|
|
recovered_count=s.recovered_count,
|
|
))
|
|
|
|
# 6. 일별 풀이량 30일 (date 기준 — UTC, 시간대 차이는 Phase 5 후보)
|
|
start_30d = (now - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
daily_rows = (
|
|
await session.execute(
|
|
select(
|
|
cast(StudyQuestionAttempt.answered_at, SQLDate).label("d"),
|
|
func.count().label("cnt"),
|
|
)
|
|
.where(
|
|
StudyQuestionAttempt.user_id == user.id,
|
|
StudyQuestionAttempt.study_topic_id == topic_id,
|
|
StudyQuestionAttempt.answered_at >= start_30d,
|
|
)
|
|
.group_by("d")
|
|
.order_by("d")
|
|
)
|
|
).all()
|
|
daily_attempts_30d = [StatsDailyAttempt(date=r.d, count=int(r.cnt)) for r in daily_rows]
|
|
|
|
# 7. 과목별 약점
|
|
subj_rows = (
|
|
await session.execute(
|
|
select(
|
|
func.coalesce(StudyQuestion.subject, "(미분류)").label("subject"),
|
|
func.count(StudyQuestion.id.distinct()).label("total"),
|
|
func.count(StudyQuestionProgress.id.distinct()).filter(
|
|
StudyQuestionProgress.last_outcome.is_not(None)
|
|
).label("attempted"),
|
|
func.count(StudyQuestionProgress.id.distinct()).filter(
|
|
StudyQuestionProgress.last_outcome == "correct"
|
|
).label("last_correct"),
|
|
func.count(StudyQuestionProgress.id.distinct()).filter(
|
|
and_(
|
|
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
|
|
or_(
|
|
StudyQuestionProgress.last_reviewed_at.is_(None),
|
|
and_(
|
|
StudyQuestionProgress.last_reviewed_at.is_not(None),
|
|
StudyQuestionProgress.last_attempted_at.is_not(None),
|
|
StudyQuestionProgress.last_reviewed_at
|
|
< StudyQuestionProgress.last_attempted_at,
|
|
),
|
|
),
|
|
)
|
|
).label("pending_review"),
|
|
func.count(StudyQuestionProgress.id.distinct()).filter(
|
|
StudyQuestionProgress.pattern_state == "chronic_wrong"
|
|
).label("chronic"),
|
|
)
|
|
.select_from(StudyQuestion)
|
|
.outerjoin(
|
|
StudyQuestionProgress,
|
|
and_(
|
|
StudyQuestionProgress.user_id == StudyQuestion.user_id,
|
|
StudyQuestionProgress.study_topic_id == StudyQuestion.study_topic_id,
|
|
StudyQuestionProgress.study_question_id == StudyQuestion.id,
|
|
),
|
|
)
|
|
.where(
|
|
StudyQuestion.user_id == user.id,
|
|
StudyQuestion.study_topic_id == topic_id,
|
|
StudyQuestion.deleted_at.is_(None),
|
|
)
|
|
.group_by("subject")
|
|
.order_by(func.count(StudyQuestion.id.distinct()).desc())
|
|
)
|
|
).all()
|
|
subject_breakdown = [
|
|
StatsSubjectBreakdown(
|
|
subject=r.subject,
|
|
total=int(r.total),
|
|
attempted=int(r.attempted),
|
|
last_correct=int(r.last_correct),
|
|
accuracy=round((int(r.last_correct) / int(r.attempted)) * 100) if int(r.attempted) > 0 else 0,
|
|
pending_review=int(r.pending_review),
|
|
chronic=int(r.chronic),
|
|
)
|
|
for r in subj_rows
|
|
]
|
|
|
|
return StatsResponse(
|
|
questions=StatsQuestions(
|
|
total=total_q, attempted=attempted, unattempted=unattempted
|
|
),
|
|
pattern_distribution=pattern_distribution,
|
|
review_stage_distribution=review_stage_distribution,
|
|
due=due,
|
|
session_trend=session_trend,
|
|
daily_attempts_30d=daily_attempts_30d,
|
|
subject_breakdown=subject_breakdown,
|
|
)
|