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>
This commit is contained in:
lorentz 2026-04-29 14:00:22 -04:00
parent b20c94ea1a
commit bd3401df1c
33 changed files with 7132 additions and 554 deletions

View file

@ -246,6 +246,206 @@ export const AnalyzerJob = z.object({
});
export type AnalyzerJob = z.infer<typeof AnalyzerJob>;
// =============================================================================
// Phase 2 — Stage 6 fingerprint schema (cross-ticket aggregation).
// =============================================================================
export const RootCauseClass = z.enum([
'configuration_drift',
'user_error',
'vendor_issue',
'hardware_failure',
'documentation_gap',
'process_gap',
'unknown',
'other',
]);
export type RootCauseClass = z.infer<typeof RootCauseClass>;
export const ResolutionPath = z.enum([
'resolved_by_wulf',
'resolved_by_vendor',
'resolved_by_client',
'unresolved',
'self_resolved_before_wulf_action',
]);
export type ResolutionPath = z.infer<typeof ResolutionPath>;
export const FingerprintConfidence = z.enum(['low', 'medium', 'high']);
export type FingerprintConfidence = z.infer<typeof FingerprintConfidence>;
export const AggregateFingerprint = z.object({
category: z.string(),
subcategories: z.array(z.string()),
ticket_type_inferred: z.string(),
root_cause_class: RootCauseClass,
client_name: z.string(),
vendors_involved: z.array(z.string()),
applications_involved: z.array(z.string()),
device_classes: z.array(z.string()),
wulf_actions_taken: z.array(z.string()),
vendor_cases_opened: z.number().int().nonnegative(),
resolution_path: ResolutionPath,
documentation_gaps_observed: z
.array(
z.object({
description: z.string(),
confidence: FingerprintConfidence,
})
)
.max(5),
process_gaps_observed: z
.array(
z.object({
description: z.string(),
severity: Severity,
})
)
.max(5),
similar_to_signals: z.array(z.string()),
tags: z.array(z.string()),
generated_by_model: z.string(),
generated_at: z.string().datetime({ offset: true }),
});
export type AggregateFingerprint = z.infer<typeof AggregateFingerprint>;
// =============================================================================
// Phase 2 — Stage execution row (per-stage I/O persistence).
// =============================================================================
export const StageName = z.enum([
'preprocess',
'triage',
'itglue',
'analyze',
'deep_review',
'fingerprint',
]);
export type StageName = z.infer<typeof StageName>;
/**
* In-memory shape used by the pipeline to record per-stage I/O. The pipeline
* pushes one of these into the worker's array via the onStageRecord callback,
* and the worker bulk-inserts them after the analyzer_analyses row exists
* (success OR failure).
*/
export interface StageExecutionRecord {
stage: StageName;
stage_order: number;
model_id: string | null;
input_payload: unknown;
output_payload: unknown;
input_tokens: number | null;
output_tokens: number | null;
latency_ms: number | null;
started_at: Date;
completed_at: Date;
error_message: string | null;
}
export const StageExecution = z.object({
id: z.string().uuid(),
analysisId: z.string().uuid(),
stage: StageName,
stageOrder: z.number().int().positive(),
modelId: z.string().nullable(),
inputPayload: z.unknown(),
outputPayload: z.unknown(),
inputTokens: z.number().int().nullable(),
outputTokens: z.number().int().nullable(),
latencyMs: z.number().int().nullable(),
startedAt: z.string().datetime({ offset: true }),
completedAt: z.string().datetime({ offset: true }),
errorMessage: z.string().nullable(),
});
export type StageExecution = z.infer<typeof StageExecution>;
// =============================================================================
// Phase 2.6 — Aggregate report (reduce step) schemas.
// =============================================================================
export const ITGlueCheck = z.enum([
'no_doc_exists',
'doc_exists_but_unused',
'unable_to_verify',
]);
export type ITGlueCheck = z.infer<typeof ITGlueCheck>;
export const RecommendedActionType = z.enum([
'documentation',
'process',
'training',
'tooling',
]);
export type RecommendedActionType = z.infer<typeof RecommendedActionType>;
export const AggregateReduceResponse = z.object({
documentation_gaps: z.array(
z.object({
gap: z.string(),
frequency: z.number().int().nonnegative(),
example_ticket_numbers: z.array(z.string()),
evidence: z.string(),
itglue_check: ITGlueCheck,
})
),
process_gaps: z.array(
z.object({
gap: z.string(),
frequency: z.number().int().nonnegative(),
severity: Severity,
example_ticket_numbers: z.array(z.string()),
evidence: z.string(),
})
),
client_patterns: z.array(
z.object({
client: z.string(),
pattern: z.string(),
frequency: z.number().int().nonnegative(),
example_ticket_numbers: z.array(z.string()),
})
),
recurrence_clusters: z.array(
z.object({
theme: z.string(),
ticket_numbers: z.array(z.string()),
summary: z.string(),
})
),
systemic_observations: z.array(
z.object({
observation: z.string(),
evidence: z.string(),
severity: Severity,
})
),
recommended_actions: z.array(
z.object({
action: z.string(),
rationale: z.string(),
priority: Severity,
type: RecommendedActionType,
})
),
executive_summary: z.string(),
narrative_summary: z.string(),
});
export type AggregateReduceResponse = z.infer<typeof AggregateReduceResponse>;
export const AggregateReportStatus = z.enum([
'pending',
'running',
'complete',
'failed',
]);
export type AggregateReportStatus = z.infer<typeof AggregateReportStatus>;
// =============================================================================
// API request bodies.
// =============================================================================