Files
server-26/drb-frontend/components/admin/ReplayTab.tsx
T
Logan CusanoandClaude Opus 5.5 aff3f16d32 Admin Replay: re-run the pipeline over past calls in a sandbox (#170)
Correlation has only ever been measured through live AI windows: days of
wall time per change, and the 09-20→22 window was invalidated outright by
unfunded AI accounts (#169). Recordings are kept regardless of AI, so the
traffic to measure against already exists.

- internal/replay.py: runs a time range of real calls through the live
  pipeline code in original order, clock pinned per call, into
  replay_runs/{run_id}/calls|incidents. Modes: audio (re-transcribe),
  transcripts (re-extract), reuse (correlation only from a prior run's
  scenes). Simulates the idle-resolve and orphan-recorrelation sweeps on
  virtual time. No alerts, summaries, vocab, AI-health alerts or pending
  terms. One run at a time, <=5000 calls, <=7 days.
- firestore.py: ContextVar sandbox redirect for calls/incidents.
- clock.py: ContextVar-pinnable now(), used on the correlation path.
- feature_flags.py: ContextVar flag override so replay runs with live AI off.
- upload.py: scene loop extracted to _extract_and_correlate, shared by the
  live pipeline and replay so replay measures the code that runs live.
- resolved_via on every incident resolve, so a real clear can be told
  from the idle timeout — live and in replay.
- routers/replay.py + /admin Replay tab: estimate, start, compare runs,
  drill into incidents with audio.

Reviewed by drb-correlation-review; its leak and fidelity findings are
fixed and covered by tests. c2-core: 456 pass. Frontend typecheck not run
(no Node on the authoring box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:23:57 -04:00

440 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(setData).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>
)}
{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&apos; 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>
);
}