373 lines
11 KiB
TypeScript
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();
|