智学文枢:实体模型层

实体模型层:七张核心业务表的 ORM 设计

一句话摘要:models.py 用 SQLAlchemy 2.0 的 DeclarativeBase 声明式建模,覆盖学生、题目、变式、分析、对话、题包等十张表,用字符串 UUID 主键、JSON 列、父子自关联与级联删除支撑整套业务。

一、引言

第一性原理:业务代码关心的不是「表结构」,而是「对象」。如果让 UI 层直接写 SELECT ... JOIN ...,那么每个页面都要重复处理字段拼装、类型转换、关系遍历,代码会迅速腐化。ORM 的本质是把关系型数据映射为对象图 ,让开发者以 student.questions 这种自然方式访问数据。

但 ORM 不是银弹:建模时对「主键类型、级联策略、唯一约束、索引设计」的每一个选择,都直接决定上层业务(如批改同步、组合题、变式生成)的实现复杂度。本文拆解 models.py 的十张表设计。

二、核心原理

2.1 声明式基类与工具函数

模型层只有约 370 行,却要支撑 13 个页面、5 个 AI 引擎的数据读写,靠的是三个基础约定:

约定 实现 说明
声明式基类 class Base(DeclarativeBase) SQLAlchemy 2.0 新式声明,替代旧 declarative_base()
全局 UUID 主键 gen_uuid() 所有表 id = Column(String, primary_key=True, default=gen_uuid)
Unix 时间戳 now_ts() created_at / updated_at 统一用整数秒,排序比较零成本
python 复制代码
class Base(DeclarativeBase):
    """ORM基类"""
    pass

def gen_uuid() -> str:
    """生成UUID字符串"""
    return str(uuid.uuid4())

def now_ts() -> int:
    """当前Unix时间戳"""
    return int(time.time())

源码位置:data/models.py L12-L24

2.2 为什么是字符串 UUID 而不是自增整数

SQLite 的 INTEGER PRIMARY KEY 自带 rowid 优化,性能更好,但本项目坚持 UUID,理由有三:

  1. 跨库迁移友好 :备份恢复、学生数据转移(student_transfer_service.py)时,UUID 永不冲突,无需处理自增序列对齐。
  2. 业务表在 UI 层流通:ID 会被塞进对话框、拖拽数据、临时 JSON,字符串比整数更不容易被误当数值计算。
  3. 组合题需要「先有子后有父」:插入子题时父题 ID 已由客户端生成,无需等待数据库返回。

代价是索引体积略大、无顺序语义,但桌面单机数据量(万级)下完全可忽略。

2.3 级联删除:外键 + relationship 双管齐下

以 Student 为例,删学生必须连坐删掉他的所有从属数据:

python 复制代码
subject_configs = relationship("SubjectConfig", back_populates="student", cascade="all, delete-orphan")
questions = relationship("Question", back_populates="student", cascade="all, delete-orphan")
learning_progress = relationship("LearningProgress", back_populates="student", cascade="all, delete-orphan")
student_paper_errors = relationship("StudentPaperError", back_populates="student", cascade="all, delete-orphan")

源码位置:data/models.py L44-L47

这里的「双保险」指:数据库层 ForeignKey("students.id", ondelete="CASCADE")(依赖第 10 篇的 PRAGMA 外键开关)负责物理级联;ORM 层 cascade="all, delete-orphan" 负责对象图级联。二者缺一不可------只靠 ORM 级联,绕过 ORM 的 SQL 删除会留下孤儿数据;只靠外键级联,session.delete(student) 后内存中的子对象仍是脏状态。

三、代码实现

3.1 学生与学科配置

python 复制代码
class Student(Base):
    """学生档案"""
    __tablename__ = "students"

    id = Column(String, primary_key=True, default=gen_uuid)
    name = Column(String, nullable=False)
    grade = Column(String)                                  # 初一/高一/高三
    class_name = Column(String)                             # 班级名称
    school = Column(String)
    parent_contact = Column(String)
    notes = Column(Text)                                    # 私教备注
    avatar_path = Column(String)                            # 头像本地路径
    ai_preferences = Column(JSON, default=dict)             # AI偏好配置
    created_at = Column(Integer, nullable=False, default=now_ts)
    updated_at = Column(Integer)

    __table_args__ = (
        Index("idx_students_name", "name"),
        Index("idx_students_grade", "grade"),
        Index("idx_students_class", "class_name"),
    )

源码位置:data/models.py L27-L53

三个索引对应学生列表页的三个高频筛选维度:按姓名搜、按年级筛、按班级筛。ai_preferences 用 JSON 列存 {tone, example_preference, custom_notes},SQLite 底层是 TEXT,应用层自动序列化/反序列化。

