diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index eea75ce..1032944 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -241,6 +241,200 @@ def _tag_to_title(tag: str) -> str: return " ".join(w.capitalize() for w in tag.replace("-", " ").split()) +# ───────────────────────────────────────────────────────────────────────────── +# Location label + map pin — one value, written together (server-26#23) +# +# `location` (the label under the incident title) and `location_coords` (the pin +# on the map) used to be two independent last-write-wins fields. Each call that +# linked could move one without the other, so they drifted apart: in the +# 2026-08-20 production dump 5 of 6 incidents were pinned somewhere other than +# the place they were labelled — `b9b4f392` said "100 South Mosher" and pinned +# `Westmed`. A missing pin reads as missing data; a wrong pin reads as fact, +# and this is a map people may act on. So the pair is resolved as ONE value, +# the pin carries the label it was geocoded from (`location_coords_source`), and +# a pin that cannot be tied back to the current label is dropped rather than +# shown. +# ───────────────────────────────────────────────────────────────────────────── + +# A place name contains a pronounceable word. Bare numbers ("49", from +# "Flames from 49"), ten-codes ("10-24") and unit designators ("5-5-2") are box +# or unit references that the extractor picked up because they followed a +# preposition. They are not places, they never geocode, and once one reaches +# `location` the summarizer repeats it as fact — incident `9d376ffe` carried +# `location: "49"` and a summary reading "A fire incident was reported at +# location 49". Two or more consecutive letters is the whole test: it keeps +# "Rt 9" and "Westmed", and rejects everything that is only digits and dashes. +_LOCATION_WORD_RE = re.compile(r"[^\W\d_]{2,}") + + +def clean_location(value) -> Optional[str]: + """ + Return a usable location label, or None when the string is not a place. + + Public because `intelligence.py` applies it at extraction time, so junk + never reaches the call document, the geocoder, or the summarizer prompt. + """ + if value is None: + return None + s = str(value).strip() + if not s or not _LOCATION_WORD_RE.search(s): + return None + return s + + +def _place_key(value) -> str: + """Case- and punctuation-blind key for comparing two location labels.""" + return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip() + + +def _same_place(a, b) -> bool: + key = _place_key(a) + return bool(key) and key == _place_key(b) + + +def _verified_pin(inc: dict) -> Optional[dict]: + """ + The incident's map pin, but only when it provably belongs to the incident's + current label. + + Incidents written before this change carry no `location_coords_source`, so + their pin cannot be tied to their label at all — and those are exactly the + ones the dump showed to be wrong 5 times in 6. Unverifiable means dropped: + the incident keeps its label and loses the pin until a call geocodes that + same label again. + """ + coords = inc.get("location_coords") + label = clean_location(inc.get("location")) + if not coords or not label: + return None + if _same_place(inc.get("location_coords_source"), label): + return coords + return None + + +def _resolve_location_pair( + inc: dict, + location: Optional[str], + location_coords: Optional[dict], +) -> dict: + """ + Resolve (label, pin) for an incident as a single value, given one linking + call's location. Always returns all three stored fields, so no code path + can update one and leave another behind. + + Rules: + • An incident with no place yet takes the first one a call gives it. + • An incident that already has a place KEEPS it. A later call naming a + different street is that call's address, not a correction of this + incident's — that is the same defect as the title (server-26#26), and + letting it win is how "100 South Mosher" replaced the Grasslands Road + search the incident actually opened on. Every mention is still kept in + `location_mentions`, which is what the map path is drawn from. + • The one permitted change is filling in a pin the incident never had, + from a later call naming the SAME place — an address that failed to + geocode once often succeeds on a cleaner transcription of it. + • A pin is only ever kept alongside the label it was geocoded from. + """ + inc_label = clean_location(inc.get("location")) + inc_pin = _verified_pin(inc) + new_label = clean_location(location) + # Coordinates come from geocoding the call's own location string, so a + # rejected label takes its coordinates with it. + new_pin = location_coords if new_label else None + + if not inc_label: + label, pin = new_label, new_pin + elif inc_pin is None and new_pin and _same_place(new_label, inc_label): + label, pin = inc_label, new_pin + else: + label, pin = inc_label, inc_pin + + return { + "location": label, + "location_coords": pin, + "location_coords_source": label if pin else None, + } + + +def _compose_title(primary_tag: str, location: Optional[str], tg_label: Optional[str]) -> str: + """Render an incident title from its event name and where it is.""" + if location and primary_tag.lower() != location.lower(): + return f"{primary_tag} at {location}" + if tg_label: + return f"{primary_tag} — {tg_label}" + return primary_tag + + +def _resolve_incident_title( + inc: dict, + tags: list[str], + incident_type: Optional[str], + location: Optional[str], + talkgroup_name: Optional[str], + talkgroup_id: Optional[int], + call_severity: Optional[str], +) -> dict: + """ + Decide whether a linking call may rename the incident (server-26#26). + + The title used to be re-derived from the newest classified call, so an + incident was named after its most recent transmission: `b9b4f392` opened on + a suspect search and was titled "Open 911 at 100 South Mosher", its third + call; `f5190670` was titled from the thirteenth of its thirteen events. + + The title now names the FOUNDING event and can only be replaced by a call + of strictly higher severity. Rationale in the commit message; in short, an + incident's identity is the event that opened it, and the one situation + where the header must change is the one where things got worse. This + mirrors `_max_severity`: monotonic, never walked back by later chatter. + + Two non-renames are still allowed, because neither replaces an event name: + • filling in a placeholder title on an incident that opened on a call + with no content tags ("Police — TGID 383"), and + • re-rendering the same event once the incident learns its address. + """ + if not incident_type: + # Routine status traffic ("10-4", "en route") never touches the title. + return {} + + content_tags = [t for t in tags if t != "auto-generated"] + primary_tag = _tag_to_title(content_tags[0]) if content_tags else None + + current_title = inc.get("title") or "" + # Incidents created before this change have no `title_tag` key at all, so + # their founding event name is unrecoverable — treat their existing title + # as the founding one rather than letting the next call claim it. + if "title_tag" in inc: + current_tag = inc.get("title_tag") + else: + current_tag = current_title or None + + current_rank = _SEVERITY_RANK.get(inc.get("title_severity") or "routine", 0) + new_rank = _SEVERITY_RANK.get(call_severity or "routine", 0) + + tg_label = ( + talkgroup_name + or (f"TGID {talkgroup_id}" if talkgroup_id else current_title.split(" — ")[-1]) + or None + ) + + if primary_tag and (not current_tag or new_rank > current_rank): + return { + "title": _compose_title(primary_tag, location, tg_label), + "title_tag": primary_tag, + "title_severity": call_severity if call_severity in _SEVERITY_RANK else "routine", + } + + # Same event as before — but the incident may only now have learned where + # it is, and the title should say so. + stored_tag = inc.get("title_tag") + if stored_tag: + retitled = _compose_title(stored_tag, location, tg_label) + if retitled != current_title: + return {"title": retitled} + return {} + + def _is_dispatch_channel(talkgroup_name: Optional[str]) -> bool: """True when the talkgroup is a shared dispatch backbone (not a tactical/working channel).""" if not talkgroup_name: @@ -567,6 +761,13 @@ async def _build_context( call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or []) call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or []) call_severity = call_doc.get("severity") or "routine" + # A string that is not a place is not a location anywhere downstream — not + # in the fit tests, not in the thin-call test, not in the LLM prompt, and + # not on the incident. Its coordinates go with it: coords are geocoded + # from this very string, so a rejected label invalidates them. + location = clean_location(location) + if location is None: + location_coords = None coords = location_coords or call_doc.get("location_coords") is_thin_call = _is_thin_call( call_units, call_vehicles, coords, tags, location, call_severity, reassignment @@ -1168,13 +1369,23 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]: ) else: # Candidate is a standalone — create master shell, demote both + # Take the master's place from ONE side, label and pin together — + # the old `parent.location or call.location` / `parent.coords or + # call.coords` pair could take the label from the parent and the + # pin from the call, which is server-26#23 in its purest form. + if clean_location(cross_parent.get("location")): + master_location = cross_parent.get("location") + master_coords = _verified_pin(cross_parent) + else: + master_location = location + master_coords = location_coords master_id = await _create_master_incident( first_child_id=existing_child_id, second_child_id=incident_id, org_id=org_id, operational_type=incident_type, - location=cross_parent.get("location") or location, - location_coords=cross_parent.get("location_coords") or coords, + location=master_location, + location_coords=master_coords, now=now, ) await _demote_to_child(existing_child_id, master_id) @@ -1531,13 +1742,16 @@ async def _update_incident( if u not in units_cleared: units_cleared.append(u) + # The incident's label and its pin are resolved together, as one value. + location = clean_location(location) + location_coords = location_coords if location else None + location_fields = _resolve_location_pair(inc, location, location_coords) + best_location = location_fields["location"] + location_mentions = list(inc.get("location_mentions") or []) if location and location not in location_mentions: location_mentions.append(location) - best_location = location or inc.get("location") - best_coords = location_coords or inc.get("location_coords") - embedding_updates = _merge_embedding_vecs(inc, call_embedding) if call_embedding else {} updates: dict = { @@ -1552,6 +1766,9 @@ async def _update_incident( "location_mentions": location_mentions, "summary_stale": True, "severity": _max_severity(inc.get("severity"), call_severity), + # Always all three, always together — writing one without the others is + # what let the label and the pin drift apart (server-26#23). + **location_fields, **embedding_updates, } @@ -1565,31 +1782,17 @@ async def _update_incident( updates["updated_at"] = _floor_at_started_at(inc, now).isoformat() else: updates["last_thin_at"] = now.isoformat() - if best_location: - updates["location"] = best_location - if best_coords: - updates["location_coords"] = best_coords - # Update incident type when a re-classified call provides a concrete type. # This handles the case where admin correction changes fire→police, etc. if incident_type and incident_type != inc.get("type"): updates["type"] = incident_type - # Re-evaluate title when a substantive call (classified incident_type) brings new tags. - # Routine status calls (type=None) do not clobber the title. - if incident_type: - content_tags = [t for t in tags if t != "auto-generated"] - primary_tag = _tag_to_title(content_tags[0]) if content_tags else None - tg_label = ( - talkgroup_name - or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split(" — ")[-1]) - ) - if primary_tag and best_location and best_coords and primary_tag.lower() != best_location.lower(): - updates["title"] = f"{primary_tag} at {best_location}" - elif primary_tag and tg_label: - updates["title"] = f"{primary_tag} — {tg_label}" - elif primary_tag: - updates["title"] = primary_tag + # The title names the founding event and only escalates — see + # _resolve_incident_title (server-26#26). + updates.update(_resolve_incident_title( + inc, tags, incident_type, best_location, + talkgroup_name, talkgroup_id, call_severity, + )) # Signal-based auto-resolve: every tracked unit has cleared, none still active. # Requires at least one unit to have explicitly signalled back-in-service so we @@ -1631,13 +1834,17 @@ async def _create_incident( or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup") ) - # Build a descriptive title from tags + location when available + # Label and pin resolve as one value, from this founding call only. + location_fields = _resolve_location_pair({}, location, location_coords) + location = location_fields["location"] + + # Build a descriptive title from tags + location when available. This is + # the incident's name for the rest of its life unless something worse + # happens on it — see _resolve_incident_title. content_tags = [t for t in tags if t != "auto-generated"] primary_tag = _tag_to_title(content_tags[0]) if content_tags else None - if primary_tag and location and location_coords and primary_tag.lower() != location.lower(): - title = f"{primary_tag} at {location}" - elif primary_tag: - title = f"{primary_tag} — {tg_label}" + if primary_tag: + title = _compose_title(primary_tag, location, tg_label) else: title = f"{_tag_to_title(incident_type)} — {tg_label}" @@ -1645,11 +1852,15 @@ async def _create_incident( "incident_id": incident_id, "org_id": org_id, "title": title, + # Which event the title names, and how bad it was judged to be. A + # later call may only take the title over by being worse than this. + # Written even when None: its absence marks a pre-server-26#26 doc. + "title_tag": primary_tag, + "title_severity": call_severity if call_severity in _SEVERITY_RANK else "routine", "incident_type": "master", # structural role; "child" set on demotion "type": incident_type, "status": "active", - "location": location, - "location_coords": location_coords, + **location_fields, "location_mentions": [location] if location else [], "call_ids": [call_id], "talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [], @@ -1710,8 +1921,7 @@ async def _create_master_incident( "incident_type": "master", "type": operational_type, "status": "active", - "location": location, - "location_coords": location_coords, + **_resolve_location_pair({}, location, location_coords), "child_incident_ids": [first_child_id, second_child_id], "parent_incident_id": None, "call_ids": [], diff --git a/drb-c2-core/app/internal/intelligence.py b/drb-c2-core/app/internal/intelligence.py index 694ed2b..0def62c 100644 --- a/drb-c2-core/app/internal/intelligence.py +++ b/drb-c2-core/app/internal/intelligence.py @@ -15,6 +15,10 @@ import re from typing import Optional from app.internal.logger import logger from app.internal import firestore as fstore +# Location validity is defined once, by the module that owns the incident's +# location/pin invariant. incident_correlator does not import this module, so +# this is not a cycle. +from app.internal.incident_correlator import clean_location _PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio. @@ -227,7 +231,12 @@ async def extract_scenes( for scene in raw_scenes: tags: list[str] = scene.get("tags") or [] incident_type: Optional[str] = scene.get("incident_type") or None - location: Optional[str] = scene.get("location") or None + # A location that is not a place ("49", from "Flames from 49") is + # rejected here, at the source: it never reaches the geocoder, the call + # document, the correlator or the summarizer prompt — which used to + # repeat it back as "A fire incident was reported at location 49". + # See incident_correlator.clean_location (server-26#23). + location: Optional[str] = clean_location(scene.get("location")) vehicles: list[str] = scene.get("vehicles") or [] units: list[str] = scene.get("units") or [] cleared_units: list[str] = scene.get("cleared_units") or [] @@ -314,8 +323,9 @@ async def extract_scenes( updates: dict = {"tags": all_tags, "severity": primary["severity"]} if primary["location"]: - updates["location"] = primary["location"] - if primary["location_coords"]: + # Both, together, always — a re-extraction that produces a new address + # must not leave the previous address's pin on the call (server-26#23). + updates["location"] = primary["location"] updates["location_coords"] = primary["location_coords"] if all_units: updates["units"] = all_units diff --git a/drb-c2-core/tests/test_incident_identity.py b/drb-c2-core/tests/test_incident_identity.py new file mode 100644 index 0000000..4213f39 --- /dev/null +++ b/drb-c2-core/tests/test_incident_identity.py @@ -0,0 +1,342 @@ +""" +An incident must not lie about what it is or where it is — server-26#23 / #26. + +Both defects come from the 2026-08-20 production dump +(CORRELATION_REVIEW_0820.md) and both are the same shape: a field of the +incident header re-derived from whichever call linked most recently. + + * `location` (the label) and `location_coords` (the map pin) were two + independent last-write-wins fields. A call could move one and not the + other, so they drifted: 5 of 6 incidents in the dump were pinned somewhere + other than the place they were labelled. `b9b4f392` said "100 South + Mosher" and pinned `Westmed`. + * `location` was never validated, so "Flames from 49" put `location: "49"` + on `9d376ffe` and the summarizer wrote "reported at location 49". + * `title` was re-derived from every classified call, so `b9b4f392` — opened + on a suspect search at 80 Grasslands Road — was named after its third + call, and `f5190670` after the thirteenth of its thirteen events. + +The rules under test: + 1. label and pin are ONE value, written together on every path; + 2. a pin is only ever kept next to the label it was geocoded from; + 3. a string with no word in it is not a place; + 4. the title names the founding event and only ever escalates. +""" +import pytest +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +from app.internal.incident_correlator import ( + _build_context, _create_incident, _update_incident, + _resolve_location_pair, _verified_pin, clean_location, +) + +NOW = datetime(2026, 8, 20, 7, 25, 0, tzinfo=timezone.utc) + +# TG 383 from the dump: "Ch 1 (Patched with 155.310)" — a shared dispatch +# backbone, which is where every one of these chains happened. +DISPATCH_TG = "Ch 1 (Patched with 155.310)" + +GRASSLANDS = {"lat": 41.0891, "lng": -73.8010} +WESTMED = {"lat": 41.0348, "lng": -73.7629} + + +# --------------------------------------------------------------------------- +# Harness — incidents are stored with merge=True, so folding each write back +# into the dict is exactly what Firestore does between calls. +# --------------------------------------------------------------------------- + +async def _create(**call) -> dict: + with patch("app.internal.incident_correlator.fstore") as mock_fstore: + mock_fstore.doc_set = AsyncMock() + await _create_incident( + call.get("call_id", "call-0"), "org-1", + call.get("incident_type", "police"), 383, DISPATCH_TG, "sys-1", + call.get("tags", []), call.get("location"), call.get("coords"), + call.get("units", []), [], None, + call.get("severity", "routine"), call.get("now", NOW), + ) + return dict(mock_fstore.doc_set.await_args.args[2]) + + +async def _link(inc: dict, **call) -> dict: + """Run one call through _update_incident, fold the write back, return it.""" + with patch("app.internal.incident_correlator.fstore") as mock_fstore: + mock_fstore.doc_set = AsyncMock() + await _update_incident( + inc, call.get("call_id", "call-n"), 383, "sys-1", + call.get("tags", []), call.get("location"), call.get("coords"), + call.get("units", []), [], None, call.get("now", NOW), + talkgroup_name=DISPATCH_TG, + incident_type=call.get("incident_type"), + call_severity=call.get("severity", "routine"), + ) + updates = dict(mock_fstore.doc_set.await_args.args[2]) + inc.update(updates) + return updates + + +def _assert_pin_matches_label(doc: dict): + """The invariant: a pin exists only alongside the label it was geocoded from.""" + if doc.get("location_coords") is not None: + assert doc.get("location"), "pin with no label" + assert doc.get("location_coords_source") == doc["location"], ( + f"pin sourced from {doc.get('location_coords_source')!r} " + f"but incident is labelled {doc['location']!r}" + ) + + +# --------------------------------------------------------------------------- +# server-26#23 — the label and the pin are one value +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_label_and_pin_stay_consistent_across_a_chain_of_calls(): + """ + Replays `b9b4f392` exactly: a suspect search at 80 Grasslands Road, then an + EMS transport to Westmed, then a brand-new open-911 dispatch at 100 South + Mosher. Production ended up labelled "100 South Mosher" and pinned at + Westmed — the label from one call, the pin from another. + """ + inc = await _create( + tags=["suspect-search"], location="80 Grasslands Road", coords=GRASSLANDS, + severity="moderate", incident_type="police", + ) + _assert_pin_matches_label(inc) + + await _link( # 07:41 — "one female to Westmed" + inc, call_id="call-ems", tags=["ems-transport"], location="Westmed", + coords=WESTMED, incident_type="ems", now=NOW + timedelta(minutes=16), + ) + _assert_pin_matches_label(inc) + + await _link( # 07:55 — "open 911 line", a different job entirely + inc, call_id="call-911", tags=["open-911"], location="100 South Mosher", + coords=None, incident_type="police", now=NOW + timedelta(minutes=30), + ) + _assert_pin_matches_label(inc) + + assert inc["location"] == "80 Grasslands Road" + assert inc["location_coords"] == GRASSLANDS + # Every place anyone named is still recorded — that is what the map path + # is drawn from; it just isn't the incident's own location. + assert inc["location_mentions"] == [ + "80 Grasslands Road", "Westmed", "100 South Mosher", + ] + + +@pytest.mark.asyncio +async def test_a_later_call_never_moves_the_pin_without_the_label(): + """The direct mechanism: coords updating on their own.""" + inc = await _create(tags=["suspect-search"], location="80 Grasslands Road", + coords=None, incident_type="police") + assert inc["location_coords"] is None + + updates = await _link(inc, tags=["ems-transport"], location="Westmed", + coords=WESTMED, incident_type="ems") + + assert updates["location"] == "80 Grasslands Road" + assert updates["location_coords"] is None + _assert_pin_matches_label(inc) + + +@pytest.mark.asyncio +async def test_a_pin_can_still_be_filled_in_for_the_same_place(): + """ + The one permitted change. Geocoding is not deterministic in practice — it + needs the node's position, an API quota and a response — so the same + address can fail on one call and resolve on the next. Filling in a pin the + incident never had is not a move; matching is deliberately by exact label, + so it can never quietly re-point at a different street. + """ + inc = await _create(tags=["welfare-check"], location="55 Hyman Hills Road", + coords=None, incident_type="police") + assert inc["location_coords"] is None + + await _link(inc, tags=["welfare-check"], location="55 Hyman Hills Road", + coords=GRASSLANDS, incident_type="police") + + assert inc["location"] == "55 Hyman Hills Road" + assert inc["location_coords"] == GRASSLANDS + _assert_pin_matches_label(inc) + + +def test_a_pin_that_cannot_be_tied_to_the_label_is_not_shown(): + """ + Every incident written before this change carries a pin with no record of + where it came from — and the dump says 5 of 6 of those are wrong. An + unverifiable pin is dropped, not displayed: a missing pin reads as missing + data, a wrong one reads as fact. + """ + legacy = {"location": "100 South Mosher", "location_coords": WESTMED} + assert _verified_pin(legacy) is None + + resolved = _resolve_location_pair(legacy, None, None) + assert resolved["location"] == "100 South Mosher" + assert resolved["location_coords"] is None + assert resolved["location_coords_source"] is None + + tagged = {"location": "Westmed", "location_coords": WESTMED, + "location_coords_source": "westmed"} + assert _verified_pin(tagged) == WESTMED + + +# --------------------------------------------------------------------------- +# server-26#23 — "49" is not a place +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("junk", [ + "49", # 9d376ffe, from "Fire received. Flames from 49." + "10-24", # a ten-code + "5-5-2", # a unit designator + " ", + "", + None, + "1", +]) +def test_bare_numbers_are_rejected_as_locations(junk): + assert clean_location(junk) is None + + +@pytest.mark.parametrize("place", [ + "80 Grasslands Road", + "Westmed", + "Rt 9", + "226 East Main Street, apartment number 1", +]) +def test_real_place_names_survive(place): + assert clean_location(place) == place + + +@pytest.mark.asyncio +async def test_a_bare_number_never_reaches_the_correlator(): + """ + Rejected at the context boundary, so it is not a location in the fit tests, + the thin-call test, the LLM prompt or the incident — and its coordinates go + with it, because they were geocoded from that very string. + """ + with patch("app.internal.incident_correlator.fstore") as mock_fstore: + mock_fstore.doc_get = AsyncMock(return_value={}) + mock_fstore.collection_list = AsyncMock(return_value=[]) + ctx = await _build_context( + call_id="call-49", units=None, vehicles=None, cleared_units=None, + location_coords={"lat": 41.0, "lng": -73.8}, reference_time=NOW, + system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG, + tags=[], incident_type="fire", location="49", + reassignment=False, create_if_new=True, + ) + assert ctx["location"] is None + assert ctx["location_coords"] is None + + +@pytest.mark.asyncio +async def test_a_bare_number_never_becomes_an_incident_location_or_title(): + inc = await _create(tags=["flames"], location="49", coords=None, + incident_type="fire") + assert inc["location"] is None + assert inc["location_coords"] is None + assert "49" not in inc["title"] + assert inc["location_mentions"] == [] + + +# --------------------------------------------------------------------------- +# server-26#26 — the title names the founding event +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_an_unrelated_later_call_does_not_rename_the_incident(): + """`b9b4f392` again, from the title's side.""" + inc = await _create(tags=["suspect-search"], location="80 Grasslands Road", + coords=GRASSLANDS, severity="moderate", incident_type="police") + assert inc["title"] == "Suspect Search at 80 Grasslands Road" + + await _link(inc, tags=["ems-transport"], location="Westmed", coords=WESTMED, + incident_type="ems", severity="routine") + await _link(inc, tags=["open-911"], location="100 South Mosher", + incident_type="police", severity="moderate") + + assert inc["title"] == "Suspect Search at 80 Grasslands Road" + assert inc["title_tag"] == "Suspect Search" + + +@pytest.mark.asyncio +async def test_routine_status_traffic_never_touches_the_title(): + inc = await _create(tags=["welfare-check"], location="55 Hyman Hills Road", + incident_type="police") + updates = await _link(inc, tags=[], incident_type=None, units=["6-Adam"]) + assert "title" not in updates + + +@pytest.mark.asyncio +async def test_a_worse_event_takes_the_title_over(): + """ + The one case where the header must change: a check-condition that turns + into a structure fire is a structure fire. Monotonic like _max_severity — + a calmer later call can never take it back. + """ + inc = await _create(tags=["check-condition"], location="226 East Main Street", + severity="minor", incident_type="police") + assert inc["title"] == "Check Condition at 226 East Main Street" + + await _link(inc, tags=["structure-fire"], incident_type="fire", severity="major") + assert inc["title"] == "Structure Fire at 226 East Main Street" + assert inc["severity"] == "major" + + await _link(inc, tags=["ems-transport"], incident_type="ems", severity="routine") + assert inc["title"] == "Structure Fire at 226 East Main Street" + + +@pytest.mark.asyncio +async def test_a_placeholder_title_is_filled_in_not_overwritten(): + """ + An incident that opened on a call with no content tags is named after its + type ("Police — "). That is a placeholder, not an event name, + so the first classified call may name it — and only the first. + """ + inc = await _create(tags=[], location=None, incident_type="police") + assert inc["title"] == f"Police — {DISPATCH_TG}" + assert inc["title_tag"] is None + + await _link(inc, tags=["vehicle-accident"], location="Airport Road", + incident_type="police", severity="minor") + assert inc["title"] == "Vehicle Accident at Airport Road" + + await _link(inc, tags=["disabled-vehicle"], location="Yonkers Avenue", + incident_type="police", severity="minor") + assert inc["title"] == "Vehicle Accident at Airport Road" + + +@pytest.mark.asyncio +async def test_the_title_picks_up_an_address_learned_later(): + """ + Same event, new information — not a rename. The founding call classified + the event but named no place; a later call on the same event does. + """ + inc = await _create(tags=["welfare-check"], location=None, incident_type="police") + assert inc["title"] == f"Welfare Check — {DISPATCH_TG}" + + await _link(inc, tags=["welfare-check"], location="55 Hyman Hills Road", + incident_type="police") + assert inc["title"] == "Welfare Check at 55 Hyman Hills Road" + assert inc["location"] == "55 Hyman Hills Road" + + +@pytest.mark.asyncio +async def test_a_legacy_incidents_title_is_not_claimed_by_the_next_call(): + """ + Incidents created before this change have no `title_tag`, so their founding + event is unrecoverable. Their existing title is treated as the founding + one rather than handed to whichever call links next. + """ + legacy = { + "incident_id": "b9b4f392", + "title": "Suspect Search at 80 Grasslands Road", + "location": "80 Grasslands Road", + "call_ids": ["call-0"], + "started_at": NOW.isoformat(), + "updated_at": NOW.isoformat(), + } + updates = await _link(legacy, tags=["open-911"], location="100 South Mosher", + incident_type="police", severity="routine") + assert "title" not in updates + assert updates["location"] == "80 Grasslands Road"