Skip to content

Commit 951bd73

Browse files
author
iCAD Dispatch
committed
Add geocoding bounds and cities management to address extraction settings
Backend: - Add bounds_min_lat/max_lat/min_lng/max_lng to address extraction settings - Add geocoding_cities table and CRUD functions (get/add/update/delete/reorder/bulk) - Update AddressExtractionSettings dataclass with bounds and cities fields - Add cities CRUD API endpoints and bounds/compute endpoint Frontend: - Add bounds input fields and inline Leaflet map picker to Address Extraction tab - Add 'Compute from calls' and 'Fit map to values' buttons for bounds - Add Geocoding Cities table with add/edit/delete/reorder (mirrors Regions UI) - Add city modals and event handlers in edit_systems.js Migrations: - 029_add_geocode_bounds.sql - 030_add_geocoding_cities.sql
1 parent 756e259 commit 951bd73

9 files changed

Lines changed: 1420 additions & 27 deletions

File tree

lib/address_extractor_module.py

Lines changed: 99 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ class AddressExtractionSettings:
5959
geocode_state: Optional[str] = None
6060
geocode_city: Optional[str] = None
6161

62+
# Phase 1: Bounding box for Google Maps viewport bias
63+
bounds_min_lat: Optional[float] = None
64+
bounds_max_lat: Optional[float] = None
65+
bounds_min_lng: Optional[float] = None
66+
bounds_max_lng: Optional[float] = None
67+
68+
# Phase 2: Service area cities for LLM hints (ordered list)
69+
geocode_cities: Optional[List[str]] = None
70+
6271
# Regions table (list of dicts from DB/API: state_code, county_name, priority, ...)
6372
regions: Optional[List[Dict[str, Any]]] = None
6473

@@ -73,6 +82,13 @@ def from_system_row(system_row: Dict[str, Any]) -> "AddressExtractionSettings":
7382
cfg = (system_row or {}).get("address_extraction") or {}
7483
regions = cfg.get("regions") or []
7584

85+
# Parse bounds from float or None
86+
def _f(val):
87+
try:
88+
return float(val) if val is not None else None
89+
except (ValueError, TypeError):
90+
return None
91+
7692
return AddressExtractionSettings(
7793
enabled=bool(int(cfg.get("enabled") or 0)),
7894
openai_api_key=cfg.get("openai_api_key"),
@@ -81,6 +97,11 @@ def from_system_row(system_row: Dict[str, Any]) -> "AddressExtractionSettings":
8197
geocode_country=cfg.get("geocode_country") or cfg.get("country"),
8298
geocode_state=cfg.get("geocode_state") or cfg.get("state"),
8399
geocode_city=cfg.get("geocode_city") or cfg.get("city"),
100+
bounds_min_lat=_f(cfg.get("bounds_min_lat")),
101+
bounds_max_lat=_f(cfg.get("bounds_max_lat")),
102+
bounds_min_lng=_f(cfg.get("bounds_min_lng")),
103+
bounds_max_lng=_f(cfg.get("bounds_max_lng")),
104+
geocode_cities=cfg.get("geocode_cities") or [],
84105
regions=regions,
85106
)
86107

