diff --git a/drb-c2-core/app/config.py b/drb-c2-core/app/config.py index 272c5fe..098ff2d 100644 --- a/drb-c2-core/app/config.py +++ b/drb-c2-core/app/config.py @@ -21,6 +21,9 @@ class Settings(BaseSettings): openai_api_key: Optional[str] = None stt_model: str = "gpt-4o-transcribe" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe + # Google Maps (geocoding) + google_maps_api_key: Optional[str] = None + # Gemini (intelligence extraction, embeddings, incident summaries) gemini_api_key: Optional[str] = None summary_interval_minutes: int = 2 # how often the summary loop runs diff --git a/drb-c2-core/app/internal/intelligence.py b/drb-c2-core/app/internal/intelligence.py index 87c42a4..6a833de 100644 --- a/drb-c2-core/app/internal/intelligence.py +++ b/drb-c2-core/app/internal/intelligence.py @@ -52,11 +52,8 @@ System: {system_id} Talkgroup: {talkgroup_name} {ten_codes_block}{vocabulary_block}{transcript_block}""" -# Nominatim viewbox half-width in degrees (~11 km at mid-latitudes) -_GEO_DELTA = 0.5 # ~55 km bias radius; viewbox used as preference, not hard bound - -# node_id → {"county": str, "state": str} from one-time reverse geocode -_node_place_cache: dict[str, dict] = {} +# Geographic bias radius for geocoding — half-width in degrees (~55 km) +_GEO_DELTA = 0.5 # Police/law-enforcement phonetic alphabet words (APCO + NATO). # A run of 5+ of these in a transcript is a strong Whisper hallucination signal. @@ -197,18 +194,13 @@ async def extract_scenes( if incident_type in ("unknown", "other", ""): incident_type = None - # Geocode this scene's location + # Geocode this scene's location. + # Include the municipality from the talkgroup name to help Google + # resolve ambiguous local names (e.g. "Pinebrook" → "Pinebrook, Yorktown"). location_coords: Optional[dict] = None if location and node_lat is not None and node_lon is not None: - place = await _get_node_place(node_id, node_lat, node_lon) - muni = _municipality_from_tg(talkgroup_name) - county = place.get("county", "") - state = place.get("state", "") - # Build hint from most specific to least: municipality → county → state. - # Including county prevents common street names (e.g. "East Main Street") - # from resolving to a wrong county when the address is ambiguous. - hint_parts = [p for p in [muni, county, state] if p] - query = f"{location}, {', '.join(hint_parts)}" if hint_parts else location + muni = _municipality_from_tg(talkgroup_name) + query = f"{location}, {muni}" if muni else location location_coords = await _geocode_location(query, node_lat, node_lon) # Embed this scene's content @@ -287,89 +279,52 @@ async def _geocode_location( location_str: str, node_lat: float, node_lon: float ) -> Optional[dict]: """ - Geocode a location string using Nominatim, biased toward the node's area. + 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). """ import httpx from app.config import settings - viewbox = ( - f"{node_lon - _GEO_DELTA},{node_lat - _GEO_DELTA}," - f"{node_lon + _GEO_DELTA},{node_lat + _GEO_DELTA}" + if not settings.google_maps_api_key: + logger.warning("GOOGLE_MAPS_API_KEY not set — geocoding disabled") + return None + + bounds = ( + f"{node_lat - _GEO_DELTA},{node_lon - _GEO_DELTA}" + f"|{node_lat + _GEO_DELTA},{node_lon + _GEO_DELTA}" ) params = { - "q": location_str, - "format": "json", - "limit": 1, - "viewbox": viewbox, - "bounded": 0, # viewbox biases results but doesn't hard-clip them + "address": location_str, + "bounds": bounds, + "region": "us", + "key": settings.google_maps_api_key, } - headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"} try: async with httpx.AsyncClient(timeout=5.0) as client: r = await client.get( - "https://nominatim.openstreetmap.org/search", + "https://maps.googleapis.com/maps/api/geocode/json", params=params, - headers=headers, - ) - r.raise_for_status() - results = r.json() - if results: - lat = float(results[0]["lat"]) - lng = float(results[0]["lon"]) - dist_km = _geo_dist_km(node_lat, node_lon, lat, lng) - if dist_km > settings.geocode_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}" - ) - return None - coords = {"lat": lat, "lng": lng} - logger.info(f"Geocoded '{location_str}' → {coords} ({dist_km:.1f}km from node)") - return coords - except Exception as e: - logger.warning(f"Geocoding failed for '{location_str}': {e}") - return None - - -async def _get_node_place(node_id: str, lat: float, lon: float) -> dict: - """ - Reverse geocode the node's position once to extract county + state. - Uses zoom=10 so Nominatim returns county-level granularity, which is - included in geocoding queries to prevent common street names from resolving - to a wrong county (e.g. "East Main Street" in Orange vs. Westchester). - Result is cached for the process lifetime — nodes don't move. - Returns dict with "county" and "state" keys (empty string if not found). - """ - if node_id in _node_place_cache: - return _node_place_cache[node_id] - - import httpx - headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"} - place: dict = {"county": "", "state": ""} - try: - async with httpx.AsyncClient(timeout=5.0) as client: - r = await client.get( - "https://nominatim.openstreetmap.org/reverse", - params={"lat": lat, "lon": lon, "format": "json", "zoom": 10}, - headers=headers, ) r.raise_for_status() data = r.json() - addr = data.get("address", {}) - place["county"] = addr.get("county", "") - place["state"] = addr.get("state", "") + if data.get("status") != "OK" or not data.get("results"): + return None + loc = data["results"][0]["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: + 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}" + ) + return None + coords = {"lat": lat, "lng": lng} + logger.info(f"Geocoded '{location_str}' → {coords} ({dist_km:.1f}km from node)") + return coords except Exception as e: - logger.warning(f"Node place reverse geocode failed: {e}") - - if place["county"] or place["state"]: - _node_place_cache[node_id] = place - logger.info( - f"Node {node_id} reverse-geocoded: county={place['county']!r}, " - f"state={place['state']!r}" - ) - return place + logger.warning(f"Geocoding failed for '{location_str}': {e}") + return None def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]: diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index f0bd722..ea24d3f 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -326,12 +326,31 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate const [mapInstance, setMapInstance] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [agoClock, setAgoClock] = useState(0); + const [radarEpoch, setRadarEpoch] = useState(() => Date.now()); + const [clockStr, setClockStr] = useState(() => + new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) + ); useEffect(() => { const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000); return () => clearInterval(id); }, []); + // Radar tiles are static once loaded — force remount every 5 min to refresh + useEffect(() => { + const id = setInterval(() => setRadarEpoch(Date.now()), 5 * 60 * 1000); + return () => clearInterval(id); + }, []); + + // Live clock for TOC situational awareness + useEffect(() => { + const id = setInterval(() => + setClockStr(new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })), + 1000 + ); + return () => clearInterval(id); + }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); @@ -419,9 +438,20 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate - {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet (no API key) */} + {/* Overlay: Traffic — Google Maps traffic layer */} + + + + + {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} )} + {/* ── Clock — bottom-left for TOC situational awareness ───────────────── */} +
+ {clockStr} +
+ {/* ── Legend — bottom-right to avoid incident panel on left ────────────── */}
Online
@@ -480,6 +515,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
{incidents.map((inc) => { const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other; + const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null; + const unitCount = inc.units?.length ?? 0; return (