mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 13:33:11 +00:00
358 lines
16 KiB
JavaScript
358 lines
16 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.ResourceSimilarityService = void 0;
|
|
const logger_1 = require("../utils/logger");
|
|
class ResourceSimilarityService {
|
|
constructor(repository) {
|
|
this.resourceCache = new Map();
|
|
this.suggestionCache = new Map();
|
|
this.repository = repository;
|
|
this.commonPatterns = this.initializeCommonPatterns();
|
|
}
|
|
cleanupExpiredEntries() {
|
|
const now = Date.now();
|
|
for (const [key, value] of this.resourceCache.entries()) {
|
|
if (now - value.timestamp >= ResourceSimilarityService.CACHE_DURATION_MS) {
|
|
this.resourceCache.delete(key);
|
|
}
|
|
}
|
|
if (this.suggestionCache.size > 100) {
|
|
const entries = Array.from(this.suggestionCache.entries());
|
|
this.suggestionCache.clear();
|
|
entries.slice(-50).forEach(([key, value]) => {
|
|
this.suggestionCache.set(key, value);
|
|
});
|
|
}
|
|
}
|
|
initializeCommonPatterns() {
|
|
const patterns = new Map();
|
|
patterns.set('googleDrive', [
|
|
{ pattern: 'files', suggestion: 'file', confidence: 0.95, reason: 'Use singular "file" not plural' },
|
|
{ pattern: 'folders', suggestion: 'folder', confidence: 0.95, reason: 'Use singular "folder" not plural' },
|
|
{ pattern: 'permissions', suggestion: 'permission', confidence: 0.9, reason: 'Use singular form' },
|
|
{ pattern: 'fileAndFolder', suggestion: 'fileFolder', confidence: 0.9, reason: 'Use "fileFolder" for combined operations' },
|
|
{ pattern: 'driveFiles', suggestion: 'file', confidence: 0.8, reason: 'Use "file" for file operations' },
|
|
{ pattern: 'sharedDrives', suggestion: 'drive', confidence: 0.85, reason: 'Use "drive" for shared drive operations' },
|
|
]);
|
|
patterns.set('slack', [
|
|
{ pattern: 'messages', suggestion: 'message', confidence: 0.95, reason: 'Use singular "message" not plural' },
|
|
{ pattern: 'channels', suggestion: 'channel', confidence: 0.95, reason: 'Use singular "channel" not plural' },
|
|
{ pattern: 'users', suggestion: 'user', confidence: 0.95, reason: 'Use singular "user" not plural' },
|
|
{ pattern: 'msg', suggestion: 'message', confidence: 0.85, reason: 'Use full "message" not abbreviation' },
|
|
{ pattern: 'dm', suggestion: 'message', confidence: 0.7, reason: 'Use "message" for direct messages' },
|
|
{ pattern: 'conversation', suggestion: 'channel', confidence: 0.7, reason: 'Use "channel" for conversations' },
|
|
]);
|
|
patterns.set('database', [
|
|
{ pattern: 'tables', suggestion: 'table', confidence: 0.95, reason: 'Use singular "table" not plural' },
|
|
{ pattern: 'queries', suggestion: 'query', confidence: 0.95, reason: 'Use singular "query" not plural' },
|
|
{ pattern: 'collections', suggestion: 'collection', confidence: 0.95, reason: 'Use singular "collection" not plural' },
|
|
{ pattern: 'documents', suggestion: 'document', confidence: 0.95, reason: 'Use singular "document" not plural' },
|
|
{ pattern: 'records', suggestion: 'record', confidence: 0.85, reason: 'Use "record" or "document"' },
|
|
{ pattern: 'rows', suggestion: 'row', confidence: 0.9, reason: 'Use singular "row"' },
|
|
]);
|
|
patterns.set('googleSheets', [
|
|
{ pattern: 'sheets', suggestion: 'sheet', confidence: 0.95, reason: 'Use singular "sheet" not plural' },
|
|
{ pattern: 'spreadsheets', suggestion: 'spreadsheet', confidence: 0.95, reason: 'Use singular "spreadsheet"' },
|
|
{ pattern: 'cells', suggestion: 'cell', confidence: 0.9, reason: 'Use singular "cell"' },
|
|
{ pattern: 'ranges', suggestion: 'range', confidence: 0.9, reason: 'Use singular "range"' },
|
|
{ pattern: 'worksheets', suggestion: 'sheet', confidence: 0.8, reason: 'Use "sheet" for worksheet operations' },
|
|
]);
|
|
patterns.set('email', [
|
|
{ pattern: 'emails', suggestion: 'email', confidence: 0.95, reason: 'Use singular "email" not plural' },
|
|
{ pattern: 'messages', suggestion: 'message', confidence: 0.9, reason: 'Use "message" for email operations' },
|
|
{ pattern: 'mails', suggestion: 'email', confidence: 0.9, reason: 'Use "email" not "mail"' },
|
|
{ pattern: 'attachments', suggestion: 'attachment', confidence: 0.95, reason: 'Use singular "attachment"' },
|
|
]);
|
|
patterns.set('generic', [
|
|
{ pattern: 'items', suggestion: 'item', confidence: 0.9, reason: 'Use singular form' },
|
|
{ pattern: 'objects', suggestion: 'object', confidence: 0.9, reason: 'Use singular form' },
|
|
{ pattern: 'entities', suggestion: 'entity', confidence: 0.9, reason: 'Use singular form' },
|
|
{ pattern: 'resources', suggestion: 'resource', confidence: 0.9, reason: 'Use singular form' },
|
|
{ pattern: 'elements', suggestion: 'element', confidence: 0.9, reason: 'Use singular form' },
|
|
]);
|
|
return patterns;
|
|
}
|
|
findSimilarResources(nodeType, invalidResource, maxSuggestions = ResourceSimilarityService.MAX_SUGGESTIONS) {
|
|
if (Math.random() < 0.1) {
|
|
this.cleanupExpiredEntries();
|
|
}
|
|
const cacheKey = `${nodeType}:${invalidResource}`;
|
|
if (this.suggestionCache.has(cacheKey)) {
|
|
return this.suggestionCache.get(cacheKey);
|
|
}
|
|
const suggestions = [];
|
|
const validResources = this.getNodeResources(nodeType);
|
|
for (const resource of validResources) {
|
|
const resourceValue = this.getResourceValue(resource);
|
|
if (resourceValue.toLowerCase() === invalidResource.toLowerCase()) {
|
|
return [];
|
|
}
|
|
}
|
|
const nodePatterns = this.getNodePatterns(nodeType);
|
|
for (const pattern of nodePatterns) {
|
|
if (pattern.pattern.toLowerCase() === invalidResource.toLowerCase()) {
|
|
const exists = validResources.some(r => {
|
|
const resourceValue = this.getResourceValue(r);
|
|
return resourceValue === pattern.suggestion;
|
|
});
|
|
if (exists) {
|
|
suggestions.push({
|
|
value: pattern.suggestion,
|
|
confidence: pattern.confidence,
|
|
reason: pattern.reason
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const singularForm = this.toSingular(invalidResource);
|
|
const pluralForm = this.toPlural(invalidResource);
|
|
for (const resource of validResources) {
|
|
const resourceValue = this.getResourceValue(resource);
|
|
if (resourceValue === singularForm || resourceValue === pluralForm) {
|
|
if (!suggestions.some(s => s.value === resourceValue)) {
|
|
suggestions.push({
|
|
value: resourceValue,
|
|
confidence: 0.9,
|
|
reason: invalidResource.endsWith('s') ?
|
|
'Use singular form for resources' :
|
|
'Incorrect plural/singular form',
|
|
availableOperations: typeof resource === 'object' ? resource.operations : undefined
|
|
});
|
|
}
|
|
}
|
|
const similarity = this.calculateSimilarity(invalidResource, resourceValue);
|
|
if (similarity >= ResourceSimilarityService.MIN_CONFIDENCE) {
|
|
if (!suggestions.some(s => s.value === resourceValue)) {
|
|
suggestions.push({
|
|
value: resourceValue,
|
|
confidence: similarity,
|
|
reason: this.getSimilarityReason(similarity, invalidResource, resourceValue),
|
|
availableOperations: typeof resource === 'object' ? resource.operations : undefined
|
|
});
|
|
}
|
|
}
|
|
}
|
|
suggestions.sort((a, b) => b.confidence - a.confidence);
|
|
const topSuggestions = suggestions.slice(0, maxSuggestions);
|
|
this.suggestionCache.set(cacheKey, topSuggestions);
|
|
return topSuggestions;
|
|
}
|
|
getResourceValue(resource) {
|
|
if (typeof resource === 'string') {
|
|
return resource;
|
|
}
|
|
if (typeof resource === 'object' && resource !== null) {
|
|
return resource.value || '';
|
|
}
|
|
return '';
|
|
}
|
|
getNodeResources(nodeType) {
|
|
if (Math.random() < 0.05) {
|
|
this.cleanupExpiredEntries();
|
|
}
|
|
const cacheKey = nodeType;
|
|
const cached = this.resourceCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < ResourceSimilarityService.CACHE_DURATION_MS) {
|
|
return cached.resources;
|
|
}
|
|
const nodeInfo = this.repository.getNode(nodeType);
|
|
if (!nodeInfo)
|
|
return [];
|
|
const resources = [];
|
|
const resourceMap = new Map();
|
|
try {
|
|
const properties = nodeInfo.properties || [];
|
|
for (const prop of properties) {
|
|
if (prop.name === 'resource' && prop.options) {
|
|
for (const option of prop.options) {
|
|
resources.push({
|
|
value: option.value,
|
|
name: option.name,
|
|
operations: []
|
|
});
|
|
resourceMap.set(option.value, []);
|
|
}
|
|
}
|
|
if (prop.name === 'operation' && prop.displayOptions?.show?.resource) {
|
|
const resourceValues = Array.isArray(prop.displayOptions.show.resource)
|
|
? prop.displayOptions.show.resource
|
|
: [prop.displayOptions.show.resource];
|
|
for (const resourceValue of resourceValues) {
|
|
if (resourceMap.has(resourceValue) && prop.options) {
|
|
const ops = prop.options.map((op) => op.value);
|
|
resourceMap.get(resourceValue).push(...ops);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const resource of resources) {
|
|
if (resourceMap.has(resource.value)) {
|
|
resource.operations = resourceMap.get(resource.value);
|
|
}
|
|
}
|
|
if (resources.length === 0) {
|
|
const implicitResources = this.extractImplicitResources(properties);
|
|
resources.push(...implicitResources);
|
|
}
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.warn(`Failed to extract resources for ${nodeType}:`, error);
|
|
}
|
|
this.resourceCache.set(cacheKey, { resources, timestamp: Date.now() });
|
|
return resources;
|
|
}
|
|
extractImplicitResources(properties) {
|
|
const resources = [];
|
|
for (const prop of properties) {
|
|
if (prop.name === 'operation' && prop.options) {
|
|
const resourceFromOps = this.inferResourceFromOperations(prop.options);
|
|
if (resourceFromOps) {
|
|
resources.push({
|
|
value: resourceFromOps,
|
|
name: resourceFromOps.charAt(0).toUpperCase() + resourceFromOps.slice(1),
|
|
operations: prop.options.map((op) => op.value)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return resources;
|
|
}
|
|
inferResourceFromOperations(operations) {
|
|
const patterns = [
|
|
{ keywords: ['file', 'upload', 'download'], resource: 'file' },
|
|
{ keywords: ['folder', 'directory'], resource: 'folder' },
|
|
{ keywords: ['message', 'send', 'reply'], resource: 'message' },
|
|
{ keywords: ['channel', 'broadcast'], resource: 'channel' },
|
|
{ keywords: ['user', 'member'], resource: 'user' },
|
|
{ keywords: ['table', 'row', 'column'], resource: 'table' },
|
|
{ keywords: ['document', 'doc'], resource: 'document' },
|
|
];
|
|
for (const pattern of patterns) {
|
|
for (const op of operations) {
|
|
const opName = (op.value || op).toLowerCase();
|
|
if (pattern.keywords.some(keyword => opName.includes(keyword))) {
|
|
return pattern.resource;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
getNodePatterns(nodeType) {
|
|
const patterns = [];
|
|
if (nodeType.includes('googleDrive')) {
|
|
patterns.push(...(this.commonPatterns.get('googleDrive') || []));
|
|
}
|
|
else if (nodeType.includes('slack')) {
|
|
patterns.push(...(this.commonPatterns.get('slack') || []));
|
|
}
|
|
else if (nodeType.includes('postgres') || nodeType.includes('mysql') || nodeType.includes('mongodb')) {
|
|
patterns.push(...(this.commonPatterns.get('database') || []));
|
|
}
|
|
else if (nodeType.includes('googleSheets')) {
|
|
patterns.push(...(this.commonPatterns.get('googleSheets') || []));
|
|
}
|
|
else if (nodeType.includes('gmail') || nodeType.includes('email')) {
|
|
patterns.push(...(this.commonPatterns.get('email') || []));
|
|
}
|
|
patterns.push(...(this.commonPatterns.get('generic') || []));
|
|
return patterns;
|
|
}
|
|
toSingular(word) {
|
|
if (word.endsWith('ies')) {
|
|
return word.slice(0, -3) + 'y';
|
|
}
|
|
else if (word.endsWith('es')) {
|
|
return word.slice(0, -2);
|
|
}
|
|
else if (word.endsWith('s') && !word.endsWith('ss')) {
|
|
return word.slice(0, -1);
|
|
}
|
|
return word;
|
|
}
|
|
toPlural(word) {
|
|
if (word.endsWith('y') && !['ay', 'ey', 'iy', 'oy', 'uy'].includes(word.slice(-2))) {
|
|
return word.slice(0, -1) + 'ies';
|
|
}
|
|
else if (word.endsWith('s') || word.endsWith('x') || word.endsWith('z') ||
|
|
word.endsWith('ch') || word.endsWith('sh')) {
|
|
return word + 'es';
|
|
}
|
|
else {
|
|
return word + 's';
|
|
}
|
|
}
|
|
calculateSimilarity(str1, str2) {
|
|
const s1 = str1.toLowerCase();
|
|
const s2 = str2.toLowerCase();
|
|
if (s1 === s2)
|
|
return 1.0;
|
|
if (s1.includes(s2) || s2.includes(s1)) {
|
|
const ratio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length);
|
|
return Math.max(ResourceSimilarityService.CONFIDENCE_THRESHOLDS.MIN_SUBSTRING, ratio);
|
|
}
|
|
const distance = this.levenshteinDistance(s1, s2);
|
|
const maxLength = Math.max(s1.length, s2.length);
|
|
let similarity = 1 - (distance / maxLength);
|
|
if (distance === 1 && maxLength <= 5) {
|
|
similarity = Math.max(similarity, 0.75);
|
|
}
|
|
else if (distance === 2 && maxLength <= 5) {
|
|
similarity = Math.max(similarity, 0.72);
|
|
}
|
|
return similarity;
|
|
}
|
|
levenshteinDistance(str1, str2) {
|
|
const m = str1.length;
|
|
const n = str2.length;
|
|
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
|
for (let i = 0; i <= m; i++)
|
|
dp[i][0] = i;
|
|
for (let j = 0; j <= n; j++)
|
|
dp[0][j] = j;
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
if (str1[i - 1] === str2[j - 1]) {
|
|
dp[i][j] = dp[i - 1][j - 1];
|
|
}
|
|
else {
|
|
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1);
|
|
}
|
|
}
|
|
}
|
|
return dp[m][n];
|
|
}
|
|
getSimilarityReason(confidence, invalid, valid) {
|
|
const { VERY_HIGH, HIGH, MEDIUM } = ResourceSimilarityService.CONFIDENCE_THRESHOLDS;
|
|
if (confidence >= VERY_HIGH) {
|
|
return 'Almost exact match - likely a typo';
|
|
}
|
|
else if (confidence >= HIGH) {
|
|
return 'Very similar - common variation';
|
|
}
|
|
else if (confidence >= MEDIUM) {
|
|
return 'Similar resource name';
|
|
}
|
|
else if (invalid.includes(valid) || valid.includes(invalid)) {
|
|
return 'Partial match';
|
|
}
|
|
else {
|
|
return 'Possibly related resource';
|
|
}
|
|
}
|
|
clearCache() {
|
|
this.resourceCache.clear();
|
|
this.suggestionCache.clear();
|
|
}
|
|
}
|
|
exports.ResourceSimilarityService = ResourceSimilarityService;
|
|
ResourceSimilarityService.CACHE_DURATION_MS = 5 * 60 * 1000;
|
|
ResourceSimilarityService.MIN_CONFIDENCE = 0.3;
|
|
ResourceSimilarityService.MAX_SUGGESTIONS = 5;
|
|
ResourceSimilarityService.CONFIDENCE_THRESHOLDS = {
|
|
EXACT: 1.0,
|
|
VERY_HIGH: 0.95,
|
|
HIGH: 0.8,
|
|
MEDIUM: 0.6,
|
|
MIN_SUBSTRING: 0.7
|
|
};
|
|
//# sourceMappingURL=resource-similarity-service.js.map
|