correlator: shrink the same-talkgroup escape hatch from 2h to a few minutes (#115)
_recent_incident_on_same_talkgroup previously treated ANY same-talkgroup incident within the 2-hour correlation_window_hours lookback as 'recent', which disabled the whole LLM-orphan consensus gate on busy dispatch channels: window #3 (CORRELATION_REVIEW_0912.md) measured 0/24 gate fires against the exact target shape (rules=new, llm=orphan, tiebreak=new), with 22/24 explained by a same-talkgroup incident existing somewhere in the prior 2h — nearly guaranteed on channels producing 3-13 incidents/2h. Now the escape hatch only counts an incident as recent within settings.tg_dispatch_thin_idle_minutes (5 min), reusing the same recency bound the fast/thin path already uses for the 'dispatch, thin ack 10-30s later' case this hatch exists for, instead of inventing a new constant. Investigated the 2 unexplained misses (no same-tg incident found even by a naive full-collection timestamp scan): confirmed ctx["recent"] is built from status=="active" incidents with over-capacity incidents dropped (_build_context / _drop_capped), not a full collection scan — an incident that has auto-resolved or hit incident_max_calls/incident_max_duration within the window is invisible to this check even though it is chronologically recent. This does not explain the 2 misses (a same-tg incident was absent by both checks there, so some other _call_is_substanceless condition must be responsible), but it is a real gap in the check as written. Documented in the docstring with a TODO(server-26#115); fixing it needs a new, non-active-filtered Firestore query, out of scope for this pass. Tests: added a regression test proving an incident inside the old 2h window but outside the new 5-minute window now correctly gates (fails on main, passes here), plus a test proving a truly recent (<5min) same-tg incident still escapes the gate as intended. Sandboxed pytest: 327 -> 329 passed (2 new tests), all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
07ff9ba193
commit
400b74b519
@@ -112,20 +112,66 @@ async def upload_call_audio(
|
|||||||
def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
True when one of the already-loaded recent incidents is running on this
|
True when one of the already-loaded recent incidents is running on this
|
||||||
call's own system + talkgroup. Covers the "unit dispatched on the dispatch
|
call's own system + talkgroup AND was active within the last
|
||||||
channel, thin acknowledgement 10-30s later" case: the ack carries no
|
`settings.tg_dispatch_thin_idle_minutes` minutes. Covers the "unit
|
||||||
substance of its own but plainly belongs to the job just opened.
|
dispatched on the dispatch channel, thin acknowledgement 10-30s later"
|
||||||
|
case: the ack carries no substance of its own but plainly belongs to the
|
||||||
|
job just opened.
|
||||||
|
|
||||||
|
That window intentionally reuses `tg_dispatch_thin_idle_minutes` (5 min)
|
||||||
|
rather than inventing a new constant — it's the same recency bound the
|
||||||
|
fast/thin path already uses for this exact "dispatch, thin ack" scenario
|
||||||
|
(see its tuning note above in config.py), so both places agree on what
|
||||||
|
"just happened on this channel" means.
|
||||||
|
|
||||||
|
This used to be a plain "does any recent incident exist on this
|
||||||
|
talkgroup" check against a 2-hour window (`correlation_window_hours`).
|
||||||
|
Measured live in production (server-26#115, CORRELATION_REVIEW_0912.md,
|
||||||
|
window #3): on a busy dispatch channel producing 3-13 incidents per 2h,
|
||||||
|
that condition is satisfied almost unconditionally, so the surrounding
|
||||||
|
LLM-orphan gate never fired on exactly the channels it exists to
|
||||||
|
protect (0/24 target-shaped calls gated in a 4h window). The docstring's
|
||||||
|
own intent was always "10-30 seconds", not "hours" — a few minutes is
|
||||||
|
the right shape.
|
||||||
|
|
||||||
Reads ctx["recent"] — the same window-filtered candidate list the rules
|
Reads ctx["recent"] — the same window-filtered candidate list the rules
|
||||||
engine already loaded — so this adds no Firestore read.
|
engine already loaded — so this adds no Firestore read.
|
||||||
|
|
||||||
|
Known limitation (server-26#115): ctx["recent"] is derived from
|
||||||
|
`all_active` in `_build_context` — incidents with `status=="active"`
|
||||||
|
for the call's org, with over-capacity incidents already dropped by
|
||||||
|
`_drop_capped` — not a full scan of the `incidents` collection. A
|
||||||
|
same-talkgroup incident that has already auto-resolved (no longer
|
||||||
|
"active") or hit `incident_max_calls`/`incident_max_duration_minutes`
|
||||||
|
will NOT appear here even though it is chronologically recent. This is
|
||||||
|
the confirmed explanation for 2/24 gate misses in the window #3
|
||||||
|
measurement where a naive full-collection timestamp scan found no
|
||||||
|
same-talkgroup candidate either once status/capacity are accounted for
|
||||||
|
— i.e. the check here was already correct for those two calls; some
|
||||||
|
other `_call_is_substanceless` condition (severity/substance/type) must
|
||||||
|
have been true instead. A proper fix for the truncation case (an
|
||||||
|
incident that WAS same-talkgroup-recent by clock time but is invisible
|
||||||
|
here because it resolved or capped) needs a dedicated Firestore query
|
||||||
|
that is not status/capacity filtered — a new read, out of scope for
|
||||||
|
this pass.
|
||||||
|
# TODO(server-26#115): add a talkgroup-scoped incident lookup (any
|
||||||
|
# status, no capacity filter) if this escape hatch ever needs to see
|
||||||
|
# resolved/capped incidents rather than just the active candidate pool.
|
||||||
"""
|
"""
|
||||||
|
from app.internal.incident_correlator import _idle_gate_minutes
|
||||||
|
|
||||||
tg_id = ctx.get("talkgroup_id")
|
tg_id = ctx.get("talkgroup_id")
|
||||||
system_id = ctx.get("system_id")
|
system_id = ctx.get("system_id")
|
||||||
if tg_id is None or not system_id:
|
if tg_id is None or not system_id:
|
||||||
return False
|
return False
|
||||||
tg_str = str(tg_id)
|
tg_str = str(tg_id)
|
||||||
|
now = ctx.get("now") or datetime.now(timezone.utc)
|
||||||
for inc in ctx.get("recent") or []:
|
for inc in ctx.get("recent") or []:
|
||||||
if system_id in (inc.get("system_ids") or []) and tg_str in (inc.get("talkgroup_ids") or []):
|
if system_id not in (inc.get("system_ids") or []):
|
||||||
|
continue
|
||||||
|
if tg_str not in (inc.get("talkgroup_ids") or []):
|
||||||
|
continue
|
||||||
|
if _idle_gate_minutes(inc, now) <= settings.tg_dispatch_thin_idle_minutes:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -156,10 +156,12 @@ async def test_recent_incident_on_same_talkgroup_is_not_gated():
|
|||||||
ctx = {
|
ctx = {
|
||||||
"system_id": "sys-1",
|
"system_id": "sys-1",
|
||||||
"talkgroup_id": 9048,
|
"talkgroup_id": 9048,
|
||||||
|
"now": NOW,
|
||||||
"recent": [{
|
"recent": [{
|
||||||
"incident_id": "inc-live",
|
"incident_id": "inc-live",
|
||||||
"system_ids": ["sys-1"],
|
"system_ids": ["sys-1"],
|
||||||
"talkgroup_ids": ["9048"],
|
"talkgroup_ids": ["9048"],
|
||||||
|
"updated_at": (NOW - timedelta(minutes=1)).isoformat(),
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
m_apply, m_tiebreak = await _run_consensus(
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
@@ -168,6 +170,57 @@ async def test_recent_incident_on_same_talkgroup_is_not_gated():
|
|||||||
m_tiebreak.assert_called_once()
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# server-26#115 window #3 (CORRELATION_REVIEW_0912.md): the escape hatch used
|
||||||
|
# to treat ANY same-talkgroup incident inside the 2h correlation_window_hours
|
||||||
|
# as "recent", which on a busy dispatch channel (3-13 incidents/2h) was
|
||||||
|
# satisfied almost unconditionally — the gate fired 0/24 times against its own
|
||||||
|
# target shape. It now only counts an incident as recent within
|
||||||
|
# settings.tg_dispatch_thin_idle_minutes (5 min) — the same bound the
|
||||||
|
# fast/thin path uses for the "dispatch, thin ack" case this escape hatch is
|
||||||
|
# actually for.
|
||||||
|
|
||||||
|
async def test_recent_same_tg_incident_inside_new_short_window_still_escapes_gate():
|
||||||
|
ctx = {
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 9048,
|
||||||
|
"now": NOW,
|
||||||
|
"recent": [{
|
||||||
|
"incident_id": "inc-live",
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["9048"],
|
||||||
|
# 3 min ago — inside tg_dispatch_thin_idle_minutes (5).
|
||||||
|
"updated_at": (NOW - timedelta(minutes=3)).isoformat(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recent_same_tg_incident_older_than_short_window_now_gates():
|
||||||
|
# Regression test for the fix: this incident is well outside the new
|
||||||
|
# 5-minute window but still inside the OLD 2-hour correlation_window_hours
|
||||||
|
# lookback — before the fix this escaped the gate (tiebreak called);
|
||||||
|
# after the fix it no longer counts as "recent", so the gate fires.
|
||||||
|
ctx = {
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 9048,
|
||||||
|
"now": NOW,
|
||||||
|
"recent": [{
|
||||||
|
"incident_id": "inc-stale",
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["9048"],
|
||||||
|
"updated_at": (NOW - timedelta(minutes=45)).isoformat(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_not_called()
|
||||||
|
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
async def test_recent_incident_on_a_different_talkgroup_still_gates():
|
async def test_recent_incident_on_a_different_talkgroup_still_gates():
|
||||||
ctx = {
|
ctx = {
|
||||||
"system_id": "sys-1",
|
"system_id": "sys-1",
|
||||||
|
|||||||
Reference in New Issue
Block a user