"""ORM 模型:知识点、题目、练习记录、错题与每日完成状态。""" from __future__ import annotations from datetime import date, datetime from sqlalchemy import ( Boolean, Date, Float, ForeignKey, Integer, String, Text, UniqueConstraint, DateTime, ) from sqlalchemy.orm import Mapped, mapped_column from database import Base class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(Integer, primary_key=True) username: Mapped[str] = mapped_column(String(64), unique=True, index=True) password_hash: Mapped[str] = mapped_column(Text) display_name: Mapped[str] = mapped_column(String(64), default="") active: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) class Role(Base): __tablename__ = "roles" id: Mapped[int] = mapped_column(Integer, primary_key=True) code: Mapped[str] = mapped_column(String(32), unique=True) name: Mapped[str] = mapped_column(String(64)) description: Mapped[str] = mapped_column(Text, default="") class Permission(Base): __tablename__ = "permissions" id: Mapped[int] = mapped_column(Integer, primary_key=True) code: Mapped[str] = mapped_column(String(64), unique=True) name: Mapped[str] = mapped_column(String(64)) group: Mapped[str] = mapped_column(String(32), default="system") class UserRole(Base): __tablename__ = "user_roles" __table_args__ = (UniqueConstraint("user_id", "role_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) role_id: Mapped[int] = mapped_column( ForeignKey("roles.id", ondelete="CASCADE"), index=True ) class RolePermission(Base): __tablename__ = "role_permissions" __table_args__ = (UniqueConstraint("role_id", "permission_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) role_id: Mapped[int] = mapped_column( ForeignKey("roles.id", ondelete="CASCADE"), index=True ) permission_id: Mapped[int] = mapped_column( ForeignKey("permissions.id", ondelete="CASCADE"), index=True ) class Knowledge(Base): """全局知识点(同一知识点可被多个章节复用,用关系表关联)。""" __tablename__ = "knowledge" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(64), unique=True) domain: Mapped[str] = mapped_column(String(16), default="初等") # 初等 / 高等 category: Mapped[str] = mapped_column(String(64), default="") description: Mapped[str] = mapped_column(Text, default="") class KnowledgeRelation(Base): """知识点间关系,支持未来知识图谱(前置/包含/相关等)。""" __tablename__ = "knowledge_relations" __table_args__ = ( UniqueConstraint("source_id", "target_id", "relation_type"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) source_id: Mapped[int] = mapped_column( ForeignKey("knowledge.id", ondelete="CASCADE"), index=True ) target_id: Mapped[int] = mapped_column( ForeignKey("knowledge.id", ondelete="CASCADE"), index=True ) relation_type: Mapped[str] = mapped_column( String(16), default="前置" ) # 前置 / 包含 / 相关 class KnowledgeResource(Base): """知识点对外资源链接(视频 / 在线课程 / 电子教材)。 教材与章节不在此登记:章节通过 ChapterKnowledge 反向引用知识点, 知识点详情据此反查“哪本书的哪一章讲了它”。 """ __tablename__ = "knowledge_resources" __table_args__ = (UniqueConstraint("knowledge_id", "kind", "url"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) knowledge_id: Mapped[int] = mapped_column( ForeignKey("knowledge.id", ondelete="CASCADE"), index=True ) kind: Mapped[str] = mapped_column(String(16), default="video") # video | book title: Mapped[str] = mapped_column(String(128), default="") url: Mapped[str] = mapped_column(String(256), default="") textbook_id: Mapped[int | None] = mapped_column( ForeignKey("textbooks.id", ondelete="CASCADE"), nullable=True, ) course_id: Mapped[int | None] = mapped_column( ForeignKey("courses.id", ondelete="CASCADE"), nullable=True, ) position: Mapped[int] = mapped_column(Integer, default=0) class ChapterKnowledge(Base): __tablename__ = "chapter_knowledge" __table_args__ = (UniqueConstraint("chapter_id", "knowledge_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) chapter_id: Mapped[int] = mapped_column( ForeignKey("chapters.id", ondelete="CASCADE"), index=True ) knowledge_id: Mapped[int] = mapped_column( ForeignKey("knowledge.id", ondelete="CASCADE"), index=True ) class QuestionKnowledge(Base): __tablename__ = "question_knowledge" __table_args__ = (UniqueConstraint("question_id", "knowledge_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) question_id: Mapped[int] = mapped_column( ForeignKey("questions.id", ondelete="CASCADE"), index=True ) knowledge_id: Mapped[int] = mapped_column( ForeignKey("knowledge.id", ondelete="CASCADE"), index=True ) class UserKnowledge(Base): """用户对某全局知识点的掌握度(知识点经章节/题目引用,不靠名称匹配)。""" __tablename__ = "user_knowledge" __table_args__ = (UniqueConstraint("user_id", "knowledge_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) knowledge_id: Mapped[int] = mapped_column( ForeignKey("knowledge.id", ondelete="CASCADE"), index=True ) mastery: Mapped[int] = mapped_column(Integer, default=60) position: Mapped[int] = mapped_column(Integer, default=0) class Textbook(Base): __tablename__ = "textbooks" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(128), unique=True) author: Mapped[str] = mapped_column(String(128), default="") publisher: Mapped[str] = mapped_column(String(128), default="") isbn: Mapped[str] = mapped_column(String(32), default="") edition: Mapped[str] = mapped_column(String(64), default="") grade: Mapped[str] = mapped_column(String(32), default="") link: Mapped[str] = mapped_column(String(256), default="") description: Mapped[str] = mapped_column(Text, default="") position: Mapped[int] = mapped_column(Integer, default=0) # 电子书:文件放在 data/ebooks/{textbook_id}/ 下,表里只存文件名与元信息 ebook_file: Mapped[str] = mapped_column(String(256), default="") ebook_name: Mapped[str] = mapped_column(String(256), default="") # 上传时的原始文件名 ebook_format: Mapped[str] = mapped_column(String(8), default="") # pdf | epub ebook_size: Mapped[int] = mapped_column(Integer, default=0) # 篇幅:PDF 页数 / EPUB 章节数,阅读器首次打开后回填真实值 ebook_pages: Mapped[int] = mapped_column(Integer, default=0) ebook_uploaded_at: Mapped[datetime | None] = mapped_column( DateTime, nullable=True ) class Course(Base): """在线课程 / 视频课:只有外链,不带章节与电子书,因此与教材分表存放。""" __tablename__ = "courses" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(128), unique=True) provider: Mapped[str] = mapped_column(String(128), default="") # 开课方 url: Mapped[str] = mapped_column(String(256), default="") grade: Mapped[str] = mapped_column(String(32), default="") description: Mapped[str] = mapped_column(Text, default="") position: Mapped[int] = mapped_column(Integer, default=0) class Chapter(Base): __tablename__ = "chapters" id: Mapped[int] = mapped_column(Integer, primary_key=True) textbook_id: Mapped[int] = mapped_column( ForeignKey("textbooks.id", ondelete="CASCADE"), index=True ) name: Mapped[str] = mapped_column(String(128)) position: Mapped[int] = mapped_column(Integer, default=0) summary: Mapped[str] = mapped_column(Text, default="") # 章节在电子书里的起始定位:PDF 是页码,EPUB 是 spine 节序号;0 表示未设置 ebook_page: Mapped[int] = mapped_column(Integer, default=0) class Question(Base): __tablename__ = "questions" id: Mapped[int] = mapped_column(Integer, primary_key=True) # 遗留字段:早期“专项练习 / 自适应题库”分类,已不再作为业务概念使用, # 仅为兼容旧数据库保留列。 source: Mapped[str] = mapped_column(String(16), default="bank") stem: Mapped[str] = mapped_column(Text) options: Mapped[str] = mapped_column(Text) # JSON 字符串 correct_index: Mapped[int] = mapped_column(Integer) knowledge_name: Mapped[str] = mapped_column(String(64), index=True) explanation: Mapped[str] = mapped_column(Text, default="") chapter_id: Mapped[int | None] = mapped_column( ForeignKey("chapters.id", ondelete="SET NULL"), nullable=True, index=True, ) difficulty: Mapped[int] = mapped_column(Integer, default=1) is_generated: Mapped[bool] = mapped_column(Boolean, default=False) used_count: Mapped[int] = mapped_column(Integer, default=0) class AttemptSession(Base): __tablename__ = "attempt_sessions" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) mode: Mapped[str] = mapped_column(String(16)) title: Mapped[str] = mapped_column(String(64)) total: Mapped[int] = mapped_column(Integer) correct: Mapped[int] = mapped_column(Integer) chapter_id: Mapped[int | None] = mapped_column( ForeignKey("chapters.id", ondelete="SET NULL"), nullable=True, index=True, ) happened_on: Mapped[date] = mapped_column(Date, default=date.today) class AttemptItem(Base): """练习中的逐题作答明细(题目做快照保存,删除题库不影响历史记录)。""" __tablename__ = "attempt_items" id: Mapped[int] = mapped_column(Integer, primary_key=True) attempt_id: Mapped[int] = mapped_column( ForeignKey("attempt_sessions.id", ondelete="CASCADE"), index=True ) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) question_index: Mapped[int] = mapped_column(Integer, default=1) question_id: Mapped[int | None] = mapped_column( ForeignKey("questions.id", ondelete="SET NULL"), nullable=True, ) stem: Mapped[str] = mapped_column(Text) options: Mapped[str] = mapped_column(Text) # JSON 字符串 correct_index: Mapped[int] = mapped_column(Integer) selected: Mapped[int | None] = mapped_column(Integer, nullable=True) correct: Mapped[bool] = mapped_column(Boolean) knowledge_name: Mapped[str] = mapped_column(String(64), index=True) knowledge_names: Mapped[str] = mapped_column(Text, default="[]") explanation: Mapped[str] = mapped_column(Text, default="") class ErrorEntry(Base): __tablename__ = "error_entries" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) source_label: Mapped[str] = mapped_column(String(64)) knowledge_name: Mapped[str] = mapped_column(String(64), index=True) knowledge_names: Mapped[str] = mapped_column(Text, default="[]") diagnosis: Mapped[str] = mapped_column(Text) status: Mapped[str] = mapped_column(String(16), default="待复习") created_on: Mapped[date] = mapped_column(Date, default=date.today) attempt_id: Mapped[int | None] = mapped_column( ForeignKey("attempt_sessions.id", ondelete="SET NULL"), nullable=True, ) question_id: Mapped[int | None] = mapped_column( ForeignKey("questions.id", ondelete="SET NULL"), nullable=True, ) stem: Mapped[str] = mapped_column(Text, default="") options: Mapped[str] = mapped_column(Text, default="") # JSON 字符串 selected: Mapped[int | None] = mapped_column(Integer, nullable=True) correct_index: Mapped[int | None] = mapped_column(Integer, nullable=True) explanation: Mapped[str] = mapped_column(Text, default="") class DailyCompletion(Base): __tablename__ = "daily_completions" __table_args__ = (UniqueConstraint("user_id", "done_on"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) chapter_id: Mapped[int | None] = mapped_column( ForeignKey("chapters.id", ondelete="SET NULL"), nullable=True, index=True ) rating: Mapped[str] = mapped_column(String(16), default="") done_on: Mapped[date] = mapped_column(Date) class LlmSetting(Base): __tablename__ = "llm_settings" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(128), default="") provider: Mapped[str] = mapped_column(String(32), default="openai") api_key: Mapped[str] = mapped_column(Text, default="") base_url: Mapped[str] = mapped_column(String(256), default="") model: Mapped[str] = mapped_column(String(64), default="") temperature: Mapped[float] = mapped_column(Float, default=0.3) max_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) is_default: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) class LlmTask(Base): """异步大模型任务:题目生成 / 章节生成,支持进度查询。""" __tablename__ = "llm_tasks" id: Mapped[int] = mapped_column(Integer, primary_key=True) kind: Mapped[str] = mapped_column(String(32)) # generate_questions / generate_chapters status: Mapped[str] = mapped_column(String(16), default="pending") progress: Mapped[int] = mapped_column(Integer, default=0) message: Mapped[str] = mapped_column(Text, default="等待执行") params: Mapped[str] = mapped_column(Text, default="{}") result: Mapped[str] = mapped_column(Text, default="") created_by: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) finished_at: Mapped[datetime | None] = mapped_column( DateTime, nullable=True ) class EbookProgress(Base): """阅读进度:PDF 记页码,EPUB 记 CFI,每人每本书一条。""" __tablename__ = "ebook_progress" __table_args__ = (UniqueConstraint("user_id", "textbook_id"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) textbook_id: Mapped[int] = mapped_column( ForeignKey("textbooks.id", ondelete="CASCADE"), index=True ) locator: Mapped[str] = mapped_column(String(512), default="") # EPUB CFI page: Mapped[int] = mapped_column(Integer, default=0) total: Mapped[int] = mapped_column(Integer, default=0) position: Mapped[float] = mapped_column(Float, default=0.0) # 0~1 updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow )