From 70d63abeaaa24a56aa6fd3cbc6320e50e9f77b98 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Wed, 19 Aug 2026 22:57:20 -0400 Subject: [PATCH] Re-evaluate incident severity on link, stamp resolved_at at every resolution site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .../app/internal/incident_correlator.py | 35 +++++- drb-c2-core/app/internal/summarizer.py | 5 +- drb-c2-core/app/routers/calls.py | 1 + drb-c2-core/app/routers/upload.py | 11 +- drb-c2-core/tests/test_correlator_gate.py | 115 ++++++++++++++++++ 5 files changed, 163 insertions(+), 4 deletions(-) diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index cc8baad..c4ceb8f 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -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)" diff --git a/drb-c2-core/app/internal/summarizer.py b/drb-c2-core/app/internal/summarizer.py index 58d6f7b..a77f6ea 100644 --- a/drb-c2-core/app/internal/summarizer.py +++ b/drb-c2-core/app/internal/summarizer.py @@ -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( diff --git a/drb-c2-core/app/routers/calls.py b/drb-c2-core/app/routers/calls.py index 2914944..3f6ae2a 100644 --- a/drb-c2-core/app/routers/calls.py +++ b/drb-c2-core/app/routers/calls.py @@ -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}) diff --git a/drb-c2-core/app/routers/upload.py b/drb-c2-core/app/routers/upload.py index 74db2bd..063f044 100644 --- a/drb-c2-core/app/routers/upload.py +++ b/drb-c2-core/app/routers/upload.py @@ -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)") diff --git a/drb-c2-core/tests/test_correlator_gate.py b/drb-c2-core/tests/test_correlator_gate.py index 7d628b7..1714204 100644 --- a/drb-c2-core/tests/test_correlator_gate.py +++ b/drb-c2-core/tests/test_correlator_gate.py @@ -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"]