Compare commits
4
Commits
4df801c5e0
...
454fe7e81c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
454fe7e81c | ||
|
|
422e9a4dc8 | ||
|
|
b1884852d5 | ||
|
|
0473e6a583 |
@@ -47,3 +47,4 @@ Thumbs.db
|
|||||||
|
|
||||||
# Out of scope - not a deployed service (server-26#56)
|
# Out of scope - not a deployed service (server-26#56)
|
||||||
drb-telegram-bot/
|
drb-telegram-bot/
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
+12
-22
@@ -90,29 +90,19 @@ class Settings(BaseSettings):
|
|||||||
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
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
|
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
|
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.
|
# Tier-2 thin calls attach to a lone candidate idle < this, on every
|
||||||
# Was 10, which is long enough for the channel to have moved on to something else:
|
# channel (server-26#133/#134 removed the dispatch/tactical split — a
|
||||||
# on 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central train
|
# channel's name doesn't change how much scrutiny it gets). Was 10, which
|
||||||
# meet 9.6 min later, and a status check absorbed a records lookup at 9.7 min.
|
# is long enough for the channel to have moved on to something else: on
|
||||||
# Across that dump every correct thin attach was <= 3.4 min idle and every wrong
|
# 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central
|
||||||
# one was >= 8.2, so 5 separates them with room on both sides. Genuine
|
# train meet 9.6 min later, and a status check absorbed a records lookup
|
||||||
# back-and-forth is handled by the 30-second tier-1 path above this.
|
# at 9.7 min. Every correct thin attach in that dump was <= 3.4 min idle
|
||||||
# Second consumer (server-26#115): routers/upload.py's LLM-orphan-gate escape
|
# and every wrong one was >= 8.2, so 5 separates them with room on both
|
||||||
# hatch (_recent_incident_on_same_talkgroup) reuses this same value, selected
|
# sides. Genuine back-and-forth is handled by the 30-second tier-1 path
|
||||||
# the same way (dispatch vs tactical) via _is_dispatch_channel. Retuning this
|
# above this. Also the escape hatch in routers/upload.py's LLM-orphan gate
|
||||||
# for fast/thin reasons moves that gate's behavior too — check both call
|
# (_recent_incident_on_same_talkgroup, server-26#115) — check both call
|
||||||
# sites before changing it.
|
# sites before retuning this.
|
||||||
tg_dispatch_thin_idle_minutes: int = 5
|
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 ──────
|
# ── Hard caps: an incident past either of these stops accepting calls ──────
|
||||||
# Enforced on every correlation path (see _incident_at_capacity). Pairwise fit
|
# 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.
|
# overlap with the candidate OR a distance under this tighter bar.
|
||||||
_LOCATION_TIGHT_PROXIMITY_KM = 0.2
|
_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.
|
# 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.
|
# Groups: numbered routes (Route 202, NY-9, US-6, I-87, CR-35) and named parkways/highways.
|
||||||
_ROAD_RE = re.compile(
|
_ROAD_RE = re.compile(
|
||||||
@@ -494,13 +487,6 @@ def _resolve_incident_title(
|
|||||||
return {}
|
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:
|
def _incident_idle_minutes(inc: dict, now: datetime) -> float:
|
||||||
"""Minutes since the incident was last updated (or started). Returns 9999 on parse error."""
|
"""Minutes since the incident was last updated (or started). Returns 9999 on parse error."""
|
||||||
try:
|
try:
|
||||||
@@ -973,14 +959,13 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
if talkgroup_id is not None and system_id:
|
if talkgroup_id is not None and system_id:
|
||||||
tg_str = str(talkgroup_id)
|
tg_str = str(talkgroup_id)
|
||||||
# talkgroup_name may be None when the upload form omits it (node sets it
|
# talkgroup_name may be None when the upload form omits it (node sets it
|
||||||
# directly on the Firestore call doc). Fall back to the call doc so that
|
# directly on the Firestore call doc). Fall back to the call doc so the
|
||||||
# dispatch-channel strictness works regardless of how the call arrived.
|
# log lines below still name the channel.
|
||||||
effective_talkgroup_name = talkgroup_name or call_doc.get("talkgroup_name")
|
effective_talkgroup_name = talkgroup_name or call_doc.get("talkgroup_name")
|
||||||
is_dispatch = _is_dispatch_channel(effective_talkgroup_name)
|
|
||||||
if effective_talkgroup_name != talkgroup_name:
|
if effective_talkgroup_name != talkgroup_name:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator: talkgroup_name missing from request for call {call_id}, "
|
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 = [
|
tg_matches = [
|
||||||
@@ -1024,10 +1009,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
# single-candidate requirement and no fit test of any kind. Four
|
# single-candidate requirement and no fit test of any kind. Four
|
||||||
# hours is not a bound, and neither is ninety minutes.
|
# hours is not a bound, and neither is ninety minutes.
|
||||||
THIN_CONVERSATIONAL_SECS = 30
|
THIN_CONVERSATIONAL_SECS = 30
|
||||||
thin_window_min = (
|
thin_window_min = settings.tg_dispatch_thin_idle_minutes
|
||||||
settings.tg_dispatch_thin_idle_minutes if is_dispatch
|
|
||||||
else settings.tg_thin_idle_minutes
|
|
||||||
)
|
|
||||||
very_recent = [
|
very_recent = [
|
||||||
inc for inc in tg_recent
|
inc for inc in tg_recent
|
||||||
if _idle_gate_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
if _idle_gate_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
||||||
@@ -1049,8 +1031,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
if len(thin_pool) > 1:
|
if len(thin_pool) > 1:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator fast-path thin (tier-2): {len(thin_pool)} active incidents "
|
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 = []
|
thin_pool = []
|
||||||
|
|
||||||
@@ -1080,14 +1061,14 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
candidate = tg_recent[0]
|
candidate = tg_recent[0]
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator fast/single: call {call_id} vs incident {candidate['incident_id']} "
|
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"idle={round(_incident_idle_minutes(candidate, now), 1)}min "
|
||||||
f"call_units={call_units} inc_units={candidate.get('units')} "
|
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'}"
|
f"call_coords={'yes' if coords else 'no'} inc_coords={'yes' if candidate.get('location_coords') else 'no'}"
|
||||||
)
|
)
|
||||||
fit, fit_signal = _call_fits_incident(
|
fit, fit_signal = _call_fits_incident(
|
||||||
candidate, call_units, call_vehicles, coords,
|
candidate, call_units, call_vehicles, coords,
|
||||||
settings.location_proximity_km, is_dispatch=is_dispatch,
|
settings.location_proximity_km,
|
||||||
call_embedding=call_embedding, now=now,
|
call_embedding=call_embedding, now=now,
|
||||||
reassignment=reassignment,
|
reassignment=reassignment,
|
||||||
)
|
)
|
||||||
@@ -1097,13 +1078,12 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
"corr_path": "fast/single",
|
"corr_path": "fast/single",
|
||||||
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
|
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
|
||||||
"corr_fit_signal": fit_signal,
|
"corr_fit_signal": fit_signal,
|
||||||
"corr_is_dispatch": is_dispatch,
|
|
||||||
}
|
}
|
||||||
if fit_signal == "unit_overlap" and call_units:
|
if fit_signal == "unit_overlap" and call_units:
|
||||||
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
|
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator fast-path: call {call_id} → {candidate['incident_id']} "
|
f"Correlator fast-path: call {call_id} → {candidate['incident_id']} "
|
||||||
f"(signal={fit_signal}, is_dispatch={is_dispatch})"
|
f"(signal={fit_signal})"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1119,14 +1099,14 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
# dispatch channel should create its own incident, not be force-merged.
|
# dispatch channel should create its own incident, not be force-merged.
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator fast/disambig: call {call_id} vs incident {candidate['incident_id']} "
|
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"idle={round(_incident_idle_minutes(candidate, now), 1)}min "
|
||||||
f"call_units={call_units} inc_units={candidate.get('units')} "
|
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'}"
|
f"call_coords={'yes' if coords else 'no'} inc_coords={'yes' if candidate.get('location_coords') else 'no'}"
|
||||||
)
|
)
|
||||||
fit, fit_signal = _call_fits_incident(
|
fit, fit_signal = _call_fits_incident(
|
||||||
candidate, call_units, call_vehicles, coords,
|
candidate, call_units, call_vehicles, coords,
|
||||||
settings.location_proximity_km, is_dispatch=is_dispatch,
|
settings.location_proximity_km,
|
||||||
call_embedding=call_embedding, now=now,
|
call_embedding=call_embedding, now=now,
|
||||||
reassignment=reassignment,
|
reassignment=reassignment,
|
||||||
)
|
)
|
||||||
@@ -1137,7 +1117,6 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
|
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
|
||||||
"corr_candidates": len(tg_recent),
|
"corr_candidates": len(tg_recent),
|
||||||
"corr_fit_signal": fit_signal,
|
"corr_fit_signal": fit_signal,
|
||||||
"corr_is_dispatch": is_dispatch,
|
|
||||||
}
|
}
|
||||||
if fit_signal == "unit_overlap" and call_units:
|
if fit_signal == "unit_overlap" and call_units:
|
||||||
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
|
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
|
||||||
@@ -1716,7 +1695,6 @@ def _call_fits_incident(
|
|||||||
call_vehicles: list[str],
|
call_vehicles: list[str],
|
||||||
call_coords: Optional[dict],
|
call_coords: Optional[dict],
|
||||||
proximity_km: float,
|
proximity_km: float,
|
||||||
is_dispatch: bool = False,
|
|
||||||
call_embedding: Optional[list] = None,
|
call_embedding: Optional[list] = None,
|
||||||
now: Optional[datetime] = None,
|
now: Optional[datetime] = None,
|
||||||
reassignment: bool = False,
|
reassignment: bool = False,
|
||||||
@@ -1726,48 +1704,24 @@ def _call_fits_incident(
|
|||||||
the incident; signal names the specific evidence that drove the decision.
|
the incident; signal names the specific evidence that drove the decision.
|
||||||
|
|
||||||
fits=True signals: "unit_overlap" | "vehicle_overlap" | "location_proximity"
|
fits=True signals: "unit_overlap" | "vehicle_overlap" | "location_proximity"
|
||||||
| "time_fallback" | "tactical_default"
|
|
||||||
fits=False signals: "unit_loc_conflict" | "content_divergence"
|
fits=False signals: "unit_loc_conflict" | "content_divergence"
|
||||||
| "location_conflict" | "no_signal" | "tactical_idle"
|
| "location_conflict" | "no_signal"
|
||||||
|
|
||||||
Original docstring (logic unchanged):
|
Evaluation order:
|
||||||
Return True if this call plausibly belongs to the given incident.
|
1. Unit overlap. Same officer = same call. Also runs a location-conflict
|
||||||
|
guard: geocoded on both sides and clearly different → the officer has
|
||||||
Evaluation order for dispatch channels (is_dispatch=True):
|
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)
|
||||||
1. Unit overlap
|
→ content divergence as a location proxy (embedding similarity < 0.82
|
||||||
Same officer = same call. On dispatch channels, also run a location
|
→ different scene). Skipped for recent incidents (< 15 min) — an
|
||||||
conflict guard: if both sides carry geocoded coords and they differ
|
update without re-stating the address is normal.
|
||||||
significantly, the officer has moved to a new scene and the unit match
|
2. Vehicle overlap → True.
|
||||||
is a false positive.
|
3. Location proximity. Both geocoded and close → True; far apart with no
|
||||||
When the call has NO geocoded coordinates AND the incident is old
|
other positive signal → False.
|
||||||
(≥ 15 min), use content divergence as a location proxy: an officer at
|
4. No positive signal at all → False. A shared channel must not absorb
|
||||||
a genuinely different scene will be talking about clearly different
|
calls by default (server-26#134 — this used to default True within
|
||||||
things. For recent incidents (< 15 min) we skip this proxy — the
|
20 min on any channel not name-matched as "dispatch"; a talkgroup
|
||||||
officer may simply be giving an update without mentioning the address.
|
named tac/tactical is no less scrutinized in practice than any other).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Thin calls (no units/vehicles/coords) never reach this function —
|
Thin calls (no units/vehicles/coords) never reach this function —
|
||||||
they are intercepted before it in correlate_call.
|
they are intercepted before it in correlate_call.
|
||||||
@@ -1776,7 +1730,7 @@ def _call_fits_incident(
|
|||||||
# signed value: the re-correlation sweep anchors `now` to the call's own
|
# signed value: the re-correlation sweep anchors `now` to the call's own
|
||||||
# started_at, which can be earlier than the incident's last activity and
|
# started_at, which can be earlier than the incident's last activity and
|
||||||
# send the signed value negative — silently defeating every `idle_min`
|
# send the signed value negative — silently defeating every `idle_min`
|
||||||
# gate below (content-divergence veto and the tactical default alike).
|
# gate below, the content-divergence veto included.
|
||||||
# See `_idle_gate_minutes` docstring. The signed value is reported to
|
# See `_idle_gate_minutes` docstring. The signed value is reported to
|
||||||
# callers separately as `corr_incident_idle_min` (they compute it via
|
# callers separately as `corr_incident_idle_min` (they compute it via
|
||||||
# `_incident_idle_minutes` themselves) — nothing here needs it.
|
# `_incident_idle_minutes` themselves) — nothing here needs it.
|
||||||
@@ -1787,44 +1741,43 @@ def _call_fits_incident(
|
|||||||
inc_units = _unit_keys(inc.get("units"))
|
inc_units = _unit_keys(inc.get("units"))
|
||||||
matched_units = _matching_units(call_units, inc.get("units"))
|
matched_units = _matching_units(call_units, inc.get("units"))
|
||||||
if matched_units:
|
if matched_units:
|
||||||
if is_dispatch:
|
if call_coords:
|
||||||
if call_coords:
|
# Hard location conflict: geocoded on both sides and clearly different.
|
||||||
# Hard location conflict: geocoded on both sides and clearly different.
|
inc_coords_u = inc.get("location_coords")
|
||||||
inc_coords_u = inc.get("location_coords")
|
if inc_coords_u:
|
||||||
if inc_coords_u:
|
dist_km = _haversine_km(
|
||||||
dist_km = _haversine_km(
|
call_coords["lat"], call_coords["lng"],
|
||||||
call_coords["lat"], call_coords["lng"],
|
inc_coords_u["lat"], inc_coords_u["lng"],
|
||||||
inc_coords_u["lat"], inc_coords_u["lng"],
|
)
|
||||||
)
|
if dist_km > proximity_km:
|
||||||
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")
|
||||||
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"
|
||||||
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"
|
|
||||||
elif call_embedding and idle_min >= 15:
|
elif call_embedding and idle_min >= 15:
|
||||||
# No geocode available AND old incident: use content divergence as a
|
# Call has geocode but incident doesn't — fall back to content
|
||||||
# location-proxy veto. After 15+ minutes an officer at a completely
|
# divergence as a location proxy. Without this, stale incidents
|
||||||
# different scene will be discussing clearly different content.
|
# that never geocoded absorb unrelated calls purely on unit
|
||||||
# Skip this for recent incidents — an officer updating on the same
|
# overlap (e.g. a patrol officer working a second scene 70 min
|
||||||
# scene without re-stating the address is normal and their update
|
# after the original call).
|
||||||
# won't share much vocabulary with the original dispatch.
|
|
||||||
inc_emb_u = inc.get("embedding")
|
inc_emb_u = inc.get("embedding")
|
||||||
if inc_emb_u:
|
if inc_emb_u:
|
||||||
sim = _cosine_similarity(call_embedding, inc_emb_u)
|
sim = _cosine_similarity(call_embedding, inc_emb_u)
|
||||||
if sim < 0.82:
|
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"
|
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"
|
return True, "unit_overlap"
|
||||||
|
|
||||||
# ── 2. Vehicle overlap ────────────────────────────────────────────────────
|
# ── 2. Vehicle overlap ────────────────────────────────────────────────────
|
||||||
@@ -1878,29 +1831,19 @@ def _call_fits_incident(
|
|||||||
return False, "location_conflict"
|
return False, "location_conflict"
|
||||||
|
|
||||||
# ── 4. No positive signals ────────────────────────────────────────────────
|
# ── 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(
|
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_units={list(inc_units)} call_units={call_units} "
|
||||||
f"inc_vehicles={list(inc_vehicles)} call_vehicles={call_vehicles} "
|
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}"
|
f"call_coords={call_coords is not None} inc_coords={inc_coords is not None}"
|
||||||
)
|
)
|
||||||
if is_dispatch:
|
return False, "no_signal"
|
||||||
# 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"
|
|
||||||
|
|
||||||
|
|
||||||
async def _update_incident(
|
async def _update_incident(
|
||||||
|
|||||||
@@ -111,55 +111,17 @@ 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 a recent incident is running on this call's own system +
|
||||||
call's own system + talkgroup AND was active within the last
|
talkgroup, within `settings.tg_dispatch_thin_idle_minutes` (5 min) —
|
||||||
`settings.tg_dispatch_thin_idle_minutes` minutes. Covers the "unit
|
applied uniformly regardless of the talkgroup's name (server-26#134).
|
||||||
dispatched on the dispatch channel, thin acknowledgement 10-30s later"
|
Covers "unit dispatched, thin ack 10-30s later": the ack has no
|
||||||
case: the ack carries no substance of its own but plainly belongs to the
|
substance of its own but plainly belongs to the job just opened.
|
||||||
job just opened.
|
|
||||||
|
|
||||||
Always uses `settings.tg_dispatch_thin_idle_minutes` (5 min), regardless
|
Reads ctx["recent"] (the rules engine's own candidate list — no extra
|
||||||
of what the talkgroup is named. An earlier version of this branched on
|
Firestore read). That list is status=="active" incidents only, so an
|
||||||
`_is_dispatch_channel` (mirroring incident_correlator.py's fast/thin idle-
|
already-resolved or capacity-capped same-talkgroup incident won't be
|
||||||
window selection) to use a longer 15-minute window on anything not
|
seen here even if chronologically recent (server-26#115, unresolved —
|
||||||
literally named "dispatch"/"patched"/"primary" — owner correction,
|
would need a dedicated non-status-filtered query).
|
||||||
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.
|
|
||||||
|
|
||||||
Whether this limitation explains the 2/24 unexplained gate misses in the
|
Whether this limitation explains the 2/24 unexplained gate misses in the
|
||||||
window #3 measurement is UNANSWERED, not confirmed either way — a prior
|
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)
|
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
|
# TG 383 from the dump: "Ch 1 (Patched with 155.310)", a shared dispatch
|
||||||
# "patched", so this is a shared dispatch backbone carrying the whole department.
|
# 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)"
|
DISPATCH_TG = "Ch 1 (Patched with 155.310)"
|
||||||
TACTICAL_TG = "Fireground 2"
|
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"
|
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():
|
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
|
||||||
The widest version of the bug: non-dispatch talkgroups skipped the tiering
|
assertion as the dispatch-named case above, different fixture name."""
|
||||||
entirely and used the whole 90-minute fast-path window with no
|
|
||||||
single-candidate requirement, so ANY thin call joined whatever was newest.
|
|
||||||
"""
|
|
||||||
inc = _incident(idle_minutes=40)
|
inc = _incident(idle_minutes=40)
|
||||||
decision = _run_decision(_ctx(
|
decision = _run_decision(_ctx(
|
||||||
all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG,
|
all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG,
|
||||||
@@ -153,9 +152,18 @@ def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_channel():
|
|||||||
assert decision["action"] == "orphan"
|
assert decision["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
def test_tactical_thin_call_still_attaches_inside_its_own_window():
|
def test_tactical_named_channel_uses_the_dispatch_window_now():
|
||||||
"""Bounded, not removed — a "10-4" on a working channel is still context."""
|
"""server-26#134: 14 min was inside the old 15-min tactical window; now
|
||||||
inc = _incident(idle_minutes=settings.tg_thin_idle_minutes - 1)
|
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,
|
||||||
|
))
|
||||||
|
assert decision["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tactical_named_channel_still_attaches_inside_the_dispatch_window():
|
||||||
|
inc = _incident(idle_minutes=settings.tg_dispatch_thin_idle_minutes - 1)
|
||||||
decision = _run_decision(_ctx(
|
decision = _run_decision(_ctx(
|
||||||
all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG,
|
all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG,
|
||||||
))
|
))
|
||||||
@@ -224,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):
|
def test_back_dated_call_does_not_bypass_the_content_divergence_veto(monkeypatch):
|
||||||
"""
|
"""
|
||||||
Same `9d376ffe` failure mode, but exercised directly against
|
Same `9d376ffe` failure mode, exercised directly against
|
||||||
`_call_fits_incident` on a dispatch channel: unit overlap plus a
|
`_call_fits_incident`: unit overlap plus a back-dated call (incident
|
||||||
back-dated call (incident updated 45 minutes AFTER the call's own
|
updated 45 minutes AFTER the call's own `started_at`, which the sweep
|
||||||
`started_at`, which the sweep passes as `now`) used to make the signed
|
passes as `now`) used to make the signed idle -45, so `idle_min >= 15`
|
||||||
idle -45, so `idle_min >= 15` read False and the content-divergence
|
read False and the content-divergence veto never ran — unit overlap
|
||||||
veto never ran — unit overlap alone forced the merge regardless of
|
alone forced the merge regardless of what the call was actually about.
|
||||||
what the call was actually about. With the gate fixed to compare
|
With the gate fixed to compare distance, idle_min is 45 (>= 15), the
|
||||||
distance, idle_min is 45 (>= 15), the veto runs, and a divergent
|
veto runs, and a divergent embedding (patched below so the assertion
|
||||||
embedding (patched below so the assertion doesn't depend on numpy
|
doesn't depend on numpy being installed in this environment) fails it.
|
||||||
being installed in this environment) fails it.
|
|
||||||
"""
|
"""
|
||||||
monkeypatch.setattr(correlator_mod, "_cosine_similarity", lambda a, b: 0.0)
|
monkeypatch.setattr(correlator_mod, "_cosine_similarity", lambda a, b: 0.0)
|
||||||
inc = _incident(idle_minutes=-45, units=["6-Adam"])
|
inc = _incident(idle_minutes=-45, units=["6-Adam"])
|
||||||
inc["embedding"] = [1.0, 0.0]
|
inc["embedding"] = [1.0, 0.0]
|
||||||
fits, signal = _call_fits_incident(
|
fits, signal = _call_fits_incident(
|
||||||
inc, call_units=["6-Adam"], call_vehicles=[], call_coords=None,
|
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,
|
call_embedding=[0.0, 1.0], now=NOW,
|
||||||
)
|
)
|
||||||
assert (fits, signal) == (False, "content_divergence")
|
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
|
# 4. Hard caps — path-independent, because pairwise fit tests can't see shape
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user