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

@@ -0,0 +1,25 @@
import type { NextRequest } from 'next/server';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import { loadMakeloreCourse, parseLearningBinding } from '@/lib/makelore-runtime/course-store';
import { isRuntimeOpenRequest } from '@/lib/makelore-runtime/protocol';
export async function POST(request: NextRequest) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const body: unknown = await request.json().catch(() => null);
if (!isRuntimeOpenRequest(body)) return Response.json({ error: 'invalid_request' }, { status: 400 });
const binding = parseLearningBinding(body.binding.key);
if (!binding) return Response.json({ error: 'invalid_binding' }, { status: 400 });
const course = await loadMakeloreCourse(binding.courseId, binding.contentHash);
if (!course) return Response.json({ error: 'course_not_found' }, { status: 404 });
return Response.json({
type: 'learning.course.snapshot',
schema_version: 1,
binding_source_sequence: 0,
payload: {
courseId: course.courseId,
contentHash: course.contentHash,
title: course.title,
sceneCount: course.sceneCount,
},
});
}

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
import { after, type NextRequest } from 'next/server';
import { nanoid } from 'nanoid';
import { createCourse, runCourseFrameworkGeneration } from '@/lib/course-framework/runner';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import {
LEARNING_GENERATION_CONTRACT_VERSION,
LearningGenerationRequestError,
parseLearningGenerationRequest,
} from '@/lib/makelore-runtime/generation-request';
import { createClassroomGenerationJob } from '@/lib/server/classroom-job-store';
import { runClassroomGenerationJob } from '@/lib/server/classroom-job-runner';
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
export const maxDuration = 300;
export async function POST(request: NextRequest) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
try {
const generation = await parseLearningGenerationRequest(request);
const jobId = nanoid(10);
const baseUrl = buildRequestOrigin(request);
if (generation.mode === 'large') {
await createCourse(jobId, generation.input, {
ownerPrincipalId: generation.ownerUserId,
});
// Phase 1 only. The original Ops builder reviews the framework and calls
// /api/courses/:id/start before the original per-module runner is used.
after(() => runCourseFrameworkGeneration(jobId, baseUrl));
} else {
await createClassroomGenerationJob(jobId, generation.input, {
ownerPrincipalId: generation.ownerUserId,
});
after(() => runClassroomGenerationJob(jobId, generation.input, baseUrl));
}
return Response.json(
{
contractVersion: LEARNING_GENERATION_CONTRACT_VERSION,
jobId,
status: 'queued',
mode: generation.mode,
done: false,
},
{ status: 202 },
);
} catch (error) {
if (error instanceof LearningGenerationRequestError) {
return Response.json(
{ error: error.code, message: error.message },
{ status: error.status },
);
}
return Response.json(
{
error: 'generation_request_failed',
message: error instanceof Error ? error.message : 'Generation request failed',
},
{ status: 500 },
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import { LEARNING_ENGINE_API_VERSION, LEARNING_ENGINE_SERVICE } from '@/lib/makelore-runtime/health';
export const dynamic = 'force-dynamic';
export function GET() {
return Response.json({
service: LEARNING_ENGINE_SERVICE,
apiVersion: LEARNING_ENGINE_API_VERSION,
status: 'ok',
});
}

View File

@@ -0,0 +1,23 @@
import {
inspectLearningReadiness,
LEARNING_ENGINE_API_VERSION,
LEARNING_ENGINE_SERVICE,
} from '@/lib/makelore-runtime/health';
export const dynamic = 'force-dynamic';
export async function GET() {
const readiness = await inspectLearningReadiness();
return Response.json(
{
service: LEARNING_ENGINE_SERVICE,
apiVersion: LEARNING_ENGINE_API_VERSION,
status: readiness.ready ? 'ready' : 'not_ready',
checks: readiness.checks,
},
{
status: readiness.ready ? 200 : 503,
headers: { 'Cache-Control': 'no-store' },
},
);
}

View File

@@ -0,0 +1,7 @@
import type { NextRequest } from 'next/server';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
export async function POST(request: NextRequest) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
return Response.json({ status: 'not_supported', message: 'The current turn finishes independently.' });
}

View File

@@ -0,0 +1,94 @@
import type { NextRequest } from 'next/server';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import {
courseKnowledge,
loadMakeloreCourse,
parseLearningBinding,
resolveMakeloreCourseModule,
runtimeSceneId,
} from '@/lib/makelore-runtime/course-store';
import { isRuntimeRunRequest } from '@/lib/makelore-runtime/protocol';
import { retrieveChunks } from '@/lib/qa/knowledge';
import { runQaAgent, type QaTurn } from '@/lib/qa/agent';
import { resolveModel } from '@/lib/server/resolve-model';
export const maxDuration = 120;
export async function POST(request: NextRequest) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const body: unknown = await request.json().catch(() => null);
if (!isRuntimeRunRequest(body)) return Response.json({ error: 'invalid_request' }, { status: 400 });
const binding = parseLearningBinding(body.binding.key);
if (!binding) return Response.json({ error: 'invalid_binding' }, { status: 400 });
const course = await loadMakeloreCourse(binding.courseId, binding.contentHash);
if (!course) return Response.json({ error: 'course_not_found' }, { status: 404 });
const module = await resolveMakeloreCourseModule(course, {
moduleId: body.input.anchor?.moduleId,
moduleContentHash: body.input.anchor?.moduleContentHash,
});
if (!module) return Response.json({ error: 'course_module_not_found' }, { status: 404 });
const knowledge = courseKnowledge(module);
const message = body.input.message!.trim().slice(0, 4000);
const retrieved = retrieveChunks(knowledge.knowledge, message, 3);
const anchor = body.input.anchor;
const sceneIndex = module.manifest?.scenes.findIndex((scene, index) =>
(Boolean(anchor?.sceneId) &&
anchor?.sceneId === runtimeSceneId(course, module.descriptor, index)) ||
(anchor?.sceneOrder !== undefined && scene.order === anchor.sceneOrder),
) ?? -1;
const anchored = sceneIndex >= 0
? knowledge.knowledge.scenes[sceneIndex]
: knowledge.knowledge.scenes.find((scene) =>
(anchor?.sceneOrder !== undefined && scene.order === anchor.sceneOrder) ||
(Boolean(anchor?.sceneId) && scene.sceneId === anchor?.sceneId),
);
const chunks = anchored && !retrieved.some((chunk) => chunk.sceneId === anchored.sceneId)
? [{ ...anchored, score: Number.MAX_SAFE_INTEGER }, ...retrieved].slice(0, 3)
: retrieved;
const history: QaTurn[] = (body.input.history ?? [])
.filter((turn): turn is QaTurn => Boolean(turn)
&& (turn.role === 'user' || turn.role === 'assistant')
&& typeof turn.content === 'string')
.slice(-7)
.map((turn) => ({ role: turn.role, content: turn.content.slice(0, 4000) }));
history.push({ role: 'user', content: message });
const resolved = await resolveModel({ stage: 'qa-assistant' });
const encoder = new TextEncoder();
let sequence = 0;
const event = (type: string, payload: Record<string, unknown>, terminal = false) => `${JSON.stringify({
event_id: `${body.run_id}:${++sequence}`,
type,
payload,
schema_version: 1,
terminal,
...(terminal ? { outcome: 'completed' } : {}),
})}\n`;
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const update of runQaAgent({ model: resolved.model, courseware: knowledge, chunks, thinkingConfig: resolved.thinkingConfig }, history)) {
if (update.type === 'text') {
controller.enqueue(encoder.encode(event('learning.assistant.delta', { delta: update.delta })));
} else if (update.type === 'done') {
controller.enqueue(encoder.encode(event('learning.assistant.completed', {
sources: update.sources.map((source) => ({ sceneId: source.sceneId, order: source.order, title: source.title })),
}, true)));
}
}
} catch {
controller.enqueue(encoder.encode(event('learning.assistant.failed', { code: 'learning_runtime_failed', message: '助教暂时无法回答' }, true)));
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Content-Encoding': 'identity',
'Cache-Control': 'no-store, no-transform',
},
});
}