feat: productionize learning engine and classroom
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user