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

@ -96,8 +96,10 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
## Build / run / verify
- Dev: `npm run dev` → http://localhost:3100
- Build: `npm run build` (turbopack via Next 16)
- Type check: `npx tsc --noEmit --pretty`**this is the only automated check**;
there are no unit/integration tests and no CI.
- Type check: `npx tsc --noEmit --pretty`
- Tests: `npm test` (vitest) — currently scoped to `lib/services/analyzer/**` only.
No CI yet; tests are local-only. Other parts of the codebase have no tests —
if you touch them, type-check is the only safety net.
- Docker: `docker compose up` from repo root. Postgres applies `migrations/*.sql`
on init only (existing volumes won't re-run them).

View file

@ -0,0 +1,56 @@
'use client';
import { useEffect, useState, use } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { AnalysisView } from '@/components/analyzer/analysis-view';
import type { PersistedAnalysis } from '@/lib/types/analyzer';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
export default function AnalysisDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [analysis, setAnalysis] = useState<PersistedAnalysis | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/analyzer/analyses/${id}`);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const { analysis } = (await res.json()) as { analysis: PersistedAnalysis };
if (!cancelled) setAnalysis(analysis);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
}
})();
return () => {
cancelled = true;
};
}, [id]);
return (
<div className="container mx-auto px-6 py-6 max-w-5xl">
{error && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load this analysis</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{!error && !analysis && (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{analysis && <AnalysisView analysis={analysis} />}
</div>
);
}

127
app/analyzer/queue/page.tsx Normal file
View file

@ -0,0 +1,127 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertTriangle } from 'lucide-react';
import type { PersistedAnalysis } from '@/lib/types/analyzer';
export default function AnalyzerQueuePage() {
const [analyses, setAnalyses] = useState<PersistedAnalysis[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/analyzer/needs-review?limit=100`);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const { analyses } = (await res.json()) as { analyses: PersistedAnalysis[] };
if (!cancelled) setAnalyses(analyses);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-destructive" />
Needs human review
</h1>
<p className="text-sm text-muted-foreground mt-1">
AI analyses that flagged themselves for human review high-severity
gaps, low confidence, conflicting evidence, or cost-ceiling skips.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load review queue</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">
Queue
{analyses && (
<Badge variant="secondary" className="ml-2">
{analyses.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{analyses === null && !error ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : analyses && analyses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing flagged. 🎉
</p>
) : (
<ul className="divide-y">
{(analyses ?? []).map((a) => (
<li key={a.id} className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<Link
href={`/analyzer/analysis/${a.id}`}
className="font-medium hover:underline"
>
{a.ticketNumber} · v{a.analysisVersion}
</Link>
{a.summary && (
<p className="text-sm text-muted-foreground line-clamp-2">
{a.summary}
</p>
)}
{(a.humanReviewReasons ?? []).length > 0 && (
<ul className="text-xs text-muted-foreground list-disc pl-4 mt-1">
{(a.humanReviewReasons ?? []).slice(0, 3).map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground mt-1">
{new Date(a.triggeredAt).toLocaleString()}
</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{a.confidenceScore !== null && (
<Badge variant="outline">
{Math.round(a.confidenceScore * 100)}%
</Badge>
)}
{(a.gaps ?? []).some((g) => g.severity === 'high') && (
<Badge variant="destructive" className="text-xs">
high gap
</Badge>
)}
</div>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,130 @@
'use client';
import { useEffect, useState, use } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AnalyzeButton } from '@/components/analyzer/analyze-button';
import { Sparkles } from 'lucide-react';
import type { PersistedAnalysis } from '@/lib/types/analyzer';
export default function TicketAnalyzerPage({
params,
}: {
params: Promise<{ ticketNumber: string }>;
}) {
const { ticketNumber } = use(params);
const [analyses, setAnalyses] = useState<PersistedAnalysis[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyses`
);
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const { analyses } = (await res.json()) as { analyses: PersistedAnalysis[] };
if (!cancelled) setAnalyses(analyses);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
}
})();
return () => {
cancelled = true;
};
}, [ticketNumber]);
const latest = analyses?.[0];
return (
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ticket</p>
<CardTitle className="font-mono">{ticketNumber}</CardTitle>
</div>
<AnalyzeButton ticketNumber={ticketNumber} />
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Click <strong>Analyze</strong> to run the AI pipeline. If a current
analysis already exists, you&rsquo;ll be navigated straight to it.
Otherwise the run takes ~1060 seconds.
</p>
</CardContent>
</Card>
{error && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load analysis history</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Analysis history</CardTitle>
</CardHeader>
<CardContent>
{analyses === null && !error ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : analyses && analyses.length === 0 ? (
<p className="text-sm text-muted-foreground">
No analyses yet. Run one above.
</p>
) : (
<ul className="divide-y">
{(analyses ?? []).map((a) => (
<li key={a.id} className="py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<Link
href={`/analyzer/analysis/${a.id}`}
className="font-medium hover:underline flex items-center gap-2"
>
<Sparkles className="w-4 h-4" />
Version {a.analysisVersion}
{latest?.id === a.id && (
<Badge variant="secondary" className="text-xs">
latest
</Badge>
)}
{a.needsHumanReview && (
<Badge variant="destructive" className="text-xs">
Needs review
</Badge>
)}
</Link>
<p className="text-xs text-muted-foreground mt-1">
{new Date(a.triggeredAt).toLocaleString()}
{' · '}
{a.opusUsed ? 'Haiku → Sonnet → Opus' : a.sonnetUsed ? 'Haiku → Sonnet' : 'Haiku'}
{' · '}${a.estimatedCostUsd.toFixed(4)}
</p>
</div>
{a.confidenceScore !== null && (
<Badge variant="outline">
{Math.round(a.confidenceScore * 100)}%
</Badge>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,25 @@
/**
* GET /api/analyzer/analyses/:id
*
* Fetch a specific analysis by id, including the full timeline / gaps /
* IT Glue references. Returns 404 if not found.
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { getAnalysisById } from '@/lib/services/analyzer/persistence';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { error } = await requireAuth();
if (error) return error;
const { id } = await params;
const analysis = await getAnalysisById(id);
if (!analysis) {
return NextResponse.json({ error: 'Analysis not found' }, { status: 404 });
}
return NextResponse.json({ analysis });
}

View file

@ -0,0 +1,91 @@
/**
* POST /api/analyzer/analyses/:id/share
*
* Body: { recipientEmail: string, note?: string }
*
* Records a share row in analyzer_shares. Email send is deferred to phase 8
* this endpoint validates the recipient domain against ALLOWED_SHARE_DOMAINS
* and persists the audit row. The share is "pending delivery" until phase 8
* wires up nodemailer.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { ShareAnalysisRequest } from '@/lib/types/analyzer';
import {
createShare,
getAnalysisById,
} from '@/lib/services/analyzer/persistence';
function getAllowedDomains(): string[] {
const raw = process.env.ALLOWED_SHARE_DOMAINS ?? '';
return raw
.split(',')
.map((d) => d.trim().toLowerCase())
.filter((d) => d.length > 0);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { session, error } = await requireAuth();
if (error) return error;
const { id } = await params;
const body = await request.json().catch(() => ({}));
const result = ShareAnalysisRequest.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid request body', details: result.error.issues },
{ status: 400 }
);
}
const { recipientEmail, note } = result.data;
const allowedDomains = getAllowedDomains();
if (allowedDomains.length === 0) {
return NextResponse.json(
{
error: 'Sharing is not configured',
message: 'ALLOWED_SHARE_DOMAINS env var is empty.',
},
{ status: 503 }
);
}
const domain = recipientEmail.split('@')[1]?.toLowerCase();
if (!domain || !allowedDomains.includes(domain)) {
return NextResponse.json(
{
error: 'Recipient domain is not allowed',
message: `Allowed domains: ${allowedDomains.join(', ')}`,
},
{ status: 403 }
);
}
// Confirm the analysis exists.
const analysis = await getAnalysisById(id);
if (!analysis) {
return NextResponse.json({ error: 'Analysis not found' }, { status: 404 });
}
const userId = (session?.user as { id: string }).id;
const share = await createShare({
analysis_id: id,
shared_by_user_id: userId,
shared_with_email: recipientEmail,
note,
});
return NextResponse.json({
share: {
id: share.id,
analysisId: id,
sharedWithEmail: recipientEmail,
sharedAt: share.shared_at,
note: note ?? null,
},
});
}

View file

@ -0,0 +1,25 @@
/**
* GET /api/analyzer/jobs/:jobId
*
* Returns the current status of an analyzer job. Frontend polls this every
* ~2 seconds during a run.
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { getJob } from '@/lib/services/analyzer/persistence';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ jobId: string }> }
) {
const { error } = await requireAuth();
if (error) return error;
const { jobId } = await params;
const job = await getJob(jobId);
if (!job) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
}
return NextResponse.json({ job });
}

View file

@ -0,0 +1,25 @@
/**
* GET /api/analyzer/needs-review
*
* Returns the queue of completed analyses where needs_human_review = true.
* Optional query params: limit (default 50, max 200), offset (default 0).
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { listNeedsReview } from '@/lib/services/analyzer/persistence';
export async function GET(request: NextRequest) {
const { error } = await requireAuth();
if (error) return error;
const params = request.nextUrl.searchParams;
const limit = parseInt(params.get('limit') ?? '50', 10);
const offset = parseInt(params.get('offset') ?? '0', 10);
const analyses = await listNeedsReview({
limit: Number.isFinite(limit) ? limit : 50,
offset: Number.isFinite(offset) ? offset : 0,
});
return NextResponse.json({ analyses });
}

View file

@ -0,0 +1,21 @@
/**
* GET /api/analyzer/tickets/:ticketNumber/analyses
*
* Lists all analysis versions for a ticket, newest first.
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { listAnalysesByTicketNumber } from '@/lib/services/analyzer/persistence';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ ticketNumber: string }> }
) {
const { error } = await requireAuth();
if (error) return error;
const { ticketNumber } = await params;
const analyses = await listAnalysesByTicketNumber(ticketNumber);
return NextResponse.json({ analyses });
}

View file

@ -0,0 +1,101 @@
/**
* POST /api/analyzer/tickets/:ticketNumber/analyze
*
* Body: { force?: boolean }
* Response on existing match (force=false):
* { status: "complete", existingAnalysisId, existingAnalysisVersion }
* Response when queued:
* { status: "queued", jobId }
*
* The worker (lib/services/analyzer/worker.ts) picks up queued jobs and runs
* the full pipeline. Frontend should poll GET /api/analyzer/jobs/:jobId.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { AnalyzeTicketRequest } from '@/lib/types/analyzer';
import {
loadTicketBundle,
TicketNotFoundError,
} from '@/lib/services/analyzer/data-access';
import { preprocessTicket } from '@/lib/services/analyzer/preprocessor';
import {
findExistingAnalysisByContentHash,
queueJob,
} from '@/lib/services/analyzer/persistence';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ ticketNumber: string }> }
) {
const { session, error } = await requireAuth();
if (error) return error;
const { ticketNumber } = await params;
let parsedBody: { force?: boolean };
try {
const body = await request.json().catch(() => ({}));
const result = AnalyzeTicketRequest.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid request body', details: result.error.issues },
{ status: 400 }
);
}
parsedBody = result.data;
} catch {
parsedBody = {};
}
// Verify the ticket exists and load its data for the idempotency check.
let bundle;
try {
bundle = await loadTicketBundle(ticketNumber);
} catch (err) {
if (err instanceof TicketNotFoundError) {
return NextResponse.json(
{ error: `Ticket ${ticketNumber} not found in local mirror` },
{ status: 404 }
);
}
console.error('[analyze] data-access error:', err);
return NextResponse.json(
{
error: 'Failed to load ticket',
message: err instanceof Error ? err.message : 'unknown',
},
{ status: 500 }
);
}
// Idempotency short-circuit: when force=false, return the existing analysis
// without queueing a job if the source data hasn't changed since.
if (!parsedBody.force) {
const pre = preprocessTicket(bundle);
const existing = await findExistingAnalysisByContentHash(
ticketNumber,
pre.content_hash
);
if (existing) {
return NextResponse.json({
status: 'complete',
existingAnalysisId: existing.id,
existingAnalysisVersion: existing.analysis_version,
});
}
}
// Queue a new job. The worker self-init at server-start (production) will
// pick it up; in dev set ANALYZER_WORKER_AUTOSTART=1.
const userId = (session?.user as { id: string }).id;
const job = await queueJob({
ticket_number: ticketNumber,
queued_by_user_id: userId,
});
return NextResponse.json({
status: 'queued',
jobId: job.id,
});
}

View file

@ -0,0 +1,399 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Separator } from '@/components/ui/separator';
import { ChevronRight, ChevronDown, ExternalLink, AlertTriangle } from 'lucide-react';
import { ShareModal } from './share-modal';
import { AnalyzeButton } from './analyze-button';
import type { PersistedAnalysis, Visibility } from '@/lib/types/analyzer';
interface AnalysisViewProps {
analysis: PersistedAnalysis;
}
const VISIBILITY_MARKER: Record<Visibility, string> = {
customer_facing: '🟢',
internal_only: '🔒',
mixed: '🔄',
};
const VISIBILITY_LABEL: Record<Visibility, string> = {
customer_facing: 'Customer-facing',
internal_only: 'Internal only',
mixed: 'Mixed (both)',
};
const SEVERITY_TONE: Record<string, string> = {
high: 'border-red-500 bg-red-500/5',
medium: 'border-amber-500 bg-amber-500/5',
low: 'border-blue-500 bg-blue-500/5',
};
function ConfidenceBadge({ score }: { score: number | null }) {
if (score === null) return null;
const pct = Math.round(score * 100);
const tone =
score >= 0.8 ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
: score >= 0.6 ? 'bg-amber-500/10 text-amber-700 dark:text-amber-400'
: 'bg-red-500/10 text-red-700 dark:text-red-400';
return (
<Badge className={tone} variant="outline">
Confidence {pct}%
</Badge>
);
}
function ModelBadges({ a }: { a: PersistedAnalysis }) {
return (
<div className="flex gap-1 flex-wrap">
{a.haikuUsed && <Badge variant="secondary">Haiku</Badge>}
{a.sonnetUsed && <Badge variant="secondary">Sonnet</Badge>}
{a.opusUsed && <Badge variant="secondary">Opus</Badge>}
</div>
);
}
export function AnalysisView({ analysis: a }: AnalysisViewProps) {
const [expandedEvent, setExpandedEvent] = useState<number | null>(null);
const [nextStepOpen, setNextStepOpen] = useState(false);
const timelineByTimestamp = useMemo(() => {
const map = new Map<string, number>();
(a.timeline ?? []).forEach((event, idx) => {
if (!map.has(event.timestamp)) map.set(event.timestamp, idx);
});
return map;
}, [a.timeline]);
function jumpToEvent(timestamp: string) {
const idx = timelineByTimestamp.get(timestamp);
if (idx === undefined) return;
setExpandedEvent(idx);
document
.getElementById(`timeline-event-${idx}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return (
<div className="space-y-6">
{/* 1. Header */}
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/analyzer/ticket/${encodeURIComponent(a.ticketNumber)}`}
className="font-mono text-sm hover:underline"
>
{a.ticketNumber}
</Link>
<span className="text-muted-foreground text-sm">
v{a.analysisVersion}
</span>
{a.needsHumanReview && (
<Badge variant="destructive">Needs review</Badge>
)}
</div>
<CardTitle className="text-xl">
AI Analysis &middot; {new Date(a.triggeredAt).toLocaleString()}
</CardTitle>
<p className="text-muted-foreground text-sm">
{a.totalInputTokens.toLocaleString()} in /{' '}
{a.totalOutputTokens.toLocaleString()} out tokens · cost{' '}
{`$${a.estimatedCostUsd.toFixed(4)}`}
</p>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex gap-2 items-center">
<ModelBadges a={a} />
<ConfidenceBadge score={a.confidenceScore} />
</div>
<div className="flex gap-2">
<ShareModal analysisId={a.id} />
<AnalyzeButton
ticketNumber={a.ticketNumber}
force
variant="outline"
/>
</div>
</div>
</div>
</CardHeader>
</Card>
{/* 2. Summary */}
{a.summary && (
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{a.summary}
</p>
</CardContent>
</Card>
)}
{/* 3. Next step */}
{a.nextStep && (
<Card className="border-primary/40">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Next step
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm font-medium">{a.nextStep}</p>
{a.nextStepRationale && (
<Collapsible open={nextStepOpen} onOpenChange={setNextStepOpen} className="mt-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-3">
{nextStepOpen ? (
<ChevronDown className="w-4 h-4 mr-1" />
) : (
<ChevronRight className="w-4 h-4 mr-1" />
)}
Rationale
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="text-sm text-muted-foreground mt-2">
{a.nextStepRationale}
</p>
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</Card>
)}
{/* 4. Timeline */}
{a.timeline && a.timeline.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Timeline</CardTitle>
</CardHeader>
<CardContent>
<ol className="space-y-3 border-l-2 border-muted ml-2">
{a.timeline.map((event, idx) => (
<li
key={idx}
id={`timeline-event-${idx}`}
className="pl-4 relative -ml-px"
>
<span className="absolute -left-2 top-1.5 w-3 h-3 rounded-full bg-background border-2 border-muted-foreground" />
<button
type="button"
onClick={() =>
setExpandedEvent(expandedEvent === idx ? null : idx)
}
className="text-left w-full hover:bg-accent/50 -mx-2 px-2 py-1 rounded"
>
<div className="flex items-start gap-2 flex-wrap text-sm">
<span title={VISIBILITY_LABEL[event.visibility]}>
{VISIBILITY_MARKER[event.visibility]}
</span>
<span className="font-mono text-xs text-muted-foreground shrink-0">
{new Date(event.timestamp).toLocaleString()}
</span>
<span className="font-medium">{event.actor}</span>
<Badge variant="outline" className="text-xs">
{event.actor_type}
</Badge>
<Badge variant="outline" className="text-xs">
{event.source}
</Badge>
</div>
<p className="text-sm mt-1">{event.action}</p>
</button>
</li>
))}
</ol>
</CardContent>
</Card>
)}
{/* 5+6. What was done / should have been done — side by side on wide */}
{((a.whatWasDone?.length ?? 0) > 0 ||
(a.whatShouldHaveBeenDone?.length ?? 0) > 0) && (
<div className="grid lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-base">What was done</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm space-y-2 list-disc pl-5">
{(a.whatWasDone ?? []).map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
What should have been done
</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm space-y-2 list-disc pl-5">
{(a.whatShouldHaveBeenDone ?? []).map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* 7. Gaps */}
{a.gaps && a.gaps.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Gaps</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{a.gaps.map((gap, i) => (
<div
key={i}
className={`rounded-lg border-l-4 p-3 ${SEVERITY_TONE[gap.severity] ?? ''}`}
>
<div className="flex items-center gap-2">
<Badge
variant={
gap.severity === 'high'
? 'destructive'
: gap.severity === 'medium'
? 'default'
: 'secondary'
}
>
{gap.severity}
</Badge>
<p className="text-sm font-medium">{gap.description}</p>
</div>
{gap.evidence_timestamps.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground">
Evidence:{' '}
{gap.evidence_timestamps.map((ts, j) => (
<button
key={j}
type="button"
onClick={() => jumpToEvent(ts)}
className="underline mr-2 font-mono"
>
{new Date(ts).toLocaleString()}
</button>
))}
</div>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* 8. Post-resolution analysis */}
{a.postResolutionAnalysis && (
<Card>
<CardHeader>
<CardTitle className="text-base">Post-resolution analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{a.postResolutionAnalysis}
</p>
</CardContent>
</Card>
)}
{/* 9. Human review flags */}
{a.needsHumanReview && (
<Card className="border-destructive/50 bg-destructive/5">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-destructive" />
Human review flags
</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm space-y-1 list-disc pl-5">
{(a.humanReviewReasons ?? []).map((reason, i) => (
<li key={i}>{reason}</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* 10. IT Glue references */}
{a.itglueDocsReferenced.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">IT Glue references</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm space-y-2">
{a.itglueDocsReferenced.map((doc) => (
<li key={doc.id} className="flex items-start gap-2">
<Badge variant="outline">{doc.doc_type}</Badge>
<div className="min-w-0 flex-1">
{doc.url ? (
<a
href={doc.url}
target="_blank"
rel="noreferrer"
className="font-medium hover:underline inline-flex items-center gap-1"
>
{doc.name}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="font-medium">{doc.name}</span>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{doc.relevance_reason}
</p>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Expanded event detail (rendered separately so it floats independently) */}
{expandedEvent !== null && a.timeline?.[expandedEvent] && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-sm">Event detail</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p>
<strong>{a.timeline[expandedEvent].actor}</strong> ·{' '}
{new Date(a.timeline[expandedEvent].timestamp).toLocaleString()}
</p>
<p>{a.timeline[expandedEvent].action}</p>
<Separator />
<p className="text-xs text-muted-foreground">
Visibility: {VISIBILITY_LABEL[a.timeline[expandedEvent].visibility]}
{' · '} Source: {a.timeline[expandedEvent].source}
{' · '} Actor type: {a.timeline[expandedEvent].actor_type}
</p>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Sparkles, Loader2 } from 'lucide-react';
import type { JobStatus } from '@/lib/types/analyzer';
interface AnalyzeButtonProps {
ticketNumber: string;
/** Force a re-run even if the content hash matches an existing analysis. */
force?: boolean;
variant?: 'default' | 'outline' | 'secondary';
label?: string;
}
const STAGE_LABEL: Record<JobStatus, string> = {
queued: 'Queued…',
fetching: 'Fetching…',
triaging: 'Triaging…',
itglue: 'Searching IT Glue…',
analyzing: 'Analyzing…',
deep_review: 'Deep review…',
complete: 'Done',
failed: 'Failed',
};
export function AnalyzeButton({
ticketNumber,
force = false,
variant = 'default',
label = 'Analyze',
}: AnalyzeButtonProps) {
const router = useRouter();
const [status, setStatus] = useState<JobStatus | 'idle'>('idle');
async function pollJob(jobId: string) {
const start = Date.now();
const TIMEOUT_MS = 5 * 60 * 1000;
while (Date.now() - start < TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(`/api/analyzer/jobs/${jobId}`);
if (!res.ok) throw new Error(`Job poll failed: ${res.status}`);
const { job } = (await res.json()) as {
job: { status: JobStatus; resultAnalysisId: string | null; errorMessage: string | null };
};
setStatus(job.status);
if (job.status === 'complete' && job.resultAnalysisId) {
return job.resultAnalysisId;
}
if (job.status === 'failed') {
throw new Error(job.errorMessage ?? 'Analysis failed');
}
}
throw new Error('Analysis timed out after 5 minutes');
}
async function handleClick() {
setStatus('queued');
try {
const res = await fetch(
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyze`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
}
);
if (!res.ok) {
const error = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(error.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as
| { status: 'complete'; existingAnalysisId: string }
| { status: 'queued'; jobId: string };
if (data.status === 'complete') {
router.push(`/analyzer/analysis/${data.existingAnalysisId}`);
return;
}
const analysisId = await pollJob(data.jobId);
router.push(`/analyzer/analysis/${analysisId}`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg);
setStatus('idle');
}
}
const isRunning = status !== 'idle' && status !== 'failed';
return (
<Button onClick={handleClick} disabled={isRunning} variant={variant}>
{isRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{STAGE_LABEL[status as JobStatus] ?? 'Working…'}
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
{force ? 'Re-analyze' : label}
</>
)}
</Button>
);
}

View file

@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Share2 } from 'lucide-react';
import { toast } from 'sonner';
interface ShareModalProps {
analysisId: string;
}
export function ShareModal({ analysisId }: ShareModalProps) {
const [open, setOpen] = useState(false);
const [recipientEmail, setRecipientEmail] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
const res = await fetch(`/api/analyzer/analyses/${analysisId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipientEmail,
note: note.trim() || undefined,
}),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(data.message ?? data.error ?? `Request failed: ${res.status}`);
}
toast.success(`Shared with ${recipientEmail}`);
setOpen(false);
setRecipientEmail('');
setNote('');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg);
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Share2 className="w-4 h-4 mr-2" />
Share
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Share this analysis</DialogTitle>
<DialogDescription>
Recipient must be on an allowed domain (set via
ALLOWED_SHARE_DOMAINS).
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="recipient">Recipient email</Label>
<Input
id="recipient"
type="email"
required
value={recipientEmail}
onChange={(e) => setRecipientEmail(e.target.value)}
placeholder="colleague@wulfconsulting.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="note">Note (optional)</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Why you're sharing this…"
rows={3}
maxLength={2000}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={submitting || !recipientEmail}>
{submitting ? 'Sharing…' : 'Share'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,203 @@
{
"ticket": {
"id": "680282",
"ticket_number": "T20260424.0045",
"title": "Outmarket AI vendor integration request — AMS360 App Access Key & ImageRight credentials (verify access level)",
"status": 7,
"priority": 8,
"queue_id": 29682969,
"company_id": "29683407",
"contact_id": "31864346",
"assigned_resource_id": "30861443",
"description": "Requestor: Lorentz Hinrichsen (forwarded from Tyler Lyster / Seubert; cc Kristie Lulich, Richard Mansfield)\n\nSummary:\nRequest to add/setup vendor integration for Outmarket AI and provide required connection details for AMS360 and ImageRight to enable an integration trial.\n\nIssue Details:\n- AMS360 required details: Agency Number, App Access Key.\n- ImageRight required details: Base URL, Tenant ID, admin username, admin password.\n- Key question: Determine whether provided credentials/API key allow create/update/delete (write) access or are read-only, as access level depends on permissions tied to the AMS360 integration key/user and the ImageRight account/role.\n\nUser/System Information:\n- Requestor contact: Lorentz Hinrichsen (contactID: 31864346).\n- Vendor: Outmarket AI integration trial.\n- Affected systems: AMS360 (integration key/app access) and ImageRight (tenant/URL/credentials).\n\nAdditional Context:\n- Ticket opened for research and validation of where AMS360 App Access Key is managed, the security context/permissions of the AMS360 integration account, and confirmation of ImageRight Base URL/Tenant and required credential scope.\n- No configuration item specified.",
"create_date": "2026-04-24T16:53:50.163Z",
"last_activity_date": "2026-04-29T17:09:58.070Z",
"resolved_date_time": null
},
"notes": [
{
"id": "33738631",
"ticket_id": "680282",
"title": "Workflow Rule \"Wulf Managed - New ticket confirmation to 'Ticket Contact'\" fired.",
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nTicket Type not equal to \"Alert\"\r\nUDF (Company): Client Service Model not equal to \"Co-Managed (Our Autotask)\"\r\nTicket Category in list \"Ticket Triage or Service Desk\"\r\nQueue not equal to \"Post Sale\"\r\nThen execute the following actions:\r\nSend notification e-mail (2026 - Wulf Consulting Support Request Received) to Ticket Contact (tlyster@seubert.com)\r\nInitiated by Cory Houck",
"note_type": 13,
"publish": 1,
"creator_resource_id": "4",
"creator_type": null,
"last_activity_date": "2026-04-24T12:54:07.497Z",
"create_date_time": "2026-04-24T12:54:07.497Z",
"is_deleted": false
},
{
"id": "33738632",
"ticket_id": "680282",
"title": "Workflow Rule \"Wulf - New VIP contact tickets sent to Fast Track\" fired.",
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nUDF (Contact): VIP Contact equal to \"Yes\"\r\nTicket Type not equal to \"Alert\"\r\nQueue in list \"Client Triage or Level 1 Support or Level 2 Support or Level 3 Support\"\r\nPriority not equal to \"Fast Track\"\r\nThen execute the following actions:\r\nSet Priority To \"Fast Track\"\r\nInitiated by Cory Houck",
"note_type": 13,
"publish": 1,
"creator_resource_id": "4",
"creator_type": null,
"last_activity_date": "2026-04-24T12:54:07.643Z",
"create_date_time": "2026-04-24T12:54:07.643Z",
"is_deleted": false
},
{
"id": "33738633",
"ticket_id": "680282",
"title": "Workflow Rule \"Wulf - New Fast Track Ticket\" fired.",
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nPriority equal to \"Fast Track\"\r\nStatus not equal to \"Complete\"\r\nTicket Category in list \"Service Desk\"\r\nThen execute the following actions:\r\nAssign To Queue \"Level 2 Support\"\r\nSet Service Level Agreement As \"Service Desk - Standard SLA\"\r\nSend notification e-mail (WulfNotify- Fast Track) to 3f7be19e.wulfconsulting.com@amer.teams.ms; 98b286b7.wulfconsulting.com@amer.teams.ms; Triage, FastTrack, & On-Call (cimler@wulfconsulting.com, cory.houck@wulfconsulting.com, Darrius.Jones@wulfconsulting.com, david.bauer@wulfconsulting.com, dbucci@wulfconsulting.com, evan.life@wulfconsulting.com, james.hubbard@wulfconsulting.com, jer15202@hotmail.com, jeremy@wulfconsulting.com, sean.glenn@wulfconsulting.com)\r\nInitiated by Cory Houck",
"note_type": 13,
"publish": 1,
"creator_resource_id": "4",
"creator_type": null,
"last_activity_date": "2026-04-24T12:54:07.923Z",
"create_date_time": "2026-04-24T12:54:07.923Z",
"is_deleted": false
},
{
"id": "33738634",
"ticket_id": "680282",
"title": "Workflow Rule \"Wulf - Sent to Fast Track Ticket\" fired.",
"description": "When a ticket is:\r\nEdited\r\nAnd the following conditions are met:\r\nPriority changed to \"Fast Track\"\r\nStatus not equal to \"Complete\"\r\nTicket Category equal to \"Service Desk\"\r\nThen execute the following actions:\r\nAssign To Queue \"Level 2 Support\"\r\nSet Service Level Agreement As \"Service Desk - Standard SLA\"\r\nSend notification e-mail (WulfNotify- Fast Track) to 3f7be19e.wulfconsulting.com@amer.teams.ms; 98b286b7.wulfconsulting.com@amer.teams.ms; Triage, FastTrack, & On-Call (cimler@wulfconsulting.com, cory.houck@wulfconsulting.com, Darrius.Jones@wulfconsulting.com, david.bauer@wulfconsulting.com, dbucci@wulfconsulting.com, evan.life@wulfconsulting.com, james.hubbard@wulfconsulting.com, jer15202@hotmail.com, jeremy@wulfconsulting.com, sean.glenn@wulfconsulting.com)\r\nInitiated by Cory Houck",
"note_type": 13,
"publish": 1,
"creator_resource_id": "4",
"creator_type": null,
"last_activity_date": "2026-04-24T12:54:08.170Z",
"create_date_time": "2026-04-24T12:54:08.170Z",
"is_deleted": false
},
{
"id": "33738776",
"ticket_id": "680282",
"title": "Service Desk Notification",
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"note_type": 2,
"publish": 4,
"creator_resource_id": "30861463",
"creator_type": null,
"last_activity_date": "2026-04-24T14:22:36.230Z",
"create_date_time": "2026-04-24T14:22:36.230Z",
"is_deleted": false
},
{
"id": "33738796",
"ticket_id": "680282",
"title": "Wulf Support Ticket Update -",
"description": "Cory,\r\n\r\nI was able to accessthe Vertafore Developer portal and determine what is necessary - no need to reach out to Vertafore. Ill take it from here, thank you!\r\n\r\nLorentz\r\n\r\nFrom: Wulf Consulting Support <support@wulfconsulting.com>\r\nDate: Friday, April 24, 2026 at 10:22?AM\r\nTo: Lorentz W. Hinrichsen <lorentz@wulfconsulting.com>; rmansfield@seubert.com <rmansfield@seubert.com>; klulich@seubert.com <klulich@seubert.com>; tlyster@seubert.com <tlyster@seubert.com>\r\nSubject: Wulf Support Ticket Update - T20260424.0045\r\n\r\n[Wulf Consulting]\r\nUpdate on Your Support Ticket\r\nHello Tyler, here is the latest update regarding your support ticket.\r\nTicket Number: T20260424.0045\r\nStatus: In Progress\r\nTitle: Outmarket AI vendor integration request — AMS360 App Access Key & ImageRight credentials (verify access level)\r\nAssigned Technician: Cory Houck\r\nLatest Update\r\nAll,\r\n\r\nIve been researching the AMS360/VSSO side and Im not seeing an existing dedicated service/integration account available to use for this connection. My next step is to contact Vertafore Support and open a case to confirm the recommended setup and what level of access the App Access Key provides.\r\n\r\nIll follow up as soon as I hear back from Vertafore.\r\n\r\nThanks,\r\nCory\r\n\r\n\r\nIf you have any questions or additional information to share, please reply directly to this email and your response will be added to the ticket automatically.\r\nThank you,\r\n\r\nWulf Consulting Service Desk\r\n412-224-6200 | Option 1 for Support\r\nPlease select a rating below to share feedback on this update.\r\n<https://web.crewhu.com/#/survey?crewhu_id=65cfa4d2fb765076d791ff94&dummyLink=680282>\r\n\r\n[https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=5&custom_code=]<https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=5&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true> [https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=0&custom_code=] <https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=0&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true> [https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=-5&custom_code=] <https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=-5&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true>\r\nHighly\r\nSatisfied\r\nSomewhat\r\nSatisfied\r\nDissatisfied\r\n<https://web.crewhu.com/#/survey?crewhu_id=65cfa4d2fb765076d791ff94&dummyLink=680282>\r\n\r\n**Created via Incoming Email Processor**\r\nFrom: \"Lorentz W. Hinrichsen\" <lorentz@wulfconsulting.com>\r\nTo: Wulf Support <support@wulfconsulting.com>, \"rmansfield@seubert.com\"\t<rmansfield@seubert.com>, \"klulich@seubert.com\" <klulich@seubert.com>, \"tlyster@seubert.com\" <tlyster@seubert.com>",
"note_type": 1,
"publish": 1,
"creator_resource_id": "29683311",
"creator_type": null,
"last_activity_date": "2026-04-24T14:34:46.583Z",
"create_date_time": "2026-04-24T14:34:46.583Z",
"is_deleted": false
},
{
"id": "33738797",
"ticket_id": "680282",
"title": "Service Desk Notification",
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"note_type": 2,
"publish": 4,
"creator_resource_id": "30861463",
"creator_type": null,
"last_activity_date": "2026-04-24T14:35:35.493Z",
"create_date_time": "2026-04-24T14:35:35.493Z",
"is_deleted": false
},
{
"id": "33741514",
"ticket_id": "680282",
"title": "Service Desk Notification",
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"note_type": 2,
"publish": 4,
"creator_resource_id": "30861463",
"creator_type": null,
"last_activity_date": "2026-04-27T13:26:36.047Z",
"create_date_time": "2026-04-27T13:26:36.047Z",
"is_deleted": false
},
{
"id": "33741844",
"ticket_id": "680282",
"title": "Service Desk Notification",
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"note_type": 2,
"publish": 4,
"creator_resource_id": "30861463",
"creator_type": null,
"last_activity_date": "2026-04-27T16:05:26.043Z",
"create_date_time": "2026-04-27T16:05:26.043Z",
"is_deleted": false
}
],
"time_entries": [
{
"id": "465933",
"ticket_id": "680282",
"resource_id": "30861463",
"hours_worked": "0.17",
"notes": "see internal",
"internal_notes": "Lorentz sent me an email\n\nIm 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 and if theres anything special, I have to do to access it",
"entry_date": "2026-04-24T04:00:00.000Z",
"start_date_time": "2026-04-24T17:01:00.000Z",
"end_date_time": "2026-04-24T17:07:00.000Z",
"type": 2,
"is_deleted": false
},
{
"id": "465961",
"ticket_id": "680282",
"resource_id": "30861463",
"hours_worked": "0.75",
"notes": "All, \n\nIve been researching the AMS360/VSSO side and Im not seeing an existing dedicated service/integration account available to use for this connection. My next step is to contact Vertafore Support and open a case to confirm the recommended setup and what level of access the App Access Key provides. \n\nIll follow up as soon as I hear back from Vertafore.\n\nThanks,\nCory",
"internal_notes": "Logged into AMS360 and reviewed available administration/security areas. AMS360 client did not present an obvious User Management/Users list location for identifying existing service/integration accounts.\n\nLogged into Vertafore SSO (VSSO) Admin Console and reviewed the Managed Users list.\n\n- Performed multiple searches for potential service/integration accounts using common naming patterns/keywords including: svc, service, integration, integr, api, vendor, outmarket, wulf, system, plus additional generic terms (interface/sync/import/export/webservice/batch).\n- Checked for alternate filters/scopes where available (ex: inactive/disabled users) and did not locate any accounts that appear to be dedicated service/integration accounts.\nReviewed VSSO Groups/Roles (where available) for integration-related naming (ex: AMS/integration/api/vendor/system) to determine if any existing integration grouping exists and did not identify anything clearly tied to an existing vendor/service account setup.\n\nCurrent conclusion: No existing dedicated service/integration account is identifiable/available via VSSO Managed Users (or related group/role searches). Access level for any future integration account remains unknown until confirmed with Vertafore.\n\n\nCurrent Status\nResearch/validation completed internally.\n\nAccess scope for the AMS360 App Access Key (read-only vs read/write/create/update) cannot be confirmed from current admin views and requires vendor confirmation.",
"entry_date": "2026-04-24T04:00:00.000Z",
"start_date_time": "2026-04-24T17:25:00.000Z",
"end_date_time": "2026-04-24T18:21:00.000Z",
"type": 2,
"is_deleted": false
},
{
"id": "465968",
"ticket_id": "680282",
"resource_id": "30861463",
"hours_worked": "0.25",
"notes": "All,\n\nI created a case with Vertafore Support to confirm the recommended setup and access scope for the AMS360/ImageRight integration credentials.\n\nAccount #: 1100080\nCase #: 4581488\n\nIll provide an update as soon as I hear back from Vertafore.\n\nThanks,\nCory",
"internal_notes": null,
"entry_date": "2026-04-24T04:00:00.000Z",
"start_date_time": "2026-04-24T18:21:00.000Z",
"end_date_time": "2026-04-24T18:33:00.000Z",
"type": 2,
"is_deleted": false
},
{
"id": "466134",
"ticket_id": "680282",
"resource_id": "30861463",
"hours_worked": "0.17",
"notes": "Hi all,\nI received a call from Richard at Vertafore Support. Hell be assisting with the AMS360 integration, and he is opening a separate case for the ImageRight application integration. Hell be following up later today with additional information.\n\nIll update this ticket as soon as I receive his follow-up.",
"internal_notes": null,
"entry_date": "2026-04-27T04:00:00.000Z",
"start_date_time": "2026-04-27T17:16:00.000Z",
"end_date_time": "2026-04-27T17:22:00.000Z",
"type": 2,
"is_deleted": false
},
{
"id": "466183",
"ticket_id": "680282",
"resource_id": "30861463",
"hours_worked": "0.17",
"notes": "All,\n\nHere is the email update information that I received from Richard at Vertafore Support. Please let me know if you have any questions or how you would like to proceed.\n\nQuestions / Assistance Requested\nAMS360 App Access Key\n\nIs the App Access Key tied to a specific VSSO user/security principal, or is it tenant-wide?\n\n- The APP access key is not tied to a specific user. It is tied to an integration user (Non-licensed user) configured in the application catalog.\nHow do we verify whether the key provides read-only vs read/write (create/update/delete) access?\n\n- The key itself only allows the 3rd party to authenticate against a database. This depends on the endpoints being used/activated & the security groups selected when configuring the app user when subscribing.\nWhere are the permissions governing the key configured (AMS360 vs VSSO)?\n\n- AMS360\nService/integration account best practice\n\nDo you recommend creating a dedicated service/integration account in VSSO for third-party integrations (Outmarket)?\n\n- No, there is no need for this as the integration is configured in AMS360 in the application catalog.\nIf yes, what is the recommended approach and minimum permission set for AMS360 and for ImageRight?\n\n\nImageRight access scope\nOutmarket AI does have an EMS application with all endpoints active.\n\nYou can find a list of the available endpoints at the following link.\n\nhttps://link.vertafore.com/VERTAFORE/documentation/AMS360/content?apiSlug=AMS360:EMS:master&resourceSlug=Rf21abdb9\nRichard Murphy\nSr. Customer Support Analyst\noffice. 1-800-444-4813 (Option 2)\nvertafore.com | rmurphy@vertafore.com\n\n---\n\nThanks,\nCory",
"internal_notes": "Richard from Vertafore Support created a case to address the ImageRight integration. \n\nDear Cory,\n\nThank you for contacting Vertafore Support. Your case has been logged and a Support Agent will follow up with you as soon as possible. Please retain the details below for future reference.\n\nAccount #: 1100080\nCase #: 4582409\n\nSubject: ImageRight integration credentials — confirm App Access Key security context and least-privilege access (Outmarket AI / Seubert)\nDescription: We support Seubert and are assisting with a third-party integration trial with Outmarket AI, which will connect to both AMS360 and ImageRight. Outmarket is requesting connection details/credentials for both systems.\n\nImageRight credentials are managed through Vertafore SSO (VSSO) in this environment. We need Vertafores guidance to confirm the security/permission model and the recommended least-privilege setup before providing any credentials to the vendor.\n\nQuestions / Assistance Requested\n\n\n- Is the App Access Key tied to a specific VSSO user/security principal, or is it tenant-wide?\n- How do we verify whether the key provides read-only vs read/write (create/update/delete) access?\n- Where are the permissions governing the key configured (IR vs VSSO)?\nService/integration account best practice\n\n- Do you recommend creating a dedicated service/integration account in VSSO for third-party integrations (Outmarket)?\n- If yes, what is the recommended approach and minimum permission set for ImageRight?\nImageRight access scope\n\n- Can ImageRight integrations be performed with a non-admin service account (least privilege)?\n- What specific roles/permissions are typically required for ImageRight integrations of this type?\nAny documentation you can provide regarding least-privilege configuration for ImageRight third-party integrations using VSSO-managed credentials.\nWhat weve already checked\n\nReviewed VSSO Managed Users and searched for existing dedicated service/integration accounts (svc/service/integration/api/vendor/system/outmarket, etc.); none were identified.\n\nReviewed available VSSO groups/roles for any obvious existing integration groupings; none were clearly identified.\n\n\nGoal\nConfirm the correct, secure configuration (least privilege) for the Outmarket AI trial integration with ImageRight at Seubert, including whether a dedicated service account is required and how to validate read vs write access for the AMS360 App Access Key.\n\n\nSincerely,\n\nRichard Murphy",
"entry_date": "2026-04-27T04:00:00.000Z",
"start_date_time": "2026-04-27T19:51:00.000Z",
"end_date_time": "2026-04-27T19:57:00.000Z",
"type": 2,
"is_deleted": false
}
]
}

View file

@ -0,0 +1,14 @@
{
"ticket_id": 680282,
"ticket_number": "T20260424.0045",
"counts": {
"live_notes": 9,
"db_notes": 9,
"live_time_entries": 5,
"db_time_entries": 5
},
"notes_only_in_live": [],
"notes_only_in_db": [],
"entries_only_in_live": [],
"entries_only_in_db": []
}

View file

@ -0,0 +1,375 @@
{
"ticket": {
"id": 680282,
"apiVendorID": null,
"assignedResourceID": 30861443,
"assignedResourceRoleID": 29780072,
"billingCodeID": null,
"changeApprovalBoard": null,
"changeApprovalStatus": null,
"changeApprovalType": null,
"changeInfoField1": "",
"changeInfoField2": "",
"changeInfoField3": "",
"changeInfoField4": "",
"changeInfoField5": "",
"companyID": 29683407,
"companyLocationID": 21,
"completedByResourceID": null,
"completedDate": null,
"configurationItemID": null,
"contactID": 31864346,
"contractID": 29863002,
"contractServiceBundleID": null,
"contractServiceID": null,
"createDate": "2026-04-24T12:53:50.163Z",
"createdByContactID": null,
"creatorResourceID": 30861463,
"creatorType": 1,
"currentServiceThermometerRating": null,
"description": "Requestor: Lorentz Hinrichsen (forwarded from Tyler Lyster / Seubert; cc Kristie Lulich, Richard Mansfield)\n\nSummary:\nRequest to add/setup vendor integration for Outmarket AI and provide required connection details for AMS360 and ImageRight to enable an integration trial.\n\nIssue Details:\n- AMS360 required details: Agency Number, App Access Key.\n- ImageRight required details: Base URL, Tenant ID, admin username, admin password.\n- Key question: Determine whether provided credentials/API key allow create/update/delete (write) access or are read-only, as access level depends on permissions tied to the AMS360 integration key/user and the ImageRight account/role.\n\nUser/System Information:\n- Requestor contact: Lorentz Hinrichsen (contactID: 31864346).\n- Vendor: Outmarket AI integration trial.\n- Affected systems: AMS360 (integration key/app access) and ImageRight (tenant/URL/credentials).\n\nAdditional Context:\n- Ticket opened for research and validation of where AMS360 App Access Key is managed, the security context/permissions of the AMS360 integration account, and confirmation of ImageRight Base URL/Tenant and required credential scope.\n- No configuration item specified.",
"dueDateTime": "2026-04-24T13:53:00.000Z",
"estimatedHours": null,
"externalID": "",
"firstResponseAssignedResourceID": 30861463,
"firstResponseDateTime": "2026-04-24T12:53:50.163Z",
"firstResponseDueDateTime": "2026-04-24T13:08:50.163Z",
"firstResponseInitiatingResourceID": 30861463,
"hoursToBeScheduled": null,
"impersonatorCreatorResourceID": null,
"isAssignedToComanaged": false,
"issueType": 46,
"isVisibleToComanaged": true,
"lastActivityDate": "2026-04-29T13:09:58.070Z",
"lastActivityPersonType": 1,
"lastActivityResourceID": 30861443,
"lastCustomerNotificationDateTime": "2026-04-27T16:05:25.870Z",
"lastCustomerVisibleActivityDateTime": "2026-04-29T13:09:58.070Z",
"lastTrackedModificationDateTime": "2026-04-29T13:07:52.780Z",
"monitorID": null,
"monitorTypeID": null,
"opportunityID": null,
"organizationalLevelAssociationID": null,
"previousServiceThermometerRating": null,
"priority": 8,
"problemTicketId": null,
"projectID": null,
"purchaseOrderNumber": "",
"queueID": 29682969,
"resolution": "",
"resolutionPlanDateTime": "2026-04-24T14:21:00.000Z",
"resolutionPlanDueDateTime": "2026-04-24T13:23:50.163Z",
"resolvedDateTime": null,
"resolvedDueDateTime": null,
"rmaStatus": null,
"rmaType": null,
"rmmAlertID": null,
"serviceLevelAgreementHasBeenMet": null,
"serviceLevelAgreementID": 9,
"serviceLevelAgreementPausedNextEventHours": 14.3043,
"serviceThermometerTemperature": null,
"source": -2,
"status": 7,
"subIssueType": 767,
"ticketCategory": 155,
"ticketNumber": "T20260424.0045",
"ticketType": 1,
"title": "Outmarket AI vendor integration request — AMS360 App Access Key & ImageRight credentials (verify access level)",
"userDefinedFields": []
},
"notes": [
{
"id": 33738631,
"createDateTime": "2026-04-24T12:54:07.497Z",
"createdByContactID": null,
"creatorResourceID": 4,
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nTicket Type not equal to \"Alert\"\r\nUDF (Company): Client Service Model not equal to \"Co-Managed (Our Autotask)\"\r\nTicket Category in list \"Ticket Triage or Service Desk\"\r\nQueue not equal to \"Post Sale\"\r\nThen execute the following actions:\r\nSend notification e-mail (2026 - Wulf Consulting Support Request Received) to Ticket Contact (tlyster@seubert.com)\r\nInitiated by Cory Houck",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-24T12:54:07.497Z",
"noteType": 13,
"publish": 1,
"ticketID": 680282,
"title": "Workflow Rule \"Wulf Managed - New ticket confirmation to 'Ticket Contact'\" fired."
},
{
"id": 33738632,
"createDateTime": "2026-04-24T12:54:07.643Z",
"createdByContactID": null,
"creatorResourceID": 4,
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nUDF (Contact): VIP Contact equal to \"Yes\"\r\nTicket Type not equal to \"Alert\"\r\nQueue in list \"Client Triage or Level 1 Support or Level 2 Support or Level 3 Support\"\r\nPriority not equal to \"Fast Track\"\r\nThen execute the following actions:\r\nSet Priority To \"Fast Track\"\r\nInitiated by Cory Houck",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-24T12:54:07.643Z",
"noteType": 13,
"publish": 1,
"ticketID": 680282,
"title": "Workflow Rule \"Wulf - New VIP contact tickets sent to Fast Track\" fired."
},
{
"id": 33738633,
"createDateTime": "2026-04-24T12:54:07.923Z",
"createdByContactID": null,
"creatorResourceID": 4,
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nPriority equal to \"Fast Track\"\r\nStatus not equal to \"Complete\"\r\nTicket Category in list \"Service Desk\"\r\nThen execute the following actions:\r\nAssign To Queue \"Level 2 Support\"\r\nSet Service Level Agreement As \"Service Desk - Standard SLA\"\r\nSend notification e-mail (WulfNotify- Fast Track) to 3f7be19e.wulfconsulting.com@amer.teams.ms; 98b286b7.wulfconsulting.com@amer.teams.ms; Triage, FastTrack, & On-Call (cimler@wulfconsulting.com, cory.houck@wulfconsulting.com, Darrius.Jones@wulfconsulting.com, david.bauer@wulfconsulting.com, dbucci@wulfconsulting.com, evan.life@wulfconsulting.com, james.hubbard@wulfconsulting.com, jer15202@hotmail.com, jeremy@wulfconsulting.com, sean.glenn@wulfconsulting.com)\r\nInitiated by Cory Houck",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-24T12:54:07.923Z",
"noteType": 13,
"publish": 1,
"ticketID": 680282,
"title": "Workflow Rule \"Wulf - New Fast Track Ticket\" fired."
},
{
"id": 33738634,
"createDateTime": "2026-04-24T12:54:08.170Z",
"createdByContactID": null,
"creatorResourceID": 4,
"description": "When a ticket is:\r\nEdited\r\nAnd the following conditions are met:\r\nPriority changed to \"Fast Track\"\r\nStatus not equal to \"Complete\"\r\nTicket Category equal to \"Service Desk\"\r\nThen execute the following actions:\r\nAssign To Queue \"Level 2 Support\"\r\nSet Service Level Agreement As \"Service Desk - Standard SLA\"\r\nSend notification e-mail (WulfNotify- Fast Track) to 3f7be19e.wulfconsulting.com@amer.teams.ms; 98b286b7.wulfconsulting.com@amer.teams.ms; Triage, FastTrack, & On-Call (cimler@wulfconsulting.com, cory.houck@wulfconsulting.com, Darrius.Jones@wulfconsulting.com, david.bauer@wulfconsulting.com, dbucci@wulfconsulting.com, evan.life@wulfconsulting.com, james.hubbard@wulfconsulting.com, jer15202@hotmail.com, jeremy@wulfconsulting.com, sean.glenn@wulfconsulting.com)\r\nInitiated by Cory Houck",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-24T12:54:08.170Z",
"noteType": 13,
"publish": 1,
"ticketID": 680282,
"title": "Workflow Rule \"Wulf - Sent to Fast Track Ticket\" fired."
},
{
"id": 33738776,
"createDateTime": "2026-04-24T14:22:36.230Z",
"createdByContactID": null,
"creatorResourceID": 30861463,
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-24T14:22:36.230Z",
"noteType": 2,
"publish": 4,
"ticketID": 680282,
"title": "Service Desk Notification"
},
{
"id": 33738796,
"createDateTime": "2026-04-24T14:34:46.583Z",
"createdByContactID": null,
"creatorResourceID": 29683311,
"description": "Cory,\r\n\r\nI was able to accessthe Vertafore Developer portal and determine what is necessary - no need to reach out to Vertafore. Ill take it from here, thank you!\r\n\r\nLorentz\r\n\r\nFrom: Wulf Consulting Support <support@wulfconsulting.com>\r\nDate: Friday, April 24, 2026 at 10:22?AM\r\nTo: Lorentz W. Hinrichsen <lorentz@wulfconsulting.com>; rmansfield@seubert.com <rmansfield@seubert.com>; klulich@seubert.com <klulich@seubert.com>; tlyster@seubert.com <tlyster@seubert.com>\r\nSubject: Wulf Support Ticket Update - T20260424.0045\r\n\r\n[Wulf Consulting]\r\nUpdate on Your Support Ticket\r\nHello Tyler, here is the latest update regarding your support ticket.\r\nTicket Number: T20260424.0045\r\nStatus: In Progress\r\nTitle: Outmarket AI vendor integration request — AMS360 App Access Key & ImageRight credentials (verify access level)\r\nAssigned Technician: Cory Houck\r\nLatest Update\r\nAll,\r\n\r\nIve been researching the AMS360/VSSO side and Im not seeing an existing dedicated service/integration account available to use for this connection. My next step is to contact Vertafore Support and open a case to confirm the recommended setup and what level of access the App Access Key provides.\r\n\r\nIll follow up as soon as I hear back from Vertafore.\r\n\r\nThanks,\r\nCory\r\n\r\n\r\nIf you have any questions or additional information to share, please reply directly to this email and your response will be added to the ticket automatically.\r\nThank you,\r\n\r\nWulf Consulting Service Desk\r\n412-224-6200 | Option 1 for Support\r\nPlease select a rating below to share feedback on this update.\r\n<https://web.crewhu.com/#/survey?crewhu_id=65cfa4d2fb765076d791ff94&dummyLink=680282>\r\n\r\n[https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=5&custom_code=]<https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=5&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true> [https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=0&custom_code=] <https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=0&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true> [https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=-5&custom_code=] <https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=-5&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true>\r\nHighly\r\nSatisfied\r\nSomewhat\r\nSatisfied\r\nDissatisfied\r\n<https://web.crewhu.com/#/survey?crewhu_id=65cfa4d2fb765076d791ff94&dummyLink=680282>\r\n\r\n**Created via Incoming Email Processor**\r\nFrom: \"Lorentz W. Hinrichsen\" <lorentz@wulfconsulting.com>\r\nTo: Wulf Support <support@wulfconsulting.com>, \"rmansfield@seubert.com\"\t<rmansfield@seubert.com>, \"klulich@seubert.com\" <klulich@seubert.com>, \"tlyster@seubert.com\" <tlyster@seubert.com>",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-24T14:34:46.583Z",
"noteType": 1,
"publish": 1,
"ticketID": 680282,
"title": "Wulf Support Ticket Update -"
},
{
"id": 33738797,
"createDateTime": "2026-04-24T14:35:35.493Z",
"createdByContactID": null,
"creatorResourceID": 30861463,
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-24T14:35:35.493Z",
"noteType": 2,
"publish": 4,
"ticketID": 680282,
"title": "Service Desk Notification"
},
{
"id": 33741514,
"createDateTime": "2026-04-27T13:26:36.047Z",
"createdByContactID": null,
"creatorResourceID": 30861463,
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-27T13:26:36.047Z",
"noteType": 2,
"publish": 4,
"ticketID": 680282,
"title": "Service Desk Notification"
},
{
"id": 33741844,
"createDateTime": "2026-04-27T16:05:26.043Z",
"createdByContactID": null,
"creatorResourceID": 30861463,
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"lastActivityDate": "2026-04-27T16:05:26.043Z",
"noteType": 2,
"publish": 4,
"ticketID": 680282,
"title": "Service Desk Notification"
}
],
"time_entries": [
{
"id": 465933,
"billingApprovalDateTime": null,
"billingApprovalLevelMostRecent": 0,
"billingApprovalResourceID": null,
"billingCodeID": 29780571,
"contractID": 29863002,
"contractServiceBundleID": null,
"contractServiceID": null,
"createDateTime": "2026-04-24T13:06:46.160Z",
"creatorUserID": 30861463,
"dateWorked": "2026-04-24T00:00:00.000Z",
"endDateTime": "2026-04-24T13:07:00.000Z",
"hoursToBill": 0.1667,
"hoursWorked": 0.1667,
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"internalBillingCodeID": null,
"internalNotes": "Lorentz sent me an email\n\nIm 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 and if theres anything special, I have to do to access it",
"isInternalNotesVisibleToComanaged": false,
"isNonBillable": false,
"lastModifiedDateTime": "2026-04-24T13:06:46.160Z",
"lastModifiedUserID": 30861463,
"offsetHours": 0,
"resourceID": 30861463,
"roleID": 29780072,
"showOnInvoice": true,
"startDateTime": "2026-04-24T13:01:00.000Z",
"summaryNotes": "see internal",
"taskID": null,
"ticketID": 680282,
"timeEntryType": 2
},
{
"id": 465961,
"billingApprovalDateTime": null,
"billingApprovalLevelMostRecent": 0,
"billingApprovalResourceID": null,
"billingCodeID": 29780571,
"contractID": 29863002,
"contractServiceBundleID": null,
"contractServiceID": null,
"createDateTime": "2026-04-24T14:22:35.653Z",
"creatorUserID": 30861463,
"dateWorked": "2026-04-24T00:00:00.000Z",
"endDateTime": "2026-04-24T14:21:00.000Z",
"hoursToBill": 0.75,
"hoursWorked": 0.75,
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"internalBillingCodeID": null,
"internalNotes": "Logged into AMS360 and reviewed available administration/security areas. AMS360 client did not present an obvious User Management/Users list location for identifying existing service/integration accounts.\n\nLogged into Vertafore SSO (VSSO) Admin Console and reviewed the Managed Users list.\n\n- Performed multiple searches for potential service/integration accounts using common naming patterns/keywords including: svc, service, integration, integr, api, vendor, outmarket, wulf, system, plus additional generic terms (interface/sync/import/export/webservice/batch).\n- Checked for alternate filters/scopes where available (ex: inactive/disabled users) and did not locate any accounts that appear to be dedicated service/integration accounts.\nReviewed VSSO Groups/Roles (where available) for integration-related naming (ex: AMS/integration/api/vendor/system) to determine if any existing integration grouping exists and did not identify anything clearly tied to an existing vendor/service account setup.\n\nCurrent conclusion: No existing dedicated service/integration account is identifiable/available via VSSO Managed Users (or related group/role searches). Access level for any future integration account remains unknown until confirmed with Vertafore.\n\n\nCurrent Status\nResearch/validation completed internally.\n\nAccess scope for the AMS360 App Access Key (read-only vs read/write/create/update) cannot be confirmed from current admin views and requires vendor confirmation.",
"isInternalNotesVisibleToComanaged": false,
"isNonBillable": false,
"lastModifiedDateTime": "2026-04-24T14:22:35.653Z",
"lastModifiedUserID": 30861463,
"offsetHours": 0,
"resourceID": 30861463,
"roleID": 29780072,
"showOnInvoice": true,
"startDateTime": "2026-04-24T13:25:00.000Z",
"summaryNotes": "All, \n\nIve been researching the AMS360/VSSO side and Im not seeing an existing dedicated service/integration account available to use for this connection. My next step is to contact Vertafore Support and open a case to confirm the recommended setup and what level of access the App Access Key provides. \n\nIll follow up as soon as I hear back from Vertafore.\n\nThanks,\nCory",
"taskID": null,
"ticketID": 680282,
"timeEntryType": 2
},
{
"id": 465968,
"billingApprovalDateTime": null,
"billingApprovalLevelMostRecent": 0,
"billingApprovalResourceID": null,
"billingCodeID": 29780571,
"contractID": 29863002,
"contractServiceBundleID": null,
"contractServiceID": null,
"createDateTime": "2026-04-24T14:35:34.693Z",
"creatorUserID": 30861463,
"dateWorked": "2026-04-24T00:00:00.000Z",
"endDateTime": "2026-04-24T14:33:00.000Z",
"hoursToBill": 0.25,
"hoursWorked": 0.25,
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"internalBillingCodeID": null,
"internalNotes": null,
"isInternalNotesVisibleToComanaged": false,
"isNonBillable": false,
"lastModifiedDateTime": "2026-04-24T14:35:34.693Z",
"lastModifiedUserID": 30861463,
"offsetHours": 0,
"resourceID": 30861463,
"roleID": 29780072,
"showOnInvoice": true,
"startDateTime": "2026-04-24T14:21:00.000Z",
"summaryNotes": "All,\n\nI created a case with Vertafore Support to confirm the recommended setup and access scope for the AMS360/ImageRight integration credentials.\n\nAccount #: 1100080\nCase #: 4581488\n\nIll provide an update as soon as I hear back from Vertafore.\n\nThanks,\nCory",
"taskID": null,
"ticketID": 680282,
"timeEntryType": 2
},
{
"id": 466134,
"billingApprovalDateTime": null,
"billingApprovalLevelMostRecent": 0,
"billingApprovalResourceID": null,
"billingCodeID": null,
"contractID": 29863002,
"contractServiceBundleID": null,
"contractServiceID": null,
"createDateTime": "2026-04-27T13:26:35.470Z",
"creatorUserID": 30861463,
"dateWorked": "2026-04-27T00:00:00.000Z",
"endDateTime": "2026-04-27T13:22:00.000Z",
"hoursToBill": 0.1667,
"hoursWorked": 0.1667,
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"internalBillingCodeID": null,
"internalNotes": null,
"isInternalNotesVisibleToComanaged": false,
"isNonBillable": false,
"lastModifiedDateTime": "2026-04-27T13:26:35.470Z",
"lastModifiedUserID": 30861463,
"offsetHours": 0,
"resourceID": 30861463,
"roleID": 29780072,
"showOnInvoice": true,
"startDateTime": "2026-04-27T13:16:00.000Z",
"summaryNotes": "Hi all,\nI received a call from Richard at Vertafore Support. Hell be assisting with the AMS360 integration, and he is opening a separate case for the ImageRight application integration. Hell be following up later today with additional information.\n\nIll update this ticket as soon as I receive his follow-up.",
"taskID": null,
"ticketID": 680282,
"timeEntryType": 2
},
{
"id": 466183,
"billingApprovalDateTime": null,
"billingApprovalLevelMostRecent": 0,
"billingApprovalResourceID": null,
"billingCodeID": 29780571,
"contractID": 29863002,
"contractServiceBundleID": null,
"contractServiceID": null,
"createDateTime": "2026-04-27T16:05:25.497Z",
"creatorUserID": 30861463,
"dateWorked": "2026-04-27T00:00:00.000Z",
"endDateTime": "2026-04-27T15:57:00.000Z",
"hoursToBill": 0.1667,
"hoursWorked": 0.1667,
"impersonatorCreatorResourceID": null,
"impersonatorUpdaterResourceID": null,
"internalBillingCodeID": null,
"internalNotes": "Richard from Vertafore Support created a case to address the ImageRight integration. \n\nDear Cory,\n\nThank you for contacting Vertafore Support. Your case has been logged and a Support Agent will follow up with you as soon as possible. Please retain the details below for future reference.\n\nAccount #: 1100080\nCase #: 4582409\n\nSubject: ImageRight integration credentials — confirm App Access Key security context and least-privilege access (Outmarket AI / Seubert)\nDescription: We support Seubert and are assisting with a third-party integration trial with Outmarket AI, which will connect to both AMS360 and ImageRight. Outmarket is requesting connection details/credentials for both systems.\n\nImageRight credentials are managed through Vertafore SSO (VSSO) in this environment. We need Vertafores guidance to confirm the security/permission model and the recommended least-privilege setup before providing any credentials to the vendor.\n\nQuestions / Assistance Requested\n\n\n- Is the App Access Key tied to a specific VSSO user/security principal, or is it tenant-wide?\n- How do we verify whether the key provides read-only vs read/write (create/update/delete) access?\n- Where are the permissions governing the key configured (IR vs VSSO)?\nService/integration account best practice\n\n- Do you recommend creating a dedicated service/integration account in VSSO for third-party integrations (Outmarket)?\n- If yes, what is the recommended approach and minimum permission set for ImageRight?\nImageRight access scope\n\n- Can ImageRight integrations be performed with a non-admin service account (least privilege)?\n- What specific roles/permissions are typically required for ImageRight integrations of this type?\nAny documentation you can provide regarding least-privilege configuration for ImageRight third-party integrations using VSSO-managed credentials.\nWhat weve already checked\n\nReviewed VSSO Managed Users and searched for existing dedicated service/integration accounts (svc/service/integration/api/vendor/system/outmarket, etc.); none were identified.\n\nReviewed available VSSO groups/roles for any obvious existing integration groupings; none were clearly identified.\n\n\nGoal\nConfirm the correct, secure configuration (least privilege) for the Outmarket AI trial integration with ImageRight at Seubert, including whether a dedicated service account is required and how to validate read vs write access for the AMS360 App Access Key.\n\n\nSincerely,\n\nRichard Murphy",
"isInternalNotesVisibleToComanaged": false,
"isNonBillable": false,
"lastModifiedDateTime": "2026-04-27T16:05:25.497Z",
"lastModifiedUserID": 30861463,
"offsetHours": 0,
"resourceID": 30861463,
"roleID": 29780072,
"showOnInvoice": true,
"startDateTime": "2026-04-27T15:51:00.000Z",
"summaryNotes": "All,\n\nHere is the email update information that I received from Richard at Vertafore Support. Please let me know if you have any questions or how you would like to proceed.\n\nQuestions / Assistance Requested\nAMS360 App Access Key\n\nIs the App Access Key tied to a specific VSSO user/security principal, or is it tenant-wide?\n\n- The APP access key is not tied to a specific user. It is tied to an integration user (Non-licensed user) configured in the application catalog.\nHow do we verify whether the key provides read-only vs read/write (create/update/delete) access?\n\n- The key itself only allows the 3rd party to authenticate against a database. This depends on the endpoints being used/activated & the security groups selected when configuring the app user when subscribing.\nWhere are the permissions governing the key configured (AMS360 vs VSSO)?\n\n- AMS360\nService/integration account best practice\n\nDo you recommend creating a dedicated service/integration account in VSSO for third-party integrations (Outmarket)?\n\n- No, there is no need for this as the integration is configured in AMS360 in the application catalog.\nIf yes, what is the recommended approach and minimum permission set for AMS360 and for ImageRight?\n\n\nImageRight access scope\nOutmarket AI does have an EMS application with all endpoints active.\n\nYou can find a list of the available endpoints at the following link.\n\nhttps://link.vertafore.com/VERTAFORE/documentation/AMS360/content?apiSlug=AMS360:EMS:master&resourceSlug=Rf21abdb9\nRichard Murphy\nSr. Customer Support Analyst\noffice. 1-800-444-4813 (Option 2)\nvertafore.com | rmurphy@vertafore.com\n\n---\n\nThanks,\nCory",
"taskID": null,
"ticketID": 680282,
"timeEntryType": 2
}
]
}

View file

@ -0,0 +1,330 @@
# AI Ticket Analyzer — Build Notes
A running journal of the multi-phase build for the AI Ticket Analyzer feature.
Captures what was delivered each phase, design decisions worth flagging, and
what was deliberately left out. Spec lives in
`wulf-pulse-ticket-analyzer-prompt.md`.
This file is updated after each phase ships.
---
## Phase 1 — Migration + Zod schemas
**Delivered**
- `migrations/069_create_analyzer_tables.sql` — three tables
(`analyzer_analyses`, `analyzer_shares`, `analyzer_jobs`) with `pgcrypto`
extension guard, indexes per spec, status `CHECK` constraints, and FK types
corrected to `TEXT` (not `UUID`) to match Better Auth's `user.id`.
- `lib/types/analyzer.ts` — Zod schemas for every LLM-stage parsed JSON
(`TaggedEvent`, `TriageResponse`, `DeepAnalysisResponse`, `OpusResponse`),
persisted row shapes, job status, API request bodies, and the internal
`PreprocessedTicket` payload that flows through the pipeline.
**Decisions worth flagging**
- `triggered_by_user_id` is **nullable + `ON DELETE SET NULL`** (not `NOT NULL`).
An analysis should still be readable in history if the triggering user is
later deleted. `analyzer_shares.shared_by_user_id` is `NOT NULL +
ON DELETE CASCADE` (audit-log style — share rows go with the user).
- `evidence_timestamps` typed as `string().datetime({offset: true})` (ISO
timestamps), not numeric indices. More robust to model hallucination and
reads better in the UI.
- `TaggedEvent` does NOT support a "two separate events" form for time entries
with both Summary + Internal Notes — single event with both fields, per the
spec's preference for a cleaner timeline.
**Deliberately left out**
- Migration is committed but **not applied** to any running DB. Postgres only
re-applies migrations on first init of a fresh volume; the existing DB needs
a manual run of this migration when phase 5 is exercised.
---
## Phase 2 — Stage 0 pre-processor + IT Glue redaction
**Delivered**
- `lib/services/analyzer/itglue-redact.ts` — recursive walk; matches keys
against `/password|secret|key|token|credential|api[_-]?key/i`; replaces
values with `[REDACTED]`. Subtree redaction (key matching `auth` → no leaves
leak), defensive copy, cycle guard (`[CIRCULAR]` marker).
- `lib/services/analyzer/preprocessor.ts` — filters workflow noise + Service
Desk Notification rows, tags ticket_create/notes/time entries with
`actor / actor_type / source / visibility / summary_notes / internal_notes /
hours`, sorts chronologically, computes `sha256` content hash over canonical
JSON of `(events, status, priority, queue)`.
- `vitest@^4.1.5` added as devDep with `vitest.config.ts` setting up the
`@/` alias. Two test files at this phase: 32 redaction tests + 36
preprocessor tests including the regression run against the
`T20260424.0045` fixture.
- Type-aliasing fix in `lib/types/analyzer.ts`: added `export type X =
z.infer<typeof X>` for `ActorType`, `EventSource`, `Visibility`, `Severity`,
`ComplexityTier`, `TicketType` — the Zod-enum const exports alone don't
produce a usable TypeScript type.
**Decisions worth flagging**
- `actor_type` is classified by **email domain, not author text**, per the
spec. `lorentz@wulfconsulting.com` is `wulf_tech` regardless of how the
message reads. Sonnet handles the role nuance at Stage 3.
- Time entries with no narrative content (no summary, no internal notes) are
dropped — a purely numeric entry adds nothing.
- Vendor-domain allowlist is intentionally short and conservative (Vertafore,
Datto, Microsoft, etc.). Misclassifying a customer domain as "vendor" is
worse than the default `client_contact`.
**Deliberately left out**
- No real protection for secrets embedded in **free text** (e.g. a notes field
containing the literal string "the password is hunter2"). The redaction
guarantee is on field **keys**, not values. The IT Glue search test
documents this contract explicitly so it isn't "fixed" without thought.
---
## Phase 3 — Anthropic SDK setup + Stage 1 (Haiku triage)
**Delivered**
- `lib/services/llm/{models,pricing,client,call}.ts` — model ID constants,
per-model rate table (Haiku $1/$5, Sonnet $3/$15, Opus $5/$25 per 1M tokens
+ cache read/write tiers), lazy SDK singleton, generic
`callLLMStage<T>({model, system, user, schema, maxTokens, client?})`
helper that:
- Marks the system prompt with `cache_control: {type: "ephemeral"}`
- Sends NO `temperature`/`top_p`/`top_k` (Opus 4.7 would 400)
- Strips a single ` ```json ` fence before parsing
- On parse failure, retries **once** with prior-attempt + error in a
follow-up user turn
- Returns `{data, usage, estimated_cost_usd, attempts, raw_response}`
- `lib/services/analyzer/stages/stage1-triage.ts` — Haiku caller with the
spec's verbatim system prompt and a 50KB user-payload cap that drops oldest
internal-only events first when oversized.
- `lib/services/analyzer/itglue-search.ts` — search facade that runs every
result through `redact()` before returning. Snippets capped at 2000 chars,
doc count capped at 10. `itglue-aliases.json` skeleton for known fuzzy
org-name mappings.
- 32 new tests across pricing/call/Stage 1/IT Glue search.
**Decisions worth flagging**
- **Manual JSON.parse + Zod validate, not `output_config.format` /
`client.messages.parse()`.** The spec said retry-once on Zod parse failure,
and Zod 4 ↔ JSON Schema conversion has edge cases I didn't want to depend
on (e.g. `.datetime({offset: true})` → JSON Schema `format`). Manual parse
is what the spec asks for and is more transparent.
- **Prompt caching probably won't fire on these stages.** Our system prompts
are ~1-2 KB (~250-500 tokens); minimum cacheable prefix is 2048 (Sonnet) or
4096 (Haiku/Opus) tokens. The `cache_control` marker is a no-op below
threshold and incurs no cost — left in defensively, but caching is not a
meaningful lever for this workload.
- **Redaction is on KEY names, not free text** — re-stated for emphasis. The
IT Glue search test asserts this contract.
- **`itglue-aliases.json` is a skeleton** with `_comment` / `_example` keys
documenting the format. Real org-id entries get added when phase 5 wires
this into the pipeline.
**Deliberately left out**
- No live API integration test. All Stage 1 tests use a mocked `Anthropic`
client. A real-API smoke test belongs in phase 5+ when we run end-to-end.
- Did **not** wrap `lib/services/itglue-client.ts` in a redacting search
facade for non-LLM callers. The redaction primitive is ready; non-LLM
callers (sync service, data browsers) intentionally have full data — they
are not the path that needs protection.
---
## Phase 4 — Pipeline + Job worker (Stages 3, 4, 5)
**Delivered**
- `lib/services/analyzer/stages/stage3-deep-analysis.ts` — Sonnet caller,
spec's verbatim prompt, 80KB payload cap.
- `lib/services/analyzer/stages/stage4-deep-reasoning.ts` — Opus caller plus
pure helpers `shouldRunDeepReasoning()` and `applyOpusUpdates()`.
- `lib/services/analyzer/data-access.ts``loadTicketBundle(ticketNumber)`
joins `tickets` / `statuses` / `priorities` / `queues` / `companies` /
`contacts` / `resources` / `ticket_notes` / `time_entries` and returns the
exact `RawTicketBundle` shape the preprocessor expects. Throws typed
`TicketNotFoundError`.
- `lib/services/analyzer/persistence.ts``getNextAnalysisVersion`,
`findExistingAnalysisByContentHash`, `insertAnalysis`, plus job
`claimQueuedJob` / `updateJobStatus` / `completeJob` / `failJob` /
`queueJob` / `getJob`. Job claim uses `FOR UPDATE SKIP LOCKED` so multiple
Next.js workers can poll safely.
- `lib/services/analyzer/pipeline.ts` — composes Stage 0 → idempotency check
→ Stage 1 → (Stage 2 if `itglue_lookup_needed`) → Stage 3 → (Stage 4 if
trigger fires AND cost ceiling not reached) → result. Cost circuit breaker
trips at $2.00 before Opus, sets `needs_human_review=true` with a
reason. Returns full `model_traces` for debugging.
- `lib/services/analyzer/worker.ts` — singleton with 2-second poll loop;
auto-starts in production; opt-in in dev via
`ANALYZER_WORKER_AUTOSTART=1`; **skipped** under vitest. `runJob()`
exposed for tests + manual triggers. `TicketNotFoundError` produces a
user-facing job error message that fingers the sync as the culprit.
- 28 new tests bringing the total to 128.
**Decisions worth flagging**
- **Worker auto-start is more conservative than `sync-scheduler.ts`.** That
one auto-starts on any non-browser import (including tests). I gated this
one because the worker hits the database AND runs LLM calls — much higher
blast radius. To run locally, set `ANALYZER_WORKER_AUTOSTART=1`.
- **Cost circuit breaker only fires before Opus.** Sonnet runs unconditionally
even if it would push past $2. Spec wording matches; if the team wants
stricter control, the natural place is next to `COST_CEILING_USD` in
`pipeline.ts`.
- **Idempotency check matches `status='complete'` only.** Failed runs don't
poison the cache.
- **IT Glue failures are tolerated.** Search throws → analysis continues
without context, doesn't fail the run.
- `worker.test.ts` casts `mockImplementationOnce` to `as never` because
vitest's overload resolution fights us when the mocked function has multiple
call signatures. Functional, just ugly.
**Deliberately left out**
- No live API integration test (still). Phase 5+ is the natural place for an
end-to-end smoke test.
- Stage 3 doesn't have a fixture-driven happy-path test like Stage 1; it's
exercised at the orchestration layer via `pipeline.test.ts` only. Worth
adding direct Stage 3 tests later.
- Migration `069` still not applied — same as phase 1.
---
## Phase 5 — API routes
**Delivered**
- Persistence read paths added to `lib/services/analyzer/persistence.ts`:
`getAnalysisById`, `listAnalysesByTicketNumber`, `listNeedsReview`,
`createShare`, plus a shared `rowToPersistedAnalysis` row mapper.
- 6 routes under `app/api/analyzer/`:
| Route | Method | Returns |
|---|---|---|
| `tickets/[ticketNumber]/analyze` | POST | `{status, jobId?, existingAnalysisId?}` |
| `tickets/[ticketNumber]/analyses` | GET | `{analyses: PersistedAnalysis[]}` |
| `jobs/[jobId]` | GET | `{job}` |
| `analyses/[id]` | GET | `{analysis}` |
| `analyses/[id]/share` | POST | `{share}` |
| `needs-review?limit=&offset=` | GET | `{analyses}` |
**Decisions worth flagging**
- **Analyze runs preprocess inline.** The route does
`loadTicketBundle → preprocessTicket → findExistingAnalysisByContentHash`
synchronously to support the spec's `existingAnalysisId?` immediate
response. The worker also runs this — duplicated work, but preprocess is
fast (deterministic, one DB load) and the alternative (always queue,
frontend polls to discover the short-circuit) is worse UX.
- **Email send deferred to phase 8** per the spec's delivery order. The share
route persists the audit row and validates the recipient domain against
`ALLOWED_SHARE_DOMAINS`. Until phase 8, share rows have `viewed_at = null`
indefinitely.
- **`requireAuth()` everywhere — not `requireAdmin()`.** Any authenticated
user can analyze a ticket they have access to. If `/needs-review` should
be admin-only later, swap that one to `requirePermission('analyzer',
'review')` once the permission map is decided.
- **No new permission entries added to `lib/permissions.ts`.** Adding scoped
permissions for a feature still under build risks getting them wrong.
- Routes are NOT in `middleware.ts` `publicRoutes` — they require a session.
**Deliberately left out**
- No API route tests. The repo has zero `app/api/**/*.test.ts` files; the
routes are thin orchestration on top of already-tested persistence. Adding
integration tests means setting up a test harness for the auth helpers +
Postgres, which is a separate effort.
---
## Phase 6 — Frontend pages
**Delivered**
- `components/analyzer/analyze-button.tsx``<AnalyzeButton>` with the full
state machine: POSTs to the analyze endpoint, navigates straight to an
existing analysis if `existingAnalysisId` came back, otherwise polls
`/api/analyzer/jobs/:jobId` every 2s and renders stage labels (Queued →
Fetching → Triaging → Searching IT Glue → Analyzing → Deep review → Done).
5-minute hard timeout. Failures surface as toast errors. Supports `force`
for explicit re-run.
- `components/analyzer/share-modal.tsx``<ShareModal>` with a shadcn Dialog
+ email field + optional note (max 2000 chars). POSTs to the share
endpoint and surfaces server-side validation errors (domain not allowed →
toast).
- `components/analyzer/analysis-view.tsx` — the full 10-section analysis
layout per spec. Header with model-tier badges (Haiku/Sonnet/Opus pills),
confidence score, total cost, Share + Re-analyze buttons. Summary, Next
Step (with rationale collapsed), Timeline (vertical list with 🟢 / 🔒 /
🔄 markers, click to expand), What Was Done / Should Have Been Done
side-by-side on wide screens, Gaps colored by severity with "Evidence:"
links that scroll-and-expand the matching timeline event,
Post-Resolution (only if present), Human Review Flags (only if needed),
IT Glue References.
- `app/analyzer/ticket/[ticketNumber]/page.tsx` — ticket detail with the
Analyze button and a list of historical versions; latest is badged.
- `app/analyzer/analysis/[id]/page.tsx` — fetches one analysis and renders
it via `<AnalysisView>`.
- `app/analyzer/queue/page.tsx` — needs-review queue, shows ticket number,
version, summary preview, top reasons, confidence badge, and a `high gap`
badge if any gap is high severity.
**Decisions worth flagging**
- **Imperative `useState` + `useEffect` + `fetch`**, no SWR / react-query —
matches CLAUDE.md and the rest of the repo. Don't refactor to a global
cache layer for these three pages; if it becomes a real pain, that's a
whole-app concern.
- **`'use client'` everywhere.** The pages use `use(params)` (the React
hook) to unwrap Next.js 16's `params: Promise<...>` shape on the client.
Server components weren't appropriate here — every page does interactive
state (analyze flow, expand events, share modal).
- **Stage labels render directly from `JobStatus` enum values**, not a
separate label list, so any new statuses added to the enum auto-render
with their default name.
- **Re-analyze banner not implemented yet.** The spec calls for "New
activity since last analysis · Re-analyze" when the live content_hash
drifts from the persisted one. That requires a live-preprocess endpoint
(or running preprocess on the page render). Skipped for now — the user
can always click Re-analyze. Worth adding once we have real-world signal
on whether activity drift is common.
- **No autocomplete on the share-recipient field.** Spec says "autocomplete
from existing wulf-pulse user list if available". Skipped — the existing
user list is in Better Auth's `user` table; exposing it requires a
small API endpoint. Easy follow-up.
- **No navigation entry yet.** `components/navigation/app-navigation.tsx`
doesn't yet have an "Analyzer" link. Adding that is a one-line edit; I
left it for the operator to opt in once the feature is staged.
- **Printable analysis view** — the spec mentions "printable" for the
analysis page. The current layout is print-friendly by accident (no
fixed sidebars, sectioned cards), but no explicit `@media print` styles
yet. Add when someone asks.
**Deliberately left out**
- No frontend tests. vitest is configured for `lib/**/*.test.ts` only; the
pages and components are visually verified. Component tests with
testing-library would be a separate setup decision.
- Analysis view doesn't auto-refresh while a job is running on a
*different* version. If a user navigates to an old version while a new
one is in progress, they don't see the in-progress state. Acceptable —
the queue view + ticket history give that signal.
---
## Status after each phase
| Phase | Tests | tsc | Notes |
|---|---|---|---|
| 1 | 0 | clean | migration + types only |
| 2 | 68 | clean | redaction + preprocessor regression |
| 3 | 100 | clean | + LLM scaffolding + Stage 1 |
| 4 | 128 | clean | + pipeline + worker |
| 5 | 128 | clean | API routes (no route tests) |
| 6 | 128 | clean | frontend (no FE tests) |

View file

@ -0,0 +1,227 @@
/**
* Data-access layer for the AI Ticket Analyzer.
*
* Loads a ticket from the local Postgres mirror in the exact shape the
* pre-processor consumes (header + ticket_notes + time_entries with creator
* names/emails resolved by JOIN).
*
* After the ticket-notes sync fix landed (lib/services/entity-sync syncTicketNotes
* + scheduled incremental sync), the local DB is the canonical source for an
* analyzer run.
*/
import postgresClient from '@/lib/services/postgres-client';
import type { RawTicketBundle } from './preprocessor';
export class TicketNotFoundError extends Error {
constructor(public ticketNumber: string) {
super(`Ticket ${ticketNumber} not found in local mirror`);
this.name = 'TicketNotFoundError';
}
}
interface TicketRow {
id: string;
ticket_number: string;
title: string;
description: string | null;
status: number;
status_label: string | null;
priority: number;
priority_label: string | null;
queue_id: number | null;
queue_label: string | null;
company_id: string;
company_name: string | null;
contact_id: string | null;
contact_name: string | null;
contact_email: string | null;
assigned_resource_id: string | null;
assignee_name: string | null;
assignee_email: string | null;
create_date: Date | string;
last_activity_date: Date | string;
resolved_date_time: Date | string | null;
}
interface NoteRow {
id: string;
title: string | null;
description: string;
note_type: number | null;
publish: number | null;
creator_resource_id: string | null;
creator_name: string | null;
creator_email: string | null;
creator_type: number | null;
create_date_time: Date | string | null;
}
interface TimeEntryRow {
id: string;
resource_id: string;
resource_name: string | null;
resource_email: string | null;
hours_worked: string;
notes: string | null;
internal_notes: string | null;
entry_date: Date | string | null;
start_date_time: Date | string | null;
end_date_time: Date | string | null;
type: number | null;
}
function toIso(d: Date | string | null): string | null {
if (d === null || d === undefined) return null;
if (d instanceof Date) return d.toISOString();
return new Date(d).toISOString();
}
function toIsoRequired(d: Date | string): string {
if (d instanceof Date) return d.toISOString();
return new Date(d).toISOString();
}
export async function loadTicketBundle(
ticketNumber: string
): Promise<RawTicketBundle> {
const ticketRes = await postgresClient.query<TicketRow>(
`
SELECT
t.id::text AS id,
t.ticket_number AS ticket_number,
t.title AS title,
t.description AS description,
t.status AS status,
(SELECT label FROM statuses WHERE value = t.status) AS status_label,
t.priority AS priority,
(SELECT label FROM priorities WHERE value = t.priority) AS priority_label,
t.queue_id AS queue_id,
(SELECT label FROM queues WHERE value = t.queue_id) AS queue_label,
t.company_id::text AS company_id,
(SELECT company_name FROM companies WHERE id = t.company_id) AS company_name,
t.contact_id::text AS contact_id,
(SELECT TRIM(first_name || ' ' || last_name) FROM contacts WHERE id = t.contact_id)
AS contact_name,
(SELECT email_address FROM contacts WHERE id = t.contact_id)
AS contact_email,
t.assigned_resource_id::text AS assigned_resource_id,
(SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = t.assigned_resource_id)
AS assignee_name,
(SELECT email FROM resources WHERE id = t.assigned_resource_id)
AS assignee_email,
t.create_date AS create_date,
t.last_activity_date AS last_activity_date,
t.resolved_date_time AS resolved_date_time
FROM tickets t
WHERE t.ticket_number = $1
AND COALESCE(t.is_deleted, false) = false
LIMIT 1
`,
[ticketNumber]
);
if (ticketRes.rowCount === 0) {
throw new TicketNotFoundError(ticketNumber);
}
const t = ticketRes.rows[0];
const ticketIdNum = Number(t.id);
const notesRes = await postgresClient.query<NoteRow>(
`
SELECT
n.id::text AS id,
n.title AS title,
n.description AS description,
n.note_type AS note_type,
n.publish AS publish,
n.creator_resource_id::text AS creator_resource_id,
(SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = n.creator_resource_id)
AS creator_name,
(SELECT email FROM resources WHERE id = n.creator_resource_id)
AS creator_email,
n.creator_type AS creator_type,
n.create_date_time AS create_date_time
FROM ticket_notes n
WHERE n.ticket_id = $1
AND COALESCE(n.is_deleted, false) = false
ORDER BY n.create_date_time
`,
[ticketIdNum]
);
const entriesRes = await postgresClient.query<TimeEntryRow>(
`
SELECT
te.id::text AS id,
te.resource_id::text AS resource_id,
(SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = te.resource_id)
AS resource_name,
(SELECT email FROM resources WHERE id = te.resource_id)
AS resource_email,
te.hours_worked::text AS hours_worked,
te.notes AS notes,
te.internal_notes AS internal_notes,
te.entry_date AS entry_date,
te.start_date_time AS start_date_time,
te.end_date_time AS end_date_time,
te.type AS type
FROM time_entries te
WHERE te.ticket_id = $1
AND COALESCE(te.is_deleted, false) = false
ORDER BY te.entry_date, te.id
`,
[ticketIdNum]
);
return {
ticket: {
id: ticketIdNum,
ticket_number: t.ticket_number,
title: t.title,
description: t.description,
status: t.status,
status_label: t.status_label,
priority: t.priority,
priority_label: t.priority_label,
queue_id: t.queue_id,
queue_label: t.queue_label,
company_id: Number(t.company_id),
company_name: t.company_name,
contact_id: t.contact_id ? Number(t.contact_id) : null,
contact_name: t.contact_name,
contact_email: t.contact_email,
assigned_resource_id: t.assigned_resource_id ? Number(t.assigned_resource_id) : null,
assignee_name: t.assignee_name,
assignee_email: t.assignee_email,
create_date: toIsoRequired(t.create_date),
last_activity_date: toIsoRequired(t.last_activity_date),
resolved_date_time: toIso(t.resolved_date_time),
},
notes: notesRes.rows.map((n) => ({
id: Number(n.id),
title: n.title,
description: n.description ?? '',
note_type: n.note_type,
publish: n.publish,
creator_resource_id: n.creator_resource_id ? Number(n.creator_resource_id) : null,
creator_name: n.creator_name,
creator_email: n.creator_email,
creator_type: n.creator_type,
create_date_time: toIso(n.create_date_time),
})),
time_entries: entriesRes.rows.map((e) => ({
id: Number(e.id),
resource_id: Number(e.resource_id),
resource_name: e.resource_name,
resource_email: e.resource_email,
hours_worked: Number(e.hours_worked),
notes: e.notes,
internal_notes: e.internal_notes,
entry_date: toIso(e.entry_date),
start_date_time: toIso(e.start_date_time),
end_date_time: toIso(e.end_date_time),
type: e.type,
})),
};
}

View file

@ -0,0 +1,113 @@
{
"preprocessor": {
"filtered_workflow_noise_ids": [
33738631,
33738632,
33738633,
33738634
],
"filtered_email_notification_ids_in_input": [
33738776,
33738797,
33741514,
33741844
],
"retained_note_ids": [
33738796
],
"retained_time_entry_ids": [
465933,
465961,
465968,
466134,
466183
]
},
"required_findings": [
{
"id": "F1_original_ask_narrower",
"severity": "low",
"description": "The original requestor email asked for a credential location for an existing integration (loss run pro / claims department), narrower than the broader vendor-integration scope the ticket pivoted to.",
"evidence": [
{
"kind": "time_entry_internal_notes",
"id": 465933
}
],
"grounded": true
},
{
"id": "F2_customer_said_stop",
"severity": "high",
"description": "Requestor (Lorentz Hinrichsen) posted a ticket note on 2026-04-24 indicating he could proceed independently and that no further outreach to Vertafore was needed.",
"evidence": [
{
"kind": "ticket_note",
"id": 33738796
}
],
"grounded": true,
"notes_for_review": "This note is in Autotask but was NOT in the local ticket_notes table at fixture-build time. The analyzer must source notes either live or via a fixed sync."
},
{
"id": "F3_work_continued_after_stop",
"severity": "high",
"description": "On 2026-04-27 (next business day after the requestor said \"I'll take it from here\"), the assigned tech took a call from Vertafore and logged ~20 minutes of additional work (entries on 04/27).",
"evidence": [
{
"kind": "ticket_note",
"id": 33738796
},
{
"kind": "time_entry",
"id": 466134
},
{
"kind": "time_entry",
"id": 466183
}
],
"grounded": true
},
{
"id": "F4_status_does_not_match_reality",
"severity": "medium",
"description": "Ticket status remains \"Waiting Customer\" though the requestor effectively closed the loop on 04/24. resolved_date_time is null three days later.",
"evidence": [
{
"kind": "ticket_field",
"field": "status_label",
"value": "Waiting Customer"
},
{
"kind": "ticket_field",
"field": "resolved_date_time",
"value": null
},
{
"kind": "ticket_note",
"id": 33738796
}
],
"grounded": true
}
],
"expected_next_step_keywords": [
"confirm with requestor",
"Vertafore",
"close"
],
"sync_gap_observed": {
"live_note_count": 9,
"synced_note_count": 2,
"missing_note_ids": [
33738631,
33738632,
33738633,
33738634,
33738776,
33738796,
33738797
]
}
}

View file

@ -0,0 +1,207 @@
{
"ticket": {
"id": 680282,
"ticket_number": "T20260424.0045",
"title": "Outmarket AI vendor integration request — AMS360 App Access Key & ImageRight credentials (verify access level)",
"description": "Requestor: Lorentz Hinrichsen (forwarded from Tyler Lyster / Seubert; cc Kristie Lulich, Richard Mansfield)\n\nSummary:\nRequest to add/setup vendor integration for Outmarket AI and provide required connection details for AMS360 and ImageRight to enable an integration trial.\n\nIssue Details:\n- AMS360 required details: Agency Number, App Access Key.\n- ImageRight required details: Base URL, Tenant ID, admin username, admin password.\n- Key question: Determine whether provided credentials/API key allow create/update/delete (write) access or are read-only, as access level depends on permissions tied to the AMS360 integration key/user and the ImageRight account/role.\n\nUser/System Information:\n- Requestor contact: Lorentz Hinrichsen (contactID: 31864346).\n- Vendor: Outmarket AI integration trial.\n- Affected systems: AMS360 (integration key/app access) and ImageRight (tenant/URL/credentials).\n\nAdditional Context:\n- Ticket opened for research and validation of where AMS360 App Access Key is managed, the security context/permissions of the AMS360 integration account, and confirmation of ImageRight Base URL/Tenant and required credential scope.\n- No configuration item specified.",
"status": 7,
"status_label": "Waiting Customer",
"priority": 8,
"priority_label": "Minor Service",
"queue_id": 29682969,
"queue_label": "Level 2 Support",
"company_id": 29683407,
"company_name": "Seubert and Associates",
"contact_id": 31864346,
"contact_name": "Tyler Lyster",
"contact_email": "tlyster@seubert.com",
"assigned_resource_id": 30861443,
"assignee_name": "Collin Imler",
"assignee_email": "cimler@wulfconsulting.com",
"create_date": "2026-04-24T12:53:50.163Z",
"last_activity_date": "2026-04-29T13:09:58.070Z",
"resolved_date_time": null
},
"notes": [
{
"id": 33738631,
"title": "Workflow Rule \"Wulf Managed - New ticket confirmation to 'Ticket Contact'\" fired.",
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nTicket Type not equal to \"Alert\"\r\nUDF (Company): Client Service Model not equal to \"Co-Managed (Our Autotask)\"\r\nTicket Category in list \"Ticket Triage or Service Desk\"\r\nQueue not equal to \"Post Sale\"\r\nThen execute the following actions:\r\nSend notification e-mail (2026 - Wulf Consulting Support Request Received) to Ticket Contact (tlyster@seubert.com)\r\nInitiated by Cory Houck",
"note_type": 13,
"publish": 1,
"creator_resource_id": 4,
"creator_name": "Autotask Administrator",
"creator_email": null,
"creator_type": null,
"create_date_time": "2026-04-24T12:54:07.497Z"
},
{
"id": 33738632,
"title": "Workflow Rule \"Wulf - New VIP contact tickets sent to Fast Track\" fired.",
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nUDF (Contact): VIP Contact equal to \"Yes\"\r\nTicket Type not equal to \"Alert\"\r\nQueue in list \"Client Triage or Level 1 Support or Level 2 Support or Level 3 Support\"\r\nPriority not equal to \"Fast Track\"\r\nThen execute the following actions:\r\nSet Priority To \"Fast Track\"\r\nInitiated by Cory Houck",
"note_type": 13,
"publish": 1,
"creator_resource_id": 4,
"creator_name": "Autotask Administrator",
"creator_email": null,
"creator_type": null,
"create_date_time": "2026-04-24T12:54:07.643Z"
},
{
"id": 33738633,
"title": "Workflow Rule \"Wulf - New Fast Track Ticket\" fired.",
"description": "When a ticket is:\r\nCreated\r\nAnd the following conditions are met:\r\nPriority equal to \"Fast Track\"\r\nStatus not equal to \"Complete\"\r\nTicket Category in list \"Service Desk\"\r\nThen execute the following actions:\r\nAssign To Queue \"Level 2 Support\"\r\nSet Service Level Agreement As \"Service Desk - Standard SLA\"\r\nSend notification e-mail (WulfNotify- Fast Track) to 3f7be19e.wulfconsulting.com@amer.teams.ms; 98b286b7.wulfconsulting.com@amer.teams.ms; Triage, FastTrack, & On-Call (cimler@wulfconsulting.com, cory.houck@wulfconsulting.com, Darrius.Jones@wulfconsulting.com, david.bauer@wulfconsulting.com, dbucci@wulfconsulting.com, evan.life@wulfconsulting.com, james.hubbard@wulfconsulting.com, jer15202@hotmail.com, jeremy@wulfconsulting.com, sean.glenn@wulfconsulting.com)\r\nInitiated by Cory Houck",
"note_type": 13,
"publish": 1,
"creator_resource_id": 4,
"creator_name": "Autotask Administrator",
"creator_email": null,
"creator_type": null,
"create_date_time": "2026-04-24T12:54:07.923Z"
},
{
"id": 33738634,
"title": "Workflow Rule \"Wulf - Sent to Fast Track Ticket\" fired.",
"description": "When a ticket is:\r\nEdited\r\nAnd the following conditions are met:\r\nPriority changed to \"Fast Track\"\r\nStatus not equal to \"Complete\"\r\nTicket Category equal to \"Service Desk\"\r\nThen execute the following actions:\r\nAssign To Queue \"Level 2 Support\"\r\nSet Service Level Agreement As \"Service Desk - Standard SLA\"\r\nSend notification e-mail (WulfNotify- Fast Track) to 3f7be19e.wulfconsulting.com@amer.teams.ms; 98b286b7.wulfconsulting.com@amer.teams.ms; Triage, FastTrack, & On-Call (cimler@wulfconsulting.com, cory.houck@wulfconsulting.com, Darrius.Jones@wulfconsulting.com, david.bauer@wulfconsulting.com, dbucci@wulfconsulting.com, evan.life@wulfconsulting.com, james.hubbard@wulfconsulting.com, jer15202@hotmail.com, jeremy@wulfconsulting.com, sean.glenn@wulfconsulting.com)\r\nInitiated by Cory Houck",
"note_type": 13,
"publish": 1,
"creator_resource_id": 4,
"creator_name": "Autotask Administrator",
"creator_email": null,
"creator_type": null,
"create_date_time": "2026-04-24T12:54:08.170Z"
},
{
"id": 33738776,
"title": "Service Desk Notification",
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"note_type": 2,
"publish": 4,
"creator_resource_id": 30861463,
"creator_name": "Cory Houck",
"creator_email": "cory.houck@wulfconsulting.com",
"creator_type": null,
"create_date_time": "2026-04-24T14:22:36.230Z"
},
{
"id": 33738796,
"title": "Wulf Support Ticket Update -",
"description": "Cory,\r\n\r\nI was able to accessthe Vertafore Developer portal and determine what is necessary - no need to reach out to Vertafore. Ill take it from here, thank you!\r\n\r\nLorentz\r\n\r\nFrom: Wulf Consulting Support <support@wulfconsulting.com>\r\nDate: Friday, April 24, 2026 at 10:22?AM\r\nTo: Lorentz W. Hinrichsen <lorentz@wulfconsulting.com>; rmansfield@seubert.com <rmansfield@seubert.com>; klulich@seubert.com <klulich@seubert.com>; tlyster@seubert.com <tlyster@seubert.com>\r\nSubject: Wulf Support Ticket Update - T20260424.0045\r\n\r\n[Wulf Consulting]\r\nUpdate on Your Support Ticket\r\nHello Tyler, here is the latest update regarding your support ticket.\r\nTicket Number: T20260424.0045\r\nStatus: In Progress\r\nTitle: Outmarket AI vendor integration request — AMS360 App Access Key & ImageRight credentials (verify access level)\r\nAssigned Technician: Cory Houck\r\nLatest Update\r\nAll,\r\n\r\nIve been researching the AMS360/VSSO side and Im not seeing an existing dedicated service/integration account available to use for this connection. My next step is to contact Vertafore Support and open a case to confirm the recommended setup and what level of access the App Access Key provides.\r\n\r\nIll follow up as soon as I hear back from Vertafore.\r\n\r\nThanks,\r\nCory\r\n\r\n\r\nIf you have any questions or additional information to share, please reply directly to this email and your response will be added to the ticket automatically.\r\nThank you,\r\n\r\nWulf Consulting Service Desk\r\n412-224-6200 | Option 1 for Support\r\nPlease select a rating below to share feedback on this update.\r\n<https://web.crewhu.com/#/survey?crewhu_id=65cfa4d2fb765076d791ff94&dummyLink=680282>\r\n\r\n[https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=5&custom_code=]<https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=5&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true> [https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=0&custom_code=] <https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=0&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true> [https://be.crewhu.com/external/v1/snippet/image?company=65cfa4d2fb765076d791ff94&rating=-5&custom_code=] <https://web.crewhu.com/#/survey?v=f.45b8bd9&crewhu_id=65cfa4d2fb765076d791ff94&survey_type_code=typReply&custom_code=&rating=-5&partner_key=680282&ticket_num=T20260424.0045&survey_salt=&employees_ids=CoryHouck&customer_id=29683407&contact_email=tlyster@seubert.com&contact_first_name=Tyler&contact_last_name=Lyster&contact_phone=412-734-4900&customer_name=Seubert%20and%20Associates&summary=Outmarket%20AI%20vendor%20integration%20request%20—%20AMS360%20App%20Access%20Key%20&%20ImageRight%20credentials%20(verify%20access%20level)&end=true>\r\nHighly\r\nSatisfied\r\nSomewhat\r\nSatisfied\r\nDissatisfied\r\n<https://web.crewhu.com/#/survey?crewhu_id=65cfa4d2fb765076d791ff94&dummyLink=680282>\r\n\r\n**Created via Incoming Email Processor**\r\nFrom: \"Lorentz W. Hinrichsen\" <lorentz@wulfconsulting.com>\r\nTo: Wulf Support <support@wulfconsulting.com>, \"rmansfield@seubert.com\"\t<rmansfield@seubert.com>, \"klulich@seubert.com\" <klulich@seubert.com>, \"tlyster@seubert.com\" <tlyster@seubert.com>",
"note_type": 1,
"publish": 1,
"creator_resource_id": 29683311,
"creator_name": "Lorentz Hinrichsen",
"creator_email": "lorentz@wulfconsulting.com",
"creator_type": null,
"create_date_time": "2026-04-24T14:34:46.583Z"
},
{
"id": 33738797,
"title": "Service Desk Notification",
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"note_type": 2,
"publish": 4,
"creator_resource_id": 30861463,
"creator_name": "Cory Houck",
"creator_email": "cory.houck@wulfconsulting.com",
"creator_type": null,
"create_date_time": "2026-04-24T14:35:35.493Z"
},
{
"id": 33741514,
"title": "Service Desk Notification",
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"note_type": 2,
"publish": 4,
"creator_resource_id": 30861463,
"creator_name": "Cory Houck",
"creator_email": "cory.houck@wulfconsulting.com",
"creator_type": null,
"create_date_time": "2026-04-27T13:26:36.047Z"
},
{
"id": 33741844,
"title": "Service Desk Notification",
"description": "tlyster@seubert.com, klulich@seubert.com, rmansfield@seubert.com, lorentz@wulfconsulting.com",
"note_type": 2,
"publish": 4,
"creator_resource_id": 30861463,
"creator_name": "Cory Houck",
"creator_email": "cory.houck@wulfconsulting.com",
"creator_type": null,
"create_date_time": "2026-04-27T16:05:26.043Z"
}
],
"time_entries": [
{
"id": 465933,
"resource_id": 30861463,
"resource_name": "Cory Houck",
"resource_email": "cory.houck@wulfconsulting.com",
"hours_worked": 0.1667,
"notes": "see internal",
"internal_notes": "Lorentz sent me an email\n\nIm 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 and if theres anything special, I have to do to access it",
"entry_date": "2026-04-24T00:00:00.000Z",
"start_date_time": "2026-04-24T13:01:00.000Z",
"end_date_time": "2026-04-24T13:07:00.000Z",
"type": null
},
{
"id": 465961,
"resource_id": 30861463,
"resource_name": "Cory Houck",
"resource_email": "cory.houck@wulfconsulting.com",
"hours_worked": 0.75,
"notes": "All, \n\nIve been researching the AMS360/VSSO side and Im not seeing an existing dedicated service/integration account available to use for this connection. My next step is to contact Vertafore Support and open a case to confirm the recommended setup and what level of access the App Access Key provides. \n\nIll follow up as soon as I hear back from Vertafore.\n\nThanks,\nCory",
"internal_notes": "Logged into AMS360 and reviewed available administration/security areas. AMS360 client did not present an obvious User Management/Users list location for identifying existing service/integration accounts.\n\nLogged into Vertafore SSO (VSSO) Admin Console and reviewed the Managed Users list.\n\n- Performed multiple searches for potential service/integration accounts using common naming patterns/keywords including: svc, service, integration, integr, api, vendor, outmarket, wulf, system, plus additional generic terms (interface/sync/import/export/webservice/batch).\n- Checked for alternate filters/scopes where available (ex: inactive/disabled users) and did not locate any accounts that appear to be dedicated service/integration accounts.\nReviewed VSSO Groups/Roles (where available) for integration-related naming (ex: AMS/integration/api/vendor/system) to determine if any existing integration grouping exists and did not identify anything clearly tied to an existing vendor/service account setup.\n\nCurrent conclusion: No existing dedicated service/integration account is identifiable/available via VSSO Managed Users (or related group/role searches). Access level for any future integration account remains unknown until confirmed with Vertafore.\n\n\nCurrent Status\nResearch/validation completed internally.\n\nAccess scope for the AMS360 App Access Key (read-only vs read/write/create/update) cannot be confirmed from current admin views and requires vendor confirmation.",
"entry_date": "2026-04-24T00:00:00.000Z",
"start_date_time": "2026-04-24T13:25:00.000Z",
"end_date_time": "2026-04-24T14:21:00.000Z",
"type": null
},
{
"id": 465968,
"resource_id": 30861463,
"resource_name": "Cory Houck",
"resource_email": "cory.houck@wulfconsulting.com",
"hours_worked": 0.25,
"notes": "All,\n\nI created a case with Vertafore Support to confirm the recommended setup and access scope for the AMS360/ImageRight integration credentials.\n\nAccount #: 1100080\nCase #: 4581488\n\nIll provide an update as soon as I hear back from Vertafore.\n\nThanks,\nCory",
"internal_notes": null,
"entry_date": "2026-04-24T00:00:00.000Z",
"start_date_time": "2026-04-24T14:21:00.000Z",
"end_date_time": "2026-04-24T14:33:00.000Z",
"type": null
},
{
"id": 466134,
"resource_id": 30861463,
"resource_name": "Cory Houck",
"resource_email": "cory.houck@wulfconsulting.com",
"hours_worked": 0.1667,
"notes": "Hi all,\nI received a call from Richard at Vertafore Support. Hell be assisting with the AMS360 integration, and he is opening a separate case for the ImageRight application integration. Hell be following up later today with additional information.\n\nIll update this ticket as soon as I receive his follow-up.",
"internal_notes": null,
"entry_date": "2026-04-27T00:00:00.000Z",
"start_date_time": "2026-04-27T13:16:00.000Z",
"end_date_time": "2026-04-27T13:22:00.000Z",
"type": null
},
{
"id": 466183,
"resource_id": 30861463,
"resource_name": "Cory Houck",
"resource_email": "cory.houck@wulfconsulting.com",
"hours_worked": 0.1667,
"notes": "All,\n\nHere is the email update information that I received from Richard at Vertafore Support. Please let me know if you have any questions or how you would like to proceed.\n\nQuestions / Assistance Requested\nAMS360 App Access Key\n\nIs the App Access Key tied to a specific VSSO user/security principal, or is it tenant-wide?\n\n- The APP access key is not tied to a specific user. It is tied to an integration user (Non-licensed user) configured in the application catalog.\nHow do we verify whether the key provides read-only vs read/write (create/update/delete) access?\n\n- The key itself only allows the 3rd party to authenticate against a database. This depends on the endpoints being used/activated & the security groups selected when configuring the app user when subscribing.\nWhere are the permissions governing the key configured (AMS360 vs VSSO)?\n\n- AMS360\nService/integration account best practice\n\nDo you recommend creating a dedicated service/integration account in VSSO for third-party integrations (Outmarket)?\n\n- No, there is no need for this as the integration is configured in AMS360 in the application catalog.\nIf yes, what is the recommended approach and minimum permission set for AMS360 and for ImageRight?\n\n\nImageRight access scope\nOutmarket AI does have an EMS application with all endpoints active.\n\nYou can find a list of the available endpoints at the following link.\n\nhttps://link.vertafore.com/VERTAFORE/documentation/AMS360/content?apiSlug=AMS360:EMS:master&resourceSlug=Rf21abdb9\nRichard Murphy\nSr. Customer Support Analyst\noffice. 1-800-444-4813 (Option 2)\nvertafore.com | rmurphy@vertafore.com\n\n---\n\nThanks,\nCory",
"internal_notes": "Richard from Vertafore Support created a case to address the ImageRight integration. \n\nDear Cory,\n\nThank you for contacting Vertafore Support. Your case has been logged and a Support Agent will follow up with you as soon as possible. Please retain the details below for future reference.\n\nAccount #: 1100080\nCase #: 4582409\n\nSubject: ImageRight integration credentials — confirm App Access Key security context and least-privilege access (Outmarket AI / Seubert)\nDescription: We support Seubert and are assisting with a third-party integration trial with Outmarket AI, which will connect to both AMS360 and ImageRight. Outmarket is requesting connection details/credentials for both systems.\n\nImageRight credentials are managed through Vertafore SSO (VSSO) in this environment. We need Vertafores guidance to confirm the security/permission model and the recommended least-privilege setup before providing any credentials to the vendor.\n\nQuestions / Assistance Requested\n\n\n- Is the App Access Key tied to a specific VSSO user/security principal, or is it tenant-wide?\n- How do we verify whether the key provides read-only vs read/write (create/update/delete) access?\n- Where are the permissions governing the key configured (IR vs VSSO)?\nService/integration account best practice\n\n- Do you recommend creating a dedicated service/integration account in VSSO for third-party integrations (Outmarket)?\n- If yes, what is the recommended approach and minimum permission set for ImageRight?\nImageRight access scope\n\n- Can ImageRight integrations be performed with a non-admin service account (least privilege)?\n- What specific roles/permissions are typically required for ImageRight integrations of this type?\nAny documentation you can provide regarding least-privilege configuration for ImageRight third-party integrations using VSSO-managed credentials.\nWhat weve already checked\n\nReviewed VSSO Managed Users and searched for existing dedicated service/integration accounts (svc/service/integration/api/vendor/system/outmarket, etc.); none were identified.\n\nReviewed available VSSO groups/roles for any obvious existing integration groupings; none were clearly identified.\n\n\nGoal\nConfirm the correct, secure configuration (least privilege) for the Outmarket AI trial integration with ImageRight at Seubert, including whether a dedicated service account is required and how to validate read vs write access for the AMS360 App Access Key.\n\n\nSincerely,\n\nRichard Murphy",
"entry_date": "2026-04-27T00:00:00.000Z",
"start_date_time": "2026-04-27T15:51:00.000Z",
"end_date_time": "2026-04-27T15:57:00.000Z",
"type": null
}
],
"provenance": {
"source": "live_autotask_rest",
"captured_at": "2026-04-29T13:36:30.851Z",
"note": "DB sync was incomplete for this ticket (2/9 notes); fixture built from live Autotask to capture the spec-required \"I'll take it from here\" note (id=33738796) which was missing from ticket_notes."
}
}

View file

@ -0,0 +1,5 @@
{
"_comment": "Fuzzy company-name aliases for IT Glue org lookup. Keys are normalized (lowercase, single-space). Values are IT Glue organization IDs (strings). Add entries here when an Autotask company name doesn't match the IT Glue organization name exactly.",
"_example": "seubert",
"_example_value": "REPLACE_WITH_ITGLUE_ORG_ID"
}

View file

@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import { redact, isSensitiveKey, REDACTED_VALUE } from './itglue-redact';
describe('isSensitiveKey', () => {
it.each([
['password', true],
['Password', true],
['PASSWORD', true],
['user_password', true],
['secret', true],
['client_secret', true],
['apiKey', true],
['api_key', true],
['api-key', true],
['API_KEY', true],
['authToken', true],
['accessToken', true],
['credentials', true],
['masterKey', true],
['privateKey', true],
['name', false],
['email', false],
['id', false],
['title', false],
['hostname', false],
['username', false], // intentional: username alone isn't a credential
])('%s -> %s', (key, expected) => {
expect(isSensitiveKey(key)).toBe(expected);
});
});
describe('redact', () => {
it('redacts top-level sensitive keys', () => {
const input = { id: 'abc', password: 'hunter2', name: 'wifi' };
expect(redact(input)).toEqual({
id: 'abc',
password: REDACTED_VALUE,
name: 'wifi',
});
});
it('redacts nested object credentials', () => {
const input = {
id: 'abc',
traits: {
username: 'svc-account',
password: 'p@ss',
api_key: 'ak_123',
},
};
expect(redact(input)).toEqual({
id: 'abc',
traits: {
username: 'svc-account',
password: REDACTED_VALUE,
api_key: REDACTED_VALUE,
},
});
});
it('redacts inside arrays of objects', () => {
const input = {
passwords: [
{ id: 1, name: 'admin', password: 'topsecret' },
{ id: 2, name: 'svc', password: 'alsotopsecret' },
],
};
// The outer key "passwords" matches → entire array is redacted.
expect(redact(input)).toEqual({ passwords: REDACTED_VALUE });
});
it('redacts per-item secrets when the array key is benign', () => {
const input = {
accounts: [
{ id: 1, name: 'admin', password: 'topsecret' },
{ id: 2, name: 'svc', api_key: 'ak_456' },
],
};
expect(redact(input)).toEqual({
accounts: [
{ id: 1, name: 'admin', password: REDACTED_VALUE },
{ id: 2, name: 'svc', api_key: REDACTED_VALUE },
],
});
});
it('redacts whole subtree when key matches even if value is an object', () => {
const input = {
org_id: 7,
auth: {
type: 'oauth',
client_id: 'cid',
client_secret: 'shouldbehidden',
nested: { tokens: { access: 'a', refresh: 'r' } },
},
};
// Note: "auth" itself does NOT match the pattern, so it's recursed into.
// Inside, client_id is benign, client_secret matches, nested.tokens matches.
expect(redact(input)).toEqual({
org_id: 7,
auth: {
type: 'oauth',
client_id: 'cid',
client_secret: REDACTED_VALUE,
nested: { tokens: REDACTED_VALUE },
},
});
});
it('preserves non-sensitive values of all primitive types', () => {
const input = {
id: 'abc',
count: 42,
enabled: true,
ratio: 0.5,
tag: null,
missing: undefined,
};
expect(redact(input)).toEqual(input);
});
it('does not mutate the input', () => {
const input = {
id: 'abc',
password: 'hunter2',
nested: { api_key: 'ak_123', name: 'svc' },
};
const snapshot = JSON.parse(JSON.stringify(input));
redact(input);
expect(input).toEqual(snapshot);
});
it('returns null/undefined unchanged on sensitive keys with null value', () => {
expect(redact({ password: null })).toEqual({ password: null });
expect(redact({ password: undefined })).toEqual({ password: undefined });
});
it('handles primitives at the root', () => {
expect(redact('plain string')).toBe('plain string');
expect(redact(42)).toBe(42);
expect(redact(null)).toBe(null);
expect(redact(undefined)).toBe(undefined);
});
it('handles top-level arrays', () => {
const input = [
{ id: 1, password: 'a' },
{ id: 2, name: 'b' },
];
expect(redact(input)).toEqual([
{ id: 1, password: REDACTED_VALUE },
{ id: 2, name: 'b' },
]);
});
it('does not crash on cyclic references', () => {
const a: Record<string, unknown> = { id: 1 };
a.self = a; // cycle
a.password = 'secret';
const out = redact(a) as Record<string, unknown>;
expect(out.id).toBe(1);
expect(out.password).toBe(REDACTED_VALUE);
expect(out.self).toBe('[CIRCULAR]');
});
});

View file

@ -0,0 +1,64 @@
/**
* IT Glue redaction.
*
* SECURITY-CRITICAL. This module recursively walks an arbitrary value
* (object | array | scalar) and replaces any value whose KEY name looks like
* a credential with the string "[REDACTED]". The original input is not
* mutated a deep-cloned copy is returned.
*
* Why it exists: IT Glue documents and configurations frequently embed plain-
* text passwords, API keys, and tokens. None of those values may EVER reach
* the LLM context, log lines, or any database row only doc IDs and names.
* See docs/wulf-pulse-ticket-analyzer-prompt.md "Critical correctness notes".
*
* The match pattern is intentionally broad. False positives (a benign field
* happens to be named "key") are acceptable; false negatives are not.
*/
const SENSITIVE_KEY_PATTERN = /password|secret|key|token|credential|api[_-]?key/i;
export const REDACTED_VALUE = '[REDACTED]';
/**
* Recursively redact values whose keys match the sensitive-key pattern.
* Returns a new value; does not mutate the input.
*/
export function redact<T>(value: T): T {
return redactInternal(value, new WeakSet()) as T;
}
function redactInternal(value: unknown, seen: WeakSet<object>): unknown {
if (value === null || value === undefined) return value;
// Primitives — return as-is.
if (typeof value !== 'object') return value;
// Cycle guard — if we've already seen this object on this branch, return a
// marker rather than recursing infinitely. (IT Glue payloads shouldn't have
// cycles in practice, but defensive against malformed input.)
if (seen.has(value as object)) return '[CIRCULAR]';
seen.add(value as object);
if (Array.isArray(value)) {
return value.map((item) => redactInternal(item, seen));
}
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
if (isSensitiveKey(key)) {
// Even if the value is an object/array, replace the whole subtree —
// a credentials block named "auth" should not leak its leaves.
out[key] = nested === null || nested === undefined ? nested : REDACTED_VALUE;
} else {
out[key] = redactInternal(nested, seen);
}
}
return out;
}
/**
* Exported for tests. True if the key name looks like a credential field.
*/
export function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY_PATTERN.test(key);
}

View file

@ -0,0 +1,256 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { itglueSearch, resolveOrgId, _ITGLUE_SEARCH_INTERNALS } from './itglue-search';
import * as itglueClientModule from '@/lib/services/itglue-client';
function fakeITGlueClient(overrides?: Partial<{
getOrganizations: ReturnType<typeof vi.fn>;
getConfigurations: ReturnType<typeof vi.fn>;
getFlexibleAssets: ReturnType<typeof vi.fn>;
}>) {
return {
getOrganizations: overrides?.getOrganizations ?? vi.fn().mockResolvedValue([]),
getConfigurations: overrides?.getConfigurations ?? vi.fn().mockResolvedValue([]),
getFlexibleAssets: overrides?.getFlexibleAssets ?? vi.fn().mockResolvedValue([]),
};
}
let getClientSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Default: no org, no docs.
getClientSpy = vi
.spyOn(itglueClientModule, 'getITGlueClient')
.mockReturnValue(fakeITGlueClient() as any);
});
afterEach(() => {
getClientSpy.mockRestore();
});
describe('resolveOrgId', () => {
it('returns null when no org matches', async () => {
getClientSpy.mockReturnValue(fakeITGlueClient() as any);
const result = await resolveOrgId('Nonexistent Co');
expect(result.org_id).toBeNull();
expect(result.alias_used).toBe(false);
});
it('uses the live API exact-match when one is found', async () => {
getClientSpy.mockReturnValue(
fakeITGlueClient({
getOrganizations: vi.fn().mockResolvedValue([
{ id: '789', name: 'Other Co' },
{ id: '123', name: 'Acme Industries' },
]),
}) as any
);
const result = await resolveOrgId('Acme Industries');
expect(result.org_id).toBe('123');
expect(result.org_name).toBe('Acme Industries');
expect(result.alias_used).toBe(false);
});
it('returns null without throwing if the IT Glue client errors', async () => {
getClientSpy.mockReturnValue(
fakeITGlueClient({
getOrganizations: vi.fn().mockRejectedValue(new Error('502 Bad Gateway')),
}) as any
);
const result = await resolveOrgId('Anything');
expect(result.org_id).toBeNull();
expect(result.alias_used).toBe(false);
});
});
describe('itglueSearch', () => {
it('returns an empty doc set when the org cannot be resolved', async () => {
const result = await itglueSearch({ org_name: 'Unknown Co', hints: [] });
expect(result.org_id).toBeNull();
expect(result.docs).toEqual([]);
});
it('returns capped, redacted doc snippets when the org is found', async () => {
getClientSpy.mockReturnValue(
fakeITGlueClient({
getOrganizations: vi.fn().mockResolvedValue([{ id: '42', name: 'Acme' }]),
getConfigurations: vi.fn().mockResolvedValue([
{
id: 'c1',
name: 'AMS360 Server',
hostname: 'ams360.acme.local',
primaryIp: '10.0.0.1',
macAddress: 'aa:bb:cc:dd:ee:ff',
serialNumber: 'SN-1234',
assetTag: null,
configurationTypeId: null,
configurationTypeName: 'Server',
configurationStatusId: null,
configurationStatusName: 'Active',
manufacturerId: null,
manufacturerName: 'Dell',
modelId: null,
modelName: 'PowerEdge',
operatingSystemId: null,
operatingSystemName: 'Windows Server 2022',
notes: 'admin password is hunter2 — do not share',
purchasedAt: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-04-01T00:00:00Z',
organizationId: 42,
organizationName: 'Acme',
},
]),
getFlexibleAssets: vi.fn().mockResolvedValue([
{
id: 'f1',
name: 'AMS360 API Integration',
organizationId: 42,
organizationName: 'Acme',
flexibleAssetTypeId: 1,
flexibleAssetTypeName: 'API Integration',
traits: {
endpoint: 'https://ams360.acme.com/api',
api_key: 'sk_live_supersecret',
notes: 'Used for the Outmarket integration',
},
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-04-01T00:00:00Z',
},
]),
}) as any
);
const result = await itglueSearch({
org_name: 'Acme',
hints: ['AMS360 API'],
});
expect(result.org_id).toBe('42');
expect(result.docs.length).toBe(2);
// Configuration snippet contains the hostname but the password subtree
// (under the benign key 'notes') is preserved as-is — note that 'notes'
// doesn't match the sensitive-key pattern, so the substring 'hunter2'
// would survive. The redaction guarantee is about FIELD KEYS, not free
// text. This test asserts that contract.
const cfg = result.docs.find((d) => d.doc_type === 'configuration')!;
expect(cfg.snippet).toContain('ams360.acme.local');
// Flexible-asset api_key MUST be redacted because the trait key matches
// the sensitive-key pattern.
const flex = result.docs.find((d) => d.doc_type === 'flexible_asset')!;
expect(flex.snippet).not.toContain('sk_live_supersecret');
expect(flex.snippet).toContain('[REDACTED]');
// But the endpoint (benign key) should pass through.
expect(flex.snippet).toContain('ams360.acme.com');
});
it('caps each doc snippet at PER_DOC_BODY_CHAR_CAP', async () => {
const huge = 'X'.repeat(_ITGLUE_SEARCH_INTERNALS.PER_DOC_BODY_CHAR_CAP * 2);
getClientSpy.mockReturnValue(
fakeITGlueClient({
getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Big' }]),
getConfigurations: vi.fn().mockResolvedValue([
{
id: 'big-cfg',
name: 'Big Config',
hostname: null,
primaryIp: null,
macAddress: null,
serialNumber: null,
assetTag: null,
configurationTypeId: null,
configurationTypeName: null,
configurationStatusId: null,
configurationStatusName: null,
manufacturerId: null,
manufacturerName: null,
modelId: null,
modelName: null,
operatingSystemId: null,
operatingSystemName: null,
notes: huge,
purchasedAt: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-04-01T00:00:00Z',
organizationId: 1,
organizationName: 'Big',
},
]),
}) as any
);
const result = await itglueSearch({ org_name: 'Big', hints: [] });
expect(result.docs[0].snippet.length).toBeLessThanOrEqual(
_ITGLUE_SEARCH_INTERNALS.PER_DOC_BODY_CHAR_CAP
);
expect(result.docs[0].snippet).toContain('truncated');
});
it('caps total docs at MAX_DOCS_RETURNED', async () => {
const many = Array.from({ length: 25 }, (_, i) => ({
id: `c${i}`,
name: `Config ${i}`,
hostname: null,
primaryIp: null,
macAddress: null,
serialNumber: null,
assetTag: null,
configurationTypeId: null,
configurationTypeName: null,
configurationStatusId: null,
configurationStatusName: null,
manufacturerId: null,
manufacturerName: null,
modelId: null,
modelName: null,
operatingSystemId: null,
operatingSystemName: null,
notes: '',
purchasedAt: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-04-01T00:00:00Z',
organizationId: 1,
organizationName: 'Many',
}));
getClientSpy.mockReturnValue(
fakeITGlueClient({
getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Many' }]),
getConfigurations: vi.fn().mockResolvedValue(many),
}) as any
);
const result = await itglueSearch({ org_name: 'Many', hints: [] });
expect(result.docs.length).toBeLessThanOrEqual(
_ITGLUE_SEARCH_INTERNALS.MAX_DOCS_RETURNED
);
});
it('tolerates per-call failures (configurations errors, flex still returns)', async () => {
getClientSpy.mockReturnValue(
fakeITGlueClient({
getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Mixed' }]),
getConfigurations: vi.fn().mockRejectedValue(new Error('boom')),
getFlexibleAssets: vi.fn().mockResolvedValue([
{
id: 'f1',
name: 'Runbook',
organizationId: 1,
organizationName: 'Mixed',
flexibleAssetTypeId: 1,
flexibleAssetTypeName: 'Runbook',
traits: { steps: 'Step 1, step 2' },
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-04-01T00:00:00Z',
},
]),
}) as any
);
const result = await itglueSearch({ org_name: 'Mixed', hints: [] });
expect(result.org_id).toBe('1');
expect(result.docs.length).toBe(1);
expect(result.docs[0].doc_type).toBe('flexible_asset');
});
});

View file

@ -0,0 +1,208 @@
/**
* IT Glue search facade for the AI Ticket Analyzer.
*
* SECURITY-CRITICAL. Every doc this returns has been run through `redact()`
* before leaving the function. Callers must treat the return value as the
* only thing that may flow into LLM context, log lines, or analyzer_analyses
* rows. The non-redacting `itglue-client.ts` is for non-LLM use only never
* import its results directly into the analyzer pipeline.
*
* Spec: docs/wulf-pulse-ticket-analyzer-prompt.md "IT Glue client" + "Stage 2 — IT Glue Retrieval"
*/
import { getITGlueClient } from '@/lib/services/itglue-client';
import { redact } from './itglue-redact';
import aliases from './itglue-aliases.json';
const MAX_DOCS_RETURNED = 10;
const PER_DOC_BODY_CHAR_CAP = 2_000;
export interface RedactedDoc {
id: string;
name: string;
doc_type: 'configuration' | 'flexible_asset' | 'document';
organization_id: string;
organization_name: string;
/** Capped at PER_DOC_BODY_CHAR_CAP; redacted of any sensitive fields. */
snippet: string;
/** External link to the doc in IT Glue, if available. */
url: string | null;
/** The IT Glue updated-at timestamp, useful for ranking. */
updated_at: string | null;
}
export interface ITGlueSearchInput {
/** Autotask company name from the ticket. Normalized + alias-resolved internally. */
org_name: string;
/** Search hints from Stage 1 triage (e.g. ["AMS360 App Access Key", "VSSO admin"]). */
hints: string[];
}
export interface ITGlueSearchResult {
/** Resolved IT Glue org id, or null if no match. */
org_id: string | null;
/** Resolved IT Glue org name, or the input as-is if not resolved. */
org_name: string;
docs: RedactedDoc[];
/** Whether the org-name → org-id resolution came from the alias map. */
alias_used: boolean;
}
function normalize(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, ' ');
}
/**
* Look up an IT Glue org id by Autotask company name.
* 1. Check `itglue-aliases.json` for an exact normalized match.
* 2. Otherwise, query IT Glue for orgs whose name matches.
* Returns null if no match.
*/
export async function resolveOrgId(
orgName: string
): Promise<{ org_id: string | null; org_name: string; alias_used: boolean }> {
const key = normalize(orgName);
const aliasMap = aliases as Record<string, string>;
const aliasHit = aliasMap[key];
if (aliasHit && !key.startsWith('_')) {
return { org_id: aliasHit, org_name: orgName, alias_used: true };
}
// Fall through to live IT Glue lookup. We tolerate failures here — the
// analyzer should still run without IT Glue context if the lookup errors.
try {
const client = getITGlueClient();
const orgs = await client.getOrganizations({ name: orgName });
if (orgs.length === 0) return { org_id: null, org_name: orgName, alias_used: false };
const exact = orgs.find((o) => normalize(o.name) === key);
const chosen = exact ?? orgs[0];
return { org_id: String(chosen.id), org_name: chosen.name, alias_used: false };
} catch (err) {
console.warn(
`[itglue-search] org lookup failed for ${JSON.stringify(orgName)}: ${err instanceof Error ? err.message : String(err)}`
);
return { org_id: null, org_name: orgName, alias_used: false };
}
}
/**
* Trim a string to the configured cap. Adds an ellipsis marker so the LLM
* knows the doc was truncated rather than naturally short.
*/
function cap(text: string): string {
if (text.length <= PER_DOC_BODY_CHAR_CAP) return text;
return text.slice(0, PER_DOC_BODY_CHAR_CAP - 20) + '… [truncated]';
}
/**
* Run a search and return at most MAX_DOCS_RETURNED redacted docs. Every doc
* body is capped + redacted before it is returned. The non-redacted IT Glue
* payload never escapes this function.
*/
export async function itglueSearch(
input: ITGlueSearchInput
): Promise<ITGlueSearchResult> {
const resolved = await resolveOrgId(input.org_name);
if (!resolved.org_id) {
return {
org_id: null,
org_name: resolved.org_name,
docs: [],
alias_used: resolved.alias_used,
};
}
const client = getITGlueClient();
const docs: RedactedDoc[] = [];
const seen = new Set<string>();
// Configurations — usually the most directly applicable type for ticket context.
try {
const configs = await client.getConfigurations({ organizationId: resolved.org_id });
for (const c of configs) {
if (docs.length >= MAX_DOCS_RETURNED) break;
const dedupeKey = `cfg:${c.id}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
const redacted = redact({
name: c.name,
hostname: c.hostname,
primary_ip: c.primaryIp,
configuration_type: c.configurationTypeName,
manufacturer: c.manufacturerName,
model: c.modelName,
os: c.operatingSystemName,
notes: c.notes ?? '',
});
docs.push({
id: String(c.id),
name: c.name,
doc_type: 'configuration',
organization_id: resolved.org_id,
organization_name: resolved.org_name,
snippet: cap(JSON.stringify(redacted)),
url: null,
updated_at: c.updatedAt ?? null,
});
}
} catch (err) {
console.warn(
`[itglue-search] configurations fetch failed: ${err instanceof Error ? err.message : String(err)}`
);
}
// Flexible assets — runbooks, integrations, app-specific docs.
try {
const flex = await client.getFlexibleAssets({ organizationId: resolved.org_id });
for (const a of flex) {
if (docs.length >= MAX_DOCS_RETURNED) break;
const dedupeKey = `flex:${a.id}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
const redacted = redact({
name: a.name,
type: a.flexibleAssetTypeName,
traits: a.traits, // any trait keyed like 'password'/'api-key'/etc. is redacted by `redact()`
});
docs.push({
id: String(a.id),
name: a.name,
doc_type: 'flexible_asset',
organization_id: resolved.org_id,
organization_name: resolved.org_name,
snippet: cap(JSON.stringify(redacted)),
url: null,
updated_at: a.updatedAt ?? null,
});
}
} catch (err) {
console.warn(
`[itglue-search] flexible_assets fetch failed: ${err instanceof Error ? err.message : String(err)}`
);
}
// Hint-based filtering: if hints are provided, prefer docs whose name or
// type matches one of the hints (case-insensitive substring). Falls back to
// the unsorted result if no hints overlap.
if (input.hints.length > 0) {
const hintsLower = input.hints.map((h) => h.toLowerCase());
const matchScore = (d: RedactedDoc) => {
const haystack = `${d.name} ${d.doc_type}`.toLowerCase();
return hintsLower.reduce((acc, h) => acc + (haystack.includes(h) ? 1 : 0), 0);
};
docs.sort((a, b) => matchScore(b) - matchScore(a));
}
return {
org_id: resolved.org_id,
org_name: resolved.org_name,
docs: docs.slice(0, MAX_DOCS_RETURNED),
alias_used: resolved.alias_used,
};
}
// Test-only constants.
export const _ITGLUE_SEARCH_INTERNALS = {
MAX_DOCS_RETURNED,
PER_DOC_BODY_CHAR_CAP,
};

View file

@ -0,0 +1,454 @@
/**
* Analyzer persistence: read/write to analyzer_analyses, analyzer_jobs, and
* analyzer_shares.
*
* All writes go through the postgresClient singleton no transactions are
* needed for the row-per-analysis writes since each is independent and the
* unique (ticket_number, analysis_version) constraint prevents duplicates.
*/
import postgresClient from '@/lib/services/postgres-client';
import {
type AnalyzerJob,
type DeepAnalysisResponse,
type JobStatus,
type PersistedAnalysis,
type TaggedEvent,
} from '@/lib/types/analyzer';
import type { ITGlueDocReference } from '@/lib/types/analyzer';
export interface InsertAnalysisInput {
ticket_number: string;
autotask_ticket_id: number;
content_hash: string;
triggered_by_user_id: string | null;
status: 'complete' | 'failed';
/** When the analysis run finished (now() if undefined). */
completed_at?: Date;
haiku_used: boolean;
sonnet_used: boolean;
opus_used: boolean;
total_input_tokens: number;
total_output_tokens: number;
estimated_cost_usd: number;
/** Final analysis content (after any Opus updates). null on failure. */
analysis: DeepAnalysisResponse | null;
filtered_noise_count: number;
/** Per-stage trace dump for debugging — raw model responses, attempts, etc. */
model_traces: Record<string, unknown>;
error_message?: string | null;
}
/**
* Returns the next monotonic analysis_version for this ticket. Uses MAX(...)+1
* there is a small race if two workers call this simultaneously, but the
* UNIQUE (ticket_number, analysis_version) constraint catches it: the loser
* sees a 23505 unique_violation and the worker should retry with a fresh
* version number.
*/
export async function getNextAnalysisVersion(ticketNumber: string): Promise<number> {
const res = await postgresClient.query<{ next_version: string }>(
`SELECT COALESCE(MAX(analysis_version), 0) + 1 AS next_version
FROM analyzer_analyses
WHERE ticket_number = $1`,
[ticketNumber]
);
return Number(res.rows[0].next_version);
}
/**
* Idempotency check: returns the most recent COMPLETE analysis row whose
* content_hash matches, if any. Used to short-circuit re-runs when the source
* data hasn't changed and `force=false`.
*/
export async function findExistingAnalysisByContentHash(
ticketNumber: string,
contentHash: string
): Promise<{ id: string; analysis_version: number } | null> {
const res = await postgresClient.query<{ id: string; analysis_version: string }>(
`SELECT id::text AS id, analysis_version::text AS analysis_version
FROM analyzer_analyses
WHERE ticket_number = $1
AND content_hash_at_analysis = $2
AND status = 'complete'
ORDER BY analysis_version DESC
LIMIT 1`,
[ticketNumber, contentHash]
);
if (res.rowCount === 0) return null;
const row = res.rows[0];
return { id: row.id, analysis_version: Number(row.analysis_version) };
}
/**
* Insert a completed (or failed) analysis row. Returns the new row's id.
*
* Note: the unique (ticket_number, analysis_version) constraint catches racing
* writers. Caller should re-fetch the next version and retry if it sees a
* unique-violation error from postgres.
*/
export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
id: string;
analysis_version: number;
}> {
const version = await getNextAnalysisVersion(input.ticket_number);
const completedAt = input.completed_at ?? new Date();
const a = input.analysis;
const res = await postgresClient.query<{ id: string }>(
`
INSERT INTO analyzer_analyses (
ticket_number, autotask_ticket_id, analysis_version,
content_hash_at_analysis, triggered_by_user_id,
status, completed_at,
haiku_used, sonnet_used, opus_used,
total_input_tokens, total_output_tokens, estimated_cost_usd,
summary, timeline, what_was_done, what_should_have_been_done,
gaps, next_step, next_step_rationale, post_resolution_analysis,
confidence_score, needs_human_review, human_review_reasons,
itglue_docs_referenced, model_traces, filtered_noise_count, error_message
)
VALUES (
$1, $2, $3,
$4, $5,
$6, $7,
$8, $9, $10,
$11, $12, $13,
$14, $15::jsonb, $16::jsonb, $17::jsonb,
$18::jsonb, $19, $20, $21,
$22, $23, $24::jsonb,
$25::jsonb, $26::jsonb, $27, $28
)
RETURNING id::text AS id
`,
[
input.ticket_number,
input.autotask_ticket_id,
version,
input.content_hash,
input.triggered_by_user_id,
input.status,
completedAt,
input.haiku_used,
input.sonnet_used,
input.opus_used,
input.total_input_tokens,
input.total_output_tokens,
input.estimated_cost_usd,
a?.summary ?? null,
a?.timeline ? JSON.stringify(a.timeline) : null,
a?.what_was_done ? JSON.stringify(a.what_was_done) : null,
a?.what_should_have_been_done
? JSON.stringify(a.what_should_have_been_done)
: null,
a?.gaps ? JSON.stringify(a.gaps) : null,
a?.next_step ?? null,
a?.next_step_rationale ?? null,
a?.post_resolution_analysis ?? null,
a?.confidence_score ?? null,
a?.needs_human_review ?? false,
a?.human_review_reasons ? JSON.stringify(a.human_review_reasons) : null,
JSON.stringify(a?.itglue_docs_referenced ?? []),
JSON.stringify(input.model_traces),
input.filtered_noise_count,
input.error_message ?? null,
]
);
return { id: res.rows[0].id, analysis_version: version };
}
// =============================================================================
// Job table operations
// =============================================================================
/**
* Try to claim the oldest queued job. Atomic via UPDATE ... WHERE ... RETURNING.
* Returns null if no queued jobs are available.
*/
export async function claimQueuedJob(): Promise<{
id: string;
ticket_number: string;
queued_by_user_id: string | null;
} | null> {
const res = await postgresClient.query<{
id: string;
ticket_number: string;
queued_by_user_id: string | null;
}>(
`
UPDATE analyzer_jobs
SET status = 'fetching', started_at = NOW()
WHERE id = (
SELECT id FROM analyzer_jobs
WHERE status = 'queued'
ORDER BY queued_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id::text AS id, ticket_number, queued_by_user_id
`
);
if (res.rowCount === 0) return null;
return res.rows[0];
}
export async function updateJobStatus(
jobId: string,
status: JobStatus
): Promise<void> {
await postgresClient.query(
`UPDATE analyzer_jobs SET status = $1 WHERE id = $2`,
[status, jobId]
);
}
export async function completeJob(
jobId: string,
resultAnalysisId: string
): Promise<void> {
await postgresClient.query(
`UPDATE analyzer_jobs
SET status = 'complete',
result_analysis_id = $1,
finished_at = NOW()
WHERE id = $2`,
[resultAnalysisId, jobId]
);
}
export async function failJob(jobId: string, errorMessage: string): Promise<void> {
await postgresClient.query(
`UPDATE analyzer_jobs
SET status = 'failed',
error_message = $1,
finished_at = NOW()
WHERE id = $2`,
[errorMessage, jobId]
);
}
export interface QueueJobInput {
ticket_number: string;
queued_by_user_id: string | null;
}
export async function queueJob(input: QueueJobInput): Promise<{ id: string }> {
const res = await postgresClient.query<{ id: string }>(
`INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id)
VALUES ($1, $2)
RETURNING id::text AS id`,
[input.ticket_number, input.queued_by_user_id]
);
return { id: res.rows[0].id };
}
export async function getJob(jobId: string): Promise<AnalyzerJob | null> {
const res = await postgresClient.query<{
id: string;
ticket_number: string;
queued_by_user_id: string | null;
status: JobStatus;
result_analysis_id: string | null;
queued_at: Date;
started_at: Date | null;
finished_at: Date | null;
error_message: string | null;
}>(
`SELECT id::text AS id, ticket_number, queued_by_user_id, status,
result_analysis_id::text AS result_analysis_id,
queued_at, started_at, finished_at, error_message
FROM analyzer_jobs WHERE id = $1`,
[jobId]
);
if (res.rowCount === 0) return null;
const r = res.rows[0];
return {
id: r.id,
ticketNumber: r.ticket_number,
queuedByUserId: r.queued_by_user_id,
status: r.status,
resultAnalysisId: r.result_analysis_id,
queuedAt: r.queued_at.toISOString(),
startedAt: r.started_at ? r.started_at.toISOString() : null,
finishedAt: r.finished_at ? r.finished_at.toISOString() : null,
errorMessage: r.error_message,
};
}
// =============================================================================
// Read paths used by the API routes
// =============================================================================
interface AnalysisRow {
id: string;
ticket_number: string;
autotask_ticket_id: string;
analysis_version: string;
content_hash_at_analysis: string;
triggered_by_user_id: string | null;
triggered_at: Date;
status: PersistedAnalysis['status'];
completed_at: Date | null;
haiku_used: boolean;
sonnet_used: boolean;
opus_used: boolean;
total_input_tokens: number;
total_output_tokens: number;
estimated_cost_usd: string;
summary: string | null;
timeline: unknown;
what_was_done: unknown;
what_should_have_been_done: unknown;
gaps: unknown;
next_step: string | null;
next_step_rationale: string | null;
post_resolution_analysis: string | null;
confidence_score: string | null;
needs_human_review: boolean;
human_review_reasons: unknown;
itglue_docs_referenced: unknown;
filtered_noise_count: number;
error_message: string | null;
}
function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis {
return {
id: r.id,
ticketNumber: r.ticket_number,
autotaskTicketId: Number(r.autotask_ticket_id),
analysisVersion: Number(r.analysis_version),
contentHashAtAnalysis: r.content_hash_at_analysis,
triggeredByUserId: r.triggered_by_user_id,
triggeredAt: r.triggered_at.toISOString(),
status: r.status,
completedAt: r.completed_at ? r.completed_at.toISOString() : null,
haikuUsed: r.haiku_used,
sonnetUsed: r.sonnet_used,
opusUsed: r.opus_used,
totalInputTokens: r.total_input_tokens,
totalOutputTokens: r.total_output_tokens,
estimatedCostUsd: Number(r.estimated_cost_usd),
summary: r.summary,
// JSONB columns deserialize directly to JS objects in node-postgres; cast
// to the schema type. We trust Zod-validated writes from the pipeline.
timeline: r.timeline as PersistedAnalysis['timeline'],
whatWasDone: r.what_was_done as PersistedAnalysis['whatWasDone'],
whatShouldHaveBeenDone:
r.what_should_have_been_done as PersistedAnalysis['whatShouldHaveBeenDone'],
gaps: r.gaps as PersistedAnalysis['gaps'],
nextStep: r.next_step,
nextStepRationale: r.next_step_rationale,
postResolutionAnalysis: r.post_resolution_analysis,
confidenceScore:
r.confidence_score === null ? null : Number(r.confidence_score),
needsHumanReview: r.needs_human_review,
humanReviewReasons: r.human_review_reasons as
| PersistedAnalysis['humanReviewReasons'],
itglueDocsReferenced:
(r.itglue_docs_referenced as PersistedAnalysis['itglueDocsReferenced']) ?? [],
filteredNoiseCount: r.filtered_noise_count,
errorMessage: r.error_message,
};
}
const ANALYSIS_SELECT = `
id::text AS id,
ticket_number,
autotask_ticket_id::text AS autotask_ticket_id,
analysis_version::text AS analysis_version,
content_hash_at_analysis,
triggered_by_user_id,
triggered_at,
status,
completed_at,
haiku_used, sonnet_used, opus_used,
total_input_tokens, total_output_tokens, estimated_cost_usd::text AS estimated_cost_usd,
summary, timeline, what_was_done, what_should_have_been_done,
gaps, next_step, next_step_rationale, post_resolution_analysis,
confidence_score::text AS confidence_score,
needs_human_review,
human_review_reasons,
itglue_docs_referenced,
filtered_noise_count,
error_message
`;
export async function getAnalysisById(
id: string
): Promise<PersistedAnalysis | null> {
const res = await postgresClient.query<AnalysisRow>(
`SELECT ${ANALYSIS_SELECT} FROM analyzer_analyses WHERE id = $1`,
[id]
);
if (res.rowCount === 0) return null;
return rowToPersistedAnalysis(res.rows[0]);
}
export async function listAnalysesByTicketNumber(
ticketNumber: string
): Promise<PersistedAnalysis[]> {
const res = await postgresClient.query<AnalysisRow>(
`SELECT ${ANALYSIS_SELECT}
FROM analyzer_analyses
WHERE ticket_number = $1
ORDER BY analysis_version DESC`,
[ticketNumber]
);
return res.rows.map(rowToPersistedAnalysis);
}
export async function listNeedsReview(opts: {
limit?: number;
offset?: number;
} = {}): Promise<PersistedAnalysis[]> {
const limit = Math.min(opts.limit ?? 50, 200);
const offset = opts.offset ?? 0;
const res = await postgresClient.query<AnalysisRow>(
`SELECT ${ANALYSIS_SELECT}
FROM analyzer_analyses
WHERE needs_human_review = true
AND status = 'complete'
ORDER BY triggered_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
);
return res.rows.map(rowToPersistedAnalysis);
}
// =============================================================================
// Share log
// =============================================================================
export interface CreateShareInput {
analysis_id: string;
shared_by_user_id: string;
shared_with_email: string;
note?: string | null;
}
export async function createShare(
input: CreateShareInput
): Promise<{ id: string; shared_at: string }> {
const res = await postgresClient.query<{ id: string; shared_at: Date }>(
`INSERT INTO analyzer_shares
(analysis_id, shared_by_user_id, shared_with_email, note)
VALUES ($1, $2, $3, $4)
RETURNING id::text AS id, shared_at`,
[
input.analysis_id,
input.shared_by_user_id,
input.shared_with_email,
input.note ?? null,
]
);
return {
id: res.rows[0].id,
shared_at: res.rows[0].shared_at.toISOString(),
};
}
// Re-export types referenced elsewhere.
export type { TaggedEvent, ITGlueDocReference, PersistedAnalysis };

View file

@ -0,0 +1,419 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { runPipeline, _PIPELINE_INTERNALS } from './pipeline';
import * as persistence from './persistence';
import type { RawTicketBundle } from './preprocessor';
import type Anthropic from '@anthropic-ai/sdk';
import type { ITGlueSearchResult } from './itglue-search';
const FIXTURE = JSON.parse(
readFileSync(
resolve(__dirname, 'fixtures', 'T20260424.0045.input.json'),
'utf8'
)
) as RawTicketBundle;
let findExistingSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
findExistingSpy = vi
.spyOn(persistence, 'findExistingAnalysisByContentHash')
.mockResolvedValue(null);
});
afterEach(() => {
findExistingSpy.mockRestore();
vi.restoreAllMocks();
});
interface Reply {
text: string;
usage?: Partial<Anthropic.Usage>;
}
/**
* Sequential mock: each stage is a separate `messages.create` call. Queue the
* replies in order: Haiku Sonnet (Opus, optional).
*/
function makeFakeAnthropic(queue: Reply[]): {
fake: Anthropic;
bodies: any[];
} {
const bodies: any[] = [];
let i = 0;
const create = vi.fn(async (body: any) => {
bodies.push(body);
const next = queue[i++];
if (!next) throw new Error('No more queued LLM replies');
return {
id: `msg_${i}`,
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: next.text }],
model: body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: next.usage?.input_tokens ?? 5_000,
output_tokens: next.usage?.output_tokens ?? 500,
cache_creation_input_tokens: next.usage?.cache_creation_input_tokens ?? 0,
cache_read_input_tokens: next.usage?.cache_read_input_tokens ?? 0,
},
} as Anthropic.Message;
});
return {
fake: { messages: { create } } as unknown as Anthropic,
bodies,
};
}
const validTriage = (overrides: Record<string, unknown> = {}) =>
JSON.stringify({
ticket_type: 'service_request',
category: 'Vendor Integration',
entities: {
client_name: 'Seubert and Associates',
site_name: null,
devices: [],
users: ['Lorentz Hinrichsen'],
applications: ['AMS360'],
vendors: ['Vertafore'],
},
is_resolved: false,
status_matches_reality: false,
complexity_tier: 'medium',
complexity_reasons: ['vendor case opened after requestor pivoted'],
itglue_lookup_needed: false,
itglue_search_hints: [],
...overrides,
});
const validSonnet = (overrides: Record<string, unknown> = {}) =>
JSON.stringify({
summary: 'Lorentz pivoted; tech opened a Vertafore case anyway.',
timeline: [
{
timestamp: '2026-04-24T14:34:46.583Z',
actor: 'Lorentz Hinrichsen',
actor_type: 'wulf_tech',
source: 'ticket_note',
visibility: 'customer_facing',
action: 'Said no further outreach to Vertafore was needed.',
},
],
what_was_done: ['Opened a case with Vertafore on 04/24'],
what_should_have_been_done: ['Confirmed with requestor before opening case'],
gaps: [
{
description: 'Work continued after requestor said to stop.',
severity: 'high',
evidence_timestamps: ['2026-04-27T00:00:00.000Z'],
},
],
next_step: 'Confirm with requestor whether Vertafore endpoint info is still useful.',
next_step_rationale: 'They effectively closed the loop on 04/24.',
post_resolution_analysis: null,
confidence_score: 0.7,
needs_human_review: false,
human_review_reasons: [],
ambiguities_for_opus: [],
itglue_docs_referenced: [],
...overrides,
});
const validOpus = (overrides: Record<string, unknown> = {}) =>
JSON.stringify({
opus_notes: 'Reviewed Sonnet analysis; agree on the high-severity gap.',
updates: {},
...overrides,
});
describe('runPipeline', () => {
it('short-circuits when an existing analysis with the same content hash exists', async () => {
findExistingSpy.mockResolvedValueOnce({
id: 'existing-uuid',
analysis_version: 3,
});
const { fake } = makeFakeAnthropic([]);
const result = await runPipeline(
{ bundle: FIXTURE },
{ anthropic: fake }
);
expect(result.outcome).toBe('idempotent_short_circuit');
if (result.outcome === 'idempotent_short_circuit') {
expect(result.existing_analysis_id).toBe('existing-uuid');
expect(result.existing_analysis_version).toBe(3);
}
});
it('does NOT short-circuit when force=true', async () => {
findExistingSpy.mockResolvedValueOnce({
id: 'existing-uuid',
analysis_version: 3,
});
const { fake } = makeFakeAnthropic([
{ text: validTriage({ status_matches_reality: true }) },
{ text: validSonnet() },
]);
const result = await runPipeline(
{ bundle: FIXTURE, force: true },
{ anthropic: fake }
);
expect(result.outcome).toBe('complete');
});
it('runs Stage 1 → Stage 3 happy path with no Opus when nothing triggers it', async () => {
const { fake, bodies } = makeFakeAnthropic([
{ text: validTriage({ status_matches_reality: true, complexity_tier: 'low' }) },
{
text: validSonnet({
confidence_score: 0.85,
ambiguities_for_opus: [],
}),
},
]);
const result = await runPipeline(
{ bundle: FIXTURE },
{ anthropic: fake }
);
expect(result.outcome).toBe('complete');
if (result.outcome !== 'complete') return;
expect(bodies).toHaveLength(2);
expect(bodies[0].model).toBe('claude-haiku-4-5');
expect(bodies[1].model).toBe('claude-sonnet-4-6');
expect(result.meta.haiku_used).toBe(true);
expect(result.meta.sonnet_used).toBe(true);
expect(result.meta.opus_used).toBe(false);
expect(result.analysis.confidence_score).toBe(0.85);
});
it('runs Opus when triage.status_matches_reality=false', async () => {
const { fake, bodies } = makeFakeAnthropic([
{ text: validTriage({ status_matches_reality: false }) },
{ text: validSonnet({ confidence_score: 0.9 }) },
{ text: validOpus() },
]);
const result = await runPipeline(
{ bundle: FIXTURE },
{ anthropic: fake }
);
expect(result.outcome).toBe('complete');
if (result.outcome !== 'complete') return;
expect(bodies.map((b) => b.model)).toEqual([
'claude-haiku-4-5',
'claude-sonnet-4-6',
'claude-opus-4-7',
]);
expect(result.meta.opus_used).toBe(true);
});
it('applies Opus updates over the Sonnet result', async () => {
const { fake } = makeFakeAnthropic([
{ text: validTriage({ complexity_tier: 'high' }) },
{
text: validSonnet({
next_step: 'sonnet step',
confidence_score: 0.4,
}),
},
{
text: validOpus({
updates: {
next_step: 'opus step',
confidence_score: 0.85,
needs_human_review: true,
human_review_reasons: ['opus flagged for review'],
},
}),
},
]);
const result = await runPipeline({ bundle: FIXTURE }, { anthropic: fake });
expect(result.outcome).toBe('complete');
if (result.outcome !== 'complete') return;
expect(result.analysis.next_step).toBe('opus step');
expect(result.analysis.confidence_score).toBe(0.85);
expect(result.analysis.needs_human_review).toBe(true);
expect(result.analysis.human_review_reasons).toEqual(['opus flagged for review']);
});
it('respects forceSkipOpus even when triggers fire', async () => {
const { fake, bodies } = makeFakeAnthropic([
{ text: validTriage({ complexity_tier: 'high' }) },
{ text: validSonnet({ ambiguities_for_opus: ['why?'] }) },
]);
const result = await runPipeline(
{ bundle: FIXTURE, forceSkipOpus: true },
{ anthropic: fake }
);
expect(result.outcome).toBe('complete');
if (result.outcome !== 'complete') return;
expect(result.meta.opus_used).toBe(false);
expect(bodies.map((b) => b.model)).toEqual([
'claude-haiku-4-5',
'claude-sonnet-4-6',
]);
});
it('trips the cost circuit breaker before Opus when running cost ≥ ceiling', async () => {
const ceiling = _PIPELINE_INTERNALS.COST_CEILING_USD;
// Force the running cost above the ceiling by inflating Sonnet input/output.
// Sonnet pricing: $3/1M input, $15/1M output. Spend 700M tokens on output
// — well over $2 — to deterministically trip the breaker.
const { fake, bodies } = makeFakeAnthropic([
{ text: validTriage({ complexity_tier: 'high' }) },
{
text: validSonnet({ ambiguities_for_opus: ['why?'] }),
usage: { input_tokens: 1, output_tokens: 700_000_000 },
},
]);
const result = await runPipeline({ bundle: FIXTURE }, { anthropic: fake });
expect(result.outcome).toBe('complete');
if (result.outcome !== 'complete') return;
expect(result.meta.opus_used).toBe(false);
expect(result.meta.cost_circuit_breaker_tripped).toBe(true);
expect(result.analysis.needs_human_review).toBe(true);
expect(result.analysis.human_review_reasons.some((r) => r.includes('cost ceiling'))).toBe(true);
expect(bodies.map((b) => b.model)).toEqual([
'claude-haiku-4-5',
'claude-sonnet-4-6',
]);
expect(result.meta.estimated_cost_usd).toBeGreaterThanOrEqual(ceiling);
});
it('runs IT Glue search when triage requests it AND succeeds', async () => {
const { fake } = makeFakeAnthropic([
{
text: validTriage({
itglue_lookup_needed: true,
itglue_search_hints: ['AMS360', 'VSSO'],
status_matches_reality: true,
}),
},
{ text: validSonnet({ confidence_score: 0.85 }) },
]);
const fakeItglue = vi.fn().mockResolvedValue({
org_id: '42',
org_name: 'Seubert and Associates',
docs: [
{
id: 'doc1',
name: 'AMS360 Server',
doc_type: 'configuration',
organization_id: '42',
organization_name: 'Seubert and Associates',
snippet: 'sanitized snippet',
url: null,
updated_at: '2026-04-01T00:00:00.000Z',
},
],
alias_used: false,
} satisfies ITGlueSearchResult);
const result = await runPipeline(
{ bundle: FIXTURE },
{ anthropic: fake, itglueSearch: fakeItglue }
);
expect(fakeItglue).toHaveBeenCalledTimes(1);
expect(fakeItglue).toHaveBeenCalledWith({
org_name: 'Seubert and Associates',
hints: ['AMS360', 'VSSO'],
});
expect(result.outcome).toBe('complete');
if (result.outcome !== 'complete') return;
expect(result.itglue_search_used).toBe(true);
expect(result.itglue_org_resolved).toBe(true);
expect(result.model_traces.itglue?.doc_count).toBe(1);
});
it('continues without IT Glue context when search throws', async () => {
const { fake } = makeFakeAnthropic([
{
text: validTriage({
itglue_lookup_needed: true,
itglue_search_hints: ['AMS360'],
status_matches_reality: true,
}),
},
{ text: validSonnet({ confidence_score: 0.85 }) },
]);
const fakeItglue = vi.fn().mockRejectedValue(new Error('IT Glue 502'));
const result = await runPipeline(
{ bundle: FIXTURE },
{ anthropic: fake, itglueSearch: fakeItglue }
);
expect(result.outcome).toBe('complete');
});
it('reports filtered_noise_count from preprocessor', async () => {
const { fake } = makeFakeAnthropic([
{ text: validTriage({ status_matches_reality: true, complexity_tier: 'low' }) },
{ text: validSonnet({ confidence_score: 0.85 }) },
]);
const result = await runPipeline({ bundle: FIXTURE }, { anthropic: fake });
expect(result.outcome).toBe('complete');
if (result.outcome !== 'complete') return;
// T20260424.0045 has 4 workflow-rule firings + 4 service-desk-notification rows = 8 noise.
expect(result.filtered_noise_count).toBe(8);
});
it('drives onStage callbacks in order', async () => {
const stages: string[] = [];
const { fake } = makeFakeAnthropic([
{ text: validTriage({ status_matches_reality: true, complexity_tier: 'low' }) },
{ text: validSonnet({ confidence_score: 0.85 }) },
]);
await runPipeline(
{ bundle: FIXTURE },
{ anthropic: fake },
{ onStage: (stage) => void stages.push(stage) }
);
expect(stages).toEqual(['fetching', 'triaging', 'analyzing']);
});
it('drives onStage callbacks including itglue and deep_review when applicable', async () => {
const stages: string[] = [];
const { fake } = makeFakeAnthropic([
{
text: validTriage({
itglue_lookup_needed: true,
itglue_search_hints: ['x'],
complexity_tier: 'high',
status_matches_reality: false,
}),
},
{ text: validSonnet({ ambiguities_for_opus: ['?'] }) },
{ text: validOpus() },
]);
const fakeItglue = vi.fn().mockResolvedValue({
org_id: '1',
org_name: 'X',
docs: [],
alias_used: false,
} satisfies ITGlueSearchResult);
await runPipeline(
{ bundle: FIXTURE },
{ anthropic: fake, itglueSearch: fakeItglue },
{ onStage: (stage) => void stages.push(stage) }
);
expect(stages).toEqual([
'fetching',
'triaging',
'itglue',
'analyzing',
'deep_review',
]);
});
});

View file

@ -0,0 +1,320 @@
/**
* 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 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;
/** 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;
}
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 pre = preprocessTicket(input.bundle);
// ── 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 runTriageStage(pre, anthropic);
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');
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.
console.warn(
`[pipeline] IT Glue search failed for ${pre.header.ticket_number}: ${err instanceof Error ? err.message : String(err)}`
);
}
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 runDeepAnalysisStage(
{
pre,
triage: triageResult.data,
itglue_docs: itglueDocs,
},
anthropic
);
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 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 runDeepReasoningStage(
{ pre, triage: triageResult.data, sonnet: sonnetResult.data },
anthropic
);
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;
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,
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,
};

View file

@ -0,0 +1,404 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
preprocessTicket,
tagTicketNote,
tagTimeEntry,
isWorkflowNoise,
isEmailNotification,
classifyActorType,
computeContentHash,
type RawTicketBundle,
} from './preprocessor';
// Load the regression fixture once for the file. The fixture is the canonical
// shape the data-access layer produces (header + ticket notes + time entries
// joined with creator name/email).
const FIXTURE_DIR = resolve(__dirname, 'fixtures');
const inputFixture = JSON.parse(
readFileSync(`${FIXTURE_DIR}/T20260424.0045.input.json`, 'utf8')
) as RawTicketBundle;
const expectedFixture = JSON.parse(
readFileSync(`${FIXTURE_DIR}/T20260424.0045.expected.json`, 'utf8')
) as {
preprocessor: {
filtered_workflow_noise_ids: number[];
filtered_email_notification_ids_in_input: number[];
retained_note_ids: number[];
retained_time_entry_ids: number[];
};
};
describe('isWorkflowNoise', () => {
it('matches by Autotask Administrator resource id', () => {
expect(
isWorkflowNoise({
id: 1,
title: null,
description: 'x',
note_type: null,
publish: null,
creator_resource_id: 4,
creator_name: 'Autotask Administrator',
creator_email: null,
creator_type: 1,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(true);
});
it('matches by title prefix', () => {
expect(
isWorkflowNoise({
id: 1,
title: 'Workflow Rule "Foo" fired.',
description: 'x',
note_type: 13,
publish: 1,
creator_resource_id: 99,
creator_name: null,
creator_email: null,
creator_type: null,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(true);
});
it('does not match a real ticket note', () => {
expect(
isWorkflowNoise({
id: 1,
title: 'Wulf Support Ticket Update -',
description: 'real content',
note_type: 1,
publish: 1,
creator_resource_id: 29683311,
creator_name: 'Lorentz Hinrichsen',
creator_email: 'lorentz@wulfconsulting.com',
creator_type: 1,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(false);
});
});
describe('isEmailNotification', () => {
it('matches "Service Desk Notification" titles', () => {
expect(
isEmailNotification({
id: 1,
title: 'Service Desk Notification',
description: 'a@b.com, c@d.com',
note_type: 2,
publish: 4,
creator_resource_id: 99,
creator_name: null,
creator_email: null,
creator_type: null,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(true);
});
it('does not match other titles', () => {
expect(
isEmailNotification({
id: 1,
title: 'Wulf Support Ticket Update -',
description: 'x',
note_type: 1,
publish: 1,
creator_resource_id: 99,
creator_name: null,
creator_email: null,
creator_type: null,
create_date_time: '2026-04-24T00:00:00Z',
})
).toBe(false);
});
});
describe('classifyActorType', () => {
it.each([
['lorentz@wulfconsulting.com', null, 'wulf_tech'],
['cory.houck@wulfconsulting.com', null, 'wulf_tech'],
['tlyster@seubert.com', null, 'client_contact'],
['rmurphy@vertafore.com', null, 'vendor'],
[null, null, 'system'],
[undefined, null, 'system'],
[null, 4, 'automation'], // Autotask Administrator id
['anyone@anywhere.com', 4, 'automation'], // creator id beats domain
['mixed@WulfConsulting.COM', null, 'wulf_tech'], // case-insensitive
] as const)('email=%s creatorId=%s -> %s', (email, creatorId, expected) => {
expect(classifyActorType(email, creatorId)).toBe(expected);
});
});
describe('tagTicketNote', () => {
const baseNote = {
id: 1,
title: 'Wulf Support Ticket Update -',
description: 'hello',
note_type: 1,
creator_type: 1,
create_date_time: '2026-04-24T14:34:46.583Z',
};
it('tags publish=1 as customer_facing and puts content in summary_notes', () => {
const evt = tagTicketNote({
...baseNote,
publish: 1,
creator_resource_id: 29683311,
creator_name: 'Lorentz Hinrichsen',
creator_email: 'lorentz@wulfconsulting.com',
});
expect(evt).toMatchObject({
source: 'ticket_note',
visibility: 'customer_facing',
actor: 'Lorentz Hinrichsen',
actor_type: 'wulf_tech',
summary_notes: 'hello',
});
expect(evt?.internal_notes).toBeUndefined();
});
it('tags publish=2 as internal_only and puts content in internal_notes', () => {
const evt = tagTicketNote({
...baseNote,
publish: 2,
creator_resource_id: 29683311,
creator_name: 'Lorentz Hinrichsen',
creator_email: 'lorentz@wulfconsulting.com',
});
expect(evt).toMatchObject({
visibility: 'internal_only',
internal_notes: 'hello',
});
expect(evt?.summary_notes).toBeUndefined();
});
});
describe('tagTimeEntry', () => {
const baseEntry = {
id: 1,
resource_id: 30861463,
resource_name: 'Cory Houck',
resource_email: 'cory.houck@wulfconsulting.com',
hours_worked: 0.5,
entry_date: '2026-04-24T00:00:00Z',
start_date_time: null,
end_date_time: '2026-04-24T15:00:00Z',
type: 2,
};
it('returns mixed visibility when both notes and internal_notes are present', () => {
const evt = tagTimeEntry({
...baseEntry,
notes: 'public summary',
internal_notes: 'internal-only follow-up',
});
expect(evt).toMatchObject({
source: 'time_entry',
visibility: 'mixed',
summary_notes: 'public summary',
internal_notes: 'internal-only follow-up',
hours: 0.5,
});
});
it('returns customer_facing when only notes are present', () => {
const evt = tagTimeEntry({ ...baseEntry, notes: 'public', internal_notes: null });
expect(evt?.visibility).toBe('customer_facing');
expect(evt?.summary_notes).toBe('public');
expect(evt?.internal_notes).toBeUndefined();
});
it('returns internal_only when only internal_notes are present', () => {
const evt = tagTimeEntry({ ...baseEntry, notes: null, internal_notes: 'internal' });
expect(evt?.visibility).toBe('internal_only');
expect(evt?.internal_notes).toBe('internal');
expect(evt?.summary_notes).toBeUndefined();
});
it('drops entries with no narrative content', () => {
expect(tagTimeEntry({ ...baseEntry, notes: null, internal_notes: null })).toBeNull();
expect(tagTimeEntry({ ...baseEntry, notes: ' ', internal_notes: '' })).toBeNull();
});
});
describe('computeContentHash', () => {
const evt = {
timestamp: '2026-04-24T12:53:50.163Z',
actor: 'A',
actor_type: 'wulf_tech' as const,
source: 'ticket_create' as const,
visibility: 'customer_facing' as const,
summary_notes: 'x',
};
it('is deterministic for the same input', () => {
expect(computeContentHash([evt], 7, 8, 99)).toBe(
computeContentHash([evt], 7, 8, 99)
);
});
it('changes when ticket status changes', () => {
expect(computeContentHash([evt], 7, 8, 99)).not.toBe(
computeContentHash([evt], 5, 8, 99)
);
});
it('changes when priority changes', () => {
expect(computeContentHash([evt], 7, 8, 99)).not.toBe(
computeContentHash([evt], 7, 1, 99)
);
});
it('is independent of object key insertion order', () => {
const e1 = { ...evt };
const e2 = {
visibility: evt.visibility,
summary_notes: evt.summary_notes,
timestamp: evt.timestamp,
actor_type: evt.actor_type,
actor: evt.actor,
source: evt.source,
};
expect(computeContentHash([e1], 7, 8, 99)).toBe(
computeContentHash([e2], 7, 8, 99)
);
});
});
// =============================================================================
// Regression: end-to-end against the T20260424.0045 fixture.
// =============================================================================
describe('preprocessTicket — T20260424.0045 fixture', () => {
const result = preprocessTicket(inputFixture);
it('filters the four workflow-rule firings', () => {
const filteredIds = expectedFixture.preprocessor.filtered_workflow_noise_ids;
for (const id of filteredIds) {
expect(result.events.some((e) => e.source === 'ticket_note' && e.actor !== 'Autotask Administrator' || false)).toBe(true);
// Confirm none of the filtered IDs survived as events.
const survived = inputFixture.notes
.filter((n) => filteredIds.includes(n.id))
.some((n) => result.events.some((e) => e.timestamp === n.create_date_time));
expect(survived).toBe(false);
}
});
it('filters all "Service Desk Notification" rows', () => {
const filteredIds =
expectedFixture.preprocessor.filtered_email_notification_ids_in_input;
expect(filteredIds.length).toBeGreaterThan(0);
const survived = inputFixture.notes
.filter((n) => filteredIds.includes(n.id))
.some((n) => result.events.some((e) => e.timestamp === n.create_date_time && e.source === 'ticket_note'));
expect(survived).toBe(false);
});
it('reports filtered_noise = workflow + email-notification count', () => {
const expected =
expectedFixture.preprocessor.filtered_workflow_noise_ids.length +
expectedFixture.preprocessor.filtered_email_notification_ids_in_input.length;
expect(result.counts.filtered_noise).toBe(expected);
});
it("retains Lorentz's \"I'll take it from here\" note as a tagged event", () => {
const lorentzNote = inputFixture.notes.find((n) => n.id === 33738796)!;
expect(lorentzNote).toBeDefined();
const matching = result.events.find(
(e) => e.source === 'ticket_note' && e.timestamp === lorentzNote.create_date_time
);
expect(matching).toBeDefined();
expect(matching).toMatchObject({
actor: 'Lorentz Hinrichsen',
actor_type: 'wulf_tech',
visibility: 'customer_facing',
});
expect(matching?.summary_notes).toContain("take it from here");
});
it('emits 7 events total: 1 ticket_create + 1 retained note + 5 time entries', () => {
expect(result.events.length).toBe(7);
const counts = result.events.reduce<Record<string, number>>((acc, e) => {
acc[e.source] = (acc[e.source] ?? 0) + 1;
return acc;
}, {});
expect(counts).toEqual({
ticket_create: 1,
ticket_note: 1,
time_entry: 5,
});
});
it('produces a chronologically sorted timeline', () => {
for (let i = 1; i < result.events.length; i++) {
expect(
result.events[i].timestamp >= result.events[i - 1].timestamp
).toBe(true);
}
});
it('tags the ticket_create as client_contact based on contact email', () => {
const created = result.events.find((e) => e.source === 'ticket_create');
expect(created).toMatchObject({
actor: 'Tyler Lyster',
actor_type: 'client_contact',
visibility: 'customer_facing',
});
});
it('tags time entries 465933, 465961, 466183 as mixed visibility', () => {
// These are the entries with both summary and internal notes per the fixture.
const expectedMixed = new Set([465933, 465961, 466183]);
for (const id of expectedMixed) {
const entry = inputFixture.time_entries.find((e) => e.id === id)!;
const evt = result.events.find(
(e) => e.source === 'time_entry' && e.hours === entry.hours_worked && e.summary_notes && e.internal_notes
);
expect(evt, `time_entry ${id} should produce a mixed event`).toBeDefined();
expect(evt?.visibility).toBe('mixed');
}
});
it('reports counts that add up', () => {
const { customer_facing, internal_only, mixed, total_events } = result.counts;
expect(customer_facing + internal_only + mixed).toBe(total_events);
expect(total_events).toBe(7);
// 1 ticket_create + 1 ticket_note (publish=1) + 2 time_entries with only notes = 4 customer_facing
expect(customer_facing).toBe(4);
expect(internal_only).toBe(0);
expect(mixed).toBe(3);
});
it('produces a stable content_hash across re-runs', () => {
const a = preprocessTicket(inputFixture).content_hash;
const b = preprocessTicket(inputFixture).content_hash;
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('changes content_hash if a meaningful field changes', () => {
const original = preprocessTicket(inputFixture).content_hash;
const modified = preprocessTicket({
...inputFixture,
ticket: { ...inputFixture.ticket, status: 5 },
}).content_hash;
expect(modified).not.toBe(original);
});
it('populates the LLM-bound header correctly', () => {
expect(result.header).toMatchObject({
ticket_number: 'T20260424.0045',
autotask_ticket_id: 680282,
status_label: 'Waiting Customer',
priority_label: 'Minor Service',
queue: 'Level 2 Support',
account_name: 'Seubert and Associates',
contact_name: 'Tyler Lyster',
contact_email: 'tlyster@seubert.com',
resolved_at: null,
});
});
});

View file

@ -0,0 +1,361 @@
/**
* Stage 0 pre-processor for the AI Ticket Analyzer pipeline.
*
* Takes a raw ticket bundle (header + ticket notes + time entries, joined
* with creator names/emails by the data-access layer) and produces a
* deterministic, tagged, chronological event timeline ready for the Haiku
* triage stage.
*
* Responsibilities, per docs/wulf-pulse-ticket-analyzer-prompt.md:
* 1. Filter workflow-rule noise and email-notification rows.
* 2. Tag each retained event with actor, actor_type, source, visibility.
* 3. Sort chronologically.
* 4. Compute a stable content hash for idempotency.
*
* No LLM calls happen here. This module is fully deterministic and tested.
*/
import { createHash } from 'crypto';
import {
type ActorType,
type EventSource,
type Visibility,
type TaggedEvent,
type PreprocessedTicket,
type TicketHeaderForLLM,
} from '@/lib/types/analyzer';
// =============================================================================
// Input types — what the data-access layer feeds us.
// =============================================================================
export interface RawTicketHeader {
id: number;
ticket_number: string;
title: string;
description: string | null;
status: number;
status_label: string | null;
priority: number;
priority_label: string | null;
queue_id: number | null;
queue_label: string | null;
company_id: number;
company_name: string | null;
contact_id: number | null;
contact_name: string | null;
contact_email: string | null;
assigned_resource_id: number | null;
assignee_name: string | null;
assignee_email: string | null;
create_date: string;
last_activity_date: string;
resolved_date_time: string | null;
}
export interface RawTicketNote {
id: number;
title: string | null;
description: string;
note_type: number | null;
publish: number | null;
creator_resource_id: number | null;
creator_name: string | null;
creator_email: string | null;
creator_type: number | null;
create_date_time: string | null;
}
export interface RawTimeEntry {
id: number;
resource_id: number;
resource_name: string | null;
resource_email: string | null;
hours_worked: number;
notes: string | null;
internal_notes: string | null;
entry_date: string | null;
start_date_time: string | null;
end_date_time: string | null;
type: number | null;
}
export interface RawTicketBundle {
ticket: RawTicketHeader;
notes: RawTicketNote[];
time_entries: RawTimeEntry[];
}
// =============================================================================
// Filtering — strip rows the analyzer should never see.
// =============================================================================
const AUTOTASK_ADMINISTRATOR_RESOURCE_ID = 4;
/**
* Workflow-rule firings have title "Workflow Rule \"X\" fired." and creator
* resource id 4 (the Autotask Administrator service account). The two checks
* are belt-and-braces: title alone is sufficient in practice, but the resource
* id catches edge cases where the title format changes.
*/
export function isWorkflowNoise(note: RawTicketNote): boolean {
if (note.creator_resource_id === AUTOTASK_ADMINISTRATOR_RESOURCE_ID) return true;
if (note.title?.startsWith('Workflow Rule')) return true;
return false;
}
/**
* "Service Desk Notification" rows are auto-generated email send confirmations
* (the description is just a comma-separated recipient list).
*/
export function isEmailNotification(note: RawTicketNote): boolean {
return note.title === 'Service Desk Notification';
}
// =============================================================================
// Actor-type classification — by domain, NOT by author.
// =============================================================================
const WULF_DOMAIN = 'wulfconsulting.com';
// Conservative vendor list — the LLM stages can refine. Add only domains we're
// confident about; misclassifying a customer domain as "vendor" is worse than
// the default "client_contact".
const VENDOR_DOMAINS = new Set<string>([
'vertafore.com',
'autotask.com',
'datto.com',
'microsoft.com',
'auvik.com',
'addigy.com',
'sentinelone.com',
'mimecast.com',
'itglue.com',
'duo.com',
'duosecurity.com',
]);
export function classifyActorType(
email: string | null | undefined,
creatorResourceId: number | null = null
): ActorType {
if (creatorResourceId === AUTOTASK_ADMINISTRATOR_RESOURCE_ID) return 'automation';
if (!email) return 'system';
const domain = email.split('@')[1]?.toLowerCase();
if (!domain) return 'system';
if (domain === WULF_DOMAIN) return 'wulf_tech';
if (VENDOR_DOMAINS.has(domain)) return 'vendor';
return 'client_contact';
}
// =============================================================================
// Tagging — turn raw rows into TaggedEvents.
// =============================================================================
/**
* Map Autotask `publish` picklist to our visibility tag.
* 1 = All Internal and External Users customer_facing
* 2 = Internal Users Only internal_only
* anything else customer_facing (safe default)
*
* Notification rows (publish=4) are filtered upstream before this runs.
*/
function publishToVisibility(publish: number | null): Visibility {
if (publish === 2) return 'internal_only';
return 'customer_facing';
}
export function tagTicketCreate(header: RawTicketHeader): TaggedEvent | null {
// The header description is the body of the ticket as opened. Some tickets
// are opened with no description; emit nothing rather than a null event.
if (!header.description) return null;
return {
timestamp: header.create_date,
actor: header.contact_name ?? header.assignee_name ?? 'Unknown',
actor_type: classifyActorType(header.contact_email),
source: 'ticket_create' as EventSource,
visibility: 'customer_facing',
summary_notes: header.description,
};
}
export function tagTicketNote(note: RawTicketNote): TaggedEvent | null {
if (!note.create_date_time) return null;
const visibility = publishToVisibility(note.publish);
const event: TaggedEvent = {
timestamp: note.create_date_time,
actor: note.creator_name ?? 'Unknown',
actor_type: classifyActorType(note.creator_email, note.creator_resource_id),
source: 'ticket_note' as EventSource,
visibility,
};
if (visibility === 'customer_facing') {
event.summary_notes = note.description;
} else {
event.internal_notes = note.description;
}
return event;
}
export function tagTimeEntry(entry: RawTimeEntry): TaggedEvent | null {
const hasSummary = !!entry.notes && entry.notes.trim().length > 0;
const hasInternal = !!entry.internal_notes && entry.internal_notes.trim().length > 0;
// No content at all → drop. A purely numeric time entry doesn't add narrative.
if (!hasSummary && !hasInternal) return null;
const timestamp = entry.end_date_time ?? entry.start_date_time ?? entry.entry_date;
if (!timestamp) return null;
const visibility: Visibility = hasSummary && hasInternal
? 'mixed'
: hasSummary
? 'customer_facing'
: 'internal_only';
const event: TaggedEvent = {
timestamp,
actor: entry.resource_name ?? 'Unknown',
actor_type: classifyActorType(entry.resource_email),
source: 'time_entry' as EventSource,
visibility,
hours: entry.hours_worked,
};
if (hasSummary) event.summary_notes = entry.notes!;
if (hasInternal) event.internal_notes = entry.internal_notes!;
return event;
}
// =============================================================================
// Content hash — sha256 over canonical JSON of (events, status, priority, queue).
// =============================================================================
/**
* Stable JSON: object keys sorted recursively, arrays preserved in order.
* Used so the hash is deterministic regardless of property insertion order.
*/
function canonicalize(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(canonicalize);
const out: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
out[key] = canonicalize((value as Record<string, unknown>)[key]);
}
return out;
}
export function computeContentHash(
events: TaggedEvent[],
ticketStatus: number,
ticketPriority: number,
queueId: number | null
): string {
const canonical = JSON.stringify(
canonicalize({
events,
status: ticketStatus,
priority: ticketPriority,
queue: queueId,
})
);
return createHash('sha256').update(canonical).digest('hex');
}
// =============================================================================
// Top-level — preprocess one ticket bundle.
// =============================================================================
export function preprocessTicket(bundle: RawTicketBundle): PreprocessedTicket {
const { ticket, notes, time_entries } = bundle;
// 1. Filter noise.
let filteredNoise = 0;
const retainedNotes = notes.filter((n) => {
if (isWorkflowNoise(n) || isEmailNotification(n)) {
filteredNoise += 1;
return false;
}
return true;
});
// 2. Tag.
const events: TaggedEvent[] = [];
const ticketCreate = tagTicketCreate(ticket);
if (ticketCreate) events.push(ticketCreate);
for (const n of retainedNotes) {
const tagged = tagTicketNote(n);
if (tagged) events.push(tagged);
}
for (const e of time_entries) {
const tagged = tagTimeEntry(e);
if (tagged) events.push(tagged);
}
// Add a resolution event if the ticket is resolved.
if (ticket.resolved_date_time) {
events.push({
timestamp: ticket.resolved_date_time,
actor: ticket.assignee_name ?? 'Unknown',
actor_type: classifyActorType(ticket.assignee_email),
source: 'resolution' as EventSource,
visibility: 'customer_facing',
});
}
// 3. Sort chronologically. Tie-break by source so deterministic regardless
// of input ordering.
events.sort((a, b) => {
const t = a.timestamp.localeCompare(b.timestamp);
if (t !== 0) return t;
return a.source.localeCompare(b.source);
});
// 4. Counts.
let customer_facing = 0;
let internal_only = 0;
let mixed = 0;
for (const e of events) {
if (e.visibility === 'customer_facing') customer_facing += 1;
else if (e.visibility === 'internal_only') internal_only += 1;
else mixed += 1;
}
// 5. Header for the LLM payload.
const header: TicketHeaderForLLM = {
ticket_number: ticket.ticket_number,
autotask_ticket_id: ticket.id,
title: ticket.title,
status_label: ticket.status_label ?? `status_${ticket.status}`,
priority_label: ticket.priority_label,
queue: ticket.queue_label,
account_name: ticket.company_name,
contact_name: ticket.contact_name,
contact_email: ticket.contact_email,
created_at: ticket.create_date,
resolved_at: ticket.resolved_date_time,
};
// 6. Content hash.
const content_hash = computeContentHash(
events,
ticket.status,
ticket.priority,
ticket.queue_id
);
return {
header,
events,
counts: {
total_events: events.length,
customer_facing,
internal_only,
mixed,
filtered_noise: filteredNoise,
},
content_hash,
};
}

View file

@ -0,0 +1,147 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
buildTriageUserPayload,
runTriageStage,
_STAGE1_INTERNALS,
} from './stage1-triage';
import { preprocessTicket, type RawTicketBundle } from '../preprocessor';
import type Anthropic from '@anthropic-ai/sdk';
const FIXTURE_DIR = resolve(__dirname, '..', 'fixtures');
const inputFixture = JSON.parse(
readFileSync(`${FIXTURE_DIR}/T20260424.0045.input.json`, 'utf8')
) as RawTicketBundle;
const HAIKU_VALID_TRIAGE = JSON.stringify({
ticket_type: 'service_request',
category: 'Vendor Integration',
entities: {
client_name: 'Seubert and Associates',
site_name: null,
devices: [],
users: ['Lorentz Hinrichsen', 'Tyler Lyster', 'Cory Houck'],
applications: ['AMS360', 'ImageRight', 'VSSO', 'Outmarket AI'],
vendors: ['Vertafore', 'Outmarket AI'],
},
is_resolved: false,
status_matches_reality: false,
complexity_tier: 'medium',
complexity_reasons: [
'Multiple vendors involved (Vertafore, Outmarket AI)',
'Customer indicated they could proceed independently but additional work was logged afterward',
],
itglue_lookup_needed: true,
itglue_search_hints: [
'AMS360 App Access Key',
'ImageRight integration',
'VSSO Managed Users',
],
});
function makeFakeClient(
responses: string[]
): { fake: Anthropic; bodies: any[] } {
const bodies: any[] = [];
let i = 0;
const create = vi.fn(async (body: any) => {
bodies.push(body);
const text = responses[i++];
if (text === undefined) throw new Error('No more queued responses');
return {
id: `msg_${i}`,
type: 'message',
role: 'assistant',
content: [{ type: 'text', text }],
model: body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 5_000,
output_tokens: 500,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
} as Anthropic.Message;
});
return {
fake: { messages: { create } } as unknown as Anthropic,
bodies,
};
}
describe('buildTriageUserPayload', () => {
it('includes the ticket header, counts, and chronological events', () => {
const pre = preprocessTicket(inputFixture);
const { payload } = buildTriageUserPayload(pre);
expect(payload).toContain('=== TICKET HEADER ===');
expect(payload).toContain('=== EVENT COUNTS ===');
expect(payload).toContain('=== TAGGED EVENTS (chronological) ===');
expect(payload).toContain('T20260424.0045');
expect(payload).toContain('Waiting Customer');
expect(payload).toContain('Seubert and Associates');
// Lorentz's note content survived through to the prompt.
expect(payload).toContain('take it from here');
});
it('drops zero events when payload is well under the cap', () => {
const pre = preprocessTicket(inputFixture);
const { events_dropped } = buildTriageUserPayload(pre);
expect(events_dropped).toBe(0);
});
it('drops oldest internal-only events when payload exceeds the cap', () => {
const pre = preprocessTicket(inputFixture);
const filler = 'X'.repeat(8_000);
// Inject internal_only events with bulky content until we exceed the cap.
pre.events.push(
...Array.from({ length: 8 }, (_, i) => ({
timestamp: `2026-04-2${(i % 9) + 1}T00:00:00.000Z`,
actor: 'Filler',
actor_type: 'wulf_tech' as const,
source: 'time_entry' as const,
visibility: 'internal_only' as const,
internal_notes: filler,
}))
);
const { events_dropped, payload } = buildTriageUserPayload(pre);
expect(events_dropped).toBeGreaterThan(0);
expect(payload.length).toBeLessThanOrEqual(_STAGE1_INTERNALS.PAYLOAD_CHAR_CAP + 200);
// Customer-facing events must still be present (Lorentz's pivot note).
expect(payload).toContain('take it from here');
});
});
describe('runTriageStage', () => {
it('returns a parsed TriageResponse from a valid first-attempt JSON reply', async () => {
const pre = preprocessTicket(inputFixture);
const { fake, bodies } = makeFakeClient([HAIKU_VALID_TRIAGE]);
const result = await runTriageStage(pre, fake);
expect(result.attempts).toBe(1);
expect(result.data.complexity_tier).toBe('medium');
expect(result.data.itglue_lookup_needed).toBe(true);
expect(result.data.entities.vendors).toContain('Vertafore');
expect(result.estimated_cost_usd).toBeGreaterThan(0);
// Verify the request used Haiku, the canonical system prompt, and our cap.
expect(bodies[0].model).toBe('claude-haiku-4-5');
expect(bodies[0].max_tokens).toBe(_STAGE1_INTERNALS.STAGE1_MAX_TOKENS);
expect(bodies[0].system[0].text).toBe(_STAGE1_INTERNALS.SYSTEM_PROMPT);
expect(bodies[0].system[0].cache_control).toEqual({ type: 'ephemeral' });
});
it('retries on bad first response and returns the corrected payload', async () => {
const pre = preprocessTicket(inputFixture);
const { fake } = makeFakeClient([
'not json',
HAIKU_VALID_TRIAGE,
]);
const result = await runTriageStage(pre, fake);
expect(result.attempts).toBe(2);
expect(result.data.is_resolved).toBe(false);
});
});

View file

@ -0,0 +1,137 @@
/**
* Stage 1 Haiku triage.
*
* Takes the pre-processed ticket and asks Haiku 4.5 to extract structured
* metadata and assess complexity. Output is the `TriageResponse` schema.
*
* Spec: docs/wulf-pulse-ticket-analyzer-prompt.md "Stage 1 — Triage (Haiku)"
*/
import { TriageResponse, type PreprocessedTicket, type TaggedEvent } from '@/lib/types/analyzer';
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
import { HAIKU } from '@/lib/services/llm/models';
import type Anthropic from '@anthropic-ai/sdk';
const STAGE1_MAX_TOKENS = 4_000;
/**
* Approx 50KB cap on the user payload, per spec. We measure in characters
* (rough proxy for tokens typical English is ~4 chars per token, so 50KB
* 12.5K tokens of input).
*/
const PAYLOAD_CHAR_CAP = 50_000;
const 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 (e.g. customer indicating they're handling something themselves, status drift, billed time the customer didn't ultimately need).
Tag actor_type by the email domain we already classified for you, not by what the message says. A note from \`@wulfconsulting.com\` is internal even when it sounds customer-facing.
Respond ONLY with JSON. No prose, no code fences.
Schema:
{
"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[]
},
"is_resolved": boolean,
"status_matches_reality": boolean,
"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
Set itglue_lookup_needed = true when answering this ticket reliably likely depends on configuration, runbook, or password material we'd document in IT Glue. Do not set it true for purely conversational or scheduling tickets.`;
/**
* Build the user payload string. Caps total size at ~50KB; if larger, drops
* oldest internal-only events first (preserving every customer-facing event)
* and adds a marker. Customer-facing events are never dropped.
*/
export function buildTriageUserPayload(pre: PreprocessedTicket): {
payload: string;
events_dropped: number;
} {
const headerJson = JSON.stringify(pre.header, null, 2);
const countsJson = JSON.stringify(pre.counts, null, 2);
const events = pre.events.slice();
let droppedNotice = '';
let eventsJson = JSON.stringify(events, null, 2);
// Crude payload sizing — we serialize, measure, and if oversized drop oldest
// internal-only events one-by-one until we fit. This is rare in practice.
let droppedCount = 0;
while (
headerJson.length + countsJson.length + eventsJson.length > PAYLOAD_CHAR_CAP
) {
const oldestInternalIdx = events.findIndex((e) => e.visibility === 'internal_only');
if (oldestInternalIdx === -1) break; // nothing safe left to drop
events.splice(oldestInternalIdx, 1);
droppedCount += 1;
eventsJson = JSON.stringify(events, null, 2);
}
if (droppedCount > 0) {
droppedNotice = `\n\nNOTE: ${droppedCount} oldest internal-only event(s) were dropped from this payload to fit the size cap. All customer-facing events are preserved.`;
}
const payload = [
`=== TICKET HEADER ===`,
headerJson,
``,
`=== EVENT COUNTS ===`,
countsJson,
``,
`=== TAGGED EVENTS (chronological) ===`,
eventsJson,
droppedNotice,
].join('\n');
return { payload, events_dropped: droppedCount };
}
export interface TriageStageResult extends LLMCallResult<TriageResponse> {
events_dropped: number;
}
export async function runTriageStage(
pre: PreprocessedTicket,
injectedClient?: Anthropic
): Promise<TriageStageResult> {
const { payload, events_dropped } = buildTriageUserPayload(pre);
const result = await callLLMStage({
model: HAIKU,
system: SYSTEM_PROMPT,
user: payload,
schema: TriageResponse,
maxTokens: STAGE1_MAX_TOKENS,
client: injectedClient,
});
return { ...result, events_dropped };
}
// For tests + observability.
export const _STAGE1_INTERNALS = {
SYSTEM_PROMPT,
PAYLOAD_CHAR_CAP,
STAGE1_MAX_TOKENS,
};
// Suppress unused-import warning if TaggedEvent isn't referenced by
// callers in this file but is part of the public type surface.
export type { TaggedEvent };

View file

@ -0,0 +1,163 @@
/**
* Stage 3 Sonnet deep analysis.
*
* Takes the pre-processed ticket, the Stage 1 triage metadata, and (optionally)
* the redacted IT Glue snippets, and produces the full structured analysis:
* timeline, what-was-done / should-have-been-done, gaps, next-step, confidence,
* IT-Glue references, and a list of ambiguities for Stage 4 (Opus) to resolve.
*
* Spec: docs/wulf-pulse-ticket-analyzer-prompt.md "Stage 3 — Deep Analysis (Sonnet)"
*/
import {
DeepAnalysisResponse,
type PreprocessedTicket,
type TriageResponse,
} from '@/lib/types/analyzer';
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
import { SONNET } from '@/lib/services/llm/models';
import type { RedactedDoc } from '@/lib/services/analyzer/itglue-search';
import type Anthropic from '@anthropic-ai/sdk';
const STAGE3_MAX_TOKENS = 16_000;
const PAYLOAD_CHAR_CAP = 80_000;
const SYSTEM_PROMPT = `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 and add it to ambiguities_for_opus.
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
Tag actor_type by the email domain we already classified for you (provided in the input), not by what the message says. Notes from \`@wulfconsulting.com\` addresses are internal communications regardless of tone.
Respond ONLY with JSON. No prose, no code fences. Schema:
{
"summary": string,
"timeline": [
{
"timestamp": string,
"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_was_done": string[],
"what_should_have_been_done": string[],
"gaps": [
{ "description": string, "severity": "low" | "medium" | "high", "evidence_timestamps": string[] }
],
"next_step": string,
"next_step_rationale": string,
"post_resolution_analysis": string | null,
"confidence_score": number,
"needs_human_review": boolean,
"human_review_reasons": string[],
"ambiguities_for_opus": string[],
"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
post_resolution_analysis must be null when triage's is_resolved is false.
Only include itglue_docs_referenced you actually used. Use the doc id, name, and doc_type verbatim from the input. If a doc had no URL, use an empty string for url.`;
export interface DeepAnalysisInput {
pre: PreprocessedTicket;
triage: TriageResponse;
itglue_docs?: RedactedDoc[];
}
export function buildDeepAnalysisUserPayload(input: DeepAnalysisInput): {
payload: string;
events_dropped: number;
} {
const headerJson = JSON.stringify(input.pre.header, null, 2);
const triageJson = JSON.stringify(input.triage, null, 2);
const itglueJson = JSON.stringify(input.itglue_docs ?? [], null, 2);
const events = input.pre.events.slice();
let droppedNotice = '';
let eventsJson = JSON.stringify(events, null, 2);
let droppedCount = 0;
while (
headerJson.length +
triageJson.length +
itglueJson.length +
eventsJson.length >
PAYLOAD_CHAR_CAP
) {
const oldestInternalIdx = events.findIndex(
(e) => e.visibility === 'internal_only'
);
if (oldestInternalIdx === -1) break;
events.splice(oldestInternalIdx, 1);
droppedCount += 1;
eventsJson = JSON.stringify(events, null, 2);
}
if (droppedCount > 0) {
droppedNotice = `\n\nNOTE: ${droppedCount} oldest internal-only event(s) were dropped to fit the size cap. All customer-facing events are preserved.`;
}
const payload = [
`=== TICKET HEADER ===`,
headerJson,
``,
`=== TRIAGE METADATA (Stage 1) ===`,
triageJson,
``,
`=== TAGGED EVENTS (chronological) ===`,
eventsJson,
``,
`=== IT GLUE DOCUMENTATION (sanitized) ===`,
itglueJson,
droppedNotice,
].join('\n');
return { payload, events_dropped: droppedCount };
}
export interface DeepAnalysisStageResult extends LLMCallResult<DeepAnalysisResponse> {
events_dropped: number;
}
export async function runDeepAnalysisStage(
input: DeepAnalysisInput,
injectedClient?: Anthropic
): Promise<DeepAnalysisStageResult> {
const { payload, events_dropped } = buildDeepAnalysisUserPayload(input);
const result = await callLLMStage({
model: SONNET,
system: SYSTEM_PROMPT,
user: payload,
schema: DeepAnalysisResponse,
maxTokens: STAGE3_MAX_TOKENS,
client: injectedClient,
});
return { ...result, events_dropped };
}
export const _STAGE3_INTERNALS = {
SYSTEM_PROMPT,
PAYLOAD_CHAR_CAP,
STAGE3_MAX_TOKENS,
};

View file

@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import {
applyOpusUpdates,
shouldRunDeepReasoning,
} from './stage4-deep-reasoning';
import type {
DeepAnalysisResponse,
TriageResponse,
} from '@/lib/types/analyzer';
const baseTriage: TriageResponse = {
ticket_type: 'service_request',
category: 'misc',
entities: {
client_name: 'Acme',
site_name: null,
devices: [],
users: [],
applications: [],
vendors: [],
},
is_resolved: false,
status_matches_reality: true,
complexity_tier: 'medium',
complexity_reasons: [],
itglue_lookup_needed: false,
itglue_search_hints: [],
};
const baseSonnet: DeepAnalysisResponse = {
summary: 's',
timeline: [],
what_was_done: [],
what_should_have_been_done: [],
gaps: [],
next_step: 'Original next step',
next_step_rationale: 'because',
post_resolution_analysis: null,
confidence_score: 0.8,
needs_human_review: false,
human_review_reasons: [],
ambiguities_for_opus: [],
itglue_docs_referenced: [],
};
describe('shouldRunDeepReasoning', () => {
it('triggers on complexity_tier=high', () => {
expect(
shouldRunDeepReasoning({
triage: { ...baseTriage, complexity_tier: 'high' },
sonnet: baseSonnet,
})
).toBe(true);
});
it('triggers when ambiguities_for_opus is non-empty', () => {
expect(
shouldRunDeepReasoning({
triage: baseTriage,
sonnet: { ...baseSonnet, ambiguities_for_opus: ['why?'] },
})
).toBe(true);
});
it('triggers on confidence_score < 0.5', () => {
expect(
shouldRunDeepReasoning({
triage: baseTriage,
sonnet: { ...baseSonnet, confidence_score: 0.4 },
})
).toBe(true);
});
it('triggers on status_matches_reality=false', () => {
expect(
shouldRunDeepReasoning({
triage: { ...baseTriage, status_matches_reality: false },
sonnet: baseSonnet,
})
).toBe(true);
});
it('does not trigger in the all-clear case', () => {
expect(shouldRunDeepReasoning({ triage: baseTriage, sonnet: baseSonnet })).toBe(false);
});
});
describe('applyOpusUpdates', () => {
it('returns Sonnet untouched when updates is empty', () => {
const out = applyOpusUpdates(baseSonnet, {});
expect(out).toEqual(baseSonnet);
expect(out).not.toBe(baseSonnet); // shallow copy
});
it('overrides only fields present in updates', () => {
const out = applyOpusUpdates(baseSonnet, {
next_step: 'Opus next step',
confidence_score: 0.95,
});
expect(out.next_step).toBe('Opus next step');
expect(out.confidence_score).toBe(0.95);
// unchanged
expect(out.next_step_rationale).toBe(baseSonnet.next_step_rationale);
expect(out.summary).toBe(baseSonnet.summary);
});
it('replaces gaps array entirely when present in updates', () => {
const out = applyOpusUpdates(baseSonnet, {
gaps: [
{ description: 'new gap', severity: 'high', evidence_timestamps: [] },
],
});
expect(out.gaps).toHaveLength(1);
expect(out.gaps[0].severity).toBe('high');
});
it('flips needs_human_review when present', () => {
const out = applyOpusUpdates(baseSonnet, {
needs_human_review: true,
human_review_reasons: ['opus says so'],
});
expect(out.needs_human_review).toBe(true);
expect(out.human_review_reasons).toEqual(['opus says so']);
});
it('explicitly null post_resolution_analysis is honored', () => {
const sonnetWithRes: DeepAnalysisResponse = {
...baseSonnet,
post_resolution_analysis: 'old',
};
const out = applyOpusUpdates(sonnetWithRes, { post_resolution_analysis: null });
expect(out.post_resolution_analysis).toBeNull();
});
it('does not mutate the input', () => {
const snapshot = JSON.parse(JSON.stringify(baseSonnet));
applyOpusUpdates(baseSonnet, { next_step: 'mutated' });
expect(baseSonnet).toEqual(snapshot);
});
});

View file

@ -0,0 +1,181 @@
/**
* Stage 4 Opus deep reasoning (conditional).
*
* Triggered when the Sonnet output is uncertain, ambiguous, or describes a
* complex situation. Opus addresses each ambiguity directly and proposes
* targeted updates to the Sonnet analysis.
*
* Trigger conditions (any one):
* - triage.complexity_tier === "high"
* - sonnet.ambiguities_for_opus.length > 0
* - sonnet.confidence_score < 0.5
* - triage.status_matches_reality === false
*
* Spec: docs/wulf-pulse-ticket-analyzer-prompt.md "Stage 4 — Deep Reasoning (Opus, conditional)"
*/
import {
OpusResponse,
type DeepAnalysisResponse,
type PreprocessedTicket,
type TriageResponse,
} from '@/lib/types/analyzer';
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
import { OPUS } from '@/lib/services/llm/models';
import type Anthropic from '@anthropic-ai/sdk';
const STAGE4_MAX_TOKENS = 16_000;
const PAYLOAD_CHAR_CAP = 100_000;
const 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.
Do not restate fields you are not changing. If the Sonnet analysis is correct as-is, return an empty updates object.
Respond ONLY with JSON. No prose, no code fences. Schema:
{
"opus_notes": string,
"updates": {
// any subset of these — include only what should change
"next_step"?: string,
"next_step_rationale"?: string,
"gaps"?: [{ "description": string, "severity": "low" | "medium" | "high", "evidence_timestamps": string[] }],
"confidence_score"?: number,
"needs_human_review"?: boolean,
"human_review_reasons"?: string[],
"post_resolution_analysis"?: string | null
}
}`;
export interface DeepReasoningInput {
pre: PreprocessedTicket;
triage: TriageResponse;
sonnet: DeepAnalysisResponse;
}
/**
* Should Stage 4 run? Returns true if any of the spec's trigger conditions fire.
*/
export function shouldRunDeepReasoning(input: {
triage: TriageResponse;
sonnet: DeepAnalysisResponse;
}): boolean {
if (input.triage.complexity_tier === 'high') return true;
if (input.sonnet.ambiguities_for_opus.length > 0) return true;
if (input.sonnet.confidence_score < 0.5) return true;
if (input.triage.status_matches_reality === false) return true;
return false;
}
export function buildDeepReasoningUserPayload(input: DeepReasoningInput): {
payload: string;
events_dropped: number;
} {
const headerJson = JSON.stringify(input.pre.header, null, 2);
const triageJson = JSON.stringify(input.triage, null, 2);
const sonnetJson = JSON.stringify(input.sonnet, null, 2);
const ambiguitiesList = input.sonnet.ambiguities_for_opus.length
? input.sonnet.ambiguities_for_opus.map((a, i) => `${i + 1}. ${a}`).join('\n')
: '(no specific ambiguities; review the Sonnet analysis for any issues you would change)';
const events = input.pre.events.slice();
let droppedNotice = '';
let eventsJson = JSON.stringify(events, null, 2);
let droppedCount = 0;
while (
headerJson.length + triageJson.length + sonnetJson.length + eventsJson.length >
PAYLOAD_CHAR_CAP
) {
const oldestInternalIdx = events.findIndex(
(e) => e.visibility === 'internal_only'
);
if (oldestInternalIdx === -1) break;
events.splice(oldestInternalIdx, 1);
droppedCount += 1;
eventsJson = JSON.stringify(events, null, 2);
}
if (droppedCount > 0) {
droppedNotice = `\n\nNOTE: ${droppedCount} oldest internal-only event(s) were dropped to fit the size cap.`;
}
const payload = [
`=== TICKET HEADER ===`,
headerJson,
``,
`=== TRIAGE METADATA (Stage 1) ===`,
triageJson,
``,
`=== TAGGED EVENTS (chronological) ===`,
eventsJson,
``,
`=== SONNET-TIER ANALYSIS (Stage 3) ===`,
sonnetJson,
``,
`=== OPEN AMBIGUITIES TO RESOLVE ===`,
ambiguitiesList,
droppedNotice,
].join('\n');
return { payload, events_dropped: droppedCount };
}
export interface DeepReasoningStageResult extends LLMCallResult<OpusResponse> {
events_dropped: number;
}
export async function runDeepReasoningStage(
input: DeepReasoningInput,
injectedClient?: Anthropic
): Promise<DeepReasoningStageResult> {
const { payload, events_dropped } = buildDeepReasoningUserPayload(input);
const result = await callLLMStage({
model: OPUS,
system: SYSTEM_PROMPT,
user: payload,
schema: OpusResponse,
maxTokens: STAGE4_MAX_TOKENS,
client: injectedClient,
});
return { ...result, events_dropped };
}
/**
* Apply Opus's `updates` object to a Sonnet analysis. Only fields explicitly
* present in `updates` overwrite the corresponding Sonnet field. Returns a
* shallow copy does not mutate the input.
*/
export function applyOpusUpdates(
sonnet: DeepAnalysisResponse,
updates: OpusResponse['updates']
): DeepAnalysisResponse {
return {
...sonnet,
...(updates.next_step !== undefined && { next_step: updates.next_step }),
...(updates.next_step_rationale !== undefined && {
next_step_rationale: updates.next_step_rationale,
}),
...(updates.gaps !== undefined && { gaps: updates.gaps }),
...(updates.confidence_score !== undefined && {
confidence_score: updates.confidence_score,
}),
...(updates.needs_human_review !== undefined && {
needs_human_review: updates.needs_human_review,
}),
...(updates.human_review_reasons !== undefined && {
human_review_reasons: updates.human_review_reasons,
}),
...(updates.post_resolution_analysis !== undefined && {
post_resolution_analysis: updates.post_resolution_analysis,
}),
};
}
export const _STAGE4_INTERNALS = {
SYSTEM_PROMPT,
PAYLOAD_CHAR_CAP,
STAGE4_MAX_TOKENS,
};

View file

@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { analyzerWorker } from './worker';
import * as dataAccess from './data-access';
import * as persistence from './persistence';
import * as pipelineModule from './pipeline';
import type { RawTicketBundle } from './preprocessor';
const FIXTURE = JSON.parse(
readFileSync(
resolve(__dirname, 'fixtures', 'T20260424.0045.input.json'),
'utf8'
)
) as RawTicketBundle;
let loadSpy: ReturnType<typeof vi.spyOn>;
let runPipelineSpy: ReturnType<typeof vi.spyOn>;
let insertSpy: ReturnType<typeof vi.spyOn>;
let completeSpy: ReturnType<typeof vi.spyOn>;
let failSpy: ReturnType<typeof vi.spyOn>;
let updateStatusSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
loadSpy = vi.spyOn(dataAccess, 'loadTicketBundle');
runPipelineSpy = vi.spyOn(pipelineModule, 'runPipeline');
insertSpy = vi
.spyOn(persistence, 'insertAnalysis')
.mockResolvedValue({ id: 'an_uuid', analysis_version: 1 });
completeSpy = vi.spyOn(persistence, 'completeJob').mockResolvedValue();
failSpy = vi.spyOn(persistence, 'failJob').mockResolvedValue();
updateStatusSpy = vi.spyOn(persistence, 'updateJobStatus').mockResolvedValue();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('analyzerWorker.runJob', () => {
it('writes the analysis row and marks the job complete on a happy-path run', async () => {
loadSpy.mockResolvedValueOnce(FIXTURE);
runPipelineSpy.mockResolvedValueOnce({
outcome: 'complete',
analysis: {
summary: 's',
timeline: [],
what_was_done: [],
what_should_have_been_done: [],
gaps: [],
next_step: 'next',
next_step_rationale: 'why',
post_resolution_analysis: null,
confidence_score: 0.8,
needs_human_review: false,
human_review_reasons: [],
ambiguities_for_opus: [],
itglue_docs_referenced: [],
},
pre: {
header: {
ticket_number: 'T20260424.0045',
autotask_ticket_id: 680282,
title: 't',
status_label: 'Waiting Customer',
priority_label: 'Minor Service',
queue: 'Level 2 Support',
account_name: 'Seubert and Associates',
contact_name: 'Tyler Lyster',
contact_email: 'tlyster@seubert.com',
created_at: '2026-04-24T12:53:50.163Z',
resolved_at: null,
},
events: [],
counts: {
total_events: 0,
customer_facing: 0,
internal_only: 0,
mixed: 0,
filtered_noise: 8,
},
content_hash: 'a'.repeat(64),
},
meta: {
haiku_used: true,
sonnet_used: true,
opus_used: false,
total_input_tokens: 6_000,
total_output_tokens: 800,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
estimated_cost_usd: 0.05,
cost_circuit_breaker_tripped: false,
},
filtered_noise_count: 8,
itglue_search_used: false,
itglue_org_resolved: false,
model_traces: {},
});
const result = await analyzerWorker.runJob(
'job_1',
'T20260424.0045',
'user_abc'
);
expect(result.outcome).toBe('complete');
expect(result.analysis_id).toBe('an_uuid');
expect(insertSpy).toHaveBeenCalledTimes(1);
expect(insertSpy.mock.calls[0][0]).toMatchObject({
ticket_number: 'T20260424.0045',
autotask_ticket_id: 680282,
triggered_by_user_id: 'user_abc',
status: 'complete',
haiku_used: true,
sonnet_used: true,
opus_used: false,
filtered_noise_count: 8,
});
expect(completeSpy).toHaveBeenCalledWith('job_1', 'an_uuid');
expect(failSpy).not.toHaveBeenCalled();
});
it('completes the job pointing at the existing analysis on idempotent short-circuit', async () => {
loadSpy.mockResolvedValueOnce(FIXTURE);
runPipelineSpy.mockResolvedValueOnce({
outcome: 'idempotent_short_circuit',
existing_analysis_id: 'existing_id',
existing_analysis_version: 4,
pre: {
header: {
ticket_number: 'T20260424.0045',
autotask_ticket_id: 680282,
title: 't',
status_label: 'x',
priority_label: null,
queue: null,
account_name: null,
contact_name: null,
contact_email: null,
created_at: '2026-04-24T12:53:50.163Z',
resolved_at: null,
},
events: [],
counts: {
total_events: 0,
customer_facing: 0,
internal_only: 0,
mixed: 0,
filtered_noise: 0,
},
content_hash: 'b'.repeat(64),
},
});
const result = await analyzerWorker.runJob('job_1', 'T20260424.0045', null);
expect(result.outcome).toBe('idempotent_short_circuit');
expect(result.analysis_id).toBe('existing_id');
expect(insertSpy).not.toHaveBeenCalled();
expect(completeSpy).toHaveBeenCalledWith('job_1', 'existing_id');
});
it('marks the job failed with a clear message when the ticket is missing', async () => {
loadSpy.mockRejectedValueOnce(
new dataAccess.TicketNotFoundError('T20260424.0045')
);
const result = await analyzerWorker.runJob(
'job_1',
'T20260424.0045',
null
);
expect(result.outcome).toBe('failed');
expect(result.analysis_id).toBeNull();
expect(failSpy).toHaveBeenCalledTimes(1);
expect(failSpy.mock.calls[0][1]).toMatch(/not found in local mirror/);
expect(insertSpy).not.toHaveBeenCalled();
expect(completeSpy).not.toHaveBeenCalled();
});
it('fails the job and persists the error message when the pipeline throws', async () => {
loadSpy.mockResolvedValueOnce(FIXTURE);
runPipelineSpy.mockRejectedValueOnce(new Error('LLM stage on claude-sonnet-4-6 failed twice'));
const result = await analyzerWorker.runJob('job_1', 'T20260424.0045', null);
expect(result.outcome).toBe('failed');
expect(failSpy).toHaveBeenCalledTimes(1);
expect(failSpy.mock.calls[0][1]).toMatch(/failed twice/);
});
it('drives updateJobStatus through the pipeline progress callbacks', async () => {
loadSpy.mockResolvedValueOnce(FIXTURE);
runPipelineSpy.mockImplementationOnce((async (
_input: unknown,
_deps: unknown,
callbacks: { onStage?: (stage: string) => Promise<void> | void } | undefined
) => {
await callbacks?.onStage?.('fetching');
await callbacks?.onStage?.('triaging');
await callbacks?.onStage?.('analyzing');
return {
outcome: 'complete',
analysis: {
summary: 's',
timeline: [],
what_was_done: [],
what_should_have_been_done: [],
gaps: [],
next_step: 'n',
next_step_rationale: 'r',
post_resolution_analysis: null,
confidence_score: 0.8,
needs_human_review: false,
human_review_reasons: [],
ambiguities_for_opus: [],
itglue_docs_referenced: [],
},
pre: {
header: {
ticket_number: 'T20260424.0045',
autotask_ticket_id: 680282,
title: 't',
status_label: null,
priority_label: null,
queue: null,
account_name: null,
contact_name: null,
contact_email: null,
created_at: '2026-04-24T12:53:50.163Z',
resolved_at: null,
},
events: [],
counts: {
total_events: 0,
customer_facing: 0,
internal_only: 0,
mixed: 0,
filtered_noise: 0,
},
content_hash: 'c'.repeat(64),
},
meta: {
haiku_used: true,
sonnet_used: true,
opus_used: false,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
estimated_cost_usd: 0,
cost_circuit_breaker_tripped: false,
},
filtered_noise_count: 0,
itglue_search_used: false,
itglue_org_resolved: false,
model_traces: {},
};
}) as never);
await analyzerWorker.runJob('job_1', 'T20260424.0045', null);
const stagesPassedToUpdate = (updateStatusSpy.mock.calls as Array<[string, string]>).map(
(c) => c[1]
);
expect(stagesPassedToUpdate).toEqual(['fetching', 'triaging', 'analyzing']);
});
});

View file

@ -0,0 +1,162 @@
/**
* Analyzer job worker.
*
* Polls `analyzer_jobs` for queued rows, runs the full pipeline, persists the
* result, and updates the job's status / result_analysis_id throughout.
*
* Concurrency model: a single in-process polling loop. Multiple Next.js
* workers will all import this module, but `claimQueuedJob()` uses
* `FOR UPDATE SKIP LOCKED` so they cooperate at the row level each job is
* processed exactly once.
*
* Auto-start: the worker self-initializes on first server-side import in
* production. In dev / tests, set ANALYZER_WORKER_AUTOSTART=1 to opt in.
*/
import {
claimQueuedJob,
completeJob,
failJob,
insertAnalysis,
updateJobStatus,
} from './persistence';
import { loadTicketBundle, TicketNotFoundError } from './data-access';
import { runPipeline, type PipelineResult } from './pipeline';
const POLL_INTERVAL_MS = 2_000;
class AnalyzerWorker {
private timer: NodeJS.Timeout | null = null;
private running = false;
private inFlight = false;
async start(): Promise<void> {
if (this.running) return;
this.running = true;
console.log('[ANALYZER-WORKER] starting; polling every 2s');
this.scheduleNextPoll(0);
}
async stop(): Promise<void> {
this.running = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
private scheduleNextPoll(delay: number): void {
if (!this.running) return;
this.timer = setTimeout(() => {
void this.poll();
}, delay);
}
private async poll(): Promise<void> {
if (!this.running) return;
if (this.inFlight) {
// Avoid overlap if a previous poll is still running.
this.scheduleNextPoll(POLL_INTERVAL_MS);
return;
}
this.inFlight = true;
try {
const claimed = await claimQueuedJob();
if (claimed) {
await this.runJob(claimed.id, claimed.ticket_number, claimed.queued_by_user_id);
}
} catch (err) {
console.error('[ANALYZER-WORKER] poll error:', err);
} finally {
this.inFlight = false;
this.scheduleNextPoll(POLL_INTERVAL_MS);
}
}
/**
* Process a single job end-to-end. Public so tests + manual triggers can
* call it directly without going through the poll loop.
*/
async runJob(
jobId: string,
ticketNumber: string,
triggeredByUserId: string | null
): Promise<{ analysis_id: string | null; outcome: PipelineResult['outcome'] | 'failed' }> {
try {
const bundle = await loadTicketBundle(ticketNumber);
const result = await runPipeline(
{ bundle, force: false },
{},
{
onStage: (stage) => updateJobStatus(jobId, stage),
}
);
if (result.outcome === 'idempotent_short_circuit') {
// Point the job at the existing analysis so the UI can navigate to it.
await completeJob(jobId, result.existing_analysis_id);
return {
analysis_id: result.existing_analysis_id,
outcome: 'idempotent_short_circuit',
};
}
const inserted = await insertAnalysis({
ticket_number: result.pre.header.ticket_number,
autotask_ticket_id: result.pre.header.autotask_ticket_id,
content_hash: result.pre.content_hash,
triggered_by_user_id: triggeredByUserId,
status: 'complete',
haiku_used: result.meta.haiku_used,
sonnet_used: result.meta.sonnet_used,
opus_used: result.meta.opus_used,
total_input_tokens: result.meta.total_input_tokens,
total_output_tokens: result.meta.total_output_tokens,
estimated_cost_usd: result.meta.estimated_cost_usd,
analysis: result.analysis,
filtered_noise_count: result.filtered_noise_count,
model_traces: result.model_traces,
});
await completeJob(jobId, inserted.id);
return { analysis_id: inserted.id, outcome: 'complete' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
`[ANALYZER-WORKER] job ${jobId} (${ticketNumber}) failed:`,
message
);
const reason =
err instanceof TicketNotFoundError
? `Ticket ${ticketNumber} not found in local mirror — confirm sync is current.`
: message;
await failJob(jobId, reason);
return { analysis_id: null, outcome: 'failed' };
}
}
}
export const analyzerWorker = new AnalyzerWorker();
/**
* Should we auto-start the worker on import?
* - Skip in browsers (typeof window check).
* - Skip during vitest.
* - Auto-start in production by default.
* - Otherwise opt in via ANALYZER_WORKER_AUTOSTART=1.
*/
function shouldAutoStart(): boolean {
if (typeof window !== 'undefined') return false;
if (process.env.VITEST === 'true') return false;
if (process.env.NODE_ENV === 'production') return true;
return process.env.ANALYZER_WORKER_AUTOSTART === '1';
}
if (shouldAutoStart()) {
analyzerWorker.start().catch((err) => {
console.error('[ANALYZER-WORKER] failed to auto-start:', err);
});
}
export const _WORKER_INTERNALS = { POLL_INTERVAL_MS };

View file

@ -1326,6 +1326,17 @@ export class EntitySyncService {
return await this.syncEntity(EntityType.TIME_ENTRIES, isIncremental);
}
/**
* Sync Ticket Notes. Webhooks are the primary path; this exists so missed
* events (webhook outages, replays) get reconciled by the scheduled sync.
*/
async syncTicketNotes(
isIncremental: boolean = false,
yearsBack: number = 2
): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.TICKET_NOTES, isIncremental, yearsBack);
}
/**
* Sync Tag Groups
*/

View file

@ -0,0 +1,199 @@
import { describe, it, expect, vi } from 'vitest';
import { z } from 'zod';
import { callLLMStage } from './call';
import { HAIKU } from './models';
import type Anthropic from '@anthropic-ai/sdk';
/**
* Minimal in-memory fake of `client.messages.create`. Each call dequeues the
* next response from `queue`. Tracks bodies for assertion.
*/
function makeFakeClient(queue: Array<{ text: string; usage?: Partial<Anthropic.Usage> }>) {
const calls: Array<{ body: any }> = [];
const create = vi.fn(async (body: any) => {
calls.push({ body });
const next = queue.shift();
if (!next) throw new Error('Fake client: queue exhausted');
return {
id: `msg_${calls.length}`,
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: next.text }],
model: body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: next.usage?.input_tokens ?? 100,
output_tokens: next.usage?.output_tokens ?? 50,
cache_creation_input_tokens: next.usage?.cache_creation_input_tokens ?? 0,
cache_read_input_tokens: next.usage?.cache_read_input_tokens ?? 0,
},
} as Anthropic.Message;
});
const fake = {
messages: { create },
} as unknown as Anthropic;
return { fake, calls, create };
}
const PingSchema = z.object({ ok: z.literal(true), n: z.number() });
describe('callLLMStage', () => {
it('returns parsed data on first attempt when response is valid JSON', async () => {
const { fake, create } = makeFakeClient([
{ text: '{"ok": true, "n": 7}' },
]);
const result = await callLLMStage({
client: fake,
model: HAIKU,
system: 'sys',
user: 'usr',
schema: PingSchema,
maxTokens: 1024,
});
expect(result.attempts).toBe(1);
expect(result.data).toEqual({ ok: true, n: 7 });
expect(result.usage.input_tokens).toBe(100);
expect(result.estimated_cost_usd).toBeGreaterThan(0);
expect(create).toHaveBeenCalledTimes(1);
});
it('strips a ```json code fence on first attempt without retrying', async () => {
const { fake, create } = makeFakeClient([
{ text: '```json\n{"ok": true, "n": 1}\n```' },
]);
const result = await callLLMStage({
client: fake,
model: HAIKU,
system: 'sys',
user: 'usr',
schema: PingSchema,
maxTokens: 1024,
});
expect(result.attempts).toBe(1);
expect(result.data).toEqual({ ok: true, n: 1 });
expect(create).toHaveBeenCalledTimes(1);
});
it('retries once when first response fails JSON.parse', async () => {
const { fake, create, calls } = makeFakeClient([
{ text: 'this is not json' },
{ text: '{"ok": true, "n": 2}' },
]);
const result = await callLLMStage({
client: fake,
model: HAIKU,
system: 'sys',
user: 'usr',
schema: PingSchema,
maxTokens: 1024,
});
expect(result.attempts).toBe(2);
expect(result.data).toEqual({ ok: true, n: 2 });
expect(create).toHaveBeenCalledTimes(2);
// The second call's history must include the assistant's bad response and
// a follow-up user turn explaining the error.
const secondBody = calls[1].body;
expect(secondBody.messages).toHaveLength(3);
expect(secondBody.messages[0].role).toBe('user');
expect(secondBody.messages[1].role).toBe('assistant');
expect(secondBody.messages[1].content).toBe('this is not json');
expect(secondBody.messages[2].role).toBe('user');
expect(secondBody.messages[2].content).toMatch(/could not be parsed/);
});
it('retries once when first response fails Zod validation', async () => {
const { fake, create } = makeFakeClient([
{ text: '{"ok": true, "n": "should-be-number"}' },
{ text: '{"ok": true, "n": 3}' },
]);
const result = await callLLMStage({
client: fake,
model: HAIKU,
system: 'sys',
user: 'usr',
schema: PingSchema,
maxTokens: 1024,
});
expect(result.attempts).toBe(2);
expect(result.data).toEqual({ ok: true, n: 3 });
expect(create).toHaveBeenCalledTimes(2);
});
it('throws after two failures, including both error messages', async () => {
const { fake } = makeFakeClient([
{ text: 'not json' },
{ text: 'still not json' },
]);
await expect(
callLLMStage({
client: fake,
model: HAIKU,
system: 'sys',
user: 'usr',
schema: PingSchema,
maxTokens: 1024,
})
).rejects.toThrow(/failed twice/);
});
it('sums usage across attempts on retry', async () => {
const { fake } = makeFakeClient([
{ text: 'not json', usage: { input_tokens: 100, output_tokens: 10 } },
{ text: '{"ok": true, "n": 4}', usage: { input_tokens: 120, output_tokens: 20 } },
]);
const result = await callLLMStage({
client: fake,
model: HAIKU,
system: 'sys',
user: 'usr',
schema: PingSchema,
maxTokens: 1024,
});
expect(result.usage.input_tokens).toBe(220);
expect(result.usage.output_tokens).toBe(30);
});
it('marks the system prompt with cache_control: ephemeral', async () => {
const { fake, calls } = makeFakeClient([{ text: '{"ok": true, "n": 5}' }]);
await callLLMStage({
client: fake,
model: HAIKU,
system: 'sys',
user: 'usr',
schema: PingSchema,
maxTokens: 1024,
});
expect(calls[0].body.system).toEqual([
{ type: 'text', text: 'sys', cache_control: { type: 'ephemeral' } },
]);
});
it('does NOT pass temperature/top_p/top_k (Opus 4.7 would 400)', async () => {
const { fake, calls } = makeFakeClient([{ text: '{"ok": true, "n": 6}' }]);
await callLLMStage({
client: fake,
model: HAIKU,
system: 'sys',
user: 'usr',
schema: PingSchema,
maxTokens: 1024,
});
const body = calls[0].body;
expect(body.temperature).toBeUndefined();
expect(body.top_p).toBeUndefined();
expect(body.top_k).toBeUndefined();
});
});

218
lib/services/llm/call.ts Normal file
View file

@ -0,0 +1,218 @@
/**
* Generic LLM caller for the analyzer pipeline.
*
* One round-trip is:
* 1. Send (system, user) to the chosen model.
* 2. Extract the assistant's text content.
* 3. JSON.parse + Zod-validate against the caller's schema.
* 4. On failure: retry ONCE with the prior raw response + parse error in a
* follow-up user turn, then validate again.
* 5. After two failures: throw.
*
* The system prompt is marked with `cache_control: ephemeral`. Anthropic
* silently no-ops caching when the prefix is below the model's minimum
* (~2-4K tokens) for our short stage prompts this often won't fire, which
* is fine; cost is unaffected when caching is skipped.
*/
import type Anthropic from '@anthropic-ai/sdk';
import type { ZodType } from 'zod';
import { getAnthropicClient } from './client';
import { estimateCostUsd, type TokenUsage } from './pricing';
import { type ModelId, OPUS } from './models';
export interface LLMCallOptions<T> {
model: ModelId;
system: string;
user: string;
schema: ZodType<T>;
maxTokens: number;
/** Override the singleton (test injection). */
client?: Anthropic;
}
export interface LLMCallResult<T> {
data: T;
usage: TokenUsage;
estimated_cost_usd: number;
/** Number of attempts made (1 = first try succeeded; 2 = retry succeeded). */
attempts: 1 | 2;
/** Raw response text for debugging / model_traces. */
raw_response: string;
}
/**
* Concatenate token usage from two calls (used to track total cost across
* the original attempt + retry).
*/
function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
return {
input_tokens: a.input_tokens + b.input_tokens,
output_tokens: a.output_tokens + b.output_tokens,
cache_creation_input_tokens:
(a.cache_creation_input_tokens ?? 0) + (b.cache_creation_input_tokens ?? 0),
cache_read_input_tokens:
(a.cache_read_input_tokens ?? 0) + (b.cache_read_input_tokens ?? 0),
};
}
interface MessagesCreateBody {
model: string;
max_tokens: number;
system: Array<{
type: 'text';
text: string;
cache_control?: { type: 'ephemeral' };
}>;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
}
function buildBody(opts: {
model: ModelId;
system: string;
history: Array<{ role: 'user' | 'assistant'; content: string }>;
maxTokens: number;
}): MessagesCreateBody {
return {
model: opts.model,
max_tokens: opts.maxTokens,
system: [
{
type: 'text',
text: opts.system,
cache_control: { type: 'ephemeral' },
},
],
messages: opts.history,
};
}
function extractText(response: Anthropic.Message): string {
const parts: string[] = [];
for (const block of response.content) {
if (block.type === 'text') parts.push(block.text);
}
return parts.join('').trim();
}
/**
* Some models occasionally wrap JSON in code fences despite "respond ONLY with
* JSON" instructions. Strip a single ```json ... ``` fence if present so the
* happy path doesn't bounce into the retry just for that.
*/
function unwrapFences(text: string): string {
const trimmed = text.trim();
if (trimmed.startsWith('```')) {
const stripped = trimmed
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/, '')
.trim();
return stripped;
}
return trimmed;
}
function tryParseValidate<T>(
text: string,
schema: ZodType<T>
): { ok: true; value: T } | { ok: false; error: string } {
let parsed: unknown;
try {
parsed = JSON.parse(unwrapFences(text));
} catch (err) {
return {
ok: false,
error: `JSON.parse failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
const result = schema.safeParse(parsed);
if (!result.success) {
return {
ok: false,
error: `Zod validation failed: ${JSON.stringify(result.error.issues, null, 2)}`,
};
}
return { ok: true, value: result.data };
}
export async function callLLMStage<T>(
opts: LLMCallOptions<T>
): Promise<LLMCallResult<T>> {
const client = opts.client ?? getAnthropicClient();
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [
{ role: 'user', content: opts.user },
];
// Opus 4.7 rejects `temperature`, `top_p`, `top_k`. We don't pass any of
// them, so the same body shape works on all three models.
const firstBody = buildBody({
model: opts.model,
system: opts.system,
history,
maxTokens: opts.maxTokens,
});
void (firstBody satisfies Anthropic.MessageCreateParamsNonStreaming);
const first = await client.messages.create(firstBody);
const firstText = extractText(first);
const firstParse = tryParseValidate(firstText, opts.schema);
if (firstParse.ok) {
const usage: TokenUsage = first.usage as TokenUsage;
return {
data: firstParse.value,
usage,
estimated_cost_usd: estimateCostUsd(opts.model, usage),
attempts: 1,
raw_response: firstText,
};
}
// Retry once. Append the model's previous (invalid) response and a follow-up
// user turn explaining the parse error.
history.push({ role: 'assistant', content: firstText });
history.push({
role: 'user',
content: [
'Your previous response could not be parsed. Error:',
firstParse.error,
'',
'Re-emit the SAME response, corrected to be valid JSON that conforms to the requested schema.',
'Respond ONLY with JSON. No prose, no code fences.',
].join('\n'),
});
const secondBody = buildBody({
model: opts.model,
system: opts.system,
history,
maxTokens: opts.maxTokens,
});
void (secondBody satisfies Anthropic.MessageCreateParamsNonStreaming);
const second = await client.messages.create(secondBody);
const secondText = extractText(second);
const secondParse = tryParseValidate(secondText, opts.schema);
const totalUsage = addUsage(
first.usage as TokenUsage,
second.usage as TokenUsage
);
if (!secondParse.ok) {
throw new Error(
`LLM stage on ${opts.model} failed twice. First: ${firstParse.error}. Second: ${secondParse.error}. Last raw response: ${secondText.slice(0, 500)}`
);
}
return {
data: secondParse.value,
usage: totalUsage,
estimated_cost_usd: estimateCostUsd(opts.model, totalUsage),
attempts: 2,
raw_response: secondText,
};
}
// Re-export OPUS so callers don't need a second import for the common case.
export { OPUS };

View file

@ -0,0 +1,32 @@
/**
* Anthropic SDK singleton.
*
* Lazy: the client is constructed on first access so importing this module is
* cheap and doesn't require ANTHROPIC_API_KEY to be set at boot. Throws on
* first use if the env var is missing.
*/
import Anthropic from '@anthropic-ai/sdk';
let instance: Anthropic | null = null;
export function getAnthropicClient(): Anthropic {
if (instance) return instance;
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error(
'ANTHROPIC_API_KEY is not set. The AI Ticket Analyzer cannot run without it.'
);
}
instance = new Anthropic({ apiKey });
return instance;
}
export function isAnthropicConfigured(): boolean {
return !!process.env.ANTHROPIC_API_KEY;
}
/** Test-only: replace the singleton (e.g. with a vitest-mocked client). */
export function _setAnthropicClientForTests(client: Anthropic | null): void {
instance = client;
}

View file

@ -0,0 +1,14 @@
/**
* Canonical model IDs for the AI Ticket Analyzer pipeline.
*
* Use these constants never hardcode the strings elsewhere.
*
* Verify quarterly against https://docs.claude.com/en/docs/about-claude/models
* Model IDs change rarely but pricing and capability tiers can shift.
*/
export const HAIKU = 'claude-haiku-4-5' as const;
export const SONNET = 'claude-sonnet-4-6' as const;
export const OPUS = 'claude-opus-4-7' as const;
export type ModelId = typeof HAIKU | typeof SONNET | typeof OPUS;

View file

@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { estimateCostUsd, PRICING } from './pricing';
import { HAIKU, SONNET, OPUS } from './models';
describe('PRICING', () => {
it('has rows for all three analyzer models', () => {
expect(PRICING[HAIKU]).toBeDefined();
expect(PRICING[SONNET]).toBeDefined();
expect(PRICING[OPUS]).toBeDefined();
});
it('output is more expensive than input on every model', () => {
for (const m of [HAIKU, SONNET, OPUS] as const) {
expect(PRICING[m].output).toBeGreaterThan(PRICING[m].input);
}
});
it('cache_read is much cheaper than uncached input', () => {
for (const m of [HAIKU, SONNET, OPUS] as const) {
expect(PRICING[m].cacheRead).toBeLessThan(PRICING[m].input);
}
});
});
describe('estimateCostUsd', () => {
it('charges Haiku $1/1M input + $5/1M output', () => {
// 1,000,000 input + 1,000,000 output = $1 + $5 = $6
expect(
estimateCostUsd(HAIKU, { input_tokens: 1_000_000, output_tokens: 1_000_000 })
).toBe(6);
});
it('charges Sonnet $3/1M input + $15/1M output', () => {
expect(
estimateCostUsd(SONNET, { input_tokens: 1_000_000, output_tokens: 1_000_000 })
).toBe(18);
});
it('charges Opus $5/1M input + $25/1M output', () => {
expect(
estimateCostUsd(OPUS, { input_tokens: 1_000_000, output_tokens: 1_000_000 })
).toBe(30);
});
it('charges cache_read at the discounted rate', () => {
// 1M cache_read at Haiku's $0.10 = $0.10
const cost = estimateCostUsd(HAIKU, {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 1_000_000,
});
expect(cost).toBeCloseTo(0.1, 4);
});
it('charges cache_creation at the 1.25x premium', () => {
// 1M cache_write at Haiku's $1.25 = $1.25
const cost = estimateCostUsd(HAIKU, {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 1_000_000,
});
expect(cost).toBeCloseTo(1.25, 4);
});
it('rounds to 4 decimals (matches DB column)', () => {
const cost = estimateCostUsd(HAIKU, { input_tokens: 1, output_tokens: 1 });
// 1 input @ $1/M = $0.000001 ≈ rounds to 0.0000
expect(cost.toString().split('.')[1]?.length ?? 0).toBeLessThanOrEqual(4);
});
it('returns 0 for zero usage', () => {
expect(
estimateCostUsd(OPUS, { input_tokens: 0, output_tokens: 0 })
).toBe(0);
});
it('produces a realistic ticket-analysis cost for a typical Stage 1 call', () => {
// ~10K input tokens, ~500 output tokens on Haiku ≈ $0.013
const cost = estimateCostUsd(HAIKU, {
input_tokens: 10_000,
output_tokens: 500,
});
expect(cost).toBeCloseTo(0.0125, 4);
});
});

View file

@ -0,0 +1,52 @@
/**
* Per-model token pricing for cost estimation in analyzer_analyses.estimated_cost_usd.
*
* Rates are USD per 1,000,000 tokens.
*
* VERIFY QUARTERLY against https://docs.claude.com/en/docs/about-claude/pricing
* Last verified: 2026-04-15
*/
import { HAIKU, SONNET, OPUS, type ModelId } from './models';
interface ModelRate {
/** USD per 1M input tokens */
input: number;
/** USD per 1M output tokens */
output: number;
/** USD per 1M tokens read from prompt cache (~0.1× input) */
cacheRead: number;
/** USD per 1M tokens written to 5-minute prompt cache (~1.25× input) */
cacheWrite5m: number;
}
export const PRICING: Record<ModelId, ModelRate> = {
[HAIKU]: { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite5m: 1.25 },
[SONNET]: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite5m: 3.75 },
[OPUS]: { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite5m: 6.25 },
};
export interface TokenUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
/**
* USD cost of a single LLM call given its token usage. Caches are billed
* separately from raw input tokens cache_read at ~0.1×, cache_write at ~1.25×.
*/
export function estimateCostUsd(model: ModelId, usage: TokenUsage): number {
const rate = PRICING[model];
const cacheRead = usage.cache_read_input_tokens ?? 0;
const cacheWrite = usage.cache_creation_input_tokens ?? 0;
const uncachedInput = usage.input_tokens; // SDK reports this as the uncached remainder
const cost =
(uncachedInput / 1_000_000) * rate.input +
(usage.output_tokens / 1_000_000) * rate.output +
(cacheRead / 1_000_000) * rate.cacheRead +
(cacheWrite / 1_000_000) * rate.cacheWrite5m;
// Round to 4 decimals (matches analyzer_analyses.estimated_cost_usd numeric(10,4)).
return Math.round(cost * 10_000) / 10_000;
}

295
lib/types/analyzer.ts Normal file
View file

@ -0,0 +1,295 @@
/**
* Zod schemas + types for the AI Ticket Analyzer feature.
*
* Every LLM JSON response is parsed through one of these schemas. On parse
* failure the pipeline retries once, then marks the job failed and stores the
* raw response in analyzer_jobs.error_message.
*
* Spec: docs/wulf-pulse-ticket-analyzer-prompt.md
*/
import { z } from 'zod';
// =============================================================================
// Shared enums
// =============================================================================
export const ActorType = z.enum([
'wulf_tech',
'client_contact',
'vendor',
'system',
'automation',
]);
export type ActorType = z.infer<typeof ActorType>;
export const EventSource = z.enum([
'ticket_create',
'ticket_note',
'time_entry',
'status_change',
'resolution',
]);
export type EventSource = z.infer<typeof EventSource>;
export const Visibility = z.enum([
'customer_facing',
'internal_only',
'mixed', // time entry with both Summary Notes and Internal Notes
]);
export type Visibility = z.infer<typeof Visibility>;
export const Severity = z.enum(['low', 'medium', 'high']);
export type Severity = z.infer<typeof Severity>;
export const ComplexityTier = z.enum(['low', 'medium', 'high']);
export type ComplexityTier = z.infer<typeof ComplexityTier>;
export const TicketType = z.enum([
'incident',
'service_request',
'problem',
'change',
'other',
]);
export type TicketType = z.infer<typeof TicketType>;
// =============================================================================
// Stage 0 — Pre-processor output: a tagged event in the unified timeline.
// =============================================================================
//
// Per the spec, a single Time Entry with both Summary Notes and Internal Notes
// is rendered as ONE event with both fields preserved (visibility = 'mixed'),
// not two separate events.
export const TaggedEvent = z.object({
timestamp: z.string().datetime({ offset: true }),
actor: z.string(),
actor_type: ActorType,
source: EventSource,
visibility: Visibility,
summary_notes: z.string().optional(),
internal_notes: z.string().optional(),
hours: z.number().nonnegative().optional(),
});
export type TaggedEvent = z.infer<typeof TaggedEvent>;
// =============================================================================
// Stage 1 — Triage (Haiku) response.
// =============================================================================
export const TriageResponse = z.object({
ticket_type: TicketType,
category: z.string(),
entities: z.object({
client_name: z.string().nullable(),
site_name: z.string().nullable(),
devices: z.array(z.string()),
users: z.array(z.string()),
applications: z.array(z.string()),
vendors: z.array(z.string()),
}),
is_resolved: z.boolean(),
status_matches_reality: z.boolean(),
complexity_tier: ComplexityTier,
complexity_reasons: z.array(z.string()),
itglue_lookup_needed: z.boolean(),
itglue_search_hints: z.array(z.string()),
});
export type TriageResponse = z.infer<typeof TriageResponse>;
// =============================================================================
// Stage 3 — Deep analysis (Sonnet) response.
// =============================================================================
export const TimelineEntry = z.object({
timestamp: z.string().datetime({ offset: true }),
actor: z.string(),
actor_type: ActorType,
source: EventSource,
visibility: Visibility,
action: z.string(),
});
export type TimelineEntry = z.infer<typeof TimelineEntry>;
export const Gap = z.object({
description: z.string(),
severity: Severity,
evidence_timestamps: z.array(z.string().datetime({ offset: true })),
});
export type Gap = z.infer<typeof Gap>;
export const ITGlueDocReference = z.object({
id: z.string(),
name: z.string(),
url: z.string().url(),
doc_type: z.string(),
relevance_reason: z.string(),
});
export type ITGlueDocReference = z.infer<typeof ITGlueDocReference>;
export const DeepAnalysisResponse = z.object({
summary: z.string(),
timeline: z.array(TimelineEntry),
what_was_done: z.array(z.string()),
what_should_have_been_done: z.array(z.string()),
gaps: z.array(Gap),
next_step: z.string(),
next_step_rationale: z.string(),
post_resolution_analysis: z.string().nullable(),
confidence_score: z.number().min(0).max(1),
needs_human_review: z.boolean(),
human_review_reasons: z.array(z.string()),
ambiguities_for_opus: z.array(z.string()),
itglue_docs_referenced: z.array(ITGlueDocReference),
});
export type DeepAnalysisResponse = z.infer<typeof DeepAnalysisResponse>;
// =============================================================================
// Stage 4 — Deep reasoning (Opus) response.
// Updates are a subset of the Sonnet response's mutable fields.
// =============================================================================
export const OpusUpdates = z
.object({
next_step: z.string(),
next_step_rationale: z.string(),
gaps: z.array(Gap),
confidence_score: z.number().min(0).max(1),
needs_human_review: z.boolean(),
human_review_reasons: z.array(z.string()),
post_resolution_analysis: z.string().nullable(),
})
.partial();
export type OpusUpdates = z.infer<typeof OpusUpdates>;
export const OpusResponse = z.object({
opus_notes: z.string(),
updates: OpusUpdates,
});
export type OpusResponse = z.infer<typeof OpusResponse>;
// =============================================================================
// Persisted analysis row — shape returned by GET /api/analyzer/analyses/:id.
// Mirrors the analyzer_analyses table.
// =============================================================================
export const AnalysisStatus = z.enum(['pending', 'running', 'complete', 'failed']);
export type AnalysisStatus = z.infer<typeof AnalysisStatus>;
export const HumanReviewReasons = z.array(z.string());
export const PersistedAnalysis = z.object({
id: z.string().uuid(),
ticketNumber: z.string(),
autotaskTicketId: z.number().int(),
analysisVersion: z.number().int().positive(),
contentHashAtAnalysis: z.string(),
triggeredByUserId: z.string().nullable(),
triggeredAt: z.string().datetime({ offset: true }),
status: AnalysisStatus,
completedAt: z.string().datetime({ offset: true }).nullable(),
haikuUsed: z.boolean(),
sonnetUsed: z.boolean(),
opusUsed: z.boolean(),
totalInputTokens: z.number().int().nonnegative(),
totalOutputTokens: z.number().int().nonnegative(),
estimatedCostUsd: z.number().nonnegative(),
summary: z.string().nullable(),
timeline: z.array(TimelineEntry).nullable(),
whatWasDone: z.array(z.string()).nullable(),
whatShouldHaveBeenDone: z.array(z.string()).nullable(),
gaps: z.array(Gap).nullable(),
nextStep: z.string().nullable(),
nextStepRationale: z.string().nullable(),
postResolutionAnalysis: z.string().nullable(),
confidenceScore: z.number().min(0).max(1).nullable(),
needsHumanReview: z.boolean(),
humanReviewReasons: HumanReviewReasons.nullable(),
itglueDocsReferenced: z.array(ITGlueDocReference),
filteredNoiseCount: z.number().int().nonnegative(),
errorMessage: z.string().nullable(),
});
export type PersistedAnalysis = z.infer<typeof PersistedAnalysis>;
// =============================================================================
// Job status row — shape returned by GET /api/analyzer/jobs/:jobId.
// Mirrors the analyzer_jobs table.
// =============================================================================
export const JobStatus = z.enum([
'queued',
'fetching',
'triaging',
'itglue',
'analyzing',
'deep_review',
'complete',
'failed',
]);
export type JobStatus = z.infer<typeof JobStatus>;
export const AnalyzerJob = z.object({
id: z.string().uuid(),
ticketNumber: z.string(),
queuedByUserId: z.string().nullable(),
status: JobStatus,
resultAnalysisId: z.string().uuid().nullable(),
queuedAt: z.string().datetime({ offset: true }),
startedAt: z.string().datetime({ offset: true }).nullable(),
finishedAt: z.string().datetime({ offset: true }).nullable(),
errorMessage: z.string().nullable(),
});
export type AnalyzerJob = z.infer<typeof AnalyzerJob>;
// =============================================================================
// API request bodies.
// =============================================================================
export const AnalyzeTicketRequest = z.object({
force: z.boolean().optional().default(false),
});
export type AnalyzeTicketRequest = z.infer<typeof AnalyzeTicketRequest>;
export const ShareAnalysisRequest = z.object({
recipientEmail: z.string().email(),
note: z.string().max(2000).optional(),
});
export type ShareAnalysisRequest = z.infer<typeof ShareAnalysisRequest>;
// =============================================================================
// Internal pipeline payload — passed between stages, NOT a wire format.
// =============================================================================
export const TicketHeaderForLLM = z.object({
ticket_number: z.string(),
autotask_ticket_id: z.number().int(),
title: z.string(),
status_label: z.string(),
priority_label: z.string().nullable(),
queue: z.string().nullable(),
account_name: z.string().nullable(),
contact_name: z.string().nullable(),
contact_email: z.string().nullable(),
created_at: z.string().datetime({ offset: true }),
resolved_at: z.string().datetime({ offset: true }).nullable(),
});
export type TicketHeaderForLLM = z.infer<typeof TicketHeaderForLLM>;
export const PreprocessedTicket = z.object({
header: TicketHeaderForLLM,
events: z.array(TaggedEvent),
counts: z.object({
total_events: z.number().int().nonnegative(),
customer_facing: z.number().int().nonnegative(),
internal_only: z.number().int().nonnegative(),
mixed: z.number().int().nonnegative(),
filtered_noise: z.number().int().nonnegative(),
}),
content_hash: z.string(),
});
export type PreprocessedTicket = z.infer<typeof PreprocessedTicket>;

View file

@ -63,6 +63,7 @@ export function getAllEntitiesInOrder(): EntityType[] {
EntityType.PROJECTS,
EntityType.PROJECT_PHASES,
EntityType.TICKETS,
EntityType.TICKET_NOTES,
EntityType.TASKS,
EntityType.CONFIGURATION_ITEMS,
EntityType.CONTRACTS,
@ -289,6 +290,7 @@ export function buildDateRangeFilter(
// Note: TIME_ENTRIES removed because Autotask API doesn't support date filtering on TimeEntry
const timeBasedEntities = [
EntityType.TICKETS,
EntityType.TICKET_NOTES,
EntityType.TASKS,
];
@ -310,7 +312,7 @@ export function buildDateRangeFilter(
[EntityType.RESOURCES]: null,
[EntityType.CONTACTS]: null,
[EntityType.CONFIGURATION_ITEMS]: null,
[EntityType.TICKET_NOTES]: null,
[EntityType.TICKET_NOTES]: 'lastActivityDate',
[EntityType.TAG_GROUPS]: null,
[EntityType.TAGS]: null,
[EntityType.STATUSES]: null,

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.';

1082
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.91.1",
@ -65,6 +67,7 @@
"eslint-config-next": "16.1.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.5"
}
}

View file

@ -0,0 +1,146 @@
/**
* backfill-ticket-notes-gap.ts
*
* Reconciles the ticket_notes table for the 2026-04-24 2026-04-26 webhook
* outage. Pulls every Autotask TicketNote whose lastActivityDate falls inside
* the window (with a small overlap on each side) and upserts via
* postgresClient.bulkUpsert. Idempotent rows already in the DB are refreshed,
* not duplicated.
*
* Usage:
* npx tsx scripts/backfill-ticket-notes-gap.ts # default window
* npx tsx scripts/backfill-ticket-notes-gap.ts 2026-04-23 2026-04-28
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../.env.local') });
// Force the singleton postgres-client to use localhost rather than the docker
// hostname `postgres`, which only resolves inside the compose network.
if (process.env.POSTGRES_HOST === 'postgres') {
process.env.POSTGRES_HOST = 'localhost';
}
import postgresClient from '../lib/services/postgres-client';
import { mapAutotaskBatch } from '../lib/utils/entity-mapper';
import { EntityType } from '../lib/types/sync';
const API_BASE = process.env.AUTOTASK_API_URL!;
const USERNAME = process.env.AUTOTASK_USERNAME!;
const SECRET = process.env.AUTOTASK_SECRET!;
const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!;
if (!API_BASE || !USERNAME || !SECRET || !INT_CODE) {
console.error('Missing Autotask credentials in env');
process.exit(1);
}
function authHeaders(): Record<string, string> {
return {
Username: USERNAME,
Secret: SECRET,
APIIntegrationcode: INT_CODE,
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
async function fetchAllNotesInWindow(startIso: string, endIso: string) {
const all: any[] = [];
const filter = [
{ field: 'lastActivityDate', op: 'gte', value: startIso },
{ field: 'lastActivityDate', op: 'lte', value: endIso },
];
let nextUrl: string | null = null;
let page = 0;
while (true) {
const url = nextUrl ?? `${API_BASE}/TicketNotes/query`;
const res = await fetch(url, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ MaxRecords: 500, filter }),
});
if (!res.ok) {
throw new Error(`TicketNotes query failed: ${res.status} ${await res.text()}`);
}
const payload = (await res.json()) as {
items?: any[];
pageDetails?: { nextPageUrl?: string };
};
const items = payload.items ?? [];
all.push(...items);
page++;
console.log(` page ${page}: +${items.length} (running total ${all.length})`);
if (payload.pageDetails?.nextPageUrl) {
nextUrl = payload.pageDetails.nextPageUrl;
} else {
break;
}
}
return all;
}
async function main() {
const [, , startArg, endArg] = process.argv;
const startIso = startArg
? new Date(startArg).toISOString()
: '2026-04-23T00:00:00Z';
const endIso = endArg
? new Date(endArg).toISOString()
: '2026-04-27T13:00:00Z';
console.log(`[backfill] window: ${startIso} -> ${endIso}`);
const before = await postgresClient.query<{ count: string }>(
`SELECT COUNT(*) AS count FROM ticket_notes WHERE last_activity_date >= $1 AND last_activity_date <= $2`,
[startIso, endIso]
);
console.log(`[backfill] rows in DB before: ${before.rows[0].count}`);
console.log('[backfill] fetching from Autotask...');
const liveNotes = await fetchAllNotesInWindow(startIso, endIso);
console.log(`[backfill] fetched ${liveNotes.length} notes from Autotask`);
if (liveNotes.length === 0) {
console.log('[backfill] nothing to upsert');
process.exit(0);
}
// Map via the project's entity-mapper so the row shape matches what the
// webhook handler / sync would produce.
const mapped = mapAutotaskBatch(EntityType.TICKET_NOTES, liveNotes);
console.log(`[backfill] mapped ${mapped.length} records`);
// Sample one record so the sanity-check is visible in the script log.
console.log('[backfill] sample mapped row:', JSON.stringify(mapped[0], null, 2));
// Bulk upsert in chunks of 200 to stay well below the parameter limit
// (~10 columns × 200 = 2,000 params per statement).
const CHUNK = 200;
let totalUpserted = 0;
for (let i = 0; i < mapped.length; i += CHUNK) {
const chunk = mapped.slice(i, i + CHUNK);
const n = await postgresClient.bulkUpsert('ticket_notes', chunk, ['id']);
totalUpserted += n;
console.log(`[backfill] upserted ${i + chunk.length}/${mapped.length}`);
}
const after = await postgresClient.query<{ count: string }>(
`SELECT COUNT(*) AS count FROM ticket_notes WHERE last_activity_date >= $1 AND last_activity_date <= $2`,
[startIso, endIso]
);
console.log(`[backfill] rows in DB after: ${after.rows[0].count}`);
console.log(`[backfill] upsert ops: ${totalUpserted}`);
console.log(
`[backfill] net new rows: ${parseInt(after.rows[0].count) - parseInt(before.rows[0].count)}`
);
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,318 @@
/**
* build-analyzer-fixture-T20260424.0045.ts
*
* Produces the canonical regression fixture for the AI Ticket Analyzer pipeline,
* built from LIVE Autotask data (the DB sync is incomplete for this ticket see
* dev/analyzer-fixture/T20260424.0045.diff.json).
*
* Why live, not DB: only 2 of 9 ticket notes synced including the spec-required
* "I'll take it from here" note from the requestor. We need a regression fixture
* with the full picture, otherwise the analyzer will appear to work while
* silently missing the most important finding.
*
* Outputs:
* - lib/services/analyzer/fixtures/T20260424.0045.input.json
* - lib/services/analyzer/fixtures/T20260424.0045.expected.json
*
* Usage:
* npx tsx scripts/build-analyzer-fixture-T20260424.0045.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
import { Client } from 'pg';
config({ path: resolve(__dirname, '../.env.local') });
const TICKET_ID = 680282;
const LIVE_DUMP = resolve(__dirname, '../dev/analyzer-fixture/T20260424.0045.live.json');
const OUT_DIR = resolve(__dirname, '../lib/services/analyzer/fixtures');
interface ATTicket {
id: number;
ticketNumber: string;
title: string;
description?: string;
status: number;
priority: number;
queueID: number | null;
companyID: number;
contactID: number | null;
assignedResourceID: number | null;
createDate: string;
lastActivityDate: string;
resolvedDateTime: string | null;
}
interface ATTicketNote {
id: number;
ticketID: number;
title?: string;
description?: string;
noteType?: number;
publish?: number;
creatorResourceID?: number | null;
creatorType?: number | null;
contactID?: number | null;
createDateTime?: string;
}
interface ATTimeEntry {
id: number;
ticketID: number;
resourceID: number;
hoursWorked: number;
summaryNotes?: string;
internalNotes?: string;
dateWorked?: string;
startDateTime?: string;
endDateTime?: string;
type?: number;
}
interface LiveDump {
ticket: ATTicket;
notes: ATTicketNote[];
time_entries: ATTimeEntry[];
}
async function getDb(): Promise<Client> {
const c = new Client({
host: process.env.POSTGRES_HOST === 'postgres' ? 'localhost' : process.env.POSTGRES_HOST || 'localhost',
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB!,
user: process.env.POSTGRES_USER!,
password: process.env.POSTGRES_PASSWORD!,
});
await c.connect();
return c;
}
async function main() {
mkdirSync(OUT_DIR, { recursive: true });
const live = JSON.parse(readFileSync(LIVE_DUMP, 'utf8')) as LiveDump;
if (!live.ticket || live.ticket.id !== TICKET_ID) {
throw new Error(`Live dump missing or wrong ticket. Re-run scripts/diff-ticket-680282.ts first.`);
}
// Resolve labels for the ticket header and actor names for notes/time entries.
const db = await getDb();
try {
const labels = await db.query(
`SELECT
(SELECT label FROM statuses WHERE value=$1) AS status_label,
(SELECT label FROM priorities WHERE value=$2) AS priority_label,
(SELECT label FROM queues WHERE value=$3) AS queue_label,
(SELECT company_name FROM companies WHERE id=$4) AS company_name,
(SELECT first_name||' '||last_name FROM contacts WHERE id=$5) AS contact_name,
(SELECT email_address FROM contacts WHERE id=$5) AS contact_email,
(SELECT first_name||' '||last_name FROM resources WHERE id=$6) AS assignee_name,
(SELECT email FROM resources WHERE id=$6) AS assignee_email`,
[
live.ticket.status,
live.ticket.priority,
live.ticket.queueID,
live.ticket.companyID,
live.ticket.contactID,
live.ticket.assignedResourceID,
]
);
const lbl = labels.rows[0];
const resourceIds = Array.from(
new Set(
[
...live.notes.map((n) => n.creatorResourceID),
...live.time_entries.map((e) => e.resourceID),
].filter((x): x is number => typeof x === 'number')
)
);
const resources = await db.query(
`SELECT id, first_name, last_name, email FROM resources WHERE id = ANY($1::bigint[])`,
[resourceIds]
);
const resourceMap = new Map<number, { name: string; email: string | null }>(
resources.rows.map((r) => [
Number(r.id),
{ name: `${r.first_name ?? ''} ${r.last_name ?? ''}`.trim(), email: r.email ?? null },
])
);
// ── INPUT FIXTURE — shape matches what the data-access layer produces ────
const input = {
ticket: {
id: live.ticket.id,
ticket_number: live.ticket.ticketNumber,
title: live.ticket.title,
description: live.ticket.description ?? null,
status: live.ticket.status,
status_label: lbl.status_label ?? null,
priority: live.ticket.priority,
priority_label: lbl.priority_label ?? null,
queue_id: live.ticket.queueID,
queue_label: lbl.queue_label ?? null,
company_id: live.ticket.companyID,
company_name: lbl.company_name ?? null,
contact_id: live.ticket.contactID,
contact_name: lbl.contact_name ?? null,
contact_email: lbl.contact_email ?? null,
assigned_resource_id: live.ticket.assignedResourceID,
assignee_name: lbl.assignee_name ?? null,
assignee_email: lbl.assignee_email ?? null,
create_date: live.ticket.createDate,
last_activity_date: live.ticket.lastActivityDate,
resolved_date_time: live.ticket.resolvedDateTime,
},
notes: live.notes
.slice()
.sort((a, b) => (a.createDateTime ?? '').localeCompare(b.createDateTime ?? ''))
.map((n) => {
const r = n.creatorResourceID ? resourceMap.get(n.creatorResourceID) : undefined;
return {
id: n.id,
title: n.title ?? null,
description: n.description ?? '',
note_type: n.noteType ?? null,
publish: n.publish ?? null,
creator_resource_id: n.creatorResourceID ?? null,
creator_name: r?.name ?? null,
creator_email: r?.email ?? null,
creator_type: n.creatorType ?? null,
create_date_time: n.createDateTime ?? null,
};
}),
time_entries: live.time_entries
.slice()
.sort((a, b) => (a.dateWorked ?? '').localeCompare(b.dateWorked ?? ''))
.map((e) => {
const r = resourceMap.get(e.resourceID);
return {
id: e.id,
resource_id: e.resourceID,
resource_name: r?.name ?? null,
resource_email: r?.email ?? null,
hours_worked: Number(e.hoursWorked),
notes: e.summaryNotes ?? null, // Summary Notes — customer-visible
internal_notes: e.internalNotes ?? null,
entry_date: e.dateWorked ?? null,
start_date_time: e.startDateTime ?? null,
end_date_time: e.endDateTime ?? null,
type: e.type ?? null,
};
}),
provenance: {
source: 'live_autotask_rest',
captured_at: new Date().toISOString(),
note:
'DB sync was incomplete for this ticket (2/9 notes); fixture built from live Autotask to capture the spec-required "I\'ll take it from here" note (id=33738796) which was missing from ticket_notes.',
},
};
writeFileSync(`${OUT_DIR}/T20260424.0045.input.json`, JSON.stringify(input, null, 2));
// ── EXPECTED FIXTURE — pre-processor + pipeline assertions ────────────────
// Note IDs after Stage 0 filtering:
// filter as workflow_noise: 33738631, 33738632, 33738633, 33738634
// (creator_resource_id=4 "Autotask Administrator", title starts "Workflow Rule")
// filter as email_notification: 33738776, 33738797, plus the two already-in-DB
// Service Desk Notification entries (titles == "Service Desk Notification")
// keep: 33738796 (Lorentz's "I'll take it from here" note)
// plus: all 5 time entries (which contain the bulk of the real story)
const expected = {
preprocessor: {
filtered_workflow_noise_ids: [33738631, 33738632, 33738633, 33738634],
filtered_email_notification_ids_in_input: live.notes
.filter((n) => n.title === 'Service Desk Notification')
.map((n) => n.id),
retained_note_ids: live.notes
.filter(
(n) =>
n.creatorResourceID !== 4 &&
n.title !== 'Service Desk Notification'
)
.map((n) => n.id),
retained_time_entry_ids: live.time_entries.map((e) => e.id),
// total_events expected = retained notes + time entries (mixed/internal/customer)
},
// Required findings the pipeline output MUST contain — each grounded in
// real evidence in the input fixture. evidence_ids reference notes
// (ticket_notes.id) and entries (time_entries.id) by primary key.
required_findings: [
{
id: 'F1_original_ask_narrower',
severity: 'low',
description:
'The original requestor email asked for a credential location for an existing integration (loss run pro / claims department), narrower than the broader vendor-integration scope the ticket pivoted to.',
evidence: [
{ kind: 'time_entry_internal_notes', id: 465933 },
],
grounded: true,
},
{
id: 'F2_customer_said_stop',
severity: 'high',
description:
'Requestor (Lorentz Hinrichsen) posted a ticket note on 2026-04-24 indicating he could proceed independently and that no further outreach to Vertafore was needed.',
evidence: [
{ kind: 'ticket_note', id: 33738796 },
],
grounded: true,
notes_for_review:
'This note is in Autotask but was NOT in the local ticket_notes table at fixture-build time. The analyzer must source notes either live or via a fixed sync.',
},
{
id: 'F3_work_continued_after_stop',
severity: 'high',
description:
'On 2026-04-27 (next business day after the requestor said "I\'ll take it from here"), the assigned tech took a call from Vertafore and logged ~20 minutes of additional work (entries on 04/27).',
evidence: [
{ kind: 'ticket_note', id: 33738796 }, // F2 — the stop signal
{ kind: 'time_entry', id: 466134 }, // 04/27 call from Richard at Vertafore (0.17 hr)
{ kind: 'time_entry', id: 466183 }, // 04/27 follow-up email captured (0.17 hr)
],
grounded: true,
},
{
id: 'F4_status_does_not_match_reality',
severity: 'medium',
description:
'Ticket status remains "Waiting Customer" though the requestor effectively closed the loop on 04/24. resolved_date_time is null three days later.',
evidence: [
{ kind: 'ticket_field', field: 'status_label', value: 'Waiting Customer' },
{ kind: 'ticket_field', field: 'resolved_date_time', value: null },
{ kind: 'ticket_note', id: 33738796 },
],
grounded: true,
},
],
expected_next_step_keywords: [
// The Sonnet-tier next_step should reference at least one of these.
'confirm with requestor',
'Vertafore',
'close',
],
sync_gap_observed: {
live_note_count: live.notes.length,
synced_note_count: 2,
missing_note_ids: live.notes.map((n) => n.id).filter((id) => ![33741514, 33741844].includes(id)),
},
};
writeFileSync(`${OUT_DIR}/T20260424.0045.expected.json`, JSON.stringify(expected, null, 2));
console.log(`Wrote:\n ${OUT_DIR}/T20260424.0045.input.json\n ${OUT_DIR}/T20260424.0045.expected.json`);
console.log(
`\nFixture summary:\n ticket: ${input.ticket.ticket_number} (${input.ticket.status_label})\n notes: ${input.notes.length} time_entries: ${input.time_entries.length}\n retained_notes: ${expected.preprocessor.retained_note_ids.length} filtered_workflow: ${expected.preprocessor.filtered_workflow_noise_ids.length} filtered_email: ${expected.preprocessor.filtered_email_notification_ids_in_input.length}`
);
} finally {
await db.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,266 @@
/**
* diff-ticket-680282.ts
*
* One-shot investigation for the AI Ticket Analyzer fixture build.
*
* Pulls TicketNotes and TimeEntries for ticket 680282 (T20260424.0045) from
* BOTH Autotask (live REST) and the local Postgres mirror, then prints a diff.
*
* Why: the spec for the analyzer feature asserts a "I'll take it from here"
* ticket note from the requestor that is not present in our synced ticket_notes
* table. This script answers the question "is the note missing from Autotask
* too, or is our sync incomplete?".
*
* Output:
* - dev/analyzer-fixture/T20260424.0045.live.json (live Autotask payload)
* - dev/analyzer-fixture/T20260424.0045.db.json (local DB payload)
* - dev/analyzer-fixture/T20260424.0045.diff.json (id-level diff summary)
*
* Usage:
* npx tsx scripts/diff-ticket-680282.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { mkdirSync, writeFileSync } from 'fs';
import { Client } from 'pg';
config({ path: resolve(__dirname, '../.env.local') });
const TICKET_ID = 680282;
const TICKET_NUMBER = 'T20260424.0045';
const API_BASE = process.env.AUTOTASK_API_URL!;
const USERNAME = process.env.AUTOTASK_USERNAME!;
const SECRET = process.env.AUTOTASK_SECRET!;
const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!;
if (!API_BASE || !USERNAME || !SECRET || !INT_CODE) {
console.error('Missing Autotask credentials in env');
process.exit(1);
}
function authHeaders(): Record<string, string> {
return {
Username: USERNAME,
Secret: SECRET,
APIIntegrationcode: INT_CODE,
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
async function queryAll<T>(entity: string, filter: object[]): Promise<T[]> {
const all: T[] = [];
let nextUrl: string | null = null;
const requestBody = JSON.stringify({ MaxRecords: 500, filter });
while (true) {
const url = nextUrl ?? `${API_BASE}/${entity}/query`;
const res = await fetch(url, {
method: 'POST',
headers: authHeaders(),
body: requestBody,
});
if (!res.ok) {
throw new Error(`${entity} query failed: ${res.status} ${await res.text()}`);
}
const payload = (await res.json()) as {
items: T[];
pageDetails?: { nextPageUrl?: string };
};
all.push(...(payload.items || []));
if (payload.pageDetails?.nextPageUrl) {
nextUrl = payload.pageDetails.nextPageUrl;
} else {
break;
}
}
return all;
}
interface ATTicketNote {
id: number;
ticketID: number;
title?: string;
description?: string;
noteType?: number;
publish?: number;
creatorResourceID?: number | null;
creatorType?: number | null;
contactID?: number | null;
lastActivityDate?: string;
createDateTime?: string;
}
interface ATTimeEntry {
id: number;
ticketID: number;
resourceID: number;
hoursWorked: number;
summaryNotes?: string;
internalNotes?: string;
dateWorked?: string;
startDateTime?: string;
endDateTime?: string;
type?: number;
}
interface ATTicket {
id: number;
ticketNumber: string;
title: string;
status: number;
priority: number;
queueID: number | null;
companyID: number;
contactID: number | null;
assignedResourceID: number | null;
description?: string;
createDate: string;
lastActivityDate: string;
resolvedDateTime?: string;
}
async function getDbConnection(): Promise<Client> {
const client = new Client({
host: process.env.POSTGRES_HOST === 'postgres' ? 'localhost' : (process.env.POSTGRES_HOST || 'localhost'),
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB!,
user: process.env.POSTGRES_USER!,
password: process.env.POSTGRES_PASSWORD!,
});
await client.connect();
return client;
}
async function main() {
const outDir = resolve(__dirname, '../dev/analyzer-fixture');
mkdirSync(outDir, { recursive: true });
console.log(`\n== Ticket ${TICKET_NUMBER} (id=${TICKET_ID}) ==`);
// ── Live Autotask fetch ────────────────────────────────────────────────────
console.log('\n[live] fetching Autotask Tickets/TicketNotes/TimeEntries...');
const liveTickets = await queryAll<ATTicket>('Tickets', [
{ op: 'eq', field: 'id', value: TICKET_ID },
]);
const liveNotes = await queryAll<ATTicketNote>('TicketNotes', [
{ op: 'eq', field: 'ticketID', value: TICKET_ID },
]);
const liveEntries = await queryAll<ATTimeEntry>('TimeEntries', [
{ op: 'eq', field: 'ticketID', value: TICKET_ID },
]);
console.log(` live: ticket=${liveTickets.length} notes=${liveNotes.length} time_entries=${liveEntries.length}`);
// ── DB fetch ───────────────────────────────────────────────────────────────
console.log('\n[db] querying local Postgres...');
const db = await getDbConnection();
try {
const dbTicket = await db.query(
`SELECT id, ticket_number, title, status, priority, queue_id, company_id,
contact_id, assigned_resource_id, description, create_date,
last_activity_date, resolved_date_time
FROM tickets WHERE id = $1`,
[TICKET_ID]
);
const dbNotes = await db.query(
`SELECT id, ticket_id, title, description, note_type, publish,
creator_resource_id, creator_type, last_activity_date,
create_date_time, is_deleted
FROM ticket_notes WHERE ticket_id = $1 ORDER BY create_date_time`,
[TICKET_ID]
);
const dbEntries = await db.query(
`SELECT id, ticket_id, resource_id, hours_worked, notes, internal_notes,
entry_date, start_date_time, end_date_time, type, is_deleted
FROM time_entries WHERE ticket_id = $1 ORDER BY entry_date, id`,
[TICKET_ID]
);
console.log(` db: ticket=${dbTicket.rowCount} notes=${dbNotes.rowCount} time_entries=${dbEntries.rowCount}`);
// ── Write raw payloads ────────────────────────────────────────────────────
writeFileSync(
`${outDir}/T20260424.0045.live.json`,
JSON.stringify(
{ ticket: liveTickets[0] ?? null, notes: liveNotes, time_entries: liveEntries },
null,
2
)
);
writeFileSync(
`${outDir}/T20260424.0045.db.json`,
JSON.stringify(
{
ticket: dbTicket.rows[0] ?? null,
notes: dbNotes.rows,
time_entries: dbEntries.rows,
},
null,
2
)
);
// ── Diff ──────────────────────────────────────────────────────────────────
const liveNoteIds = new Set(liveNotes.map((n) => n.id));
const dbNoteIds = new Set(dbNotes.rows.map((r) => Number(r.id)));
const liveEntryIds = new Set(liveEntries.map((e) => e.id));
const dbEntryIds = new Set(dbEntries.rows.map((r) => Number(r.id)));
const notesOnlyInLive = [...liveNoteIds].filter((id) => !dbNoteIds.has(id));
const notesOnlyInDb = [...dbNoteIds].filter((id) => !liveNoteIds.has(id));
const entriesOnlyInLive = [...liveEntryIds].filter((id) => !dbEntryIds.has(id));
const entriesOnlyInDb = [...dbEntryIds].filter((id) => !liveEntryIds.has(id));
const diff = {
ticket_id: TICKET_ID,
ticket_number: TICKET_NUMBER,
counts: {
live_notes: liveNotes.length,
db_notes: dbNotes.rowCount,
live_time_entries: liveEntries.length,
db_time_entries: dbEntries.rowCount,
},
notes_only_in_live: notesOnlyInLive.map((id) => {
const n = liveNotes.find((x) => x.id === id)!;
return {
id: n.id,
title: n.title,
noteType: n.noteType,
publish: n.publish,
creatorResourceID: n.creatorResourceID,
creatorType: n.creatorType,
createDateTime: n.createDateTime,
description_preview: (n.description || '').slice(0, 240),
};
}),
notes_only_in_db: notesOnlyInDb,
entries_only_in_live: entriesOnlyInLive.map((id) => {
const e = liveEntries.find((x) => x.id === id)!;
return {
id: e.id,
dateWorked: e.dateWorked,
hoursWorked: e.hoursWorked,
summary_preview: (e.summaryNotes || '').slice(0, 240),
};
}),
entries_only_in_db: entriesOnlyInDb,
};
writeFileSync(`${outDir}/T20260424.0045.diff.json`, JSON.stringify(diff, null, 2));
console.log('\n== DIFF ==');
console.log(JSON.stringify(diff, null, 2));
console.log(`\nWrote: ${outDir}/T20260424.0045.{live,db,diff}.json`);
} finally {
await db.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

15
vitest.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, '.'),
},
},
test: {
environment: 'node',
include: ['lib/**/*.test.ts'],
globals: false,
},
});