"""학습 진행 상태 (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 # Phase 2-F: due_today 탭에서만 채움. due_at < today 0시 (UTC) + stage < 4. # UI 가 "정체 N건" 경고 + [정리] 버튼 노출 판단에 사용. overdue_count: int = 0 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 ] # Phase 2-F: due_today 탭일 때 overdue 카운트 (오늘 0시 UTC 이전 due) — UI 경고 노출용 overdue_count = 0 if tab == "due_today": today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) overdue_row = await session.execute( select(func.count()) .select_from(StudyQuestionProgress) .where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, StudyQuestionProgress.due_at.is_not(None), StudyQuestionProgress.due_at < today_start, or_( StudyQuestionProgress.review_stage.is_(None), StudyQuestionProgress.review_stage < 4, ), ) ) overdue_count = int(overdue_row.scalar() or 0) return ReviewQueueResponse( tab=tab, total=total, items=items, page=page, page_size=page_size, overdue_count=overdue_count, ) # ─── redistribute (Phase 2-F due_at 정체 정리) ─── class RedistributeRequest(BaseModel): spread_days: int = 7 # 1~14 일 사이. default 7. class RedistributeResponse(BaseModel): redistributed_count: int spread_days: int @router.post( "/{topic_id}/review-queue/redistribute", response_model=RedistributeResponse ) async def redistribute_overdue( topic_id: int, body: RedistributeRequest, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """overdue (due_at < today 0시 UTC + stage < 4) 를 내일~spread_days 일에 round-robin 분산. 동작: - 오늘 0시 이전에 due 된 항목 모두 fetch (오래된 순) - i % spread_days + 1 일 후 자정 + i*7분 (분산용 분단위) 로 due_at 갱신 - review_stage 는 건드리지 않음 (정체 처리는 시간 재배치만) """ if not (1 <= body.spread_days <= 14): raise HTTPException(status_code=400, detail="spread_days 는 1~14 사이여야 합니다") topic = await session.get(StudyTopic, topic_id) _verify_topic_owner(topic, user) now = datetime.now(timezone.utc) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) overdue = ( await session.execute( select(StudyQuestionProgress) .where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, StudyQuestionProgress.due_at.is_not(None), StudyQuestionProgress.due_at < today_start, or_( StudyQuestionProgress.review_stage.is_(None), StudyQuestionProgress.review_stage < 4, ), ) .order_by(StudyQuestionProgress.due_at.asc()) ) ).scalars().all() if not overdue: return RedistributeResponse(redistributed_count=0, spread_days=body.spread_days) base_day = today_start # 오늘 0시 기준 — +1일부터 분산 for i, p in enumerate(overdue): days_offset = (i % body.spread_days) + 1 # 같은 날 안에서도 분산하려고 i*7분 추가 (200건 까지 24시간 안에 겹침 없이 spread) minute_offset = (i * 7) % (24 * 60) p.due_at = base_day + timedelta(days=days_offset, minutes=minute_offset) await session.commit() return RedistributeResponse( redistributed_count=len(overdue), spread_days=body.spread_days ) # ─── 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 StatsAiExplanation(BaseModel): """Phase 4-A 운영 관찰 — AI 풀이 캐시 진척 + 최근 7일 worker 결과.""" # study_questions.ai_explanation_status 분포 (이 토픽 전체) status_distribution: dict # 'none' / 'ready' / 'failed' / 'skipped' / 'stale' / 'pending' # wrong/unsure 중 ready 박힌 비율 (캐시 hit 가능성 추정) target_total: int # progress.last_outcome IN (wrong, unsure) 의 qid 수 target_ready: int # 그 중 ai_explanation_status='ready' 인 수 # 최근 7일 study_question_jobs 의 (status, error_code) 분포 recent_jobs: dict # {'completed': N, 'failed:guard_fail': N, 'failed:parse_fail': N, 'skipped:evidence_missing': N, 'pending': N, ...} 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] ai_explanation: StatsAiExplanation @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 ] # 8. Phase 4-A: AI 풀이 캐시 진척 + 최근 7일 worker 결과 # 8a. study_questions.ai_explanation_status 분포 (토픽 전체) ai_status_rows = ( await session.execute( select( func.coalesce(StudyQuestion.ai_explanation_status, "none").label("st"), func.count().label("cnt"), ) .where( StudyQuestion.user_id == user.id, StudyQuestion.study_topic_id == topic_id, StudyQuestion.deleted_at.is_(None), ) .group_by("st") ) ).all() ai_status_distribution = {r.st: int(r.cnt) for r in ai_status_rows} for k in ("none", "ready", "failed", "skipped", "stale", "pending"): ai_status_distribution.setdefault(k, 0) # 8b. wrong/unsure 의 ready 비율 (캐시 hit 가능성) target_total_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.in_(("wrong", "unsure")), ) ) target_total = int(target_total_row.scalar() or 0) target_ready_row = await session.execute( select(func.count()) .select_from(StudyQuestionProgress) .join( StudyQuestion, and_( StudyQuestion.id == StudyQuestionProgress.study_question_id, StudyQuestion.deleted_at.is_(None), ), ) .where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")), StudyQuestion.ai_explanation_status == "ready", ) ) target_ready = int(target_ready_row.scalar() or 0) # 8c. 최근 7일 study_question_jobs 분포 — terminal status × error_code from models.study_question_job import StudyQuestionJob recent_cutoff = now - timedelta(days=7) job_rows = ( await session.execute( select( StudyQuestionJob.status.label("st"), func.coalesce(StudyQuestionJob.error_code, "").label("err"), func.count().label("cnt"), ) .join( StudyQuestion, and_( StudyQuestion.id == StudyQuestionJob.study_question_id, StudyQuestion.study_topic_id == topic_id, StudyQuestion.user_id == user.id, ), ) .where( StudyQuestionJob.user_id == user.id, StudyQuestionJob.created_at >= recent_cutoff, ) .group_by("st", "err") ) ).all() recent_jobs: dict[str, int] = {} for r in job_rows: key = f"{r.st}:{r.err}" if r.err else r.st recent_jobs[key] = int(r.cnt) 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, ai_explanation=StatsAiExplanation( status_distribution=ai_status_distribution, target_total=target_total, target_ready=target_ready, recent_jobs=recent_jobs, ), )