wulf-pulse/lib/services/analyzer/preprocessor.test.ts
lorentz 8f8b5ab7be feat: AI ticket analyzer (phases 1-6)
Multi-stage LLM pipeline that produces structured analyses of Autotask
tickets from local Postgres. Migration 069 + Zod schemas, Stage 0
preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages
1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker
(opt-in autostart), 6 API routes, 3 frontend pages, share-row
persistence (email send deferred to phase 7). 128 vitest tests, tsc
clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md.

Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered
entities so the analyzer's local mirror stays current via scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:59:40 -04:00

404 lines
13 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
preprocessTicket,
tagTicketNote,
tagTimeEntry,
isWorkflowNoise,
isEmailNotification,
classifyActorType,
computeContentHash,
type RawTicketBundle,
} from './preprocessor';
// Load the regression fixture once for the file. The fixture is the canonical
// shape the data-access layer produces (header + ticket notes + time entries
// joined with creator name/email).
const FIXTURE_DIR = resolve(__dirname, 'fixtures');
const inputFixture = JSON.parse(
readFileSync(`${FIXTURE_DIR}/T20260424.0045.input.json`, 'utf8')
) as RawTicketBundle;
const expectedFixture = JSON.parse(
readFileSync(`${FIXTURE_DIR}/T20260424.0045.expected.json`, 'utf8')
) as {
preprocessor: {
filtered_workflow_noise_ids: number[];
filtered_email_notification_ids_in_input: number[];
retained_note_ids: number[];
retained_time_entry_ids: number[];
};
};
describe('isWorkflowNoise', () => {
it('matches by Autotask Administrator resource id', () => {
expect(
isWorkflowNoise({
id: 1,
title: null,
description: 'x',
note_type: null,
publish: null,
creator_resource_id: 4,
creator_name: 'Autotask Administrator',
creator_email: null,
creator_type: 1,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(true);
});
it('matches by title prefix', () => {
expect(
isWorkflowNoise({
id: 1,
title: 'Workflow Rule "Foo" fired.',
description: 'x',
note_type: 13,
publish: 1,
creator_resource_id: 99,
creator_name: null,
creator_email: null,
creator_type: null,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(true);
});
it('does not match a real ticket note', () => {
expect(
isWorkflowNoise({
id: 1,
title: 'Wulf Support Ticket Update -',
description: 'real content',
note_type: 1,
publish: 1,
creator_resource_id: 29683311,
creator_name: 'Lorentz Hinrichsen',
creator_email: 'lorentz@wulfconsulting.com',
creator_type: 1,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(false);
});
});
describe('isEmailNotification', () => {
it('matches "Service Desk Notification" titles', () => {
expect(
isEmailNotification({
id: 1,
title: 'Service Desk Notification',
description: 'a@b.com, c@d.com',
note_type: 2,
publish: 4,
creator_resource_id: 99,
creator_name: null,
creator_email: null,
creator_type: null,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(true);
});
it('does not match other titles', () => {
expect(
isEmailNotification({
id: 1,
title: 'Wulf Support Ticket Update -',
description: 'x',
note_type: 1,
publish: 1,
creator_resource_id: 99,
creator_name: null,
creator_email: null,
creator_type: null,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(false);
});
});
describe('classifyActorType', () => {
it.each([
['lorentz@wulfconsulting.com', null, 'wulf_tech'],
['cory.houck@wulfconsulting.com', null, 'wulf_tech'],
['tlyster@seubert.com', null, 'client_contact'],
['rmurphy@vertafore.com', null, 'vendor'],
[null, null, 'system'],
[undefined, null, 'system'],
[null, 4, 'automation'], // Autotask Administrator id
['anyone@anywhere.com', 4, 'automation'], // creator id beats domain
['mixed@WulfConsulting.COM', null, 'wulf_tech'], // case-insensitive
] as const)('email=%s creatorId=%s -> %s', (email, creatorId, expected) => {
expect(classifyActorType(email, creatorId)).toBe(expected);
});
});
describe('tagTicketNote', () => {
const baseNote = {
id: 1,
title: 'Wulf Support Ticket Update -',
description: 'hello',
note_type: 1,
creator_type: 1,
create_date_time: '2026-04-24T14:34:46.583Z',
};
it('tags publish=1 as customer_facing and puts content in summary_notes', () => {
const evt = tagTicketNote({
...baseNote,
publish: 1,
creator_resource_id: 29683311,
creator_name: 'Lorentz Hinrichsen',
creator_email: 'lorentz@wulfconsulting.com',
});
expect(evt).toMatchObject({
source: 'ticket_note',
visibility: 'customer_facing',
actor: 'Lorentz Hinrichsen',
actor_type: 'wulf_tech',
summary_notes: 'hello',
});
expect(evt?.internal_notes).toBeUndefined();
});
it('tags publish=2 as internal_only and puts content in internal_notes', () => {
const evt = tagTicketNote({
...baseNote,
publish: 2,
creator_resource_id: 29683311,
creator_name: 'Lorentz Hinrichsen',
creator_email: 'lorentz@wulfconsulting.com',
});
expect(evt).toMatchObject({
visibility: 'internal_only',
internal_notes: 'hello',
});
expect(evt?.summary_notes).toBeUndefined();
});
});
describe('tagTimeEntry', () => {
const baseEntry = {
id: 1,
resource_id: 30861463,
resource_name: 'Cory Houck',
resource_email: 'cory.houck@wulfconsulting.com',
hours_worked: 0.5,
entry_date: '2026-04-24T00:00:00Z',
start_date_time: null,
end_date_time: '2026-04-24T15:00:00Z',
type: 2,
};
it('returns mixed visibility when both notes and internal_notes are present', () => {
const evt = tagTimeEntry({
...baseEntry,
notes: 'public summary',
internal_notes: 'internal-only follow-up',
});
expect(evt).toMatchObject({
source: 'time_entry',
visibility: 'mixed',
summary_notes: 'public summary',
internal_notes: 'internal-only follow-up',
hours: 0.5,
});
});
it('returns customer_facing when only notes are present', () => {
const evt = tagTimeEntry({ ...baseEntry, notes: 'public', internal_notes: null });
expect(evt?.visibility).toBe('customer_facing');
expect(evt?.summary_notes).toBe('public');
expect(evt?.internal_notes).toBeUndefined();
});
it('returns internal_only when only internal_notes are present', () => {
const evt = tagTimeEntry({ ...baseEntry, notes: null, internal_notes: 'internal' });
expect(evt?.visibility).toBe('internal_only');
expect(evt?.internal_notes).toBe('internal');
expect(evt?.summary_notes).toBeUndefined();
});
it('drops entries with no narrative content', () => {
expect(tagTimeEntry({ ...baseEntry, notes: null, internal_notes: null })).toBeNull();
expect(tagTimeEntry({ ...baseEntry, notes: ' ', internal_notes: '' })).toBeNull();
});
});
describe('computeContentHash', () => {
const evt = {
timestamp: '2026-04-24T12:53:50.163Z',
actor: 'A',
actor_type: 'wulf_tech' as const,
source: 'ticket_create' as const,
visibility: 'customer_facing' as const,
summary_notes: 'x',
};
it('is deterministic for the same input', () => {
expect(computeContentHash([evt], 7, 8, 99)).toBe(
computeContentHash([evt], 7, 8, 99)
);
});
it('changes when ticket status changes', () => {
expect(computeContentHash([evt], 7, 8, 99)).not.toBe(
computeContentHash([evt], 5, 8, 99)
);
});
it('changes when priority changes', () => {
expect(computeContentHash([evt], 7, 8, 99)).not.toBe(
computeContentHash([evt], 7, 1, 99)
);
});
it('is independent of object key insertion order', () => {
const e1 = { ...evt };
const e2 = {
visibility: evt.visibility,
summary_notes: evt.summary_notes,
timestamp: evt.timestamp,
actor_type: evt.actor_type,
actor: evt.actor,
source: evt.source,
};
expect(computeContentHash([e1], 7, 8, 99)).toBe(
computeContentHash([e2], 7, 8, 99)
);
});
});
// =============================================================================
// Regression: end-to-end against the T20260424.0045 fixture.
// =============================================================================
describe('preprocessTicket — T20260424.0045 fixture', () => {
const result = preprocessTicket(inputFixture);
it('filters the four workflow-rule firings', () => {
const filteredIds = expectedFixture.preprocessor.filtered_workflow_noise_ids;
for (const id of filteredIds) {
expect(result.events.some((e) => e.source === 'ticket_note' && e.actor !== 'Autotask Administrator' || false)).toBe(true);
// Confirm none of the filtered IDs survived as events.
const survived = inputFixture.notes
.filter((n) => filteredIds.includes(n.id))
.some((n) => result.events.some((e) => e.timestamp === n.create_date_time));
expect(survived).toBe(false);
}
});
it('filters all "Service Desk Notification" rows', () => {
const filteredIds =
expectedFixture.preprocessor.filtered_email_notification_ids_in_input;
expect(filteredIds.length).toBeGreaterThan(0);
const survived = inputFixture.notes
.filter((n) => filteredIds.includes(n.id))
.some((n) => result.events.some((e) => e.timestamp === n.create_date_time && e.source === 'ticket_note'));
expect(survived).toBe(false);
});
it('reports filtered_noise = workflow + email-notification count', () => {
const expected =
expectedFixture.preprocessor.filtered_workflow_noise_ids.length +
expectedFixture.preprocessor.filtered_email_notification_ids_in_input.length;
expect(result.counts.filtered_noise).toBe(expected);
});
it("retains Lorentz's \"I'll take it from here\" note as a tagged event", () => {
const lorentzNote = inputFixture.notes.find((n) => n.id === 33738796)!;
expect(lorentzNote).toBeDefined();
const matching = result.events.find(
(e) => e.source === 'ticket_note' && e.timestamp === lorentzNote.create_date_time
);
expect(matching).toBeDefined();
expect(matching).toMatchObject({
actor: 'Lorentz Hinrichsen',
actor_type: 'wulf_tech',
visibility: 'customer_facing',
});
expect(matching?.summary_notes).toContain("take it from here");
});
it('emits 7 events total: 1 ticket_create + 1 retained note + 5 time entries', () => {
expect(result.events.length).toBe(7);
const counts = result.events.reduce<Record<string, number>>((acc, e) => {
acc[e.source] = (acc[e.source] ?? 0) + 1;
return acc;
}, {});
expect(counts).toEqual({
ticket_create: 1,
ticket_note: 1,
time_entry: 5,
});
});
it('produces a chronologically sorted timeline', () => {
for (let i = 1; i < result.events.length; i++) {
expect(
result.events[i].timestamp >= result.events[i - 1].timestamp
).toBe(true);
}
});
it('tags the ticket_create as client_contact based on contact email', () => {
const created = result.events.find((e) => e.source === 'ticket_create');
expect(created).toMatchObject({
actor: 'Tyler Lyster',
actor_type: 'client_contact',
visibility: 'customer_facing',
});
});
it('tags time entries 465933, 465961, 466183 as mixed visibility', () => {
// These are the entries with both summary and internal notes per the fixture.
const expectedMixed = new Set([465933, 465961, 466183]);
for (const id of expectedMixed) {
const entry = inputFixture.time_entries.find((e) => e.id === id)!;
const evt = result.events.find(
(e) => e.source === 'time_entry' && e.hours === entry.hours_worked && e.summary_notes && e.internal_notes
);
expect(evt, `time_entry ${id} should produce a mixed event`).toBeDefined();
expect(evt?.visibility).toBe('mixed');
}
});
it('reports counts that add up', () => {
const { customer_facing, internal_only, mixed, total_events } = result.counts;
expect(customer_facing + internal_only + mixed).toBe(total_events);
expect(total_events).toBe(7);
// 1 ticket_create + 1 ticket_note (publish=1) + 2 time_entries with only notes = 4 customer_facing
expect(customer_facing).toBe(4);
expect(internal_only).toBe(0);
expect(mixed).toBe(3);
});
it('produces a stable content_hash across re-runs', () => {
const a = preprocessTicket(inputFixture).content_hash;
const b = preprocessTicket(inputFixture).content_hash;
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('changes content_hash if a meaningful field changes', () => {
const original = preprocessTicket(inputFixture).content_hash;
const modified = preprocessTicket({
...inputFixture,
ticket: { ...inputFixture.ticket, status: 5 },
}).content_hash;
expect(modified).not.toBe(original);
});
it('populates the LLM-bound header correctly', () => {
expect(result.header).toMatchObject({
ticket_number: 'T20260424.0045',
autotask_ticket_id: 680282,
status_label: 'Waiting Customer',
priority_label: 'Minor Service',
queue: 'Level 2 Support',
account_name: 'Seubert and Associates',
contact_name: 'Tyler Lyster',
contact_email: 'tlyster@seubert.com',
resolved_at: null,
});
});
});