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,26 @@
import type { NextRequest } from 'next/server';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import {
readFrozenLearningPackageRecord,
readUniqueMakeloreCoursePackage,
} from '@/lib/makelore-course/package';
export async function GET(request: NextRequest, context: { params: Promise<{ courseId: string }> }) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const { courseId } = await context.params;
const contentHash = request.headers.get('x-content-hash')?.trim() || '';
const record = await readFrozenLearningPackageRecord(courseId).catch(() => null);
if (!record || record.contentHash !== contentHash) {
return Response.json({ error: 'course_not_found' }, { status: 404 });
}
const bytes = await readUniqueMakeloreCoursePackage(courseId);
if (!bytes) return Response.json({ error: 'package_not_found' }, { status: 404 });
return new Response(bytes as unknown as BodyInit, {
headers: {
'Content-Type': 'application/zip',
'Content-Length': String(bytes.byteLength),
'X-Archive-SHA256': record.archiveSha256,
'Cache-Control': 'private, no-store',
},
});
}
@@ -0,0 +1,238 @@
import { isDeepStrictEqual } from 'node:util';
import { NextRequest } from 'next/server';
import { POST as quizGrade } from '@/app/api/quiz-grade/route';
import { POST as pblTaskUpdate } from '@/app/api/pbl/v2/task/update/route';
import { POST as pblOpenTask } from '@/app/api/pbl/v2/open-task/route';
import { POST as pblSimulator } from '@/app/api/pbl/v2/simulator/route';
import { POST as pblInstructor } from '@/app/api/pbl/v2/instructor/route';
import { POST as pblEvaluate } from '@/app/api/pbl/v2/evaluate/route';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import {
loadMakeloreCourse,
resolveMakeloreCourseModule,
runtimeSceneId,
type RuntimeCourseModule,
} from '@/lib/makelore-runtime/course-store';
import { stripToDesignTemplate } from '@/lib/pbl/v2/runtime/learner-state';
import { isPBLProjectV2, type PBLProjectV2 } from '@/lib/pbl/v2/types';
import type { QuizQuestion, SceneContent } from '@/lib/types/stage';
export const runtime = 'nodejs';
export const maxDuration = 300;
const MAX_BODY_BYTES = 2 * 1024 * 1024;
const SHA256 = /^[0-9a-f]{64}$/;
const CAPABILITIES = {
'quiz-grade': quizGrade,
'pbl/v2/task/update': pblTaskUpdate,
'pbl/v2/open-task': pblOpenTask,
'pbl/v2/simulator': pblSimulator,
'pbl/v2/instructor': pblInstructor,
'pbl/v2/evaluate': pblEvaluate,
} as const;
type Capability = keyof typeof CAPABILITIES;
type RuntimeContext = {
moduleId?: string | null;
moduleContentHash?: string | null;
anchor: { sceneId: string; sceneOrder?: number };
};
function object(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function error(code: string, status: number): Response {
return Response.json({ error: code }, { status });
}
function parseContext(value: unknown): RuntimeContext | null {
const context = object(value);
const anchor = object(context?.anchor);
const sceneOrder = anchor?.sceneOrder;
if (
!context ||
!anchor ||
typeof anchor.sceneId !== 'string' ||
anchor.sceneId.length < 1 ||
anchor.sceneId.length > 256 ||
(sceneOrder !== undefined &&
(!Number.isSafeInteger(sceneOrder) || Number(sceneOrder) < 0)) ||
(context.moduleId !== undefined && context.moduleId !== null && typeof context.moduleId !== 'string') ||
(context.moduleContentHash !== undefined &&
context.moduleContentHash !== null &&
(typeof context.moduleContentHash !== 'string' || !SHA256.test(context.moduleContentHash)))
) {
return null;
}
return {
moduleId: context.moduleId as string | null | undefined,
moduleContentHash: context.moduleContentHash as string | null | undefined,
anchor: {
sceneId: anchor.sceneId,
...(typeof sceneOrder === 'number' ? { sceneOrder } : {}),
},
};
}
function selectedScene(
module: RuntimeCourseModule,
context: RuntimeContext,
): { content: SceneContent; order: number } | null {
if (module.manifest) {
const index = module.manifest.scenes.findIndex((_scene, sceneIndex) =>
runtimeSceneId(module.course, module.descriptor, sceneIndex) === context.anchor.sceneId,
);
if (index < 0) return null;
const scene = module.manifest.scenes[index]!;
if (context.anchor.sceneOrder !== undefined && scene.order !== context.anchor.sceneOrder) return null;
return { content: scene.content, order: scene.order };
}
const scene = module.classroom?.scenes.find((candidate) => candidate.id === context.anchor.sceneId);
if (!scene || (context.anchor.sceneOrder !== undefined && scene.order !== context.anchor.sceneOrder)) {
return null;
}
return { content: scene.content, order: scene.order };
}
function quizBody(scene: SceneContent, value: unknown): Record<string, unknown> | null {
if (scene.type !== 'quiz') return null;
const body = object(value);
if (!body || typeof body.question !== 'string' || typeof body.userAnswer !== 'string') return null;
const question = scene.questions.find((candidate: QuizQuestion) =>
candidate.type === 'short_answer' && candidate.question === body.question,
);
if (!question || body.userAnswer.length > 20_000) return null;
return {
question: question.question,
userAnswer: body.userAnswer,
points: question.points ?? 1,
...(question.commentPrompt ? { commentPrompt: question.commentPrompt } : {}),
...(body.language === 'zh-CN' ? { language: 'zh-CN' } : {}),
};
}
function validatedProject(scene: SceneContent, value: unknown): PBLProjectV2 | null {
if (scene.type !== 'pbl' || !isPBLProjectV2(scene.projectV2) || !isPBLProjectV2(value)) return null;
try {
return isDeepStrictEqual(
stripToDesignTemplate(value),
stripToDesignTemplate(scene.projectV2),
)
? value
: null;
} catch {
return null;
}
}
function pblBody(
capability: Exclude<Capability, 'quiz-grade'>,
scene: SceneContent,
value: unknown,
): Record<string, unknown> | null {
const body = object(value);
const project = validatedProject(scene, body?.project);
if (!body || !project) return null;
switch (capability) {
case 'pbl/v2/task/update':
return {
project,
action: body.action,
...(typeof body.microtaskId === 'string' ? { microtaskId: body.microtaskId } : {}),
};
case 'pbl/v2/open-task':
return {
project,
phase: body.phase,
...(Array.isArray(body.priorQuizResults)
? { priorQuizResults: body.priorQuizResults.slice(0, 100) }
: {}),
};
case 'pbl/v2/simulator':
return {
project,
...(typeof body.userMessage === 'string'
? { userMessage: body.userMessage.slice(0, 20_000) }
: {}),
...(body.phase === 'greeting' ? { phase: 'greeting' } : {}),
};
case 'pbl/v2/instructor':
return {
project,
...(typeof body.userMessage === 'string'
? { userMessage: body.userMessage.slice(0, 20_000) }
: {}),
...(typeof body.phase === 'string' ? { phase: body.phase } : {}),
};
case 'pbl/v2/evaluate':
return {
project,
kind: body.kind,
...(typeof body.milestoneId === 'string' ? { milestoneId: body.milestoneId } : {}),
...(typeof body.microtaskId === 'string' ? { microtaskId: body.microtaskId } : {}),
...(typeof body.recentChatSummary === 'string'
? { recentChatSummary: body.recentChatSummary.slice(0, 40_000) }
: {}),
};
}
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ courseId: string; capability: string[] }> },
) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const owner = request.headers.get('x-owner-user-id')?.trim();
const aggregateHash = request.headers.get('x-content-hash')?.trim();
if (!owner || !aggregateHash || !SHA256.test(aggregateHash)) {
return error('invalid_runtime_context', 400);
}
const contentLength = Number(request.headers.get('content-length') ?? '0');
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
return error('runtime_request_too_large', 413);
}
const { courseId, capability: parts } = await context.params;
const capability = parts.join('/') as Capability;
const handler = CAPABILITIES[capability];
if (!handler) return error('runtime_capability_not_found', 404);
const text = await request.text();
if (new TextEncoder().encode(text).byteLength > MAX_BODY_BYTES) {
return error('runtime_request_too_large', 413);
}
let envelope: Record<string, unknown> | null;
try {
envelope = object(JSON.parse(text) as unknown);
} catch {
return error('invalid_runtime_request', 400);
}
const runtimeContext = parseContext(envelope?.context);
const rawBody = envelope?.body;
if (!runtimeContext || !object(rawBody)) return error('invalid_runtime_request', 400);
const course = await loadMakeloreCourse(courseId, aggregateHash);
if (!course) return error('course_not_found', 404);
const module = await resolveMakeloreCourseModule(course, runtimeContext);
if (!module) return error('course_module_not_found', 404);
const scene = selectedScene(module, runtimeContext);
if (!scene) return error('course_scene_not_found', 404);
const safeBody = capability === 'quiz-grade'
? quizBody(scene.content, rawBody)
: pblBody(capability, scene.content, rawBody);
if (!safeBody) return error('runtime_capability_context_mismatch', 409);
const internalRequest = new NextRequest(
`http://learning-engine.internal/api/${capability}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: request.headers.get('accept') ?? '*/*' },
body: JSON.stringify(safeBody),
signal: request.signal,
},
);
return handler(internalRequest);
}