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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user