feat(generation): move outline generation into the package (#1065)

Part B of #1057. Outline primitives, buildOutlinePrompt (single-source prompt construction with golden snapshots), logger injection, JSON repair, and the bare-Node second-consumer smoke script with CI coverage. Shim-first: no app-side behavior changes; duplicated prompt assets guarded by a CI parity check until Part D.
This commit is contained in:
wyuc
2026-08-05 23:29:36 -04:00
committed by GitHub
parent c0da724ec8
commit 9556a035b1
21 changed files with 3648 additions and 9 deletions
+41
View File
@@ -128,6 +128,47 @@ jobs:
- name: Unit Tests (generation)
run: pnpm --filter @openmaic/generation test
# Remove this duplicate-asset guard in Part D with the app prompt copy.
- name: Prompt asset parity (generation)
shell: bash
run: |
set -euo pipefail
for path in templates/requirements-to-outlines snippets/{image-instructions,json-output-rules,media-safety-guidelines,slide-generated-image-instructions,slide-image-instructions,slide-video-instructions,video-instructions}.md; do
if ! diff -r "lib/prompts/$path" "packages/@openmaic/generation/$path"; then
echo "::error file=lib/prompts/$path::Prompt asset $path diverged; the app and package copies must be edited together until Part D removes the app copy."
exit 1
fi
done
- name: Node consumer smoke (generation outlines)
shell: bash
run: |
set -euo pipefail
port=43127
node scripts/generation-node-smoke-server.mjs --port "$port" >"$RUNNER_TEMP/generation-smoke-server.log" 2>&1 &
server_pid=$!
cleanup() {
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
}
trap cleanup EXIT
for attempt in {1..50}; do
if node -e "fetch('http://127.0.0.1:$port/health').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"; then
break
fi
if [ "$attempt" -eq 50 ]; then
cat "$RUNNER_TEMP/generation-smoke-server.log"
exit 1
fi
sleep 0.1
done
output="$(node scripts/generation-node-smoke.mjs \
--requirement "Explain dependency injection" \
--endpoint "http://127.0.0.1:$port" \
--model smoke-model)"
printf '%s\n' "$output"
node -e 'const value = JSON.parse(process.argv[1]); if (!Array.isArray(value.outlines) || value.outlines.length === 0) process.exit(1)' "$output"
# Covers the package's own test tree, which the root tsc reaches only by an
# incidental glob. The device-scope guard is written as `@ts-expect-error`
# probes in those tests, and a probe nothing type-checks proves nothing.
+9 -8
View File
@@ -140,8 +140,9 @@ const eslintConfig = defineConfig([
},
// Package boundary (machine-enforced): @openmaic/generation is a standalone,
// app-agnostic package. Every package code directory is covered so future
// scripts and tooling cannot bypass the boundary. Package code may import
// only @openmaic/dsl, Node built-ins, or relative modules.
// scripts and tooling cannot bypass the boundary. The leaf dependency list
// mirrors the package's declared dependencies and is widened only together
// with package.json.
{
files: ['packages/@openmaic/generation/**/*.{ts,tsx,js,jsx,mjs,cjs}'],
rules: {
@@ -160,21 +161,21 @@ const eslintConfig = defineConfig([
},
{
selector:
'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|node:|\\.\\.?\\/).+/]',
'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|(nanoid|jsonrepair)(\\/|$)|node:|\\.\\.?\\/).+/]',
message:
'@openmaic/generation may import only from @openmaic/dsl, Node built-ins, or relative modules (./… or ../…).',
'@openmaic/generation may import only from @openmaic/dsl, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).',
},
{
selector:
'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|node:|\\.\\.?\\/).+/]',
'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|(nanoid|jsonrepair)(\\/|$)|node:|\\.\\.?\\/).+/]',
message:
'@openmaic/generation may re-export only from @openmaic/dsl, Node built-ins, or relative modules (./… or ../…).',
'@openmaic/generation may re-export only from @openmaic/dsl, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).',
},
{
selector:
'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|node:|\\.\\.?\\/).+/]',
'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|(nanoid|jsonrepair)(\\/|$)|node:|\\.\\.?\\/).+/]',
message:
'@openmaic/generation may re-export only from @openmaic/dsl, Node built-ins, or relative modules (./… or ../…).',
'@openmaic/generation may re-export only from @openmaic/dsl, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).',
},
{
selector: 'ImportExpression',
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openmaic/generation",
"version": "0.0.1",
"version": "0.1.0",
"description": "Pure generation pipeline contracts and packaged prompt assets for MAIC consumers.",
"type": "module",
"main": "./dist/index.js",
@@ -41,6 +41,10 @@
"url": "https://github.com/THU-MAIC/OpenMAIC",
"directory": "packages/@openmaic/generation"
},
"dependencies": {
"jsonrepair": "^3.13.2",
"nanoid": "^5.1.6"
},
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
@@ -0,0 +1,2 @@
export const MAX_PDF_CONTENT_CHARS = 50_000;
export const MAX_VISION_IMAGES = 20;
@@ -6,4 +6,32 @@ export type {
SceneGenerationContext,
} from './pipeline-types.js';
export {
DEFAULT_LANGUAGE_DIRECTIVE,
applyOutlineFallbacks,
buildOutlinePrompt,
generateSceneOutlinesFromRequirements,
sanitizeProceduralSkillOutline,
} from './outline-generator.js';
export type {
OutlineFallbackOptions,
OutlineGenerationOptions,
OutlinePromptContext,
} from './outline-generator.js';
export { changeOutlineType } from './outline-type.js';
export { uniquifyMediaElementIds } from './outline-media.js';
export { parseJsonResponse } from './json-repair.js';
export type { JsonParsingOptions } from './json-repair.js';
export { noopGenerationLogger } from './logger.js';
export type { GenerationLogger } from './logger.js';
export type {
ImageMapping,
MediaGenerationRequest,
PdfImage,
SceneOutline,
UserRequirements,
WidgetOutline,
WidgetType,
} from './outline-types.js';
export * from './prompts/index.js';
@@ -0,0 +1,278 @@
/**
* JSON parsing with fallback strategies for AI-generated responses.
*/
import { jsonrepair } from 'jsonrepair';
import { noopGenerationLogger, type GenerationLogger } from './logger.js';
export interface JsonParsingOptions {
logger?: GenerationLogger;
}
function repairQuotedPropertyFragments(jsonStr: string): string {
return jsonStr.replace(
/([,{]\s*)"([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(true|false|null|[+-]?\d+(?:\.\d+)?)"(?=\s*[,}])/g,
(_match, prefix, key, value) => `${prefix}"${key}": ${value}`,
);
}
function logJsonParseError(
stage: string,
jsonStr: string,
error: unknown,
logger: GenerationLogger,
): void {
const message = error instanceof Error ? error.message : String(error);
const positionMatch = message.match(/position\s+(\d+)/i);
const position = positionMatch ? Number(positionMatch[1]) : undefined;
if (typeof position === 'number' && Number.isFinite(position)) {
const start = Math.max(0, position - 120);
const end = Math.min(jsonStr.length, position + 120);
logger.warn(
`${stage} parse error at position ${position}: ${message}. Context: ${jsonStr
.slice(start, end)
.replace(/\n/g, '\\n')}`,
);
return;
}
logger.warn(`${stage} parse error: ${message}`);
}
export function parseJsonResponse<T>(response: string, options: JsonParsingOptions = {}): T | null {
const logger = options.logger ?? noopGenerationLogger;
const exactParsed = tryParseExactJson<T>(response);
if (exactParsed !== null) return exactParsed;
const cleanedResponse = stripReasoningPrefix(response);
if (cleanedResponse !== response.trim()) {
const parsedCleaned = parseJsonResponseCandidate<T>(cleanedResponse, logger);
if (parsedCleaned !== null) return parsedCleaned;
}
const parsed = parseJsonResponseCandidate<T>(response, logger);
if (parsed !== null) return parsed;
logger.error('Failed to parse JSON from response');
logger.error('Raw response (first 500 chars):', cleanedResponse.substring(0, 500));
logger.error(
'Raw response (last 500 chars):',
cleanedResponse.substring(Math.max(0, cleanedResponse.length - 500)),
);
return null;
}
function tryParseExactJson<T>(response: string): T | null {
try {
return JSON.parse(response.trim()) as T;
} catch {
return null;
}
}
function stripReasoningPrefix(response: string): string {
const trimmed = response.trim();
const matches = [...trimmed.matchAll(/<\/(?:think|thinking|reasoning)>\s*/gi)];
const lastMatch = matches.at(-1);
if (!lastMatch || lastMatch.index === undefined) return trimmed;
return trimmed.slice(lastMatch.index + lastMatch[0].length).trim();
}
function parseJsonResponseCandidate<T>(response: string, logger: GenerationLogger): T | null {
const cleanedResponse = response.trim();
// Strategy 1: Try to extract JSON from markdown code blocks (may have multiple)
const codeBlockMatches = cleanedResponse.matchAll(/```(?:json)?\s*([\s\S]*?)```/g);
for (const match of codeBlockMatches) {
const extracted = match[1].trim();
// Only try if it looks like JSON (starts with { or [)
if (extracted.startsWith('{') || extracted.startsWith('[')) {
const result = tryParseJson<T>(extracted, { logger });
if (result !== null) {
logger.debug('Successfully parsed JSON from code block');
return result;
}
}
}
// Strategy 2: Try to find JSON structure directly in response (no code block)
// Look for array or object start
const jsonStartArray = cleanedResponse.indexOf('[');
const jsonStartObject = cleanedResponse.indexOf('{');
if (jsonStartArray !== -1 || jsonStartObject !== -1) {
// Prefer the structure that appears first
const startIndex =
jsonStartArray === -1
? jsonStartObject
: jsonStartObject === -1
? jsonStartArray
: Math.min(jsonStartArray, jsonStartObject);
// Find the matching close bracket
let depth = 0;
let endIndex = -1;
let inString = false;
let escapeNext = false;
for (let i = startIndex; i < cleanedResponse.length; i++) {
const char = cleanedResponse[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\' && inString) {
escapeNext = true;
continue;
}
// escapeNext is always false here: the first branch of the loop resets
// and skips the iteration whenever it was set.
if (char === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (char === '[' || char === '{') depth++;
else if (char === ']' || char === '}') {
depth--;
if (depth === 0) {
endIndex = i;
break;
}
}
}
}
if (endIndex !== -1) {
const jsonStr = cleanedResponse.substring(startIndex, endIndex + 1);
const result = tryParseJson<T>(jsonStr, { logger });
if (result !== null) {
logger.debug('Successfully parsed JSON from response body');
return result;
}
}
}
// Strategy 3: Last resort - try the whole response
const result = tryParseJson<T>(cleanedResponse.trim(), { logger });
if (result !== null) {
logger.debug('Successfully parsed raw response as JSON');
return result;
}
return null;
}
/**
* Try to parse JSON with various fixes for common AI response issues
*/
export function tryParseJson<T>(jsonStr: string, options: JsonParsingOptions = {}): T | null {
const logger = options.logger ?? noopGenerationLogger;
// Attempt 1: Try parsing as-is
try {
return JSON.parse(jsonStr) as T;
} catch (error) {
logJsonParseError('Attempt 1', jsonStr, error, logger);
// Continue to fix attempts
}
// Attempt 2: Fix common JSON issues from AI responses
try {
let fixed = jsonStr;
// Fix 0: Recover malformed property fragments that were accidentally
// emitted as standalone strings inside an object, such as:
// `"height: 76"` -> `"height": 76`
// `"fixedRatio: false"` -> `"fixedRatio": false`
// The object-context prefix/suffix guards keep valid JSON strings intact.
fixed = repairQuotedPropertyFragments(fixed);
// Fix 1: Handle LaTeX-style escapes that break JSON (e.g., \frac, \left, \right, \times, etc.)
// These are common in math content and need to be double-escaped
// Match backslash followed by letters (LaTeX commands) inside strings,
// but skip valid JSON escape sequences (\b, \f, \n, \r, \t, \u)
fixed = fixed.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (_match, content) => {
// Double-escape backslash+letter ONLY for non-JSON-escape letters
const fixedContent = content.replace(/\\([a-zA-Z])/g, (_m: string, ch: string) => {
// Preserve valid JSON escape sequences
if ('bfnrtu'.includes(ch)) return `\\${ch}`;
return `\\\\${ch}`;
});
return `"${fixedContent}"`;
});
// Fix 2: Fix other invalid escape sequences (e.g., \S, \L, etc.)
// Valid JSON escapes: \", \\, \/, \b, \f, \n, \r, \t, \uXXXX
fixed = fixed.replace(/\\([^"\\\/bfnrtu\n\r])/g, (match, char) => {
// If it's a letter, it's likely a LaTeX command
if (/[a-zA-Z]/.test(char)) {
return '\\\\' + char;
}
return match;
});
// Fix 3: Try to fix truncated JSON arrays/objects
const trimmed = fixed.trim();
if (trimmed.startsWith('[') && !trimmed.endsWith(']')) {
const lastCompleteObj = fixed.lastIndexOf('}');
if (lastCompleteObj > 0) {
fixed = fixed.substring(0, lastCompleteObj + 1) + ']';
logger.warn('Fixed truncated JSON array');
}
} else if (trimmed.startsWith('{') && !trimmed.endsWith('}')) {
// Try to close incomplete object
const openBraces = (fixed.match(/{/g) || []).length;
const closeBraces = (fixed.match(/}/g) || []).length;
if (openBraces > closeBraces) {
fixed += '}'.repeat(openBraces - closeBraces);
logger.warn('Fixed truncated JSON object');
}
}
return JSON.parse(fixed) as T;
} catch (error) {
logJsonParseError('Attempt 2', jsonStr, error, logger);
// Continue to next attempt
}
// Attempt 3: Use jsonrepair to fix malformed JSON (e.g. unescaped quotes in Chinese text)
try {
const repaired = jsonrepair(jsonStr);
return JSON.parse(repaired) as T;
} catch (error) {
logJsonParseError('Attempt 3', jsonStr, error, logger);
// Continue to next attempt
}
// Attempt 4: More aggressive fixing - remove control characters
try {
let fixed = jsonStr;
// Remove or escape control characters
fixed = fixed.replace(/[\x00-\x1F\x7F]/g, (char) => {
switch (char) {
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
default:
return '';
}
});
return JSON.parse(fixed) as T;
} catch (error) {
logJsonParseError('Attempt 4', jsonStr, error, logger);
return null;
}
}
@@ -0,0 +1,15 @@
/** Logging surface accepted by generation primitives. */
export interface GenerationLogger {
debug(message: string, ...meta: unknown[]): void;
info(message: string, ...meta: unknown[]): void;
warn(message: string, ...meta: unknown[]): void;
error(message: string, ...meta: unknown[]): void;
}
/** Default logger for consumers that do not inject one. */
export const noopGenerationLogger: GenerationLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
@@ -0,0 +1,38 @@
import type { PdfImage } from './outline-types.js';
export function formatImageDescription(img: PdfImage): string {
let dimInfo = '';
if (img.width && img.height) {
const ratio = (img.width / img.height).toFixed(2);
dimInfo = ` | size: ${img.width}×${img.height} (aspect ratio ${ratio})`;
}
const sourceInfo = img.sourceDocumentName ? ` from ${img.sourceDocumentName}` : ' from PDF';
const desc = img.description ? ` | ${img.description}` : '';
return `- **${img.id}**:${sourceInfo} page ${img.pageNumber}${dimInfo}${desc}`;
}
export function formatImagePlaceholder(img: PdfImage): string {
let dimInfo = '';
if (img.width && img.height) {
const ratio = (img.width / img.height).toFixed(2);
dimInfo = ` | size: ${img.width}×${img.height} (aspect ratio ${ratio})`;
}
const sourceInfo = img.sourceDocumentName ? ` from ${img.sourceDocumentName}` : ' from PDF';
return `- **${img.id}**: image${sourceInfo} page ${img.pageNumber}${dimInfo} [see attached]`;
}
export function sortDocumentImagesForVision<
T extends Pick<PdfImage, 'visionPriority' | 'pageNumber' | 'id'>,
>(images: T[]): T[] {
return [...images].sort((a, b) => {
const priorityDiff = (b.visionPriority ?? 0) - (a.visionPriority ?? 0);
if (priorityDiff !== 0) return priorityDiff;
if (a.pageNumber !== b.pageNumber) return a.pageNumber - b.pageNumber;
const aNumericId = Number(a.id.match(/^img_(\d+)$/)?.[1] ?? Number.NaN);
const bNumericId = Number(b.id.match(/^img_(\d+)$/)?.[1] ?? Number.NaN);
if (Number.isFinite(aNumericId) && Number.isFinite(bNumericId)) {
return aNumericId - bNumericId;
}
return a.id.localeCompare(b.id);
});
}
@@ -0,0 +1,233 @@
/**
* Stage 1: Generate scene outlines from user requirements.
* Also contains outline fallback logic.
*/
import { nanoid } from 'nanoid';
import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from './constants.js';
import { parseJsonResponse } from './json-repair.js';
import { noopGenerationLogger, type GenerationLogger } from './logger.js';
import {
formatImageDescription,
formatImagePlaceholder,
sortDocumentImagesForVision,
} from './outline-formatters.js';
import { uniquifyMediaElementIds } from './outline-media.js';
import type { ImageMapping, PdfImage, SceneOutline, UserRequirements } from './outline-types.js';
import type { AICallFn, GenerationResult } from './pipeline-types.js';
import { buildPrompt, PROMPT_IDS } from './prompts/index.js';
export const DEFAULT_LANGUAGE_DIRECTIVE =
'Teach in the language that matches the user requirement.';
export interface OutlinePromptContext {
pdfText?: string;
pdfImages?: PdfImage[];
visionEnabled?: boolean;
imageMapping?: ImageMapping;
imageGenerationEnabled?: boolean;
videoGenerationEnabled?: boolean;
researchContext?: string;
teacherContext?: string;
}
export interface OutlineGenerationOptions extends Omit<
OutlinePromptContext,
'pdfText' | 'pdfImages'
> {
logger?: GenerationLogger;
}
export interface OutlineFallbackOptions {
allowProceduralSkill?: boolean;
logger?: GenerationLogger;
}
function buildAvailableImages(
pdfImages: PdfImage[] | undefined,
context: OutlinePromptContext,
): { availableImagesText: string; visionImages?: Array<{ id: string; src: string }> } {
let availableImagesText = 'No images available';
let visionImages: Array<{ id: string; src: string }> | undefined;
if (pdfImages && pdfImages.length > 0) {
if (context.visionEnabled && context.imageMapping) {
const sortedImages = sortDocumentImagesForVision(pdfImages);
const allWithSrc = sortedImages.filter((image) => context.imageMapping![image.id]);
const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES);
const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES);
const noSrcImages = sortedImages.filter((image) => !context.imageMapping![image.id]);
const visionDescriptions = visionSlice.map((image) => formatImagePlaceholder(image));
const textDescriptions = [...textOnlySlice, ...noSrcImages].map((image) =>
formatImageDescription(image),
);
availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n');
visionImages = visionSlice.map((image) => ({
id: image.id,
src: context.imageMapping![image.id],
width: image.width,
height: image.height,
}));
} else {
availableImagesText = pdfImages.map((image) => formatImageDescription(image)).join('\n');
}
}
return { availableImagesText, visionImages };
}
/** Build the byte-stable system and user prompts for outline generation. */
export function buildOutlinePrompt(
requirements: UserRequirements,
context: OutlinePromptContext = {},
): { system: string; user: string } {
const { pdfText, pdfImages } = context;
const { availableImagesText } = buildAvailableImages(pdfImages, context);
const userProfileText =
requirements.userNickname || requirements.userBio
? `## Student Profile\n\nStudent: ${requirements.userNickname || 'Unknown'}${requirements.userBio ? ` — ${requirements.userBio}` : ''}\n\nConsider this student's background when designing the course. Adapt difficulty, examples, and teaching approach accordingly.\n\n---`
: '';
const imageEnabled = context.imageGenerationEnabled ?? false;
const videoEnabled = context.videoGenerationEnabled ?? false;
const mediaEnabled = imageEnabled || videoEnabled;
const hasSourceImages = (pdfImages?.length ?? 0) > 0;
const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, {
requirement: requirements.requirement,
pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : 'None',
availableImages: availableImagesText,
userProfile: userProfileText,
hasSourceImages,
imageEnabled,
videoEnabled,
mediaEnabled,
researchContext: context.researchContext || 'None',
teacherContext: context.teacherContext || '',
});
if (!prompts) {
throw new Error('Prompt template not found');
}
return prompts;
}
/** Generate scene outlines from user requirements. */
export async function generateSceneOutlinesFromRequirements(
requirements: UserRequirements,
pdfText: string | undefined,
pdfImages: PdfImage[] | undefined,
aiCall: AICallFn,
options?: OutlineGenerationOptions,
): Promise<
GenerationResult<{ languageDirective: string; courseTitle?: string; outlines: SceneOutline[] }>
> {
const logger = options?.logger ?? noopGenerationLogger;
const context: OutlinePromptContext = { ...options, pdfText, pdfImages };
let prompts: { system: string; user: string };
try {
prompts = buildOutlinePrompt(requirements, context);
} catch (error) {
if (error instanceof Error && error.message === 'Prompt template not found') {
return { success: false, error: 'Prompt template not found' };
}
throw error;
}
const { visionImages } = buildAvailableImages(pdfImages, context);
try {
const response = await aiCall(prompts.system, prompts.user, visionImages);
const parsed = parseJsonResponse<
{ languageDirective: string; courseTitle?: string; outlines: SceneOutline[] } | SceneOutline[]
>(response, { logger });
let languageDirective: string;
let courseTitle: string | undefined;
let rawOutlines: SceneOutline[];
if (Array.isArray(parsed)) {
languageDirective = DEFAULT_LANGUAGE_DIRECTIVE;
rawOutlines = parsed;
} else if (parsed && parsed.outlines) {
languageDirective = parsed.languageDirective || DEFAULT_LANGUAGE_DIRECTIVE;
const rawTitle = parsed.courseTitle;
courseTitle =
typeof rawTitle === 'string' && rawTitle.trim() ? rawTitle.trim().slice(0, 120) : undefined;
rawOutlines = parsed.outlines;
} else {
return { success: false, error: 'Failed to parse scene outlines response' };
}
if (!Array.isArray(rawOutlines)) {
return { success: false, error: 'Failed to parse scene outlines response' };
}
const enriched = rawOutlines.map((outline, index) => ({
...outline,
id: outline.id || nanoid(),
order: index + 1,
}));
const result = uniquifyMediaElementIds(enriched);
return { success: true, data: { languageDirective, courseTitle, outlines: result } };
} catch (error) {
return { success: false, error: String(error) };
}
}
export function sanitizeProceduralSkillOutline(outline: SceneOutline): SceneOutline {
const widgetOutline = { ...(outline.widgetOutline ?? {}) };
delete widgetOutline.procedureType;
delete widgetOutline.task;
delete widgetOutline.tools;
delete widgetOutline.steps;
delete widgetOutline.successCriteria;
delete widgetOutline.errorConsequences;
return {
...outline,
type: 'interactive',
widgetType: 'diagram',
description: outline.description
? `${outline.description} Present this as a process or structure diagram.`
: 'Present this topic as a process or structure diagram.',
widgetOutline,
};
}
export function applyOutlineFallbacks(
outline: SceneOutline,
hasLanguageModel: boolean,
options: OutlineFallbackOptions = {},
): SceneOutline {
const logger = options.logger ?? noopGenerationLogger;
const hasWidgetConfig = outline.widgetType && outline.widgetOutline;
if (outline.widgetType === 'procedural-skill' && !options.allowProceduralSkill) {
logger.warn(
`Procedural-skill outline "${outline.title}" is not enabled, falling back to diagram`,
);
return sanitizeProceduralSkillOutline(outline);
}
if (outline.type === 'interactive' && !outline.interactiveConfig && !hasWidgetConfig) {
logger.warn(
`Interactive outline "${outline.title}" missing interactiveConfig and widget config, falling back to slide`,
);
return { ...outline, type: 'slide' };
}
if (outline.type === 'pbl' && (!outline.pblConfig || !hasLanguageModel)) {
logger.warn(
`PBL outline "${outline.title}" missing pblConfig or languageModel, falling back to slide`,
);
return { ...outline, type: 'slide' };
}
return outline;
}
@@ -0,0 +1,30 @@
import { nanoid } from 'nanoid';
import type { SceneOutline } from './outline-types.js';
/** Replace course-local generated-media IDs with globally unique IDs. */
export function uniquifyMediaElementIds(outlines: SceneOutline[]): SceneOutline[] {
const idMap = new Map<string, string>();
for (const outline of outlines) {
if (!outline.mediaGenerations) continue;
for (const mediaGeneration of outline.mediaGenerations) {
if (!idMap.has(mediaGeneration.elementId)) {
const prefix = mediaGeneration.type === 'video' ? 'gen_vid_' : 'gen_img_';
idMap.set(mediaGeneration.elementId, `${prefix}${nanoid(8)}`);
}
}
}
if (idMap.size === 0) return outlines;
return outlines.map((outline) => {
if (!outline.mediaGenerations) return outline;
return {
...outline,
mediaGenerations: outline.mediaGenerations.map((mediaGeneration) => ({
...mediaGeneration,
elementId: idMap.get(mediaGeneration.elementId) || mediaGeneration.elementId,
})),
};
});
}
@@ -0,0 +1,76 @@
import type { SceneOutline, WidgetOutline } from './outline-types.js';
type SceneType = SceneOutline['type'];
const DEFAULT_QUIZ_CONFIG = {
questionCount: 3,
difficulty: 'medium' as const,
questionTypes: ['single' as const],
};
const MAX_TARGET_SKILLS = 6;
/** Return a new outline valid by construction for the selected scene type. */
export function changeOutlineType(outline: SceneOutline, newType: SceneType): SceneOutline {
if (newType === outline.type) {
return outline;
}
const baseOutline: SceneOutline = {
id: outline.id,
type: newType,
title: outline.title,
description: outline.description,
keyPoints: outline.keyPoints ?? [],
order: outline.order,
...(outline.teachingObjective !== undefined && {
teachingObjective: outline.teachingObjective,
}),
...(outline.estimatedDuration !== undefined && {
estimatedDuration: outline.estimatedDuration,
}),
...(outline.languageNote !== undefined && { languageNote: outline.languageNote }),
...(outline.suggestedImageIds !== undefined && {
suggestedImageIds: outline.suggestedImageIds,
}),
...(outline.mediaGenerations !== undefined && { mediaGenerations: outline.mediaGenerations }),
};
switch (newType) {
case 'quiz':
return { ...baseOutline, quizConfig: outline.quizConfig ?? { ...DEFAULT_QUIZ_CONFIG } };
case 'interactive': {
if (outline.widgetType && outline.widgetOutline) {
return {
...baseOutline,
widgetType: outline.widgetType,
widgetOutline: outline.widgetOutline,
};
}
const widgetOutline: WidgetOutline = { concept: outline.title || '' };
return { ...baseOutline, widgetType: 'simulation', widgetOutline };
}
case 'pbl': {
if (outline.pblConfig?.projectTopic) {
return { ...baseOutline, pblConfig: outline.pblConfig };
}
const targetSkills = Array.from(new Set((outline.keyPoints ?? []).filter(Boolean))).slice(
0,
MAX_TARGET_SKILLS,
);
return {
...baseOutline,
pblConfig: {
projectTopic: outline.title || '',
projectDescription: outline.description || '',
targetSkills,
},
};
}
case 'slide':
default:
return baseOutline;
}
}
@@ -0,0 +1,111 @@
/** Image extracted from a source document with metadata used by outline prompts. */
export interface PdfImage {
id: string;
src: string;
pageNumber: number;
description?: string;
storageId?: string;
width?: number;
height?: number;
originalId?: string;
sourceDocumentId?: string;
sourceDocumentName?: string;
sourceDocumentOrder?: number;
visionPriority?: number;
}
export type ImageMapping = Record<string, string>;
/** Free-form requirements accepted by outline generation. */
export interface UserRequirements {
requirement: string;
userNickname?: string;
userBio?: string;
webSearch?: boolean;
interactiveMode?: boolean;
taskEngineMode?: boolean;
}
export type WidgetType =
| 'simulation'
| 'diagram'
| 'code'
| 'game'
| 'visualization3d'
| 'procedural-skill';
export interface WidgetOutline {
concept?: string;
keyVariables?: string[];
diagramType?: 'flowchart' | 'mindmap' | 'hierarchy' | 'system';
language?: 'python' | 'javascript' | 'typescript' | 'java' | 'cpp';
gameType?: 'quiz' | 'puzzle' | 'strategy' | 'card' | 'action';
visualizationType?: 'molecular' | 'solar' | 'anatomy' | 'geometry' | 'physics' | 'custom';
objects?: string[];
interactions?: string[];
procedureType?: 'repair' | 'assembly' | 'inspection' | 'operation' | 'custom';
task?: string;
tools?: string[];
steps?: string[];
successCriteria?: string[];
errorConsequences?: string[];
challenge?: string;
playerControls?: string[];
nodeCount?: number;
nodes?: Array<{
id: string;
label: string;
parentId?: string;
icon?: string;
details?: string;
}>;
challengeType?: string;
}
export interface MediaGenerationRequest {
type: 'image' | 'video';
prompt: string;
elementId: string;
aspectRatio?: '16:9' | '4:3' | '1:1' | '9:16';
style?: string;
}
/** A generation-ready description of one course scene. */
export interface SceneOutline {
id: string;
type: 'slide' | 'quiz' | 'interactive' | 'pbl';
title: string;
description: string;
keyPoints: string[];
teachingObjective?: string;
estimatedDuration?: number;
order: number;
languageNote?: string;
suggestedImageIds?: string[];
mediaGenerations?: MediaGenerationRequest[];
quizConfig?: {
questionCount: number;
difficulty: 'easy' | 'medium' | 'hard';
questionTypes: ('single' | 'multiple' | 'text')[];
};
/**
* @deprecated Use widgetType + widgetOutline instead
* Legacy interactive config - kept for backward compatibility only
*/
interactiveConfig?: {
conceptName: string;
conceptOverview: string;
designIdea: string;
subject?: string;
};
pblConfig?: {
projectTopic: string;
projectDescription: string;
targetSkills: string[];
issueCount?: number;
scenarioRoleplay?: boolean;
scenarioBrief?: string;
};
widgetType?: WidgetType;
widgetOutline?: WidgetOutline;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,106 @@
// Behavior-parity port of tests/generation/json-repair.test.ts.
import { describe, expect, it } from 'vitest';
import { parseJsonResponse } from '@openmaic/generation';
describe('json-repair targeted fixes', () => {
it('repairs quoted key-value fragments such as "height: 76"', () => {
const raw = `{
"background": {
"type": "solid",
"color": "#ffffff"
},
"elements": [
{
"id": "code_text",
"type": "text",
"left": 80,
"top": 420,
"width": 840,
"height: 76",
"content": "<p style=\\"font-size: 22px;\\">age = 25</p>",
"defaultFontName": "",
"defaultColor": "#333333"
}
]
}`;
const parsed = parseJsonResponse<{
elements: Array<{ height: number; content: string }>;
}>(raw);
expect(parsed).not.toBeNull();
expect(parsed?.elements[0]?.height).toBe(76);
expect(parsed?.elements[0]?.content).toContain('age = 25');
});
it('repairs boolean property fragments without touching valid string values', () => {
const raw = `{
"elements": [
{
"id": "shape_1",
"fixedRatio: false",
"height: 58",
"content": "<p>literal text: height: 58</p>"
}
]
}`;
const parsed = parseJsonResponse<{
elements: Array<{ fixedRatio: boolean; height: number; content: string }>;
}>(raw);
expect(parsed).not.toBeNull();
expect(parsed?.elements[0]?.fixedRatio).toBe(false);
expect(parsed?.elements[0]?.height).toBe(58);
expect(parsed?.elements[0]?.content).toBe('<p>literal text: height: 58</p>');
});
it('strips reasoning prefix ending with an unpaired closing think tag before JSON', () => {
const raw = `reasoning prose with {not json} and [not json] </think>
{"ok": true}`;
const parsed = parseJsonResponse<{ ok: boolean }>(raw);
expect(parsed).toEqual({ ok: true });
});
it('prefers the final payload after an unpaired closing tag with parseable draft JSON', () => {
const raw = `reasoning draft {"draft": true} </think>
{"ok": true}`;
const parsed = parseJsonResponse<{ ok: boolean }>(raw);
expect(parsed).toEqual({ ok: true });
});
it('prefers the final payload after a reasoning block with parseable draft JSON', () => {
const raw = `<think>{"draft": true}</think>
{"ok": true}`;
const parsed = parseJsonResponse<{ ok: boolean }>(raw);
expect(parsed).toEqual({ ok: true });
});
it('prefers the final payload after a reasoning block with fenced draft JSON', () => {
const raw = `<think>
\`\`\`json
{"draft": true}
\`\`\`
</think>
{"ok": true}`;
const parsed = parseJsonResponse<{ ok: boolean }>(raw);
expect(parsed).toEqual({ ok: true });
});
it('preserves literal think tags inside valid JSON strings', () => {
const raw = '{"text":"literal <think>keep me</think>"}';
const parsed = parseJsonResponse<{ text: string }>(raw);
expect(parsed).toEqual({ text: 'literal <think>keep me</think>' });
});
});
@@ -0,0 +1,224 @@
// Behavior-parity port of lib/generation/outline-generator.ts assertions from
// tests/generation/media-prompt-wiring.test.ts and procedural-skill-content-gates.test.ts.
import { describe, expect, test, vi } from 'vitest';
import {
DEFAULT_LANGUAGE_DIRECTIVE,
applyOutlineFallbacks,
generateSceneOutlinesFromRequirements,
sanitizeProceduralSkillOutline,
type AICallFn,
type GenerationLogger,
type SceneOutline,
type UserRequirements,
} from '@openmaic/generation';
const baseOutline: SceneOutline = {
id: 'scene_1',
type: 'slide',
title: 'Photosynthesis',
description: 'How plants make food',
keyPoints: ['light', 'water', 'carbon dioxide'],
order: 99,
};
describe('generateSceneOutlinesFromRequirements', () => {
test('returns enriched outlines from a valid wrapped response', async () => {
const aiCall: AICallFn = vi.fn(async () =>
JSON.stringify({
languageDirective: 'Teach in English.',
courseTitle: 'Photosynthesis Basics',
outlines: [{ ...baseOutline, id: '', order: 42 }],
}),
);
const result = await generateSceneOutlinesFromRequirements(
{ requirement: 'Teach photosynthesis' },
undefined,
undefined,
aiCall,
);
expect(result.success).toBe(true);
expect(result.data?.languageDirective).toBe('Teach in English.');
expect(result.data?.courseTitle).toBe('Photosynthesis Basics');
expect(result.data?.outlines[0]?.id).toBeTruthy();
expect(result.data?.outlines[0]?.order).toBe(1);
});
test('integrates repairable JSON parsing', async () => {
const response = `{
"languageDirective": "Teach in English.",
"courseTitle": "Repair",
"outlines": [{
"id": "scene_1",
"type": "slide",
"title": "Repairable",
"description": "A repaired response",
"keyPoints": ["one"],
"order: 7"
}]
}`;
const result = await generateSceneOutlinesFromRequirements(
{ requirement: 'Test repair' },
undefined,
undefined,
async () => response,
);
expect(result.success).toBe(true);
expect(result.data?.outlines).toMatchObject([{ title: 'Repairable', order: 1 }]);
});
test('supports the legacy flat-array response with a default language directive', async () => {
const result = await generateSceneOutlinesFromRequirements(
{ requirement: 'Teach photosynthesis' },
undefined,
undefined,
async () => JSON.stringify([baseOutline]),
);
expect(result.success).toBe(true);
expect(result.data?.languageDirective).toBe(DEFAULT_LANGUAGE_DIRECTIVE);
});
test('passes media enable flags into prompt conditionals', async () => {
let capturedPrompt = '';
const aiCall: AICallFn = async (system, user) => {
capturedPrompt = `${system}\n${user}`;
return JSON.stringify({
languageDirective: 'Teach in English.',
courseTitle: 'Evaporation',
outlines: [],
});
};
const requirements: UserRequirements = {
requirement: 'Teach evaporation with an animation',
};
const result = await generateSceneOutlinesFromRequirements(
requirements,
undefined,
undefined,
aiCall,
{ imageGenerationEnabled: false, videoGenerationEnabled: true },
);
expect(result.success).toBe(true);
expect(capturedPrompt).toContain('gen_vid_1');
expect(capturedPrompt).not.toContain('gen_img_');
expect(capturedPrompt).not.toContain('suggestedImageIds');
expect(capturedPrompt).not.toContain('{{');
});
const requirements: UserRequirements = { requirement: 'Teach photosynthesis' };
async function runWith(raw: unknown) {
return generateSceneOutlinesFromRequirements(requirements, undefined, undefined, async () =>
JSON.stringify(raw),
);
}
test('trims and caps a string courseTitle', async () => {
const result = await runWith({
languageDirective: 'Teach in English.',
courseTitle: ` ${'A '.repeat(80)} `,
outlines: [],
});
expect(result.data?.courseTitle?.length).toBeLessThanOrEqual(120);
expect(result.data?.courseTitle?.startsWith(' ')).toBe(false);
});
test.each([
[{ languageDirective: 'Teach in English.', outlines: [] }],
[{ languageDirective: 'Teach in English.', courseTitle: ' ', outlines: [] }],
[{ languageDirective: 'Teach in English.', courseTitle: 123, outlines: [] }],
])('omits a missing, empty, or non-string courseTitle', async (raw) => {
const result = await runWith(raw);
expect(result.success).toBe(true);
expect(result.data?.courseTitle).toBeUndefined();
});
});
describe('outline fallbacks', () => {
test('downgrades incomplete interactive and PBL outlines', () => {
expect(applyOutlineFallbacks({ ...baseOutline, type: 'interactive' }, true).type).toBe('slide');
expect(applyOutlineFallbacks({ ...baseOutline, type: 'pbl' }, true).type).toBe('slide');
expect(
applyOutlineFallbacks(
{
...baseOutline,
type: 'pbl',
pblConfig: { projectTopic: 'Garden', projectDescription: 'Grow it', targetSkills: [] },
},
false,
).type,
).toBe('slide');
});
test('keeps configured interactive and PBL outlines when a language model is present', () => {
const interactive = {
...baseOutline,
type: 'interactive' as const,
widgetType: 'diagram' as const,
widgetOutline: { concept: 'Cycle' },
};
const pbl = {
...baseOutline,
type: 'pbl' as const,
pblConfig: { projectTopic: 'Garden', projectDescription: 'Grow it', targetSkills: [] },
};
expect(applyOutlineFallbacks(interactive, true)).toBe(interactive);
expect(applyOutlineFallbacks(pbl, true)).toBe(pbl);
});
test('logs a fallback through the injected structural logger', () => {
const warn = vi.fn();
const logger: GenerationLogger = {
debug: vi.fn(),
info: vi.fn(),
warn,
error: vi.fn(),
};
applyOutlineFallbacks({ ...baseOutline, type: 'interactive' }, true, { logger });
expect(warn).toHaveBeenCalledOnce();
});
});
describe('sanitizeProceduralSkillOutline', () => {
const procedural: SceneOutline = {
...baseOutline,
type: 'interactive',
widgetType: 'procedural-skill',
widgetOutline: {
concept: 'calibration procedure',
procedureType: 'operation',
task: 'Calibrate a device',
tools: ['meter'],
steps: ['inspect'],
successCriteria: ['within range'],
errorConsequences: ['stop'],
interactions: ['inspect details'],
},
};
test('strips every task-engine field and preserves unrelated widget fields', () => {
const safe = sanitizeProceduralSkillOutline(procedural);
expect(safe.widgetType).toBe('diagram');
expect(safe.widgetOutline).toEqual({
concept: 'calibration procedure',
interactions: ['inspect details'],
});
expect(safe.description).toContain('Present this as a process or structure diagram.');
});
test('uses the fallback description when the source description is empty', () => {
expect(sanitizeProceduralSkillOutline({ ...procedural, description: '' }).description).toBe(
'Present this topic as a process or structure diagram.',
);
});
test('retains procedural-skill only when explicitly allowed', () => {
expect(applyOutlineFallbacks(procedural, true, { allowProceduralSkill: true }).widgetType).toBe(
'procedural-skill',
);
});
});
@@ -0,0 +1,42 @@
// Behavior-parity coverage for uniquifyMediaElementIds from lib/generation/scene-builder.ts.
import { describe, expect, test } from 'vitest';
import { uniquifyMediaElementIds, type SceneOutline } from '@openmaic/generation';
describe('uniquifyMediaElementIds', () => {
test('replaces colliding generated IDs consistently without mutating the input', () => {
const outlines: SceneOutline[] = [1, 2].map((order) => ({
id: `scene_${order}`,
type: 'slide',
title: `Scene ${order}`,
description: 'Description',
keyPoints: [],
order,
mediaGenerations: [
{ type: 'image', prompt: 'A diagram', elementId: 'gen_img_1' },
{ type: 'video', prompt: 'A clip', elementId: `custom_${order}` },
],
}));
const result = uniquifyMediaElementIds(outlines);
const firstId = result[0]?.mediaGenerations?.[0]?.elementId;
expect(firstId).toMatch(/^gen_img_[A-Za-z0-9_-]{8}$/);
expect(result[1]?.mediaGenerations?.[0]?.elementId).toBe(firstId);
expect(result[0]?.mediaGenerations?.[1]?.elementId).toMatch(/^gen_vid_[A-Za-z0-9_-]{8}$/);
expect(outlines[0]?.mediaGenerations?.[0]?.elementId).toBe('gen_img_1');
});
test('returns the original array when no media IDs exist', () => {
const outlines: SceneOutline[] = [
{
id: 'scene',
type: 'slide',
title: 'Scene',
description: 'Description',
keyPoints: [],
order: 1,
},
];
expect(uniquifyMediaElementIds(outlines)).toBe(outlines);
});
});
@@ -0,0 +1,82 @@
// Behavior-parity golden guard for prompt construction in lib/generation/outline-generator.ts.
import { describe, expect, test } from 'vitest';
import { buildOutlinePrompt } from '@openmaic/generation';
describe('buildOutlinePrompt golden output', () => {
test('pins every conditional off', () => {
expect(
buildOutlinePrompt(
{ requirement: 'Teach recursion to beginners' },
{ researchContext: '', teacherContext: '' },
),
).toMatchSnapshot();
});
test('pins every conditional on', () => {
expect(
buildOutlinePrompt(
{
requirement: '用中文讲解光合作用',
userNickname: 'Lin',
userBio: 'Middle-school learner',
},
{
pdfText: 'Source notes about chlorophyll.',
pdfImages: [
{
id: 'img_2',
src: '',
pageNumber: 2,
width: 800,
height: 600,
description: 'Leaf cross-section',
sourceDocumentName: 'biology.pdf',
visionPriority: 3,
},
],
visionEnabled: true,
imageMapping: { img_2: 'data:image/png;base64,AAAA' },
imageGenerationEnabled: true,
videoGenerationEnabled: true,
researchContext: 'A current source summary.',
teacherContext: 'Teacher Persona:\nUse a Socratic style.',
},
),
).toMatchSnapshot();
});
test('pins image and media conditionals on with video off', () => {
expect(
buildOutlinePrompt(
{ requirement: 'Explain the water cycle with generated diagrams' },
{
imageGenerationEnabled: true,
videoGenerationEnabled: false,
},
),
).toMatchSnapshot();
});
test('pins source-image conditionals on with generated media off', () => {
expect(
buildOutlinePrompt(
{ requirement: 'Explain the labeled anatomy diagram' },
{
pdfImages: [
{
id: 'source_1',
src: '',
pageNumber: 4,
width: 1200,
height: 900,
description: 'Labeled cross-section of a plant cell',
sourceDocumentName: 'cell-biology.pdf',
},
],
imageGenerationEnabled: false,
videoGenerationEnabled: false,
},
),
).toMatchSnapshot();
});
});
@@ -0,0 +1,129 @@
// Behavior-parity port of tests/generation/outline-type.test.ts.
import { describe, expect, it } from 'vitest';
import { applyOutlineFallbacks, changeOutlineType, type SceneOutline } from '@openmaic/generation';
const base: SceneOutline = {
id: 'a',
type: 'slide',
title: 'Photosynthesis',
description: 'How plants make food',
keyPoints: ['light', 'water', 'CO2'],
order: 1,
};
describe('changeOutlineType', () => {
it('seeds widget config when switching to interactive', () => {
const r = changeOutlineType(base, 'interactive');
expect(r.type).toBe('interactive');
expect(r.widgetType).toBe('simulation');
expect(r.widgetOutline?.concept).toBe('Photosynthesis');
});
it('seeds pblConfig from shared fields when switching to pbl', () => {
const r = changeOutlineType(base, 'pbl');
expect(r.type).toBe('pbl');
expect(r.pblConfig?.projectTopic).toBe('Photosynthesis');
expect(r.pblConfig?.projectDescription).toBe('How plants make food');
expect(r.pblConfig?.targetSkills).toEqual(['light', 'water', 'CO2']);
});
it('strips foreign config when switching away', () => {
const interactive = changeOutlineType(base, 'interactive');
const slide = changeOutlineType(interactive, 'slide');
expect(slide.type).toBe('slide');
expect(slide.widgetType).toBeUndefined();
expect(slide.widgetOutline).toBeUndefined();
});
it('seeds default quizConfig when switching to quiz', () => {
const r = changeOutlineType(base, 'quiz');
expect(r.quizConfig).toEqual({
questionCount: 3,
difficulty: 'medium',
questionTypes: ['single'],
});
});
it('keeps an existing valid pblConfig instead of overwriting', () => {
const withPbl = changeOutlineType(
{
...base,
type: 'pbl',
pblConfig: {
projectTopic: 'Custom',
projectDescription: 'd',
targetSkills: ['x'],
scenarioRoleplay: true,
scenarioBrief: 'A realistic conversation.',
},
},
'pbl',
);
expect(withPbl.pblConfig?.projectTopic).toBe('Custom');
expect(withPbl.pblConfig?.scenarioRoleplay).toBe(true);
expect(withPbl.pblConfig?.scenarioBrief).toBe('A realistic conversation.');
});
it('preserves shared fields', () => {
const r = changeOutlineType(base, 'pbl');
expect(r.id).toBe('a');
expect(r.title).toBe('Photosynthesis');
expect(r.order).toBe(1);
});
// The bug-fix invariant: editor-produced interactive/pbl outlines must survive
// applyOutlineFallbacks (which otherwise degrades config-less ones to slide).
it('produces outlines that survive applyOutlineFallbacks', () => {
expect(applyOutlineFallbacks(changeOutlineType(base, 'interactive'), true).type).toBe(
'interactive',
);
expect(applyOutlineFallbacks(changeOutlineType(base, 'pbl'), true).type).toBe('pbl');
});
it('preserves an existing procedural-skill widget config instead of downgrading it', () => {
const proc = changeOutlineType(
{
...base,
type: 'interactive',
widgetType: 'procedural-skill',
widgetOutline: {
concept: 'Brake repair',
procedureType: 'repair',
steps: ['loosen', 'replace'],
},
},
'interactive',
);
expect(proc.widgetType).toBe('procedural-skill');
expect(proc.widgetOutline?.steps).toEqual(['loosen', 'replace']);
});
it('is a no-op when the type is unchanged (preserves non-seeded fields)', () => {
const interactive: SceneOutline = {
...base,
type: 'interactive',
widgetType: 'simulation',
widgetOutline: { concept: 'X' },
interactiveConfig: { conceptName: 'X', conceptOverview: 'o', designIdea: 'd' },
};
// Same reference back — re-selecting the current type touches nothing.
expect(changeOutlineType(interactive, 'interactive')).toBe(interactive);
const partialPbl: SceneOutline = {
...base,
type: 'pbl',
pblConfig: { projectTopic: '', projectDescription: 'keep me', targetSkills: ['s'] },
};
const r = changeOutlineType(partialPbl, 'pbl');
expect(r.pblConfig?.projectDescription).toBe('keep me');
expect(r.pblConfig?.targetSkills).toEqual(['s']);
});
it('dedupes and caps seeded pbl targetSkills', () => {
const many = changeOutlineType(
{ ...base, keyPoints: ['a', 'a', 'b', 'c', 'd', 'e', 'f', 'g'] },
'pbl',
);
expect(many.pblConfig?.targetSkills).toEqual(['a', 'b', 'c', 'd', 'e', 'f']);
});
});
+7
View File
@@ -452,6 +452,13 @@ importers:
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(jsdom@29.1.1(@noble/hashes@1.8.0)(canvas@3.2.3))(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@8.0.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@22.19.15)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0))
packages/@openmaic/generation:
dependencies:
jsonrepair:
specifier: ^3.13.2
version: 3.13.3
nanoid:
specifier: ^5.1.6
version: 5.1.14
devDependencies:
typescript:
specifier: ^5
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
import { createServer } from 'node:http';
function readPort(argv) {
if (argv.length !== 2 || argv[0] !== '--port' || !/^\d+$/.test(argv[1])) {
throw new Error('Usage: node generation-node-smoke-server.mjs --port <port>');
}
const port = Number(argv[1]);
if (port < 1 || port > 65_535) throw new Error(`Invalid port: ${argv[1]}`);
return port;
}
const port = readPort(process.argv.slice(2));
const outlinePayload = JSON.stringify({
languageDirective: 'Teach in English with concise explanations.',
courseTitle: 'Node Smoke Course',
outlines: [
{
id: 'scene_1',
type: 'slide',
title: 'Smoke-Test Outline',
description: 'Confirms outline generation through an OpenAI-compatible endpoint.',
keyPoints: ['Package import', 'HTTP model seam', 'Outline validation'],
order: 1,
},
],
});
const server = createServer((request, response) => {
if (request.method === 'GET' && request.url === '/health') {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
return;
}
if (request.method !== 'POST' || request.url !== '/chat/completions') {
response.writeHead(404, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: 'not found' }));
return;
}
let body = '';
request.setEncoding('utf8');
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
try {
const payload = JSON.parse(body);
if (!payload.model || !Array.isArray(payload.messages)) {
throw new Error('model and messages are required');
}
response.writeHead(200, { 'content-type': 'application/json' });
response.end(
JSON.stringify({
id: 'chatcmpl-generation-smoke',
object: 'chat.completion',
choices: [{ index: 0, message: { role: 'assistant', content: outlinePayload } }],
}),
);
} catch (error) {
response.writeHead(400, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: String(error) }));
}
});
});
server.listen(port, '127.0.0.1', () => {
console.log(`Generation smoke server listening on http://127.0.0.1:${port}`);
});
const close = () => server.close(() => process.exit(0));
process.on('SIGINT', close);
process.on('SIGTERM', close);
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
import { generateSceneOutlinesFromRequirements } from '@openmaic/generation';
function parseFlags(argv) {
const values = new Map();
for (let index = 0; index < argv.length; index += 2) {
const flag = argv[index];
const value = argv[index + 1];
if (!flag?.startsWith('--') || value === undefined || value.startsWith('--')) {
throw new Error(`Expected a value after ${flag ?? 'the final flag'}`);
}
if (!['--requirement', '--endpoint', '--model', '--api-key'].includes(flag)) {
throw new Error(`Unknown flag: ${flag}`);
}
values.set(flag, value);
}
for (const required of ['--requirement', '--endpoint', '--model']) {
if (!values.get(required)) throw new Error(`Missing required flag: ${required}`);
}
return values;
}
const flags = parseFlags(process.argv.slice(2));
const endpoint = flags.get('--endpoint').replace(/\/+$/, '');
const model = flags.get('--model');
const apiKey = flags.get('--api-key');
const logger = {
debug: () => undefined,
info: () => undefined,
warn: (...args) => console.warn(...args),
error: (...args) => console.error(...args),
};
const aiCall = async (systemPrompt, userPrompt) => {
const headers = { 'content-type': 'application/json' };
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const response = await fetch(`${endpoint}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
}),
});
if (!response.ok) {
throw new Error(`Model endpoint returned ${response.status}: ${await response.text()}`);
}
const payload = await response.json();
const content = payload?.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('Model endpoint response did not contain choices[0].message.content');
}
return content;
};
const result = await generateSceneOutlinesFromRequirements(
{ requirement: flags.get('--requirement') },
undefined,
undefined,
aiCall,
{ logger },
);
if (!result.success || !result.data) {
throw new Error(result.error || 'Outline generation failed');
}
if (result.data.outlines.length === 0) {
throw new Error('Outline generation returned no outlines');
}
for (const outline of result.data.outlines) {
if (!outline.title || !outline.type) {
throw new Error('Outline generation returned an outline without a title or type');
}
}
console.log(JSON.stringify(result.data, null, 2));
// Part C: scene generation extends here