UNPKG

mcp-product-manager

Version:

MCP Orchestrator for task and project management with web interface

496 lines 18.7 kB
/** * Semantic Prefix Extractor Service * * Extracts and analyzes semantic prefixes from task descriptions * to enable intelligent task grouping and bundling */ class SemanticPrefixExtractor { constructor() { // Common patterns for task prefixes this.patterns = { // [AREA-FEATURE-###] - Most specific pattern areaFeatureNumber: /^\[([A-Z]+[A-Z0-9]*)-([A-Z]+[A-Z0-9]*)-(\d+)\]/, // [AREA-###] - Simple area pattern areaNumber: /^\[([A-Z]+[A-Z0-9]*)-(\d+)\]/, // [FEATURE:###] - Alternative format with colon featureColon: /^\[([A-Z]+[A-Z0-9]*):(\d+)\]/, // (AREA) prefix - Parenthetical format parenthetical: /^\(([A-Z]+[A-Z0-9]*)\)/, // AREA-FEATURE: description - No brackets noBrackets: /^([A-Z]+[A-Z0-9]*)-([A-Z]+[A-Z0-9]*): /, // ###: description - Ticket number format ticketNumber: /^(\d+): /, // fix: , feat: , docs: , etc - Conventional commits style conventional: /^(fix|feat|docs|style|refactor|test|chore|perf|ci|build|revert)(?:\(([^)]+)\))?: /i }; // Semantic categories for common areas this.semanticCategories = { // Backend/API areas API: 'backend', AUTH: 'backend', DB: 'backend', DATABASE: 'backend', SERVER: 'backend', ENDPOINT: 'backend', ROUTE: 'backend', MIDDLEWARE: 'backend', // Frontend areas UI: 'frontend', UX: 'frontend', COMPONENT: 'frontend', VIEW: 'frontend', PAGE: 'frontend', STYLE: 'frontend', CSS: 'frontend', LAYOUT: 'frontend', // Testing areas TEST: 'testing', SPEC: 'testing', E2E: 'testing', UNIT: 'testing', INTEGRATION: 'testing', // Infrastructure areas INFRA: 'infrastructure', DEPLOY: 'infrastructure', CI: 'infrastructure', CD: 'infrastructure', DOCKER: 'infrastructure', K8S: 'infrastructure', // Documentation areas DOC: 'documentation', DOCS: 'documentation', README: 'documentation', GUIDE: 'documentation', // Performance areas PERF: 'performance', OPTIMIZE: 'performance', CACHE: 'performance', // Security areas SEC: 'security', SECURITY: 'security', VULN: 'security', // Bundle-specific areas BUNDLE: 'bundling', ERROR: 'error-handling', RBAC: 'permissions', TOOLS: 'tooling' }; } /** * Extract semantic prefix from a task description * @param {string} description - Task description * @returns {Object} Extracted prefix information */ extract(description) { if (!description || typeof description !== 'string') { return this.getDefaultResult(); } // Try each pattern in order of specificity // 1. [AREA-FEATURE-###] pattern let match = description.match(this.patterns.areaFeatureNumber); if (match) { return { full_prefix: match[0].slice(1, -1), // Remove brackets area: match[1], feature: match[2], number: match[3], semantic_prefix: `${match[1]}-${match[2]}`, pattern_type: 'area-feature-number', semantic_category: this.semanticCategories[match[1]] || 'general', confidence: 1.0 }; } // 2. [AREA-###] pattern match = description.match(this.patterns.areaNumber); if (match) { return { full_prefix: match[0].slice(1, -1), area: match[1], feature: null, number: match[2], semantic_prefix: match[1], pattern_type: 'area-number', semantic_category: this.semanticCategories[match[1]] || 'general', confidence: 0.9 }; } // 3. [FEATURE:###] pattern match = description.match(this.patterns.featureColon); if (match) { return { full_prefix: match[0].slice(1, -1), area: match[1], feature: null, number: match[2], semantic_prefix: match[1], pattern_type: 'feature-colon', semantic_category: this.semanticCategories[match[1]] || 'general', confidence: 0.8 }; } // 4. Conventional commit pattern match = description.match(this.patterns.conventional); if (match) { const type = match[1].toLowerCase(); const scope = match[2] || null; return { full_prefix: match[0].trim(), area: type.toUpperCase(), feature: scope ? scope.toUpperCase() : null, number: null, semantic_prefix: scope ? `${type.toUpperCase()}-${scope.toUpperCase()}` : type.toUpperCase(), pattern_type: 'conventional', semantic_category: this.getConventionalCategory(type), confidence: 0.7 }; } // 5. No brackets pattern match = description.match(this.patterns.noBrackets); if (match) { return { full_prefix: match[0].trim(), area: match[1], feature: match[2], number: null, semantic_prefix: `${match[1]}-${match[2]}`, pattern_type: 'no-brackets', semantic_category: this.semanticCategories[match[1]] || 'general', confidence: 0.6 }; } // 6. Parenthetical pattern match = description.match(this.patterns.parenthetical); if (match) { return { full_prefix: match[0], area: match[1], feature: null, number: null, semantic_prefix: match[1], pattern_type: 'parenthetical', semantic_category: this.semanticCategories[match[1]] || 'general', confidence: 0.5 }; } // 7. Ticket number pattern match = description.match(this.patterns.ticketNumber); if (match) { return { full_prefix: match[0], area: 'TICKET', feature: null, number: match[1], semantic_prefix: 'TICKET', pattern_type: 'ticket-number', semantic_category: 'general', confidence: 0.4 }; } // No pattern matched return this.getDefaultResult(description); } /** * Extract prefixes from multiple tasks * @param {Array} tasks - Array of task objects * @returns {Array} Tasks with extracted prefix information */ extractBatch(tasks) { return tasks.map(task => ({ ...task, prefix_info: this.extract(task.description) })); } /** * Group tasks by semantic prefix * @param {Array} tasks - Array of tasks with descriptions * @returns {Map} Map of semantic prefixes to task arrays */ groupBySemanticPrefix(tasks) { const groups = new Map(); for (const task of tasks) { const prefixInfo = this.extract(task.description); const key = prefixInfo.semantic_prefix; if (!groups.has(key)) { groups.set(key, { prefix: key, category: prefixInfo.semantic_category, confidence: prefixInfo.confidence, tasks: [] }); } groups.get(key).tasks.push({ ...task, prefix_info: prefixInfo }); } // Sort tasks within each group by number if available for (const group of groups.values()) { group.tasks.sort((a, b) => { const numA = parseInt(a.prefix_info.number) || 999999; const numB = parseInt(b.prefix_info.number) || 999999; return numA - numB; }); } return groups; } /** * Analyze semantic relationships between tasks * @param {Array} tasks - Array of tasks * @returns {Object} Analysis results */ analyzeSemanticRelationships(tasks) { const groups = this.groupBySemanticPrefix(tasks); const analysis = { total_tasks: tasks.length, unique_prefixes: groups.size, semantic_groups: [], ungrouped_tasks: [], statistics: { by_category: {}, by_pattern: {}, by_confidence: { high: 0, // confidence >= 0.8 medium: 0, // confidence >= 0.5 low: 0 // confidence < 0.5 } } }; // Process each group for (const [prefix, group] of groups) { if (prefix === 'MISC') { analysis.ungrouped_tasks.push(...group.tasks); } else { analysis.semantic_groups.push({ prefix: group.prefix, category: group.category, task_count: group.tasks.length, confidence: group.confidence, task_ids: group.tasks.map(t => t.id), sequential: this.isSequential(group.tasks) }); } // Update statistics const category = group.category; analysis.statistics.by_category[category] = (analysis.statistics.by_category[category] || 0) + group.tasks.length; for (const task of group.tasks) { const pattern = task.prefix_info.pattern_type; analysis.statistics.by_pattern[pattern] = (analysis.statistics.by_pattern[pattern] || 0) + 1; if (task.prefix_info.confidence >= 0.8) { analysis.statistics.by_confidence.high++; } else if (task.prefix_info.confidence >= 0.5) { analysis.statistics.by_confidence.medium++; } else { analysis.statistics.by_confidence.low++; } } } // Sort semantic groups by task count analysis.semantic_groups.sort((a, b) => b.task_count - a.task_count); return analysis; } /** * Check if tasks in a group are sequential * @param {Array} tasks - Array of tasks with prefix info * @returns {boolean} True if tasks are sequential */ isSequential(tasks) { const numbers = tasks .map(t => parseInt(t.prefix_info.number)) .filter(n => !isNaN(n)) .sort((a, b) => a - b); if (numbers.length < 2) return false; // Check if numbers are consecutive for (let i = 1; i < numbers.length; i++) { if (numbers[i] - numbers[i - 1] > 1) { return false; } } return true; } /** * Get category for conventional commit type * @param {string} type - Conventional commit type * @returns {string} Semantic category */ getConventionalCategory(type) { const categoryMap = { fix: 'bug-fix', feat: 'feature', docs: 'documentation', style: 'frontend', refactor: 'refactoring', test: 'testing', chore: 'maintenance', perf: 'performance', ci: 'infrastructure', build: 'infrastructure', revert: 'bug-fix' }; return categoryMap[type.toLowerCase()] || 'general'; } /** * Get default result when no pattern matches * @param {string} description - Task description * @returns {Object} Default prefix information */ getDefaultResult(description = '') { // Try to extract first word as potential area const firstWord = description.split(/[\s:,-]/)[0].toUpperCase(); const isValidArea = /^[A-Z][A-Z0-9]*$/.test(firstWord) && firstWord.length <= 10; return { full_prefix: '', area: isValidArea ? firstWord : 'MISC', feature: null, number: null, semantic_prefix: isValidArea ? firstWord : 'MISC', pattern_type: 'none', semantic_category: 'general', confidence: isValidArea ? 0.3 : 0.0 }; } /** * Suggest prefix for a task based on context * @param {string} description - Task description without prefix * @param {Array} relatedTasks - Related tasks to infer pattern from * @returns {string} Suggested prefix */ suggestPrefix(description, relatedTasks = []) { // Analyze related tasks to find common pattern const prefixes = relatedTasks .map(t => this.extract(t.description)) .filter(p => p.confidence > 0.5); if (prefixes.length === 0) { return this.generateDefaultPrefix(description); } // Find most common pattern const patternCounts = {}; for (const prefix of prefixes) { patternCounts[prefix.pattern_type] = (patternCounts[prefix.pattern_type] || 0) + 1; } const mostCommonPattern = Object.entries(patternCounts) .sort((a, b) => b[1] - a[1])[0][0]; // Find most common area const areaCounts = {}; for (const prefix of prefixes) { if (prefix.area) { areaCounts[prefix.area] = (areaCounts[prefix.area] || 0) + 1; } } const mostCommonArea = Object.entries(areaCounts) .sort((a, b) => b[1] - a[1])[0]?.[0] || 'GENERAL'; // Generate next number in sequence const numbers = prefixes .map(p => parseInt(p.number)) .filter(n => !isNaN(n)) .sort((a, b) => b - a); const nextNumber = numbers.length > 0 ? String(numbers[0] + 1).padStart(3, '0') : '001'; // Format based on most common pattern switch (mostCommonPattern) { case 'area-feature-number': const feature = this.inferFeature(description); return `[${mostCommonArea}-${feature}-${nextNumber}]`; case 'area-number': return `[${mostCommonArea}-${nextNumber}]`; case 'conventional': const type = this.inferConventionalType(description); return `${type}: `; default: return `[${mostCommonArea}-${nextNumber}]`; } } /** * Generate default prefix based on description * @param {string} description - Task description * @returns {string} Generated prefix */ generateDefaultPrefix(description) { const lower = description.toLowerCase(); // Infer area from keywords if (lower.includes('error') || lower.includes('exception')) return '[ERROR-001]'; if (lower.includes('test') || lower.includes('spec')) return '[TEST-001]'; if (lower.includes('api') || lower.includes('endpoint')) return '[API-001]'; if (lower.includes('ui') || lower.includes('component')) return '[UI-001]'; if (lower.includes('database') || lower.includes('db')) return '[DB-001]'; if (lower.includes('auth') || lower.includes('permission')) return '[AUTH-001]'; if (lower.includes('bundle') || lower.includes('group')) return '[BUNDLE-001]'; if (lower.includes('doc') || lower.includes('readme')) return '[DOCS-001]'; return '[GENERAL-001]'; } /** * Infer feature from description * @param {string} description - Task description * @returns {string} Inferred feature */ inferFeature(description) { const lower = description.toLowerCase(); if (lower.includes('login') || lower.includes('signin')) return 'LOGIN'; if (lower.includes('register') || lower.includes('signup')) return 'REGISTER'; if (lower.includes('profile')) return 'PROFILE'; if (lower.includes('search')) return 'SEARCH'; if (lower.includes('filter')) return 'FILTER'; if (lower.includes('sort')) return 'SORT'; if (lower.includes('create') || lower.includes('add')) return 'CREATE'; if (lower.includes('update') || lower.includes('edit')) return 'UPDATE'; if (lower.includes('delete') || lower.includes('remove')) return 'DELETE'; if (lower.includes('list') || lower.includes('index')) return 'LIST'; return 'GENERAL'; } /** * Infer conventional commit type * @param {string} description - Task description * @returns {string} Conventional commit type */ inferConventionalType(description) { const lower = description.toLowerCase(); if (lower.includes('fix') || lower.includes('bug')) return 'fix'; if (lower.includes('feature') || lower.includes('add')) return 'feat'; if (lower.includes('document') || lower.includes('readme')) return 'docs'; if (lower.includes('style') || lower.includes('format')) return 'style'; if (lower.includes('refactor')) return 'refactor'; if (lower.includes('test') || lower.includes('spec')) return 'test'; if (lower.includes('performance') || lower.includes('optimize')) return 'perf'; return 'chore'; } } // Singleton instance let extractorInstance = null; export function getSemanticPrefixExtractor() { if (!extractorInstance) { extractorInstance = new SemanticPrefixExtractor(); } return extractorInstance; } export default SemanticPrefixExtractor; //# sourceMappingURL=semanticPrefixExtractor.js.map