wulf-pulse/lib/services/robotic-classifier.ts

373 lines
11 KiB
TypeScript

/**
* 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<RuleType, ClassificationRule[]> = 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<Map<RuleType, ClassificationRule[]>> {
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<ClassificationRule>(
`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<ClassificationResult> {
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<ClassificationStepResult | null> {
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<void> {
await this.loadRules(true);
}
}
// Export singleton instance
export const roboticClassifier = new RoboticClassifier();