Stop an incident lying about what it is and where it is
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Successful in 2m5s
Build & Deploy / Report a failed deploy (push) Skipped

An incident header had two independently last-write-wins halves, and in the
2026-08-20 dump both were wrong at once. `b9b4f392` opened on a suspect search
at 80 Grasslands Road; it was labelled "100 South Mosher" (its third call),
pinned at `Westmed` (its second), and titled after the label. Five of six
incidents were pinned somewhere other than the place they claimed to be.

Location and pin are now one value
----------------------------------
`_resolve_location_pair()` computes `location`, `location_coords` and the new
`location_coords_source` together, and `_update_incident`/`_create_incident`/
`_create_master_incident` always write all three. There is no longer a code
path that can move one and leave another behind — including the cross-system
master, which used to take its label from the parent and its pin from the call.

The pin now carries the label it was geocoded from. `_verified_pin()` returns
it only when that source still matches the incident's current label; anything
else is dropped. That includes every pre-existing incident, whose pin has no
recorded source and therefore cannot be reconciled — which is the right
outcome, since the dump says 5 in 6 of those are wrong. A missing pin reads as
missing data; a wrong pin reads as fact, and this is a map people may act on.

An incident also keeps the first place it was given rather than the latest.
Later mentions still accumulate in `location_mentions` (what the map path is
drawn from); they just don't rename the incident's own location. The one
permitted change is filling in a pin the incident never had, from a later call
naming the exact same label — geocoding needs the node position, a quota and a
response, so the same address genuinely does fail once and resolve later.

"49" is not a place
-------------------
`clean_location()` rejects any string with no two-letter word in it, applied at
extraction (intelligence.py, before the geocoder and before the call document)
and again at the correlator's context boundary. `9d376ffe` carried
`location: "49"` from "Fire received. Flames from 49." — a box number — and its
summary asserted "A fire incident was reported at location 49". Nothing
validated that field at all, so it would have recurred.

Title: the founding event, escalation only
------------------------------------------
The title was re-derived from the newest classified call, so `f5190670` was
named after the thirteenth of its thirteen events. It now names the call that
opened the incident, recorded in `title_tag`/`title_severity`, and can only be
replaced by a call of strictly higher severity.

Three candidates were considered:

  * Newest call (status quo) — rejected. The same incident has a different name
    at different times, so a user who saw it in the rail cannot find it again,
    and the name is decided by radio timing rather than by the event.
  * Highest severity alone — rejected as the sole rule. Severity has four
    levels and most traffic sits on one of them, so ties are the common case
    and the tiebreak degrades to "newest" — the defect it was meant to fix.
  * Founding event, escalated by strictly-greater severity — chosen. An
    incident's identity is the event that opened it, so that is its default
    name and it is stable for the incident's whole life. The single case where
    the header MUST change is the one where the situation got worse: a check
    condition that becomes a structure fire is a structure fire, and the
    worst-first rail, the "Major only" filter and the map colour all exist so
    that is never missed. Requiring strictly-greater makes it monotonic, the
    same contract `_max_severity` already gives the severity field: routine
    chatter can never take the name back.

A summary-level title regenerated as a whole was rejected outright: it needs an
LLM call per incident, AI flags are off in production, and every incident today
would have no title at all.

Two renames survive, because neither replaces an event name: filling in the
placeholder title of an incident that opened on a call with no content tags
("Police — Ch 1"), and re-rendering the same event once the incident learns its
address. Incidents created before this change have no `title_tag`, so their
existing title is treated as the founding one rather than handed to whichever
call links next.

Interaction with the caps from 33a247d: `incident_max_duration_minutes` /
`incident_max_calls` bound how far an incident can drift, but they don't fix
this — `b9b4f392` was renamed by its third call, 30 minutes in, well inside
both caps. What the caps do change is the cost of being wrong in the other
direction: a founding-derived title can no longer be left describing a
four-hour chain, because there are no four-hour chains any more.

Tests
-----
tests/test_incident_identity.py, 23 cases: the b9b4f392 chain replayed
end-to-end with the label/pin invariant asserted after every link; the pin not
moving without the label; the same-label pin fill-in; an unverifiable legacy
pin dropped; bare numbers, ten-codes and unit designators rejected at
`clean_location`, at `_build_context` and at incident creation; an unrelated
later call not renaming; a worse call renaming and a calmer one not taking it
back; placeholder fill-in; address learned later; legacy title not claimed.
Each was confirmed to fail against the reverted behaviour. 171 passed.

