"""세션 종료 시 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, ) # SR 산술은 sr_schedule.py 단일 source (문제 SR + 카드 SR 공용). 상수는 재-export 유지. from services.study.sr_schedule import ( # noqa: E402 DEFAULT_FIRST_DUE_DAYS, # noqa: F401 REVIEW_INTERVAL_DAYS, # noqa: F401 REVIEW_STAGE_MASTERED, # noqa: F401 advance as sr_advance, ) @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 박힌 문제만 (산술은 sr_schedule 공용) if progress.due_at is not None: result = sr_advance(progress.review_stage, outcome, now) if result is not None: # skipped 는 None → due_at/stage 불변 progress.review_stage, progress.due_at = result # 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 # 6. Phase 4-A: 이 세션의 wrong/unsure qid AI 풀이 prefetch enqueue (best-effort). # 실패가 finalize 자체를 깨뜨리지 않도록 try/except 로 격리. 응답 카운트와 무관. try: from services.study.explanation_enqueue import enqueue_explanation_for_qids wrong_unsure_qids = [ qid for qid, a in last_per_qid.items() if a.outcome in ("wrong", "unsure") ] if wrong_unsure_qids: res = await enqueue_explanation_for_qids( session, user_id=user_id, qids=wrong_unsure_qids, ) import logging logging.getLogger(__name__).info( "phase4a_finalize_enqueue session=%s candidates=%s enqueued=%s skipped=%s", quiz_session_id, res["candidate_count"], res["enqueue_count"], res["skipped_count"], ) except Exception as e: import logging logging.getLogger(__name__).warning( "phase4a_finalize_enqueue_failed session=%s: %s: %s", quiz_session_id, type(e).__name__, e, ) # 7. Phase 4-B v1: 세션 단위 분석 enqueue (best-effort). # wrong/unsure < 5 면 worker 가 insufficient_attempts 로 skipped — finalize 는 무관. try: from services.study.session_analysis_enqueue import enqueue_session_analysis_auto res = await enqueue_session_analysis_auto( session, user_id=user_id, study_quiz_session_id=quiz_session_id, ) import logging logging.getLogger(__name__).info( "phase4b_finalize_enqueue session=%s enqueued=%s", quiz_session_id, res["enqueued"], ) except Exception as e: import logging logging.getLogger(__name__).warning( "phase4b_finalize_enqueue_failed session=%s: %s: %s", quiz_session_id, type(e).__name__, e, ) 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)