kredd25 Claude Opus 4.7 (1M context) commited on
Commit
dedefab
·
1 Parent(s): f9386fc

feature: streetview agent — multimodal Gemma 4 vision

Browse files

The single deepest Gemma 4 capability we hadn't been using. Adds a
6th data agent that fetches a Google Street View image at the
property and asks Gemma 4 (vision) to identify visible flood-risk
indicators: basement-level windows, ground-floor HVAC, drainage
infrastructure, evidence of past water damage, elevation vs street.

Backend:
- New backend/app/tools/streetview.py — Google Street View Static
API client. Metadata-first pattern (free, doesn't count against
quota): walks radii None→50m→200m→500m looking for a real
panorama before fetching the image. Computes a bearing from the
panorama's actual location back to the geocoded address so the
camera aims AT the building (without this, default heading shows
the road, not the property — verified with a Drexel Blvd test
case where unaimed heading produced property_visible=false but
the aimed shot found a real impermeable-paving indicator).
Image returned as a data: URL ready to inline into the vision
request.
- New backend/app/agents/streetview_agent.py — sends the data URL
to Gemma 4 with the OpenAI-format image_url content-part. System
prompt explicitly tells the model NOT to fabricate features it
can't see (returning indicators=[] with low confidence is the
right answer when the property isn't clearly visible). Output
schema: indicators[], property_visible, image_quality,
elevation_vs_street, overall_visual_risk, confidence, summary.
Image data URL passed through to the dossier so the UI can show
the thumbnail Gemma actually saw.
- Orchestrator now builds the data-agent pack per-request (so the
streetview agent can receive the request's language for its
summary copy). Streetview runs in parallel with the other 5
data agents.

Frontend:
- AGENTS list gets a 'Streetview surveyor' entry (so the agents
screen shows it lighting up live).
- mapDossier surfaces raw.streetview.
- New §03 'What we saw at the property' section appears when
Street View has coverage. Shows the actual image side-by-side
with the model's interpretation: visual-risk badge, elevation
badge, confidence badge, bullet list of indicators with severity
+ 1-line implication. Falls back gracefully to the model's
honest summary when no specific indicators are visible.
- Subsequent section numbers shift §04→§05, §05→§06 when streetview
is shown.

Vision capability verified on the :free tier with BYOK in
scripts/smoke_test_vision.py. Image fetch + base64 + heading
calculation cost is ~150ms; the Gemma 4 vision call itself is
~3-5s. Total pipeline still ~30-40s.

Requires GOOGLE_MAPS_API_KEY env var (added to backend/app/config.py).
Free tier: 28K Street View Static requests/month against the
$200/month Maps Platform credit. For hackathon usage = $0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

app/agents/orchestrator.py CHANGED
@@ -22,6 +22,7 @@ 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.weather_agent import run_weather_agent
26
  from app.tools.geocoder import geocode_address
27
 
@@ -56,14 +57,33 @@ async def _archive(ctx: GeoCtx) -> dict:
56
  )
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
59
  # Order here is the order the frontend renders agent rows in.
60
- DATA_AGENTS: dict[str, AgentFn] = {
61
- "fema": _fema,
62
- "local": _local,
63
- "weather": _weather,
64
- "news": _news,
65
- "archive": _archive,
66
- }
 
 
 
 
 
 
 
 
67
 
68
 
69
  def sse(event: str, data: dict) -> str:
@@ -94,8 +114,12 @@ async def run_assessment(
94
  "county": geo.get("county", ""),
95
  })
96
 
 
 
 
 
97
  # Announce all data agents up front so the UI can render rows.
98
- for name in DATA_AGENTS:
99
  yield sse("agent_update", {
100
  "agent": name,
101
  "status": "working",
@@ -113,7 +137,7 @@ async def run_assessment(
113
  })
114
 
115
  # Run data agents in parallel; stream results in completion order.
116
- tasks = {name: asyncio.create_task(fn(ctx)) for name, fn in DATA_AGENTS.items()}
117
  task_to_name = {t: n for n, t in tasks.items()}
118
  pending = set(tasks.values())
119
  results: dict[str, dict] = {}
@@ -203,6 +227,7 @@ def _compile_dossier(geo: GeoCtx, results: dict) -> dict:
203
  "weather": results.get("weather", {}),
204
  "news": results.get("news", {}),
205
  "archive": results.get("archive", {}),
 
206
  "risk": results.get("risk", {}),
207
  "advisor": results.get("advisor", {}),
208
  }
 
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.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
 
 
57
  )
