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] = []
@@ -116,11 +116,37 @@ def _dedupe(items: list[str]) -> list[str]:
# mishearing this pass exists to fix (server-26#162).
_CODE_TOKEN_RE = re.compile(r"\b\d{1,3}(?:-\d{1,3})+\b")
# Bare numbers (the rest of server-26#162): the same call had a bare "7"
# turned into "ShotSpotter". A unit number, a post, a house number or a count
# the recogniser DID hear must survive, in order. A number may still be
# added, because a sound-alike can legitimately be one ("back to Rose" ->
# "back to Route 9"). "4th" is not bare (it is a word).
_BARE_NUMBER_RE = re.compile(r"(?<![\w-])\d+(?![\w-])")
# "NEVER add information" is the other prompt rule nothing checked. A
# correction substitutes sound-alikes, so it may shift the word count a little
# ("Holmes Street and 4th and Rowe" -> "Home Street and Forest Ave"); growing
# by more than this is the model writing, not correcting.
_MAX_ADDED_WORDS = 4
_MAX_GROWTH_RATIO = 0.25
def _code_tokens(text: str) -> list[str]:
return _CODE_TOKEN_RE.findall(text or "")
def _lost_numbers(original: str, corrected: str) -> bool:
"""True unless every bare number of `original` is still in `corrected`, in order."""
remaining = iter(_BARE_NUMBER_RE.findall(corrected or ""))
return not all(any(n == m for m in remaining) for n in _BARE_NUMBER_RE.findall(original or ""))
def _grew(original: str, corrected: str) -> bool:
n_orig = len((original or "").split())
n_new = len((corrected or "").split())
return n_new - n_orig > max(_MAX_ADDED_WORDS, int(n_orig * _MAX_GROWTH_RATIO))
def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
"""The config.talkgroups[] entry for this talkgroup, or {}."""
if talkgroup_id is None:
@@ -241,6 +267,7 @@ async def correct(
system_id: Optional[str] = None,
talkgroup_id: Optional[int] = None,
talkgroup_name: Optional[str] = None,
node_id: Optional[str] = None,
) -> tuple[Optional[str], Optional[list[dict]], bool]:
"""
Second-opinion pass over a transcript.
@@ -330,6 +357,8 @@ async def correct(
context.get("tg_area"),
system_id=system_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
node_id=node_id,
)
except Exception as e:
logger.warning(f"Place verification failed for call {call_id}: {e}")
@@ -354,6 +383,18 @@ async def correct(
f"discarding the joined correction"
)
corrected = None
if corrected is not None and _lost_numbers(text, corrected):
logger.warning(
f"Transcript correction for call {call_id} dropped or changed a number — "
f"discarding the joined correction"
)
corrected = None
if corrected is not None and _grew(text, corrected):
logger.warning(
f"Transcript correction for call {call_id} grew the transcript "
f"({len(text.split())} -> {len(corrected.split())} words) — discarding the joined correction"
)
corrected = None
if corrected_segments is not None:
for seg, orig in zip(corrected_segments, segments or []):
if _code_tokens(seg["text"]) != _code_tokens(orig.get("text", "")):
@@ -363,6 +404,13 @@ async def correct(
)
corrected_segments = None
break
if _lost_numbers(orig.get("text", ""), seg["text"]) or _grew(orig.get("text", ""), seg["text"]):
logger.warning(
f"Transcript correction for call {call_id} grew a segment or dropped "
f"a number in one — discarding segment corrections"
)
corrected_segments = None
break
if corrected or corrected_segments or not_speech:
changed = raw.get("changed") or []
@@ -260,7 +260,10 @@ async def test_segment_code_change_discards_segments_only():
"segments": ["Headquarters, 10-13.", "Show it out to Ossining.", "360 north, back to Route 9."],
}
with _system(), _gemini(payload):
text, segs, _ = await tc.correct("c1", "x y z w", SEGS, system_id="sys-1", talkgroup_id=9048)
# A realistic joined original (code-free, like the correction): a
# placeholder like "x y z w" now trips the server-26#162 growth guard.
text, segs, _ = await tc.correct("c1", "Shout out to Optum, back to Rose.", SEGS,
system_id="sys-1", talkgroup_id=9048)
assert segs is None
assert text == "Show it out to Ossining, back to Route 9."
@@ -279,3 +282,30 @@ async def test_reference_data_reaches_the_prompt():
assert "Sing Sing — state prison" in p, "a term without its meaning is half the information"
assert "Ossining, Westchester, New York" in p, "state must reach the prompt (server-26#36)"
assert "10-13=officer needs assistance" in p
# ── server-26#162, the rest: bare numbers and invented words ────────────────
@pytest.mark.asyncio
async def test_a_bare_number_turned_into_a_word_is_rejected():
"""The live case: a bare "7" came back as "ShotSpotter"."""
payload = {"corrected": "Car ShotSpotter on Home Street and Forest Ave."}
with _system(), _gemini(payload):
text, _, _ = await tc.correct("c1", "Car 7 on Holmes Street and 4th and Rowe.", None, system_id="sys-1")
assert text is None
@pytest.mark.asyncio
async def test_a_place_fix_that_keeps_its_numbers_still_passes():
payload = {"corrected": "Car 7 on Home Street and Forest Ave."}
with _system(), _gemini(payload):
text, _, _ = await tc.correct("c1", "Car 7 on Holmes Street and 4th and Rowe.", None, system_id="sys-1")
assert text == "Car 7 on Home Street and Forest Ave."
@pytest.mark.asyncio
async def test_a_correction_that_writes_new_sentences_is_rejected():
payload = {"corrected": "Car 7 on Main Street. Suspect is armed and fled northbound in a red sedan."}
with _system(), _gemini(payload):
text, _, _ = await tc.correct("c1", "Car 7 on Mane Street.", None, system_id="sys-1")
assert text is None