新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.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>
99 lines
6.8 KiB
Markdown
99 lines
6.8 KiB
Markdown
# 从书籍生成教案 — 功能规格(Spec)
|
||
|
||
## 背景与目标
|
||
|
||
当前 FakeTeachingDesign(Vue 3 + Hono/Bun + SQLite,DeepSeek `deepseek-v4-flash`)只能:
|
||
- **生成一篇**:手输一个主题 → `/api/generate` 生成单篇教案。
|
||
- **批量生成**:输入主题 → `/api/generate/outline` 生成标题列表 → 逐篇生成。
|
||
|
||
缺口:无法基于**真实教材内容**生成教案。本功能让用户提供一本教材的**目录**与**正文**,由 AI 据此划分课时,用户确认后批量生成,且生成每篇时把对应章节正文作为上下文喂给 AI,产出更贴合教材的教案。
|
||
|
||
## 输入(用户提供)
|
||
|
||
1. **目录**:多行文本,用户**粘贴**到文本框。每行一个标题,给出课程的**顺序**与**完整标题清单**。
|
||
2. **正文压缩包**:用户**上传**一个 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` 把 `[\\/:*?"<>|]` 换成 `_`)。匹配时对**两侧都归一化**后比对:
|
||
1. 去掉 `.md` 后缀;
|
||
2. `trim`,把连续空白折叠为单空格;
|
||
3. 把 `[\\/:*?"<>|]` 替换为 `_`(与服务端 `sanitizeFilename` 一致)。
|
||
归一化后字符串相等即匹配。匹配不到的目录行 `matched=false`、`content=''`(仍可生成,但只靠标题)。
|
||
|
||
## 接口
|
||
|
||
### `POST /api/generate/lessons-from-book`(新增,挂 `/api/generate/*`,复用现有鉴权)
|
||
请求:
|
||
```json
|
||
{ "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 范围内),丢弃非法行。
|
||
|
||
响应:
|
||
```json
|
||
{ "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`:可编辑课时列表(改标题、删行);显示每课时所辖章节标题。「开始生成」emit `start:[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 / 全部不匹配 → 友好提示。
|