@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
658 lines • 31.1 kB
JavaScript
/**
* Update Template Processor
*
* Processes UPDATE templates by merging template data with existing entity data,
* validating update compatibility, and preparing processed templates for orchestration.
*
* Key Responsibilities:
* - Merge template data with existing entity state
* - Validate update operations won't break existing configurations
* - Process template placeholders with context-aware resolution
* - Prepare processed templates for EntityOrchestrator UPDATE methods
*
* Template Placeholder Processing:
* - {EXISTING: field} → Extract from existing entity
* - {CURRENT: field} → Show current value for context
* - {FILL: field} → Validate required fields are provided
* - {OPTIONAL: field} → Handle optional fields gracefully
* - {REBALANCE: weights} → Auto-calculate traffic distributions
*
* @author Optimizely MCP Server
* @version 1.0.0
*/
import { getLogger } from '../logging/Logger.js';
import { FIELDS } from '../generated/fields.generated.js';
import { PrescriptiveValidator } from '../validation/PrescriptiveValidator.js';
import { ComprehensiveAutoCorrector } from '../validation/ComprehensiveAutoCorrector.js';
import { IntelligentPayloadParser } from '../parsers/IntelligentPayloadParser.js';
export class UpdateTemplateProcessor {
logger = getLogger();
validator = new PrescriptiveValidator();
payloadParser = new IntelligentPayloadParser();
/**
* Process an UPDATE template with existing entity data
*/
async processUpdateTemplate(template, templateData, context) {
this.logger.info({
templateType: template.metadata.entity_type,
updateType: template.metadata.update_type,
complexity: template.metadata.complexity_score
}, 'UpdateTemplateProcessor: Processing UPDATE template');
try {
// Step 1: Extract contextual data from existing entity
const contextualData = await this.extractContextualData(template, context.existingEntity, context.relatedEntities);
// Step 2: Process template placeholders
const processedData = await this.processTemplatePlaceholders(template.template, templateData, contextualData, context);
// Step 3: CRITICAL - Apply auto-correction and intelligent parsing
const correctedData = await this.applyIntelligentCorrections(processedData, template.metadata, context);
// Step 3.5: Remove null/undefined fields to follow PATCH semantics
const cleanedData = this.removeNullFields(correctedData);
// Step 4: Validate update compatibility
const validationResults = await this.validateUpdateTemplate(cleanedData, context.existingEntity, template.metadata);
// Step 5: Generate orchestration hints
const orchestrationHints = await this.generateOrchestrationHints(template.metadata, correctedData, validationResults);
return {
originalTemplate: template,
processedData: cleanedData,
existingEntityData: context.existingEntity,
mergeStrategy: template.metadata.update_type,
validationResults,
contextualData,
orchestrationHints
};
}
catch (error) {
this.logger.error({
error: error?.message,
templateType: template.metadata.entity_type
}, 'UpdateTemplateProcessor: Template processing failed');
throw new Error(`Template processing failed: ${error?.message || 'Unknown error'}`);
}
}
/**
* CRITICAL: Apply intelligent corrections and parsing to processed data
* This ensures ALL UPDATE template data goes through our auto-correction pipeline
*/
async applyIntelligentCorrections(processedData, metadata, context) {
this.logger.info({
entityType: metadata.entity_type,
updateType: metadata.update_type,
platform: metadata.platform
}, 'UpdateTemplateProcessor: Applying intelligent corrections');
try {
let correctedData = { ...processedData };
// Step 1: Apply ComprehensiveAutoCorrector for entity-specific corrections
this.logger.debug({ entityType: metadata.entity_type }, 'Applying ComprehensiveAutoCorrector');
const autoCorrectResult = ComprehensiveAutoCorrector.autoCorrect(metadata.entity_type, correctedData);
if (autoCorrectResult.corrections.length > 0) {
correctedData = autoCorrectResult.correctedData;
this.logger.info({
corrections: autoCorrectResult.corrections.length,
details: autoCorrectResult.corrections.map(c => `${c.field}: ${c.reason}`)
}, 'Auto-corrections applied to UPDATE template data');
}
// Step 2: Apply IntelligentPayloadParser for complex parsing
this.logger.debug({
entityType: metadata.entity_type,
beforeParser: correctedData,
hasVariations: !!correctedData?.variations,
variationsType: Array.isArray(correctedData?.variations) ? 'array' : typeof correctedData?.variations,
firstVariation: correctedData?.variations?.[0]
}, 'Applying IntelligentPayloadParser - BEFORE parsing');
// Convert 'auto' platform to specific platform for parser
let resolvedPlatform = context.platform === 'auto' ? metadata.platform === 'feature' ? 'feature' : 'web' : context.platform;
const parseResult = await this.payloadParser.parsePayload(correctedData, {
entityType: metadata.entity_type,
operation: 'update',
platform: resolvedPlatform,
enableFuzzyMatching: true
});
if (parseResult.success && parseResult.transformedPayload) {
this.logger.debug({
entityType: metadata.entity_type,
beforeTransform: correctedData,
afterTransform: parseResult.transformedPayload,
transformations: parseResult.appliedTransformations,
confidence: parseResult.confidence
}, 'IntelligentPayloadParser - transformation applied');
// CRITICAL FIX: Preserve complete variation data when processing ab_test templates
// The IntelligentPayloadParser may transform variations but lose key/name fields
if (metadata.entity_type === 'flag' && correctedData?.variations && parseResult.transformedPayload) {
this.logger.debug({
originalVariations: correctedData.variations,
transformedStructure: parseResult.transformedPayload
}, 'Preserving variation data during template transformation');
// If the transformation resulted in a JSON Patch or other structure that lost variation data,
// preserve the original variations in the corrected data
if (parseResult.transformedPayload !== correctedData) {
// Preserve variation data in the ab_test structure
if (correctedData.ab_test?.variations) {
if (!parseResult.transformedPayload.ab_test) {
parseResult.transformedPayload.ab_test = {};
}
parseResult.transformedPayload.ab_test.variations = correctedData.ab_test.variations;
this.logger.info({
preservedVariations: correctedData.ab_test.variations.length
}, 'Preserved ab_test variations during template transformation');
}
}
}
correctedData = parseResult.transformedPayload;
this.logger.info({
confidence: parseResult.confidence,
transformations: parseResult.appliedTransformations,
suggestions: parseResult.suggestions
}, 'Intelligent parsing applied to UPDATE template data');
}
// Step 3: Apply entity-specific weight corrections (basis points)
correctedData = this.applyWeightCorrections(correctedData, metadata);
// Step 4: Apply audience_conditions string formatting (critical for all entities)
correctedData = this.applyAudienceConditionsFormatting(correctedData);
// Step 5: Apply platform-specific corrections
if (metadata.platform === 'feature') {
correctedData = this.applyFeatureExperimentationCorrections(correctedData);
}
else if (metadata.platform === 'web') {
correctedData = this.applyWebExperimentationCorrections(correctedData);
}
this.logger.info({
entityType: metadata.entity_type,
fieldsProcessed: Object.keys(correctedData).length
}, 'Intelligent corrections completed successfully');
return correctedData;
}
catch (error) {
this.logger.error({
error: error?.message,
entityType: metadata.entity_type
}, 'Failed to apply intelligent corrections - using original data');
// Graceful fallback to original data if corrections fail
return processedData;
}
}
/**
* Apply weight corrections (percentage to basis points)
*/
applyWeightCorrections(data, metadata) {
const corrected = { ...data };
// Handle variations array
if (corrected.variations && Array.isArray(corrected.variations)) {
corrected.variations = corrected.variations.map((variation) => {
if (variation.weight !== undefined && variation.weight <= 100) {
return {
...variation,
weight: variation.weight * 100 // Convert percentage to basis points
};
}
return variation;
});
}
// Handle traffic_allocation array
if (corrected.traffic_allocation && Array.isArray(corrected.traffic_allocation)) {
corrected.traffic_allocation = corrected.traffic_allocation.map((allocation) => {
if (allocation.weight !== undefined && allocation.weight <= 100) {
return {
...allocation,
weight: allocation.weight * 100 // Convert percentage to basis points
};
}
return allocation;
});
}
return corrected;
}
/**
* Apply audience_conditions formatting (object to JSON string)
*/
applyAudienceConditionsFormatting(data) {
const corrected = { ...data };
if (corrected.audience_conditions && typeof corrected.audience_conditions === 'object') {
corrected.audience_conditions = JSON.stringify(corrected.audience_conditions);
this.logger.debug('Converted audience_conditions object to JSON string');
}
return corrected;
}
/**
* Apply Feature Experimentation specific corrections
*/
applyFeatureExperimentationCorrections(data) {
const corrected = { ...data };
// Feature flags don't use weight field on variations (it's on rules)
if (corrected.variations && Array.isArray(corrected.variations)) {
corrected.variations = corrected.variations.map((variation) => {
// CRITICAL FIX: Only remove weight field, preserve all other fields including key and name
const { weight, ...variationWithoutWeight } = variation;
// DEBUG: Log what fields are being preserved
this.logger.debug({
originalVariation: variation,
processedVariation: variationWithoutWeight,
hasKey: !!variationWithoutWeight.key,
hasName: !!variationWithoutWeight.name,
removedWeight: weight
}, 'Feature Experimentation: Removing weight field from variation');
return variationWithoutWeight;
});
}
return corrected;
}
/**
* Apply Web Experimentation specific corrections
*/
applyWebExperimentationCorrections(data) {
const corrected = { ...data };
// Web experiments require weight on variations
if (corrected.variations && Array.isArray(corrected.variations)) {
corrected.variations = corrected.variations.map((variation) => {
if (variation.weight === undefined) {
return {
...variation,
weight: 5000 // Default 50% traffic
};
}
return variation;
});
}
return corrected;
}
/**
* Extract contextual data from existing entity and related entities
*/
async extractContextualData(template, existingEntity, relatedEntities) {
const contextualData = {
currentValues: {},
extractedReferences: {},
autoCalculations: {}
};
// Extract current values for reference
if (existingEntity) {
contextualData.currentValues = {
id: existingEntity.id,
key: existingEntity.key,
name: existingEntity.name,
status: existingEntity.status,
// Add more fields based on entity type
...(existingEntity.variations && { variations: existingEntity.variations }),
...(existingEntity.metrics && { metrics: existingEntity.metrics }),
...(existingEntity.conditions && { conditions: existingEntity.conditions })
};
}
// Extract references for {EXISTING: field} placeholders
contextualData.extractedReferences = {
project_id: existingEntity?.project_id,
flag_key: existingEntity?.key || existingEntity?.flag_key,
experiment_key: existingEntity?.key || existingEntity?.experiment_key,
experiment_identifier: existingEntity?.key || existingEntity?.experiment_key || existingEntity?.id,
audience_key: existingEntity?.key || existingEntity?.audience_key,
page_key: existingEntity?.key || existingEntity?.page_key
};
// Auto-calculate values for {REBALANCE: weights} placeholders
if (template.metadata.update_type === 'rebalance' && existingEntity?.variations) {
contextualData.autoCalculations = await this.calculateTrafficRebalancing(existingEntity.variations);
}
return contextualData;
}
/**
* Process template placeholders with context-aware resolution
*/
async processTemplatePlaceholders(templateStructure, templateData, contextualData, context) {
const processed = JSON.parse(JSON.stringify(templateStructure));
// Recursive function to process nested structures
const processValue = (value, path = '') => {
if (typeof value === 'string') {
return this.resolvePlaceholder(value, templateData, contextualData, context);
}
else if (Array.isArray(value)) {
return value.map((item, index) => processValue(item, `${path}[${index}]`));
}
else if (typeof value === 'object' && value !== null) {
const processedObj = {};
for (const [key, val] of Object.entries(value)) {
processedObj[key] = processValue(val, `${path}.${key}`);
}
return processedObj;
}
else {
return value;
}
};
return processValue(processed);
}
/**
* Resolve individual template placeholders
*/
resolvePlaceholder(placeholder, templateData, contextualData, context) {
// {EXISTING: field} - Extract from existing entity
const existingMatch = placeholder.match(/^\{EXISTING:\s*([^}]+)\}$/);
if (existingMatch) {
const fieldName = existingMatch[1].trim();
return contextualData.extractedReferences[fieldName] ||
contextualData.currentValues[fieldName] ||
`[MISSING: ${fieldName}]`;
}
// {CURRENT: field} - Show current value for context
const currentMatch = placeholder.match(/^\{CURRENT:\s*([^}]+)\}$/);
if (currentMatch) {
const fieldName = currentMatch[1].trim();
return contextualData.currentValues[fieldName] || null;
}
// {FILL: field} - Required field from template data
const fillMatch = placeholder.match(/^\{FILL:\s*([^}]+)\}$/);
if (fillMatch) {
const fieldName = fillMatch[1].trim();
return this.getTemplateDataValue(templateData, fieldName);
}
// {OPTIONAL: field} - Optional field from template data
const optionalMatch = placeholder.match(/^\{OPTIONAL:\s*([^}]+)\}$/);
if (optionalMatch) {
const fieldName = optionalMatch[1].trim();
return this.getTemplateDataValue(templateData, fieldName) || null;
}
// {OPTIONAL_ENUM: option1|option2} - Optional enum field
const optionalEnumMatch = placeholder.match(/^\{OPTIONAL_ENUM:\s*([^}]+)\}$/);
if (optionalEnumMatch) {
// For optional enum, don't provide a default value - let the user provide it or omit it
return null;
}
// {FILL_ENUM: option1|option2} - Enum validation
const enumMatch = placeholder.match(/^\{FILL_ENUM:\s*([^}]+)\}$/);
if (enumMatch) {
const options = enumMatch[1].split('|').map(opt => opt.trim());
// For now, return the first option as default (can be enhanced)
return options[0];
}
// {REBALANCE: weights} - Auto-calculate traffic weights
const rebalanceMatch = placeholder.match(/^\{REBALANCE:\s*([^}]+)\}$/);
if (rebalanceMatch) {
const fieldName = rebalanceMatch[1].trim();
return contextualData.autoCalculations[fieldName] || 5000; // Default 50%
}
// Return placeholder as-is if no match found
return placeholder;
}
/**
* Get value from template data using dot notation
*/
getTemplateDataValue(templateData, fieldPath) {
const parts = fieldPath.split('.');
let value = templateData;
for (const part of parts) {
if (value && typeof value === 'object' && part in value) {
value = value[part];
}
else {
return undefined;
}
}
return value;
}
/**
* Calculate traffic rebalancing for variations
*/
async calculateTrafficRebalancing(existingVariations) {
const totalVariations = existingVariations.length;
if (totalVariations === 0)
return {};
const evenWeight = Math.floor(10000 / totalVariations);
const remainder = 10000 % totalVariations;
const weights = {};
existingVariations.forEach((variation, index) => {
weights[variation.key] = evenWeight + (index < remainder ? 1 : 0);
});
return {
weights,
even_distribution: evenWeight,
total_variations: totalVariations
};
}
/**
* Validate update template for compatibility and safety
*/
async validateUpdateTemplate(processedData, existingEntity, metadata) {
const result = {
isValid: true,
errors: [],
warnings: [],
requiredFields: [],
conflictingFields: [],
safetyChecks: {}
};
// Validate required fields are present
await this.validateRequiredFields(processedData, metadata, result);
// Check for conflicting configurations
await this.validateCompatibility(processedData, existingEntity, metadata, result);
// Perform safety checks
await this.performSafetyChecks(processedData, existingEntity, metadata, result);
// Set overall validity
result.isValid = result.errors.length === 0;
return result;
}
/**
* Validate required fields using OpenAPI schema
*/
async validateRequiredFields(processedData, metadata, result) {
// Use OpenAPI schema for validation instead of hardcoded rules
const entityType = metadata.entity_type;
const schema = FIELDS[entityType];
if (!schema) {
result.errors.push(`No schema found for entity type: ${entityType}`);
return;
}
// Check required fields from schema
for (const requiredField of schema.required) {
if (!(requiredField in processedData) || processedData[requiredField] === undefined || processedData[requiredField] === null) {
result.errors.push(`Required field '${requiredField}' is missing for ${entityType} updates`);
result.requiredFields.push(requiredField);
}
}
// Validate field types from schema
for (const [fieldName, fieldValue] of Object.entries(processedData)) {
if (fieldValue !== undefined && fieldValue !== null) {
const expectedType = schema.fieldTypes?.[fieldName];
if (expectedType && !this.validateFieldType(fieldValue, expectedType)) {
result.errors.push(`Field '${fieldName}' must be of type ${expectedType}, got ${typeof fieldValue}`);
}
// Validate enums
const enumValues = schema.enums?.[fieldName];
if (enumValues && !enumValues.includes(fieldValue)) {
result.errors.push(`Field '${fieldName}' must be one of [${enumValues.join(', ')}], got '${fieldValue}'`);
}
// Validate numeric ranges
if (expectedType === 'integer' || expectedType === 'number') {
const min = schema.validation?.minimum?.[fieldName];
const max = schema.validation?.maximum?.[fieldName];
if (min !== undefined && fieldValue < min) {
result.errors.push(`Field '${fieldName}' must be >= ${min}, got ${fieldValue}`);
}
if (max !== undefined && fieldValue > max) {
result.errors.push(`Field '${fieldName}' must be <= ${max}, got ${fieldValue}`);
}
}
}
}
}
/**
* Validate field type matches schema expectation
*/
validateFieldType(value, expectedType) {
switch (expectedType) {
case 'string':
return typeof value === 'string';
case 'integer':
return Number.isInteger(value);
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
return typeof value === 'boolean';
case 'array':
return Array.isArray(value);
case 'object':
return typeof value === 'object' && value !== null && !Array.isArray(value);
case 'any':
return true;
default:
return true; // Unknown types pass validation
}
}
/**
* Validate compatibility with existing entity
*/
async validateCompatibility(processedData, existingEntity, metadata, result) {
// Check if update type is compatible with existing entity state
if (metadata.update_type === 'rebalance' && !existingEntity?.variations?.length) {
result.errors.push('Cannot rebalance traffic: no existing variations found');
}
if (metadata.update_type === 'additive' && metadata.entity_type === 'flag') {
// Check if variations already exist with same keys
const newVariationKeys = processedData.variations?.map((v) => v.key) || [];
const existingKeys = existingEntity?.variations?.map((v) => v.key) || [];
const conflicts = newVariationKeys.filter((key) => existingKeys.includes(key));
if (conflicts.length > 0) {
result.warnings.push(`Variation keys already exist: ${conflicts.join(', ')}`);
}
}
}
/**
* Perform safety checks for destructive operations
*/
async performSafetyChecks(processedData, existingEntity, metadata, result) {
// Traffic allocation safety checks
if (processedData.traffic_allocation || processedData.variations) {
const trafficTotal = this.calculateTrafficTotal(processedData);
result.safetyChecks.trafficTotals = trafficTotal;
if (Math.abs(trafficTotal - 10000) > 1) { // Allow 0.01% tolerance
result.warnings.push(`Traffic allocation totals ${trafficTotal / 100}% instead of 100%`);
}
}
// Breaking changes detection
if (metadata.update_type === 'replace') {
result.safetyChecks.breakingChanges = [
'This operation will replace existing configuration',
'Previous settings will be lost'
];
result.warnings.push('Replace operation will overwrite existing configuration');
}
}
/**
* Calculate total traffic allocation
*/
calculateTrafficTotal(processedData) {
let total = 0;
if (processedData.traffic_allocation) {
total = processedData.traffic_allocation.reduce((sum, allocation) => sum + (allocation.weight || 0), 0);
}
else if (processedData.variations && Array.isArray(processedData.variations)) {
total = processedData.variations.reduce((sum, variation) => sum + (variation.weight || 0), 0);
}
return total;
}
/**
* Generate orchestration hints for EntityOrchestrator
*/
async generateOrchestrationHints(metadata, processedData, validationResults) {
const hints = {
requiredOperations: [],
affectedEntities: metadata.affects_entities || [],
createdEntities: metadata.creates_entities || [],
riskLevel: 'low'
};
// Determine risk level
if (validationResults.errors.length > 0) {
hints.riskLevel = 'high';
}
else if (validationResults.warnings.length > 2 || metadata.complexity_score > 3) {
hints.riskLevel = 'medium';
}
// Determine required operations
if (metadata.creates_entities?.includes('variation')) {
hints.requiredOperations.push('create_variations');
}
if (metadata.creates_entities?.includes('event')) {
hints.requiredOperations.push('create_events');
}
if (metadata.affects_entities?.includes('ruleset')) {
hints.requiredOperations.push('update_ruleset');
}
return hints;
}
/**
* Merge template with existing entity data based on update type
*/
async mergeTemplateWithExisting(processedTemplate, existingEntity, updateType) {
switch (updateType) {
case 'replace':
return processedTemplate;
case 'merge':
return { ...existingEntity, ...processedTemplate };
case 'additive':
return this.performAdditiveOperation(existingEntity, processedTemplate);
case 'rebalance':
return this.performRebalanceOperation(existingEntity, processedTemplate);
default:
throw new Error(`Unsupported update type: ${updateType}`);
}
}
/**
* Perform additive operations (add new items to arrays)
*/
performAdditiveOperation(existing, addition) {
const result = { ...existing };
// Add new variations
if (addition.variations) {
result.variations = [...(existing.variations || []), ...addition.variations];
}
// Add new metrics
if (addition.metrics) {
result.metrics = [...(existing.metrics || []), ...addition.metrics];
}
return result;
}
/**
* Perform rebalance operations (recalculate weights)
*/
performRebalanceOperation(existing, rebalance) {
const result = { ...existing };
if (rebalance.traffic_allocation && existing.variations) {
// Update variation weights based on new allocation
result.variations = existing.variations.map((variation) => {
const newAllocation = rebalance.traffic_allocation.find((alloc) => alloc.variation_key === variation.key);
return {
...variation,
weight: newAllocation ? newAllocation.weight : variation.weight
};
});
}
return result;
}
/**
* Remove null, undefined, and empty fields from processed data
* This ensures PATCH operations only include fields that are actually being updated
*/
removeNullFields(obj) {
if (Array.isArray(obj)) {
const filtered = obj.map(item => this.removeNullFields(item)).filter(item => item !== null && item !== undefined &&
!(typeof item === 'object' && Object.keys(item).length === 0));
return filtered.length > 0 ? filtered : undefined;
}
else if (obj !== null && typeof obj === 'object') {
const cleaned = {};
for (const [key, value] of Object.entries(obj)) {
const cleanedValue = this.removeNullFields(value);
if (cleanedValue !== null && cleanedValue !== undefined) {
// For objects, also check if it's an empty object
if (typeof cleanedValue === 'object' && !Array.isArray(cleanedValue)) {
if (Object.keys(cleanedValue).length > 0) {
cleaned[key] = cleanedValue;
}
}
else {
cleaned[key] = cleanedValue;
}
}
}
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
}
// For primitive values, return as-is unless null/undefined
return (obj !== null && obj !== undefined) ? obj : undefined;
}
}
//# sourceMappingURL=UpdateTemplateProcessor.js.map