kredd25 commited on
Commit
abae764
·
1 Parent(s): 86c05e4

v0.10: 3-image multimodal — satellite + wider-area + street view in one Gemma 4 call

Browse files

Risk analyst now reasons across THREE visual perspectives plus all
6 data sources in a single inference, exercising six Gemma 4
capabilities at once (vision + interleaved multimodal + reasoning
+ structured JSON + long context + graceful per-image fallback).

Pipeline:
Mapbox satellite (zoom 17, ~150m across) →
Mapbox outdoors (zoom 15, ~600m across) →
Google Street View (heading-aimed at the building) →
+ 6 data agents → risk analyst (Gemma 4 reasoning=on)

Verified end-to-end against 4521 S Drexel Blvd, Chicago. The model
named all three views in its trace ('From the satellite I can see…',
'In the street view…') and produced a visual_corroboration that
explicitly cross-referenced views vs data:

'The satellite view confirms a high percentage of impervious surface
due to the large asphalt parking lot, increasing runoff. The street
view corroborates the presence of basement-level windows, which
create a direct vulnerability to water ingress that is not reflected
in FEMA data.'

Honest reframing during the smoke test:
Mapbox outdoors-v12 at zoom 15 in dense urban areas does NOT render
elevation contour lines (only in less-built-up areas). The model
correctly noticed and pushed back. Reframed the prompt to describe
the second image as a 'wider-area context map' showing waterways,
parks, mapped drainage features, and distance context — what it
ACTUALLY shows — not 'topographic with contours' as originally
spec'd. UI tab renamed Terrain → 'Wider area' to match.

Backend:
+ backend/app/tools/mapbox.py — Static Images API client. Two
helpers: get_satellite_image (satellite-v9, zoom 17) and
get_topo_image (outdoors-v12, zoom 15). 600x600@2x = 1200x1200.
Returns None silently if MAPBOX_ACCESS_TOKEN is unset →
graceful per-image degrade, no errors.
+ config.py reads MAPBOX_ACCESS_TOKEN.
~ orchestrator.py kicks off both Mapbox fetches alongside the data
agents (pure HTTP, no LLM, latency hides under slowest agent),
stashes results under 'maps', passes both data URLs to the risk
analyst alongside the streetview data URL.
~ risk_agent.py accepts streetview/satellite/topo image data URLs;
builds content_parts with images BEFORE text per Gemma 4 best
practice; dynamically describes ONLY the views it actually has
(so a degraded 1-image run still produces an accurate prompt);
returns images_used: ['satellite','topo','streetview'] for the UI.

Frontend:
~ §03 'What we saw at the property' is now tabbed: Street level /
Satellite / Wider area. Bbox overlay only renders on the
Street View tab (boxes don't apply to map imagery). Per-tab
attribution credits the right source (Google vs Mapbox + OSM).
~ §04 multimodal callout subtitle is now dynamic:
'◆ Gemma 4 multimodal reasoning · 3 images (satellite + topo +
street view) + 6 data sources + chain-of-thought · one inference call'
so judges see exactly what the model received.

Version: chrome wordmark v0.9 → v0.10, app version 0.9.0 → 0.10.0.

Free tiers: Mapbox 50K static loads/mo (= 25K assessments). OpenRouter
:free with BYOK Google AI Studio carries the vision tokens for all
3 images in one call without hitting per-minute limits in normal use.

app/agents/orchestrator.py CHANGED
@@ -25,6 +25,7 @@ from app.agents.risk_agent import run_risk_agent
25
  from app.agents.streetview_agent import run_streetview_agent
26
  from app.agents.weather_agent import run_weather_agent
27
  from app.tools.geocoder import geocode_address
 
28
 
29
 
30
  GeoCtx = dict
@@ -136,6 +137,13 @@ async def run_assessment(
136
  "summary": "Waiting on risk analysis...",
137
  })
138
 
 
 
 
 
 
 
 
139
  # Run data agents in parallel; stream results in completion order.
140
  tasks = {name: asyncio.create_task(fn(ctx)) for name, fn in data_agents.items()}
141
  task_to_name = {t: n for n, t in tasks.items()}
