from datetime import datetime from pgvector.sqlalchemy import Vector from sqlalchemy import Column, DateTime, Integer, String, Text, JSON, ForeignKey from sqlalchemy.orm import relationship, deferred from app.config import settings from app.database import Base from app.models.question_category import QuestionCategory # noqa — ensures mapper resolves from app.models.quiz_question_link import QuizQuestionLink # noqa class Question(Base): __tablename__ = "questions" id = Column(Integer, primary_key=True, index=True) # source_quiz_id: which quiz this question was originally extracted for (informational). # Content membership is tracked via quiz_question_links junction table. source_quiz_id = Column("quiz_id", Integer, ForeignKey("quizzes.id", ondelete="SET NULL"), nullable=True) question_category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True) question_text = Column(Text, nullable=False) question_type = Column(String, nullable=False) # mcq, true_false, fill_blank options = Column(JSON, nullable=True) # list of strings for mcq correct_answer = Column(String, nullable=False) explanation = Column(Text, nullable=True) page_reference = Column(Integer, nullable=True) image_path = Column(String, nullable=True) explanation_image_path = Column(String, nullable=True) option_explanations = Column(JSON, nullable=True) # {option_text: explanation} key_points = Column(JSON, nullable=True) # [{"text", "article_id"?, "article_section_id"?}] smart links # One sentence an attending would say at the bedside. Read before the answer # is known, so it must point at the thinking without giving the answer away. attending_tip = Column(Text, nullable=True) difficulty = Column(String(10), nullable=True) # easy | medium | hard user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) embedding = deferred(Column(Vector(settings.EMBEDDING_DIMENSIONS), nullable=True)) # semantic search vector — deferred: not loaded in standard queries # Which model produced `embedding`. Vectors from different models are not # comparable, so a model change must be detectable rather than silent. embedding_model = Column(String(120), nullable=True, index=True) embedded_at = Column(DateTime, nullable=True) # Deleting is hiding, not erasing. Ids come from a sequence and are never # reissued, and fourteen tables point at this one — attempts, quiz # membership, exam membership, media, notes, feedback. A hard delete takes # all of that with it and nothing can put it back, so the row stays and # this column says it is gone. deleted_at = Column(DateTime, nullable=True, index=True) question_category = relationship("QuestionCategory", back_populates="questions", foreign_keys=[question_category_id]) class QuestionVersion(Base): """A snapshot of a question as it was before an edit. Only the last MAX_VERSIONS are kept: the point is undoing a recent mistake, not an audit trail, and full question bodies add up. """ __tablename__ = "question_versions" id = Column(Integer, primary_key=True, index=True) question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True) snapshot = Column(JSON, nullable=False) edited_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_at = Column(DateTime, default=datetime.utcnow)