- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
362 lines
11 KiB
TypeScript
362 lines
11 KiB
TypeScript
/**
|
|
* 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;
|
|
problem_ticket_id: number | 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,
|
|
};
|
|
}
|