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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
58efdbd6eb
commit
964343c819
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user