/** * AI Ticket Analyzer pipeline. * * Stage 0 preprocess (filter + tag + content hash) * ── idempotency short-circuit if force=false * Stage 1 Haiku triage * Stage 2 (conditional) IT Glue retrieval — redacted * Stage 3 Sonnet deep analysis * Stage 4 (conditional) Opus deep reasoning — apply updates * Stage 5 persist * * The pipeline is split from the worker so that: * - tests can drive it directly with injected LLM clients * - API routes (re-analyze on demand) can call it synchronously if desired * - the worker provides only queue semantics around it */ import { type DeepAnalysisResponse, type OpusResponse, type PreprocessedTicket, type StageExecutionRecord, type StageName, type TriageResponse, } from '@/lib/types/analyzer'; import { preprocessTicket, type RawTicketBundle } from './preprocessor'; import { runTriageStage } from './stages/stage1-triage'; import { runDeepAnalysisStage, } from './stages/stage3-deep-analysis'; import { applyOpusUpdates, runDeepReasoningStage, shouldRunDeepReasoning, } from './stages/stage4-deep-reasoning'; import { itglueSearch, type ITGlueSearchResult, type RedactedDoc, } from './itglue-search'; import { findExistingAnalysisByContentHash, } from './persistence'; import type { TokenUsage } from '@/lib/services/llm/pricing'; import type Anthropic from '@anthropic-ai/sdk'; /** * If the running cost would exceed this before the (expensive) Stage 4 call, * we skip Opus and flag the analysis for human review with reason * "cost ceiling reached". */ const COST_CEILING_USD = 2.0; export interface PipelineDeps { /** Inject for tests. Defaults to live IT Glue search. */ itglueSearch?: typeof itglueSearch; /** Inject for tests. Single Anthropic client used for all stages. */ anthropic?: Anthropic; } export interface PipelineInput { bundle: RawTicketBundle; /** When false (default), check for an existing analysis with the same content hash and short-circuit if present. */ force?: boolean; /** Override Stage 4 — useful for tests + cost-conscious operators. */ forceSkipOpus?: boolean; } export interface PipelineRunMeta { haiku_used: boolean; sonnet_used: boolean; opus_used: boolean; total_input_tokens: number; total_output_tokens: number; total_cache_creation_tokens: number; total_cache_read_tokens: number; estimated_cost_usd: number; cost_circuit_breaker_tripped: boolean; } /** Per-stage trace entry — written to analyzer_analyses.model_traces for debugging. */ interface StageTrace { model: string; attempts: 1 | 2; input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; estimated_cost_usd: number; events_dropped?: number; } export interface PipelineSuccess { outcome: 'complete'; analysis: DeepAnalysisResponse; pre: PreprocessedTicket; meta: PipelineRunMeta; filtered_noise_count: number; itglue_search_used: boolean; itglue_org_resolved: boolean; /** Phase 2: the raw triage / sonnet / opus responses fed to Stage 6 fingerprint. */ triage_response: TriageResponse; sonnet_response: DeepAnalysisResponse; opus_response: OpusResponse | null; /** LEGACY: per-stage debug payload — goes into analyzer_analyses.model_traces. */ model_traces: { triage?: StageTrace; deep_analysis?: StageTrace; deep_reasoning?: StageTrace; triage_response?: TriageResponse; sonnet_response?: DeepAnalysisResponse; opus_response?: OpusResponse; itglue?: { org_id: string | null; alias_used: boolean; doc_count: number }; }; } export interface PipelineShortCircuit { outcome: 'idempotent_short_circuit'; existing_analysis_id: string; existing_analysis_version: number; pre: PreprocessedTicket; } export type PipelineResult = PipelineSuccess | PipelineShortCircuit; function addUsage(running: TokenUsage, add: TokenUsage): TokenUsage { return { input_tokens: running.input_tokens + add.input_tokens, output_tokens: running.output_tokens + add.output_tokens, cache_creation_input_tokens: (running.cache_creation_input_tokens ?? 0) + (add.cache_creation_input_tokens ?? 0), cache_read_input_tokens: (running.cache_read_input_tokens ?? 0) + (add.cache_read_input_tokens ?? 0), }; } /** Stage progression callbacks — used by the worker to update analyzer_jobs.status. */ export interface PipelineProgressCallbacks { onStage?: ( stage: | 'fetching' | 'triaging' | 'itglue' | 'analyzing' | 'deep_review' ) => Promise | void; /** * Phase 2 — emitted once per stage attempt, on success OR failure. Worker * collects these into an array; on pipeline failure the array still has * everything that ran. Pipeline pushes the record before re-throwing. */ onStageRecord?: (record: StageExecutionRecord) => void; /** * Phase 2 — emitted right after Stage 0 succeeds. Lets the worker capture * the preprocessed ticket so it can persist a failed analyzer_analyses row * (with source_snapshot + content_hash) when a later stage throws. */ onPreprocessed?: (pre: PreprocessedTicket) => void; } /** Internal helper: run a stage, time it, push a record, propagate errors. */ async function recordedStage( meta: { stage: StageName; stage_order: number; model_id: string | null; input_payload: unknown; }, fn: () => Promise, callbacks: PipelineProgressCallbacks, outputSelector: (result: T) => unknown ): Promise { const startedAt = new Date(); try { const result = await fn(); const completedAt = new Date(); callbacks.onStageRecord?.({ ...meta, output_payload: outputSelector(result), input_tokens: result.usage?.input_tokens ?? null, output_tokens: result.usage?.output_tokens ?? null, latency_ms: completedAt.getTime() - startedAt.getTime(), started_at: startedAt, completed_at: completedAt, error_message: null, }); return result; } catch (err) { const completedAt = new Date(); callbacks.onStageRecord?.({ ...meta, output_payload: {}, input_tokens: null, output_tokens: null, latency_ms: completedAt.getTime() - startedAt.getTime(), started_at: startedAt, completed_at: completedAt, error_message: err instanceof Error ? err.message : String(err), }); throw err; } } export async function runPipeline( input: PipelineInput, deps: PipelineDeps = {}, callbacks: PipelineProgressCallbacks = {} ): Promise { const itglueSearchFn = deps.itglueSearch ?? itglueSearch; const anthropic = deps.anthropic; // ── Stage 0: preprocess ────────────────────────────────────────────────── await callbacks.onStage?.('fetching'); const preStart = new Date(); const pre = preprocessTicket(input.bundle); const preEnd = new Date(); callbacks.onPreprocessed?.(pre); callbacks.onStageRecord?.({ stage: 'preprocess', stage_order: 1, model_id: null, input_payload: { ticket_number: input.bundle.ticket.ticket_number, notes_count: input.bundle.notes?.length ?? 0, time_entries_count: input.bundle.time_entries?.length ?? 0, }, output_payload: { events_count: pre.events.length, counts: pre.counts, content_hash: pre.content_hash, }, input_tokens: null, output_tokens: null, latency_ms: preEnd.getTime() - preStart.getTime(), started_at: preStart, completed_at: preEnd, error_message: null, }); // ── Idempotency: short-circuit if force=false and we have a complete row ─ if (!input.force) { const existing = await findExistingAnalysisByContentHash( pre.header.ticket_number, pre.content_hash ); if (existing) { return { outcome: 'idempotent_short_circuit', existing_analysis_id: existing.id, existing_analysis_version: existing.analysis_version, pre, }; } } // Running cost + usage trackers. let usage: TokenUsage = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }; let estimatedCostUsd = 0; const traces: PipelineSuccess['model_traces'] = {}; // ── Stage 1: Haiku triage ──────────────────────────────────────────────── await callbacks.onStage?.('triaging'); const triageResult = await recordedStage( { stage: 'triage', stage_order: 2, model_id: 'claude-haiku-4-5', input_payload: { ticket_number: pre.header.ticket_number, events_count: pre.events.length, filtered_noise_count: pre.counts.filtered_noise, }, }, () => runTriageStage(pre, anthropic), callbacks, (r) => r.data ); usage = addUsage(usage, triageResult.usage); estimatedCostUsd += triageResult.estimated_cost_usd; traces.triage = { model: 'claude-haiku-4-5', attempts: triageResult.attempts, input_tokens: triageResult.usage.input_tokens, output_tokens: triageResult.usage.output_tokens, cache_creation_input_tokens: triageResult.usage.cache_creation_input_tokens, cache_read_input_tokens: triageResult.usage.cache_read_input_tokens, estimated_cost_usd: triageResult.estimated_cost_usd, events_dropped: triageResult.events_dropped, }; traces.triage_response = triageResult.data; // ── Stage 2 (conditional): IT Glue retrieval ───────────────────────────── let itglueDocs: RedactedDoc[] = []; let itglueResult: ITGlueSearchResult | null = null; if ( triageResult.data.itglue_lookup_needed && pre.header.account_name ) { await callbacks.onStage?.('itglue'); const itglueStart = new Date(); let itglueErr: Error | null = null; try { itglueResult = await itglueSearchFn({ org_name: pre.header.account_name, hints: triageResult.data.itglue_search_hints, }); itglueDocs = itglueResult.docs; } catch (err) { // Tolerate IT Glue failures — analysis continues without context. itglueErr = err instanceof Error ? err : new Error(String(err)); console.warn( `[pipeline] IT Glue search failed for ${pre.header.ticket_number}: ${itglueErr.message}` ); } const itglueEnd = new Date(); callbacks.onStageRecord?.({ stage: 'itglue', stage_order: 3, model_id: null, input_payload: { org_name: pre.header.account_name, hints: triageResult.data.itglue_search_hints, }, // Redacted-only payload (itglue-search applies redact() internally). output_payload: itglueErr ? {} : { org_id: itglueResult?.org_id ?? null, alias_used: itglueResult?.alias_used ?? false, docs: itglueDocs, }, input_tokens: null, output_tokens: null, latency_ms: itglueEnd.getTime() - itglueStart.getTime(), started_at: itglueStart, completed_at: itglueEnd, error_message: itglueErr ? itglueErr.message : null, }); traces.itglue = { org_id: itglueResult?.org_id ?? null, alias_used: itglueResult?.alias_used ?? false, doc_count: itglueDocs.length, }; } // ── Stage 3: Sonnet deep analysis ──────────────────────────────────────── await callbacks.onStage?.('analyzing'); const sonnetResult = await recordedStage( { stage: 'analyze', stage_order: 4, model_id: 'claude-sonnet-4-6', input_payload: { ticket_number: pre.header.ticket_number, events_count: pre.events.length, triage: triageResult.data, itglue_doc_count: itglueDocs.length, }, }, () => runDeepAnalysisStage( { pre, triage: triageResult.data, itglue_docs: itglueDocs }, anthropic ), callbacks, (r) => r.data ); usage = addUsage(usage, sonnetResult.usage); estimatedCostUsd += sonnetResult.estimated_cost_usd; traces.deep_analysis = { model: 'claude-sonnet-4-6', attempts: sonnetResult.attempts, input_tokens: sonnetResult.usage.input_tokens, output_tokens: sonnetResult.usage.output_tokens, cache_creation_input_tokens: sonnetResult.usage.cache_creation_input_tokens, cache_read_input_tokens: sonnetResult.usage.cache_read_input_tokens, estimated_cost_usd: sonnetResult.estimated_cost_usd, events_dropped: sonnetResult.events_dropped, }; traces.sonnet_response = sonnetResult.data; let analysis: DeepAnalysisResponse = sonnetResult.data; let opusResponseForResult: OpusResponse | null = null; let opusUsed = false; let costCircuitBreakerTripped = false; // ── Stage 4 (conditional): Opus deep reasoning ─────────────────────────── const opusTrigger = shouldRunDeepReasoning({ triage: triageResult.data, sonnet: sonnetResult.data, }); if (opusTrigger && !input.forceSkipOpus) { if (estimatedCostUsd >= COST_CEILING_USD) { // Skip Opus; flag for review; record the reason so the UI can surface it. costCircuitBreakerTripped = true; analysis = { ...analysis, needs_human_review: true, human_review_reasons: [ ...analysis.human_review_reasons, `cost ceiling reached ($${estimatedCostUsd.toFixed(4)} ≥ $${COST_CEILING_USD.toFixed(2)} before Opus)`, ], }; } else { await callbacks.onStage?.('deep_review'); const opusResult = await recordedStage( { stage: 'deep_review', stage_order: 5, model_id: 'claude-opus-4-7', input_payload: { ticket_number: pre.header.ticket_number, events_count: pre.events.length, triage: triageResult.data, sonnet_summary: sonnetResult.data.summary, }, }, () => runDeepReasoningStage( { pre, triage: triageResult.data, sonnet: sonnetResult.data }, anthropic ), callbacks, // Per spec: store the FULL Opus response including opus_notes, not // just the merged updates that previously overwrote everything. (r) => r.data ); usage = addUsage(usage, opusResult.usage); estimatedCostUsd += opusResult.estimated_cost_usd; opusUsed = true; traces.deep_reasoning = { model: 'claude-opus-4-7', attempts: opusResult.attempts, input_tokens: opusResult.usage.input_tokens, output_tokens: opusResult.usage.output_tokens, cache_creation_input_tokens: opusResult.usage.cache_creation_input_tokens, cache_read_input_tokens: opusResult.usage.cache_read_input_tokens, estimated_cost_usd: opusResult.estimated_cost_usd, events_dropped: opusResult.events_dropped, }; traces.opus_response = opusResult.data; opusResponseForResult = opusResult.data; analysis = applyOpusUpdates(analysis, opusResult.data.updates); } } return { outcome: 'complete', analysis, pre, filtered_noise_count: pre.counts.filtered_noise, itglue_search_used: itglueResult !== null, itglue_org_resolved: !!itglueResult?.org_id, triage_response: triageResult.data, sonnet_response: sonnetResult.data, opus_response: opusResponseForResult, meta: { haiku_used: true, sonnet_used: true, opus_used: opusUsed, total_input_tokens: usage.input_tokens, total_output_tokens: usage.output_tokens, total_cache_creation_tokens: usage.cache_creation_input_tokens ?? 0, total_cache_read_tokens: usage.cache_read_input_tokens ?? 0, estimated_cost_usd: Math.round(estimatedCostUsd * 10_000) / 10_000, cost_circuit_breaker_tripped: costCircuitBreakerTripped, }, model_traces: traces, }; } export const _PIPELINE_INTERNALS = { COST_CEILING_USD, };