Files
hyungi_document_server/app/api/study_question_progress.py
T
Hyungi Ahn d39882c38e feat(study): Phase 2-D 학습 통계 대시보드 — 6 섹션
신규 라우트 /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>
2026-05-01 10:04:03 +09:00

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,
)