diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index be66145..4c3bda0 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -731,8 +731,16 @@ def _run_decision(ctx: dict) -> dict: # Anything the extractor judged a real event, or that carries any concrete # content, now opens an incident under the neutral "other" type. Only # 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: - 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: resolved_type = "other" logger.info( diff --git a/drb-c2-core/app/internal/intelligence.py b/drb-c2-core/app/internal/intelligence.py index f86ef77..694ed2b 100644 --- a/drb-c2-core/app/internal/intelligence.py +++ b/drb-c2-core/app/internal/intelligence.py @@ -68,6 +68,13 @@ System: {system_id} Talkgroup: {talkgroup_name} {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) _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 an untypeable call could never open an incident — see the creation # 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 # Geocode this scene's location. @@ -420,11 +439,17 @@ async def _geocode_location( return None result = data["results"][0] location_type = result.get("geometry", {}).get("location_type", "") - # Only accept address-level precision. GEOMETRIC_CENTER (city/neighborhood - # centroid) and APPROXIMATE (region boundary) produce coordinates that look - # 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. - if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED"): + # Reject only APPROXIMATE — a region/city boundary centroid, which is + # what an ungeocodable string degrades to and is genuinely useless. + # + # ROOFTOP-only was too strict and emptied the map: 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"). 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( f"Geocoding rejected '{location_str}' — imprecise result " f"(location_type={location_type!r}), returning None" diff --git a/drb-c2-core/tests/test_correlator_gate.py b/drb-c2-core/tests/test_correlator_gate.py index 19eb83b..2736d51 100644 --- a/drb-c2-core/tests/test_correlator_gate.py +++ b/drb-c2-core/tests/test_correlator_gate.py @@ -67,10 +67,8 @@ def test_any_real_severity_opens_an_untyped_incident(severity): @pytest.mark.parametrize("field,value", [ - ("call_units", ["6 Adam"]), ("call_vehicles", ["RMP 22146"]), ("coords", {"lat": 41.0, "lng": -73.8}), - ("location", "District 6"), ("tags", ["prisoner-transport"]), ]) 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" +@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(): decision = _run_decision(_ctx(incident_type="police", call_severity="moderate")) assert decision["action"] == "new"