SubjectConfig(表名 student_subject)与之形成一对多:一个学生六个学科(数理化政史地)各一份配置,用 UniqueConstraint("student_id", "subject") 保证「同一学生同一学科只有一条配置」,这是第 13 篇分科配置读写的唯一性基础。

3.2 习题表:组合题的父子自关联

Question 是业务核心表,两个设计亮点值得展开:

python 复制代码
paper_question_id = Column(String, ForeignKey("paper_questions.id", ondelete="SET NULL"), nullable=True)
parent_question_id = Column(String, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True)

parent = relationship("Question", remote_side=[id],
                      foreign_keys=[parent_question_id],
                      back_populates="children")
children = relationship("Question", foreign_keys=[parent_question_id],
                        back_populates="parent", cascade="all, delete-orphan")

源码位置:data/models.py L101-L116

  • parent_question_id 自关联 :支持「组合题」------一道题干挂多道子题(如大题的 (1)(2)(3) 小问)。自关联必须显式声明 remote_side=[id] 告诉 SQLAlchemy 哪边是「一」。
  • paper_question_id 外联题包母题 :批改同步时,同一母题同一学生只保留一条副本(见 3.4 的唯一索引),ondelete="SET NULL" 保证删除题包时复制题不误删。

3.3 AI 分析:一对一 + 教学讲解扩展

python 复制代码
class AIAnalysis(Base):
    """AI分析结果表(一对一,兼容单题/题包模式)"""
    __tablename__ = "ai_analysis"

    id = Column(String, primary_key=True, default=gen_uuid)
    question_id = Column(String, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True, unique=True)
    analysis_type = Column(String)                          # science/humanities
    raw_response = Column(Text)                              # AI原始返回(调试用)
    step_by_step = Column(JSON)                              # 分步解析 JSON数组
    core_concept = Column(Text)                              # 理科:核心公式;文科:核心概念
    ...
    has_teaching_talk = Column(Integer, default=0)           # 是否已生成讲解词
    teaching_talk = Column(Text)                             # 讲解词(自由文本)
    teaching_talk_model = Column(String)                     # 生成讲解词所用模型
    teaching_talk_generated_at = Column(Integer)             # 生成时间戳

源码位置:data/models.py L149-L175

question_id 带 unique=True 实现逻辑一对一;question_id 可空(nullable=True)是为了兼容「只有题包母题、没有单题副本」的分析场景。raw_response 保留 AI 原始返回用于调试,业务字段(core_concept / knowledge_gap 等)则是从原始返回解析出的结构化结果。讲解词(teaching_talk)设计为独立字段而非复用 raw_response,是「方案 B:AI 分析之外独立生成讲解」的落地。

3.4 题包三表:Paper / PaperQuestion / StudentPaperError

题包模块是三张表的协作:
#mermaid-svg-9I2Vqw8yDMWKQssQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9I2Vqw8yDMWKQssQ .error-icon{fill:#552222;}#mermaid-svg-9I2Vqw8yDMWKQssQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9I2Vqw8yDMWKQssQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9I2Vqw8yDMWKQssQ .marker.cross{stroke:#333333;}#mermaid-svg-9I2Vqw8yDMWKQssQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9I2Vqw8yDMWKQssQ p{margin:0;}#mermaid-svg-9I2Vqw8yDMWKQssQ .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-9I2Vqw8yDMWKQssQ .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-9I2Vqw8yDMWKQssQ .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-9I2Vqw8yDMWKQssQ .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-9I2Vqw8yDMWKQssQ .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-9I2Vqw8yDMWKQssQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-9I2Vqw8yDMWKQssQ .node rect,#mermaid-svg-9I2Vqw8yDMWKQssQ .node circle,#mermaid-svg-9I2Vqw8yDMWKQssQ .node ellipse,#mermaid-svg-9I2Vqw8yDMWKQssQ .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9I2Vqw8yDMWKQssQ .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-9I2Vqw8yDMWKQssQ .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9I2Vqw8yDMWKQssQ .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-9I2Vqw8yDMWKQssQ .edgeLabel .label text{fill:#333;}#mermaid-svg-9I2Vqw8yDMWKQssQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 批改
包含
被批改
父子(组合题)
STUDENT
PAPER_ERROR
string
student_id
FK
string
paper_id
FK
string
paper_question_id
FK
string
analysis_id
FK
int
is_mistake
int
reviewed
PAPER
PAPER_QUESTION

StudentPaperError 是核心多对多关联表,唯一约束是防重复批改的关键:

