kredd25 commited on
Commit
fa21d71
·
1 Parent(s): 719cc9b

v0.14: regional_risk_agent — FEMA NRI multi-hazard at county level

Browse files

Broadens FlutIQ from flood-only to multi-hazard. Every county in the US
now gets a calibrated risk profile across 18 hazards (wildfire, hurricane,
tornado, earthquake, drought, heat wave, etc.) at the neighborhood level
from FEMA's National Risk Index — the canonical source.

The win that matters: NRI's data was previously gated behind FEMA's
click-through CSV download. Found the exposed ArcGIS REST endpoint
behind RAPT (Resilience Analysis and Planning Tool):

https://services.arcgis.com/XG15cJAlne2vxtgt/arcgis/rest/services/
National_Risk_Index_Counties/FeatureServer/0/query

Free, no auth, point queries, 467 fields per county, full national
coverage. Verified live across 6 geographies with very different
hazard fingerprints:

Chicago: Cold wave + Winter weather + Tornado + Inland flood
LA: Earthquake + Wildfire + Inland flood (Community
Resilience: Very Low — interesting demo signal)
Houston: Hurricane + Tornado + Inland flood + Lightning
Miami: Hurricane + Coastal flood + Lightning
Boulder: Wildfire + Lightning + Hail (Front Range fingerprint)
Anchorage: Avalanche + Earthquake + Volcanic activity

Gemma 4 ran on Chicago and produced authentically Chicago-specific
headline-hazard explanations:
'extreme Great Lakes winter cycles' (Winter Weather)
'Midwest corridor susceptibility' (Tornadoes)
'Urban heat island effect in Chicago' (Heat Waves)

Backend:
+ backend/app/tools/nri_county.py — Free ArcGIS point query against
FEMA's NRI Counties FeatureServer. Curates the 467-field response
down to: composite risk score+rating, top 5 hazards by NRI rating
tier, social vulnerability, community resilience, total EAL.
Field-name nuance documented (NRI's _RISKR is rating string,
_RISKS is numeric score — opposite of how the schema aliases
read).
+ backend/app/agents/regional_risk_agent.py — wraps the tool, asks
Gemma 4 to:
1. Pick 2-4 hazards that GENUINELY matter for THIS county
(not just 'all are very high' — that's a population-scale
artifact for big counties)
2. Note what the county-level NRI flood score adds vs the
property-level FEMA zone
3. Interpret SoVI + Community Resilience as recovery context
+ orchestrator: 'regional' added to the parallel data-agent pack.
~ risk_agent: prompt now includes a 'Regional Multi-Hazard Profile'
section so the synthesis can cross-reference county-level NRI with
property-level FEMA / 311 / satellite findings.

Frontend:
+ AGENTS list gets 'Regional risk analyst' (and 'Satellite surveyor'
which was previously missing from the agents-screen even though
it ran).
+ New §05 dossier section 'Wider neighborhood — beyond just flooding'
— purple-accented, only renders when NRI data is available. Shows
the composite badge, EAL, SoVI/Resilience chips, top hazards as a
numbered list with severity colors, plus Gemma's flood-misses
callout and resilience-context callout, sourced from RAPT.
+ Section numbering refactored to a useMemo helper (_sectionNums)
that walks through conditional sections counting up — cleaner
than the nested-conditional approach we had.

Version: chrome wordmark v0.13 → v0.14, app version 0.13.0 → 0.14.0.

app/agents/orchestrator.py CHANGED
@@ -21,6 +21,7 @@ from app.agents.archive_agent import run_archive_agent
21
  from app.agents.fema_agent import run_fema_agent
22
  from app.agents.local_agent import run_local_agent
23
  from app.agents.news_agent import run_news_agent
 
24
  from app.agents.risk_agent import run_risk_agent
25
  from app.agents.satellite_agent import run_satellite_agent
26
  from app.agents.streetview_agent import run_streetview_agent
@@ -78,10 +79,19 @@ def _make_satellite(language: str) -> AgentFn:
78
  return _run
79
 
80
 
 
 
 
 
 
 
 
 
 
81
  # Order here is the order the frontend renders agent rows in.
82
  # The two vision agents (streetview eye-level + satellite bird's-eye)
83
- # are placed last in the data row so users see the slower vision
84
- # calls finishing after the cheap text-API calls.
85
  def _data_agents_for(language: str) -> dict[str, AgentFn]:
86
  return {
87
  "fema": _fema,
@@ -89,6 +99,7 @@ def _data_agents_for(language: str) -> dict[str, AgentFn]:
89
  "weather": _weather,
90
  "news": _news,
91
  "archive": _archive,
 
92
  "satellite": _make_satellite(language),
93
  "streetview": _make_streetview(language),
94
  }