58
 
59
 
60
+ # The streetview agent needs language for its summary copy. Curried in
61
+ # at agent-pack-construction time below.
62
+ def _make_streetview(language: str) -> AgentFn:
63
+ async def _run(ctx: GeoCtx) -> dict:
64
+ return await run_streetview_agent(
65
+ ctx["lat"], ctx["lon"], ctx.get("display_name", ""),
66
+ language=language,
67
+ )
68
+ return _run
69
+
70
+
71
  # Order here is the order the frontend renders agent rows in.
72
+ # streetview is the only multimodal one — placed last in the data row.
73
+ def _data_agents_for(language: str) -> dict[str, AgentFn]:
74
+ return {
75
+ "fema": _fema,
76
+ "local": _local,
77
+ "weather": _weather,
78
+ "news": _news,
79
+ "archive": _archive,
80
+ "streetview": _make_streetview(language),
81
+ }
82
+
83
+
84
+ # Static handle used by the frontend to enumerate agent ids before a
85
+ # request fires (see AGENTS array in index.html).
86
+ DATA_AGENTS: dict[str, AgentFn] = _data_agents_for("en")
87
 
88
 
89
  def sse(event: str, data: dict) -> str:
 
114
  "county": geo.get("county", ""),
115
  })
116
 
117
+ # Build the data-agent pack with the request's language so the
118
+ # streetview agent's summary text matches the dossier language.
119
+ data_agents = _data_agents_for(language)
120
+
121
  # Announce all data agents up front so the UI can render rows.
122
+ for name in data_agents:
123
  yield sse("agent_update", {
124
  "agent": name,
125
  "status": "working",
 
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()}
142
  pending = set(tasks.values())
143
  results: dict[str, dict] = {}
 
227
  "weather": results.get("weather", {}),
228
  "news": results.get("news", {}),
229
  "archive": results.get("archive", {}),
230
+ "streetview": results.get("streetview", {}),
231
  "risk": results.get("risk", {}),
232
  "advisor": results.get("advisor", {}),
233
  }
