Re-evaluate incident severity on link, stamp resolved_at at every resolution site

#17: severity was written once at _create_incident and never touched again,
so an incident that opened routine and escalated to a working fire stayed
routine forever. _update_incident now merges call_severity into the incident
via _max_severity() on every link.

Severity is monotonic: it only ever rises, never falls. An incident briefly
assessed "major" genuinely was major at that moment; a later, calmer-sounding
call is evidence the situation is winding down, not that the earlier read was
wrong. status/resolved_at exist to retire an incident — severity should stay
as the high-water mark so the worst-first rail, "Major only" filter, and map
colouring never bury a call that was genuinely major. See _max_severity's
docstring in incident_correlator.py for the full argument.

#18: none of the resolution sites wrote resolved_at, so an incident's
lifespan couldn't be reconstructed for the history-scrub feature. Added
resolved_at alongside status="resolved" at all six sites that flip it:
  - incident_correlator.py _update_incident (signal-based: units all cleared)
  - incident_correlator.py maybe_resolve_parent (master auto-resolve)
  - summarizer.py _stale_sweep (90-minute auto-resolve)
  - upload.py, both scene-resolution loops (single- and multi-scene)
  - calls.py reprocess/correction path
(_update_incident's signal-resolve and maybe_resolve_parent's master-resolve
weren't named in the issue's four call sites, but they set status the same
way and were missing resolved_at too.)

No backfill: existing resolved incidents keep resolved_at = null, which
means "resolved before this field existed," not "never resolved." Backfilling
from updated_at would be a guess dressed up as data.

Tests: added to tests/test_correlator_gate.py, which needs no Firestore for
the pure _max_severity cases and patches fstore for the _update_incident/
maybe_resolve_parent writes. Covers the escalation case (routine -> major),
the no-downgrade case, and resolved_at on both the signal-resolve and
master-resolve paths. 52/52 passing in that file; 83 passed / 10
pre-existing failures for drb-c2-core overall (baseline was 69/10 — the
+14 is exactly the new tests, no regressions).

