Files
hyungi_document_server/app/models/study_question.py
Hyungi Ahn 219e233a48 feat(study): related-types DB 캐시 — HNSW 매번 재계산 제거
- migrations 220/221: study_questions 에 related_repeat/similar JSONB + 카운트/grade/computed_at/threshold_version + partial idx
- 임베딩 워커: ready 처리 직후 같은 트랜잭션에서 related 계산·저장 + 같은 토픽 ready 행들의 related_computed_at=NULL invalidation
- 신규 cron study_q_related_refresh (1분, batch=20) — stale 캐시 일괄 재계산
- API list_related_types: cache hit (computed_at + threshold version 일치) 시 SELECT 1번으로 응답. miss 면 즉시 계산+저장 후 응답
- update_question PATCH: 본문/exam_round 변경 시 related_computed_at=NULL
- soft delete: 같은 토픽 ready 행 invalidation

threshold 변경 시: related_types.THRESHOLD_VERSION 갱신 + UPDATE WHERE version != '<신>' SET computed_at=NULL 한 번이면 cron 자동 일괄 재계산.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:22:31 +09:00

135 lines
6.6 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
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")