@@ -260,6 +271,7 @@ def _compile_dossier(geo: GeoCtx, results: dict) -> dict:
260
  "archive": results.get("archive", {}),
261
  "streetview": results.get("streetview", {}),
262
  "satellite": results.get("satellite", {}),
 
263
  "risk": results.get("risk", {}),
264
  "advisor": results.get("advisor", {}),
265
  }
 
21
  from app.agents.fema_agent import run_fema_agent
22
  from app.agents.local_agent import run_local_agent
23
  from app.agents.news_agent import run_news_agent
24
+ from app.agents.regional_risk_agent import run_regional_risk_agent
25
  from app.agents.risk_agent import run_risk_agent
26
  from app.agents.satellite_agent import run_satellite_agent
27
  from app.agents.streetview_agent import run_streetview_agent
 
79
  return _run
80
 
81
 
82
+ def _make_regional(language: str) -> AgentFn:
83
+ async def _run(ctx: GeoCtx) -> dict:
84
+ return await run_regional_risk_agent(
85
+ ctx["lat"], ctx["lon"], ctx.get("display_name", ""),
86
+ language=language,
87
+ )
88
+ return _run
89
+
90
+
91
  # Order here is the order the frontend renders agent rows in.
92
  # The two vision agents (streetview eye-level + satellite bird's-eye)
93
+ # are placed last so users see the slower vision calls finishing after
94
+ # the cheap text-API calls.
95
  def _data_agents_for(language: str) -> dict[str, AgentFn]:
96
  return {
97
  "fema": _fema,
 
99
  "weather": _weather,
100
  "news": _news,
101
  "archive": _archive,
102
+ "regional": _make_regional(language),
103
  "satellite": _make_satellite(language),
104
  "streetview": _make_streetview(language),
105
  }
 
271
  "archive": results.get("archive", {}),
272
  "streetview": results.get("streetview", {}),
273
  "satellite": results.get("satellite", {}),
274
+ "regional": results.get("regional", {}),
275
  "risk": results.get("risk", {}),
276
  "advisor": results.get("advisor", {}),
277
  }