Fixes #17, #18.
This commit is contained in:
Logan Cusano
2026-08-19 22:57:20 -04:00
parent 3a786bc227
commit 70d63abeaa
5 changed files with 163 additions and 4 deletions
@@ -51,6 +51,32 @@ _PURSUIT_TAGS = frozenset({
"fleeing-vehicle", "suspect-vehicle", "eluding", "fleeing-vehicle", "suspect-vehicle", "eluding",
}) })
# Four-level severity ladder, low → high (see intelligence.py EXTRACTION_PROMPT).
_SEVERITY_RANK = {"routine": 0, "minor": 1, "moderate": 2, "major": 3}
def _max_severity(current: Optional[str], new: Optional[str]) -> str:
"""
Highest of two severities on the four-level ladder — the merge rule used
every time a call attaches to an existing incident.
Severity is monotonic by design: it only ever rises, never falls, as more
calls link. An incident briefly assessed "major" genuinely was major at
that moment; a later call that sounds calmer ("units clear", dispatcher
moving on) is evidence the SITUATION is winding down, not that the earlier
read was wrong. That's what `status`/`resolved_at` are for — resolution
retires an incident, it doesn't retroactively erase how serious it was.
Every severity-driven surface (worst-first incident rail, "Major only"
filter, map colour) exists to make sure a major event is never missed;
downgrading severity mid-incident would silently defeat that on the exact
incidents it exists to protect. A malformed/unrecognized value from either
side ranks as "routine" so it can never suppress a real escalation.
"""
current = current if current in _SEVERITY_RANK else "routine"
new = new if new in _SEVERITY_RANK else "routine"
return current if _SEVERITY_RANK[current] >= _SEVERITY_RANK[new] else new
# Maximum plausible ground speed for a moving incident (pursuit/transport). # Maximum plausible ground speed for a moving incident (pursuit/transport).
# ~3 miles/min ≈ 180 mph — well above real pursuit speeds, but generous enough # ~3 miles/min ≈ 180 mph — well above real pursuit speeds, but generous enough
# to tolerate GPS drift and call-timing jitter. Anything faster is a bad geocode # to tolerate GPS drift and call-timing jitter. Anything faster is a bad geocode
@@ -877,6 +903,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
location, location_coords, call_units, call_vehicles, call_embedding, now, location, location_coords, call_units, call_vehicles, call_embedding, now,
talkgroup_name=talkgroup_name, incident_type=incident_type, talkgroup_name=talkgroup_name, incident_type=incident_type,
cleared_units=call_cleared, refresh_activity=not thin_link, cleared_units=call_cleared, refresh_activity=not thin_link,
call_severity=call_severity,
) )
return matched_incident["incident_id"] return matched_incident["incident_id"]
@@ -1251,6 +1278,7 @@ async def _update_incident(
incident_type: Optional[str] = None, incident_type: Optional[str] = None,
cleared_units: Optional[list[str]] = None, cleared_units: Optional[list[str]] = None,
refresh_activity: bool = True, refresh_activity: bool = True,
call_severity: Optional[str] = None,
) -> None: ) -> None:
incident_id = inc["incident_id"] incident_id = inc["incident_id"]
@@ -1303,6 +1331,7 @@ async def _update_incident(
"units_cleared": units_cleared, "units_cleared": units_cleared,
"location_mentions": location_mentions, "location_mentions": location_mentions,
"summary_stale": True, "summary_stale": True,
"severity": _max_severity(inc.get("severity"), call_severity),
**embedding_updates, **embedding_updates,
} }
@@ -1347,6 +1376,7 @@ async def _update_incident(
# don't fire on incidents where units were never tracked (no unit mentions at all). # don't fire on incidents where units were never tracked (no unit mentions at all).
if units_cleared and not units_active: if units_cleared and not units_active:
updates["status"] = "resolved" updates["status"] = "resolved"
updates["resolved_at"] = now.isoformat()
await fstore.doc_set("incidents", incident_id, updates) await fstore.doc_set("incidents", incident_id, updates)
logger.info( logger.info(
f"Correlator: signal-resolved incident {incident_id} " f"Correlator: signal-resolved incident {incident_id} "
@@ -1538,7 +1568,10 @@ async def maybe_resolve_parent(incident_id: str) -> None:
return # at least one sibling still active return # at least one sibling still active
# All children resolved — close the master # All children resolved — close the master
await fstore.doc_set("incidents", parent_id, {"status": "resolved"}) await fstore.doc_set("incidents", parent_id, {
"status": "resolved",
"resolved_at": datetime.now(timezone.utc).isoformat(),
})
logger.info( logger.info(
f"Auto-resolved master incident {parent_id} " f"Auto-resolved master incident {parent_id} "
f"(all {len(child_ids)} child(ren) resolved)" f"(all {len(child_ids)} child(ren) resolved)"
+4 -1
View File
@@ -101,7 +101,10 @@ async def _resolve_stale_incidents() -> None:
updated_dt = updated_dt.replace(tzinfo=timezone.utc) updated_dt = updated_dt.replace(tzinfo=timezone.utc)
idle_minutes = (now - updated_dt).total_seconds() / 60 idle_minutes = (now - updated_dt).total_seconds() / 60
if idle_minutes > settings.incident_auto_resolve_minutes: if idle_minutes > settings.incident_auto_resolve_minutes:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"}) await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": now.isoformat(),
})
from app.internal.incident_correlator import maybe_resolve_parent from app.internal.incident_correlator import maybe_resolve_parent
await maybe_resolve_parent(incident_id) await maybe_resolve_parent(incident_id)
logger.info( logger.info(
+1
View File
@@ -173,6 +173,7 @@ async def patch_transcript(
await fstore.doc_set("incidents", old_incident_id, { await fstore.doc_set("incidents", old_incident_id, {
"call_ids": [], "call_ids": [],
"status": "resolved", "status": "resolved",
"resolved_at": datetime.now(timezone.utc).isoformat(),
"summary_stale": True, "summary_stale": True,
}) })
await fstore.doc_set("calls", call_id, {"incident_ids": [], "incident_id": None}) await fstore.doc_set("calls", call_id, {"incident_ids": [], "incident_id": None})
+9 -2
View File
@@ -1,4 +1,5 @@
from typing import Optional from typing import Optional
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio from app.internal.storage import upload_audio
@@ -202,7 +203,10 @@ async def _run_extraction_pipeline(
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
if scene["resolved"] and incident_id: if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"}) await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": datetime.now(timezone.utc).isoformat(),
})
await incident_correlator.maybe_resolve_parent(incident_id) await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)") logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
@@ -305,7 +309,10 @@ async def _run_intelligence_pipeline(
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
if scene["resolved"] and incident_id: if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"}) await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": datetime.now(timezone.utc).isoformat(),
})
await incident_correlator.maybe_resolve_parent(incident_id) await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)") logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
+115
View File
@@ -18,6 +18,7 @@ from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from app.internal.incident_correlator import ( from app.internal.incident_correlator import (
_run_decision, _update_incident, _normalize_unit, _matching_units, _run_decision, _update_incident, _normalize_unit, _matching_units,
_max_severity, maybe_resolve_parent,
) )
NOW = datetime(2026, 8, 16, 21, 0, 0, tzinfo=timezone.utc) NOW = datetime(2026, 8, 16, 21, 0, 0, tzinfo=timezone.utc)
@@ -247,3 +248,117 @@ def test_normalised_units_link_a_call_that_exact_match_would_orphan():
call_units=["K-9A2"], is_thin_call=False, call_severity="routine", call_units=["K-9A2"], is_thin_call=False, call_severity="routine",
)) ))
assert decision["action"] == "link" assert decision["action"] == "link"
# ---------------------------------------------------------------------------
# Issue #17 — severity re-evaluation as calls attach (monotonic ladder)
#
# Decision: severity only ever rises, never falls, as more calls link (see
# _max_severity's docstring in incident_correlator.py for the full argument).
# An incident briefly assessed "major" genuinely was major at that moment;
# resolution (status/resolved_at), not a later calmer-sounding call, is what
# retires it. These tests lock in both halves of that: escalation raises the
# stored severity, and a later lower-severity call does not undo it.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("current,new,expected", [
("routine", "major", "major"), # escalation — the motivating case
("routine", "minor", "minor"),
("minor", "moderate", "moderate"),
("major", "routine", "major"), # calmer call does NOT downgrade
("major", "minor", "major"),
("moderate", "moderate", "moderate"), # tie
(None, "moderate", "moderate"), # incident with no prior severity
("major", None, "major"),
("major", "bogus", "major"), # malformed value ranks as routine
("bogus", "minor", "minor"),
])
def test_max_severity_is_monotonic(current, new, expected):
assert _max_severity(current, new) == expected
@pytest.mark.asyncio
async def test_escalating_call_raises_stored_incident_severity():
"""The #17 motivating case: an incident opened routine, a later call is a
working structure fire — the incident's severity must reflect it."""
inc = _incident(2.0)
inc["severity"] = "routine"
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_set = AsyncMock()
await _update_incident(
inc, "call-2", 9048, "sys-1", [], None, None, [], [], None, NOW,
call_severity="major",
)
updates = mock_fstore.doc_set.await_args.args[2]
assert updates["severity"] == "major"
@pytest.mark.asyncio
async def test_calmer_followup_call_does_not_downgrade_severity():
inc = _incident(2.0)
inc["severity"] = "major"
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_set = AsyncMock()
await _update_incident(
inc, "call-2", 9048, "sys-1", [], None, None, [], [], None, NOW,
call_severity="routine",
)
updates = mock_fstore.doc_set.await_args.args[2]
assert updates["severity"] == "major"
# ---------------------------------------------------------------------------
# Issue #18 — every resolution site stamps resolved_at
#
# updated_at is not a substitute (thin/ack calls deliberately don't move it,
# unrelated field updates do) and existing rows are left null, not backfilled
# — null means "resolved before this field existed", not "never resolved".
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_resolve_stamps_resolved_at():
"""All tracked units clear -> _update_incident's own auto-resolve path."""
inc = _incident(2.0)
inc["units_active"] = ["6-Adam"]
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_set = AsyncMock()
# standalone incident — maybe_resolve_parent's own doc_get short-circuits on None
mock_fstore.doc_get = AsyncMock(return_value=None)
await _update_incident(
inc, "call-2", 9048, "sys-1", [], None, None, [], [], None, NOW,
cleared_units=["6-Adam"],
)
updates = mock_fstore.doc_set.await_args.args[2]
assert updates["status"] == "resolved"
assert updates["resolved_at"] == NOW.isoformat()
@pytest.mark.asyncio
async def test_master_auto_resolve_stamps_resolved_at():
"""maybe_resolve_parent closes a master once every child has resolved."""
child_a = {"incident_id": "child-a", "parent_incident_id": "master-1"}
master = {
"incident_id": "master-1",
"status": "active",
"child_incident_ids": ["child-a", "child-b"],
}
child_b_resolved = {"incident_id": "child-b", "status": "resolved"}
async def fake_doc_get(collection, doc_id):
return {
"child-a": child_a,
"master-1": master,
"child-b": child_b_resolved,
}.get(doc_id)
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(side_effect=fake_doc_get)
mock_fstore.doc_set = AsyncMock()
await maybe_resolve_parent("child-a")
mock_fstore.doc_set.assert_awaited_once()
args = mock_fstore.doc_set.await_args.args
assert args[0] == "incidents"
assert args[1] == "master-1"
assert args[2]["status"] == "resolved"
assert "resolved_at" in args[2] and args[2]["resolved_at"]