/** * 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 AggregateFingerprint, type AnalyzerJob, type DeepAnalysisResponse, type JobStatus, type PersistedAnalysis, type StageExecutionRecord, 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; /** anthropic | openrouter — defaults to 'anthropic' for back-compat. */ provider?: 'anthropic' | 'openrouter'; 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; /** * LEGACY (phase 1). Per-stage trace dump. Retained for back-compat until * analyzer_stage_executions has full coverage and we drop the column. */ model_traces: Record; /** Phase 2: Stage 0 preprocessed event list at analysis time. */ source_snapshot?: TaggedEvent[] | null; error_message?: string | null; } /** * Returns the next monotonic analysis_version for this ticket **and provider**. * Uses MAX(...)+1 — there is a small race if two workers call this * simultaneously, but the UNIQUE (ticket_number, provider, 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, provider: 'anthropic' | 'openrouter' = 'anthropic' ): Promise { 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 AND provider = $2`, [ticketNumber, provider] ); return Number(res.rows[0].next_version); } /** * Idempotency check: returns the most recent COMPLETE analysis row whose * content_hash matches **for the given provider**, if any. Used to * short-circuit re-runs when the source data hasn't changed and `force=false`. * * Provider-scoped so a Claude run doesn't short-circuit a request for a * DeepSeek run (and vice versa) — the user wants a parallel analysis. */ export async function findExistingAnalysisByContentHash( ticketNumber: string, contentHash: string, provider: 'anthropic' | 'openrouter' = 'anthropic' ): 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 provider = $3 AND status = 'complete' ORDER BY analysis_version DESC LIMIT 1`, [ticketNumber, contentHash, provider] ); 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 provider = input.provider ?? 'anthropic'; const version = await getNextAnalysisVersion(input.ticket_number, provider); 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, source_snapshot, provider ) 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, $29::jsonb, $30 ) 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, input.source_snapshot ? JSON.stringify(input.source_snapshot) : null, provider, ] ); return { id: res.rows[0].id, analysis_version: version }; } /** * Bulk-insert one analyzer_stage_executions row per record. No-op when records * is empty. Single multi-VALUES INSERT — fast enough for the few rows produced * per pipeline run that we don't need COPY. */ export async function bulkInsertStageExecutions( analysisId: string, records: StageExecutionRecord[] ): Promise { if (records.length === 0) return; const values: unknown[] = [analysisId]; const tuples: string[] = []; for (const r of records) { const base = values.length; values.push( r.stage, r.stage_order, r.model_id, JSON.stringify(r.input_payload ?? {}), JSON.stringify(r.output_payload ?? {}), r.input_tokens, r.output_tokens, r.latency_ms, r.started_at, r.completed_at, r.error_message ); tuples.push( `($1, $${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}::jsonb, ` + `$${base + 5}::jsonb, $${base + 6}, $${base + 7}, $${base + 8}, ` + `$${base + 9}, $${base + 10}, $${base + 11})` ); } await postgresClient.query( `INSERT INTO analyzer_stage_executions ( analysis_id, stage, stage_order, model_id, input_payload, output_payload, input_tokens, output_tokens, latency_ms, started_at, completed_at, error_message ) VALUES ${tuples.join(', ')}`, values ); } /** * Phase 2: write the Stage 6 fingerprint to an existing analysis row. */ export async function updateAnalysisFingerprint( analysisId: string, fingerprint: AggregateFingerprint ): Promise { await postgresClient.query( `UPDATE analyzer_analyses SET aggregate_fingerprint = $2::jsonb, fingerprint_generated_at = NOW() WHERE id = $1`, [analysisId, JSON.stringify(fingerprint)] ); } /** * Phase 2: persist a 'failed' analyzer_analyses row when the pipeline throws. * Carries content_hash + source_snapshot so partial-run forensics work, plus * any stage records the pipeline managed to record before throwing. */ export async function insertFailedAnalysis(input: { ticket_number: string; autotask_ticket_id: number; content_hash: string; triggered_by_user_id: string | null; source_snapshot: TaggedEvent[]; filtered_noise_count: number; error_message: string; partial_input_tokens: number; partial_output_tokens: number; partial_cost_usd: number; haiku_used: boolean; sonnet_used: boolean; opus_used: boolean; provider?: 'anthropic' | 'openrouter'; }): Promise<{ id: string; analysis_version: number }> { return await insertAnalysis({ ticket_number: input.ticket_number, autotask_ticket_id: input.autotask_ticket_id, content_hash: input.content_hash, triggered_by_user_id: input.triggered_by_user_id, status: 'failed', haiku_used: input.haiku_used, sonnet_used: input.sonnet_used, opus_used: input.opus_used, total_input_tokens: input.partial_input_tokens, total_output_tokens: input.partial_output_tokens, estimated_cost_usd: input.partial_cost_usd, analysis: null, filtered_noise_count: input.filtered_noise_count, model_traces: {}, source_snapshot: input.source_snapshot, error_message: input.error_message, provider: input.provider, }); } // ============================================================================= // 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; provider: 'anthropic' | 'openrouter'; } | null> { const res = await postgresClient.query<{ id: string; ticket_number: string; queued_by_user_id: string | null; provider: 'anthropic' | 'openrouter'; }>( ` 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, provider ` ); if (res.rowCount === 0) return null; return res.rows[0]; } export async function updateJobStatus( jobId: string, status: JobStatus ): Promise { await postgresClient.query( `UPDATE analyzer_jobs SET status = $1 WHERE id = $2`, [status, jobId] ); } export async function completeJob( jobId: string, resultAnalysisId: string ): Promise { await postgresClient.query( `UPDATE analyzer_jobs SET status = 'complete', result_analysis_id = $1, finished_at = NOW() WHERE id = $2`, [resultAnalysisId, jobId] ); } /** * Reclaim orphaned in-flight jobs whose started_at is older than the threshold. * Called once on worker boot — any job in an active state (fetching/triaging/ * itglue/analyzing/deep_review) that's been "running" longer than the expected * pipeline ceiling is almost certainly orphaned by a container restart and * needs to be re-queued. Returns the number of rows reset. */ export async function resetStaleJobsToQueued( thresholdMinutes = 10 ): Promise { const res = await postgresClient.query( `UPDATE analyzer_jobs SET status = 'queued', started_at = NULL WHERE status IN ('fetching','triaging','itglue','analyzing','deep_review') AND started_at IS NOT NULL AND started_at < NOW() - ($1::int || ' minutes')::interval`, [thresholdMinutes] ); return res.rowCount ?? 0; } export async function failJob(jobId: string, errorMessage: string): Promise { 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; provider?: 'anthropic' | 'openrouter'; } 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, provider) VALUES ($1, $2, $3) RETURNING id::text AS id`, [input.ticket_number, input.queued_by_user_id, input.provider ?? 'anthropic'] ); return { id: res.rows[0].id }; } export async function getJob(jobId: string): Promise { 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; provider: 'anthropic' | 'openrouter'; } 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, provider: r.provider, }; } 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, provider `; export async function getAnalysisById( id: string ): Promise { const res = await postgresClient.query( `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 { // Order chronologically (most recent first) so the latest run shows up at // the top of the history regardless of provider. Two providers maintain // their own monotonic version numbers, so a strict version sort would // interleave them oddly. const res = await postgresClient.query( `SELECT ${ANALYSIS_SELECT} FROM analyzer_analyses WHERE ticket_number = $1 ORDER BY triggered_at DESC, analysis_version DESC`, [ticketNumber] ); return res.rows.map(rowToPersistedAnalysis); } export async function listNeedsReview(opts: { limit?: number; offset?: number; } = {}): Promise { const limit = Math.min(opts.limit ?? 50, 200); const offset = opts.offset ?? 0; const res = await postgresClient.query( `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 };