Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 76 additions & 10 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@
import os
import random
import sys
import threading
import time
import uuid
from collections.abc import Callable
from dataclasses import asdict
from pathlib import Path
from typing import Any

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -58,6 +61,7 @@ def default(self, obj: object) -> object:
# Ensure src/ is importable regardless of working directory
sys.path.insert(0, str(Path(__file__).parent / "src"))

from loguru import logger as _llog
from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI, HTTPException, WebSocket
from fastapi.middleware.cors import CORSMiddleware
Expand Down Expand Up @@ -113,6 +117,15 @@ def default(self, obj: object) -> object:
# In-process store for polling-based runs: run_id → state dict
_runs: dict[str, dict] = {}

# Session-level caches to skip redundant flood work on repeated simulation runs.
# Cleared by /satellite/refresh so a manual satellite update forces re-injection.
_flood_union_cache: dict[str, Any] = {} # flood_data_path → merged Shapely geometry
_flood_injected: dict[str, str] = {} # flood_event_id → flood_data_path last injected with

# Abort flag: set by ws_run when a new connection arrives so a stale thread
# from a previous (disconnected) WebSocket stops as soon as possible.
_abort_event = threading.Event()


# ── Models ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -157,55 +170,97 @@ def run_orchestration(
flood_data_path = scenario["flood_data_path"]
n_agents = n_agents_override if n_agents_override is not None else scenario["n_agents"]

_abort_event.clear()
t_start = time.monotonic()

def _elapsed() -> str:
return f"{time.monotonic() - t_start:.1f}s"

driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
try:
# ── 1. Flood injection ─────────────────────────────────────────────────
polygons = get_flooded_sectors(source="local", path=flood_data_path)
raw_union = unary_union(polygons)
if raw_union.geom_type not in ("Polygon", "MultiPolygon"):
flood_geom = MultiPolygon(
[g for g in raw_union.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
)
# Cache the unary_union — merging 1117 EMSR773 polygons takes 5–30 s.
# Cache is keyed by path and lives for the server session; cleared by
# /satellite/refresh so a manual update still forces re-injection.
if flood_data_path not in _flood_union_cache:
_llog.info("[{}] building flood union from {} …", _elapsed(), flood_data_path)
polygons = get_flooded_sectors(source="local", path=flood_data_path)
raw_union = unary_union(polygons)
if raw_union.geom_type not in ("Polygon", "MultiPolygon"):
raw_union = MultiPolygon(
[g for g in raw_union.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
)
_flood_union_cache[flood_data_path] = raw_union
_llog.info("[{}] flood union cached", _elapsed())
flood_geom = _flood_union_cache[flood_data_path]

# Skip Neo4j reset+inject when flood state is already current for this
# scenario — the graph doesn't change between runs.
if _flood_injected.get(flood_event_id) != flood_data_path:
reset_flood(flood_event_id, driver)
inject_flood(flood_geom, flood_event_id, driver)
_flood_injected[flood_event_id] = flood_data_path
else:
flood_geom = raw_union
_llog.info("[{}] flood injection skipped (cached)", _elapsed())

reset_flood(flood_event_id, driver)
inject_flood(flood_geom, flood_event_id, driver)
if _abort_event.is_set():
raise RuntimeError("run aborted — new connection arrived")

# ── 2. Hermes ──────────────────────────────────────────────────────────
_llog.info("[{}] get_graph_context …", _elapsed())
ctx = get_graph_context(sector, driver)
_llog.info("[{}] hermes.generate …", _elapsed())
hermes = HermesEngine(sop_scenario=scenario_name)
hermes_result = hermes.generate(ctx, sector=sector)
_llog.info("[{}] hermes done", _elapsed())

if _abort_event.is_set():
raise RuntimeError("run aborted — new connection arrived")

# ── 3. Build swarm ─────────────────────────────────────────────────────
_llog.info("[{}] build_nx_graph …", _elapsed())
G_passable, G_full = build_nx_graph(driver)
_llog.info("[{}] graph: {} nodes, {} edges (passable)", _elapsed(),
G_passable.number_of_nodes(), G_passable.number_of_edges())
shelter_node = find_shelter_node(G_passable, driver)
key_tokens = extract_key_tokens(hermes_result)
agents = spawn_agents(G_full, n_agents)
_llog.info("[{}] agents spawned ({})", _elapsed(), len(agents))

if _abort_event.is_set():
raise RuntimeError("run aborted — new connection arrived")

# ── 4. Simulation ──────────────────────────────────────────────────────
config = SimulationConfig(n_agents=n_agents, max_ticks=50)
_llog.info("[{}] simulation init + dijkstra …", _elapsed())
config = SimulationConfig(n_agents=n_agents, max_ticks=100)
sim = Simulation(
G_passable, G_full, agents, key_tokens, shelter_node, config,
tick_callback=tick_callback,
)
_llog.info("[{}] simulation running …", _elapsed())
sim_result = sim.run()
_llog.info("[{}] simulation done ({} ticks, {} safe)", _elapsed(),
sim_result.ticks_run, sim_result.evacuated)

# ── 5. Critic ──────────────────────────────────────────────────────────
_llog.info("[{}] critic …", _elapsed())
critic = CriticEngine(sop_scenario=scenario_name)
sop_update = critic.analyze(
hermes_message=hermes_result.message.human_readable,
sim_result=asdict(sim_result),
)
_llog.info("[{}] critic done", _elapsed())

# ── 6. Geometry lookups ────────────────────────────────────────────────
_llog.info("[{}] geometry lookups …", _elapsed())
unique_node_ids = list({a.node_id for a in agents} | {shelter_node})
node_coords = get_node_coords(unique_node_ids, driver)

flooded_road_ids = [r["id"] for r in ctx.get("flooded_roads", []) if r.get("id")]
road_geom = get_road_geometry(sim_result.bottleneck_edges, flooded_road_ids, driver)

# ── 7. Assemble payload ────────────────────────────────────────────────
_llog.info("[{}] build_payload …", _elapsed())
payload = build_payload(
scenario_name=scenario_name,
hermes_result=hermes_result,
Expand All @@ -221,6 +276,7 @@ def run_orchestration(
finally:
driver.close()

_llog.info("[{}] orchestration complete — sending payload", _elapsed())
# Sentinel: signals WebSocket/polling that orchestration is done
if tick_callback is not None:
tick_callback(None)
Expand Down Expand Up @@ -254,6 +310,11 @@ async def ws_run(
{"type": "complete", "data": <full SimulationPayload>}
{"type": "error", "message": "<error string>"}
"""
# Signal any in-progress run from a previous (now-disconnected) WebSocket
# to stop at its next abort checkpoint. Prevents two threads fighting over
# the same Groq API quota when the browser reconnects mid-simulation.
_abort_event.set()

await websocket.accept()
loop = asyncio.get_running_loop()
queue: asyncio.Queue[dict | None] = asyncio.Queue()
Expand Down Expand Up @@ -406,6 +467,11 @@ def _run() -> dict:
for polygon in polygons:
total_edges += inject_flood(polygon, body.flood_event_id, driver)

# Invalidate session caches so the next run_orchestration call
# recomputes the union and re-injects the new flood state.
_flood_union_cache.clear()
_flood_injected.clear()

return {
"status": source_label,
"source": "sentinel-1-cdse" if source_label == "live" else "copernicus-ems-local",
Expand Down
9 changes: 5 additions & 4 deletions sops/paiporta.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## SOP Update — Inadequate Verifiable Content
- **Rule:** Include at least two distinct verifiable data points (e.g., satellite timestamp, confirmed road closure, authority name, and nearby landmark) to enable Skeptical agents to self-validate without requiring external confirmation.
- **Rule:** Specify the shelter destination as a precise street intersection, including the nearest notable landmark, to minimize route ambiguity and facilitate efficient word-of-mouth transmission.
- **Rule:** State the evacuation route using both street names and cardinal directions (e.g., "proceed north on Avinguda de la Independència") to enhance clarity and reduce degradation of instructions through multi-hop communication.
## SOP Update — Skeptical Agents Need Self-Verifiable Authority Anchors

- **Rule:** Prepend every evacuation message with a named authority source and timestamp (e.g., "Valencia Regional Emergency Authority, 14:32 UTC") so Skeptical agents can verify credibility without requiring peer confirmation.
- **Rule:** Replace building names with precise GPS coordinates or street intersections (e.g., "43.41°N, 0.36°W" or "Avenida Paiporta & Carrer de la Pau") and list 2–3 landmark roads to *avoid* by name, so route clarity survives multi-hop word-of-mouth degradation.
- **Rule:** Include one scannable, self-contained fact that requires no external source to validate (e.g., "Copernicus satellite confirms flooding at CV-407 as of [time]; do not attempt crossing") to allow Skeptical agents to act without waiting for a second neighbor's confirmation.
10 changes: 10 additions & 0 deletions sops/paiporta_history.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,13 @@
- **Rule:** Include at least two distinct verifiable data points (e.g., satellite timestamp, confirmed road closure, authority name, and nearby landmark) to enable Skeptical agents to self-validate without requiring external confirmation.
- **Rule:** Specify the shelter destination as a precise street intersection, including the nearest notable landmark, to minimize route ambiguity and facilitate efficient word-of-mouth transmission.
- **Rule:** State the evacuation route using both street names and cardinal directions (e.g., "proceed north on Avinguda de la Independència") to enhance clarity and reduce degradation of instructions through multi-hop communication.

---

**Run:** c0606451 · **2026-04-25T23:22:18Z** · Evac rate: 43.0%

## SOP Update — Skeptical Agents Need Self-Verifiable Authority Anchors

- **Rule:** Prepend every evacuation message with a named authority source and timestamp (e.g., "Valencia Regional Emergency Authority, 14:32 UTC") so Skeptical agents can verify credibility without requiring peer confirmation.
- **Rule:** Replace building names with precise GPS coordinates or street intersections (e.g., "43.41°N, 0.36°W" or "Avenida Paiporta & Carrer de la Pau") and list 2–3 landmark roads to *avoid* by name, so route clarity survives multi-hop word-of-mouth degradation.
- **Rule:** Include one scannable, self-contained fact that requires no external source to validate (e.g., "Copernicus satellite confirms flooding at CV-407 as of [time]; do not attempt crossing") to allow Skeptical agents to act without waiting for a second neighbor's confirmation.
4 changes: 2 additions & 2 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# ── Provider Selection ────────────────────────────────────────────────────────
# "groq" → Groq API (llama-3.1-70b-versatile) — default for dev/simulation
# "anthropic" → Anthropic API (claude-sonnet-4-6) — production swap
LLM_PROVIDER: str = os.getenv("LLM_PROVIDER", "groq")
LLM_PROVIDER: str = os.getenv("LLM_PROVIDER", "anthropic")

# ── Groq (default: development + simulation) ──────────────────────────────────
GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "")
Expand All @@ -25,7 +25,7 @@

# ── Anthropic (production swap) ───────────────────────────────────────────────
ANTHROPIC_API_KEY: str = os.getenv("ANTHROPIC_API_KEY", "")
ANTHROPIC_MAIN_MODEL: str = "claude-3-5-sonnet-20240620" # or claude-sonnet-4-6
ANTHROPIC_MAIN_MODEL: str = "claude-haiku-4-5-20251001"
ANTHROPIC_FAST_MODEL: str = "claude-haiku-4-5-20251001" # clarity validator

# ── Satellite / CDSE (Phase 2) ───────────────────────────────────────────────
Expand Down
66 changes: 41 additions & 25 deletions src/graph/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,13 @@
out skel qt;
"""

# Kumi Systems mirror — avoids the 406 rejection from overpass-api.de's
# default rate-limiting policy when no User-Agent is supplied.
_OVERPASS_URL = "https://overpass.kumi.systems/api/interpreter"
# Tried in order; skips to next on 404/connection failure so a dead mirror
# never blocks the pipeline. User-Agent is required by overpass-api.de.
_OVERPASS_ENDPOINTS = [
"https://overpass-api.de/api/interpreter",
"https://lz4.overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
]
_USER_AGENT = "ECHO-SWARM-Hackathon-Bot/1.0"

# km/h defaults used when OSM maxspeed tag is absent
Expand Down Expand Up @@ -183,28 +187,40 @@ def _fetch_overpass(
api = overpy.Overpass()
headers = {"User-Agent": _USER_AGENT}

for attempt in range(max_retries):
try:
logger.info(
"Querying Overpass (attempt %d/%d) bbox=%s …",
attempt + 1, max_retries, bbox,
)
response = requests.post(
_OVERPASS_URL,
data={"data": query},
headers=headers,
timeout=90,
)
response.raise_for_status()
return api.parse_json(response.text)
except (requests.HTTPError, requests.ConnectionError, overpy.exception.OverPyException) as exc:
if attempt < max_retries - 1:
wait = 2 ** attempt
logger.warning("Overpass error: %s — retrying in %ds", exc, wait)
time.sleep(wait)
else:
raise
raise RuntimeError("Overpass query failed after all retries") # unreachable
last_exc: Exception | None = None
for url in _OVERPASS_ENDPOINTS:
for attempt in range(max_retries):
try:
logger.info(
"Querying Overpass %s (attempt %d/%d) bbox=%s …",
url, attempt + 1, max_retries, bbox,
)
response = requests.post(
url, data={"data": query}, headers=headers, timeout=90,
)
response.raise_for_status()
return api.parse_json(response.text)
except requests.HTTPError as exc:
last_exc = exc
code = exc.response.status_code if exc.response is not None else 0
if code in (400, 404):
logger.warning("Overpass %s returned HTTP %d — trying next endpoint", url, code)
break
if attempt < max_retries - 1:
wait = 2 ** attempt
logger.warning("Overpass %s HTTP %d — retrying in %ds", url, code, wait)
time.sleep(wait)
except (requests.ConnectionError, overpy.exception.OverPyException) as exc:
last_exc = exc
if attempt < max_retries - 1:
wait = 2 ** attempt
logger.warning("Overpass %s error: %s — retrying in %ds", url, exc, wait)
time.sleep(wait)
else:
logger.warning("Overpass %s failed: %s — trying next endpoint", url, exc)
raise RuntimeError(
f"Overpass query failed on all {len(_OVERPASS_ENDPOINTS)} endpoints. Last: {last_exc}"
)


# ─────────────────────────────────────────────────────────────────────────────
Expand Down
9 changes: 7 additions & 2 deletions src/graph/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from typing import Union

import shapely.geometry
import shapely.prepared
from loguru import logger
from neo4j import Driver

Expand Down Expand Up @@ -265,14 +266,18 @@ def _nodes_inside(

swap_axes=True tests Point(lat, lon) to detect a lat/lon storage flip.
"""
# prep() builds an R-tree index on the geometry once so each subsequent
# contains() call is 3–10× faster than calling it on the raw polygon.
prepared = shapely.prepared.prep(polygon)
check = prepared.contains
if swap_axes:
return {
n["id"] for n in nodes
if polygon.contains(shapely.geometry.Point(n["lat"], n["lon"]))
if check(shapely.geometry.Point(n["lat"], n["lon"]))
}
return {
n["id"] for n in nodes
if polygon.contains(shapely.geometry.Point(n["lon"], n["lat"]))
if check(shapely.geometry.Point(n["lon"], n["lat"]))
}


Expand Down
5 changes: 4 additions & 1 deletion src/hermes/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ def __init__(self, model: str, *, use_cache: bool = True) -> None:
raise RuntimeError("anthropic package not installed. Run: uv add anthropic") from exc
if not config.ANTHROPIC_API_KEY:
raise RuntimeError("ANTHROPIC_API_KEY is not set. Add it to your .env file.")
self._client = _anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
self._client = _anthropic.Anthropic(
api_key=config.ANTHROPIC_API_KEY,
timeout=60.0, # fail fast instead of hanging for up to 600 s (SDK default)
)
self.model = model
self._use_cache = use_cache

Expand Down
Loading