feat(study): Phase 2-B 결과 화면 변화 카운트 + 확인완료 progress 통합

Phase 1 finalize 가 계산하던 SessionSummary 가 응답에 포함되지 않고 discard
되던 것을 quiz_session row 4 컬럼으로 영속화. 결과 화면 헤더에 회복/퇴행/
새로 맞힘/반복 오답 누적 변화 카운트 + "바로 할 일" 콜아웃 (지금 시점
progress 기반 동적 카운트 — pending_review/chronic/regressed). 동적 카운트는
결과 GET 호출 시점에만 계산 (목록 endpoint 비용 회피).

확인완료 통합 — 결과 카드의 [학습완료] 버튼이 attempts.reviewed_at 만 박던
것을 progress.last_reviewed_at + (wrong/unsure 면 due_at 최초 부여) 도 같이
박도록. reviewed=false 토글은 attempts 만 되돌림 (다른 attempt 가 검토 표시
했을 수 있어 progress 의 last_reviewed_at 은 보존).

- migrations/230 — quiz_sessions 4 컬럼 ADD (단일 ALTER TABLE)
- StudyQuizSession 모델 + finalize_session 가 row 영속화
- QuizSessionSummary 응답에 4 스냅샷 + 3 동적 필드 (default 0)
- _build_session_summary include_progress_counts=True 시 SQL 3회
- review-mark 가 reveiwed=true 시 progress 동기화
- 결과 화면: 헤더 변화 카운트 줄 + 바로 할 일 콜아웃 (값 있을 때만)

Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-B)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-05-01 09:49:01 +09:00
parent c46fd564af
commit d3bf963a66
6 changed files with 176 additions and 6 deletions
+32 -3
View File
@@ -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)
+80 -3
View File
@@ -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,
+6
View File
@@ -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
+9
View File
@@ -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,
@@ -243,6 +243,24 @@
<span class="text-text font-medium">정답률 {accuracy}%</span>
</div>
<!-- Phase 2-B: 변화 카운트 (세션 종료 시점 스냅샷) — 값이 있을 때만 -->
{#if (s.newly_correct_count ?? 0) + (s.relapsed_count ?? 0) + (s.recovered_count ?? 0) > 0}
<div class="text-[11px] text-dim flex flex-wrap gap-x-3 gap-y-1">
{#if s.newly_correct_count > 0}
<span><span class="text-success">🆕 새로 맞힘</span> {s.newly_correct_count}</span>
{/if}
{#if s.recovered_count > 0}
<span><span class="text-success">↗ 회복</span> {s.recovered_count}</span>
{/if}
{#if s.relapsed_count > 0}
<span><span class="text-error">↘ 다시 틀림</span> {s.relapsed_count}</span>
{/if}
{#if s.chronic_remaining_count > 0}
<span><span class="text-warning">⚠ 반복 오답 누적</span> {s.chronic_remaining_count}</span>
{/if}
</div>
{/if}
{#if Object.keys(s.subject_distribution || {}).length > 0}
<div class="text-[11px] text-dim">
과목 분포:
@@ -252,6 +270,24 @@
</div>
{/if}
<!-- Phase 2-B: 바로 할 일 (지금 시점 progress 기반 동적 카운트) -->
{#if (s.pending_review_count ?? 0) + (s.chronic_count ?? 0) + (s.regressed_count ?? 0) > 0}
<div class="rounded border border-warning/30 bg-warning/5 p-3 text-xs text-text flex flex-col gap-1">
<div class="font-medium">바로 할 일</div>
<ul class="flex flex-col gap-0.5 text-dim">
{#if s.pending_review_count > 0}
<li>· <span class="text-error">미확인 오답·모르겠음</span> {s.pending_review_count}</li>
{/if}
{#if s.chronic_count > 0}
<li>· <span class="text-warning">반복 오답</span> {s.chronic_count}</li>
{/if}
{#if s.regressed_count > 0}
<li>· <span class="text-warning">퇴행</span> {s.regressed_count}</li>
{/if}
</ul>
</div>
{/if}
<!---->
<div class="flex border-b border-default">
<button
@@ -0,0 +1,13 @@
-- 230_quiz_sessions_finalize_summary.sql
-- Phase 2-B: finalize 시점 결과 요약 4 컬럼 영속화 (결과 화면 헤더).
-- newly_correct: 처음 푸는데 맞힘 (이전 attempts 0건)
-- relapsed: 이전 정답이었으나 이번 wrong
-- recovered: pattern_state 가 recovered 로 새로 박힘
-- chronic_remaining: 이번 세션 후 chronic_wrong 으로 박힌 행 수 (전체 chronic — 세션 외 포함)
-- 다중 ADD COLUMN 은 단일 ALTER TABLE 안에서 1 statement 로 통과.
ALTER TABLE study_quiz_sessions
ADD COLUMN IF NOT EXISTS newly_correct_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS relapsed_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS recovered_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS chronic_remaining_count INTEGER NOT NULL DEFAULT 0;