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",
})
# 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).
# ~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
@@ -877,6 +903,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
location, location_coords, call_units, call_vehicles, call_embedding, now,
talkgroup_name=talkgroup_name, incident_type=incident_type,
cleared_units=call_cleared, refresh_activity=not thin_link,
call_severity=call_severity,
)
return matched_incident["incident_id"]
@@ -1251,6 +1278,7 @@ async def _update_incident(
incident_type: Optional[str] = None,
cleared_units: Optional[list[str]] = None,
refresh_activity: bool = True,
call_severity: Optional[str] = None,
) -> None:
incident_id = inc["incident_id"]
@@ -1303,6 +1331,7 @@ async def _update_incident(
"units_cleared": units_cleared,
"location_mentions": location_mentions,
"summary_stale": True,
"severity": _max_severity(inc.get("severity"), call_severity),
**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).
if units_cleared and not units_active:
updates["status"] = "resolved"
updates["resolved_at"] = now.isoformat()
await fstore.doc_set("incidents", incident_id, updates)
logger.info(
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
# 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(
f"Auto-resolved master incident {parent_id} "
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)
idle_minutes = (now - updated_dt).total_seconds() / 60
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
await maybe_resolve_parent(incident_id)
logger.info(
+1
View File
@@ -173,6 +173,7 @@ async def patch_transcript(
await fstore.doc_set("incidents", old_incident_id, {
"call_ids": [],
"status": "resolved",
"resolved_at": datetime.now(timezone.utc).isoformat(),
"summary_stale": True,
})
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 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
@@ -202,7 +203,10 @@ async def _run_extraction_pipeline(
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"})
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)")
@@ -305,7 +309,10 @@ async def _run_intelligence_pipeline(
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"})
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)")
+115
View File
@@ -18,6 +18,7 @@ from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from app.internal.incident_correlator import (
_run_decision, _update_incident, _normalize_unit, _matching_units,
_max_severity, maybe_resolve_parent,
)
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",
))
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"]