UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

658 lines 31.1 kB
/** * 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