correlator: fix consensus orphan-gate to test call substance, not empty corr_debug (#115)
The gate added in ca1d8fb checked rules_decision["corr_debug"] for a positive
signal, but that dict is empty at preview time for action=="new" (corr_path is
written at apply time). The check was always False, so the gate fired on real
events — replayed against corr_dump_9-7_pm.json it dropped ~36 linked calls
including a major "extinguishing fire", a moderate fire-alarm, geocoded calls
and pursuit updates.
Gate now runs against ctx (fully populated at preview time). It fires ONLY when
the call is substanceless: routine severity, no vehicle/geocode/tag, and no
incident already running on the same talkgroup. Any of those escalates to the
tiebreak instead. The substance predicate (has_event_substance) is factored out
of incident_correlator's creation gate and shared, so the two cannot diverge.
recorrelation_sweep: a call the gate parked gets a longer link-only retry budget
(10 vs 3) — the gate fires before any incident for the job exists, so the
substantive call that justifies linking can land after the standard ~6 min.
Still create_if_new=False.
incident_correlator location path: evaluate every in-radius candidate and link
the nearest that carries corroboration, instead of the first in an unsorted
`recent`. A unit-overlap location link is now tagged "location_unit_overlap" so
it stops merging into the fast path's bucket in the admin fit-signal histogram.
tests/test_consensus_gate.py: replaced the corr_debug-signal cases with ctx
substance cases (severity, coords, tags, vehicles, same-tg incident); added a
nearest-wins location test; the two location guard tests now assert they reach
the new guard. Full drb-c2-core suite 322 -> 325.
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
ca1d8fbdae
commit
dd426572fc
@@ -100,30 +100,56 @@ async def upload_call_audio(
|
||||
return {"url": gcs_uri}
|
||||
|
||||
|
||||
# server-26#115 — a rules "new" only counts as a real "this is an event" verdict
|
||||
# when it carries one of these signals. A bare "new" (no link candidate found)
|
||||
# is trivially true for radio housekeeping (check-ins, roll call, 10-8/10-98) and
|
||||
# must not out-vote a cheap-LLM "orphan" that has actually read the transcript.
|
||||
_POSITIVE_CORR_PATHS = frozenset({
|
||||
"unit-continuity", "location", "fast/disambig", "fast/single",
|
||||
})
|
||||
_POSITIVE_FIT_SIGNALS = frozenset({"unit_overlap", "location_proximity"})
|
||||
# server-26#115 — the consensus LLM-orphan gate only fires when the call is
|
||||
# genuinely substanceless. The earlier version tested `rules_decision["corr_debug"]`
|
||||
# for a "positive signal", but corr_debug is EMPTY at preview time for
|
||||
# action=="new" (corr_path:"new" is written at APPLY time), so that test was
|
||||
# always False and the gate dropped real events — a major "extinguishing fire",
|
||||
# geocoded calls, pursuit updates. The substance test now runs against `ctx`,
|
||||
# which is fully populated at preview time.
|
||||
|
||||
|
||||
def _rules_has_positive_event_signal(rules_decision: dict) -> bool:
|
||||
def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
||||
"""
|
||||
True when the rules engine's decision carries a positive "this is an event"
|
||||
signal (unit overlap, location proximity, or a continuity/disambiguation
|
||||
path) rather than merely "no incident to link to".
|
||||
True when one of the already-loaded recent incidents is running on this
|
||||
call's own system + talkgroup. Covers the "unit dispatched on the dispatch
|
||||
channel, thin acknowledgement 10-30s later" case: the ack carries no
|
||||
substance of its own but plainly belongs to the job just opened.
|
||||
|
||||
Reads ctx["recent"] — the same window-filtered candidate list the rules
|
||||
engine already loaded — so this adds no Firestore read.
|
||||
"""
|
||||
dbg = rules_decision.get("corr_debug") or {}
|
||||
if dbg.get("corr_fit_signal") in _POSITIVE_FIT_SIGNALS:
|
||||
return True
|
||||
if dbg.get("corr_path") in _POSITIVE_CORR_PATHS:
|
||||
return True
|
||||
tg_id = ctx.get("talkgroup_id")
|
||||
system_id = ctx.get("system_id")
|
||||
if tg_id is None or not system_id:
|
||||
return False
|
||||
tg_str = str(tg_id)
|
||||
for inc in ctx.get("recent") or []:
|
||||
if system_id in (inc.get("system_ids") or []) and tg_str in (inc.get("talkgroup_ids") or []):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _call_is_substanceless(ctx: dict) -> bool:
|
||||
"""
|
||||
True when the call carries nothing that marks it as a real event:
|
||||
• 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.
|
||||
"""
|
||||
from app.internal import incident_correlator
|
||||
|
||||
if (ctx.get("call_severity") or "routine") in ("moderate", "major"):
|
||||
return False
|
||||
if incident_correlator.has_event_substance(ctx):
|
||||
return False
|
||||
if _recent_incident_on_same_talkgroup(ctx):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _correlate_with_consensus(
|
||||
call_id: str,
|
||||
node_id: str,
|
||||
@@ -176,22 +202,23 @@ async def _correlate_with_consensus(
|
||||
return await incident_correlator.apply_correlation(preview)
|
||||
|
||||
# server-26#115 — LLM-orphan gate.
|
||||
# When the cheap LLM says `orphan` and the rules engine says `new` with NO
|
||||
# positive event signal (i.e. rules only found "nothing to link to" — trivially
|
||||
# true for radio housekeeping), resolve to `orphan` and DO NOT pay for the
|
||||
# smart tiebreaker. The LLM has read the transcript; a bare rules `new` has
|
||||
# not, and the tiebreaker sided with rules ~21/21 of the time on exactly this
|
||||
# disagreement (CORRELATION_REVIEW_0907b.md). A genuine event the LLM misreads
|
||||
# as orphan still escalates, because the rules result then carries a real
|
||||
# signal (unit overlap, location proximity, unit-continuity / disambig).
|
||||
# When the cheap LLM says `orphan`, the rules engine says `new`, and the call
|
||||
# is genuinely substanceless (routine severity, no vehicle/geocode/tag, and
|
||||
# no incident already running on this talkgroup), resolve to `orphan` and DO
|
||||
# NOT pay for the smart tiebreaker. A bare rules `new` there means only
|
||||
# "nothing to link to" — trivially true for radio housekeeping (check-ins,
|
||||
# roll call, 10-8/10-98) — and the tiebreaker rubber-stamped it ~21/21 of the
|
||||
# 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 not _rules_has_positive_event_signal(rules_decision)
|
||||
and _call_is_substanceless(ctx)
|
||||
):
|
||||
logger.info(
|
||||
f"Consensus gate for call {call_id}: llm=orphan vs rules=new with no "
|
||||
f"positive rules signal — resolving orphan, skipping tiebreak"
|
||||
f"Consensus gate for call {call_id}: llm=orphan vs rules=new and call "
|
||||
f"is substanceless — resolving orphan, skipping tiebreak"
|
||||
)
|
||||
gated = {
|
||||
"action": "orphan",
|
||||
|
||||
Reference in New Issue
Block a user