"""study_questions / study_question_attempts ORM — 학습 워크스페이스의 문제은행 트랙 PR-2 가드레일: - study_topic 1차 컨테이너에 자산 타입별 조인 테이블 추가 방식. polymorphic 단일 테이블 영구 금지. - subject/scope 는 강한 enum 미사용 (jlpt 등 어학 분류 확장 여지). - 문제 삭제는 API 에서 soft delete only. attempts FK 는 ON DELETE RESTRICT 로 DB 레벨 보호 (hard delete 실수 차단, 이력 보존). - correct_choice 변경 시 기존 attempt.is_correct 재계산 안 함 (기록은 그 시점의 사실). """ from datetime import datetime from pgvector.sqlalchemy import Vector from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, SmallInteger, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base class StudyQuestion(Base): __tablename__ = "study_questions" 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 ) question_text: Mapped[str] = mapped_column(Text, nullable=False) choice_1: Mapped[str] = mapped_column(Text, nullable=False) choice_2: Mapped[str] = mapped_column(Text, nullable=False) choice_3: Mapped[str] = mapped_column(Text, nullable=False) choice_4: Mapped[str] = mapped_column(Text, nullable=False) correct_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False) subject: Mapped[str | None] = mapped_column(String(120)) scope: Mapped[str | None] = mapped_column(String(200)) exam_name: Mapped[str | None] = mapped_column(String(120)) exam_round: Mapped[str | None] = mapped_column(String(120)) explanation: Mapped[str | None] = mapped_column(Text) source_note: Mapped[str | None] = mapped_column(Text) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) # PR-6: 회차 안 문항 번호 (1~exam_round_size). NULL 허용 — 기존 행 + 회차 미설정 입력 exam_question_number: Mapped[int | None] = mapped_column(SmallInteger) # PR-3: AI 풀이 캐시 (수동 트리거) # status: none | pending | ready | failed | stale (강한 enum 미사용, VARCHAR 권장값) ai_explanation: Mapped[str | None] = mapped_column(Text) ai_explanation_status: Mapped[str] = mapped_column( String(20), default="none", nullable=False ) ai_explanation_generated_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True) ) ai_explanation_model: Mapped[str | None] = mapped_column(String(120)) # PR-4: 자동 임베딩 (bge-m3 1024차원). status 가 큐 역할. # 재계산 트리거 = question_text / choice_1~4 변경. # correct_choice / subject / scope / explanation 변경은 재계산 안 함. embedding = mapped_column(Vector(1024), nullable=True) embedding_status: Mapped[str] = mapped_column( String(20), default="none", nullable=False ) embedding_updated_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True) ) embedding_model: Mapped[str | None] = mapped_column(String(120)) # PR-12-A 후속: related-types 영속 캐시. 임베딩 ready 워커가 채우고, # 같은 토픽 다른 문제 ready 시 related_computed_at=NULL 마킹 → 다음 cron 재계산. related_repeat: Mapped[list | None] = mapped_column(JSONB) related_similar: Mapped[list | None] = mapped_column(JSONB) related_repeat_round_count: Mapped[int | None] = mapped_column(Integer) related_similar_round_count: Mapped[int | None] = mapped_column(Integer) related_repeat_grade: Mapped[str | None] = mapped_column(String(50)) related_computed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) related_threshold_version: Mapped[str | None] = mapped_column(String(20)) 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 ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 연관 — 통합 뷰/통계 조회 시 selectinload 으로 끌어옴 topic: Mapped["StudyTopic | None"] = relationship( # type: ignore[name-defined] # noqa: F821 "StudyTopic", back_populates="questions", lazy="noload" ) attempts: Mapped[list["StudyQuestionAttempt"]] = relationship( back_populates="question", cascade="all, delete-orphan", # ORM 레벨 cascade — 실 hard delete 는 RESTRICT FK 가 막음 order_by="StudyQuestionAttempt.answered_at.desc()", lazy="noload", ) class StudyQuestionAttempt(Base): __tablename__ = "study_question_attempts" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) user_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) study_question_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("study_questions.id", ondelete="RESTRICT"), nullable=False, ) study_topic_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False ) # PR-9: selected_choice 는 NULL 허용 (unsure 케이스). is_correct 는 false 로 박힘. selected_choice: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) correct_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False) is_correct: Mapped[bool] = mapped_column(Boolean, nullable=False) # PR-9: outcome 권장값 (correct/wrong/unsure). 강한 enum 미사용. outcome: Mapped[str] = mapped_column(String(20), nullable=False) answered_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) # PR-10: 어떤 quiz 세션의 attempt 인지 (NULL = 세션 외 직접 입력 또는 세션 삭제됨). quiz_session_id: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("study_quiz_sessions.id", ondelete="SET NULL"), nullable=True ) # PR-10: 결과 카드에서 "학습완료" 체크 시 박힘. NULL = 미확인. reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) question: Mapped["StudyQuestion"] = relationship(back_populates="attempts")