- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
495 lines
16 KiB
TypeScript
495 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 Provider,
|
|
stageModelsFor,
|
|
} from '@/lib/services/llm/models';
|
|
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;
|
|
/** LLM provider for this run. Defaults to 'anthropic' for back-compat. */
|
|
provider?: Provider;
|
|
}
|
|
|
|
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;
|
|
const provider: Provider = input.provider ?? 'anthropic';
|
|
const stageModels = stageModelsFor(provider);
|
|
|
|
// ── 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,
|
|
provider
|
|
);
|
|
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: triage ──────────────────────────────────────────────────────
|
|
await callbacks.onStage?.('triaging');
|
|
const triageModel = stageModels.triage;
|
|
const triageResult = await recordedStage(
|
|
{
|
|
stage: 'triage',
|
|
stage_order: 2,
|
|
model_id: triageModel,
|
|
input_payload: {
|
|
ticket_number: pre.header.ticket_number,
|
|
events_count: pre.events.length,
|
|
filtered_noise_count: pre.counts.filtered_noise,
|
|
},
|
|
},
|
|
() => runTriageStage(pre, anthropic, triageModel),
|
|
callbacks,
|
|
(r) => r.data
|
|
);
|
|
usage = addUsage(usage, triageResult.usage);
|
|
estimatedCostUsd += triageResult.estimated_cost_usd;
|
|
traces.triage = {
|
|
model: triageModel,
|
|
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: deep analysis ───────────────────────────────────────────────
|
|
await callbacks.onStage?.('analyzing');
|
|
const deepAnalysisModel = stageModels.deep_analysis;
|
|
const sonnetResult = await recordedStage(
|
|
{
|
|
stage: 'analyze',
|
|
stage_order: 4,
|
|
model_id: deepAnalysisModel,
|
|
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,
|
|
deepAnalysisModel
|
|
),
|
|
callbacks,
|
|
(r) => r.data
|
|
);
|
|
usage = addUsage(usage, sonnetResult.usage);
|
|
estimatedCostUsd += sonnetResult.estimated_cost_usd;
|
|
traces.deep_analysis = {
|
|
model: deepAnalysisModel,
|
|
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 deepReasoningModel = stageModels.deep_reasoning;
|
|
const opusResult = await recordedStage(
|
|
{
|
|
stage: 'deep_review',
|
|
stage_order: 5,
|
|
model_id: deepReasoningModel,
|
|
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,
|
|
deepReasoningModel
|
|
),
|
|
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: deepReasoningModel,
|
|
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,
|
|
};
|