"""题目、标签、题目-标签关联。""" import enum from sqlalchemy import ( Boolean, ForeignKey, Integer, String, Text, UniqueConstraint, ) from sqlalchemy import Enum as SAEnum from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.types import JSON from app.models.base import new_uuid from app.db import Base, TimestampMixin class QuestionType(str, enum.Enum): # 拍照入库的默认值:先收进来,想练习时再补题型/答案 unclassified = "unclassified" single_choice = "single_choice" multiple_choice = "multiple_choice" true_false = "true_false" fill_blank = "fill_blank" short_answer = "short_answer" # 客观题:有标准答案时可本地精确判分;其余交给 AI 判 OBJECTIVE_TYPES = { QuestionType.single_choice, QuestionType.multiple_choice, QuestionType.true_false, } class Tag(Base, TimestampMixin): __tablename__ = "tags" __table_args__ = (UniqueConstraint("user_id", "name", name="uq_tag_user_name"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) user_id: Mapped[str] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) name: Mapped[str] = mapped_column(String(128)) questions: Mapped[list["Question"]] = relationship( secondary="question_tags", back_populates="tags" ) class QuestionTag(Base): __tablename__ = "question_tags" question_id: Mapped[str] = mapped_column( ForeignKey("questions.id", ondelete="CASCADE"), primary_key=True ) tag_id: Mapped[str] = mapped_column( ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True ) class Question(Base, TimestampMixin): """一条笔记。 只有 type 和 stem_markdown 是必填,其中 type 可以是 unclassified, 所以"拍照识别完直接存"不需要用户补任何东西。 """ __tablename__ = "questions" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) user_id: Mapped[str] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) type: Mapped[QuestionType] = mapped_column( SAEnum(QuestionType), default=QuestionType.unclassified ) stem_markdown: Mapped[str] = mapped_column(Text) # 选项:[{"key": "A", "text_markdown": "..."}],选择/判断题用 options: Mapped[list | None] = mapped_column(JSON, nullable=True) # 标准答案:选择题 ["A","C"],填空/简答 ["文本"] correct_answer: Mapped[list | None] = mapped_column(JSON, nullable=True) # AI 生成的讲解(每次 /ai/explain 会覆写,别往这里写用户内容) explanation_markdown: Mapped[str | None] = mapped_column(Text, nullable=True) # 用户自己写的笔记 / 错因反思,AI 永不触碰 my_note_markdown: Mapped[str | None] = mapped_column(Text, nullable=True) # 手动标记"待复习";复习清单 = 这个标记 ∪ 最近一次作答判错 needs_review: Mapped[bool] = mapped_column(Boolean, default=False, index=True) difficulty: Mapped[int | None] = mapped_column(Integer, nullable=True) # 1-5 source_image_id: Mapped[str | None] = mapped_column( ForeignKey("images.id", ondelete="SET NULL"), nullable=True, index=True ) ocr_engine: Mapped[str | None] = mapped_column(String(32), nullable=True) tags: Mapped[list["Tag"]] = relationship( secondary="question_tags", back_populates="questions" ) # 便于详情页把原图和笔记一起展示 source_image: Mapped["Image | None"] = relationship("Image")