Stop thin calls fusing a work shift into one incident (server-26#22)
The 2026-08-20 production dump had 4 of 6 sampled incidents as junk chains,
the worst being f5190670: 68 calls over 4h09m, 44 units, 12 tags, at least
13 genuinely distinct events. 58 of 133 linked calls took the fast/thin
path, which is the one path that attaches a call with no fit test at all.
Three defects combined to produce that, and all three are fixed here.
1. What counted as thin was wrong.
is_thin_call was "not units and not vehicles and not coords". A real
dispatch qualified as thin whenever no unit ID parsed and the geocode
failed - six of them did in that dump, including "All units head over to
the powerhouse, 55 Hyman Hills Road ... she's 87 years old", a brand new
job that attached to the four-hour chain and then overwrote its location
and its title. A call is now substantive if it carries tags, a location
string, a severity above routine, or is a reassignment; only genuinely
content-free housekeeping ("10-4", "Copy") stays thin. Those calls now go
through _call_fits_incident like everything else, which on a dispatch
backbone with no positive signal means they open their own incident or
orphan rather than merging.
The reassignment clause closes a self-defeating guard: upload.py blanks
units when dispatch pulls a unit onto a NEW job, specifically to stop
unit-overlap chaining - and blanking units made the call thin, routing it
to the only path with no fit check. The guard produced the merge it
existed to prevent.
2. The thin path was bounded on dispatch channels only.
Every other talkgroup fell through to "thin_pool = tg_recent": any
incident idle up to tg_fast_path_idle_minutes (90), no single-candidate
requirement, no fit test. The 30-second tier-1 / single-candidate tier-2
structure now applies to all channels. Non-dispatch gets its own window,
TG_THIN_IDLE_MINUTES=15, rather than sharing the dispatch value: a
tactical channel really is dedicated to one scene so it earns longer, but
15 sits inside the 20-minute tactical-default window already used in
_call_fits_incident, so the no-evidence path is never more permissive than
the fit-tested path on the same channel.
Recency gates now compare the magnitude of the idle, not the signed value.
The re-correlation sweep anchors "now" to the call's own started_at, so
idle goes negative routinely - incident 9d376ffe recorded
corr_incident_idle_min: -4.1 - and every "idle <= window" test in this
module reads True for a negative number. Those gates had silently stopped
bounding anything for exactly the calls the sweep re-examines.
3. Nothing capped an incident's total size.
Every fit test in the correlator is pairwise: does this call belong with
that incident. Each of f5190670's 68 links was individually arguable; the
mistake was the accumulated shape, which no pairwise rule can see. Two
hard caps now remove an incident from the candidate pool entirely, before
any path can choose it - including the LLM tier, which reads the same
ctx lists.
INCIDENT_MAX_DURATION_MINUTES=120. The one incident in that dump that was
genuinely a single event ran 63 minutes (06:15 wrong-way driver to 07:18
closeout), so the cap has to clear an hour with real headroom. The four
junk chains ran 3h41m, 3h43m, 4h05m and 4h09m, so it has to sit well under
three hours. 120 also equals correlation_window_hours: the location and
slow paths already refuse a candidate older than that, and the fast path
was the only one exempt, so this removes an inconsistency rather than
inventing a number.
INCIDENT_MAX_CALLS=40. A backstop for a burst that fills up inside the
duration cap, not the primary bound. The worst chain averaged ~16
calls/hour while absorbing an entire dispatch backbone, so 40 calls in
under two hours means one incident is eating most of the channel. Set
deliberately above any plausible single-incident call volume (a
multi-alarm fire on its own tactical channel) so this cap errs toward
keeping real incidents whole and lets the duration cap do the cutting.
Capping is not truncation: the incident keeps every call it has and still
auto-resolves on the normal idle sweep. It just stops being a candidate.
Every ambiguous call here was resolved toward a separate incident rather
than a merge. A wrongly-separate incident is visibly wrong and can be
merged later; a wrongly-merged one silently corrupts every unit, tag,
severity and map pin on the incident it joined, and poisons the AI
summary written from them. The cost is some acknowledgements orphaning
instead of riding along on an incident, which is a small, visible loss.
Deliberately NOT changed, since both push toward more merging while the
current failure mode is entirely over-merging (every incident in the dump
has exactly one "new" call; there is no over-splitting left to trade
against):
- unit-overlap positive feedback on shared dispatch channels, which is
now bounded by the caps rather than fixed at its root
- the sweep retry budget expiring before the target incident exists
Tests: 31 new cases in tests/test_correlator_merge_caps.py, including a
replay of the f5190670 night - 13 unrelated jobs at their real offsets,
plus roster unit traffic and acknowledgements every two minutes. Without
the caps that traffic still builds a 125-call incident spanning 244
minutes; with the old thinness test on top, 153 calls over 247 minutes in
3 incidents. With this commit it is 13 incidents, largest 40 calls over 80
minutes. Each new case was checked to fail when the behaviour it covers is
reverted. Suite: 138 passed.
No AI feature flag was touched; correlation stays off in production.
Closes logan/server-26#22
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
baa9d1811f
commit
33a247d306
@@ -65,6 +65,41 @@ class Settings(BaseSettings):
|
||||
# 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.
|
||||
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
|
||||
# tests judge one call against one incident and cannot see the shape of the
|
||||
# chain they are building, so these are the only guard against a "work shift"
|
||||
# incident regardless of how individually plausible each link looked.
|
||||
#
|
||||
# 120 minutes: the one incident in the 2026-08-20 dump that was genuinely a
|
||||
# single event ran 63 minutes (06:15 wrong-way driver → 07:18 closeout), so
|
||||
# the cap has to clear an hour with real headroom. The four junk chains ran
|
||||
# 3h41m, 3h43m, 4h05m and 4h09m, so it has to sit well under three hours.
|
||||
# 120 also equals correlation_window_hours: the location and slow paths
|
||||
# already refuse to consider a candidate older than that, and the fast path
|
||||
# was the only one exempt. Making it agree removes that inconsistency rather
|
||||
# than inventing a new number.
|
||||
incident_max_duration_minutes: int = 120
|
||||
# 40 calls: a backstop for a burst that fills up inside the duration cap
|
||||
# rather than the primary bound. The worst observed chain averaged ~16
|
||||
# calls/hour while absorbing an ENTIRE dispatch backbone, so 40 calls in
|
||||
# under two hours means the incident is eating most of the channel — that is
|
||||
# a chain, not an event. Set deliberately above any plausible single-incident
|
||||
# call volume (a multi-alarm fire on its own tactical channel) so this cap
|
||||
# errs toward keeping real incidents whole and lets the duration cap do the
|
||||
# cutting.
|
||||
incident_max_calls: int = 40
|
||||
|
||||
# Vocabulary learning
|
||||
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
|
||||
|
||||
@@ -21,9 +21,16 @@ Matching priority (in order):
|
||||
or location proximity) to link. Without this, every ungeocoded call on a
|
||||
dispatch backbone would link to the one active incident on that channel.
|
||||
|
||||
Thin calls (no units/vehicles/coords) skip scene verification and link to the
|
||||
most recently updated incident on this TGID — but only if that incident is
|
||||
within the recency window.
|
||||
Thin calls — genuinely content-free housekeeping, see `_is_thin_call` — skip
|
||||
scene verification and link to the most recently updated incident on this
|
||||
TGID, but only inside a tight conversational window (30s any-candidate, then
|
||||
single-candidate up to the channel's thin window). They are the one class of
|
||||
call that links without a fit test, so that window is the whole guard.
|
||||
|
||||
0. Hard caps (`_incident_at_capacity`) — an incident past
|
||||
`incident_max_duration_minutes` or `incident_max_calls` stops being a
|
||||
candidate on every path below, including the LLM tier. Pairwise fit tests
|
||||
cannot see the shape of a chain; only a cap can.
|
||||
|
||||
2. Location path — geocoded coords within `location_proximity_km` (time-limited)
|
||||
Primary mutual-aid signal: EMS + police at the same scene.
|
||||
@@ -253,6 +260,153 @@ def _incident_idle_minutes(inc: dict, now: datetime) -> float:
|
||||
return 9999.0
|
||||
|
||||
|
||||
def _idle_gate_minutes(inc: dict, now: datetime) -> float:
|
||||
"""
|
||||
Absolute distance in minutes between `now` and the incident's last activity —
|
||||
the value every recency gate must compare against.
|
||||
|
||||
`_incident_idle_minutes` is signed and can go NEGATIVE: the re-correlation
|
||||
sweep anchors `now` to the call's own `started_at`, so a back-dated call is
|
||||
routinely evaluated against an incident that was updated later. Observed
|
||||
2026-08-20 on incident `9d376ffe`: `corr_incident_idle_min: -4.1`. Every
|
||||
`idle <= window` gate in this module reads True for a negative number, so
|
||||
those gates silently stopped bounding anything for exactly the calls the
|
||||
sweep re-examines. Distance in either direction is what the gates actually
|
||||
mean, so they compare against the magnitude and the signed value is kept
|
||||
only for the debug field.
|
||||
"""
|
||||
return abs(_incident_idle_minutes(inc, now))
|
||||
|
||||
|
||||
def _incident_span_minutes(inc: dict, now: datetime) -> float:
|
||||
"""
|
||||
Wall-clock minutes the incident has been open: from its `started_at` to the
|
||||
later of `now` and its last update. The `max` matters because the
|
||||
re-correlation sweep passes a back-dated `now` (the call's own started_at),
|
||||
and a raw `now - started_at` would understate — or invert — the span for
|
||||
exactly the calls most likely to be force-attached to an old chain.
|
||||
|
||||
Returns 0.0 when `started_at` is unparseable, so a malformed timestamp can
|
||||
never be the sole reason an incident is capped.
|
||||
"""
|
||||
try:
|
||||
raw = inc.get("started_at") or ""
|
||||
started = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||
if started.tzinfo is None:
|
||||
started = started.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return 0.0
|
||||
end = now
|
||||
try:
|
||||
raw_u = inc.get("updated_at") or ""
|
||||
updated = datetime.fromisoformat(str(raw_u).replace("Z", "+00:00"))
|
||||
if updated.tzinfo is None:
|
||||
updated = updated.replace(tzinfo=timezone.utc)
|
||||
# The sweep evaluates old calls against incidents that have since moved
|
||||
# on, so `now` alone understates the span. Take whichever is later.
|
||||
end = max(now, updated)
|
||||
except Exception:
|
||||
pass
|
||||
return max((end - started).total_seconds() / 60, 0.0)
|
||||
|
||||
|
||||
def _incident_at_capacity(inc: dict, now: datetime) -> Optional[str]:
|
||||
"""
|
||||
Return a reason string when this incident has grown past the hard caps and
|
||||
must not absorb any more calls, or None when it is still open for business.
|
||||
|
||||
These caps are deliberately path-independent. Every fit test in this module
|
||||
is a *pairwise* judgement — does THIS call belong with THAT incident — and
|
||||
each one can be individually defensible while the chain they build is not.
|
||||
`f5190670` (2026-08-20 dump) is the proof: 68 calls, 4h09m, 44 units, 12
|
||||
tags and at least 13 genuinely distinct events, every link arrived at one
|
||||
call at a time. No pairwise rule catches that, because the mistake is the
|
||||
accumulated shape, not any single link. A cap is the only guard that can
|
||||
see the shape, so it is applied once, to the candidate pool itself, before
|
||||
any path gets to choose — which is also why it binds the LLM tier, since
|
||||
that reads the same ctx["recent"] / ctx["all_active"] lists.
|
||||
|
||||
Capping does not delete or truncate anything: the incident keeps the calls
|
||||
it has and still auto-resolves on the normal idle sweep. It just stops
|
||||
being a candidate, so the next call opens a fresh incident.
|
||||
"""
|
||||
call_count = len(inc.get("call_ids") or [])
|
||||
if call_count >= settings.incident_max_calls:
|
||||
return f"call_cap:{call_count}"
|
||||
span = _incident_span_minutes(inc, now)
|
||||
if span > settings.incident_max_duration_minutes:
|
||||
return f"duration_cap:{span:.0f}min"
|
||||
return None
|
||||
|
||||
|
||||
def _drop_capped(incidents: list[dict], now: datetime) -> list[dict]:
|
||||
"""Remove over-cap incidents from a candidate pool (see _incident_at_capacity)."""
|
||||
kept: list[dict] = []
|
||||
for inc in incidents:
|
||||
reason = _incident_at_capacity(inc, now)
|
||||
if reason:
|
||||
logger.info(
|
||||
f"Correlator: incident {inc.get('incident_id', '?')} is at capacity "
|
||||
f"({reason}) — excluded as a correlation candidate"
|
||||
)
|
||||
continue
|
||||
kept.append(inc)
|
||||
return kept
|
||||
|
||||
|
||||
def _is_thin_call(
|
||||
call_units: list,
|
||||
call_vehicles: list,
|
||||
coords: Optional[dict],
|
||||
tags: Optional[list],
|
||||
location: Optional[str],
|
||||
call_severity: Optional[str],
|
||||
reassignment: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
True only for genuinely content-free radio housekeeping — "10-4", "Copy",
|
||||
"En route" — the traffic that legitimately has no evidence of its own and
|
||||
can only be placed by conversational context.
|
||||
|
||||
This test used to be `not units and not vehicles and not coords`, which is
|
||||
where the 2026-08-20 junk chains came from. Thin calls are the ONE class
|
||||
that attaches without a `_call_fits_incident` check, so anything wrongly
|
||||
called thin is force-merged into whatever was most recent. Six substantive
|
||||
dispatches in that dump qualified as thin purely because no unit ID parsed
|
||||
and the geocode failed — including *"All units head over to the powerhouse,
|
||||
55 Hyman Hills Road … she's 87 years old"*, a brand-new job that attached to
|
||||
a four-hour chain and then overwrote its location and title.
|
||||
|
||||
So the test now also rejects a call as thin when it carries:
|
||||
• tags — the extractor classified the event, so there IS content
|
||||
• a location — the call names a place; that is a claim to be checked,
|
||||
not context-free chatter
|
||||
• real severity (minor/moderate/major) — the extractor judged it an event
|
||||
• reassignment — dispatch pulling a unit to a NEW job, by definition not
|
||||
a continuation. `upload.py` blanks `units` on these to
|
||||
stop unit-overlap chaining, which used to make the call
|
||||
thin and route it to the one path with no fit check —
|
||||
the guard produced the merge it existed to prevent.
|
||||
|
||||
Such calls now go through the normal fit-tested path. On a dispatch channel
|
||||
with no positive signal that means they open their own incident or orphan,
|
||||
which is the correct direction: a wrongly-separate incident is visibly
|
||||
wrong and can be merged later, a wrongly-merged one silently corrupts
|
||||
every unit, tag, severity and map pin on the incident it joined.
|
||||
"""
|
||||
if call_units or call_vehicles or coords:
|
||||
return False
|
||||
if tags:
|
||||
return False
|
||||
if location and str(location).strip():
|
||||
return False
|
||||
if call_severity in ("minor", "moderate", "major"):
|
||||
return False
|
||||
if reassignment:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -376,6 +530,10 @@ async def _build_context(
|
||||
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
|
||||
else:
|
||||
all_active = await fstore.collection_list("incidents", status="active")
|
||||
# Incidents past the hard caps are removed from the candidate pool here, so
|
||||
# neither the rules engine nor the LLM tier (which reads ctx["recent"] /
|
||||
# ctx["all_active"]) can propose linking into one.
|
||||
all_active = _drop_capped(all_active, now)
|
||||
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
|
||||
|
||||
call_embedding = call_doc.get("embedding")
|
||||
@@ -384,7 +542,9 @@ async def _build_context(
|
||||
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||
call_severity = call_doc.get("severity") or "routine"
|
||||
coords = location_coords or call_doc.get("location_coords")
|
||||
is_thin_call = not call_units and not call_vehicles and not coords
|
||||
is_thin_call = _is_thin_call(
|
||||
call_units, call_vehicles, coords, tags, location, call_severity, reassignment
|
||||
)
|
||||
|
||||
return {
|
||||
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
|
||||
@@ -415,8 +575,23 @@ def _run_decision(ctx: dict) -> dict:
|
||||
incident_type resolved type (action == "new")
|
||||
corr_debug fields to write to the call doc
|
||||
"""
|
||||
all_active = ctx["all_active"]
|
||||
recent = ctx["recent"]
|
||||
now = ctx["now"]
|
||||
# Hard caps, enforced regardless of which path would have matched. Normally
|
||||
# a no-op because _build_context already filtered these out; re-applied here
|
||||
# so the guarantee holds for any caller that assembles a ctx directly
|
||||
# (tests, the LLM tiebreak path) rather than through _build_context.
|
||||
_capped_ids = {
|
||||
inc.get("incident_id")
|
||||
for inc in (list(ctx["all_active"]) + list(ctx["recent"]))
|
||||
if _incident_at_capacity(inc, now)
|
||||
}
|
||||
if _capped_ids:
|
||||
logger.info(
|
||||
f"Correlator: {len(_capped_ids)} incident(s) at capacity, excluded as "
|
||||
f"candidates for call {ctx['call_id']}: {sorted(_capped_ids)}"
|
||||
)
|
||||
all_active = [inc for inc in ctx["all_active"] if inc.get("incident_id") not in _capped_ids]
|
||||
recent = [inc for inc in ctx["recent"] if inc.get("incident_id") not in _capped_ids]
|
||||
call_doc = ctx["call_doc"]
|
||||
call_embedding = ctx["call_embedding"]
|
||||
call_units = ctx["call_units"]
|
||||
@@ -424,7 +599,6 @@ def _run_decision(ctx: dict) -> dict:
|
||||
call_severity = ctx["call_severity"]
|
||||
coords = ctx["coords"]
|
||||
is_thin_call = ctx["is_thin_call"]
|
||||
now = ctx["now"]
|
||||
system_id = ctx["system_id"]
|
||||
talkgroup_id = ctx["talkgroup_id"]
|
||||
talkgroup_name = ctx["talkgroup_name"]
|
||||
@@ -469,7 +643,7 @@ def _run_decision(ctx: dict) -> dict:
|
||||
# Apply recency gate — only incidents active within the rolling window.
|
||||
tg_recent = [
|
||||
inc for inc in tg_matches
|
||||
if _incident_idle_minutes(inc, now) <= settings.tg_fast_path_idle_minutes
|
||||
if _idle_gate_minutes(inc, now) <= settings.tg_fast_path_idle_minutes
|
||||
]
|
||||
|
||||
if tg_matches and not tg_recent:
|
||||
@@ -479,43 +653,58 @@ def _run_decision(ctx: dict) -> dict:
|
||||
)
|
||||
|
||||
if tg_recent and is_thin_call:
|
||||
# Content-free status calls ("10-4", "Copy", "En route") — two tiers:
|
||||
# Content-free status calls ("10-4", "Copy", "En route") — the only
|
||||
# class of call that links without a `_call_fits_incident` check,
|
||||
# because by construction it has no unit, vehicle, coordinate, tag,
|
||||
# location or severity to test. There is therefore nothing to fit;
|
||||
# the only honest evidence is "someone just said something on this
|
||||
# channel and this is the reply". That is a claim about SECONDS,
|
||||
# so the window is the whole guard and it has to be tight.
|
||||
#
|
||||
# Tier 1 — ≤30 seconds idle: this is a direct conversational reply to
|
||||
# whatever was just transmitted. Attach to the most recently updated
|
||||
# incident regardless of how many are active; within 30 seconds, the
|
||||
# "most recently updated" IS the active thread.
|
||||
# Tier 1 — ≤30 seconds idle: a direct conversational reply. Attach
|
||||
# to the most recently updated incident regardless of how many are
|
||||
# active; within 30 seconds, "most recently updated" IS the live
|
||||
# thread on the channel.
|
||||
#
|
||||
# Tier 2 — 30 seconds to tg_dispatch_thin_idle_minutes: channel context
|
||||
# is less clear. Only attach when there is exactly ONE candidate to
|
||||
# avoid guessing on a busy multi-incident channel.
|
||||
if is_dispatch:
|
||||
THIN_CONVERSATIONAL_SECS = 30
|
||||
very_recent = [
|
||||
inc for inc in tg_recent
|
||||
if _incident_idle_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
||||
]
|
||||
if very_recent:
|
||||
# Tier 1: direct conversational reply — most recent wins.
|
||||
thin_pool = [max(very_recent, key=lambda inc: inc.get("updated_at", ""))]
|
||||
logger.info(
|
||||
f"Correlator fast-path thin (tier-1, ≤{THIN_CONVERSATIONAL_SECS}s): "
|
||||
f"using most-recent of {len(very_recent)} candidate(s) for call {call_id}"
|
||||
)
|
||||
else:
|
||||
# Tier 2: less certain — require single candidate.
|
||||
thin_pool = [
|
||||
inc for inc in tg_recent
|
||||
if _incident_idle_minutes(inc, now) <= settings.tg_dispatch_thin_idle_minutes
|
||||
]
|
||||
if len(thin_pool) > 1:
|
||||
logger.info(
|
||||
f"Correlator fast-path thin (tier-2): {len(thin_pool)} active incidents "
|
||||
f"on dispatch channel — ambiguous, skipping thin call {call_id}"
|
||||
)
|
||||
thin_pool = []
|
||||
# Tier 2 — 30 seconds up to the channel's thin window: context is
|
||||
# less clear. Only attach when there is exactly ONE candidate, so
|
||||
# we never guess on a busy multi-incident channel.
|
||||
#
|
||||
# This bounding used to apply to dispatch channels only; every other
|
||||
# talkgroup fell through to `thin_pool = tg_recent`, i.e. any
|
||||
# incident idle up to tg_fast_path_idle_minutes (90) with no
|
||||
# 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
|
||||
)
|
||||
very_recent = [
|
||||
inc for inc in tg_recent
|
||||
if _idle_gate_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
||||
]
|
||||
if very_recent:
|
||||
# Tier 1: direct conversational reply — most recent wins.
|
||||
thin_pool = [max(very_recent, key=lambda inc: inc.get("updated_at", ""))]
|
||||
logger.info(
|
||||
f"Correlator fast-path thin (tier-1, ≤{THIN_CONVERSATIONAL_SECS}s): "
|
||||
f"using most-recent of {len(very_recent)} candidate(s) for call {call_id}"
|
||||
)
|
||||
else:
|
||||
thin_pool = tg_recent
|
||||
# Tier 2: less certain — require a single candidate inside the
|
||||
# channel's thin window.
|
||||
thin_pool = [
|
||||
inc for inc in tg_recent
|
||||
if _idle_gate_minutes(inc, now) <= thin_window_min
|
||||
]
|
||||
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}"
|
||||
)
|
||||
thin_pool = []
|
||||
|
||||
if not thin_pool:
|
||||
logger.info(
|
||||
@@ -627,7 +816,7 @@ def _run_decision(ctx: dict) -> dict:
|
||||
# has almost certainly moved on or the incident closed.
|
||||
unit_candidates = [
|
||||
inc for inc in unit_candidates
|
||||
if _incident_idle_minutes(inc, now) <= settings.unit_continuity_max_idle_minutes
|
||||
if _idle_gate_minutes(inc, now) <= settings.unit_continuity_max_idle_minutes
|
||||
]
|
||||
if unit_candidates:
|
||||
best_unit_inc = max(unit_candidates, key=lambda i: i.get("updated_at", ""))
|
||||
|
||||
Reference in New Issue
Block a user