// Answer Service (T048)
// Orchestrates: search → risk/safety → conflict resolution → citations → summary
import { ValidationError } from '../utils/errors.mjs';
export function createAnswerService({ searchService, llmAdapter, logger }) {
if (!searchService) throw new ValidationError('searchService required');
logger = logger || { log: () => {} };
function aggregateResults(searchResults) {
// Conflict resolution: component > service > time (newer wins)
const byComponent = new Map();
const byService = new Map();
const fallback = [];
for (const result of searchResults) {
// Mock metadata extraction - in real version would parse frontmatter more thoroughly
const metadata = {
component: extractFromPath(result.docId, /component[_-]([^/._-]+)/i),
service: extractFromPath(result.docId, /service[_-]([^/._-]+)/i) || extractFromPath(result.title, /service\s+([a-z]+)/i),
updated: result.metadata?.updated || '1970-01-01'
};
const enhancedResult = { ...result, metadata };
if (metadata.component) {
if (!byComponent.has(metadata.component)) byComponent.set(metadata.component, []);
byComponent.get(metadata.component).push(enhancedResult);
} else if (metadata.service) {
if (!byService.has(metadata.service)) byService.set(metadata.service, []);
byService.get(metadata.service).push(enhancedResult);
} else {
fallback.push(enhancedResult);
}
}
// Sort by recency within each group
const sortByTime = (a, b) => {
const timeA = new Date(a.metadata.updated);
const timeB = new Date(b.metadata.updated);
return timeB - timeA;
};
const aggregated = [];
// Component level (highest priority)
for (const [component, results] of byComponent) {
aggregated.push(...results.sort(sortByTime));
}
// Service level
for (const [service, results] of byService) {
aggregated.push(...results.sort(sortByTime));
}
// Fallback
aggregated.push(...fallback.sort(sortByTime));
return aggregated;
}
function formatCitations(results) {
return results.map((result, index) => ({
id: index + 1,
doc_id: result.docId,
title: result.title,
snippet: result.text ? result.text.slice(0, 200) + (result.text.length > 200 ? '...' : '') : '',
heading: result.heading || null,
stale: Boolean(result.stale),
chunk_id: result.chunkId || null,
score: result.score || 0,
// Enhanced formatting for structured procedures
structured_content: extractStructuredContent(result.text),
validation_points: extractValidationPoints(result.text)
}));
}
// Extract structured content (steps, lists, etc.) for better presentation
function extractStructuredContent(text) {
if (!text) return null;
const steps = [];
const lines = text.split('\n').map(line => line.trim()).filter(line => line);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Detect numbered steps
const stepMatch = line.match(/^(\d+)\.?\s+(.+)/);
if (stepMatch) {
steps.push({
number: parseInt(stepMatch[1]),
instruction: stepMatch[2],
type: 'step'
});
continue;
}
// Detect bullet points
const bulletMatch = line.match(/^[-*]\s+(.+)/);
if (bulletMatch) {
steps.push({
instruction: bulletMatch[1],
type: 'bullet'
});
continue;
}
// Detect command blocks (lines starting with $, #, or common commands)
const commandMatch = line.match(/^[\$#]?\s*(sudo|systemctl|service|docker|kubectl|npm|yarn|git|curl)\s+(.+)/);
if (commandMatch) {
steps.push({
command: line,
tool: commandMatch[1],
type: 'command'
});
}
}
return steps.length > 0 ? steps : null;
}
// Extract validation points from text
function extractValidationPoints(text) {
if (!text) return [];
const validationKeywords = [
'verify', 'check', 'confirm', 'validate', 'ensure', 'monitor', 'watch', 'test'
];
const lines = text.split('\n').map(line => line.trim()).filter(line => line);
const validationPoints = [];
for (const line of lines) {
const lowerLine = line.toLowerCase();
for (const keyword of validationKeywords) {
if (lowerLine.includes(keyword)) {
validationPoints.push({
text: line,
keyword: keyword,
type: 'validation'
});
break;
}
}
}
return validationPoints;
}
function extractFromPath(path, regex) {
const match = path.match(regex);
return match ? match[1] : null;
}
// Generate search suggestions for no-results scenarios
function generateSearchSuggestions(question, intentAnalysis) {
const commonTopics = [
'service management', 'troubleshooting', 'configuration',
'monitoring', 'deployment', 'backup procedures'
];
let suggestions = 'Try searching for related topics: ';
if (intentAnalysis.intents.length > 0) {
const intentKeywords = {
troubleshooting: ['error handling', 'debugging', 'diagnostics'],
howTo: ['procedures', 'step-by-step guides', 'tutorials'],
restart: ['service management', 'system operations'],
configuration: ['setup guides', 'configuration management']
};
const relevantSuggestions = [];
for (const intent of intentAnalysis.intents) {
if (intentKeywords[intent]) {
relevantSuggestions.push(...intentKeywords[intent]);
}
}
suggestions += relevantSuggestions.slice(0, 3).join(', ');
} else {
suggestions += commonTopics.slice(0, 3).join(', ');
}
return suggestions;
}
// Generate clarification suggestions for ambiguous questions
function generateClarificationSuggestions(question, results) {
const topTopics = results.slice(0, 3).map(r => r.title || r.heading || 'general procedures');
return `Did you mean questions about: ${topTopics.join(', ')}? Please be more specific about the service or component you're asking about.`;
}
// Enhanced question intent understanding
function analyzeQuestionIntent(question) {
const normalizedQuestion = question.toLowerCase();
// Intent patterns for better understanding
const intentPatterns = {
troubleshooting: /\b(error|issue|problem|fail|broken|not work|trouble|debug)\b/,
howTo: /\b(how to|how do|how can|steps to|procedure|process)\b/,
status: /\b(status|check|verify|confirm|validate)\b/,
restart: /\b(restart|reboot|reload|refresh)\b/,
configuration: /\b(config|configure|setting|setup|install)\b/,
emergency: /\b(urgent|critical|down|outage|emergency|immediate)\b/
};
const detectedIntents = [];
for (const [intent, pattern] of Object.entries(intentPatterns)) {
if (pattern.test(normalizedQuestion)) {
detectedIntents.push(intent);
}
}
// Extract key terms for better matching
const keyTerms = question
.toLowerCase()
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(term => term.length > 2 && !['the', 'and', 'for', 'how', 'can', 'what', 'when', 'where'].includes(term));
return {
intents: detectedIntents,
keyTerms: keyTerms,
isEmergency: detectedIntents.includes('emergency'),
needsClarification: keyTerms.length < 2 && detectedIntents.length === 0
};
}
// Query caching for frequently asked questions
const queryCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const MAX_CACHE_SIZE = 100;
// Priority queue for query processing
function determineQueryPriority(question, intentAnalysis) {
// Standard queries get higher priority
if (question.length < 50 && intentAnalysis.keyTerms.length <= 3) {
return 'standard';
}
// Emergency queries get highest priority
if (intentAnalysis.isEmergency) {
return 'emergency';
}
// Complex queries get lower priority
return 'complex';
}
// Optimized query processing with caching and prioritization
async function answer(question, options = {}) {
const startTime = Date.now(); // Track response time
if (!question || typeof question !== 'string') {
throw new ValidationError('question string required');
}
const topK = options.topK || 5;
const useLLM = options.useLLM !== false; // Default to true
// Quick cache check for identical questions
const cacheKey = `${question.toLowerCase().trim()}_${topK}_${useLLM}`;
const cached = queryCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
logger.log('answer.cache_hit', { responseTime: Date.now() - startTime });
return { ...cached.result, startTime }; // Return cached result with new startTime
}
// Step 1: Analyze question intent for better understanding (optimized)
const intentAnalysis = analyzeQuestionIntent(question);
const priority = determineQueryPriority(question, intentAnalysis);
// Step 2: Enhanced search with intent-aware query
let enhancedQuery = question;
if (intentAnalysis.keyTerms.length > 0) {
enhancedQuery = `${question} ${intentAnalysis.keyTerms.join(' ')}`;
}
const searchResult = await searchService.search(enhancedQuery, { topK: topK * 2 }); // Get more for aggregation
if (searchResult.results.length === 0) {
// Enhanced no-results handling with search suggestions
const suggestions = generateSearchSuggestions(question, intentAnalysis);
return {
question,
summary: null,
citations: [],
risks: [],
safe_ops: [],
mode: 'no_results',
message: `No relevant runbook content found. ${suggestions}`
};
}
// Handle ambiguous questions with clarification suggestions
if (intentAnalysis.needsClarification && searchResult.results[0].score < 0.6) {
const clarificationSuggestions = generateClarificationSuggestions(question, searchResult.results);
return {
question,
summary: `Your question might be ambiguous. ${clarificationSuggestions}`,
citations: formatCitations(searchResult.results.slice(0, 3)),
risks: [],
safe_ops: [],
mode: useLLM && llmAdapter ? 'online' : 'offline',
message: 'Consider rephrasing your question for more specific results.'
};
}
// Step 2: Aggregate and resolve conflicts
const aggregatedResults = aggregateResults(searchResult.results).slice(0, topK);
// Step 3: Extract risk and safety information
const allRisks = new Set();
const allSafeOps = new Set();
for (const result of aggregatedResults) {
if (result.risks) result.risks.forEach(r => allRisks.add(r));
if (result.safeOps) result.safeOps.forEach(s => allSafeOps.add(s));
}
// Step 4: Format citations (ensure it's always an array)
const citations = formatCitations(aggregatedResults) || [];
// Step 5: Generate summary (if LLM available and requested)
let summary = null;
let mode = 'offline';
if (llmAdapter && useLLM) {
try {
const llmResult = await llmAdapter.generateSummary(aggregatedResults, question);
summary = llmResult.summary;
mode = 'online';
} catch (error) {
logger.log('answer.llm_failed', { error: error.message });
mode = 'degraded';
}
}
logger.log('answer.complete', {
question: '[REDACTED]',
citationCount: citations.length,
riskCount: allRisks.size,
mode
});
const result = {
question,
summary,
citations,
risks: Array.from(allRisks),
safe_ops: Array.from(allSafeOps),
mode,
startTime, // Include start time for response time tracking
aggregation_info: {
total_found: searchResult.total,
selected: aggregatedResults.length,
conflicts_resolved: searchResult.results.length - aggregatedResults.length
}
};
// Cache successful results (exclude no_results to avoid caching negative results)
if (mode !== 'no_results' && citations.length > 0) {
// Implement LRU cache eviction
if (queryCache.size >= MAX_CACHE_SIZE) {
const oldestKey = queryCache.keys().next().value;
queryCache.delete(oldestKey);
}
queryCache.set(cacheKey, {
result: { ...result },
timestamp: Date.now(),
priority: priority
});
}
return result;
}
return Object.freeze({ answer });
}
export default { createAnswerService };