UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

486 lines 18.3 kB
/** * IntentParser - Converts natural language queries to structured query intents */ import { QUERY_PATTERNS, JSON_PATH_MAPPINGS } from './constants.js'; export class IntentParser { /** * Parse natural language query into structured intent */ parse(naturalQuery) { const normalizedQuery = naturalQuery.toLowerCase().trim(); const intent = { filters: [], aggregations: [], limit: parseInt(process.env.ANALYTICS_DEFAULT_PAGE_SIZE || '10') // default from environment }; // 1. Detect primary action intent.action = this.detectAction(normalizedQuery); // 2. Extract entities const entities = this.extractEntities(normalizedQuery); intent.primaryEntity = entities.primary; intent.relatedEntities = entities.related; // 3. Extract metrics intent.metrics = this.extractMetrics(normalizedQuery); // 4. Extract grouping intent.groupBy = this.extractGroupBy(normalizedQuery); // 5. Extract ordering intent.orderBy = this.extractOrderBy(normalizedQuery); // 6. Extract filters intent.filters = this.extractFilters(normalizedQuery); // 7. Extract time range intent.timeRange = this.extractTimeRange(normalizedQuery); // 8. Extract limit const limitMatch = normalizedQuery.match(/(?:top|first|limit)\s+(\d+)/i); if (limitMatch) { intent.limit = parseInt(limitMatch[1], 10); } // 9. Infer aggregations based on action and metrics intent.aggregations = this.inferAggregations(intent); // 10. Validate and enrich the intent return this.validateAndEnrich(intent); } detectAction(query) { if (QUERY_PATTERNS.counting.test(query)) return 'count'; if (QUERY_PATTERNS.grouping.test(query)) return 'group'; if (QUERY_PATTERNS.trending.test(query)) return 'trend'; if (QUERY_PATTERNS.comparison.test(query)) return 'compare'; if (QUERY_PATTERNS.analyzing.test(query)) return 'analyze'; if (QUERY_PATTERNS.summarizing.test(query)) return 'summarize'; if (QUERY_PATTERNS.finding.test(query)) return 'find'; // Default actions based on keywords if (query.includes('show') || query.includes('display')) return 'show'; if (query.includes('list')) return 'list'; return 'find'; // default } extractEntities(query) { const foundEntities = []; // Check for each entity type const entityChecks = [ [QUERY_PATTERNS.experiments, 'experiments'], [QUERY_PATTERNS.flags, 'flags'], [QUERY_PATTERNS.audiences, 'audiences'], [QUERY_PATTERNS.variations, 'variations'], [QUERY_PATTERNS.rules, 'rules'], [QUERY_PATTERNS.events, 'events'], [QUERY_PATTERNS.attributes, 'attributes'], [QUERY_PATTERNS.pages, 'pages'], [QUERY_PATTERNS.projects, 'projects'], [QUERY_PATTERNS.environments, 'environments'], [QUERY_PATTERNS.rulesets, 'rulesets'] ]; for (const [pattern, entity] of entityChecks) { if (pattern.test(query)) { foundEntities.push(entity); } } // Determine primary entity (first found or based on context) let primary = foundEntities[0] || 'flags'; // Handle special cases where secondary entity should be primary if (query.includes('experiments using audiences')) { primary = 'experiments'; } else if (query.includes('flags with variations')) { primary = 'flags'; } else if (query.includes('audiences in experiments')) { primary = 'audiences'; } const related = foundEntities.filter(e => e !== primary); return { primary, related }; } extractMetrics(query) { const metrics = []; if (QUERY_PATTERNS.traffic.test(query)) { metrics.push('traffic_allocation'); } if (QUERY_PATTERNS.conversion.test(query)) { metrics.push('conversion_rate', 'performance'); } if (QUERY_PATTERNS.complexity.test(query)) { metrics.push('complexity_score', 'variation_count', 'rule_count'); } if (QUERY_PATTERNS.usage.test(query)) { metrics.push('usage_count', 'experiment_count'); } // Extract specific metric names const metricMatch = query.match(/(?:by|with|show|display)\s+(\w+(?:_\w+)*)/g); if (metricMatch) { metricMatch.forEach(match => { const metric = match.replace(/^(by|with|show|display)\s+/, ''); if (!metrics.includes(metric)) { metrics.push(metric); } }); } return metrics; } extractGroupBy(query) { const groupByFields = []; // Multiple patterns for grouping const patterns = [ QUERY_PATTERNS.groupedBy, QUERY_PATTERNS.byField, QUERY_PATTERNS.perField ]; for (const pattern of patterns) { const matches = query.matchAll(pattern); for (const match of matches) { const field = match[1]; if (field && !groupByFields.includes(field)) { groupByFields.push(field); } } } // Handle "group by X and Y" pattern const multiGroupMatch = query.match(/group(?:ed)?\s+by\s+(\w+)(?:\s+and\s+(\w+))*/i); if (multiGroupMatch) { if (multiGroupMatch[1] && !groupByFields.includes(multiGroupMatch[1])) { groupByFields.push(multiGroupMatch[1]); } if (multiGroupMatch[2] && !groupByFields.includes(multiGroupMatch[2])) { groupByFields.push(multiGroupMatch[2]); } } return groupByFields; } extractOrderBy(query) { const orderByList = []; // Check for ordering patterns const orderMatch = query.match(QUERY_PATTERNS.orderBy) || query.match(QUERY_PATTERNS.sortBy); if (orderMatch) { const field = orderMatch[2]; const direction = orderMatch[4]?.startsWith('desc') ? 'desc' : 'asc'; orderByList.push({ field, direction }); } // Check for top/bottom N (implies ordering) const topMatch = query.match(QUERY_PATTERNS.topN); if (topMatch && !orderByList.length) { // Default to ordering by count or first metric orderByList.push({ field: 'count', direction: 'desc' }); } const bottomMatch = query.match(QUERY_PATTERNS.bottomN); if (bottomMatch && !orderByList.length) { orderByList.push({ field: 'count', direction: 'asc' }); } return orderByList; } extractFilters(query) { const filters = []; // Extract "has/with" filters const hasMatch = query.matchAll(QUERY_PATTERNS.hasProperty); for (const match of hasMatch) { const property = match[1] || match[2]; if (property) { // Map common property names to JSON paths const jsonPath = this.mapToJsonPath(property); filters.push({ field: jsonPath || property, operator: 'exists' }); } } // Extract "without/no" filters const notHasMatch = query.matchAll(QUERY_PATTERNS.notHasProperty); for (const match of notHasMatch) { const property = match[1] || match[2]; if (property) { const jsonPath = this.mapToJsonPath(property); filters.push({ field: jsonPath || property, operator: 'not_exists' }); } } // Extract numeric comparisons const moreThanMatch = query.match(QUERY_PATTERNS.moreThan); if (moreThanMatch) { const value = parseInt(moreThanMatch[1] || moreThanMatch[2] || moreThanMatch[3], 10); // Infer field from context const field = this.inferFieldFromContext(query, 'numeric'); if (field) { filters.push({ field, operator: 'gt', value }); } } const lessThanMatch = query.match(QUERY_PATTERNS.lessThan); if (lessThanMatch) { const value = parseInt(lessThanMatch[1] || lessThanMatch[2] || lessThanMatch[3], 10); const field = this.inferFieldFromContext(query, 'numeric'); if (field) { filters.push({ field, operator: 'lt', value }); } } // Extract equality filters const equalsMatch = query.match(QUERY_PATTERNS.equals); if (equalsMatch) { const value = equalsMatch[1] || equalsMatch[2] || equalsMatch[3]; const field = this.inferFieldFromContext(query, 'equality'); if (field && value) { filters.push({ field, operator: 'eq', value: this.parseValue(value) }); } } // Extract contains filters const containsMatch = query.match(QUERY_PATTERNS.contains); if (containsMatch) { const value = containsMatch[1] || containsMatch[2]; const field = this.inferFieldFromContext(query, 'contains'); if (field && value) { filters.push({ field, operator: 'contains', value }); } } // Handle specific common patterns if (query.includes('archived') || query.includes('active')) { filters.push({ field: 'archived', operator: 'eq', value: query.includes('archived') }); } if (query.includes('production') || query.includes('staging')) { const envValue = query.includes('production') ? 'production' : 'staging'; filters.push({ field: 'environment', operator: 'eq', value: envValue }); } return filters; } extractTimeRange(query) { // Check for "last N days/weeks/months" pattern const lastNMatch = query.match(QUERY_PATTERNS.lastN); if (lastNMatch) { const value = parseInt(lastNMatch[1], 10); const unit = lastNMatch[2].replace(/s$/, ''); return { relative: { value, unit: unit + 's' } }; } // Check for "between X and Y" pattern const betweenMatch = query.match(QUERY_PATTERNS.between); if (betweenMatch) { return { start: this.parseDate(betweenMatch[1]), end: this.parseDate(betweenMatch[2]) }; } // Check for "since X" pattern const sinceMatch = query.match(QUERY_PATTERNS.since); if (sinceMatch) { return { start: this.parseDate(sinceMatch[1]) }; } // Check for "before X" pattern const beforeMatch = query.match(QUERY_PATTERNS.before); if (beforeMatch) { return { end: this.parseDate(beforeMatch[1]) }; } // Check for "after X" pattern const afterMatch = query.match(QUERY_PATTERNS.after); if (afterMatch) { return { start: this.parseDate(afterMatch[1]) }; } return undefined; } inferAggregations(intent) { const aggregations = []; // Based on action if (intent.action === 'count') { aggregations.push('count'); } else if (intent.action === 'summarize' || intent.action === 'analyze') { aggregations.push('count', 'avg', 'sum'); } // Based on metrics if (intent.metrics) { for (const metric of intent.metrics) { if (metric.includes('count')) { aggregations.push('count'); } else if (metric.includes('average') || metric.includes('avg')) { aggregations.push('avg'); } else if (metric.includes('sum') || metric.includes('total')) { aggregations.push('sum'); } else if (metric.includes('percent')) { aggregations.push('percent'); } } } // Based on grouping (if grouping, usually want counts) if (intent.groupBy && intent.groupBy.length > 0 && !aggregations.includes('count')) { aggregations.push('count'); } // Remove duplicates return [...new Set(aggregations)]; } validateAndEnrich(intent) { // Ensure we have a primary entity if (!intent.primaryEntity) { intent.primaryEntity = 'flags'; // default } // If no specific metrics but action suggests metrics, add defaults if (!intent.metrics || intent.metrics.length === 0) { if (intent.action === 'analyze' || intent.action === 'summarize') { intent.metrics = ['count', 'status_distribution']; } } // If grouping by field but no order, add default ordering if (intent.groupBy && intent.groupBy.length > 0 && (!intent.orderBy || intent.orderBy.length === 0)) { intent.orderBy = [{ field: 'count', direction: 'desc' }]; } // Validate limit if (!intent.limit || intent.limit <= 0) { intent.limit = parseInt(process.env.ANALYTICS_DEFAULT_PAGE_SIZE || '10'); } else if (intent.limit > 10000) { intent.limit = 10000; } return intent; } mapToJsonPath(property) { // Direct mapping if (JSON_PATH_MAPPINGS[property]) { return JSON_PATH_MAPPINGS[property]; } // Try common patterns const lowerProp = property.toLowerCase(); // Variable-related if (lowerProp.includes('variable') || lowerProp.includes('var')) { return 'data_json.variable_definitions'; } // CDN settings if (lowerProp.includes('cdn')) { return 'data_json.variable_values.cdnVariationSettings'; } // Audience-related if (lowerProp.includes('audience')) { return 'data_json.audience_ids'; } // Metric-related if (lowerProp.includes('metric')) { return 'data_json.metrics'; } return undefined; } inferFieldFromContext(query, filterType) { const words = query.split(/\s+/); if (filterType === 'numeric') { // Look for numeric-related fields if (query.includes('variation')) return 'variation_count'; if (query.includes('audience')) return 'audience_count'; if (query.includes('rule')) return 'rule_count'; if (query.includes('experiment')) return 'experiment_count'; if (query.includes('traffic')) return 'traffic_allocation'; } else if (filterType === 'equality') { // Look for status, type, etc. if (query.includes('status')) return 'status'; if (query.includes('type')) return 'type'; if (query.includes('environment')) return 'environment'; if (query.includes('platform')) return 'platform'; } else if (filterType === 'contains') { // Look for text fields if (query.includes('name')) return 'name'; if (query.includes('description')) return 'description'; if (query.includes('key')) return 'key'; } // Default based on entity context return 'name'; } parseValue(value) { // Remove quotes if present value = value.trim().replace(/^["']|["']$/g, ''); // Boolean if (value.toLowerCase() === 'true') return true; if (value.toLowerCase() === 'false') return false; // Number if (/^\d+$/.test(value)) return parseInt(value, 10); if (/^\d+\.\d+$/.test(value)) return parseFloat(value); // Array (comma-separated) if (value.includes(',')) { return value.split(',').map(v => v.trim()); } // String return value; } parseDate(dateStr) { // This is a simplified date parser // In production, you'd want to use a proper date parsing library const trimmed = dateStr.trim(); // ISO date format if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) { return trimmed; } // Relative dates const today = new Date(); if (trimmed === 'today') { return today.toISOString().split('T')[0]; } if (trimmed === 'yesterday') { today.setDate(today.getDate() - 1); return today.toISOString().split('T')[0]; } if (trimmed === 'last week') { today.setDate(today.getDate() - 7); return today.toISOString().split('T')[0]; } if (trimmed === 'last month') { today.setMonth(today.getMonth() - 1); return today.toISOString().split('T')[0]; } // Default to the input return dateStr; } } //# sourceMappingURL=IntentParser.js.map