From e5982ebde424f7bad24b6cc4f2383a0648738f66 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 1 May 2026 09:28:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20Phase=201=20=ED=95=99=EC=8A=B5?= =?UTF-8?q?=20=EB=A3=A8=ED=94=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=E2=80=94=20progress=20=EC=BA=90=EC=8B=9C=20+=20fin?= =?UTF-8?q?alize=20+=20review=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vision (풀이 → 확인 → 학습 → 복습 → 다음 풀이 가중치) 의 데이터 계층. 데이터 모델 (migrations 222~225): - study_question_progress 테이블 — user × topic × question 단위 현재 상태 캐시 - 마지막 시도: last_outcome, last_attempted_at, last_attempt_id - 검토 상태: last_reviewed_at - 복습 큐: due_at, review_stage - 패턴 분류 (derived): pattern_state, pattern_updated_at, pattern_window_attempts - 3 partial idx (due / topic_pattern / pending_review) — 탭별 빠른 조회 패턴 분류 (services/study/learning_pattern.py): - 7 분류: unattempted/unsure/chronic_wrong/regressed/recovered/stable/unstable - 윈도우 = 최근 3회 + 과거 correct/wrong 존재 여부 - chronic_wrong > regressed > recovered 우선순위 (보수적 학습) - 가드: wrong 1회만으로 regressed 안 됨 (이전 correct 이력 필요) - stable 은 3 연속 correct 부터 세션 종료 집계 (services/study/session_finalize.py): - attempts append-only 원본 보존, progress upsert 만 - 마지막 attempt 직후 finalize hook 자동 발동 - finalize 는 last_* + pattern_state 만 갱신, due_at 미진입 문제는 NULL 유지 - 이미 due_at 박힌 문제는 finalize 가 stage 갱신 (correct → +1 / wrong → 리셋) API (api/study_question_progress.py): - POST /study-topics/{tid}/questions/{qid}/review-complete → last_reviewed_at + (wrong/unsure 인 경우만) due_at 최초 부여 - GET /study-topics/{tid}/review-queue?tab=due_today|pending_review|chronic|regressed|mastered → 5 탭 paginated 조회 → pending_review 는 last_reviewed_at < last_attempted_at 까지 포함 (이전 확인완료 후 다시 wrong 잡힘) Phase 1-E (풀이 선별 알고리즘) 은 후속 commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/study_question_progress.py | 212 ++++++++++++++ app/api/study_questions.py | 23 ++ app/main.py | 3 + app/models/study_question_progress.py | 73 +++++ app/services/study/learning_pattern.py | 92 +++++++ app/services/study/session_finalize.py | 259 ++++++++++++++++++ app/workers/study_question_embed_worker.py | 1 + migrations/222_study_question_progress.sql | 42 +++ migrations/223_progress_due_idx.sql | 6 + migrations/224_progress_topic_pattern_idx.sql | 5 + .../225_progress_pending_review_idx.sql | 8 + 11 files changed, 724 insertions(+) create mode 100644 app/api/study_question_progress.py create mode 100644 app/models/study_question_progress.py create mode 100644 app/services/study/learning_pattern.py create mode 100644 app/services/study/session_finalize.py create mode 100644 migrations/222_study_question_progress.sql create mode 100644 migrations/223_progress_due_idx.sql create mode 100644 migrations/224_progress_topic_pattern_idx.sql create mode 100644 migrations/225_progress_pending_review_idx.sql diff --git a/app/api/study_question_progress.py b/app/api/study_question_progress.py new file mode 100644 index 0000000..ce3b409 --- /dev/null +++ b/app/api/study_question_progress.py @@ -0,0 +1,212 @@ +"""학습 진행 상태 (progress) API — review-complete + review-queue (Phase 1). + +review-complete: 사용자가 오답/모르겠음 문제를 검토했음을 표시. due_at 최초 부여. +review-queue: 5 탭 (due_today / pending_review / chronic / regressed / mastered) 으로 progress 조회. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from core.database import get_session +from models.study_question import StudyQuestion +from models.study_question_progress import StudyQuestionProgress +from models.study_topic import StudyTopic +from models.user import User + +router = APIRouter(prefix="/study-topics", tags=["study-progress"]) + +# 1차 due_at 부여 시 디폴트 1일 뒤 +DEFAULT_FIRST_DUE_DAYS = 1 + + +def _verify_topic_owner(topic: StudyTopic | None, user: User) -> None: + if topic is None or topic.deleted_at is not None or topic.user_id != user.id: + raise HTTPException(status_code=404, detail="주제를 찾을 수 없습니다") + + +@router.post("/{topic_id}/questions/{question_id}/review-complete", status_code=204) +async def review_complete( + topic_id: int, + question_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """확인완료 처리 — last_reviewed_at + (wrong/unsure 인 경우) due_at 최초 부여. + + 이미 due_at 박힌 문제면 due_at 그대로 유지 (큐 위치 보존). + 정답 맞춘 문제면 due_at 박지 않음 (큐 폭발 방지). + """ + topic = await session.get(StudyTopic, topic_id) + _verify_topic_owner(topic, user) + + q = await session.get(StudyQuestion, question_id) + if q is None or q.deleted_at is not None or q.user_id != user.id or q.study_topic_id != topic_id: + raise HTTPException(status_code=404, detail="문제를 찾을 수 없습니다") + + progress = ( + await session.execute( + select(StudyQuestionProgress).where( + StudyQuestionProgress.user_id == user.id, + StudyQuestionProgress.study_topic_id == topic_id, + StudyQuestionProgress.study_question_id == question_id, + ) + ) + ).scalar_one_or_none() + if progress is None: + # attempt 없는데 review-complete 시도. 진척 상태가 없어 의미 없음. + raise HTTPException(status_code=409, detail="아직 시도한 적이 없는 문제입니다") + + now = datetime.now(timezone.utc) + progress.last_reviewed_at = now + + # due_at 최초 부여는 wrong/unsure 일 때만. 이미 박혀있으면 유지. + if progress.last_outcome in ("wrong", "unsure") and progress.due_at is None: + progress.review_stage = 0 + progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS) + + await session.commit() + + +# ─── review-queue ─── + + +class ReviewQueueItem(BaseModel): + question_id: int + question_text: str + subject: str | None + scope: str | None + exam_round: str | None + exam_question_number: int | None + last_outcome: str | None + last_attempted_at: datetime | None + last_reviewed_at: datetime | None + due_at: datetime | None + review_stage: int | None + pattern_state: str | None + + +class ReviewQueueResponse(BaseModel): + tab: str + total: int + items: list[ReviewQueueItem] + page: int + page_size: int + + +def _truncate(text: str, n: int = 80) -> str: + if not text: + return "" + s = text.strip() + return s if len(s) <= n else s[:n].rstrip() + "…" + + +@router.get("/{topic_id}/review-queue", response_model=ReviewQueueResponse) +async def review_queue( + topic_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + tab: str = Query(..., pattern="^(due_today|pending_review|chronic|regressed|mastered)$"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), +): + """5 탭 진행 상태 조회. + + - due_today: progress.due_at <= now() AND review_stage < 4 + - pending_review: last_outcome IN (wrong, unsure) + AND (last_reviewed_at IS NULL OR last_reviewed_at < last_attempted_at) + - chronic: pattern_state = 'chronic_wrong' + - regressed: pattern_state = 'regressed' + - mastered: review_stage >= 4 + """ + topic = await session.get(StudyTopic, topic_id) + _verify_topic_owner(topic, user) + + base = ( + select(StudyQuestionProgress, StudyQuestion) + .join(StudyQuestion, StudyQuestion.id == StudyQuestionProgress.study_question_id) + .where( + StudyQuestionProgress.user_id == user.id, + StudyQuestionProgress.study_topic_id == topic_id, + StudyQuestion.deleted_at.is_(None), + ) + ) + + now = datetime.now(timezone.utc) + if tab == "due_today": + base = base.where( + StudyQuestionProgress.due_at.is_not(None), + StudyQuestionProgress.due_at <= now, + or_( + StudyQuestionProgress.review_stage.is_(None), + StudyQuestionProgress.review_stage < 4, + ), + ).order_by(StudyQuestionProgress.due_at.asc()) + elif tab == "pending_review": + base = base.where( + 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, + ), + ), + ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last()) + elif tab == "chronic": + base = base.where( + StudyQuestionProgress.pattern_state == "chronic_wrong", + ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last()) + elif tab == "regressed": + base = base.where( + StudyQuestionProgress.pattern_state == "regressed", + ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last()) + elif tab == "mastered": + base = base.where( + StudyQuestionProgress.review_stage.is_not(None), + StudyQuestionProgress.review_stage >= 4, + ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last()) + + # total + total_row = await session.execute( + select(func.count()).select_from(base.subquery()) + ) + total = int(total_row.scalar() or 0) + + # paged + rows = ( + await session.execute( + base.offset((page - 1) * page_size).limit(page_size) + ) + ).all() + + items = [ + ReviewQueueItem( + question_id=q.id, + question_text=_truncate(q.question_text, 80), + subject=q.subject, + scope=q.scope, + exam_round=q.exam_round, + exam_question_number=q.exam_question_number, + last_outcome=p.last_outcome, + last_attempted_at=p.last_attempted_at, + last_reviewed_at=p.last_reviewed_at, + due_at=p.due_at, + review_stage=p.review_stage, + pattern_state=p.pattern_state, + ) + for (p, q) in rows + ] + + return ReviewQueueResponse( + tab=tab, total=total, items=items, page=page, page_size=page_size, + ) diff --git a/app/api/study_questions.py b/app/api/study_questions.py index fb5486e..d6e70d7 100644 --- a/app/api/study_questions.py +++ b/app/api/study_questions.py @@ -940,6 +940,7 @@ async def submit_attempt( ) session.add(attempt) + session_just_finished = False if quiz_session is not None: quiz_session.cursor = quiz_session.cursor + 1 if outcome == "correct": @@ -951,11 +952,33 @@ async def submit_attempt( if quiz_session.cursor >= len(quiz_session.question_ids or []): quiz_session.status = "done" quiz_session.finished_at = datetime.now(timezone.utc) + session_just_finished = True quiz_session.updated_at = datetime.now(timezone.utc) + # 1차 commit: attempt + quiz_session.status (실패 시 attempt 조차 안 박힘 — 사용자 재시도) await session.commit() await session.refresh(attempt) + # Phase 1: 세션 종료 시 progress upsert + pattern 갱신 + (있는 경우) 복습 stage 갱신. + # 별도 트랜잭션 — finalize 실패가 attempt 보존을 위협하지 않게 분리. 멱등성 보장 (다음 review/queue + # 호출 시 stale 진단 가능). 첫 차 commit 의 attempts 가 이미 가시화된 상태에서 시작. + if session_just_finished and quiz_session is not None: + from services.study.session_finalize import finalize_session + try: + await finalize_session( + session, + user_id=user.id, + study_topic_id=q.study_topic_id, + quiz_session_id=quiz_session.id, + ) + await session.commit() + except Exception: + import logging + logging.getLogger(__name__).exception( + "finalize_session_failed quiz_session_id=%s", quiz_session.id + ) + await session.rollback() + stats = await _attempt_stats(session, user.id, question_id) return AttemptResponse( id=attempt.id, diff --git a/app/main.py b/app/main.py index 60d4638..cdca5f7 100644 --- a/app/main.py +++ b/app/main.py @@ -19,6 +19,7 @@ from api.memos import router as memos_router from api.news import router as news_router from api.search import router as search_router from api.setup import router as setup_router +from api.study_question_progress import router as study_question_progress_router from api.study_questions import router as study_questions_router from api.study_sessions import router as study_sessions_router from api.study_topics import router as study_topics_router @@ -130,6 +131,8 @@ app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["s app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"]) # study_questions: 라우터 안에서 /study-topics/{id}/questions 와 /study-questions/{id} 두 줄기를 모두 정의하므로 prefix=/api 로 등록 app.include_router(study_questions_router, prefix="/api", tags=["study-questions"]) +# Phase 1: 학습 진행 상태 (review-complete + review-queue). prefix=/api/study-topics 안에 정의됨. +app.include_router(study_question_progress_router, prefix="/api", tags=["study-progress"]) # TODO: Phase 5에서 추가 # app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"]) diff --git a/app/models/study_question_progress.py b/app/models/study_question_progress.py new file mode 100644 index 0000000..c204978 --- /dev/null +++ b/app/models/study_question_progress.py @@ -0,0 +1,73 @@ +"""study_question_progress — 사용자 × 토픽 × 문제 단위 현재 상태 캐시 (Phase 1). + +attempts (append-only 원본 로그) 와 분리. 한 번 박힌 attempts 는 절대 update 안 함. +progress 는 마지막 시도 / 사용자 검토 / 복습 큐 / 패턴 분류 derived 4 차원 메타. + +세션 종료 시 finalize 가 다음 갱신: + - last_outcome / last_attempted_at / last_attempt_id + - pattern_state / pattern_updated_at / pattern_window_attempts + - (이미 due_at 박힌 행만) review_stage / due_at ← 복습 stage 갱신 + +review-complete 가 다음 갱신: + - last_reviewed_at + - (wrong/unsure 인 경우) due_at 최초 부여 + +study_question_id 는 단일 topic 소속 전제 (현재 가스기사 토픽 4 단일 운영). 향후 question +재사용/N:M 가능성 대비 unique 키는 (user_id, study_topic_id, study_question_id) 3 키. +""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, SmallInteger, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from core.database import Base + + +class StudyQuestionProgress(Base): + __tablename__ = "study_question_progress" + __table_args__ = ( + UniqueConstraint( + "user_id", "study_topic_id", "study_question_id", + name="uq_progress_user_topic_question", + ), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + study_topic_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False + ) + study_question_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("study_questions.id", ondelete="RESTRICT"), nullable=False + ) + + # 마지막 시도 요약 + last_outcome: Mapped[str | None] = mapped_column(String(20)) + last_attempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_attempt_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("study_question_attempts.id", ondelete="SET NULL") + ) + + # 사용자 검토 상태 + last_reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + # 복습 큐 + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + review_stage: Mapped[int | None] = mapped_column(SmallInteger) + + # 패턴 분류 (derived) + pattern_state: Mapped[str | None] = mapped_column(String(30)) + pattern_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + pattern_window_attempts: Mapped[int | None] = mapped_column(SmallInteger) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False + ) diff --git a/app/services/study/learning_pattern.py b/app/services/study/learning_pattern.py new file mode 100644 index 0000000..5b25dbc --- /dev/null +++ b/app/services/study/learning_pattern.py @@ -0,0 +1,92 @@ +"""학습 패턴 분류 (Phase 1). + +attempts 시간 순 리스트 → pattern_state 7 분류: + unattempted / unsure / chronic_wrong / regressed / recovered / stable / unstable + +윈도우 = 최근 3회 attempts + 과거 correct/wrong 존재 여부. +recent3 만으로는 regressed/recovered 판정 불가능 — 과거 이력 함께 필요. + +우선순위 (위→아래, 첫 매칭 박힘): + 1. unattempted (attempts == 0) + 2. unsure (latest == 'unsure') + 3. chronic_wrong (recent3 wrong ≥ 2) + 4. regressed (latest wrong + 이전 correct 이력) + 5. recovered (latest 2 연속 correct + 이전 wrong 이력) + 6. stable (recent3 모두 correct, 3회 충족) + 7. unstable (그 외) + +UI 라벨 (가칭): + unattempted="미학습" / unsure="모르겠음" / chronic_wrong="반복 오답 경향" + regressed="퇴행" / recovered="회복" / stable="안정" / unstable="불안정" + +chronic_wrong 은 [wrong,wrong,correct] 같이 latest correct 케이스도 잡힘. +"지금 틀림" 의미 X, "최근 3회 오답 비중 높음" 의미 — UI 라벨 주의. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +PATTERN_UNATTEMPTED = "unattempted" +PATTERN_UNSURE = "unsure" +PATTERN_CHRONIC_WRONG = "chronic_wrong" +PATTERN_REGRESSED = "regressed" +PATTERN_RECOVERED = "recovered" +PATTERN_STABLE = "stable" +PATTERN_UNSTABLE = "unstable" + +WINDOW_SIZE = 3 + + +@dataclass +class _AttemptOutcome: + """compute_pattern_state 입력용 가벼운 dataclass. 시간 순 (oldest → newest).""" + outcome: str # correct / wrong / unsure / skipped + + +def compute_pattern_state(attempts: list[_AttemptOutcome]) -> tuple[str, int]: + """attempts (oldest → newest) → (pattern_state, window_size). + + window_size = min(WINDOW_SIZE, len(attempts)). + """ + if not attempts: + return PATTERN_UNATTEMPTED, 0 + + latest = attempts[-1].outcome + if latest == "unsure": + return PATTERN_UNSURE, min(WINDOW_SIZE, len(attempts)) + + recent_window = attempts[-WINDOW_SIZE:] + wrong_in_window = sum(1 for a in recent_window if a.outcome == "wrong") + + # chronic_wrong: 최근 3회 중 wrong ≥ 2 (또는 attempts 가 2회뿐이고 둘 다 wrong) + if wrong_in_window >= 2: + return PATTERN_CHRONIC_WRONG, len(recent_window) + + # regressed: latest wrong + 이전 correct 이력 1건 이상 + if latest == "wrong" and any(a.outcome == "correct" for a in attempts[:-1]): + return PATTERN_REGRESSED, len(recent_window) + + # recovered: latest 2 연속 correct + 그 이전 wrong 이력 + if ( + len(attempts) >= 3 + and attempts[-1].outcome == "correct" + and attempts[-2].outcome == "correct" + and any(a.outcome == "wrong" for a in attempts[:-2]) + ): + return PATTERN_RECOVERED, len(recent_window) + + # stable: 최근 3회 모두 correct (3회 충족) + if len(recent_window) >= WINDOW_SIZE and all(a.outcome == "correct" for a in recent_window): + return PATTERN_STABLE, WINDOW_SIZE + + # 그 외: unstable (1~2회 correct, wrong 1회 + correct 이력 없음, 등) + return PATTERN_UNSTABLE, len(recent_window) + + +def attempts_from_rows(rows) -> list[_AttemptOutcome]: + """SQL row (outcome 컬럼) → _AttemptOutcome 리스트. 호출자가 정렬은 미리. + + rows 는 oldest → newest 정렬되어 들어와야 함 (answered_at asc). + """ + return [_AttemptOutcome(outcome=r.outcome) for r in rows] diff --git a/app/services/study/session_finalize.py b/app/services/study/session_finalize.py new file mode 100644 index 0000000..3e970c0 --- /dev/null +++ b/app/services/study/session_finalize.py @@ -0,0 +1,259 @@ +"""세션 종료 시 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 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) + + 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) diff --git a/app/workers/study_question_embed_worker.py b/app/workers/study_question_embed_worker.py index 18be04c..016da7b 100644 --- a/app/workers/study_question_embed_worker.py +++ b/app/workers/study_question_embed_worker.py @@ -46,6 +46,7 @@ from models.user import User # noqa: F401 from models.document import Document # noqa: F401 from models.study_topic import StudyTopic # noqa: F401 from models.study_session import StudySession # noqa: F401 +from models.study_question_progress import StudyQuestionProgress # noqa: F401 logger = logging.getLogger("study_question_embed_worker") diff --git a/migrations/222_study_question_progress.sql b/migrations/222_study_question_progress.sql new file mode 100644 index 0000000..fc050c7 --- /dev/null +++ b/migrations/222_study_question_progress.sql @@ -0,0 +1,42 @@ +-- 222_study_question_progress.sql +-- Phase 1: 학습 루프 데이터 계층 — 사용자 × 토픽 × 문제 단위 현재 상태 캐시. +-- +-- 책임 분리: +-- attempts (append-only 원본 로그) → progress (현재 상태 캐시) → pattern derived +-- +-- 컬럼 분류: +-- 마지막 시도: last_outcome / last_attempted_at / last_attempt_id +-- 사용자 검토: last_reviewed_at +-- 복습 큐: due_at / review_stage +-- 패턴 분류 (derived): pattern_state / pattern_updated_at / pattern_window_attempts +-- +-- finalize 가 last_* + pattern_state 만 갱신. due_at 최초 부여는 review-complete 단계. +-- 이미 due_at 박힌 문제 다시 풀면 finalize 가 stage 갱신. +-- +-- study_question_id 는 현재 단일 topic 소속 전제. 향후 N:M 도입되어도 user×topic×question +-- 3 키 unique 가 안전 → 처음부터 3 키. + +CREATE TABLE IF NOT EXISTS study_question_progress ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + study_topic_id BIGINT NOT NULL REFERENCES study_topics(id) ON DELETE CASCADE, + study_question_id BIGINT NOT NULL REFERENCES study_questions(id) ON DELETE RESTRICT, + + last_outcome VARCHAR(20), + last_attempted_at TIMESTAMPTZ, + last_attempt_id BIGINT REFERENCES study_question_attempts(id) ON DELETE SET NULL, + + last_reviewed_at TIMESTAMPTZ, + + due_at TIMESTAMPTZ, + review_stage SMALLINT, + + pattern_state VARCHAR(30), + pattern_updated_at TIMESTAMPTZ, + pattern_window_attempts SMALLINT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uq_progress_user_topic_question UNIQUE (user_id, study_topic_id, study_question_id) +); diff --git a/migrations/223_progress_due_idx.sql b/migrations/223_progress_due_idx.sql new file mode 100644 index 0000000..b1965b6 --- /dev/null +++ b/migrations/223_progress_due_idx.sql @@ -0,0 +1,6 @@ +-- 223_progress_due_idx.sql +-- 복습 큐 due_today 탭 — 사용자별 due_at 오름차순 조회 빠르게. + +CREATE INDEX IF NOT EXISTS idx_progress_due + ON study_question_progress (user_id, due_at) + WHERE due_at IS NOT NULL; diff --git a/migrations/224_progress_topic_pattern_idx.sql b/migrations/224_progress_topic_pattern_idx.sql new file mode 100644 index 0000000..0e19dd6 --- /dev/null +++ b/migrations/224_progress_topic_pattern_idx.sql @@ -0,0 +1,5 @@ +-- 224_progress_topic_pattern_idx.sql +-- 패턴별 필터 — chronic/regressed/recovered 탭 조회 빠르게. + +CREATE INDEX IF NOT EXISTS idx_progress_topic_pattern + ON study_question_progress (user_id, study_topic_id, pattern_state); diff --git a/migrations/225_progress_pending_review_idx.sql b/migrations/225_progress_pending_review_idx.sql new file mode 100644 index 0000000..816000f --- /dev/null +++ b/migrations/225_progress_pending_review_idx.sql @@ -0,0 +1,8 @@ +-- 225_progress_pending_review_idx.sql +-- pending_review 탭 — 미확인 오답 조회 빠르게. +-- partial 조건은 last_outcome 만, reviewed/attempted 비교는 쿼리에서 (이전 확인완료한 +-- 문제가 다시 wrong/unsure 됐을 때 잡히도록 last_reviewed_at < last_attempted_at 필터). + +CREATE INDEX IF NOT EXISTS idx_progress_pending_review + ON study_question_progress (user_id, study_topic_id, last_attempted_at DESC) + WHERE last_outcome IN ('wrong', 'unsure');