Review of the first clearance fix found it pushes toward early resolve:
- parser cleared units on questions ("45-9, are you clear?"), negations
("not clear yet"), orders ("clear the scene", "clear to transport"),
places/times ("Room 2 clear", "1400 hours, clear") and split "45 9" into
unit 45. Now rejects "?", not/is/are/you, anything after the status word
but sign-offs, place/time words; joins "45 9" -> "45-9".
- a clear from a unit never active on an incident was recorded in
units_cleared and could pass the all-clear gate. Only units actually
active there can clear there now.
- clearance-only calls skip the LLM tier (same as thin calls): only the
rules engine's unit match can say which incident a 10-8 belongs to.
- replay incident view carries srcaddr/srcaddrs for the radio-ID clearance
investigation.
Replay 09-22 10:00-12:00 ET with f0a88d4: real clears 0 -> 2 (both LLM
closure), unit clears still 0 — the parsed clears are right but those
units were never recorded as assigned (Whisper mangles unit IDs at
dispatch), which this commit does not fix.
c2-core: 465 pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
7.0 KiB
Python
184 lines
7.0 KiB
Python
"""
|
|
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"),
|
|
"srcaddr": c.get("srcaddr"),
|
|
"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"),
|
|
"srcaddrs": inc.get("srcaddrs"),
|
|
"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}
|