Files
hyungi 688532b1fa fix(briefing): held→409 표면화 + study attempt naive datetime→UTC (R8)
- briefing.regenerate: held(정책상 정상 보류)를 digest.py 정본처럼 409 로 표면화. 이전엔
  briefing_worker.run() 이 held/timeout/exception 셋 다 None 반환 → API 가 셋 다 500 으로
  오보(silent-state-conflation). 진입부 'briefing' in pipeline_held_stages 가드.
- study_question.answered_at: naive default datetime.now → lambda datetime.now(timezone.utc).
  컨테이너=UTC 실측이라 값 동일·백필 불요, 컨테이너 TZ 바뀌면 9h 어긋나던 잠복 의존 제거.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:51:42 +09:00

143 lines
7.3 KiB
Python

"""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, timezone
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))
# 공부 암기노트 Phase 1: 검수 대기 플래그 (DDL=migration 296). 정정/삭제 훅 + needs_review 큐가 set/clear.
# flagged_by 권장값: 'user' / 'source_changed' / 'source_deleted' (서버측 상수, read-time 매핑).
needs_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
flagged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
flagged_by: Mapped[str | None] = mapped_column(String(40))
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(
# TZ-aware 명시 (R8) — naive datetime.now() 는 컨테이너 TZ 의존. 현 컨테이너=UTC 라
# 값 동일(백필 불요)이나, 컨테이너 TZ 가 바뀌면 9시간 어긋나는 잠복 의존 제거.
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), 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")