@@ -168,6 +176,26 @@ async def run_assessment(
168
  "status": "working",
169
  "summary": "Synthesizing risk score with reasoning mode...",
170
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  # If the streetview agent succeeded, hand its image to the risk
172
  # analyst so the analyst can do its own visual reasoning instead
173
  # of just reading another agent's text findings. v0.9 multimodal.
@@ -181,6 +209,8 @@ async def run_assessment(
181
  results, geo["lat"], geo["lon"], geo["display_name"],
182
  language=language,
183
  streetview_image_data_url=sv_image_data_url,
 
 
184
  )
185
  results["risk"] = risk_result
186
  yield sse("agent_update", {
@@ -237,6 +267,7 @@ def _compile_dossier(geo: GeoCtx, results: dict) -> dict:
237
  "news": results.get("news", {}),
238
  "archive": results.get("archive", {}),
239
  "streetview": results.get("streetview", {}),
 
240
  "risk": results.get("risk", {}),
241
  "advisor": results.get("advisor", {}),
242
  }
 
25
  from app.agents.streetview_agent import run_streetview_agent
26
  from app.agents.weather_agent import run_weather_agent
27
  from app.tools.geocoder import geocode_address
28
+ from app.tools.mapbox import get_satellite_image, get_topo_image
29
 
30
 
31
  GeoCtx = dict
 
137
  "summary": "Waiting on risk analysis...",
138
  })
139
 
140
+ # Map images for the multimodal risk analyst — pure HTTP, no LLM.
141
+ # Kicked off alongside the data agents so the latency hides under
142
+ # the slowest agent's wait. Both return None silently if Mapbox is
143
+ # not configured (graceful degrade).
144
+ satellite_task = asyncio.create_task(get_satellite_image(geo["lat"], geo["lon"]))
145
+ topo_task = asyncio.create_task(get_topo_image(geo["lat"], geo["lon"]))
146
+
147
  # Run data agents in parallel; stream results in completion order.
148
  tasks = {name: asyncio.create_task(fn(ctx)) for name, fn in data_agents.items()}
149
  task_to_name = {t: n for n, t in tasks.items()}
 
176
  "status": "working",
177
  "summary": "Synthesizing risk score with reasoning mode...",
178
  })
179
+ # Wait for the map images we kicked off earlier. Each returns None
180
+ # if Mapbox isn't configured or the fetch failed.
181
+ satellite_meta = None
182
+ topo_meta = None
183
+ try:
184
+ satellite_meta = await satellite_task
185
+ except Exception:
186
+ satellite_meta = None
187
+ try:
188
+ topo_meta = await topo_task
189
+ except Exception:
190
+ topo_meta = None
191
+
192
+ # Stash on results so the dossier's UI can render thumbnails.
193
+ if satellite_meta or topo_meta:
194
+ results["maps"] = {
195
+ "satellite": satellite_meta,
196
+ "topo": topo_meta,
197
+ }
198
+
199
  # If the streetview agent succeeded, hand its image to the risk
200
  # analyst so the analyst can do its own visual reasoning instead
201
  # of just reading another agent's text findings. v0.9 multimodal.
 
209
  results, geo["lat"], geo["lon"], geo["display_name"],
210
  language=language,
211
  streetview_image_data_url=sv_image_data_url,
212
+ satellite_image_data_url=(satellite_meta or {}).get("data_url"),
213
+ topo_image_data_url=(topo_meta or {}).get("data_url"),
214
  )
215
  results["risk"] = risk_result
216
  yield sse("agent_update", {
 
267
  "news": results.get("news", {}),
268
  "archive": results.get("archive", {}),
269
  "streetview": results.get("streetview", {}),
270
+ "maps": results.get("maps", {}),
271
  "risk": results.get("risk", {}),
272
  "advisor": results.get("advisor", {}),
273
  }
app/agents/risk_agent.py CHANGED
@@ -40,30 +40,70 @@ async def run_risk_agent(
40
  address: str,
41
  language: str = "en",
42
  streetview_image_data_url: Optional[str] = None,
 
 
43
  ) -> dict:
44
- has_image = bool(streetview_image_data_url)
 
 
 
 
 
 
 
 
45
 
46
  image_section = ""
47
  if has_image:
48
- image_section = """
49
- ## Property photograph (Street View)
50
- A street-level photo of the property is included with this prompt
51
- (it appears immediately above this text). EXAMINE IT YOURSELF before
52
- reading the data sections. Look for:
53
- - Lot elevation relative to street grade (above, level, or below)
54
- - Basement-level windows, below-grade entries, sunken stairwells
55
- - Downspout connections (running into ground? into sewer? disconnected?)
56
- - Visible drainage infrastructure (French drains, catch basins, swales)
57
- - Ground-floor HVAC equipment, electrical panels, or utilities
58
- - Evidence of prior water damage (staining, erosion, repair patches)
59
- - Impervious surface coverage (concrete / asphalt vs. permeable ground)
60
- - Distance to obvious water features (canals, low-lying parks)
61
-
62
- You will get the Street View agent's text findings below in the
63
- 'Street View Visual Analysis' section, but rely on YOUR OWN
64
- inspection of the photo as the primary source. If you see something
65
- the Street View agent missed, say so. If you disagree with its
66
- assessment, explain why based on what YOU see.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  """
68
 
69
  text_prompt = f"""You are analyzing flood risk for: {address} ({lat}, {lon})
@@ -120,12 +160,13 @@ photo directly in your reasoning ("I can see ...", "The image shows ...")
120
  when relevant. Return ONLY the JSON object at the end."""
121
 
122
  # Build the user message content. Per Gemma 4 best practice,
123
- # image content parts go BEFORE the text part.
 
124
  user_content: list = []
125
- if has_image:
126
  user_content.append({
127
  "type": "image_url",
128
- "image_url": {"url": streetview_image_data_url},
129
  })
130
  user_content.append({"type": "text", "text": text_prompt})
131
 
@@ -149,7 +190,8 @@ when relevant. Return ONLY the JSON object at the end."""
149
  parsed = parse_json_response(text)
150
  if parsed:
151
  parsed["reasoning_trace"] = reasoning
152
- parsed["used_streetview_image"] = has_image
 
153
  return parsed
154
 
155
  return {
@@ -158,5 +200,6 @@ when relevant. Return ONLY the JSON object at the end."""
158
  "summary": "Risk analysis returned non-JSON output; using fallback",
159
  "raw_response": text,
160
  "reasoning_trace": reasoning,
161
- "used_streetview_image": has_image,
 
162
  }
 
