backend: vertical slice — geocoder + FEMA agent + SSE end-to-end
Browse filesStands up the FastAPI backend with one agent fully wired through the
SSE pipe so the architecture is proven before we fan out the rest:
POST /api/assess → geocode → FEMA NFHL → Gemma 4 interpret
→ agent_update events → complete{dossier}
Verified end-to-end against two cases:
- Houston Allen Pkwy (Zone AE) → is_sfha=true, requires_insurance=true
- Chicago S Drexel (Zone X) → gap_warning generated unprompted,
surfacing the urban-sewer-backup blind spot that is the whole pitch
Smoke-tested Gemma 4 on OpenRouter free tier (scripts/smoke_test.py):
basic completion, reasoning mode, and OpenAI-format tool calls all
pass on google/gemma-4-31b-it:free.
Spec bugs fixed while building:
- llm/client.extract_reasoning read message.reasoning_details[i].content;
the actual key is .text. Also falls back to top-level .reasoning string.
- tools/fema requested VERSION_ID, which does not exist on NFHL layer 28.
ArcGIS returned HTTP 200 with an {"error": ...} body that silently
looked like UNMAPPED. Now requests SFHA_TF/DEPTH/STUDY_TYP/SOURCE_CIT
and surfaces ArcGIS error envelopes.
- 429 retry policy widened: switch primary→fallback model on first 429,
then exponential backoff (2s/4s/8s) with a clear RateLimitedError
message pointing at BYOK as the fix.
Local dev:
cd backend && python3.13 -m venv .venv
.venv/bin/pip install -r requirements.txt
set -a && source .env && set +a
.venv/bin/uvicorn app.main:app --reload --port 8000
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- .gitignore +9 -0
- Dockerfile +7 -0
- README.md +11 -0
- app/__init__.py +0 -0
- app/agents/__init__.py +0 -0
- app/agents/fema_agent.py +72 -0
- app/agents/orchestrator.py +100 -0
- app/api/__init__.py +0 -0
- app/api/assess.py +25 -0
- app/api/health.py +13 -0
- app/config.py +12 -0
- app/llm/__init__.py +0 -0
- app/llm/client.py +230 -0
- app/llm/prompts.py +71 -0
- app/main.py +26 -0
- app/models/__init__.py +0 -0
- app/tools/__init__.py +0 -0
- app/tools/fema.py +53 -0
- app/tools/geocoder.py +37 -0
- requirements.txt +4 -0
- scripts/smoke_test.py +166 -0
- scripts/smoke_test_tools.py +75 -0
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
.env.local
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.mypy_cache/
|
| 9 |
+
.ruff_cache/
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY app/ ./app/
|
| 6 |
+
EXPOSE 8000
|
| 7 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: FloodIQ API
|
| 3 |
+
emoji: 🌊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: teal
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
FloodIQ backend — multi-agent flood risk assessment powered by Gemma 4.
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FEMA expert agent.
|
| 2 |
+
|
| 3 |
+
Pattern: fetch raw NFHL data directly (no LLM needed for the lookup),
|
| 4 |
+
then ask Gemma 4 to interpret the data into a structured finding.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
from app.llm.client import call_gemma4, extract_text, parse_json_response
|
| 9 |
+
from app.llm.prompts import FEMA_AGENT_SYSTEM_PROMPT
|
| 10 |
+
from app.tools.fema import lookup_fema_flood_zone
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
SFHA_ZONES = {"A", "AE", "AH", "AO", "AR", "A99", "V", "VE"}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def run_fema_agent(lat: float, lon: float) -> dict:
|
| 17 |
+
fema_data = await lookup_fema_flood_zone(lat, lon)
|
| 18 |
+
|
| 19 |
+
if not fema_data:
|
| 20 |
+
return {
|
| 21 |
+
"flood_zone": "unknown",
|
| 22 |
+
"is_sfha": False,
|
| 23 |
+
"summary": "FEMA returned no data",
|
| 24 |
+
"raw": fema_data,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
if fema_data.get("FLD_ZONE") == "ERROR":
|
| 28 |
+
return {
|
| 29 |
+
"flood_zone": "error",
|
| 30 |
+
"is_sfha": False,
|
| 31 |
+
"summary": "FEMA query failed",
|
| 32 |
+
"raw": fema_data,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
user_prompt = f"""Interpret this FEMA flood zone data for coordinates ({lat}, {lon}):
|
| 36 |
+
|
| 37 |
+
{json.dumps(fema_data, indent=2, default=str)}
|
| 38 |
+
|
| 39 |
+
Return a JSON object with these fields:
|
| 40 |
+
- flood_zone: the zone code (e.g. "X", "AE", "VE")
|
| 41 |
+
- zone_description: what this zone means in plain English (1 sentence)
|
| 42 |
+
- is_sfha: boolean, whether this is a Special Flood Hazard Area
|
| 43 |
+
- requires_insurance: boolean, whether federal law mandates flood insurance
|
| 44 |
+
- base_flood_elevation: number or null
|
| 45 |
+
- map_date: the FIRM panel effective date if available, else null
|
| 46 |
+
- gap_warning: string or null — if the zone is X but the location is in a known urban flooding area (flat terrain, combined sewers), note that FEMA maps may not reflect actual risk
|
| 47 |
+
- summary: a 1-sentence finding for the status feed
|
| 48 |
+
|
| 49 |
+
Return ONLY the JSON object, no other text."""
|
| 50 |
+
|
| 51 |
+
response = await call_gemma4(
|
| 52 |
+
messages=[
|
| 53 |
+
{"role": "system", "content": FEMA_AGENT_SYSTEM_PROMPT},
|
| 54 |
+
{"role": "user", "content": user_prompt},
|
| 55 |
+
],
|
| 56 |
+
temperature=0.1,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
text = extract_text(response)
|
| 60 |
+
parsed = parse_json_response(text)
|
| 61 |
+
if parsed:
|
| 62 |
+
parsed["raw"] = fema_data
|
| 63 |
+
return parsed
|
| 64 |
+
|
| 65 |
+
zone = fema_data.get("FLD_ZONE", "unknown")
|
| 66 |
+
return {
|
| 67 |
+
"flood_zone": zone,
|
| 68 |
+
"is_sfha": zone in SFHA_ZONES,
|
| 69 |
+
"summary": f"Zone {zone}",
|
| 70 |
+
"raw": fema_data,
|
| 71 |
+
"interpretation_raw": text,
|
| 72 |
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Runs all FloodIQ agents and yields SSE events to the client.
|
| 3 |
+
|
| 4 |
+
Flow:
|
| 5 |
+
0. Geocode the address.
|
| 6 |
+
1. Run data-fetcher agents concurrently.
|
| 7 |
+
2. Run risk-analyst agent (needs all data, uses Gemma 4 reasoning).
|
| 8 |
+
3. Run advisor agent (needs the risk analysis).
|
| 9 |
+
4. Yield the compiled dossier.
|
| 10 |
+
|
| 11 |
+
Agents register themselves in DATA_AGENTS so this file does not have
|
| 12 |
+
to know about every agent's signature.
|
| 13 |
+
"""
|
| 14 |
+
import asyncio
|
| 15 |
+
import json
|
| 16 |
+
from typing import AsyncGenerator, Awaitable, Callable
|
| 17 |
+
|
| 18 |
+
from app.agents.fema_agent import run_fema_agent
|
| 19 |
+
from app.tools.geocoder import geocode_address
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
GeoCtx = dict
|
| 23 |
+
AgentFn = Callable[[GeoCtx], Awaitable[dict]]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def _fema(ctx: GeoCtx) -> dict:
|
| 27 |
+
return await run_fema_agent(ctx["lat"], ctx["lon"])
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# As we implement more agents, add them here. Frontend uses these keys
|
| 31 |
+
# to know which agent rows to render.
|
| 32 |
+
DATA_AGENTS: dict[str, AgentFn] = {
|
| 33 |
+
"fema": _fema,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def sse(event: str, data: dict) -> str:
|
| 38 |
+
return f"event: {event}\ndata: {json.dumps(data, default=str)}\n\n"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def run_assessment(address: str) -> AsyncGenerator[str, None]:
|
| 42 |
+
geo = await geocode_address(address)
|
| 43 |
+
if not geo:
|
| 44 |
+
yield sse("error", {"message": f"Could not geocode address: {address!r}"})
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
ctx: GeoCtx = geo
|
| 48 |
+
yield sse("geocoded", {
|
| 49 |
+
"address": geo["display_name"],
|
| 50 |
+
"lat": geo["lat"],
|
| 51 |
+
"lon": geo["lon"],
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
# Announce all agents as working up front so the UI can render rows.
|
| 55 |
+
for name in DATA_AGENTS:
|
| 56 |
+
yield sse("agent_update", {
|
| 57 |
+
"agent": name,
|
| 58 |
+
"status": "working",
|
| 59 |
+
"summary": "Investigating...",
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
# Kick off all data agents in parallel.
|
| 63 |
+
tasks = {name: asyncio.create_task(fn(ctx)) for name, fn in DATA_AGENTS.items()}
|
| 64 |
+
|
| 65 |
+
results: dict[str, dict] = {}
|
| 66 |
+
# Stream completions as they finish, not in launch order.
|
| 67 |
+
pending = set(tasks.values())
|
| 68 |
+
task_to_name = {t: n for n, t in tasks.items()}
|
| 69 |
+
|
| 70 |
+
while pending:
|
| 71 |
+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
| 72 |
+
for t in done:
|
| 73 |
+
name = task_to_name[t]
|
| 74 |
+
try:
|
| 75 |
+
result = t.result()
|
| 76 |
+
results[name] = result
|
| 77 |
+
yield sse("agent_update", {
|
| 78 |
+
"agent": name,
|
| 79 |
+
"status": "done",
|
| 80 |
+
"summary": result.get("summary", "Complete"),
|
| 81 |
+
})
|
| 82 |
+
except Exception as e:
|
| 83 |
+
results[name] = {"error": str(e), "summary": f"Error: {str(e)[:120]}"}
|
| 84 |
+
yield sse("agent_update", {
|
| 85 |
+
"agent": name,
|
| 86 |
+
"status": "error",
|
| 87 |
+
"summary": f"Error: {str(e)[:120]}",
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
# Risk and advisor will be added next vertical slice.
|
| 91 |
+
dossier = _compile_dossier(geo, results)
|
| 92 |
+
yield sse("complete", {"dossier": dossier})
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _compile_dossier(geo: GeoCtx, results: dict) -> dict:
|
| 96 |
+
return {
|
| 97 |
+
"address": geo["display_name"],
|
| 98 |
+
"coordinates": {"lat": geo["lat"], "lon": geo["lon"]},
|
| 99 |
+
**{name: results.get(name, {}) for name in DATA_AGENTS},
|
| 100 |
+
}
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""POST /api/assess — SSE stream of agent updates ending with a dossier."""
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
from fastapi.responses import StreamingResponse
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
from app.agents.orchestrator import run_assessment
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AssessRequest(BaseModel):
|
| 12 |
+
address: str = Field(..., min_length=3, max_length=300)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.post("/api/assess")
|
| 16 |
+
async def assess(req: AssessRequest):
|
| 17 |
+
return StreamingResponse(
|
| 18 |
+
run_assessment(req.address),
|
| 19 |
+
media_type="text/event-stream",
|
| 20 |
+
headers={
|
| 21 |
+
"Cache-Control": "no-cache",
|
| 22 |
+
"Connection": "keep-alive",
|
| 23 |
+
"X-Accel-Buffering": "no",
|
| 24 |
+
},
|
| 25 |
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
from app.config import OPENROUTER_API_KEY
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@router.get("/api/health")
|
| 9 |
+
async def health() -> dict:
|
| 10 |
+
return {
|
| 11 |
+
"status": "ok",
|
| 12 |
+
"openrouter_key_configured": bool(OPENROUTER_API_KEY),
|
| 13 |
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Environment configuration."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
| 5 |
+
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
| 6 |
+
|
| 7 |
+
MODEL_PRIMARY = "google/gemma-4-31b-it:free"
|
| 8 |
+
MODEL_FALLBACK = "google/gemma-4-26b-a4b-it:free"
|
| 9 |
+
|
| 10 |
+
APP_NAME = "FloodIQ"
|
| 11 |
+
APP_URL = "https://floodiq.pages.dev"
|
| 12 |
+
USER_AGENT = "FloodIQ/1.0 (floodiq.pages.dev)"
|
|
File without changes
|
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemma 4 client via OpenRouter free tier.
|
| 3 |
+
|
| 4 |
+
Uses the OpenAI-compatible chat/completions endpoint. Supports function
|
| 5 |
+
calling and reasoning mode (the latter is the risk-analyst agent's
|
| 6 |
+
showcase capability).
|
| 7 |
+
|
| 8 |
+
Notes from the smoke test (scripts/smoke_test.py):
|
| 9 |
+
- Reasoning trace lives at message.reasoning_details[i].text — the
|
| 10 |
+
earlier spec said .content, which is wrong on Gemma 4 / OpenRouter
|
| 11 |
+
today. We also fall back to the top-level message.reasoning string
|
| 12 |
+
that OpenRouter returns concatenated for convenience.
|
| 13 |
+
- The shared :free upstream pool 429s very quickly (we got rate-limited
|
| 14 |
+
after 2 calls). BYOK a Google AI Studio key into OpenRouter
|
| 15 |
+
integrations for the demo. We retry on 429 with the fallback model,
|
| 16 |
+
then exponential backoff up to a cap.
|
| 17 |
+
"""
|
| 18 |
+
import asyncio
|
| 19 |
+
import json
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
import httpx
|
| 23 |
+
|
| 24 |
+
from app.config import (
|
| 25 |
+
APP_URL,
|
| 26 |
+
APP_NAME,
|
| 27 |
+
MODEL_FALLBACK,
|
| 28 |
+
MODEL_PRIMARY,
|
| 29 |
+
OPENROUTER_API_KEY,
|
| 30 |
+
OPENROUTER_BASE,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class RateLimitedError(Exception):
|
| 35 |
+
"""All retries exhausted while upstream was rate-limiting."""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def call_gemma4(
|
| 39 |
+
messages: list[dict],
|
| 40 |
+
tools: Optional[list[dict]] = None,
|
| 41 |
+
model: str = MODEL_PRIMARY,
|
| 42 |
+
reasoning: bool = False,
|
| 43 |
+
max_tokens: int = 4096,
|
| 44 |
+
temperature: float = 0.3,
|
| 45 |
+
retries: int = 3,
|
| 46 |
+
) -> dict:
|
| 47 |
+
"""
|
| 48 |
+
Call Gemma 4 via OpenRouter. Returns the raw response JSON.
|
| 49 |
+
|
| 50 |
+
Retry policy:
|
| 51 |
+
- On 429 with primary model: switch to fallback model, then retry
|
| 52 |
+
with exponential backoff (2s, 4s, 8s).
|
| 53 |
+
- On timeout: short retry.
|
| 54 |
+
"""
|
| 55 |
+
if not OPENROUTER_API_KEY:
|
| 56 |
+
raise RuntimeError("OPENROUTER_API_KEY is not set")
|
| 57 |
+
|
| 58 |
+
headers = {
|
| 59 |
+
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 60 |
+
"Content-Type": "application/json",
|
| 61 |
+
"HTTP-Referer": APP_URL,
|
| 62 |
+
"X-Title": APP_NAME,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
payload: dict = {
|
| 66 |
+
"model": model,
|
| 67 |
+
"messages": messages,
|
| 68 |
+
"max_tokens": max_tokens,
|
| 69 |
+
"temperature": temperature,
|
| 70 |
+
}
|
| 71 |
+
if tools:
|
| 72 |
+
payload["tools"] = tools
|
| 73 |
+
payload["tool_choice"] = "auto"
|
| 74 |
+
if reasoning:
|
| 75 |
+
payload["reasoning"] = {"enabled": True}
|
| 76 |
+
|
| 77 |
+
current_model = model
|
| 78 |
+
backoff = 2.0
|
| 79 |
+
|
| 80 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 81 |
+
for attempt in range(retries + 1):
|
| 82 |
+
try:
|
| 83 |
+
resp = await client.post(
|
| 84 |
+
f"{OPENROUTER_BASE}/chat/completions",
|
| 85 |
+
headers=headers,
|
| 86 |
+
json={**payload, "model": current_model},
|
| 87 |
+
)
|
| 88 |
+
except httpx.TimeoutException:
|
| 89 |
+
if attempt >= retries:
|
| 90 |
+
raise
|
| 91 |
+
await asyncio.sleep(backoff)
|
| 92 |
+
backoff *= 2
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
if resp.status_code == 429:
|
| 96 |
+
if current_model == MODEL_PRIMARY and attempt == 0:
|
| 97 |
+
current_model = MODEL_FALLBACK
|
| 98 |
+
continue
|
| 99 |
+
if attempt >= retries:
|
| 100 |
+
raise RateLimitedError(
|
| 101 |
+
f"Both Gemma 4 free models rate-limited after {retries + 1} attempts. "
|
| 102 |
+
"BYOK a Google AI Studio key at openrouter.ai/settings/integrations."
|
| 103 |
+
)
|
| 104 |
+
await asyncio.sleep(backoff)
|
| 105 |
+
backoff *= 2
|
| 106 |
+
continue
|
| 107 |
+
|
| 108 |
+
resp.raise_for_status()
|
| 109 |
+
return resp.json()
|
| 110 |
+
|
| 111 |
+
raise RuntimeError("call_gemma4 exited retry loop without returning")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def extract_text(response: dict) -> str:
|
| 115 |
+
choices = response.get("choices") or []
|
| 116 |
+
if not choices:
|
| 117 |
+
return ""
|
| 118 |
+
return (choices[0].get("message") or {}).get("content") or ""
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def extract_tool_calls(response: dict) -> list[dict]:
|
| 122 |
+
choices = response.get("choices") or []
|
| 123 |
+
if not choices:
|
| 124 |
+
return []
|
| 125 |
+
return (choices[0].get("message") or {}).get("tool_calls") or []
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def extract_reasoning(response: dict) -> str:
|
| 129 |
+
"""
|
| 130 |
+
Extract the chain-of-thought reasoning trace from a Gemma 4 response.
|
| 131 |
+
|
| 132 |
+
OpenRouter returns reasoning two ways for Gemma 4:
|
| 133 |
+
1. message.reasoning_details: [{type, text, format, index}, ...]
|
| 134 |
+
2. message.reasoning: "<concatenated string>"
|
| 135 |
+
|
| 136 |
+
The (1) form is canonical (per-block, indexable). The (2) form is a
|
| 137 |
+
convenience concatenation. We prefer (1) and fall back to (2).
|
| 138 |
+
"""
|
| 139 |
+
choices = response.get("choices") or []
|
| 140 |
+
if not choices:
|
| 141 |
+
return ""
|
| 142 |
+
msg = choices[0].get("message") or {}
|
| 143 |
+
|
| 144 |
+
details = msg.get("reasoning_details") or []
|
| 145 |
+
if details:
|
| 146 |
+
parts = []
|
| 147 |
+
for d in details:
|
| 148 |
+
if not isinstance(d, dict):
|
| 149 |
+
continue
|
| 150 |
+
text = d.get("text") or d.get("content") or ""
|
| 151 |
+
if text:
|
| 152 |
+
parts.append(text)
|
| 153 |
+
if parts:
|
| 154 |
+
return "\n".join(parts)
|
| 155 |
+
|
| 156 |
+
reasoning = msg.get("reasoning")
|
| 157 |
+
if isinstance(reasoning, str) and reasoning:
|
| 158 |
+
return reasoning
|
| 159 |
+
return ""
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def parse_json_response(text: str) -> Optional[dict]:
|
| 163 |
+
"""Strip ```json fences and parse. Return None on failure."""
|
| 164 |
+
if not text:
|
| 165 |
+
return None
|
| 166 |
+
clean = text.strip()
|
| 167 |
+
if clean.startswith("```"):
|
| 168 |
+
clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
|
| 169 |
+
if clean.endswith("```"):
|
| 170 |
+
clean = clean.rsplit("```", 1)[0]
|
| 171 |
+
clean = clean.strip()
|
| 172 |
+
if clean.startswith("json"):
|
| 173 |
+
clean = clean[4:].strip()
|
| 174 |
+
try:
|
| 175 |
+
return json.loads(clean)
|
| 176 |
+
except json.JSONDecodeError:
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
async def run_tool_loop(
|
| 181 |
+
system_prompt: str,
|
| 182 |
+
user_prompt: str,
|
| 183 |
+
tools: list[dict],
|
| 184 |
+
tool_handlers: dict,
|
| 185 |
+
max_iterations: int = 5,
|
| 186 |
+
model: str = MODEL_PRIMARY,
|
| 187 |
+
) -> str:
|
| 188 |
+
"""
|
| 189 |
+
Run a Gemma 4 agent that can call tools.
|
| 190 |
+
|
| 191 |
+
tool_handlers: dict mapping function name → async callable that
|
| 192 |
+
returns a JSON-serializable result.
|
| 193 |
+
"""
|
| 194 |
+
messages = [
|
| 195 |
+
{"role": "system", "content": system_prompt},
|
| 196 |
+
{"role": "user", "content": user_prompt},
|
| 197 |
+
]
|
| 198 |
+
|
| 199 |
+
for _ in range(max_iterations):
|
| 200 |
+
response = await call_gemma4(messages, tools=tools, model=model)
|
| 201 |
+
tool_calls = extract_tool_calls(response)
|
| 202 |
+
|
| 203 |
+
if not tool_calls:
|
| 204 |
+
return extract_text(response)
|
| 205 |
+
|
| 206 |
+
messages.append(response["choices"][0]["message"])
|
| 207 |
+
|
| 208 |
+
for tc in tool_calls:
|
| 209 |
+
fn_name = tc["function"]["name"]
|
| 210 |
+
try:
|
| 211 |
+
fn_args = json.loads(tc["function"]["arguments"])
|
| 212 |
+
except json.JSONDecodeError:
|
| 213 |
+
fn_args = {}
|
| 214 |
+
|
| 215 |
+
handler = tool_handlers.get(fn_name)
|
| 216 |
+
if handler:
|
| 217 |
+
try:
|
| 218 |
+
result = await handler(**fn_args)
|
| 219 |
+
except Exception as e:
|
| 220 |
+
result = {"error": str(e)}
|
| 221 |
+
else:
|
| 222 |
+
result = {"error": f"Unknown tool: {fn_name}"}
|
| 223 |
+
|
| 224 |
+
messages.append({
|
| 225 |
+
"role": "tool",
|
| 226 |
+
"tool_call_id": tc["id"],
|
| 227 |
+
"content": json.dumps(result, default=str),
|
| 228 |
+
})
|
| 229 |
+
|
| 230 |
+
return extract_text(response)
|
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompts for each FloodIQ agent."""
|
| 2 |
+
|
| 3 |
+
FEMA_AGENT_SYSTEM_PROMPT = """You are a FEMA flood zone expert. Your job is to interpret FEMA National Flood Hazard Layer data and explain what it means for a property owner.
|
| 4 |
+
|
| 5 |
+
Key knowledge:
|
| 6 |
+
- Zone X (unshaded) = minimal flood risk, no insurance required
|
| 7 |
+
- Zone X (shaded) = 0.2% annual chance (500-year flood)
|
| 8 |
+
- Zone A, AE = 1% annual chance (100-year flood), SFHA, insurance required for federally backed mortgages
|
| 9 |
+
- Zone V, VE = coastal high hazard, 1% annual chance with wave action
|
| 10 |
+
- FEMA maps primarily measure RIVERINE and COASTAL flooding
|
| 11 |
+
- Urban flooding from sewer backup is NOT reflected in FEMA zones
|
| 12 |
+
- Many FIRM panels are 10-20+ years old and may not reflect current risk
|
| 13 |
+
|
| 14 |
+
Always respond with valid JSON only."""
|
| 15 |
+
|
| 16 |
+
RISK_AGENT_SYSTEM_PROMPT = """You are a flood risk analyst specializing in urban flood risk assessment. You synthesize data from multiple sources (FEMA, municipal 311 reports, USGS stream gauges, weather forecasts, historical storm events, and news) into a comprehensive risk score.
|
| 17 |
+
|
| 18 |
+
Critical knowledge:
|
| 19 |
+
- "100-year flood" = 1% Annual Exceedance Probability (AEP), NOT once per century
|
| 20 |
+
- P(at least 1 flood in n years) = 1 - (1 - AEP)^n
|
| 21 |
+
- 1% AEP over 30 years = 26% chance. Over 80-year lifetime = 55% chance.
|
| 22 |
+
- In flat cities with combined sewer systems, FEMA zones dramatically UNDERSTATE risk
|
| 23 |
+
- Chicago's combined sewers overwhelm after ~0.67 in/hr of rain
|
| 24 |
+
- 42% of Cook County is impervious surface
|
| 25 |
+
- MWRD's Deep Tunnel (TARP) has 17.5B gallon capacity but local sewers still bottleneck
|
| 26 |
+
- 311 basement flooding reports are a strong signal of actual urban flood risk, even in Zone X
|
| 27 |
+
|
| 28 |
+
Think step by step. Use the AEP formula. Be specific with numbers.
|
| 29 |
+
Always respond with valid JSON only."""
|
| 30 |
+
|
| 31 |
+
ADVISOR_AGENT_SYSTEM_PROMPT = """You are a flood insurance and mitigation advisor. You translate technical flood risk data into specific, actionable recommendations for homeowners and renters.
|
| 32 |
+
|
| 33 |
+
Key knowledge:
|
| 34 |
+
- NFIP Preferred Risk Policy: available in Zone X, ~$400-600/yr, covers building+contents up to $250K/$100K
|
| 35 |
+
- NFIP Standard: for SFHAs, costs vary by zone and building
|
| 36 |
+
- Sewer backup rider: add-on to homeowners policy, ~$40-75/yr, covers the #1 cause of Chicago flooding
|
| 37 |
+
- Parametric insurance (FloodFlash model): sensor-triggered instant payout, pre-agreed trigger depth and amount
|
| 38 |
+
- Basis risk = mismatch between trigger event and actual loss
|
| 39 |
+
- Best for business interruption coverage
|
| 40 |
+
- Private excess flood: fills gaps above NFIP limits
|
| 41 |
+
- Key mitigation actions (prioritized by cost-effectiveness):
|
| 42 |
+
1. Disconnect downspouts (free, DIY) — Chicago DWM: 312-747-7030
|
| 43 |
+
2. Install backwater valve ($1K-2.5K) — check MWRD cost-share programs
|
| 44 |
+
3. Sewer camera inspection ($150-300)
|
| 45 |
+
4. Rain barrels ($22.30 from MWRD)
|
| 46 |
+
5. Permeable pavement for patios/walkways
|
| 47 |
+
6. CNT RainReady home assessment (free)
|
| 48 |
+
|
| 49 |
+
Write at a 5th-grade reading level. No jargon without explanation.
|
| 50 |
+
Always respond with valid JSON only."""
|
| 51 |
+
|
| 52 |
+
LOCAL_AGENT_SYSTEM_PROMPT = """You are a local flooding investigator. You analyze municipal 311 service-request data and local infrastructure to assess sewer-backup and urban flooding risk in a specific neighborhood.
|
| 53 |
+
|
| 54 |
+
Focus on: density of basement-flooding (WIB) and street-flooding (SFL) reports near the address, recency, and what that implies about combined-sewer capacity.
|
| 55 |
+
Always respond with valid JSON only."""
|
| 56 |
+
|
| 57 |
+
WEATHER_AGENT_SYSTEM_PROMPT = """You are a hydrometeorology analyst. You interpret USGS stream gauge data, NOAA NWS forecasts and active alerts, and Open-Meteo flood forecasts to assess near-term flood risk.
|
| 58 |
+
|
| 59 |
+
Focus on: current river/stream levels relative to flood stage, active flood watches/warnings, precipitation forecasts.
|
| 60 |
+
Always respond with valid JSON only."""
|
| 61 |
+
|
| 62 |
+
NEWS_AGENT_SYSTEM_PROMPT = """You are a flood news researcher. Given recent news articles about flooding in a specific area, summarize the key findings that are relevant to a homeowner's flood risk assessment.
|
| 63 |
+
|
| 64 |
+
Focus on: recent flood events, infrastructure failures, insurance cost changes, government programs, community initiatives.
|
| 65 |
+
Ignore: national policy debates, unrelated weather events, opinion pieces without data.
|
| 66 |
+
Always respond with valid JSON only."""
|
| 67 |
+
|
| 68 |
+
ARCHIVE_AGENT_SYSTEM_PROMPT = """You are a flood history archivist. You analyze historical storm event records and FEMA disaster declarations to establish the flooding track record for a specific area.
|
| 69 |
+
|
| 70 |
+
Focus on: frequency of events, severity trends, types of flooding (flash flood vs riverine vs urban), property damage patterns.
|
| 71 |
+
Always respond with valid JSON only."""
|
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
|
| 4 |
+
from app.api.assess import router as assess_router
|
| 5 |
+
from app.api.health import router as health_router
|
| 6 |
+
|
| 7 |
+
app = FastAPI(title="FloodIQ API", version="0.1.0")
|
| 8 |
+
|
| 9 |
+
_CORS_ORIGIN_REGEX = (
|
| 10 |
+
r"https?://("
|
| 11 |
+
r"localhost(:\d+)?|"
|
| 12 |
+
r"127\.0\.0\.1(:\d+)?|"
|
| 13 |
+
r".*\.pages\.dev|"
|
| 14 |
+
r".*\.hf\.space"
|
| 15 |
+
r")"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
app.add_middleware(
|
| 19 |
+
CORSMiddleware,
|
| 20 |
+
allow_origin_regex=_CORS_ORIGIN_REGEX,
|
| 21 |
+
allow_methods=["*"],
|
| 22 |
+
allow_headers=["*"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
app.include_router(health_router)
|
| 26 |
+
app.include_router(assess_router)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FEMA NFHL flood zone lookup via ArcGIS REST. Free, no API key."""
|
| 2 |
+
import httpx
|
| 3 |
+
|
| 4 |
+
FEMA_URL = (
|
| 5 |
+
"https://hazards.fema.gov/arcgis/rest/services/public/NFHL/MapServer/28/query"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def lookup_fema_flood_zone(latitude: float, longitude: float) -> dict:
|
| 10 |
+
# The spec previously listed VERSION_ID here — that field does not
|
| 11 |
+
# exist on layer 28 and causes ArcGIS to return HTTP 200 with an
|
| 12 |
+
# {"error": ...} body, which silently looked like UNMAPPED.
|
| 13 |
+
out_fields = ",".join([
|
| 14 |
+
"FLD_ZONE",
|
| 15 |
+
"ZONE_SUBTY",
|
| 16 |
+
"SFHA_TF", # "T" / "F" — official SFHA flag
|
| 17 |
+
"STATIC_BFE",
|
| 18 |
+
"DEPTH",
|
| 19 |
+
"STUDY_TYP",
|
| 20 |
+
"DFIRM_ID",
|
| 21 |
+
"SOURCE_CIT",
|
| 22 |
+
])
|
| 23 |
+
params = {
|
| 24 |
+
"geometry": (
|
| 25 |
+
f'{{"x":{longitude},"y":{latitude},'
|
| 26 |
+
f'"spatialReference":{{"wkid":4326}}}}'
|
| 27 |
+
),
|
| 28 |
+
"geometryType": "esriGeometryPoint",
|
| 29 |
+
"inSR": "4326",
|
| 30 |
+
"outFields": out_fields,
|
| 31 |
+
"returnGeometry": "false",
|
| 32 |
+
"f": "json",
|
| 33 |
+
}
|
| 34 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 35 |
+
resp = await client.get(FEMA_URL, params=params)
|
| 36 |
+
resp.raise_for_status()
|
| 37 |
+
data = resp.json()
|
| 38 |
+
|
| 39 |
+
# ArcGIS returns 200 + {"error": {...}} on bad queries.
|
| 40 |
+
if isinstance(data, dict) and "error" in data:
|
| 41 |
+
return {
|
| 42 |
+
"FLD_ZONE": "ERROR",
|
| 43 |
+
"error": data["error"],
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
features = data.get("features") or []
|
| 47 |
+
if not features:
|
| 48 |
+
return {
|
| 49 |
+
"FLD_ZONE": "UNMAPPED",
|
| 50 |
+
"note": "No FEMA NFHL polygon at this point",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return features[0].get("attributes") or {}
|
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Nominatim geocoder. Free, no API key (just User-Agent)."""
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
|
| 6 |
+
from app.config import USER_AGENT
|
| 7 |
+
|
| 8 |
+
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def geocode_address(address: str) -> Optional[dict]:
|
| 12 |
+
params = {
|
| 13 |
+
"q": address,
|
| 14 |
+
"format": "json",
|
| 15 |
+
"limit": 1,
|
| 16 |
+
"addressdetails": 1,
|
| 17 |
+
}
|
| 18 |
+
headers = {"User-Agent": USER_AGENT}
|
| 19 |
+
|
| 20 |
+
async with httpx.AsyncClient(timeout=15, headers=headers) as client:
|
| 21 |
+
resp = await client.get(NOMINATIM_URL, params=params)
|
| 22 |
+
resp.raise_for_status()
|
| 23 |
+
results = resp.json()
|
| 24 |
+
|
| 25 |
+
if not results:
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
r = results[0]
|
| 29 |
+
addr = r.get("address", {})
|
| 30 |
+
return {
|
| 31 |
+
"lat": float(r["lat"]),
|
| 32 |
+
"lon": float(r["lon"]),
|
| 33 |
+
"display_name": r.get("display_name", address),
|
| 34 |
+
"city": addr.get("city") or addr.get("town") or addr.get("village") or "",
|
| 35 |
+
"state": addr.get("state", ""),
|
| 36 |
+
"county": addr.get("county", ""),
|
| 37 |
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.32.0
|
| 3 |
+
httpx==0.28.0
|
| 4 |
+
pydantic==2.10.0
|
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smoke test: confirm Gemma 4 free tier on OpenRouter supports
|
| 3 |
+
the two features FloodIQ depends on:
|
| 4 |
+
1. reasoning mode (risk-analyst agent)
|
| 5 |
+
2. OpenAI-format tool calling (data agents)
|
| 6 |
+
|
| 7 |
+
Run:
|
| 8 |
+
cd backend && set -a && source .env && set +a && .venv/bin/python scripts/smoke_test.py
|
| 9 |
+
"""
|
| 10 |
+
import asyncio
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
|
| 17 |
+
API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
| 18 |
+
BASE = "https://openrouter.ai/api/v1/chat/completions"
|
| 19 |
+
PRIMARY = "google/gemma-4-31b-it:free"
|
| 20 |
+
FALLBACK = "google/gemma-4-26b-a4b-it:free"
|
| 21 |
+
|
| 22 |
+
HEADERS = {
|
| 23 |
+
"Authorization": f"Bearer {API_KEY}",
|
| 24 |
+
"Content-Type": "application/json",
|
| 25 |
+
"HTTP-Referer": "https://floodiq.pages.dev",
|
| 26 |
+
"X-Title": "FloodIQ smoke test",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def section(title: str) -> None:
|
| 31 |
+
print("\n" + "=" * 70)
|
| 32 |
+
print(title)
|
| 33 |
+
print("=" * 70)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def call(payload: dict) -> dict:
|
| 37 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 38 |
+
resp = await client.post(BASE, headers=HEADERS, json=payload)
|
| 39 |
+
print(f" HTTP {resp.status_code}")
|
| 40 |
+
if resp.status_code != 200:
|
| 41 |
+
print(f" body: {resp.text[:600]}")
|
| 42 |
+
return {}
|
| 43 |
+
return resp.json()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def test_basic(model: str) -> bool:
|
| 47 |
+
section(f"TEST 1 — basic completion ({model})")
|
| 48 |
+
data = await call({
|
| 49 |
+
"model": model,
|
| 50 |
+
"messages": [{"role": "user", "content": "Say only: pong"}],
|
| 51 |
+
"max_tokens": 16,
|
| 52 |
+
"temperature": 0,
|
| 53 |
+
})
|
| 54 |
+
if not data:
|
| 55 |
+
return False
|
| 56 |
+
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 57 |
+
print(f" response: {text!r}")
|
| 58 |
+
return bool(text)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def test_reasoning(model: str) -> bool:
|
| 62 |
+
section(f"TEST 2 — reasoning mode ({model})")
|
| 63 |
+
data = await call({
|
| 64 |
+
"model": model,
|
| 65 |
+
"messages": [{
|
| 66 |
+
"role": "user",
|
| 67 |
+
"content": (
|
| 68 |
+
"If the annual exceedance probability of a flood is 0.01, "
|
| 69 |
+
"what is the probability of at least one flood in 30 years? "
|
| 70 |
+
"Show your work, then return only the final number."
|
| 71 |
+
),
|
| 72 |
+
}],
|
| 73 |
+
"reasoning": {"enabled": True},
|
| 74 |
+
"max_tokens": 1024,
|
| 75 |
+
"temperature": 0,
|
| 76 |
+
})
|
| 77 |
+
if not data:
|
| 78 |
+
return False
|
| 79 |
+
msg = data.get("choices", [{}])[0].get("message", {})
|
| 80 |
+
text = msg.get("content", "")
|
| 81 |
+
reasoning_details = msg.get("reasoning_details", [])
|
| 82 |
+
reasoning_field = msg.get("reasoning", "")
|
| 83 |
+
print(f" content (first 300 chars): {text[:300]!r}")
|
| 84 |
+
print(f" reasoning_details present: {bool(reasoning_details)} (len={len(reasoning_details)})")
|
| 85 |
+
print(f" reasoning field present: {bool(reasoning_field)} (len={len(reasoning_field) if isinstance(reasoning_field, str) else 'n/a'})")
|
| 86 |
+
if reasoning_details:
|
| 87 |
+
first = reasoning_details[0]
|
| 88 |
+
print(f" reasoning_details[0] keys: {list(first.keys()) if isinstance(first, dict) else type(first).__name__}")
|
| 89 |
+
sample = json.dumps(first)[:300] if isinstance(first, dict) else str(first)[:300]
|
| 90 |
+
print(f" reasoning_details[0] sample: {sample}")
|
| 91 |
+
elif reasoning_field:
|
| 92 |
+
sample = reasoning_field[:300] if isinstance(reasoning_field, str) else str(reasoning_field)[:300]
|
| 93 |
+
print(f" reasoning sample: {sample!r}")
|
| 94 |
+
print(f" usage: {data.get('usage', {})}")
|
| 95 |
+
return bool(reasoning_details or reasoning_field)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
async def test_tools(model: str) -> bool:
|
| 99 |
+
section(f"TEST 3 — function calling ({model})")
|
| 100 |
+
tools = [{
|
| 101 |
+
"type": "function",
|
| 102 |
+
"function": {
|
| 103 |
+
"name": "lookup_fema_flood_zone",
|
| 104 |
+
"description": "Look up FEMA flood zone for coordinates.",
|
| 105 |
+
"parameters": {
|
| 106 |
+
"type": "object",
|
| 107 |
+
"properties": {
|
| 108 |
+
"latitude": {"type": "number"},
|
| 109 |
+
"longitude": {"type": "number"},
|
| 110 |
+
},
|
| 111 |
+
"required": ["latitude", "longitude"],
|
| 112 |
+
},
|
| 113 |
+
},
|
| 114 |
+
}]
|
| 115 |
+
data = await call({
|
| 116 |
+
"model": model,
|
| 117 |
+
"messages": [{
|
| 118 |
+
"role": "user",
|
| 119 |
+
"content": "What is the FEMA flood zone for 41.8087, -87.6062?",
|
| 120 |
+
}],
|
| 121 |
+
"tools": tools,
|
| 122 |
+
"tool_choice": "auto",
|
| 123 |
+
"max_tokens": 512,
|
| 124 |
+
"temperature": 0,
|
| 125 |
+
})
|
| 126 |
+
if not data:
|
| 127 |
+
return False
|
| 128 |
+
msg = data.get("choices", [{}])[0].get("message", {})
|
| 129 |
+
tool_calls = msg.get("tool_calls", []) or []
|
| 130 |
+
text = msg.get("content", "") or ""
|
| 131 |
+
print(f" content: {text[:200]!r}")
|
| 132 |
+
print(f" tool_calls count: {len(tool_calls)}")
|
| 133 |
+
if tool_calls:
|
| 134 |
+
tc = tool_calls[0]
|
| 135 |
+
print(f" tool_call[0]: {json.dumps(tc)[:400]}")
|
| 136 |
+
return bool(tool_calls)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
async def main() -> int:
|
| 140 |
+
if not API_KEY:
|
| 141 |
+
print("ERROR: OPENROUTER_API_KEY not set", file=sys.stderr)
|
| 142 |
+
return 2
|
| 143 |
+
|
| 144 |
+
results = {}
|
| 145 |
+
for model in (PRIMARY, FALLBACK):
|
| 146 |
+
results[(model, "basic")] = await test_basic(model)
|
| 147 |
+
results[(model, "reasoning")] = await test_reasoning(model)
|
| 148 |
+
results[(model, "tools")] = await test_tools(model)
|
| 149 |
+
|
| 150 |
+
section("SUMMARY")
|
| 151 |
+
for (model, name), ok in results.items():
|
| 152 |
+
mark = "PASS" if ok else "FAIL"
|
| 153 |
+
print(f" [{mark}] {model:42s} {name}")
|
| 154 |
+
|
| 155 |
+
all_critical = all([
|
| 156 |
+
results.get((PRIMARY, "basic"), False),
|
| 157 |
+
results.get((PRIMARY, "reasoning"), False) or results.get((FALLBACK, "reasoning"), False),
|
| 158 |
+
results.get((PRIMARY, "tools"), False) or results.get((FALLBACK, "tools"), False),
|
| 159 |
+
])
|
| 160 |
+
print()
|
| 161 |
+
print("Overall:", "OK to proceed" if all_critical else "BLOCKED — adjust spec before building")
|
| 162 |
+
return 0 if all_critical else 1
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
if __name__ == "__main__":
|
| 166 |
+
sys.exit(asyncio.run(main()))
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke-test just function calling on Gemma 4 (after rate-limit clears)."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
API_KEY = os.environ["OPENROUTER_API_KEY"]
|
| 10 |
+
BASE = "https://openrouter.ai/api/v1/chat/completions"
|
| 11 |
+
HEADERS = {
|
| 12 |
+
"Authorization": f"Bearer {API_KEY}",
|
| 13 |
+
"Content-Type": "application/json",
|
| 14 |
+
"HTTP-Referer": "https://floodiq.pages.dev",
|
| 15 |
+
"X-Title": "FloodIQ smoke test",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
TOOLS = [{
|
| 19 |
+
"type": "function",
|
| 20 |
+
"function": {
|
| 21 |
+
"name": "lookup_fema_flood_zone",
|
| 22 |
+
"description": "Look up FEMA flood zone for coordinates.",
|
| 23 |
+
"parameters": {
|
| 24 |
+
"type": "object",
|
| 25 |
+
"properties": {
|
| 26 |
+
"latitude": {"type": "number"},
|
| 27 |
+
"longitude": {"type": "number"},
|
| 28 |
+
},
|
| 29 |
+
"required": ["latitude", "longitude"],
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
}]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def try_model(model: str) -> bool:
|
| 36 |
+
print(f"\n--- {model} ---")
|
| 37 |
+
payload = {
|
| 38 |
+
"model": model,
|
| 39 |
+
"messages": [{
|
| 40 |
+
"role": "user",
|
| 41 |
+
"content": "What is the FEMA flood zone for 41.8087, -87.6062? Use the tool.",
|
| 42 |
+
}],
|
| 43 |
+
"tools": TOOLS,
|
| 44 |
+
"tool_choice": "auto",
|
| 45 |
+
"max_tokens": 512,
|
| 46 |
+
"temperature": 0,
|
| 47 |
+
}
|
| 48 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 49 |
+
resp = await client.post(BASE, headers=HEADERS, json=payload)
|
| 50 |
+
print(f"HTTP {resp.status_code}")
|
| 51 |
+
if resp.status_code != 200:
|
| 52 |
+
print(resp.text[:500])
|
| 53 |
+
return False
|
| 54 |
+
msg = resp.json()["choices"][0]["message"]
|
| 55 |
+
text = msg.get("content", "") or ""
|
| 56 |
+
tool_calls = msg.get("tool_calls", []) or []
|
| 57 |
+
print(f"content: {text[:200]!r}")
|
| 58 |
+
print(f"tool_calls ({len(tool_calls)}):")
|
| 59 |
+
for tc in tool_calls:
|
| 60 |
+
print(f" {json.dumps(tc)[:300]}")
|
| 61 |
+
return bool(tool_calls)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def main() -> int:
|
| 65 |
+
for model in ("google/gemma-4-31b-it:free", "google/gemma-4-26b-a4b-it:free"):
|
| 66 |
+
ok = await try_model(model)
|
| 67 |
+
if ok:
|
| 68 |
+
print("\nPASS")
|
| 69 |
+
return 0
|
| 70 |
+
print("\nFAIL on both models")
|
| 71 |
+
return 1
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
sys.exit(asyncio.run(main()))
|