/** * Robotic Classifier * DB-driven keyword classification engine for ticket triage. * Handles ~90% of classification deterministically without AI. */ import { postgresClient } from './postgres-client'; import { ClassificationRule, ClassificationResult, ClassificationStepResult, ConfidenceLevel, RuleType, TicketData, } from '../types/workflow'; // Cache TTL for classification rules (5 minutes) const RULES_CACHE_TTL_MS = 5 * 60 * 1000; export class RoboticClassifier { private rulesCache: Map = new Map(); private cacheLoadedAt: number = 0; /** * Load classification rules from DB, grouped by rule_type. * Cached for RULES_CACHE_TTL_MS to avoid repeated DB queries. */ async loadRules(forceRefresh = false): Promise> { const now = Date.now(); if (!forceRefresh && this.rulesCache.size > 0 && now - this.cacheLoadedAt < RULES_CACHE_TTL_MS) { return this.rulesCache; } const result = await postgresClient.query( `SELECT * FROM classification_rules WHERE is_active = true ORDER BY rule_type, sort_order` ); this.rulesCache.clear(); for (const rule of result.rows) { const existing = this.rulesCache.get(rule.rule_type) || []; existing.push(rule); this.rulesCache.set(rule.rule_type, existing); } this.cacheLoadedAt = now; console.log(`[CLASSIFIER] Loaded ${result.rows.length} classification rules`); return this.rulesCache; } /** * Run full classification pipeline on a ticket. */ async classify(ticket: TicketData): Promise { await this.loadRules(); const branch = await this.classifyByType('branch_routing', ticket); const ticketType = await this.classifyByType('ticket_type', ticket); const issueClassification = await this.classifyByType('issue_classification', ticket); // Priority classification can use ticket_type result as context const priorityTicket = { ...ticket }; if (ticketType?.value != null) { priorityTicket.ticket_type = ticketType.value; } const priority = await this.classifyByType('priority', priorityTicket); // Queue routing can use priority result as context const queueTicket = { ...priorityTicket }; if (priority?.value != null) { queueTicket.priority = priority.value; } const queue = await this.classifyByType('queue_routing', queueTicket); // Determine overall confidence and whether AI is needed const stepResults = [branch, ticketType, issueClassification, priority, queue]; const { overallConfidence, needsAi, aiReasons } = this.assessConfidence( stepResults, ticket ); return { branch: branch || this.defaultBranch(), ticket_type: ticketType, issue_classification: issueClassification, priority: priority, queue: queue, overall_confidence: overallConfidence, needs_ai: needsAi, ai_reasons: aiReasons, }; } /** * Run classification for a specific rule type. */ private async classifyByType( ruleType: RuleType, ticket: TicketData ): Promise { const rules = this.rulesCache.get(ruleType) || []; for (const rule of rules) { if (this.evaluateRule(rule, ticket)) { return { field: rule.result_field, value: rule.result_value, field_2: rule.result_field_2 || undefined, value_2: rule.result_value_2 || undefined, confidence: rule.confidence, matched_rule_id: rule.id, matched_rule_name: rule.name, method: 'robotic', }; } } return null; } /** * Evaluate a single classification rule against ticket data. */ private evaluateRule(rule: ClassificationRule, ticket: TicketData): boolean { const fieldValue = this.getFieldValue(rule.match_field, ticket); if (fieldValue === null || fieldValue === undefined) { return false; } return this.evaluateMatch( rule.match_operator, fieldValue, rule.match_value, rule.match_case_sensitive ); } /** * Get the value of a field from the ticket data. * For 'title_or_description', returns a combined check target. */ private getFieldValue( matchField: string, ticket: TicketData ): string | number | null { switch (matchField) { case 'title': return ticket.title || null; case 'description': return ticket.description || null; case 'title_or_description': // Return combined text for pattern matching return [ticket.title, ticket.description].filter(Boolean).join(' ') || null; case 'ticket_category': return ticket.ticket_category; case 'ticket_type': return ticket.ticket_type; case 'priority': return ticket.priority; case 'policy_name': return ticket.policy_name || null; case 'device_name': return ticket.device_name || null; case 'creator_resource_id': return ticket.creator_resource_id; case 'person_id': return ticket.person_id; case 'company_id': return ticket.company_id; default: return null; } } /** * Evaluate a match operation. */ private evaluateMatch( operator: string, fieldValue: string | number, matchValue: any, caseSensitive: boolean ): boolean { switch (operator) { case 'contains': return this.evaluateContains(fieldValue, matchValue, caseSensitive); case 'starts_with': return this.evaluateStartsWith(fieldValue, matchValue, caseSensitive); case 'regex': return this.evaluateRegex(fieldValue, matchValue, caseSensitive); case 'equals': return this.evaluateEquals(fieldValue, matchValue); case 'in': return this.evaluateIn(fieldValue, matchValue); case 'not_in': return !this.evaluateIn(fieldValue, matchValue); default: return false; } } /** * Contains: check if field contains any of the match values. * matchValue can be a string or array of strings. */ private evaluateContains( fieldValue: string | number, matchValue: any, caseSensitive: boolean ): boolean { const text = String(fieldValue); const searchText = caseSensitive ? text : text.toLowerCase(); const patterns = Array.isArray(matchValue) ? matchValue : [matchValue]; return patterns.some((pattern: string) => { const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase(); return searchText.includes(searchPattern); }); } /** * Starts with: check if field starts with the match value. */ private evaluateStartsWith( fieldValue: string | number, matchValue: any, caseSensitive: boolean ): boolean { const text = String(fieldValue); const searchText = caseSensitive ? text : text.toLowerCase(); const patterns = Array.isArray(matchValue) ? matchValue : [matchValue]; return patterns.some((pattern: string) => { const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase(); return searchText.startsWith(searchPattern); }); } /** * Regex: evaluate a regex pattern against the field value. */ private evaluateRegex( fieldValue: string | number, matchValue: any, caseSensitive: boolean ): boolean { const text = String(fieldValue); const pattern = String(matchValue); try { const flags = caseSensitive ? '' : 'i'; const regex = new RegExp(pattern, flags); return regex.test(text); } catch { console.error(`[CLASSIFIER] Invalid regex pattern: ${pattern}`); return false; } } /** * Equals: exact match (handles numbers and strings). */ private evaluateEquals( fieldValue: string | number, matchValue: any ): boolean { // Compare as numbers if both are numeric const numField = Number(fieldValue); const numMatch = Number(matchValue); if (!isNaN(numField) && !isNaN(numMatch)) { return numField === numMatch; } return String(fieldValue).toLowerCase() === String(matchValue).toLowerCase(); } /** * In: check if field value is in the match value array. */ private evaluateIn( fieldValue: string | number, matchValue: any ): boolean { const values = Array.isArray(matchValue) ? matchValue : [matchValue]; const numField = Number(fieldValue); return values.some((v: any) => { const numV = Number(v); if (!isNaN(numField) && !isNaN(numV)) { return numField === numV; } return String(fieldValue).toLowerCase() === String(v).toLowerCase(); }); } /** * Default branch when no branch routing rule matches. */ private defaultBranch(): ClassificationStepResult { return { field: 'branch', value: 'service_desk', confidence: 'high', matched_rule_id: null, matched_rule_name: 'Default (service_desk)', method: 'robotic', }; } /** * Assess overall confidence and determine if AI enhancement is needed. */ private assessConfidence( results: (ClassificationStepResult | null)[], ticket: TicketData ): { overallConfidence: ConfidenceLevel; needsAi: boolean; aiReasons: string[] } { const aiReasons: string[] = []; // Check for missing classifications const [branch, ticketType, issueClass, priority, queue] = results; if (!ticketType) { aiReasons.push('No ticket type classification matched'); } if (!issueClass) { aiReasons.push('No issue type classification matched'); } if (!priority) { aiReasons.push('No priority classification matched'); } // Check for low confidence results for (const result of results) { if (result && result.confidence === 'low') { aiReasons.push(`Low confidence on ${result.field}: ${result.matched_rule_name}`); } } // Check if title looks like it needs cleanup (email subject, too long, garbled) if (ticket.title) { if (ticket.title.length > 150) { aiReasons.push('Title is very long (possible email subject)'); } if (/^(re:|fw:|fwd:)/i.test(ticket.title)) { aiReasons.push('Title is a forwarded/replied email subject'); } } // Determine overall confidence let overallConfidence: ConfidenceLevel; if (aiReasons.length === 0) { overallConfidence = 'high'; } else if (aiReasons.length <= 2) { overallConfidence = 'medium'; } else { overallConfidence = 'low'; } return { overallConfidence, needsAi: aiReasons.length > 0, aiReasons, }; } /** * Force refresh the rules cache. */ async refreshCache(): Promise { await this.loadRules(true); } } // Export singleton instance export const roboticClassifier = new RoboticClassifier();