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>
440 lines
20 KiB
TypeScript
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' 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>
|
|
);
|
|
}
|