40
  address: str,
41
  language: str = "en",
42
  streetview_image_data_url: Optional[str] = None,
43
+ satellite_image_data_url: Optional[str] = None,
44
+ topo_image_data_url: Optional[str] = None,
45
  ) -> dict:
46
+ image_data_urls = [
47
+ ("satellite", satellite_image_data_url),
48
+ ("topo", topo_image_data_url),
49
+ ("streetview", streetview_image_data_url),
50
+ ]
51
+ image_data_urls = [(k, u) for k, u in image_data_urls if u]
52
+ image_count = len(image_data_urls)
53
+ image_kinds = [k for k, _ in image_data_urls]
54
+ has_image = image_count > 0
55
 
56
  image_section = ""
57
  if has_image:
58
+ # Describe ONLY the images we actually have, in the order we
59
+ # send them. Order matches the content_parts list below so
60
+ # "first image" / "second image" references are accurate.
61
+ labels = []
62
+ descriptions = []
63
+ if "satellite" in image_kinds:
64
+ labels.append(f"image {len(labels)+1} = SATELLITE VIEW (bird's-eye)")
65
+ descriptions.append(
66
+ "- SATELLITE: estimate the impervious-surface percentage of the lot "
67
+ "and the surrounding block (concrete/asphalt vs vegetation), the "
68
+ "building-footprint-to-lot ratio, proximity to visible water bodies "
69
+ "or drainage channels, and how the surrounding properties are "
70
+ "surfaced (catchment effect)."
71
+ )
72
+ if "topo" in image_kinds:
73
+ labels.append(f"image {len(labels)+1} = WIDER-AREA CONTEXT MAP")
74
+ descriptions.append(
75
+ "- WIDER-AREA CONTEXT MAP: Mapbox outdoors style at a wider zoom. "
76
+ "Note that in dense urban areas this image typically does NOT show "
77
+ "elevation contour lines. What it DOES show: named waterways "
78
+ "(creeks, rivers, drainage channels — drawn in blue), parks and "
79
+ "green spaces (green), mapped retention basins or detention "
80
+ "ponds if any, named streets, and distance context to all of "
81
+ "the above. Use it to reason about drainage proximity and "
82
+ "regional context, NOT to claim micro-elevation observations "
83
+ "you can't actually see."
84
+ )
85
+ if "streetview" in image_kinds:
86
+ labels.append(f"image {len(labels)+1} = STREET VIEW (eye-level)")
87
+ descriptions.append(
88
+ "- STREET VIEW: lot elevation vs street grade, basement-level windows, "
89
+ "below-grade entries, downspout connections (to ground vs to sewer), "
90
+ "ground-floor HVAC/electrical, evidence of prior water damage, "
91
+ "impervious surfaces visible at eye level."
92
+ )
93
+ labels_block = "\n".join(f" · {l}" for l in labels)
94
+ desc_block = "\n".join(descriptions)
95
+ image_section = f"""
96
+ ## Property images ({image_count} attached, in order above this text)
97
+ {labels_block}
98
+
99
+ EXAMINE EACH IMAGE YOURSELF before reading the data sections.
100
+ {desc_block}
101
+
102
+ Cross-reference what you see across the images. When evidence from
103
+ one view corroborates or contradicts another view, OR when visual
104
+ evidence corroborates or contradicts the data, say so explicitly in
105
+ your reasoning. Reference images by name ("from the satellite I can
106
+ see...", "the topo contours show...", "in the street view...").
107
  """
