0a7402b327
study_memo_cards 추출 파이프라인 + 버전키 폴러 + needs_review 컬럼. 운영 SR 코드(session_finalize/quiz_selection) 무수정.
- migrations 287~298: study_memo_cards/_evidence/_jobs/_progress(P1 휴면)·study_reminders·study_topics.focused_at·study_questions needs_review 3컬럼. dedup PARTIAL UNIQUE(deleted_at IS NULL).
- 워커: in-process RAG gather → MLX {cards} → 카드 가드(정량=evidence 원문 등장·cue/cloze 누출·dedup) → supersede 구버전 retire → append. 별 consumer 로 기존 study_queue 격리.
- 폴러 study_card_enqueue: 버전키 NOT EXISTS(source_version) 멱등 + ai_explanation_generated_at NOT NULL 가드 + per-poll LIMIT(thundering-herd).
- 검증: 실 prod 스키마 덤프 위 12 마이그 적용 OK + dedup/supersede/active-unique 기능 7/7 PASS + 정규화 util 15/15.
plan: PKM plans/2026-06-05-study-memo-card-p1-plan.html
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
7.0 KiB
Python
141 lines
7.0 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))
|
|
|
|
# 공부 암기노트 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(
|
|
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")
|