diff --git a/drb-c2-core/app/internal/place_verifier.py b/drb-c2-core/app/internal/place_verifier.py index d83394b..ebbd191 100644 --- a/drb-c2-core/app/internal/place_verifier.py +++ b/drb-c2-core/app/internal/place_verifier.py @@ -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] = [] diff --git a/drb-c2-core/app/internal/transcript_correction.py b/drb-c2-core/app/internal/transcript_correction.py index e1a25bb..2f09fd4 100644 --- a/drb-c2-core/app/internal/transcript_correction.py +++ b/drb-c2-core/app/internal/transcript_correction.py @@ -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"(? "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 [] diff --git a/drb-c2-core/tests/test_transcript_correction.py b/drb-c2-core/tests/test_transcript_correction.py index 060328e..dabd279 100644 --- a/drb-c2-core/tests/test_transcript_correction.py +++ b/drb-c2-core/tests/test_transcript_correction.py @@ -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