Files
hyungi_document_server/app/models/study_question_progress.py
T
Hyungi Ahn e5982ebde4 feat(study): Phase 1 학습 루프 데이터 계층 — progress 캐시 + finalize + review API
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>
2026-05-01 09:28:46 +09:00

74 lines
3.0 KiB
Python

"""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
)