Files

563 lines
21 KiB
TypeScript

// 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<string, Promise<void>>();
const runningControllers = new Map<string, AbortController>();
/** courseId → classroom job ids currently running for that course (for cancel). */
const moduleJobsByCourse = new Map<string, Set<string>>();
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<void>,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<FrameworkAICallFn> {
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<string | undefined> {
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<void> {
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<CourseModuleRecord, 'index' | 'title'> & { 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<void> {
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<boolean> {
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<string>();
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<void> {
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<void> {
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,
});
}