From b9e7524817a16bd91a4cc5a36aecf5a962fd3e39 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sat, 26 Sep 2026 19:43:30 -0400 Subject: [PATCH] reopen: act on drb-correlation-review of e972cac MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - only a substantive call after the close reopens a timer-closed incident; a thin "10-4" (doesn't refresh updated_at) or a sweep link of a call from before the close rides along without reopening — otherwise the next sweep closed it again and the portal flickered. - substantive_call_count counts a call once, not once per scene. - a timer-closed incident is never adopted as a cross-system parent. Replay e972cac (correlation-only on 731b54b's extraction) vs the 09-22 answer key: pairwise F1 0.548 -> 0.807 (precision 0.839 -> 0.956, recall 0.407 -> 0.698); the bridge MVA is one 76-call incident instead of two 40-call halves. server-26#170. c2-core: 468 pass. Co-Authored-By: Claude Opus 5.5 --- .../app/internal/incident_correlator.py | 33 +++++++++++++++---- drb-c2-core/tests/test_clearance_tracking.py | 6 ++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 04e73ee..a04cd2c 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -224,6 +224,16 @@ def _normalize_unit(unit: str) -> str: return key or unit.strip().lower() +def _after_close(inc: dict, now: datetime) -> bool: + try: + closed = datetime.fromisoformat(str(inc.get("resolved_at") or "").replace("Z", "+00:00")) + except ValueError: + return True + if closed.tzinfo is None: + closed = closed.replace(tzinfo=timezone.utc) + return now > closed + + def _is_trackable_unit(unit: str) -> bool: """ Whether a unit is concrete enough to hold an incident open until it clears. @@ -2029,7 +2039,8 @@ async def _update_incident( incident_id = inc["incident_id"] call_ids = list(inc.get("call_ids") or []) - if call_id not in call_ids: + is_new_call = call_id not in call_ids + if is_new_call: call_ids.append(call_id) talkgroup_ids = list(inc.get("talkgroup_ids") or []) @@ -2101,10 +2112,11 @@ async def _update_incident( # thin traffic rides along without extending its life. if refresh_activity: updates["updated_at"] = _floor_at_started_at(inc, now).isoformat() - updates["substantive_call_count"] = ( - inc.get("substantive_call_count") - if inc.get("substantive_call_count") is not None else len(inc.get("call_ids") or []) - ) + 1 + if is_new_call: # a second scene of the same call is not a second call + updates["substantive_call_count"] = ( + inc.get("substantive_call_count") + if inc.get("substantive_call_count") is not None else len(inc.get("call_ids") or []) + ) + 1 else: updates["last_thin_at"] = now.isoformat() # Update incident type when a re-classified call provides a concrete type. @@ -2122,8 +2134,11 @@ async def _update_incident( # Signal-based auto-resolve: every tracked unit has cleared, none still active. # Requires at least one unit to have explicitly signalled back-in-service so we # don't fire on incidents where units were never tracked (no unit mentions at all). - if inc.get("status") == "resolved": - # A timer close was provisional and a related call just arrived. + if inc.get("status") == "resolved" and refresh_activity and _after_close(inc, now): + # A timer close was provisional and a related, substantive call arrived + # after it. A thin "10-4" rides along without reopening (it would not + # refresh updated_at, so the next sweep would just close it again), + # and neither does a sweep link of a call from before the close. updates.update({"status": "active", "resolved_at": None, "resolved_via": None, "reopenable": False, "reopened_count": (inc.get("reopened_count") or 0) + 1}) logger.info(f"Correlator: reopened timer-closed incident {incident_id} (call {call_id})") @@ -2373,6 +2388,10 @@ async def _find_cross_system_parent( best_score = 0.0 for inc in recent: + # A timer-closed incident is in `recent` only so a related call can + # reopen it; it must not be adopted as another agency's parent. + if inc.get("status") != "active": + continue # Only cross-system candidates if system_id in (inc.get("system_ids") or []): continue diff --git a/drb-c2-core/tests/test_clearance_tracking.py b/drb-c2-core/tests/test_clearance_tracking.py index cdd77d6..4f55b0c 100644 --- a/drb-c2-core/tests/test_clearance_tracking.py +++ b/drb-c2-core/tests/test_clearance_tracking.py @@ -93,3 +93,9 @@ def test_thin_calls_do_not_fill_the_call_cap(): assert ic._incident_at_capacity(inc, now) is None legacy = {k: v for k, v in inc.items() if k != "substantive_call_count"} assert ic._incident_at_capacity(legacy, now).startswith("call_cap") + + +def test_reopen_only_for_a_call_after_the_close(): + closed = {"resolved_at": "2026-09-22T15:00:00+00:00"} + assert ic._after_close(closed, datetime(2026, 9, 22, 15, 5, tzinfo=timezone.utc)) + assert not ic._after_close(closed, datetime(2026, 9, 22, 14, 55, tzinfo=timezone.utc))