Files
hyungi_document_server/app/models/study_quiz_session.py
T
hyungi 8d3b648b5f feat(ingest): P2 DS write-back — /ingest/study/attempts 멱등 finalize 재생 (study→viewer)
뷰어 로컬 풀이 세션을 DS 로 흘려 학습엔진(SR/pattern/오답/4-A·4-B) 재생. 기본 inert(flag off).
- 마이그 373~376: study_quiz_sessions 에 finalized_at(멱등 마커)·client_session_uuid·source
  + UNIQUE(client_session_uuid, study_topic_id) partial.
- outcome.py derive_outcome = 채점 단일 소스(라이브 submit_attempt 도 이걸로 리팩터 → 정오 어휘
  한 곳, ingest 는 raw 신호 selected+unsure 만 싣고 DS 산출 = '무수정 재생' 성립).
- ingest_study.py: Bearer(VIEWER_SYNC_TOKEN)+study_ingest_enabled gate. pub_id→source_id→question
  해소(graceful skip)·principal=question.user_id(mixed 거부)·topic 별 DS 세션(source=viewer·uuid)
  생성+attempt+finalize_session 무수정 재생+finalized_at, 1-tx 원자. uuid 존재=already_ingested
  캐시반환(멱등 → at-least-once 재전송에도 SR 이중 advance 0).
- config study_ingest_enabled + compose 매핑 + main 등록.

검증: py_compile·ephemeral 마이그(373~376 라이브스키마 위 클린)·single-statement. 배포 후
합성 세션 멱등/무이중SR 실측 예정. 배포=inert(STUDY_INGEST_ENABLED 미설정=503).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:27:34 +09:00

63 lines
3.4 KiB
Python

"""study_quiz_sessions ORM (PR-10) — 문제풀이 세션 기록 + 이어풀기.
한 토픽의 한 회차 풀이 = 한 행. question_ids 는 출제 순서 스냅샷.
status: in_progress / done / abandoned (강한 enum 미사용 — VARCHAR 권장값).
한 토픽당 in_progress 1개 강제는 partial unique idx (마이그레이션 207).
"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyQuizSession(Base):
__tablename__ = "study_quiz_sessions"
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
)
target_per_subject: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
subject_filter: Mapped[str | None] = mapped_column(String(120))
wrong_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# PR-12-B: 출제 모드. 권장값 = random (1차) / frequent_focus / wrong_variants (예약).
quiz_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="random")
# 출제 순서 스냅샷 — list[int] (question id). 출제 후 변경 안 됨.
question_ids: Mapped[list] = mapped_column(JSONB, nullable=False)
# {subject: count} 분포. 결과 카드 통계 표시용.
subject_distribution: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="in_progress")
cursor: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
correct_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
wrong_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
unsure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Phase 2-B: finalize 결과 요약 스냅샷. 세션 종료 시점에 박혀 결과 화면 헤더에 노출.
newly_correct_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
relapsed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
recovered_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
chronic_remaining_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# study-to-viewer P2: 뷰어 ingest 멱등/출처. 라이브 세션=finalized_at·client_session_uuid NULL, source='live'.
finalized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 멱등 마커(mig 373)
client_session_uuid: Mapped[str | None] = mapped_column(String(64)) # 뷰어 세션 UUID(mig 374, uq mig376)
source: Mapped[str] = mapped_column(String(20), nullable=False, default="live") # live|viewer(mig 375)
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
)