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:
parent
ea3471d38d
commit
8f8b5ab7be
53 changed files with 9377 additions and 33 deletions
109
components/analyzer/analyze-button.tsx
Normal file
109
components/analyzer/analyze-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue