Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:
2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
or failure. Worker persists a status='failed' analyzer_analyses row when
the pipeline throws so partial stage records have a parent. Pipeline
exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
queue/status/priority/assignee, sticky filter bar, active-filter chips,
bulk selection persisted via localStorage, "Analyze N selected" +
"Generate aggregate report" actions. New <MultiSelect> primitive.
Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
SQL distributions immediately so UI shows partial state during the
Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
/:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
$20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
override. Every gating decision audited.
2.8 Runbook + build notes updated.
128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
7.3 KiB
TypeScript
222 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState, Suspense } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ArrowLeft, Sparkles, AlertTriangle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
function NewReportInner() {
|
|
const router = useRouter();
|
|
const params = useSearchParams();
|
|
const ticketNumbers = useMemo(
|
|
() =>
|
|
(params.get('ids') ?? '')
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean),
|
|
[params]
|
|
);
|
|
|
|
const [title, setTitle] = useState('');
|
|
const [includeItglue, setIncludeItglue] = useState(true);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [warning, setWarning] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (ticketNumbers.length === 0)
|
|
setWarning('No tickets specified. Pick tickets from the browse page first.');
|
|
else if (ticketNumbers.length > 100)
|
|
setWarning(
|
|
`${ticketNumbers.length} tickets selected — the cap is 100. Narrow the selection or split into multiple reports.`
|
|
);
|
|
else setWarning(null);
|
|
}, [ticketNumbers]);
|
|
|
|
async function postReport(confirmedCost: boolean) {
|
|
const res = await fetch('/api/analyzer/aggregate-reports', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
ticketNumbers,
|
|
includeItglueContext: includeItglue,
|
|
reportTitle: title.trim() || null,
|
|
confirmedCost,
|
|
}),
|
|
});
|
|
return {
|
|
ok: res.ok,
|
|
status: res.status,
|
|
data: (await res.json().catch(() => ({}))) as {
|
|
reportId?: string;
|
|
error?: string;
|
|
message?: string;
|
|
missingFingerprint?: string[];
|
|
staleAnalyses?: string[];
|
|
hint?: string;
|
|
requiresConfirmation?: boolean;
|
|
estimatedCost?: number;
|
|
dailySpendBefore?: number;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function handleGenerate() {
|
|
if (ticketNumbers.length === 0 || ticketNumbers.length > 100) return;
|
|
setSubmitting(true);
|
|
try {
|
|
let attempt = await postReport(false);
|
|
if (
|
|
!attempt.ok &&
|
|
attempt.status === 400 &&
|
|
attempt.data.requiresConfirmation &&
|
|
attempt.data.estimatedCost
|
|
) {
|
|
const ok = window.confirm(
|
|
`Estimated cost for this report is $${attempt.data.estimatedCost.toFixed(
|
|
2
|
|
)} (above $5 threshold). Daily spend so far: $${(attempt.data.dailySpendBefore ?? 0).toFixed(2)}.\n\nProceed?`
|
|
);
|
|
if (!ok) {
|
|
setSubmitting(false);
|
|
return;
|
|
}
|
|
attempt = await postReport(true);
|
|
}
|
|
if (!attempt.ok || !attempt.data.reportId) {
|
|
const d = attempt.data;
|
|
const detail = d.missingFingerprint?.length
|
|
? ` Missing fingerprint on: ${d.missingFingerprint.slice(0, 5).join(', ')}${
|
|
d.missingFingerprint.length > 5
|
|
? `, +${d.missingFingerprint.length - 5} more`
|
|
: ''
|
|
}.`
|
|
: d.staleAnalyses?.length
|
|
? ` Stale: ${d.staleAnalyses.slice(0, 5).join(', ')}${
|
|
d.staleAnalyses.length > 5
|
|
? `, +${d.staleAnalyses.length - 5} more`
|
|
: ''
|
|
}.`
|
|
: '';
|
|
throw new Error(
|
|
(d.message ?? d.error ?? 'Request failed') +
|
|
detail +
|
|
(d.hint ? ` (${d.hint})` : '')
|
|
);
|
|
}
|
|
router.push(`/analyzer/reports/${attempt.data.reportId}`);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Unknown error');
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 space-y-6 max-w-3xl">
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<Link href="/analyzer/tickets">
|
|
<ArrowLeft className="w-4 h-4 mr-1" />
|
|
Back to browse
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
Generate aggregate report
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">
|
|
Cross-ticket analysis over the {ticketNumbers.length} ticket
|
|
{ticketNumbers.length === 1 ? '' : 's'} you selected.
|
|
</p>
|
|
</div>
|
|
|
|
{warning && (
|
|
<Card className="border-amber-300 bg-amber-50/50 dark:bg-amber-950/30">
|
|
<CardContent className="pt-6 flex items-start gap-3">
|
|
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
|
<p className="text-sm">{warning}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Selected tickets</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap gap-1.5 max-h-48 overflow-y-auto">
|
|
{ticketNumbers.length === 0 ? (
|
|
<span className="text-sm text-muted-foreground">None</span>
|
|
) : (
|
|
ticketNumbers.map((tn) => (
|
|
<Badge key={tn} variant="secondary" className="font-mono text-xs">
|
|
{tn}
|
|
</Badge>
|
|
))
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Options</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="report-title">Report title (optional)</Label>
|
|
<Input
|
|
id="report-title"
|
|
placeholder="e.g. April backup tickets"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
maxLength={200}
|
|
/>
|
|
</div>
|
|
<Label className="flex items-start gap-3 cursor-pointer">
|
|
<Checkbox
|
|
checked={includeItglue}
|
|
onCheckedChange={(c) => setIncludeItglue(c === true)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span className="space-y-0.5">
|
|
<span className="text-sm font-medium block">Include IT Glue context</span>
|
|
<span className="text-xs text-muted-foreground block">
|
|
Fetch documentation titles for the affected clients so the model
|
|
can distinguish "no doc exists" from "doc exists but unused".
|
|
</span>
|
|
</span>
|
|
</Label>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" asChild>
|
|
<Link href="/analyzer/tickets">Cancel</Link>
|
|
</Button>
|
|
<Button
|
|
disabled={submitting || ticketNumbers.length === 0 || ticketNumbers.length > 100}
|
|
onClick={handleGenerate}
|
|
>
|
|
<Sparkles className="w-4 h-4 mr-2" />
|
|
{submitting ? 'Queueing…' : 'Generate report'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function NewReportPage() {
|
|
return (
|
|
<Suspense fallback={<div className="p-6 text-sm text-muted-foreground">Loading…</div>}>
|
|
<NewReportInner />
|
|
</Suspense>
|
|
);
|
|
}
|