@@ -194,6 +215,8 @@ def __init__(
194215
regions: Optional[Dict[str, List[str]]] = None,
195216
city_hint: Optional[str] = None,
196217
state_hint: Optional[str] = None,
218+
bounds: Optional[Tuple[Tuple[float, float], Tuple[float, float]]] = None,
219+
region: Optional[str] = None,
197220
timeout: int = 10,
198221
logger: Optional[logging.Logger] = None,
199222
):
@@ -206,6 +229,8 @@ def __init__(
206229
regions: Dict mapping state codes to county lists
207230
city_hint: Optional city to help filter city-level results
208231
state_hint: Optional state to help filter city-level results
232+
bounds: Optional ((sw_lat, sw_lng), (ne_lat, ne_lng)) viewport bias
233+
region: Optional ccTLD region bias (e.g. 'ca', 'us')
209234
timeout: Request timeout in seconds
210235
logger: Custom logger instance
211236
"""
@@ -216,6 +241,8 @@ def __init__(
216241
self.country = (country or "us").lower()
217242
self.city_hint = (city_hint or "").strip()
218243
self.state_hint = (state_hint or "").strip()
244+
self.bounds = bounds
245+
self.region = (region or "").strip().lower()
219246
self.timeout = timeout
220247

221248
# Derived values
@@ -227,9 +254,11 @@ def __init__(
227254
self._validate_config()
228255

229256
self.log.info(
230-
"AddressGeocoder initialized: states=%s, counties=%d",
257+
"AddressGeocoder initialized: states=%s, counties=%d, bounds=%s, region=%s",
231258
self.target_states,
232259
len(self.target_counties),
260+
"yes" if self.bounds else "no",
261+
self.region or "none",
233262
)
234263

235264
def _validate_config(self) -> None:
@@ -298,6 +327,16 @@ def geocode_once(query: str) -> Optional[dict]:
298327
"components": components,
299328
}
300329

330+
# Phase 1: Add viewport bounds bias
331+
if self.bounds:
332+
sw_lat, sw_lng = self.bounds[0]
333+
ne_lat, ne_lng = self.bounds[1]
334+
params["bounds"] = f"{sw_lat},{sw_lng}|{ne_lat},{ne_lng}"
335+
336+
# Phase 3: Add region (ccTLD) bias
337+
if self.region:
338+
params["region"] = self.region
339+
301340
try:
302341
response = requests.get(
303342
endpoint,
@@ -595,7 +634,8 @@ def extract_address(
595634
self,
596635
transcript: str,
597636
*,
598-
town_hint: Optional[str] = None,
637+
town_hints: Optional[List[str]] = None,
638+
town_hint: Optional[str] = None, # legacy alias
599639
county_hint: Optional[str] = None,
600640
state_hint: Optional[str] = None,
601641
country_hint: str = "US",
@@ -605,7 +645,8 @@ def extract_address(
605645
606646
Args:
607647
transcript: The dispatch transcript text
608-
town_hint: The town/city to use as a default when completing addresses
648+
town_hints: List of cities/towns in the service area (Phase 2)
649+
town_hint: Legacy single city hint (falls back to town_hints[0])
609650
county_hint: Expected county
610651
state_hint: Expected state
611652
country_hint: Expected country (default "US")
@@ -618,9 +659,14 @@ def extract_address(
618659
self.log.info("AddressExtractorLLM: empty transcript, skipping")
619660
return None
620661

662+
# Normalize legacy single hint to list
663+
hints = town_hints or []
664+
if not hints and town_hint:
665+
hints = [town_hint]
666+
621667
prompt = self._build_prompt(
622668
text,
623-
town_hint=town_hint,
669+
town_hints=hints,
624670
county_hint=county_hint,
625671
state_hint=state_hint,
626672
country_hint=country_hint,
@@ -641,7 +687,7 @@ def extract_address(
641687
return None
642688

643689
# Filter out generic city/state-only responses
644-
if town_hint and self._is_generic_city_response(raw, town_hint, state_hint):
690+
if town_hints and self._is_generic_city_response(raw, town_hints, state_hint):
645691
self.log.info(
646692
"AddressExtractorLLM: filtered generic city/state response: %s",
647693
raw,
@@ -682,7 +728,7 @@ def _build_prompt(
682728
self,
683729
transcript: str,
684730
*,
685-
town_hint: Optional[str],
731+
town_hints: List[str],
686732
county_hint: Optional[str],
687733
state_hint: Optional[str],
688734
country_hint: str,
@@ -696,15 +742,16 @@ def _build_prompt(
696742
counties_str = ", ".join(self.target_counties) if self.target_counties else county_hint or "Not specified"
697743

698744
town_section = ""
699-
if town_hint:
745+
if town_hints:
746+
hints_str = ", ".join(f'"{h}"' for h in town_hints[:20]) # cap at 20 to keep prompt size reasonable
700747
town_section = f"""
701-
TOWN HINT (NOT A HARD FILTER)
702-
- The dispatch/talkgroup is associated with "{town_hint}".
703-
- Use "{town_hint}" ONLY as a hint to complete an address when:
748+
SERVICE AREA CITIES (NOT A HARD FILTER)
749+
- The following cities/towns are within this dispatch service area: {hints_str}.
750+
- Use these ONLY as hints to complete an address when:
704751
• The transcript clearly gives a street / intersection / place name
705752
• BUT does NOT clearly specify a city/town.
706-
- If the transcript clearly mentions a different city/town, use THAT city/town instead of "{town_hint}".
707-
- Do NOT restrict yourself to "{town_hint}" only. Any city/town INSIDE the service region is acceptable.
753+
- If the transcript clearly mentions a different city/town, use THAT city/town instead.
754+
- Do NOT restrict yourself to these cities only. Any city/town INSIDE the service region is acceptable.
708755
"""
709756

710757
return f"""You are an assistant that extracts and completes addresses from first responder dispatch transcripts.
@@ -820,27 +867,27 @@ def _is_no_address_response(s: str) -> bool:
820867
@staticmethod
821868
def _is_generic_city_response(
822869
response: str,
823-
town_hint: Optional[str],
870+
town_hints: List[str],
824871
state_hint: Optional[str],
825872
) -> bool:
826873
"""
827-
Check if LLM just returned the generic city/state with no street.
874+
Check if LLM just returned a generic city/state with no street.
828875
This indicates it couldn't find a real address.
829876
"""
830-
if not town_hint:
877+
if not town_hints:
831878
return False
832879

833880
response_clean = response.strip()
834881

835-
# Match patterns like "Sayre, PA" or just "Sayre"
836-
patterns = [
837-
rf"^{re.escape(town_hint)}$",
838-
rf"^{re.escape(town_hint)},\s*{re.escape(state_hint or '')}$",
839-
]
840-
841-
for pattern in patterns:
842-
if re.match(pattern, response_clean, re.IGNORECASE):
843-
return True
882+
for hint in town_hints:
883+
# Match patterns like "Sayre, PA" or just "Sayre"
884+
patterns = [
885+
rf"^{re.escape(hint)}$",
886+
rf"^{re.escape(hint)},\s*{re.escape(state_hint or '')}$",
887+
]
888+
for pattern in patterns:
889+
if re.match(pattern, response_clean, re.IGNORECASE):
890+
return True
844891

845892
return False
846893

@@ -918,13 +965,29 @@ def __init__(
918965
if not target_states and settings.geocode_state:
919966
target_states = [settings.geocode_state.strip().upper()]
920967

968+
# Build bounds tuple if all four corners are present
969+
bounds = None
970+
if (settings.bounds_min_lat is not None and
971+
settings.bounds_max_lat is not None and
972+
settings.bounds_min_lng is not None and
973+
settings.bounds_max_lng is not None):
974+
bounds = (
975+
(settings.bounds_min_lat, settings.bounds_min_lng),
976+
(settings.bounds_max_lat, settings.bounds_max_lng),
977+
)
978+
979+
# Phase 3: derive region from country (ccTLD bias)
980+
region = (settings.geocode_country or "us").lower()
981+
921982
# Instantiate geocoder from settings (Google Maps only)
922983
self.geocoder = AddressGeocoder(
923984
google_api_key=settings.google_maps_api_key,
924985
country=(settings.geocode_country or "us").lower(),
925986
regions=region_map or None,
926987
city_hint=settings.geocode_city,
927988
state_hint=settings.geocode_state,
989+
bounds=bounds,
990+
region=region,
928991
timeout=10,
929992
logger=self.log,
930993
)
@@ -981,11 +1044,21 @@ def extract_and_geocode(
9811044
)
9821045
return {"extracted": None, "geocoded": None}
9831046

984-
town_hint = town_hint_override or self.settings.geocode_city
1047+
# Build town hints list: override + configured cities
1048+
town_hints = []
1049+
if town_hint_override:
1050+
town_hints.append(town_hint_override)
1051+
if self.settings.geocode_cities:
1052+
for city in self.settings.geocode_cities:
1053+
if city and city not in town_hints:
1054+
town_hints.append(city)
1055+
# Fallback to legacy single city if nothing else
1056+
if not town_hints and self.settings.geocode_city:
1057+
town_hints.append(self.settings.geocode_city)
9851058

9861059
addr = self.llm.extract_address(
9871060
transcript,
988-
town_hint=town_hint,
1061+
town_hints=town_hints,
9891062
county_hint=None, # optional, could be wired from settings if needed
9901063
state_hint=self.settings.geocode_state,
9911064
country_hint=self.settings.geocode_country or "US",

0 commit comments

Comments
 (0)