wulf-pulse/lib/services/analyzer/pipeline.ts
lorentz bd3401df1c feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards
Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:

2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
    aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
    model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
    or failure. Worker persists a status='failed' analyzer_analyses row when
    the pipeline throws so partial stage records have a parent. Pipeline
    exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
    phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
    New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
    headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
    scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
    legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
    queue/status/priority/assignee, sticky filter bar, active-filter chips,
    bulk selection persisted via localStorage, "Analyze N selected" +
    "Generate aggregate report" actions. New <MultiSelect> primitive.
    Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
    SQL distributions immediately so UI shows partial state during the
    Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
    /:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
    $20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
    override. Every gating decision audited.
2.8 Runbook + build notes updated.

128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:00:22 -04:00

481 lines
16 KiB
TypeScript

/**
* 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> | 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<T extends { usage?: { input_tokens: number; output_tokens: number } }>(
meta: {
stage: StageName;
stage_order: number;
model_id: string | null;
input_payload: unknown;
},
fn: () => Promise<T>,
callbacks: PipelineProgressCallbacks,
outputSelector: (result: T) => unknown
): Promise<T> {
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<PipelineResult> {
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,
};