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:
@@ -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)"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user