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>
This commit is contained in:
parent
ea3471d38d
commit
8f8b5ab7be
53 changed files with 9377 additions and 33 deletions
404
lib/services/analyzer/preprocessor.test.ts
Normal file
404
lib/services/analyzer/preprocessor.test.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue