@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
486 lines • 18.3 kB
JavaScript
/**
* 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