@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
459 lines • 15.6 kB
JavaScript
/**
* Query Normalizer for Intelligent Query Caching
*
* Converts natural language queries and variations into a standardized format
* for consistent cache key generation and semantic understanding.
*/
import { getLogger } from '../../../logging/Logger.js';
export class QueryNormalizer {
logger = getLogger();
// Common query patterns and their normalized forms
queryPatterns = [
// List operations
{ pattern: /^(show|list|get|display|find)?\s*(?:all\s+)?(\w+)s?$/i, operation: 'list' },
{ pattern: /^(how many|count|total)\s+(\w+)s?/i, operation: 'count' },
{ pattern: /^(analyze|analysis|breakdown)\s+(\w+)s?/i, operation: 'analyze' },
{ pattern: /^(compare|diff|difference)\s+(\w+)s?/i, operation: 'compare' },
{ pattern: /^(\w+)\s+details?\s+(?:for\s+)?(.+)$/i, operation: 'detail' },
];
// Entity synonyms mapping
entitySynonyms = {
'flags': 'flag',
'feature flags': 'flag',
'feature flag': 'flag',
'experiments': 'experiment',
'campaigns': 'campaign',
'audiences': 'audience',
'users': 'audience',
'events': 'event',
'metrics': 'event',
'attributes': 'attribute',
'projects': 'project',
'environments': 'environment',
'envs': 'environment',
};
// Time range patterns
timePatterns = [
{ pattern: /last\s+(\d+)\s+(days?|weeks?|months?)/i, type: 'relative' },
{ pattern: /past\s+(\d+)\s+(days?|weeks?|months?)/i, type: 'relative' },
{ pattern: /today/i, type: 'relative', duration: 'today' },
{ pattern: /yesterday/i, type: 'relative', duration: 'yesterday' },
{ pattern: /this\s+(week|month|year)/i, type: 'relative' },
{ pattern: /since\s+(.+)/i, type: 'absolute' },
{ pattern: /between\s+(.+)\s+and\s+(.+)/i, type: 'absolute' },
];
/**
* Normalize a query from natural language or structured format
*/
normalize(query) {
if (typeof query === 'string') {
return this.normalizeNaturalLanguage(query);
}
else if (query.find) {
// Handle UniversalQuery format
return this.normalizeUniversalQuery(query);
}
else {
return this.normalizeStructuredQuery(query);
}
}
/**
* Normalize a natural language query
*/
normalizeNaturalLanguage(query) {
this.logger.debug({ query }, 'Normalizing natural language query');
const normalized = {
entity: '',
operation: 'list',
filters: {},
projections: [],
joins: [],
aggregations: [],
};
// Clean and standardize the query
const cleanQuery = query.toLowerCase().trim();
// Extract entity
normalized.entity = this.extractEntity(cleanQuery);
// Extract operation
normalized.operation = this.extractOperation(cleanQuery);
// Extract filters
normalized.filters = this.extractFilters(cleanQuery);
// Extract time range
const timeRange = this.extractTimeRange(cleanQuery);
if (timeRange) {
normalized.timeRange = timeRange;
}
// Extract aggregations
normalized.aggregations = this.extractAggregations(cleanQuery);
// Extract grouping
const groupBy = this.extractGroupBy(cleanQuery);
if (groupBy.length > 0) {
normalized.groupBy = groupBy;
}
// Extract joins from context
normalized.joins = this.extractImplicitJoins(cleanQuery, normalized.entity);
// Extract limit
const limit = this.extractLimit(cleanQuery);
if (limit) {
normalized.limit = limit;
}
this.logger.debug({ normalized }, 'Query normalization complete');
return normalized;
}
/**
* Normalize a UniversalQuery format
*/
normalizeUniversalQuery(query) {
const normalized = {
entity: query.find,
operation: query.select?.length ? 'select' : 'list',
filters: {},
projections: query.select || [],
joins: query.join?.map((j) => j.with) || [],
aggregations: query.aggregations?.map((a) => a.function.toUpperCase()) || [],
};
// Convert where conditions to filters object
if (query.where && Array.isArray(query.where)) {
query.where.forEach((condition) => {
normalized.filters[condition.field] = condition.value;
});
}
if (query.groupBy) {
normalized.groupBy = query.groupBy;
}
if (query.orderBy) {
normalized.orderBy = query.orderBy.map((o) => ({
field: o.field,
direction: o.direction.toLowerCase()
}));
}
if (query.limit) {
normalized.limit = query.limit;
}
return normalized;
}
/**
* Normalize a structured query
*/
normalizeStructuredQuery(query) {
const normalized = {
entity: query.from,
operation: query.select?.length ? 'select' : 'list',
filters: query.where || {},
projections: query.select || [],
joins: query.joins?.map(j => j.entity) || [],
aggregations: this.extractAggregationsFromProjections(query.select || []),
};
if (query.groupBy) {
normalized.groupBy = query.groupBy;
}
if (query.orderBy) {
normalized.orderBy = query.orderBy;
}
if (query.limit) {
normalized.limit = query.limit;
}
return normalized;
}
/**
* Extract entity type from query
*/
extractEntity(query) {
// Check each synonym pattern
for (const [synonym, entity] of Object.entries(this.entitySynonyms)) {
if (query.includes(synonym)) {
return entity;
}
}
// Try to find entity name in common patterns
const entityMatch = query.match(/\b(flag|experiment|campaign|audience|event|attribute|project|environment|variation|rule)\b/);
if (entityMatch) {
return entityMatch[1];
}
// Default to flag if unclear
return 'flag';
}
/**
* Extract operation type from query
*/
extractOperation(query) {
// Check for aggregation keywords first
if (/\b(count|how many|total)\b/i.test(query)) {
return 'count';
}
if (/\b(analyze|analysis|breakdown|distribution)\b/i.test(query)) {
return 'analyze';
}
if (/\b(compare|diff|difference|versus)\b/i.test(query)) {
return 'compare';
}
if (/\b(detail|details|info|information|specific)\b/i.test(query)) {
return 'detail';
}
if (/\b(group|grouped|by)\b/i.test(query)) {
return 'aggregate';
}
// Default to list
return 'list';
}
/**
* Extract filters from query
*/
extractFilters(query) {
const filters = {};
// Status filters
if (/\benabled\b/.test(query)) {
filters.enabled = true;
}
if (/\bdisabled\b/.test(query)) {
filters.enabled = false;
}
if (/\barchived\b/.test(query)) {
filters.archived = true;
}
if (/\bactive\b/.test(query)) {
filters.archived = false;
}
// Environment filters
const envMatch = query.match(/\bin\s+(production|development|staging|dev|prod|stage)\b/);
if (envMatch) {
filters.environment = envMatch[1].replace('prod', 'production').replace('dev', 'development');
}
// Platform filters
if (/\bweb\s+platform\b/.test(query)) {
filters.platform = 'web';
}
if (/\bfeature\s+(?:experimentation|flags?)\b/.test(query)) {
filters.platform = 'feature';
}
// Status filters for experiments
if (/\brunning\b/.test(query)) {
filters.status = 'running';
}
if (/\bpaused\b/.test(query)) {
filters.status = 'paused';
}
if (/\bcompleted\b/.test(query)) {
filters.status = 'completed';
}
return filters;
}
/**
* Extract time range from query
*/
extractTimeRange(query) {
// Check relative patterns
for (const pattern of this.timePatterns) {
const match = query.match(pattern.pattern);
if (match) {
if (pattern.type === 'relative') {
if (pattern.duration) {
return { type: 'relative', duration: pattern.duration };
}
else if (match[1] && match[2]) {
return {
type: 'relative',
duration: `last_${match[1]}_${match[2]}`
};
}
else if (match[1]) {
return {
type: 'relative',
duration: `this_${match[1]}`
};
}
}
else if (pattern.type === 'absolute') {
if (match[2]) {
// Between X and Y
return {
type: 'absolute',
start: match[1],
end: match[2]
};
}
else if (match[1]) {
// Since X
return {
type: 'absolute',
start: match[1]
};
}
}
}
}
// Check for specific date patterns
const dateMatch = query.match(/\b(\d{4}-\d{2}-\d{2})\b/);
if (dateMatch) {
return {
type: 'absolute',
start: dateMatch[1],
end: dateMatch[1]
};
}
return undefined;
}
/**
* Extract aggregation functions
*/
extractAggregations(query) {
const aggregations = [];
if (/\bcount\b/i.test(query)) {
aggregations.push('COUNT');
}
if (/\b(sum|total)\b/i.test(query)) {
aggregations.push('SUM');
}
if (/\b(avg|average)\b/i.test(query)) {
aggregations.push('AVG');
}
if (/\b(min|minimum)\b/i.test(query)) {
aggregations.push('MIN');
}
if (/\b(max|maximum)\b/i.test(query)) {
aggregations.push('MAX');
}
return aggregations;
}
/**
* Extract aggregations from projection fields
*/
extractAggregationsFromProjections(projections) {
const aggregations = [];
const aggPattern = /^(COUNT|SUM|AVG|MIN|MAX)\(/i;
for (const projection of projections) {
const match = projection.match(aggPattern);
if (match) {
aggregations.push(match[1].toUpperCase());
}
}
return aggregations;
}
/**
* Extract group by fields
*/
extractGroupBy(query) {
const groupBy = [];
// Look for "by X" or "group by X" patterns
const groupMatch = query.match(/(?:group\s+)?by\s+(\w+)(?:\s+and\s+(\w+))?/i);
if (groupMatch) {
groupBy.push(groupMatch[1]);
if (groupMatch[2]) {
groupBy.push(groupMatch[2]);
}
}
return groupBy;
}
/**
* Extract implicit joins based on query context
*/
extractImplicitJoins(query, primaryEntity) {
const joins = [];
// Flag queries mentioning experiments
if (primaryEntity === 'flag' && /\bexperiment/i.test(query)) {
joins.push('experiment');
}
// Experiment queries mentioning metrics/events
if (primaryEntity === 'experiment' && /\b(metric|event|conversion)/i.test(query)) {
joins.push('event');
}
// Queries mentioning audiences
if (/\baudience|targeting/i.test(query) && !joins.includes('audience')) {
joins.push('audience');
}
// Queries mentioning projects
if (/\bproject/i.test(query) && primaryEntity !== 'project') {
joins.push('project');
}
return joins;
}
/**
* Extract limit from query
*/
extractLimit(query) {
const limitMatch = query.match(/(?:top|first|limit)\s+(\d+)/i);
if (limitMatch) {
return parseInt(limitMatch[1], 10);
}
// Check for "show X" pattern
const showMatch = query.match(/show\s+(\d+)\s+/i);
if (showMatch) {
return parseInt(showMatch[1], 10);
}
return undefined;
}
/**
* Check if two normalized queries are semantically equivalent
*/
areEquivalent(query1, query2) {
// Must be same entity and operation
if (query1.entity !== query2.entity || query1.operation !== query2.operation) {
return false;
}
// Compare filters (order doesn't matter)
if (!this.objectsEqual(query1.filters, query2.filters)) {
return false;
}
// Compare arrays (order matters for some, not for others)
if (!this.arraysEqual(query1.projections, query2.projections)) {
return false;
}
if (!this.arraysEqual(query1.joins, query2.joins, false)) {
return false;
}
if (!this.arraysEqual(query1.aggregations, query2.aggregations, false)) {
return false;
}
// Compare optional fields
if (query1.limit !== query2.limit) {
return false;
}
// Time ranges need special comparison
if (!this.timeRangesEqual(query1.timeRange, query2.timeRange)) {
return false;
}
return true;
}
objectsEqual(obj1, obj2) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
for (const key of keys1) {
if (obj1[key] !== obj2[key]) {
return false;
}
}
return true;
}
arraysEqual(arr1, arr2, orderMatters = true) {
if (arr1.length !== arr2.length) {
return false;
}
if (orderMatters) {
return arr1.every((val, idx) => val === arr2[idx]);
}
else {
// For unordered comparison
const sorted1 = [...arr1].sort();
const sorted2 = [...arr2].sort();
return sorted1.every((val, idx) => val === sorted2[idx]);
}
}
timeRangesEqual(tr1, tr2) {
if (!tr1 && !tr2)
return true;
if (!tr1 || !tr2)
return false;
if (tr1.type !== tr2.type)
return false;
if (tr1.type === 'relative') {
return tr1.duration === tr2.duration;
}
else {
return tr1.start === tr2.start && tr1.end === tr2.end;
}
}
}
//# sourceMappingURL=QueryNormalizer.js.map