feat: AI ticket analyzer (phases 1-6)

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

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

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

View file

@ -0,0 +1,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,
});
}