app/agents/streetview_agent.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Street View flood-indicator agent — the multimodal Gemma 4 showcase.
3
+
4
+ Fetches a Google Street View image at the property, then asks Gemma 4
5
+ (vision) to identify visible flood-risk features: basement-level
6
+ windows, ground-floor HVAC, below-grade entries, drainage
7
+ infrastructure, evidence of prior water damage, elevation relative
8
+ to street grade.
9
+
10
+ This is the only agent that exercises Gemma 4's multimodal capability.
11
+ Verified to work on the :free tier with BYOK in scripts/smoke_test_vision.py.
12
+
13
+ Returns the verdict alongside the image (data URL) so the dossier UI
14
+ can show a thumbnail next to the findings.
15
+ """
16
+ import json
17
+
18
+ from app.data.languages import prompt_directive
19
+ from app.llm.client import call_gemma4, extract_text, parse_json_response
20
+ from app.tools.streetview import fetch_streetview_for
21
+
22
+
23
+ SYSTEM_PROMPT = """You are a flood-risk surveyor analyzing a street-level photograph of a residential or small-commercial property. Your job is to identify visible features in the image that affect flood vulnerability.
24
+
25
+ Critical rules:
26
+ - Only describe features you can actually see. NEVER fabricate a feature to fill the JSON.
27
+ - If the image is partially obstructed, low quality, or doesn't show the property clearly, say so via "confidence": "low" and a short summary explaining what's missing.
28
+ - Distinguish "I see X and it implies Y" from "I'd want to verify Y by looking inside."
29
+
30
+ What to look for, in plain language:
31
+ - Basement-level windows (small, below sidewalk level, sometimes with metal grating) — vulnerable to surface water entering directly
32
+ - Ground-floor HVAC units, water heaters, electrical meters mounted at low elevation — expensive to replace if submerged
33
+ - Below-grade entries, basement stairwells, driveways sloping toward the building
34
+ - Visible downspouts that discharge into hard surfaces or storm drains rather than landscaping
35
+ - Storm drains, swales, retaining walls, French drains, sandbags
36
+ - Watermarks, staining, repair patches, or rust — evidence of past water exposure
37
+ - The property's elevation relative to the street and neighboring buildings (sunken vs raised)
38
+ - Proximity to obvious water features (canals, low-lying parks, creeks)
39
+
40
+ Always respond with valid JSON only."""
41
+
42
+
43
+ async def run_streetview_agent(
44
+ lat: float,
45
+ lon: float,
46
+ address: str,
47
+ language: str = "en",
48
+ ) -> dict:
49
+ sv = await fetch_streetview_for(lat, lon)
50
+
51
+ if not sv.get("available"):
52
+ return {
53
+ "available": False,
54
+ "summary": (
55
+ "No street-level imagery available for this address — "
56
+ "Google Street View has no panorama within 500m."
57
+ ),
58
+ "raw": sv,
59
+ }
60
+
61
+ user_prompt = f"""Examine this street-level photo of the property at {address}.
62
+
63
+ The photo was captured by Google Street View in {sv.get('capture_date') or 'an unknown date'} from
64
+ approximately ({sv.get('pano_lat'):.5f}, {sv.get('pano_lon'):.5f})
65
+ {f"— {sv.get('radius_m')}m from the geocoded address" if sv.get('radius_m') else "— at the geocoded address"}.
66
+
67
+ Identify visible flood-risk indicators. Return a JSON object with:
68
+ {{
69
+ "indicators": [
70
+ {{
71
+ "feature": "<short name, e.g. 'basement-level windows'>",
72
+ "location_in_image": "<e.g. 'lower-left of the building facade'>",
73
+ "risk_implication": "<1 sentence on what this means for flood risk>",
74
+ "severity": "low" | "moderate" | "high"
75
+ }}
76
+ ],
77
+ "property_visible": true | false,
78
+ "image_quality": "good" | "partial" | "poor",
79
+ "elevation_vs_street": "below_grade" | "at_grade" | "elevated" | "unclear",
80
+ "overall_visual_risk": "low" | "moderate" | "high",
81
+ "confidence": "low" | "medium" | "high",
82
+ "summary": "<1 sentence for the status feed>"
83
+ }}
84
+
85
+ If you can't see the property clearly (image obstructed, wrong angle, distant
86
+ street), set property_visible=false and confidence=low; don't invent indicators.
87
+
88
+ Return ONLY the JSON object."""
89
+
90
+ response = await call_gemma4(
91
+ messages=[
92
+ {"role": "system", "content": SYSTEM_PROMPT + prompt_directive(language)},
93
+ {"role": "user", "content": [
94
+ {"type": "text", "text": user_prompt},
95
+ {"type": "image_url", "image_url": {"url": sv["image_data_url"]}},
96
+ ]},
97
+ ],
98
+ temperature=0.2,
99
+ max_tokens=1500,
100
+ )
101
+
102
+ text = extract_text(response)
103
+ parsed = parse_json_response(text) or {}
104
+
105
+ # Always attach the image data URL + provenance so the UI can render
106
+ # the thumbnail and credit Google for the imagery.
107
+ parsed["available"] = True
108
+ parsed["image_data_url"] = sv["image_data_url"]
109
+ parsed["pano_id"] = sv.get("pano_id")
110
+ parsed["capture_date"] = sv.get("capture_date")
111
+ parsed["copyright"] = sv.get("copyright")
112
+ parsed["pano_distance_m"] = sv.get("radius_m")
113
+
114
+ if "summary" not in parsed:
115
+ parsed["summary"] = "Street-level survey complete"
116
+ if not parsed.get("indicators"):
117
+ parsed.setdefault("indicators", [])
118
+ parsed.setdefault("raw_text", text if not parse_json_response(text) else None)
119
+ if parsed["raw_text"] is None:
120
+ parsed.pop("raw_text", None)
121
+
122
+ return parsed
app/config.py CHANGED
@@ -5,6 +5,7 @@ import os
5
  # paste flows (HF Spaces secrets UI adds a trailing \n which makes httpx
6
  # reject the Authorization header as an illegal value).
7
  OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "").strip()
 
8
  OPENROUTER_BASE = "https://openrouter.ai/api/v1"
9
 
10
  MODEL_PRIMARY = "google/gemma-4-31b-it:free"
 
5
  # paste flows (HF Spaces secrets UI adds a trailing \n which makes httpx
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"
app/tools/streetview.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google Street View Static API tool.
3
+
4
+ Strategy (per Google's guidance):
5
+ 1. Hit /streetview/metadata first — FREE, doesn't count against
6
+ billing quota. Tells us if there's actual panorama coverage.
7
+ 2. If status == OK at the requested point or within a small radius,
8
+ fetch the actual image at the panorama's true lat/lon (which may
9
+ differ from the input by tens of meters if Google snapped to the
10
+ nearest street).
11
+ 3. If no coverage anywhere within 500m, return None — the agent
12
+ surfaces an honest "no street-level imagery" finding instead
13
+ of feeding the gray "no imagery" placeholder to the vision
14
+ model (which would hallucinate features in gray pixels).
15
+
16
+ The image is returned as a data: URL (base64-encoded) so we can
17
+ inline it directly into the Gemma 4 vision request without making
18
+ the upstream provider re-fetch (which we learned the hard way fails
19
+ when User-Agent rules block the upstream).
20
+ """
21
+ import base64
22
+ import math
23
+ from typing import Optional
24
+
25
+ import httpx
26
+
27
+ from app.config import GOOGLE_MAPS_API_KEY
28
+
29
+ METADATA_URL = "https://maps.googleapis.com/maps/api/streetview/metadata"
30
+ IMAGE_URL = "https://maps.googleapis.com/maps/api/streetview"
31
+
32
+ # Try increasingly wider radii until we find a panorama.
33
+ # Most addresses with coverage hit on the first attempt; the 500m
34
+ # fallback catches addresses on private roads or driveways where the
35
+ # nearest pano is a block or two away.
36
+ SEARCH_RADII_M = (None, 50, 200, 500)
37
+
38
+ # Bias the camera angle slightly downward — flood-risk indicators
39
+ # (basement windows, ground-floor HVAC, drainage) are at street level
40
+ # and below.
41
+ DEFAULT_PITCH = -5
42
+ DEFAULT_FOV = 90
43
+
44
+
45
+ def _bearing_deg(from_lat: float, from_lon: float, to_lat: float, to_lon: float) -> float:
46
+ """Compute the initial bearing (in degrees, 0=N, 90=E) from one
47
+ coordinate to another. Used to aim the Street View camera from the
48
+ captured panorama back toward the user's geocoded address."""
49
+ phi1 = math.radians(from_lat)
50
+ phi2 = math.radians(to_lat)
51
+ dlon = math.radians(to_lon - from_lon)
52
+ x = math.sin(dlon) * math.cos(phi2)
53
+ y = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlon)
54
+ return (math.degrees(math.atan2(x, y)) + 360.0) % 360.0
55
+
56
+
57
+ async def _metadata(client: httpx.AsyncClient, lat: float, lon: float, radius: Optional[int]) -> dict:
58
+ params = {
59
+ "location": f"{lat},{lon}",
60
+ "key": GOOGLE_MAPS_API_KEY,
61
+ }
62
+ if radius is not None:
63
+ params["radius"] = str(radius)
64
+ resp = await client.get(METADATA_URL, params=params)
65
+ if resp.status_code != 200:
66
+ return {"status": f"HTTP_{resp.status_code}"}
67
+ return resp.json() or {}
68
+
69
+
70
+ async def fetch_streetview_for(lat: float, lon: float, size: str = "640x480") -> dict:
71
+ """
72
+ Fetch the best available Street View image near (lat, lon).
73
+
74
+ Returns:
75
+ On success:
76
+ {
77
+ "available": True,
78
+ "image_data_url": "data:image/jpeg;base64,...",
79
+ "pano_id": str,
80
+ "pano_lat": float, "pano_lon": float,
81
+ "capture_date": "YYYY-MM",
82
+ "radius_m": int (which radius found it; None = exact),
83
+ }
84
+ On no coverage:
85
+ {"available": False, "reason": "no panorama within 500m"}
86
+ On config error:
87
+ {"available": False, "reason": "GOOGLE_MAPS_API_KEY not set"}
88
+ """
89
+ if not GOOGLE_MAPS_API_KEY:
90
+ return {"available": False, "reason": "GOOGLE_MAPS_API_KEY not set"}
91
+
92
+ async with httpx.AsyncClient(timeout=20) as client:
93
+ meta = None
94
+ used_radius = None
95
+ for radius in SEARCH_RADII_M:
96
+ meta = await _metadata(client, lat, lon, radius)
97
+ if meta.get("status") == "OK":
98
+ used_radius = radius
99
+ break
100
+
101
+ if not meta or meta.get("status") != "OK":
102
+ return {
103
+ "available": False,
104
+ "reason": f"no Street View panorama within {SEARCH_RADII_M[-1]}m",
105
+ "metadata_status": meta.get("status") if meta else "unknown",
106
+ }
107
+
108
+ pano_loc = meta.get("location") or {}
109
+ pano_lat = pano_loc.get("lat", lat)
110
+ pano_lon = pano_loc.get("lng", lon)
111
+
112
+ # Aim the camera back at the user's actual address. Without
113
+ # this, the API picks a default heading that often shows the
114
+ # road instead of the building.
115
+ heading = _bearing_deg(pano_lat, pano_lon, lat, lon)
116
+
117
+ img_params = {
118
+ "size": size,
119
+ "location": f"{pano_lat},{pano_lon}",
120
+ "heading": f"{heading:.1f}",
121
+ "fov": str(DEFAULT_FOV),
122
+ "pitch": str(DEFAULT_PITCH),
123
+ "key": GOOGLE_MAPS_API_KEY,
124
+ "return_error_code": "true", # 4xx instead of placeholder image
125
+ }
126
+ img_resp = await client.get(IMAGE_URL, params=img_params)
127
+ if img_resp.status_code != 200:
128
+ return {
129
+ "available": False,
130
+ "reason": f"image fetch returned HTTP {img_resp.status_code}",
131
+ }
132
+
133
+ b64 = base64.b64encode(img_resp.content).decode("ascii")
134
+ ct = img_resp.headers.get("content-type", "image/jpeg").split(";")[0].strip()
135
+
136
+ return {
137
+ "available": True,
138
+ "image_data_url": f"data:{ct};base64,{b64}",
139
+ "image_bytes": len(img_resp.content),
140
+ "pano_id": meta.get("pano_id"),
141
+ "pano_lat": pano_lat,
142
+ "pano_lon": pano_lon,
143
+ "heading_deg": heading,
144
+ "capture_date": meta.get("date"),
145
+ "copyright": meta.get("copyright"),
146
+ "radius_m": used_radius,
147
+ }
static/index.html CHANGED
@@ -540,6 +540,9 @@ 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: "risk", name: "Risk analyst",
544
  finding: "Synthesizing… Score 74/100. AEP ≈ 5.1%, 30-yr cumulative probability of basement flood ≈ 79%. FEMA gap material.",
