Extraction: model in config, chatter gate, geocode cache (server-26#298, server-26#104, server-26#88)
EXTRACTION_MODEL / EMBEDDING_MODEL replace the gpt-4o-mini and text-embedding-3-small literals. gate_chatter (classify_chatter with roll_call narrowed to whole-transcript roll calls) skips correction, extraction, embedding and geocoding for housekeeping, writes skip_reason=non_event_chatter and still runs the free clearance/plate backstops; upload.py's no-scene fallback admits it like transcript_too_short. Ships OFF (CHATTER_GATE_ENABLED =false): a gated call has no units, so it takes the no-fit-check thin attach, and #127 requires a clean live shadow window first. In-process TTL cache (24h, negatives 1h, 5000 entries, LRU) in front of the Geocoding API, keyed by address+bounds; holds coordinates only, inside the Maps 30-day lat/lng caching limit; errors and key/quota statuses not cached. #104: the no-scene fallback passes the call doc's severity. #88: resolved/reassignment parsed by _llm_bool, not bool(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d485eae41a
commit
240bbee5a4
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
server-26#298 — geocode result cache in front of the Google Geocoding API,
|
||||
and server-26#160 — place verification gets an anchor from the talkgroup's
|
||||
own name when nobody configured one.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import app.internal.intelligence as intel
|
||||
from app.config import settings
|
||||
from app.internal import place_verifier as pv
|
||||
|
||||
ANCHOR = {"lat": 41.16, "lng": -73.86, "radius_km": 10.0}
|
||||
|
||||
|
||||
class _Counter:
|
||||
def __init__(self, payload=None, exc=None):
|
||||
self.calls = 0
|
||||
self.payload = payload
|
||||
self.exc = exc
|
||||
|
||||
def patch(self):
|
||||
outer = self
|
||||
|
||||
class _Resp:
|
||||
def raise_for_status(self):
|
||||
if outer.exc:
|
||||
raise outer.exc
|
||||
|
||||
def json(self):
|
||||
return outer.payload
|
||||
|
||||
class _Client:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return False
|
||||
|
||||
async def get(self, *a, **k):
|
||||
outer.calls += 1
|
||||
return _Resp()
|
||||
|
||||
return patch("httpx.AsyncClient", lambda *a, **k: _Client())
|
||||
|
||||
|
||||
def _ok(lat=41.161, lng=-73.861):
|
||||
return {"status": "OK", "results": [{"geometry": {
|
||||
"location": {"lat": lat, "lng": lng}, "location_type": "ROOFTOP"}}]}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _key():
|
||||
with patch.object(settings, "google_maps_api_key", "test-key"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeat_address_is_one_request():
|
||||
c = _Counter(_ok())
|
||||
with c.patch():
|
||||
a = await intel._geocode_location("12 Main Street, Ossining", anchor=ANCHOR)
|
||||
b = await intel._geocode_location("12 main street, ossining ", anchor=ANCHOR)
|
||||
assert a == b == {"lat": 41.161, "lng": -73.861, "location_type": "ROOFTOP"}
|
||||
assert c.calls == 1
|
||||
assert intel.geocode_cache_stats == {"hits": 1, "misses": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_answer_is_still_judged_against_the_caller_anchor():
|
||||
c = _Counter(_ok())
|
||||
with c.patch():
|
||||
assert await intel._geocode_location("12 Main Street", anchor=ANCHOR)
|
||||
far = {"lat": 0.0, "lng": 0.0, "radius_km": 10.0}
|
||||
# Different bounds -> different key, so a separate request — never a
|
||||
# cached answer leaking past a different area's distance check.
|
||||
assert await intel._geocode_location("12 Main Street", anchor=far) is None
|
||||
assert c.calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_results_is_cached_but_errors_are_not():
|
||||
c = _Counter({"status": "ZERO_RESULTS", "results": []})
|
||||
with c.patch():
|
||||
await intel._geocode_location("Cool Parts, Illinois", anchor=ANCHOR)
|
||||
await intel._geocode_location("Cool Parts, Illinois", anchor=ANCHOR)
|
||||
assert c.calls == 1
|
||||
|
||||
denied = _Counter({"status": "REQUEST_DENIED"})
|
||||
with denied.patch():
|
||||
await intel._geocode_location("Snowden Avenue", anchor=ANCHOR)
|
||||
await intel._geocode_location("Snowden Avenue", anchor=ANCHOR)
|
||||
assert denied.calls == 2, "a key/quota failure is not an answer about the address"
|
||||
|
||||
boom = _Counter(_ok(), exc=RuntimeError("503"))
|
||||
with boom.patch():
|
||||
await intel._geocode_location("Croton Point", anchor=ANCHOR)
|
||||
await intel._geocode_location("Croton Point", anchor=ANCHOR)
|
||||
assert boom.calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_expiry_and_size_bound():
|
||||
c = _Counter(_ok())
|
||||
with c.patch(), patch.object(settings, "geocode_cache_ttl_seconds", 0):
|
||||
await intel._geocode_location("12 Main Street", anchor=ANCHOR)
|
||||
await intel._geocode_location("12 Main Street", anchor=ANCHOR)
|
||||
assert c.calls == 2, "TTL 0 disables the cache"
|
||||
|
||||
c = _Counter(_ok())
|
||||
with c.patch(), patch.object(settings, "geocode_cache_max_entries", 2):
|
||||
for street in ("A Street", "B Street", "C Street"):
|
||||
await intel._geocode_location(street, anchor=ANCHOR)
|
||||
assert len(intel._geocode_cache) == 2
|
||||
|
||||
|
||||
# ── server-26#160 ───────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconfigured_channel_gets_an_anchor_from_its_talkgroup_name():
|
||||
resolved = {"center": {"lat": 40.71, "lng": -74.0}, "radius_km": 25.0}
|
||||
seen = {}
|
||||
|
||||
async def _geo(query, anchor):
|
||||
seen["query"], seen["anchor"] = query, anchor
|
||||
return {"lat": 40.7, "lng": -74.0}
|
||||
|
||||
with patch.object(pv.settings, "place_verification_enabled", True), \
|
||||
patch.object(intel.fstore, "doc_get_cached", AsyncMock(return_value={"lat": 41.2, "lon": -73.8})), \
|
||||
patch.object(intel, "_get_node_state", AsyncMock(return_value="New York")), \
|
||||
patch.object(pv.area_context, "resolve_anchor", AsyncMock(return_value=resolved)) as res, \
|
||||
patch.object(pv, "_geocode_in_anchor", _geo):
|
||||
await pv.verify("c1", "Respond to Lexington Avenue", None, ["Lexington Avenue"],
|
||||
{}, {}, talkgroup_name="New York City PD", node_id="node-1")
|
||||
res.assert_awaited_once_with({"municipality": "New York City", "state": "New York"})
|
||||
assert seen["anchor"] == {"lat": 40.71, "lng": -74.0, "radius_km": 25.0}
|
||||
assert seen["query"] == "Lexington Avenue, New York City, New York"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_state_means_no_derived_anchor():
|
||||
"""'Springfield PD' alone could anchor anywhere — skip, never guess."""
|
||||
with patch.object(pv.settings, "place_verification_enabled", True), \
|
||||
patch.object(intel.fstore, "doc_get_cached", AsyncMock(return_value={"lat": 41.2, "lon": -73.8})), \
|
||||
patch.object(intel, "_get_node_state", AsyncMock(return_value="")), \
|
||||
patch.object(pv, "_geocode_in_anchor") as geo:
|
||||
out = await pv.verify("c1", "Respond to Main Street", None, ["Main Street"],
|
||||
{}, {}, talkgroup_name="Springfield PD", node_id="node-1")
|
||||
assert out == (None, None)
|
||||
geo.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configured_place_without_anchor_still_skips():
|
||||
"""A place that is set but too wide (no anchor stored) keeps the
|
||||
load-bearing skip; the talkgroup-name fallback is only for unset areas."""
|
||||
with patch.object(pv.settings, "place_verification_enabled", True), \
|
||||
patch.object(pv, "_talkgroup_anchor", AsyncMock()) as derived, \
|
||||
patch.object(pv, "_geocode_in_anchor") as geo:
|
||||
out = await pv.verify("c1", "Main Street", None, ["Main Street"],
|
||||
{"state": "Colorado"}, {}, talkgroup_name="Denver PD", node_id="n")
|
||||
assert out == (None, None)
|
||||
derived.assert_not_awaited()
|
||||
geo.assert_not_called()
|
||||
Reference in New Issue
Block a user