kredd25 Claude Opus 4.7 (1M context) commited on
Commit
577ea9f
·
0 Parent(s):

backend: vertical slice — geocoder + FEMA agent + SSE end-to-end

Browse files

Stands 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 ADDED
@@ -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/
Dockerfile ADDED
@@ -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"]
README.md ADDED
@@ -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.
app/__init__.py ADDED
File without changes
app/agents/__init__.py ADDED
File without changes
app/agents/fema_agent.py ADDED
@@ -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
+ }
app/agents/orchestrator.py ADDED
@@ -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
+ }
app/api/__init__.py ADDED
File without changes
app/api/assess.py ADDED
@@ -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
+ )
app/api/health.py ADDED
@@ -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
+ }
app/config.py ADDED
@@ -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)"
app/llm/__init__.py ADDED
File without changes
app/llm/client.py ADDED
@@ -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)
app/llm/prompts.py ADDED
@@ -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."""
app/main.py ADDED
@@ -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)
app/models/__init__.py ADDED
File without changes
app/tools/__init__.py ADDED
File without changes
app/tools/fema.py ADDED
@@ -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 {}
app/tools/geocoder.py ADDED
@@ -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
+ }
requirements.txt ADDED
@@ -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
scripts/smoke_test.py ADDED
@@ -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()))
scripts/smoke_test_tools.py ADDED
@@ -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()))