Files
teaching-design/docs/book-import-spec.md
yuetsh a427bba8c8 feat: 从书籍生成教案(粘贴目录 + 上传 md 压缩包)
新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.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>
2026-06-24 05:07:19 -06:00

99 lines
6.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 从书籍生成教案 — 功能规格Spec
## 背景与目标
当前 FakeTeachingDesignVue 3 + Hono/Bun + SQLiteDeepSeek `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 无 key502 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 / 全部不匹配 → 友好提示。