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,
});
}

View File

@@ -17,7 +17,8 @@ import { buildCourseModuleOutputDigest } from './module-digest';
const log = createLogger('CourseStore');
export const COURSE_FRAMEWORK_DIR =
process.env.COURSE_FRAMEWORK_DIR ?? path.join(process.cwd(), 'data', 'course-frameworks');
process.env.COURSE_FRAMEWORK_DIR ??
path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'course-frameworks');
export function isValidCourseId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id);
@@ -146,10 +147,13 @@ export async function createCourseRecord(
courseId: string,
requirement: string,
options: {
ownerPrincipalId?: string;
enableWebSearch?: boolean;
enableImageGeneration?: boolean;
enableVideoGeneration?: boolean;
enableTTS?: boolean;
interactiveMode?: boolean;
taskEngineMode?: boolean;
pdfText?: string;
} = {},
): Promise<CourseRecord> {
@@ -159,10 +163,15 @@ export async function createCourseRecord(
status: 'queued',
requirement,
modules: [],
...(options.ownerPrincipalId ? { ownerPrincipalId: options.ownerPrincipalId } : {}),
...(options.enableWebSearch ? { enableWebSearch: true } : {}),
...(options.enableImageGeneration ? { enableImageGeneration: true } : {}),
...(options.enableVideoGeneration ? { enableVideoGeneration: true } : {}),
...(options.enableTTS ? { enableTTS: true } : {}),
// Persist the explicit false value: finalization uses it to decide whether
// narration bytes are mandatory or intentionally omitted.
enableTTS: options.enableTTS ?? true,
interactiveMode: options.interactiveMode ?? true,
...(options.taskEngineMode ? { taskEngineMode: true } : {}),
...(options.pdfText ? { pdfText: options.pdfText } : {}),
createdAt: now,
updatedAt: now,
@@ -306,6 +315,7 @@ export async function invalidateCourseModulesFrom(
...existing,
modules,
publication: undefined,
externalPublication: undefined,
status: 'generating',
error: undefined,
updatedAt: new Date().toISOString(),

View File

@@ -157,6 +157,8 @@ export interface CoursePublicationReceipt {
export interface CourseRecord {
id: string;
status: CourseStatus;
/** Works account that owns runtime-created courses; absent on legacy ops records. */
ownerPrincipalId?: string;
/** Original requirement — persisted so unfinished modules can be re-run. */
requirement: string;
/** Course-level failure reason (status === 'failed'). */
@@ -167,11 +169,20 @@ export interface CourseRecord {
modules: CourseModuleRecord[];
/** Exact remote/local publish result; private to ops and invalidated on regeneration. */
publication?: CoursePublicationReceipt;
/** Works catalog receipt for the aggregate package (new Learning Ops path). */
externalPublication?: {
courseId: string;
status: 'ready' | 'published';
publishedAt: string;
};
/** Ops flags forwarded to each module's classroom generation. */
enableWebSearch?: boolean;
enableImageGeneration?: boolean;
enableVideoGeneration?: boolean;
enableTTS?: boolean;
/** Module generation affordances forwarded to the original classroom runner. */
interactiveMode?: boolean;
taskEngineMode?: boolean;
/** PDF text used as research context by the Layer-1 framework agent. */
pdfText?: string;
createdAt: string;
@@ -185,6 +196,8 @@ export interface CourseCreateInput {
enableImageGeneration?: boolean;
enableVideoGeneration?: boolean;
enableTTS?: boolean;
interactiveMode?: boolean;
taskEngineMode?: boolean;
pdfContent?: { text: string; images: string[] };
}