"""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.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-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)) 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 ) selected_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False) correct_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False) is_correct: Mapped[bool] = mapped_column(Boolean, nullable=False) answered_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) question: Mapped["StudyQuestion"] = relationship(back_populates="attempts")