@@ -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