feat: productionize learning engine and classroom
This commit is contained in:
8
OpenMAIC/packages/@makelore/learning-contracts/README.md
Normal file
8
OpenMAIC/packages/@makelore/learning-contracts/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Makelore learning contracts
|
||||
|
||||
This package defines the small, dependency-free boundary between a downloaded
|
||||
course, the local Makelore player, and cloud learning services.
|
||||
|
||||
A course is an immutable, unique product entity. It has a `courseId` and a
|
||||
`contentHash`; it does not have a user-visible version. `formatVersion` and
|
||||
`minPlayerVersion` are technical compatibility fields only.
|
||||
1
OpenMAIC/packages/@makelore/learning-contracts/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
Normal file
1
OpenMAIC/packages/@makelore/learning-contracts/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":"4.1.8","results":[[":test/contracts.test.ts",{"duration":2.0517079999999908,"failed":false}]]}
|
||||
28
OpenMAIC/packages/@makelore/learning-contracts/package.json
Normal file
28
OpenMAIC/packages/@makelore/learning-contracts/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@makelore/learning-contracts",
|
||||
"version": "0.1.0",
|
||||
"description": "Dependency-free contracts shared by the Makelore learning player and cloud learning services.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
155
OpenMAIC/packages/@makelore/learning-contracts/src/index.ts
Normal file
155
OpenMAIC/packages/@makelore/learning-contracts/src/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
export const LEARNING_PACKAGE_SCHEMA_VERSION = 1 as const;
|
||||
export const LEARNING_PACKAGE_FORMAT_VERSION = 1 as const;
|
||||
|
||||
export type CourseSceneKind = 'slide' | 'interactive' | 'quiz' | 'pbl';
|
||||
|
||||
export interface CourseCapabilities {
|
||||
sceneKinds: CourseSceneKind[];
|
||||
hasAudio: boolean;
|
||||
hasWhiteboard: boolean;
|
||||
hasAgent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidecar metadata returned by the control plane and stored beside the ZIP.
|
||||
* `courseId` identifies one immutable course. Regeneration creates another ID.
|
||||
*/
|
||||
export interface CoursePackageDescriptor {
|
||||
schemaVersion: typeof LEARNING_PACKAGE_SCHEMA_VERSION;
|
||||
kind: 'makelore-course-package';
|
||||
courseId: string;
|
||||
contentHash: string;
|
||||
/** Sidecar-only transport digest. Omitted from descriptor.json to avoid self-hashing. */
|
||||
archiveSha256?: string;
|
||||
/** Sidecar-only transport size. Omitted from descriptor.json to avoid self-reference. */
|
||||
archiveBytes?: number;
|
||||
formatVersion: typeof LEARNING_PACKAGE_FORMAT_VERSION;
|
||||
minPlayerVersion: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
language?: string;
|
||||
sceneCount: number;
|
||||
capabilities: CourseCapabilities;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Exact playback position sent with an Agent request. */
|
||||
export interface CourseAgentAnchor {
|
||||
courseId: string;
|
||||
contentHash: string;
|
||||
sceneOrder: number;
|
||||
actionIndex?: number;
|
||||
positionMs?: number;
|
||||
}
|
||||
|
||||
export interface CourseAgentRequest {
|
||||
requestId: string;
|
||||
anchor: CourseAgentAnchor;
|
||||
input: {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CourseProgressSnapshot {
|
||||
courseId: string;
|
||||
contentHash: string;
|
||||
sceneOrder: number;
|
||||
actionIndex?: number;
|
||||
positionMs?: number;
|
||||
completed: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult<T> {
|
||||
ok: boolean;
|
||||
value?: T;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isSha256(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function isOptionalNonNegativeInteger(value: unknown): boolean {
|
||||
return value === undefined || isNonNegativeInteger(value);
|
||||
}
|
||||
|
||||
function isIsoDate(value: unknown): value is string {
|
||||
return isNonEmptyString(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
const SCENE_KINDS = new Set<CourseSceneKind>(['slide', 'interactive', 'quiz', 'pbl']);
|
||||
|
||||
export function validateCoursePackageDescriptor(
|
||||
input: unknown,
|
||||
): ValidationResult<CoursePackageDescriptor> {
|
||||
const errors: string[] = [];
|
||||
if (!isRecord(input)) return { ok: false, errors: ['descriptor must be an object'] };
|
||||
|
||||
if (input.schemaVersion !== LEARNING_PACKAGE_SCHEMA_VERSION)
|
||||
errors.push(`schemaVersion must be ${LEARNING_PACKAGE_SCHEMA_VERSION}`);
|
||||
if (input.kind !== 'makelore-course-package')
|
||||
errors.push('kind must be makelore-course-package');
|
||||
if (!isNonEmptyString(input.courseId)) errors.push('courseId is required');
|
||||
if (!isSha256(input.contentHash)) errors.push('contentHash must be a SHA-256 hex string');
|
||||
if (input.archiveSha256 !== undefined && !isSha256(input.archiveSha256))
|
||||
errors.push('archiveSha256 must be a SHA-256 hex string');
|
||||
if (
|
||||
input.archiveBytes !== undefined &&
|
||||
(!isNonNegativeInteger(input.archiveBytes) || input.archiveBytes === 0)
|
||||
) errors.push('archiveBytes must be a positive integer');
|
||||
if (input.formatVersion !== LEARNING_PACKAGE_FORMAT_VERSION)
|
||||
errors.push(`formatVersion must be ${LEARNING_PACKAGE_FORMAT_VERSION}`);
|
||||
if (!isNonEmptyString(input.minPlayerVersion)) errors.push('minPlayerVersion is required');
|
||||
if (!isNonEmptyString(input.title)) errors.push('title is required');
|
||||
if (!isNonNegativeInteger(input.sceneCount) || input.sceneCount === 0)
|
||||
errors.push('sceneCount must be a positive integer');
|
||||
if (!isIsoDate(input.createdAt)) errors.push('createdAt must be an ISO date');
|
||||
|
||||
if (!isRecord(input.capabilities)) {
|
||||
errors.push('capabilities must be an object');
|
||||
} else {
|
||||
const capabilities = input.capabilities;
|
||||
if (
|
||||
!Array.isArray(capabilities.sceneKinds) ||
|
||||
capabilities.sceneKinds.some((kind) => !SCENE_KINDS.has(kind as CourseSceneKind))
|
||||
) {
|
||||
errors.push('capabilities.sceneKinds contains an unsupported scene kind');
|
||||
}
|
||||
for (const flag of ['hasAudio', 'hasWhiteboard', 'hasAgent'] as const) {
|
||||
if (typeof capabilities[flag] !== 'boolean') errors.push(`capabilities.${flag} must be boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length === 0
|
||||
? { ok: true, value: input as unknown as CoursePackageDescriptor, errors }
|
||||
: { ok: false, errors };
|
||||
}
|
||||
|
||||
export function validateCourseAgentAnchor(input: unknown): ValidationResult<CourseAgentAnchor> {
|
||||
const errors: string[] = [];
|
||||
if (!isRecord(input)) return { ok: false, errors: ['anchor must be an object'] };
|
||||
if (!isNonEmptyString(input.courseId)) errors.push('courseId is required');
|
||||
if (!isSha256(input.contentHash)) errors.push('contentHash must be a SHA-256 hex string');
|
||||
if (!isNonNegativeInteger(input.sceneOrder)) errors.push('sceneOrder must be a non-negative integer');
|
||||
if (!isOptionalNonNegativeInteger(input.actionIndex))
|
||||
errors.push('actionIndex must be a non-negative integer');
|
||||
if (!isOptionalNonNegativeInteger(input.positionMs))
|
||||
errors.push('positionMs must be a non-negative integer');
|
||||
return errors.length === 0
|
||||
? { ok: true, value: input as unknown as CourseAgentAnchor, errors }
|
||||
: { ok: false, errors };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
validateCourseAgentAnchor,
|
||||
validateCoursePackageDescriptor,
|
||||
} from '../src/index.js';
|
||||
|
||||
const SHA = 'a'.repeat(64);
|
||||
|
||||
describe('course package descriptor', () => {
|
||||
it('accepts immutable unique-course metadata without a product version', () => {
|
||||
const descriptor = {
|
||||
schemaVersion: 1,
|
||||
kind: 'makelore-course-package',
|
||||
courseId: 'course_python_basics',
|
||||
contentHash: SHA,
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
archiveBytes: 1024,
|
||||
formatVersion: 1,
|
||||
minPlayerVersion: '0.1.0',
|
||||
title: 'Python 零基础入门',
|
||||
sceneCount: 10,
|
||||
capabilities: {
|
||||
sceneKinds: ['slide', 'interactive', 'quiz'],
|
||||
hasAudio: false,
|
||||
hasWhiteboard: false,
|
||||
hasAgent: true,
|
||||
},
|
||||
createdAt: '2026-08-16T00:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(validateCoursePackageDescriptor(descriptor)).toEqual({
|
||||
ok: true,
|
||||
value: descriptor,
|
||||
errors: [],
|
||||
});
|
||||
expect(descriptor).not.toHaveProperty('version');
|
||||
});
|
||||
|
||||
it('rejects malformed hashes and zero-byte archives', () => {
|
||||
const result = validateCoursePackageDescriptor({
|
||||
schemaVersion: 1,
|
||||
kind: 'makelore-course-package',
|
||||
courseId: 'course_1',
|
||||
contentHash: 'bad',
|
||||
archiveSha256: SHA,
|
||||
archiveBytes: 0,
|
||||
formatVersion: 1,
|
||||
minPlayerVersion: '0.1.0',
|
||||
title: 'Course',
|
||||
sceneCount: 1,
|
||||
capabilities: {
|
||||
sceneKinds: ['slide'],
|
||||
hasAudio: false,
|
||||
hasWhiteboard: false,
|
||||
hasAgent: false,
|
||||
},
|
||||
createdAt: '2026-08-16T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors).toContain('contentHash must be a SHA-256 hex string');
|
||||
expect(result.errors).toContain('archiveBytes must be a positive integer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent anchor', () => {
|
||||
it('binds an Agent request to an exact course and playback position', () => {
|
||||
expect(
|
||||
validateCourseAgentAnchor({
|
||||
courseId: 'course_python_basics',
|
||||
contentHash: SHA,
|
||||
sceneOrder: 2,
|
||||
actionIndex: 1,
|
||||
positionMs: 3200,
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
19
OpenMAIC/packages/@makelore/learning-contracts/tsconfig.json
Normal file
19
OpenMAIC/packages/@makelore/learning-contracts/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user