feat: productionize learning engine and classroom

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

No files matched your search

@@ -0,0 +1,55 @@
import type { NextRequest } from 'next/server';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import { LEARNING_GENERATION_CONTRACT_VERSION } from '@/lib/makelore-runtime/generation-request';
import {
controlLargeCourse,
largeCourseControlErrorResponse,
} from '@/lib/makelore-runtime/large-course-control';
import { isValidClassroomJobId, readClassroomGenerationJob } from '@/lib/server/classroom-job-store';
import { cancelClassroomGenerationJob } from '@/lib/server/classroom-job-runner';
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
export async function POST(
request: NextRequest,
context: { params: Promise<{ jobId: string }> },
) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const { jobId } = await context.params;
const owner = request.headers.get('x-owner-user-id')?.trim();
if (!owner || !isValidClassroomJobId(jobId)) {
return Response.json({ error: 'invalid_request' }, { status: 400 });
}
const job = await readClassroomGenerationJob(jobId);
if (job) {
if (job.ownerPrincipalId !== owner) {
return Response.json({ error: 'job_not_found' }, { status: 404 });
}
if (!cancelClassroomGenerationJob(jobId)) {
return Response.json({ error: 'job_not_running' }, { status: 409 });
}
return Response.json({
contractVersion: LEARNING_GENERATION_CONTRACT_VERSION,
jobId,
mode: 'single',
status: 'cancelling',
done: false,
});
}
try {
await controlLargeCourse({
courseId: jobId,
ownerPrincipalId: owner,
action: 'cancel',
baseUrl: buildRequestOrigin(request),
});
return Response.json({
contractVersion: LEARNING_GENERATION_CONTRACT_VERSION,
jobId,
mode: 'large',
status: 'cancelling',
done: false,
});
} catch (error) {
return largeCourseControlErrorResponse(error);
}
}
@@ -0,0 +1,76 @@
import { after, type NextRequest } from 'next/server';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import { LEARNING_GENERATION_CONTRACT_VERSION } from '@/lib/makelore-runtime/generation-request';
import {
controlLargeCourse,
LARGE_COURSE_CONTROL_ACTIONS,
largeCourseControlErrorResponse,
type LargeCourseControlAction,
} from '@/lib/makelore-runtime/large-course-control';
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
const MAX_CONTROL_BODY_BYTES = 8 * 1024;
function parseControlBody(raw: string): {
action: LargeCourseControlAction;
moduleIndex?: number;
} | null {
if (new TextEncoder().encode(raw).byteLength > MAX_CONTROL_BODY_BYTES) return null;
try {
const value = JSON.parse(raw) as unknown;
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (Object.keys(record).some((key) => key !== 'action' && key !== 'moduleIndex')) return null;
const action = record.action as LargeCourseControlAction;
if (typeof record.action !== 'string' || !LARGE_COURSE_CONTROL_ACTIONS.includes(action)) {
return null;
}
if (action === 'module-regenerate') {
if (
typeof record.moduleIndex !== 'number'
|| !Number.isInteger(record.moduleIndex)
|| record.moduleIndex < 1
) return null;
return { action, moduleIndex: record.moduleIndex };
}
if (record.moduleIndex !== undefined) return null;
return { action };
} catch {
return null;
}
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ jobId: string }> },
) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const ownerPrincipalId = request.headers.get('x-owner-user-id')?.trim() ?? '';
const { jobId } = await context.params;
const body = parseControlBody(await request.text());
if (!ownerPrincipalId || !body) {
return Response.json({ error: 'invalid_request' }, { status: 400 });
}
try {
const result = await controlLargeCourse({
courseId: jobId,
ownerPrincipalId,
action: body.action,
moduleIndex: body.moduleIndex,
baseUrl: buildRequestOrigin(request),
});
if (result.run) after(() => result.run!);
return Response.json({
contractVersion: LEARNING_GENERATION_CONTRACT_VERSION,
jobId,
mode: 'large',
action: result.action,
status: result.status,
done: false,
message: result.message,
...(result.moduleIndex === undefined ? {} : { moduleIndex: result.moduleIndex }),
});
} catch (error) {
return largeCourseControlErrorResponse(error);
}
}
@@ -0,0 +1,69 @@
import type { NextRequest } from 'next/server';
import { readCourseRecordReconciled } from '@/lib/course-framework/store';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import { isValidClassroomJobId, readClassroomGenerationJob } from '@/lib/server/classroom-job-store';
import { buildRequestOrigin, readClassroom } from '@/lib/server/classroom-storage';
import {
finalizeLargeLearningCourse,
finalizeSingleLearningCourse,
frozenLearningFinalizeResponse,
} from '@/lib/makelore-course/finalize';
import { FrozenLearningCourseMutationError } from '@/lib/makelore-course/immutability';
export async function POST(request: NextRequest, context: { params: Promise<{ jobId: string }> }) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const { jobId } = await context.params;
const owner = request.headers.get('x-owner-user-id')?.trim();
if (!owner || !isValidClassroomJobId(jobId)) return Response.json({ error: 'invalid_request' }, { status: 400 });
const job = await readClassroomGenerationJob(jobId);
try {
if (job) {
if (job.ownerPrincipalId !== owner) {
return Response.json({ error: 'job_not_found' }, { status: 404 });
}
if (job.status !== 'succeeded' || !job.result?.classroomId) {
return Response.json({ error: 'job_not_ready' }, { status: 409 });
}
const classroom = await readClassroom(job.result.classroomId);
if (!classroom) return Response.json({ error: 'classroom_not_found' }, { status: 404 });
const record = await finalizeSingleLearningCourse({
jobId,
classroom,
baseUrl: buildRequestOrigin(request),
requireInteractiveHtml: job.input.interactiveMode === true,
requireNarrationAudio: job.input.enableTTS === true,
});
return Response.json(frozenLearningFinalizeResponse(record));
}
const course = await readCourseRecordReconciled(jobId);
if (!course || course.ownerPrincipalId !== owner) {
return Response.json({ error: 'job_not_found' }, { status: 404 });
}
if (course.status !== 'completed') {
return Response.json(
{
error: course.status === 'framework_ready' ? 'review_required' : 'job_not_ready',
...(course.status === 'framework_ready'
? { reviewUrl: `/learning-ops/courses/${course.id}` }
: {}),
},
{ status: 409 },
);
}
const record = await finalizeLargeLearningCourse({
jobId,
course,
baseUrl: buildRequestOrigin(request),
});
return Response.json(frozenLearningFinalizeResponse(record));
} catch (error) {
if (error instanceof FrozenLearningCourseMutationError) {
return Response.json({ error: 'course_frozen', message: error.message }, { status: 409 });
}
return Response.json({
error: 'course_not_offline_complete',
message: error instanceof Error ? error.message : 'Course finalization failed',
}, { status: 422 });
}
}
@@ -0,0 +1,57 @@
import { after, type NextRequest } from 'next/server';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import { LEARNING_GENERATION_CONTRACT_VERSION } from '@/lib/makelore-runtime/generation-request';
import {
controlLargeCourse,
largeCourseControlErrorResponse,
} from '@/lib/makelore-runtime/large-course-control';
import { isValidClassroomJobId, readClassroomGenerationJob } from '@/lib/server/classroom-job-store';
import { resumeClassroomGenerationJob } from '@/lib/server/classroom-job-runner';
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
export async function POST(
request: NextRequest,
context: { params: Promise<{ jobId: string }> },
) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const { jobId } = await context.params;
const owner = request.headers.get('x-owner-user-id')?.trim();
if (!owner || !isValidClassroomJobId(jobId)) {
return Response.json({ error: 'invalid_request' }, { status: 400 });
}
const baseUrl = buildRequestOrigin(request);
const job = await readClassroomGenerationJob(jobId);
if (job) {
if (job.ownerPrincipalId !== owner) {
return Response.json({ error: 'job_not_found' }, { status: 404 });
}
if (!(await resumeClassroomGenerationJob(jobId, baseUrl))) {
return Response.json({ error: 'job_not_resumable' }, { status: 409 });
}
return Response.json({
contractVersion: LEARNING_GENERATION_CONTRACT_VERSION,
jobId,
mode: 'single',
status: 'queued',
done: false,
});
}
try {
const result = await controlLargeCourse({
courseId: jobId,
ownerPrincipalId: owner,
action: 'resume',
baseUrl,
});
if (result.run) after(() => result.run!);
return Response.json({
contractVersion: LEARNING_GENERATION_CONTRACT_VERSION,
jobId,
mode: 'large',
status: result.status,
done: false,
});
} catch (error) {
return largeCourseControlErrorResponse(error);
}
}
@@ -0,0 +1,61 @@
import type { NextRequest } from 'next/server';
import { readCourseRecordReconciled } from '@/lib/course-framework/store';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import { LEARNING_GENERATION_CONTRACT_VERSION } from '@/lib/makelore-runtime/generation-request';
import { isValidClassroomJobId, readClassroomGenerationJob } from '@/lib/server/classroom-job-store';
function largeCourseProgress(status: string, succeeded: number, total: number): number {
if (status === 'completed') return 100;
if (status === 'framework_ready') return 25;
if (status === 'framework_generating') return 10;
if (status === 'queued') return 0;
if (total > 0) return Math.min(99, Math.round(25 + (succeeded / total) * 75));
return 0;
}
export async function GET(request: NextRequest, context: { params: Promise<{ jobId: string }> }) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const { jobId } = await context.params;
const owner = request.headers.get('x-owner-user-id')?.trim();
if (!owner || !isValidClassroomJobId(jobId)) return Response.json({ error: 'invalid_request' }, { status: 400 });
const job = await readClassroomGenerationJob(jobId);
if (job) {
if (job.ownerPrincipalId !== owner) return Response.json({ error: 'job_not_found' }, { status: 404 });
return Response.json({
contractVersion: LEARNING_GENERATION_CONTRACT_VERSION,
jobId,
mode: 'single',
status: job.status,
step: job.step,
progress: job.progress,
message: job.message,
scenesGenerated: job.scenesGenerated,
totalScenes: job.totalScenes,
courseId: job.result?.classroomId,
error: job.error,
done: ['succeeded', 'failed', 'cancelled'].includes(job.status),
});
}
const course = await readCourseRecordReconciled(jobId);
if (!course || course.ownerPrincipalId !== owner) {
return Response.json({ error: 'job_not_found' }, { status: 404 });
}
const succeeded = course.modules.filter((module) => module.status === 'succeeded').length;
const total = course.modules.length;
return Response.json({
contractVersion: LEARNING_GENERATION_CONTRACT_VERSION,
jobId,
mode: 'large',
status: course.status,
step: course.status,
progress: largeCourseProgress(course.status, succeeded, total),
message:
course.status === 'framework_ready'
? 'Course framework is ready for operations review'
: undefined,
courseId: course.id,
error: course.error,
done: ['completed', 'failed', 'cancelled'].includes(course.status),
});
}