545
  pin: null, delay: 1600 },
@@ -1108,6 +1111,7 @@ const mapDossier = (raw) => {
1108
  mitigating_factors: risk.mitigating_factors || [],
1109
  reasoning_trace: risk.reasoning_trace || "",
1110
  advisor_tldr,
 
1111
  };
1112
  };
1113
 
@@ -1201,8 +1205,63 @@ const DossierScreen = ({ onBack, dossier }) => {
1201
  )}
1202
  </Section>
1203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1204
  <Section
1205
- num="§03"
1206
  icon={<Icon name="warn" color="var(--coral)" size={16}/>}
1207
  iconBg="var(--coral-soft)"
1208
  title="Why FEMA's flood map isn't the whole story"
@@ -1256,7 +1315,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1256
  </Section>
1257
 
1258
  <Section
1259
- num="§04"
1260
  icon={<Icon name="drop" color="var(--accent)" size={16}/>}
1261
  iconBg="var(--accent-soft)"
1262
  title="The raw signals we looked at"
@@ -1270,7 +1329,7 @@ const DossierScreen = ({ onBack, dossier }) => {
1270
  </Section>
1271
 
1272
  <Section
1273
- num="§05"
1274
  icon={<Icon name="news" color="var(--amber)" size={16}/>}
1275
  iconBg="var(--amber-soft)"
1276
  title="Recent local flood news">
 
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 },
546
  { id: "risk", name: "Risk analyst",
547
  finding: "Synthesizing… Score 74/100. AEP ≈ 5.1%, 30-yr cumulative probability of basement flood ≈ 79%. FEMA gap material.",
548
  pin: null, delay: 1600 },
 
