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
@@ -15,6 +15,7 @@ import re
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import area_context
|
||||
# Location validity is defined once, by the module that owns the incident's
|
||||
# location/pin invariant. incident_correlator does not import this module, so
|
||||
# this is not a cycle.
|
||||
@@ -225,6 +226,20 @@ async def extract_scenes(
|
||||
node_lat = node_doc.get("lat")
|
||||
node_lon = node_doc.get("lon")
|
||||
|
||||
# The talkgroup's own anchor and place, when an operator has described it
|
||||
# (server-26#36). This is what "where is this channel" should mean; the node
|
||||
# position below is only the fallback for a system nobody has described.
|
||||
tg_anchor: Optional[dict] = None
|
||||
tg_area: dict = {}
|
||||
if system_id:
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if system_doc:
|
||||
system_area = system_doc.get("area_context") or {}
|
||||
tg_entry = area_context.talkgroup_entry(system_doc, talkgroup_id)
|
||||
own_area = tg_entry.get("area_context") or {}
|
||||
tg_area = area_context.effective(system_area, own_area)
|
||||
tg_anchor = area_context.anchor_for(system_area, own_area)
|
||||
|
||||
processed: list[dict] = []
|
||||
for scene in raw_scenes:
|
||||
tags: list[str] = scene.get("tags") or []
|
||||
@@ -273,20 +288,27 @@ async def extract_scenes(
|
||||
# Build the most specific query possible: location + municipality + state.
|
||||
# e.g. "High Street" → "High Street, Yorktown, New York"
|
||||
# This prevents generic street names from resolving to wrong-country results.
|
||||
#
|
||||
# Prefer the place an operator actually set over the one guessed from
|
||||
# the talkgroup's name and the node's reverse-geocoded position. A name
|
||||
# like "Ossining PD" gives a municipality with no state behind it, which
|
||||
# is how a generic street name ends up resolving in the wrong half of
|
||||
# the country.
|
||||
location_coords: Optional[dict] = None
|
||||
if location and node_lat is not None and node_lon is not None:
|
||||
muni = _municipality_from_tg(talkgroup_name)
|
||||
state = await _get_node_state(node_id or "", node_lat, node_lon) if node_id else ""
|
||||
county = _node_county_cache.get(node_id or "") if node_id else ""
|
||||
parts = [location]
|
||||
if muni:
|
||||
parts.append(muni)
|
||||
if county:
|
||||
parts.append(county)
|
||||
if state:
|
||||
parts.append(state)
|
||||
if location:
|
||||
parts = [location]
|
||||
if tg_area.get("municipality") or tg_area.get("county") or tg_area.get("state"):
|
||||
parts += [tg_area[f] for f in area_context.PLACE_FIELDS if tg_area.get(f)]
|
||||
elif node_lat is not None and node_lon is not None:
|
||||
muni = _municipality_from_tg(talkgroup_name)
|
||||
state = await _get_node_state(node_id or "", node_lat, node_lon) if node_id else ""
|
||||
county = _node_county_cache.get(node_id or "") if node_id else ""
|
||||
parts += [p for p in (muni, county, state) if p]
|
||||
query = ", ".join(parts)
|
||||
location_coords = await _geocode_location(query, node_lat, node_lon)
|
||||
if tg_anchor or (node_lat is not None and node_lon is not None):
|
||||
location_coords = await _geocode_location(
|
||||
query, node_lat, node_lon, anchor=tg_anchor
|
||||
)
|
||||
|
||||
# Embed this scene's content
|
||||
scene_text = _build_scene_embed_text(
|
||||
@@ -411,12 +433,25 @@ async def _get_node_state(node_id: str, lat: float, lon: float) -> str:
|
||||
|
||||
|
||||
async def _geocode_location(
|
||||
location_str: str, node_lat: float, node_lon: float
|
||||
location_str: str,
|
||||
node_lat: Optional[float] = None,
|
||||
node_lon: Optional[float] = None,
|
||||
anchor: Optional[dict] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Geocode using Google Maps Geocoding API, biased toward the node's area.
|
||||
Returns {"lat": float, "lng": float} or None if geocoding fails or the
|
||||
result is farther than geocode_max_km from the node (wrong-jurisdiction guard).
|
||||
Geocode using Google Maps Geocoding API, biased toward the channel's area.
|
||||
|
||||
Returns {"lat": float, "lng": float}, or None if geocoding fails or the
|
||||
result lands outside the area this channel covers.
|
||||
|
||||
THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This
|
||||
used to reject anything more than geocode_max_km (40km) from the receiving
|
||||
node, which conflates an antenna with a jurisdiction: a system can span a
|
||||
county or several, so a node legitimately sits far from the area a talkgroup
|
||||
covers, and real dispatch locations were being thrown away for it. When the
|
||||
talkgroup has a resolved anchor, that is the reference and its own radius is
|
||||
the bound. Distance-from-node stays only as the fallback for a system nobody
|
||||
has described yet — it was always a stand-in for this.
|
||||
"""
|
||||
import httpx
|
||||
from app.config import settings
|
||||
@@ -425,9 +460,24 @@ async def _geocode_location(
|
||||
logger.warning("GOOGLE_MAPS_API_KEY not set — geocoding disabled")
|
||||
return None
|
||||
|
||||
if anchor:
|
||||
ref_lat, ref_lon = anchor["lat"], anchor["lng"]
|
||||
max_km = anchor["radius_km"]
|
||||
# Bias box scaled to the anchor rather than a fixed half-degree, so a
|
||||
# village biases tightly and a county loosely.
|
||||
delta = max(max_km / 111.0, 0.05)
|
||||
ref_label = "anchor"
|
||||
elif node_lat is not None and node_lon is not None:
|
||||
ref_lat, ref_lon = node_lat, node_lon
|
||||
max_km = settings.geocode_max_km
|
||||
delta = _GEO_DELTA
|
||||
ref_label = "node"
|
||||
else:
|
||||
return None
|
||||
|
||||
bounds = (
|
||||
f"{node_lat - _GEO_DELTA},{node_lon - _GEO_DELTA}"
|
||||
f"|{node_lat + _GEO_DELTA},{node_lon + _GEO_DELTA}"
|
||||
f"{ref_lat - delta},{ref_lon - delta}"
|
||||
f"|{ref_lat + delta},{ref_lon + delta}"
|
||||
)
|
||||
params = {
|
||||
"address": location_str,
|
||||
@@ -465,15 +515,18 @@ async def _geocode_location(
|
||||
return None
|
||||
loc = result["geometry"]["location"]
|
||||
lat, lng = float(loc["lat"]), float(loc["lng"])
|
||||
dist_km = _geo_dist_km(node_lat, node_lon, lat, lng)
|
||||
if dist_km > settings.geocode_max_km:
|
||||
dist_km = _geo_dist_km(ref_lat, ref_lon, lat, lng)
|
||||
if dist_km > max_km:
|
||||
logger.warning(
|
||||
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
|
||||
f"— {dist_km:.1f}km from node exceeds geocode_max_km={settings.geocode_max_km}"
|
||||
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
|
||||
)
|
||||
return None
|
||||
coords = {"lat": lat, "lng": lng}
|
||||
logger.info(f"Geocoded '{location_str}' → {coords} ({dist_km:.1f}km from node) [{location_type}]")
|
||||
logger.info(
|
||||
f"Geocoded '{location_str}' → {coords} "
|
||||
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
|
||||
)
|
||||
return coords
|
||||
except Exception as e:
|
||||
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
||||
|
||||
Reference in New Issue
Block a user