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:
co-authored by
Claude Opus 5.5
parent
f4d78aba77
commit
7ecfca7648
@@ -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
|
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
|
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.
|
than not checking — it looks like verification and is not.
|
||||||
|
|
||||||
THE FREE TIER RUNS FIRST. A sound-alike among the terms the operator already
|
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
|
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 --------------------------------------------------------------------
|
# -- Public --------------------------------------------------------------------
|
||||||
|
|
||||||
def _substitute(text: str, swaps: list[tuple[str, str]]) -> str:
|
def _substitute(text: str, swaps: list[tuple[str, str]]) -> str:
|
||||||
@@ -186,6 +231,8 @@ async def verify(
|
|||||||
tg_area: Optional[dict],
|
tg_area: Optional[dict],
|
||||||
system_id: Optional[str] = None,
|
system_id: Optional[str] = None,
|
||||||
talkgroup_id: Optional[Any] = None,
|
talkgroup_id: Optional[Any] = None,
|
||||||
|
talkgroup_name: Optional[str] = None,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
) -> tuple[Optional[str], Optional[list[dict]]]:
|
) -> tuple[Optional[str], Optional[list[dict]]]:
|
||||||
"""
|
"""
|
||||||
Check the corrector's location nouns against the talkgroup's anchor.
|
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:
|
if not settings.place_verification_enabled or not locations:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
area = area_context.effective(system_area, tg_area)
|
||||||
anchor = area_context.anchor_for(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:
|
if not anchor:
|
||||||
return None, None # load-bearing: no anchor means skip, never accept
|
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]] = []
|
swaps: list[tuple[str, str]] = []
|
||||||
proposals: list[dict] = []
|
proposals: list[dict] = []
|
||||||
|
|
||||||
|
|||||||
@@ -116,11 +116,37 @@ def _dedupe(items: list[str]) -> list[str]:
|
|||||||
# mishearing this pass exists to fix (server-26#162).
|
# mishearing this pass exists to fix (server-26#162).
|
||||||
_CODE_TOKEN_RE = re.compile(r"\b\d{1,3}(?:-\d{1,3})+\b")
|
_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]:
|
def _code_tokens(text: str) -> list[str]:
|
||||||
return _CODE_TOKEN_RE.findall(text or "")
|
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:
|
def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
|
||||||
"""The config.talkgroups[] entry for this talkgroup, or {}."""
|
"""The config.talkgroups[] entry for this talkgroup, or {}."""
|
||||||
if talkgroup_id is None:
|
if talkgroup_id is None:
|
||||||
@@ -241,6 +267,7 @@ async def correct(
|
|||||||
system_id: Optional[str] = None,
|
system_id: Optional[str] = None,
|
||||||
talkgroup_id: Optional[int] = None,
|
talkgroup_id: Optional[int] = None,
|
||||||
talkgroup_name: Optional[str] = None,
|
talkgroup_name: Optional[str] = None,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
) -> tuple[Optional[str], Optional[list[dict]], bool]:
|
) -> tuple[Optional[str], Optional[list[dict]], bool]:
|
||||||
"""
|
"""
|
||||||
Second-opinion pass over a transcript.
|
Second-opinion pass over a transcript.
|
||||||
@@ -330,6 +357,8 @@ async def correct(
|
|||||||
context.get("tg_area"),
|
context.get("tg_area"),
|
||||||
system_id=system_id,
|
system_id=system_id,
|
||||||
talkgroup_id=talkgroup_id,
|
talkgroup_id=talkgroup_id,
|
||||||
|
talkgroup_name=talkgroup_name,
|
||||||
|
node_id=node_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Place verification failed for call {call_id}: {e}")
|
logger.warning(f"Place verification failed for call {call_id}: {e}")
|
||||||
@@ -354,6 +383,18 @@ async def correct(
|
|||||||
f"discarding the joined correction"
|
f"discarding the joined correction"
|
||||||
)
|
)
|
||||||
corrected = None
|
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:
|
if corrected_segments is not None:
|
||||||
for seg, orig in zip(corrected_segments, segments or []):
|
for seg, orig in zip(corrected_segments, segments or []):
|
||||||
if _code_tokens(seg["text"]) != _code_tokens(orig.get("text", "")):
|
if _code_tokens(seg["text"]) != _code_tokens(orig.get("text", "")):
|
||||||
@@ -363,6 +404,13 @@ async def correct(
|
|||||||
)
|
)
|
||||||
corrected_segments = None
|
corrected_segments = None
|
||||||
break
|
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:
|
if corrected or corrected_segments or not_speech:
|
||||||
changed = raw.get("changed") or []
|
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."],
|
"segments": ["Headquarters, 10-13.", "Show it out to Ossining.", "360 north, back to Route 9."],
|
||||||
}
|
}
|
||||||
with _system(), _gemini(payload):
|
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 segs is None
|
||||||
assert text == "Show it out to Ossining, back to Route 9."
|
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 "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 "Ossining, Westchester, New York" in p, "state must reach the prompt (server-26#36)"
|
||||||
assert "10-13=officer needs assistance" in p
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user