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>
130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
'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’ll be navigated straight to it.
|
||
Otherwise the run takes ~10–60 seconds.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>Couldn’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>
|
||
);
|
||
}
|