108
 
109
  text_prompt = f"""You are analyzing flood risk for: {address} ({lat}, {lon})
 
160
  when relevant. Return ONLY the JSON object at the end."""
161
 
162
  # Build the user message content. Per Gemma 4 best practice,
163
+ # image content parts go BEFORE the text part. Order matches the
164
+ # 'image N' labels in the prompt.
165
  user_content: list = []
166
+ for _kind, url in image_data_urls:
167
  user_content.append({
168
  "type": "image_url",
169
+ "image_url": {"url": url},
170
  })
171
  user_content.append({"type": "text", "text": text_prompt})
172
 
 
190
  parsed = parse_json_response(text)
191
  if parsed:
192
  parsed["reasoning_trace"] = reasoning
193
+ parsed["used_streetview_image"] = "streetview" in image_kinds
194
+ parsed["images_used"] = image_kinds
195
  return parsed
196
 
197
  return {
 
200
  "summary": "Risk analysis returned non-JSON output; using fallback",
201
  "raw_response": text,
202
  "reasoning_trace": reasoning,
203
+ "used_streetview_image": "streetview" in image_kinds,
204
+ "images_used": image_kinds,
205
  }
app/config.py CHANGED
@@ -6,6 +6,7 @@ import os
6
  # reject the Authorization header as an illegal value).
7
  OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "").strip()
8
  GOOGLE_MAPS_API_KEY = os.environ.get("GOOGLE_MAPS_API_KEY", "").strip()
 
9
  OPENROUTER_BASE = "https://openrouter.ai/api/v1"
10
 
11
  MODEL_PRIMARY = "google/gemma-4-31b-it:free"
 
6
  # reject the Authorization header as an illegal value).
7
  OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "").strip()
8
  GOOGLE_MAPS_API_KEY = os.environ.get("GOOGLE_MAPS_API_KEY", "").strip()
9
+ MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", "").strip()
10
  OPENROUTER_BASE = "https://openrouter.ai/api/v1"
11
 
