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>
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
/**
|
|
* Analyzer persistence: read/write to analyzer_analyses, analyzer_jobs, and
|
|
* analyzer_shares.
|
|
*
|
|
* All writes go through the postgresClient singleton — no transactions are
|
|
* needed for the row-per-analysis writes since each is independent and the
|
|
* unique (ticket_number, analysis_version) constraint prevents duplicates.
|
|
*/
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
import {
|
|
type AnalyzerJob,
|
|
type DeepAnalysisResponse,
|
|
type JobStatus,
|
|
type PersistedAnalysis,
|
|
type TaggedEvent,
|
|
} from '@/lib/types/analyzer';
|
|
import type { ITGlueDocReference } from '@/lib/types/analyzer';
|
|
|
|
export interface InsertAnalysisInput {
|
|
ticket_number: string;
|
|
autotask_ticket_id: number;
|
|
content_hash: string;
|
|
triggered_by_user_id: string | null;
|
|
status: 'complete' | 'failed';
|
|
/** When the analysis run finished (now() if undefined). */
|
|
completed_at?: Date;
|
|
|
|
haiku_used: boolean;
|
|
sonnet_used: boolean;
|
|
opus_used: boolean;
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
estimated_cost_usd: number;
|
|
|
|
/** Final analysis content (after any Opus updates). null on failure. */
|
|
analysis: DeepAnalysisResponse | null;
|
|
filtered_noise_count: number;
|
|
/** Per-stage trace dump for debugging — raw model responses, attempts, etc. */
|
|
model_traces: Record<string, unknown>;
|
|
error_message?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Returns the next monotonic analysis_version for this ticket. Uses MAX(...)+1
|
|
* — there is a small race if two workers call this simultaneously, but the
|
|
* UNIQUE (ticket_number, analysis_version) constraint catches it: the loser
|
|
* sees a 23505 unique_violation and the worker should retry with a fresh
|
|
* version number.
|
|
*/
|
|
export async function getNextAnalysisVersion(ticketNumber: string): Promise<number> {
|
|
const res = await postgresClient.query<{ next_version: string }>(
|
|
`SELECT COALESCE(MAX(analysis_version), 0) + 1 AS next_version
|
|
FROM analyzer_analyses
|
|
WHERE ticket_number = $1`,
|
|
[ticketNumber]
|
|
);
|
|
return Number(res.rows[0].next_version);
|
|
}
|
|
|
|
/**
|
|
* Idempotency check: returns the most recent COMPLETE analysis row whose
|
|
* content_hash matches, if any. Used to short-circuit re-runs when the source
|
|
* data hasn't changed and `force=false`.
|
|
*/
|
|
export async function findExistingAnalysisByContentHash(
|
|
ticketNumber: string,
|
|
contentHash: string
|
|
): Promise<{ id: string; analysis_version: number } | null> {
|
|
const res = await postgresClient.query<{ id: string; analysis_version: string }>(
|
|
`SELECT id::text AS id, analysis_version::text AS analysis_version
|
|
FROM analyzer_analyses
|
|
WHERE ticket_number = $1
|
|
AND content_hash_at_analysis = $2
|
|
AND status = 'complete'
|
|
ORDER BY analysis_version DESC
|
|
LIMIT 1`,
|
|
[ticketNumber, contentHash]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
const row = res.rows[0];
|
|
return { id: row.id, analysis_version: Number(row.analysis_version) };
|
|
}
|
|
|
|
/**
|
|
* Insert a completed (or failed) analysis row. Returns the new row's id.
|
|
*
|
|
* Note: the unique (ticket_number, analysis_version) constraint catches racing
|
|
* writers. Caller should re-fetch the next version and retry if it sees a
|
|
* unique-violation error from postgres.
|
|
*/
|
|
export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|
id: string;
|
|
analysis_version: number;
|
|
}> {
|
|
const version = await getNextAnalysisVersion(input.ticket_number);
|
|
const completedAt = input.completed_at ?? new Date();
|
|
const a = input.analysis;
|
|
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`
|
|
INSERT INTO analyzer_analyses (
|
|
ticket_number, autotask_ticket_id, analysis_version,
|
|
content_hash_at_analysis, triggered_by_user_id,
|
|
status, completed_at,
|
|
haiku_used, sonnet_used, opus_used,
|
|
total_input_tokens, total_output_tokens, estimated_cost_usd,
|
|
summary, timeline, what_was_done, what_should_have_been_done,
|
|
gaps, next_step, next_step_rationale, post_resolution_analysis,
|
|
confidence_score, needs_human_review, human_review_reasons,
|
|
itglue_docs_referenced, model_traces, filtered_noise_count, error_message
|
|
)
|
|
VALUES (
|
|
$1, $2, $3,
|
|
$4, $5,
|
|
$6, $7,
|
|
$8, $9, $10,
|
|
$11, $12, $13,
|
|
$14, $15::jsonb, $16::jsonb, $17::jsonb,
|
|
$18::jsonb, $19, $20, $21,
|
|
$22, $23, $24::jsonb,
|
|
$25::jsonb, $26::jsonb, $27, $28
|
|
)
|
|
RETURNING id::text AS id
|
|
`,
|
|
[
|
|
input.ticket_number,
|
|
input.autotask_ticket_id,
|
|
version,
|
|
input.content_hash,
|
|
input.triggered_by_user_id,
|
|
input.status,
|
|
completedAt,
|
|
input.haiku_used,
|
|
input.sonnet_used,
|
|
input.opus_used,
|
|
input.total_input_tokens,
|
|
input.total_output_tokens,
|
|
input.estimated_cost_usd,
|
|
a?.summary ?? null,
|
|
a?.timeline ? JSON.stringify(a.timeline) : null,
|
|
a?.what_was_done ? JSON.stringify(a.what_was_done) : null,
|
|
a?.what_should_have_been_done
|
|
? JSON.stringify(a.what_should_have_been_done)
|
|
: null,
|
|
a?.gaps ? JSON.stringify(a.gaps) : null,
|
|
a?.next_step ?? null,
|
|
a?.next_step_rationale ?? null,
|
|
a?.post_resolution_analysis ?? null,
|
|
a?.confidence_score ?? null,
|
|
a?.needs_human_review ?? false,
|
|
a?.human_review_reasons ? JSON.stringify(a.human_review_reasons) : null,
|
|
JSON.stringify(a?.itglue_docs_referenced ?? []),
|
|
JSON.stringify(input.model_traces),
|
|
input.filtered_noise_count,
|
|
input.error_message ?? null,
|
|
]
|
|
);
|
|
|
|
return { id: res.rows[0].id, analysis_version: version };
|
|
}
|
|
|
|
// =============================================================================
|
|
// Job table operations
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Try to claim the oldest queued job. Atomic via UPDATE ... WHERE ... RETURNING.
|
|
* Returns null if no queued jobs are available.
|
|
*/
|
|
export async function claimQueuedJob(): Promise<{
|
|
id: string;
|
|
ticket_number: string;
|
|
queued_by_user_id: string | null;
|
|
} | null> {
|
|
const res = await postgresClient.query<{
|
|
id: string;
|
|
ticket_number: string;
|
|
queued_by_user_id: string | null;
|
|
}>(
|
|
`
|
|
UPDATE analyzer_jobs
|
|
SET status = 'fetching', started_at = NOW()
|
|
WHERE id = (
|
|
SELECT id FROM analyzer_jobs
|
|
WHERE status = 'queued'
|
|
ORDER BY queued_at
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT 1
|
|
)
|
|
RETURNING id::text AS id, ticket_number, queued_by_user_id
|
|
`
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return res.rows[0];
|
|
}
|
|
|
|
export async function updateJobStatus(
|
|
jobId: string,
|
|
status: JobStatus
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE analyzer_jobs SET status = $1 WHERE id = $2`,
|
|
[status, jobId]
|
|
);
|
|
}
|
|
|
|
export async function completeJob(
|
|
jobId: string,
|
|
resultAnalysisId: string
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE analyzer_jobs
|
|
SET status = 'complete',
|
|
result_analysis_id = $1,
|
|
finished_at = NOW()
|
|
WHERE id = $2`,
|
|
[resultAnalysisId, jobId]
|
|
);
|
|
}
|
|
|
|
export async function failJob(jobId: string, errorMessage: string): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE analyzer_jobs
|
|
SET status = 'failed',
|
|
error_message = $1,
|
|
finished_at = NOW()
|
|
WHERE id = $2`,
|
|
[errorMessage, jobId]
|
|
);
|
|
}
|
|
|
|
export interface QueueJobInput {
|
|
ticket_number: string;
|
|
queued_by_user_id: string | null;
|
|
}
|
|
|
|
export async function queueJob(input: QueueJobInput): Promise<{ id: string }> {
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id)
|
|
VALUES ($1, $2)
|
|
RETURNING id::text AS id`,
|
|
[input.ticket_number, input.queued_by_user_id]
|
|
);
|
|
return { id: res.rows[0].id };
|
|
}
|
|
|
|
export async function getJob(jobId: string): Promise<AnalyzerJob | null> {
|
|
const res = await postgresClient.query<{
|
|
id: string;
|
|
ticket_number: string;
|
|
queued_by_user_id: string | null;
|
|
status: JobStatus;
|
|
result_analysis_id: string | null;
|
|
queued_at: Date;
|
|
started_at: Date | null;
|
|
finished_at: Date | null;
|
|
error_message: string | null;
|
|
}>(
|
|
`SELECT id::text AS id, ticket_number, queued_by_user_id, status,
|
|
result_analysis_id::text AS result_analysis_id,
|
|
queued_at, started_at, finished_at, error_message
|
|
FROM analyzer_jobs WHERE id = $1`,
|
|
[jobId]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
const r = res.rows[0];
|
|
return {
|
|
id: r.id,
|
|
ticketNumber: r.ticket_number,
|
|
queuedByUserId: r.queued_by_user_id,
|
|
status: r.status,
|
|
resultAnalysisId: r.result_analysis_id,
|
|
queuedAt: r.queued_at.toISOString(),
|
|
startedAt: r.started_at ? r.started_at.toISOString() : null,
|
|
finishedAt: r.finished_at ? r.finished_at.toISOString() : null,
|
|
errorMessage: r.error_message,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Read paths used by the API routes
|
|
// =============================================================================
|
|
|
|
interface AnalysisRow {
|
|
id: string;
|
|
ticket_number: string;
|
|
autotask_ticket_id: string;
|
|
analysis_version: string;
|
|
content_hash_at_analysis: string;
|
|
triggered_by_user_id: string | null;
|
|
triggered_at: Date;
|
|
status: PersistedAnalysis['status'];
|
|
completed_at: Date | null;
|
|
haiku_used: boolean;
|
|
sonnet_used: boolean;
|
|
opus_used: boolean;
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
estimated_cost_usd: string;
|
|
summary: string | null;
|
|
timeline: unknown;
|
|
what_was_done: unknown;
|
|
what_should_have_been_done: unknown;
|
|
gaps: unknown;
|
|
next_step: string | null;
|
|
next_step_rationale: string | null;
|
|
post_resolution_analysis: string | null;
|
|
confidence_score: string | null;
|
|
needs_human_review: boolean;
|
|
human_review_reasons: unknown;
|
|
itglue_docs_referenced: unknown;
|
|
filtered_noise_count: number;
|
|
error_message: string | null;
|
|
}
|
|
|
|
function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis {
|
|
return {
|
|
id: r.id,
|
|
ticketNumber: r.ticket_number,
|
|
autotaskTicketId: Number(r.autotask_ticket_id),
|
|
analysisVersion: Number(r.analysis_version),
|
|
contentHashAtAnalysis: r.content_hash_at_analysis,
|
|
triggeredByUserId: r.triggered_by_user_id,
|
|
triggeredAt: r.triggered_at.toISOString(),
|
|
status: r.status,
|
|
completedAt: r.completed_at ? r.completed_at.toISOString() : null,
|
|
haikuUsed: r.haiku_used,
|
|
sonnetUsed: r.sonnet_used,
|
|
opusUsed: r.opus_used,
|
|
totalInputTokens: r.total_input_tokens,
|
|
totalOutputTokens: r.total_output_tokens,
|
|
estimatedCostUsd: Number(r.estimated_cost_usd),
|
|
summary: r.summary,
|
|
// JSONB columns deserialize directly to JS objects in node-postgres; cast
|
|
// to the schema type. We trust Zod-validated writes from the pipeline.
|
|
timeline: r.timeline as PersistedAnalysis['timeline'],
|
|
whatWasDone: r.what_was_done as PersistedAnalysis['whatWasDone'],
|
|
whatShouldHaveBeenDone:
|
|
r.what_should_have_been_done as PersistedAnalysis['whatShouldHaveBeenDone'],
|
|
gaps: r.gaps as PersistedAnalysis['gaps'],
|
|
nextStep: r.next_step,
|
|
nextStepRationale: r.next_step_rationale,
|
|
postResolutionAnalysis: r.post_resolution_analysis,
|
|
confidenceScore:
|
|
r.confidence_score === null ? null : Number(r.confidence_score),
|
|
needsHumanReview: r.needs_human_review,
|
|
humanReviewReasons: r.human_review_reasons as
|
|
| PersistedAnalysis['humanReviewReasons'],
|
|
itglueDocsReferenced:
|
|
(r.itglue_docs_referenced as PersistedAnalysis['itglueDocsReferenced']) ?? [],
|
|
filteredNoiseCount: r.filtered_noise_count,
|
|
errorMessage: r.error_message,
|
|
};
|
|
}
|
|
|
|
const ANALYSIS_SELECT = `
|
|
id::text AS id,
|
|
ticket_number,
|
|
autotask_ticket_id::text AS autotask_ticket_id,
|
|
analysis_version::text AS analysis_version,
|
|
content_hash_at_analysis,
|
|
triggered_by_user_id,
|
|
triggered_at,
|
|
status,
|
|
completed_at,
|
|
haiku_used, sonnet_used, opus_used,
|
|
total_input_tokens, total_output_tokens, estimated_cost_usd::text AS estimated_cost_usd,
|
|
summary, timeline, what_was_done, what_should_have_been_done,
|
|
gaps, next_step, next_step_rationale, post_resolution_analysis,
|
|
confidence_score::text AS confidence_score,
|
|
needs_human_review,
|
|
human_review_reasons,
|
|
itglue_docs_referenced,
|
|
filtered_noise_count,
|
|
error_message
|
|
`;
|
|
|
|
export async function getAnalysisById(
|
|
id: string
|
|
): Promise<PersistedAnalysis | null> {
|
|
const res = await postgresClient.query<AnalysisRow>(
|
|
`SELECT ${ANALYSIS_SELECT} FROM analyzer_analyses WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return rowToPersistedAnalysis(res.rows[0]);
|
|
}
|
|
|
|
export async function listAnalysesByTicketNumber(
|
|
ticketNumber: string
|
|
): Promise<PersistedAnalysis[]> {
|
|
const res = await postgresClient.query<AnalysisRow>(
|
|
`SELECT ${ANALYSIS_SELECT}
|
|
FROM analyzer_analyses
|
|
WHERE ticket_number = $1
|
|
ORDER BY analysis_version DESC`,
|
|
[ticketNumber]
|
|
);
|
|
return res.rows.map(rowToPersistedAnalysis);
|
|
}
|
|
|
|
export async function listNeedsReview(opts: {
|
|
limit?: number;
|
|
offset?: number;
|
|
} = {}): Promise<PersistedAnalysis[]> {
|
|
const limit = Math.min(opts.limit ?? 50, 200);
|
|
const offset = opts.offset ?? 0;
|
|
const res = await postgresClient.query<AnalysisRow>(
|
|
`SELECT ${ANALYSIS_SELECT}
|
|
FROM analyzer_analyses
|
|
WHERE needs_human_review = true
|
|
AND status = 'complete'
|
|
ORDER BY triggered_at DESC
|
|
LIMIT $1 OFFSET $2`,
|
|
[limit, offset]
|
|
);
|
|
return res.rows.map(rowToPersistedAnalysis);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Share log
|
|
// =============================================================================
|
|
|
|
export interface CreateShareInput {
|
|
analysis_id: string;
|
|
shared_by_user_id: string;
|
|
shared_with_email: string;
|
|
note?: string | null;
|
|
}
|
|
|
|
export async function createShare(
|
|
input: CreateShareInput
|
|
): Promise<{ id: string; shared_at: string }> {
|
|
const res = await postgresClient.query<{ id: string; shared_at: Date }>(
|
|
`INSERT INTO analyzer_shares
|
|
(analysis_id, shared_by_user_id, shared_with_email, note)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id::text AS id, shared_at`,
|
|
[
|
|
input.analysis_id,
|
|
input.shared_by_user_id,
|
|
input.shared_with_email,
|
|
input.note ?? null,
|
|
]
|
|
);
|
|
return {
|
|
id: res.rows[0].id,
|
|
shared_at: res.rows[0].shared_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
// Re-export types referenced elsewhere.
|
|
export type { TaggedEvent, ITGlueDocReference, PersistedAnalysis };
|