- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison) - Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis - Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison) - Add veeam-analysis-state.ts and rmm-device-resolver.ts services - Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis - Add backup-status page updates and nav links for new Veeam pages - Add scripts: deactivate-cis-for-inactive-companies, workstation category updates - Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt - Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
24 KiB
Feature: AI Ticket Analyzer (wulf-pulse)
Add an on-demand AI-powered ticket analysis feature to the existing wulf-pulse app at forgejo.wulfconsulting.cloud/lorentz/wulf-pulse.
This is a feature addition, not a new project. Conform to existing wulf-pulse conventions: React + Vite + TailwindCSS + shadcn/ui frontend, Node.js + Express backend, Entra ID OIDC auth, the existing PostgreSQL instance (the one that already has Autotask data syncing into it), Forgejo CI, Pangolin reverse proxy. Do not introduce new frameworks. Match the existing folder layout, error handling style, and route conventions.
Before writing any code, read claude.md, the route registration file, the auth middleware, and the data-access layer for tickets so the feature plugs into the existing patterns. If those files don't exist, ask before guessing.
What this feature does
A wulf-pulse user opens a ticket view, clicks Analyze, and the system produces a structured analysis covering:
- A unified chronological timeline (with markers for source type)
- What was actually done vs. what should have been done
- Gaps — including subtle ones like the customer telling us to stop while work continued, status not matching reality, or the original ask never being directly answered
- Recommended next step with rationale
- Post-resolution analysis (if the ticket is resolved)
- Confidence score and human-review flag
- Referenced IT Glue documentation
The analysis is stored versioned by ticket number, can be re-run when new activity arrives, and can be emailed to other Wulf users.
Critical: how Autotask notes/entries actually work
The current generic prompt would mis-handle the real Autotask note structure. Here are the distinctions the analyzer must make:
Note types the analyzer must classify in pre-processing (Stage 0)
-
Workflow rule firings —
Note | Autotask Administratorwith title likeWorkflow Rule "..." fired.These are pure noise. Filter them out entirely before any model call. Tag each asworkflow_noise. -
Service Desk Notification emails —
Note | <tech name>with titleService Desk Notificationand a description that's just a list of email addresses. These are auto-generated email send confirmations. Filter out before model calls. Tag asemail_notification. -
Ticket Notes —
Ticket Note | <person name>. These are real communications, often from the customer or a forwarded email. Keep. Tag ascustomer_communicationorinternal_communicationbased on the person's email domain. -
Time Entry Summary Notes — the
Summary Notesfield of a Time Entry. Customer-visible. Tag astime_entry_summary. Keep. -
Time Entry Internal Notes — the
Internal Notesfield of the same Time Entry. Technician-only. Tag astime_entry_internal. Keep — these are usually the highest-signal entries.
A single Time Entry can have BOTH Summary Notes and Internal Notes — they should appear as two separate timeline events with the same timestamp but different visibility markers, OR as a single event with both fields preserved. Choose the latter for cleaner timelines but always render them visually distinct.
The tagging schema
Every retained event in the unified timeline must have:
{
timestamp: string, // ISO 8601
actor: string, // person name
actor_type: "wulf_tech" | "client_contact" | "vendor" | "system" | "automation",
source: "ticket_create" | "ticket_note" | "time_entry" | "status_change" | "resolution",
visibility: "customer_facing" | "internal_only" | "mixed", // mixed = time entry with both fields
summary_notes?: string, // customer-facing content if present
internal_notes?: string, // internal content if present
hours?: number, // for time entries
}
Render markers in the analysis output as:
- 🟢 customer-facing
- 🔒 internal-only
- 🔄 mixed (both)
Real example to test against
Use this real ticket as a fixture for your tests. The analyzer must catch all four findings listed below — if any are missed, the analysis prompts need refinement.
Ticket T20260424.0045 — "Outmarket AI vendor integration request"
The internal notes reveal:
- The original ask was narrower than the public summary suggests — Lorentz's email said "I'm gonna need access to that for another integration with the claims department for loss run pro please let me know where that credential is in Passportal."
- At 04/24 10:34, Lorentz posted a Ticket Note saying "I was able to access the Vertafore Developer portal and determine what is necessary - no need to reach out to Vertafore. I'll take it from here, thank you!"
- On 04/27 (next business day), the assigned tech took a call from Vertafore anyway and logged ~20 min of additional work after the customer said to stop.
- Status is still "Waiting Customer" three days after the requestor effectively closed the loop.
The analyzer must flag:
- Gap (high): Work continued after the customer indicated they were taking it from here.
- Gap (medium): Status hasn't been updated to reflect the requestor's resolution.
- Gap (low): The original credential-locator ask was never directly answered before the conversation pivoted.
- Next step: Confirm with requestor whether the Vertafore endpoint info is still useful, then close.
Build a test fixture from this ticket (PDF is available, transcribe the structured fields) and assert these findings appear in the analysis output. The fixture lives in apps/api/test/fixtures/tickets/T20260424.0045.json.
Database changes
Add these tables to the wulf-pulse Postgres database. Use the project's existing migration tool. Prefix all new tables with analyzer_ to keep them clearly scoped to this feature.
analyzer_analyses
id uuid pk default gen_random_uuid()
ticket_number text not null
autotask_ticket_id bigint not null
analysis_version int not null -- monotonic per ticket_number
content_hash_at_analysis text not null -- sha256 of source data at analysis time
triggered_by_user_id uuid -- references existing pulse users table
triggered_at timestamptz default now()
status text not null default 'pending' -- pending|running|complete|failed
completed_at timestamptz
-- model usage
haiku_used boolean default false
sonnet_used boolean default false
opus_used boolean default false
total_input_tokens int default 0
total_output_tokens int default 0
estimated_cost_usd numeric(10,4) default 0
-- structured output
summary text
timeline jsonb -- unified, with visibility markers
what_was_done jsonb
what_should_have_been_done jsonb
gaps jsonb -- [{description, severity, evidence_timestamps}]
next_step text
next_step_rationale text
post_resolution_analysis text
confidence_score numeric(3,2)
needs_human_review boolean default false
human_review_reasons jsonb
-- IT Glue
itglue_docs_referenced jsonb default '[]'
-- debugging
model_traces jsonb
filtered_noise_count int default 0 -- how many workflow/notification entries were stripped
error_message text
unique (ticket_number, analysis_version)
Indexes: (ticket_number, analysis_version desc), (triggered_at desc), (needs_human_review) where needs_human_review = true.
analyzer_shares
id uuid pk default gen_random_uuid()
analysis_id uuid not null references analyzer_analyses(id) on delete cascade
shared_by_user_id uuid not null
shared_with_email text not null -- validate against ALLOWED_SHARE_DOMAINS
note text
shared_at timestamptz default now()
viewed_at timestamptz
analyzer_jobs
id uuid pk default gen_random_uuid()
ticket_number text not null
queued_by_user_id uuid
status text not null default 'queued' -- queued|fetching|triaging|itglue|analyzing|deep_review|complete|failed
result_analysis_id uuid references analyzer_analyses(id)
queued_at timestamptz default now()
started_at timestamptz
finished_at timestamptz
error_message text
If wulf-pulse already uses BullMQ or another job queue, plug into it. If not, a simple Postgres-row-based queue with a worker polling every 2 seconds is acceptable for an on-demand-only feature — discuss with me before pulling in a new dependency.
Backend changes
Source-of-truth question (ask me before deciding)
Wulf-pulse already syncs Autotask data to Postgres. Before implementing, look at the existing sync to determine:
- Does the Pulse sync include ticket notes and time entries, or just ticket headers?
- How fresh is the sync? Real-time (webhook), minute-level, or hourly?
- Are Internal Notes synced? (They may be excluded from some syncs for privacy reasons.)
Based on what you find, choose one of:
- (A) Read everything from Pulse Postgres — preferred if notes + internal notes + time entries are all synced and fresh.
- (B) Pull live from Autotask REST at analyze-time — required if the sync is incomplete.
- (C) Hybrid: Pulse for fast list/search, live REST fetch for the full payload at analyze-time — most likely the right answer.
Tell me which one fits before writing the data-access layer.
New routes (mount under existing wulf-pulse API namespace)
POST /api/analyzer/tickets/:ticketNumber/analyze
# body: { force?: boolean }
# returns: { jobId, status, existingAnalysisId? }
GET /api/analyzer/jobs/:jobId # poll status
GET /api/analyzer/analyses/:id # fetch a specific analysis
GET /api/analyzer/tickets/:ticketNumber/analyses
# list versions
GET /api/analyzer/needs-review # filtered queue
POST /api/analyzer/analyses/:id/share
# body: { recipientEmail, note? }
All routes require existing wulf-pulse auth middleware.
IT Glue client
New module apps/api/src/services/itglue/:
client.ts— REST client withx-api-keyauth, retry/backoffredact.ts— strips fields matching/password|secret|key|token|credential|api[_-]?key/i(case-insensitive, recursive) BEFORE any value reaches the LLM or the database. Replace with"[REDACTED]". Include unit tests for nested objects and arrays.search.ts— given a client name and search hints, returns sanitized doc snippets capped at 2000 chars per doc, max 10 docs.
The redaction is a security-critical code path. Add a comment explaining why and link to this prompt section. Do not log full doc bodies anywhere — only IDs and names.
Anthropic SDK setup
Add @anthropic-ai/sdk to apps/api/package.json. Create apps/api/src/services/llm/:
client.ts— singleton SDK instance, readsANTHROPIC_API_KEYfrom envpricing.ts— per-model input/output rates with a comment to verify againsthttps://docs.claude.com/en/docs/about-claude/pricingquarterly. Don't hardcode rates without comments noting the as-of date.models.ts— exports the canonical model IDs:HAIKU = "claude-haiku-4-5"SONNET = "claude-sonnet-4-6"OPUS = "claude-opus-4-7"
Before finalizing those constants, verify each model ID is current and available on the API. If you find newer versions or the IDs are wrong, ask me before substituting.
The analysis pipeline
Implemented as apps/api/src/services/analyzer/pipeline.ts. Each stage is a separate function for testability.
Stage 0 — Fetch & Pre-process
- Resolve ticket via the data-access strategy chosen above.
- Filter noise: strip
Note | Autotask Administratorworkflow firings andService Desk Notificationnotes. Count them and store infiltered_noise_countfor transparency. - Tag remaining events per the schema in the "How Autotask notes actually work" section.
- Sort chronologically.
- Compute
content_hash = sha256(canonical_json({tagged_events, ticket_status, ticket_priority, queue})). - Idempotency check: if
force=falseand a complete analysis exists with the same hash, short-circuit.
Stage 1 — Triage (Haiku)
Model: claude-haiku-4-5
System prompt:
You are a ticket triage assistant for Wulf Consulting, an MSP. You will receive
an Autotask ticket with notes and time entries that have already been pre-filtered
to remove workflow noise and tagged by visibility (customer-facing vs internal).
Extract structured metadata and assess complexity. Pay special attention to
internal-only notes — these often contain the real story.
Respond ONLY with JSON:
{
"ticket_type": "incident" | "service_request" | "problem" | "change" | "other",
"category": string,
"entities": {
"client_name": string | null,
"site_name": string | null,
"devices": string[],
"users": string[],
"applications": string[],
"vendors": string[] // third parties involved (Vertafore, etc.)
},
"is_resolved": boolean,
"status_matches_reality": boolean, // does the Autotask status reflect the actual state?
"complexity_tier": "low" | "medium" | "high",
"complexity_reasons": string[],
"itglue_lookup_needed": boolean,
"itglue_search_hints": string[]
}
Complexity rubric:
- low: single straightforward issue, ≤3 retained events, clear path
- medium: multiple events, some back-and-forth, moderate ambiguity
- high: any of — bounced between techs, conflicting notes, unresolved >5 days,
customer-vs-internal narrative mismatch, multiple vendors involved,
or status appears to disagree with the actual state of the work
User message: a structured payload containing:
- Ticket header fields (title, status, priority, queue, account, contact, dates)
- Tagged event list (filtered + tagged from Stage 0)
- Counts: total events, internal-only count, customer-facing count
Cap total payload at ~50KB. If larger, truncate oldest internal-only events first (preserving all customer-facing communications), and add a marker.
Stage 2 — IT Glue Retrieval (conditional)
Run if itglue_lookup_needed === true.
- Resolve client name → IT Glue org ID. Maintain a small JSON alias map at
apps/api/src/services/itglue/aliases.jsonfor known fuzzy mappings (e.g. "Seubert" / "Seubert and Associates" / "S&A" → org id). Document this file in the README. - For each search hint, query configurations, flexible_assets, and documents endpoints.
- Dedupe, cap at 10 docs total.
- Run each result through
redact.tsBEFORE adding to context. - Cap each doc body at 2000 chars in the LLM context.
Stage 3 — Deep Analysis (Sonnet)
Model: claude-sonnet-4-6
System prompt (verbatim):
You are a senior MSP technician at Wulf Consulting reviewing a ticket. You will
receive:
1. The full ticket with all retained notes and time entries (already filtered for
workflow noise; tagged with visibility markers — customer_facing, internal_only,
or mixed)
2. Triage metadata from a previous pass
3. Optionally, sanitized IT Glue documentation snippets for the client
Be specific and reference events by their timestamp and actor. Do not invent facts.
If something is unclear, say so explicitly.
Pay particular attention to these patterns, which are common failure modes:
- The customer indicates they have resolved the issue or want to take it over,
but work continues afterward
- The Autotask status does not match the actual state (e.g. "Waiting Customer"
when the customer has already responded, or "In Progress" with no recent activity)
- The original ask in the requester's first message is different from what the
ticket pivoted to addressing
- Internal notes contradict or add important context missing from customer-facing
summary notes
- A vendor case was opened but the customer's direct ask could have been answered
without it
- Time was billed for work the customer didn't ultimately need
Respond ONLY with JSON:
{
"summary": string, // 2-4 sentences, neutral tone
"timeline": [
{
"timestamp": string, // ISO 8601
"actor": string,
"actor_type": "wulf_tech" | "client_contact" | "vendor" | "system" | "automation",
"source": "ticket_create" | "ticket_note" | "time_entry" | "status_change" | "resolution",
"visibility": "customer_facing" | "internal_only" | "mixed",
"action": string // what happened, in plain language
}
],
"what_was_done": string[], // concrete actions in order
"what_should_have_been_done": string[], // ideal actions per MSP best practice
// and any IT Glue docs provided
"gaps": [
{
"description": string,
"severity": "low" | "medium" | "high",
"evidence_timestamps": string[] // which timeline events support this
}
],
"next_step": string, // single concrete next action
"next_step_rationale": string,
"post_resolution_analysis": string | null, // only if is_resolved=true
"confidence_score": number, // 0.0–1.0
"needs_human_review": boolean,
"human_review_reasons": string[],
"ambiguities_for_opus": string[], // questions a deeper-reasoning model should resolve
"itglue_docs_referenced": [
{
"id": string,
"name": string,
"url": string,
"doc_type": string,
"relevance_reason": string
}
]
}
Set needs_human_review=true if any of:
- confidence_score < 0.6
- gaps contain any "high" severity item
- ticket open >7 days with no clear resolution path
- conflicting information between notes
- billed hours appear excessive for the work performed
Stage 4 — Deep Reasoning (Opus, conditional)
Model: claude-opus-4-7
Trigger conditions (any one):
complexity_tier === "high"from Stage 1ambiguities_for_opus.length > 0from Stage 3confidence_score < 0.5from Stage 3- Stage 1's
status_matches_reality === false
System prompt:
You are a principal-level MSP engineer doing a final review of a complex ticket.
You will be given:
1. The full tagged ticket
2. The Sonnet-tier analysis
3. A list of specific ambiguities or open questions
Address each ambiguity directly with reasoning. Then produce updates ONLY for
fields that should change.
Respond ONLY with JSON:
{
"opus_notes": string, // your reasoning, 1–3 paragraphs
"updates": {
// any subset of: next_step, next_step_rationale, gaps,
// confidence_score, needs_human_review, human_review_reasons,
// post_resolution_analysis
}
}
Stage 5 — Persistence
- Compute next
analysis_versionfor thisticket_number. - Insert into
analyzer_analyses. - Sum tokens, compute estimated cost.
- Update
analyzer_jobsrow.
Frontend changes
Routes (add to existing wulf-pulse router)
/analyzer/ticket/:ticketNumber— ticket detail with Analyze button + history of prior analyses/analyzer/analysis/:id— full analysis view, printable, with timeline visualization/analyzer/queue— needs-human-review queue
Analyze button behavior
- Click → POST to analyze endpoint.
- If
existingAnalysisIdreturned and content unchanged, navigate straight to it. - Otherwise show progress UI polling job status every 2s with stage labels: "Fetching..." → "Triaging..." → "Searching IT Glue..." → "Analyzing..." → "Deep review..." → "Done".
- On completion, navigate to the analysis view.
Analysis view layout
Use shadcn/ui components. Sections in order:
- Header — ticket number + title + Autotask deep link, model tier badges (Haiku/Sonnet/Opus pills), confidence score, total cost, "Share" button
- Summary — paragraph
- Next Step — highlighted card with rationale collapsed by default
- Timeline — vertical timeline with the three visibility markers (🟢 / 🔒 / 🔄), each event clickable to expand full notes
- What Was Done — bulleted list
- What Should Have Been Done — bulleted list, side-by-side with #5 on wide screens
- Gaps — cards colored by severity, with "Evidence:" linking back to timeline events
- Post-Resolution Analysis — only if present
- Human Review Flags — only if
needs_human_review = true - IT Glue References — list of docs with external links
Re-analyze indicator
On the ticket view, if cached content_hash differs from the latest analysis's content_hash_at_analysis, show a banner: "New activity since last analysis · Re-analyze".
Share modal
- Recipient email field (autocomplete from existing wulf-pulse user list if available)
- Validate domain against
ALLOWED_SHARE_DOMAINSenv var - Optional note
- Sends via M365 Graph using existing wulf-pulse mail integration if one exists; otherwise use a new module under
apps/api/src/services/mail/and ask before adding new credentials
Environment variables (additions)
# IT Glue
ITGLUE_API_KEY=
ITGLUE_API_BASE=https://api.itglue.com
# Anthropic
ANTHROPIC_API_KEY=
# Sharing
ALLOWED_SHARE_DOMAINS=wulfconsulting.com
Add to ~/projects_env/wulf-pulse.env. Do not commit example values.
Testing
Required tests (use whatever wulf-pulse already uses for testing):
- IT Glue redaction — nested objects, arrays of objects, mixed-case field names. Must redact and never allow a password to reach the database.
- Note pre-processor — given a fixture with all five note types, asserts workflow firings and notification emails are filtered, and remaining events are correctly tagged.
- Pipeline against the T20260424.0045 fixture — asserts all four required findings appear in the analysis output. This is a regression test for the prompts, not just code.
- Schema validation — every LLM response is parsed through Zod. Test with deliberately malformed responses to confirm graceful retry then failure.
- Idempotency — same content_hash with
force=falsereturns the existing analysis without invoking the LLM.
Critical correctness notes
- Never store IT Glue secrets/passwords in any database row, log line, or LLM context. Redact before everything.
- Never log full IT Glue document content. Only doc IDs and names.
- Validate every LLM JSON response with Zod. On parse failure, retry once with the prior response and the parse error. After two failures, mark the job failed and store the raw response in
error_message. - Token budget guard: if any single model call would exceed 100k input tokens, truncate oldest internal-only events first while preserving all customer-facing communications, and add a marker. Log a warning.
- Cost circuit breaker: if estimated total cost would exceed $2.00 before the Opus call, skip Opus, set
needs_human_review = true, and add reason "cost ceiling reached". - Idempotency: the analyze endpoint must be idempotent on
(ticket_number, content_hash)whenforce=false. - Verify model IDs and pricing against
https://docs.claude.combefore finalizing constants. Models and rates change. - Ticket Notes from
wulfconsulting.comaddresses are internal communications, not customer communications. Tag them by domain, not by author.
Delivery order
Build and ship in this order, asking me to review between each phase:
- Database migrations + Zod schemas in
packages/shared(or wulf-pulse equivalent). - Note pre-processor with unit tests against the T20260424.0045 fixture (PDF → JSON transcription is the first deliverable).
- IT Glue client + redaction (security-critical, must land before LLM integration).
- Anthropic SDK setup + per-stage prompt files.
- Full pipeline + job worker.
- API routes.
- Frontend pages.
- Share-via-email integration.
- README updates with operator runbook (how to monitor cost, how to add IT Glue org aliases, how to triage failed analyses).
For phase 1 specifically: confirm the data-access strategy (read from Pulse Postgres / live Autotask REST / hybrid) before writing the migration, since the schema for tickets_cache may or may not be needed depending on which path we take.