@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
926 lines (925 loc) • 64.2 kB
JavaScript
/**
* Intelligent Payload Parser
* @description Transforms AI-generated payloads into correct API formats before validation
*
* Purpose: Eliminate template mode friction by intelligently parsing creative AI payloads
* and automatically converting them to the expected format for orchestration.
*
* Key Features:
* - Non-destructive: Graceful fallback to template mode if parsing fails
* - Pattern recognition: Detects page, event, variation patterns in payloads
* - Field mapping: Maps AI field names to template field names using fuzzy matching
* - Structure analysis: Finds data in deeply nested payload structures
* - Format transformation: Converts complex objects to API-expected formats
*
* @author Optimizely MCP Server
* @version 1.0.0
*/
import { JSONPath } from 'jsonpath-plus';
import { getLogger } from '../logging/Logger.js';
import { PatternRecognitionEngine } from './PatternRecognitionEngine.js';
import { FieldMapper } from './FieldMapper.js';
import { TemplateAutoFiller } from './TemplateAutoFiller.js';
import { RulesetPayloadTransformer } from './RulesetPayloadTransformer.js';
import { rulesetUpdateTransformer } from './RulesetUpdateTransformer.js';
import { JSONataFieldExtractor } from './JSONataFieldExtractor.js';
/**
* Main Intelligent Payload Parser class
*/
export class IntelligentPayloadParser {
logger = getLogger();
fuse = null;
patternEngine;
fieldMapper;
templateAutoFiller;
rulesetTransformer;
jsonataExtractor;
constructor() {
this.patternEngine = new PatternRecognitionEngine();
this.fieldMapper = new FieldMapper();
this.templateAutoFiller = new TemplateAutoFiller();
this.rulesetTransformer = new RulesetPayloadTransformer();
this.jsonataExtractor = new JSONataFieldExtractor();
this.logger.debug('IntelligentPayloadParser initialized');
}
/**
* Main entry point: Parse and transform an AI-generated payload
*/
async parsePayload(payload, options) {
try {
// TEMPLATE MARKER DETECTION: Check for template marker field first
if (payload._template_type) {
this.logger.info({
templateType: payload._template_type,
originalPayload: payload,
operation: options.operation,
entityType: options.entityType
}, 'IntelligentPayloadParser: Template marker detected - preserving original field structure');
// Return payload with minimal transformation (just remove the marker)
const cleanPayload = { ...payload };
delete cleanPayload._template_type;
return {
success: true,
transformedPayload: cleanPayload,
confidence: 1.0,
appliedTransformations: ['template_marker_removal'],
suggestions: [`Template type '${payload._template_type}' detected - field structure preserved`]
};
}
// Log the incoming payload for variation processing
if (options.entityType === 'variation') {
this.logger.debug({
operation: options.operation,
entityType: options.entityType,
originalPayload: payload,
hasKey: !!payload?.key,
hasName: !!payload?.name,
keyValue: payload?.key,
nameValue: payload?.name
}, 'IntelligentPayloadParser: Processing variation payload');
}
// CRITICAL FIX: Handle variation creation with confused key/flag_key fields
if (options.operation === 'create' && options.entityType === 'variation' && payload) {
const transformed = await this.fixVariationKeyConfusion(payload);
if (transformed !== payload) {
this.logger.debug({
originalPayload: payload,
transformedPayload: transformed,
keyChanged: payload.key !== transformed.key,
nameChanged: payload.name !== transformed.name
}, 'IntelligentPayloadParser: Applied variation key confusion fix');
return {
success: true,
transformedPayload: transformed,
confidence: 0.85,
appliedTransformations: ['variation_key_confusion_fix'],
suggestions: ['Detected and fixed variation key confusion - moved flag key to flag_key field']
};
}
}
// Handle JSON Patch arrays for update operations
if (Array.isArray(payload) && options.operation === 'update' && options.entityType === 'ruleset') {
this.logger.debug('Detected JSON Patch array for ruleset update - checking for Web vs Feature format');
// First check if this is a Web-style ruleset update that needs transformation
const transformResult = rulesetUpdateTransformer.transformRulesetUpdate(payload, options.platform || 'feature', {} // EntityRouter will provide proper context with flag_key and environment_key
);
if (!transformResult.success) {
return {
success: false,
errors: transformResult.errors,
suggestions: transformResult.guidance ? [transformResult.guidance] : [],
confidence: 0.95
};
}
// Apply any transformations
const transformedPayload = transformResult.transformed || payload;
// Then apply path corrections
const correctedPatch = await this.correctJsonPatchPaths(transformedPayload, options);
return {
success: true,
transformedPayload: correctedPatch,
confidence: 0.9,
appliedTransformations: ['web_to_feature_transformation', 'json_patch_path_correction'],
suggestions: ['Transformed Web-style ruleset update to Feature Experimentation format']
};
}
// INTELLIGENT RULESET UPDATE DETECTION - Handle ANY creative format
if (options.operation === 'update' && options.entityType === 'ruleset' && !Array.isArray(payload)) {
this.logger.info({
payloadType: typeof payload,
keys: Object.keys(payload || {}),
sampleData: JSON.stringify(payload).substring(0, 200)
}, 'Detecting ruleset update format in non-array payload');
// Strategy 1: Deep search for JSON Patch arrays using JSONPath
const jsonPatchPaths = JSONPath({
path: '$..*[?(@.op && @.path)]',
json: payload,
resultType: 'parent'
});
if (jsonPatchPaths.length > 0) {
// Found JSON Patch operations somewhere in the structure
let patchArray = [];
// Check if the parent is already an array of patches
const firstResult = jsonPatchPaths[0];
if (Array.isArray(firstResult)) {
patchArray = firstResult;
}
else {
// Individual patch objects found, collect them
patchArray = jsonPatchPaths.filter((item) => item.op && item.path && ['add', 'remove', 'replace', 'move', 'copy', 'test'].includes(item.op));
}
this.logger.info({
foundPatches: patchArray.length,
searchDepth: 'deep',
firstPatch: patchArray[0]
}, 'Found JSON Patch operations via deep search');
const correctedPatch = await this.correctJsonPatchPaths(patchArray, options);
return {
success: true,
transformedPayload: correctedPatch,
confidence: 0.8,
appliedTransformations: ['deep_json_patch_extraction', 'json_patch_path_correction'],
suggestions: ['Send JSON Patch operations as a direct array at the root level for better performance']
};
}
// Strategy 2: Look for ruleset-like structures (rules, variations, etc.)
const rulesetPatterns = [
'$.ruleset_data', '$.ruleset', '$.data', '$.patch', '$.patches', '$.operations',
'$.update', '$.updates', '$.changes', '$.modifications', '$.rules_update'
];
for (const pattern of rulesetPatterns) {
const results = JSONPath({ path: pattern, json: payload });
if (results.length > 0 && results[0]) {
const potentialData = results[0];
// Check if it's a JSON Patch array
if (Array.isArray(potentialData) && potentialData.length > 0) {
const isJsonPatch = potentialData.every((item) => item.op && item.path && typeof item.op === 'string');
if (isJsonPatch) {
this.logger.info({
pattern,
opsCount: potentialData.length
}, `Found JSON Patch array at ${pattern}`);
const correctedPatch = await this.correctJsonPatchPaths(potentialData, options);
return {
success: true,
transformedPayload: correctedPatch,
confidence: 0.85,
appliedTransformations: ['pattern_based_extraction', 'json_patch_path_correction'],
suggestions: [`Remove the wrapper '${pattern.substring(2)}' and send JSON Patch array directly`]
};
}
}
// Check if it's a ruleset object that needs conversion
if (typeof potentialData === 'object' && !Array.isArray(potentialData)) {
const converted = await this.convertRulesetObjectToJsonPatch(potentialData, options);
if (converted.length > 0) {
this.logger.info({
pattern,
convertedOps: converted.length
}, `Converted ruleset object at ${pattern} to JSON Patch`);
return {
success: true,
transformedPayload: converted,
confidence: 0.75,
appliedTransformations: ['ruleset_object_to_json_patch', 'json_patch_path_correction'],
suggestions: ['Use JSON Patch format directly for ruleset updates']
};
}
}
}
}
// Strategy 3: Check if the payload itself is a ruleset object
if (payload.rules || payload.enabled !== undefined || payload.default_variation_key) {
const converted = await this.convertRulesetObjectToJsonPatch(payload, options);
if (converted.length > 0) {
this.logger.info({
convertedOps: converted.length
}, 'Converted root ruleset object to JSON Patch');
return {
success: true,
transformedPayload: converted,
confidence: 0.7,
appliedTransformations: ['direct_ruleset_to_json_patch'],
suggestions: ['Use JSON Patch format for ruleset updates: [{"op": "replace", "path": "/rules/...", "value": {...}}]']
};
}
}
// Strategy 4: Look for variations updates in various formats
// CRITICAL FIX: Skip this strategy for experiments since they have variations at the root level
// This prevents experiments from being misidentified as ruleset updates
if (options.entityType.toLowerCase() === 'experiment') {
this.logger.debug('Skipping variations detection for experiment entity type');
// Continue to next strategy
}
else {
const variationPatterns = [
'$.variations', '$.*.variations', '$..variations',
'$.rules.*.variations', '$..rules.*.variations'
];
for (const pattern of variationPatterns) {
const results = JSONPath({ path: pattern, json: payload, resultType: 'all' });
if (results.length > 0) {
// Found variations somewhere, create patches for them
const patches = [];
for (const result of results) {
const parentPath = JSONPath.toPathString(result.path.slice(0, -1));
const variations = result.value;
// Extract rule key from path if possible
const ruleKeyMatch = parentPath.match(/rules\[['"]([^'"]+)['"]\]/);
const ruleKey = ruleKeyMatch ? ruleKeyMatch[1] : 'unknown_rule';
if (variations) {
patches.push({
op: 'replace',
path: `/rules/${ruleKey}/variations`,
value: Array.isArray(variations) ? variations : Object.values(variations)
});
}
}
if (patches.length > 0) {
const correctedPatch = await this.correctJsonPatchPaths(patches, options);
return {
success: true,
transformedPayload: correctedPatch,
confidence: 0.65,
appliedTransformations: ['variations_extraction_to_json_patch'],
suggestions: ['Detected variation updates - converted to JSON Patch format']
};
}
}
}
} // Close the else block for Strategy 4
// No valid update format found
return {
success: false,
errors: ['Could not detect valid ruleset update format in payload'],
suggestions: [
'Use JSON Patch format: [{"op": "replace", "path": "/rules/rule_key", "value": {...}}]',
'Or wrap in standard fields: { "patches": [...] } or { "ruleset_data": [...] }',
'See documentation for valid ruleset update formats'
],
confidence: 0
};
}
this.logger.debug({
entityType: options.entityType,
operation: options.operation,
keys: Object.keys(payload || {}),
hasAbTest: !!payload?.ab_test,
abTestVariationCount: payload?.ab_test?.variations?.length || 0,
abTestVariationKeys: payload?.ab_test?.variations?.map((v) => v.key) || []
}, `Starting payload parsing for ${options.entityType}:${options.operation}`);
// Step 0: Use JSONata to extract and normalize fields first
const extractionResult = await this.jsonataExtractor.extractFields(options.entityType, payload, { platform: options.platform });
if (extractionResult.success && extractionResult.confidence > 0.7) {
this.logger.debug({
entityType: options.entityType,
transformations: extractionResult.transformations.length,
confidence: extractionResult.confidence,
beforeExtraction: {
hasAbTest: !!payload?.ab_test,
abTestVariationCount: payload?.ab_test?.variations?.length || 0
},
afterExtraction: {
hasAbTest: !!extractionResult.extractedFields?.ab_test,
abTestVariationCount: extractionResult.extractedFields?.ab_test?.variations?.length || 0
}
}, 'JSONataFieldExtractor successfully normalized fields');
// CRITICAL DEBUG: Track description field transformation
if (options.entityType === 'flag') {
this.logger.info({
originalDescription: payload.description,
originalDescriptionType: typeof payload.description,
extractedDescription: extractionResult.extractedFields.description,
extractedDescriptionType: typeof extractionResult.extractedFields.description,
extractedDescriptionIsArray: Array.isArray(extractionResult.extractedFields.description),
extractedKeys: Object.keys(extractionResult.extractedFields)
}, 'DESCRIPTION BUG TRACE: JSONataFieldExtractor result');
}
// CRITICAL FIX: Merge extracted fields with original payload to preserve complex fields
// like ab_test that aren't in the base schema but are valid for template operations
payload = {
...payload, // Preserve original fields (ab_test, etc.)
...extractionResult.extractedFields // Overlay normalized fields
};
}
// Step 1: Detect patterns in the payload
const analysis = await this.patternEngine.analyzePayload(payload);
const patterns = analysis.structurePatterns;
// Step 2: Map fields using fuzzy matching
const fieldMappings = await this.fieldMapper.mapFields(payload, {
entityType: options.entityType,
threshold: 0.6,
enableTransformations: true,
strictMode: options.strict || false
});
// CRITICAL DEBUG: Track field mappings
if (options.entityType === 'flag') {
const descriptionMapping = fieldMappings.find(m => m.templateFieldName === 'description');
if (descriptionMapping) {
this.logger.info({
descriptionMapping: {
aiFieldName: descriptionMapping.aiFieldName,
templateFieldName: descriptionMapping.templateFieldName,
confidence: descriptionMapping.confidence
}
}, 'DESCRIPTION BUG TRACE: Found description field mapping');
}
}
// Step 3: Transform the payload structure
// CRITICAL DEBUG: Track payload before transformation
if (options.entityType === 'flag') {
this.logger.info({
beforeTransform: {
description: payload.description,
descriptionType: typeof payload.description,
hasAbTest: !!payload.ab_test,
abTestVariations: payload.ab_test?.variations?.length || 0
}
}, 'DESCRIPTION BUG TRACE: Before transformPayload');
}
const transformedPayload = await this.transformPayload(payload, fieldMappings, patterns, options);
// CRITICAL DEBUG: Track payload after transformation
if (options.entityType === 'flag') {
this.logger.info({
afterTransform: {
description: transformedPayload.description,
descriptionType: typeof transformedPayload.description,
descriptionIsArray: Array.isArray(transformedPayload.description),
hasAbTest: !!transformedPayload.ab_test,
abTestVariations: transformedPayload.ab_test?.variations?.length || 0
}
}, 'DESCRIPTION BUG TRACE: After transformPayload');
}
// Step 4: Auto-fill template with transformed data
// CRITICAL FIX: Skip auto-fill for UPDATE operations to preserve all fields
// The auto-filler only knows about CREATE fields and strips everything else
let finalPayload = transformedPayload;
let confidence = 0.85; // High confidence for UPDATE operations
let allErrors = [];
let allSuggestions = [];
if (options.operation !== 'update') {
const autoFillResult = await this.templateAutoFiller.autoFillTemplate(transformedPayload, fieldMappings, analysis, {
entityType: options.entityType,
operation: options.operation,
platform: options.platform,
minimumConfidence: 0.6,
validateAfterFill: true,
allowPartialFill: !options.strict
});
finalPayload = autoFillResult.success ? autoFillResult.filledTemplate : transformedPayload;
allErrors = [...(autoFillResult.validationErrors || [])];
allSuggestions = [...(autoFillResult.suggestions || [])];
confidence = autoFillResult.confidence;
}
else {
// For UPDATE operations, preserve all fields
this.logger.debug({
operation: options.operation,
entityType: options.entityType,
preservedFields: Object.keys(finalPayload),
hasAbTest: !!finalPayload.ab_test,
abTestVariationCount: finalPayload.ab_test?.variations?.length || 0,
abTestVariationKeys: finalPayload.ab_test?.variations?.map((v) => v.key) || []
}, 'Skipping auto-fill for UPDATE operation to preserve all fields');
}
// Step 5: Final validation and result compilation
const result = {
success: options.operation === 'update' || confidence >= 0.6,
transformedPayload: finalPayload,
errors: allErrors.length > 0 ? allErrors : undefined,
suggestions: allSuggestions.length > 0 ? allSuggestions : undefined,
confidence: confidence
};
this.logger.debug(`Payload parsing completed: success=${result.success}, confidence=${result.confidence}`);
return result;
}
catch (error) {
this.logger.error(`Payload parsing failed for ${options.entityType}:${options.operation}: ${error instanceof Error ? error.message : 'Unknown error'}`);
return {
success: false,
errors: [`Parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}`],
confidence: 0
};
}
}
/**
* Transform the payload structure based on mappings and patterns
*/
async transformPayload(payload, fieldMappings, patterns, options) {
let transformed = { ...payload };
// Apply field mappings first
if (fieldMappings.length > 0) {
this.logger.debug({
entityType: options.entityType,
operation: options.operation,
beforeFieldMapping: {
hasAbTest: !!transformed.ab_test,
abTestVariationCount: transformed.ab_test?.variations?.length || 0,
abTestVariationKeys: transformed.ab_test?.variations?.map((v) => v.key) || []
},
fieldMappingsCount: fieldMappings.length,
fieldMappings: fieldMappings.map(fm => ({
aiField: fm.aiFieldName,
templateField: fm.templateFieldName,
confidence: fm.confidence
}))
}, 'About to apply field mappings');
transformed = await this.fieldMapper.applyMappings(transformed, fieldMappings);
this.logger.debug({
entityType: options.entityType,
operation: options.operation,
afterFieldMapping: {
hasAbTest: !!transformed.ab_test,
abTestVariationCount: transformed.ab_test?.variations?.length || 0,
abTestVariationKeys: transformed.ab_test?.variations?.map((v) => v.key) || []
}
}, 'Applied field mappings');
}
// Apply structure-specific transformations using JSONPath
for (const pattern of patterns) {
transformed = await this.applyStructureTransformation(transformed, pattern, options);
}
// Handle special cases based on entity type and operation
if (options.entityType === 'ruleset' && options.operation === 'update_ruleset') {
// Use specialized ruleset transformer for better results
const rulesetResult = await this.rulesetTransformer.transformRulesetPayload(transformed);
if (rulesetResult.success && rulesetResult.transformedRuleset) {
transformed = rulesetResult.transformedRuleset;
}
else {
// Fallback to basic transformation
transformed = await this.transformRulesetStructure(transformed);
}
}
// Handle event type routing for non-custom events
if (options.entityType === 'event' && options.operation === 'create') {
transformed = await this.handleEventTypeRouting(transformed);
}
// CRITICAL FIX: Handle flag update with ab_test structure
// When updating a flag with variations/metrics at root level, wrap in ab_test
if (options.entityType === 'flag' && options.operation === 'update') {
transformed = await this.handleFlagAbTestStructure(transformed);
}
this.logger.debug(`Payload transformation completed with ${Object.keys(transformed).length} fields`);
return transformed;
}
/**
* Apply structure-specific transformations using JSONPath
*/
async applyStructureTransformation(payload, pattern, options) {
let transformed = { ...payload };
switch (pattern.pattern) {
case 'nested_ruleset_data':
transformed = await this.flattenNestedRulesetData(transformed);
break;
case 'array_rules_structure':
transformed = await this.wrapRulesInRulesetStructure(transformed);
break;
case 'ruleset_transformation':
transformed = await this.normalizeRulesetFields(transformed);
break;
default:
this.logger.debug(`No transformation handler for pattern: ${pattern.pattern}`);
}
return transformed;
}
/**
* Flatten nested ruleset_data to root level using JSONPath
*/
async flattenNestedRulesetData(payload) {
const transformed = { ...payload };
// Use JSONPath to find nested ruleset data
const rulesetDataPaths = JSONPath({ path: '$..ruleset_data', json: payload, resultType: 'path' });
for (const path of rulesetDataPaths) {
const pathString = JSONPath.toPathString(path);
const rulesetData = JSONPath({ path: pathString, json: payload })[0];
if (rulesetData && typeof rulesetData === 'object') {
// Extract common ruleset fields to root level
if (rulesetData.enabled !== undefined)
transformed.enabled = rulesetData.enabled;
if (rulesetData.rules)
transformed.rules = rulesetData.rules;
if (rulesetData.default_variation_key)
transformed.default_variation_key = rulesetData.default_variation_key;
if (rulesetData.rollout_id)
transformed.rollout_id = rulesetData.rollout_id;
if (rulesetData.archived !== undefined)
transformed.archived = rulesetData.archived;
// Remove the nested structure
delete transformed.ruleset_data;
}
}
return transformed;
}
/**
* Wrap rules array in proper ruleset structure
*/
async wrapRulesInRulesetStructure(payload) {
const transformed = { ...payload };
if (Array.isArray(transformed.rules)) {
// Ensure we have required ruleset fields
if (transformed.enabled === undefined)
transformed.enabled = true;
if (!transformed.default_variation_key) {
// Try to infer from first rule's variations
const firstRule = transformed.rules[0];
if (firstRule && firstRule.variations && firstRule.variations.length > 0) {
transformed.default_variation_key = firstRule.variations[0].key;
}
}
}
return transformed;
}
/**
* Normalize ruleset field names and structure
*/
async normalizeRulesetFields(payload) {
const transformed = { ...payload };
// Common field normalizations using JSONPath queries
const enabledPaths = JSONPath({ path: '$..enabled', json: payload, resultType: 'path' });
const isActivePaths = JSONPath({ path: '$..is_active', json: payload, resultType: 'path' });
// Prefer 'enabled' over 'is_active'
if (isActivePaths.length > 0 && enabledPaths.length === 0) {
const isActiveValue = JSONPath({ path: JSONPath.toPathString(isActivePaths[0]), json: payload })[0];
transformed.enabled = isActiveValue;
// Remove is_active field
const parentPath = isActivePaths[0].slice(0, -1);
const parent = JSONPath({ path: JSONPath.toPathString(parentPath), json: transformed })[0];
if (parent && typeof parent === 'object') {
delete parent.is_active;
}
}
return transformed;
}
/**
* Transform ruleset structure for API compatibility
*/
async transformRulesetStructure(payload) {
const transformed = { ...payload };
// Ensure we have a complete ruleset structure
if (!transformed.enabled && transformed.enabled !== false) {
transformed.enabled = true; // Default to enabled
}
// Ensure rules array exists
if (!transformed.rules) {
transformed.rules = [];
}
// Validate default_variation_key exists
if (!transformed.default_variation_key && transformed.rules.length > 0) {
const firstRuleWithVariations = transformed.rules.find((rule) => rule.variations && rule.variations.length > 0);
if (firstRuleWithVariations) {
transformed.default_variation_key = firstRuleWithVariations.variations[0].key;
}
}
return transformed;
}
/**
* Handle event type routing for non-custom events
* @description Detects when event_type is not "custom" and transforms payload for page event endpoint
*/
async handleEventTypeRouting(payload) {
const transformed = { ...payload };
// If event_type is not specified or is 'custom', no transformation needed
if (!transformed.event_type || transformed.event_type === 'custom') {
return transformed;
}
// For non-custom events (click, pageview, etc.), we need to route to page events endpoint
// This requires a page_id to be present
if (!transformed.page_id) {
this.logger.warn(`Event with type '${transformed.event_type}' requires a page_id but none was provided`);
// Add a special marker to indicate this needs page event routing
transformed._requiresPageEventEndpoint = true;
transformed._missingPageId = true;
// Try to extract page_id from other fields
if (transformed.page || transformed.page_key) {
transformed.page_id = transformed.page || transformed.page_key;
delete transformed._missingPageId;
this.logger.info(`Auto-extracted page_id from ${transformed.page ? 'page' : 'page_key'} field`);
}
}
else {
// Page ID is present, just mark for routing
transformed._requiresPageEventEndpoint = true;
}
// Ensure event_type is valid for page events
const validPageEventTypes = ['click', 'pageview', 'custom', 'touch', 'submission'];
if (!validPageEventTypes.includes(transformed.event_type)) {
this.logger.warn(`Event type '${transformed.event_type}' may not be valid for page events. Valid types: ${validPageEventTypes.join(', ')}`);
// Auto-correct common mistakes
const eventTypeMapping = {
'clicked': 'click',
'tap': 'touch',
'tapped': 'touch',
'submit': 'submission',
'submitted': 'submission',
'form': 'submission',
'view': 'pageview',
'viewed': 'pageview',
'page_view': 'pageview'
};
const correctedType = eventTypeMapping[transformed.event_type.toLowerCase()];
if (correctedType) {
this.logger.info(`Auto-correcting event_type from '${transformed.event_type}' to '${correctedType}'`);
transformed.event_type = correctedType;
transformed._eventTypeCorrected = true;
}
}
return transformed;
}
/**
* Handle flag update with ab_test structure
* @description Detects when variations/metrics are at root level and wraps them in ab_test
*/
async handleFlagAbTestStructure(payload) {
let transformed = { ...payload };
// If already has ab_test structure, just return
if (transformed.ab_test) {
this.logger.debug('Payload already has ab_test structure, no transformation needed');
return transformed;
}
// Check if payload has variations/metrics at root level (common AI agent mistake)
const hasRootVariations = transformed.variations && Array.isArray(transformed.variations);
const hasRootMetrics = transformed.metrics && Array.isArray(transformed.metrics);
const hasRootEnvironment = transformed.environment || transformed.environment_key;
const hasRootAudience = transformed.audience || transformed.audience_conditions;
// If we have any ab_test-like fields at root, wrap them
if (hasRootVariations || hasRootMetrics || hasRootEnvironment || hasRootAudience) {
this.logger.info({
hasRootVariations,
hasRootMetrics,
hasRootEnvironment,
hasRootAudience,
variationCount: transformed.variations?.length || 0,
metricCount: transformed.metrics?.length || 0
}, 'Detected ab_test fields at root level, wrapping in ab_test structure');
// Build the ab_test structure
const abTest = {};
// Move variations
if (hasRootVariations) {
abTest.variations = transformed.variations;
delete transformed.variations;
}
// Move metrics
if (hasRootMetrics) {
abTest.metrics = transformed.metrics;
delete transformed.metrics;
}
// Move environment
if (transformed.environment) {
abTest.environment = transformed.environment;
delete transformed.environment;
}
else if (transformed.environment_key) {
abTest.environment = transformed.environment_key;
delete transformed.environment_key;
}
// Move audience conditions
if (transformed.audience_conditions) {
abTest.audience_conditions = transformed.audience_conditions;
delete transformed.audience_conditions;
}
else if (transformed.audience) {
abTest.audience_conditions = transformed.audience;
delete transformed.audience;
}
// Move rule-related fields
if (transformed.rule_key) {
abTest.rule_key = transformed.rule_key;
delete transformed.rule_key;
}
if (transformed.rule_name) {
abTest.rule_name = transformed.rule_name;
delete transformed.rule_name;
}
// Add the ab_test to transformed payload
transformed.ab_test = abTest;
this.logger.info({
abTestKeys: Object.keys(abTest),
remainingKeys: Object.keys(transformed),
abTestStructure: abTest
}, 'Successfully wrapped fields in ab_test structure');
}
return transformed;
}
/**
* Specialized handler for ruleset payload transformations
*/
async transformRulesetPayload(payload) {
this.logger.debug(`Transforming ruleset payload with ${Object.keys(payload || {}).length} fields`);
try {
// Use specialized ruleset transformer first
const rulesetResult = await this.rulesetTransformer.transformRulesetPayload(payload);
if (rulesetResult.success && rulesetResult.transformedRuleset) {
return {
success: true,
transformedPayload: rulesetResult.transformedRuleset,
confidence: rulesetResult.confidence,
suggestions: rulesetResult.warnings
};
}
else {
// Fallback to general parser if specialized transformer fails
this.logger.debug('Specialized ruleset transformer failed, falling back to general parser');
const result = await this.parsePayload(payload, {
entityType: 'ruleset',
operation: 'update_ruleset',
platform: 'feature',
enableFuzzyMatching: true
});
return result;
}
}
catch (error) {
this.logger.error(`Ruleset transformation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
return {
success: false,
errors: [`Ruleset transformation failed: ${error instanceof Error ? error.message : 'Unknown error'}`],
confidence: 0
};
}
}
/**
* Get field mapping suggestions for a given entity type
*/
async getFieldMappingSuggestions(entityType, aiFieldNames) {
return await this.fieldMapper.mapFields(Object.fromEntries(aiFieldNames.map(name => [name, null])), { entityType, threshold: 0.8 });
}
/**
* Analyze payload structure and provide insights
*/
async analyzePayloadStructure(payload) {
// TODO: Implement structure analysis
// - Calculate payload complexity
// - Identify potential entity types
// - Suggest transformations
return {
depth: 0,
fieldCount: 0,
suspectedEntityTypes: [],
recommendedTransformations: []
};
}
/**
* Correct JSON Patch paths for ruleset updates
* Fixes common mistakes like /rules/{key}/variations -> /rules/{key}/actions/0/changes/0/value/variations
*/
async correctJsonPatchPaths(patchOps, options) {
this.logger.info({
originalOps: patchOps,
opsCount: patchOps.length
}, 'Starting JSON Patch path correction');
const correctedOps = await Promise.all(patchOps.map(async (op) => {
if (!op.path)
return op;
// Fix rule addition with wrong structure for Feature Experimentation
const ruleAddMatch = op.path.match(/^\/rules\/([^\/]+)$/);
if (ruleAddMatch && op.op === 'add' && op.value && typeof op.value === 'object') {
const ruleKey = ruleAddMatch[1];
const ruleValue = op.value;
// Check if this is a simplified rule structure that needs conversion
if (ruleValue.variations && !ruleValue.actions) {
this.logger.info({
originalPath: op.path,
ruleKey,
hasVariations: true,
hasActions: false,
variationsType: Array.isArray(ruleValue.variations) ? 'array' : 'object'
}, 'Converting simplified rule structure to Feature Experimentation format');
// Convert variations from object to array if needed
let variationsArray = ruleValue.variations;
if (!Array.isArray(ruleValue.variations) && typeof ruleValue.variations === 'object') {
this.logger.info({
variationKeys: Object.keys(ruleValue.variations)
}, 'Converting variations from object to array format');
variationsArray = Object.entries(ruleValue.variations).map(([varKey, varData]) => {
// If the variation data is just the percentage, convert it
if (typeof varData === 'number') {
return {
key: varKey,
percentage_included: varData
};
}
// Otherwise ensure it has the key field
return {
key: varKey,
...varData
};
});
}
// Convert simplified structure to proper FX structure
const properRule = {
key: ruleValue.key || ruleKey,
name: ruleValue.name,
type: ruleValue.type || 'a/b',
percentage_included: ruleValue.percentage_included || 10000,
audience_conditions: ruleValue.audience_conditions || [],
enabled: ruleValue.enabled !== undefined ? ruleValue.enabled : true,
actions: [{
changes: [{
type: 'apply_feature_variables',
value: {
variations: variationsArray
}
}]
}]
};
// Copy any metrics if present
if (ruleValue.metrics) {
properRule.metrics = ruleValue.metrics;
}
return {
...op,
value: properRule
};
}
}
// Fix variation paths that are missing the actions/changes structure
const variationPathMatch = op.path.match(/^\/rules\/([^\/]+)\/variations$/);
if (variationPathMatch) {
const ruleKey = variationPathMatch[1];
this.logger.info({
originalPath: op.path,
operation: op.op,
value: op.value
}, `Correcting variation path for rule ${ruleKey}`);
// If trying to replace entire variations array, convert to individual updates
if (op.op === 'replace' && Array.isArray(op.value)) {
// Check if this is a Feature Experimentation variations format
// FX variations use { key: string, percentage_included: number } format
// But AI agents might use many variations of the field name
const percentageFieldVariations = [
'percentage', 'percent', 'pct', 'weight', 'allocation', 'traffic',
'traffic_percentage', 'traffic_allocation', 'traffic_weight', 'traffic_split',
'split_percentage', 'percentage_included', 'percent_included', 'inclusion_percentage',
'rollout_percentage', 'rollout_percent', 'exposure', 'exposure_percentage',
'distribution', 'distribution_percentage', 'serving_percentage', 'serving_percent',
'variant_weight', 'variation_weight', 'variation_percentage', 'bucket_percentage',
'sample_rate', 'ramp', 'rollout_pct', 'weight_total'
];
const isFXVariations = op.value.length > 0 && op.value.every((v) => {
if (!v.key)
return false;
// Check if the variation has any percentage-related field
return Object.keys(v).some(key => key === 'percentage_included' ||
percentageFieldVariations.includes(key.toLowerCase().replace(/[\s\-_]+/g, '_')));
});
if (isFXVariations) {
// Fix FX variations: use FieldMapper to handle all percentage field variations
const fixedVariations = await Promise.all(op.value.map(async (variation) => {
// Check if we need to map any percentage-related fields
const mappedVariation = { key: variation.key };
// Find any field that should map to percentage_included
for (const [fieldName, fieldValue] of Object.entries(variation)) {
if (fieldName === 'key')
continue; // Skip the key field
// Check if this field should map to percentage_included
const fieldMappings = await this.fieldMapper.mapFields({ [fieldName]: fieldValue }, {
entityType: 'rule'
});
const mapping = fieldMappings.find(m => m.templateFieldName === 'percentage_included');
if (mapping && mapping.confidence > 0.7) {
this.logger.info({
variationKey: variation.key,
originalField: fieldName,
mappedField: 'percentage_included',
value: fieldValue,
confidence: mapping.confidence
}, `Mapping '${fieldName}' to 'percentage_included' for FX variation`);
mappedVariation.percentage_included = fieldValue;
}
else if (fieldName === 'percentage_included') {
// Already correct field name
mappedVariation.percentage_included = fieldValue;
}
else {
// Keep other fields as-is
mappedVariation[fieldName] = fieldValue;
}
}
// Ensure we have percentage_included
if (!mappedVariation.percentage_included) {
this.logger.debug({
variationKey: variation.key,
originalFields: Object.keys(variation)
}, `No percentage field found for variation - using default 0`);
mappedVariation.percentage_included = 0;