/**
* Content Plan Builder - Plan Generator
*
* Core orchestration for generating project plans from content.
*/
import Anthropic from '@anthropic-ai/sdk';
import type {
ContentPlanOptions,
ContentProjectPlan,
CompactTaskOutput,
PlanGenerationResult,
CreativityLevel
} from './types.js';
import {
PLAN_GENERATION_SYSTEM_PROMPT,
SUMMARIZATION_SYSTEM_PROMPT,
getPlanGenerationUserPrompt,
getSummarizationUserPrompt
} from './prompts.js';
import { processAllInputs, getPrimaryInputType } from './document-parser.js';
// Maximum characters before triggering multi-pass summarization
const MAX_DIRECT_LENGTH = 40000;
// Anthropic client (initialized lazily)
let anthropic: Anthropic | null = null;
function getAnthropicClient(): Anthropic {
if (!anthropic) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error('ANTHROPIC_API_KEY environment variable is required');
}
anthropic = new Anthropic({ apiKey });
}
return anthropic;
}
/**
* Generate a project plan from content inputs
*/
export async function generatePlan(options: ContentPlanOptions): Promise<PlanGenerationResult> {
try {
// Step 1: Process all inputs into combined context
const rawContent = await processAllInputs(options.inputs);
if (!rawContent || rawContent.trim().length === 0) {
return {
status: 'error',
error: 'No content could be extracted from the provided inputs'
};
}
// Step 2: Add knowledge base context if provided
let combinedContext = rawContent;
if (options.knowledgeBaseContext && options.knowledgeBaseContext.trim().length > 0) {
combinedContext = `${rawContent}\n\nADDITIONAL CONTEXT FROM KNOWLEDGE BASE:\n${options.knowledgeBaseContext}`;
}
// Step 3: Determine input type for prompt
const inputType = getPrimaryInputType(options.inputs);
// Step 4: If content is very large, use multi-pass approach
if (combinedContext.length > MAX_DIRECT_LENGTH &&
(inputType === 'transcript' || inputType === 'meeting_notes')) {
console.error(`Large content detected (${combinedContext.length} chars), using multi-pass summarization`);
combinedContext = await summarizeLargeContent(combinedContext, options.knowledgeBaseContext);
} else if (combinedContext.length > MAX_DIRECT_LENGTH) {
// For other content types, truncate with notice
console.error(`Large content detected (${combinedContext.length} chars), truncating`);
combinedContext = combinedContext.slice(0, MAX_DIRECT_LENGTH) +
'\n\n[Content continues - see original source for full details]';
}
// Step 5: Generate the project plan
const plan = await callPlanGenerationLLM(
inputType,
combinedContext,
options.projectName,
options.creativity || 'balanced'
);
// Step 6: Format outputs
const result: PlanGenerationResult = {
status: 'success',
plan
};
// Add formatted outputs based on outputFormat option
const outputFormat = options.outputFormat || 'both';
if (outputFormat === 'detailed' || outputFormat === 'both') {
result.detailed = plan;
}
if (outputFormat === 'compact' || outputFormat === 'both') {
result.compact = formatCompactOutput(plan);
}
// Add summary statistics
result.summary = {
totalTopLevelTasks: plan.topLevelTasks.length,
totalSubtasks: plan.topLevelTasks.reduce((sum, t) => sum + t.subtasks.length, 0),
totalSmartGoals: plan.smartGoals.length,
totalKeyDataPoints: plan.keyDataPoints.length
};
return result;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`Plan generation failed: ${message}`);
return {
status: 'error',
error: message
};
}
}
/**
* Summarize large content using multi-pass approach
*/
async function summarizeLargeContent(
content: string,
knowledgeBaseContext?: string
): Promise<string> {
const client = getAnthropicClient();
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: SUMMARIZATION_SYSTEM_PROMPT,
messages: [
{
role: 'user',
content: getSummarizationUserPrompt(content)
}
]
});
const summary = response.content[0].type === 'text'
? response.content[0].text
: '';
// Combine summary with knowledge base context
let result = `CONTENT ANALYSIS:\n${summary}`;
if (knowledgeBaseContext) {
result += `\n\nORIGINAL CONTEXT:\n${knowledgeBaseContext}`;
}
return result;
}
/**
* Call the LLM to generate the project plan
*/
async function callPlanGenerationLLM(
inputType: string,
combinedContext: string,
projectName?: string,
creativity: CreativityLevel = 'balanced'
): Promise<ContentProjectPlan> {
const client = getAnthropicClient();
const userPrompt = getPlanGenerationUserPrompt(
inputType,
combinedContext,
projectName,
creativity
);
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 8192,
system: PLAN_GENERATION_SYSTEM_PROMPT,
messages: [
{
role: 'user',
content: userPrompt
}
]
});
const responseText = response.content[0].type === 'text'
? response.content[0].text
: '';
// Parse JSON from response
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in LLM response');
}
try {
const plan = JSON.parse(jsonMatch[0]) as ContentProjectPlan;
return validatePlan(plan);
} catch (parseError) {
throw new Error(`Failed to parse LLM response as JSON: ${parseError instanceof Error ? parseError.message : 'Unknown error'}`);
}
}
/**
* Validate and normalize the generated plan
*/
function validatePlan(plan: ContentProjectPlan): ContentProjectPlan {
// Ensure required fields exist
if (!plan.projectGoal) {
throw new Error('Plan missing projectGoal');
}
if (!Array.isArray(plan.topLevelTasks) || plan.topLevelTasks.length === 0) {
throw new Error('Plan missing topLevelTasks');
}
// Ensure arrays exist with defaults
plan.keyDataPoints = plan.keyDataPoints || [];
plan.keywordsAndPhrases = plan.keywordsAndPhrases || [];
plan.questionsAndExercises = plan.questionsAndExercises || [];
plan.smartGoals = plan.smartGoals || [];
plan.additionalMaterials = plan.additionalMaterials || [];
// Validate each task
for (const task of plan.topLevelTasks) {
if (!task.name || !task.description) {
throw new Error('Task missing name or description');
}
task.assignedTo = task.assignedTo || 'Unassigned';
task.estimate = task.estimate || 'TBD';
task.blockedBy = task.blockedBy || 'N/A';
task.subtasks = task.subtasks || [];
// Validate subtasks
for (const subtask of task.subtasks) {
if (!subtask.name || !subtask.description) {
throw new Error('Subtask missing name or description');
}
subtask.assignedTo = subtask.assignedTo || 'Unassigned';
subtask.estimate = subtask.estimate || 'TBD';
subtask.blockedBy = subtask.blockedBy || 'N/A';
}
}
return plan;
}
/**
* Format the plan into compact output format
*/
function formatCompactOutput(plan: ContentProjectPlan): CompactTaskOutput[] {
const compact: CompactTaskOutput[] = [];
let index = 1;
for (const topTask of plan.topLevelTasks) {
for (const subtask of topTask.subtasks) {
compact.push({
index,
'Parent-task': topTask.name,
'Sub-Task': subtask.name,
'Task Description': subtask.description,
Assignee: subtask.assignedTo,
'Estimated Time': subtask.estimate,
'Statistical-Data': plan.keyDataPoints[0] || 'N/A',
'Keywords-Phrases': plan.keywordsAndPhrases.slice(0, 3).join(', ') || 'N/A',
'Questions-Exercises': plan.questionsAndExercises[0] || 'N/A',
Goals: plan.smartGoals[0] || 'N/A',
Role: subtask.assignedTo,
Dependencies: subtask.blockedBy
});
index++;
}
}
return compact;
}
/**
* Preview a plan without saving or creating in Asana
*/
export async function previewPlan(options: ContentPlanOptions): Promise<PlanGenerationResult> {
// Preview is the same as generate, just don't create in Asana
const previewOptions = { ...options, createInAsana: false };
return generatePlan(previewOptions);
}