UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

390 lines 17.8 kB
/** * Ruleset Payload Transformer * @description Specialized transformer for converting AI-generated ruleset payloads to API-compatible format * * Purpose: Handle the specific case that triggered this implementation - malformed ruleset payloads * where AI agents send nested objects instead of the complete ruleset structure required by PATCH operations. * * Key Features: * - Handles nested ruleset_data extraction * - Converts A/B test structures to proper rulesets * - Validates ruleset completeness * - Provides detailed transformation logging * * @author Optimizely MCP Server * @version 1.0.0 */ import { getLogger } from '../logging/Logger.js'; /** * Specialized transformer for ruleset payloads */ export class RulesetPayloadTransformer { logger = getLogger(); // Required fields for a complete ruleset according to API spec requiredRulesetFields = [ 'enabled', 'rules', 'default_variation_key' ]; // Optional but common ruleset fields optionalRulesetFields = [ 'rollout_id', 'archived' ]; constructor() { this.logger.debug('RulesetPayloadTransformer initialized'); } /** * Transform malformed ruleset payload to API-compatible format */ async transformRulesetPayload(payload) { this.logger.debug(`Transforming ruleset payload with ${Object.keys(payload || {}).length} root fields`); const transformationsApplied = []; let transformed = { ...payload }; const errors = []; const warnings = []; try { // Step 1: Extract nested ruleset_data if present if (payload.ruleset_data) { const extractionResult = await this.extractNestedRulesetData(transformed); transformed = extractionResult.transformed; transformationsApplied.push(...extractionResult.transformations); warnings.push(...extractionResult.warnings); } // Step 2: Handle A/B test structure conversion if (this.isABTestStructure(transformed)) { const abTestResult = await this.convertABTestToRuleset(transformed); transformed = abTestResult.transformed; transformationsApplied.push(...abTestResult.transformations); warnings.push(...abTestResult.warnings); } // Step 3: Normalize field names and values const normalizationResult = await this.normalizeRulesetFields(transformed); transformed = normalizationResult.transformed; transformationsApplied.push(...normalizationResult.transformations); // Step 4: Add missing required fields with defaults const completionResult = await this.completeRulesetStructure(transformed); transformed = completionResult.transformed; transformationsApplied.push(...completionResult.transformations); warnings.push(...completionResult.warnings); // Step 5: Validate final structure const validation = await this.validateRulesetStructure(transformed); errors.push(...validation.errors); warnings.push(...validation.warnings); // Calculate confidence based on validation and transformations const confidence = this.calculateTransformationConfidence(validation, transformationsApplied, payload, transformed); const result = { success: validation.isValid && errors.length === 0, transformedRuleset: validation.isValid ? transformed : undefined, errors: errors.length > 0 ? errors : undefined, warnings: warnings.length > 0 ? warnings : undefined, transformationsApplied, confidence }; this.logger.debug(`Ruleset transformation completed: success=${result.success}, transformations=${transformationsApplied.length}, confidence=${confidence.toFixed(3)}`); return result; } catch (error) { this.logger.error(`Ruleset transformation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); return { success: false, errors: [`Transformation failed: ${error instanceof Error ? error.message : 'Unknown error'}`], transformationsApplied, confidence: 0 }; } } /** * Extract nested ruleset_data to root level */ async extractNestedRulesetData(payload) { const transformed = { ...payload }; const transformations = []; const warnings = []; if (payload.ruleset_data && typeof payload.ruleset_data === 'object') { const rulesetData = payload.ruleset_data; // Extract each field to root level if (rulesetData.enabled !== undefined) { transformed.enabled = rulesetData.enabled; transformations.push('Extracted enabled from ruleset_data'); } if (rulesetData.rules) { transformed.rules = rulesetData.rules; transformations.push('Extracted rules from ruleset_data'); } if (rulesetData.default_variation_key) { transformed.default_variation_key = rulesetData.default_variation_key; transformations.push('Extracted default_variation_key from ruleset_data'); } if (rulesetData.rollout_id) { transformed.rollout_id = rulesetData.rollout_id; transformations.push('Extracted rollout_id from ruleset_data'); } if (rulesetData.archived !== undefined) { transformed.archived = rulesetData.archived; transformations.push('Extracted archived from ruleset_data'); } // Remove the nested structure delete transformed.ruleset_data; transformations.push('Removed nested ruleset_data structure'); // Check for any remaining fields in original ruleset_data const remainingFields = Object.keys(rulesetData).filter(key => !['enabled', 'rules', 'default_variation_key', 'rollout_id', 'archived'].includes(key)); if (remainingFields.length > 0) { warnings.push(`Unhandled fields in ruleset_data: ${remainingFields.join(', ')}`); } } return { transformed, transformations, warnings }; } /** * Check if payload has A/B test structure */ isABTestStructure(payload) { // Look for common A/B test indicators return !!(payload.variations && Array.isArray(payload.variations) || payload.test_variations || payload.ab_test || payload.split_test || (payload.control && payload.treatment)); } /** * Convert A/B test structure to proper ruleset */ async convertABTestToRuleset(payload) { const transformed = { ...payload }; const transformations = []; const warnings = []; // Handle variations array if (payload.variations && Array.isArray(payload.variations)) { // Create a rule from variations const rule = { key: 'default_rule', enabled: true, audience_conditions: ['everyone'], variations: payload.variations.map((variation, index) => ({ key: variation.key || `variation_${index}`, name: variation.name || `Variation ${index + 1}`, percentage_included: variation.percentage_included || variation.weight || (index === 0 ? 5000 : 5000) // Default 50/50 split })) }; transformed.rules = [rule]; transformations.push('Converted variations array to rules structure'); // Set default variation from first variation if (payload.variations[0]) { transformed.default_variation_key = payload.variations[0].key || 'variation_0'; transformations.push('Set default_variation_key from first variation'); } // Remove original variations field delete transformed.variations; } // Handle control/treatment structure if (payload.control && payload.treatment) { const rule = { key: 'ab_test_rule', enabled: true, audience_conditions: ['everyone'], variations: [ { key: payload.control.key || 'control', name: payload.control.name || 'Control', percentage_included: 5000 }, { key: payload.treatment.key || 'treatment', name: payload.treatment.name || 'Treatment', percentage_included: 5000 } ] }; transformed.rules = [rule]; transformed.default_variation_key = payload.control.key || 'control'; transformations.push('Converted control/treatment to rules structure'); delete transformed.control; delete transformed.treatment; } return { transformed, transformations, warnings }; } /** * Normalize field names and values */ async normalizeRulesetFields(payload) { const transformed = { ...payload }; const transformations = []; // Normalize enabled field if (payload.is_active !== undefined && payload.enabled === undefined) { transformed.enabled = payload.is_active; delete transformed.is_active; transformations.push('Converted is_active to enabled'); } if (payload.active !== undefined && payload.enabled === undefined) { transformed.enabled = payload.active; delete transformed.active; transformations.push('Converted active to enabled'); } // Normalize boolean values if (typeof transformed.enabled === 'string') { const normalizedEnabled = transformed.enabled.toLowerCase(); if (['true', '1', 'yes', 'on'].includes(normalizedEnabled)) { transformed.enabled = true; transformations.push('Normalized enabled string to boolean true'); } else if (['false', '0', 'no', 'off'].includes(normalizedEnabled)) { transformed.enabled = false; transformations.push('Normalized enabled string to boolean false'); } } // Normalize archived field if (typeof transformed.archived === 'string') { const normalizedArchived = transformed.archived.toLowerCase(); if (['true', '1', 'yes'].includes(normalizedArchived)) { transformed.archived = true; transformations.push('Normalized archived string to boolean true'); } else if (['false', '0', 'no'].includes(normalizedArchived)) { transformed.archived = false; transformations.push('Normalized archived string to boolean false'); } } // Ensure rules is an array if (transformed.rules && !Array.isArray(transformed.rules)) { if (typeof transformed.rules === 'object') { transformed.rules = [transformed.rules]; transformations.push('Converted single rule object to array'); } } return { transformed, transformations }; } /** * Complete ruleset structure with required defaults */ async completeRulesetStructure(payload) { const transformed = { ...payload }; const transformations = []; const warnings = []; // Ensure enabled field exists if (transformed.enabled === undefined) { transformed.enabled = true; transformations.push('Added default enabled=true'); } // Ensure rules array exists if (!transformed.rules) { transformed.rules = []; transformations.push('Added empty rules array'); warnings.push('No rules provided - ruleset will not serve any variations'); } // Ensure default_variation_key exists if (!transformed.default_variation_key) { // Try to infer from rules if (transformed.rules && Array.isArray(transformed.rules) && transformed.rules.length > 0) { const firstRule = transformed.rules[0]; if (firstRule.variations && firstRule.variations.length > 0) { transformed.default_variation_key = firstRule.variations[0].key; transformations.push('Inferred default_variation_key from first rule variation'); } } // If still no default_variation_key, this is an error that will be caught in validation if (!transformed.default_variation_key) { warnings.push('Could not determine default_variation_key - this is required for rulesets'); } } // Set archived default if not specified if (transformed.archived === undefined) { transformed.archived = false; transformations.push('Added default archived=false'); } return { transformed, transformations, warnings }; } /** * Validate final ruleset structure */ async validateRulesetStructure(ruleset) { const errors = []; const warnings = []; const missingFields = []; const suggestions = []; // Check required fields for (const field of this.requiredRulesetFields) { if (ruleset[field] === undefined || ruleset[field] === null) { errors.push(`Required field '${field}' is missing or null`); missingFields.push(field); } } // Validate field types and values if (ruleset.enabled !== undefined && typeof ruleset.enabled !== 'boolean') { errors.push('Field "enabled" must be a boolean'); } if (ruleset.rules !== undefined && !Array.isArray(ruleset.rules)) { errors.push('Field "rules" must be an array'); } if (ruleset.default_variation_key !== undefined && typeof ruleset.default_variation_key !== 'string') { errors.push('Field "default_variation_key" must be a string'); } if (ruleset.archived !== undefined && typeof ruleset.archived !== 'boolean') { errors.push('Field "archived" must be a boolean'); } // Validate rules structure if (ruleset.rules && Array.isArray(ruleset.rules)) { if (ruleset.rules.length === 0) { warnings.push('Ruleset has no rules - it will not serve any variations to users'); } ruleset.rules.forEach((rule, index) => { if (!rule.key) { warnings.push(`Rule ${index} missing key field`); } if (!rule.variations || !Array.isArray(rule.variations) || rule.variations.length === 0) { warnings.push(`Rule ${index} has no variations`); } }); } // Generate helpful suggestions if (missingFields.length > 0) { suggestions.push(`Add missing required fields: ${missingFields.join(', ')}`); } if (errors.length === 0 && warnings.length > 0) { suggestions.push('Consider reviewing warnings to ensure optimal ruleset configuration'); } return { isValid: errors.length === 0, errors, warnings, missingFields, suggestions }; } /** * Calculate transformation confidence score */ calculateTransformationConfidence(validation, transformationsApplied, originalPayload, transformedPayload) { let confidence = 0; // Base confidence on validation success if (validation.isValid) { confidence += 0.4; // 40% for valid structure } else { confidence -= 0.2; // Penalty for invalid structure } // Factor in successful transformations const transformationScore = Math.min(transformationsApplied.length / 5, 1) * 0.3; // Up to 30% confidence += transformationScore; // Bonus for having all required fields after transformation const requiredFieldsPresent = this.requiredRulesetFields.every(field => transformedPayload[field] !== undefined); if (requiredFieldsPresent) { confidence += 0.2; // 20% bonus } // Small bonus for preserving original data const originalFieldsCount = Object.keys(originalPayload).length; const preservedFieldsCount = Object.keys(originalPayload).filter(key => transformedPayload[key] !== undefined || key === 'ruleset_data' // ruleset_data gets extracted ).length; if (originalFieldsCount > 0) { const preservationRatio = preservedFieldsCount / originalFieldsCount; confidence += preservationRatio * 0.1; // Up to 10% bonus } // Ensure confidence is between 0 and 1 return Math.max(0, Math.min(1, confidence)); } } /** * Singleton instance for global use */ export const rulesetPayloadTransformer = new RulesetPayloadTransformer(); //# sourceMappingURL=RulesetPayloadTransformer.js.map