feature: streetview agent — multimodal Gemma 4 vision
Browse filesThe 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 +34 -9
- app/agents/streetview_agent.py +122 -0
- app/config.py +1 -0
- app/tools/streetview.py +147 -0
- static/index.html +62 -3
|
@@ -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 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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
|
| 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
|
| 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 |
}
|
|
@@ -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
|
|
@@ -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"
|
|
@@ -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 |
+
}
|
|
@@ -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">
|