Correction: reject lost numbers and growth; place verifier anchors from the talkgroup name (server-26#162, server-26#160)

transcript_correction now also discards a correction that drops or changes a
bare number the recogniser heard (the live "7" -> "ShotSpotter" case) or
grows the transcript past max(4 words, 25%). place_verifier.verify() derives
an anchor from the talkgroup's own name plus the receiving node's state when
no area is configured; no state means no anchor, and a configured place
without an anchor keeps the load-bearing skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-27 20:28:29 -04:00
co-authored by Claude Opus 5.5
parent f4d78aba77
commit 7ecfca7648
3 changed files with 136 additions and 5 deletions
+57 -4
View File
@@ -23,7 +23,8 @@ permanently improves the reference data for that channel.
NO ANCHOR MEANS SKIP, NOT ACCEPT. An anchor too wide to discriminate is not
stored at all (see `area_context`), and without one this module returns
immediately. A statewide radius would confirm anything inside it, which is worse
immediately. A channel nobody described may still get one from its own
talkgroup name plus the node's state (server-26#160, `_talkgroup_anchor`). A statewide radius would confirm anything inside it, which is worse
than not checking — it looks like verification and is not.
THE FREE TIER RUNS FIRST. A sound-alike among the terms the operator already
@@ -169,6 +170,50 @@ def _known_soundalike(heard: str, area: dict) -> Optional[dict]:
return best
async def _talkgroup_anchor(
talkgroup_name: Optional[str], node_id: Optional[str],
) -> tuple[Optional[dict], str]:
"""
An anchor derived from the talkgroup's own name plus the receiving node's
state, or (None, ""). Same width rule as an operator anchor
(area_context.resolve_anchor drops anything wider than
area_anchor_max_radius_km, and caches per place for the process).
A STATE IS REQUIRED. "Springfield PD" alone geocodes to whichever
Springfield Google prefers, and an anchor in the wrong half of the
country would let Maps rewrite real local streets into sound-alikes from
there (the failure server-26#36 added `state` to prevent). The node's
reverse-geocoded state is coarse enough to be right even when the node
sits far from the area the channel covers.
"""
from app.internal import firestore as fstore
from app.internal.intelligence import _get_node_state, _municipality_from_tg
muni = _municipality_from_tg(talkgroup_name)
if not muni or not node_id:
return None, ""
node = await fstore.doc_get_cached("nodes", node_id) or {}
lat, lon = node.get("lat"), node.get("lon")
if lat is None or lon is None:
return None, ""
state = await _get_node_state(node_id, lat, lon)
if not state:
return None, ""
place = {"municipality": muni, "state": state}
resolved = await area_context.resolve_anchor(place)
if not resolved:
return None, ""
try:
anchor = {
"lat": float(resolved["center"]["lat"]),
"lng": float(resolved["center"]["lng"]),
"radius_km": float(resolved["radius_km"]),
}
except (KeyError, TypeError, ValueError):
return None, ""
return anchor, f"{muni}, {state}"
# -- Public --------------------------------------------------------------------
def _substitute(text: str, swaps: list[tuple[str, str]]) -> str:
@@ -186,6 +231,8 @@ async def verify(
tg_area: Optional[dict],
system_id: Optional[str] = None,
talkgroup_id: Optional[Any] = None,
talkgroup_name: Optional[str] = None,
node_id: Optional[str] = None,
) -> tuple[Optional[str], Optional[list[dict]]]:
"""
Check the corrector's location nouns against the talkgroup's anchor.
@@ -197,12 +244,18 @@ async def verify(
if not settings.place_verification_enabled or not locations:
return None, None
area = area_context.effective(system_area, tg_area)
anchor = area_context.anchor_for(system_area, tg_area)
suffix = _place_suffix(area)
if not anchor and not area_context.has_place(area):
# server-26#160: nobody described this channel, but its own name may
# (e.g. "New York City - NYPD Citywide 2 Patch") — the same signal
# #159 trusted in intelligence.py's geocoder. Only when an operator set
# NOTHING: a place that is set but has no anchor was too wide or is
# stale, and that skip stays load-bearing.
anchor, suffix = await _talkgroup_anchor(talkgroup_name, node_id)
if not anchor:
return None, None # load-bearing: no anchor means skip, never accept
area = area_context.effective(system_area, tg_area)
suffix = _place_suffix(area)
swaps: list[tuple[str, str]] = []
proposals: list[dict] = []