e5982ebde4
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) <noreply@anthropic.com>
213 lines
7.5 KiB
Python
213 lines
7.5 KiB
Python
"""학습 진행 상태 (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,
|
|
)
|