12
  MODEL_PRIMARY = "google/gemma-4-31b-it:free"
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.9.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.10.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/mapbox.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mapbox Static Images API — satellite + topographic map fetchers.
3
+
4
+ The risk analyst (v0.10+) reasons about three visual perspectives in
5
+ a single Gemma 4 inference call:
6
+
7
+ - Street view (Google) — eye-level: lot grade, basement entries, downspouts
8
+ - Satellite (Mapbox) — bird's-eye: impervious surface %, drainage proximity
9
+ - Topographic (Mapbox) — contour lines: micro-depressions, terrain slope
10
+
11
+ Each capture is fetched server-side, base64-encoded, and inlined as a
12
+ data: URL into the risk analyst's prompt. No upstream re-fetch.
13
+
14
+ Free tier: Mapbox gives 50K static-image loads/month per account, so
15
+ 2 maps × 25K assessments/month is well within budget.
16
+
17
+ If MAPBOX_ACCESS_TOKEN is not set the helpers return None and the
18
+ risk analyst gracefully falls back to whatever images it does have
19
+ (Street View only, or none).
20
+ """
21
+ import base64
22
+ from typing import Optional
23
+
24
+ import httpx
25
+
26
+ from app.config import MAPBOX_ACCESS_TOKEN
27
+
28
+ # Both endpoints follow the Static Images API contract:
29
+ # /styles/v1/{user}/{style_id}/static/{lon},{lat},{zoom},{bearing}/{w}x{h}{@2x}
30
+ # We use the public 'mapbox' user's stock styles.
31
+ _BASE = "https://api.mapbox.com/styles/v1/mapbox"
32
+
33
+ # Satellite zoom 17 = tight neighborhood (~150m across @2x). Enough to
34
+ # distinguish individual buildings and impervious surfaces.
35
+ _SAT_STYLE = "satellite-v9"
36
+ _SAT_ZOOM = 17
37
+
38
+ # Topo / outdoor zoom 15 = wider area (~600m across @2x). Wide enough to
39
+ # show terrain context, drainage features, and named waterways.
40
+ _TOPO_STYLE = "outdoors-v12"
41
+ _TOPO_ZOOM = 15
42
+
43
+ # 600x600 @2x → 1200x1200 actual pixels. Good detail for vision tokens
44
+ # without blowing the context budget.
45
+ _SIZE = "600x600@2x"
46
+
47
+
48
+ def _is_configured() -> bool:
49
+ return bool(MAPBOX_ACCESS_TOKEN)
50
+
51
+
52
+ async def _fetch_static(style: str, zoom: int, lat: float, lon: float) -> Optional[dict]:
53
+ if not _is_configured():
54
+ return None
55
+ url = (
56
+ f"{_BASE}/{style}/static/{lon},{lat},{zoom},0/{_SIZE}"
57
+ f"?access_token={MAPBOX_ACCESS_TOKEN}"
58
+ )
59
+ async with httpx.AsyncClient(timeout=20) as client:
60
+ resp = await client.get(url)
61
+ if resp.status_code != 200:
62
+ return None
63
+ ct = resp.headers.get("content-type", "image/png").split(";")[0].strip()
64
+ b64 = base64.b64encode(resp.content).decode("ascii")
65
+ return {
66
+ "data_url": f"data:{ct};base64,{b64}",
67
+ "bytes": len(resp.content),
68
+ "style": style,
69
+ "zoom": zoom,
70
+ "lat": lat,
71
+ "lon": lon,
72
+ }
73
+
74
+
75
+ async def get_satellite_image(lat: float, lon: float) -> Optional[dict]:
76
+ return await _fetch_static(_SAT_STYLE, _SAT_ZOOM, lat, lon)
77
+
78
+
79
+ async def get_topo_image(lat: float, lon: float) -> Optional[dict]:
80
+ return await _fetch_static(_TOPO_STYLE, _TOPO_ZOOM, lat, lon)
static/index.html CHANGED
@@ -1112,8 +1112,10 @@ const mapDossier = (raw) => {
1112
  reasoning_trace: risk.reasoning_trace || "",
1113
  visual_corroboration: risk.visual_corroboration || "",
1114
  used_streetview_image: !!risk.used_streetview_image,
 
1115
  advisor_tldr,
1116
  streetview: raw.streetview || {},
 
1117
  };
1118
  };
1119
 
@@ -1121,6 +1123,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1121
  const D = useMemo(() => mapDossier(dossier), [dossier]);
1122
  const [showReasoning, setShowReasoning] = useState(false);
1123
  const [showBoxes, setShowBoxes] = useState(true);
 
1124
  return (
1125
  <div className="screen dossier-wrap">
1126
  <div className="dossier">
@@ -1208,7 +1211,24 @@ const DossierScreen = ({ onBack, dossier }) => {
1208
  )}
1209
  </Section>
1210
 
1211
- {D.streetview?.available && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1212
  <Section
1213
  num="§03"
1214
  icon={<Icon name="pin" color="var(--accent)" size={16}/>}
@@ -1217,13 +1237,34 @@ const DossierScreen = ({ onBack, dossier }) => {
1217
  badge={<span className="risk-tag purple">Gemma 4 vision</span>}>
1218
  <div style={{display:"grid", gridTemplateColumns:"320px 1fr", gap:18, alignItems:"start"}}>
1219
  <div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1220
  <div style={{position:"relative", borderRadius:10, overflow:"hidden", border:"1px solid var(--line)", lineHeight:0}}>
1221
  <img
1222
- src={D.streetview.image_data_url}
1223
- alt="Google Street View of the property"
1224
  style={{width:"100%", display:"block"}}
1225
  />
1226
- {showBoxes && D.streetview.indicators?.some(i => i.box_2d) && (
1227
  <svg
1228
  viewBox="0 0 1000 1000"
1229
  preserveAspectRatio="none"
@@ -1238,9 +1279,8 @@ const DossierScreen = ({ onBack, dossier }) => {
1238
  : sev === "low" ? "#0F8A6A"
1239
  : "#2B6FD4";
1240
  const labelText = `${i+1}. ${ind.feature || "indicator"}`;
1241
- // SVG text has no native background; we put a rect behind it.
1242
- const labelWidthEstimate = labelText.length * 11 + 12; // rough px estimate at scale
1243
- const labelY = y1 > 50 ? y1 - 8 : y2 + 32; // above if room, else below
1244
  return (
1245
  <g key={i}>
1246
  <rect
@@ -1273,7 +1313,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1273
  </svg>
1274
  )}
1275
  </div>
1276
- {D.streetview.indicators?.some(i => i.box_2d) && (
1277
  <button
1278
  onClick={() => setShowBoxes(s => !s)}
1279
  style={{
@@ -1287,9 +1327,18 @@ const DossierScreen = ({ onBack, dossier }) => {
1287
  </button>
1288
  )}
1289
  <div style={{fontSize:11, color:"var(--ink-4)", marginTop:6, fontFamily:"'JetBrains Mono', monospace"}}>
1290
- {D.streetview.copyright || "© Google"}
1291
- {D.streetview.capture_date && ` · captured ${D.streetview.capture_date}`}
1292
- {D.streetview.pano_distance_m != null && ` · ${D.streetview.pano_distance_m}m from address`}
 
 
 
 
 
 
 
 
 
1293
  </div>
1294
  </div>
1295
  <div>
@@ -1341,10 +1390,11 @@ const DossierScreen = ({ onBack, dossier }) => {
1341
  </div>
1342
  </div>
1343
  </Section>
1344
- )}
 
1345
 
1346
  <Section
1347
- num={D.streetview?.available ? "§04" : "§03"}
1348
  icon={<Icon name="warn" color="var(--coral)" size={16}/>}
1349
  iconBg="var(--coral-soft)"
1350
  title="Why FEMA's flood map isn't the whole story"
@@ -1382,11 +1432,19 @@ const DossierScreen = ({ onBack, dossier }) => {
1382
  )}
1383
  </div>
1384
  )}
1385
- {(D.visual_corroboration || D.used_streetview_image) && (
 
 
 
 
 
 
 
 
1386
  <div style={{marginTop: 18, padding: "14px 16px", background: "linear-gradient(135deg, rgba(110,95,216,0.08), rgba(43,111,212,0.06))", border: "1px solid rgba(110,95,216,0.25)", borderRadius: 10}}>
1387
- <div style={{display:"flex", alignItems:"center", gap: 8, fontSize: 11, fontFamily: "'JetBrains Mono', monospace", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--purple)", marginBottom: 8}}>
1388
  <span>◆ Gemma 4 multimodal reasoning</span>
1389
- <span style={{color: "var(--ink-4)", fontWeight: 400, textTransform: "none", letterSpacing: "0.02em"}}>· image + 6 data sources + chain-of-thought · one inference call</span>
1390
  </div>
1391
  {D.visual_corroboration ? (
1392
  <p style={{margin: 0, fontSize: 14, lineHeight: 1.55, color: "var(--ink)"}}>
@@ -1394,11 +1452,12 @@ const DossierScreen = ({ onBack, dossier }) => {
1394
  </p>
1395
  ) : (
1396
  <p style={{margin: 0, fontSize: 13, color: "var(--ink-3)"}}>
1397
- The risk analyst received the Street View photograph and reasoned about it directly alongside the data — see the trace below.
1398
  </p>
1399
  )}
1400
  </div>
1401
- )}
 
1402
  {D.reasoning_trace && (
1403
  <div style={{marginTop: 12, padding: "12px 14px", background: "rgba(110,95,216,0.06)", border: "1px solid rgba(110,95,216,0.2)", borderRadius: 10}}>
1404
  <div style={{display:"flex", alignItems:"center", justifyContent:"space-between", cursor:"pointer"}} onClick={() => setShowReasoning(s => !s)}>
@@ -1415,7 +1474,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1415
  </Section>
1416
 
1417
  <Section
1418
- num={D.streetview?.available ? "§05" : "§04"}
1419
  icon={<Icon name="drop" color="var(--accent)" size={16}/>}
1420
  iconBg="var(--accent-soft)"
1421
  title="The raw signals we looked at"
@@ -1429,7 +1488,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1429
  </Section>
1430
 
1431
  <Section
1432
- num={D.streetview?.available ? "§06" : "§05"}
1433
  icon={<Icon name="news" color="var(--amber)" size={16}/>}
1434
  iconBg="var(--amber-soft)"
1435
  title="Recent local flood news">
@@ -1464,7 +1523,7 @@ const Chrome = ({ screen, onJump, dark, onToggleDark, language, onLanguageChange
1464
  <div className="wordmark" onClick={()=>onJump("search")} style={{cursor:"pointer"}}>
1465
  <span className="glyph">F</span>
1466
  <span>FlutIQ</span>
1467
- <span style={{color:"var(--ink-4)",fontSize:12,marginLeft:8,fontFamily:"JetBrains Mono"}}>v0.9 · beta</span>
1468
  </div>
1469
  <div className="chrome-meta">
1470
  <span className="pill static"><span className="dot"/>gemma-4 · OpenRouter</span>
 
1112
  reasoning_trace: risk.reasoning_trace || "",
1113
  visual_corroboration: risk.visual_corroboration || "",
1114
  used_streetview_image: !!risk.used_streetview_image,
1115
+ images_used: risk.images_used || [],
1116
  advisor_tldr,
1117
  streetview: raw.streetview || {},
1118
+ maps: raw.maps || {},
1119
  };
1120
  };
1121
 
 
1123
  const D = useMemo(() => mapDossier(dossier), [dossier]);
1124
  const [showReasoning, setShowReasoning] = useState(false);
1125
  const [showBoxes, setShowBoxes] = useState(true);
1126
+ const [imageTab, setImageTab] = useState("streetview"); // "streetview" | "satellite" | "topo"
1127
  return (
1128
  <div className="screen dossier-wrap">
1129
  <div className="dossier">
 
1211
  )}
1212
  </Section>
1213
 
1214
+ {(D.streetview?.available || D.maps?.satellite || D.maps?.topo) && (() => {
1215
+ const tabs = [
1216
+ D.streetview?.available && {key:"streetview", label:"Street level", source:"Google"},
1217
+ D.maps?.satellite && {key:"satellite", label:"Satellite", source:"Mapbox"},
1218
+ D.maps?.topo && {key:"topo", label:"Wider area", source:"Mapbox"},
1219
+ ].filter(Boolean);
1220
+ // Auto-correct selection if current tab isn't available.
1221
+ const activeKey = tabs.find(t => t.key === imageTab) ? imageTab : tabs[0]?.key;
1222
+ const activeSrc = activeKey === "streetview" ? D.streetview.image_data_url
1223
+ : activeKey === "satellite" ? D.maps.satellite.data_url
1224
+ : activeKey === "topo" ? D.maps.topo.data_url
1225
+ : null;
1226
+ const activeAlt = activeKey === "streetview" ? "Google Street View of the property"
1227
+ : activeKey === "satellite" ? "Mapbox satellite view of the property"
1228
+ : activeKey === "topo" ? "Mapbox wider-area context map of the property"
1229
+ : "";
1230
+ const showOverlay = activeKey === "streetview" && showBoxes && D.streetview.indicators?.some(i => i.box_2d);
1231
+ return (
1232
  <Section
1233
  num="§03"
1234
  icon={<Icon name="pin" color="var(--accent)" size={16}/>}
 
1237
  badge={<span className="risk-tag purple">Gemma 4 vision</span>}>
1238
  <div style={{display:"grid", gridTemplateColumns:"320px 1fr", gap:18, alignItems:"start"}}>
1239
  <div>
1240
+ {tabs.length > 1 && (
1241
+ <div style={{display:"flex", gap:6, marginBottom:8}}>
1242
+ {tabs.map(t => (
1243
+ <button
1244
+ key={t.key}
1245
+ onClick={() => setImageTab(t.key)}
1246
+ style={{
1247
+ flex:1, padding:"6px 8px", fontSize:11,
1248
+ fontFamily:"'JetBrains Mono', monospace",
1249
+ letterSpacing:"0.04em", textTransform:"uppercase",
1250
+ background: activeKey === t.key ? "var(--accent-soft)" : "transparent",
1251
+ color: activeKey === t.key ? "var(--accent)" : "var(--ink-3)",
1252
+ border: `1px solid ${activeKey === t.key ? "var(--accent)" : "var(--line)"}`,
1253
+ borderRadius:6, cursor:"pointer",
1254
+ }}
1255
+ >
1256
+ {t.label}
1257
+ </button>
1258
+ ))}
1259
+ </div>
1260
+ )}
1261
  <div style={{position:"relative", borderRadius:10, overflow:"hidden", border:"1px solid var(--line)", lineHeight:0}}>
1262
  <img
1263
+ src={activeSrc}
1264
+ alt={activeAlt}
1265
  style={{width:"100%", display:"block"}}
1266
  />
1267
+ {showOverlay && (
1268
  <svg
1269
  viewBox="0 0 1000 1000"
1270
  preserveAspectRatio="none"
 
1279
  : sev === "low" ? "#0F8A6A"
1280
  : "#2B6FD4";
1281
  const labelText = `${i+1}. ${ind.feature || "indicator"}`;
1282
+ const labelWidthEstimate = labelText.length * 11 + 12;
1283
+ const labelY = y1 > 50 ? y1 - 8 : y2 + 32;
 
1284
  return (
1285
  <g key={i}>
1286
  <rect
 
1313
  </svg>
1314
  )}
1315
  </div>
1316
+ {activeKey === "streetview" && D.streetview.indicators?.some(i => i.box_2d) && (
1317
  <button
1318
  onClick={() => setShowBoxes(s => !s)}
1319
  style={{
 
1327
  </button>
1328
  )}
1329
  <div style={{fontSize:11, color:"var(--ink-4)", marginTop:6, fontFamily:"'JetBrains Mono', monospace"}}>
1330
+ {activeKey === "streetview" && (
1331
+ <>{D.streetview.copyright || Google"}
1332
+ {D.streetview.capture_date && ` · captured ${D.streetview.capture_date}`}
1333
+ {D.streetview.pano_distance_m != null && ` · ${D.streetview.pano_distance_m}m from address`}
1334
+ </>
1335
+ )}
1336
+ {activeKey === "satellite" && (
1337
+ <>© Mapbox · © OpenStreetMap · zoom {D.maps.satellite.zoom}</>
1338
+ )}
1339
+ {activeKey === "topo" && (
1340
+ <>© Mapbox · © OpenStreetMap · zoom {D.maps.topo.zoom}</>
1341
+ )}
1342
  </div>
1343
  </div>
1344
  <div>
 
1390
  </div>
1391
  </div>
1392
  </Section>
1393
+ );
1394
+ })()}
1395
 
1396
  <Section
1397
+ num={(D.streetview?.available || D.maps?.satellite || D.maps?.topo) ? "§04" : "§03"}
1398
  icon={<Icon name="warn" color="var(--coral)" size={16}/>}
1399
  iconBg="var(--coral-soft)"
1400
  title="Why FEMA's flood map isn't the whole story"
 
1432
  )}
1433
  </div>
1434
  )}
1435
+ {(D.visual_corroboration || D.used_streetview_image || (D.images_used && D.images_used.length > 0)) && (() => {
1436
+ const _imgLabel = {streetview: "street view", satellite: "satellite", topo: "wider-area map"};
1437
+ const usedImages = (D.images_used || []).map(k => _imgLabel[k] || k);
1438
+ const imgPart = usedImages.length === 0
1439
+ ? "no images"
1440
+ : usedImages.length === 1
1441
+ ? `${usedImages[0]} image`
1442
+ : `${usedImages.length} images (${usedImages.join(" + ")})`;
1443
+ return (
1444
  <div style={{marginTop: 18, padding: "14px 16px", background: "linear-gradient(135deg, rgba(110,95,216,0.08), rgba(43,111,212,0.06))", border: "1px solid rgba(110,95,216,0.25)", borderRadius: 10}}>
1445
+ <div style={{display:"flex", alignItems:"center", gap: 8, fontSize: 11, fontFamily: "'JetBrains Mono', monospace", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--purple)", marginBottom: 8, flexWrap:"wrap"}}>
1446
  <span>◆ Gemma 4 multimodal reasoning</span>
1447
+ <span style={{color: "var(--ink-4)", fontWeight: 400, textTransform: "none", letterSpacing: "0.02em"}}>· {imgPart} + 6 data sources + chain-of-thought · one inference call</span>
1448
  </div>
1449
  {D.visual_corroboration ? (
1450
  <p style={{margin: 0, fontSize: 14, lineHeight: 1.55, color: "var(--ink)"}}>
 
1452
  </p>
1453
  ) : (
1454
  <p style={{margin: 0, fontSize: 13, color: "var(--ink-3)"}}>
1455
+ The risk analyst received {imgPart} and reasoned about them directly alongside the data — see the trace below.
1456
  </p>
1457
  )}
1458
  </div>
1459
+ );
1460
+ })()}
1461
  {D.reasoning_trace && (
1462
  <div style={{marginTop: 12, padding: "12px 14px", background: "rgba(110,95,216,0.06)", border: "1px solid rgba(110,95,216,0.2)", borderRadius: 10}}>
1463
  <div style={{display:"flex", alignItems:"center", justifyContent:"space-between", cursor:"pointer"}} onClick={() => setShowReasoning(s => !s)}>
 
1474
  </Section>
1475
 
1476
  <Section
1477
+ num={(D.streetview?.available || D.maps?.satellite || D.maps?.topo) ? "§05" : "§04"}
1478
  icon={<Icon name="drop" color="var(--accent)" size={16}/>}
1479
  iconBg="var(--accent-soft)"
1480
  title="The raw signals we looked at"
 
1488
  </Section>
1489
 
1490
  <Section
1491
+ num={(D.streetview?.available || D.maps?.satellite || D.maps?.topo) ? "§06" : "§05"}
1492
  icon={<Icon name="news" color="var(--amber)" size={16}/>}
1493
  iconBg="var(--amber-soft)"
1494
  title="Recent local flood news">
 
1523
  <div className="wordmark" onClick={()=>onJump("search")} style={{cursor:"pointer"}}>
1524
  <span className="glyph">F</span>
1525
  <span>FlutIQ</span>
1526
+ <span style={{color:"var(--ink-4)",fontSize:12,marginLeft:8,fontFamily:"JetBrains Mono"}}>v0.10 · beta</span>
1527
  </div>
1528
  <div className="chrome-meta">
1529
  <span className="pill static"><span className="dot"/>gemma-4 · OpenRouter</span>