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>
This commit is contained in:
Logan Cusano
2026-09-26 15:23:57 -04:00
co-authored by Claude Opus 5.5
parent e79b8bc37d
commit aff3f16d32
19 changed files with 1976 additions and 101 deletions
+22
View File
@@ -233,6 +233,28 @@ export const c2api = {
getCorrelationDebug: (limit: number, orphanHours: number) =>
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
// Replay (admin) — re-run the pipeline over past calls into a sandbox.
// See drb-c2-core/app/internal/replay.py.
estimateReplay: (p: { date_from: string; date_to: string; mode: import("@/lib/types").ReplayMode; system_ids?: string[] }) => {
const qs = new URLSearchParams({ date_from: p.date_from, date_to: p.date_to, mode: p.mode });
if (p.system_ids?.length) qs.set("system_ids", p.system_ids.join(","));
return request<import("@/lib/types").ReplayEstimate>(`/admin/replay/estimate?${qs}`);
},
startReplay: (body: {
date_from: string; date_to: string; mode: import("@/lib/types").ReplayMode;
system_ids?: string[]; source_run_id?: string | null; label?: string;
}) =>
request<import("@/lib/types").ReplayRun>("/admin/replay", { method: "POST", body: JSON.stringify(body) }),
listReplays: () =>
request<{ runs: import("@/lib/types").ReplayRun[]; active_run_id: string | null }>("/admin/replay"),
getReplay: (runId: string) => request<import("@/lib/types").ReplayRun>(`/admin/replay/${runId}`),
cancelReplay: (runId: string) =>
request<{ ok: boolean }>(`/admin/replay/${runId}/cancel`, { method: "POST" }),
deleteReplay: (runId: string) =>
request<{ ok: boolean }>(`/admin/replay/${runId}`, { method: "DELETE" }),
getReplayIncidents: (runId: string) =>
request<import("@/lib/types").ReplayIncidents>(`/admin/replay/${runId}/incidents`),
// Preferred bot token per system
setPreferredToken: (tokenId: string, systemId: string) =>
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
+90
View File
@@ -306,3 +306,93 @@ export interface TalkgroupPending {
talkgroup_name?: string;
pending: PendingLocalTerm[];
}
// ---------------------------------------------------------------------------
// Replay (admin) — drb-c2-core/app/internal/replay.py
// ---------------------------------------------------------------------------
export type ReplayMode = "audio" | "transcripts" | "reuse";
export interface ReplayEstimate {
calls: number;
calls_with_transcript: number;
calls_with_audio: number;
audio_minutes: number;
est_cost_usd: number;
truncated: boolean;
max_calls: number;
}
export interface ReplayMetrics {
calls: number;
calls_linked: number;
calls_orphaned: number;
incidents: number;
single_call_incidents: number;
single_call_pct: number | null;
median_calls_per_incident: number | null;
max_calls_in_incident: number | null;
incidents_with_units_cleared: number;
incidents_with_coords: number;
resolved_via: Record<string, number>;
corr_path: Record<string, number>;
corr_consensus: Record<string, number>;
llm_decisions: number;
est_cost_usd: number;
}
export interface ReplayRun {
run_id: string;
label: string;
mode: ReplayMode;
source_run_id: string | null;
date_from: string;
date_to: string;
system_ids: string[];
git_sha: string;
created_by: string;
created_at: string;
finished_at?: string;
status: "running" | "done" | "cancelled" | "failed" | "interrupted";
estimate: Omit<ReplayEstimate, "truncated" | "max_calls">;
progress: { total: number; done: number; errors: number; skipped?: number };
metrics: ReplayMetrics | null;
errors: string[];
}
export interface ReplayCallRow {
call_id: string;
started_at: string;
talkgroup_name: string | null;
transcript: string | null;
units: string[] | null;
cleared_units: string[] | null;
location: string | null;
skip_reason: string | null;
corr_path: string[];
incident_ids: string[];
}
export interface ReplayIncident {
incident_id: string;
title: string | null;
type: string | null;
severity: string | null;
status: string;
resolved_via: string | null;
started_at: string;
updated_at: string | null;
resolved_at: string | null;
location: string | null;
location_coords: { lat: number; lng: number } | null;
units: string[] | null;
units_active: string[] | null;
units_cleared: string[] | null;
talkgroup_ids: number[] | null;
calls: ReplayCallRow[];
}
export interface ReplayIncidents {
incidents: ReplayIncident[];
orphans: ReplayCallRow[];
}