wulf-pulse/app/analyzer/reports/new/page.tsx
lorentz bd3401df1c feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards
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>
2026-04-29 14:00:22 -04:00

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