mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 09:24:43 +08:00
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:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Generated
+7
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user