wulf-pulse/components/analyzer/analyze-button.tsx
lorentz 8f8b5ab7be 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>
2026-04-29 10:59:40 -04:00

109 lines
3.3 KiB
TypeScript

'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>
);
}