/** * 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([ '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 = {}; for (const key of Object.keys(value as Record).sort()) { out[key] = canonicalize((value as Record)[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, }; }