wulf-pulse/lib/services/analyzer/persistence.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

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 };