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
454
lib/services/analyzer/persistence.ts
Normal file
454
lib/services/analyzer/persistence.ts
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
/**
|
||||
* 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue