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>
109 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|