Files
teaching-design/server/routes/generate.ts
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

269 lines
9.8 KiB
TypeScript
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.
import { readFileSync } from 'node:fs'
import { Hono } from 'hono'
const DEFAULT_SYSTEM_PROMPT = `你是一名教学设计专家,需要根据用户提供的主题生成一份 Markdown 格式的教案。
请严格遵循以下结构(标题、表格列数、章节名称必须完全一致,便于程序解析),只输出 Markdown 正文本身,不要使用代码块包裹整篇文档,不要添加任何额外说明:
1. 第一行是一级标题:\`# <课程标题> 教学设计\`
2. 紧接着是一个两列表格(表头使用 \`|:---|:---|\`),依次包含以下行:
- \`| **课题** | **<课题名称>** |\`
- \`| **课时** | <课时说明,例如 1课时40分钟> |\`
- \`| **教学目标** | **知识目标**...<br>**技能目标**...<br>**素养目标**... |\`
- \`| **教学重难点** | **重点**...<br>**难点**... |\`
- \`| **教学资源准备** | ... |\`
3. 二级标题 \`## 教学过程\`,后接一个 5 列表格,表头固定为:
\`| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |\`,分隔行 \`|:---|:---|:---|:---|:---|\`
包含 4-6 个教学环节行,每个环节名称写作 \`**N. 环节名称**<br>(时长)\` 的格式。
4. 二级标题 \`## 板书设计\`,内容放在 \`\`\`text 代码块中。
5. 二级标题 \`## 教学成效与反思\`,后接一个两列表格:
- \`| **教学成效** | ... |\`
- \`| **教学反思** | ... |\`
`
function loadSystemPrompt(): string {
try {
return readFileSync('data/SKILLS.md', 'utf8')
} catch {
return DEFAULT_SYSTEM_PROMPT
}
}
function sanitizeFilename(topic: string): string {
const sanitized = topic.trim().replace(/[\\/:*?"<>|]/g, '_')
return sanitized || 'lesson'
}
export function createGenerateRouter(apiKey: string | undefined): Hono {
const app = new Hono()
app.post('/', async (c) => {
const body = (await c.req.json().catch(() => null)) as
| { topic?: unknown; content?: unknown }
| null
const topic = body?.topic
if (typeof topic !== 'string' || topic.trim() === '') {
return c.json({ error: '请提供教案主题。' }, 400)
}
if (!apiKey) {
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
}
const content = typeof body?.content === 'string' ? body.content.trim() : ''
const userContent = content
? `请围绕主题"${topic.trim()}"生成一份教案。\n\n以下是教材相关章节内容作为编写依据请提炼要点不要照抄\n"""\n${content.slice(0, 6000)}\n"""`
: `请围绕主题"${topic.trim()}"生成一份教案。`
const systemPrompt = loadSystemPrompt()
let response: Response
try {
response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
}),
})
} catch {
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
}
if (!response.ok) {
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
}
const payload = (await response.json().catch(() => null)) as
| { choices?: Array<{ message?: { content?: string } }> }
| null
const raw = payload?.choices?.[0]?.message?.content
if (!raw) {
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
}
const fenceMatch = raw.trim().match(/^```(?:\w+)?\n([\s\S]*?)\n```\s*$/)
const markdown = fenceMatch ? fenceMatch[1]! : raw
return c.json({ filename: `${sanitizeFilename(topic)}.md`, markdown })
})
app.post('/outline', async (c) => {
const body = (await c.req.json().catch(() => null)) as
| { theme?: unknown; count?: unknown }
| null
const theme = body?.theme
if (typeof theme !== 'string' || theme.trim() === '') {
return c.json({ error: '请提供课程主题。' }, 400)
}
const rawCount = typeof body?.count === 'number' ? body.count : 18
const count = Math.min(50, Math.max(1, Math.round(rawCount)))
if (!apiKey) {
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
}
let response: Response
try {
response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content:
`你是教学设计专家。根据用户提供的课程主题,生成一份完整的课时大纲标题列表,共约${count}条。` +
'每个标题格式为"项目名——课时任务",一行一个,不加序号、不加任何说明,直接输出标题列表。',
},
{ role: 'user', content: `课程主题:${theme.trim()}` },
],
}),
})
} catch {
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
}
if (!response.ok) {
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
}
const payload = (await response.json().catch(() => null)) as
| { choices?: Array<{ message?: { content?: string } }> }
| null
const raw = payload?.choices?.[0]?.message?.content
if (!raw) {
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
}
const titles = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
return c.json({ titles })
})
app.post('/lessons-from-book', async (c) => {
const body = (await c.req.json().catch(() => null)) as { entries?: unknown } | null
const rawEntries = Array.isArray(body?.entries) ? body!.entries : null
if (!rawEntries || rawEntries.length === 0) {
return c.json({ error: '请提供教材目录。' }, 400)
}
const entries = rawEntries
.map((entry, index) => {
const e = entry as { index?: unknown; title?: unknown; charCount?: unknown }
const title = typeof e?.title === 'string' ? e.title.trim() : ''
const entryIndex = typeof e?.index === 'number' ? e.index : index
const charCount = typeof e?.charCount === 'number' ? e.charCount : 0
return { index: entryIndex, title, charCount }
})
.filter((entry) => entry.title !== '')
if (entries.length === 0) {
return c.json({ error: '目录为空或格式无效。' }, 400)
}
if (!apiKey) {
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
}
const tocText = entries
.map((entry) => `${entry.index}\t${entry.title}\t约${entry.charCount}`)
.join('\n')
let response: Response
try {
response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content:
'你是教学设计专家。下面是一本教材的目录,每行为「序号<TAB>标题<TAB>正文体量」。' +
'请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' +
'每个课题对应一个或多个目录条目(用其序号表示)。' +
'仅输出 JSON不要解释、不要代码块围栏形如' +
'{"lessons":[{"title":"项目名——课时任务","sourceIndexes":[0]}]}。',
},
{ role: 'user', content: tocText },
],
}),
})
} catch {
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
}
if (!response.ok) {
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
}
const payload = (await response.json().catch(() => null)) as
| { choices?: Array<{ message?: { content?: string } }> }
| null
const raw = payload?.choices?.[0]?.message?.content
if (!raw) {
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
}
const fenceMatch = raw.trim().match(/^```(?:\w+)?\n([\s\S]*?)\n```\s*$/)
const jsonText = fenceMatch ? fenceMatch[1]! : raw
let parsed: unknown
try {
parsed = JSON.parse(jsonText)
} catch {
return c.json({ error: 'Deepseek 返回的课时格式无法解析。' }, 502)
}
const maxIndex = entries.reduce((max, entry) => Math.max(max, entry.index), 0)
const rawLessons = (parsed as { lessons?: unknown })?.lessons
const lessons = Array.isArray(rawLessons)
? rawLessons
.map((lesson) => {
const l = lesson as { title?: unknown; sourceIndexes?: unknown }
const title = typeof l?.title === 'string' ? l.title.trim() : ''
const sourceIndexes = Array.isArray(l?.sourceIndexes)
? l.sourceIndexes.filter(
(n): n is number => typeof n === 'number' && n >= 0 && n <= maxIndex,
)
: []
return { title, sourceIndexes }
})
.filter((lesson) => lesson.title !== '')
: []
if (lessons.length === 0) {
return c.json({ error: 'Deepseek 未返回有效课时。' }, 502)
}
return c.json({ lessons })
})
return app
}