python 复制代码
__table_args__ = (
    Index("idx_spe_student", "student_id"),
    Index("idx_spe_paper", "paper_id"),
    Index("idx_spe_mistake", "is_mistake"),
    # 同一学生同一题包同一母题只允许一条批改记录,防止重复批改累积
    Index("uq_spe_student_paper_pq", "student_id", "paper_id", "paper_question_id", unique=True),
)

源码位置:data/models.py L363-L368

这个唯一索引直接呼应第 10 篇 _migrate_schema 里的兜底逻辑:旧库若已存在重复批改数据,建唯一索引会失败,系统记录 warning 并交由专门清理脚本(tests/cleanup_duplicate_paper_errors.py)处理。

3.5 其余三张轻量表

  • Variant(变式题) :difficulty_delta 用字符串存 -1/0/+1(降/平/升难度),solution_steps 用 JSON 存步骤数组,hint 字段仅降难度时使用。
  • ConversationRecord(追问对话) :每道题独立对话线程,order_index 整数保证消息严格有序,配合 (question_id, student_id) 复合索引。
  • QuestionEngineConfig(追问引擎配置) :通过 config_type(global / subject / student_subject)+ target_id 实现三级配置继承。

四、设计权衡 / 踩坑记录

1. 组合题与唯一索引的冲突

批改同步唯一键是 (student_id, paper_question_id),但组合题的子题共享同一母题 ID?不会------母题是「题干记录」,子题绑定的是各自 paper_question_id,题干记录本身不参与批改计分(paper_repo 的 get_by_paper_leaves 专门过滤题干,见第 15 篇)。

2. 一处值得注意的隐患:重复的 __table_args__

源码中 PaperQuestion 类体内出现了两次 __table_args__(models.py L324-L328 与 L334-L337)。Python 类定义中同名属性后定义者覆盖先定义者,因此实际生效的是第二个------意味着 idx_pq_parent 索引(组合题父题查询加速)不会真正创建。当前数据量下不影响正确性,但若组合题量大,应按最终生效版本补齐该索引。

3. uselist=False 的取舍

Question.ai_analysis 声明为 uselist=False(一对一)。分析结果在模型层是唯一键约束,ORM 层一对一声明后,question.ai_analysis 直接返回对象而非列表,UI 层少一层 [0] 解包。

4. JSON 列的默认值陷阱

Column(JSON, default=dict) 传入的是工厂 而非实例,保证每个新对象拿到独立字典;若误写 default={},所有新对象的默认值将共享同一引用,改一个全变。

五、小结

  • 十张表统一采用「字符串 UUID 主键 + Unix 时间戳 + JSON 灵活列」的建模约定,兼顾跨库迁移与灵活扩展。
  • 级联删除由「外键 ondelete + ORM cascade」双保险实现,防止孤儿数据。
  • 组合题通过 Question.parent_question_id 自关联建模,题包-学生关联由 StudentPaperError 承载并加唯一索引防重复。
  • 分析表通过 question_id UNIQUE 实现一对一,并用 paper_question_id 兼容题包模式。
  • 建模时的每个唯一约束(学科配置、知识点进度、批改记录)都在为上层去重逻辑兜底。
相关推荐
张张123y1 小时前
# 后端第七课:FastAPI 实战入门(用刚学的 Python 写第一个 API 接口)
开发语言·python·mysql·api·fastapi
CCblandy1 小时前
一致性哈希到底省了多少搬迁?本机实测 3 轮:取模 6→7 搬 85.8%,V=128 只搬 14.3%,虚拟节点太少会被倾斜骗
后端·python·云原生·性能优化·架构
Yan-英杰1 小时前
【论文精读】 基于 TensorFlow 对卷积神经网络的优化研究:异构式 CPU+GPU 协同计算 + ReLU-Swish 激活函数
python·卷积神经网络
ydjs678899-bot2 小时前
如何在Java中打印pdf静默打印和打印对话框选择
java·python·pdf
把我格子衫拿来2 小时前
误差预算怎么做?从雅可比到蒙特卡洛的三维定位不确定度分析
python·蒙特卡洛·三维定位·误差分析·不确定度
文章永久免费只为良心3 小时前
Ctfshow_Web应用安全与防护_第二章_无字母数字命令执行
前端·python·安全·网络安全·渗透测试·ctf·红队渗透测试
2601_962885723 小时前
如何用 Python 获取上证指数、沪深300、中证500 等指数行情?
开发语言·python
昔我往昔3 小时前
LangChain_RAG_学习小结
后端·python
张张123y3 小时前
后端第六课:Python 语法速通(0 基础入门,AI 应用开发的第一门语言)
开发语言·人工智能·python·ai·后端开发