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