Closes logan/server-26#23
Closes logan/server-26#26

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-08-23 01:35:00 -04:00
co-authored by Claude Opus 5
parent c7be6416f2
commit 82c88379d4
3 changed files with 599 additions and 37 deletions
+244 -34
View File
@@ -241,6 +241,200 @@ def _tag_to_title(tag: str) -> str:
return " ".join(w.capitalize() for w in tag.replace("-", " ").split()) 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: def _is_dispatch_channel(talkgroup_name: Optional[str]) -> bool:
"""True when the talkgroup is a shared dispatch backbone (not a tactical/working channel).""" """True when the talkgroup is a shared dispatch backbone (not a tactical/working channel)."""
if not talkgroup_name: 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_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_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
call_severity = call_doc.get("severity") or "routine" 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") coords = location_coords or call_doc.get("location_coords")
is_thin_call = _is_thin_call( is_thin_call = _is_thin_call(
call_units, call_vehicles, coords, tags, location, call_severity, reassignment 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: else:
# Candidate is a standalone — create master shell, demote both # 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( master_id = await _create_master_incident(
first_child_id=existing_child_id, first_child_id=existing_child_id,
second_child_id=incident_id, second_child_id=incident_id,
org_id=org_id, org_id=org_id,
operational_type=incident_type, operational_type=incident_type,
location=cross_parent.get("location") or location, location=master_location,
location_coords=cross_parent.get("location_coords") or coords, location_coords=master_coords,
now=now, now=now,
) )
await _demote_to_child(existing_child_id, master_id) await _demote_to_child(existing_child_id, master_id)
@@ -1531,13 +1742,16 @@ async def _update_incident(
if u not in units_cleared: if u not in units_cleared:
units_cleared.append(u) 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 []) location_mentions = list(inc.get("location_mentions") or [])
if location and location not in location_mentions: if location and location not in location_mentions:
location_mentions.append(location) 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 {} embedding_updates = _merge_embedding_vecs(inc, call_embedding) if call_embedding else {}
updates: dict = { updates: dict = {
@@ -1552,6 +1766,9 @@ async def _update_incident(
"location_mentions": location_mentions, "location_mentions": location_mentions,
"summary_stale": True, "summary_stale": True,
"severity": _max_severity(inc.get("severity"), call_severity), "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, **embedding_updates,
} }
@@ -1565,31 +1782,17 @@ async def _update_incident(
updates["updated_at"] = _floor_at_started_at(inc, now).isoformat() updates["updated_at"] = _floor_at_started_at(inc, now).isoformat()
else: else:
updates["last_thin_at"] = now.isoformat() 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. # Update incident type when a re-classified call provides a concrete type.
# This handles the case where admin correction changes fire→police, etc. # This handles the case where admin correction changes fire→police, etc.
if incident_type and incident_type != inc.get("type"): if incident_type and incident_type != inc.get("type"):
updates["type"] = incident_type updates["type"] = incident_type
# Re-evaluate title when a substantive call (classified incident_type) brings new tags. # The title names the founding event and only escalates — see
# Routine status calls (type=None) do not clobber the title. # _resolve_incident_title (server-26#26).
if incident_type: updates.update(_resolve_incident_title(
content_tags = [t for t in tags if t != "auto-generated"] inc, tags, incident_type, best_location,
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None talkgroup_name, talkgroup_id, call_severity,
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
# Signal-based auto-resolve: every tracked unit has cleared, none still active. # 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 # 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") 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"] content_tags = [t for t in tags if t != "auto-generated"]
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None 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(): if primary_tag:
title = f"{primary_tag} at {location}" title = _compose_title(primary_tag, location, tg_label)
elif primary_tag:
title = f"{primary_tag} — {tg_label}"
else: else:
title = f"{_tag_to_title(incident_type)} — {tg_label}" title = f"{_tag_to_title(incident_type)} — {tg_label}"
@@ -1645,11 +1852,15 @@ async def _create_incident(
"incident_id": incident_id, "incident_id": incident_id,
"org_id": org_id, "org_id": org_id,
"title": title, "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 "incident_type": "master", # structural role; "child" set on demotion
"type": incident_type, "type": incident_type,
"status": "active", "status": "active",
"location": location, **location_fields,
"location_coords": location_coords,
"location_mentions": [location] if location else [], "location_mentions": [location] if location else [],
"call_ids": [call_id], "call_ids": [call_id],
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [], "talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
@@ -1710,8 +1921,7 @@ async def _create_master_incident(
"incident_type": "master", "incident_type": "master",
"type": operational_type, "type": operational_type,
"status": "active", "status": "active",
"location": location, **_resolve_location_pair({}, location, location_coords),
"location_coords": location_coords,
"child_incident_ids": [first_child_id, second_child_id], "child_incident_ids": [first_child_id, second_child_id],
"parent_incident_id": None, "parent_incident_id": None,
"call_ids": [], "call_ids": [],
+13 -3
View File
@@ -15,6 +15,10 @@ import re
from typing import Optional from typing import Optional
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import firestore as fstore 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. _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: for scene in raw_scenes:
tags: list[str] = scene.get("tags") or [] tags: list[str] = scene.get("tags") or []
incident_type: Optional[str] = scene.get("incident_type") or None 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 [] vehicles: list[str] = scene.get("vehicles") or []
units: list[str] = scene.get("units") or [] units: list[str] = scene.get("units") or []
cleared_units: list[str] = scene.get("cleared_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"]} updates: dict = {"tags": all_tags, "severity": primary["severity"]}
if primary["location"]: if primary["location"]:
updates["location"] = primary["location"] # Both, together, always — a re-extraction that produces a new address
if primary["location_coords"]: # must not leave the previous address's pin on the call (server-26#23).
updates["location"] = primary["location"]
updates["location_coords"] = primary["location_coords"] updates["location_coords"] = primary["location_coords"]
if all_units: if all_units:
updates["units"] = all_units updates["units"] = all_units
+342
View File
@@ -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 — <talkgroup>"). 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"