app/agents/regional_risk_agent.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Regional risk agent — area-level multi-hazard context.
3
+
4
+ Wraps the FEMA NRI county lookup. Asks Gemma 4 to:
5
+ 1. Identify which 2-3 hazards genuinely matter for THIS county
6
+ (not just "all are very high" — that's true for any big-population
7
+ county on a national-percentile-ranked index)
8
+ 2. Frame the result against the property-level flood signals the
9
+ other agents already produced
10
+ 3. Note social vulnerability + community resilience as risk-context
11
+ modulators, not extra hazards
12
+
13
+ The point of this agent is to broaden FlutIQ from flood-only to
14
+ "here's the multi-hazard picture of your neighborhood." Wildfire,
15
+ hurricane, earthquake, tornado are all surfaced in plain English at
16
+ the COUNTY (not address) level — exactly the resolution of FEMA's NRI.
17
+ """
18
+ import json
19
+
20
+ from app.data.languages import prompt_directive
21
+ from app.llm.client import call_gemma4, extract_text, parse_json_response
22
+ from app.tools.nri_county import lookup_nri_county
23
+
24
+
25
+ SYSTEM_PROMPT = """You are a regional natural-hazard analyst. You read FEMA's National Risk Index (NRI) county-level data and explain in plain language which hazards meaningfully affect a specific neighborhood.
26
+
27
+ Critical guidance:
28
+ - NRI scores are NATIONAL PERCENTILES. A county at the 99th percentile for tornado risk is in the top 1% of the country for tornado risk — that's meaningful. But large-population counties tend to score Very High on many hazards just because more people + property = more expected loss; rating alone is not enough, look at the SCORES too.
29
+ - Distinguish "this county is genuinely high-risk for hazard X" (high score AND meaningful expected annual loss vs population) from "this county hits Very High because of population scale, not unusual hazard exposure."
30
+ - Cross-reference with property-level flood signals provided by other agents. NRI's Inland Flooding score is COUNTY-LEVEL; the property's actual FEMA Zone designation and 311 record are more precise.
31
+ - Social Vulnerability and Community Resilience are POPULATION-level — they describe how well the surrounding community can respond to and recover from a disaster. Note them; don't conflate with hazard exposure.
32
+ - Plain English. Avoid hazard-jargon (e.g. say "tornadoes" not "convective windstorm events").
33
+
34
+ Always respond with valid JSON only."""
35
+
36
+
37
+ async def run_regional_risk_agent(
38
+ lat: float,
39
+ lon: float,
40
+ address: str,
41
+ fema_findings: dict | None = None,
42
+ language: str = "en",
43
+ ) -> dict:
44
+ nri = await lookup_nri_county(lat, lon)
45
+
46
+ if not nri.get("available"):
47
+ return {
48
+ "available": False,
49
+ "summary": (
50
+ f"FEMA National Risk Index lookup unavailable: "
51
+ f"{nri.get('error', 'unknown reason')}"
52
+ ),
53
+ "raw": nri,
54
+ }
55
+
56
+ # Trim the raw NRI for the prompt — keep top hazards + ratings only,
57
+ # drop the long all-18-hazards array since the model already gets
58
+ # the top 5 ranked.
59
+ nri_for_prompt = {
60
+ "county": nri["county"],
61
+ "state": nri["state"],
62
+ "population": nri.get("population"),
63
+ "composite_risk_score": nri["composite_risk_score"],
64
+ "composite_risk_rating": nri["composite_risk_rating"],
65
+ "expected_annual_loss_usd": nri["expected_annual_loss_usd"],
66
+ "social_vulnerability_rating": nri["social_vulnerability_rating"],
67
+ "community_resilience_rating": nri["community_resilience_rating"],
68
+ "top_hazards": nri["top_hazards"],
69
+ }
70
+
71
+ fema_for_prompt = (
72
+ {k: v for k, v in (fema_findings or {}).items() if k != "raw"}
73
+ if fema_findings else None
74
+ )
75
+
76
+ user_prompt = f"""Analyze the FEMA National Risk Index profile for the property's COUNTY.
77
+
78
+ Address: {address}
79
+
80
+ ## NRI county-level signal
81
+ {json.dumps(nri_for_prompt, indent=2, default=str)}
82
+
83
+ ## Property-level FEMA flood designation (from the property-specific FEMA agent)
84
+ {json.dumps(fema_for_prompt, indent=2, default=str) if fema_for_prompt else "(not available)"}
85
+
86
+ Return a JSON object:
87
+
88
+ {{
89
+ "headline_hazards": [
90
+ {{
91
+ "name": "<short hazard name, e.g. 'Hurricane', 'Wildfire'>",
92
+ "rating": "<copy from top_hazards above>",
93
+ "score": <copy from top_hazards above>,
94
+ "matters_because": "<1 sentence explaining why this hazard genuinely affects THIS county — geography, climate, history. Do not just restate the rating.>"
95
+ }}
96
+ ],
97
+ "what_property_level_flood_misses": "<1-2 sentences on what the county-level NRI flood score adds vs. the property's specific FEMA Zone designation. If the FEMA agent already showed this is a SFHA property, say so. If FEMA says minimal but NRI inland-flooding is Very High, that's a meaningful gap to call out.>",
98
+ "resilience_context": "<1-2 sentences interpreting Social Vulnerability + Community Resilience for this area, in plain English. Examples: 'Highly resilient community: well-resourced response infrastructure.' / 'Lower community resilience means a similar storm here would take longer to recover from than in a higher-resilience county.'>",
99
+ "summary": "<1 sentence for the status feed>"
100
+ }}
101
+
102
+ CONSTRAINTS:
103
+ - headline_hazards: pick 2 to 4 hazards. Skip anything with rating "No Rating" / "Insufficient Data" / "Not Applicable".
104
+ - Don't pad with non-meaningful hazards just to fill 4 slots.
105
+ - If the county is genuinely low-risk overall (composite < 50), say so honestly.
106
+ - Return ONLY the JSON object."""
107
+
108
+ response = await call_gemma4(
109
+ messages=[
110
+ {"role": "system", "content": SYSTEM_PROMPT + prompt_directive(language)},
111
+ {"role": "user", "content": user_prompt},
112
+ ],
113
+ temperature=0.2,
114
+ max_tokens=2000,
115
+ )
116
+
117
+ text = extract_text(response)
118
+ parsed = parse_json_response(text) or {}
119
+
120
+ # Always pass through the structured NRI data so the dossier UI can
121
+ # render the full top-hazards table even if the model's interpretation
122
+ # truncated to 2.
123
+ parsed["available"] = True
124
+ parsed["nri"] = nri
125
+ if "summary" not in parsed:
126
+ parsed["summary"] = (
127
+ f"County composite risk: {nri['composite_risk_rating']} "
128
+ f"({nri['composite_risk_score']}/100)"
129
+ )
130
+ return parsed
app/agents/risk_agent.py CHANGED
@@ -110,6 +110,15 @@ Here is all the data collected by our investigation team:
110
  ## Satellite Visual Analysis (from the satellite agent)
111
  {json.dumps({k: v for k, v in (all_data.get('satellite') or {}).items() if k != 'image_data_url'}, indent=2, default=str)}
112
 
 
 
 
 
 
 
 
 
 
113
  ## Weather & Hydrology Findings
114
  {json.dumps(all_data.get('weather', {}), indent=2, default=str)}
115
 
 
110
  ## Satellite Visual Analysis (from the satellite agent)
111
  {json.dumps({k: v for k, v in (all_data.get('satellite') or {}).items() if k != 'image_data_url'}, indent=2, default=str)}
112
 
113
+ ## Regional Multi-Hazard Profile (FEMA NRI, county-level — from the regional_risk agent)
114
+ This is the COUNTY-level National Risk Index profile — wider than the
115
+ property-specific signals above, narrower than national. Use it to widen
116
+ the risk story beyond flooding (wildfire, hurricane, tornado, earthquake,
117
+ etc. as applicable to this geography). Don't double-count: NRI's Inland
118
+ Flooding score is county-level; the FEMA flood zone above is the
119
+ authoritative property-level designation.
120
+ {json.dumps({k: v for k, v in (all_data.get('regional') or {}).items() if k != 'nri'}, indent=2, default=str)}
121
+
122
  ## Weather & Hydrology Findings
123
  {json.dumps(all_data.get('weather', {}), indent=2, default=str)}
124
 
app/main.py CHANGED
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
8
  from app.api.assess import router as assess_router
9
  from app.api.health import router as health_router
10
 
11
- app = FastAPI(title="FlutIQ", version="0.13.0")
12
 
13
  # CORS still permissive for split-deployment scenarios. With the
14
  # bundled deploy (frontend served from FastAPI) it's a no-op because
 
8
  from app.api.assess import router as assess_router
9
  from app.api.health import router as health_router
10
 
11
+ app = FastAPI(title="FlutIQ", version="0.14.0")
12
 
13
  # CORS still permissive for split-deployment scenarios. With the
14
  # bundled deploy (frontend served from FastAPI) it's a no-op because
app/tools/nri_county.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FEMA National Risk Index — county-level multi-hazard scores.
3
+
4
+ Why this matters: every other agent in FlutIQ is flood-focused. NRI gives
5
+ us the calibrated, neighborhood-level "everything else" — wildfire,
6
+ hurricane, tornado, earthquake, drought, heat wave, lightning, etc., plus
7
+ Social Vulnerability and Community Resilience indices.
8
+
9
+ Source: FEMA's Resilience Analysis and Planning Tool (RAPT) hosts the
10
+ NRI Counties dataset on its public ArcGIS Online org. The FeatureServer
11
+ accepts point queries without auth — exactly what we need.
12
+
13
+ https://services.arcgis.com/XG15cJAlne2vxtgt/arcgis/rest/services/
14
+ National_Risk_Index_Counties/FeatureServer/0/query?...
15
+
16
+ Coverage: all 3,143 US counties + county-equivalents. Updated annually.
17
+
18
+ Each county record has 467 fields. We surface a curated subset:
19
+ - Composite risk score + rating + percentile
20
+ - Per-hazard score + rating for the 18 NRI hazards
21
+ - Social Vulnerability + Community Resilience
22
+ - Total expected annual loss in dollars
23
+
24
+ We DROP all the per-asset breakdown fields (building / population /
25
+ agriculture EAL split out across 18 hazards = 100+ columns) because
26
+ the dossier doesn't need them and they bloat the prompt budget.
27
+ """
28
+ import json
29
+
30
+ import httpx
31
+
32
+
33
+ SERVICE_URL = (
34
+ "https://services.arcgis.com/XG15cJAlne2vxtgt/arcgis/rest/services/"
35
+ "National_Risk_Index_Counties/FeatureServer/0/query"
36
+ )
37
+
38
+ # 18 NRI hazards — codes used in field names + display labels.
39
+ # Order matters: most common-to-explain hazards first so any "top N"
40
+ # truncation surfaces the most-recognizable ones.
41
+ HAZARDS: list[tuple[str, str]] = [
42
+ ("IFLD", "Inland flooding"),
43
+ ("CFLD", "Coastal flooding"),
44
+ ("HRCN", "Hurricane"),
45
+ ("WFIR", "Wildfire"),
46
+ ("ERQK", "Earthquake"),
47
+ ("TRND", "Tornado"),
48
+ ("HAIL", "Hail"),
49
+ ("HWAV", "Heat wave"),
50
+ ("CWAV", "Cold wave"),
51
+ ("DRGT", "Drought"),
52
+ ("WNTW", "Winter weather"),
53
+ ("ISTM", "Ice storm"),
54
+ ("SWND", "Strong wind"),
55
+ ("LTNG", "Lightning"),
56
+ ("LNDS", "Landslide"),
57
+ ("TSUN", "Tsunami"),
58
+ ("VLCN", "Volcanic activity"),
59
+ ("AVLN", "Avalanche"),
60
+ ]
61
+
62
+ # Order from FEMA's NRI rating bins, low → high. Used to bucket-rank.
63
+ _RATING_ORDER = (
64
+ "Very Low",
65
+ "Relatively Low",
66
+ "Relatively Moderate",
67
+ "Relatively High",
68
+ "Very High",
69
+ "No Rating",
70
+ "Insufficient Data",
71
+ "Not Applicable",
72
+ )
73
+
74
+
75
+ def _is_meaningful_rating(s: str | None) -> bool:
76
+ if not s:
77
+ return False
78
+ s = s.strip()
79
+ return s not in ("No Rating", "Insufficient Data", "Not Applicable", "")
80
+
81
+
82
+ def _rating_rank(s: str | None) -> int:
83
+ """Rank a rating from 0 (very low) to 4 (very high). Non-rated → -1."""
84
+ try:
85
+ idx = _RATING_ORDER.index((s or "").strip())
86
+ return idx if idx <= 4 else -1
87
+ except ValueError:
88
+ return -1
89
+
90
+
91
+ async def lookup_nri_county(lat: float, lon: float) -> dict:
92
+ """Point query the NRI Counties FeatureServer for (lat, lon).
93
+
94
+ Returns a dict with:
95
+ - county / state / fips
96
+ - composite risk: score (0-100) + rating
97
+ - expected_annual_loss_usd
98
+ - hazards: list of {code, name, score, rating, rank} sorted by rank desc
99
+ - top_hazards: top 5 by rating rank (only meaningfully-rated ones)
100
+ - social_vulnerability: rating + score
101
+ - community_resilience: rating + score
102
+ """
103
+ params = {
104
+ "geometry": json.dumps({
105
+ "x": lon, "y": lat,
106
+ "spatialReference": {"wkid": 4326},
107
+ }),
108
+ "geometryType": "esriGeometryPoint",
109
+ "inSR": "4326",
110
+ "outFields": "*",
111
+ "returnGeometry": "false",
112
+ "f": "json",
113
+ }
114
+
115
+ async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
116
+ resp = await client.get(SERVICE_URL, params=params)
117
+
118
+ if resp.status_code != 200:
119
+ return {"available": False, "error": f"HTTP {resp.status_code}"}
120
+
121
+ try:
122
+ data = resp.json()
123
+ except ValueError:
124
+ return {"available": False, "error": "non-JSON response"}
125
+
126
+ if isinstance(data, dict) and "error" in data:
127
+ return {
128
+ "available": False,
129
+ "error": (data["error"].get("message") or "ArcGIS error"),
130
+ }
131
+
132
+ features = data.get("features") or []
133
+ if not features:
134
+ return {
135
+ "available": False,
136
+ "error": "No NRI county polygon at this point (out of US?)",
137
+ }
138
+
139
+ a = features[0].get("attributes") or {}
140
+
141
+ hazards = []
142
+ for code, name in HAZARDS:
143
+ # NRI field naming, verified against live response 2026-05-06:
144
+ # _RISKR = Rating (string like "Very High")
145
+ # _RISKS = Score (numeric 0-100)
146
+ # _RISKV = Value (dollar expected loss) — not used here
147
+ rating = a.get(f"{code}_RISKR")
148
+ score = a.get(f"{code}_RISKS")
149
+ if score is None and not _is_meaningful_rating(rating):
150
+ continue
151
+ hazards.append({
152
+ "code": code,
153
+ "name": name,
154
+ "score": (
155
+ round(float(score), 2)
156
+ if isinstance(score, (int, float))
157
+ else None
158
+ ),
159
+ "rating": rating if _is_meaningful_rating(rating) else None,
160
+ "rank": _rating_rank(rating),
161
+ })
162
+
163
+ hazards_meaningful = [h for h in hazards if h["rank"] >= 0]
164
+ hazards_meaningful.sort(
165
+ key=lambda h: (-h["rank"], -(h["score"] or 0)),
166
+ )
167
+ top_hazards = hazards_meaningful[:5]
168
+
169
+ return {
170
+ "available": True,
171
+ "county": a.get("COUNTY"),
172
+ "state": a.get("STATEABBRV"),
173
+ "state_full": a.get("STATE"),
174
+ "fips": a.get("STCOFIPS"),
175
+ "population": a.get("POPULATION"),
176
+ "composite_risk_score": (
177
+ round(float(a["RISK_SCORE"]), 2)
178
+ if isinstance(a.get("RISK_SCORE"), (int, float)) else None
179
+ ),
180
+ "composite_risk_rating": a.get("RISK_RATNG"),
181
+ "expected_annual_loss_usd": (
182
+ int(a["EAL_VALT"])
183
+ if isinstance(a.get("EAL_VALT"), (int, float)) else None
184
+ ),
185
+ "social_vulnerability_rating": a.get("SOVI_RATNG"),
186
+ "social_vulnerability_score": (
187
+ round(float(a["SOVI_SCORE"]), 2)
188
+ if isinstance(a.get("SOVI_SCORE"), (int, float)) else None
189
+ ),
190
+ "community_resilience_rating": a.get("RESL_RATNG"),
191
+ "community_resilience_score": (
192
+ round(float(a["RESL_SCORE"]), 2)
193
+ if isinstance(a.get("RESL_SCORE"), (int, float)) else None
194
+ ),
195
+ "hazards": hazards_meaningful,
196
+ "top_hazards": top_hazards,
197
+ "source": (
198
+ "FEMA National Risk Index, county level. Hosted via FEMA's "
199
+ "Resilience Analysis and Planning Tool (RAPT)."
200
+ ),
201
+ }
static/index.html CHANGED
@@ -540,6 +540,12 @@ const AGENTS = [
540
  { id: "archive", name: "Archivist",
541
  finding: "Cook County: 11 NOAA flood events in 10yr, 4 FEMA disaster declarations. 2008, 2013, 2020, 2023 caused widespread basement flooding.",
542
  pin: { x: 62, y: 70, label: "11 events / 10y", tone: "amber" }, delay: 1100 },
 
 
 
 
 
 
543
  { id: "streetview", name: "Streetview surveyor",
544
  finding: "Multimodal: feeding Google Street View imagery to Gemma 4 vision to spot ground-floor flood-risk indicators.",
545
  pin: null, delay: 1800 },
@@ -1117,12 +1123,29 @@ const mapDossier = (raw) => {
1117
  streetview: raw.streetview || {},
1118
  satellite: raw.satellite || {},
1119
  local: raw.local || {},
 
1120
  };
1121
  };
