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
127
app/analyzer/queue/page.tsx
Normal file
127
app/analyzer/queue/page.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue