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
266
lib/services/analyzer/worker.test.ts
Normal file
266
lib/services/analyzer/worker.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { analyzerWorker } from './worker';
|
||||
import * as dataAccess from './data-access';
|
||||
import * as persistence from './persistence';
|
||||
import * as pipelineModule from './pipeline';
|
||||
import type { RawTicketBundle } from './preprocessor';
|
||||
|
||||
const FIXTURE = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, 'fixtures', 'T20260424.0045.input.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as RawTicketBundle;
|
||||
|
||||
let loadSpy: ReturnType<typeof vi.spyOn>;
|
||||
let runPipelineSpy: ReturnType<typeof vi.spyOn>;
|
||||
let insertSpy: ReturnType<typeof vi.spyOn>;
|
||||
let completeSpy: ReturnType<typeof vi.spyOn>;
|
||||
let failSpy: ReturnType<typeof vi.spyOn>;
|
||||
let updateStatusSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
loadSpy = vi.spyOn(dataAccess, 'loadTicketBundle');
|
||||
runPipelineSpy = vi.spyOn(pipelineModule, 'runPipeline');
|
||||
insertSpy = vi
|
||||
.spyOn(persistence, 'insertAnalysis')
|
||||
.mockResolvedValue({ id: 'an_uuid', analysis_version: 1 });
|
||||
completeSpy = vi.spyOn(persistence, 'completeJob').mockResolvedValue();
|
||||
failSpy = vi.spyOn(persistence, 'failJob').mockResolvedValue();
|
||||
updateStatusSpy = vi.spyOn(persistence, 'updateJobStatus').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('analyzerWorker.runJob', () => {
|
||||
it('writes the analysis row and marks the job complete on a happy-path run', async () => {
|
||||
loadSpy.mockResolvedValueOnce(FIXTURE);
|
||||
runPipelineSpy.mockResolvedValueOnce({
|
||||
outcome: 'complete',
|
||||
analysis: {
|
||||
summary: 's',
|
||||
timeline: [],
|
||||
what_was_done: [],
|
||||
what_should_have_been_done: [],
|
||||
gaps: [],
|
||||
next_step: 'next',
|
||||
next_step_rationale: 'why',
|
||||
post_resolution_analysis: null,
|
||||
confidence_score: 0.8,
|
||||
needs_human_review: false,
|
||||
human_review_reasons: [],
|
||||
ambiguities_for_opus: [],
|
||||
itglue_docs_referenced: [],
|
||||
},
|
||||
pre: {
|
||||
header: {
|
||||
ticket_number: 'T20260424.0045',
|
||||
autotask_ticket_id: 680282,
|
||||
title: 't',
|
||||
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',
|
||||
created_at: '2026-04-24T12:53:50.163Z',
|
||||
resolved_at: null,
|
||||
},
|
||||
events: [],
|
||||
counts: {
|
||||
total_events: 0,
|
||||
customer_facing: 0,
|
||||
internal_only: 0,
|
||||
mixed: 0,
|
||||
filtered_noise: 8,
|
||||
},
|
||||
content_hash: 'a'.repeat(64),
|
||||
},
|
||||
meta: {
|
||||
haiku_used: true,
|
||||
sonnet_used: true,
|
||||
opus_used: false,
|
||||
total_input_tokens: 6_000,
|
||||
total_output_tokens: 800,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
estimated_cost_usd: 0.05,
|
||||
cost_circuit_breaker_tripped: false,
|
||||
},
|
||||
filtered_noise_count: 8,
|
||||
itglue_search_used: false,
|
||||
itglue_org_resolved: false,
|
||||
model_traces: {},
|
||||
});
|
||||
|
||||
const result = await analyzerWorker.runJob(
|
||||
'job_1',
|
||||
'T20260424.0045',
|
||||
'user_abc'
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('complete');
|
||||
expect(result.analysis_id).toBe('an_uuid');
|
||||
expect(insertSpy).toHaveBeenCalledTimes(1);
|
||||
expect(insertSpy.mock.calls[0][0]).toMatchObject({
|
||||
ticket_number: 'T20260424.0045',
|
||||
autotask_ticket_id: 680282,
|
||||
triggered_by_user_id: 'user_abc',
|
||||
status: 'complete',
|
||||
haiku_used: true,
|
||||
sonnet_used: true,
|
||||
opus_used: false,
|
||||
filtered_noise_count: 8,
|
||||
});
|
||||
expect(completeSpy).toHaveBeenCalledWith('job_1', 'an_uuid');
|
||||
expect(failSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes the job pointing at the existing analysis on idempotent short-circuit', async () => {
|
||||
loadSpy.mockResolvedValueOnce(FIXTURE);
|
||||
runPipelineSpy.mockResolvedValueOnce({
|
||||
outcome: 'idempotent_short_circuit',
|
||||
existing_analysis_id: 'existing_id',
|
||||
existing_analysis_version: 4,
|
||||
pre: {
|
||||
header: {
|
||||
ticket_number: 'T20260424.0045',
|
||||
autotask_ticket_id: 680282,
|
||||
title: 't',
|
||||
status_label: 'x',
|
||||
priority_label: null,
|
||||
queue: null,
|
||||
account_name: null,
|
||||
contact_name: null,
|
||||
contact_email: null,
|
||||
created_at: '2026-04-24T12:53:50.163Z',
|
||||
resolved_at: null,
|
||||
},
|
||||
events: [],
|
||||
counts: {
|
||||
total_events: 0,
|
||||
customer_facing: 0,
|
||||
internal_only: 0,
|
||||
mixed: 0,
|
||||
filtered_noise: 0,
|
||||
},
|
||||
content_hash: 'b'.repeat(64),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await analyzerWorker.runJob('job_1', 'T20260424.0045', null);
|
||||
expect(result.outcome).toBe('idempotent_short_circuit');
|
||||
expect(result.analysis_id).toBe('existing_id');
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
expect(completeSpy).toHaveBeenCalledWith('job_1', 'existing_id');
|
||||
});
|
||||
|
||||
it('marks the job failed with a clear message when the ticket is missing', async () => {
|
||||
loadSpy.mockRejectedValueOnce(
|
||||
new dataAccess.TicketNotFoundError('T20260424.0045')
|
||||
);
|
||||
|
||||
const result = await analyzerWorker.runJob(
|
||||
'job_1',
|
||||
'T20260424.0045',
|
||||
null
|
||||
);
|
||||
expect(result.outcome).toBe('failed');
|
||||
expect(result.analysis_id).toBeNull();
|
||||
expect(failSpy).toHaveBeenCalledTimes(1);
|
||||
expect(failSpy.mock.calls[0][1]).toMatch(/not found in local mirror/);
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
expect(completeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the job and persists the error message when the pipeline throws', async () => {
|
||||
loadSpy.mockResolvedValueOnce(FIXTURE);
|
||||
runPipelineSpy.mockRejectedValueOnce(new Error('LLM stage on claude-sonnet-4-6 failed twice'));
|
||||
|
||||
const result = await analyzerWorker.runJob('job_1', 'T20260424.0045', null);
|
||||
expect(result.outcome).toBe('failed');
|
||||
expect(failSpy).toHaveBeenCalledTimes(1);
|
||||
expect(failSpy.mock.calls[0][1]).toMatch(/failed twice/);
|
||||
});
|
||||
|
||||
it('drives updateJobStatus through the pipeline progress callbacks', async () => {
|
||||
loadSpy.mockResolvedValueOnce(FIXTURE);
|
||||
runPipelineSpy.mockImplementationOnce((async (
|
||||
_input: unknown,
|
||||
_deps: unknown,
|
||||
callbacks: { onStage?: (stage: string) => Promise<void> | void } | undefined
|
||||
) => {
|
||||
await callbacks?.onStage?.('fetching');
|
||||
await callbacks?.onStage?.('triaging');
|
||||
await callbacks?.onStage?.('analyzing');
|
||||
return {
|
||||
outcome: 'complete',
|
||||
analysis: {
|
||||
summary: 's',
|
||||
timeline: [],
|
||||
what_was_done: [],
|
||||
what_should_have_been_done: [],
|
||||
gaps: [],
|
||||
next_step: 'n',
|
||||
next_step_rationale: 'r',
|
||||
post_resolution_analysis: null,
|
||||
confidence_score: 0.8,
|
||||
needs_human_review: false,
|
||||
human_review_reasons: [],
|
||||
ambiguities_for_opus: [],
|
||||
itglue_docs_referenced: [],
|
||||
},
|
||||
pre: {
|
||||
header: {
|
||||
ticket_number: 'T20260424.0045',
|
||||
autotask_ticket_id: 680282,
|
||||
title: 't',
|
||||
status_label: null,
|
||||
priority_label: null,
|
||||
queue: null,
|
||||
account_name: null,
|
||||
contact_name: null,
|
||||
contact_email: null,
|
||||
created_at: '2026-04-24T12:53:50.163Z',
|
||||
resolved_at: null,
|
||||
},
|
||||
events: [],
|
||||
counts: {
|
||||
total_events: 0,
|
||||
customer_facing: 0,
|
||||
internal_only: 0,
|
||||
mixed: 0,
|
||||
filtered_noise: 0,
|
||||
},
|
||||
content_hash: 'c'.repeat(64),
|
||||
},
|
||||
meta: {
|
||||
haiku_used: true,
|
||||
sonnet_used: true,
|
||||
opus_used: false,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
estimated_cost_usd: 0,
|
||||
cost_circuit_breaker_tripped: false,
|
||||
},
|
||||
filtered_noise_count: 0,
|
||||
itglue_search_used: false,
|
||||
itglue_org_resolved: false,
|
||||
model_traces: {},
|
||||
};
|
||||
}) as never);
|
||||
|
||||
await analyzerWorker.runJob('job_1', 'T20260424.0045', null);
|
||||
|
||||
const stagesPassedToUpdate = (updateStatusSpy.mock.calls as Array<[string, string]>).map(
|
||||
(c) => c[1]
|
||||
);
|
||||
expect(stagesPassedToUpdate).toEqual(['fetching', 'triaging', 'analyzing']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue