Files
hyungi_document_server/app/services/study/session_finalize.py
T
Hyungi Ahn d3bf963a66 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>
2026-05-01 09:49:01 +09:00

269 lines
10 KiB
Python

"""세션 종료 시 progress upsert + pattern 갱신 + 복습 stage 갱신 (Phase 1).
호출 시점: 마지막 attempt 가 박혀 status='done' 으로 전이된 직후.
한 트랜잭션 안에서 처리 — caller 가 commit 책임.
책임:
1. 이 세션의 attempts qid 별 그룹
2. 각 qid 별 progress upsert:
- last_outcome / last_attempted_at / last_attempt_id 갱신
- 최근 3회 attempts (이번 세션 + 과거) 로 pattern_state 계산
- pattern_window_attempts 갱신
3. 만약 progress.due_at IS NOT NULL (이미 복습 큐에 있던 문제):
- correct → review_stage += 1, due_at 다음 단계 (1→3→7→14일)
- stage ≥ 4 → due_at = NULL (학습완료)
- wrong/unsure → review_stage = 0, due_at = now()+1일
4. progress.due_at IS NULL 인 일반 풀이 결과는 due_at/stage 건드리지 않음
5. 세션 결과 요약 dict 반환
attempts 원본 수정 0건. progress upsert 만.
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
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,
PATTERN_REGRESSED,
PATTERN_STABLE,
attempts_from_rows,
compute_pattern_state,
)
# review_stage 별 다음 due_at interval (days)
REVIEW_INTERVAL_DAYS = {1: 3, 2: 7, 3: 14}
REVIEW_STAGE_MASTERED = 4
DEFAULT_FIRST_DUE_DAYS = 1
@dataclass
class SessionSummary:
"""세션 결과 요약 — 응답 payload 직전 단계."""
correct: int
wrong: int
unsure: int
skipped: int
# 상태 변화 카운트
newly_correct: int # 처음 푸는데 맞힘 (이전 attempts 0건)
relapsed: int # 이전 정답이었으나 이번 wrong
recovered: int # pattern_state = recovered 새로 박힘
chronic_remaining: int # 이번 세션 후 chronic_wrong 으로 박힌 행 수
# 바로 할 일 후보
pending_review_count: int # wrong/unsure + 미확인 (last_reviewed_at < last_attempted_at)
chronic_count: int # pattern_state = chronic_wrong
regressed_count: int # pattern_state = regressed
async def finalize_session(
session: AsyncSession,
*,
user_id: int,
study_topic_id: int,
quiz_session_id: int,
) -> SessionSummary:
"""세션 종료 후처리. caller 가 commit. 멱등성 — 두 번 호출되어도 결과 같음."""
# 1. 이 세션 attempts 시간순
session_attempts = (
await session.execute(
select(StudyQuestionAttempt)
.where(
StudyQuestionAttempt.quiz_session_id == quiz_session_id,
StudyQuestionAttempt.user_id == user_id,
)
.order_by(StudyQuestionAttempt.answered_at.asc(), StudyQuestionAttempt.id.asc())
)
).scalars().all()
if not session_attempts:
return SessionSummary(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
# qid 그룹: 한 세션 안에 같은 qid 가 두 번 박힐 가능성 거의 없지만 안전하게.
last_per_qid: dict[int, StudyQuestionAttempt] = {}
for a in session_attempts:
last_per_qid[a.study_question_id] = a # 시간 순 정렬이라 마지막이 최종
qids = list(last_per_qid.keys())
# 2. 기존 progress 행 일괄 fetch
existing = (
await session.execute(
select(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user_id,
StudyQuestionProgress.study_topic_id == study_topic_id,
StudyQuestionProgress.study_question_id.in_(qids),
)
)
).scalars().all()
progress_by_qid: dict[int, StudyQuestionProgress] = {p.study_question_id: p for p in existing}
# 3. qid 별 과거 attempts 일괄 fetch (이 세션 attempts 까지 포함, 시간순)
history_rows = (
await session.execute(
select(StudyQuestionAttempt)
.where(
StudyQuestionAttempt.user_id == user_id,
StudyQuestionAttempt.study_topic_id == study_topic_id,
StudyQuestionAttempt.study_question_id.in_(qids),
)
.order_by(
StudyQuestionAttempt.study_question_id.asc(),
StudyQuestionAttempt.answered_at.asc(),
StudyQuestionAttempt.id.asc(),
)
)
).scalars().all()
history_by_qid: dict[int, list[StudyQuestionAttempt]] = defaultdict(list)
for r in history_rows:
history_by_qid[r.study_question_id].append(r)
now = datetime.now(timezone.utc)
# 카운터 초기화
correct = wrong = unsure = skipped = 0
newly_correct = relapsed = recovered_count = 0
chronic_remaining = 0
for qid, last_attempt in last_per_qid.items():
outcome = last_attempt.outcome
if outcome == "correct":
correct += 1
elif outcome == "wrong":
wrong += 1
elif outcome == "unsure":
unsure += 1
elif outcome == "skipped":
skipped += 1
history = history_by_qid.get(qid, [])
# 이번 세션 이전 attempts 만 (status 변화 판단용)
prior = [a for a in history if a.id != last_attempt.id]
# 상태 변화 카운트
if not prior:
if outcome == "correct":
newly_correct += 1
else:
prior_last_outcome = prior[-1].outcome
if prior_last_outcome == "correct" and outcome == "wrong":
relapsed += 1
# pattern 계산
pattern_state, window_size = compute_pattern_state(attempts_from_rows(history))
if pattern_state == PATTERN_RECOVERED and (
qid not in progress_by_qid
or progress_by_qid[qid].pattern_state != PATTERN_RECOVERED
):
recovered_count += 1
if pattern_state == PATTERN_CHRONIC_WRONG:
chronic_remaining += 1
# progress upsert
progress = progress_by_qid.get(qid)
if progress is None:
progress = StudyQuestionProgress(
user_id=user_id,
study_topic_id=study_topic_id,
study_question_id=qid,
)
session.add(progress)
progress_by_qid[qid] = progress
progress.last_outcome = outcome
progress.last_attempted_at = last_attempt.answered_at
progress.last_attempt_id = last_attempt.id
progress.pattern_state = pattern_state
progress.pattern_updated_at = now
progress.pattern_window_attempts = window_size
# 복습 stage 갱신 — 이미 due_at 박힌 문제만
if progress.due_at is not None:
if outcome == "correct":
progress.review_stage = (progress.review_stage or 0) + 1
if progress.review_stage >= REVIEW_STAGE_MASTERED:
progress.due_at = None # 학습완료
else:
days = REVIEW_INTERVAL_DAYS[progress.review_stage]
progress.due_at = now + timedelta(days=days)
elif outcome in ("wrong", "unsure"):
progress.review_stage = 0
progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)
# skipped 는 due_at 그대로 (큐 유지, stage 변경 안 함)
# progress.due_at IS NULL 일반 풀이 → stage 건드리지 않음
# 4. 바로 할 일 카운트 (요약 응답용) — finalize 직후 progress 상태 기준 SQL 한 번
pending_review_count = await _count_pending_review(session, user_id, study_topic_id)
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,
unsure=unsure,
skipped=skipped,
newly_correct=newly_correct,
relapsed=relapsed,
recovered=recovered_count,
chronic_remaining=chronic_remaining,
pending_review_count=pending_review_count,
chronic_count=chronic_count,
regressed_count=regressed_count,
)
async def _count_pending_review(session: AsyncSession, user_id: int, topic_id: int) -> int:
"""pending_review 탭 조건 기반 카운트."""
from sqlalchemy import and_, func, or_
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")),
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,
),
),
)
)
return int(row.scalar() or 0)
async def _count_pattern(
session: AsyncSession, user_id: int, topic_id: int, pattern: str
) -> int:
from sqlalchemy import func
row = await session.execute(
select(func.count())
.select_from(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user_id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.pattern_state == pattern,
)
)
return int(row.scalar() or 0)