1122
 
1123
  const DossierScreen = ({ onBack, dossier }) => {
1124
  const D = useMemo(() => mapDossier(dossier), [dossier]);
1125
  const [showReasoning, setShowReasoning] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1126
  const [showBoxes, setShowBoxes] = useState(true);
1127
  const [imageTab, setImageTab] = useState("streetview"); // "streetview" | "satellite" | "topo"
1128
  return (
@@ -1228,7 +1251,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1228
  const showOverlay = showBoxes && indicatorsWithBoxes.length > 0;
1229
  return (
1230
  <Section
1231
- num="§03"
1232
  icon={<Icon name="pin" color="var(--accent)" size={16}/>}
1233
  iconBg="var(--accent-soft)"
1234
  title="What we saw at the property"
@@ -1406,7 +1429,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1406
  })()}
1407
 
1408
  <Section
1409
- num={(D.streetview?.available || D.satellite?.available) ? "§04" : "§03"}
1410
  icon={<Icon name="warn" color="var(--coral)" size={16}/>}
1411
  iconBg="var(--coral-soft)"
1412
  title="Why FEMA's flood map isn't the whole story"
@@ -1544,8 +1567,90 @@ const DossierScreen = ({ onBack, dossier }) => {
1544
  )}
1545
  </Section>
