UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

533 lines 20.3 kB
/** * UpdateAutoCorrector - Safe auto-corrections for UPDATE operations * * This class provides a subset of ComprehensiveAutoCorrector's capabilities * that are safe for UPDATE operations. It focuses on fixing AI agent mistakes * without applying defaults or making assumptions about missing fields. * * SAFE FOR UPDATES: * - Field name corrections (flag_key → key) * - Enum fuzzy matching (SUM → sum) * - URL protocol fixes * - Format validations * - Typo corrections * * NOT SAFE FOR UPDATES: * - Swagger defaults (could overwrite existing values) * - Safety fallbacks (could change intended nulls) * - Missing field additions (partial updates are valid) */ import { getLogger } from '../logging/Logger.js'; import { FIELDS } from '../generated/fields.generated.js'; // Field name mappings for common AI agent mistakes const fieldMappings = { common: { flag_key: 'key', experiment_key: 'key', audience_key: 'key', page_key: 'key', campaign_key: 'key', rule_key: 'key', attribute_key: 'key', desc: 'description', desc_text: 'description', audience_targeting: 'audience_conditions', traffic_split: 'traffic_allocation', abTest: 'ab_test', ab_test_info: 'ab_test', aggregator_type: 'aggregator', event_Key: 'event_key', Aggregator: 'aggregator' }, flag: { flag_key: 'key' }, experiment: { experiment_key: 'key' }, audience: { audience_key: 'key', audience_conditions: 'conditions' } }; // Enum value mappings const enumMappings = { aggregator: { sum: 'sum', unique: 'unique', total: 'sum', count: 'count', uniques: 'unique', SUM: 'sum', UNIQUE: 'unique', TOTAL: 'sum', COUNT: 'count' }, audience_conditions: { everyone: 'everyone', 'every one': 'everyone', all: 'everyone', none: 'everyone', noone: 'everyone' }, status: { // Only map to 'running' for experiments, not variations // active: 'running', // REMOVED - this breaks variation status live: 'running', paused: 'paused', stopped: 'paused', archived: 'archived' } }; // Audience condition type mappings const conditionTypeMappings = { cookies: 'cookies', cookie: 'cookies', code: 'code', javascript: 'code', js: 'code', query: 'query', queryparameter: 'query', querystring: 'query', referrer: 'referrer', referer: 'referrer', campaign: 'campaign', utm: 'campaign', browser: 'browser_version', browserversion: 'browser_version', event: 'event', customevent: 'event', customtag: 'custom_tag', tag: 'custom_tag', customdimension: 'custom_dimension', dimension: 'custom_dimension', ip: 'ip', ipaddress: 'ip', language: 'language', lang: 'language', location: 'location', geo: 'location', platform: 'platform', os: 'platform', firstsession: 'first_session', newsession: 'first_session', time: 'time_and_day', timeandday: 'time_and_day', sourcetype: 'source_type', source: 'source_type' }; export class UpdateAutoCorrector { static corrections = []; static entity = ''; /** * Main entry point for UPDATE operation auto-correction */ static autoCorrectForUpdate(entityType, userInput, context) { this.corrections = []; this.entity = entityType; // Skip if input is JSON Patch format (array) if (Array.isArray(userInput)) { getLogger().debug({ entityType, reason: 'JSON Patch format detected, skipping auto-correction' }, 'UpdateAutoCorrector: Skipped for JSON Patch'); return { correctedData: userInput, corrections: [], userMessage: 'JSON Patch format detected, skipping auto-correction', wasCorrected: false }; } let correctedData = JSON.parse(JSON.stringify(userInput)); // Apply UPDATE-safe corrections in order correctedData = this.correctFieldNames(correctedData, entityType); correctedData = this.applyStatusSemanticExpansion(correctedData, entityType); correctedData = this.fixUrlProtocols(correctedData); correctedData = this.correctEnumValues(correctedData); correctedData = this.validateAndFixFormats(correctedData, entityType, context?.platform); correctedData = this.removeInvalidFields(correctedData, entityType); const userMessage = this.generateUserMessage(); return { correctedData, corrections: this.corrections, userMessage, wasCorrected: this.corrections.length > 0 }; } /** * Correct common field name mistakes */ static correctFieldNames(data, entityType) { if (typeof data !== 'object' || data === null) return data; const corrected = Array.isArray(data) ? [] : {}; const mappings = fieldMappings[entityType] || fieldMappings['common']; for (const [key, value] of Object.entries(data)) { let correctedKey = key; // Check entity-specific mappings first if (fieldMappings[entityType] && fieldMappings[entityType][key]) { correctedKey = fieldMappings[entityType][key]; if (correctedKey !== key) { this.addCorrection({ field: key, original: key, corrected: correctedKey, reason: `Field name corrected for ${entityType}`, type: 'field_rename' }); } } // Then check common mappings else if (fieldMappings.common[key]) { correctedKey = fieldMappings.common[key]; if (correctedKey !== key) { this.addCorrection({ field: key, original: key, corrected: correctedKey, reason: 'Common field name correction', type: 'field_rename' }); } } // Recursively correct nested objects if (typeof value === 'object' && value !== null) { corrected[correctedKey] = this.correctFieldNames(value, entityType); } else { corrected[correctedKey] = value; } } return corrected; } /** * Fix URL protocols (add https:// if missing) */ static fixUrlProtocols(data) { if (typeof data !== 'object' || data === null) return data; const urlFields = ['edit_url', 'webhook_url', 'target_url', 'click_url', 'url']; const corrected = Array.isArray(data) ? [...data] : { ...data }; for (const [key, value] of Object.entries(corrected)) { if (urlFields.includes(key) && typeof value === 'string' && value.length > 0) { // Check if it looks like a URL but lacks protocol if (!value.startsWith('http://') && !value.startsWith('https://') && (value.includes('.') || value.startsWith('localhost'))) { corrected[key] = `https://${value}`; this.addCorrection({ field: key, original: value, corrected: corrected[key], reason: 'Added HTTPS protocol to URL', type: 'url_fix' }); } // Upgrade HTTP to HTTPS else if (value.startsWith('http://')) { corrected[key] = value.replace('http://', 'https://'); this.addCorrection({ field: key, original: value, corrected: corrected[key], reason: 'Upgraded HTTP to HTTPS', type: 'url_fix' }); } } // Recurse for nested objects else if (typeof value === 'object' && value !== null) { corrected[key] = this.fixUrlProtocols(value); } } return corrected; } /** * Correct enum values using fuzzy matching */ static correctEnumValues(data, parentKey) { if (typeof data !== 'object' || data === null) return data; const corrected = Array.isArray(data) ? [...data] : { ...data }; for (const [field, value] of Object.entries(corrected)) { // CRITICAL: Skip status mapping for variations to prevent 'active' -> 'running' conversion const isVariationStatus = field === 'status' && parentKey === 'variations'; // Check if this field has enum mappings if (enumMappings[field] && typeof value === 'string' && !isVariationStatus) { const validValues = Object.keys(enumMappings[field]); const normalizedValue = value.toLowerCase().trim(); // Exact match (case-insensitive) const exactMatch = validValues.find(v => v.toLowerCase() === normalizedValue); if (exactMatch && exactMatch !== value) { corrected[field] = exactMatch; this.addCorrection({ field, original: value, corrected: exactMatch, reason: 'Enum value case correction', type: 'enum_correction' }); continue; } // Check mapping const mapping = enumMappings[field]; if (mapping && mapping[normalizedValue]) { corrected[field] = mapping[normalizedValue]; this.addCorrection({ field, original: value, corrected: corrected[field], reason: 'Enum value corrected via mapping', type: 'enum_correction' }); } } // Special handling for audience condition types if (field === 'type' && data.audience_id !== undefined) { const normalized = value.toString().toLowerCase().replace(/[_\s-]+/g, ''); if (conditionTypeMappings[normalized]) { corrected[field] = conditionTypeMappings[normalized]; this.addCorrection({ field: 'conditions.type', original: value, corrected: corrected[field], reason: 'Audience condition type corrected', type: 'enum_correction' }); } } // Recurse for nested objects if (typeof corrected[field] === 'object' && corrected[field] !== null) { corrected[field] = this.correctEnumValues(corrected[field], field); } } return corrected; } /** * Validate and fix common format issues */ static validateAndFixFormats(data, entityType, platform) { if (typeof data !== 'object' || data === null) return data; const corrected = Array.isArray(data) ? [...data] : { ...data }; // Fix weight format (percentage string to basis points) if ('weight' in corrected) { if (typeof corrected.weight === 'string' && corrected.weight.includes('%')) { const percentage = parseFloat(corrected.weight.replace('%', '')); if (!isNaN(percentage)) { corrected.weight = Math.round(percentage * 100); this.addCorrection({ field: 'weight', original: data.weight, corrected: corrected.weight, reason: 'Converted percentage to basis points', type: 'format_fix' }); } } } // Fix description if it's an array or object if ('description' in corrected) { if (Array.isArray(corrected.description)) { corrected.description = corrected.description.join(' '); this.addCorrection({ field: 'description', original: '[Array]', corrected: corrected.description, reason: 'Converted array to string', type: 'format_fix' }); } else if (typeof corrected.description === 'object' && corrected.description !== null) { corrected.description = JSON.stringify(corrected.description); this.addCorrection({ field: 'description', original: '[Object]', corrected: corrected.description, reason: 'Converted object to string', type: 'format_fix' }); } } // Fix metrics with sum aggregator missing field if (corrected.metrics && Array.isArray(corrected.metrics)) { corrected.metrics = corrected.metrics.map((metric) => { if (metric.aggregator === 'sum' && !metric.field) { return { ...metric, field: 'value' }; } return metric; }); } // Recursively process nested objects for (const [key, value] of Object.entries(corrected)) { if (typeof value === 'object' && value !== null && key !== 'metrics') { corrected[key] = this.validateAndFixFormats(value, entityType, platform); } } return corrected; } /** * Generate user-friendly message */ static generateUserMessage() { if (this.corrections.length === 0) { return 'No corrections needed'; } const messages = ['Applied UPDATE corrections:']; for (const correction of this.corrections) { messages.push(`- ${correction.field}: ${correction.reason}`); } return messages.join('\n'); } /** * Apply Status semantic expansion for entity-specific meanings * Phase 1: Status semantic expansion (flags only) */ static applyStatusSemanticExpansion(data, entityType) { if (typeof data !== 'object' || data === null || data.Status === undefined) { return data; } const corrected = { ...data }; const statusValue = String(corrected.Status).toUpperCase(); // Entity-specific Status semantic expansion if (entityType === 'flag') { // For flags: Status maps to archived field (inverted logic) if (statusValue === 'ACTIVE') { corrected.archived = false; this.addCorrection({ field: 'archived', original: `Status: "${data.Status}"`, corrected: 'archived: false', reason: 'Status "ACTIVE" semantically expanded to archived: false', type: 'field_rename' }); } else if (statusValue === 'INACTIVE') { corrected.archived = true; this.addCorrection({ field: 'archived', original: `Status: "${data.Status}"`, corrected: 'archived: true', reason: 'Status "INACTIVE" semantically expanded to archived: true', type: 'field_rename' }); } // Remove the original Status field delete corrected.Status; this.addCorrection({ field: 'Status', original: 'Status', corrected: 'removed', reason: 'Status field removed after semantic expansion', type: 'field_rename' }); } // TODO: Add other entity types (experiments, etc.) in future phases return corrected; } /** * Remove fields that don't exist in the entity schema * Phase 2: Schema-based field cleanup */ static removeInvalidFields(data, entityType) { if (typeof data !== 'object' || data === null) { return data; } // CRITICAL: Skip field removal for template data if (data._template_type) { getLogger().debug({ entityType, templateType: data._template_type, reason: 'Template data detected, preserving all fields' }, 'UpdateAutoCorrector: Skipping field cleanup for template'); return data; } const schema = FIELDS[entityType]; if (!schema) { // No schema available, return as-is getLogger().debug({ entityType, reason: 'No schema found' }, 'UpdateAutoCorrector: Skipping field cleanup - no schema'); return data; } const validFields = [ ...(schema.required || []), ...(schema.optional || []) ]; const cleanedData = {}; const removedFields = []; for (const [field, value] of Object.entries(data)) { if (validFields.includes(field) || field === '_template_type') { cleanedData[field] = value; } else { removedFields.push(field); } } // Log removed fields if any if (removedFields.length > 0) { this.addCorrection({ field: 'schema_cleanup', original: removedFields.join(', '), corrected: 'removed', reason: `Removed invalid fields not in ${entityType} schema: ${removedFields.join(', ')}`, type: 'field_rename' }); getLogger().info({ entityType, removedFields, validFieldsCount: Object.keys(cleanedData).length, removedFieldsCount: removedFields.length }, 'UpdateAutoCorrector: Schema-based field cleanup applied'); } return cleanedData; } /** * Add a correction to the list */ static addCorrection(correction) { this.corrections.push(correction); getLogger().debug({ entity: this.entity, field: correction.field, original: correction.original, corrected: correction.corrected, reason: correction.reason }, 'UpdateAutoCorrector: Applied correction'); } } /** * Integration point for EntityRouter * * Add this to EntityRouter.handleUpdateOperation before line 2283: * * ```typescript * // Apply UPDATE-safe auto-corrections * if (data && !Array.isArray(data)) { * const { UpdateAutoCorrector } = await import('../validation/UpdateAutoCorrector.js'); * * const correctionResult = UpdateAutoCorrector.autoCorrectForUpdate( * entityType, * data, * { * platform: projectId ? await this.getProjectPlatformType(projectId) : undefined, * projectId * } * ); * * if (correctionResult.corrections.length > 0) { * data = correctionResult.correctedData; * getLogger().info({ * entityType, * operation: 'update', * corrections: correctionResult.corrections.length, * details: correctionResult.corrections.map(c => `${c.field}: ${c.reason}`) * }, 'Applied UPDATE auto-corrections'); * } * } * ``` */ //# sourceMappingURL=UpdateAutoCorrector.js.map