#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>
193 lines
7.4 KiB
Python
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
|