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,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);
|
||||
}
|
||||
Reference in new issue
Block a user