feat: AI ticket analyzer (phases 1-6)

Multi-stage LLM pipeline that produces structured analyses of Autotask
tickets from local Postgres. Migration 069 + Zod schemas, Stage 0
preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages
1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker
(opt-in autostart), 6 API routes, 3 frontend pages, share-row
persistence (email send deferred to phase 7). 128 vitest tests, tsc
clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md.

Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered
entities so the analyzer's local mirror stays current via scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-04-29 10:59:40 -04:00
parent ea3471d38d
commit 8f8b5ab7be
53 changed files with 9377 additions and 33 deletions

View file

@ -0,0 +1,116 @@
-- AI Ticket Analyzer feature
-- Stores versioned analyses of Autotask tickets, share log, and an on-demand job queue.
-- See docs/wulf-pulse-ticket-analyzer-prompt.md for the feature spec.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- =============================================================================
-- analyzer_analyses
-- One row per completed (or failed) analysis; versioned per ticket_number.
-- =============================================================================
CREATE TABLE IF NOT EXISTS analyzer_analyses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_number TEXT NOT NULL,
autotask_ticket_id BIGINT NOT NULL,
analysis_version INT NOT NULL,
content_hash_at_analysis TEXT NOT NULL,
triggered_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL,
triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','running','complete','failed')),
completed_at TIMESTAMPTZ,
-- Model usage
haiku_used BOOLEAN NOT NULL DEFAULT false,
sonnet_used BOOLEAN NOT NULL DEFAULT false,
opus_used BOOLEAN NOT NULL DEFAULT false,
total_input_tokens INT NOT NULL DEFAULT 0,
total_output_tokens INT NOT NULL DEFAULT 0,
estimated_cost_usd NUMERIC(10,4) NOT NULL DEFAULT 0,
-- Structured output (each LLM stage's parsed JSON)
summary TEXT,
timeline JSONB,
what_was_done JSONB,
what_should_have_been_done JSONB,
gaps JSONB,
next_step TEXT,
next_step_rationale TEXT,
post_resolution_analysis TEXT,
confidence_score NUMERIC(3,2),
needs_human_review BOOLEAN NOT NULL DEFAULT false,
human_review_reasons JSONB,
itglue_docs_referenced JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Debugging / observability
model_traces JSONB,
filtered_noise_count INT NOT NULL DEFAULT 0,
error_message TEXT,
CONSTRAINT analyzer_analyses_version_unique UNIQUE (ticket_number, analysis_version)
);
CREATE INDEX IF NOT EXISTS idx_analyzer_analyses_ticket_version
ON analyzer_analyses (ticket_number, analysis_version DESC);
CREATE INDEX IF NOT EXISTS idx_analyzer_analyses_triggered_at
ON analyzer_analyses (triggered_at DESC);
CREATE INDEX IF NOT EXISTS idx_analyzer_analyses_needs_review
ON analyzer_analyses (needs_human_review)
WHERE needs_human_review = true;
CREATE INDEX IF NOT EXISTS idx_analyzer_analyses_autotask_ticket_id
ON analyzer_analyses (autotask_ticket_id);
COMMENT ON TABLE analyzer_analyses IS 'Versioned AI analyses of Autotask tickets; one row per completed analysis run.';
COMMENT ON COLUMN analyzer_analyses.content_hash_at_analysis IS 'sha256 of canonical-JSON of (tagged_events, ticket_status, ticket_priority, queue) at analysis time. Used for idempotency.';
COMMENT ON COLUMN analyzer_analyses.filtered_noise_count IS 'Count of workflow-rule and notification-email notes stripped during pre-processing.';
COMMENT ON COLUMN analyzer_analyses.itglue_docs_referenced IS 'IDs/names of IT Glue docs cited; doc bodies are NEVER stored here (security).';
-- =============================================================================
-- analyzer_shares
-- Audit log of share-by-email events for any analysis.
-- =============================================================================
CREATE TABLE IF NOT EXISTS analyzer_shares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
analysis_id UUID NOT NULL REFERENCES analyzer_analyses(id) ON DELETE CASCADE,
shared_by_user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
shared_with_email TEXT NOT NULL,
note TEXT,
shared_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
viewed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_analyzer_shares_analysis_id ON analyzer_shares (analysis_id);
CREATE INDEX IF NOT EXISTS idx_analyzer_shares_shared_by ON analyzer_shares (shared_by_user_id);
CREATE INDEX IF NOT EXISTS idx_analyzer_shares_shared_at ON analyzer_shares (shared_at DESC);
COMMENT ON TABLE analyzer_shares IS 'Audit log of analyses shared by email. Recipient domains validated against ALLOWED_SHARE_DOMAINS at write time.';
-- =============================================================================
-- analyzer_jobs
-- On-demand pipeline queue. A worker initialised at server start polls this table.
-- =============================================================================
CREATE TABLE IF NOT EXISTS analyzer_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_number TEXT NOT NULL,
queued_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','fetching','triaging','itglue','analyzing','deep_review','complete','failed')),
result_analysis_id UUID REFERENCES analyzer_analyses(id) ON DELETE SET NULL,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_analyzer_jobs_status_queued
ON analyzer_jobs (status, queued_at)
WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review');
CREATE INDEX IF NOT EXISTS idx_analyzer_jobs_ticket_number ON analyzer_jobs (ticket_number);
CREATE INDEX IF NOT EXISTS idx_analyzer_jobs_queued_at ON analyzer_jobs (queued_at DESC);
COMMENT ON TABLE analyzer_jobs IS 'On-demand analyzer pipeline jobs. Polled by a single worker that initialises at server start.';