correlator: mirror the dispatch/tactical idle split in the escape hatch, record the gate-veto reason (#115)
Review of #126 found: (1) the escape hatch applied tg_dispatch_thin_idle_minutes (5 min) unconditionally, but incident_correlator's own fast/thin path only uses that on dispatch channels and 15 min on tactical ones via _is_dispatch_channel -- mirrored the same selection here, plus a config.py note flagging the second consumer. (2) the docstring claimed a 'confirmed explanation' for 2 window-3 gate misses that was actually wrong (self-contradictory in its own text); replaced the guess with corr_gate_veto, written into corr_debug on every llm=orphan/rules=new disagreement that escalates, so window #4 can see *why* each one escaped instead of reconstructing it from the raw dump. 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
400b74b519
commit
598054746a
@@ -118,11 +118,14 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
||||
case: the ack carries no substance of its own but plainly belongs to the
|
||||
job just opened.
|
||||
|
||||
That window intentionally reuses `tg_dispatch_thin_idle_minutes` (5 min)
|
||||
rather than inventing a new constant — it's the same recency bound the
|
||||
fast/thin path already uses for this exact "dispatch, thin ack" scenario
|
||||
(see its tuning note above in config.py), so both places agree on what
|
||||
"just happened on this channel" means.
|
||||
The window mirrors whatever the fast/thin path would use for this same
|
||||
channel — `tg_dispatch_thin_idle_minutes` (5 min) on a dispatch backbone,
|
||||
`tg_thin_idle_minutes` (15 min) on a tactical/working channel, selected via
|
||||
the same `_is_dispatch_channel` test incident_correlator.py uses at its own
|
||||
fast/thin idle-window selection (~:1005-1007). Using the dispatch constant
|
||||
unconditionally would be wrong off dispatch — a retune of one for fast/thin
|
||||
reasons would then silently widen or narrow this gate too, on channels
|
||||
window #3 never measured.
|
||||
|
||||
This used to be a plain "does any recent incident exist on this
|
||||
talkgroup" check against a 2-hour window (`correlation_window_hours`).
|
||||
@@ -143,22 +146,20 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
||||
`_drop_capped` — not a full scan of the `incidents` collection. A
|
||||
same-talkgroup incident that has already auto-resolved (no longer
|
||||
"active") or hit `incident_max_calls`/`incident_max_duration_minutes`
|
||||
will NOT appear here even though it is chronologically recent. This is
|
||||
the confirmed explanation for 2/24 gate misses in the window #3
|
||||
measurement where a naive full-collection timestamp scan found no
|
||||
same-talkgroup candidate either once status/capacity are accounted for
|
||||
— i.e. the check here was already correct for those two calls; some
|
||||
other `_call_is_substanceless` condition (severity/substance/type) must
|
||||
have been true instead. A proper fix for the truncation case (an
|
||||
incident that WAS same-talkgroup-recent by clock time but is invisible
|
||||
here because it resolved or capped) needs a dedicated Firestore query
|
||||
that is not status/capacity filtered — a new read, out of scope for
|
||||
this pass.
|
||||
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.
|
||||
|
||||
This does NOT explain the 2/24 unexplained gate misses in the window #3
|
||||
measurement — re-review found a code-level explanation for both instead
|
||||
(see `_call_is_substanceless`'s `incident_type`/`reassignment` branch and
|
||||
the scene-level severity/tags fields the call-doc alone doesn't show), so
|
||||
this limitation is believed inactive so far, not a live loose end.
|
||||
# TODO(server-26#115): add a talkgroup-scoped incident lookup (any
|
||||
# status, no capacity filter) if this escape hatch ever needs to see
|
||||
# resolved/capped incidents rather than just the active candidate pool.
|
||||
# status, no capacity filter) if a future measurement window pins a real
|
||||
# gate miss on a resolved/capped same-talkgroup incident.
|
||||
"""
|
||||
from app.internal.incident_correlator import _idle_gate_minutes
|
||||
from app.internal.incident_correlator import _idle_gate_minutes, _is_dispatch_channel
|
||||
|
||||
tg_id = ctx.get("talkgroup_id")
|
||||
system_id = ctx.get("system_id")
|
||||
@@ -166,24 +167,39 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
||||
return False
|
||||
tg_str = str(tg_id)
|
||||
now = ctx.get("now") or datetime.now(timezone.utc)
|
||||
idle_limit = (
|
||||
settings.tg_dispatch_thin_idle_minutes
|
||||
if _is_dispatch_channel(ctx.get("talkgroup_name"))
|
||||
else settings.tg_thin_idle_minutes
|
||||
)
|
||||
for inc in ctx.get("recent") or []:
|
||||
if system_id not in (inc.get("system_ids") or []):
|
||||
continue
|
||||
if tg_str not in (inc.get("talkgroup_ids") or []):
|
||||
continue
|
||||
if _idle_gate_minutes(inc, now) <= settings.tg_dispatch_thin_idle_minutes:
|
||||
if _idle_gate_minutes(inc, now) <= idle_limit:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _call_is_substanceless(ctx: dict) -> bool:
|
||||
def _call_is_substanceless(ctx: dict) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
True when the call carries nothing that marks it as a real event:
|
||||
• no resolved incident_type and not a reassignment, AND
|
||||
• severity is not moderate/major, AND
|
||||
• no vehicle, geocode or tag (incident_correlator.has_event_substance —
|
||||
the same predicate the incident-creation gate uses), AND
|
||||
• no recent incident already running on the same talkgroup.
|
||||
Only then may the LLM-orphan gate drop the call without a tiebreak.
|
||||
|
||||
Returns (substanceless, veto_reason). veto_reason names whichever
|
||||
condition kept the tiebreak alive ("type" | "reassignment" | "severity" |
|
||||
"substance" | "recent_tg"), or None when the call is substanceless. The
|
||||
caller writes this into corr_debug on the escalation path so a live
|
||||
measurement window can see *why* each llm=orphan/rules=new call escaped
|
||||
the gate instead of inferring it after the fact from the raw dump —
|
||||
exactly the guesswork that produced a wrong "confirmed explanation" for
|
||||
2 window-#3 misses on the first pass of this fix.
|
||||
"""
|
||||
from app.internal import incident_correlator
|
||||
|
||||
@@ -193,15 +209,17 @@ def _call_is_substanceless(ctx: dict) -> bool:
|
||||
# re-check here. reassignment=True is dispatch pulling a unit onto a NEW
|
||||
# job (units are blanked at :296 for exactly that reason): the strongest
|
||||
# new-incident signal in the pipeline. Either one means "keep the tiebreak".
|
||||
if ctx.get("incident_type") or ctx.get("reassignment"):
|
||||
return False
|
||||
if ctx.get("incident_type"):
|
||||
return False, "type"
|
||||
if ctx.get("reassignment"):
|
||||
return False, "reassignment"
|
||||
if (ctx.get("call_severity") or "routine") in ("moderate", "major"):
|
||||
return False
|
||||
return False, "severity"
|
||||
if incident_correlator.has_event_substance(ctx):
|
||||
return False
|
||||
return False, "substance"
|
||||
if _recent_incident_on_same_talkgroup(ctx):
|
||||
return False
|
||||
return True
|
||||
return False, "recent_tg"
|
||||
return True, None
|
||||
|
||||
|
||||
async def _correlate_with_consensus(
|
||||
@@ -265,11 +283,9 @@ async def _correlate_with_consensus(
|
||||
# time on exactly this disagreement (CORRELATION_REVIEW_0907b.md). Any real
|
||||
# signal (severity, coords, tags, a live same-talkgroup incident) still
|
||||
# escalates, so an event the LLM misreads as orphan is not lost.
|
||||
if (
|
||||
llm_decision["action"] == "orphan"
|
||||
and rules_decision["action"] == "new"
|
||||
and _call_is_substanceless(ctx)
|
||||
):
|
||||
is_orphan_vs_new = llm_decision["action"] == "orphan" and rules_decision["action"] == "new"
|
||||
substanceless, gate_veto_reason = _call_is_substanceless(ctx) if is_orphan_vs_new else (False, None)
|
||||
if is_orphan_vs_new and substanceless:
|
||||
logger.info(
|
||||
f"Consensus gate for call {call_id}: llm=orphan vs rules=new and call "
|
||||
f"is substanceless — resolving orphan, skipping tiebreak"
|
||||
@@ -297,6 +313,12 @@ async def _correlate_with_consensus(
|
||||
final["corr_debug"]["corr_consensus"] = "tiebreak"
|
||||
final["corr_debug"]["corr_rules_action"] = rules_decision["action"]
|
||||
final["corr_debug"]["corr_llm_action"] = llm_decision["action"]
|
||||
if is_orphan_vs_new:
|
||||
# server-26#115 — record *why* the llm=orphan/rules=new gate stood
|
||||
# down instead of leaving a future measurement window to guess it
|
||||
# from the raw dump (which produced a wrong "confirmed explanation"
|
||||
# for 2/24 misses the first time around).
|
||||
final["corr_debug"]["corr_gate_veto"] = gate_veto_reason
|
||||
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user