mcp-product-manager
Version:
MCP Orchestrator for task and project management with web interface
496 lines • 18.7 kB
JavaScript
/**
* 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