1546
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1547
  <Section
1548
- num={(D.streetview?.available || D.satellite?.available) ? "§05" : "§04"}
1549
  icon={<Icon name="drop" color="var(--accent)" size={16}/>}
1550
  iconBg="var(--accent-soft)"
1551
  title="The raw signals we looked at"
@@ -1559,7 +1664,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1559
  </Section>
1560
 
1561
  <Section
1562
- num={(D.streetview?.available || D.satellite?.available) ? "§06" : "§05"}
1563
  icon={<Icon name="news" color="var(--amber)" size={16}/>}
1564
  iconBg="var(--amber-soft)"
1565
  title="Recent local flood news">
@@ -1594,7 +1699,7 @@ const Chrome = ({ screen, onJump, dark, onToggleDark, language, onLanguageChange
1594
  <div className="wordmark" onClick={()=>onJump("search")} style={{cursor:"pointer"}}>
1595
  <span className="glyph">F</span>
1596
  <span>FlutIQ</span>
1597
- <span style={{color:"var(--ink-4)",fontSize:12,marginLeft:8,fontFamily:"JetBrains Mono"}}>v0.13 · beta</span>
1598
  </div>
1599
  <div className="chrome-meta">
1600
  <span className="pill static"><span className="dot"/>gemma-4 · OpenRouter</span>
 
540
  { id: "archive", name: "Archivist",
541
  finding: "Cook County: 11 NOAA flood events in 10yr, 4 FEMA disaster declarations. 2008, 2013, 2020, 2023 caused widespread basement flooding.",
542
  pin: { x: 62, y: 70, label: "11 events / 10y", tone: "amber" }, delay: 1100 },
543
+ { id: "regional", name: "Regional risk analyst",
544
+ finding: "FEMA NRI county-level multi-hazard profile — wildfire, hurricane, tornado, earthquake, etc. — for the wider neighborhood.",
545
+ pin: null, delay: 1300 },
546
+ { id: "satellite", name: "Satellite surveyor",
547
+ finding: "Multimodal: bird's-eye Mapbox imagery → Gemma 4 vision → impervious-surface and catchment analysis.",
548
+ pin: null, delay: 1700 },
549
  { id: "streetview", name: "Streetview surveyor",
550
  finding: "Multimodal: feeding Google Street View imagery to Gemma 4 vision to spot ground-floor flood-risk indicators.",
551
  pin: null, delay: 1800 },
 
1123
  streetview: raw.streetview || {},
1124
  satellite: raw.satellite || {},
1125
  local: raw.local || {},
1126
+ regional: raw.regional || {},
1127
  };
1128
  };
