wulf-pulse/lib/services/analyzer/preprocessor.ts

362 lines
11 KiB
TypeScript
Raw Normal View History

/**
* Stage 0 pre-processor for the AI Ticket Analyzer pipeline.
*
* Takes a raw ticket bundle (header + ticket notes + time entries, joined
* with creator names/emails by the data-access layer) and produces a
* deterministic, tagged, chronological event timeline ready for the Haiku
* triage stage.
*
* Responsibilities, per docs/wulf-pulse-ticket-analyzer-prompt.md:
* 1. Filter workflow-rule noise and email-notification rows.
* 2. Tag each retained event with actor, actor_type, source, visibility.
* 3. Sort chronologically.
* 4. Compute a stable content hash for idempotency.
*
* No LLM calls happen here. This module is fully deterministic and tested.
*/
import { createHash } from 'crypto';
import {
type ActorType,
type EventSource,
type Visibility,
type TaggedEvent,
type PreprocessedTicket,
type TicketHeaderForLLM,
} from '@/lib/types/analyzer';
// =============================================================================
// Input types — what the data-access layer feeds us.
// =============================================================================
export interface RawTicketHeader {
id: number;
ticket_number: string;
title: string;
description: string | null;
status: number;
status_label: string | null;
priority: number;
priority_label: string | null;
queue_id: number | null;
queue_label: string | null;
company_id: number;
company_name: string | null;
contact_id: number | null;
contact_name: string | null;
contact_email: string | null;
assigned_resource_id: number | null;
assignee_name: string | null;
assignee_email: string | null;
create_date: string;
last_activity_date: string;
resolved_date_time: string | null;
}
export interface RawTicketNote {
id: number;
title: string | null;
description: string;
note_type: number | null;
publish: number | null;
creator_resource_id: number | null;
creator_name: string | null;
creator_email: string | null;
creator_type: number | null;
create_date_time: string | null;
}
export interface RawTimeEntry {
id: number;
resource_id: number;
resource_name: string | null;
resource_email: string | null;
hours_worked: number;
notes: string | null;
internal_notes: string | null;
entry_date: string | null;
start_date_time: string | null;
end_date_time: string | null;
type: number | null;
}
export interface RawTicketBundle {
ticket: RawTicketHeader;
notes: RawTicketNote[];
time_entries: RawTimeEntry[];
}
// =============================================================================
// Filtering — strip rows the analyzer should never see.
// =============================================================================
const AUTOTASK_ADMINISTRATOR_RESOURCE_ID = 4;
/**
* Workflow-rule firings have title "Workflow Rule \"X\" fired." and creator
* resource id 4 (the Autotask Administrator service account). The two checks
* are belt-and-braces: title alone is sufficient in practice, but the resource
* id catches edge cases where the title format changes.
*/
export function isWorkflowNoise(note: RawTicketNote): boolean {
if (note.creator_resource_id === AUTOTASK_ADMINISTRATOR_RESOURCE_ID) return true;
if (note.title?.startsWith('Workflow Rule')) return true;
return false;
}
/**
* "Service Desk Notification" rows are auto-generated email send confirmations
* (the description is just a comma-separated recipient list).
*/
export function isEmailNotification(note: RawTicketNote): boolean {
return note.title === 'Service Desk Notification';
}
// =============================================================================
// Actor-type classification — by domain, NOT by author.
// =============================================================================
const WULF_DOMAIN = 'wulfconsulting.com';
// Conservative vendor list — the LLM stages can refine. Add only domains we're
// confident about; misclassifying a customer domain as "vendor" is worse than
// the default "client_contact".
const VENDOR_DOMAINS = new Set<string>([
'vertafore.com',
'autotask.com',
'datto.com',
'microsoft.com',
'auvik.com',
'addigy.com',
'sentinelone.com',
'mimecast.com',
'itglue.com',
'duo.com',
'duosecurity.com',
]);
export function classifyActorType(
email: string | null | undefined,
creatorResourceId: number | null = null
): ActorType {
if (creatorResourceId === AUTOTASK_ADMINISTRATOR_RESOURCE_ID) return 'automation';
if (!email) return 'system';
const domain = email.split('@')[1]?.toLowerCase();
if (!domain) return 'system';
if (domain === WULF_DOMAIN) return 'wulf_tech';
if (VENDOR_DOMAINS.has(domain)) return 'vendor';
return 'client_contact';
}
// =============================================================================
// Tagging — turn raw rows into TaggedEvents.
// =============================================================================
/**
* Map Autotask `publish` picklist to our visibility tag.
* 1 = All Internal and External Users customer_facing
* 2 = Internal Users Only internal_only
* anything else customer_facing (safe default)
*
* Notification rows (publish=4) are filtered upstream before this runs.
*/
function publishToVisibility(publish: number | null): Visibility {
if (publish === 2) return 'internal_only';
return 'customer_facing';
}
export function tagTicketCreate(header: RawTicketHeader): TaggedEvent | null {
// The header description is the body of the ticket as opened. Some tickets
// are opened with no description; emit nothing rather than a null event.
if (!header.description) return null;
return {
timestamp: header.create_date,
actor: header.contact_name ?? header.assignee_name ?? 'Unknown',
actor_type: classifyActorType(header.contact_email),
source: 'ticket_create' as EventSource,
visibility: 'customer_facing',
summary_notes: header.description,
};
}
export function tagTicketNote(note: RawTicketNote): TaggedEvent | null {
if (!note.create_date_time) return null;
const visibility = publishToVisibility(note.publish);
const event: TaggedEvent = {
timestamp: note.create_date_time,
actor: note.creator_name ?? 'Unknown',
actor_type: classifyActorType(note.creator_email, note.creator_resource_id),
source: 'ticket_note' as EventSource,
visibility,
};
if (visibility === 'customer_facing') {
event.summary_notes = note.description;
} else {
event.internal_notes = note.description;
}
return event;
}
export function tagTimeEntry(entry: RawTimeEntry): TaggedEvent | null {
const hasSummary = !!entry.notes && entry.notes.trim().length > 0;
const hasInternal = !!entry.internal_notes && entry.internal_notes.trim().length > 0;
// No content at all → drop. A purely numeric time entry doesn't add narrative.
if (!hasSummary && !hasInternal) return null;
const timestamp = entry.end_date_time ?? entry.start_date_time ?? entry.entry_date;
if (!timestamp) return null;
const visibility: Visibility = hasSummary && hasInternal
? 'mixed'
: hasSummary
? 'customer_facing'
: 'internal_only';
const event: TaggedEvent = {
timestamp,
actor: entry.resource_name ?? 'Unknown',
actor_type: classifyActorType(entry.resource_email),
source: 'time_entry' as EventSource,
visibility,
hours: entry.hours_worked,
};
if (hasSummary) event.summary_notes = entry.notes!;
if (hasInternal) event.internal_notes = entry.internal_notes!;
return event;
}
// =============================================================================
// Content hash — sha256 over canonical JSON of (events, status, priority, queue).
// =============================================================================
/**
* Stable JSON: object keys sorted recursively, arrays preserved in order.
* Used so the hash is deterministic regardless of property insertion order.
*/
function canonicalize(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(canonicalize);
const out: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
out[key] = canonicalize((value as Record<string, unknown>)[key]);
}
return out;
}
export function computeContentHash(
events: TaggedEvent[],
ticketStatus: number,
ticketPriority: number,
queueId: number | null
): string {
const canonical = JSON.stringify(
canonicalize({
events,
status: ticketStatus,
priority: ticketPriority,
queue: queueId,
})
);
return createHash('sha256').update(canonical).digest('hex');
}
// =============================================================================
// Top-level — preprocess one ticket bundle.
// =============================================================================
export function preprocessTicket(bundle: RawTicketBundle): PreprocessedTicket {
const { ticket, notes, time_entries } = bundle;
// 1. Filter noise.
let filteredNoise = 0;
const retainedNotes = notes.filter((n) => {
if (isWorkflowNoise(n) || isEmailNotification(n)) {
filteredNoise += 1;
return false;
}
return true;
});
// 2. Tag.
const events: TaggedEvent[] = [];
const ticketCreate = tagTicketCreate(ticket);
if (ticketCreate) events.push(ticketCreate);
for (const n of retainedNotes) {
const tagged = tagTicketNote(n);
if (tagged) events.push(tagged);
}
for (const e of time_entries) {
const tagged = tagTimeEntry(e);
if (tagged) events.push(tagged);
}
// Add a resolution event if the ticket is resolved.
if (ticket.resolved_date_time) {
events.push({
timestamp: ticket.resolved_date_time,
actor: ticket.assignee_name ?? 'Unknown',
actor_type: classifyActorType(ticket.assignee_email),
source: 'resolution' as EventSource,
visibility: 'customer_facing',
});
}
// 3. Sort chronologically. Tie-break by source so deterministic regardless
// of input ordering.
events.sort((a, b) => {
const t = a.timestamp.localeCompare(b.timestamp);
if (t !== 0) return t;
return a.source.localeCompare(b.source);
});
// 4. Counts.
let customer_facing = 0;
let internal_only = 0;
let mixed = 0;
for (const e of events) {
if (e.visibility === 'customer_facing') customer_facing += 1;
else if (e.visibility === 'internal_only') internal_only += 1;
else mixed += 1;
}
// 5. Header for the LLM payload.
const header: TicketHeaderForLLM = {
ticket_number: ticket.ticket_number,
autotask_ticket_id: ticket.id,
title: ticket.title,
status_label: ticket.status_label ?? `status_${ticket.status}`,
priority_label: ticket.priority_label,
queue: ticket.queue_label,
account_name: ticket.company_name,
contact_name: ticket.contact_name,
contact_email: ticket.contact_email,
created_at: ticket.create_date,
resolved_at: ticket.resolved_date_time,
};
// 6. Content hash.
const content_hash = computeContentHash(
events,
ticket.status,
ticket.priority,
ticket.queue_id
);
return {
header,
events,
counts: {
total_events: events.length,
customer_facing,
internal_only,
mixed,
filtered_noise: filteredNoise,
},
content_hash,
};
}