v0.10: 3-image multimodal — satellite + wider-area + street view in one Gemma 4 call
Browse filesRisk 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 +31 -0
- app/agents/risk_agent.py +68 -25
- app/config.py +1 -0
- app/main.py +1 -1
- app/tools/mapbox.py +80 -0
- static/index.html +80 -21
|
@@ -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 |
}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
image_section = ""
|
| 47 |
if has_image:
|
| 48 |
-
|
| 49 |
-
#
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
-
|
| 57 |
-
|
| 58 |
-
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 126 |
user_content.append({
|
| 127 |
"type": "image_url",
|
| 128 |
-
"image_url": {"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"] =
|
|
|
|
| 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":
|
|
|
|
| 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 |
}
|
|
@@ -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"
|
|
@@ -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.
|
| 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
|
|
@@ -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)
|
|
@@ -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={
|
| 1223 |
-
alt=
|
| 1224 |
style={{width:"100%", display:"block"}}
|
| 1225 |
/>
|
| 1226 |
-
{
|
| 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 |
-
|
| 1242 |
-
const
|
| 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 |
-
{
|
| 1291 |
-
|
| 1292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"}}>·
|
| 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
|
| 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.
|
| 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>
|