1111
  mitigating_factors: risk.mitigating_factors || [],
1112
  reasoning_trace: risk.reasoning_trace || "",
1113
  advisor_tldr,
1114
+ streetview: raw.streetview || {},
1115
  };
1116
  };
1117
 
 
1205
  )}
1206
  </Section>
1207
 
1208
+ {D.streetview?.available && (
1209
+ <Section
1210
+ num="§03"
1211
+ icon={<Icon name="pin" color="var(--accent)" size={16}/>}
1212
+ iconBg="var(--accent-soft)"
1213
+ title="What we saw at the property"
1214
+ badge={<span className="risk-tag purple">Gemma 4 vision</span>}>
1215
+ <div style={{display:"grid", gridTemplateColumns:"260px 1fr", gap:18, alignItems:"start"}}>
1216
+ <div>
1217
+ <img
1218
+ src={D.streetview.image_data_url}
1219
+ alt="Google Street View of the property"
1220
+ style={{width:"100%", borderRadius:10, border:"1px solid var(--line)", display:"block"}}
1221
+ />
1222
+ <div style={{fontSize:11, color:"var(--ink-4)", marginTop:6, fontFamily:"'JetBrains Mono', monospace"}}>
1223
+ {D.streetview.copyright || "© Google"}
1224
+ {D.streetview.capture_date && ` · captured ${D.streetview.capture_date}`}
1225
+ {D.streetview.pano_distance_m != null && ` · ${D.streetview.pano_distance_m}m from address`}
1226
+ </div>
1227
+ </div>
1228
+ <div>
1229
+ <p style={{margin:"0 0 10px"}}>
1230
+ We fed this Street View frame to Gemma 4 with vision turned on
1231
+ and asked it to flag visible flood-risk indicators. The model
1232
+ was instructed not to fabricate features it couldn't actually see.
1233
+ </p>
1234
+ <div style={{display:"flex", gap:8, flexWrap:"wrap", marginBottom:12, fontSize:12}}>
1235
+ <span className={`risk-tag ${D.streetview.overall_visual_risk === "high" ? "coral" : D.streetview.overall_visual_risk === "moderate" ? "amber" : "teal"}`}>
1236
+ visual risk: {D.streetview.overall_visual_risk || "unclear"}
1237
+ </span>
1238
+ {D.streetview.elevation_vs_street && D.streetview.elevation_vs_street !== "unclear" && (
1239
+ <span className="risk-tag neutral">{D.streetview.elevation_vs_street.replace(/_/g, " ")}</span>
1240
+ )}
1241
+ <span className="risk-tag neutral">confidence: {D.streetview.confidence || "unknown"}</span>
1242
+ </div>
1243
+ {D.streetview.indicators?.length > 0 ? (
1244
+ <ul style={{margin:0, paddingLeft:16, fontSize:13, color:"var(--ink-2)", lineHeight:1.55}}>
1245
+ {D.streetview.indicators.map((ind, i) => (
1246
+ <li key={i} style={{marginBottom:8}}>
1247
+ <strong>{ind.feature}</strong>
1248
+ {ind.severity && <span className="small" style={{marginLeft:6}}> · {ind.severity}</span>}
1249
+ <div style={{color:"var(--ink-3)", fontSize:12, marginTop:2}}>{ind.risk_implication}</div>
1250
+ </li>
1251
+ ))}
1252
+ </ul>
1253
+ ) : (
1254
+ <p style={{color:"var(--ink-3)", fontSize:13, margin:0}}>
1255
+ {D.streetview.summary || "No specific indicators visible from the available angle."}
1256
+ </p>
1257
+ )}
1258
+ </div>
1259
+ </div>
1260
+ </Section>
1261
+ )}
1262
+
1263
  <Section
1264
+ num={D.streetview?.available ? 04" : "§03"}
1265
  icon={<Icon name="warn" color="var(--coral)" size={16}/>}
1266
  iconBg="var(--coral-soft)"
1267
  title="Why FEMA's flood map isn't the whole story"
 
1315
  </Section>
1316
 
1317
  <Section
1318
+ num={D.streetview?.available ? 05" : "§04"}
1319
  icon={<Icon name="drop" color="var(--accent)" size={16}/>}
1320
  iconBg="var(--accent-soft)"
1321
  title="The raw signals we looked at"
 
1329
  </Section>
1330
 
1331
  <Section
1332
+ num={D.streetview?.available ? 06" : "§05"}
1333
  icon={<Icon name="news" color="var(--amber)" size={16}/>}
1334
  iconBg="var(--amber-soft)"
1335
  title="Recent local flood news">