feat: productionize learning engine and classroom

This commit is contained in:
inman
2026-08-16 21:44:52 +08:00
parent 2d04197f3f
commit ba35adfbfa
124 changed files with 9071 additions and 796 deletions

View File

@@ -47,6 +47,10 @@ import { generateCourseFramework, type FrameworkAICallFn } from './generate-fram
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,
@@ -119,7 +123,11 @@ function trackRun(
try {
await fn(controller.signal);
} catch (error) {
if (isAbortError(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 {
@@ -145,6 +153,7 @@ function trackRun(
/** 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
@@ -158,6 +167,7 @@ export function runCourseFrameworkGeneration(courseId: string, baseUrl: string):
*/
export function runCourseModuleGeneration(courseId: string, baseUrl: string): Promise<void> {
return trackRun(`${courseId}:modules`, courseId, async (signal) => {
await assertLearningCourseMutable(courseId);
await runModulePhase(courseId, baseUrl, { signal });
});
}
@@ -171,6 +181,7 @@ export function runCourseGenerationJob(
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 });
});
}
@@ -178,6 +189,7 @@ export function runCourseGenerationJob(
/** 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')) {
@@ -187,6 +199,7 @@ export function regenerateCourseFramework(courseId: string, baseUrl: string): Pr
framework: undefined,
modules: [],
publication: undefined,
externalPublication: undefined,
});
await runFrameworkPhase(courseId, baseUrl, signal);
});
@@ -195,7 +208,7 @@ export function regenerateCourseFramework(courseId: string, baseUrl: string): Pr
// ─── Phase implementations ─────────────────────────────────────────────────
/** Resolve the Layer-1 model + build the framework aiCall closure. */
async function buildFrameworkAiCall(): Promise<FrameworkAICallFn> {
async function buildFrameworkAiCall(signal: AbortSignal): Promise<FrameworkAICallFn> {
const {
model: languageModel,
modelInfo,
@@ -221,6 +234,7 @@ async function buildFrameworkAiCall(): Promise<FrameworkAICallFn> {
],
maxOutputTokens: modelInfo?.outputWindow ?? 8192,
maxRetries: 0,
abortSignal: signal,
},
'course-framework',
undefined,
@@ -272,7 +286,7 @@ async function runFrameworkPhase(
await updateCourseRecord(courseId, { status: 'framework_generating', error: undefined });
log.info(`Course ${courseId}: generating framework (Layer 1)`);
const aiCall = await buildFrameworkAiCall();
const aiCall = await buildFrameworkAiCall(signal);
const researchContext = await runFrameworkResearch(record, aiCall);
if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError');
@@ -286,6 +300,8 @@ async function runFrameworkPhase(
aiCall,
);
if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError');
if (!result.success || !result.data) {
throw new Error(`课程框架生成失败:${result.error ?? 'unknown'}`);
}
@@ -441,14 +457,15 @@ async function generateModule(
...(record.enableWebSearch ? { enableWebSearch: true } : {}),
...(record.enableImageGeneration ? { enableImageGeneration: true } : {}),
...(record.enableVideoGeneration ? { enableVideoGeneration: true } : {}),
// Published large-course modules are frozen bundles: every narration cue
// must have durable audio bytes before the publish gate can pass.
enableTTS: true,
interactiveMode: true,
enableTTS: record.enableTTS ?? true,
interactiveMode: record.interactiveMode ?? true,
...(record.taskEngineMode ? { taskEngineMode: true } : {}),
};
const jobId = nanoid(10);
await createClassroomGenerationJob(jobId, input);
await createClassroomGenerationJob(jobId, input, {
ownerPrincipalId: record.ownerPrincipalId,
});
const jobs = moduleJobsByCourse.get(courseId) ?? new Set<string>();
jobs.add(jobId);
moduleJobsByCourse.set(courseId, jobs);
@@ -527,12 +544,19 @@ async function finalizeCourse(courseId: string, signal: AbortSignal): Promise<vo
}
/** Create a course record (does not start generation). */
export async function createCourse(courseId: string, input: CourseCreateInput): Promise<void> {
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: true,
enableTTS: input.enableTTS ?? true,
interactiveMode: input.interactiveMode,
taskEngineMode: input.taskEngineMode,
pdfText: input.pdfContent?.text,
});
}