correlator: remove is_dispatch and the tactical fit path entirely (#134)
Full removal, not a hardcoded flag: _is_dispatch_channel, _DISPATCH_TG_RE, the is_dispatch parameter, and _call_fits_incident's tactical branch are gone. One evaluation path for every channel. 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
0473e6a583
commit
b1884852d5
@@ -47,3 +47,4 @@ Thumbs.db
|
||||
|
||||
# Out of scope - not a deployed service (server-26#56)
|
||||
drb-telegram-bot/
|
||||
.claude/worktrees/
|
||||
|
||||
+12
-24
@@ -90,31 +90,19 @@ class Settings(BaseSettings):
|
||||
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
||||
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
|
||||
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
||||
# Dispatch channels only: tier-2 thin calls attach to a lone candidate idle < this.
|
||||
# Was 10, which is long enough for the channel to have moved on to something else:
|
||||
# on 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central train
|
||||
# meet 9.6 min later, and a status check absorbed a records lookup at 9.7 min.
|
||||
# Across that dump every correct thin attach was <= 3.4 min idle and every wrong
|
||||
# one was >= 8.2, so 5 separates them with room on both sides. Genuine
|
||||
# back-and-forth is handled by the 30-second tier-1 path above this.
|
||||
# Second consumer (server-26#115): routers/upload.py's LLM-orphan-gate
|
||||
# escape hatch (_recent_incident_on_same_talkgroup) always uses this same
|
||||
# value now — no dispatch/tactical branch there since #133. Retuning this
|
||||
# for fast/thin reasons moves that gate's behavior too — check both call
|
||||
# sites before changing it. (tg_thin_idle_minutes below is now unused in
|
||||
# production — is_dispatch is hardcoded True at its only real call site,
|
||||
# incident_correlator.py's fast-path — see server-26#134.)
|
||||
# Tier-2 thin calls attach to a lone candidate idle < this, on every
|
||||
# channel (server-26#133/#134 removed the dispatch/tactical split — a
|
||||
# channel's name doesn't change how much scrutiny it gets). Was 10, which
|
||||
# is long enough for the channel to have moved on to something else: on
|
||||
# 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central
|
||||
# train meet 9.6 min later, and a status check absorbed a records lookup
|
||||
# at 9.7 min. Every correct thin attach in that dump was <= 3.4 min idle
|
||||
# and every wrong one was >= 8.2, so 5 separates them with room on both
|
||||
# sides. Genuine back-and-forth is handled by the 30-second tier-1 path
|
||||
# above this. Also the escape hatch in routers/upload.py's LLM-orphan gate
|
||||
# (_recent_incident_on_same_talkgroup, server-26#115) — check both call
|
||||
# sites before retuning this.
|
||||
tg_dispatch_thin_idle_minutes: int = 5
|
||||
# Every other channel: tier-2 thin calls attach to a lone candidate idle < this.
|
||||
# Non-dispatch talkgroups previously had NO tier-2 bound at all — they used the
|
||||
# whole 90-minute tg_fast_path_idle_minutes window with no single-candidate
|
||||
# requirement and no fit test, which is the widest version of the 2026-08-20
|
||||
# over-merge. A tactical channel really is dedicated to one scene, so it earns
|
||||
# a longer window than a dispatch backbone, but not an unbounded one: 15 sits
|
||||
# inside the 20-minute tactical-default window in _call_fits_incident, so the
|
||||
# no-evidence thin path is never more permissive than the fit-tested path on
|
||||
# the same channel.
|
||||
tg_thin_idle_minutes: int = 15
|
||||
|
||||
# ── Hard caps: an incident past either of these stops accepting calls ──────
|
||||
# Enforced on every correlation path (see _incident_at_capacity). Pairwise fit
|
||||
|
||||
@@ -98,13 +98,6 @@ _PURSUIT_PROXIMITY_KM = 20.0 # expanded radius for moving incidents
|
||||
# overlap with the candidate OR a distance under this tighter bar.
|
||||
_LOCATION_TIGHT_PROXIMITY_KM = 0.2
|
||||
|
||||
_DISPATCH_TG_RE = re.compile(
|
||||
r"\bdispatch\b|\bdisp\b"
|
||||
r"|\bpatched\b" # patched channels aggregate multiple call streams
|
||||
r"|\bprimary\b", # "Primary" channels serve as shared backbones
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Matches route/road identifiers in location strings for cross-system parent detection.
|
||||
# Groups: numbered routes (Route 202, NY-9, US-6, I-87, CR-35) and named parkways/highways.
|
||||
_ROAD_RE = re.compile(
|
||||
@@ -494,13 +487,6 @@ def _resolve_incident_title(
|
||||
return {}
|
||||
|
||||
|
||||
def _is_dispatch_channel(talkgroup_name: Optional[str]) -> bool:
|
||||
"""True when the talkgroup is a shared dispatch backbone (not a tactical/working channel)."""
|
||||
if not talkgroup_name:
|
||||
return False
|
||||
return bool(_DISPATCH_TG_RE.search(talkgroup_name))
|
||||
|
||||
|
||||
def _incident_idle_minutes(inc: dict, now: datetime) -> float:
|
||||
"""Minutes since the incident was last updated (or started). Returns 9999 on parse error."""
|
||||
try:
|
||||
@@ -976,15 +962,10 @@ def _run_decision(ctx: dict) -> dict:
|
||||
# directly on the Firestore call doc). Fall back to the call doc so that
|
||||
# dispatch-channel strictness works regardless of how the call arrived.
|
||||
effective_talkgroup_name = talkgroup_name or call_doc.get("talkgroup_name")
|
||||
# server-26#134: always dispatch-strict now, not name-guessed — a
|
||||
# channel labeled "tac"/"tactical" is rare and no less scrutinized in
|
||||
# practice than any other. _call_fits_incident's tactical branch is
|
||||
# kept, unreached, in case that's ever wrong.
|
||||
is_dispatch = True
|
||||
if effective_talkgroup_name != talkgroup_name:
|
||||
logger.info(
|
||||
f"Correlator: talkgroup_name missing from request for call {call_id}, "
|
||||
f"resolved from call doc: {effective_talkgroup_name!r} → is_dispatch={is_dispatch}"
|
||||
f"resolved from call doc: {effective_talkgroup_name!r}"
|
||||
)
|
||||
|
||||
tg_matches = [
|
||||
@@ -1028,10 +1009,7 @@ def _run_decision(ctx: dict) -> dict:
|
||||
# single-candidate requirement and no fit test of any kind. Four
|
||||
# hours is not a bound, and neither is ninety minutes.
|
||||
THIN_CONVERSATIONAL_SECS = 30
|
||||
thin_window_min = (
|
||||
settings.tg_dispatch_thin_idle_minutes if is_dispatch
|
||||
else settings.tg_thin_idle_minutes
|
||||
)
|
||||
thin_window_min = settings.tg_dispatch_thin_idle_minutes
|
||||
very_recent = [
|
||||
inc for inc in tg_recent
|
||||
if _idle_gate_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
||||
@@ -1053,8 +1031,7 @@ def _run_decision(ctx: dict) -> dict:
|
||||
if len(thin_pool) > 1:
|
||||
logger.info(
|
||||
f"Correlator fast-path thin (tier-2): {len(thin_pool)} active incidents "
|
||||
f"on {'dispatch' if is_dispatch else 'tactical'} channel — "
|
||||
f"ambiguous, skipping thin call {call_id}"
|
||||
f"— ambiguous, skipping thin call {call_id}"
|
||||
)
|
||||
thin_pool = []
|
||||
|
||||
@@ -1084,14 +1061,14 @@ def _run_decision(ctx: dict) -> dict:
|
||||
candidate = tg_recent[0]
|
||||
logger.info(
|
||||
f"Correlator fast/single: call {call_id} vs incident {candidate['incident_id']} "
|
||||
f"tg_name={effective_talkgroup_name!r} is_dispatch={is_dispatch} "
|
||||
f"tg_name={effective_talkgroup_name!r} "
|
||||
f"idle={round(_incident_idle_minutes(candidate, now), 1)}min "
|
||||
f"call_units={call_units} inc_units={candidate.get('units')} "
|
||||
f"call_coords={'yes' if coords else 'no'} inc_coords={'yes' if candidate.get('location_coords') else 'no'}"
|
||||
)
|
||||
fit, fit_signal = _call_fits_incident(
|
||||
candidate, call_units, call_vehicles, coords,
|
||||
settings.location_proximity_km, is_dispatch=is_dispatch,
|
||||
settings.location_proximity_km,
|
||||
call_embedding=call_embedding, now=now,
|
||||
reassignment=reassignment,
|
||||
)
|
||||
@@ -1101,13 +1078,12 @@ def _run_decision(ctx: dict) -> dict:
|
||||
"corr_path": "fast/single",
|
||||
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
|
||||
"corr_fit_signal": fit_signal,
|
||||
"corr_is_dispatch": is_dispatch,
|
||||
}
|
||||
if fit_signal == "unit_overlap" and call_units:
|
||||
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
|
||||
logger.info(
|
||||
f"Correlator fast-path: call {call_id} → {candidate['incident_id']} "
|
||||
f"(signal={fit_signal}, is_dispatch={is_dispatch})"
|
||||
f"(signal={fit_signal})"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
@@ -1123,14 +1099,14 @@ def _run_decision(ctx: dict) -> dict:
|
||||
# dispatch channel should create its own incident, not be force-merged.
|
||||
logger.info(
|
||||
f"Correlator fast/disambig: call {call_id} vs incident {candidate['incident_id']} "
|
||||
f"tg_name={effective_talkgroup_name!r} is_dispatch={is_dispatch} "
|
||||
f"tg_name={effective_talkgroup_name!r} "
|
||||
f"idle={round(_incident_idle_minutes(candidate, now), 1)}min "
|
||||
f"call_units={call_units} inc_units={candidate.get('units')} "
|
||||
f"call_coords={'yes' if coords else 'no'} inc_coords={'yes' if candidate.get('location_coords') else 'no'}"
|
||||
)
|
||||
fit, fit_signal = _call_fits_incident(
|
||||
candidate, call_units, call_vehicles, coords,
|
||||
settings.location_proximity_km, is_dispatch=is_dispatch,
|
||||
settings.location_proximity_km,
|
||||
call_embedding=call_embedding, now=now,
|
||||
reassignment=reassignment,
|
||||
)
|
||||
@@ -1141,7 +1117,6 @@ def _run_decision(ctx: dict) -> dict:
|
||||
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
|
||||
"corr_candidates": len(tg_recent),
|
||||
"corr_fit_signal": fit_signal,
|
||||
"corr_is_dispatch": is_dispatch,
|
||||
}
|
||||
if fit_signal == "unit_overlap" and call_units:
|
||||
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
|
||||
@@ -1720,7 +1695,6 @@ def _call_fits_incident(
|
||||
call_vehicles: list[str],
|
||||
call_coords: Optional[dict],
|
||||
proximity_km: float,
|
||||
is_dispatch: bool = False,
|
||||
call_embedding: Optional[list] = None,
|
||||
now: Optional[datetime] = None,
|
||||
reassignment: bool = False,
|
||||
@@ -1730,48 +1704,24 @@ def _call_fits_incident(
|
||||
the incident; signal names the specific evidence that drove the decision.
|
||||
|
||||
fits=True signals: "unit_overlap" | "vehicle_overlap" | "location_proximity"
|
||||
| "time_fallback" | "tactical_default"
|
||||
fits=False signals: "unit_loc_conflict" | "content_divergence"
|
||||
| "location_conflict" | "no_signal" | "tactical_idle"
|
||||
| "location_conflict" | "no_signal"
|
||||
|
||||
Original docstring (logic unchanged):
|
||||
Return True if this call plausibly belongs to the given incident.
|
||||
|
||||
Evaluation order for dispatch channels (is_dispatch=True):
|
||||
─────────────────────────────────────────────────────────
|
||||
1. Unit overlap
|
||||
Same officer = same call. On dispatch channels, also run a location
|
||||
conflict guard: if both sides carry geocoded coords and they differ
|
||||
significantly, the officer has moved to a new scene and the unit match
|
||||
is a false positive.
|
||||
When the call has NO geocoded coordinates AND the incident is old
|
||||
(≥ 15 min), use content divergence as a location proxy: an officer at
|
||||
a genuinely different scene will be talking about clearly different
|
||||
things. For recent incidents (< 15 min) we skip this proxy — the
|
||||
officer may simply be giving an update without mentioning the address.
|
||||
|
||||
2. Vehicle overlap → True
|
||||
|
||||
3. Location proximity
|
||||
Both sides geocoded and close → True.
|
||||
Both sides geocoded and far apart (no other positive signal) → False.
|
||||
|
||||
4. No positive signals fired → dispatch fallback
|
||||
a. Conversational continuity: idle < 2 min → True.
|
||||
A call arriving within 2 minutes of the last incident activity almost
|
||||
certainly belongs to the same dispatch thread. "Baker, head over
|
||||
there too" or "copy that" carries no incident-specific vocabulary but
|
||||
is unambiguously a response to what was just said. We do not require
|
||||
embedding similarity here — embeddings capture word meaning, not
|
||||
conversational context, and short operational messages will always
|
||||
have lower similarity than the incident's accumulated content.
|
||||
b. Older incident, no positive signals → False.
|
||||
A shared dispatch channel must not absorb calls by default.
|
||||
|
||||
Tactical / working channel (is_dispatch=False):
|
||||
────────────────────────────────────────────────
|
||||
Channel is dedicated to one scene. No evidence of separation ≈ same call.
|
||||
Default → True.
|
||||
Evaluation order:
|
||||
1. Unit overlap. Same officer = same call. Also runs a location-conflict
|
||||
guard: geocoded on both sides and clearly different → the officer has
|
||||
moved to a new scene, false positive. No geocode on the call (or on
|
||||
the incident, with a call geocode) AND the incident is old (≥ 15 min)
|
||||
→ content divergence as a location proxy (embedding similarity < 0.82
|
||||
→ different scene). Skipped for recent incidents (< 15 min) — an
|
||||
update without re-stating the address is normal.
|
||||
2. Vehicle overlap → True.
|
||||
3. Location proximity. Both geocoded and close → True; far apart with no
|
||||
other positive signal → False.
|
||||
4. No positive signal at all → False. A shared channel must not absorb
|
||||
calls by default (server-26#134 — this used to default True within
|
||||
20 min on any channel not name-matched as "dispatch"; a talkgroup
|
||||
named tac/tactical is no less scrutinized in practice than any other).
|
||||
|
||||
Thin calls (no units/vehicles/coords) never reach this function —
|
||||
they are intercepted before it in correlate_call.
|
||||
@@ -1791,44 +1741,43 @@ def _call_fits_incident(
|
||||
inc_units = _unit_keys(inc.get("units"))
|
||||
matched_units = _matching_units(call_units, inc.get("units"))
|
||||
if matched_units:
|
||||
if is_dispatch:
|
||||
if call_coords:
|
||||
# Hard location conflict: geocoded on both sides and clearly different.
|
||||
inc_coords_u = inc.get("location_coords")
|
||||
if inc_coords_u:
|
||||
dist_km = _haversine_km(
|
||||
call_coords["lat"], call_coords["lng"],
|
||||
inc_coords_u["lat"], inc_coords_u["lng"],
|
||||
)
|
||||
if dist_km > proximity_km:
|
||||
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict")
|
||||
return False, "unit_loc_conflict"
|
||||
elif call_embedding and idle_min >= 15:
|
||||
# Call has geocode but incident doesn't — fall back to content
|
||||
# divergence as a location proxy. Without this, stale incidents
|
||||
# that never geocoded absorb unrelated calls purely on unit
|
||||
# overlap (e.g. a patrol officer working a second scene 70 min
|
||||
# after the original call).
|
||||
inc_emb_u = inc.get("embedding")
|
||||
if inc_emb_u:
|
||||
sim = _cosine_similarity(call_embedding, inc_emb_u)
|
||||
if sim < 0.82:
|
||||
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence (has_call_coords/no_inc_coords) sim={sim:.3f} → content_divergence")
|
||||
return False, "content_divergence"
|
||||
if call_coords:
|
||||
# Hard location conflict: geocoded on both sides and clearly different.
|
||||
inc_coords_u = inc.get("location_coords")
|
||||
if inc_coords_u:
|
||||
dist_km = _haversine_km(
|
||||
call_coords["lat"], call_coords["lng"],
|
||||
inc_coords_u["lat"], inc_coords_u["lng"],
|
||||
)
|
||||
if dist_km > proximity_km:
|
||||
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict")
|
||||
return False, "unit_loc_conflict"
|
||||
elif call_embedding and idle_min >= 15:
|
||||
# No geocode available AND old incident: use content divergence as a
|
||||
# location-proxy veto. After 15+ minutes an officer at a completely
|
||||
# different scene will be discussing clearly different content.
|
||||
# Skip this for recent incidents — an officer updating on the same
|
||||
# scene without re-stating the address is normal and their update
|
||||
# won't share much vocabulary with the original dispatch.
|
||||
# Call has geocode but incident doesn't — fall back to content
|
||||
# divergence as a location proxy. Without this, stale incidents
|
||||
# that never geocoded absorb unrelated calls purely on unit
|
||||
# overlap (e.g. a patrol officer working a second scene 70 min
|
||||
# after the original call).
|
||||
inc_emb_u = inc.get("embedding")
|
||||
if inc_emb_u:
|
||||
sim = _cosine_similarity(call_embedding, inc_emb_u)
|
||||
if sim < 0.82:
|
||||
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence sim={sim:.3f} → content_divergence")
|
||||
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence (has_call_coords/no_inc_coords) sim={sim:.3f} → content_divergence")
|
||||
return False, "content_divergence"
|
||||
logger.info(f" fits[{inc_id}]: unit_overlap matched={matched_units} is_dispatch={is_dispatch} → unit_overlap")
|
||||
elif call_embedding and idle_min >= 15:
|
||||
# No geocode available AND old incident: use content divergence as a
|
||||
# location-proxy veto. After 15+ minutes an officer at a completely
|
||||
# different scene will be discussing clearly different content.
|
||||
# Skip this for recent incidents — an officer updating on the same
|
||||
# scene without re-stating the address is normal and their update
|
||||
# won't share much vocabulary with the original dispatch.
|
||||
inc_emb_u = inc.get("embedding")
|
||||
if inc_emb_u:
|
||||
sim = _cosine_similarity(call_embedding, inc_emb_u)
|
||||
if sim < 0.82:
|
||||
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence sim={sim:.3f} → content_divergence")
|
||||
return False, "content_divergence"
|
||||
logger.info(f" fits[{inc_id}]: unit_overlap matched={matched_units} → unit_overlap")
|
||||
return True, "unit_overlap"
|
||||
|
||||
# ── 2. Vehicle overlap ────────────────────────────────────────────────────
|
||||
@@ -1882,29 +1831,19 @@ def _call_fits_incident(
|
||||
return False, "location_conflict"
|
||||
|
||||
# ── 4. No positive signals ────────────────────────────────────────────────
|
||||
# Requires at least one positive signal (unit, vehicle, or location match).
|
||||
# A substantive call with no matching signals is more likely a separate
|
||||
# incident than a follow-up — two dispatches can arrive within seconds of
|
||||
# each other on a busy channel. Content-free thin calls are handled before
|
||||
# this function via the thin path in correlate_call, with a tighter
|
||||
# 30-second recency window.
|
||||
logger.info(
|
||||
f" fits[{inc_id}]: no positive signal — is_dispatch={is_dispatch} idle={idle_min:.1f}min "
|
||||
f" fits[{inc_id}]: no positive signal — idle={idle_min:.1f}min "
|
||||
f"inc_units={list(inc_units)} call_units={call_units} "
|
||||
f"inc_vehicles={list(inc_vehicles)} call_vehicles={call_vehicles} "
|
||||
f"call_coords={call_coords is not None} inc_coords={inc_coords is not None}"
|
||||
)
|
||||
if is_dispatch:
|
||||
# Dispatch channels require at least one positive signal (unit, vehicle,
|
||||
# or location match). A substantive call with no matching signals is more
|
||||
# likely a separate incident than a follow-up to the current one — two
|
||||
# dispatches can arrive within seconds of each other on a busy channel.
|
||||
# Content-free thin calls are handled before this function via the thin
|
||||
# path in correlate_call, with a tighter 30-second recency window.
|
||||
return False, "no_signal"
|
||||
|
||||
# Tactical channel: one scene per channel.
|
||||
# Within 20 min of the last incident activity, link by default — same
|
||||
# working channel almost certainly means same scene.
|
||||
# After 20 min of silence, require at least one positive signal; the same
|
||||
# frequency can be reused for a new unrelated incident later in the shift.
|
||||
if idle_min < 20.0:
|
||||
return True, "tactical_default"
|
||||
return False, "tactical_idle"
|
||||
return False, "no_signal"
|
||||
|
||||
|
||||
async def _update_incident(
|
||||
|
||||
@@ -111,55 +111,17 @@ async def upload_call_audio(
|
||||
|
||||
def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
||||
"""
|
||||
True when one of the already-loaded recent incidents is running on this
|
||||
call's own system + talkgroup AND was active within the last
|
||||
`settings.tg_dispatch_thin_idle_minutes` minutes. Covers the "unit
|
||||
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.
|
||||
True when a recent incident is running on this call's own system +
|
||||
talkgroup, within `settings.tg_dispatch_thin_idle_minutes` (5 min) —
|
||||
applied uniformly regardless of the talkgroup's name (server-26#134).
|
||||
Covers "unit dispatched, thin ack 10-30s later": the ack has no
|
||||
substance of its own but plainly belongs to the job just opened.
|
||||
|
||||
Always uses `settings.tg_dispatch_thin_idle_minutes` (5 min), regardless
|
||||
of what the talkgroup is named. An earlier version of this branched on
|
||||
`_is_dispatch_channel` (mirroring incident_correlator.py's fast/thin idle-
|
||||
window selection) to use a longer 15-minute window on anything not
|
||||
literally named "dispatch"/"patched"/"primary" — owner correction,
|
||||
2026-09-13, from direct scanning experience: a talkgroup named "tac"/
|
||||
"tactical" genuinely does see materially different traffic only during a
|
||||
real incident, and that's rare; the overwhelming majority of traffic on
|
||||
ANY monitored channel — including high-risk stops and pursuits — runs on
|
||||
the main channel regardless of what it's named. `_is_dispatch_channel`'s
|
||||
string-match is a naming-convention guess, not a detector of actual
|
||||
channel behavior, and trusting it here meant a busy single-channel
|
||||
department not literally named "dispatch" would silently get the more
|
||||
permissive window and reproduce #115's original bug. One constant,
|
||||
applied uniformly, is the safer default; a real low-volume channel is the
|
||||
rare case, not the norm, so erring toward the tighter window costs little.
|
||||
NOT applied to incident_correlator.py's own fast/thin selection (out of
|
||||
scope for this pass — a bigger, decision-changing surface, worth its own
|
||||
review rather than changing under this fix).
|
||||
|
||||
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
|
||||
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. A proper
|
||||
fix needs a dedicated Firestore query that is not status/capacity
|
||||
filtered — a new read, out of scope for this pass.
|
||||
Reads ctx["recent"] (the rules engine's own candidate list — no extra
|
||||
Firestore read). That list is status=="active" incidents only, so an
|
||||
already-resolved or capacity-capped same-talkgroup incident won't be
|
||||
seen here even if chronologically recent (server-26#115, unresolved —
|
||||
would need a dedicated non-status-filtered query).
|
||||
|
||||
Whether this limitation explains the 2/24 unexplained gate misses in the
|
||||
window #3 measurement is UNANSWERED, not confirmed either way — a prior
|
||||
|
||||
@@ -31,8 +31,10 @@ from app.internal.incident_correlator import (
|
||||
|
||||
NOW = datetime(2026, 8, 20, 7, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# TG 383 from the dump: "Ch 1 (Patched with 155.310)". _DISPATCH_TG_RE matches
|
||||
# "patched", so this is a shared dispatch backbone carrying the whole department.
|
||||
# TG 383 from the dump: "Ch 1 (Patched with 155.310)", a shared dispatch
|
||||
# backbone carrying the whole department. Kept as two distinct fixture names
|
||||
# for readability even though the channel's name no longer affects behavior
|
||||
# (server-26#134).
|
||||
DISPATCH_TG = "Ch 1 (Patched with 155.310)"
|
||||
TACTICAL_TG = "Fireground 2"
|
||||
|
||||
@@ -140,12 +142,9 @@ def test_thin_call_with_no_overlap_does_not_attach_on_a_dispatch_channel():
|
||||
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan"
|
||||
|
||||
|
||||
def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_channel():
|
||||
"""
|
||||
The widest version of the bug: non-dispatch talkgroups skipped the tiering
|
||||
entirely and used the whole 90-minute fast-path window with no
|
||||
single-candidate requirement, so ANY thin call joined whatever was newest.
|
||||
"""
|
||||
def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_named_channel():
|
||||
"""A channel's name no longer changes anything (server-26#134) — same
|
||||
assertion as the dispatch-named case above, different fixture name."""
|
||||
inc = _incident(idle_minutes=40)
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG,
|
||||
@@ -154,10 +153,9 @@ def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_channel():
|
||||
|
||||
|
||||
def test_tactical_named_channel_uses_the_dispatch_window_now():
|
||||
"""server-26#134: is_dispatch is hardcoded True — a channel's name no
|
||||
longer changes the thin window. 14 min (the old tactical bound minus 1,
|
||||
inside the old 15-min window) is now past the 5-min dispatch window."""
|
||||
inc = _incident(idle_minutes=settings.tg_thin_idle_minutes - 1)
|
||||
"""server-26#134: 14 min was inside the old 15-min tactical window; now
|
||||
every channel uses the 5-min window regardless of name."""
|
||||
inc = _incident(idle_minutes=14)
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG,
|
||||
))
|
||||
@@ -234,47 +232,27 @@ def test_back_dated_thin_call_does_not_sail_through_the_recency_gate():
|
||||
|
||||
def test_back_dated_call_does_not_bypass_the_content_divergence_veto(monkeypatch):
|
||||
"""
|
||||
Same `9d376ffe` failure mode, but exercised directly against
|
||||
`_call_fits_incident` on a dispatch channel: unit overlap plus a
|
||||
back-dated call (incident updated 45 minutes AFTER the call's own
|
||||
`started_at`, which the sweep passes as `now`) used to make the signed
|
||||
idle -45, so `idle_min >= 15` read False and the content-divergence
|
||||
veto never ran — unit overlap alone forced the merge regardless of
|
||||
what the call was actually about. With the gate fixed to compare
|
||||
distance, idle_min is 45 (>= 15), the veto runs, and a divergent
|
||||
embedding (patched below so the assertion doesn't depend on numpy
|
||||
being installed in this environment) fails it.
|
||||
Same `9d376ffe` failure mode, exercised directly against
|
||||
`_call_fits_incident`: unit overlap plus a back-dated call (incident
|
||||
updated 45 minutes AFTER the call's own `started_at`, which the sweep
|
||||
passes as `now`) used to make the signed idle -45, so `idle_min >= 15`
|
||||
read False and the content-divergence veto never ran — unit overlap
|
||||
alone forced the merge regardless of what the call was actually about.
|
||||
With the gate fixed to compare distance, idle_min is 45 (>= 15), the
|
||||
veto runs, and a divergent embedding (patched below so the assertion
|
||||
doesn't depend on numpy being installed in this environment) fails it.
|
||||
"""
|
||||
monkeypatch.setattr(correlator_mod, "_cosine_similarity", lambda a, b: 0.0)
|
||||
inc = _incident(idle_minutes=-45, units=["6-Adam"])
|
||||
inc["embedding"] = [1.0, 0.0]
|
||||
fits, signal = _call_fits_incident(
|
||||
inc, call_units=["6-Adam"], call_vehicles=[], call_coords=None,
|
||||
proximity_km=settings.location_proximity_km, is_dispatch=True,
|
||||
proximity_km=settings.location_proximity_km,
|
||||
call_embedding=[0.0, 1.0], now=NOW,
|
||||
)
|
||||
assert (fits, signal) == (False, "content_divergence")
|
||||
|
||||
|
||||
def test_back_dated_call_on_tactical_channel_does_not_get_the_default():
|
||||
"""
|
||||
Tactical-channel counterpart: no unit/vehicle/location signal, so the
|
||||
function falls through to step 4's `idle_min < 20.0` default. A
|
||||
back-dated call (incident updated 45 minutes after the call's own
|
||||
started_at) used to read idle_min as -45, which is always < 20.0, so
|
||||
`tactical_default` fired unconditionally no matter how stale the
|
||||
incident actually was relative to this call. Fixed, idle_min is the
|
||||
45-minute distance, which is not < 20.0.
|
||||
"""
|
||||
inc = _incident(idle_minutes=-45)
|
||||
fits, signal = _call_fits_incident(
|
||||
inc, call_units=[], call_vehicles=[], call_coords=None,
|
||||
proximity_km=settings.location_proximity_km, is_dispatch=False,
|
||||
call_embedding=None, now=NOW,
|
||||
)
|
||||
assert (fits, signal) == (False, "tactical_idle")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Hard caps — path-independent, because pairwise fit tests can't see shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user