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>
This commit is contained in:
2026-06-24 05:07:19 -06:00
parent b18bde8d2a
commit a427bba8c8
19 changed files with 1101 additions and 48 deletions

View File

@@ -160,4 +160,149 @@ describe('generate route', () => {
expect(res.status).toBe(502)
})
it('does not add content to the prompt when topic is sent alone', async () => {
let sentBody = ''
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
sentBody = init.body ?? ''
return new Response(
JSON.stringify({ choices: [{ message: { content: '# x 教学设计' } }] }),
{ status: 200 },
)
}) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
await app.request('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
})
expect(sentBody).not.toContain('作为编写依据')
expect(sentBody).toContain('请围绕主题')
expect(sentBody).toContain('生成一份教案。')
})
it('injects chapter content into the prompt when content is provided', async () => {
let sentBody = ''
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
sentBody = init.body ?? ''
return new Response(
JSON.stringify({ choices: [{ message: { content: '# x 教学设计' } }] }),
{ status: 200 },
)
}) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: 'CSS 弹性布局', content: '弹性盒模型的正文内容片段' }),
})
expect(res.status).toBe(200)
expect(sentBody).toContain('作为编写依据')
expect(sentBody).toContain('弹性盒模型的正文内容片段')
})
it('returns 400 when entries are missing on lessons-from-book', async () => {
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate/lessons-from-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
it('parses lessons with sourceIndexes from lessons-from-book', async () => {
globalThis.fetch = mock(async () =>
new Response(
JSON.stringify({
choices: [
{
message: {
content:
'{"lessons":[{"title":"C# 入门——搭建环境","sourceIndexes":[0]},{"title":"变量与类型","sourceIndexes":[1,2]}]}',
},
},
],
}),
{ status: 200 },
),
) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate/lessons-from-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entries: [
{ index: 0, title: '第1章 C# 入门', charCount: 5000 },
{ index: 1, title: '第2章 变量', charCount: 3000 },
{ index: 2, title: '第3章 类型', charCount: 2000 },
],
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
lessons: { title: string; sourceIndexes: number[] }[]
}
expect(body.lessons).toEqual([
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
{ title: '变量与类型', sourceIndexes: [1, 2] },
])
})
it('strips code fences and drops out-of-range sourceIndexes on lessons-from-book', async () => {
globalThis.fetch = mock(async () =>
new Response(
JSON.stringify({
choices: [
{
message: {
content:
'```json\n{"lessons":[{"title":"课时一","sourceIndexes":[0,9]}]}\n```',
},
},
],
}),
{ status: 200 },
),
) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate/lessons-from-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries: [{ index: 0, title: '第1章', charCount: 100 }] }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
lessons: { title: string; sourceIndexes: number[] }[]
}
expect(body.lessons).toEqual([{ title: '课时一', sourceIndexes: [0] }])
})
it('returns 502 when lessons-from-book JSON is malformed', async () => {
globalThis.fetch = mock(async () =>
new Response(
JSON.stringify({ choices: [{ message: { content: '这不是 JSON' } }] }),
{ status: 200 },
),
) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate/lessons-from-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries: [{ index: 0, title: '第1章', charCount: 100 }] }),
})
expect(res.status).toBe(502)
})
})

View File

@@ -37,7 +37,9 @@ 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 } | null
const body = (await c.req.json().catch(() => null)) as
| { topic?: unknown; content?: unknown }
| null
const topic = body?.topic
if (typeof topic !== 'string' || topic.trim() === '') {
@@ -48,6 +50,11 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
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
@@ -62,7 +69,7 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
model: 'deepseek-v4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `请围绕主题"${topic.trim()}"生成一份教案。` },
{ role: 'user', content: userContent },
],
}),
})
@@ -152,5 +159,110 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
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
}