feat: productionize learning engine and classroom
This commit is contained in:
25
OpenMAIC/app/api/runtime/v1/bindings/validate/route.ts
Normal file
25
OpenMAIC/app/api/runtime/v1/bindings/validate/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
62
OpenMAIC/app/api/runtime/v1/courses/generate/route.ts
Normal file
62
OpenMAIC/app/api/runtime/v1/courses/generate/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
61
OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/route.ts
Normal file
61
OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/route.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
11
OpenMAIC/app/api/runtime/v1/health/live/route.ts
Normal file
11
OpenMAIC/app/api/runtime/v1/health/live/route.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
23
OpenMAIC/app/api/runtime/v1/health/ready/route.ts
Normal file
23
OpenMAIC/app/api/runtime/v1/health/ready/route.ts
Normal 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' },
|
||||
},
|
||||
);
|
||||
}
|
||||
7
OpenMAIC/app/api/runtime/v1/runs/[runId]/cancel/route.ts
Normal file
7
OpenMAIC/app/api/runtime/v1/runs/[runId]/cancel/route.ts
Normal 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.' });
|
||||
}
|
||||
94
OpenMAIC/app/api/runtime/v1/runs/route.ts
Normal file
94
OpenMAIC/app/api/runtime/v1/runs/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user