correlation: rules decide, one cheap LLM call only when ambiguous; drop the consensus tiebreak (server-26#299)
The rules engine now commits every confident decision alone. _run_decision marks a decision ambiguous (new_with_candidates, unfit_tg_candidate, unverified_unit_link) and only then does upload._correlate make ONE llm_correlator.decide call; a usable answer is committed, otherwise the rules decision stands. Removed: _correlate_with_consensus, llm_correlator.tiebreak / decisions_agree / tiebreak prompt, corr_smart_model, the correlation_smart health tier, the LLM-orphan gate (_call_is_substanceless, effectively dead: it could only fire on minor-severity untyped calls) and its sweep budget. corr_consensus is now rules_only | llm | rules_fallback | rules_guard, with corr_band naming why; admin debug and replay metrics read the new values. Section fixes in the same pass: - #115: no tiebreaker left to manufacture incidents; an LLM orphan of a rules "new" is honoured only for untyped, substanceless, routine/minor calls. - #86: divergence veto bar moved to config (embedding_divergence_threshold); the band between it and the match bar is ambiguous, not a silent merge. - #103: a unit link with no embedding is "unit_overlap_unverified" -> ambiguous, instead of skipping the veto and linking as verified. - #189: unit-only and LLM links refused once an incident is open 90 min; unit links on incidents open >30 min are ambiguous; LLM links distance- guarded (incident_max_spread_km) and only to shown candidates. - #190: severity steps down on an explicit stand-down phrase; new severity_peak keeps the monotonic max. - #88: parse_llm_bool fails closed on "false"; scene closures honoured only on confident links or the incident the scene opened. - #27: sweep budget is the scan window (wall clock), not 3 attempts. - LLM prompt: "Reserve new" bullet no longer contradicts the #189 EXCEPT. - updated_at never moves backward on a back-dated link. Unmeasured: no live AI or replay (credits unfunded); band frequency, F1 and thresholds are unverified. Must be re-scored on both answer keys in Replay before merge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f4d78aba77
commit
3246fc97b0
@@ -100,7 +100,7 @@ async def _run_ingest(global_correlation, system_ai_flags):
|
||||
)
|
||||
with g, sysdoc, \
|
||||
patch.object(upload, "fstore") as fs, \
|
||||
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr, \
|
||||
patch.object(upload, "_correlate", AsyncMock(return_value=(None, {}))) as corr, \
|
||||
patch("app.internal.transcription.transcribe_call",
|
||||
AsyncMock(return_value=("units respond to main street", []))), \
|
||||
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])) as scenes, \
|
||||
|
||||
@@ -136,15 +136,15 @@ async def test_transient_failure_alerts_once_threshold_crossed():
|
||||
async def test_recovery_alerts_once_after_an_alerted_episode():
|
||||
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
|
||||
await ai_health.report_degraded(
|
||||
"correlation_smart", "gemini", "gemini-1.5-pro",
|
||||
"correlation_cheap", "gemini", "gemini-3.6-flash",
|
||||
"the Gemini account is out of credit", "top up billing", permanent=True,
|
||||
)
|
||||
webhook.reset_mock()
|
||||
|
||||
await ai_health.report_healthy("correlation_smart")
|
||||
await ai_health.report_healthy("correlation_cheap")
|
||||
|
||||
webhook.assert_awaited_once()
|
||||
state = ai_health.snapshot()["correlation_smart"]
|
||||
state = ai_health.snapshot()["correlation_cheap"]
|
||||
assert state["degraded"] is False
|
||||
assert state["alerted"] is False
|
||||
assert state["consecutive_failures"] == 0
|
||||
|
||||
@@ -153,8 +153,10 @@ def test_timeout_messages_classify_as_transient():
|
||||
|
||||
|
||||
def _corr_ctx():
|
||||
from datetime import datetime, timezone
|
||||
return {"call_id": "c1", "is_thin_call": False, "recent": [{"incident_id": "i1"}],
|
||||
"all_active": [], "call_units": ["12"], "call_cleared": [], "tags": ["mva"]}
|
||||
"all_active": [], "call_units": ["12"], "call_cleared": [], "tags": ["mva"],
|
||||
"now": datetime.now(timezone.utc)}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -163,27 +165,14 @@ async def test_gemini_timeout_in_decide_falls_back_to_rules():
|
||||
raise httpx.ReadTimeout("The read operation timed out")
|
||||
|
||||
with patch.object(settings, "gemini_api_key", "test-key"), \
|
||||
patch.object(llm_correlator, "_build_decide_prompt", lambda ctx: "p"), \
|
||||
patch.object(llm_correlator, "_build_decide_prompt", lambda ctx, cands: "p"), \
|
||||
patch.object(llm_correlator, "_sync_gemini", hang):
|
||||
assert await llm_correlator.decide("c1", _corr_ctx()) is None # None == rules_only
|
||||
assert await llm_correlator.decide("c1", _corr_ctx()) is None # None == keep the rules decision
|
||||
state = ai_health.snapshot()["correlation_cheap"]
|
||||
assert state["consecutive_failures"] == 1
|
||||
assert state["permanent"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_timeout_in_tiebreak_keeps_the_rules_decision():
|
||||
def hang(model, prompt):
|
||||
raise httpx.ReadTimeout("The read operation timed out")
|
||||
|
||||
rules = {"action": "new", "matched_incident": None, "corr_debug": {}}
|
||||
with patch.object(llm_correlator, "_build_tiebreak_prompt", lambda r, l, c: "p"), \
|
||||
patch.object(llm_correlator, "_sync_gemini", hang):
|
||||
out = await llm_correlator.tiebreak(rules, {"action": "orphan"}, _corr_ctx())
|
||||
assert out is rules
|
||||
assert ai_health.snapshot()["correlation_smart"]["permanent"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_timeout_in_transcript_correction_keeps_raw_text():
|
||||
def hang(model, prompt):
|
||||
@@ -233,8 +222,8 @@ def test_worst_case_bound_follows_the_settings():
|
||||
patch.object(settings, "gemini_timeout_seconds", 20.0), \
|
||||
patch.object(settings, "stt_retry_on_degenerate", True):
|
||||
bound = recorrelation_sweep.pipeline_worst_case_seconds(n_scenes=2)
|
||||
# stt 2x2x100 + correction 20 + extraction 2x10 + embeds 2x2x10 + decide/tiebreak 2x20 + slack
|
||||
assert bound == 400 + 20 + 20 + 40 + 40 + recorrelation_sweep._NON_AI_SLACK_SECONDS
|
||||
# stt 2x2x100 + correction 20 + extraction 2x10 + embeds 2x2x10 + one correlation call 20 + slack
|
||||
assert bound == 400 + 20 + 20 + 40 + 20 + recorrelation_sweep._NON_AI_SLACK_SECONDS
|
||||
|
||||
|
||||
def test_hold_back_covers_the_worst_case_and_never_drops_below_old_floor():
|
||||
|
||||
@@ -53,12 +53,11 @@ def test_a_unit_never_on_the_incident_cannot_close_it():
|
||||
|
||||
|
||||
def test_clearance_only_call_skips_the_llm():
|
||||
from app.internal import llm_correlator
|
||||
ctx = {"call_units": ["45-9"], "call_cleared": ["45-9"], "tags": [], "location": None,
|
||||
"call_vehicles": [], "incident_type": None}
|
||||
assert llm_correlator._is_clearance_only(ctx)
|
||||
assert not llm_correlator._is_clearance_only({**ctx, "tags": ["mva"]})
|
||||
assert not llm_correlator._is_clearance_only({**ctx, "call_cleared": []})
|
||||
assert ic.is_clearance_only(ctx)
|
||||
assert not ic.is_clearance_only({**ctx, "tags": ["mva"]})
|
||||
assert not ic.is_clearance_only({**ctx, "call_cleared": []})
|
||||
|
||||
|
||||
def test_only_numbered_units_hold_an_incident_open():
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
"""
|
||||
server-26#115 — two consensus-quality fixes.
|
||||
|
||||
Fix 1 (routers/upload.py): 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 the same talkgroup),
|
||||
resolve to `orphan` and DO NOT pay for the smart tiebreaker. Radio housekeeping
|
||||
(unit check-ins, roll call, 10-8/10-98) was being promoted to incidents because
|
||||
the tiebreaker rubber-stamped the rules `new` ~21/21 of the time
|
||||
(CORRELATION_REVIEW_0907b.md).
|
||||
|
||||
The substance test runs against `ctx` (fully populated at preview time), NOT
|
||||
against `rules_decision["corr_debug"]` — that dict is EMPTY at preview time for
|
||||
action=="new" (corr_path:"new" is written at APPLY time), so the first version of
|
||||
this gate fired on real events (a `major` "extinguishing fire", geocoded calls,
|
||||
pursuit updates).
|
||||
|
||||
Fix 2 (incident_correlator.py): the `location` correlation path linked on a bare
|
||||
sub-`location_proximity_km` (0.5 km) distance alone, taking whichever incident
|
||||
came first in an unsorted `recent`. In a dense village two unrelated events
|
||||
routinely geocode that close. A `location` link now needs unit overlap with the
|
||||
candidate OR a distance under a tighter bar, and picks the NEAREST qualifying
|
||||
candidate. A unit-overlap location link is tagged `location_unit_overlap` so it
|
||||
does not merge into the fast path's bucket in the admin fit-signal histogram.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.routers import upload
|
||||
from app.internal.incident_correlator import _run_decision, has_event_substance
|
||||
|
||||
NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Fix 1 — the LLM-orphan gate in _correlate_with_consensus
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _preview(action, corr_debug=None, ctx=None):
|
||||
base_ctx = {"call_id": "call-1"}
|
||||
if ctx:
|
||||
base_ctx.update(ctx)
|
||||
return {
|
||||
"decision": {
|
||||
"action": action,
|
||||
"matched_incident": None,
|
||||
"incident_type": "other" if action == "new" else None,
|
||||
"corr_debug": {} if corr_debug is None else dict(corr_debug),
|
||||
},
|
||||
"ctx": base_ctx,
|
||||
}
|
||||
|
||||
|
||||
def _llm(action, reasoning="—"):
|
||||
md = {"incident_id": "inc-1"} if action == "link" else None
|
||||
return {"action": action, "matched_incident": md, "reasoning": reasoning}
|
||||
|
||||
|
||||
async def _run_consensus(preview, llm_decision):
|
||||
tiebreak_result = {
|
||||
"action": "new", "matched_incident": None, "incident_type": "other",
|
||||
"corr_debug": {}, "reasoning": "tb",
|
||||
}
|
||||
with patch("app.internal.incident_correlator.preview_correlation",
|
||||
new=AsyncMock(return_value=preview)), \
|
||||
patch("app.internal.incident_correlator.apply_correlation",
|
||||
new=AsyncMock(return_value="incident-x")) as m_apply, \
|
||||
patch("app.internal.llm_correlator.decide",
|
||||
new=AsyncMock(return_value=llm_decision)), \
|
||||
patch("app.internal.llm_correlator.tiebreak",
|
||||
new=AsyncMock(return_value=tiebreak_result)) as m_tiebreak:
|
||||
await upload._correlate_with_consensus(
|
||||
call_id="call-1", node_id="n1", system_id="sys-1",
|
||||
talkgroup_id=9048, talkgroup_name="Dispatch", tags=[],
|
||||
incident_type=None, location=None, location_coords=None,
|
||||
)
|
||||
return m_apply, m_tiebreak
|
||||
|
||||
|
||||
async def test_substanceless_no_recent_same_tg_incident_gates_without_tiebreak():
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}), _llm("orphan", "unit check-in, not an incident"),
|
||||
)
|
||||
m_tiebreak.assert_not_called()
|
||||
m_apply.assert_called_once()
|
||||
gated = m_apply.call_args[0][0]["decision"]
|
||||
assert gated["action"] == "orphan"
|
||||
dbg = gated["corr_debug"]
|
||||
assert dbg["corr_consensus"] == "llm_orphan_gate"
|
||||
assert dbg["corr_consensus"] != "tiebreak"
|
||||
assert dbg["corr_rules_action"] == "new"
|
||||
assert dbg["corr_llm_action"] == "orphan"
|
||||
assert dbg["corr_llm_reasoning"] == "unit check-in, not an incident"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("severity", ["moderate", "major"])
|
||||
async def test_moderate_or_major_severity_is_not_gated(severity):
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx={"call_severity": severity}), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
async def test_routine_severity_alone_still_gates():
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx={"call_severity": "routine"}), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_not_called()
|
||||
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||
|
||||
|
||||
async def test_call_with_coords_is_not_gated():
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx={"coords": {"lat": 41.15, "lng": -73.86}}),
|
||||
_llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
async def test_call_with_tags_is_not_gated():
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx={"tags": ["structure-fire"]}), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
async def test_call_with_vehicles_is_not_gated():
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx={"call_vehicles": ["red sedan"]}), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
async def test_call_with_resolved_incident_type_is_not_gated():
|
||||
# The creation gate skips has_event_substance when a type resolved, so a
|
||||
# typed call (fire/medical/…) opens an incident on substance the gate does
|
||||
# not re-check — it must keep the tiebreak, not be dropped.
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx={"incident_type": "fire"}), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
async def test_reassignment_call_is_not_gated():
|
||||
# reassignment=True is dispatch pulling a unit onto a NEW job (units are
|
||||
# blanked for exactly that reason) — the strongest new-incident signal.
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx={"reassignment": True}), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
async def test_recent_incident_on_same_talkgroup_is_not_gated():
|
||||
ctx = {
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 9048,
|
||||
"talkgroup_name": "Dispatch",
|
||||
"now": NOW,
|
||||
"recent": [{
|
||||
"incident_id": "inc-live",
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["9048"],
|
||||
"updated_at": (NOW - timedelta(minutes=1)).isoformat(),
|
||||
}],
|
||||
}
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
# server-26#115 window #3 (CORRELATION_REVIEW_0912.md): the escape hatch used
|
||||
# to treat ANY same-talkgroup incident inside the 2h correlation_window_hours
|
||||
# as "recent", which on a busy dispatch channel (3-13 incidents/2h) was
|
||||
# satisfied almost unconditionally — the gate fired 0/24 times against its own
|
||||
# target shape. It now only counts an incident as recent within
|
||||
# settings.tg_dispatch_thin_idle_minutes (5 min), applied uniformly regardless
|
||||
# of the talkgroup's name (owner correction, 2026-09-13 — see
|
||||
# test_channel_name_does_not_affect_the_window below for why the dichotomy
|
||||
# this originally had with incident_correlator's fast/thin idle selection was
|
||||
# removed here).
|
||||
|
||||
async def test_recent_same_tg_incident_inside_new_short_window_still_escapes_gate():
|
||||
ctx = {
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 9048,
|
||||
"talkgroup_name": "Dispatch",
|
||||
"now": NOW,
|
||||
"recent": [{
|
||||
"incident_id": "inc-live",
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["9048"],
|
||||
# 3 min ago — inside tg_dispatch_thin_idle_minutes (5).
|
||||
"updated_at": (NOW - timedelta(minutes=3)).isoformat(),
|
||||
}],
|
||||
}
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
async def test_recent_same_tg_incident_older_than_short_window_now_gates():
|
||||
# Regression test for the fix: 8 minutes is past the 5-minute bound but
|
||||
# still inside the OLD 2-hour correlation_window_hours lookback. Before
|
||||
# the fix this escaped the gate on any channel; after the fix it gates.
|
||||
ctx = {
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 9048,
|
||||
"talkgroup_name": "Dispatch",
|
||||
"now": NOW,
|
||||
"recent": [{
|
||||
"incident_id": "inc-stale",
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["9048"],
|
||||
"updated_at": (NOW - timedelta(minutes=8)).isoformat(),
|
||||
}],
|
||||
}
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_not_called()
|
||||
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||
|
||||
|
||||
async def test_channel_name_does_not_affect_the_window():
|
||||
# Owner correction, 2026-09-13 (direct scanning experience): a talkgroup
|
||||
# named "tac"/"tactical" only sees materially different traffic during a
|
||||
# real incident, and that's rare -- the bulk of traffic on any monitored
|
||||
# channel, including high-risk stops and pursuits, runs on the main
|
||||
# channel regardless of what it's named. An earlier version of this used
|
||||
# a longer 15-minute window on anything not literally named "dispatch"/
|
||||
# "patched"/"primary" (mirroring incident_correlator's fast/thin idle
|
||||
# selection); that meant a busy single-channel department not literally
|
||||
# named "dispatch" silently got the more permissive window and could
|
||||
# reproduce #115's original bug. Same 8-minute age as the dispatch-named
|
||||
# test above, but on a channel named "Tac 3" -- must gate identically,
|
||||
# not escape into a longer window just because of the name.
|
||||
ctx = {
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 383,
|
||||
"talkgroup_name": "Tac 3",
|
||||
"now": NOW,
|
||||
"recent": [{
|
||||
"incident_id": "inc-tac",
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["383"],
|
||||
"updated_at": (NOW - timedelta(minutes=8)).isoformat(),
|
||||
}],
|
||||
}
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_not_called()
|
||||
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||
|
||||
|
||||
async def test_gate_veto_reason_is_recorded_on_the_escalation_path():
|
||||
# server-26#115: a live measurement window must be able to see *why* an
|
||||
# llm=orphan/rules=new call escaped the gate without guessing from the raw
|
||||
# dump (which produced a wrong "confirmed explanation" for 2 window-#3
|
||||
# misses the first time). corr_gate_veto names the surviving condition.
|
||||
ctx = {"call_severity": "major"}
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
final = m_apply.call_args[0][0]["decision"]
|
||||
assert final["corr_debug"]["corr_gate_veto"] == "severity"
|
||||
|
||||
|
||||
async def test_gate_veto_reason_is_absent_when_the_disagreement_is_not_orphan_vs_new():
|
||||
# corr_gate_veto is only meaningful for the llm=orphan/rules=new shape the
|
||||
# gate targets — it must not appear (or be misleadingly None-vs-absent) on
|
||||
# an unrelated disagreement shape.
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("link", {}), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_called_once()
|
||||
final = m_apply.call_args[0][0]["decision"]
|
||||
assert "corr_gate_veto" not in final["corr_debug"]
|
||||
|
||||
|
||||
async def test_recent_incident_on_a_different_talkgroup_still_gates():
|
||||
ctx = {
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 9048,
|
||||
"recent": [{
|
||||
"incident_id": "inc-other",
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["1200"],
|
||||
}],
|
||||
}
|
||||
m_apply, m_tiebreak = await _run_consensus(
|
||||
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||
)
|
||||
m_tiebreak.assert_not_called()
|
||||
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||
|
||||
|
||||
async def test_llm_link_vs_rules_new_still_escalates():
|
||||
m_apply, m_tiebreak = await _run_consensus(_preview("new", {}), _llm("link", "same job"))
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
async def test_llm_orphan_vs_rules_link_still_escalates():
|
||||
# Not the gate condition (gate needs rules=="new"); must fall through.
|
||||
m_apply, m_tiebreak = await _run_consensus(_preview("link", {}), _llm("orphan"))
|
||||
m_tiebreak.assert_called_once()
|
||||
|
||||
|
||||
def test_has_event_substance_predicate():
|
||||
assert has_event_substance({"coords": {"lat": 1, "lng": 2}})
|
||||
assert has_event_substance({"tags": ["fire"]})
|
||||
assert has_event_substance({"call_vehicles": ["sedan"]})
|
||||
assert not has_event_substance({})
|
||||
assert not has_event_substance({"coords": None, "tags": [], "call_vehicles": []})
|
||||
# units and location are NOT substance — nearly every transmission has them.
|
||||
assert not has_event_substance({"call_units": ["7-Adam"], "location": "Main St"})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Fix 2 — tighten corr_path=location
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CALL_COORDS = {"lat": 41.150000, "lng": -73.860000}
|
||||
# ~0.39 km north of the call — inside location_proximity_km (0.5) but well
|
||||
# outside the tight bar (_LOCATION_TIGHT_PROXIMITY_KM, 0.2).
|
||||
FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000}
|
||||
# ~0.13 km north of the call — inside the tight bar.
|
||||
NEAR_INC_COORDS = {"lat": 41.151200, "lng": -73.860000}
|
||||
# ~0.28 km north — inside the 0.5 radius, outside the 0.2 tight bar; used as a
|
||||
# second candidate that must lose the nearest-wins sort to NEAR_INC_COORDS.
|
||||
MID_INC_COORDS = {"lat": 41.152500, "lng": -73.860000}
|
||||
|
||||
|
||||
def _inc(incident_id, coords, units):
|
||||
return {
|
||||
"incident_id": incident_id,
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["100"], # different TGID → fast path is a no-op
|
||||
"location_coords": coords,
|
||||
"units": units,
|
||||
"tags": [],
|
||||
"type": "police",
|
||||
"updated_at": (NOW - timedelta(minutes=6)).isoformat(),
|
||||
"started_at": (NOW - timedelta(minutes=20)).isoformat(),
|
||||
"status": "active",
|
||||
"call_ids": ["c0"],
|
||||
}
|
||||
|
||||
|
||||
def _loc_ctx(*, incidents, call_units):
|
||||
return {
|
||||
"call_id": "call-loc",
|
||||
"all_active": list(incidents),
|
||||
"recent": list(incidents),
|
||||
"call_doc": {},
|
||||
"call_embedding": None,
|
||||
"call_units": call_units,
|
||||
"call_vehicles": [],
|
||||
"call_cleared": [],
|
||||
"call_severity": "routine",
|
||||
"coords": CALL_COORDS,
|
||||
"is_thin_call": False,
|
||||
"now": NOW,
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 999, # not in inc.talkgroup_ids
|
||||
"talkgroup_name": "Tactical",
|
||||
"tags": [],
|
||||
"incident_type": "police",
|
||||
"location": "Main St",
|
||||
"location_coords": CALL_COORDS,
|
||||
"reassignment": True, # suppress the unit-continuity path
|
||||
"create_if_new": True,
|
||||
}
|
||||
|
||||
|
||||
def test_location_path_in_radius_but_no_unit_overlap_no_tight_proximity_does_not_link(caplog):
|
||||
ctx = _loc_ctx(
|
||||
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["7-Adam"])],
|
||||
call_units=["3-Boy"],
|
||||
)
|
||||
with caplog.at_level("INFO", logger="drb-c2-core"):
|
||||
decision = _run_decision(ctx)
|
||||
# Reaches, and is rejected by, the new guard (not an earlier path).
|
||||
assert "location-path skipped" in caplog.text
|
||||
assert decision["action"] != "link"
|
||||
assert (decision.get("corr_debug") or {}).get("corr_path") != "location"
|
||||
|
||||
|
||||
def test_location_path_links_on_unit_overlap_with_distinct_fit_signal():
|
||||
ctx = _loc_ctx(
|
||||
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["5-Adam"])],
|
||||
call_units=["5-Adam"],
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_path"] == "location"
|
||||
# NOT "unit_overlap" — that value belongs to the fast path's histogram bucket.
|
||||
assert decision["corr_debug"]["corr_fit_signal"] == "location_unit_overlap"
|
||||
|
||||
|
||||
def test_location_path_links_on_tight_proximity_without_unit_overlap():
|
||||
ctx = _loc_ctx(
|
||||
incidents=[_inc("inc-loc", NEAR_INC_COORDS, ["7-Adam"])],
|
||||
call_units=["3-Boy"],
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_path"] == "location"
|
||||
assert decision["corr_debug"]["corr_fit_signal"] == "location_proximity"
|
||||
|
||||
|
||||
def test_location_path_picks_nearest_in_radius_candidate():
|
||||
# `recent` order puts the farther tight-proximity incident first; the guard
|
||||
# must still select the nearest one.
|
||||
ctx = _loc_ctx(
|
||||
incidents=[
|
||||
_inc("inc-mid", MID_INC_COORDS, ["3-Boy"]), # ~0.28 km, tight-fail
|
||||
_inc("inc-near", NEAR_INC_COORDS, ["3-Boy"]), # ~0.13 km, tight-pass
|
||||
],
|
||||
call_units=["3-Boy"],
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
assert decision["action"] == "link"
|
||||
assert decision["matched_incident"]["incident_id"] == "inc-near"
|
||||
assert decision["corr_debug"]["corr_path"] == "location"
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
server-26#299 — rules decide confident outcomes; ONE cheap LLM call only in the
|
||||
ambiguous band; no cheap/smart consensus tiebreak.
|
||||
|
||||
Also pins the section fixes that ride on the new shape:
|
||||
#86 divergence veto bar is a setting, and the band between it and the match
|
||||
bar is ambiguous rather than a silent merge
|
||||
#103 a scene with no embedding is "unverified", not verified
|
||||
#88 LLM yes/no fields fail closed; a closure only counts on a confident link
|
||||
#189 weak links refused past the span cap; LLM links distance-guarded
|
||||
#190 severity steps down on an explicit stand-down, the peak never does
|
||||
#27 sweep budget is wall-clock
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import incident_correlator as ic
|
||||
from app.internal import llm_correlator, recorrelation_sweep
|
||||
from app.routers import upload
|
||||
|
||||
NOW = datetime(2026, 10, 27, 15, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _inc(incident_id="inc-1", idle=1.0, span=None, **kw) -> dict:
|
||||
updated = NOW - timedelta(minutes=idle)
|
||||
started = NOW - timedelta(minutes=span if span is not None else idle)
|
||||
inc = {
|
||||
"incident_id": incident_id, "system_ids": ["sys-1"], "talkgroup_ids": ["383"],
|
||||
"updated_at": updated.isoformat(), "started_at": started.isoformat(),
|
||||
"status": "active", "call_ids": ["seed"], "type": "police",
|
||||
}
|
||||
inc.update(kw)
|
||||
return inc
|
||||
|
||||
|
||||
def _ctx(incidents=(), **kw) -> dict:
|
||||
incidents = list(incidents)
|
||||
base = {
|
||||
"call_id": "call-1", "all_active": incidents, "recent": incidents,
|
||||
"call_doc": {}, "call_embedding": None, "call_units": [], "call_vehicles": [],
|
||||
"call_cleared": [], "call_severity": "routine", "coords": None,
|
||||
"is_thin_call": False, "now": NOW, "system_id": "sys-1", "talkgroup_id": 383,
|
||||
"talkgroup_name": "Ch 1", "tags": [], "incident_type": None, "location": None,
|
||||
"location_coords": None, "reassignment": False, "create_if_new": True,
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def _vec(sim: float) -> tuple[list, list]:
|
||||
"""Two unit vectors with cosine similarity `sim`."""
|
||||
import math
|
||||
return [1.0, 0.0], [sim, math.sqrt(max(0.0, 1 - sim * sim))]
|
||||
|
||||
|
||||
# ── The band ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_fresh_unit_link_is_confident():
|
||||
d = ic._run_decision(_ctx([_inc(idle=2, units=["6-Adam"])], call_units=["6-Adam"]))
|
||||
assert d["action"] == "link"
|
||||
assert d["corr_debug"]["corr_fit_signal"] == "unit_overlap"
|
||||
assert d["ambiguous"] is None
|
||||
|
||||
|
||||
def test_stale_unit_link_without_embedding_is_ambiguous_not_verified():
|
||||
# #103: no embedding used to skip the veto and link as if verified.
|
||||
d = ic._run_decision(_ctx([_inc(idle=20, units=["6-Adam"])], call_units=["6-Adam"]))
|
||||
assert d["action"] == "link"
|
||||
assert d["corr_debug"]["corr_fit_signal"] == "unit_overlap_unverified"
|
||||
assert d["ambiguous"] == "unverified_unit_link"
|
||||
|
||||
|
||||
def test_young_but_long_running_unit_link_is_ambiguous():
|
||||
# #189: busy channel keeps idle low; age is what reveals a sink forming.
|
||||
inc = _inc(idle=1, span=settings.unit_link_confident_max_span_minutes + 5, units=["6-Adam"])
|
||||
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"]))
|
||||
assert d["ambiguous"] == "unverified_unit_link"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sim,expected", [
|
||||
(0.80, ("orphan", "content_divergence")), # below the divergence bar → veto
|
||||
(0.88, ("link", "unit_overlap_unverified")), # between the bars → ambiguous (#86)
|
||||
(0.95, ("link", "unit_content_match")), # at the match bar → confident
|
||||
])
|
||||
def test_divergence_band_between_the_two_thresholds(sim, expected):
|
||||
call_emb, inc_emb = _vec(sim)
|
||||
inc = _inc(idle=20, units=["6-Adam"], embedding=inc_emb)
|
||||
fit, signal = ic._call_fits_incident(
|
||||
inc, ["6-Adam"], [], None, settings.location_proximity_km,
|
||||
call_embedding=call_emb, now=NOW,
|
||||
)
|
||||
assert (("link" if fit else "orphan"), signal) == expected
|
||||
|
||||
|
||||
def test_divergence_bar_is_the_setting_not_a_literal():
|
||||
call_emb, inc_emb = _vec(0.88)
|
||||
inc = _inc(idle=20, units=["6-Adam"], embedding=inc_emb)
|
||||
with patch.object(settings, "embedding_divergence_threshold", 0.90):
|
||||
fit, signal = ic._call_fits_incident(
|
||||
inc, ["6-Adam"], [], None, settings.location_proximity_km,
|
||||
call_embedding=call_emb, now=NOW,
|
||||
)
|
||||
assert (fit, signal) == (False, "content_divergence")
|
||||
|
||||
|
||||
def test_new_with_live_candidates_is_ambiguous_without_none():
|
||||
d = ic._run_decision(_ctx([_inc(talkgroup_ids=["999"])], incident_type="police"))
|
||||
assert (d["action"], d["ambiguous"]) == ("new", "new_with_candidates")
|
||||
d = ic._run_decision(_ctx([], incident_type="police"))
|
||||
assert (d["action"], d["ambiguous"]) == ("new", None)
|
||||
|
||||
|
||||
def test_orphan_with_unfit_same_tg_candidate_is_ambiguous():
|
||||
# Substantive (a location) but untyped, live incident on its talkgroup, no evidence.
|
||||
d = ic._run_decision(_ctx([_inc(idle=2)], location="Mohegan Park Ave"))
|
||||
assert (d["action"], d["ambiguous"]) == ("orphan", "unfit_tg_candidate")
|
||||
|
||||
|
||||
def test_thin_sweep_and_clearance_calls_are_never_ambiguous():
|
||||
inc = _inc(idle=0.2)
|
||||
assert ic._run_decision(_ctx([inc], is_thin_call=True))["ambiguous"] is None
|
||||
assert ic._run_decision(_ctx([inc], incident_type="police", create_if_new=False))["ambiguous"] is None
|
||||
stale = _inc(idle=20, units=["45-9"])
|
||||
d = ic._run_decision(_ctx([stale], call_units=["45-9"], call_cleared=["45-9"]))
|
||||
assert d["action"] == "link" and d["ambiguous"] is None
|
||||
|
||||
|
||||
# ── #189: caps on absorption ────────────────────────────────────────────────
|
||||
|
||||
def test_weak_link_span_cap_refuses_unit_only_links():
|
||||
inc = _inc(idle=1, span=settings.incident_weak_link_max_span_minutes + 1, units=["6-Adam"])
|
||||
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"], incident_type="accident"))
|
||||
assert d["action"] == "new"
|
||||
assert ic.llm_candidates(_ctx([inc])) == []
|
||||
|
||||
|
||||
def test_weak_link_span_cap_also_binds_unit_continuity():
|
||||
inc = _inc(idle=1, span=settings.incident_weak_link_max_span_minutes + 1,
|
||||
units=["6-Adam"], talkgroup_ids=["999"])
|
||||
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"]))
|
||||
assert d["action"] == "orphan"
|
||||
|
||||
|
||||
def test_llm_link_distance_guard():
|
||||
pin = {"lat": 41.0, "lng": -73.8}
|
||||
inc = _inc(location="Main St", location_coords=pin, location_coords_source="Main St")
|
||||
near = _ctx(coords={"lat": 41.005, "lng": -73.8})
|
||||
far = _ctx(coords={"lat": 41.2, "lng": -73.8}) # ~22 km
|
||||
assert ic.llm_link_allowed(inc, near)
|
||||
assert not ic.llm_link_allowed(inc, far)
|
||||
assert ic.llm_link_allowed({**inc, "tags": ["vehicle-pursuit"]}, far)
|
||||
|
||||
|
||||
# ── llm_correlator: one call, validated ─────────────────────────────────────
|
||||
|
||||
def test_parse_rejects_links_to_unshown_incidents_and_bad_actions():
|
||||
shown = [_inc("inc-1")]
|
||||
ctx = _ctx(shown)
|
||||
assert llm_correlator._parse_response({"action": "link", "incident_id": "nope"}, ctx, shown) is None
|
||||
assert llm_correlator._parse_response({"action": "maybe"}, ctx, shown) is None
|
||||
ok = llm_correlator._parse_response({"action": "link", "incident_id": "inc-1"}, ctx, shown)
|
||||
assert ok["action"] == "link" and ok["matched_incident"]["incident_id"] == "inc-1"
|
||||
|
||||
|
||||
def test_parse_new_uses_the_rules_creation_gate():
|
||||
ctx = _ctx(call_severity="minor")
|
||||
assert llm_correlator._parse_response({"action": "new"}, ctx, [])["incident_type"] == "other"
|
||||
out = llm_correlator._parse_response({"action": "new"}, _ctx(), [])
|
||||
assert out["action"] == "orphan"
|
||||
|
||||
|
||||
async def _correlate_with(rules_decision, llm_return, ctx=None):
|
||||
ctx = ctx or _ctx([_inc()])
|
||||
preview = {"decision": rules_decision, "ctx": ctx}
|
||||
with patch("app.internal.incident_correlator.preview_correlation", AsyncMock(return_value=preview)), \
|
||||
patch("app.internal.incident_correlator.apply_correlation", AsyncMock(return_value="inc-x")) as apply, \
|
||||
patch("app.internal.llm_correlator.decide", AsyncMock(return_value=llm_return)) as decide:
|
||||
_, final = await upload._correlate(
|
||||
call_id="call-1", node_id="n", system_id="sys-1", talkgroup_id=383,
|
||||
talkgroup_name="Ch 1", tags=[], incident_type=None, location=None,
|
||||
location_coords=None,
|
||||
)
|
||||
return final, decide, apply
|
||||
|
||||
|
||||
def _rules(action, ambiguous=None, inc=None):
|
||||
return {"action": action, "matched_incident": inc, "incident_type": "police" if action == "new" else None,
|
||||
"corr_debug": {}, "ambiguous": ambiguous}
|
||||
|
||||
|
||||
def _llm(action, inc=None):
|
||||
return {"action": action, "matched_incident": inc, "incident_type": "police" if action == "new" else None,
|
||||
"corr_debug": {"corr_llm_reasoning": "r"}, "reasoning": "r"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confident_rules_decision_never_calls_the_llm():
|
||||
final, decide, _ = await _correlate_with(_rules("new"), _llm("orphan"))
|
||||
decide.assert_not_awaited()
|
||||
assert final["corr_debug"]["corr_consensus"] == "rules_only"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_decision_makes_exactly_one_llm_call_and_commits_it():
|
||||
inc = _inc("inc-2")
|
||||
final, decide, apply = await _correlate_with(_rules("new", "new_with_candidates"), _llm("link", inc))
|
||||
decide.assert_awaited_once()
|
||||
committed = apply.await_args.args[0]["decision"]
|
||||
assert committed["action"] == "link" and committed["matched_incident"]["incident_id"] == "inc-2"
|
||||
dbg = final["corr_debug"]
|
||||
assert dbg["corr_consensus"] == "llm" and dbg["corr_path"] == "llm"
|
||||
assert dbg["corr_band"] == "new_with_candidates"
|
||||
assert (dbg["corr_rules_action"], dbg["corr_llm_action"]) == ("new", "link")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_unavailable_keeps_the_rules_decision():
|
||||
rules = _rules("new", "new_with_candidates")
|
||||
final, decide, _ = await _correlate_with(rules, None)
|
||||
assert final is rules and final["corr_debug"]["corr_consensus"] == "rules_fallback"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_orphan_cannot_drop_a_major_or_reassignment_call():
|
||||
final, _, _ = await _correlate_with(
|
||||
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()], call_severity="major"))
|
||||
assert final["action"] == "new" and final["corr_debug"]["corr_consensus"] == "rules_guard"
|
||||
final, _, _ = await _correlate_with(
|
||||
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()]))
|
||||
assert final["action"] == "orphan" and final["corr_debug"]["corr_consensus"] == "llm"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("extra", [{"incident_type": "fire"}, {"tags": ["structure-fire"]},
|
||||
{"coords": {"lat": 41.0, "lng": -73.8}}, {"reassignment": True}])
|
||||
async def test_llm_orphan_cannot_drop_a_typed_or_substantive_call(extra):
|
||||
final, _, _ = await _correlate_with(
|
||||
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()], **extra))
|
||||
assert final["action"] == "new" and final["corr_debug"]["corr_consensus"] == "rules_guard"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_orphan_over_a_rules_link_is_tombstoned_for_the_sweep():
|
||||
inc = _inc()
|
||||
rules = _rules("link", "unverified_unit_link", inc)
|
||||
final, _, _ = await _correlate_with(rules, _llm("orphan"))
|
||||
assert final["action"] == "orphan" and final["corr_debug"]["corr_path"] == "llm_orphan"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_back_dated_link_never_moves_updated_at_backward():
|
||||
inc = _inc(idle=1, span=10)
|
||||
with patch.object(ic, "fstore") as fs:
|
||||
fs.doc_set = AsyncMock()
|
||||
await ic._update_incident(inc, "late-call", 383, "sys-1", [], None, None, ["6-Adam"], [], None,
|
||||
NOW - timedelta(hours=3))
|
||||
assert fs.doc_set.await_args.args[2]["updated_at"] == inc["updated_at"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_confirming_the_rules_link_keeps_the_rules_path():
|
||||
inc = _inc()
|
||||
rules = _rules("link", "unverified_unit_link", inc)
|
||||
rules["corr_debug"] = {"corr_path": "fast/single", "corr_fit_signal": "unit_overlap_unverified"}
|
||||
final, _, _ = await _correlate_with(rules, _llm("link", inc))
|
||||
assert final["corr_debug"]["corr_path"] == "fast/single"
|
||||
assert final["corr_debug"]["corr_consensus"] == "llm"
|
||||
|
||||
|
||||
def test_no_tiebreak_tier_remains():
|
||||
assert not hasattr(llm_correlator, "tiebreak")
|
||||
assert not hasattr(settings, "corr_smart_model")
|
||||
assert not hasattr(upload, "_correlate_with_consensus")
|
||||
|
||||
|
||||
# ── #88 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
(True, True), (False, False), ("true", True), ("True", True), ("yes", True),
|
||||
("false", False), ("False", False), ("no", False), ("", False), (None, False),
|
||||
("maybe", False), (1, False), ({"x": 1}, False),
|
||||
])
|
||||
def test_parse_llm_bool_fails_closed(value, expected):
|
||||
assert ic.parse_llm_bool(value) is expected
|
||||
|
||||
|
||||
def test_closure_only_honoured_on_a_confident_link_or_own_new_incident():
|
||||
assert upload._may_resolve({"action": "new", "corr_debug": {"corr_consensus": "llm"}})
|
||||
assert upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "rules_only",
|
||||
"corr_path": "fast/single"}})
|
||||
assert not upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "rules_only",
|
||||
"corr_path": "fast/thin"}})
|
||||
assert not upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "llm",
|
||||
"corr_path": "llm"}})
|
||||
assert not upload._may_resolve({"action": "orphan", "corr_debug": {}})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quoted_false_does_not_resolve_or_blank_units():
|
||||
scene = {"tags": [], "incident_type": "police", "location": None, "location_coords": None,
|
||||
"units": ["6-Adam"], "resolved": "false", "reassignment": "false"}
|
||||
corr = AsyncMock(return_value=("inc-1", {"action": "new", "corr_debug": {}}))
|
||||
with patch.object(upload, "_correlate", corr), patch.object(upload, "fstore") as fs:
|
||||
fs.doc_set = AsyncMock()
|
||||
await upload._correlate_scenes("call-1", "n", "sys-1", 383, "Ch 1", [scene])
|
||||
assert corr.await_args.kwargs["units"] == ["6-Adam"]
|
||||
assert corr.await_args.kwargs["reassignment"] is False
|
||||
fs.doc_set.assert_not_awaited()
|
||||
|
||||
|
||||
# ── #190 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_severity_escalates_immediately_and_keeps_its_peak():
|
||||
out = ic._severity_after_link({"severity": "minor"}, "major", "shots fired")
|
||||
assert out["severity"] == "major" and out["severity_peak"] == "major"
|
||||
|
||||
|
||||
def test_calmer_followup_without_stand_down_does_not_deescalate():
|
||||
out = ic._severity_after_link({"severity": "major"}, "routine", "Engine 5 on scene, 10-4")
|
||||
assert out["severity"] == "major" and "severity_deescalated_from" not in out
|
||||
|
||||
|
||||
def test_explicit_stand_down_deescalates_but_peak_stays():
|
||||
inc = {"severity": "major"}
|
||||
out = ic._severity_after_link(inc, "minor", "Only smoke showing, no flames.")
|
||||
assert out == {"severity": "minor", "severity_peak": "major", "severity_deescalated_from": "major"}
|
||||
later = ic._severity_after_link({**inc, **out}, "routine", "copy")
|
||||
assert later["severity"] == "minor" and later["severity_peak"] == "major"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"call was unfounded", "false alarm, resetting the panel", "GOA, nothing found",
|
||||
"Code 4, you can stand down", "fire is out",
|
||||
])
|
||||
def test_stand_down_phrases(text):
|
||||
assert ic._is_stand_down(text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["disregard my last", "code 40", "flames showing",
|
||||
"primary search all clear", "Code 4", None])
|
||||
def test_not_stand_down_phrases(text):
|
||||
assert not ic._is_stand_down(text)
|
||||
|
||||
|
||||
# ── #27 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_sweep_tombstones_only_on_the_last_visible_pass():
|
||||
scan = settings.recorrelation_scan_minutes
|
||||
young = {"ended_at": (NOW - timedelta(minutes=20)).isoformat()}
|
||||
last = {"ended_at": (NOW - timedelta(minutes=scan - 1)).isoformat()}
|
||||
assert not recorrelation_sweep._last_chance(young, NOW)
|
||||
assert recorrelation_sweep._last_chance(last, NOW)
|
||||
assert recorrelation_sweep._last_chance({}, NOW)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_keeps_retrying_past_the_old_three_attempt_budget():
|
||||
call = {"call_id": "c", "started_at": (NOW - timedelta(minutes=30)).isoformat(),
|
||||
"ended_at": (NOW - timedelta(minutes=30)).isoformat(), "corr_sweep_count": 5}
|
||||
with patch("app.internal.incident_correlator.correlate_call", AsyncMock(return_value=None)), \
|
||||
patch.object(recorrelation_sweep, "fstore") as fs:
|
||||
fs.doc_set = AsyncMock()
|
||||
assert await recorrelation_sweep._recorrelate_orphan(call, NOW) is False
|
||||
assert fs.doc_set.await_args.args[2] == {"corr_sweep_count": 6}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
server-26#115 — location-path corroboration, plus the shared substance predicate.
|
||||
|
||||
(The consensus LLM-orphan gate that used to be tested here was removed with the
|
||||
cheap/smart tiebreak in server-26#299; its replacement is tested in
|
||||
test_correlation_decision_flow.py.)
|
||||
|
||||
The `location` correlation path linked on a bare sub-`location_proximity_km`
|
||||
(0.5 km) distance alone, taking whichever incident came first in an unsorted
|
||||
`recent`. In a dense village two unrelated events routinely geocode that close.
|
||||
A `location` link now needs unit overlap with the candidate OR a distance under a
|
||||
tighter bar, and picks the NEAREST qualifying candidate. A unit-overlap location
|
||||
link is tagged `location_unit_overlap` so it does not merge into the fast path's
|
||||
bucket in the admin fit-signal histogram.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.internal.incident_correlator import _run_decision, has_event_substance
|
||||
|
||||
NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_has_event_substance_predicate():
|
||||
assert has_event_substance({"coords": {"lat": 1, "lng": 2}})
|
||||
assert has_event_substance({"tags": ["fire"]})
|
||||
assert has_event_substance({"call_vehicles": ["sedan"]})
|
||||
assert not has_event_substance({})
|
||||
assert not has_event_substance({"coords": None, "tags": [], "call_vehicles": []})
|
||||
# units and location are NOT substance — nearly every transmission has them.
|
||||
assert not has_event_substance({"call_units": ["7-Adam"], "location": "Main St"})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Fix 2 — tighten corr_path=location
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CALL_COORDS = {"lat": 41.150000, "lng": -73.860000}
|
||||
# ~0.39 km north of the call — inside location_proximity_km (0.5) but well
|
||||
# outside the tight bar (_LOCATION_TIGHT_PROXIMITY_KM, 0.2).
|
||||
FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000}
|
||||
# ~0.13 km north of the call — inside the tight bar.
|
||||
NEAR_INC_COORDS = {"lat": 41.151200, "lng": -73.860000}
|
||||
# ~0.28 km north — inside the 0.5 radius, outside the 0.2 tight bar; used as a
|
||||
# second candidate that must lose the nearest-wins sort to NEAR_INC_COORDS.
|
||||
MID_INC_COORDS = {"lat": 41.152500, "lng": -73.860000}
|
||||
|
||||
|
||||
def _inc(incident_id, coords, units):
|
||||
return {
|
||||
"incident_id": incident_id,
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["100"], # different TGID → fast path is a no-op
|
||||
"location_coords": coords,
|
||||
"units": units,
|
||||
"tags": [],
|
||||
"type": "police",
|
||||
"updated_at": (NOW - timedelta(minutes=6)).isoformat(),
|
||||
"started_at": (NOW - timedelta(minutes=20)).isoformat(),
|
||||
"status": "active",
|
||||
"call_ids": ["c0"],
|
||||
}
|
||||
|
||||
|
||||
def _loc_ctx(*, incidents, call_units):
|
||||
return {
|
||||
"call_id": "call-loc",
|
||||
"all_active": list(incidents),
|
||||
"recent": list(incidents),
|
||||
"call_doc": {},
|
||||
"call_embedding": None,
|
||||
"call_units": call_units,
|
||||
"call_vehicles": [],
|
||||
"call_cleared": [],
|
||||
"call_severity": "routine",
|
||||
"coords": CALL_COORDS,
|
||||
"is_thin_call": False,
|
||||
"now": NOW,
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 999, # not in inc.talkgroup_ids
|
||||
"talkgroup_name": "Tactical",
|
||||
"tags": [],
|
||||
"incident_type": "police",
|
||||
"location": "Main St",
|
||||
"location_coords": CALL_COORDS,
|
||||
"reassignment": True, # suppress the unit-continuity path
|
||||
"create_if_new": True,
|
||||
}
|
||||
|
||||
|
||||
def test_location_path_in_radius_but_no_unit_overlap_no_tight_proximity_does_not_link(caplog):
|
||||
ctx = _loc_ctx(
|
||||
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["7-Adam"])],
|
||||
call_units=["3-Boy"],
|
||||
)
|
||||
with caplog.at_level("INFO", logger="drb-c2-core"):
|
||||
decision = _run_decision(ctx)
|
||||
# Reaches, and is rejected by, the new guard (not an earlier path).
|
||||
assert "location-path skipped" in caplog.text
|
||||
assert decision["action"] != "link"
|
||||
assert (decision.get("corr_debug") or {}).get("corr_path") != "location"
|
||||
|
||||
|
||||
def test_location_path_links_on_unit_overlap_with_distinct_fit_signal():
|
||||
ctx = _loc_ctx(
|
||||
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["5-Adam"])],
|
||||
call_units=["5-Adam"],
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_path"] == "location"
|
||||
# NOT "unit_overlap" — that value belongs to the fast path's histogram bucket.
|
||||
assert decision["corr_debug"]["corr_fit_signal"] == "location_unit_overlap"
|
||||
|
||||
|
||||
def test_location_path_links_on_tight_proximity_without_unit_overlap():
|
||||
ctx = _loc_ctx(
|
||||
incidents=[_inc("inc-loc", NEAR_INC_COORDS, ["7-Adam"])],
|
||||
call_units=["3-Boy"],
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_path"] == "location"
|
||||
assert decision["corr_debug"]["corr_fit_signal"] == "location_proximity"
|
||||
|
||||
|
||||
def test_location_path_picks_nearest_in_radius_candidate():
|
||||
# `recent` order puts the farther tight-proximity incident first; the guard
|
||||
# must still select the nearest one.
|
||||
ctx = _loc_ctx(
|
||||
incidents=[
|
||||
_inc("inc-mid", MID_INC_COORDS, ["3-Boy"]), # ~0.28 km, tight-fail
|
||||
_inc("inc-near", NEAR_INC_COORDS, ["3-Boy"]), # ~0.13 km, tight-pass
|
||||
],
|
||||
call_units=["3-Boy"],
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
assert decision["action"] == "link"
|
||||
assert decision["matched_incident"]["incident_id"] == "inc-near"
|
||||
assert decision["corr_debug"]["corr_path"] == "location"
|
||||
@@ -332,13 +332,18 @@ def test_duration_cap_blocks_the_thin_path_too():
|
||||
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan"
|
||||
|
||||
|
||||
def test_incident_just_under_the_duration_cap_still_accepts_calls():
|
||||
def test_incident_just_under_the_duration_cap_still_accepts_strong_evidence():
|
||||
# Past the weak-link span cap (server-26#189) shared units alone no longer
|
||||
# link, but strong evidence (a shared vehicle) still does until the hard cap.
|
||||
inc = _long_running(settings.incident_max_duration_minutes - 10)
|
||||
inc["units"] = ["6-Adam"]
|
||||
inc["vehicles"] = ["red sedan"]
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], is_thin_call=False, call_units=["6-Adam"],
|
||||
all_active=[inc], recent=[inc], is_thin_call=False,
|
||||
call_units=["7-Boy"], call_vehicles=["red sedan"],
|
||||
))
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_fit_signal"] == "vehicle_overlap"
|
||||
|
||||
|
||||
def test_call_count_cap_forces_a_new_incident():
|
||||
|
||||
@@ -29,7 +29,7 @@ async def _run_ingest(skip_reason):
|
||||
patch("app.internal.firestore.doc_get_cached",
|
||||
AsyncMock(return_value={"system_id": "sys-1", "ai_flags": {}})), \
|
||||
patch.object(upload, "fstore") as fs, \
|
||||
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr, \
|
||||
patch.object(upload, "_correlate", AsyncMock(return_value=(None, {}))) as corr, \
|
||||
patch("app.internal.transcription.transcribe_call",
|
||||
AsyncMock(return_value=("10-8", []))), \
|
||||
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])), \
|
||||
|
||||
Reference in New Issue
Block a user