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:
co-authored by
Claude Opus 5.5
parent
e79b8bc37d
commit
aff3f16d32
@@ -108,6 +108,11 @@ async def report_degraded(
|
||||
TRANSIENT_ALERT_THRESHOLD consecutive failures have been reported for
|
||||
this tier, so an ordinary blip never pages anyone.
|
||||
"""
|
||||
from app.internal import firestore as fstore
|
||||
if fstore.in_sandbox():
|
||||
# A replay's rate limits are not a live outage, and must never page
|
||||
# the AI-alert webhook or flip /health/ai (app/internal/replay.py).
|
||||
return
|
||||
if tier not in _state:
|
||||
_state[tier] = _default_state()
|
||||
entry = _state[tier]
|
||||
@@ -145,6 +150,9 @@ async def report_healthy(tier: str) -> None:
|
||||
just after a failure -- it is what lets a degraded tier recover on its
|
||||
own instead of staying red forever after one transient blip.
|
||||
"""
|
||||
from app.internal import firestore as fstore
|
||||
if fstore.in_sandbox():
|
||||
return # nor may a replay's success "recover" a real live outage
|
||||
if tier not in _state:
|
||||
_state[tier] = _default_state()
|
||||
entry = _state[tier]
|
||||
|
||||
@@ -448,6 +448,8 @@ async def add_pending(system_id: str, talkgroup_id: Any, entries: list[dict]) ->
|
||||
"""
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
if fstore.in_sandbox():
|
||||
return 0 # a replay proposes nothing to the live review queue
|
||||
if not system_id or talkgroup_id is None or not entries:
|
||||
return 0
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Pipeline clock.
|
||||
|
||||
`now()` is `datetime.now(timezone.utc)` everywhere except inside a replay run
|
||||
(app/internal/replay.py), which pins it to the replayed call's own time so the
|
||||
correlator's recency windows, the idle-resolve sweep and every started_at /
|
||||
updated_at / resolved_at it writes behave the way they did live.
|
||||
|
||||
A ContextVar rather than a module global: a replay runs as a background task
|
||||
alongside real uploads, and each asyncio task (and every asyncio.to_thread it
|
||||
spawns) carries its own copy of the context, so a pinned clock can never leak
|
||||
into a live call's pipeline.
|
||||
"""
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
_pinned: ContextVar[Optional[datetime]] = ContextVar("drb_clock_pinned", default=None)
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
pinned = _pinned.get()
|
||||
return pinned if pinned is not None else datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def pin(when: Optional[datetime]):
|
||||
"""Pin the clock for the current context. Returns a token for `unpin`."""
|
||||
return _pinned.set(when)
|
||||
|
||||
|
||||
def unpin(token) -> None:
|
||||
_pinned.reset(token)
|
||||
@@ -6,7 +6,8 @@ in-memory TTL cache so flag reads don't add a Firestore round-trip to every
|
||||
call upload.
|
||||
"""
|
||||
import time
|
||||
from typing import Any
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
@@ -36,6 +37,21 @@ _DEFAULTS: dict[str, bool] = {
|
||||
"transcript_correction_enabled": True,
|
||||
}
|
||||
|
||||
# A replay run (app/internal/replay.py) states exactly which AI steps it runs,
|
||||
# independent of the live switches — the whole point is re-running the pipeline
|
||||
# while live AI is OFF. ContextVar so the override never reaches a live upload.
|
||||
_forced: ContextVar[Optional[dict[str, bool]]] = ContextVar("drb_forced_flags", default=None)
|
||||
|
||||
|
||||
def force_flags(flags: Optional[dict[str, bool]]):
|
||||
"""Override resolve_flags() for the current context. Returns a reset token."""
|
||||
return _forced.set(flags)
|
||||
|
||||
|
||||
def unforce_flags(token) -> None:
|
||||
_forced.reset(token)
|
||||
|
||||
|
||||
_cache: dict[str, Any] = {}
|
||||
_cache_ts: float = 0.0
|
||||
|
||||
@@ -211,6 +227,11 @@ async def resolve_flags(system_id: str | None):
|
||||
"""
|
||||
from app.internal import firestore as _fstore
|
||||
|
||||
forced = _forced.get()
|
||||
if forced is not None:
|
||||
full = {k: bool(forced.get(k, False)) for k in _DEFAULTS}
|
||||
return full, lambda name: full.get(name, False)
|
||||
|
||||
flags = await get_flags()
|
||||
|
||||
system_ai_flags: dict = {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import time as _time
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Any
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, firestore as fs
|
||||
@@ -40,23 +41,61 @@ _init_firebase()
|
||||
db = fs.client(database_id=settings.firestore_database)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replay sandbox (app/internal/replay.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
# While a replay run is executing, every read and write the pipeline makes to
|
||||
# `calls` or `incidents` is redirected to that run's own subcollections under
|
||||
# replay_runs/{run_id}/, so re-running the pipeline over past traffic can never
|
||||
# touch a live call or incident. A subcollection keeps the same collection ID
|
||||
# ("calls"/"incidents"), so the composite indexes prod queries depend on apply
|
||||
# to it unchanged. Everything else (systems, nodes, config) is read from prod
|
||||
# as-is. ContextVar for the same reason as app/internal/clock.py: the redirect
|
||||
# follows the replay task and never a concurrent live upload.
|
||||
SANDBOXED_COLLECTIONS = frozenset({"calls", "incidents"})
|
||||
_sandbox_root: ContextVar[Optional[str]] = ContextVar("drb_fstore_sandbox", default=None)
|
||||
|
||||
|
||||
def enter_sandbox(root: Optional[str]):
|
||||
"""Redirect calls/incidents under `root` (e.g. "replay_runs/<id>") for this context."""
|
||||
return _sandbox_root.set(root)
|
||||
|
||||
|
||||
def exit_sandbox(token) -> None:
|
||||
_sandbox_root.reset(token)
|
||||
|
||||
|
||||
def in_sandbox() -> bool:
|
||||
"""True inside a replay run. Anything that writes live state OTHER than
|
||||
calls/incidents (AI health alerts, pending-term queues) checks this and
|
||||
stands down — the redirect below only covers the two sandboxed collections."""
|
||||
return _sandbox_root.get() is not None
|
||||
|
||||
|
||||
def _path(collection: str) -> str:
|
||||
root = _sandbox_root.get()
|
||||
if root and collection in SANDBOXED_COLLECTIONS:
|
||||
return f"{root}/{collection}"
|
||||
return collection
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thin async wrappers — firebase-admin is synchronous, run in thread executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def doc_set(collection: str, doc_id: str, data: dict, merge: bool = True) -> None:
|
||||
ref = db.collection(collection).document(doc_id)
|
||||
ref = db.collection(_path(collection)).document(doc_id)
|
||||
await asyncio.to_thread(ref.set, data, merge=merge)
|
||||
|
||||
|
||||
async def doc_get(collection: str, doc_id: str) -> Optional[dict]:
|
||||
ref = db.collection(collection).document(doc_id)
|
||||
ref = db.collection(_path(collection)).document(doc_id)
|
||||
snap = await asyncio.to_thread(ref.get)
|
||||
return snap.to_dict() if snap.exists else None
|
||||
|
||||
|
||||
async def doc_update(collection: str, doc_id: str, data: dict) -> None:
|
||||
ref = db.collection(collection).document(doc_id)
|
||||
ref = db.collection(_path(collection)).document(doc_id)
|
||||
await asyncio.to_thread(ref.update, data)
|
||||
|
||||
|
||||
@@ -66,7 +105,7 @@ async def collection_list(collection: str, **filters) -> list[dict]:
|
||||
Optional keyword filters: field=value pairs passed as equality where-clauses.
|
||||
"""
|
||||
def _query():
|
||||
ref = db.collection(collection)
|
||||
ref = db.collection(_path(collection))
|
||||
for field, value in filters.items():
|
||||
ref = ref.where(filter=FieldFilter(field, "==", value))
|
||||
return [doc.to_dict() for doc in ref.stream()]
|
||||
@@ -103,7 +142,7 @@ async def collection_where(
|
||||
unscoped equality-only lookups can keep using collection_list().
|
||||
"""
|
||||
def _query():
|
||||
ref = db.collection(collection)
|
||||
ref = db.collection(_path(collection))
|
||||
for field, op, value in conditions:
|
||||
ref = ref.where(filter=FieldFilter(field, op, value))
|
||||
for field, direction in (order_by or []):
|
||||
@@ -118,7 +157,7 @@ async def collection_where(
|
||||
|
||||
|
||||
async def doc_delete(collection: str, doc_id: str) -> None:
|
||||
ref = db.collection(collection).document(doc_id)
|
||||
ref = db.collection(_path(collection)).document(doc_id)
|
||||
await asyncio.to_thread(ref.delete)
|
||||
|
||||
|
||||
@@ -128,7 +167,7 @@ async def doc_get_cached(collection: str, doc_id: str, ttl: float = 300.0) -> Op
|
||||
Use for documents that change rarely (systems config, node assignments).
|
||||
Default TTL is 5 minutes — a write will be visible within that window.
|
||||
"""
|
||||
key = f"{collection}/{doc_id}"
|
||||
key = f"{_path(collection)}/{doc_id}"
|
||||
now = _time.monotonic()
|
||||
entry = _doc_cache.get(key)
|
||||
if entry and now < entry[0]:
|
||||
|
||||
@@ -51,6 +51,7 @@ from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import clock
|
||||
from app.config import settings
|
||||
|
||||
_PURSUIT_TAGS = frozenset({
|
||||
@@ -812,7 +813,7 @@ async def _build_context(
|
||||
transcript: Optional[str] = None,
|
||||
scene_index: int = 0,
|
||||
) -> dict:
|
||||
now = reference_time or datetime.now(timezone.utc)
|
||||
now = reference_time or clock.now()
|
||||
window = timedelta(hours=settings.correlation_window_hours)
|
||||
|
||||
call_doc = await fstore.doc_get("calls", call_id) or {}
|
||||
@@ -1951,6 +1952,7 @@ async def _release_reassigned_units(ctx: dict, exclude_incident_id: Optional[str
|
||||
if auto_resolved:
|
||||
updates["status"] = "resolved"
|
||||
updates["resolved_at"] = now.isoformat()
|
||||
updates["resolved_via"] = "reassignment"
|
||||
await fstore.doc_set("incidents", inc["incident_id"], updates)
|
||||
logger.info(
|
||||
f"Correlator: reassignment released unit(s) {matched} from incident "
|
||||
@@ -2072,6 +2074,7 @@ async def _update_incident(
|
||||
if units_cleared and not units_active:
|
||||
updates["status"] = "resolved"
|
||||
updates["resolved_at"] = now.isoformat()
|
||||
updates["resolved_via"] = "units_cleared"
|
||||
await fstore.doc_set("incidents", incident_id, updates)
|
||||
logger.info(
|
||||
f"Correlator: signal-resolved incident {incident_id} "
|
||||
@@ -2274,7 +2277,8 @@ async def maybe_resolve_parent(incident_id: str) -> None:
|
||||
# All children resolved — close the master
|
||||
await fstore.doc_set("incidents", parent_id, {
|
||||
"status": "resolved",
|
||||
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
||||
"resolved_at": clock.now().isoformat(),
|
||||
"resolved_via": "children_resolved",
|
||||
})
|
||||
logger.info(
|
||||
f"Auto-resolved master incident {parent_id} "
|
||||
|
||||
@@ -90,7 +90,8 @@ def _pipeline_likely_still_running(call: dict, now: datetime) -> bool:
|
||||
|
||||
|
||||
async def _run_sweep_pass() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
from app.internal import clock
|
||||
now = clock.now()
|
||||
cutoff = now - timedelta(minutes=settings.recorrelation_scan_minutes)
|
||||
|
||||
# Server-side range query: only calls that ended within the scan window.
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""
|
||||
Replay — re-run the intelligence pipeline over past calls, in a sandbox.
|
||||
|
||||
Live AI windows are the only way the correlator has ever been measured, and
|
||||
each one costs days of real time and whatever the credits allow: a change
|
||||
ships, AI goes on, traffic trickles in, someone pulls a dump. Recordings are
|
||||
kept whether AI is on or not, so the traffic to measure against already
|
||||
exists. A replay run takes a time range of real calls, feeds them through
|
||||
the SAME pipeline code the live upload path runs (routers/upload.py
|
||||
`_extract_and_correlate`) in their original order with the clock pinned to
|
||||
each call's own end time, and writes everything to
|
||||
replay_runs/{run_id}/calls|incidents instead of the live collections. The
|
||||
same range can then be replayed after every change and the runs compared.
|
||||
|
||||
Three modes, cheapest last:
|
||||
audio re-transcribe the saved audio (Whisper + correction), then
|
||||
extract and correlate. For ranges where AI was off.
|
||||
transcripts reuse the transcript already on each call, re-run extraction
|
||||
and correlation.
|
||||
reuse reuse the scenes an earlier run extracted, re-run correlation
|
||||
only. Extraction is an LLM call and never returns quite the
|
||||
same thing twice, so this is the mode that isolates a
|
||||
correlator change from extraction noise.
|
||||
|
||||
What never happens in a replay: alerts, summaries, vocabulary learning, and
|
||||
any write to a live call or incident. The sandbox is enforced by the
|
||||
ContextVar redirect in app/internal/firestore.py, not by this module
|
||||
remembering to use different collection names.
|
||||
|
||||
One run at a time per process — a run spends real AI credits and its cost is
|
||||
only estimated, so two concurrent runs would be two unbounded bills.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import statistics
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import clock
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.feature_flags import force_flags, unforce_flags
|
||||
from app.internal.logger import logger
|
||||
|
||||
RUNS = "replay_runs"
|
||||
MODES = ("audio", "transcripts", "reuse")
|
||||
MAX_CALLS = 5000
|
||||
MAX_RANGE_DAYS = 7
|
||||
|
||||
# Extraction/transcription run ahead of correlation with this much
|
||||
# concurrency. They depend only on the call itself; correlation depends on
|
||||
# every call before it and is kept strictly in order.
|
||||
PREFETCH = 6
|
||||
|
||||
# Rough per-unit AI prices for the pre-run estimate and the running tally.
|
||||
# Estimates, not a bill — nothing in DRB reads a real invoice (server-26#45).
|
||||
USD_WHISPER_PER_MIN = 0.006
|
||||
USD_PER_EXTRACTION = 0.0005 # gpt-4o-mini scene extraction + embedding
|
||||
USD_PER_CORRECTION = 0.0003 # Gemini flash transcript correction
|
||||
USD_PER_GEOCODE = 0.005 # Google geocode, roughly one per located scene
|
||||
USD_PER_LLM_CORRELATE = 0.0005 # Gemini flash consensus decision
|
||||
|
||||
# Fields the pipeline writes onto a call doc. Stripped when a call is copied
|
||||
# into the sandbox so the replay recomputes them instead of inheriting the
|
||||
# live answer. Anything else on the doc (ids, times, talkgroup, srcaddr, audio
|
||||
# location) is an input and is kept.
|
||||
_DERIVED = {
|
||||
"transcript", "transcript_corrected", "transcript_not_speech",
|
||||
"segments", "segments_corrected", "scenes", "incident_id", "incident_ids",
|
||||
"tags", "location", "location_coords", "location_mentions", "units",
|
||||
"vehicles", "cleared_units", "severity", "incident_type", "type",
|
||||
"embedding", "skip_reason", "intelligence_started_at", "reassignment",
|
||||
"resolved", "has_updates", "audio_url",
|
||||
}
|
||||
_DERIVED_PREFIXES = ("corr_", "chatter_classifier_", "eval_")
|
||||
_TRANSCRIPT_FIELDS = (
|
||||
"transcript", "transcript_corrected", "transcript_not_speech",
|
||||
"segments", "segments_corrected",
|
||||
)
|
||||
|
||||
_active_run_id: Optional[str] = None
|
||||
_active_task: Optional[asyncio.Task] = None
|
||||
_cancel: set[str] = set()
|
||||
|
||||
|
||||
class ReplayBusy(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sandbox_root(run_id: str) -> str:
|
||||
return f"{RUNS}/{run_id}"
|
||||
|
||||
|
||||
def _scenes_coll(run_id: str) -> str:
|
||||
# Extracted scenes (embeddings included) live beside the sandbox, not on
|
||||
# its call docs, so reading a run's calls for metrics or the incident view
|
||||
# doesn't haul every scene's embedding along a second time.
|
||||
return f"{sandbox_root(run_id)}/scenes"
|
||||
|
||||
|
||||
def active_run_id() -> Optional[str]:
|
||||
if _active_task is not None and not _active_task.done():
|
||||
return _active_run_id
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Call selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _as_dt(value) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def select_calls(
|
||||
org_id: str,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
system_ids: Optional[list[str]] = None,
|
||||
cap: int = MAX_CALLS,
|
||||
) -> tuple[list[dict], bool]:
|
||||
"""
|
||||
Live calls in [date_from, date_to] for this org, oldest first.
|
||||
|
||||
Pages newest-first because the one composite index on calls that carries
|
||||
org_id is (org_id ASC, started_at DESC); ordering the other way would need
|
||||
a new index for no gain. Returns (calls, truncated) — truncated means the
|
||||
range holds more than `cap` calls and the caller must narrow it rather
|
||||
than silently replaying only part of it.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
cursor = None
|
||||
page = 1000
|
||||
while True:
|
||||
rows = await fstore.collection_where(
|
||||
"calls",
|
||||
[("org_id", "==", org_id),
|
||||
("started_at", ">=", date_from),
|
||||
("started_at", "<=", date_to)],
|
||||
order_by=[("started_at", "DESCENDING")],
|
||||
limit_to=page,
|
||||
start_after={"started_at": cursor} if cursor is not None else None,
|
||||
)
|
||||
for c in rows:
|
||||
if c.get("duplicate_of"):
|
||||
continue # another node's copy — live never processes these either
|
||||
if system_ids and c.get("system_id") not in system_ids:
|
||||
continue
|
||||
out.append(c)
|
||||
if len(out) > cap:
|
||||
return sorted(out[:cap], key=_call_time), True
|
||||
if len(rows) < page:
|
||||
break
|
||||
cursor = rows[-1].get("started_at")
|
||||
return sorted(out, key=_call_time), False
|
||||
|
||||
|
||||
def _call_time(call: dict) -> datetime:
|
||||
return _as_dt(call.get("started_at")) or datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _pipeline_time(call: dict) -> datetime:
|
||||
"""When the live pipeline would have run for this call: at upload, i.e. call end."""
|
||||
return _as_dt(call.get("ended_at")) or _call_time(call)
|
||||
|
||||
|
||||
def estimate(calls: list[dict], mode: str) -> dict:
|
||||
n = len(calls)
|
||||
with_transcript = sum(1 for c in calls if c.get("transcript_corrected") or c.get("transcript"))
|
||||
audio_min = sum(float(c.get("duration_s") or 0) for c in calls) / 60
|
||||
with_audio = sum(1 for c in calls if c.get("audio_gcs_uri"))
|
||||
# Roughly a third of calls carry a geocodable location (09-22 dump: 92/373).
|
||||
per_call = USD_PER_EXTRACTION + USD_PER_LLM_CORRELATE + USD_PER_GEOCODE / 3
|
||||
if mode == "audio":
|
||||
usd = audio_min * USD_WHISPER_PER_MIN + with_audio * (USD_PER_CORRECTION + per_call)
|
||||
elif mode == "transcripts":
|
||||
usd = with_transcript * per_call
|
||||
else:
|
||||
usd = n * USD_PER_LLM_CORRELATE
|
||||
return {
|
||||
"calls": n,
|
||||
"calls_with_transcript": with_transcript,
|
||||
"calls_with_audio": with_audio,
|
||||
"audio_minutes": round(audio_min, 1),
|
||||
"est_cost_usd": round(usd, 2),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def start_run(
|
||||
*,
|
||||
org_id: str,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
mode: str,
|
||||
system_ids: Optional[list[str]],
|
||||
source_run_id: Optional[str],
|
||||
label: str,
|
||||
actor: str,
|
||||
) -> dict:
|
||||
global _active_run_id, _active_task
|
||||
if active_run_id():
|
||||
raise ReplayBusy(f"Replay {active_run_id()} is still running.")
|
||||
if mode not in MODES:
|
||||
raise ValueError(f"mode must be one of {MODES}")
|
||||
if date_to <= date_from:
|
||||
raise ValueError("date_to must be after date_from")
|
||||
if date_to - date_from > timedelta(days=MAX_RANGE_DAYS):
|
||||
raise ValueError(f"Range is capped at {MAX_RANGE_DAYS} days.")
|
||||
if mode == "reuse":
|
||||
src = await fstore.doc_get(RUNS, source_run_id or "")
|
||||
if not src or src.get("org_id") != org_id:
|
||||
raise ValueError("reuse mode needs a source_run_id from an earlier run in this org")
|
||||
if src.get("status") != "done":
|
||||
raise ValueError("The source run did not finish; its scenes are incomplete.")
|
||||
|
||||
calls, truncated = await select_calls(org_id, date_from, date_to, system_ids)
|
||||
if truncated:
|
||||
raise ValueError(f"Range holds more than {MAX_CALLS} calls — narrow it.")
|
||||
if not calls:
|
||||
raise ValueError("No calls in that range.")
|
||||
|
||||
run_id = uuid.uuid4().hex[:12]
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc = {
|
||||
"run_id": run_id,
|
||||
"org_id": org_id,
|
||||
"label": label or "",
|
||||
"mode": mode,
|
||||
"source_run_id": source_run_id if mode == "reuse" else None,
|
||||
"date_from": date_from.isoformat(),
|
||||
"date_to": date_to.isoformat(),
|
||||
"system_ids": system_ids or [],
|
||||
"git_sha": os.getenv("GIT_SHA", "unknown"),
|
||||
"created_by": actor,
|
||||
"created_at": now,
|
||||
"status": "running",
|
||||
"estimate": estimate(calls, mode),
|
||||
"progress": {"total": len(calls), "done": 0, "errors": 0},
|
||||
"metrics": None,
|
||||
"errors": [],
|
||||
}
|
||||
await fstore.doc_set(RUNS, run_id, doc, merge=False)
|
||||
|
||||
_active_run_id = run_id
|
||||
_active_task = asyncio.create_task(_run(run_id, org_id, calls, mode, source_run_id))
|
||||
return doc
|
||||
|
||||
|
||||
def request_cancel(run_id: str) -> bool:
|
||||
if active_run_id() != run_id:
|
||||
return False
|
||||
_cancel.add(run_id)
|
||||
return True
|
||||
|
||||
|
||||
async def get_run(run_id: str) -> Optional[dict]:
|
||||
doc = await fstore.doc_get(RUNS, run_id)
|
||||
return await _reconcile(doc) if doc else None
|
||||
|
||||
|
||||
async def list_runs(org_id: str) -> list[dict]:
|
||||
docs = await fstore.collection_list(RUNS, org_id=org_id)
|
||||
docs = [await _reconcile(d) for d in docs]
|
||||
return sorted(docs, key=lambda d: d.get("created_at") or "", reverse=True)
|
||||
|
||||
|
||||
async def _reconcile(doc: dict) -> dict:
|
||||
"""A run left "running" by a process that restarted (a deploy) never finishes."""
|
||||
if doc.get("status") == "running" and doc.get("run_id") != active_run_id():
|
||||
doc["status"] = "interrupted"
|
||||
await fstore.doc_set(RUNS, doc["run_id"], {"status": "interrupted"})
|
||||
return doc
|
||||
|
||||
|
||||
async def delete_run(run_id: str) -> None:
|
||||
if active_run_id() == run_id:
|
||||
raise ReplayBusy("Cancel the run before deleting it.")
|
||||
token = fstore.enter_sandbox(sandbox_root(run_id))
|
||||
try:
|
||||
for coll, key in (("calls", "call_id"), ("incidents", "incident_id"),
|
||||
(_scenes_coll(run_id), "call_id")):
|
||||
for d in await fstore.collection_list(coll):
|
||||
if d.get(key):
|
||||
await fstore.doc_delete(coll, d[key])
|
||||
finally:
|
||||
fstore.exit_sandbox(token)
|
||||
await fstore.doc_delete(RUNS, run_id)
|
||||
|
||||
|
||||
async def sandbox_contents(run_id: str) -> tuple[list[dict], list[dict]]:
|
||||
token = fstore.enter_sandbox(sandbox_root(run_id))
|
||||
try:
|
||||
incidents = await fstore.collection_list("incidents")
|
||||
calls = await fstore.collection_list("calls")
|
||||
finally:
|
||||
fstore.exit_sandbox(token)
|
||||
return incidents, calls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The run itself
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _flags_for(mode: str) -> dict[str, bool]:
|
||||
return {
|
||||
"stt_enabled": mode == "audio",
|
||||
"transcript_correction_enabled": mode == "audio",
|
||||
"correlation_enabled": True,
|
||||
"summaries_enabled": False,
|
||||
"vocabulary_learning_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
def _stored_input(call: dict) -> tuple[Optional[str], list]:
|
||||
"""
|
||||
The transcript + segments live extraction was handed for this call.
|
||||
|
||||
Not simply `transcript_corrected`: live extraction overwrites that field
|
||||
with its primary scene's rewrite (intelligence.py), so on a call with
|
||||
several scenes it now holds only scene 0's text. The corrector's own
|
||||
output survives intact in `segments_corrected`, so rebuild from those
|
||||
when they exist; otherwise correction never produced anything and live
|
||||
extraction read the raw Whisper transcript.
|
||||
"""
|
||||
if call.get("transcript_not_speech"):
|
||||
return None, [] # transcribe_call hands nothing downstream for noise
|
||||
corrected = call.get("segments_corrected") or []
|
||||
if corrected:
|
||||
text = " ".join(str(seg.get("text") or "").strip() for seg in corrected).strip()
|
||||
return (text or call.get("transcript")), corrected
|
||||
return call.get("transcript"), call.get("segments") or []
|
||||
|
||||
|
||||
def _extraction_fields(sb_call: dict) -> dict:
|
||||
"""What extraction wrote onto the call doc (tags, units, location,
|
||||
embedding, skip_reason, ...), minus everything correlation wrote. A reuse
|
||||
run restores these so the orphan sweep, which reads them straight off the
|
||||
call doc, sees what it saw in the source run."""
|
||||
return {
|
||||
k: v for k, v in sb_call.items()
|
||||
if (k in _DERIVED or k.startswith("chatter_classifier_"))
|
||||
and k not in _TRANSCRIPT_FIELDS
|
||||
and k not in ("scenes", "incident_id", "incident_ids", "intelligence_started_at")
|
||||
}
|
||||
|
||||
|
||||
def _sandbox_seed(call: dict, mode: str) -> dict:
|
||||
keep_transcript = mode in ("transcripts", "reuse")
|
||||
seed = {}
|
||||
for k, v in call.items():
|
||||
if k in _TRANSCRIPT_FIELDS:
|
||||
if keep_transcript:
|
||||
seed[k] = v
|
||||
continue
|
||||
if k in _DERIVED or k.startswith(_DERIVED_PREFIXES):
|
||||
continue
|
||||
seed[k] = v
|
||||
# Calls are seeded ahead of the replay clock (see PREFETCH). The orphan
|
||||
# re-correlation sweep selects status=="ended" calls by ended_at, so a
|
||||
# seeded call keeping its real status would be swept up as an "orphan"
|
||||
# before its own turn. Its real status is restored when it is processed.
|
||||
seed["status"] = "replay_pending"
|
||||
return seed
|
||||
|
||||
|
||||
async def _prepare(call: dict, mode: str, source_scenes: dict[str, dict]) -> dict:
|
||||
"""
|
||||
Everything per call that doesn't depend on other calls: seed the sandbox
|
||||
doc, then transcribe and/or extract. Runs ahead of correlation.
|
||||
Returns {"transcript", "scenes", "skip"} for the in-order stage.
|
||||
"""
|
||||
from app.internal import intelligence, talkgroups, transcription
|
||||
|
||||
call_id = call["call_id"]
|
||||
await fstore.doc_set("calls", call_id, _sandbox_seed(call, mode), merge=False)
|
||||
|
||||
talkgroup_name = await talkgroups.resolve(
|
||||
call.get("system_id"), call.get("talkgroup_id"),
|
||||
hint=call.get("talkgroup_name"), call_doc=call,
|
||||
)
|
||||
|
||||
transcript: Optional[str] = None
|
||||
segments: list = []
|
||||
if mode == "audio":
|
||||
if call.get("audio_gcs_uri"):
|
||||
transcript, segments = await transcription.transcribe_call(
|
||||
call_id, call["audio_gcs_uri"], talkgroup_name,
|
||||
system_id=call.get("system_id"), talkgroup_id=call.get("talkgroup_id"),
|
||||
)
|
||||
else:
|
||||
transcript, segments = _stored_input(call)
|
||||
|
||||
if mode == "reuse":
|
||||
src = source_scenes.get(call_id)
|
||||
if src is None:
|
||||
return {"skip": "not_in_source_run", "talkgroup_name": talkgroup_name}
|
||||
if src.get("call_fields"):
|
||||
# skip_reason gates upload.py's no-scene fallback and the orphan
|
||||
# sweep correlates from tags/units/location on the call doc, so
|
||||
# extraction's call-level output comes across with its scenes.
|
||||
await fstore.doc_set("calls", call_id, src["call_fields"])
|
||||
return {"transcript": transcript, "scenes": src.get("scenes") or [],
|
||||
"talkgroup_name": talkgroup_name}
|
||||
|
||||
scenes: list = []
|
||||
if transcript:
|
||||
scenes = await intelligence.extract_scenes(
|
||||
call_id, transcript, talkgroup_name,
|
||||
talkgroup_id=call.get("talkgroup_id"), system_id=call.get("system_id"),
|
||||
segments=segments, node_id=call.get("node_id"),
|
||||
)
|
||||
return {"transcript": transcript, "scenes": scenes, "talkgroup_name": talkgroup_name}
|
||||
|
||||
|
||||
async def _sweeps_until(t: datetime, state: dict) -> None:
|
||||
"""Run the live periodic sweeps (idle auto-resolve, orphan re-correlation) at every tick up to t."""
|
||||
from app.internal import recorrelation_sweep, summarizer
|
||||
|
||||
interval = timedelta(minutes=settings.summary_interval_minutes)
|
||||
if state["last_sweep"] is None:
|
||||
state["last_sweep"] = t
|
||||
return
|
||||
while state["last_sweep"] + interval <= t:
|
||||
state["last_sweep"] += interval
|
||||
tok = clock.pin(state["last_sweep"])
|
||||
try:
|
||||
await summarizer._resolve_stale_incidents()
|
||||
await recorrelation_sweep._run_sweep_pass()
|
||||
finally:
|
||||
clock.unpin(tok)
|
||||
|
||||
|
||||
async def _run(run_id: str, org_id: str, calls: list[dict], mode: str,
|
||||
source_run_id: Optional[str]) -> None:
|
||||
from app.routers.upload import _extract_and_correlate
|
||||
|
||||
global _active_run_id
|
||||
progress = {"total": len(calls), "done": 0, "errors": 0, "skipped": 0,
|
||||
"extractions": 0, "audio_minutes": 0.0}
|
||||
errors: list[str] = []
|
||||
status = "done"
|
||||
|
||||
source_scenes: dict[str, dict] = {}
|
||||
if mode == "reuse" and source_run_id:
|
||||
rows = await fstore.collection_list(_scenes_coll(source_run_id))
|
||||
source_scenes = {r["call_id"]: r for r in rows if r.get("call_id")}
|
||||
|
||||
sb_token = fstore.enter_sandbox(sandbox_root(run_id))
|
||||
fl_token = force_flags(_flags_for(mode))
|
||||
try:
|
||||
sem = asyncio.Semaphore(PREFETCH)
|
||||
|
||||
async def prep(call: dict):
|
||||
async with sem:
|
||||
tok = clock.pin(_pipeline_time(call))
|
||||
try:
|
||||
return await _prepare(call, mode, source_scenes)
|
||||
finally:
|
||||
clock.unpin(tok)
|
||||
|
||||
pending: dict[int, asyncio.Task] = {}
|
||||
sweep_state = {"last_sweep": None}
|
||||
last_t = None
|
||||
for i, call in enumerate(calls):
|
||||
for j in range(i, min(i + PREFETCH * 2, len(calls))):
|
||||
if j not in pending:
|
||||
pending[j] = asyncio.create_task(prep(calls[j]))
|
||||
if run_id in _cancel:
|
||||
status = "cancelled"
|
||||
break
|
||||
|
||||
t = _pipeline_time(call)
|
||||
last_t = t
|
||||
try:
|
||||
prepared = await pending.pop(i)
|
||||
await _sweeps_until(t, sweep_state)
|
||||
if prepared.get("skip"):
|
||||
progress["skipped"] += 1
|
||||
else:
|
||||
tok = clock.pin(t)
|
||||
try:
|
||||
await fstore.doc_set("calls", call["call_id"], {
|
||||
"status": call.get("status") or "ended",
|
||||
"intelligence_started_at": t.isoformat(),
|
||||
})
|
||||
_, _, scenes = await _extract_and_correlate(
|
||||
call_id=call["call_id"],
|
||||
node_id=call.get("node_id"),
|
||||
system_id=call.get("system_id"),
|
||||
talkgroup_id=call.get("talkgroup_id"),
|
||||
talkgroup_name=prepared["talkgroup_name"],
|
||||
transcript=prepared["transcript"],
|
||||
scenes=prepared["scenes"],
|
||||
)
|
||||
finally:
|
||||
clock.unpin(tok)
|
||||
# Kept whole (embedding included) so a later "reuse" run
|
||||
# can correlate from exactly these scenes.
|
||||
sb_call = await fstore.doc_get("calls", call["call_id"]) or {}
|
||||
await fstore.doc_set(_scenes_coll(run_id), call["call_id"], {
|
||||
"call_id": call["call_id"],
|
||||
"scenes": scenes,
|
||||
"call_fields": _extraction_fields(sb_call),
|
||||
}, merge=False)
|
||||
if prepared["transcript"] and mode != "reuse":
|
||||
progress["extractions"] += 1
|
||||
if mode == "audio":
|
||||
progress["audio_minutes"] += float(call.get("duration_s") or 0) / 60
|
||||
except Exception as e:
|
||||
progress["errors"] += 1
|
||||
if len(errors) < 20:
|
||||
errors.append(f"{call.get('call_id')}: {type(e).__name__}: {e}"[:300])
|
||||
logger.warning(f"Replay {run_id}: call {call.get('call_id')} failed: {e}")
|
||||
progress["done"] = i + 1
|
||||
if (i + 1) % 25 == 0:
|
||||
await fstore.doc_set(RUNS, run_id, {"progress": dict(progress), "errors": errors})
|
||||
|
||||
for task in pending.values():
|
||||
task.cancel()
|
||||
|
||||
if status == "done" and last_t is not None:
|
||||
# Let every incident age out exactly as it would have live.
|
||||
await _sweeps_until(
|
||||
last_t + timedelta(minutes=settings.incident_auto_resolve_minutes
|
||||
+ 2 * settings.summary_interval_minutes),
|
||||
sweep_state,
|
||||
)
|
||||
|
||||
incidents = await fstore.collection_list("incidents")
|
||||
sb_calls = await fstore.collection_list("calls")
|
||||
metrics = compute_metrics(incidents, sb_calls)
|
||||
metrics["est_cost_usd"] = _running_cost(progress, metrics, mode)
|
||||
except Exception as e:
|
||||
status = "failed"
|
||||
errors.append(f"run: {type(e).__name__}: {e}"[:300])
|
||||
metrics = None
|
||||
logger.error(f"Replay {run_id} failed: {e}")
|
||||
finally:
|
||||
unforce_flags(fl_token)
|
||||
fstore.exit_sandbox(sb_token)
|
||||
_cancel.discard(run_id)
|
||||
_active_run_id = None
|
||||
|
||||
await fstore.doc_set(RUNS, run_id, {
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"errors": errors,
|
||||
"metrics": metrics,
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
logger.info(f"Replay {run_id} {status}: {progress}")
|
||||
|
||||
|
||||
def _running_cost(progress: dict, metrics: dict, mode: str) -> float:
|
||||
usd = progress["audio_minutes"] * USD_WHISPER_PER_MIN
|
||||
if mode == "audio":
|
||||
usd += progress["extractions"] * USD_PER_CORRECTION
|
||||
usd += progress["extractions"] * (USD_PER_EXTRACTION + USD_PER_GEOCODE / 3)
|
||||
usd += metrics.get("llm_decisions", 0) * USD_PER_LLM_CORRELATE
|
||||
return round(usd, 2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scoring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_metrics(incidents: list[dict], calls: list[dict]) -> dict:
|
||||
"""
|
||||
The numbers that say whether incidents are being tracked, from one run's
|
||||
sandbox. Same questions every correlation review has asked by hand, so two
|
||||
runs over the same range compare directly.
|
||||
"""
|
||||
sizes = [len(i.get("call_ids") or []) for i in incidents]
|
||||
resolved_via = Counter(
|
||||
(i.get("resolved_via") or ("unknown" if i.get("status") == "resolved" else "still_active"))
|
||||
for i in incidents
|
||||
)
|
||||
corr_path: Counter = Counter()
|
||||
consensus: Counter = Counter()
|
||||
for c in calls:
|
||||
scenes = c.get("scenes") or {}
|
||||
records = [s.get("corr_debug") or {} for s in scenes.values()] if scenes else [c]
|
||||
for r in records:
|
||||
corr_path[r.get("corr_path") or "none"] += 1
|
||||
consensus[r.get("corr_consensus") or "none"] += 1
|
||||
linked = sum(1 for c in calls if c.get("incident_ids"))
|
||||
llm = sum(n for k, n in consensus.items() if k not in ("none", "rules_only"))
|
||||
return {
|
||||
"calls": len(calls),
|
||||
"calls_linked": linked,
|
||||
"calls_orphaned": len(calls) - linked,
|
||||
"incidents": len(incidents),
|
||||
"single_call_incidents": sum(1 for s in sizes if s == 1),
|
||||
"single_call_pct": round(100 * sum(1 for s in sizes if s == 1) / len(sizes), 1) if sizes else None,
|
||||
"median_calls_per_incident": statistics.median(sizes) if sizes else None,
|
||||
"max_calls_in_incident": max(sizes) if sizes else None,
|
||||
"incidents_with_units_cleared": sum(1 for i in incidents if i.get("units_cleared")),
|
||||
"incidents_with_coords": sum(1 for i in incidents if i.get("location_coords")),
|
||||
"resolved_via": dict(resolved_via),
|
||||
"corr_path": dict(corr_path),
|
||||
"corr_consensus": dict(consensus),
|
||||
"llm_decisions": llm,
|
||||
}
|
||||
@@ -148,7 +148,8 @@ async def _resolve_stale_incidents() -> None:
|
||||
if not all_active:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
from app.internal import clock
|
||||
now = clock.now()
|
||||
cutoff = timedelta(minutes=settings.incident_auto_resolve_minutes)
|
||||
count = 0
|
||||
|
||||
@@ -167,6 +168,7 @@ async def _resolve_stale_incidents() -> None:
|
||||
await fstore.doc_set("incidents", incident_id, {
|
||||
"status": "resolved",
|
||||
"resolved_at": now.isoformat(),
|
||||
"resolved_via": "idle_timeout",
|
||||
})
|
||||
from app.internal.incident_correlator import maybe_resolve_parent
|
||||
await maybe_resolve_parent(incident_id)
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.internal.auth import (
|
||||
require_node_service_or_firebase_token,
|
||||
)
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||
from app.routers import enrollment, media, org, waitlist, telemetry
|
||||
from app.routers import enrollment, media, org, waitlist, telemetry, replay
|
||||
from app.internal import dynsec
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
@@ -129,6 +129,7 @@ app.include_router(trips.router, dependencies=[Depends(require_service_or_fi
|
||||
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(upload.router) # auth is per-node, handled inline
|
||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
|
||||
app.include_router(users.router) # auth: admin only
|
||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
|
||||
|
||||
@@ -443,6 +443,7 @@ async def patch_transcript(
|
||||
"call_ids": [],
|
||||
"status": "resolved",
|
||||
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
||||
"resolved_via": "emptied_by_correction",
|
||||
"summary_stale": True,
|
||||
})
|
||||
await fstore.doc_set("calls", call_id, {"incident_ids": [], "incident_id": None})
|
||||
|
||||
@@ -170,6 +170,7 @@ async def unlink_call_from_incident(incident_id: str, call_id: str, _: dict = De
|
||||
if not remaining:
|
||||
updates["status"] = "resolved"
|
||||
updates["resolved_at"] = datetime.now(timezone.utc).isoformat()
|
||||
updates["resolved_via"] = "emptied_by_admin"
|
||||
await fstore.doc_update("incidents", incident_id, updates)
|
||||
|
||||
call = await fstore.doc_get("calls", call_id)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Admin replay routes — the backend for the /admin Replay tab.
|
||||
|
||||
See app/internal/replay.py for what a run is and why it exists. Every route is
|
||||
admin-only: a run spends real AI credits.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.internal import replay
|
||||
from app.internal.audit import write_audit
|
||||
from app.internal.auth import describe_actor, require_admin_token, resolve_caller_org_id
|
||||
from app.internal.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/admin/replay", tags=["admin"])
|
||||
|
||||
|
||||
def _parse_ts(value: Optional[str], field: str) -> datetime:
|
||||
if not value:
|
||||
raise HTTPException(400, f"{field} is required.")
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"{field} is not an ISO-8601 timestamp.")
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def _org(decoded: dict) -> str:
|
||||
# Same fallback as /calls/search: a platform admin resolves to "every
|
||||
# org", which is not a scope a replay can run in.
|
||||
org_id = await resolve_caller_org_id(decoded) or decoded.get("org_id")
|
||||
if not org_id:
|
||||
raise HTTPException(403, "No organization scope for this caller.")
|
||||
return org_id
|
||||
|
||||
|
||||
async def _own_run(run_id: str, org_id: str) -> dict:
|
||||
run = await replay.get_run(run_id)
|
||||
if not run or run.get("org_id") != org_id:
|
||||
raise HTTPException(404, f"Replay run '{run_id}' not found.")
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/estimate")
|
||||
async def estimate_run(
|
||||
date_from: str = Query(...),
|
||||
date_to: str = Query(...),
|
||||
mode: Literal["audio", "transcripts", "reuse"] = Query("transcripts"),
|
||||
system_ids: Optional[str] = Query(None, description="comma-separated"),
|
||||
decoded: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""How many calls a run over this range would process, and a rough cost."""
|
||||
org_id = await _org(decoded)
|
||||
sids = [s for s in (system_ids or "").split(",") if s] or None
|
||||
calls, truncated = await replay.select_calls(
|
||||
org_id, _parse_ts(date_from, "date_from"), _parse_ts(date_to, "date_to"), sids,
|
||||
)
|
||||
return {**replay.estimate(calls, mode), "truncated": truncated, "max_calls": replay.MAX_CALLS}
|
||||
|
||||
|
||||
class StartRun(BaseModel):
|
||||
date_from: str
|
||||
date_to: str
|
||||
mode: Literal["audio", "transcripts", "reuse"] = "transcripts"
|
||||
system_ids: Optional[list[str]] = None
|
||||
source_run_id: Optional[str] = None
|
||||
label: str = ""
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def start_run(body: StartRun, decoded: dict = Depends(require_admin_token)):
|
||||
org_id = await _org(decoded)
|
||||
actor_uid, actor_email = describe_actor(decoded)
|
||||
try:
|
||||
run = await replay.start_run(
|
||||
org_id=org_id,
|
||||
date_from=_parse_ts(body.date_from, "date_from"),
|
||||
date_to=_parse_ts(body.date_to, "date_to"),
|
||||
mode=body.mode,
|
||||
system_ids=body.system_ids or None,
|
||||
source_run_id=body.source_run_id,
|
||||
label=body.label[:120],
|
||||
actor=actor_email or actor_uid,
|
||||
)
|
||||
except replay.ReplayBusy as e:
|
||||
raise HTTPException(409, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
try:
|
||||
await write_audit(actor_uid, actor_email, "replay.start", details={
|
||||
"run_id": run["run_id"], "mode": run["mode"], "calls": run["progress"]["total"],
|
||||
"est_cost_usd": run["estimate"]["est_cost_usd"],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Replay: audit write failed ({e}) — run {run['run_id']} continues")
|
||||
return run
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_runs(decoded: dict = Depends(require_admin_token)):
|
||||
org_id = await _org(decoded)
|
||||
return {"runs": await replay.list_runs(org_id), "active_run_id": replay.active_run_id()}
|
||||
|
||||
|
||||
@router.get("/{run_id}")
|
||||
async def get_run(run_id: str, decoded: dict = Depends(require_admin_token)):
|
||||
return await _own_run(run_id, await _org(decoded))
|
||||
|
||||
|
||||
@router.post("/{run_id}/cancel")
|
||||
async def cancel_run(run_id: str, decoded: dict = Depends(require_admin_token)):
|
||||
await _own_run(run_id, await _org(decoded))
|
||||
if not replay.request_cancel(run_id):
|
||||
raise HTTPException(409, "That run is not running.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{run_id}")
|
||||
async def delete_run(run_id: str, decoded: dict = Depends(require_admin_token)):
|
||||
await _own_run(run_id, await _org(decoded))
|
||||
try:
|
||||
await replay.delete_run(run_id)
|
||||
except replay.ReplayBusy as e:
|
||||
raise HTTPException(409, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _call_row(c: dict) -> dict:
|
||||
scenes = c.get("scenes") or {}
|
||||
paths = [((s.get("corr_debug") or {}).get("corr_path")) for _, s in sorted(scenes.items())]
|
||||
return {
|
||||
"call_id": c.get("call_id"),
|
||||
"started_at": c.get("started_at"),
|
||||
"talkgroup_name": c.get("talkgroup_name"),
|
||||
"transcript": c.get("transcript_corrected") or c.get("transcript"),
|
||||
"units": c.get("units"),
|
||||
"cleared_units": c.get("cleared_units"),
|
||||
"location": c.get("location"),
|
||||
"skip_reason": c.get("skip_reason"),
|
||||
"corr_path": [p for p in paths if p] or ([c["corr_path"]] if c.get("corr_path") else []),
|
||||
"incident_ids": c.get("incident_ids") or [],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{run_id}/incidents")
|
||||
async def run_incidents(run_id: str, decoded: dict = Depends(require_admin_token)):
|
||||
"""
|
||||
A run's sandbox, shaped for reading: every incident with its calls in
|
||||
order, plus the calls that never linked. Embeddings stay out.
|
||||
"""
|
||||
await _own_run(run_id, await _org(decoded))
|
||||
incidents, calls = await replay.sandbox_contents(run_id)
|
||||
by_id = {c.get("call_id"): _call_row(c) for c in calls}
|
||||
out = []
|
||||
for inc in sorted(incidents, key=lambda i: str(i.get("started_at") or "")):
|
||||
rows = [by_id[cid] for cid in (inc.get("call_ids") or []) if cid in by_id]
|
||||
rows.sort(key=lambda r: str(r["started_at"] or ""))
|
||||
out.append({
|
||||
"incident_id": inc.get("incident_id"),
|
||||
"title": inc.get("title"),
|
||||
"type": inc.get("type"),
|
||||
"severity": inc.get("severity"),
|
||||
"status": inc.get("status"),
|
||||
"resolved_via": inc.get("resolved_via"),
|
||||
"started_at": inc.get("started_at"),
|
||||
"updated_at": inc.get("updated_at"),
|
||||
"resolved_at": inc.get("resolved_at"),
|
||||
"location": inc.get("location"),
|
||||
"location_coords": inc.get("location_coords"),
|
||||
"units": inc.get("units"),
|
||||
"units_active": inc.get("units_active"),
|
||||
"units_cleared": inc.get("units_cleared"),
|
||||
"talkgroup_ids": inc.get("talkgroup_ids"),
|
||||
"calls": rows,
|
||||
})
|
||||
orphans = sorted((r for r in by_id.values() if not r["incident_ids"]),
|
||||
key=lambda r: str(r["started_at"] or ""))
|
||||
return {"incidents": out, "orphans": orphans}
|
||||
@@ -1,11 +1,11 @@
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.internal.storage import upload_audio
|
||||
from app.internal import dedup
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import clock
|
||||
from app.internal.logger import logger
|
||||
from app.config import settings
|
||||
|
||||
@@ -140,7 +140,7 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
||||
if tg_id is None or not system_id:
|
||||
return False
|
||||
tg_str = str(tg_id)
|
||||
now = ctx.get("now") or datetime.now(timezone.utc)
|
||||
now = ctx.get("now") or clock.now()
|
||||
idle_limit = settings.tg_dispatch_thin_idle_minutes
|
||||
for inc in ctx.get("recent") or []:
|
||||
if system_id not in (inc.get("system_ids") or []):
|
||||
@@ -374,7 +374,8 @@ async def _run_extraction_pipeline(
|
||||
if scene["resolved"] and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {
|
||||
"status": "resolved",
|
||||
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
||||
"resolved_at": clock.now().isoformat(),
|
||||
"resolved_via": "llm_closure",
|
||||
})
|
||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
@@ -396,6 +397,113 @@ async def _run_extraction_pipeline(
|
||||
)
|
||||
|
||||
|
||||
async def _extract_and_correlate(
|
||||
call_id: str,
|
||||
node_id: str,
|
||||
system_id: Optional[str],
|
||||
talkgroup_id: Optional[int],
|
||||
talkgroup_name: Optional[str],
|
||||
transcript: Optional[str],
|
||||
segments: Optional[list[dict]] = None,
|
||||
scenes: Optional[list[dict]] = None,
|
||||
) -> tuple[list[str], list[str], list[dict]]:
|
||||
"""
|
||||
Steps 2-3 of the intelligence pipeline for one call: scene extraction
|
||||
(skipped when `scenes` is passed in), then per-scene correlation, then the
|
||||
no-scene thin fallback. Returns (incident_ids, merged tags, scenes).
|
||||
|
||||
Shared by the live pipeline below and by replay (app/internal/replay.py),
|
||||
so a replay run measures exactly the code that runs live rather than a
|
||||
copy of it that can drift. Caller owns the correlation feature-flag check
|
||||
and alerting.
|
||||
"""
|
||||
from app.internal import intelligence, incident_correlator
|
||||
|
||||
# Step 2: Scene detection + intelligence extraction
|
||||
if scenes is None:
|
||||
scenes = []
|
||||
if transcript:
|
||||
scenes = await intelligence.extract_scenes(
|
||||
call_id, transcript, talkgroup_name,
|
||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
# Step 3: Correlate each scene independently.
|
||||
# A single recording can produce multiple incidents on a busy channel.
|
||||
incident_ids: list[str] = []
|
||||
all_tags: list[str] = []
|
||||
# server-26#96: scene_index is threaded through so each scene's
|
||||
# corr_debug/transcript lands in its own entry of the call doc's
|
||||
# `scenes` map instead of clobbering every other scene's write.
|
||||
for scene_index, scene in enumerate(scenes):
|
||||
all_tags.extend(scene["tags"])
|
||||
is_reassignment = bool(scene.get("reassignment"))
|
||||
corr_units = [] if is_reassignment else scene.get("units")
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=scene["tags"],
|
||||
incident_type=scene["incident_type"],
|
||||
location=scene["location"],
|
||||
location_coords=scene["location_coords"],
|
||||
units=corr_units,
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
reassignment=is_reassignment,
|
||||
embedding=scene.get("embedding"),
|
||||
severity=scene.get("severity"),
|
||||
transcript=scene.get("transcript"),
|
||||
scene_index=scene_index,
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
if scene["resolved"] and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {
|
||||
"status": "resolved",
|
||||
"resolved_at": clock.now().isoformat(),
|
||||
"resolved_via": "llm_closure",
|
||||
})
|
||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
|
||||
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
||||
# talkgroup-based linking even when no transcript could be produced.
|
||||
# transcript_too_short (<=5 words: "10-8", "show me clear", a unit
|
||||
# check-in) still carries a real transcript and talkgroup — exactly the
|
||||
# brief follow-up/clearance traffic an incident needs, and the thin-path
|
||||
# merge below already requires a same-talkgroup, recently-active
|
||||
# incident before attaching anything, same guard already trusted for
|
||||
# no-transcript calls. Previously excluded here, so these calls never
|
||||
# attached to anything at all. garbage_transcript (Whisper
|
||||
# hallucination) has no real content behind it and stays excluded.
|
||||
if not scenes:
|
||||
_call_doc = await fstore.doc_get("calls", call_id)
|
||||
skip_reason = (_call_doc or {}).get("skip_reason")
|
||||
if not skip_reason or skip_reason == "transcript_too_short":
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=[],
|
||||
incident_type=None,
|
||||
location=None,
|
||||
location_coords=None,
|
||||
)
|
||||
if incident_id:
|
||||
incident_ids.append(incident_id)
|
||||
|
||||
if incident_ids:
|
||||
await fstore.doc_set("calls", call_id, {"incident_ids": incident_ids})
|
||||
|
||||
return incident_ids, all_tags, scenes
|
||||
|
||||
|
||||
async def _run_intelligence_pipeline(
|
||||
call_id: str,
|
||||
node_id: str,
|
||||
@@ -411,7 +519,7 @@ async def _run_intelligence_pipeline(
|
||||
3. Correlate each scene with existing incidents (or create new ones)
|
||||
4. Check alert rules and dispatch notifications
|
||||
"""
|
||||
from app.internal import transcription, intelligence, incident_correlator, alerter, talkgroups
|
||||
from app.internal import transcription, alerter, talkgroups
|
||||
|
||||
# server-26#131: mark that real-time processing has started for this call
|
||||
# BEFORE any of the slow steps below (STT, scene extraction, correlation).
|
||||
@@ -427,7 +535,7 @@ async def _run_intelligence_pipeline(
|
||||
# calls). Best-effort: a write failure here must not abort the pipeline.
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, {
|
||||
"intelligence_started_at": datetime.now(timezone.utc).isoformat()
|
||||
"intelligence_started_at": clock.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not mark intelligence_started_at for call {call_id}: {e}")
|
||||
@@ -466,90 +574,22 @@ async def _run_intelligence_pipeline(
|
||||
scope = "globally" if not flags["stt_enabled"] else f"system {system_id}"
|
||||
logger.info(f"STT disabled ({scope}) — skipping transcription for call {call_id}")
|
||||
|
||||
# Step 2: Scene detection + intelligence extraction
|
||||
scenes: list[dict] = []
|
||||
if _flag("correlation_enabled"):
|
||||
if transcript:
|
||||
scenes = await intelligence.extract_scenes(
|
||||
call_id, transcript, talkgroup_name,
|
||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||
node_id=node_id,
|
||||
)
|
||||
else:
|
||||
scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}"
|
||||
logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id}")
|
||||
|
||||
# Step 3: Correlate each scene independently.
|
||||
# A single recording can produce multiple incidents on a busy channel.
|
||||
# Steps 2-3: scene extraction + correlation.
|
||||
incident_ids: list[str] = []
|
||||
all_tags: list[str] = []
|
||||
if _flag("correlation_enabled"):
|
||||
# server-26#96: scene_index is threaded through so each scene's
|
||||
# corr_debug/transcript lands in its own entry of the call doc's
|
||||
# `scenes` map instead of clobbering every other scene's write.
|
||||
for scene_index, scene in enumerate(scenes):
|
||||
all_tags.extend(scene["tags"])
|
||||
is_reassignment = bool(scene.get("reassignment"))
|
||||
corr_units = [] if is_reassignment else scene.get("units")
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=scene["tags"],
|
||||
incident_type=scene["incident_type"],
|
||||
location=scene["location"],
|
||||
location_coords=scene["location_coords"],
|
||||
units=corr_units,
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
reassignment=is_reassignment,
|
||||
embedding=scene.get("embedding"),
|
||||
severity=scene.get("severity"),
|
||||
transcript=scene.get("transcript"),
|
||||
scene_index=scene_index,
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
if scene["resolved"] and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {
|
||||
"status": "resolved",
|
||||
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
|
||||
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
||||
# talkgroup-based linking even when no transcript could be produced.
|
||||
# transcript_too_short (<=5 words: "10-8", "show me clear", a unit
|
||||
# check-in) still carries a real transcript and talkgroup — exactly the
|
||||
# brief follow-up/clearance traffic an incident needs, and the thin-path
|
||||
# merge below already requires a same-talkgroup, recently-active
|
||||
# incident before attaching anything, same guard already trusted for
|
||||
# no-transcript calls. Previously excluded here, so these calls never
|
||||
# attached to anything at all. garbage_transcript (Whisper
|
||||
# hallucination) has no real content behind it and stays excluded.
|
||||
if not scenes:
|
||||
_call_doc = await fstore.doc_get("calls", call_id)
|
||||
skip_reason = (_call_doc or {}).get("skip_reason")
|
||||
if not skip_reason or skip_reason == "transcript_too_short":
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=[],
|
||||
incident_type=None,
|
||||
location=None,
|
||||
location_coords=None,
|
||||
)
|
||||
if incident_id:
|
||||
incident_ids.append(incident_id)
|
||||
|
||||
if incident_ids:
|
||||
await fstore.doc_set("calls", call_id, {"incident_ids": incident_ids})
|
||||
incident_ids, all_tags, _ = await _extract_and_correlate(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
transcript=transcript,
|
||||
segments=segments,
|
||||
)
|
||||
else:
|
||||
scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}"
|
||||
logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id}")
|
||||
|
||||
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
|
||||
await alerter.check_and_dispatch(
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Replay (app/internal/replay.py): re-running the pipeline over past calls in a
|
||||
sandbox. The properties that matter, in order: a replay never writes a live
|
||||
call or incident; it runs the live correlation code with the clock pinned to
|
||||
each call's own time; and a call seeded ahead of its turn is invisible to the
|
||||
orphan sweep until it is processed.
|
||||
"""
|
||||
import asyncio
|
||||
import copy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.internal import clock, replay
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.feature_flags import force_flags, resolve_flags, unforce_flags
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# An in-memory Firestore that honours the sandbox redirect, so the real
|
||||
# correlator can run against it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _merge(dst: dict, src: dict) -> dict:
|
||||
for k, v in src.items():
|
||||
if isinstance(v, dict) and isinstance(dst.get(k), dict):
|
||||
_merge(dst[k], v)
|
||||
else:
|
||||
dst[k] = copy.deepcopy(v)
|
||||
return dst
|
||||
|
||||
|
||||
def _cmp(a, op, b) -> bool:
|
||||
if a is None:
|
||||
return False
|
||||
if isinstance(a, str) and isinstance(b, datetime):
|
||||
a = datetime.fromisoformat(a)
|
||||
return {"==": a == b, ">=": a >= b, "<=": a <= b, ">": a > b, "<": a < b}[op]
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self):
|
||||
self.data: dict[str, dict[str, dict]] = {}
|
||||
|
||||
def coll(self, name: str) -> dict:
|
||||
return self.data.setdefault(fstore._path(name), {})
|
||||
|
||||
async def doc_set(self, collection, doc_id, data, merge=True):
|
||||
c = self.coll(collection)
|
||||
if merge and doc_id in c:
|
||||
_merge(c[doc_id], data)
|
||||
else:
|
||||
c[doc_id] = copy.deepcopy(data)
|
||||
|
||||
async def doc_update(self, collection, doc_id, data):
|
||||
await self.doc_set(collection, doc_id, data)
|
||||
|
||||
async def doc_get(self, collection, doc_id):
|
||||
d = self.coll(collection).get(doc_id)
|
||||
return copy.deepcopy(d) if d is not None else None
|
||||
|
||||
async def doc_get_cached(self, collection, doc_id, ttl=300.0):
|
||||
return await self.doc_get(collection, doc_id)
|
||||
|
||||
async def doc_delete(self, collection, doc_id):
|
||||
self.coll(collection).pop(doc_id, None)
|
||||
|
||||
async def collection_list(self, collection, **filters):
|
||||
return [copy.deepcopy(d) for d in self.coll(collection).values()
|
||||
if all(d.get(k) == v for k, v in filters.items())]
|
||||
|
||||
async def collection_where(self, collection, conditions, order_by=None,
|
||||
limit_to=None, start_after=None):
|
||||
rows = [copy.deepcopy(d) for d in self.coll(collection).values()
|
||||
if all(_cmp(d.get(f), op, v) for f, op, v in conditions)]
|
||||
for field, direction in reversed(order_by or []):
|
||||
rows.sort(key=lambda d: d.get(field), reverse=direction == "DESCENDING")
|
||||
return rows[:limit_to] if limit_to else rows
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
s = FakeStore()
|
||||
names = ("doc_set", "doc_update", "doc_get", "doc_get_cached", "doc_delete",
|
||||
"collection_list", "collection_where")
|
||||
patches = [patch.object(fstore, n, getattr(s, n)) for n in names]
|
||||
for p in patches:
|
||||
p.start()
|
||||
replay._active_run_id = None
|
||||
replay._active_task = None
|
||||
yield s
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The context-scoped pieces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sandbox_redirects_only_calls_and_incidents():
|
||||
assert fstore._path("calls") == "calls"
|
||||
tok = fstore.enter_sandbox("replay_runs/r1")
|
||||
try:
|
||||
assert fstore._path("calls") == "replay_runs/r1/calls"
|
||||
assert fstore._path("incidents") == "replay_runs/r1/incidents"
|
||||
assert fstore._path("systems") == "systems"
|
||||
assert fstore._path("config") == "config"
|
||||
finally:
|
||||
fstore.exit_sandbox(tok)
|
||||
assert fstore._path("incidents") == "incidents"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_and_clock_do_not_leak_into_a_concurrent_task():
|
||||
"""A replay runs beside live uploads in one event loop. The live task
|
||||
must see the real collections and the real clock."""
|
||||
pinned = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||
seen = {}
|
||||
replay_entered = asyncio.Event()
|
||||
live_checked = asyncio.Event()
|
||||
|
||||
async def replay_task():
|
||||
fstore.enter_sandbox("replay_runs/r1")
|
||||
clock.pin(pinned)
|
||||
replay_entered.set()
|
||||
await live_checked.wait()
|
||||
seen["replay"] = (fstore._path("calls"), clock.now())
|
||||
|
||||
async def live_task():
|
||||
await replay_entered.wait()
|
||||
seen["live"] = (fstore._path("calls"), clock.now())
|
||||
live_checked.set()
|
||||
|
||||
await asyncio.gather(replay_task(), live_task())
|
||||
assert seen["replay"] == ("replay_runs/r1/calls", pinned)
|
||||
assert seen["live"][0] == "calls"
|
||||
assert seen["live"][1] != pinned
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_flags_override_global_switches():
|
||||
tok = force_flags({"correlation_enabled": True, "stt_enabled": False})
|
||||
try:
|
||||
flags, flag = await resolve_flags("sys-1")
|
||||
assert flag("correlation_enabled") is True
|
||||
assert flag("stt_enabled") is False
|
||||
assert flag("summaries_enabled") is False
|
||||
finally:
|
||||
unforce_flags(tok)
|
||||
|
||||
|
||||
def test_sandbox_seed_strips_live_answers():
|
||||
call = {
|
||||
"call_id": "c1", "org_id": "o", "talkgroup_id": 5, "srcaddr": 123,
|
||||
"status": "ended", "transcript": "engine 5 responding", "segments": [{"t": 1}],
|
||||
"incident_ids": ["live-inc"], "incident_id": "live-inc", "units": ["E5"],
|
||||
"corr_path": "fast/thin", "scenes": {"0": {}}, "skip_reason": None,
|
||||
"chatter_classifier_verdict": "x", "eval_transcript": "y", "embedding": [0.1],
|
||||
}
|
||||
seed = replay._sandbox_seed(call, "transcripts")
|
||||
assert seed["transcript"] == "engine 5 responding"
|
||||
assert seed["srcaddr"] == 123
|
||||
assert seed["status"] == "replay_pending"
|
||||
for gone in ("incident_ids", "incident_id", "units", "corr_path", "scenes",
|
||||
"chatter_classifier_verdict", "eval_transcript", "embedding"):
|
||||
assert gone not in seed
|
||||
assert "transcript" not in replay._sandbox_seed(call, "audio")
|
||||
|
||||
|
||||
def test_compute_metrics_separates_timeout_from_real_clears():
|
||||
incidents = [
|
||||
{"call_ids": ["a"], "status": "resolved", "resolved_via": "idle_timeout"},
|
||||
{"call_ids": ["b", "c"], "status": "resolved", "resolved_via": "units_cleared",
|
||||
"units_cleared": ["E5"]},
|
||||
{"call_ids": ["d", "e", "f"], "status": "active"},
|
||||
]
|
||||
calls = [
|
||||
{"call_id": "a", "incident_ids": ["1"], "scenes": {"0": {"corr_debug": {
|
||||
"corr_path": "new", "corr_consensus": "rules_only"}}}},
|
||||
{"call_id": "z", "corr_path": "unlinked"},
|
||||
]
|
||||
m = replay.compute_metrics(incidents, calls)
|
||||
assert m["incidents"] == 3
|
||||
assert m["single_call_incidents"] == 1
|
||||
assert m["resolved_via"] == {"idle_timeout": 1, "units_cleared": 1, "still_active": 1}
|
||||
assert m["incidents_with_units_cleared"] == 1
|
||||
assert m["calls_orphaned"] == 1
|
||||
assert m["corr_path"] == {"new": 1, "unlinked": 1}
|
||||
assert m["llm_decisions"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A whole run, through the real correlator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
T0 = datetime(2026, 9, 21, 14, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _live_call(i: int, minute: int, transcript: str) -> dict:
|
||||
return {
|
||||
"call_id": f"call-{i}", "org_id": "org-1", "node_id": "node-1",
|
||||
"system_id": "sys-1", "talkgroup_id": 100, "talkgroup_name": "Police Dispatch",
|
||||
"started_at": T0 + timedelta(minutes=minute),
|
||||
"ended_at": T0 + timedelta(minutes=minute, seconds=20),
|
||||
"duration_s": 20, "status": "ended",
|
||||
"transcript": transcript,
|
||||
"incident_ids": ["LIVE-INCIDENT"], "corr_path": "fast/thin",
|
||||
}
|
||||
|
||||
|
||||
def _scene(transcript: str, units: list[str]) -> dict:
|
||||
return {
|
||||
"tags": ["mva"], "incident_type": "accident", "location": "Main Street",
|
||||
"location_coords": None, "units": units, "vehicles": [], "cleared_units": [],
|
||||
"reassignment": False, "embedding": None, "severity": "moderate",
|
||||
"transcript": transcript, "resolved": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_writes_only_to_its_sandbox_and_pins_the_clock(store):
|
||||
live = {
|
||||
"call-1": _live_call(1, 0, "Car 12, MVA Main Street"),
|
||||
"call-2": _live_call(2, 1, "Car 12 on scene Main Street"),
|
||||
"call-3": _live_call(3, 300, "Car 40, alarm Oak Avenue"),
|
||||
}
|
||||
store.data["calls"] = copy.deepcopy(live)
|
||||
store.data["incidents"] = {"LIVE-INCIDENT": {"incident_id": "LIVE-INCIDENT", "org_id": "org-1",
|
||||
"status": "active", "call_ids": ["call-1"]}}
|
||||
live_before = copy.deepcopy(store.data)
|
||||
|
||||
extracted = []
|
||||
|
||||
async def fake_extract(call_id, transcript, talkgroup_name, **kw):
|
||||
extracted.append(call_id)
|
||||
# Prefetch seeds calls ahead of the clock; they must not look "ended" yet.
|
||||
return [_scene(transcript, ["Car 12"] if "12" in transcript else ["Car 40"])]
|
||||
|
||||
with patch("app.internal.intelligence.extract_scenes", fake_extract):
|
||||
calls, truncated = await replay.select_calls(
|
||||
"org-1", T0 - timedelta(hours=1), T0 + timedelta(hours=6))
|
||||
assert [c["call_id"] for c in calls] == ["call-1", "call-2", "call-3"]
|
||||
assert not truncated
|
||||
await replay.start_run(
|
||||
org_id="org-1", date_from=T0 - timedelta(hours=1),
|
||||
date_to=T0 + timedelta(hours=6), mode="transcripts", system_ids=None,
|
||||
source_run_id=None, label="t", actor="test",
|
||||
)
|
||||
await replay._active_task
|
||||
|
||||
# Live collections are exactly as they were.
|
||||
assert store.data["calls"] == live_before["calls"]
|
||||
assert store.data["incidents"] == live_before["incidents"]
|
||||
|
||||
run = next(iter(store.data["replay_runs"].values()))
|
||||
assert run["status"] == "done", run["errors"]
|
||||
root = f"replay_runs/{run['run_id']}"
|
||||
sb_calls = store.data[f"{root}/calls"]
|
||||
sb_incidents = store.data[f"{root}/incidents"]
|
||||
assert sorted(extracted) == ["call-1", "call-2", "call-3"]
|
||||
assert all(c["status"] == "ended" for c in sb_calls.values())
|
||||
assert "LIVE-INCIDENT" not in sb_incidents
|
||||
|
||||
# Incident timestamps come from the replayed calls, not the wall clock.
|
||||
for inc in sb_incidents.values():
|
||||
started = datetime.fromisoformat(inc["started_at"])
|
||||
assert T0 <= started <= T0 + timedelta(hours=6)
|
||||
# The two Car 12 calls are one job; the Car 40 call five hours later is another.
|
||||
groups = sorted(sorted(i["call_ids"]) for i in sb_incidents.values())
|
||||
assert groups == [["call-1", "call-2"], ["call-3"]]
|
||||
# Each aged out on the replayed clock the way it would have live —
|
||||
# incident_auto_resolve_minutes after its last activity, not "now".
|
||||
assert run["metrics"]["resolved_via"] == {"idle_timeout": 2}
|
||||
first = next(i for i in sb_incidents.values() if "call-1" in i["call_ids"])
|
||||
idle = datetime.fromisoformat(first["resolved_at"]) - datetime.fromisoformat(first["updated_at"])
|
||||
assert timedelta(minutes=90) < idle <= timedelta(minutes=95)
|
||||
assert run["metrics"]["calls"] == 3
|
||||
assert set(store.data[f"{root}/scenes"]) == {"call-1", "call-2", "call-3"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuse_mode_correlates_without_extracting(store):
|
||||
store.data["calls"] = {"call-1": _live_call(1, 0, "Car 12, MVA Main Street")}
|
||||
|
||||
async def fake_extract(call_id, transcript, talkgroup_name, **kw):
|
||||
# What the real extract_scenes also does: write call-level fields.
|
||||
await fstore.doc_set("calls", call_id, {"units": ["Car 12"], "tags": ["mva"]})
|
||||
return [_scene(transcript, ["Car 12"])]
|
||||
|
||||
with patch("app.internal.intelligence.extract_scenes", fake_extract):
|
||||
first = await replay.start_run(
|
||||
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||
mode="transcripts", system_ids=None, source_run_id=None, label="", actor="t")
|
||||
await replay._active_task
|
||||
|
||||
async def must_not_extract(*a, **kw):
|
||||
raise AssertionError("reuse mode re-ran extraction")
|
||||
|
||||
with patch("app.internal.intelligence.extract_scenes", must_not_extract):
|
||||
second = await replay.start_run(
|
||||
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||
mode="reuse", system_ids=None, source_run_id=first["run_id"], label="", actor="t")
|
||||
await replay._active_task
|
||||
|
||||
run = store.data["replay_runs"][second["run_id"]]
|
||||
assert run["status"] == "done", run["errors"]
|
||||
assert run["progress"]["errors"] == 0
|
||||
assert run["metrics"]["calls_linked"] == 1
|
||||
# Extraction's call-level output came across too — the orphan sweep reads
|
||||
# units/tags/location off the call doc, not off the scenes.
|
||||
sb_call = store.data[f"replay_runs/{second['run_id']}/calls"]["call-1"]
|
||||
assert sb_call["units"] == ["Car 12"]
|
||||
assert sb_call["tags"] == ["mva"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_run_at_a_time(store):
|
||||
store.data["calls"] = {"call-1": _live_call(1, 0, "x")}
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def slow_extract(*a, **kw):
|
||||
await gate.wait()
|
||||
return []
|
||||
|
||||
with patch("app.internal.intelligence.extract_scenes", slow_extract):
|
||||
await replay.start_run(
|
||||
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||
mode="transcripts", system_ids=None, source_run_id=None, label="", actor="t")
|
||||
with pytest.raises(replay.ReplayBusy):
|
||||
await replay.start_run(
|
||||
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||
mode="transcripts", system_ids=None, source_run_id=None, label="", actor="t")
|
||||
gate.set()
|
||||
await replay._active_task
|
||||
|
||||
|
||||
def test_stored_input_rebuilds_from_corrector_segments():
|
||||
"""Live extraction overwrites transcript_corrected with scene 0's text;
|
||||
the corrector's own output survives in segments_corrected."""
|
||||
call = {
|
||||
"transcript": "raw whisper",
|
||||
"transcript_corrected": "scene zero only",
|
||||
"segments": [{"text": "raw a"}, {"text": "raw b"}],
|
||||
"segments_corrected": [{"text": "fixed a"}, {"text": "fixed b"}],
|
||||
}
|
||||
text, segs = replay._stored_input(call)
|
||||
assert text == "fixed a fixed b"
|
||||
assert segs == call["segments_corrected"]
|
||||
assert replay._stored_input({"transcript": "raw", "segments": []}) == ("raw", [])
|
||||
assert replay._stored_input({"transcript": "hum", "transcript_not_speech": True}) == (None, [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_never_touches_live_ai_health_or_review_queue():
|
||||
from app.internal import ai_health, area_context
|
||||
|
||||
before = ai_health.snapshot()
|
||||
tok = fstore.enter_sandbox("replay_runs/r1")
|
||||
try:
|
||||
with patch.object(ai_health, "_post_webhook") as hook, \
|
||||
patch.object(fstore, "doc_get") as get:
|
||||
for _ in range(10):
|
||||
await ai_health.report_degraded("correlation_cheap", "gemini", "m", "429", "wait")
|
||||
await ai_health.report_healthy("transcription")
|
||||
assert await area_context.add_pending("sys-1", 5, [{"term": "x"}]) == 0
|
||||
hook.assert_not_called()
|
||||
get.assert_not_called()
|
||||
finally:
|
||||
fstore.exit_sandbox(tok)
|
||||
assert ai_health.snapshot() == before
|
||||
Reference in New Issue
Block a user