From 6875911d531204ac88f122156201825196b84123 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:28:16 +0000 Subject: [PATCH] Fix AI summary fire-status gap and NIFC perimeter sync The AI summary only read feed:fire:perimeters (NIFC), never the EONET fire entities that drive the UI fire card, so it reported a fire status gap while the dashboard showed active incidents. - summary: include entity:fire:* incidents (relevance, distance, last update) and AirNow AQI in the LLM context; label the NIFC perimeter feed as confirmed-empty vs never-synced - nifc: write an empty FeatureCollection on a successful zero-result query so consumers can distinguish no perimeters from a dead feed - nifc: uppercase entity names before stripping Wildfire/Fire suffixes so the targeted IncidentName query actually matches NIFC records - nifc: surface ArcGIS soft errors (HTTP 200 + error body) as fetch failures, escape quotes in the name query, narrow bare except https://claude.ai/code/session_01Tjq6r9HYsYY4g4BbUTQefG --- poller/pollers/nifc.py | 21 ++++++++++----- poller/pollers/summary.py | 54 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/poller/pollers/nifc.py b/poller/pollers/nifc.py index f642f8f..2dd1025 100644 --- a/poller/pollers/nifc.py +++ b/poller/pollers/nifc.py @@ -1,3 +1,4 @@ +import json import logging import math from datetime import datetime, timezone @@ -80,8 +81,6 @@ async def poll(self): } # 2. Determine which specific distant fires to fetch (targeted sync) - from bus import get_bus - import json redis = await get_bus() keys = await redis.keys("entity:fire:*") fire_names: set[str] = set() @@ -93,10 +92,12 @@ async def poll(self): ent = json.loads(raw) name = ent.get("display_name") if name and name != "Wildfire": - clean_name = name.split(',')[0].replace(" WILDFIRE", "").replace(" FIRE", "").strip() + # Uppercase BEFORE stripping suffixes — EONET titles are title-case + # ("Haystack Butte Wildfire"), and NIFC IncidentName omits the suffix. + clean_name = name.split(',')[0].upper().replace(" WILDFIRE", "").replace(" FIRE", "").strip() if clean_name: - fire_names.add(clean_name.upper()) - except: continue + fire_names.add(clean_name) + except (json.JSONDecodeError, TypeError, AttributeError): continue # Combine searches if we have distant fires to track features: list[dict] = [] @@ -105,13 +106,17 @@ async def poll(self): r1 = await client.get(_NIFC_BASE, params=spatial_params) r1.raise_for_status() data1 = r1.json() + # ArcGIS returns HTTP 200 with an "error" object on bad queries — + # treat that as a fetch failure, not a confirmed-empty result. + if isinstance(data1, dict) and data1.get("error"): + raise RuntimeError(f"ArcGIS error: {data1['error'].get('message', data1['error'])}") f1 = data1.get("features") or [] features.extend(f1) logger.debug("[nifc] spatial sync returned %d features", len(f1)) # supplement with distant named fires if any if fire_names: - names_str = ",".join([f"'{n}'" for n in fire_names]) + names_str = ",".join([f"""'{n.replace("'", "''")}'""" for n in fire_names]) name_params = { "where": f"UPPER(IncidentName) IN ({names_str})", "outFields": _FIELDS, @@ -129,7 +134,11 @@ async def poll(self): if not features: return if not features: + # Successful query with zero results is a confirmed negative — write an + # empty collection so consumers (AI summary) can distinguish "no + # perimeters" from "feed never synced". logger.info("[nifc] zero perimeters returned from ArcGIS (spatial bbox: %s)", bbox) + await set_feed("fire:perimeters", {"type": "FeatureCollection", "features": []}) return # De-duplicate by a hash of geometry or incident ID if possible, diff --git a/poller/pollers/summary.py b/poller/pollers/summary.py index 93638d4..4ff5901 100644 --- a/poller/pollers/summary.py +++ b/poller/pollers/summary.py @@ -100,7 +100,49 @@ async def _generate(self, r): else: context_parts.append("WEATHER STATUS: Data feed currently unavailable.") - # 2. Fire Activity (NIFC/EONET) + # 2a. Fire Incident Tracker (EONET entities — same data the UI fire card shows) + fire_entities: list[dict] = [] + for key in await r.keys("entity:fire:*"): + ent_raw = await r.get(key) + if not ent_raw: + continue + try: + ent = json.loads(ent_raw) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(ent, dict): + fire_entities.append(ent) + if fire_entities: + # Local (alert radius) fires first, then by distance. + fire_entities.sort(key=lambda e: ( + (e.get("identity") or {}).get("relevance") != "local", + e.get("distance_km") or 0, + )) + lines = [] + for ent in fire_entities[:10]: + relevance = (ent.get("identity") or {}).get("relevance") + tag = "ALERT (local radius)" if relevance == "local" else "WATCH (regional smoke source)" + name = _sanitise(str(ent.get("display_name") or "Wildfire"), 120) + dist = ent.get("distance_km") + dist_text = f"{round(dist)} km" if isinstance(dist, (int, float)) else "distance unknown" + updated = ent.get("last_seen") or "update time unknown" + lines.append(f"- {tag}: {name} — {dist_text}, last update {updated}") + context_parts.append("WILDFIRE INCIDENT TRACKER:\n" + "\n".join(lines)) + else: + context_parts.append( + "WILDFIRE INCIDENT TRACKER: No wildfire incidents inside the local alert or regional watch radius." + ) + + # 2b. Smoke / Air Quality (AirNow via weather feed) + raw = await r.get("feed:weather:current") + if raw: + wx = json.loads(raw) + aqi = wx.get("aqi") if isinstance(wx, dict) else None + if aqi is not None: + label = wx.get("aqi_label") or "Unknown" + context_parts.append(f"SMOKE / AIR QUALITY: AQI {aqi} ({label}).") + + # 2c. Fire Perimeters (NIFC mapped polygons) raw = await r.get("feed:fire:perimeters") if raw: payload = json.loads(raw) @@ -114,11 +156,15 @@ async def _generate(self, r): acres = props.get("acres") acres_text = f"{acres} acres" if acres is not None else "acreage unknown" lines.append(f"- {name}: {state} ({acres_text})") - context_parts.append("WILDFIRE ACTIVITY:\n" + "\n".join(lines)) + context_parts.append("MAPPED FIRE PERIMETERS (NIFC):\n" + "\n".join(lines)) else: - context_parts.append("FIRE STATUS: No active wildfire perimeters in regional radius.") + context_parts.append( + "MAPPED FIRE PERIMETERS (NIFC): Confirmed zero mapped perimeters in the regional query area." + ) else: - context_parts.append("FIRE STATUS: Data feed currently unavailable.") + context_parts.append( + "MAPPED FIRE PERIMETERS (NIFC): Perimeter feed has not synced; rely on the incident tracker above for fire status." + ) # 3. Traffic Impacts (ODOT/Real-time) raw = await r.get("feed:traffic:incidents")