1129
 
1130
  const DossierScreen = ({ onBack, dossier }) => {
1131
  const D = useMemo(() => mapDossier(dossier), [dossier]);
1132
  const [showReasoning, setShowReasoning] = useState(false);
1133
+
1134
+ // Section numbers depend on which conditional sections render. We
1135
+ // precompute them so the rendering JSX stays readable.
1136
+ const _sectionNums = useMemo(() => {
1137
+ const hasImages = D.streetview?.available || D.satellite?.available;
1138
+ const hasRegional = D.regional?.available;
1139
+ let n = 2; // §01 actions, §02 insurance fixed
1140
+ const fmt = () => "§" + String(++n).padStart(2, "0");
1141
+ return {
1142
+ images: hasImages ? fmt() : null,
1143
+ gap: fmt(),
1144
+ regional: hasRegional ? fmt() : null,
1145
+ signals: fmt(),
1146
+ news: fmt(),
1147
+ };
1148
+ }, [D]);
1149
  const [showBoxes, setShowBoxes] = useState(true);
1150
  const [imageTab, setImageTab] = useState("streetview"); // "streetview" | "satellite" | "topo"
1151
  return (
 
1251
  const showOverlay = showBoxes && indicatorsWithBoxes.length > 0;
1252
  return (
1253
  <Section
1254
+ num={_sectionNums.images || "§03"}
1255
  icon={<Icon name="pin" color="var(--accent)" size={16}/>}
1256
  iconBg="var(--accent-soft)"
1257
  title="What we saw at the property"
 
1429
  })()}
