Stop an incident lying about what it is and where it is
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:
co-authored by
Claude Opus 5
parent
c7be6416f2
commit
82c88379d4
@@ -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": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user