UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

890 lines (814 loc) 27 kB
#!/usr/bin/env node /** * @fileoverview Natural Language Router - Maps natural language phrases to framework-specific commands * * @module tools/workspace/natural-language-router * @description * Zero-friction natural language command routing for AIWG framework. Translates user intent * (e.g., "transition to elaboration") to specific framework commands (e.g., flow-inception-to-elaboration) * with confidence scoring and fuzzy matching. * * Core Responsibilities: * - Load and cache phrase translation table from markdown * - Map natural language phrases to command IDs + framework context * - Fuzzy matching with Levenshtein distance for typo tolerance * - Confidence scoring (0.0-1.0) with configurable threshold * - Multi-framework support (SDLC, Marketing, Legal, etc.) * * Performance Targets: * - Translation loading: <500ms (first load) * - Routing per phrase: <100ms (NFR-PERF-09) * - Cache TTL: 5 minutes (balance freshness vs performance) * * @author Software Implementer * @version 1.0.0 * @created 2025-10-19 * * @see FID-007 (Framework-Scoped Workspace Management) * @see UC-012 (Framework-Aware Workspace Management) * @see NFR-PERF-09 (Framework-scoped context loading optimization) * * @example * ```javascript * const router = new NaturalLanguageRouter(); * * // Route natural language to command * const result = await router.route("transition to elaboration"); * // => { * // commandId: "flow-inception-to-elaboration", * // framework: "sdlc-complete", * // confidence: 1.0, * // matchedPhrase: "transition to elaboration", * // category: "phase-transitions" * // } * * // Fuzzy matching handles typos * const fuzzy = await router.route("transision to elaboration"); * // => { commandId: "flow-inception-to-elaboration", confidence: 0.92, ... } * * // Low confidence returns null * const unknown = await router.route("do something random"); * // => null * * // Get suggestions for ambiguous phrases * const suggestions = await router.getSuggestions("start"); * // => [ * // { phrase: "start elaboration", confidence: 0.65, commandId: "..." }, * // { phrase: "start security review", confidence: 0.62, commandId: "..." } * // ] * ``` */ import fs from 'fs/promises'; import path from 'path'; import { homedir } from 'os'; /** * Natural Language Router * * @class NaturalLanguageRouter * @description Maps natural language user input to framework-specific commands with * confidence scoring and fuzzy matching. * * @property {Map<string, Translation>} translationCache - In-memory cache of phrase translations * @property {number} cacheTimestamp - Timestamp of last cache load * @property {number} cacheTTL - Cache time-to-live in milliseconds (default: 5 minutes) * @property {string} translationsPath - Path to translation table file * @property {number} confidenceThreshold - Minimum confidence score for valid match (default: 0.7) */ export class NaturalLanguageRouter { /** * Create Natural Language Router * * @constructor * @param {string} [translationsPath] - Path to translation markdown file * Default: ~/.local/share/ai-writing-guide/agentic/code/frameworks/sdlc-complete/docs/simple-language-translations.md * @param {Object} [options] - Configuration options * @param {number} [options.confidenceThreshold=0.7] - Minimum confidence score (0.0-1.0) * @param {number} [options.cacheTTL=300000] - Cache TTL in milliseconds (default: 5 min) * * @example * ```javascript * // Default configuration * const router = new NaturalLanguageRouter(); * * // Custom translation path * const customRouter = new NaturalLanguageRouter( * '/custom/path/translations.md', * { confidenceThreshold: 0.8, cacheTTL: 600000 } * ); * ``` */ constructor(translationsPath, options = {}) { // Default to AIWG installation path this.translationsPath = translationsPath || path.join( homedir(), '.local/share/ai-writing-guide/agentic/code/frameworks/sdlc-complete/docs/simple-language-translations.md' ); // Configuration this.confidenceThreshold = options.confidenceThreshold ?? 0.7; this.cacheTTL = options.cacheTTL ?? 300000; // 5 minutes // Cache state this.translationCache = new Map(); this.cacheTimestamp = 0; this.translationMetadata = { version: null, loadedAt: null, totalTranslations: 0, categories: new Set() }; } /** * Route Natural Language Phrase to Command * * @async * @param {string} phrase - User's natural language input * @returns {Promise<RouteResult|null>} Route result with command ID, framework, confidence * Returns null if no match above confidence threshold * * @typedef {Object} RouteResult * @property {string} commandId - Mapped command identifier * @property {string} framework - Framework ID (e.g., "sdlc-complete") * @property {number} confidence - Match confidence score (0.0-1.0) * @property {string} matchedPhrase - Original phrase that matched * @property {string} category - Translation category (e.g., "phase-transitions") * * @throws {TranslationLoadError} If translation file cannot be loaded * * @example * ```javascript * const result = await router.route("Let's transition to Elaboration"); * // => { * // commandId: "flow-inception-to-elaboration", * // framework: "sdlc-complete", * // confidence: 1.0, * // matchedPhrase: "transition to elaboration", * // category: "phase-transitions" * // } * * // Unknown phrase * const unknown = await router.route("do something random"); * // => null * ``` */ async route(phrase) { if (!phrase || typeof phrase !== 'string') { return null; } // Ensure translations loaded await this.loadTranslations(); // Normalize input const normalized = this.normalize(phrase); if (!normalized) { return null; } // Try exact match first (highest priority) const exactMatch = this._findExactMatch(normalized); if (exactMatch) { return { commandId: exactMatch.commandId, framework: exactMatch.framework, confidence: 1.0, matchedPhrase: exactMatch.phrase, category: exactMatch.category }; } // Fall back to fuzzy matching const fuzzyMatch = this._findFuzzyMatch(normalized); if (fuzzyMatch && fuzzyMatch.confidence >= this.confidenceThreshold) { return fuzzyMatch; } // No match above threshold return null; } /** * Route Multiple Phrases in Batch * * @async * @param {string[]} phrases - Array of natural language phrases * @returns {Promise<RouteResult[]>} Array of route results (null for no match) * * @example * ```javascript * const results = await router.routeBatch([ * "transition to elaboration", * "run security review", * "unknown phrase" * ]); * // => [ * // { commandId: "flow-inception-to-elaboration", ... }, * // { commandId: "flow-security-review-cycle", ... }, * // null * // ] * ``` */ async routeBatch(phrases) { if (!Array.isArray(phrases)) { throw new Error('routeBatch expects array of phrases'); } // Ensure translations loaded once await this.loadTranslations(); // Route all phrases in parallel return Promise.all(phrases.map(phrase => this.route(phrase))); } /** * Get Suggestions for Ambiguous Phrase * * @async * @param {string} phrase - Ambiguous or incomplete phrase * @param {number} [limit=3] - Maximum number of suggestions * @returns {Promise<Suggestion[]>} Sorted suggestions (highest confidence first) * * @typedef {Object} Suggestion * @property {string} phrase - Suggested phrase * @property {string} commandId - Command identifier * @property {string} framework - Framework ID * @property {number} confidence - Match confidence score * @property {string} category - Translation category * * @example * ```javascript * const suggestions = await router.getSuggestions("start", 5); * // => [ * // { phrase: "start elaboration", confidence: 0.65, commandId: "...", ... }, * // { phrase: "start security review", confidence: 0.62, commandId: "...", ... }, * // { phrase: "start iteration", confidence: 0.60, commandId: "...", ... } * // ] * ``` */ async getSuggestions(phrase, limit = 3) { if (!phrase || typeof phrase !== 'string') { return []; } await this.loadTranslations(); const normalized = this.normalize(phrase); const suggestions = []; // Calculate confidence for all translations for (const translation of this.translationCache.values()) { const confidence = this.fuzzyMatch(normalized, translation.phrase); if (confidence > 0.3) { // Lower threshold for suggestions suggestions.push({ phrase: translation.phrase, commandId: translation.commandId, framework: translation.framework, confidence: confidence, category: translation.category }); } } // Sort by confidence descending suggestions.sort((a, b) => b.confidence - a.confidence); return suggestions.slice(0, limit); } /** * Normalize Phrase for Matching * * @param {string} phrase - Raw user input * @returns {string} Normalized phrase (lowercase, trimmed, collapsed whitespace) * * @example * ```javascript * router.normalize(" Transition to Elaboration! "); * // => "transition to elaboration" * * router.normalize("Let's move to Construction phase"); * // => "lets move to construction phase" * ``` */ normalize(phrase) { if (!phrase || typeof phrase !== 'string') { return ''; } return phrase .toLowerCase() .trim() .replace(/[^\w\s]/g, '') // Remove punctuation .replace(/\s+/g, ' '); // Collapse whitespace } /** * Fuzzy Match with Levenshtein Distance * * @param {string} phrase - Normalized input phrase * @param {string} targetPhrase - Normalized target phrase * @returns {number} Similarity score (0.0-1.0) * * @description * Uses Levenshtein distance to calculate edit distance, then converts to similarity score: * score = 1 - (distance / maxLength) * * Confidence threshold of 0.7 means phrases must be 70%+ similar to match. * * @example * ```javascript * router.fuzzyMatch("transition to elaboration", "transition to elaboration"); * // => 1.0 (exact match) * * router.fuzzyMatch("transision to elaboration", "transition to elaboration"); * // => 0.92 (1 typo) * * router.fuzzyMatch("move to construction", "transition to elaboration"); * // => 0.31 (different phrases) * ``` */ fuzzyMatch(phrase, targetPhrase) { if (phrase === targetPhrase) { return 1.0; } const distance = this._levenshteinDistance(phrase, targetPhrase); const maxLen = Math.max(phrase.length, targetPhrase.length); if (maxLen === 0) { return 0.0; } const score = 1 - (distance / maxLen); return Math.max(0.0, score); // Ensure non-negative } /** * Find Best Match from Candidate List * * @param {string} phrase - Normalized input phrase * @param {string[]} candidates - List of candidate phrases * @returns {Object|null} Best match with phrase and confidence * * @example * ```javascript * const best = router.findBestMatch("run security", [ * "run security review", * "start security check", * "validate security" * ]); * // => { phrase: "run security review", confidence: 0.87 } * ``` */ findBestMatch(phrase, candidates) { if (!phrase || !Array.isArray(candidates) || candidates.length === 0) { return null; } const normalized = this.normalize(phrase); let bestMatch = null; let bestScore = 0; for (const candidate of candidates) { const candidateNormalized = this.normalize(candidate); const score = this.fuzzyMatch(normalized, candidateNormalized); if (score > bestScore) { bestScore = score; bestMatch = { phrase: candidate, confidence: score }; } } return bestMatch && bestScore >= this.confidenceThreshold ? bestMatch : null; } /** * Load Translation Table from Markdown * * @async * @private * @returns {Promise<void>} * @throws {TranslationLoadError} If file cannot be read or parsed * * @description * Parses simple-language-translations.md markdown table into structured translation objects. * Caches results in memory for 5 minutes (configurable). * * Table format: * | User Says | Intent | Flow Template | Expected Duration | * |-----------|--------|---------------|-------------------| * | "transition to elaboration" | ... | flow-inception-to-elaboration | ... | */ async loadTranslations() { // Check cache validity const now = Date.now(); if (this.translationCache.size > 0 && (now - this.cacheTimestamp) < this.cacheTTL) { return; // Cache still valid } try { // Read markdown file const content = await fs.readFile(this.translationsPath, 'utf-8'); // Parse markdown tables into translations const translations = this._parseMarkdownTables(content); // Update cache this.translationCache.clear(); for (const translation of translations) { // Create unique key for each phrase variant const key = this.normalize(translation.phrase); this.translationCache.set(key, translation); } this.cacheTimestamp = now; this.translationMetadata = { version: '1.0', loadedAt: new Date(now).toISOString(), totalTranslations: this.translationCache.size, categories: new Set(translations.map(t => t.category)) }; } catch (error) { throw new TranslationLoadError( `Failed to load translations from ${this.translationsPath}: ${error.message}`, { cause: error } ); } } /** * Reload Translations (Force Cache Refresh) * * @async * @returns {Promise<void>} * * @example * ```javascript * // Update translations file, then reload * await router.reloadTranslations(); * ``` */ async reloadTranslations() { this.translationCache.clear(); this.cacheTimestamp = 0; await this.loadTranslations(); } /** * Get Translation Count * * @returns {number} Total number of loaded translations * * @example * ```javascript * const count = router.getTranslationCount(); * // => 75 (if 75+ phrase translations loaded) * ``` */ getTranslationCount() { return this.translationCache.size; } /** * Get Translations by Category * * @async * @param {string} category - Category name (e.g., "phase-transitions") * @returns {Promise<Translation[]>} All translations in category * * @example * ```javascript * const transitions = await router.getByCategory("phase-transitions"); * // => [ * // { phrase: "transition to elaboration", commandId: "...", ... }, * // { phrase: "move to construction", commandId: "...", ... } * // ] * ``` */ async getByCategory(category) { await this.loadTranslations(); return Array.from(this.translationCache.values()) .filter(t => t.category === category); } /** * Get Translations by Framework * * @async * @param {string} frameworkId - Framework identifier (e.g., "sdlc-complete") * @returns {Promise<Translation[]>} All translations for framework * * @example * ```javascript * const sdlcTranslations = await router.getByFramework("sdlc-complete"); * // => [ { phrase: "...", commandId: "...", framework: "sdlc-complete", ... }, ... ] * ``` */ async getByFramework(frameworkId) { await this.loadTranslations(); return Array.from(this.translationCache.values()) .filter(t => t.framework === frameworkId); } /** * Extract Tokens from Phrase * * @param {string} phrase - Normalized phrase * @returns {string[]} Array of tokens (words) * * @example * ```javascript * router.extractTokens("transition to elaboration"); * // => ["transition", "to", "elaboration"] * ``` */ extractTokens(phrase) { if (!phrase || typeof phrase !== 'string') { return []; } const normalized = this.normalize(phrase); return normalized.split(/\s+/).filter(token => token.length > 0); } /** * Find Exact Match (Internal) * * @private * @param {string} normalizedPhrase - Normalized input phrase * @returns {Translation|null} Exact match or null */ _findExactMatch(normalizedPhrase) { return this.translationCache.get(normalizedPhrase) || null; } /** * Find Fuzzy Match (Internal) * * @private * @param {string} normalizedPhrase - Normalized input phrase * @returns {RouteResult|null} Best fuzzy match or null */ _findFuzzyMatch(normalizedPhrase) { let bestMatch = null; let bestScore = 0; for (const translation of this.translationCache.values()) { const score = this.fuzzyMatch(normalizedPhrase, translation.phrase); if (score > bestScore) { bestScore = score; bestMatch = { commandId: translation.commandId, framework: translation.framework, confidence: score, matchedPhrase: translation.phrase, category: translation.category }; } } return bestMatch; } /** * Parse Markdown Tables to Translations (Internal) * * @private * @param {string} content - Markdown file content * @returns {Translation[]} Parsed translations * * @description * Parses markdown tables with format: * | User Says | Intent | Flow Template | Expected Duration | * * Extracts phrases from "User Says" column, command from "Flow Template" column. * Infers category from surrounding headings (e.g., "### Phase Transitions"). * * Generates phrase variations by removing common prefix words to improve matching. */ _parseMarkdownTables(content) { const translations = []; let currentCategory = 'general'; let currentFramework = 'sdlc-complete'; // Default framework // Common prefix words to strip for variations const prefixWords = ['lets', 'please', 'can', 'could', 'would', 'should']; // Split into lines const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Detect category from headings const categoryMatch = line.match(/^###\s+(.+)$/); if (categoryMatch) { currentCategory = this._categorizeName(categoryMatch[1]); continue; } // Parse table rows (skip header and separator) const tableRowMatch = line.match(/^\|\s*"([^"]+)"\s*\|[^|]*\|[^|]*`([^`]+)`/); if (tableRowMatch) { const phrase = tableRowMatch[1]; const commandId = tableRowMatch[2]; // Infer framework from command ID if (commandId.includes('marketing')) { currentFramework = 'marketing-flow'; } else if (commandId.includes('legal')) { currentFramework = 'legal-review'; } else { currentFramework = 'sdlc-complete'; } const normalized = this.normalize(phrase); // Add original normalized phrase translations.push({ phrase: normalized, commandId: commandId, framework: currentFramework, category: currentCategory }); // Add variations without common prefix words // E.g., "lets transition to elaboration" → also add "transition to elaboration" const tokens = normalized.split(/\s+/); if (tokens.length > 1 && prefixWords.includes(tokens[0])) { const withoutPrefix = tokens.slice(1).join(' '); translations.push({ phrase: withoutPrefix, commandId: commandId, framework: currentFramework, category: currentCategory }); } } } return translations; } /** * Categorize Heading Name (Internal) * * @private * @param {string} heading - Markdown heading text * @returns {string} Normalized category name * * @example * "Phase Transitions" => "phase-transitions" * "Review Cycles" => "review-cycles" */ _categorizeName(heading) { return heading .toLowerCase() .trim() .replace(/\s+/g, '-') .replace(/[^\w-]/g, ''); } /** * Levenshtein Distance Algorithm (Internal) * * @private * @param {string} str1 - First string * @param {string} str2 - Second string * @returns {number} Edit distance (number of operations to transform str1 to str2) * * @description * Classic dynamic programming implementation of Levenshtein distance. * Calculates minimum number of single-character edits (insertions, deletions, substitutions). * * Time complexity: O(m*n) where m, n are string lengths * Space complexity: O(min(m,n)) with optimized implementation */ _levenshteinDistance(str1, str2) { // Handle edge cases if (str1 === str2) return 0; if (str1.length === 0) return str2.length; if (str2.length === 0) return str1.length; // Create matrix (optimized to use only 2 rows) const len1 = str1.length; const len2 = str2.length; // Previous row of distances let prev = Array(len2 + 1).fill(0).map((_, i) => i); let curr = Array(len2 + 1).fill(0); for (let i = 1; i <= len1; i++) { curr[0] = i; for (let j = 1; j <= len2; j++) { const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; curr[j] = Math.min( curr[j - 1] + 1, // Insertion prev[j] + 1, // Deletion prev[j - 1] + cost // Substitution ); } // Swap rows [prev, curr] = [curr, prev]; } return prev[len2]; } } /** * Translation Load Error * * @class TranslationLoadError * @extends Error * @description Thrown when translation file cannot be loaded or parsed */ export class TranslationLoadError extends Error { constructor(message, options) { super(message); this.name = 'TranslationLoadError'; if (options?.cause) { this.cause = options.cause; } } } /** * Usage Examples * * @example * // Example 1: Basic Routing * const router = new NaturalLanguageRouter(); * * const result = await router.route("transition to elaboration"); * console.log(result); * // => { * // commandId: "flow-inception-to-elaboration", * // framework: "sdlc-complete", * // confidence: 1.0, * // matchedPhrase: "transition to elaboration", * // category: "phase-transitions" * // } * * @example * // Example 2: Fuzzy Matching (Typo Tolerance) * const typoResult = await router.route("transision to elaboration"); * console.log(typoResult); * // => { * // commandId: "flow-inception-to-elaboration", * // framework: "sdlc-complete", * // confidence: 0.92, // High confidence despite typo * // matchedPhrase: "transition to elaboration", * // category: "phase-transitions" * // } * * @example * // Example 3: Unknown Phrase (No Match) * const unknown = await router.route("do something random"); * console.log(unknown); * // => null (confidence below threshold) * * @example * // Example 4: Get Suggestions for Ambiguous Input * const suggestions = await router.getSuggestions("start"); * console.log(suggestions); * // => [ * // { phrase: "start elaboration", confidence: 0.65, commandId: "flow-inception-to-elaboration", ... }, * // { phrase: "start security review", confidence: 0.62, commandId: "flow-security-review-cycle", ... }, * // { phrase: "start iteration", confidence: 0.60, commandId: "flow-iteration-dual-track", ... } * // ] * * @example * // Example 5: Batch Routing * const phrases = [ * "transition to elaboration", * "run security review", * "unknown phrase" * ]; * * const results = await router.routeBatch(phrases); * console.log(results); * // => [ * // { commandId: "flow-inception-to-elaboration", confidence: 1.0, ... }, * // { commandId: "flow-security-review-cycle", confidence: 1.0, ... }, * // null * // ] * * @example * // Example 6: Filter by Category * const transitions = await router.getByCategory("phase-transitions"); * console.log(transitions.length); * // => 6 (all phase transition translations) * * @example * // Example 7: Filter by Framework * const sdlcCommands = await router.getByFramework("sdlc-complete"); * console.log(sdlcCommands.length); * // => 65 (SDLC framework commands) * * const marketingCommands = await router.getByFramework("marketing-flow"); * console.log(marketingCommands.length); * // => 10 (Marketing framework commands) * * @example * // Example 8: Custom Confidence Threshold * const strictRouter = new NaturalLanguageRouter(null, { * confidenceThreshold: 0.9, // Require 90% similarity * cacheTTL: 600000 // 10 minute cache * }); * * const strictResult = await strictRouter.route("transision to elaboration"); * console.log(strictResult); * // => null (0.92 confidence below 0.9 threshold) * * @example * // Example 9: Manual Cache Refresh * await router.reloadTranslations(); * console.log("Translations reloaded:", router.getTranslationCount()); * // => "Translations reloaded: 75" * * @example * // Example 10: Translation Metadata * await router.loadTranslations(); * console.log(router.translationMetadata); * // => { * // version: "1.0", * // loadedAt: "2025-10-19T12:00:00.000Z", * // totalTranslations: 75, * // categories: Set(6) { "phase-transitions", "workflow-requests", ... } * // } */ // Export singleton instance for convenience export const defaultRouter = new NaturalLanguageRouter(); /** * Route natural language phrase using default router * * @function route * @param {string} phrase - Natural language input * @returns {Promise<RouteResult|null>} Route result or null * * @example * ```javascript * import { route } from './natural-language-router.mjs'; * * const result = await route("transition to elaboration"); * console.log(result.commandId); * // => "flow-inception-to-elaboration" * ``` */ export async function route(phrase) { return defaultRouter.route(phrase); } /** * Get suggestions for phrase using default router * * @function getSuggestions * @param {string} phrase - Ambiguous phrase * @param {number} [limit=3] - Maximum suggestions * @returns {Promise<Suggestion[]>} Sorted suggestions */ export async function getSuggestions(phrase, limit = 3) { return defaultRouter.getSuggestions(phrase, limit); }