1430
 
1431
  <Section
1432
+ num={_sectionNums.gap}
1433
  icon={<Icon name="warn" color="var(--coral)" size={16}/>}
1434
  iconBg="var(--coral-soft)"
1435
  title="Why FEMA's flood map isn't the whole story"
 
1567
  )}
1568
  </Section>
1569
 
1570
+ {D.regional?.available && (
1571
+ <Section
1572
+ num={_sectionNums.regional}
1573
+ icon={<Icon name="warn" color="var(--purple)" size={16}/>}
1574
+ iconBg="var(--purple-soft)"
1575
+ title="Wider neighborhood — beyond just flooding"
1576
+ badge={<span className="risk-tag purple">FEMA NRI · county-level</span>}>
1577
+ <p style={{color:"var(--ink-2)", marginTop:0}}>
1578
+ FlutIQ is flood-focused, but your county faces other hazards too. This is FEMA's
1579
+ National Risk Index for {D.regional.nri?.county} {D.regional.nri?.state}
1580
+ {D.regional.nri?.population ? ` (pop. ${D.regional.nri.population.toLocaleString()})` : ""},
1581
+ calibrated against every other US county.
1582
+ </p>
1583
+ <div style={{display:"flex", gap:8, flexWrap:"wrap", margin:"12px 0", fontSize:12}}>
1584
+ {D.regional.nri?.composite_risk_rating && (
1585
+ <span className={`risk-tag ${
1586
+ D.regional.nri.composite_risk_rating.includes("Very High") ? "coral"
1587
+ : D.regional.nri.composite_risk_rating.includes("High") ? "amber"
1588
+ : D.regional.nri.composite_risk_rating.includes("Moderate") ? "amber"
1589
+ : "teal"
1590
+ }`}>
1591
+ Composite: {D.regional.nri.composite_risk_rating} ({D.regional.nri.composite_risk_score}/100)
1592
+ </span>
1593
+ )}
1594
+ {D.regional.nri?.expected_annual_loss_usd != null && (
1595
+ <span className="risk-tag neutral">
1596
+ Expected annual loss: ${(D.regional.nri.expected_annual_loss_usd / 1_000_000).toFixed(0)}M
1597
+ </span>
1598
+ )}
1599
+ {D.regional.nri?.social_vulnerability_rating && (
1600
+ <span className="risk-tag neutral">SoVI: {D.regional.nri.social_vulnerability_rating}</span>
1601
+ )}
1602
+ {D.regional.nri?.community_resilience_rating && (
1603
+ <span className="risk-tag neutral">Resilience: {D.regional.nri.community_resilience_rating}</span>
1604
+ )}
1605
+ </div>
1606
+ {Array.isArray(D.regional.headline_hazards) && D.regional.headline_hazards.length > 0 && (
1607
+ <div style={{marginBottom:12}}>
1608
+ <div className="stat-label" style={{marginBottom:8}}>Headline hazards for this county</div>
1609
+ <ol style={{margin:0, paddingLeft:0, listStyle:"none", fontSize:13, color:"var(--ink-2)", lineHeight:1.55}}>
1610
+ {D.regional.headline_hazards.map((h, i) => {
1611
+ const r = (h.rating || "").toLowerCase();
1612
+ const color = r.includes("very high") ? "var(--coral)"
1613
+ : r.includes("high") ? "var(--amber)"
1614
+ : r.includes("moderate") ? "var(--amber)"
1615
+ : "var(--teal)";
1616
+ return (
1617
+ <li key={i} style={{marginBottom:10, display:"flex", gap:10}}>
1618
+ <span style={{
1619
+ flexShrink:0, width:22, height:22, borderRadius:4,
1620
+ background:color, color:"white", fontSize:12, fontWeight:600,
1621
+ fontFamily:"'JetBrains Mono', monospace",
1622
+ display:"flex", alignItems:"center", justifyContent:"center",
1623
+ }}>{i+1}</span>
1624
+ <div>
1625
+ <strong>{h.name}</strong>
1626
+ {h.rating && <span className="small" style={{marginLeft:6}}> · {h.rating}</span>}
1627
+ {h.score != null && <span className="small" style={{marginLeft:6, color:"var(--ink-4)"}}> · score {h.score}</span>}
1628
+ <div style={{color:"var(--ink-3)", fontSize:12, marginTop:2}}>{h.matters_because}</div>
1629
+ </div>
1630
+ </li>
1631
+ );
1632
+ })}
1633
+ </ol>
1634
+ </div>
1635
+ )}
1636
+ {D.regional.what_property_level_flood_misses && (
1637
+ <p style={{margin:"10px 0", padding:"10px 12px", background:"var(--bg)", borderLeft:"3px solid var(--accent)", borderRadius:4, fontSize:13, color:"var(--ink-2)", lineHeight:1.55}}>
1638
+ <strong style={{color:"var(--accent)"}}>vs property-level FEMA · </strong>{D.regional.what_property_level_flood_misses}
1639
+ </p>
1640
+ )}
1641
+ {D.regional.resilience_context && (
1642
+ <p style={{margin:"10px 0", padding:"10px 12px", background:"var(--bg)", borderLeft:"3px solid var(--teal)", borderRadius:4, fontSize:13, color:"var(--ink-2)", lineHeight:1.55}}>
1643
+ <strong style={{color:"var(--teal)"}}>Community resilience · </strong>{D.regional.resilience_context}
1644
+ </p>
1645
+ )}
1646
+ <div style={{fontSize:11, color:"var(--ink-4)", marginTop:10, fontFamily:"'JetBrains Mono', monospace"}}>
1647
+ source · FEMA National Risk Index via the Resilience Analysis and Planning Tool (RAPT)
1648
+ </div>
1649
+ </Section>
1650
+ )}
1651
+
1652
  <Section
1653
+ num={_sectionNums.signals}
1654
  icon={<Icon name="drop" color="var(--accent)" size={16}/>}
1655
  iconBg="var(--accent-soft)"
1656
  title="The raw signals we looked at"
 
1664
  </Section>
1665
 
1666
  <Section
1667
+ num={_sectionNums.news}
1668
  icon={<Icon name="news" color="var(--amber)" size={16}/>}
1669
  iconBg="var(--amber-soft)"
1670
  title="Recent local flood news">
 
1699
  <div className="wordmark" onClick={()=>onJump("search")} style={{cursor:"pointer"}}>
1700
  <span className="glyph">F</span>
1701
  <span>FlutIQ</span>
1702
+ <span style={{color:"var(--ink-4)",fontSize:12,marginLeft:8,fontFamily:"JetBrains Mono"}}>v0.14 · beta</span>
1703
  </div>
1704
  <div className="chrome-meta">
1705
  <span className="pill static"><span className="dot"/>gemma-4 · OpenRouter</span>