feat: add concurrent batch lesson generation
This commit is contained in:
@@ -2,6 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
|
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||||||
import * as booksApi from '../services/booksApi'
|
import * as booksApi from '../services/booksApi'
|
||||||
|
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
||||||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||||||
import WorkspaceView from './WorkspaceView.vue'
|
import WorkspaceView from './WorkspaceView.vue'
|
||||||
|
|
||||||
@@ -16,6 +17,32 @@ function mockBook(data = createEmptyBook()): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve: (value: T) => void
|
||||||
|
reject: (reason?: unknown) => void
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve
|
||||||
|
reject = promiseReject
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedLesson(topic: string): booksApi.GenerateResult {
|
||||||
|
return {
|
||||||
|
filename: `${topic}.md`,
|
||||||
|
markdown: [
|
||||||
|
`# ${topic} 教学设计`,
|
||||||
|
'|:---|:---|',
|
||||||
|
`| **课题** | **${topic}** |`,
|
||||||
|
'| **课时** | 1课时(40分钟) |',
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('WorkspaceView', () => {
|
describe('WorkspaceView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@@ -87,6 +114,68 @@ describe('WorkspaceView', () => {
|
|||||||
expect(wrapper.text()).not.toContain('封面')
|
expect(wrapper.text()).not.toContain('封面')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('batch generates up to three lessons concurrently and keeps outline order', async () => {
|
||||||
|
mockBook()
|
||||||
|
const requests = new Map<string, ReturnType<typeof deferred<booksApi.GenerateResult>>>()
|
||||||
|
vi.mocked(booksApi.generateLesson).mockImplementation((topic) => {
|
||||||
|
const request = deferred<booksApi.GenerateResult>()
|
||||||
|
requests.set(topic, request)
|
||||||
|
return request.promise
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="batch-generate"]').trigger('click')
|
||||||
|
wrapper.getComponent(BatchGenerateDialog).vm.$emit('start', [
|
||||||
|
'第一课',
|
||||||
|
'第二课',
|
||||||
|
'第三课',
|
||||||
|
'第四课',
|
||||||
|
'第五课',
|
||||||
|
])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(booksApi.generateLesson).toHaveBeenCalledTimes(3)
|
||||||
|
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(1, '第一课')
|
||||||
|
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(2, '第二课')
|
||||||
|
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(3, '第三课')
|
||||||
|
|
||||||
|
requests.get('第三课')!.resolve(generatedLesson('第三课'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(booksApi.generateLesson).toHaveBeenCalledTimes(4)
|
||||||
|
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(4, '第四课')
|
||||||
|
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([])
|
||||||
|
|
||||||
|
requests.get('第四课')!.resolve(generatedLesson('第四课'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(booksApi.generateLesson).toHaveBeenCalledTimes(5)
|
||||||
|
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(5, '第五课')
|
||||||
|
|
||||||
|
requests.get('第一课')!.resolve(generatedLesson('第一课'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual(['第一课'])
|
||||||
|
|
||||||
|
requests.get('第二课')!.resolve(generatedLesson('第二课'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([
|
||||||
|
'第一课',
|
||||||
|
'第二课',
|
||||||
|
'第三课',
|
||||||
|
'第四课',
|
||||||
|
])
|
||||||
|
|
||||||
|
requests.get('第五课')!.resolve(generatedLesson('第五课'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([
|
||||||
|
'第一课',
|
||||||
|
'第二课',
|
||||||
|
'第三课',
|
||||||
|
'第四课',
|
||||||
|
'第五课',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('clears the lessons after confirmation', async () => {
|
it('clears the lessons after confirmation', async () => {
|
||||||
const data = createEmptyBook()
|
const data = createEmptyBook()
|
||||||
data.designs.push(createEmptyTeachingDesign('1.md'))
|
data.designs.push(createEmptyTeachingDesign('1.md'))
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import PrintBook from './PrintBook.vue'
|
|||||||
import UploadDropzone from './UploadDropzone.vue'
|
import UploadDropzone from './UploadDropzone.vue'
|
||||||
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
||||||
|
|
||||||
|
const BATCH_GENERATE_CONCURRENCY = 3
|
||||||
|
|
||||||
const props = defineProps<{ bookId: string }>()
|
const props = defineProps<{ bookId: string }>()
|
||||||
|
|
||||||
defineEmits<{ back: [] }>()
|
defineEmits<{ back: [] }>()
|
||||||
@@ -35,6 +37,7 @@ const {
|
|||||||
updateDesign,
|
updateDesign,
|
||||||
clearBook,
|
clearBook,
|
||||||
generateLesson,
|
generateLesson,
|
||||||
|
generateLessons,
|
||||||
regenerateLesson,
|
regenerateLesson,
|
||||||
} = useTeachingBook(props.bookId)
|
} = useTeachingBook(props.bookId)
|
||||||
|
|
||||||
@@ -153,15 +156,19 @@ async function handleBatchStart(topics: string[]): Promise<void> {
|
|||||||
batchTotal.value = topics.length
|
batchTotal.value = topics.length
|
||||||
batchError.value = null
|
batchError.value = null
|
||||||
|
|
||||||
for (const topic of topics) {
|
const result = await generateLessons(topics, {
|
||||||
if (batchCancelled.value) break
|
concurrency: BATCH_GENERATE_CONCURRENCY,
|
||||||
|
isCancelled: () => batchCancelled.value,
|
||||||
|
onTopicStart: (topic) => {
|
||||||
batchCurrentTopic.value = topic
|
batchCurrentTopic.value = topic
|
||||||
const result = await generateLesson(topic)
|
},
|
||||||
|
onLessonComplete: (count) => {
|
||||||
|
batchDone.value += count
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
batchError.value = result.message
|
batchError.value = result.message
|
||||||
break
|
|
||||||
}
|
|
||||||
batchDone.value++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
batchRunning.value = false
|
batchRunning.value = false
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ export type LoadStatus = 'loading' | 'loaded' | 'error'
|
|||||||
|
|
||||||
export type GenerateLessonResult = { ok: true } | { ok: false; message: string }
|
export type GenerateLessonResult = { ok: true } | { ok: false; message: string }
|
||||||
|
|
||||||
|
export type BatchGenerateLessonResult =
|
||||||
|
| { ok: true; completed: number }
|
||||||
|
| { ok: false; completed: number; message: string }
|
||||||
|
|
||||||
|
export interface BatchGenerateLessonOptions {
|
||||||
|
concurrency?: number
|
||||||
|
isCancelled?: () => boolean
|
||||||
|
onTopicStart?: (topic: string) => void
|
||||||
|
onLessonComplete?: (count: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportResult {
|
export interface ImportResult {
|
||||||
imported: number
|
imported: number
|
||||||
failed: Array<{ filename: string; message: string }>
|
failed: Array<{ filename: string; message: string }>
|
||||||
@@ -43,6 +54,10 @@ export interface TeachingBookStore {
|
|||||||
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
|
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
|
||||||
clearBook: () => void
|
clearBook: () => void
|
||||||
generateLesson: (topic: string) => Promise<GenerateLessonResult>
|
generateLesson: (topic: string) => Promise<GenerateLessonResult>
|
||||||
|
generateLessons: (
|
||||||
|
topics: readonly string[],
|
||||||
|
options?: BatchGenerateLessonOptions,
|
||||||
|
) => Promise<BatchGenerateLessonResult>
|
||||||
regenerateLesson: (id: DesignId) => Promise<GenerateLessonResult>
|
regenerateLesson: (id: DesignId) => Promise<GenerateLessonResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +257,64 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateLessons(
|
||||||
|
topics: readonly string[],
|
||||||
|
options: BatchGenerateLessonOptions = {},
|
||||||
|
): Promise<BatchGenerateLessonResult> {
|
||||||
|
const concurrency = Math.max(1, options.concurrency ?? 3)
|
||||||
|
const workerCount = Math.min(concurrency, topics.length)
|
||||||
|
const results = new Array<TeachingDesign | undefined>(topics.length)
|
||||||
|
let nextStartIndex = 0
|
||||||
|
let nextAppendIndex = 0
|
||||||
|
let appendedCount = 0
|
||||||
|
let firstError: string | null = null
|
||||||
|
|
||||||
|
function appendReadyLessons(): void {
|
||||||
|
let readyCount = 0
|
||||||
|
|
||||||
|
while (nextAppendIndex < results.length) {
|
||||||
|
const design = results[nextAppendIndex]
|
||||||
|
if (!design) break
|
||||||
|
book.value.designs.push(design)
|
||||||
|
book.value.selectedId = design.id
|
||||||
|
nextAppendIndex++
|
||||||
|
readyCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readyCount > 0) {
|
||||||
|
appendedCount += readyCount
|
||||||
|
touch()
|
||||||
|
options.onLessonComplete?.(readyCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorker(): Promise<void> {
|
||||||
|
while (!firstError && !options.isCancelled?.()) {
|
||||||
|
const index = nextStartIndex
|
||||||
|
if (index >= topics.length) return
|
||||||
|
|
||||||
|
nextStartIndex++
|
||||||
|
const topic = topics[index]!
|
||||||
|
options.onTopicStart?.(topic)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await booksApi.generateLesson(topic)
|
||||||
|
results[index] = parseTeachingDesign(result.filename, result.markdown)
|
||||||
|
appendReadyLessons()
|
||||||
|
} catch (error) {
|
||||||
|
firstError = error instanceof Error ? error.message : '生成失败。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
|
||||||
|
appendReadyLessons()
|
||||||
|
|
||||||
|
return firstError
|
||||||
|
? { ok: false, completed: appendedCount, message: firstError }
|
||||||
|
: { ok: true, completed: appendedCount }
|
||||||
|
}
|
||||||
|
|
||||||
async function regenerateLesson(id: DesignId): Promise<GenerateLessonResult> {
|
async function regenerateLesson(id: DesignId): Promise<GenerateLessonResult> {
|
||||||
const existing = book.value.designs.find((d) => d.id === id)
|
const existing = book.value.designs.find((d) => d.id === id)
|
||||||
if (!existing) return { ok: false, message: '找不到该教案。' }
|
if (!existing) return { ok: false, message: '找不到该教案。' }
|
||||||
@@ -282,6 +355,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
updateDesign,
|
updateDesign,
|
||||||
clearBook,
|
clearBook,
|
||||||
generateLesson,
|
generateLesson,
|
||||||
|
generateLessons,
|
||||||
regenerateLesson,
|
regenerateLesson,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user