diff --git a/app/api/study_questions.py b/app/api/study_questions.py index d6e70d7..8a83dec 100644 --- a/app/api/study_questions.py +++ b/app/api/study_questions.py @@ -12,7 +12,7 @@ PR-2 가드레일: import asyncio import logging import random -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile @@ -1017,11 +1017,40 @@ async def mark_attempt_reviewed( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): - """결과 카드에서 [학습완료] 토글. reviewed=true 면 timestamp, false 면 NULL.""" + """결과 카드에서 [학습완료] 토글. reviewed=true 면 timestamp, false 면 NULL. + + Phase 2-B: progress 단위 review-complete 도 동시에 박는다 — last_reviewed_at + + (wrong/unsure 면) due_at 최초 부여. attempt.reviewed_at 과 progress.last_reviewed_at + 은 의미가 다르지만 결과 화면 UX 에서 한 버튼으로 통합. reviewed=false 로 되돌릴 때는 + progress 의 last_reviewed_at 은 건드리지 않는다 (다른 attempt 가 검토 표시했을 수 있음). + """ + from models.study_question_progress import StudyQuestionProgress + attempt = await session.get(StudyQuestionAttempt, attempt_id) if attempt is None or attempt.user_id != user.id: raise HTTPException(status_code=404, detail="attempt 를 찾을 수 없습니다") - attempt.reviewed_at = datetime.now(timezone.utc) if body.reviewed else None + + now = datetime.now(timezone.utc) + attempt.reviewed_at = now if body.reviewed else None + + # progress 동기화 — reviewed=true 일 때만. false 토글은 attempts 레벨만 되돌림. + if body.reviewed: + progress = ( + await session.execute( + select(StudyQuestionProgress).where( + StudyQuestionProgress.user_id == user.id, + StudyQuestionProgress.study_topic_id == attempt.study_topic_id, + StudyQuestionProgress.study_question_id == attempt.study_question_id, + ) + ) + ).scalar_one_or_none() + if progress is not None: + progress.last_reviewed_at = now + # due_at 최초 부여 — wrong/unsure 인 progress 만, due_at 미박힌 경우만. + if progress.last_outcome in ("wrong", "unsure") and progress.due_at is None: + progress.review_stage = 0 + progress.due_at = now + timedelta(days=1) + await session.commit() return AttemptReviewResponse(id=attempt.id, reviewed_at=attempt.reviewed_at) diff --git a/app/api/study_topics.py b/app/api/study_topics.py index 5d51ad6..a931d6a 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -25,7 +25,7 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field -from sqlalchemy import and_, case, delete, func, select, text as sql_text, update +from sqlalchemy import and_, case, delete, func, or_ as sql_or, select, text as sql_text, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -1263,6 +1263,15 @@ class QuizSessionSummary(BaseModel): finished_at: datetime | None # 결과 카드 헤더 "미확인 N건" 용 — done 세션에서만 의미. unreviewed_wrong_unsure_count: int = 0 + # Phase 2-B: finalize 시점 스냅샷 (DB 영속화 — 세션 종료 후 변하지 않음). + newly_correct_count: int = 0 + relapsed_count: int = 0 + recovered_count: int = 0 + chronic_remaining_count: int = 0 + # Phase 2-B: 동적 (지금 시점) 할 일 카운트. 결과 화면에서 다른 세션이 끼어도 fresh. + pending_review_count: int = 0 + chronic_count: int = 0 + regressed_count: int = 0 class QuizSessionListResponse(BaseModel): @@ -1273,8 +1282,14 @@ class QuizSessionListResponse(BaseModel): async def _build_session_summary( s: StudyQuizSession, session: AsyncSession, + *, + include_progress_counts: bool = False, ) -> QuizSessionSummary: - """status='done' 일 때 미확인 카운트(reviewed_at NULL + outcome IN wrong/unsure) 계산.""" + """status='done' 일 때 미확인 카운트 + (옵션) 지금 시점 progress 기반 할 일 카운트 계산. + + include_progress_counts=True 면 결과 화면 헤더용 동적 카운트 3종 (pending_review/chronic/regressed) + SQL 3회 추가. 목록 endpoint 에서는 호출당 비용을 피하려고 default False. + """ unreviewed = 0 if s.status == "done": row = ( @@ -1289,6 +1304,61 @@ async def _build_session_summary( ) ).scalar() or 0 unreviewed = int(row) + + pending_review_count = 0 + chronic_count = 0 + regressed_count = 0 + if include_progress_counts: + from models.study_question_progress import StudyQuestionProgress + + pr_row = ( + await session.execute( + select(func.count()) + .select_from(StudyQuestionProgress) + .where( + StudyQuestionProgress.user_id == s.user_id, + StudyQuestionProgress.study_topic_id == s.study_topic_id, + StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")), + sql_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, + ), + ), + ) + ) + ).scalar() or 0 + pending_review_count = int(pr_row) + + ch_row = ( + await session.execute( + select(func.count()) + .select_from(StudyQuestionProgress) + .where( + StudyQuestionProgress.user_id == s.user_id, + StudyQuestionProgress.study_topic_id == s.study_topic_id, + StudyQuestionProgress.pattern_state == "chronic_wrong", + ) + ) + ).scalar() or 0 + chronic_count = int(ch_row) + + rg_row = ( + await session.execute( + select(func.count()) + .select_from(StudyQuestionProgress) + .where( + StudyQuestionProgress.user_id == s.user_id, + StudyQuestionProgress.study_topic_id == s.study_topic_id, + StudyQuestionProgress.pattern_state == "regressed", + ) + ) + ).scalar() or 0 + regressed_count = int(rg_row) + return QuizSessionSummary( id=s.id, status=s.status, @@ -1306,6 +1376,13 @@ async def _build_session_summary( updated_at=s.updated_at, finished_at=s.finished_at, unreviewed_wrong_unsure_count=unreviewed, + newly_correct_count=s.newly_correct_count, + relapsed_count=s.relapsed_count, + recovered_count=s.recovered_count, + chronic_remaining_count=s.chronic_remaining_count, + pending_review_count=pending_review_count, + chronic_count=chronic_count, + regressed_count=regressed_count, ) @@ -1689,7 +1766,7 @@ async def get_quiz_session( for a in attempt_rows ] - summary = await _build_session_summary(qs, session) + summary = await _build_session_summary(qs, session, include_progress_counts=True) return QuizSessionDetailResponse( summary=summary, questions=questions_payload, diff --git a/app/models/study_quiz_session.py b/app/models/study_quiz_session.py index e8dd264..fd504ff 100644 --- a/app/models/study_quiz_session.py +++ b/app/models/study_quiz_session.py @@ -43,6 +43,12 @@ class StudyQuizSession(Base): wrong_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) unsure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Phase 2-B: finalize 결과 요약 스냅샷. 세션 종료 시점에 박혀 결과 화면 헤더에 노출. + newly_correct_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + relapsed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + recovered_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + chronic_remaining_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False diff --git a/app/services/study/session_finalize.py b/app/services/study/session_finalize.py index 3e970c0..4852e5f 100644 --- a/app/services/study/session_finalize.py +++ b/app/services/study/session_finalize.py @@ -30,6 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.study_question import StudyQuestion, StudyQuestionAttempt from models.study_question_progress import StudyQuestionProgress +from models.study_quiz_session import StudyQuizSession from services.study.learning_pattern import ( PATTERN_CHRONIC_WRONG, PATTERN_RECOVERED, @@ -204,6 +205,14 @@ async def finalize_session( chronic_count = await _count_pattern(session, user_id, study_topic_id, PATTERN_CHRONIC_WRONG) regressed_count = await _count_pattern(session, user_id, study_topic_id, PATTERN_REGRESSED) + # 5. quiz_session row 에 스냅샷 영속화 (Phase 2-B). 결과 화면 헤더 노출 + 멱등. + qs = await session.get(StudyQuizSession, quiz_session_id) + if qs is not None: + qs.newly_correct_count = newly_correct + qs.relapsed_count = relapsed + qs.recovered_count = recovered_count + qs.chronic_remaining_count = chronic_remaining + return SessionSummary( correct=correct, wrong=wrong, diff --git a/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte b/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte index 1e1d118..6d6d947 100644 --- a/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte @@ -243,6 +243,24 @@ 정답률 {accuracy}% + + {#if (s.newly_correct_count ?? 0) + (s.relapsed_count ?? 0) + (s.recovered_count ?? 0) > 0} +