book 的数据模型与章节结构:19 个 `BlockType` 与 13 个生成器
看一个”把资料编译成书”的模块,最快的入口不是编译流程,而是它的数据模型——因为流程可以改,落盘的对象形状一旦定下来,后面所有功能都得绕着它走。DeepTutor 的 deeptutor/book/ 就是这样一个模块,它的模型全部收在 deeptutor/book/models.py,474 行。
下面所有行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,按字段名和枚举取值去搜更保险;取值本身是否变动,请以仓库最新代码为准。整个 deeptutor/book/ 目录我们数出来是 58 个文件、Python 共 7685 行,最大的单文件是 book/engine.py 1297 行——本文只讲 models.py 这一层的形状,引擎怎么跑、目录怎么规划,我们另有两篇专门讲。
一、五层对象与四个 id 前缀
这套模型是自上而下的五层:
| 层 | 类 | id 前缀 | 落盘位置 |
|---|---|---|---|
| 书 | Book | bk_ | manifest.json |
| 骨架 | Spine | 无独立 id(带 book_id) | spine.json |
| 章 | Chapter | ch_ | 在 spine.json 内 |
| 页 | Page | pg_ | pages/{page_id}.json |
| 块 | Block | blk_ | 在页文件内 |
对应的字段定义分别在 models.py:426-446(Book)、models.py:255-266(Spine)、models.py:193-206(Chapter)、models.py:363-381(Page)、models.py:326-345(Block)。
这里有一处读代码时容易想当然的地方:Chapter 里没有正文,只有 page_ids。章的字段是 id、title、learning_objectives、content_type、source_anchors、prerequisites、page_ids、summary、order 九项,内容全在页里,页的内容又全在 blocks 里。所以”改一章的内容”在这套模型里从来不是改 Chapter 对象,而是去动它指向的那些页文件。
另一处是 Page 上的 parent_page_id(models.py:363-381),它专门用于 deep_dive 子页,也就是说页与页之间是有父子关系的,不是一维列表。Page 还有一个 links 字段承载页间关系。
二、三个状态枚举,各管一层
models.py 顶上定义了五个枚举,其中三个是状态机:
BookStatus:draft / spine_ready / compiling / ready / error / archived(models.py:29-35)PageStatus:pending / planning / generating / ready / partial / error(models.py:38-44)BlockStatus:pending / generating / ready / error / hidden(models.py:47-52)
注意三者的取值不是简单复制粘贴:页多了一个 partial,块多了一个 hidden,书多了 archived 和 compiling。partial 不是摆设——book/compiler.py:348-365 的页面状态聚合规则是:全部块 READY 才置 READY;一个 READY 都没有置 ERROR;介于两者之间置 PARTIAL,并写一句 "{errored}/{total} blocks failed."。没有块的页同样按 ERROR 处理。
这条规则的实际含义是:页面状态是块状态算出来的,不是单独维护的。排查”这页怎么只出来一半”时,去看页 JSON 里各块的 status 和 metadata.failure,比看页级 status 有用。块级失败的分类写在 book/blocks/base.py:38-71,按错误消息关键字分成 json_parse / empty_response / timeout / rate_limit / prompt_leak / provider_error / generator_error 七类,除 prompt_leak 外都标 retryable=True。
三、反直觉的那一处:19 个块类型,13 个生成器
BlockType 是这套模型里最长的枚举,共 19 个取值(models.py:55-79):text、callout、quiz、user_note、figure、interactive、animation、code、timeline、flash_cards、deep_dive、section、concept_graph、diagnostic、pretest、retrieval_practice、error_diagnosis、module_test、progress_dashboard。
源码在枚举里直接用注释做了分期标注:Phase 1 是前四个,Phase 2 是 figure 到 flash_cards 六个,Phase 3 是 deep_dive,Phase 4(标注为 BookEngine v2)是 section 与 concept_graph,剩下六个归在 Guided Learning 下(models.py:56-79)。
而生成器注册表 _build_default_registry() 只注册了 13 个(book/blocks/base.py:207-222):Text、Callout、Quiz、UserNote、Figure、Interactive、Animation、Code、Timeline、FlashCards、DeepDive、ConceptGraph、Section。
差在中间的正是 Guided Learning 那六个:diagnostic、pretest、retrieval_practice、error_diagnosis、module_test、progress_dashboard。它们在枚举里,但没有对应生成器。命中时会发生什么,代码写得很明白:book/compiler.py:216-220 写一条 "No generator registered for block type ..." 并把该块置 ERROR。往上一层看,按第二节那条聚合规则,结果取决于这一页的其余块:若该页还有其它块是 READY,整页会从 READY 落到 PARTIAL;一个 READY 都没有时,按规则直接是 ERROR。
这就是这套模型最反直觉的地方:枚举取值的存在不等于能力的存在。一个 BlockType 能被序列化、能写进页 JSON、能通过校验,但编译到它的时候拿不到生成器。读枚举来判断”这个项目支持哪些块”会得出偏乐观的结论,判断标准应该是注册表那 13 项。
顺着这条线还有两处可核实的差异,按纪律只陈述位置与内容:
book/blocks/__init__.py:4-21导出了 11 个生成器,缺SectionGenerator与ConceptGraphGenerator,而这两个在注册表里都注册了——ConceptGraphGenerator在book/blocks/base.py:196、SectionGenerator在book/blocks/base.py:202。- README 自称的 Book 块类型清单在
README.md:566,列的是 text、callouts、quizzes、flash cards、timelines、code、figures、interactive HTML、animations、concept graphs、deep dives、user notes,共 12 类(这个数字是我们按该行清单数出来的),未提section;而section是 v2 里承载长文正文的类型,静态模板每套都以它打底(book/models.py:71、book/agents/page_planner.py:74-155)。
以我们实读的仓库状态为准。说完就停。
四、ContentType 六个取值,决定页的模板长相
ContentType 有 6 个取值,每个在源码里都注明了模板倾向(models.py:82-90):theory、derivation、history、practice、concept、overview。它挂在 Chapter 和 Page 两个层级上。
与它直接绑定的是静态模板 _TEMPLATES_V2,覆盖其中 5 种,每个条目是一对 (BlockType, params)。以 THEORY 为例是 8 块:SECTION(introduction, 1200 词) → FIGURE(diagram) → SECTION(deep_dive, 1600) → CALLOUT(key_idea) → CODE(python/example) → SECTION(synthesis, 800) → QUIZ(3 题) → FLASH_CARDS(5 张)(book/agents/page_planner.py:74-94)。DERIVATION、HISTORY、PRACTICE、CONCEPT 各 7 块(page_planner.py:95-155)。
需要说明的是:这些块数、词数、题数是项目在代码里写死的实现选择,不是经过验证的教学结论。本文只陈述这些数值写在哪一行、怎么被读取,不评价它们在学习科学上的有效性,也不承诺任何学习效果。规划层怎么在 LLM 方案与静态模板之间取舍,我们另有一篇专门讲。
第六个取值 overview 是个例外:它对应的页由引擎确定性构建,compile_page 对 content_type == OVERVIEW 的页面永不走通用 LLM 编译器,注释给的理由是避免”用幻觉散文覆盖确定性的导览与章节索引”(book/engine.py:716-728)。
五、章节结构是会被改写的,不是你确认过的那一版
这是第二处容易踩空的地方。用户确认骨架之后,章列表还会被动两次:
其一,插入导览章。 _ensure_overview_chapter 在 order 0 位置插一个 Overview 章,其余章的 order 全部 +1;标题中文写死为”本书导览”、英文为 “How to read this book”。这个操作是幂等的,判据是首章 content_type == OVERVIEW,或者 extra 字段 auto_overview is True(book/engine.py:398-451)。
其二,deep_dive 会造出合成章。 create_deep_dive_subpage 会新建一个标题形如 {topic} (deep dive) 的合成章,再建一个 parent_page_id 指向父页的子页,并在父页上追加一条 PageLink(relation="deepens")(book/engine.py:1158-1215)。
合起来看结论就是:spine.json 里的章数和 order,与用户当初确认的那一版可能不同。做统计、导出或前端目录渲染时,别把”确认时的章序”当成稳定值,以磁盘上的 spine 为准。
顺带一提,Spine 除了 chapters 还带 version、concept_graph、exploration_summary(models.py:255-266)。概念图是三件套:ConceptNode(id/label/chapter_id/description/weight)、ConceptEdge(src/dst/relation,relation 默认 depends_on,注释给的取值是 'depends_on' | 'extends' | 'related')、ConceptGraph(nodes/edges 加两个辅助方法 node_by_id 与 has_edge)(models.py:214-252)。这三种关系在渲染侧被映射成 mermaid 箭头:depends_on → "-->"、extends → "==>"、related → "-.->"(book/blocks/concept_graph.py:74)。
六、溯源字段:四源输入与两种来源标记
这套模型对”这句话哪来的”有专门的字段。
入口是 BookInputs,源码称其为”四源输入快照”,字段包括 user_intent、chat_session_id(注释标为 legacy 单会话简写)、chat_selections、chat_history、notebook_refs、knowledge_bases、question_categories、question_entries、language、captured_at(models.py:143-157)。它是快照,落盘在书目录下的 inputs.json。
往下有两个各自独立的来源枚举,取值不完全一样,这点容易看混:
SourceAnchor.kind的注释取值是'kb' | 'notebook' | 'chat' | 'web' | 'manual'(models.py:188)SourceChunk.source的注释取值是'kb' | 'notebook' | 'chat' | 'questions' | 'web'(models.py:280-296)
前者多了 manual,后者多了 questions。SourceChunk 的完整字段是 chunk_id/kb_name/source/ref/text/score/query/metadata,它属于探查阶段的产物,汇总在 ExplorationReport 里(book_id/queries/chunks/summary/coverage/candidate_concepts/notes/created_at,models.py:299-318),单独落盘为 exploration.json(book/storage.py:153-159)。
anchors 在写入时是有上限的:SECTION 块的 payload 里 anchors 截断到 8 条(book/blocks/section.py:124-155),而 optional_rag_lookup 这条”nice to have”的补充检索最多取 6 条、失败直接静默吞掉(book/blocks/_rag_helpers.py:1-8、:31)。也就是说,块上挂着的 anchors 是被裁剪过的子集,不要当成完整来源清单用。
七、Progress 与 KB 指纹:两个会让页面”过期”的字段
Progress 的字段是 current_page_id、visited_page_ids、bookmarked_page_ids、quiz_attempts、weak_chapters、score(models.py:406-418)。写入规则在引擎里:record_quiz_attempt 答错就把该页的 chapter_id 加进 progress.weak_chapters,答对则 score += 1(book/engine.py:1239-1245);supplement_for_weakness 会依次插入 CALLOUT(common_pitfall)、TEXT(role=remediation)、QUIZ(num_questions=2, difficulty=easy) 三个块(book/engine.py:1248-1277)。
再说一次边界:以上是这个项目的实现选择,我们只陈述哪一行写了什么、字段怎么变化,不评价这套记分与补救逻辑在教学上是否有效,也不做任何学习效果的承诺。
Book 上另有两个和”过期”直接相关的字段:kb_fingerprints 与 stale_page_ids(models.py:426-446)。指纹的算法在 book/kb_health.py:35-65:对知识库的 raw/ 目录逐文件取 name:mtime:size,拼接后做 sha256;知识库不存在或没有文件时返回空串。注意口径里含 mtime——文件内容没变但修改时间变了,指纹同样会变。漂移报告 KBDriftReport 的字段是 has_drift、new_kbs、removed_kbs、changed_kbs、current_fingerprints、stale_page_ids(kb_health.py:80-88)。建书时引擎会立即抓一份 KB 基线指纹,注释给的理由是避免首次健康检查把所有知识库误报为”新增漂移”(book/engine.py:245-255)。
八、落到磁盘上是什么样
相对 data/user/workspace/book/,一本书的目录是(book/storage.py:7-18):
book_{book_id}/
manifest.json # Book
spine.json # Spine(含 chapters 与 concept_graph)
progress.json # Progress
inputs.json # BookInputs
exploration.json # ExplorationReport(另见 storage.py:153-159)
log.md # append-only 操作日志
pages/{page_id}.json
assets/
树里的 exploration.json 要单独说一句:它不在 storage.py:7-18 那份布局注释里,是由 _exploration_path 写在书根的(storage.py:153-159)。照着 7-18 行去核对的话,只会看到 manifest / spine / progress / inputs / log.md / pages / assets 七项。
三条落盘细节值得记:所有写入走 atomic_write_text,JSON 用 ensure_ascii=False, indent=2 序列化(storage.py:44-46),所以中文是可直接阅读的原文;log.md 是 append-only,每行格式为 - `{ISO时间}Z` **{op}** — {message}(storage.py:236-242);list_pages 按 (order, created_at) 排序,校验失败的页文件会被跳过并告警(storage.py:209-225)——这条意味着页数对不上时,先去日志里找被跳过的文件,而不是先怀疑编译流程。
块的 payload 形状随类型而变,Block 上同时保留 params(生成输入,注释说明是留着重试用)与 payload(生成结果)。几种常见形状:TEXT 是 {format:markdown, body, role}(blocks/text.py:74-76)、CALLOUT 是 {variant,label,body}(blocks/callout.py:55)、TIMELINE 是 {events:[{date,title,description}]}(blocks/timeline.py:51-59)、FLASH_CARDS 是 {cards:[{front,back,hint}]}(blocks/flash_cards.py:59-67)、CODE 是 {language,code,explanation,intent}(blocks/code.py:58-61)。用户手写的笔记块是特例:insert_block 对 USER_NOTE 直接置 READY 并写 {format:markdown, body, author:user},不经过生成器(book/engine.py:1018-1154)。
需要如实说明的一点:ANIMATION 块的生成依赖你本机的环境——它调用 MathAnimatorPipeline,若 importlib.util.find_spec("manim") is None 就直接抛 GenerationFailure 并给出安装提示(blocks/animation.py:1-9、:30)。也就是说这一类块能不能出,取决于本机装没装对应的外部依赖,而不只是模型侧的事。CODE 块的 payload 只是文本,本文不涉及它是否会被执行。
九、你可以照着核的六步
- 打开
deeptutor/book/models.py:55-79,数一遍BlockType的取值,确认是 19 个,并看清注释里的分期标注。 - 打开
deeptutor/book/blocks/base.py:207-222,数注册的生成器,确认是 13 个;两份清单相减,就是那 6 个 Guided Learning 类型。 - 打开
book/compiler.py:216-220,确认命中未注册类型时写的是"No generator registered for block type ..."并置 ERROR;再看compiler.py:348-365的页面状态聚合规则。 - 打开
book/blocks/__init__.py:4-21数导出项,与第 2 步的注册表比对,找出缺的那两个。 - 打开
book/engine.py:398-451与:1158-1215,确认导览章插在 order 0、deep_dive 会新建合成章这两件事。 - 打开
book/storage.py:7-18,对着目录结构确认每个模型对应哪个文件;再看:209-225那条”跳过校验失败页文件”的处理。
边界说明:本文只读了 models.py 的字段定义、storage.py 的布局与若干块生成器的 payload 写入侧。book/context.py(432 行)我们只读了前 50 行,book/inputs.py(362 行)只读了前 90 行,kb_health.py 只读了前 90 行,其余函数未逐行核实。前端如何渲染这些 payload 我们完全没有看,本文里的 payload 形状只反映后端写入侧。文中所有数值都是源码中的默认配置或枚举定义,不构成对实际运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。