@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
558 lines (557 loc) • 19.7 kB
JavaScript
/**
* PPT Content Planner
*
* Generates structured slide content plan using AI.
* Takes a topic and configuration, returns a detailed ContentPlan JSON.
*
* Follows the video handler pattern (vertexVideoHandler.ts):
* - Standalone module with clear responsibility
* - Uses AI provider for generation
* - Returns structured result
*
* @module presentation/contentPlanner
*/
import { PPTError, PPT_ERROR_CODES } from "./pptError.js";
import { CONTENT_PLANNING_SYSTEM_PROMPT, buildContentPlanningPrompt, CONTENT_PLANNING_TIMEOUT_MS, SLIDE_TYPE_TO_LAYOUT, } from "./constants.js";
import { normalizeSlideWithInference, applyBulletStyleToContent, } from "./slideTypeInference.js";
import { logger } from "../../utils/logger.js";
// ============================================================================
// VALID TYPE/LAYOUT CONSTANTS
// ============================================================================
/**
* All valid slide types (must match SlideType in pptTypes.ts)
*/
const VALID_SLIDE_TYPES = new Set([
// Opening/Closing
"title",
"section-header",
"thank-you",
"closing",
// Content
"content",
"agenda",
"bullets",
"numbered-list",
// Visual
"image-focus",
"image-left",
"image-right",
"full-bleed-image",
"gallery",
// Layout
"two-column",
"three-column",
"split-content",
// Data
"table",
"chart-bar",
"chart-line",
"chart-pie",
"chart-area",
"statistics",
// Special
"quote",
"timeline",
"process-flow",
"comparison",
"features",
"team",
"icons",
"conclusion",
"blank",
// Composite/Dashboard (multiple content types)
"dashboard",
"mixed-content",
"stats-grid",
"icon-grid",
]);
/**
* All valid slide layouts (must match SlideLayout in pptTypes.ts)
*/
const VALID_SLIDE_LAYOUTS = new Set([
// Title
"title-centered",
"title-bottom",
"title-left-aligned",
// Content
"title-content",
"title-content-footer",
"content-only",
// Image
"image-left-content-right",
"image-right-content-left",
"image-top-content-bottom",
"image-bottom-content-top",
"image-full-overlay",
"image-centered",
"image-grid-2x2",
// Column
"two-column-equal",
"two-column-wide-left",
"two-column-wide-right",
"three-column-equal",
// Data
"chart-full",
"chart-with-bullets",
"table-full",
"table-with-notes",
// Special
"quote-centered",
"quote-with-image",
"statistics-row",
"statistics-grid",
"timeline-horizontal",
"timeline-vertical",
"process-horizontal",
"process-vertical",
"comparison-side-by-side",
"comparison-table",
"team-grid",
"icon-grid",
"summary-bullets",
"contact-info",
"blank-full",
]);
/**
* Validate and normalize slide type
*/
function validateSlideType(type, fallback = "content") {
if (typeof type === "string" && VALID_SLIDE_TYPES.has(type)) {
return type;
}
logger.warn(`[ContentPlanner] Invalid slide type "${type}", using "${fallback}"`);
return fallback;
}
/**
* Validate and normalize slide layout
*/
function validateSlideLayout(layout, slideType) {
// If valid layout, use it
if (typeof layout === "string" &&
VALID_SLIDE_LAYOUTS.has(layout)) {
return layout;
}
// Get default layout for this slide type
const defaultLayouts = SLIDE_TYPE_TO_LAYOUT[slideType];
const fallback = defaultLayouts?.[0] || "title-content";
logger.warn(`[ContentPlanner] Invalid layout "${layout}" for type "${slideType}", using "${fallback}"`);
return fallback;
}
// ============================================================================
// CONTENT NORMALIZATION (Hybrid Bullet Formatting)
// Handles various AI output formats and converts to expected structure
// ============================================================================
/** Valid bullet styles for validation */
const VALID_BULLET_STYLES = new Set([
"disc",
"number",
"checkmark",
"arrow",
"dash",
"none",
]);
/**
* Normalize a single bullet item from AI output
* Handles: string, {text: string}, or malformed objects
*/
function normalizeBulletItem(item) {
// Case 1: Already a valid BulletPoint object
if (item && typeof item === "object" && "text" in item) {
const obj = item;
const normalized = {
text: String(obj.text || ""),
};
// Preserve optional fields if valid
if (Array.isArray(obj.subBullets)) {
normalized.subBullets = obj.subBullets.map((s) => String(s));
}
if (typeof obj.icon === "string") {
normalized.icon = obj.icon;
}
if (typeof obj.emphasis === "boolean") {
normalized.emphasis = obj.emphasis;
}
// Preserve AI-specified formatting if valid
if (typeof obj.fontSize === "number" &&
obj.fontSize >= 8 &&
obj.fontSize <= 48) {
normalized.fontSize = obj.fontSize;
}
if (typeof obj.bulletStyle === "string" &&
VALID_BULLET_STYLES.has(obj.bulletStyle)) {
normalized.bulletStyle = obj.bulletStyle;
}
if (typeof obj.color === "string" &&
obj.color.match(/^#?[0-9A-Fa-f]{6}$/)) {
normalized.color = obj.color.startsWith("#")
? obj.color
: `#${obj.color}`;
}
if (typeof obj.bold === "boolean") {
normalized.bold = obj.bold;
}
return normalized.text ? normalized : null;
}
// Case 2: Plain string - convert to BulletPoint
if (typeof item === "string" && item.trim()) {
return { text: item.trim() };
}
// Case 3: Invalid - skip
return null;
}
/**
* Normalize an array of bullets from AI output
* Handles: string[], {text: string}[], or mixed arrays
*/
function normalizeBullets(bullets) {
if (!Array.isArray(bullets)) {
return [];
}
const normalized = [];
for (const item of bullets) {
const bullet = normalizeBulletItem(item);
if (bullet) {
normalized.push(bullet);
}
}
return normalized;
}
/**
* Normalize slide content from AI output
* Ensures bullets are in correct format, preserves other content fields
*/
function normalizeSlideContent(content) {
if (!content || typeof content !== "object") {
return {};
}
const raw = content;
const normalized = {};
// Normalize main bullets array
if (raw.bullets) {
normalized.bullets = normalizeBullets(raw.bullets);
}
// Normalize column bullets (for two-column, three-column layouts)
if (raw.leftColumn && typeof raw.leftColumn === "object") {
const col = raw.leftColumn;
normalized.leftColumn = {
title: typeof col.title === "string" ? col.title : undefined,
bullets: col.bullets ? normalizeBullets(col.bullets) : undefined,
image: typeof col.image === "string" ? col.image : undefined,
};
}
if (raw.rightColumn && typeof raw.rightColumn === "object") {
const col = raw.rightColumn;
normalized.rightColumn = {
title: typeof col.title === "string" ? col.title : undefined,
bullets: col.bullets ? normalizeBullets(col.bullets) : undefined,
image: typeof col.image === "string" ? col.image : undefined,
};
}
if (raw.centerColumn && typeof raw.centerColumn === "object") {
const col = raw.centerColumn;
normalized.centerColumn = {
title: typeof col.title === "string" ? col.title : undefined,
bullets: col.bullets ? normalizeBullets(col.bullets) : undefined,
image: typeof col.image === "string" ? col.image : undefined,
};
}
// Pass through other content fields as-is (quote, statistics, chartData, etc.)
// Using keyof SlideContent for type safety
const passThrough = [
"subtitle",
"body",
"sectionNumber",
"quote",
"quoteAuthor",
"quoteAuthorTitle",
"caption",
"galleryImages",
"statistics",
"chartData",
"tableData",
"timeline",
"processSteps",
"features",
"icons",
"teamMembers",
"comparison",
"nextSteps",
"cta",
"ctaButton",
"contactInfo",
"layoutOptions",
"dashboard",
];
for (const key of passThrough) {
if (raw[key] !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
normalized[key] = raw[key];
}
}
return normalized;
}
// ============================================================================
// CONTENT PLAN VALIDATION
// ============================================================================
/**
* Validate the structure of AI-generated content plan
*/
function validateContentPlan(plan, expectedSlides) {
if (!plan || typeof plan !== "object") {
throw new PPTError("AI returned invalid response: expected object", PPT_ERROR_CODES.INVALID_AI_RESPONSE, { received: typeof plan });
}
const p = plan;
// Validate required fields
if (typeof p.title !== "string" || !p.title.trim()) {
throw new PPTError("AI response missing valid 'title' field", PPT_ERROR_CODES.INVALID_AI_RESPONSE, { field: "title", received: p.title });
}
if (!Array.isArray(p.slides)) {
throw new PPTError("AI response missing 'slides' array", PPT_ERROR_CODES.INVALID_AI_RESPONSE, { field: "slides", received: typeof p.slides });
}
if (p.slides.length !== expectedSlides) {
logger.warn(`[ContentPlanner] AI returned ${p.slides.length} slides, expected ${expectedSlides}. Adjusting...`);
// Don't throw - we'll work with what we got
}
// Validate each slide
const validatedSlides = [];
for (let i = 0; i < p.slides.length; i++) {
const slide = p.slides[i];
if (!slide || typeof slide !== "object") {
throw new PPTError(`Invalid slide at index ${i}`, PPT_ERROR_CODES.INVALID_AI_RESPONSE, { index: i, received: typeof slide });
}
// Validate required slide fields
if (typeof slide.title !== "string") {
slide.title = `Slide ${i + 1}`;
logger.warn(`[ContentPlanner] Slide ${i + 1} missing title, using default`);
}
// Validate and normalize type with proper type checking
const validatedType = validateSlideType(slide.type, "content");
// Normalize content - handles string bullets, mixed formats, etc.
const normalizedContent = normalizeSlideContent(slide.content);
// Smart inference: Infer slide type and bullet style from title keywords
// This helps when AI doesn't explicitly specify the right type
const { type: inferredType, bulletStyle, wasInferred, } = normalizeSlideWithInference(slide.title, validatedType, normalizedContent);
// Use inferred type if different from what AI provided
const finalType = wasInferred && inferredType !== validatedType
? inferredType
: validatedType;
if (wasInferred && inferredType !== validatedType) {
logger.debug(`[ContentPlanner] Slide ${i + 1} type inferred from title: "${slide.title}" → ${finalType}`);
}
// Apply bullet style to content based on slide type
const contentWithBulletStyle = applyBulletStyleToContent(normalizedContent, bulletStyle);
// Validate and normalize layout based on final type
const validatedLayout = validateSlideLayout(slide.layout, finalType);
// Ensure speakerNotes is a string
if (typeof slide.speakerNotes !== "string") {
slide.speakerNotes = "";
}
// Validate imagePrompt (can be string or null)
if (slide.imagePrompt !== null && typeof slide.imagePrompt !== "string") {
slide.imagePrompt = null;
}
validatedSlides.push({
slideNumber: i + 1,
type: finalType,
layout: validatedLayout,
title: slide.title,
content: contentWithBulletStyle,
imagePrompt: slide.imagePrompt,
speakerNotes: slide.speakerNotes,
});
}
return {
title: p.title,
totalSlides: validatedSlides.length,
audience: p.audience || "general",
tone: p.tone || "professional",
theme: p.theme || "modern",
slides: validatedSlides,
keyMessages: Array.isArray(p.keyMessages)
? p.keyMessages
: undefined,
};
}
/**
* Parse AI response to extract JSON
* Handles markdown code blocks (anywhere in the response), preambles like
* "Here is the JSON:", and raw JSON.
*/
function parseAIResponse(content) {
const trimmed = content.trim();
const candidates = [];
// 1) Extract first ```json ... ``` block from anywhere in the response.
const fenceBlock = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
if (fenceBlock && fenceBlock[1]) {
candidates.push(fenceBlock[1].trim());
}
// 2) Strict leading-fence strip (legacy behaviour).
if (trimmed.startsWith("```")) {
candidates.push(trimmed.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, ""));
}
// 3) Slice from first '{' or '[' to its matching close — handles preambles
// like "Here is the presentation plan:\n{ ... }".
const firstObj = trimmed.indexOf("{");
const firstArr = trimmed.indexOf("[");
const firstStruct = firstObj === -1
? firstArr
: firstArr === -1
? firstObj
: Math.min(firstObj, firstArr);
if (firstStruct > 0) {
const openChar = trimmed[firstStruct];
const closeChar = openChar === "{" ? "}" : "]";
const lastClose = trimmed.lastIndexOf(closeChar);
if (lastClose > firstStruct) {
candidates.push(trimmed.slice(firstStruct, lastClose + 1));
}
}
// 4) Raw input as last resort.
candidates.push(trimmed);
const errors = [];
for (const candidate of candidates) {
try {
return JSON.parse(candidate);
}
catch (err) {
errors.push(err instanceof Error ? err.message : String(err));
}
}
throw new PPTError(`Failed to parse AI response as JSON: ${errors[errors.length - 1]}`, PPT_ERROR_CODES.INVALID_AI_RESPONSE, {
contentPreview: content.substring(0, 500),
parseError: errors.join(" | "),
});
}
// ============================================================================
// MAIN CONTENT PLANNING FUNCTION
// ============================================================================
/**
* Generate a content plan for the presentation
*
* This is the main entry point for content planning. It:
* 1. Builds the AI prompt with all configuration
* 2. Calls the AI provider to generate the plan
* 3. Parses and validates the response
* 4. Returns a structured ContentPlan
*
* @param context - PPT generation context (extracted from GenerateOptions)
* @param provider - AI provider instance (already validated)
* @returns Promise<ContentPlan> - Structured slide plan
*
* @example
* ```typescript
* const context = extractPPTContext(options);
* const plan = await generateContentPlan(context, provider);
* console.log(`Generated ${plan.totalSlides} slides`);
* ```
*/
export async function generateContentPlan(context, provider) {
const startTime = Date.now();
const sessionId = `session-${Date.now()}`;
logger.info("[ContentPlanner] Starting content planning", {
topic: context.topic.substring(0, 100),
pages: context.pages,
audience: context.audience,
tone: context.tone,
theme: context.theme,
generateAIImages: context.generateAIImages,
provider: context.provider,
model: context.model,
sessionId,
});
// Build the prompt with model info for tier detection (basic vs advanced)
const userPrompt = buildContentPlanningPrompt({
topic: context.topic,
pages: context.pages,
audience: context.audience,
tone: context.tone,
theme: context.theme,
generateAIImages: context.generateAIImages,
modelInfo: {
name: context.model || "unknown",
provider: context.provider || "auto-selected",
},
});
try {
// Call AI provider
// Provider is already validated and has model set internally
const result = await provider.generate({
prompt: userPrompt,
systemPrompt: CONTENT_PLANNING_SYSTEM_PROMPT,
temperature: 0.7, // Some creativity for content
maxTokens: 8192, // Need space for full plan
disableTools: true, // Pure text generation, no tools
timeout: CONTENT_PLANNING_TIMEOUT_MS,
});
if (!result || !result.content) {
throw new PPTError("AI provider returned empty response", PPT_ERROR_CODES.PLANNING_FAILED, { provider: context.provider });
}
const planningTime = Date.now() - startTime;
logger.debug("[ContentPlanner] AI response received", {
contentLength: result.content.length,
planningTime,
});
// Parse the JSON response
const parsed = parseAIResponse(result.content);
// Validate and structure the plan
const plan = validateContentPlan(parsed, context.pages);
logger.info("[ContentPlanner] Content plan generated successfully", {
title: plan.title,
totalSlides: plan.totalSlides,
planningTime,
keyMessages: plan.keyMessages?.length || 0,
});
return plan;
}
catch (error) {
// Re-throw PPTError as-is
if (error instanceof PPTError) {
throw error;
}
// Wrap other errors
const message = error instanceof Error ? error.message : String(error);
throw new PPTError(`Content planning failed: ${message}`, PPT_ERROR_CODES.PLANNING_FAILED, {
topic: context.topic.substring(0, 100),
provider: context.provider,
}, error instanceof Error ? error : undefined);
}
}
// ============================================================================
// PLAN ADJUSTMENT UTILITIES
// ============================================================================
/**
* Ensure first slide is title type
*/
export function ensureTitleSlide(plan) {
if (plan.slides.length > 0 && plan.slides[0].type !== "title") {
plan.slides[0] = {
...plan.slides[0],
type: "title",
layout: "title-centered",
};
}
return plan;
}
/**
* Ensure last slide is thank-you type
*/
export function ensureThankYouSlide(plan) {
const lastIndex = plan.slides.length - 1;
if (lastIndex >= 0 && plan.slides[lastIndex].type !== "thank-you") {
plan.slides[lastIndex] = {
...plan.slides[lastIndex],
type: "thank-you",
layout: "contact-info",
};
}
return plan;
}
/**
* Apply post-processing to ensure plan follows best practices
*/
export function postProcessPlan(plan) {
let processed = { ...plan, slides: [...plan.slides] };
processed = ensureTitleSlide(processed);
processed = ensureThankYouSlide(processed);
return processed;
}