// Large-course mode — two-layer course generation runner. // // The pipeline is split into two user-visible phases: // 1. Framework phase (Layer 1): generate the course framework (optional // web-search research first). Ends in `framework_ready` — the operator // REVIEWS the framework and confirms before any module is generated. // 2. Module phase (Layer 2): for each framework module (in order), run the // EXISTING single-courseware generation agent via the classroom job // infrastructure (`createClassroomGenerationJob` + // `runClassroomGenerationJob`) and record per-module state. A failed // module pauses later modules. Regenerating module N atomically // invalidates N..end, then rebuilds that continuity chain in order. The // whole course can be cancelled; running state is persisted so a restarted // process can resume by re-running pending/failed modules (read-time // reconciliation in the store). // // The runner itself is in-memory (like the classroom job runner); the course // record on disk is the source of truth for resumability. import { nanoid } from 'nanoid'; import { createLogger } from '@/lib/logger'; import { callLLM } from '@/lib/ai/llm'; import { isProviderKeyRequired } from '@/lib/ai/providers'; import { resolveModel } from '@/lib/server/resolve-model'; import { resolveClassroomWebSearchConfig } from '@/lib/server/web-search-config'; import { buildSearchQuery } from '@/lib/server/search-query-builder'; import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search'; import { readClassroom } from '@/lib/server/classroom-storage'; import { createClassroomGenerationJob, readClassroomGenerationJob, } from '@/lib/server/classroom-job-store'; import { cancelClassroomGenerationJob, runClassroomGenerationJob, } from '@/lib/server/classroom-job-runner'; import type { GenerateClassroomInput } from '@/lib/server/classroom-generation'; import { createCourseRecord, invalidateCourseModulesFrom, readCourseRecord, readCourseRecordReconciled, updateCourseModule, updateCourseRecord, } from './store'; import { generateCourseFramework, type FrameworkAICallFn } from './generate-framework'; import { buildModuleRequirement, emptyModuleRecord } from './types'; import { buildCourseModuleOutputDigest, formatPreviousModuleContext } from './module-digest'; import { acquireCourseGenerationActivity } from './publish-state'; import { assertLearningCourseMutable, FrozenLearningCourseMutationError, } from '@/lib/makelore-course/immutability'; import type { CourseCreateInput, CourseModuleContinuityInputRef, CourseModuleOutputDigest, CourseModuleRecord, CourseRecord, } from './types'; const log = createLogger('CourseRunner'); function isAbortError(error: unknown): boolean { return ( (error instanceof DOMException && error.name === 'AbortError') || (error instanceof Error && error.name === 'AbortError') ); } /** Runs tracked by composite key: `${courseId}` (framework) | `${courseId}:modules` * (module pipeline) | `${courseId}:module:${index}` (regeneration cascade). */ const runningRuns = new Map>(); const runningControllers = new Map(); /** courseId → classroom job ids currently running for that course (for cancel). */ const moduleJobsByCourse = new Map>(); export function isCourseGenerationRunning(courseId: string): boolean { for (const key of runningRuns.keys()) { if (key === courseId || key.startsWith(`${courseId}:`)) return true; } return false; } /** * Cancel a course's generation: aborts the framework/module runs and * cooperatively cancels the in-flight classroom job of the current module. * Already-succeeded modules are kept. */ export function cancelCourseGenerationJob(courseId: string): boolean { let cancelled = false; for (const key of runningControllers.keys()) { if (key !== courseId && !key.startsWith(`${courseId}:`)) continue; runningControllers.get(key)?.abort(); cancelled = true; } const jobs = moduleJobsByCourse.get(courseId); if (jobs) { for (const jobId of jobs) cancelClassroomGenerationJob(jobId); } return cancelled; } /** Wrap one run in the running/controller maps; returns the run promise. */ function trackRun( key: string, courseId: string, fn: (signal: AbortSignal) => Promise, ): Promise { const existing = runningRuns.get(key); if (existing) return existing; for (const [activeKey] of runningRuns) { if (activeKey !== courseId && !activeKey.startsWith(`${courseId}:`)) continue; throw new Error(`Course ${courseId} already has an active generation run (${activeKey})`); } const releaseCourseActivity = acquireCourseGenerationActivity(courseId); const controller = new AbortController(); runningControllers.set(key, controller); const jobPromise = (async () => { try { await fn(controller.signal); } catch (error) { if (error instanceof FrozenLearningCourseMutationError) { // An immutable-course preflight is not a failed generation attempt and // must not mutate the already-frozen source record to `failed`. throw error; } else if (isAbortError(error)) { log.info(`Course run cancelled: ${courseId} (${key})`); await updateCourseRecord(courseId, { status: 'cancelled' }).catch(() => {}); } else { const message = error instanceof Error ? error.message : String(error); log.error(`Course run failed (${courseId}, ${key}):`, error); await updateCourseRecord(courseId, { status: 'failed', error: message }).catch(() => {}); } } finally { runningRuns.delete(key); runningControllers.delete(key); releaseCourseActivity(); const jobs = moduleJobsByCourse.get(courseId); if (jobs && jobs.size === 0) moduleJobsByCourse.delete(courseId); } })(); runningRuns.set(key, jobPromise); return jobPromise; } // ─── Public entry points ─────────────────────────────────────────────────── /** Phase 1: generate the course framework only. Ends `framework_ready`. */ export function runCourseFrameworkGeneration(courseId: string, baseUrl: string): Promise { return trackRun(courseId, courseId, async (signal) => { await assertLearningCourseMutable(courseId); const record = await readCourseRecordReconciled(courseId); if (!record) throw new Error(`Course not found: ${courseId}`); if (record.framework) return; // nothing to do — framework already exists await runFrameworkPhase(courseId, baseUrl, signal); }); } /** * Phase 2: generate every pending/failed module (skips succeeded). This is the * operator's explicit confirmation action after reviewing the framework. */ export function runCourseModuleGeneration(courseId: string, baseUrl: string): Promise { return trackRun(`${courseId}:modules`, courseId, async (signal) => { await assertLearningCourseMutable(courseId); await runModulePhase(courseId, baseUrl, { signal }); }); } /** Regenerate one module and every downstream module that depends on it. */ export function runCourseGenerationJob( courseId: string, baseUrl: string, options: { onlyModuleIndex?: number } = {}, ): Promise { const index = options.onlyModuleIndex; if (index === undefined) throw new Error('runCourseGenerationJob requires onlyModuleIndex'); return trackRun(`${courseId}:module:${index}`, courseId, async (signal) => { await assertLearningCourseMutable(courseId); await runModulePhase(courseId, baseUrl, { onlyModuleIndex: index, signal }); }); } /** Re-run the framework phase from scratch (resets module state). */ export function regenerateCourseFramework(courseId: string, baseUrl: string): Promise { return trackRun(courseId, courseId, async (signal) => { await assertLearningCourseMutable(courseId); const record = await readCourseRecordReconciled(courseId); if (!record) throw new Error(`Course not found: ${courseId}`); if (record.modules.some((m) => m.status === 'generating')) { throw new Error('模块正在生成中,无法重新生成框架'); } await updateCourseRecord(courseId, { framework: undefined, modules: [], publication: undefined, externalPublication: undefined, }); await runFrameworkPhase(courseId, baseUrl, signal); }); } // ─── Phase implementations ───────────────────────────────────────────────── /** Resolve the Layer-1 model + build the framework aiCall closure. */ async function buildFrameworkAiCall(signal: AbortSignal): Promise { const { model: languageModel, modelInfo, providerId, apiKey, thinkingConfig, } = await resolveModel({ stage: 'course-framework' }); if (isProviderKeyRequired(providerId) && !apiKey) { throw new Error( `No API key configured for provider "${providerId}". ` + `Set the appropriate key in .env.local or server-providers.yml.`, ); } return async (systemPrompt, userPrompt) => { const result = await callLLM( { model: languageModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], maxOutputTokens: modelInfo?.outputWindow ?? 8192, maxRetries: 0, abortSignal: signal, }, 'course-framework', undefined, thinkingConfig, ); return result.text; }; } /** Optional web-search research for the Layer-1 framework agent (graceful degradation). */ async function runFrameworkResearch( record: { enableWebSearch?: boolean; pdfText?: string; requirement: string }, aiCall: FrameworkAICallFn, ): Promise { if (!record.enableWebSearch) return undefined; const webSearchConfig = resolveClassroomWebSearchConfig({}); if (!webSearchConfig) { log.warn('enableWebSearch is true but no web search API key configured; skipping research'); return undefined; } try { const searchQuery = await buildSearchQuery(record.requirement, record.pdfText, aiCall); const searchResult = await searchWeb({ providerId: webSearchConfig.providerId, query: searchQuery.query, apiKey: webSearchConfig.apiKey, baseUrl: webSearchConfig.baseUrl, baiduSubSources: webSearchConfig.baiduSubSources, claudeModelId: webSearchConfig.claudeModelId, }); const context = formatSearchResultsAsContext(searchResult); if (context) log.info(`Course research returned ${searchResult.sources.length} sources`); return context; } catch (error) { log.warn('Course framework research failed, continuing without search context:', error); return undefined; } } /** Layer 1 — framework generation; ends with status `framework_ready`. */ async function runFrameworkPhase( courseId: string, baseUrl: string, signal: AbortSignal, ): Promise { const record = await readCourseRecord(courseId); if (!record) throw new Error(`Course not found: ${courseId}`); await updateCourseRecord(courseId, { status: 'framework_generating', error: undefined }); log.info(`Course ${courseId}: generating framework (Layer 1)`); const aiCall = await buildFrameworkAiCall(signal); const researchContext = await runFrameworkResearch(record, aiCall); if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError'); const result = await generateCourseFramework( { requirement: record.requirement, pdfText: record.pdfText, researchContext, }, aiCall, ); if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError'); if (!result.success || !result.data) { throw new Error(`课程框架生成失败:${result.error ?? 'unknown'}`); } const { data: framework } = result; const modules: CourseModuleRecord[] = framework.modules.map(emptyModuleRecord); await updateCourseRecord(courseId, { framework, modules, status: 'framework_ready', error: undefined, }); log.info( `Course ${courseId}: framework ready — ${framework.modules.length} modules, awaiting confirmation`, ); } export interface RunModulePhaseOptions { onlyModuleIndex?: number; signal?: AbortSignal; } async function buildPreviousModuleContext( courseId: string, record: CourseRecord, currentModuleIndex: number, ): Promise<{ text: string; refs: CourseModuleContinuityInputRef[] }> { const previousModules: Array< Pick & { outputDigest: CourseModuleOutputDigest } > = []; for (const moduleRec of [...record.modules].sort((a, b) => a.index - b.index)) { if (moduleRec.index >= currentModuleIndex) break; if (moduleRec.status !== 'succeeded' || !moduleRec.classroomId) { throw new Error( `Cannot generate module ${currentModuleIndex}: prior module ${moduleRec.index} has no successful actual output`, ); } let { outputDigest } = moduleRec; if (!outputDigest) { const classroom = await readClassroom(moduleRec.classroomId).catch(() => null); if (!classroom) { throw new Error( `Cannot generate module ${currentModuleIndex}: prior classroom ${moduleRec.classroomId} is unavailable`, ); } outputDigest = buildCourseModuleOutputDigest(classroom); await updateCourseModule(courseId, moduleRec.index, { outputDigest }); } previousModules.push({ index: moduleRec.index, title: moduleRec.title, outputDigest, }); } return { text: formatPreviousModuleContext(previousModules), refs: previousModules.map((module) => ({ moduleIndex: module.index, classroomId: module.outputDigest.classroomId, semanticHash: module.outputDigest.semanticHash, })), }; } /** Layer 2 — module generation, one module at a time via classroom jobs. */ async function runModulePhase( courseId: string, baseUrl: string, options: RunModulePhaseOptions, ): Promise { const { onlyModuleIndex, signal = new AbortController().signal } = options; let record = await readCourseRecordReconciled(courseId); if (!record) throw new Error(`Course not found: ${courseId}`); if (!record.framework) { throw new Error('课程框架尚未生成,请先生成框架'); } if (onlyModuleIndex !== undefined) { // Clear the target and every dependent result in one persisted write before // starting the target job. This immediately takes a completed course out of // `completed` and prevents stale downstream classrooms from being treated // as publishable while the cascade is running or paused after a failure. record = await invalidateCourseModulesFrom(courseId, onlyModuleIndex); const moduleRec = record.modules.find((m) => m.index === onlyModuleIndex); if (!moduleRec) throw new Error(`Module ${onlyModuleIndex} not found`); const targetSucceeded = await generateModule(courseId, moduleRec, baseUrl, signal); if (targetSucceeded) { const refreshed = await readCourseRecordReconciled(courseId); for (const laterModule of refreshed?.modules ?? []) { if (laterModule.index <= onlyModuleIndex) continue; const succeeded = await generateModule(courseId, laterModule, baseUrl, signal); if (!succeeded) break; } } await finalizeCourse(courseId, signal); return; } // Full module pipeline (confirmation / resume): mark generating explicitly so // the status survives the brief all-pending window before module 1 starts. await updateCourseRecord(courseId, { status: 'generating', error: undefined }); record = await readCourseRecordReconciled(courseId); if (!record?.framework) throw new Error(`Course ${courseId}: framework generation failed`); for (const moduleRec of record.modules) { if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError'); if (moduleRec.status === 'succeeded') continue; const succeeded = await generateModule(courseId, moduleRec, baseUrl, signal); if (!succeeded) break; } await finalizeCourse(courseId, signal); } /** Generate one module's courseware by reusing the single-courseware agent. */ async function generateModule( courseId: string, moduleRec: CourseModuleRecord, baseUrl: string, signal: AbortSignal, ): Promise { const record = await readCourseRecord(courseId); const framework = record?.framework; if (!record || !framework) { throw new Error( `Course ${courseId}: framework missing, cannot generate module ${moduleRec.index}`, ); } const spec = framework.modules.find((m) => m.index === moduleRec.index); if (!spec) { throw new Error(`Course ${courseId}: module ${moduleRec.index} not found in framework`); } const generationPrompt = moduleRec.generationPromptSnapshot?.trim() || spec.generationPrompt; const generationSpec = generationPrompt === spec.generationPrompt ? spec : { ...spec, generationPrompt }; const previousModuleContext = await buildPreviousModuleContext(courseId, record, moduleRec.index); const requirement = buildModuleRequirement( framework, generationSpec, framework.modules.length, record.requirement, previousModuleContext.text, ); const input: GenerateClassroomInput = { requirement, ...(record.enableWebSearch ? { enableWebSearch: true } : {}), ...(record.enableImageGeneration ? { enableImageGeneration: true } : {}), ...(record.enableVideoGeneration ? { enableVideoGeneration: true } : {}), enableTTS: record.enableTTS ?? true, interactiveMode: record.interactiveMode ?? true, ...(record.taskEngineMode ? { taskEngineMode: true } : {}), }; const jobId = nanoid(10); await createClassroomGenerationJob(jobId, input, { ownerPrincipalId: record.ownerPrincipalId, }); const jobs = moduleJobsByCourse.get(courseId) ?? new Set(); jobs.add(jobId); moduleJobsByCourse.set(courseId, jobs); await updateCourseModule(courseId, moduleRec.index, { status: 'generating', jobId, generationPromptSnapshot: generationPrompt, continuityInputRefs: previousModuleContext.refs, startedAt: new Date().toISOString(), error: undefined, completedAt: undefined, }); if (signal.aborted) { await updateCourseModule(courseId, moduleRec.index, { status: 'pending', jobId: undefined }); jobs.delete(jobId); throw new DOMException('Course generation cancelled', 'AbortError'); } log.info( `Course ${courseId}: generating module ${moduleRec.index}/${framework.modules.length} "${spec.title}" (job ${jobId})`, ); await runClassroomGenerationJob(jobId, input, baseUrl); jobs.delete(jobId); const job = await readClassroomGenerationJob(jobId); if (job?.status === 'succeeded' && job.result?.classroomId) { const classroom = await readClassroom(job.result.classroomId).catch(() => null); if (!classroom) { const error = `Generated classroom ${job.result.classroomId} could not be loaded for continuity digest`; log.error(`Course ${courseId}: module ${moduleRec.index} failed — ${error}`); await updateCourseModule(courseId, moduleRec.index, { status: 'failed', error, jobId: undefined, }); return false; } const outputDigest = buildCourseModuleOutputDigest(classroom); await updateCourseModule(courseId, moduleRec.index, { status: 'succeeded', classroomId: job.result.classroomId, jobId: undefined, outputDigest, completedAt: job.completedAt ?? new Date().toISOString(), }); log.info( `Course ${courseId}: module ${moduleRec.index} succeeded — classroom ${job.result.classroomId} (${job.result.scenesCount} scenes)`, ); return true; } else if (job?.status === 'cancelled') { await updateCourseModule(courseId, moduleRec.index, { status: 'pending', jobId: undefined }); return false; } else { const error = job?.error ?? 'Module generation failed (unknown error)'; log.warn(`Course ${courseId}: module ${moduleRec.index} failed — ${error}`); await updateCourseModule(courseId, moduleRec.index, { status: 'failed', error, jobId: undefined, }); return false; } } async function finalizeCourse(courseId: string, signal: AbortSignal): Promise { if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError'); const record = await readCourseRecord(courseId); if (!record) return; if (record.modules.every((m) => m.status === 'succeeded')) { await updateCourseRecord(courseId, { status: 'completed', error: undefined }); log.info(`Course ${courseId}: completed — ${record.modules.length} modules`); } } /** Create a course record (does not start generation). */ export async function createCourse( courseId: string, input: CourseCreateInput, ownership?: { ownerPrincipalId?: string }, ): Promise { await createCourseRecord(courseId, input.requirement, { ownerPrincipalId: ownership?.ownerPrincipalId, enableWebSearch: input.enableWebSearch, enableImageGeneration: input.enableImageGeneration, enableVideoGeneration: input.enableVideoGeneration, enableTTS: input.enableTTS ?? true, interactiveMode: input.interactiveMode, taskEngineMode: input.taskEngineMode, pdfText: input.pdfContent?.text, }); }