Stop ambient radio chatter from opening incidents, and refill the map
The 23:46Z correlation dump confirmed the severity gate fixed the problem it
was written for -- orphans fell from 69 to 16, and only three of those are
after the deploy boundary, two of them deliberate skips. Nothing on TG 9048
absorbs the channel any more; the largest post-deploy incident is four calls
over nine minutes and is genuinely one event.
It overcorrected. 37 of 50 incidents were open, most a single routine call.
The cause was the gate's own substance test, which counted `units` and
`location`. Radio protocol puts a unit ID in essentially every transmission
and a place name in most of them, so has_substance was true almost always and
the severity check never actually ran -- "11-Victor, 72 at Holland Station"
became its own permanent incident. Substance is now a vehicle, a geocode or a
tag: things the extractor found beyond who was speaking and where they stood.
Severity still opens an incident on its own, so nothing real is lost.
incident_type is now validated against the enum the prompt offers rather than
trusted. It is written straight through to incident.type and rendered as the
title, so a model that answered the severity question in the type field
produced an incident titled "Routine -- TGID 9563". Unrecognised values become
None and fall to the tag/severity path, which is what "unknown" already did.
The map was empty for a separate reason: geocoding accepted only ROOFTOP and
RANGE_INTERPOLATED. Dispatch names places the way people speak, and Google
returns GEOMETRIC_CENTER for exactly those forms -- intersections ("Lake
Street and Veterans Memorial Drive") and named POIs ("Brewster Station").
Requiring a street address discarded nearly every real dispatch location and
left only numbered addresses plotted, which is why the July incidents have
coordinates and none since do. GEOMETRIC_CENTER is now accepted; APPROXIMATE
is still rejected, since a region centroid is what an ungeocodable string
degrades to. Note this is necessary but may not be sufficient -- if
GOOGLE_MAPS_API_KEY is unset on the host the map stays empty regardless, and
that has not been checked from here.
Two things found and deliberately not fixed, both in DEFERRED.md. One call can
still land in two incidents, because upload.py correlates each extracted scene
independently and the model over-split one conversation; multi-scene is
intentional, so that is prompt tuning rather than a code change. And nothing
closes an incident that merely goes quiet -- signal-resolution and master
auto-resolve both exist, but a one-call incident nobody clears stays active
forever. That wanted the over-creation fixed first so a time-based sweeper
would not just paper over it.
Gate tests updated: units and location alone must now orphan, and the case
that matters most is kept explicit -- units with a real severity still open an
incident. 17 pass. No new environment variables, so CI deploys this without an
ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
53965e1a19
commit
96625fabd0
@@ -731,8 +731,16 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
# Anything the extractor judged a real event, or that carries any concrete
|
# Anything the extractor judged a real event, or that carries any concrete
|
||||||
# content, now opens an incident under the neutral "other" type. Only
|
# content, now opens an incident under the neutral "other" type. Only
|
||||||
# content-free routine traffic is still left for the thin path to attach.
|
# content-free routine traffic is still left for the thin path to attach.
|
||||||
|
#
|
||||||
|
# `call_units` and `location` are NOT substance. Radio protocol puts a unit
|
||||||
|
# ID in essentially every transmission and a place name in most of them, so
|
||||||
|
# including them made has_substance true almost always and the severity check
|
||||||
|
# dead code — the first version of this gate turned "11-Victor, 72 at Holland
|
||||||
|
# Station" into its own incident and left 37 of 50 incidents open, one call
|
||||||
|
# each. A vehicle, a geocode, or a tag means the extractor found something
|
||||||
|
# beyond who was speaking and where they stood.
|
||||||
if not resolved_type:
|
if not resolved_type:
|
||||||
has_substance = bool(call_units or call_vehicles or coords or location or tags)
|
has_substance = bool(call_vehicles or coords or tags)
|
||||||
if call_severity in ("minor", "moderate", "major") or has_substance:
|
if call_severity in ("minor", "moderate", "major") or has_substance:
|
||||||
resolved_type = "other"
|
resolved_type = "other"
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ System: {system_id}
|
|||||||
Talkgroup: {talkgroup_name}
|
Talkgroup: {talkgroup_name}
|
||||||
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
||||||
|
|
||||||
|
# The incident_type enum offered to the model in EXTRACTION_PROMPT. Kept here
|
||||||
|
# rather than only in the prompt so a model that invents a value cannot write it
|
||||||
|
# into incident.type. "unknown" is deliberately absent — it is a real answer
|
||||||
|
# from the model but not a usable type, and is normalised to None alongside
|
||||||
|
# anything unrecognised.
|
||||||
|
_VALID_INCIDENT_TYPES = frozenset({"fire", "ems", "police", "accident", "other"})
|
||||||
|
|
||||||
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
|
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
|
||||||
_GEO_DELTA = 0.5
|
_GEO_DELTA = 0.5
|
||||||
|
|
||||||
@@ -240,7 +247,19 @@ async def extract_scenes(
|
|||||||
# and is kept. Collapsing it to None used to make the call untypeable,
|
# and is kept. Collapsing it to None used to make the call untypeable,
|
||||||
# and an untypeable call could never open an incident — see the creation
|
# and an untypeable call could never open an incident — see the creation
|
||||||
# gate in incident_correlator._run_decision().
|
# gate in incident_correlator._run_decision().
|
||||||
if incident_type in ("unknown", ""):
|
#
|
||||||
|
# Anything outside the enum is a model error, not a new category. The
|
||||||
|
# value is written straight through to incident.type and rendered as the
|
||||||
|
# incident title, so on 2026-08-16 a model that answered the severity
|
||||||
|
# question in the type field produced an incident literally titled
|
||||||
|
# "Routine — TGID 9563". Unrecognised values become None and fall to the
|
||||||
|
# tag/severity path, which is the same treatment "unknown" already got.
|
||||||
|
if incident_type not in _VALID_INCIDENT_TYPES:
|
||||||
|
if incident_type and incident_type != "unknown":
|
||||||
|
logger.warning(
|
||||||
|
f"Intelligence: discarding invalid incident_type {incident_type!r} "
|
||||||
|
f"(not in {sorted(_VALID_INCIDENT_TYPES)})"
|
||||||
|
)
|
||||||
incident_type = None
|
incident_type = None
|
||||||
|
|
||||||
# Geocode this scene's location.
|
# Geocode this scene's location.
|
||||||
@@ -420,11 +439,17 @@ async def _geocode_location(
|
|||||||
return None
|
return None
|
||||||
result = data["results"][0]
|
result = data["results"][0]
|
||||||
location_type = result.get("geometry", {}).get("location_type", "")
|
location_type = result.get("geometry", {}).get("location_type", "")
|
||||||
# Only accept address-level precision. GEOMETRIC_CENTER (city/neighborhood
|
# Reject only APPROXIMATE — a region/city boundary centroid, which is
|
||||||
# centroid) and APPROXIMATE (region boundary) produce coordinates that look
|
# what an ungeocodable string degrades to and is genuinely useless.
|
||||||
# valid but are too vague for 0.5km proximity matching — they often resolve
|
#
|
||||||
# to the same point as the node's position and create false proximity matches.
|
# ROOFTOP-only was too strict and emptied the map: dispatch names
|
||||||
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED"):
|
# places the way people speak, and Google returns GEOMETRIC_CENTER for
|
||||||
|
# exactly those forms — intersections ("Lake Street and Veterans
|
||||||
|
# Memorial Drive") and named POIs ("Brewster Station"). Both are
|
||||||
|
# precise enough to plot and to proximity-match; requiring a street
|
||||||
|
# address threw away nearly every real dispatch location, leaving only
|
||||||
|
# numbered addresses geocoded.
|
||||||
|
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Geocoding rejected '{location_str}' — imprecise result "
|
f"Geocoding rejected '{location_str}' — imprecise result "
|
||||||
f"(location_type={location_type!r}), returning None"
|
f"(location_type={location_type!r}), returning None"
|
||||||
|
|||||||
@@ -67,10 +67,8 @@ def test_any_real_severity_opens_an_untyped_incident(severity):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field,value", [
|
@pytest.mark.parametrize("field,value", [
|
||||||
("call_units", ["6 Adam"]),
|
|
||||||
("call_vehicles", ["RMP 22146"]),
|
("call_vehicles", ["RMP 22146"]),
|
||||||
("coords", {"lat": 41.0, "lng": -73.8}),
|
("coords", {"lat": 41.0, "lng": -73.8}),
|
||||||
("location", "District 6"),
|
|
||||||
("tags", ["prisoner-transport"]),
|
("tags", ["prisoner-transport"]),
|
||||||
])
|
])
|
||||||
def test_concrete_content_opens_an_untyped_incident(field, value):
|
def test_concrete_content_opens_an_untyped_incident(field, value):
|
||||||
@@ -80,6 +78,32 @@ def test_concrete_content_opens_an_untyped_incident(field, value):
|
|||||||
assert decision["incident_type"] == "other"
|
assert decision["incident_type"] == "other"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field,value", [
|
||||||
|
("call_units", ["11-Victor"]),
|
||||||
|
("location", "Holland Station"),
|
||||||
|
])
|
||||||
|
def test_ambient_radio_fields_are_not_substance(field, value):
|
||||||
|
"""
|
||||||
|
A unit ID and a place name appear in nearly every transmission, so treating
|
||||||
|
them as substance made the severity check dead code: "11-Victor, 72 at
|
||||||
|
Holland Station" opened its own incident, and 37 of 50 incidents were single
|
||||||
|
routine calls left permanently active.
|
||||||
|
"""
|
||||||
|
assert _run_decision(_ctx(**{field: value}))["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_and_location_together_still_orphan():
|
||||||
|
decision = _run_decision(_ctx(call_units=["11-Victor"], location="Holland Station"))
|
||||||
|
assert decision["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_with_real_severity_still_open_an_incident():
|
||||||
|
"""Severity is the gate — ambient fields don't block it, they just can't open it alone."""
|
||||||
|
decision = _run_decision(_ctx(call_units=["11-Victor"], call_severity="moderate"))
|
||||||
|
assert decision["action"] == "new"
|
||||||
|
assert decision["incident_type"] == "other"
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_type_is_never_downgraded_to_other():
|
def test_explicit_type_is_never_downgraded_to_other():
|
||||||
decision = _run_decision(_ctx(incident_type="police", call_severity="moderate"))
|
decision = _run_decision(_ctx(incident_type="police", call_severity="moderate"))
|
||||||
assert decision["action"] == "new"
|
assert decision["action"] == "new"
|
||||||
|
|||||||
Reference in New Issue
Block a user