Files
server-26/drb-c2-core/tests/test_place_verifier.py
T
Logan CusanoandClaude Opus 5 964343c819
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
area_context v2 + Maps place verification (server-26#36, #37)
#36 — the correction pass shipped in 58efdbd was right, its reference-data
shape was not. One shape now, at both scopes, every field nullable:

  area_context: { municipality?, county?, state?,
                  center?, radius_km?, resolved_from?, resolved_at?,
                  local_knowledge?: [{term, meaning}] }

`state` closes the ambiguity that made "Ossining" a national guess.
`local_knowledge` replaces roads[]/landmarks[], which could not hold
intersections, schools or nicknames and carried no meanings — `11-X-ray` is
useless alone, `11-X-ray — MTA PD patrol unit` is what a corrector can act on.
Pre-#36 roads[]/landmarks[] are read forward as bare terms so nothing an
operator already entered is lost.

Nullability is the mechanism: which scope gets filled is the operator's
declaration of how homogeneous the system is. One town — fill it once at system
level. Statewide — leave it blank and fill each talkgroup.

The backend owns the derived anchor. PUT /systems/{id} merges config.talkgroups[]
against what is stored instead of writing the client's blob verbatim, which
would have erased the anchor and the pending queue — the same defect as the
ten_codes wipe.

#37 — Maps as a verifier, not as prompt stuffing. The corrector emits its
location nouns; each is geocoded against the talkgroup's anchor, and on a miss
we look for a sound-alike that does resolve there, correct to it, and propose
{term, meaning} to that talkgroup. Cost scales with location nouns, not calls.

No anchor means SKIP. An area too wide to discriminate stores no anchor at all,
because a statewide radius would confirm anything inside it — verification that
passes everything is worse than none, since it reads as a check in the data.

Also re-anchors _geocode_location, which rejected results >40km from the NODE
(server-26#6). An antenna is not a jurisdiction; distance-from-node was always
a stand-in for the anchor and is now only the fallback.

The induction loop proposes at talkgroup level and never promotes. Blast
radius: a wrong term on a channel misleads that channel, the same term
system-wide misleads one 400km away on a statewide system.

38 new tests; 240 pass. Frontend typechecks clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:43:59 -04:00

193 lines
7.4 KiB
Python

"""
Unit tests for Maps-based place verification (server-26#37).
The property that matters most is the one that looks like a no-op: WITHOUT AN
ANCHOR, NOTHING HAPPENS. A system whose area is too wide to discriminate stores
no anchor, and verification must then skip entirely rather than accept whatever
geocodes. A check that passes everything is worse than no check, because it
reads as verification in the logs and in the data.
After that: a candidate may only rewrite a transcript if it actually sounds like
what was heard. Places Text Search will return the nearest plausible business
for any garbage string, so the API answering at all is not evidence.
"""
import pytest
from unittest.mock import AsyncMock, patch
from app.internal import place_verifier as pv
ANCHOR_AREA = {
"municipality": "Ossining",
"county": "Westchester",
"state": "New York",
"local_knowledge": [{"term": "Snowden Avenue", "meaning": "residential street"}],
"center": {"lat": 41.16, "lng": -73.86},
"radius_km": 6.0,
"resolved_from": "ossining|westchester|new york",
}
SEGS = [{"start": 0.0, "end": 1.0, "text": "Shout out to Optum."},
{"start": 1.0, "end": 2.0, "text": "Copy that."}]
@pytest.fixture(autouse=True)
def _enabled():
with patch.object(pv.settings, "place_verification_enabled", True), \
patch.object(pv.settings, "google_maps_api_key", "test-key"):
yield
def _geocode(result):
return patch.object(pv, "_geocode_in_anchor", AsyncMock(return_value=result))
def _places(result):
return patch.object(pv, "_places_soundalike", AsyncMock(return_value=result))
# -- Phonetics -----------------------------------------------------------------
@pytest.mark.parametrize("heard, real", [
("Snowden Avenue", "Snowdon Ave"),
("5 acre", "5-baker"),
("why vac", "YVAC"),
("Croton Ave", "Croton Avenue"),
])
def test_real_mishearings_score_above_the_threshold(heard, real):
assert pv.sounds_like(heard, real) >= pv.settings.place_soundalike_min_ratio
@pytest.mark.parametrize("heard, unrelated", [
("Optum", "Ossining"),
("Cool Parts", "Croton Point"),
])
def test_unrelated_names_score_below_it(heard, unrelated):
assert pv.sounds_like(heard, unrelated) < pv.settings.place_soundalike_min_ratio
# -- The skip path -------------------------------------------------------------
@pytest.mark.asyncio
async def test_no_anchor_means_skip_not_accept():
"""A statewide system stores no anchor. Nothing may be checked or rewritten."""
with patch.object(pv, "_geocode_in_anchor") as geo:
out = await pv.verify("c1", "text here", SEGS, ["Optum"], {"state": "Colorado"}, {})
assert out == (None, None)
geo.assert_not_called()
@pytest.mark.asyncio
async def test_no_locations_means_no_requests():
with patch.object(pv, "_geocode_in_anchor") as geo:
assert await pv.verify("c1", "t", None, [], ANCHOR_AREA, {}) == (None, None)
geo.assert_not_called()
@pytest.mark.asyncio
async def test_disabled_by_setting():
with patch.object(pv.settings, "place_verification_enabled", False), \
patch.object(pv, "_geocode_in_anchor") as geo:
assert await pv.verify("c1", "t", None, ["Optum"], ANCHOR_AREA, {}) == (None, None)
geo.assert_not_called()
# -- The accept path -----------------------------------------------------------
@pytest.mark.asyncio
async def test_a_place_that_resolves_inside_the_anchor_is_left_alone():
with _geocode({"lat": 41.16, "lng": -73.86}), _places(None) as places:
out = await pv.verify("c1", "Units to Snowden Avenue.", None,
["Snowden Avenue"], ANCHOR_AREA, {})
assert out == (None, None)
places.assert_not_called() # a hit must not cost a second request
@pytest.mark.asyncio
async def test_the_query_carries_the_full_place():
seen = {}
async def capture(query, anchor):
seen["query"] = query
return {"lat": 41.16, "lng": -73.86}
with patch.object(pv, "_geocode_in_anchor", capture):
await pv.verify("c1", "t", None, ["High Street"], ANCHOR_AREA, {})
assert seen["query"] == "High Street, Ossining, Westchester, New York"
# -- The correction path -------------------------------------------------------
@pytest.mark.asyncio
async def test_known_term_is_preferred_and_costs_nothing():
"""
A sound-alike the operator already entered is both free and more trustworthy
than anything Maps guesses, so it must be tried before any request goes out.
"""
with _geocode(None), _places(None) as places, \
patch.object(pv.area_context, "add_pending", AsyncMock()) as add:
text, segs = await pv.verify(
"c1", "Units to Snowdon Ave.", None, ["Snowdon Ave"], ANCHOR_AREA, {}
)
assert text == "Units to Snowden Avenue."
places.assert_not_called()
add.assert_not_called() # already known — nothing to propose
@pytest.mark.asyncio
async def test_a_maps_soundalike_is_applied_and_proposed_to_the_talkgroup():
candidate = {"term": "Croton Point", "meaning": "Croton Point Ave, Croton NY", "score": 0.8}
with _geocode(None), _places(candidate), \
patch.object(pv.area_context, "add_pending", AsyncMock(return_value=1)) as add:
text, segs = await pv.verify(
"c1", "Respond to Cool Parts.", None, ["Cool Parts"], ANCHOR_AREA, {},
system_id="sys-1", talkgroup_id=9048,
)
assert text == "Respond to Croton Point."
args = add.await_args.args
assert args[0] == "sys-1" and args[1] == 9048
assert args[2][0]["term"] == "Croton Point"
assert args[2][0]["source_call_ids"] == ["c1"]
@pytest.mark.asyncio
async def test_nothing_plausible_leaves_the_transcript_untouched():
"""
An invented name with no real counterpart nearby stays as it is. Guessing
would put a fabricated location into the incident record, which is the
outcome this whole pass exists to avoid.
"""
with _geocode(None), _places(None):
assert await pv.verify("c1", "Shout out to Optum.", SEGS,
["Optum"], ANCHOR_AREA, {}) == (None, None)
@pytest.mark.asyncio
async def test_segments_are_corrected_alongside_the_joined_text():
"""Extraction reads numbered segments, so a joined-only fix reaches nothing."""
with _geocode(None), _places(None), \
patch.object(pv.area_context, "add_pending", AsyncMock()):
text, segs = await pv.verify(
"c1", "Shout out to Snowdon Ave. Copy that.",
[{"start": 0.0, "end": 1.0, "text": "Shout out to Snowdon Ave."},
{"start": 1.0, "end": 2.0, "text": "Copy that."}],
["Snowdon Ave"], ANCHOR_AREA, {},
)
assert segs is not None
assert segs[0]["text"] == "Shout out to Snowden Avenue."
assert segs[0]["start"] == 0.0, "timing survives untouched"
assert segs[1]["text"] == "Copy that."
@pytest.mark.asyncio
async def test_a_geocoder_failure_never_breaks_the_transcript():
with patch.object(pv, "_geocode_in_anchor", AsyncMock(side_effect=RuntimeError("boom"))):
assert await pv.verify("c1", "t here", None, ["Optum"], ANCHOR_AREA, {}) == (None, None)
@pytest.mark.asyncio
async def test_only_a_bounded_number_of_nouns_is_checked():
with patch.object(pv.settings, "place_verify_max_per_call", 2), \
patch.object(pv, "_geocode_in_anchor", AsyncMock(return_value={"lat": 41.16, "lng": -73.86})) as geo:
await pv.verify("c1", "t", None, ["a", "b", "c", "d"], ANCHOR_AREA, {})
assert geo.await_count == 2