新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.md 的 ZIP(文件名=目录标题),前端用 JSZip 解压匹配,AI 按章节
体量划分课时,预览/编辑后批量生成,每篇以对应章节正文作为上下文。
服务端
- generate.ts: 新增 POST /lessons-from-book(目录条目→AI 划分课时
JSON,剥围栏/校验/丢非法行);扩展 POST / 支持可选 content 上下文,
无 content 时请求体不变(向后兼容)。
前端
- bookImport.ts: normalizeTitle / parseZip / matchToc / assembleContent。
- booksApi.ts: generateLesson 兼容签名 + divideLessonsFromBook。
- useTeachingBook.ts: 抽出 runGenerationPool,新增 generateLessonsFromBook。
- BookImportDialog.vue 多阶段对话框;UploadDropzone 加 accept/multiple;
菜单串接「从书籍生成」。
顺带修复 5 个遗留失败测试:import 功能已于 fb0b8d1 删除但测试漏删,
清理孤儿测试;批量生成对齐为单参 generateLesson(topic)。
docs/book-import-spec.md 记录规格。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.8 KiB
6.8 KiB
从书籍生成教案 — 功能规格(Spec)
背景与目标
当前 FakeTeachingDesign(Vue 3 + Hono/Bun + SQLite,DeepSeek deepseek-v4-flash)只能:
- 生成一篇:手输一个主题 →
/api/generate生成单篇教案。 - 批量生成:输入主题 →
/api/generate/outline生成标题列表 → 逐篇生成。
缺口:无法基于真实教材内容生成教案。本功能让用户提供一本教材的目录与正文,由 AI 据此划分课时,用户确认后批量生成,且生成每篇时把对应章节正文作为上下文喂给 AI,产出更贴合教材的教案。
输入(用户提供)
- 目录:多行文本,用户粘贴到文本框。每行一个标题,给出课程的顺序与完整标题清单。
- 正文压缩包:用户上传一个 ZIP,内含多份
.md,文件名 = 目录中的标题,文件内容 = 该标题对应的正文。
核心流程
粘贴目录(多行) + 上传 ZIP
│
▼
前端解压(JSZip) → {标题 → 正文} 映射 → 与目录每行按文件名匹配
│ 得到 entries: [{index, title, content, charCount, matched}]
▼
POST /api/generate/lessons-from-book { entries: [{index,title,charCount}] }
│ AI 据目录与各条体量「再划分课时」(可合并/拆分)
▼
返回 lessons: [{title, sourceIndexes:[...]}]
│
▼
预览/编辑课时列表(改标题、删行)
│ 前端按 sourceIndexes 拼出每课时的正文上下文
▼
批量生成: 每课时 POST /api/generate { topic, content }
│ content = 该课时所辖章节正文(拼接并截断 ~6000 字)
▼
逐篇 parseTeachingDesign → 追加进 book.designs(复用现有有序追加/进度/取消)
关键决策
- 解压与匹配全在前端:
JSZip已是项目依赖(src/services/zipExporter.ts在用)。浏览器内JSZip.loadAsync解压,无需 PDF 解析、OCR、服务端缓存、multipart 上传——发给后端只是 JSON。 - AI 再划分课时:目录条目(章/节)不一定 1:1 对应课时。AI 据标题 + 各条字数(
charCount,廉价信号,不传全文)决定合并小节、拆分大章。 - 正文随生成请求直传:每课时生成时把拼好的正文作为
content字段直接 POST,无服务端缓存、无parseId。 - 纯增量扩展
/api/generate:新增可选content,老调用方仍只传{topic},现有测试不变。
标题↔文件名 匹配规则
ZIP 内文件名可能带 .md 后缀、含被替换的非法字符(现有 sanitizeFilename 把 [\\/:*?"<>|] 换成 _)。匹配时对两侧都归一化后比对:
- 去掉
.md后缀; trim,把连续空白折叠为单空格;- 把
[\\/:*?"<>|]替换为_(与服务端sanitizeFilename一致)。 归一化后字符串相等即匹配。匹配不到的目录行matched=false、content=''(仍可生成,但只靠标题)。
接口
POST /api/generate/lessons-from-book(新增,挂 /api/generate/*,复用现有鉴权)
请求:
{ "entries": [ { "index": 0, "title": "第1章 C# 入门", "charCount": 5821 } ] }
- 系统提示要求 AI 据目录把全书拆为单课时课题,每条对应一个或多个目录条目,难度由浅入深、覆盖全部条目,复杂条目可拆多课时;仅输出 JSON,形如
{"lessons":[{"title":"项目名——课时任务","sourceIndexes":[0]}]}。 - 复用现有
fenceMatch(generate.ts:86-87)剥代码块围栏;JSON.parse后校验每行title:string+sourceIndexes:number[](索引在 entries 范围内),丢弃非法行。
响应:
{ "lessons": [ { "title": "C# 入门——搭建环境运行首个程序", "sourceIndexes": [0] } ] }
错误:400 缺 entries;500 无 key;502 DeepSeek 失败/空。
POST /api/generate(扩展,向后兼容)
- 读可选
content:string。有则把它作为编写依据拼进 user 消息(提炼要点、不照抄),系统提示不变,返回{filename, markdown}不变。 - 无
content时请求体与现状完全一致。
前端
- 菜单:
GenerateMenuButton.vue加「从书籍生成」(data-testid="book-import"),经WorkspaceToolbar→WorkspaceView。 - 对话框
BookImportDialog.vue(仿BatchGenerateDialog.vue多阶段):input:目录粘贴<textarea>+UploadDropzone(accept.zip,单文件)。校验:目录非空 + 已传 ZIP。点「解析」→ 前端解压匹配,展示匹配概况(如「20 行目录,18 行匹配到正文,2 行缺正文」)。division-loading:调divideLessonsFromBook(entries),提示「AI 正在划分课时…」。preview:可编辑课时列表(改标题、删行);显示每课时所辖章节标题。「开始生成」emitstart:[lessons](含拼好的 content)。running / done / error:与 batch 相同(进度条 + 停止)。
- 解压匹配工具
src/services/bookImport.ts:parseZip(file)用 JSZip 读所有.md→Map<归一化标题, 正文>;matchToc(tocLines, map)→entries[]。 - API
src/services/booksApi.ts:generateLesson增加content选项(兼容旧AbortSignal第二参);新增divideLessonsFromBook(entries);新增类型BookEntry、ProposedLesson。 - Store
useTeachingBook.ts:新增generateLessonsFromBook(lessons:{title,content}[], options),复用generateLessons(:199-265)的 worker 池(并发 3、有序追加、可取消、进度回调),每个 worker 调generateLesson(title,{content,signal})。
非目标
- 不支持 PDF / Word / 扫描件 / OCR(仅 ZIP-of-md + 粘贴目录)。
- 不做章节↔课时映射的高级可视化编辑(预览仅改标题、删行)。
- 不持久化上传的原始 ZIP/目录(仅用于一次生成)。
验证
- 服务端
bun test server:/lessons-from-book从 mock JSON 解出lessons、畸形 JSON 优雅处理、400 缺 entries;POST /带content时content出现在捕获的 DeepSeek 请求体,且不带时请求体不变(向后兼容)。 - 前端
vitest run:bookImport.ts解压+匹配(含归一化、缺正文)单测;booksApi的generateLesson('x')仍发{topic:'x'}、带 content 时附加;divideLessonsFromBook发{entries};BookImportDialog走 input→preview→start 流程,断言start载荷;useTeachingBook.generateLessonsFromBook有序追加 + 取消。 - 类型检查:
npx vue-tsc -b。 - 手动 E2E:粘贴一份目录 + 传一个 md 的 ZIP → 看匹配概况 → 确认 AI 课时 → 改标题/删行 → 开始生成 → 教案追加、内容贴合章节;负向:目录空 / 传非 ZIP / 全部不匹配 → 友好提示。