First replay (290 calls, 09-22 10:00-12:00 ET) produced 0 incidents and no errors: every gpt-4o-mini extraction failed and _sync_extract swallowed it as "no scenes". Same shape as #169 — and the live extraction tier in /health/ai had no reporter at all, so this has been invisible in production too. - intelligence: API failures propagate out of _sync_extract; extract_scenes reports them to ai_health ("extraction" tier, billing/dead-model classified) and still returns [] so the pipeline degrades as before. - ai_health: inside a replay sandbox, failures go to the run's own sink instead of being dropped. - replay: aborts after 5 permanent failures on a tier, naming the cause; run metrics carry ai_failures; UI shows them. - replay estimate: audio minutes from started_at/ended_at (no duration field exists on call docs). - ReplayTab exposes the loaded run on window.__drbReplay for in-page analysis. c2-core: 458 pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
450 lines
20 KiB
TypeScript
450 lines
20 KiB
TypeScript
"use client";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Replay — re-run the intelligence pipeline over a past time range into a
|
||
// sandbox, then compare runs. Backend: drb-c2-core/app/internal/replay.py.
|
||
// Nothing here touches a live call or incident; every run spends real AI
|
||
// credits, so the form estimates first and only then offers Start.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { c2api } from "@/lib/c2api";
|
||
import { useSystems } from "@/lib/useSystems";
|
||
import type {
|
||
ReplayEstimate, ReplayIncident, ReplayIncidents, ReplayMode, ReplayRun, ReplayCallRow,
|
||
} from "@/lib/types";
|
||
|
||
const MODES: { key: ReplayMode; label: string; help: string }[] = [
|
||
{ key: "transcripts", label: "Saved transcripts", help: "Reuse each call's transcript; re-run extraction + correlation." },
|
||
{ key: "audio", label: "Re-transcribe audio", help: "Whisper the saved audio again, then extract + correlate. For ranges where AI was off." },
|
||
{ key: "reuse", label: "Correlation only", help: "Reuse an earlier run's extraction; re-run correlation only. Isolates a correlator change." },
|
||
];
|
||
|
||
// Clears that came from the radio traffic itself, vs the 90-minute idle timer.
|
||
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
|
||
|
||
const input =
|
||
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
||
const btn =
|
||
"bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
|
||
|
||
function toLocalInput(d: Date): string {
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
function fmtTime(iso: string | null | undefined): string {
|
||
if (!iso) return "—";
|
||
return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
function sum(rec: Record<string, number> | undefined, keys: string[]): number {
|
||
return keys.reduce((n, k) => n + (rec?.[k] ?? 0), 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolean; onStarted: () => void }) {
|
||
const { systems } = useSystems();
|
||
const [from, setFrom] = useState(() => toLocalInput(new Date(Date.now() - 6 * 3600_000)));
|
||
const [to, setTo] = useState(() => toLocalInput(new Date()));
|
||
const [mode, setMode] = useState<ReplayMode>("transcripts");
|
||
const [systemIds, setSystemIds] = useState<string[]>([]);
|
||
const [sourceRun, setSourceRun] = useState("");
|
||
const [label, setLabel] = useState("");
|
||
const [est, setEst] = useState<ReplayEstimate | null>(null);
|
||
const [working, setWorking] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Any change to what would be replayed invalidates the estimate.
|
||
useEffect(() => { setEst(null); }, [from, to, mode, systemIds]);
|
||
|
||
const sources = runs.filter((r) => r.status === "done" && r.mode !== "reuse");
|
||
const range = () => ({ date_from: new Date(from).toISOString(), date_to: new Date(to).toISOString() });
|
||
|
||
async function estimate() {
|
||
setWorking(true); setError(null);
|
||
try {
|
||
setEst(await c2api.estimateReplay({ ...range(), mode, system_ids: systemIds }));
|
||
} catch (e) { setError(String(e)); } finally { setWorking(false); }
|
||
}
|
||
|
||
async function start() {
|
||
setWorking(true); setError(null);
|
||
try {
|
||
await c2api.startReplay({
|
||
...range(), mode, system_ids: systemIds, label,
|
||
source_run_id: mode === "reuse" ? sourceRun : null,
|
||
});
|
||
setEst(null); setLabel("");
|
||
onStarted();
|
||
} catch (e) { setError(String(e)); } finally { setWorking(false); }
|
||
}
|
||
|
||
function toggleSystem(id: string) {
|
||
setSystemIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||
}
|
||
|
||
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
|
||
|
||
return (
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
||
<div className="flex flex-wrap items-end gap-4">
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">From</label>
|
||
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">To</label>
|
||
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
||
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
{MODES.map((m) => (
|
||
<label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer">
|
||
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
|
||
<span className="text-white">{m.label}</span>
|
||
<span className="text-gray-500 text-xs mt-0.5">{m.help}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
{mode === "reuse" && (
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Reuse extraction from</label>
|
||
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
|
||
<option value="">— pick a finished run —</option>
|
||
{sources.map((r) => (
|
||
<option key={r.run_id} value={r.run_id}>
|
||
{r.label || r.run_id} · {fmtTime(r.date_from)} → {fmtTime(r.date_to)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">Set the range to match that run; calls outside it are skipped.</p>
|
||
</div>
|
||
)}
|
||
|
||
{systems.length > 1 && (
|
||
<div className="flex flex-wrap gap-3">
|
||
<span className="text-xs text-gray-400">Systems (none = all):</span>
|
||
{systems.map((s) => (
|
||
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-gray-300 cursor-pointer">
|
||
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
|
||
{s.name}
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<button onClick={estimate} disabled={working} className={btn}>{working && !est ? "Counting…" : "Estimate"}</button>
|
||
<button
|
||
onClick={start}
|
||
disabled={!canStart || working}
|
||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||
>
|
||
Start run
|
||
</button>
|
||
{busy && <span className="text-xs text-amber-400 font-mono">a run is in progress</span>}
|
||
</div>
|
||
|
||
{est && (
|
||
<p className="text-sm font-mono text-gray-300">
|
||
{est.truncated ? (
|
||
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
|
||
) : (
|
||
<>
|
||
<span className="text-white">{est.calls}</span> calls ·{" "}
|
||
{est.calls_with_transcript} with transcripts · {est.audio_minutes} audio min ·{" "}
|
||
<span className="text-amber-400">~${est.est_cost_usd.toFixed(2)}</span> est.
|
||
{mode === "transcripts" && est.calls_with_transcript < est.calls / 2 && (
|
||
<span className="text-amber-400"> — most calls have no transcript; consider Re-transcribe audio.</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
|
||
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function RunsTable({
|
||
runs, activeId, selected, onSelect, onChanged,
|
||
}: {
|
||
runs: ReplayRun[]; activeId: string | null; selected: string | null;
|
||
onSelect: (id: string) => void; onChanged: () => void;
|
||
}) {
|
||
async function cancel(id: string) {
|
||
await c2api.cancelReplay(id).catch(() => undefined);
|
||
onChanged();
|
||
}
|
||
async function remove(id: string) {
|
||
if (!window.confirm("Delete this run and its sandbox incidents?")) return;
|
||
await c2api.deleteReplay(id).catch(() => undefined);
|
||
onChanged();
|
||
}
|
||
|
||
if (!runs.length) return <p className="text-sm text-gray-500 font-mono">No runs yet.</p>;
|
||
|
||
const th = "text-left text-xs text-gray-500 font-normal px-2 py-1.5 whitespace-nowrap";
|
||
const td = "px-2 py-1.5 whitespace-nowrap";
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm font-mono">
|
||
<thead>
|
||
<tr className="border-b border-gray-800">
|
||
<th className={th}>Run</th>
|
||
<th className={th}>Range</th>
|
||
<th className={th}>Status</th>
|
||
<th className={th} title="Incidents created">Inc</th>
|
||
<th className={th} title="Share of incidents that are a single call">1-call</th>
|
||
<th className={th} title="Calls that never linked to an incident">Orphans</th>
|
||
<th className={th} title="Closed by radio traffic (units cleared, closure, reassignment) vs the idle timer">Real clears / timeouts</th>
|
||
<th className={th} title="Correlation decisions the LLM took part in">LLM</th>
|
||
<th className={th}>Cost</th>
|
||
<th className={th}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{runs.map((r) => {
|
||
const m = r.metrics;
|
||
const running = r.run_id === activeId;
|
||
return (
|
||
<tr
|
||
key={r.run_id}
|
||
onClick={() => onSelect(r.run_id)}
|
||
className={`border-b border-gray-800/60 cursor-pointer ${selected === r.run_id ? "bg-gray-800/60" : "hover:bg-gray-900"}`}
|
||
>
|
||
<td className={td}>
|
||
<div className="text-white">{r.label || r.run_id}</div>
|
||
<div className="text-xs text-gray-500">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
|
||
</td>
|
||
<td className={`${td} text-xs text-gray-400`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
|
||
<td className={td}>
|
||
{running ? (
|
||
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
|
||
) : (
|
||
<span className={r.status === "done" ? "text-green-400" : "text-red-400"}>{r.status}</span>
|
||
)}
|
||
{r.progress.errors > 0 && <span className="text-red-400 text-xs"> · {r.progress.errors} err</span>}
|
||
</td>
|
||
<td className={td}>{m?.incidents ?? "—"}</td>
|
||
<td className={td}>{m?.single_call_pct != null ? `${m.single_call_pct}%` : "—"}</td>
|
||
<td className={td}>{m ? `${m.calls_orphaned}/${m.calls}` : "—"}</td>
|
||
<td className={td}>
|
||
{m ? (
|
||
<>
|
||
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
|
||
{" / "}
|
||
<span className="text-gray-400">{m.resolved_via.idle_timeout ?? 0}</span>
|
||
</>
|
||
) : "—"}
|
||
</td>
|
||
<td className={td}>{m?.llm_decisions ?? "—"}</td>
|
||
<td className={td}>{m ? `$${m.est_cost_usd.toFixed(2)}` : `~$${r.estimate.est_cost_usd.toFixed(2)}`}</td>
|
||
<td className={td} onClick={(e) => e.stopPropagation()}>
|
||
{running ? (
|
||
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button>
|
||
) : (
|
||
<button onClick={() => remove(r.run_id)} className="text-xs text-gray-500 hover:text-red-400">delete</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function CallLine({ call }: { call: ReplayCallRow }) {
|
||
const [audio, setAudio] = useState<string | null>(null);
|
||
async function play() {
|
||
try {
|
||
const c = await c2api.getCall(call.call_id);
|
||
setAudio(c.audio_url);
|
||
} catch { /* audio is a convenience */ }
|
||
}
|
||
return (
|
||
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono">
|
||
<div className="flex flex-wrap gap-2 text-gray-500">
|
||
<span>{fmtTime(call.started_at)}</span>
|
||
<span>{call.talkgroup_name}</span>
|
||
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)}
|
||
{call.units?.length ? <span>units {call.units.join(", ")}</span> : null}
|
||
{call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null}
|
||
{call.skip_reason && <span className="text-gray-600">{call.skip_reason}</span>}
|
||
{audio ? (
|
||
<audio src={audio} controls autoPlay className="h-6" />
|
||
) : (
|
||
<button onClick={play} className="text-gray-400 hover:text-white">▶ audio</button>
|
||
)}
|
||
</div>
|
||
<div className="text-gray-200 mt-0.5">{call.transcript || <span className="text-gray-600">(no transcript)</span>}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IncidentCard({ inc }: { inc: ReplayIncident }) {
|
||
const [open, setOpen] = useState(false);
|
||
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
|
||
return (
|
||
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
||
<button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono">
|
||
<span className="text-gray-500">{open ? "▾" : "▸"}</span>
|
||
<span className="text-white">{inc.title || inc.incident_id}</span>
|
||
<span className="text-gray-500 text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
|
||
<span className="text-gray-500 text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
|
||
<span className={`text-xs ${signal ? "text-green-400" : "text-gray-500"}`}>{inc.resolved_via ?? inc.status}</span>
|
||
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
|
||
</button>
|
||
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RunDetail({ run }: { run: ReplayRun }) {
|
||
const [data, setData] = useState<ReplayIncidents | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [filter, setFilter] = useState<"all" | "multi" | "single">("all");
|
||
const [showOrphans, setShowOrphans] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setData(null); setError(null);
|
||
if (run.status === "running") return;
|
||
c2api.getReplayIncidents(run.run_id).then((d) => {
|
||
setData(d);
|
||
// Exposed for in-page analysis (console / automation) of a run's
|
||
// sandbox — the same data this tab renders, nothing more.
|
||
(window as unknown as { __drbReplay?: unknown }).__drbReplay = { run, ...d };
|
||
}).catch((e) => setError(String(e)));
|
||
}, [run.run_id, run.status]);
|
||
|
||
const m = run.metrics;
|
||
const shown = (data?.incidents ?? []).filter((i) =>
|
||
filter === "all" ? true : filter === "multi" ? i.calls.length > 1 : i.calls.length === 1,
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{m && (
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs font-mono">
|
||
{[
|
||
["Linked calls", `${m.calls_linked}/${m.calls}`],
|
||
["Median calls / incident", m.median_calls_per_incident ?? "—"],
|
||
["With units cleared", m.incidents_with_units_cleared],
|
||
["With map pin", m.incidents_with_coords],
|
||
].map(([k, v]) => (
|
||
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2">
|
||
<div className="text-gray-500">{k}</div>
|
||
<div className="text-white text-base">{v}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{m && (
|
||
<p className="text-xs font-mono text-gray-500">
|
||
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||
<br />
|
||
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||
</p>
|
||
)}
|
||
{m?.ai_failures && Object.keys(m.ai_failures).length > 0 && (
|
||
<p className="text-xs font-mono text-amber-400">
|
||
AI failures: {Object.entries(m.ai_failures).map(([k, v]) => `${k} ×${v}`).join(" · ")}
|
||
</p>
|
||
)}
|
||
{run.errors?.length > 0 && (
|
||
<details className="text-xs font-mono text-red-400">
|
||
<summary>{run.errors.length} error(s)</summary>
|
||
{run.errors.map((e, i) => <div key={i}>{e}</div>)}
|
||
</details>
|
||
)}
|
||
{run.status === "running" && <p className="text-sm text-gray-500 font-mono">Incidents appear when the run finishes.</p>}
|
||
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||
{data && (
|
||
<>
|
||
<div className="flex gap-1 text-xs font-mono">
|
||
{(["all", "multi", "single"] as const).map((f) => (
|
||
<button
|
||
key={f}
|
||
onClick={() => setFilter(f)}
|
||
className={`px-3 py-1 rounded-md ${filter === f ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
||
>
|
||
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => setShowOrphans(!showOrphans)}
|
||
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
||
>
|
||
Orphans ({data.orphans.length})
|
||
</button>
|
||
</div>
|
||
{showOrphans ? (
|
||
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
||
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1.5">{shown.map((i) => <IncidentCard key={i.incident_id} inc={i} />)}</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function ReplayTab() {
|
||
const [runs, setRuns] = useState<ReplayRun[]>([]);
|
||
const [activeId, setActiveId] = useState<string | null>(null);
|
||
const [selected, setSelected] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const res = await c2api.listReplays();
|
||
setRuns(res.runs);
|
||
setActiveId(res.active_run_id);
|
||
setError(null);
|
||
} catch (e) { setError(String(e)); }
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
// Poll only while something is running.
|
||
useEffect(() => {
|
||
if (!activeId) return;
|
||
const t = setInterval(load, 4000);
|
||
return () => clearInterval(t);
|
||
}, [activeId, load]);
|
||
|
||
const run = runs.find((r) => r.run_id === selected) ?? null;
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<p className="text-xs text-gray-500 font-mono">
|
||
Re-run the pipeline over a past time range, in the calls' original order, into a sandbox — live incidents are never
|
||
touched. Replay the same range after each change and compare the rows below. A run starts with no incidents
|
||
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
|
||
</p>
|
||
<NewRunForm runs={runs} busy={!!activeId} onStarted={load} />
|
||
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||
<RunsTable runs={runs} activeId={activeId} selected={selected} onSelect={setSelected} onChanged={load} />
|
||
{run && <RunDetail run={run} />}
|
||
</div>
|
||
);
|
||
}
|