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,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.

View File

@@ -0,0 +1 @@
{"version":"4.1.8","results":[[":test/contracts.test.ts",{"duration":2.0517079999999908,"failed":false}]]}

View 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"
}
}

View 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 };
}

View File

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

View 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"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
},
});