area_context v2 + Maps place verification (server-26#36, #37)
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

#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:
Logan Cusano
2026-08-23 16:43:59 -04:00
co-authored by Claude Opus 5
parent 58efdbd6eb
commit 964343c819
14 changed files with 2052 additions and 167 deletions
+305
View File
@@ -0,0 +1,305 @@
"""
Unit tests for the area_context schema and anchor (server-26#36).
Three properties carry the real risk:
* NULLABILITY IS THE MECHANISM. Which scope an operator fills is their
declaration of how homogeneous the system is. Merging must let a talkgroup
narrow the system without dropping what the system already said — a
talkgroup that sets only a town must still inherit the state, or "Ossining"
is nationally ambiguous again.
* NO ANCHOR IS BETTER THAN A USELESS ONE. An anchor wider than
area_anchor_max_radius_km, or one whose resolved_from no longer matches the
place it came from, must read as ABSENT. Verification then skips. Treating
either as usable would rubber-stamp any location while looking like a check.
* THE CLIENT DOES NOT WRITE SERVER FIELDS. The systems form sends
config.talkgroups[] in full; taking it verbatim destroys the resolved anchor
and the pending queue, which is the same bug as the ten_codes wipe.
"""
import pytest
from unittest.mock import AsyncMock, patch
from app.internal import area_context as ac
SYSTEM_AREA = {
"county": "Westchester",
"state": "New York",
"local_knowledge": [{"term": "Route 9", "meaning": "state highway"}],
"center": {"lat": 41.1, "lng": -73.8},
"radius_km": 30.0,
"resolved_from": "|westchester|new york",
"resolved_at": "2026-08-23T00:00:00+00:00",
}
TG_AREA = {
"municipality": "Ossining",
"local_knowledge": [{"term": "Sing Sing", "meaning": "state prison"}],
"center": {"lat": 41.16, "lng": -73.86},
"radius_km": 6.0,
"resolved_from": "ossining|westchester|new york",
"resolved_at": "2026-08-23T00:00:00+00:00",
}
# -- Merging -------------------------------------------------------------------
def test_talkgroup_narrows_without_dropping_the_system():
merged = ac.effective(SYSTEM_AREA, TG_AREA)
assert merged["municipality"] == "Ossining"
assert merged["county"] == "Westchester"
assert merged["state"] == "New York", "the state must survive the narrowing"
def test_talkgroup_knowledge_ranks_first_and_dedupes():
system = {"local_knowledge": [{"term": "Route 9"}, {"term": "Metro-North"}]}
tg = {"local_knowledge": [{"term": "route 9", "meaning": "the local name"}]}
merged = ac.effective(system, tg)
assert [e["term"] for e in merged["local_knowledge"]] == ["route 9", "Metro-North"]
assert merged["local_knowledge"][0]["meaning"] == "the local name"
def test_empty_at_both_scopes_is_legal():
assert ac.effective(None, None) == {}
assert ac.effective({}, {}) == {}
def test_bare_strings_are_accepted_as_terms():
"""roads[]/landmarks[] from the old shape, and anything a model returns."""
assert ac.normalize_local_knowledge(["Route 9", "", "Route 9", 7]) == [{"term": "Route 9"}]
def test_pre_36_roads_and_landmarks_are_read_forward():
"""
Real systems still have the old shape stored. Dropping it the day this
shipped would silently discard ground truth an operator already entered.
"""
legacy = {"county": "Westchester", "roads": ["Route 9"], "landmarks": ["Sing Sing"]}
merged = ac.effective(legacy, None)
assert [e["term"] for e in merged["local_knowledge"]] == ["Route 9", "Sing Sing"]
assert ac.normalize(legacy) == {
"county": "Westchester",
"local_knowledge": [{"term": "Route 9"}, {"term": "Sing Sing"}],
}, "and the next save writes them in the new shape"
def test_normalize_drops_client_sent_server_fields():
out = ac.normalize({"municipality": " Ossining ", "radius_km": 5000, "center": {"lat": 0}})
assert out == {"municipality": "Ossining"}
# -- Anchor selection ----------------------------------------------------------
def test_talkgroup_anchor_wins():
anchor = ac.anchor_for(SYSTEM_AREA, TG_AREA)
assert anchor == {"lat": 41.16, "lng": -73.86, "radius_km": 6.0}
def test_system_anchor_used_when_talkgroup_sets_no_place():
anchor = ac.anchor_for(SYSTEM_AREA, {"local_knowledge": [{"term": "Post 4"}]})
assert anchor == {"lat": 41.1, "lng": -73.8, "radius_km": 30.0}
def test_no_anchor_when_nothing_is_configured():
assert ac.anchor_for({}, {}) is None
def test_stale_anchor_reads_as_absent():
"""
Someone edited the town and the refresh has not run yet. The stored centre
is for the OLD place, so using it would validate locations against an area
the channel no longer covers.
"""
stale = {**TG_AREA, "municipality": "Croton"}
assert ac.anchor_for(SYSTEM_AREA, stale) is None
def test_anchor_key_ignores_case_and_padding():
assert ac.anchor_key({"municipality": " OSSINING "}) == ac.anchor_key({"municipality": "ossining"})
# -- Anchor resolution ---------------------------------------------------------
def _maps(viewport_span_deg: float):
"""A geocode response whose viewport spans roughly the given degrees."""
payload = {
"status": "OK",
"results": [{
"geometry": {
"location": {"lat": 41.0, "lng": -73.0},
"viewport": {
"northeast": {"lat": 41.0 + viewport_span_deg, "lng": -73.0 + viewport_span_deg},
"southwest": {"lat": 41.0 - viewport_span_deg, "lng": -73.0 - viewport_span_deg},
},
}
}],
}
class _Resp:
def raise_for_status(self): pass
def json(self): return payload
class _Client:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def get(self, *a, **k): return _Resp()
return patch("httpx.AsyncClient", lambda *a, **k: _Client())
@pytest.fixture(autouse=True)
def _clear_cache():
ac._anchor_cache.clear()
with patch.object(ac.settings, "google_maps_api_key", "test-key"):
yield
ac._anchor_cache.clear()
@pytest.mark.asyncio
async def test_small_place_produces_an_anchor():
with _maps(0.05):
anchor = await ac.resolve_anchor({"municipality": "Ossining", "state": "New York"})
assert anchor is not None
assert anchor["radius_km"] < 10
assert anchor["resolved_from"] == "ossining||new york"
@pytest.mark.asyncio
async def test_statewide_place_produces_no_anchor():
"""
A radius that covers a state would confirm any location inside it. Storing
it would make the geocode check worse than useless — it would look like
verification and pass everything.
"""
with _maps(4.0), patch.object(ac.settings, "area_anchor_max_radius_km", 60.0):
assert await ac.resolve_anchor({"state": "Colorado"}) is None
@pytest.mark.asyncio
async def test_no_place_never_calls_maps():
with patch("httpx.AsyncClient") as client:
assert await ac.resolve_anchor({"local_knowledge": [{"term": "Post 4"}]}) is None
client.assert_not_called()
@pytest.mark.asyncio
async def test_refresh_skips_scopes_whose_place_is_unchanged():
doc = {"area_context": SYSTEM_AREA, "config": {"talkgroups": [{"id": 1, "area_context": TG_AREA}]}}
with patch("httpx.AsyncClient") as client:
assert await ac.refresh_anchors(doc) == {}
client.assert_not_called()
@pytest.mark.asyncio
async def test_editing_the_system_place_re_anchors_its_talkgroups():
"""
A talkgroup's anchor derives from its EFFECTIVE place, so changing the
system's county silently changes what every talkgroup should be anchored to.
"""
doc = {
"area_context": {"county": "Putnam", "state": "New York"},
"config": {"talkgroups": [{"id": 1, "area_context": {"municipality": "Ossining"}}]},
}
with _maps(0.05):
patch_out = await ac.refresh_anchors(doc)
tg = patch_out["config"]["talkgroups"][0]
assert tg["area_context"]["resolved_from"] == "ossining|putnam|new york"
assert tg["area_context"]["center"]["lat"] == 41.0
# -- Client writes -------------------------------------------------------------
def test_merge_config_preserves_the_anchor_and_the_pending_queue():
existing = {"talkgroups": [{
"id": 9048,
"area_context": TG_AREA,
ac.PENDING_KEY: [{"term": "Snowden Avenue"}],
}]}
# What the systems form actually sends: no anchor, no pending queue.
incoming = {"talkgroups": [{"id": 9048, "name": "Ossining PD",
"area_context": {"municipality": "Ossining"}}]}
merged = ac.merge_config(incoming, existing)
tg = merged["talkgroups"][0]
assert tg["area_context"]["center"] == TG_AREA["center"]
assert tg[ac.PENDING_KEY] == [{"term": "Snowden Avenue"}]
assert tg["name"] == "Ossining PD", "the client still owns the fields it owns"
def test_merge_config_drops_an_emptied_area():
existing = {"talkgroups": [{"id": 1, "area_context": TG_AREA}]}
merged = ac.merge_config({"talkgroups": [{"id": 1}]}, existing)
assert "area_context" not in merged["talkgroups"][0]
# -- Pending terms -------------------------------------------------------------
def _store(doc):
saved = {}
async def _get(_col, _id):
return doc
async def _update(_col, _id, patch):
saved.update(patch)
return saved, patch.multiple(
"app.internal.firestore", doc_get=AsyncMock(side_effect=_get),
doc_update=AsyncMock(side_effect=_update),
)
@pytest.mark.asyncio
async def test_pending_terms_land_on_the_talkgroup():
doc = {"config": {"talkgroups": [{"id": 9048}]}, "vocabulary": []}
saved, store = _store(doc)
with store:
assert await ac.add_pending("sys-1", 9048, [{"term": "Snowden Avenue"}]) == 1
assert saved["config"]["talkgroups"][0][ac.PENDING_KEY][0]["term"] == "Snowden Avenue"
assert "vocabulary" not in saved, "nothing writes to the system"
@pytest.mark.asyncio
async def test_already_known_terms_are_not_re_proposed():
doc = {
"vocabulary": ["Metro-North"],
"area_context": {"local_knowledge": [{"term": "Route 9"}]},
"config": {"talkgroups": [{"id": 9048, "local_knowledge_pending": [{"term": "Sing Sing"}]}]},
}
saved, store = _store(doc)
with store:
queued = await ac.add_pending("sys-1", 9048, [
{"term": "route 9"}, {"term": "Metro-North"}, {"term": "sing sing"},
])
assert queued == 0
assert saved == {}
@pytest.mark.asyncio
async def test_approving_writes_to_the_talkgroup_and_never_the_system():
"""
Blast radius: the same term at system level misleads every channel on the
system, including one 400km away on a statewide system.
"""
doc = {"config": {"talkgroups": [{"id": 9048, ac.PENDING_KEY: [
{"term": "Snowden Avenue", "meaning": "residential street"}]}]}}
saved, store = _store(doc)
with store:
assert await ac.resolve_pending("sys-1", 9048, "snowden avenue", approve=True) is True
tg = saved["config"]["talkgroups"][0]
assert tg["area_context"]["local_knowledge"] == [
{"term": "Snowden Avenue", "meaning": "residential street"}
]
assert tg[ac.PENDING_KEY] == []
assert "vocabulary" not in saved and "area_context" not in saved
@pytest.mark.asyncio
async def test_dismissing_adds_nothing():
doc = {"config": {"talkgroups": [{"id": 9048, ac.PENDING_KEY: [{"term": "Optum"}]}]}}
saved, store = _store(doc)
with store:
assert await ac.resolve_pending("sys-1", 9048, "Optum", approve=False) is True
tg = saved["config"]["talkgroups"][0]
assert tg[ac.PENDING_KEY] == []
assert not (tg.get("area_context") or {}).get("local_knowledge")
+192
View File
@@ -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
@@ -21,14 +21,24 @@ from app.internal import transcript_correction as tc
SYSTEM = {
"vocabulary": ["Croton-Harmon", "Metro-North"],
"ten_codes": {"10-4": "acknowledged", "10-13": "officer needs assistance"},
"area_context": {"county": "Westchester", "roads": ["Route 9", "Saw Mill Parkway"]},
"area_context": {
"county": "Westchester",
"state": "New York",
"local_knowledge": [
{"term": "Route 9", "meaning": "north-south state highway"},
{"term": "Saw Mill Parkway"},
],
},
"config": {
"talkgroups": [
{
"id": 9048,
"name": "Ossining - Police Dispatch",
"vocabulary": ["Snowden Avenue", "Croton-Harmon"],
"area_context": {"municipality": "Ossining", "landmarks": ["Sing Sing"]},
"area_context": {
"municipality": "Ossining",
"local_knowledge": [{"term": "Sing Sing", "meaning": "state prison"}],
},
},
{"id": 9600, "name": "Harrison - Police/EMS Dispatch"},
{"id": 9563, "ten_codes": {"10-4": "on scene"}},
@@ -83,6 +93,7 @@ async def test_talkgroup_without_own_data_inherits_system():
ctx = await tc.resolve_context("sys-1", 9600)
assert ctx["vocabulary"] == ["Croton-Harmon", "Metro-North"]
assert any("Westchester" in line for line in ctx["area_lines"])
assert ctx["area"].get("municipality") is None, "inherits, invents nothing"
@pytest.mark.asyncio
@@ -105,7 +116,10 @@ async def test_missing_scope_is_not_an_error(system_id, tgid):
async def test_unconfigured_system_yields_empty_context():
with _system(doc=None):
ctx = await tc.resolve_context("sys-1", 9048)
assert ctx == {"vocabulary": [], "ten_codes": {}, "area_lines": []}
assert ctx == {
"vocabulary": [], "ten_codes": {}, "area_lines": [],
"area": {}, "system_area": {}, "tg_area": {},
}
# ── Correction behaviour ────────────────────────────────────────────────────
@@ -207,5 +221,7 @@ async def test_reference_data_reaches_the_prompt():
await tc.correct("c1", "x y z w", None, system_id="sys-1",
talkgroup_id=9048, talkgroup_name="Ossining - Police Dispatch")
p = seen["prompt"]
assert "Snowden Avenue" in p and "Sing Sing" in p and "Ossining - Police Dispatch" in p
assert "Snowden Avenue" in p and "Ossining - Police Dispatch" in p
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