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
227
lib/services/analyzer/data-access.ts
Normal file
227
lib/services/analyzer/data-access.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* Data-access layer for the AI Ticket Analyzer.
|
||||
*
|
||||
* Loads a ticket from the local Postgres mirror in the exact shape the
|
||||
* pre-processor consumes (header + ticket_notes + time_entries with creator
|
||||
* names/emails resolved by JOIN).
|
||||
*
|
||||
* After the ticket-notes sync fix landed (lib/services/entity-sync syncTicketNotes
|
||||
* + scheduled incremental sync), the local DB is the canonical source for an
|
||||
* analyzer run.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import type { RawTicketBundle } from './preprocessor';
|
||||
|
||||
export class TicketNotFoundError extends Error {
|
||||
constructor(public ticketNumber: string) {
|
||||
super(`Ticket ${ticketNumber} not found in local mirror`);
|
||||
this.name = 'TicketNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
interface TicketRow {
|
||||
id: string;
|
||||
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: string;
|
||||
company_name: string | null;
|
||||
contact_id: string | null;
|
||||
contact_name: string | null;
|
||||
contact_email: string | null;
|
||||
assigned_resource_id: string | null;
|
||||
assignee_name: string | null;
|
||||
assignee_email: string | null;
|
||||
create_date: Date | string;
|
||||
last_activity_date: Date | string;
|
||||
resolved_date_time: Date | string | null;
|
||||
}
|
||||
|
||||
interface NoteRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
description: string;
|
||||
note_type: number | null;
|
||||
publish: number | null;
|
||||
creator_resource_id: string | null;
|
||||
creator_name: string | null;
|
||||
creator_email: string | null;
|
||||
creator_type: number | null;
|
||||
create_date_time: Date | string | null;
|
||||
}
|
||||
|
||||
interface TimeEntryRow {
|
||||
id: string;
|
||||
resource_id: string;
|
||||
resource_name: string | null;
|
||||
resource_email: string | null;
|
||||
hours_worked: string;
|
||||
notes: string | null;
|
||||
internal_notes: string | null;
|
||||
entry_date: Date | string | null;
|
||||
start_date_time: Date | string | null;
|
||||
end_date_time: Date | string | null;
|
||||
type: number | null;
|
||||
}
|
||||
|
||||
function toIso(d: Date | string | null): string | null {
|
||||
if (d === null || d === undefined) return null;
|
||||
if (d instanceof Date) return d.toISOString();
|
||||
return new Date(d).toISOString();
|
||||
}
|
||||
|
||||
function toIsoRequired(d: Date | string): string {
|
||||
if (d instanceof Date) return d.toISOString();
|
||||
return new Date(d).toISOString();
|
||||
}
|
||||
|
||||
export async function loadTicketBundle(
|
||||
ticketNumber: string
|
||||
): Promise<RawTicketBundle> {
|
||||
const ticketRes = await postgresClient.query<TicketRow>(
|
||||
`
|
||||
SELECT
|
||||
t.id::text AS id,
|
||||
t.ticket_number AS ticket_number,
|
||||
t.title AS title,
|
||||
t.description AS description,
|
||||
t.status AS status,
|
||||
(SELECT label FROM statuses WHERE value = t.status) AS status_label,
|
||||
t.priority AS priority,
|
||||
(SELECT label FROM priorities WHERE value = t.priority) AS priority_label,
|
||||
t.queue_id AS queue_id,
|
||||
(SELECT label FROM queues WHERE value = t.queue_id) AS queue_label,
|
||||
t.company_id::text AS company_id,
|
||||
(SELECT company_name FROM companies WHERE id = t.company_id) AS company_name,
|
||||
t.contact_id::text AS contact_id,
|
||||
(SELECT TRIM(first_name || ' ' || last_name) FROM contacts WHERE id = t.contact_id)
|
||||
AS contact_name,
|
||||
(SELECT email_address FROM contacts WHERE id = t.contact_id)
|
||||
AS contact_email,
|
||||
t.assigned_resource_id::text AS assigned_resource_id,
|
||||
(SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = t.assigned_resource_id)
|
||||
AS assignee_name,
|
||||
(SELECT email FROM resources WHERE id = t.assigned_resource_id)
|
||||
AS assignee_email,
|
||||
t.create_date AS create_date,
|
||||
t.last_activity_date AS last_activity_date,
|
||||
t.resolved_date_time AS resolved_date_time
|
||||
FROM tickets t
|
||||
WHERE t.ticket_number = $1
|
||||
AND COALESCE(t.is_deleted, false) = false
|
||||
LIMIT 1
|
||||
`,
|
||||
[ticketNumber]
|
||||
);
|
||||
|
||||
if (ticketRes.rowCount === 0) {
|
||||
throw new TicketNotFoundError(ticketNumber);
|
||||
}
|
||||
const t = ticketRes.rows[0];
|
||||
const ticketIdNum = Number(t.id);
|
||||
|
||||
const notesRes = await postgresClient.query<NoteRow>(
|
||||
`
|
||||
SELECT
|
||||
n.id::text AS id,
|
||||
n.title AS title,
|
||||
n.description AS description,
|
||||
n.note_type AS note_type,
|
||||
n.publish AS publish,
|
||||
n.creator_resource_id::text AS creator_resource_id,
|
||||
(SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = n.creator_resource_id)
|
||||
AS creator_name,
|
||||
(SELECT email FROM resources WHERE id = n.creator_resource_id)
|
||||
AS creator_email,
|
||||
n.creator_type AS creator_type,
|
||||
n.create_date_time AS create_date_time
|
||||
FROM ticket_notes n
|
||||
WHERE n.ticket_id = $1
|
||||
AND COALESCE(n.is_deleted, false) = false
|
||||
ORDER BY n.create_date_time
|
||||
`,
|
||||
[ticketIdNum]
|
||||
);
|
||||
|
||||
const entriesRes = await postgresClient.query<TimeEntryRow>(
|
||||
`
|
||||
SELECT
|
||||
te.id::text AS id,
|
||||
te.resource_id::text AS resource_id,
|
||||
(SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = te.resource_id)
|
||||
AS resource_name,
|
||||
(SELECT email FROM resources WHERE id = te.resource_id)
|
||||
AS resource_email,
|
||||
te.hours_worked::text AS hours_worked,
|
||||
te.notes AS notes,
|
||||
te.internal_notes AS internal_notes,
|
||||
te.entry_date AS entry_date,
|
||||
te.start_date_time AS start_date_time,
|
||||
te.end_date_time AS end_date_time,
|
||||
te.type AS type
|
||||
FROM time_entries te
|
||||
WHERE te.ticket_id = $1
|
||||
AND COALESCE(te.is_deleted, false) = false
|
||||
ORDER BY te.entry_date, te.id
|
||||
`,
|
||||
[ticketIdNum]
|
||||
);
|
||||
|
||||
return {
|
||||
ticket: {
|
||||
id: ticketIdNum,
|
||||
ticket_number: t.ticket_number,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
status: t.status,
|
||||
status_label: t.status_label,
|
||||
priority: t.priority,
|
||||
priority_label: t.priority_label,
|
||||
queue_id: t.queue_id,
|
||||
queue_label: t.queue_label,
|
||||
company_id: Number(t.company_id),
|
||||
company_name: t.company_name,
|
||||
contact_id: t.contact_id ? Number(t.contact_id) : null,
|
||||
contact_name: t.contact_name,
|
||||
contact_email: t.contact_email,
|
||||
assigned_resource_id: t.assigned_resource_id ? Number(t.assigned_resource_id) : null,
|
||||
assignee_name: t.assignee_name,
|
||||
assignee_email: t.assignee_email,
|
||||
create_date: toIsoRequired(t.create_date),
|
||||
last_activity_date: toIsoRequired(t.last_activity_date),
|
||||
resolved_date_time: toIso(t.resolved_date_time),
|
||||
},
|
||||
notes: notesRes.rows.map((n) => ({
|
||||
id: Number(n.id),
|
||||
title: n.title,
|
||||
description: n.description ?? '',
|
||||
note_type: n.note_type,
|
||||
publish: n.publish,
|
||||
creator_resource_id: n.creator_resource_id ? Number(n.creator_resource_id) : null,
|
||||
creator_name: n.creator_name,
|
||||
creator_email: n.creator_email,
|
||||
creator_type: n.creator_type,
|
||||
create_date_time: toIso(n.create_date_time),
|
||||
})),
|
||||
time_entries: entriesRes.rows.map((e) => ({
|
||||
id: Number(e.id),
|
||||
resource_id: Number(e.resource_id),
|
||||
resource_name: e.resource_name,
|
||||
resource_email: e.resource_email,
|
||||
hours_worked: Number(e.hours_worked),
|
||||
notes: e.notes,
|
||||
internal_notes: e.internal_notes,
|
||||
entry_date: toIso(e.entry_date),
|
||||
start_date_time: toIso(e.start_date_time),
|
||||
end_date_time: toIso(e.end_date_time),
|
||||
type: e.type,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue