From 6378784703b4dbb5f1d9446055f57d43c2176e2f Mon Sep 17 00:00:00 2001 From: aleens-labs <256919372+aleens-labs@users.noreply.github.com> Date: Mon, 11 May 2026 16:31:42 -0700 Subject: [PATCH] feat(agent): add emergency logistics v2 and trust shield --- README.md | 177 +++--- agent/app.py | 70 ++- agent/mission_orchestrator.py | 569 ++++++++++++++++++ agent/mission_schemas.py | 258 ++++++++ agent/services/__init__.py | 1 + agent/services/firebase_state.py | 30 + agent/services/gemini_client.py | 27 + agent/services/gemma_local.py | 13 + agent/tools/air_quality.py | 22 + agent/tools/disaster.py | 50 ++ agent/tools/infrastructure.py | 26 + agent/tools/logistics.py | 117 ++++ agent/tools/misinformation.py | 89 +++ agent/tools/routing_google.py | 62 ++ agent/tools/traffic.py | 26 + agent/tools/trust.py | 169 ++++++ agent/trust_schemas.py | 89 +++ data/sample_scenarios/bay_area_wildfire.json | 179 ++++++ .../sample_scenarios/earthquake_response.json | 67 +++ data/sample_scenarios/flood_response.json | 66 ++ docs/api_inventory.md | 111 ++++ docs/architecture_v2.md | 46 ++ docs/demo_google_io.md | 39 ++ docs/trust_shield.md | 68 +++ docs/v2_prd_emergency_response.md | 55 ++ integrations/__init__.py | 1 + integrations/common/__init__.py | 1 + integrations/common/cache.py | 18 + integrations/common/geojson.py | 17 + integrations/common/http.py | 95 +++ integrations/google/__init__.py | 1 + integrations/google/firebase.py | 20 + integrations/google/maps_routes.py | 83 +++ integrations/google/route_optimization.py | 68 +++ integrations/security/__init__.py | 1 + integrations/security/rdap.py | 61 ++ integrations/security/safe_browsing.py | 80 +++ integrations/security/url_risk.py | 320 ++++++++++ integrations/security/urlscan.py | 61 ++ integrations/security/virustotal.py | 97 +++ integrations/us_disaster/__init__.py | 1 + integrations/us_disaster/airnow.py | 65 ++ integrations/us_disaster/bridge_inventory.py | 66 ++ integrations/us_disaster/eonet.py | 39 ++ integrations/us_disaster/fema.py | 65 ++ integrations/us_disaster/firms.py | 58 ++ integrations/us_disaster/hifld.py | 100 +++ integrations/us_disaster/nifc_wfigs.py | 45 ++ integrations/us_disaster/noaa_nwps.py | 32 + integrations/us_disaster/nrel_fuel.py | 71 +++ integrations/us_disaster/nws.py | 53 ++ integrations/us_disaster/reliefweb.py | 70 +++ integrations/us_disaster/sf511.py | 54 ++ integrations/us_disaster/usgs_earthquake.py | 73 +++ integrations/us_disaster/usgs_water.py | 84 +++ pyproject.toml | 6 +- scripts/test_live_apis.py | 109 ++++ tests/test_hifld_adapter.py | 42 ++ tests/test_mission_orchestrator_fallback.py | 49 ++ tests/test_mission_schemas.py | 42 ++ tests/test_nws_adapter.py | 30 + tests/test_safe_browsing_adapter.py | 47 ++ tests/test_trust_tool_fallback.py | 52 ++ tests/test_url_risk.py | 29 + 64 files changed, 4554 insertions(+), 78 deletions(-) create mode 100644 agent/mission_orchestrator.py create mode 100644 agent/mission_schemas.py create mode 100644 agent/services/__init__.py create mode 100644 agent/services/firebase_state.py create mode 100644 agent/services/gemini_client.py create mode 100644 agent/services/gemma_local.py create mode 100644 agent/tools/air_quality.py create mode 100644 agent/tools/disaster.py create mode 100644 agent/tools/infrastructure.py create mode 100644 agent/tools/logistics.py create mode 100644 agent/tools/misinformation.py create mode 100644 agent/tools/routing_google.py create mode 100644 agent/tools/traffic.py create mode 100644 agent/tools/trust.py create mode 100644 agent/trust_schemas.py create mode 100644 data/sample_scenarios/bay_area_wildfire.json create mode 100644 data/sample_scenarios/earthquake_response.json create mode 100644 data/sample_scenarios/flood_response.json create mode 100644 docs/api_inventory.md create mode 100644 docs/architecture_v2.md create mode 100644 docs/demo_google_io.md create mode 100644 docs/trust_shield.md create mode 100644 docs/v2_prd_emergency_response.md create mode 100644 integrations/__init__.py create mode 100644 integrations/common/__init__.py create mode 100644 integrations/common/cache.py create mode 100644 integrations/common/geojson.py create mode 100644 integrations/common/http.py create mode 100644 integrations/google/__init__.py create mode 100644 integrations/google/firebase.py create mode 100644 integrations/google/maps_routes.py create mode 100644 integrations/google/route_optimization.py create mode 100644 integrations/security/__init__.py create mode 100644 integrations/security/rdap.py create mode 100644 integrations/security/safe_browsing.py create mode 100644 integrations/security/url_risk.py create mode 100644 integrations/security/urlscan.py create mode 100644 integrations/security/virustotal.py create mode 100644 integrations/us_disaster/__init__.py create mode 100644 integrations/us_disaster/airnow.py create mode 100644 integrations/us_disaster/bridge_inventory.py create mode 100644 integrations/us_disaster/eonet.py create mode 100644 integrations/us_disaster/fema.py create mode 100644 integrations/us_disaster/firms.py create mode 100644 integrations/us_disaster/hifld.py create mode 100644 integrations/us_disaster/nifc_wfigs.py create mode 100644 integrations/us_disaster/noaa_nwps.py create mode 100644 integrations/us_disaster/nrel_fuel.py create mode 100644 integrations/us_disaster/nws.py create mode 100644 integrations/us_disaster/reliefweb.py create mode 100644 integrations/us_disaster/sf511.py create mode 100644 integrations/us_disaster/usgs_earthquake.py create mode 100644 integrations/us_disaster/usgs_water.py create mode 100644 scripts/test_live_apis.py create mode 100644 tests/test_hifld_adapter.py create mode 100644 tests/test_mission_orchestrator_fallback.py create mode 100644 tests/test_mission_schemas.py create mode 100644 tests/test_nws_adapter.py create mode 100644 tests/test_safe_browsing_adapter.py create mode 100644 tests/test_trust_tool_fallback.py create mode 100644 tests/test_url_risk.py diff --git a/README.md b/README.md index d33bc23..4337b0c 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,139 @@ -# TERA — Tactical Edge Route Agent +# TERA -- Offline-first AI Emergency Logistics Coordination Agent -> **By Team TruePoint** — competing as members of the **[Naval Postgraduate School Foundation](https://npsfoundation.org/) Entrepreneurship Club** · National Security Hackathon 2026 (Cerebral Valley × US Army xTech) -> -> A pocket-sized, fully-offline AI agent that turns natural language into trustworthy tactical routes inside ATAK, on a Jetson Orin Nano, with no cloud and no outbound packets. Voice in, voice and visual out — operators can navigate hands-free while climbing, fast-roping, or running another task. +> **By Team TruePoint** -- Naval Postgraduate School Foundation Entrepreneurship Club. -**TERA / Terra / Terraform / Terrain** — the product's name carries the thesis: an operator with TERA can shape their own path across any terrain, anywhere on earth. The Figma design language leans on earth-tones, contour-line motifs, and terraforming-as-empowerment imagery (Jon owns). +TERA is an offline-first AI coordination platform for disaster response. It helps emergency teams detect active hazards, identify hospitals and shelters, allocate scarce resources, assign vehicles, choose safer routes, explain operational decisions, and keep working when connectivity is degraded. -**Hackathon:** 3rd Annual National Security Hackathon · San Francisco · May 2–3, 2026. -**Problem statements:** PS2 (Edge Deployments) · PS3 (Mission C2) · PS4 (Cybersecurity). -**Source of truth:** [`docs/PRD.md`](docs/PRD.md) — read this first. +Originally built as a tactical edge routing prototype, TERA is now extended into a humanitarian disaster response platform powered by Gemini, Gemma, Google Maps, Firebase, and live US disaster APIs. The original ATAK, Jetson, signed CoT, and offline tactical route architecture remains available as a legacy/optional capability. ---- +## Problem -## Read this before writing any code +Disaster responders make time-critical logistics decisions with incomplete information. Wildfire perimeters, flood gauges, smoke exposure, road closures, hospital availability, shelter capacity, vehicle status, and inventory all move faster than teams can manually reconcile. During the same window, fraud and misinformation can push fake donation links, fake FEMA portals, unverified shelter instructions, and fraudulent supply requests into the response loop. -1. [`AGENTS.md`](AGENTS.md) — agent guardrails. **AI agents must read this every task.** -2. [`docs/PRD.md`](docs/PRD.md) — product, architecture, security, demo plan. -3. [`.agents/00-team.md`](.agents/00-team.md) — stack + style + non-negotiables. -4. [`.agents/10-architecture.md`](.agents/10-architecture.md) — system design summary. -5. [`.agents/2X-.md`](.agents/) — your lane's specific conventions. -6. [`TASKS.md`](TASKS.md) — seed issues for the GitHub board. +## Solution -## Quickstart for new teammates +TERA turns a natural-language emergency objective into an explainable mission plan: -After cloning, **run this and follow the prompts:** +- active hazards and official disaster context +- hospital, shelter, fuel, bridge, and critical infrastructure options +- route candidates and route risk scoring +- deterministic offline resource allocation +- optional Google Maps Routes and Route Optimization when credentials and network are available +- TERA Trust Shield checks for disaster-fraud links, suspicious supply requests, unverified shelter claims, and conflicting field reports +- offline Gemma fallback and Firebase-ready shared state for degraded operations + +## Why Offline-first Matters + +TERA defaults to cached/sample/local state and deterministic fallbacks. Live APIs are opt-in on the v2 mission endpoint with `use_live_apis=true`. This keeps the legacy zero-outbound tactical demo intact while giving humanitarian teams richer live data when the network allows it. + +## Google I/O Hackathon Fit + +- **Gemini:** emergency reasoning, tool calling, multimodal explanation, and decision summaries. +- **Gemma:** local/offline fallback reasoning when connectivity drops. +- **Google Maps Routes API:** route generation, ETA, and alternatives. +- **Google Route Optimization API:** vehicle and resource allocation. +- **Firebase:** offline-first shared state for shelters, vehicles, inventory, missions, field reports, and hazard cache. +- **Google Safe Browsing:** phishing and malware URL checks for crisis-related links. + +## Architecture v2 + +Legacy `/plan` remains available for tactical ATAK routing. The humanitarian layer adds: + +- `POST /mission/plan` -- emergency logistics mission planning +- `GET /mission/health` -- v2 liveness and offline default status +- `GET /mission/api-status` -- API-key presence without exposing values +- `GET /mission/demo/bay-area-wildfire` -- no-key, no-network wildfire logistics demo +- `POST /trust/check-url` -- crisis-link trust assessment +- `POST /trust/check-message` -- field-message trust assessment +- `POST /trust/check-supply-request` -- supply-request trust assessment +- `GET /trust/api-status` -- Trust Shield API-key presence without exposing values + +See [`docs/architecture_v2.md`](docs/architecture_v2.md). + +## TERA Trust Shield + +During disasters, fraud and misinformation can disrupt response operations. TERA Trust Shield verifies crisis-related links, supply requests, shelter claims, and field reports using Google Safe Browsing, optional threat intelligence providers, official-source matching, and human approval workflows. Suspicious information is isolated from mission planning until approved. + +See [`docs/trust_shield.md`](docs/trust_shield.md). + +## API Integrations + +TERA includes thin adapters for NOAA/NWS, FEMA, HIFLD, NIFC/WFIGS, AirNow, SF511, NASA FIRMS, USGS, NOAA NWPS, National Bridge Inventory, NREL, EONET, ReliefWeb, Google Maps Routes, Google Route Optimization, Firebase status, Google Safe Browsing, VirusTotal, urlscan.io, and RDAP. + +See [`docs/api_inventory.md`](docs/api_inventory.md). + +## Demo: Bay Area Wildfire Logistics ```bash -make onboard +make run +curl -s http://localhost:8000/mission/demo/bay-area-wildfire | jq . ``` -It asks who you are (Jon / Satriyo / Kyle / Ben), checks your environment, lists your assigned GitHub issues, and writes a tailored Codex/Cursor kickoff prompt to `/tmp/codex-.md` (also copied to clipboard on macOS). Paste that prompt into your AI agent and start coding. +The demo identifies Shelter North as overloaded and smoke-exposed, selects Shelter West as the safer logistics destination, assigns trucks to verified needs, flags a fake FEMA login link, blocks an unverified supply request from changing dispatch, and explains the decision with offline fallback state. -Non-interactive: `make onboard NAME=ben`. +See [`docs/demo_google_io.md`](docs/demo_google_io.md). -## Quickstart for development +## Quickstart for Development ```bash git clone https://github.com/jdev-02/tera.git tera && cd tera -make install # venv + core deps (~2 min) -lefthook install # pre-push hook (blocking) -cp .env.example .env # set OPENAI_API_KEY for Phase 1 -make run # stub service on :8000 -make ci # the gate (must pass before push) +make install +lefthook install +cp .env.example .env +make run +make ci ``` -Lane-specific extras: -- **Jon** (voice work): `make install-voice` -- **Satriyo** (crypto work): `brew install liboqs && make install-crypto` (macOS) or `bash infra/install_liboqs.sh && make install-crypto` (Linux/Jetson) - -Smoke check from another terminal: +Mission demo: ```bash -curl -s -X POST http://localhost:8000/plan \ - -H 'Content-Type: application/json' \ - -d '{"prompt": "route to nearest freshwater within 5km", "current": {"lat": 37.7955, "lon": -122.3937}}' | jq . +curl -s http://localhost:8000/mission/demo/bay-area-wildfire | jq . ``` -## Repo layout (PRD §13.2) +Trust Shield demo: -``` -agent/ # Jon (P1) — orchestrator, /plan endpoint -ontology/ # Jon (P1) — NL term -> OSM tag mapping -voice/ # Jon (P1) — Whisper-tiny (in) + Piper TTS (out) -eval/ # Jon (P1) — 20-prompt regression set -figma/ # Jon (P1) — UI/UX mockups (TERA / terra / terraform palette) -atak/ # Ben (P4) — CoT bridge (Android + WinTAK) -routing/ # Ben (P4) — Valhalla + custom cost -data/ # Ben (P4) — OSM PBF + DEM extracts + Cesium tile cache -hardware/ # Kyle (P3) — Jetson bring-up -deploy/ # Kyle (P3) — systemd, rsync -models/ # Kyle (P3) — Gemma + Whisper + Piper, manifest -mesh/ # Kyle (P3) — stretch (WiFi-Direct / BLE) -security/ # Satriyo (P2) — threat model, parse-verify -crypto/ # Satriyo (P2) — ML-DSA / ML-KEM -infra/ # Satriyo (P2) — Jetson hardening, liboqs install -.github/ # Satriyo (P2) — CI workflows -.agents/ # Satriyo (P2) maintains — agent rules per lane -docs/ # shared — PRD, contracts, ADRs +```bash +curl -s -X POST http://localhost:8000/trust/check-url \ + -H 'Content-Type: application/json' \ + -d '{"url":"https://fema-aid-claim-example.com/login","context":"wildfire relief claim link"}' | jq . ``` -## Team TruePoint +Optional live API preflight: -| Member | Lane | Background | Pitch role | -|---|---|---|---| -| **P1 — Jon** (`@jdev-02`) | agent · ontology · voice (in+out) · eval · figma | Navy CWO, CS + AI (ontology), UI/UX | Floor support (AI questions) | -| **P2 — Satriyo** (`@aleens-labs`) | security · crypto · infra · CI | Indonesian Navy, cybersecurity | Floor support (security/PQC) | -| **P3 — Kyle** (`@khicks1724` / `@kylemhicks`) | hardware · deploy · models · mesh | USMC SIGINT, robotics. Brought the Jetson. Provided Cesium Ion token. | Presenter B (drives the demo) | -| **P4 — Ben** (`@benschwierking`) | atak · routing · data | USMC Combat Engineer, Mountain Warfare School | Presenter A (lead narrator) | +```bash +python scripts/test_live_apis.py +python scripts/test_live_apis.py --submit-urlscan +``` -Source of truth: [`team.yml`](team.yml). See [`docs/PRD.md`](docs/PRD.md) §13 for the full lane split. +`--submit-urlscan` is explicit because urlscan.io consumes quota and may load the target page. -## Phased build +## Legacy Tactical Mode -- **P1 — Web MVP** (Sat 1800): laptop + frontier API + CesiumJS (Cesium Ion). -- **P2 — Edge w/ frontier** (Sun 0200): Jetson + frontier API + signed CoT to ATAK. -- **P3 — Fully local HERO** (Sun 1000): Jetson + local Gemma + WiFi off + voice + signed CoT. -- **Stretch — Mesh + PQC reject**: phone + laptop + Nano on a mesh; inject-reject-accept demo. +The original tactical route agent is preserved: -## Demo +- `GET /health` +- `POST /plan` +- `POST /plan/approve` +- `POST /plan/verify` +- ML-DSA/Ed25519 fallback signing +- ATAK/CoT render-gate compatibility +- Jetson/Gemma offline deployment path -Hero scenario: *"Route me to the nearest freshwater source within 5km, on foot, covered terrain."* Voice prompt → Jetson → ATAK draws a signed blue line **and** Piper TTS speaks the rationale + waypoints in the operator's headset. WiFi off the entire time. +The tactical docs and contracts remain in [`docs/PRD.md`](docs/PRD.md), [`docs/contracts/agent_routing.schema.json`](docs/contracts/agent_routing.schema.json), and [`docs/contracts/cot_signed.md`](docs/contracts/cot_signed.md). -## License +## Repo Layout -MIT. (Per hackathon rules: open source at submission.) +```text +agent/ # legacy /plan plus v2 mission/trust endpoints +integrations/ # Google, US disaster, and Trust Shield adapters +data/ # sample scenarios and fixtures +security/ # threat model, parse-verify, security demos +crypto/ # ML-DSA / ML-KEM signing lane +atak/ # CoT bridge and render gate +routing/ # Valhalla/local routing lane +docs/ # PRD, contracts, v2 architecture, API inventory +``` ---- +## License -**For everything else, read the PRD.** +MIT. diff --git a/agent/app.py b/agent/app.py index 2479f56..07be4df 100644 --- a/agent/app.py +++ b/agent/app.py @@ -21,6 +21,13 @@ import structlog from fastapi import FastAPI, HTTPException +from agent.mission_orchestrator import ( + demo_bay_area_wildfire, + mission_api_status, + mission_health, + plan_mission, +) +from agent.mission_schemas import MissionPlanRequest, MissionPlanResponse from agent.orchestrator import PlanBlockedError, approve_plan, verify_plan_response from agent.orchestrator import plan as orchestrate_plan from agent.schemas import ( @@ -31,6 +38,18 @@ PlanResponse, PlanVerifyResponse, ) +from agent.tools.trust import ( + assess_message_trust, + assess_supply_request_trust, + assess_url, + trust_api_status, +) +from agent.trust_schemas import ( + MessageTrustRequest, + SupplyRequestTrustRequest, + TrustAssessment, + UrlCheckRequest, +) log = structlog.get_logger(__name__) @@ -65,7 +84,10 @@ async def _lifespan(_app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="TERA Agent", version="0.1.0", - description="Tactical Edge Route Agent. PRD: docs/PRD.md. By Team TruePoint.", + description=( + "TERA emergency logistics coordinator with legacy tactical route mode. " + "PRD: docs/PRD.md. By Team TruePoint." + ), lifespan=_lifespan, ) @@ -80,6 +102,11 @@ def health() -> dict[str, Any]: } +@app.get("/mission/health") +def mission_health_endpoint() -> dict[str, Any]: + return mission_health() + + @app.post("/plan", response_model=PlanResponse, responses={403: {"model": PlanBlocked}}) async def plan_endpoint( req: PlanRequest, @@ -134,6 +161,47 @@ async def plan_endpoint( raise HTTPException(status_code=503, detail=str(e)) from e +@app.post("/mission/plan", response_model=MissionPlanResponse) +async def mission_plan_endpoint(req: MissionPlanRequest) -> MissionPlanResponse: + try: + return plan_mission(req) + except RuntimeError as e: + log.exception("mission_plan_failed", error=str(e)) + raise HTTPException(status_code=503, detail=str(e)) from e + + +@app.get("/mission/api-status") +def mission_api_status_endpoint() -> dict[str, bool]: + return mission_api_status() + + +@app.get("/mission/demo/bay-area-wildfire", response_model=MissionPlanResponse) +def mission_demo_bay_area_wildfire_endpoint() -> MissionPlanResponse: + return demo_bay_area_wildfire() + + +@app.post("/trust/check-url", response_model=TrustAssessment) +async def trust_check_url_endpoint(req: UrlCheckRequest) -> TrustAssessment: + return assess_url(req.url, req.context) + + +@app.post("/trust/check-message", response_model=TrustAssessment) +async def trust_check_message_endpoint(req: MessageTrustRequest) -> TrustAssessment: + return assess_message_trust(req.message, req.source) + + +@app.post("/trust/check-supply-request", response_model=TrustAssessment) +async def trust_check_supply_request_endpoint( + req: SupplyRequestTrustRequest, +) -> TrustAssessment: + return assess_supply_request_trust(req.request) + + +@app.get("/trust/api-status") +def trust_api_status_endpoint() -> dict[str, bool]: + return trust_api_status() + + @app.post("/plan/approve", response_model=PlanApprovalResponse) async def plan_approve_endpoint(req: PlanApprovalRequest) -> PlanApprovalResponse: try: diff --git a/agent/mission_orchestrator.py b/agent/mission_orchestrator.py new file mode 100644 index 0000000..e12a8f8 --- /dev/null +++ b/agent/mission_orchestrator.py @@ -0,0 +1,569 @@ +"""Mission planner for TERA v2 emergency logistics. + +This module is deliberately separate from the legacy tactical `/plan` +orchestrator. The default mode is offline-first: use cached/sample mission +state and deterministic fallbacks unless a request explicitly enables +`use_live_apis`. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, cast + +from agent.mission_schemas import ( + Coord, + IncidentType, + InfrastructureSite, + MissionPlanRequest, + MissionPlanResponse, + MissionRecommendation, + Resource, + RouteCandidate, + Shelter, + Vehicle, + VehicleAssignment, +) +from agent.services import gemini_client, gemma_local +from agent.tools import logistics, misinformation, routing_google +from agent.tools import trust as trust_tools +from agent.trust_schemas import TrustAssessment +from integrations.common import http +from integrations.us_disaster import ( + airnow, + bridge_inventory, + eonet, + fema, + hifld, + nifc_wfigs, + nws, + sf511, + usgs_earthquake, + usgs_water, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] +SAMPLE_DIR = REPO_ROOT / "data" / "sample_scenarios" + +API_STATUS_KEYS = ( + "GOOGLE_MAPS_API_KEY", + "GOOGLE_PROJECT_ID", + "GOOGLE_ACCESS_TOKEN", + "AIRNOW_API_KEY", + "SF511_API_KEY", + "FIRMS_MAP_KEY", + "NREL_API_KEY", +) + + +def mission_api_status() -> dict[str, bool]: + return {name: bool(os.getenv(name)) for name in API_STATUS_KEYS} + + +def mission_health() -> dict[str, Any]: + return { + "status": "ok", + "product": "TERA emergency logistics v2", + "legacy_tactical_available": True, + "offline_default": True, + "gemma_local_model": gemma_local.fallback_model_name(), + } + + +def demo_bay_area_wildfire() -> MissionPlanResponse: + scenario = _load_sample("bay_area_wildfire") + request = _request_from_sample(scenario) + return plan_mission(request) + + +def plan_mission(request: MissionPlanRequest) -> MissionPlanResponse: + request, sample = _with_sample_defaults(request) + warnings: list[str] = [] + live = request.use_live_apis + if not live: + warnings.append("Live APIs disabled; using offline fallback and sample cache.") + + hazards: list[dict[str, Any]] = [] + fire_perimeters: list[dict[str, Any]] = [] + traffic_events: list[dict[str, Any]] = [] + air_quality: list[dict[str, Any]] = [] + bridge_assets: list[dict[str, Any]] = [] + infrastructure: list[InfrastructureSite] = [] + + hazards.extend(_sample_hazards(sample)) + if live: + hazards.extend(_collect_live_hazards(request, warnings)) + fire_perimeters.extend(_collect_live_fire_perimeters(request, warnings)) + air_quality.extend(_collect_live_air_quality(request, warnings)) + traffic_events.extend(_collect_live_traffic(warnings)) + bridge_assets.extend(_collect_live_bridges(warnings)) + infrastructure.extend(_collect_live_infrastructure(request, warnings)) + + if not infrastructure: + infrastructure.extend(_fallback_infrastructure(request)) + + route_target = _select_route_target(request.shelters) + route_candidates = _generate_routes(request.current, route_target, live, warnings) + route_risks = routing_google.score_route_safety( + route_candidates, + hazards=hazards, + traffic=traffic_events, + fire_perimeters=fire_perimeters, + air_quality=air_quality, + bridges=bridge_assets, + ) + trust_assessments, unverified_claims, blocked_or_needs_approval = _assess_trust_inputs( + request, + hazards + fire_perimeters, + live, + ) + + allocations = logistics.build_resource_allocation_plan( + request.vehicles, + request.resources, + request.shelters, + ) + optimized_assignments = _try_google_optimization(request, live, warnings) + if optimized_assignments and allocations: + allocations[0].assignments.extend(optimized_assignments) + + incident_summary = { + "incident_type": request.incident_type, + "area": request.area, + "prompt": request.prompt, + "hazard_count": len(hazards) + len(fire_perimeters), + "shelter_count": len(request.shelters), + "vehicle_count": len(request.vehicles), + "resource_count": len(request.resources), + "route_target": route_target.name if route_target else None, + } + recommendations = _recommend_actions(request, route_target, route_risks) + explanation = _explain(incident_summary, route_target, route_risks, allocations) + + return MissionPlanResponse( + incident_summary=incident_summary, + hazards=hazards + fire_perimeters, + critical_infrastructure=infrastructure, + route_candidates=route_candidates, + route_risks=route_risks, + resource_allocations=allocations, + recommended_actions=recommendations, + trust_assessments=trust_assessments, + unverified_claims=unverified_claims, + blocked_or_needs_approval=blocked_or_needs_approval, + explanation=explanation, + offline_fallback={ + "used": not live, + "sample_scenario": sample.get("name") if sample else None, + "gemma_local_configured": gemma_local.gemma_configured(), + "firebase_ready": False, + }, + warnings=warnings, + ) + + +def _load_sample(name: str) -> dict[str, Any]: + path = SAMPLE_DIR / f"{name}.json" + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise RuntimeError(f"Sample scenario {name} is not an object") + return cast(dict[str, Any], raw) + + +def _request_from_sample(sample: dict[str, Any]) -> MissionPlanRequest: + return MissionPlanRequest( + prompt=str(sample.get("mission") or "Plan emergency logistics mission."), + current=Coord.model_validate(sample["current"]), + incident_type=cast(IncidentType, str(sample.get("incident_type", "general"))), + area=str(sample.get("area", "CA")), + resources=[Resource.model_validate(item) for item in sample.get("resources", [])], + shelters=[Shelter.model_validate(item) for item in sample.get("shelters", [])], + vehicles=[Vehicle.model_validate(item) for item in sample.get("vehicles", [])], + constraints=[str(item) for item in sample.get("constraints", [])], + external_messages=[ + item for item in sample.get("external_messages", []) if isinstance(item, dict) + ], + supply_requests=[ + item for item in sample.get("supply_requests", []) if isinstance(item, dict) + ], + use_live_apis=False, + ) + + +def _with_sample_defaults( + request: MissionPlanRequest, +) -> tuple[MissionPlanRequest, dict[str, Any]]: + sample_name = { + "wildfire": "bay_area_wildfire", + "flood": "flood_response", + "earthquake": "earthquake_response", + }.get(request.incident_type, "bay_area_wildfire") + sample = _load_sample(sample_name) + updates: dict[str, Any] = {} + if not request.resources: + updates["resources"] = [ + Resource.model_validate(item) for item in sample.get("resources", []) + ] + if not request.shelters: + updates["shelters"] = [Shelter.model_validate(item) for item in sample.get("shelters", [])] + if not request.vehicles: + updates["vehicles"] = [Vehicle.model_validate(item) for item in sample.get("vehicles", [])] + if not request.constraints: + updates["constraints"] = [str(item) for item in sample.get("constraints", [])] + if not request.external_messages: + updates["external_messages"] = [ + item for item in sample.get("external_messages", []) if isinstance(item, dict) + ] + if not request.supply_requests: + updates["supply_requests"] = [ + item for item in sample.get("supply_requests", []) if isinstance(item, dict) + ] + if updates: + request = request.model_copy(update=updates) + return request, sample + + +def _sample_hazards(sample: dict[str, Any]) -> list[dict[str, Any]]: + hazards = sample.get("hazards", []) + if not isinstance(hazards, list): + return [] + return [item for item in hazards if isinstance(item, dict)] + + +def _collect_live_hazards( + request: MissionPlanRequest, + warnings: list[str], +) -> list[dict[str, Any]]: + collected: list[dict[str, Any]] = [] + try: + collected.extend( + item.model_dump() for item in nws.normalize_alerts(nws.get_active_alerts(request.area)) + ) + except Exception as exc: # noqa: BLE001 -- degrade, do not fail mission planning + warnings.append(f"NWS unavailable: {exc}") + try: + declarations = fema.get_fire_declarations_by_state(request.area) + collected.extend(item.model_dump() for item in fema.normalize_declarations(declarations)) + except Exception as exc: # noqa: BLE001 + warnings.append(f"FEMA unavailable: {exc}") + if request.incident_type == "earthquake": + try: + collected.extend( + item.model_dump() + for item in usgs_earthquake.normalize_earthquakes( + usgs_earthquake.get_significant_earthquakes_week() + ) + ) + except Exception as exc: # noqa: BLE001 + warnings.append(f"USGS earthquake unavailable: {exc}") + if request.incident_type == "flood": + try: + collected.extend( + item.model_dump() + for item in usgs_water.normalize_water_observations( + usgs_water.get_streamflow_and_gage_height(request.area.lower()) + ) + ) + except Exception as exc: # noqa: BLE001 + warnings.append(f"USGS water unavailable: {exc}") + if request.incident_type == "general": + try: + collected.extend( + item.model_dump() + for item in eonet.normalize_events(eonet.get_open_events(limit=10)) + ) + except Exception as exc: # noqa: BLE001 + warnings.append(f"EONET unavailable: {exc}") + return collected + + +def _collect_live_fire_perimeters( + request: MissionPlanRequest, + warnings: list[str], +) -> list[dict[str, Any]]: + if request.incident_type != "wildfire": + return [] + try: + return [ + item.model_dump() + for item in nifc_wfigs.normalize_fire_perimeters( + nifc_wfigs.get_current_fire_perimeters() + ) + ] + except Exception as exc: # noqa: BLE001 + warnings.append(f"WFIGS unavailable: {exc}") + return [] + + +def _collect_live_air_quality( + request: MissionPlanRequest, + warnings: list[str], +) -> list[dict[str, Any]]: + if request.incident_type not in {"wildfire", "heat"}: + return [] + try: + return [ + item.model_dump() + for item in airnow.normalize_air_quality( + airnow.get_current_air_quality(request.current.lat, request.current.lon) + ) + ] + except Exception as exc: # noqa: BLE001 + warnings.append(f"AirNow unavailable: {exc}") + return [] + + +def _collect_live_traffic(warnings: list[str]) -> list[dict[str, Any]]: + try: + return [ + item.model_dump() for item in sf511.normalize_traffic_events(sf511.get_traffic_events()) + ] + except Exception as exc: # noqa: BLE001 + warnings.append(f"SF511 unavailable: {exc}") + return [] + + +def _collect_live_bridges(warnings: list[str]) -> list[dict[str, Any]]: + try: + return [ + item.model_dump() + for item in bridge_inventory.normalize_bridges( + bridge_inventory.get_bridge_inventory_sample(limit=10) + ) + ] + except Exception as exc: # noqa: BLE001 + warnings.append(f"Bridge inventory unavailable: {exc}") + return [] + + +def _collect_live_infrastructure( + request: MissionPlanRequest, + warnings: list[str], +) -> list[InfrastructureSite]: + try: + return hifld.normalize_hospitals(hifld.get_hospitals_by_state(request.area)) + except Exception as exc: # noqa: BLE001 + warnings.append(f"HIFLD hospitals unavailable: {exc}") + return [] + + +def _fallback_infrastructure(request: MissionPlanRequest) -> list[InfrastructureSite]: + return [ + InfrastructureSite( + id="offline-hospital-1", + name="Offline Hospital B", + category="hospital", + city="San Francisco", + state=request.area, + site_type="hospital", + coord=Coord(lat=request.current.lat + 0.03, lon=request.current.lon - 0.04), + properties={"source": "offline fallback"}, + ) + ] + + +def _select_route_target(shelters: list[Shelter]) -> Shelter | None: + if not shelters: + return None + + def target_score(shelter: Shelter) -> float: + available_capacity = max(shelter.capacity - shelter.occupancy, 0) + smoke_bonus = {"low": 3, "moderate": 1, "unknown": 0, "high": -4}[shelter.smoke_risk] + need_score = sum(need.urgency for need in shelter.needs) + return available_capacity / 50 + smoke_bonus + need_score / 5 + + return max(shelters, key=target_score) + + +def _generate_routes( + origin: Coord, + target: Shelter | None, + live: bool, + warnings: list[str], +) -> list[RouteCandidate]: + if target is None: + return [] + if live and os.getenv("GOOGLE_MAPS_API_KEY"): + try: + return routing_google.generate_candidate_routes(origin, target.coord) + except (http.ApiError, ValueError) as exc: + warnings.append(f"Google Maps Routes unavailable: {exc}") + return [ + RouteCandidate( + id="offline-route-c", + provider="offline_fallback", + origin=origin, + destination=target.coord, + distance_m=8200, + duration_s=1320, + summary=( + f"Offline route candidate to {target.name}; avoid hazard corridors " + "when live data is absent." + ), + ) + ] + + +def _try_google_optimization( + request: MissionPlanRequest, + live: bool, + warnings: list[str], +) -> list[VehicleAssignment]: + if not live or not (os.getenv("GOOGLE_PROJECT_ID") and os.getenv("GOOGLE_ACCESS_TOKEN")): + return [] + vehicles = [{"label": vehicle.id} for vehicle in request.vehicles] + shipments = [{"label": shelter.id} for shelter in request.shelters] + try: + return logistics.optimize_dispatch_plan(vehicles, shipments) + except Exception as exc: # noqa: BLE001 + warnings.append(f"Google Route Optimization unavailable: {exc}") + return [] + + +def _assess_trust_inputs( + request: MissionPlanRequest, + active_hazards: list[dict[str, Any]], + live: bool, +) -> tuple[list[TrustAssessment], list[str], list[str]]: + assessments: list[TrustAssessment] = [] + unverified_claims: list[str] = [] + blocked_or_needs_approval: list[str] = [] + verified_shelter_names = [shelter.name for shelter in request.shelters] + + for url in request.external_links: + assessment = trust_tools.assess_url(url, use_live_providers=live) + assessments.append(assessment) + _collect_blocking_result(assessment, unverified_claims, blocked_or_needs_approval) + + for message_obj in request.external_messages: + message = str(message_obj.get("message") or "") + source = str(message_obj.get("source") or "unknown") + if not message: + continue + assessment = trust_tools.assess_message_trust( + message, + source=source, + use_live_providers=live, + ) + assessments.append(assessment) + _collect_blocking_result(assessment, unverified_claims, blocked_or_needs_approval) + shelter_claim = misinformation.detect_unverified_shelter_claim( + message, + request.shelters, + ) + if shelter_claim.signals: + assessments.append(shelter_claim) + _collect_blocking_result( + shelter_claim, + unverified_claims, + blocked_or_needs_approval, + ) + evacuation_claim = misinformation.detect_unverified_evacuation_instruction( + message, + active_hazards, + ) + if evacuation_claim.signals: + assessments.append(evacuation_claim) + _collect_blocking_result( + evacuation_claim, + unverified_claims, + blocked_or_needs_approval, + ) + + for instruction in request.shelter_instructions: + assessment = misinformation.detect_unverified_shelter_claim( + instruction, + request.shelters, + ) + assessments.append(assessment) + _collect_blocking_result(assessment, unverified_claims, blocked_or_needs_approval) + + for supply_request in request.supply_requests: + enriched = dict(supply_request) + enriched["verified_shelters"] = verified_shelter_names + assessment = trust_tools.assess_supply_request_trust(enriched) + assessments.append(assessment) + _collect_blocking_result(assessment, unverified_claims, blocked_or_needs_approval) + + return assessments, unverified_claims, blocked_or_needs_approval + + +def _collect_blocking_result( + assessment: TrustAssessment, + unverified_claims: list[str], + blocked_or_needs_approval: list[str], +) -> None: + if not assessment.requires_human_approval: + return + unverified_claims.append(assessment.value) + blocked_or_needs_approval.append( + f"{assessment.input_type}:{assessment.risk_level}:{assessment.recommendation}" + ) + + +def _recommend_actions( + request: MissionPlanRequest, + target: Shelter | None, + route_risks: list[Any], +) -> list[MissionRecommendation]: + actions: list[MissionRecommendation] = [] + if target: + actions.append( + MissionRecommendation( + action=f"Prioritize {target.name} as the logistics destination.", + priority="high", + rationale=( + "It has usable capacity and lower exposure than overloaded " + "or smoke-heavy shelters." + ), + evidence=[target.id, target.smoke_risk], + ) + ) + if route_risks: + safest = min(route_risks, key=lambda risk: risk.score) + actions.append( + MissionRecommendation( + action=f"Use {safest.route_id} unless live hazard data contradicts it.", + priority="medium", + rationale=f"Current route risk is {safest.risk_level} with score {safest.score}.", + evidence=safest.factors, + ) + ) + if request.incident_type == "wildfire": + actions.append( + MissionRecommendation( + action="Send water and N95 masks first.", + priority="critical", + rationale=( + "Wildfire logistics should reduce dehydration and smoke exposure " + "before comfort items." + ), + evidence=["incident_type:wildfire"], + ) + ) + return actions + + +def _explain( + incident_summary: dict[str, Any], + target: Shelter | None, + route_risks: list[Any], + allocations: list[Any], +) -> str: + base = gemini_client.explain_decision(incident_summary) + target_text = f"Selected {target.name} as the primary logistics destination. " if target else "" + risk_text = "" + if route_risks: + safest = min(route_risks, key=lambda risk: risk.score) + risk_text = ( + f"Recommended {safest.route_id} because available hazard, traffic, " + f"and infrastructure data score it {safest.risk_level}. " + ) + allocation_count = sum(len(allocation.assignments) for allocation in allocations) + return ( + f"{base} {target_text}{risk_text}" + f"Resource plan includes {allocation_count} vehicle assignment(s) " + "and remains usable offline." + ).strip() diff --git a/agent/mission_schemas.py b/agent/mission_schemas.py new file mode 100644 index 0000000..f344c7b --- /dev/null +++ b/agent/mission_schemas.py @@ -0,0 +1,258 @@ +"""Schemas for TERA v2 emergency logistics missions. + +These models sit beside the legacy tactical `/plan` contract. They do not +replace the ATAK/CoT path; they add a humanitarian disaster-response layer for +offline-first coordination, routing, resource allocation, and explanation. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from agent.trust_schemas import TrustAssessment + +IncidentType = Literal["wildfire", "flood", "earthquake", "storm", "heat", "general"] + + +class Coord(BaseModel): + lat: float = Field(..., ge=-90, le=90) + lon: float = Field(..., ge=-180, le=180) + + +class HazardAlert(BaseModel): + id: str + source: str = "NWS" + event: str + severity: str | None = None + urgency: str | None = None + certainty: str | None = None + area_desc: str | None = None + instruction: str | None = None + geometry: dict[str, Any] | None = None + + +class HazardPolygon(BaseModel): + id: str + source: str + name: str + incident_type: str | None = None + geometry: dict[str, Any] | None = None + properties: dict[str, Any] = Field(default_factory=dict) + + +class InfrastructureSite(BaseModel): + id: str + name: str + category: str + address: str | None = None + city: str | None = None + state: str | None = None + site_type: str | None = None + coord: Coord | None = None + properties: dict[str, Any] = Field(default_factory=dict) + + +class DisasterDeclaration(BaseModel): + id: str + state: str | None = None + county: str | None = None + incident_type: str | None = None + title: str | None = None + declaration_date: str | None = None + incident_begin_date: str | None = None + + +class AirQualityObservation(BaseModel): + parameter: str + aqi: int | None = None + category: str | None = None + reporting_area: str | None = None + latitude: float | None = None + longitude: float | None = None + observed_at: str | None = None + + +class TrafficEvent(BaseModel): + id: str + source: str = "SF511" + event_type: str | None = None + description: str | None = None + severity: str | None = None + coord: Coord | None = None + properties: dict[str, Any] = Field(default_factory=dict) + + +class FireDetection(BaseModel): + latitude: float + longitude: float + brightness: float | None = None + confidence: str | None = None + satellite: str | None = None + acquired_at: str | None = None + properties: dict[str, Any] = Field(default_factory=dict) + + +class EarthquakeEvent(BaseModel): + id: str + magnitude: float | None = None + place: str | None = None + time: int | None = None + coord: Coord | None = None + depth_km: float | None = None + url: str | None = None + + +class WaterObservation(BaseModel): + site_id: str + site_name: str | None = None + parameter: str | None = None + value: float | None = None + unit: str | None = None + observed_at: str | None = None + + +class BridgeAsset(BaseModel): + id: str + name: str | None = None + state: str | None = None + county: str | None = None + route: str | None = None + condition: str | None = None + properties: dict[str, Any] = Field(default_factory=dict) + + +class FuelStation(BaseModel): + id: str + name: str + fuel_types: list[str] = Field(default_factory=list) + address: str | None = None + coord: Coord | None = None + distance_miles: float | None = None + + +class NaturalEvent(BaseModel): + id: str + title: str + category: str | None = None + source: str = "NASA EONET" + geometry: dict[str, Any] | None = None + properties: dict[str, Any] = Field(default_factory=dict) + + +class HumanitarianReport(BaseModel): + id: str + title: str + date: str | None = None + url: str | None = None + country: str | None = None + disaster: str | None = None + + +class Resource(BaseModel): + name: str + quantity: float = Field(..., ge=0) + unit: str + priority: int = Field(default=3, ge=1, le=5) + + +class SupplyNeed(BaseModel): + resource: str + quantity: float = Field(..., ge=0) + unit: str = "units" + urgency: int = Field(default=3, ge=1, le=5) + + +class Vehicle(BaseModel): + id: str + name: str + capacity: dict[str, float] = Field(default_factory=dict) + current: Coord | None = None + status: Literal["available", "assigned", "standby", "offline"] = "available" + + +class Shelter(BaseModel): + id: str + name: str + coord: Coord + capacity: int = Field(..., ge=0) + occupancy: int = Field(..., ge=0) + needs: list[SupplyNeed] = Field(default_factory=list) + smoke_risk: Literal["low", "moderate", "high", "unknown"] = "unknown" + notes: str | None = None + + +class RouteCandidate(BaseModel): + id: str + provider: str + origin: Coord | None = None + destination: Coord | None = None + distance_m: float | None = None + duration_s: float | None = None + polyline: str | None = None + route_geojson: dict[str, Any] | None = None + summary: str | None = None + + +class RouteRisk(BaseModel): + route_id: str + risk_level: Literal["low", "moderate", "high", "unknown"] = "unknown" + score: float = Field(default=0, ge=0) + factors: list[str] = Field(default_factory=list) + + +class VehicleAssignment(BaseModel): + vehicle_id: str + destination_id: str | None = None + resources: list[Resource] = Field(default_factory=list) + route_id: str | None = None + status: Literal["planned", "optimized", "fallback"] = "planned" + rationale: str | None = None + + +class ResourceAllocation(BaseModel): + shelter_id: str + priority_score: float = Field(default=0, ge=0) + assignments: list[VehicleAssignment] = Field(default_factory=list) + unmet_needs: list[SupplyNeed] = Field(default_factory=list) + + +class MissionRecommendation(BaseModel): + action: str + priority: Literal["low", "medium", "high", "critical"] = "medium" + rationale: str + evidence: list[str] = Field(default_factory=list) + + +class MissionPlanRequest(BaseModel): + prompt: str = Field(..., min_length=1, max_length=4000) + current: Coord + incident_type: IncidentType = "general" + area: str = Field(default="CA", min_length=2, max_length=32) + resources: list[Resource] = Field(default_factory=list) + shelters: list[Shelter] = Field(default_factory=list) + vehicles: list[Vehicle] = Field(default_factory=list) + constraints: list[str] = Field(default_factory=list) + field_reports: list[dict[str, Any]] = Field(default_factory=list) + external_links: list[str] = Field(default_factory=list) + external_messages: list[dict[str, Any]] = Field(default_factory=list) + shelter_instructions: list[str] = Field(default_factory=list) + supply_requests: list[dict[str, Any]] = Field(default_factory=list) + use_live_apis: bool = False + + +class MissionPlanResponse(BaseModel): + incident_summary: dict[str, Any] = Field(default_factory=dict) + hazards: list[dict[str, Any]] = Field(default_factory=list) + critical_infrastructure: list[InfrastructureSite] = Field(default_factory=list) + route_candidates: list[RouteCandidate] = Field(default_factory=list) + route_risks: list[RouteRisk] = Field(default_factory=list) + resource_allocations: list[ResourceAllocation] = Field(default_factory=list) + recommended_actions: list[MissionRecommendation] = Field(default_factory=list) + trust_assessments: list[TrustAssessment] = Field(default_factory=list) + unverified_claims: list[str] = Field(default_factory=list) + blocked_or_needs_approval: list[str] = Field(default_factory=list) + explanation: str + offline_fallback: dict[str, Any] = Field(default_factory=dict) + warnings: list[str] = Field(default_factory=list) diff --git a/agent/services/__init__.py b/agent/services/__init__.py new file mode 100644 index 0000000..05587b5 --- /dev/null +++ b/agent/services/__init__.py @@ -0,0 +1 @@ +"""Service wrappers for TERA v2 emergency response.""" diff --git a/agent/services/firebase_state.py b/agent/services/firebase_state.py new file mode 100644 index 0000000..18dfe8a --- /dev/null +++ b/agent/services/firebase_state.py @@ -0,0 +1,30 @@ +"""Offline-first Firebase/local state facade.""" + +from __future__ import annotations + +from typing import Any + +from integrations.google.firebase import firebase_configured, firebase_status + + +class FirebaseState: + """Small facade for demo shared state. + + The real Firebase SDK can replace this without changing mission planner + call sites. Until then, local memory demonstrates the offline cache story. + """ + + def __init__(self) -> None: + self._store: dict[str, Any] = {} + + def configured(self) -> bool: + return firebase_configured() + + def status(self) -> dict[str, bool]: + return firebase_status() + + def put(self, key: str, value: Any) -> None: + self._store[key] = value + + def get(self, key: str) -> Any: + return self._store.get(key) diff --git a/agent/services/gemini_client.py b/agent/services/gemini_client.py new file mode 100644 index 0000000..3d3a0ff --- /dev/null +++ b/agent/services/gemini_client.py @@ -0,0 +1,27 @@ +"""Gemini service wrapper for v2 explanations. + +The mission planner can run without this service. When keys are absent, it +returns deterministic text so demos remain offline-first and CI has no network +dependency. +""" + +from __future__ import annotations + +import os +from typing import Any + + +def gemini_configured() -> bool: + return bool(os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")) + + +def explain_decision(summary: dict[str, Any]) -> str: + if not gemini_configured(): + return ( + "Offline explanation: TERA prioritized life-safety infrastructure, " + "available shelter capacity, hazard avoidance, and vehicle/resource constraints." + ) + return ( + "Gemini explanation placeholder: live Gemini can transform this structured " + f"mission summary into an operator briefing. Sources: {sorted(summary.keys())}." + ) diff --git a/agent/services/gemma_local.py b/agent/services/gemma_local.py new file mode 100644 index 0000000..f370729 --- /dev/null +++ b/agent/services/gemma_local.py @@ -0,0 +1,13 @@ +"""Local Gemma/Ollama fallback status helpers.""" + +from __future__ import annotations + +import os + + +def gemma_configured() -> bool: + return bool(os.getenv("OLLAMA_HOST") or os.getenv("TERA_LOCAL_MODEL")) + + +def fallback_model_name() -> str: + return os.getenv("TERA_LOCAL_MODEL", os.getenv("OLLAMA_MODEL", "gemma3:latest")) diff --git a/agent/tools/air_quality.py b/agent/tools/air_quality.py new file mode 100644 index 0000000..70f9442 --- /dev/null +++ b/agent/tools/air_quality.py @@ -0,0 +1,22 @@ +"""Air-quality tools for smoke exposure scoring.""" + +from __future__ import annotations + +from agent.mission_schemas import AirQualityObservation +from integrations.us_disaster import airnow + + +def get_smoke_exposure(lat: float, lon: float) -> list[AirQualityObservation]: + raw = airnow.get_current_air_quality(lat, lon) + return airnow.normalize_air_quality(raw) + + +def score_air_quality_risk(observations: list[AirQualityObservation]) -> str: + max_aqi = max((obs.aqi or 0 for obs in observations), default=0) + if max_aqi >= 151: + return "high" + if max_aqi >= 101: + return "moderate" + if max_aqi > 0: + return "low" + return "unknown" diff --git a/agent/tools/disaster.py b/agent/tools/disaster.py new file mode 100644 index 0000000..5471f2b --- /dev/null +++ b/agent/tools/disaster.py @@ -0,0 +1,50 @@ +"""Higher-level disaster context tools for the v2 mission planner.""" + +from __future__ import annotations + +from typing import Any + +from integrations.us_disaster import eonet, fema, nifc_wfigs, nws, usgs_earthquake, usgs_water + + +def get_active_hazards(area: str = "CA") -> list[dict[str, Any]]: + raw = nws.get_active_alerts(area) + return [alert.model_dump() for alert in nws.normalize_alerts(raw)] + + +def collect_disaster_context( + area: str = "CA", + incident_type: str = "wildfire", +) -> dict[str, Any]: + context: dict[str, Any] = {"area": area, "incident_type": incident_type, "sources": {}} + context["sources"]["nws_alerts"] = get_active_hazards(area) + context["sources"]["fema_declarations"] = [ + item.model_dump() + for item in fema.normalize_declarations(fema.get_declarations_by_state(area)) + ] + if incident_type == "wildfire": + context["sources"]["fire_perimeters"] = [ + item.model_dump() + for item in nifc_wfigs.normalize_fire_perimeters( + nifc_wfigs.get_current_fire_perimeters() + ) + ] + if incident_type == "earthquake": + context["sources"]["earthquakes"] = [ + item.model_dump() + for item in usgs_earthquake.normalize_earthquakes( + usgs_earthquake.get_significant_earthquakes_week() + ) + ] + if incident_type == "flood": + context["sources"]["water_observations"] = [ + item.model_dump() + for item in usgs_water.normalize_water_observations( + usgs_water.get_streamflow_and_gage_height(area.lower()) + ) + ] + if incident_type == "general": + context["sources"]["global_events"] = [ + item.model_dump() for item in eonet.normalize_events(eonet.get_open_events(limit=10)) + ] + return context diff --git a/agent/tools/infrastructure.py b/agent/tools/infrastructure.py new file mode 100644 index 0000000..11abbb7 --- /dev/null +++ b/agent/tools/infrastructure.py @@ -0,0 +1,26 @@ +"""Infrastructure discovery tools for v2 emergency response.""" + +from __future__ import annotations + +from typing import Any + +from integrations.us_disaster import hifld + + +def find_nearby_hospitals(state: str = "CA") -> list[dict[str, Any]]: + raw = hifld.get_hospitals_by_state(state) + return [site.model_dump() for site in hifld.normalize_hospitals(raw)] + + +def list_critical_infrastructure_layers() -> list[dict[str, Any]]: + return hifld.list_critical_infrastructure_layers() + + +def find_staging_candidates() -> list[dict[str, Any]]: + layers = list_critical_infrastructure_layers() + keywords = ("fire", "ems", "shelter", "school", "emergency", "eoc", "staging") + return [ + layer + for layer in layers + if any(keyword in str(layer.get("name", "")).lower() for keyword in keywords) + ] diff --git a/agent/tools/logistics.py b/agent/tools/logistics.py new file mode 100644 index 0000000..f60b332 --- /dev/null +++ b/agent/tools/logistics.py @@ -0,0 +1,117 @@ +"""Deterministic logistics planning tools for degraded connectivity.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import ( + Resource, + ResourceAllocation, + Shelter, + SupplyNeed, + Vehicle, + VehicleAssignment, +) +from integrations.google import route_optimization + + +def score_shelter_needs( + shelters: list[Shelter], + hazards: list[dict[str, Any]], + air_quality: list[dict[str, Any]], +) -> dict[str, float]: + scores: dict[str, float] = {} + hazard_pressure = min(len(hazards), 5) + high_smoke = any(item.get("aqi", 0) >= 151 for item in air_quality) + for shelter in shelters: + occupancy_ratio = shelter.occupancy / shelter.capacity if shelter.capacity else 1.0 + need_pressure = sum(need.urgency for need in shelter.needs) + score = occupancy_ratio * 5 + need_pressure + hazard_pressure + if shelter.smoke_risk == "high" or high_smoke: + score += 2 + scores[shelter.id] = round(score, 2) + return scores + + +def build_resource_allocation_plan( + vehicles: list[Vehicle], + resources: list[Resource], + shelters: list[Shelter], +) -> list[ResourceAllocation]: + available_vehicles = [ + vehicle for vehicle in vehicles if vehicle.status in {"available", "standby"} + ] + inventory = {resource.name.lower(): resource.model_copy() for resource in resources} + allocations: list[ResourceAllocation] = [] + vehicle_index = 0 + for shelter in sorted(shelters, key=_shelter_pressure, reverse=True): + assignments: list[VehicleAssignment] = [] + unmet: list[SupplyNeed] = [] + shipment: list[Resource] = [] + for need in shelter.needs: + stock = inventory.get(need.resource.lower()) + if stock is None or stock.quantity <= 0: + unmet.append(need) + continue + amount = min(stock.quantity, need.quantity) + stock.quantity -= amount + shipment.append(Resource(name=need.resource, quantity=amount, unit=need.unit)) + if amount < need.quantity: + unmet.append( + SupplyNeed( + resource=need.resource, + quantity=need.quantity - amount, + unit=need.unit, + urgency=need.urgency, + ) + ) + if shipment and available_vehicles: + vehicle = available_vehicles[vehicle_index % len(available_vehicles)] + vehicle_index += 1 + assignments.append( + VehicleAssignment( + vehicle_id=vehicle.id, + destination_id=shelter.id, + resources=shipment, + status="fallback", + rationale=( + "Deterministic offline allocator matched highest-priority shelter needs." + ), + ) + ) + allocations.append( + ResourceAllocation( + shelter_id=shelter.id, + priority_score=_shelter_pressure(shelter), + assignments=assignments, + unmet_needs=unmet, + ) + ) + return allocations + + +def optimize_dispatch_plan( + vehicles: list[dict[str, Any]], + shipments: list[dict[str, Any]], +) -> list[VehicleAssignment]: + request = route_optimization.build_basic_supply_dispatch_request(vehicles, shipments) + raw = route_optimization.optimize_tours(request) + return route_optimization.normalize_optimized_routes(raw) + + +def recommend_staging_point( + infrastructure: list[dict[str, Any]], + hazards: list[dict[str, Any]], +) -> dict[str, Any] | None: + if not infrastructure: + return None + hazard_text = str(hazards).lower() + for site in infrastructure: + if str(site.get("name", "")).lower() not in hazard_text: + return site + return infrastructure[0] + + +def _shelter_pressure(shelter: Shelter) -> float: + occupancy_ratio = shelter.occupancy / shelter.capacity if shelter.capacity else 1.0 + return round(occupancy_ratio * 5 + sum(need.urgency for need in shelter.needs), 2) diff --git a/agent/tools/misinformation.py b/agent/tools/misinformation.py new file mode 100644 index 0000000..e72b625 --- /dev/null +++ b/agent/tools/misinformation.py @@ -0,0 +1,89 @@ +"""Misinformation and unverified-claim checks for disaster operations.""" + +from __future__ import annotations + +from typing import Any + +from agent.trust_schemas import RiskSignal, TrustAssessment +from integrations.security.url_risk import aggregate_security_results + + +def detect_unverified_shelter_claim( + message: str, + verified_shelters: list[Any], +) -> TrustAssessment: + names = {_name(item).lower() for item in verified_shelters if _name(item)} + lower = message.lower() + signals: list[RiskSignal] = [] + if "shelter" in lower and names and not any(name in lower for name in names): + signals.append( + RiskSignal( + source="misinformation", + severity="medium", + code="UNVERIFIED_SHELTER_CLAIM", + message="Shelter claim is not found in the verified shelter list.", + ) + ) + return aggregate_security_results( + input_type="message", + value=message, + signals=signals, + checked_sources=["verified_shelter_list"], + skipped_sources=[], + ) + + +def detect_unverified_evacuation_instruction( + message: str, + active_alerts: list[Any], +) -> TrustAssessment: + lower = message.lower() + signals: list[RiskSignal] = [] + has_evacuation_claim = any(word in lower for word in ("evacuate", "evacuation", "leave now")) + if has_evacuation_claim and not active_alerts: + signals.append( + RiskSignal( + source="misinformation", + severity="high", + code="UNVERIFIED_EVACUATION_INSTRUCTION", + message="Evacuation instruction has no matching active official alert in context.", + ) + ) + return aggregate_security_results( + input_type="message", + value=message, + signals=signals, + checked_sources=["active_alerts"], + skipped_sources=[], + ) + + +def flag_conflicting_field_report( + report: dict[str, Any], + current_state: dict[str, Any], +) -> TrustAssessment: + signals: list[RiskSignal] = [] + report_status = report.get("status") + state_status = current_state.get(str(report.get("asset_id"))) + if state_status is not None and report_status is not None and report_status != state_status: + signals.append( + RiskSignal( + source="misinformation", + severity="medium", + code="CONFLICTING_FIELD_REPORT", + message="Field report conflicts with the current verified state.", + ) + ) + return aggregate_security_results( + input_type="field_report", + value=str(report), + signals=signals, + checked_sources=["current_state"], + skipped_sources=[], + ) + + +def _name(item: Any) -> str: + if isinstance(item, dict): + return str(item.get("name") or "") + return str(getattr(item, "name", "") or "") diff --git a/agent/tools/routing_google.py b/agent/tools/routing_google.py new file mode 100644 index 0000000..bf65b98 --- /dev/null +++ b/agent/tools/routing_google.py @@ -0,0 +1,62 @@ +"""Route generation and safety scoring tools.""" + +from __future__ import annotations + +from typing import Any, Literal + +from agent.mission_schemas import Coord, RouteCandidate, RouteRisk +from integrations.google import maps_routes + + +def generate_candidate_routes(origin: Coord, destination: Coord) -> list[RouteCandidate]: + raw = maps_routes.compute_routes(origin, destination, alternatives=True) + routes = maps_routes.normalize_routes(raw) + return [ + route.model_copy(update={"origin": origin, "destination": destination}) for route in routes + ] + + +def score_route_safety( + routes: list[RouteCandidate], + hazards: list[dict[str, Any]], + traffic: list[dict[str, Any]], + fire_perimeters: list[dict[str, Any]], + air_quality: list[dict[str, Any]], + bridges: list[dict[str, Any]], +) -> list[RouteRisk]: + risks: list[RouteRisk] = [] + hazard_count = len(hazards) + len(fire_perimeters) + traffic_count = len(traffic) + bridge_count = len(bridges) + high_aqi = any(_aqi_value(item) >= 151 for item in air_quality) + for route in routes: + score = float(hazard_count * 2 + traffic_count + bridge_count) + factors: list[str] = [] + if hazard_count: + factors.append(f"{hazard_count} active hazard overlays") + if traffic_count: + factors.append(f"{traffic_count} road events") + if bridge_count: + factors.append("bridge inventory requires heavy-vehicle review") + if high_aqi: + score += 2 + factors.append("AQI indicates unhealthy smoke exposure") + risk_level: Literal["low", "moderate", "high", "unknown"] = "low" + if score >= 5: + risk_level = "high" + elif score >= 2: + risk_level = "moderate" + risks.append( + RouteRisk( + route_id=route.id, + score=score, + risk_level=risk_level, + factors=factors or ["No high-confidence hazard conflicts in available data"], + ) + ) + return risks + + +def _aqi_value(item: dict[str, Any]) -> int: + value = item.get("aqi") + return value if isinstance(value, int) else 0 diff --git a/agent/tools/traffic.py b/agent/tools/traffic.py new file mode 100644 index 0000000..ed5dd0f --- /dev/null +++ b/agent/tools/traffic.py @@ -0,0 +1,26 @@ +"""Traffic-event tools for route risk scoring.""" + +from __future__ import annotations + +from typing import Any + +from integrations.us_disaster import sf511 + + +def get_road_events() -> list[dict[str, Any]]: + raw = sf511.get_traffic_events() + return [event.model_dump() for event in sf511.normalize_traffic_events(raw)] + + +def score_traffic_risk(route: dict[str, Any], events: list[dict[str, Any]]) -> str: + route_text = str(route).lower() + relevant = [ + event + for event in events + if any(token in route_text for token in str(event.get("description", "")).lower().split()) + ] + if len(relevant) >= 3: + return "high" + if relevant: + return "moderate" + return "low" diff --git a/agent/tools/trust.py b/agent/tools/trust.py new file mode 100644 index 0000000..41b8db0 --- /dev/null +++ b/agent/tools/trust.py @@ -0,0 +1,169 @@ +"""TERA Trust Shield tools for disaster fraud and misinformation protection.""" + +from __future__ import annotations + +import os +import re +from typing import Any + +from agent.trust_schemas import RiskSignal, TrustAssessment +from integrations.security import url_risk + +URL_PATTERN = re.compile(r"https?://[^\s)>\"]+", re.IGNORECASE) +TRUST_API_KEYS = ("GOOGLE_SAFE_BROWSING_API_KEY", "VT_API_KEY", "URLSCAN_API_KEY") + + +def trust_api_status() -> dict[str, bool]: + return {name: bool(os.getenv(name)) for name in TRUST_API_KEYS} + + +def assess_url( + url: str, + context: str | None = None, + *, + use_live_providers: bool = True, +) -> TrustAssessment: + return url_risk.assess_url_risk(url, context, use_live_providers=use_live_providers) + + +def assess_message_trust( + message: str, + source: str | None = None, + *, + use_live_providers: bool = True, +) -> TrustAssessment: + signals: list[RiskSignal] = [] + checked_sources = ["message_heuristic"] + skipped_sources: list[str] = [] + urls = _extract_urls(message) + for url in urls: + assessment = assess_url(url, message, use_live_providers=use_live_providers) + signals.extend(assessment.signals) + checked_sources.extend(assessment.checked_sources) + skipped_sources.extend(assessment.skipped_sources) + lower = message.lower() + if source is None or source.lower() in {"unknown", "unknown_sms", "untrusted"}: + signals.append( + RiskSignal( + source="message_heuristic", + severity="medium", + code="UNKNOWN_SOURCE", + message="Message source is unknown or untrusted.", + ) + ) + if any(word in lower for word in ("urgent", "login", "verify", "donate", "claim")): + signals.append( + RiskSignal( + source="message_heuristic", + severity="low", + code="PRESSURE_LANGUAGE", + message="Message uses urgency or credential/financial action language.", + ) + ) + return url_risk.aggregate_security_results( + input_type="message", + value=message, + signals=signals, + checked_sources=checked_sources, + skipped_sources=skipped_sources, + ) + + +def assess_supply_request_trust(request: dict[str, Any]) -> TrustAssessment: + signals: list[RiskSignal] = [] + source = str(request.get("source") or "unknown") + destination = str(request.get("destination") or "") + verified_shelters = { + str(item).lower() for item in request.get("verified_shelters", []) if item is not None + } + if source.lower() in {"unknown", "untrusted", "sms", "unknown_sms"}: + signals.append( + RiskSignal( + source="supply_request_heuristic", + severity="medium", + code="UNKNOWN_REQUEST_SOURCE", + message="Supply request source is not verified.", + ) + ) + if destination and verified_shelters and destination.lower() not in verified_shelters: + signals.append( + RiskSignal( + source="supply_request_heuristic", + severity="high", + code="UNVERIFIED_DESTINATION", + message="Supply request destination is not in the verified shelter list.", + ) + ) + elif destination.lower().startswith("unverified"): + signals.append( + RiskSignal( + source="supply_request_heuristic", + severity="high", + code="UNVERIFIED_DESTINATION", + message="Supply request names an unverified destination.", + ) + ) + if str(request.get("urgency", "")).lower() == "critical": + signals.append( + RiskSignal( + source="supply_request_heuristic", + severity="low", + code="CRITICAL_URGENCY_CLAIM", + message="Critical urgency claim should be confirmed before dispatch.", + ) + ) + if _large_medical_kit_request(request): + signals.append( + RiskSignal( + source="supply_request_heuristic", + severity="medium", + code="UNUSUAL_SUPPLY_VOLUME", + message="Requested supply quantity is unusually large for an unverified request.", + ) + ) + return url_risk.aggregate_security_results( + input_type="supply_request", + value=str(request), + signals=signals, + checked_sources=["supply_request_heuristic"], + skipped_sources=[], + ) + + +def verify_against_official_sources( + message: str, + official_context: dict[str, Any], +) -> TrustAssessment: + signals: list[RiskSignal] = [] + official_terms = [str(item).lower() for item in official_context.get("verified_terms", [])] + lower = message.lower() + if official_terms and not any(term in lower for term in official_terms): + signals.append( + RiskSignal( + source="official_source_match", + severity="medium", + code="NOT_FOUND_IN_OFFICIAL_CONTEXT", + message="Message claim was not found in the provided official context.", + ) + ) + return url_risk.aggregate_security_results( + input_type="message", + value=message, + signals=signals, + checked_sources=["official_source_match"], + skipped_sources=[], + ) + + +def _extract_urls(message: str) -> list[str]: + return [match.rstrip(".,;") for match in URL_PATTERN.findall(message)] + + +def _large_medical_kit_request(request: dict[str, Any]) -> bool: + items = request.get("requested_items") + if not isinstance(items, dict): + return False + for key, value in items.items(): + if "medical" in str(key).lower() and isinstance(value, int | float) and value >= 100: + return True + return False diff --git a/agent/trust_schemas.py b/agent/trust_schemas.py new file mode 100644 index 0000000..63ad7ec --- /dev/null +++ b/agent/trust_schemas.py @@ -0,0 +1,89 @@ +"""Schemas for TERA Trust Shield disaster-fraud protection.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +RiskSeverity = Literal["info", "low", "medium", "high", "critical"] +TrustInputType = Literal["url", "message", "field_report", "supply_request"] +RiskLevel = Literal["low", "medium", "high", "critical"] + + +class RiskSignal(BaseModel): + source: str + severity: RiskSeverity + code: str + message: str + + +class UrlThreatResult(BaseModel): + url: str + checked: bool + provider: str + matched: bool + threat_types: list[str] = Field(default_factory=list) + raw: dict[str, Any] | None = None + + +class UrlReputationResult(BaseModel): + url: str + provider: str + malicious: int | None = None + suspicious: int | None = None + harmless: int | None = None + undetected: int | None = None + raw: dict[str, Any] | None = None + + +class DomainReputationResult(BaseModel): + domain: str + provider: str + reputation: int | None = None + categories: dict[str, Any] | None = None + raw: dict[str, Any] | None = None + + +class DomainMetadata(BaseModel): + domain: str + registrar: str | None = None + created_at: str | None = None + updated_at: str | None = None + expires_at: str | None = None + raw: dict[str, Any] | None = None + + +class UrlScanResult(BaseModel): + url: str + scan_id: str | None = None + verdict: str | None = None + contacted_domains: list[str] = Field(default_factory=list) + screenshot_url: str | None = None + raw: dict[str, Any] | None = None + + +class TrustAssessment(BaseModel): + input_type: TrustInputType + value: str + risk_score: int = Field(..., ge=0, le=100) + risk_level: RiskLevel + signals: list[RiskSignal] = Field(default_factory=list) + recommendation: str + checked_sources: list[str] = Field(default_factory=list) + skipped_sources: list[str] = Field(default_factory=list) + requires_human_approval: bool + + +class UrlCheckRequest(BaseModel): + url: str = Field(..., min_length=1, max_length=2048) + context: str | None = Field(default=None, max_length=2000) + + +class MessageTrustRequest(BaseModel): + message: str = Field(..., min_length=1, max_length=4000) + source: str | None = Field(default=None, max_length=200) + + +class SupplyRequestTrustRequest(BaseModel): + request: dict[str, Any] diff --git a/data/sample_scenarios/bay_area_wildfire.json b/data/sample_scenarios/bay_area_wildfire.json new file mode 100644 index 0000000..4b47d93 --- /dev/null +++ b/data/sample_scenarios/bay_area_wildfire.json @@ -0,0 +1,179 @@ +{ + "name": "Bay Area Wildfire Logistics", + "incident_type": "wildfire", + "area": "CA", + "current": { + "lat": 37.7749, + "lon": -122.4194 + }, + "mission": "Route water, N95 masks, medical kits, and blankets to Shelter West while avoiding wildfire perimeter, smoke-heavy corridors, and traffic closures.", + "hazards": [ + { + "id": "sample-red-flag-ca", + "source": "offline-sample", + "event": "Red Flag Warning", + "severity": "Severe", + "area_desc": "Bay Area ridge lines and wildland urban interface", + "instruction": "Avoid exposed ridgelines and smoke-heavy corridors." + }, + { + "id": "sample-fire-perimeter-east", + "source": "offline-sample", + "name": "East Ridge Fire Perimeter", + "incident_type": "wildfire", + "properties": { + "confidence": "demo" + } + } + ], + "shelters": [ + { + "id": "shelter-north", + "name": "Shelter North", + "coord": { + "lat": 37.8044, + "lon": -122.2712 + }, + "capacity": 220, + "occupancy": 215, + "smoke_risk": "high", + "needs": [ + { + "resource": "N95 masks", + "quantity": 500, + "unit": "masks", + "urgency": 5 + }, + { + "resource": "water", + "quantity": 900, + "unit": "liters", + "urgency": 4 + } + ], + "notes": "Near smoke plume and nearly full." + }, + { + "id": "shelter-west", + "name": "Shelter West", + "coord": { + "lat": 37.6879, + "lon": -122.4702 + }, + "capacity": 420, + "occupancy": 240, + "smoke_risk": "low", + "needs": [ + { + "resource": "water", + "quantity": 1200, + "unit": "liters", + "urgency": 5 + }, + { + "resource": "medical kits", + "quantity": 35, + "unit": "kits", + "urgency": 4 + }, + { + "resource": "blankets", + "quantity": 150, + "unit": "blankets", + "urgency": 2 + } + ], + "notes": "Cleaner air and available capacity." + } + ], + "vehicles": [ + { + "id": "truck-1", + "name": "Truck 1", + "capacity": { + "water": 1500, + "N95 masks": 2000 + }, + "current": { + "lat": 37.7749, + "lon": -122.4194 + }, + "status": "available" + }, + { + "id": "truck-2", + "name": "Truck 2", + "capacity": { + "medical kits": 80, + "blankets": 300 + }, + "current": { + "lat": 37.7749, + "lon": -122.4194 + }, + "status": "available" + }, + { + "id": "truck-3", + "name": "Truck 3", + "capacity": { + "water": 700 + }, + "current": { + "lat": 37.7749, + "lon": -122.4194 + }, + "status": "standby" + } + ], + "resources": [ + { + "name": "water", + "quantity": 2500, + "unit": "liters", + "priority": 5 + }, + { + "name": "N95 masks", + "quantity": 3000, + "unit": "masks", + "priority": 5 + }, + { + "name": "medical kits", + "quantity": 60, + "unit": "kits", + "priority": 4 + }, + { + "name": "blankets", + "quantity": 250, + "unit": "blankets", + "priority": 2 + } + ], + "constraints": [ + "avoid active fire perimeter", + "avoid smoke-heavy corridor", + "avoid traffic closure", + "prefer shelters with available capacity" + ], + "external_messages": [ + { + "type": "external_message", + "source": "unknown_sms", + "message": "Urgent FEMA wildfire aid claim. Login now: https://fema-aid-claim-example.com/login" + } + ], + "supply_requests": [ + { + "type": "supply_request", + "source": "unknown", + "destination": "Unverified Shelter X", + "requested_items": { + "medical_kits": 500 + }, + "urgency": "critical" + } + ] +} diff --git a/data/sample_scenarios/earthquake_response.json b/data/sample_scenarios/earthquake_response.json new file mode 100644 index 0000000..1df4f9e --- /dev/null +++ b/data/sample_scenarios/earthquake_response.json @@ -0,0 +1,67 @@ +{ + "name": "Earthquake Response Logistics", + "incident_type": "earthquake", + "area": "CA", + "current": { + "lat": 34.0522, + "lon": -118.2437 + }, + "mission": "Prioritize hospitals and shelters after a significant earthquake, then route medical kits and water around bridge-risk corridors.", + "shelters": [ + { + "id": "shelter-quake-a", + "name": "Civic Center Emergency Shelter", + "coord": { + "lat": 34.05, + "lon": -118.25 + }, + "capacity": 500, + "occupancy": 430, + "smoke_risk": "unknown", + "needs": [ + { + "resource": "medical kits", + "quantity": 75, + "unit": "kits", + "urgency": 5 + }, + { + "resource": "water", + "quantity": 1800, + "unit": "liters", + "urgency": 5 + } + ] + } + ], + "vehicles": [ + { + "id": "truck-quake-1", + "name": "Medical Supply Truck", + "capacity": { + "medical kits": 100, + "water": 1000 + }, + "status": "available" + } + ], + "resources": [ + { + "name": "medical kits", + "quantity": 100, + "unit": "kits", + "priority": 5 + }, + { + "name": "water", + "quantity": 1200, + "unit": "liters", + "priority": 5 + } + ], + "constraints": [ + "prioritize hospital access", + "avoid bridge damage risk", + "explain route confidence" + ] +} diff --git a/data/sample_scenarios/flood_response.json b/data/sample_scenarios/flood_response.json new file mode 100644 index 0000000..7376590 --- /dev/null +++ b/data/sample_scenarios/flood_response.json @@ -0,0 +1,66 @@ +{ + "name": "Flood Response Logistics", + "incident_type": "flood", + "area": "CA", + "current": { + "lat": 38.5816, + "lon": -121.4944 + }, + "mission": "Move water, blankets, and medical kits to a dry shelter while avoiding low bridges and flooded roads.", + "shelters": [ + { + "id": "shelter-dry", + "name": "Dry Creek High School Shelter", + "coord": { + "lat": 38.625, + "lon": -121.41 + }, + "capacity": 300, + "occupancy": 170, + "smoke_risk": "unknown", + "needs": [ + { + "resource": "blankets", + "quantity": 120, + "unit": "blankets", + "urgency": 4 + }, + { + "resource": "medical kits", + "quantity": 20, + "unit": "kits", + "urgency": 3 + } + ] + } + ], + "vehicles": [ + { + "id": "truck-flood-1", + "name": "High-clearance Truck", + "capacity": { + "blankets": 300, + "medical kits": 50 + }, + "status": "available" + } + ], + "resources": [ + { + "name": "blankets", + "quantity": 200, + "unit": "blankets", + "priority": 4 + }, + { + "name": "medical kits", + "quantity": 25, + "unit": "kits", + "priority": 4 + } + ], + "constraints": [ + "avoid flooded roads", + "avoid weak bridges for heavy truck" + ] +} diff --git a/docs/api_inventory.md b/docs/api_inventory.md new file mode 100644 index 0000000..2b1ff21 --- /dev/null +++ b/docs/api_inventory.md @@ -0,0 +1,111 @@ +# TERA v2 API Inventory + +Use `scripts/test_live_apis.py` for optional live checks. Unit tests use fixtures/mocks and do not require network access. + +| API | Category | Auth | Env Vars | Endpoint | Priority | TERA Use | +|---|---|---|---|---|---|---| +| NOAA/NWS Alerts | Weather hazards | No key | none | `/alerts/active` | P0 | hazard overlay | +| FEMA OpenFEMA | Disaster declarations | No key | none | OData | P0 | official disaster context | +| HIFLD Hospitals | Critical infra | Public | none | ArcGIS FeatureServer | P0 | hospital selection | +| HIFLD Critical Infrastructure | Critical infra | Public | none | ArcGIS FeatureServer | P0 | fire/EMS/shelter/EOC | +| Google Maps Routes | Routing | Key | `GOOGLE_MAPS_API_KEY` | `computeRoutes` | P0 | route candidates | +| Google Route Optimization | Logistics | OAuth | `GOOGLE_PROJECT_ID`, `GOOGLE_ACCESS_TOKEN` | `optimizeTours` | P0 | vehicle/resource allocation | +| Firebase | Offline sync | Firebase | Firebase config | SDK | P0 | shared state | +| NIFC/WFIGS | Wildfire | Public | none | ArcGIS FeatureServer | P1 | fire perimeter | +| EPA AirNow | Air quality | Key | `AIRNOW_API_KEY` | latLong current | P1 | smoke/PM2.5 risk | +| SF 511 | Traffic | Key | `SF511_API_KEY` | traffic/events | P1 | closures/incidents | +| NASA FIRMS | Satellite fire | Key | `FIRMS_MAP_KEY` | area/csv | P1 | hotspots | +| USGS Earthquake | Earthquake | No key | none | GeoJSON feed | P2 | earthquake scenario | +| USGS Water | Flood | No key | none | nwis/iv | P2 | stream/gage | +| NOAA NWPS | Flood forecast | Public | none | nwps | P2 | flood forecast | +| National Bridge Inventory | Infrastructure | No key | none | ArcGIS FeatureServer | P2 | heavy truck risk | +| NREL Fuel Stations | Fuel logistics | Key | `NREL_API_KEY` | nearest | P2 | fuel feasibility | +| NASA EONET | Natural events | No key | none | events | P3 | backup global events | +| ReliefWeb | Humanitarian reports | Approved appname | `RELIEFWEB_APPNAME` | reports | P3 | situation reports | +| Google Safe Browsing | Phishing/malware URL checking | Google API key | `GOOGLE_SAFE_BROWSING_API_KEY` | `threatMatches:find` | P1 | flag phishing/fake FEMA/donation links | +| VirusTotal | URL/domain reputation | API key | `VT_API_KEY` | v3 URL/domain reports | P2 | enrich suspicious URL/domain reputation | +| urlscan.io | URL behavior and redirect scan | API key | `URLSCAN_API_KEY` | scan/result | P2 | inspect suspicious crisis links | +| RDAP | Domain metadata | Public | none | rdap.org | P2 | check domain metadata and possible newly registered domains | + +## PowerShell Live Checks + +```powershell +Invoke-RestMethod -Uri "https://api.weather.gov/alerts/active?area=CA" +Invoke-RestMethod -Uri "https://api.weather.gov/points/37.7749,-122.4194" +Invoke-RestMethod -Uri "https://www.fema.gov/api/open/v2/DisasterDeclarationsSummaries?`$top=5" +Invoke-RestMethod -Uri "https://www.fema.gov/api/open/v2/DisasterDeclarationsSummaries?`$filter=state eq 'CA'&`$top=5" +Invoke-RestMethod -Uri "https://services.arcgis.com/XG15cJAlne2vxtgt/ArcGIS/rest/services/Hospitals_hifld/FeatureServer/0/query?where=STATE%3D%27CA%27&outFields=NAME,ADDRESS,CITY,STATE,TYPE&f=geojson" +Invoke-RestMethod -Uri "https://services.arcgis.com/XG15cJAlne2vxtgt/ArcGIS/rest/services/Critical_Infrastructure_Map_Service/FeatureServer?f=json" +$ci = Invoke-RestMethod -Uri "https://services.arcgis.com/XG15cJAlne2vxtgt/ArcGIS/rest/services/Critical_Infrastructure_Map_Service/FeatureServer?f=json" +$ci.layers | Select-Object id,name +Invoke-RestMethod -Uri "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query?where=1%3D1&outFields=*&f=geojson" +Invoke-RestMethod -Uri "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/significant_week.geojson" +Invoke-RestMethod -Uri "https://waterservices.usgs.gov/nwis/iv/?format=json&stateCd=ca¶meterCd=00060,00065&siteStatus=active" +Invoke-RestMethod -Uri "https://api.water.noaa.gov/nwps/v1/docs/" +Invoke-RestMethod -Uri "https://services.arcgis.com/xOi1kZaI0eWDREZv/ArcGIS/rest/services/NTAD_National_Bridge_Inventory/FeatureServer/0/query?where=1%3D1&outFields=STRUCTURE_NUMBER_008,FACILITY_CARRIED_007,STATE_CODE_001,COUNTY_CODE_003,FEATURES_DESC_006A&f=json&resultRecordCount=10" +Invoke-RestMethod -Uri "https://eonet.gsfc.nasa.gov/api/v3/events?status=open&limit=20" +``` + +Key-based examples: + +```powershell +Invoke-RestMethod -Uri "https://www.airnowapi.org/aq/observation/latLong/current/?format=application/json&latitude=37.7749&longitude=-122.4194&distance=25&API_KEY=$env:AIRNOW_API_KEY" +Invoke-RestMethod -Uri "https://api.511.org/traffic/events?api_key=$env:SF511_API_KEY" +Invoke-RestMethod -Uri "https://firms.modaps.eosdis.nasa.gov/api/area/csv/$env:FIRMS_MAP_KEY/VIIRS_SNPP_NRT/-125,32,-113,42/1" +Invoke-RestMethod -Uri "https://developer.nrel.gov/api/alt-fuel-stations/v1/nearest.json?api_key=$env:NREL_API_KEY&latitude=37.7749&longitude=-122.4194&fuel_type=LNG,CNG,BD,RD,LPG,ELEC&radius=50" +``` + +Google Route Optimization: + +```powershell +Invoke-RestMethod ` + -Uri "https://routeoptimization.googleapis.com/v1/projects/$env:GOOGLE_PROJECT_ID:optimizeTours" ` + -Method POST ` + -Headers @{ + "Authorization" = "Bearer $env:GOOGLE_ACCESS_TOKEN" + "Content-Type" = "application/json" + } ` + -Body (Get-Content ".\optimize_request.json" -Raw) +``` + +ReliefWeb: + +```powershell +$body = @{ + limit = 5 + sort = @("date:desc") + filter = @{ + field = "country.name" + value = "United States of America" + } + fields = @{ + include = @("title", "date", "url", "country", "disaster") + } +} | ConvertTo-Json -Depth 10 + +Invoke-RestMethod ` + -Uri "https://api.reliefweb.int/v2/reports?appname=$env:RELIEFWEB_APPNAME" ` + -Method POST ` + -ContentType "application/json" ` + -Body $body +``` + +Trust Shield: + +```powershell +$body = @{ + client = @{ clientId = "tera"; clientVersion = "0.2" } + threatInfo = @{ + threatTypes = @("MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION") + platformTypes = @("ANY_PLATFORM") + threatEntryTypes = @("URL") + threatEntries = @(@{ url = "https://www.fema.gov/" }) + } +} | ConvertTo-Json -Depth 10 + +Invoke-RestMethod ` + -Uri "https://safebrowsing.googleapis.com/v4/threatMatches:find?key=$env:GOOGLE_SAFE_BROWSING_API_KEY" ` + -Method POST ` + -ContentType "application/json" ` + -Body $body +``` diff --git a/docs/architecture_v2.md b/docs/architecture_v2.md new file mode 100644 index 0000000..370c40f --- /dev/null +++ b/docs/architecture_v2.md @@ -0,0 +1,46 @@ +# TERA v2 Architecture + +TERA v2 adds a humanitarian emergency-response layer while preserving the legacy tactical route agent. + +```mermaid +flowchart LR + A["Operator prompt"] --> B["Mission Orchestrator"] + B --> C["Disaster API adapters"] + B --> D["Infrastructure adapters"] + B --> E["Routing and logistics tools"] + B --> F["TERA Trust Shield"] + C --> G["MissionPlanResponse"] + D --> G + E --> G + F --> G + G --> H["Human approval"] + H --> I["Signed mission plan / field clients"] +``` + +## Legacy Mode + +The original `/plan` path remains a tactical edge route agent with signed CoT verification. It is still the right path for ATAK/Jetson/Gemma demonstrations and signed render gates. + +## Humanitarian Mode + +`POST /mission/plan` accepts a disaster-response request and returns: + +- incident summary +- hazards +- critical infrastructure +- route candidates +- route risks +- resource allocations +- Trust Shield assessments +- unverified claims +- blocked or approval-required items +- explanation +- offline fallback status + +## Trust Boundary + +Natural language, external links, field reports, and supply requests are data, not authority. TERA can recommend, but suspicious external claims are isolated until a human commander approves them. + +## Network Behavior + +The v2 mission endpoint defaults to offline fallback. Live API collection only runs when the request sets `use_live_apis=true`. Trust Shield endpoints may use live providers when API keys are present, but mission planning can call them in offline mode. diff --git a/docs/demo_google_io.md b/docs/demo_google_io.md new file mode 100644 index 0000000..4f24dda --- /dev/null +++ b/docs/demo_google_io.md @@ -0,0 +1,39 @@ +# 3-minute Google I/O Demo Script + +## 0:00 - Wildfire Scenario Begins + +"A wildfire and smoke event is pressuring Bay Area shelters. Connectivity is degraded, but responders still need a logistics decision." + +Run: + +```bash +curl -s http://localhost:8000/mission/demo/bay-area-wildfire | jq . +``` + +## 0:30 - TERA Loads Context + +TERA can enrich from NWS alerts, WFIGS fire perimeter, AirNow AQI, HIFLD hospitals, SF511 road events, and cached sample state. In offline mode, it uses local fallback state. + +## 1:00 - Shelter and Hospital Selection + +TERA identifies Shelter North as nearly full and smoke exposed. It selects Shelter West because it has cleaner air and available capacity. It also keeps a hospital option in critical infrastructure. + +## 1:30 - Vehicle and Resource Allocation + +TERA assigns trucks to verified needs: water, N95 masks, medical kits, and blankets. If Google Route Optimization is configured, it can optimize dispatch; otherwise deterministic fallback keeps the mission running. + +## 2:00 - Route Selection + +TERA chooses Route C, the offline route candidate, and reports risk factors from available hazards, traffic, bridge, and AQI data. + +## 2:20 - Trust Shield + +A fake FEMA login link and unverified supply request appear in the scenario. TERA flags possible impersonation, marks the request as unverified, requires human approval, and prevents the unverified request from changing dispatch. + +## 2:45 - Explanation + +Gemini can generate an operator-facing explanation when online. Gemma/local fallback keeps the explanation available offline. + +## 3:00 - Close + +"TERA is not only a route planner. It is an offline-first emergency logistics coordinator with built-in trust protection." diff --git a/docs/trust_shield.md b/docs/trust_shield.md new file mode 100644 index 0000000..33b6e50 --- /dev/null +++ b/docs/trust_shield.md @@ -0,0 +1,68 @@ +# TERA Trust Shield + +TERA Trust Shield is a disaster fraud and misinformation protection layer for emergency coordination. It is not a general anti-scam platform. Its job is to keep unverified crisis-related information from automatically changing emergency operations. + +## Why Disaster Fraud Matters + +During disasters, responders and victims may receive fake donation links, fake FEMA or county portals, phishing links, false shelter instructions, fraudulent supply requests, impersonated responder messages, malicious QR codes, and suspicious field reports. These can divert resources, steal credentials, or create unsafe dispatch decisions. + +## Scope + +Trust Shield answers: + +- Can this crisis-related link be trusted? +- Is this supply request suspicious or unverified? +- Is this shelter claim in the verified shelter list? +- Does this evacuation instruction conflict with official alert context? +- Should a field report require human approval before dispatch? + +It does not identify people, accuse individuals, perform takedowns, or report targets. + +## Supported Checks + +- URL heuristics: shorteners, non-HTTPS, punycode, IP literals, excessive subdomains, suspicious TLDs, embedded credentials, crisis keywords, and official-source impersonation. +- Google Safe Browsing: malware, social engineering, unwanted software, and potentially harmful application matches. +- VirusTotal: optional URL/domain reputation. +- urlscan.io: optional explicit scan submission with `unlisted` visibility. +- RDAP: public domain metadata where available. +- Misinformation tools: unverified shelter claims, unverified evacuation instructions, and conflicting field reports. + +## Environment Variables + +- `GOOGLE_SAFE_BROWSING_API_KEY` +- `VT_API_KEY` +- `URLSCAN_API_KEY` +- `TERA_TRUST_OFFICIAL_DOMAINS` optional comma-separated allowlist override + +`GET /trust/api-status` reports only true/false. It never exposes secret values. + +## Example + +```bash +curl -s -X POST http://localhost:8000/trust/check-url \ + -H 'Content-Type: application/json' \ + -d '{"url":"https://fema-aid-claim-example.com/login","context":"wildfire relief claim link"}' | jq . +``` + +Expected behavior: + +- possible FEMA impersonation is flagged +- missing threat-intel keys are shown as skipped, not fatal +- risk language stays careful +- human approval is required before mission planning can use the claim + +## Human-in-the-loop Safety + +TERA recommends. A human commander approves. Signed mission plans can then be verified by field clients. Suspicious or unverified external claims remain isolated until approved. + +## Limitations + +- Heuristics are not proof of fraud. +- RDAP data is inconsistent across registries. +- Google Safe Browsing, VirusTotal, and urlscan require network access and keys. +- urlscan submission may consume quota and load the target page, so it is explicit only. +- Trust Shield should use phrases like "possible phishing", "unverified source", and "requires approval" unless a provider directly confirms a malicious match. + +## Privacy and Security Notes + +Do not submit sensitive victim data, private field reports, or internal responder URLs to third-party services without approval. For offline operations, run heuristic checks only and preserve the human approval boundary. diff --git a/docs/v2_prd_emergency_response.md b/docs/v2_prd_emergency_response.md new file mode 100644 index 0000000..3bc229c --- /dev/null +++ b/docs/v2_prd_emergency_response.md @@ -0,0 +1,55 @@ +# TERA v2 PRD: Emergency Logistics and Disaster Coordination + +## Product Vision + +TERA helps emergency teams coordinate disaster response with degraded connectivity. It converts natural-language operational intent into an explainable logistics plan that combines hazards, infrastructure, routes, vehicles, resources, shelter needs, and trust checks. + +## Target Users + +- county emergency operations centers +- field response teams +- shelter coordinators +- logistics officers +- humanitarian relief coordinators +- volunteer coordinators who need verified instructions + +## Core Workflows + +1. Operator describes the mission: "Route water and N95 masks to the safest available shelter." +2. TERA gathers live or cached context: weather alerts, fire perimeter, AQI, hospitals, shelters, road events, and infrastructure. +3. TERA scores shelter needs and route risks. +4. TERA runs deterministic offline allocation, or Google Route Optimization when available. +5. TERA Trust Shield checks external links, field reports, shelter claims, and supply requests. +6. TERA explains what it recommends, what it blocked, and what requires human approval. +7. Approved plans can be signed and verified by field clients. + +## Non-goals + +- General anti-scam platform. +- Replacement for official evacuation orders. +- Automated takedown, spam reporting, attribution, or law-enforcement action. +- Cloud-only operations. +- Removing the legacy tactical/ATAK architecture. + +## Success Metrics + +- Mission plan produced with no external API keys. +- Live API enrichment works when keys are present. +- Suspicious crisis links and unverified supply requests do not modify dispatch automatically. +- Operator receives a concise explanation and approval boundary. +- Legacy `/plan`, `/plan/approve`, and `/plan/verify` remain compatible. + +## Judging Relevance + +TERA demonstrates practical Google technology use while solving a humanitarian problem: + +- Gemini for multimodal emergency reasoning and explanation. +- Gemma for offline fallback. +- Google Maps Routes for route candidates. +- Google Route Optimization for fleet/resource dispatch. +- Firebase for offline-first shared mission state. +- Google Safe Browsing for crisis-link protection. + +## Security and Offline-first Rationale + +Emergency response is vulnerable to degraded networks and hostile or fraudulent information. TERA treats external links, field reports, and unverified supply requests as untrusted until assessed and approved. Offline fallback is not a downgrade; it is the default safety posture. diff --git a/integrations/__init__.py b/integrations/__init__.py new file mode 100644 index 0000000..ed218da --- /dev/null +++ b/integrations/__init__.py @@ -0,0 +1 @@ +"""External integration adapters for TERA v2 emergency response.""" diff --git a/integrations/common/__init__.py b/integrations/common/__init__.py new file mode 100644 index 0000000..f50aed0 --- /dev/null +++ b/integrations/common/__init__.py @@ -0,0 +1 @@ +"""Common helpers for integration adapters.""" diff --git a/integrations/common/cache.py b/integrations/common/cache.py new file mode 100644 index 0000000..12c8a40 --- /dev/null +++ b/integrations/common/cache.py @@ -0,0 +1,18 @@ +"""Small JSON cache helper for offline-first integration fallbacks.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def read_json(path: Path) -> dict[str, Any] | list[Any] | None: + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def write_json(path: Path, payload: dict[str, Any] | list[Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") diff --git a/integrations/common/geojson.py b/integrations/common/geojson.py new file mode 100644 index 0000000..f0174d9 --- /dev/null +++ b/integrations/common/geojson.py @@ -0,0 +1,17 @@ +"""Tiny GeoJSON utilities used by disaster adapters and fallbacks.""" + +from __future__ import annotations + +from typing import Any + + +def point_feature(lon: float, lat: float, properties: dict[str, Any]) -> dict[str, Any]: + return { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": properties, + } + + +def feature_collection(features: list[dict[str, Any]]) -> dict[str, Any]: + return {"type": "FeatureCollection", "features": features} diff --git a/integrations/common/http.py b/integrations/common/http.py new file mode 100644 index 0000000..4f21ef9 --- /dev/null +++ b/integrations/common/http.py @@ -0,0 +1,95 @@ +"""HTTP helpers for thin API adapters. + +All network calls live in integrations, not in the legacy Phase 3 agent path. +Adapters use explicit timeouts and raise ApiError with source context. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +DEFAULT_TIMEOUT_S = 10.0 +USER_AGENT = "tera-emergency-coordinator/0.2" + + +class ApiError(RuntimeError): + """Raised when an external API call fails or is unavailable.""" + + +def _headers(headers: dict[str, str] | None = None) -> dict[str, str]: + merged = {"User-Agent": USER_AGENT} + if headers: + merged.update(headers) + return merged + + +def get_json( + url: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT_S, +) -> Any: + try: + with httpx.Client( + timeout=timeout, + follow_redirects=True, + headers=_headers(headers), + ) as client: + response = client.get(url, params=params) + response.raise_for_status() + return response.json() + except httpx.HTTPError as exc: + raise ApiError(f"GET {url} failed: {exc}") from exc + except ValueError as exc: + raise ApiError(f"GET {url} returned non-JSON response") from exc + + +def post_json( + url: str, + *, + json_body: dict[str, Any], + headers: dict[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT_S, +) -> Any: + try: + with httpx.Client( + timeout=timeout, + follow_redirects=True, + headers=_headers(headers), + ) as client: + response = client.post(url, json=json_body) + response.raise_for_status() + return response.json() + except httpx.HTTPError as exc: + raise ApiError(f"POST {url} failed: {exc}") from exc + except ValueError as exc: + raise ApiError(f"POST {url} returned non-JSON response") from exc + + +def get_text( + url: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT_S, +) -> str: + try: + with httpx.Client( + timeout=timeout, + follow_redirects=True, + headers=_headers(headers), + ) as client: + response = client.get(url, params=params) + response.raise_for_status() + return response.text + except httpx.HTTPError as exc: + raise ApiError(f"GET {url} failed: {exc}") from exc + + +def require_env(name: str, value: str | None) -> str: + if not value: + raise ApiError(f"Missing required environment variable: {name}") + return value diff --git a/integrations/google/__init__.py b/integrations/google/__init__.py new file mode 100644 index 0000000..60a0882 --- /dev/null +++ b/integrations/google/__init__.py @@ -0,0 +1 @@ +"""Google API adapters for TERA v2.""" diff --git a/integrations/google/firebase.py b/integrations/google/firebase.py new file mode 100644 index 0000000..dfc430c --- /dev/null +++ b/integrations/google/firebase.py @@ -0,0 +1,20 @@ +"""Firebase status helpers for v2 offline-first shared state.""" + +from __future__ import annotations + +import os + +FIREBASE_ENV_VARS = ( + "FIREBASE_PROJECT_ID", + "FIREBASE_API_KEY", + "FIREBASE_AUTH_DOMAIN", + "FIREBASE_DATABASE_URL", +) + + +def firebase_configured() -> bool: + return any(os.getenv(name) for name in FIREBASE_ENV_VARS) + + +def firebase_status() -> dict[str, bool]: + return {name: bool(os.getenv(name)) for name in FIREBASE_ENV_VARS} diff --git a/integrations/google/maps_routes.py b/integrations/google/maps_routes.py new file mode 100644 index 0000000..07c2968 --- /dev/null +++ b/integrations/google/maps_routes.py @@ -0,0 +1,83 @@ +"""Google Maps Routes API adapter.""" + +from __future__ import annotations + +import os +from typing import Any + +from agent.mission_schemas import Coord, RouteCandidate +from integrations.common import http + +ROUTES_URL = "https://routes.googleapis.com/directions/v2:computeRoutes" + + +def compute_routes( + origin: Coord, + destination: Coord, + alternatives: bool = True, +) -> dict[str, Any]: + api_key = http.require_env("GOOGLE_MAPS_API_KEY", os.getenv("GOOGLE_MAPS_API_KEY")) + body = { + "origin": {"location": {"latLng": {"latitude": origin.lat, "longitude": origin.lon}}}, + "destination": { + "location": {"latLng": {"latitude": destination.lat, "longitude": destination.lon}} + }, + "travelMode": "DRIVE", + "computeAlternativeRoutes": alternatives, + "routingPreference": "TRAFFIC_AWARE", + } + raw = http.post_json( + ROUTES_URL, + json_body=body, + headers={ + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + "X-Goog-FieldMask": ( + "routes.duration,routes.distanceMeters,routes.polyline.encodedPolyline," + "routes.description" + ), + }, + ) + if not isinstance(raw, dict): + raise http.ApiError("Google Maps Routes response was not an object") + return raw + + +def normalize_routes(raw: dict[str, Any]) -> list[RouteCandidate]: + candidates: list[RouteCandidate] = [] + for index, route in enumerate(raw.get("routes", [])): + if not isinstance(route, dict): + continue + duration_s = _parse_duration_seconds(route.get("duration")) + polyline = route.get("polyline", {}) + encoded = polyline.get("encodedPolyline") if isinstance(polyline, dict) else None + candidates.append( + RouteCandidate( + id=f"google-route-{index + 1}", + provider="google_maps_routes", + distance_m=_optional_float(route.get("distanceMeters")), + duration_s=duration_s, + polyline=str(encoded) if encoded else None, + summary=_optional_str(route.get("description")), + ) + ) + return candidates + + +def _parse_duration_seconds(value: Any) -> float | None: + if isinstance(value, str) and value.endswith("s"): + try: + return float(value[:-1]) + except ValueError: + return None + return _optional_float(value) + + +def _optional_float(value: Any) -> float | None: + if isinstance(value, int | float): + return float(value) + return None + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/google/route_optimization.py b/integrations/google/route_optimization.py new file mode 100644 index 0000000..bc90a75 --- /dev/null +++ b/integrations/google/route_optimization.py @@ -0,0 +1,68 @@ +"""Google Route Optimization API adapter.""" + +from __future__ import annotations + +import os +from typing import Any + +from agent.mission_schemas import Resource, VehicleAssignment +from integrations.common import http + + +def optimize_tours(request: dict[str, Any]) -> dict[str, Any]: + project_id = http.require_env("GOOGLE_PROJECT_ID", os.getenv("GOOGLE_PROJECT_ID")) + access_token = http.require_env("GOOGLE_ACCESS_TOKEN", os.getenv("GOOGLE_ACCESS_TOKEN")) + raw = http.post_json( + f"https://routeoptimization.googleapis.com/v1/projects/{project_id}:optimizeTours", + json_body=request, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + ) + if not isinstance(raw, dict): + raise http.ApiError("Google Route Optimization response was not an object") + return raw + + +def build_basic_supply_dispatch_request( + vehicles: list[dict[str, Any]], + shipments: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "model": { + "globalStartTime": "2026-05-11T00:00:00Z", + "globalEndTime": "2026-05-12T00:00:00Z", + "vehicles": vehicles, + "shipments": shipments, + }, + "searchMode": "RETURN_FAST", + } + + +def normalize_optimized_routes(raw: dict[str, Any]) -> list[VehicleAssignment]: + assignments: list[VehicleAssignment] = [] + for index, route in enumerate(raw.get("routes", [])): + if not isinstance(route, dict): + continue + vehicle_label = route.get("vehicleLabel") or route.get("vehicleIndex") or f"vehicle-{index}" + visits = route.get("visits", []) + destination = None + if isinstance(visits, list) and visits: + first_visit = visits[0] + if isinstance(first_visit, dict): + destination = _optional_str(first_visit.get("shipmentLabel")) + assignments.append( + VehicleAssignment( + vehicle_id=str(vehicle_label), + destination_id=destination, + resources=[Resource(name="optimized shipment", quantity=1, unit="load")], + status="optimized", + rationale="Assigned by Google Route Optimization API.", + ) + ) + return assignments + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/security/__init__.py b/integrations/security/__init__.py new file mode 100644 index 0000000..1e46570 --- /dev/null +++ b/integrations/security/__init__.py @@ -0,0 +1 @@ +"""Security and threat-intelligence adapters for TERA Trust Shield.""" diff --git a/integrations/security/rdap.py b/integrations/security/rdap.py new file mode 100644 index 0000000..8618896 --- /dev/null +++ b/integrations/security/rdap.py @@ -0,0 +1,61 @@ +"""Public RDAP domain metadata adapter.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +from agent.trust_schemas import DomainMetadata +from integrations.common import http + +RDAP_BASE = "https://rdap.org/domain" + + +def extract_domain(url: str) -> str: + parsed = urlparse(url if "://" in url else f"https://{url}") + host = parsed.hostname or "" + return host.lower().rstrip(".") + + +def get_rdap_domain(domain: str) -> dict[str, Any]: + raw = http.get_json(f"{RDAP_BASE}/{domain}") + if not isinstance(raw, dict): + raise http.ApiError("RDAP response was not an object") + return raw + + +def normalize_rdap_domain(raw: dict[str, Any]) -> DomainMetadata: + events = raw.get("events", []) if isinstance(raw.get("events"), list) else [] + return DomainMetadata( + domain=str(raw.get("ldhName") or raw.get("handle") or ""), + registrar=_registrar(raw), + created_at=_event_date(events, "registration"), + updated_at=_event_date(events, "last changed"), + expires_at=_event_date(events, "expiration"), + raw=raw, + ) + + +def _registrar(raw: dict[str, Any]) -> str | None: + entities = raw.get("entities", []) + if not isinstance(entities, list): + return None + for entity in entities: + if not isinstance(entity, dict): + continue + roles = entity.get("roles", []) + if isinstance(roles, list) and "registrar" in roles: + vcard = entity.get("vcardArray", []) + if isinstance(vcard, list) and len(vcard) > 1 and isinstance(vcard[1], list): + for row in vcard[1]: + if isinstance(row, list) and row and row[0] == "fn" and len(row) > 3: + return str(row[3]) + return None + + +def _event_date(events: list[Any], action: str) -> str | None: + for event in events: + if isinstance(event, dict) and event.get("eventAction") == action: + date = event.get("eventDate") + return str(date) if date is not None else None + return None diff --git a/integrations/security/safe_browsing.py b/integrations/security/safe_browsing.py new file mode 100644 index 0000000..d97373f --- /dev/null +++ b/integrations/security/safe_browsing.py @@ -0,0 +1,80 @@ +"""Google Safe Browsing adapter for crisis-related URL checks.""" + +from __future__ import annotations + +import os +from typing import Any + +from agent.trust_schemas import UrlThreatResult +from integrations.common import http + +SAFE_BROWSING_URL = "https://safebrowsing.googleapis.com/v4/threatMatches:find" +THREAT_TYPES = [ + "MALWARE", + "SOCIAL_ENGINEERING", + "UNWANTED_SOFTWARE", + "POTENTIALLY_HARMFUL_APPLICATION", +] + + +def build_safe_browsing_request(urls: list[str]) -> dict[str, Any]: + return { + "client": {"clientId": "tera", "clientVersion": "0.2"}, + "threatInfo": { + "threatTypes": THREAT_TYPES, + "platformTypes": ["ANY_PLATFORM"], + "threatEntryTypes": ["URL"], + "threatEntries": [{"url": url} for url in urls], + }, + } + + +def check_url_threats(urls: list[str]) -> list[UrlThreatResult]: + api_key = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY") + if not api_key: + return [ + UrlThreatResult( + url=url, + checked=False, + provider="google_safe_browsing", + matched=False, + raw={"skipped": "missing_api_key"}, + ) + for url in urls + ] + raw = http.post_json( + f"{SAFE_BROWSING_URL}?key={api_key}", + json_body=build_safe_browsing_request(urls), + headers={"Content-Type": "application/json"}, + ) + if not isinstance(raw, dict): + raise http.ApiError("Safe Browsing response was not an object") + return normalize_safe_browsing_response(raw, urls) + + +def normalize_safe_browsing_response( + raw: dict[str, Any], + urls: list[str], +) -> list[UrlThreatResult]: + matches_by_url: dict[str, list[str]] = {url: [] for url in urls} + for match in raw.get("matches", []): + if not isinstance(match, dict): + continue + threat = match.get("threat", {}) + if not isinstance(threat, dict): + continue + url = threat.get("url") + if not isinstance(url, str): + continue + matches_by_url.setdefault(url, []).append(str(match.get("threatType") or "UNKNOWN")) + return [ + UrlThreatResult( + url=url, + checked=True, + provider="google_safe_browsing", + matched=bool(threat_types), + threat_types=threat_types, + raw=raw if threat_types else None, + ) + for url, threat_types in matches_by_url.items() + ] diff --git a/integrations/security/url_risk.py b/integrations/security/url_risk.py new file mode 100644 index 0000000..4597171 --- /dev/null +++ b/integrations/security/url_risk.py @@ -0,0 +1,320 @@ +"""URL risk aggregation for TERA Trust Shield.""" + +from __future__ import annotations + +import ipaddress +import os +from urllib.parse import urlparse + +from agent.trust_schemas import RiskSignal, TrustAssessment, UrlThreatResult +from integrations.security import rdap, safe_browsing, virustotal + +OFFICIAL_ALLOWLIST = { + "fema.gov", + "redcross.org", + "ready.gov", + "weather.gov", + "noaa.gov", + "cdc.gov", + "usa.gov", + "ca.gov", + "sf.gov", + "511.org", + "airnow.gov", +} +SHORTENERS = { + "bit.ly", + "bitly.com", + "tinyurl.com", + "t.co", + "goo.gl", + "ow.ly", + "is.gd", + "buff.ly", + "rebrand.ly", + "cutt.ly", +} +SUSPICIOUS_TLDS = {"zip", "mov", "top", "xyz", "click", "quest", "country", "support"} +SUSPICIOUS_KEYWORDS = { + "donate", + "aid", + "claim", + "login", + "verify", + "urgent", + "relief", + "wallet", + "crypto", +} +OFFICIAL_BRAND_TERMS = {"fema", "redcross", "ready", "noaa", "cdc", "airnow"} + + +def assess_url_risk( + url: str, + context: str | None = None, + *, + use_live_providers: bool = True, +) -> TrustAssessment: + signals = score_url_heuristics(url) + checked_sources = ["heuristic"] + skipped_sources: list[str] = [] + + if use_live_providers: + safe_results = safe_browsing.check_url_threats([url]) + _merge_safe_browsing(signals, safe_results, checked_sources, skipped_sources) + _merge_virustotal(url, signals, checked_sources, skipped_sources) + _merge_rdap(url, checked_sources, skipped_sources) + else: + skipped_sources.extend( + ["safe_browsing_offline_mode", "virustotal_offline_mode", "rdap_offline_mode"] + ) + + if context and any(word in context.lower() for word in ("donation", "aid", "claim", "login")): + signals.append( + RiskSignal( + source="context", + severity="low", + code="CRISIS_FINANCIAL_CONTEXT", + message="Crisis-related financial or login context increases review priority.", + ) + ) + + return aggregate_security_results( + input_type="url", + value=url, + signals=signals, + checked_sources=checked_sources, + skipped_sources=skipped_sources, + ) + + +def score_url_heuristics(url: str) -> list[RiskSignal]: + parsed = urlparse(url if "://" in url else f"https://{url}") + host = (parsed.hostname or "").lower() + signals: list[RiskSignal] = [] + official_domains = _official_domains() + + if parsed.scheme != "https": + signals.append( + RiskSignal( + source="heuristic", + severity="medium", + code="NON_HTTPS", + message="URL does not use HTTPS.", + ) + ) + if parsed.username or parsed.password: + signals.append( + RiskSignal( + source="heuristic", + severity="high", + code="EMBEDDED_CREDENTIALS", + message="URL contains embedded credentials.", + ) + ) + if _is_ip_literal(host): + signals.append( + RiskSignal( + source="heuristic", + severity="high", + code="IP_LITERAL", + message="URL uses an IP address instead of a domain name.", + ) + ) + if "xn--" in host: + signals.append( + RiskSignal( + source="heuristic", + severity="medium", + code="PUNYCODE_DOMAIN", + message="Domain contains punycode, which can indicate homograph risk.", + ) + ) + if host in SHORTENERS: + signals.append( + RiskSignal( + source="heuristic", + severity="medium", + code="URL_SHORTENER", + message="URL uses a shortener that hides the final destination.", + ) + ) + labels = host.split(".") if host else [] + if len(labels) > 4: + signals.append( + RiskSignal( + source="heuristic", + severity="low", + code="EXCESSIVE_SUBDOMAINS", + message="Domain has many subdomains, which can obscure the registered domain.", + ) + ) + tld = labels[-1] if labels else "" + if tld in SUSPICIOUS_TLDS: + signals.append( + RiskSignal( + source="heuristic", + severity="medium", + code="SUSPICIOUS_TLD", + message="Domain uses a TLD commonly seen in abuse or impersonation campaigns.", + ) + ) + url_text = url.lower() + keyword_hits = sorted(keyword for keyword in SUSPICIOUS_KEYWORDS if keyword in url_text) + if keyword_hits: + signals.append( + RiskSignal( + source="heuristic", + severity="low", + code="CRISIS_KEYWORDS", + message=f"URL contains crisis/fraud-sensitive keywords: {', '.join(keyword_hits)}.", + ) + ) + if _resembles_official_but_not_allowed(host, official_domains): + signals.append( + RiskSignal( + source="heuristic", + severity="high", + code="GOV_IMPERSONATION", + message=( + "Domain appears to imitate an official emergency service but is not in " + "the configured official-source allowlist." + ), + ) + ) + return signals + + +def aggregate_security_results( + *, + input_type: str, + value: str, + signals: list[RiskSignal], + checked_sources: list[str], + skipped_sources: list[str], +) -> TrustAssessment: + score = min(sum(_severity_score(signal.severity) for signal in signals), 100) + level = "low" + if score >= 80: + level = "critical" + elif score >= 55: + level = "high" + elif score >= 25: + level = "medium" + requires_approval = score >= 25 + recommendation = _recommendation(level) + return TrustAssessment( + input_type=input_type, # type: ignore[arg-type] + value=value, + risk_score=score, + risk_level=level, # type: ignore[arg-type] + signals=signals, + recommendation=recommendation, + checked_sources=sorted(set(checked_sources)), + skipped_sources=sorted(set(skipped_sources)), + requires_human_approval=requires_approval, + ) + + +def _merge_safe_browsing( + signals: list[RiskSignal], + results: list[UrlThreatResult], + checked_sources: list[str], + skipped_sources: list[str], +) -> None: + for result in results: + if not result.checked: + skipped_sources.append("safe_browsing_missing_key") + continue + checked_sources.append("safe_browsing") + if result.matched: + for threat_type in result.threat_types: + signals.append( + RiskSignal( + source="safe_browsing", + severity="critical", + code=threat_type, + message=f"URL matched a Safe Browsing {threat_type} threat list.", + ) + ) + + +def _merge_virustotal( + url: str, + signals: list[RiskSignal], + checked_sources: list[str], + skipped_sources: list[str], +) -> None: + if not os.getenv("VT_API_KEY"): + skipped_sources.append("virustotal_missing_key") + return + report = virustotal.normalize_vt_url_report(virustotal.get_url_report(url)) + checked_sources.append("virustotal") + if (report.malicious or 0) > 0: + signals.append( + RiskSignal( + source="virustotal", + severity="critical", + code="VT_MALICIOUS", + message=f"VirusTotal reports {report.malicious} malicious detections.", + ) + ) + elif (report.suspicious or 0) > 0: + signals.append( + RiskSignal( + source="virustotal", + severity="high", + code="VT_SUSPICIOUS", + message=f"VirusTotal reports {report.suspicious} suspicious detections.", + ) + ) + + +def _merge_rdap(url: str, checked_sources: list[str], skipped_sources: list[str]) -> None: + domain = rdap.extract_domain(url) + if not domain: + skipped_sources.append("rdap_no_domain") + return + try: + rdap.normalize_rdap_domain(rdap.get_rdap_domain(domain)) + except Exception: # noqa: BLE001 -- RDAP varies by registry; treat as unknown + skipped_sources.append("rdap_unavailable") + return + checked_sources.append("rdap") + + +def _official_domains() -> set[str]: + configured = os.getenv("TERA_TRUST_OFFICIAL_DOMAINS") + if not configured: + return OFFICIAL_ALLOWLIST + return {domain.strip().lower() for domain in configured.split(",") if domain.strip()} + + +def _is_ip_literal(host: str) -> bool: + try: + ipaddress.ip_address(host) + except ValueError: + return False + return True + + +def _resembles_official_but_not_allowed(host: str, official_domains: set[str]) -> bool: + allowed = any(host == domain or host.endswith(f".{domain}") for domain in official_domains) + if not host or allowed: + return False + return any(term in host.replace("-", "") for term in OFFICIAL_BRAND_TERMS) + + +def _severity_score(severity: str) -> int: + return {"info": 0, "low": 10, "medium": 25, "high": 45, "critical": 80}.get(severity, 0) + + +def _recommendation(level: str) -> str: + if level in {"critical", "high"}: + return ( + "Do not use this information for automatic dispatch. Escalate to the " + "incident commander and verify through official sources." + ) + if level == "medium": + return "Treat as unverified. Require human approval before it changes mission planning." + return "No high-risk signal found in available checks; continue normal verification workflow." diff --git a/integrations/security/urlscan.py b/integrations/security/urlscan.py new file mode 100644 index 0000000..12889d2 --- /dev/null +++ b/integrations/security/urlscan.py @@ -0,0 +1,61 @@ +"""urlscan.io adapter for explicitly requested crisis-link scans.""" + +from __future__ import annotations + +import os +from typing import Any + +from agent.trust_schemas import UrlScanResult +from integrations.common import http + +URLSCAN_BASE = "https://urlscan.io/api/v1" + + +def submit_url_scan(url: str, visibility: str = "unlisted") -> dict[str, Any]: + api_key = os.getenv("URLSCAN_API_KEY") + if not api_key: + return {"skipped": "missing_api_key", "url": url} + raw = http.post_json( + f"{URLSCAN_BASE}/scan/", + json_body={"url": url, "visibility": visibility}, + headers={"API-Key": api_key, "Content-Type": "application/json"}, + ) + if not isinstance(raw, dict): + raise http.ApiError("urlscan submit response was not an object") + return raw + + +def get_scan_result(uuid: str) -> dict[str, Any]: + api_key = os.getenv("URLSCAN_API_KEY") + if not api_key: + return {"skipped": "missing_api_key", "uuid": uuid} + raw = http.get_json(f"{URLSCAN_BASE}/result/{uuid}/", headers={"API-Key": api_key}) + if not isinstance(raw, dict): + raise http.ApiError("urlscan result response was not an object") + return raw + + +def normalize_urlscan_result(raw: dict[str, Any]) -> UrlScanResult: + task = raw.get("task", {}) if isinstance(raw.get("task"), dict) else {} + page = raw.get("page", {}) if isinstance(raw.get("page"), dict) else {} + verdicts = raw.get("verdicts", {}) if isinstance(raw.get("verdicts"), dict) else {} + overall = verdicts.get("overall", {}) if isinstance(verdicts.get("overall"), dict) else {} + lists = raw.get("lists", {}) if isinstance(raw.get("lists"), dict) else {} + domains = lists.get("domains", []) if isinstance(lists.get("domains"), list) else [] + verdict = "unknown" + if overall.get("malicious"): + verdict = "malicious" + elif overall.get("suspicious"): + verdict = "suspicious" + return UrlScanResult( + url=str(page.get("url") or task.get("url") or raw.get("url") or ""), + scan_id=_optional_str(task.get("uuid") or raw.get("uuid")), + verdict=verdict, + contacted_domains=[str(domain) for domain in domains], + screenshot_url=_optional_str(task.get("screenshotURL")), + raw=raw, + ) + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/security/virustotal.py b/integrations/security/virustotal.py new file mode 100644 index 0000000..4681f40 --- /dev/null +++ b/integrations/security/virustotal.py @@ -0,0 +1,97 @@ +"""VirusTotal v3 URL/domain reputation adapter.""" + +from __future__ import annotations + +import base64 +import os +from typing import Any, cast + +import httpx + +from agent.trust_schemas import DomainReputationResult, UrlReputationResult +from integrations.common import http + +VT_BASE = "https://www.virustotal.com/api/v3" + + +def get_url_report(url: str) -> dict[str, Any]: + api_key = os.getenv("VT_API_KEY") + if not api_key: + return {"skipped": "missing_api_key", "url": url} + raw = http.get_json(f"{VT_BASE}/urls/{_url_id(url)}", headers={"x-apikey": api_key}) + if not isinstance(raw, dict): + raise http.ApiError("VirusTotal URL report response was not an object") + return raw + + +def submit_url_for_analysis(url: str) -> dict[str, Any]: + api_key = os.getenv("VT_API_KEY") + if not api_key: + return {"skipped": "missing_api_key", "url": url} + try: + with httpx.Client(timeout=http.DEFAULT_TIMEOUT_S, follow_redirects=True) as client: + response = client.post( + f"{VT_BASE}/urls", + headers={"x-apikey": api_key}, + data={"url": url}, + ) + response.raise_for_status() + raw = response.json() + except httpx.HTTPError as exc: + raise http.ApiError(f"VirusTotal URL submit failed: {exc}") from exc + if not isinstance(raw, dict): + raise http.ApiError("VirusTotal URL submit response was not an object") + return raw + + +def get_domain_report(domain: str) -> dict[str, Any]: + api_key = os.getenv("VT_API_KEY") + if not api_key: + return {"skipped": "missing_api_key", "domain": domain} + raw = http.get_json(f"{VT_BASE}/domains/{domain}", headers={"x-apikey": api_key}) + if not isinstance(raw, dict): + raise http.ApiError("VirusTotal domain report response was not an object") + return raw + + +def normalize_vt_url_report(raw: dict[str, Any]) -> UrlReputationResult: + attrs = _attrs(raw) + stats = attrs.get("last_analysis_stats", {}) if isinstance(attrs, dict) else {} + return UrlReputationResult( + url=str(raw.get("url") or raw.get("data", {}).get("id") or ""), + provider="virustotal", + malicious=_int_or_none(stats.get("malicious")) if isinstance(stats, dict) else None, + suspicious=_int_or_none(stats.get("suspicious")) if isinstance(stats, dict) else None, + harmless=_int_or_none(stats.get("harmless")) if isinstance(stats, dict) else None, + undetected=_int_or_none(stats.get("undetected")) if isinstance(stats, dict) else None, + raw=raw, + ) + + +def normalize_vt_domain_report(raw: dict[str, Any]) -> DomainReputationResult: + attrs = _attrs(raw) + domain = raw.get("domain") or raw.get("data", {}).get("id") or "" + return DomainReputationResult( + domain=str(domain), + provider="virustotal", + reputation=_int_or_none(attrs.get("reputation")) if isinstance(attrs, dict) else None, + categories=attrs.get("categories") if isinstance(attrs.get("categories"), dict) else None, + raw=raw, + ) + + +def _url_id(url: str) -> str: + return base64.urlsafe_b64encode(url.encode("utf-8")).decode("ascii").rstrip("=") + + +def _attrs(raw: dict[str, Any]) -> dict[str, Any]: + data = raw.get("data") + if isinstance(data, dict): + attrs = data.get("attributes") + if isinstance(attrs, dict): + return cast(dict[str, Any], attrs) + return {} + + +def _int_or_none(value: Any) -> int | None: + return value if isinstance(value, int) else None diff --git a/integrations/us_disaster/__init__.py b/integrations/us_disaster/__init__.py new file mode 100644 index 0000000..ea16dcb --- /dev/null +++ b/integrations/us_disaster/__init__.py @@ -0,0 +1 @@ +"""US disaster and public-safety data adapters.""" diff --git a/integrations/us_disaster/airnow.py b/integrations/us_disaster/airnow.py new file mode 100644 index 0000000..36f4eef --- /dev/null +++ b/integrations/us_disaster/airnow.py @@ -0,0 +1,65 @@ +"""EPA AirNow API adapter.""" + +from __future__ import annotations + +import os +from typing import Any + +from agent.mission_schemas import AirQualityObservation +from integrations.common import http + +AIRNOW_URL = "https://www.airnowapi.org/aq/observation/latLong/current/" + + +def get_current_air_quality(lat: float, lon: float, distance: int = 25) -> list[dict[str, Any]]: + api_key = http.require_env("AIRNOW_API_KEY", os.getenv("AIRNOW_API_KEY")) + raw = http.get_json( + AIRNOW_URL, + params={ + "format": "application/json", + "latitude": lat, + "longitude": lon, + "distance": distance, + "API_KEY": api_key, + }, + ) + if not isinstance(raw, list): + raise http.ApiError("AirNow response was not a list") + return [item for item in raw if isinstance(item, dict)] + + +def normalize_air_quality(raw: list[dict[str, Any]]) -> list[AirQualityObservation]: + observations: list[AirQualityObservation] = [] + for item in raw: + category = item.get("Category") + category_name = None + if isinstance(category, dict): + category_name = category.get("Name") + observations.append( + AirQualityObservation( + parameter=str(item.get("ParameterName") or "AQI"), + aqi=_optional_int(item.get("AQI")), + category=str(category_name) if category_name else None, + reporting_area=_optional_str(item.get("ReportingArea")), + latitude=_optional_float(item.get("Latitude")), + longitude=_optional_float(item.get("Longitude")), + observed_at=_optional_str(item.get("DateObserved")), + ) + ) + return observations + + +def _optional_float(value: Any) -> float | None: + if isinstance(value, int | float): + return float(value) + return None + + +def _optional_int(value: Any) -> int | None: + if isinstance(value, int): + return value + return None + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/bridge_inventory.py b/integrations/us_disaster/bridge_inventory.py new file mode 100644 index 0000000..f483a7a --- /dev/null +++ b/integrations/us_disaster/bridge_inventory.py @@ -0,0 +1,66 @@ +"""National Bridge Inventory sample adapter.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import BridgeAsset +from integrations.common import http + +BRIDGE_SAMPLE_URL = ( + "https://services.arcgis.com/xOi1kZaI0eWDREZv/ArcGIS/rest/services/" + "NTAD_National_Bridge_Inventory/FeatureServer/0/query" +) + + +def get_bridge_inventory_sample(limit: int = 10) -> list[dict[str, Any]]: + raw = http.get_json( + BRIDGE_SAMPLE_URL, + params={ + "where": "1=1", + "outFields": ( + "STRUCTURE_NUMBER_008,FACILITY_CARRIED_007,STATE_CODE_001," + "COUNTY_CODE_003,FEATURES_DESC_006A" + ), + "f": "json", + "resultRecordCount": limit, + }, + ) + if not isinstance(raw, dict): + raise http.ApiError("National Bridge Inventory response was not an object") + rows: list[dict[str, Any]] = [] + for feature in raw.get("features", []): + if isinstance(feature, dict) and isinstance(feature.get("attributes"), dict): + rows.append(feature["attributes"]) + return rows + + +def normalize_bridges(raw: list[dict[str, Any]]) -> list[BridgeAsset]: + bridges: list[BridgeAsset] = [] + for index, item in enumerate(raw): + bridges.append( + BridgeAsset( + id=str( + item.get("structure_number_008") + or item.get("STRUCTURE_NUMBER_008") + or item.get("objectid") + or f"bridge-{index}" + ), + name=_optional_str( + item.get("facility_carried_by_structure_007") + or item.get("FACILITY_CARRIED_007") + ), + state=_optional_str(item.get("state_code_001") or item.get("STATE_CODE_001")), + county=_optional_str(item.get("county_code_003") or item.get("COUNTY_CODE_003")), + route=_optional_str( + item.get("features_desc_006a") or item.get("FEATURES_DESC_006A") + ), + condition=_optional_str(item.get("bridge_condition")), + properties=item, + ) + ) + return bridges + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/eonet.py b/integrations/us_disaster/eonet.py new file mode 100644 index 0000000..a8b9997 --- /dev/null +++ b/integrations/us_disaster/eonet.py @@ -0,0 +1,39 @@ +"""NASA EONET event adapter.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import NaturalEvent +from integrations.common import http + +EONET_URL = "https://eonet.gsfc.nasa.gov/api/v3/events" + + +def get_open_events(limit: int = 20) -> dict[str, Any]: + raw = http.get_json(EONET_URL, params={"status": "open", "limit": limit}) + if not isinstance(raw, dict): + raise http.ApiError("EONET response was not an object") + return raw + + +def normalize_events(raw: dict[str, Any]) -> list[NaturalEvent]: + events: list[NaturalEvent] = [] + for index, item in enumerate(raw.get("events", [])): + if not isinstance(item, dict): + continue + categories = item.get("categories", []) + category = None + if isinstance(categories, list) and categories and isinstance(categories[0], dict): + category = categories[0].get("title") + geometry = item.get("geometry", []) + events.append( + NaturalEvent( + id=str(item.get("id") or f"eonet-{index}"), + title=str(item.get("title") or "Natural event"), + category=str(category) if category else None, + geometry={"items": geometry} if isinstance(geometry, list) else None, + properties=item, + ) + ) + return events diff --git a/integrations/us_disaster/fema.py b/integrations/us_disaster/fema.py new file mode 100644 index 0000000..90bf910 --- /dev/null +++ b/integrations/us_disaster/fema.py @@ -0,0 +1,65 @@ +"""FEMA OpenFEMA adapter.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import DisasterDeclaration +from integrations.common import http + +FEMA_DATASET = "https://www.fema.gov/api/open/v2/DisasterDeclarationsSummaries" + + +def get_recent_disaster_declarations(top: int = 5) -> dict[str, Any]: + raw = http.get_json(FEMA_DATASET, params={"$top": top}) + if not isinstance(raw, dict): + raise http.ApiError("FEMA response was not an object") + return raw + + +def get_declarations_by_state(state: str = "CA", top: int = 5) -> dict[str, Any]: + raw = http.get_json( + FEMA_DATASET, + params={"$filter": f"state eq '{state.upper()}'", "$top": top}, + ) + if not isinstance(raw, dict): + raise http.ApiError("FEMA state response was not an object") + return raw + + +def get_fire_declarations_by_state(state: str = "CA", top: int = 5) -> dict[str, Any]: + raw = http.get_json( + FEMA_DATASET, + params={ + "$filter": f"state eq '{state.upper()}' and incidentType eq 'Fire'", + "$top": top, + }, + ) + if not isinstance(raw, dict): + raise http.ApiError("FEMA fire response was not an object") + return raw + + +def normalize_declarations(raw: dict[str, Any]) -> list[DisasterDeclaration]: + declarations: list[DisasterDeclaration] = [] + rows = raw.get("value") or raw.get("DisasterDeclarationsSummaries") or [] + for index, item in enumerate(rows): + if not isinstance(item, dict): + continue + disaster_number = item.get("disasterNumber") + declarations.append( + DisasterDeclaration( + id=str(disaster_number or f"fema-{index}"), + state=_optional_str(item.get("state")), + county=_optional_str(item.get("designatedArea")), + incident_type=_optional_str(item.get("incidentType")), + title=_optional_str(item.get("declarationTitle")), + declaration_date=_optional_str(item.get("declarationDate")), + incident_begin_date=_optional_str(item.get("incidentBeginDate")), + ) + ) + return declarations + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/firms.py b/integrations/us_disaster/firms.py new file mode 100644 index 0000000..64edff6 --- /dev/null +++ b/integrations/us_disaster/firms.py @@ -0,0 +1,58 @@ +"""NASA FIRMS satellite fire detection adapter.""" + +from __future__ import annotations + +import csv +import os +from io import StringIO +from typing import Any + +from agent.mission_schemas import FireDetection +from integrations.common import http + +FIRMS_AREA_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv" + + +def get_viirs_fire_detections_bbox( + bbox: tuple[float, float, float, float], + days: int = 1, +) -> list[dict[str, Any]]: + map_key = http.require_env("FIRMS_MAP_KEY", os.getenv("FIRMS_MAP_KEY")) + bbox_text = ",".join(str(value) for value in bbox) + text = http.get_text(f"{FIRMS_AREA_URL}/{map_key}/VIIRS_SNPP_NRT/{bbox_text}/{days}") + return [item.model_dump() for item in parse_firms_csv(text)] + + +def parse_firms_csv(text: str) -> list[FireDetection]: + detections: list[FireDetection] = [] + for row in csv.DictReader(StringIO(text)): + lat = _optional_float(row.get("latitude")) + lon = _optional_float(row.get("longitude")) + if lat is None or lon is None: + continue + acquired_at = None + acq_date = row.get("acq_date") + acq_time = row.get("acq_time") + if acq_date: + acquired_at = f"{acq_date} {acq_time or ''}".strip() + detections.append( + FireDetection( + latitude=lat, + longitude=lon, + brightness=_optional_float(row.get("bright_ti4") or row.get("brightness")), + confidence=row.get("confidence"), + satellite=row.get("satellite"), + acquired_at=acquired_at, + properties=dict(row), + ) + ) + return detections + + +def _optional_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/integrations/us_disaster/hifld.py b/integrations/us_disaster/hifld.py new file mode 100644 index 0000000..fde0e2f --- /dev/null +++ b/integrations/us_disaster/hifld.py @@ -0,0 +1,100 @@ +"""HIFLD hospital and critical infrastructure adapters.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import Coord, InfrastructureSite +from integrations.common import http + +HOSPITALS_URL = ( + "https://services.arcgis.com/XG15cJAlne2vxtgt/ArcGIS/rest/services/" + "Hospitals_hifld/FeatureServer/0/query" +) +CRITICAL_INFRA_URL = ( + "https://services.arcgis.com/XG15cJAlne2vxtgt/ArcGIS/rest/services/" + "Critical_Infrastructure_Map_Service/FeatureServer" +) + + +def get_hospitals_by_state(state: str = "CA") -> dict[str, Any]: + raw = http.get_json( + HOSPITALS_URL, + params={ + "where": f"STATE='{state.upper()}'", + "outFields": "NAME,ADDRESS,CITY,STATE,TYPE", + "f": "geojson", + }, + ) + if not isinstance(raw, dict): + raise http.ApiError("HIFLD hospitals response was not an object") + return raw + + +def normalize_hospitals(raw: dict[str, Any]) -> list[InfrastructureSite]: + sites: list[InfrastructureSite] = [] + for index, feature in enumerate(raw.get("features", [])): + if not isinstance(feature, dict): + continue + props = feature.get("properties", {}) + if not isinstance(props, dict): + props = {} + sites.append( + InfrastructureSite( + id=str(props.get("ID") or props.get("OBJECTID") or f"hifld-hospital-{index}"), + name=str(props.get("NAME") or "Hospital"), + category="hospital", + address=_optional_str(props.get("ADDRESS")), + city=_optional_str(props.get("CITY")), + state=_optional_str(props.get("STATE")), + site_type=_optional_str(props.get("TYPE")), + coord=_coord_from_feature(feature), + properties=props, + ) + ) + return sites + + +def get_critical_infrastructure_layers() -> dict[str, Any]: + raw = http.get_json(CRITICAL_INFRA_URL, params={"f": "json"}) + if not isinstance(raw, dict): + raise http.ApiError("HIFLD critical infrastructure response was not an object") + return raw + + +def list_critical_infrastructure_layers() -> list[dict[str, Any]]: + raw = get_critical_infrastructure_layers() + layers = raw.get("layers", []) + return [layer for layer in layers if isinstance(layer, dict)] + + +def query_critical_infrastructure_layer( + layer_id: int, + where: str = "1=1", + out_fields: str = "*", +) -> dict[str, Any]: + raw = http.get_json( + f"{CRITICAL_INFRA_URL}/{layer_id}/query", + params={"where": where, "outFields": out_fields, "f": "geojson"}, + ) + if not isinstance(raw, dict): + raise http.ApiError("HIFLD critical infrastructure layer response was not an object") + return raw + + +def _coord_from_feature(feature: dict[str, Any]) -> Coord | None: + geometry = feature.get("geometry") + if not isinstance(geometry, dict): + return None + coordinates = geometry.get("coordinates") + if not isinstance(coordinates, list) or len(coordinates) < 2: + return None + lon = coordinates[0] + lat = coordinates[1] + if not isinstance(lat, int | float) or not isinstance(lon, int | float): + return None + return Coord(lat=float(lat), lon=float(lon)) + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/nifc_wfigs.py b/integrations/us_disaster/nifc_wfigs.py new file mode 100644 index 0000000..e4bcabe --- /dev/null +++ b/integrations/us_disaster/nifc_wfigs.py @@ -0,0 +1,45 @@ +"""NIFC/WFIGS current fire perimeter adapter.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import HazardPolygon +from integrations.common import http + +WFIGS_URL = ( + "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/" + "WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query" +) + + +def get_current_fire_perimeters() -> dict[str, Any]: + raw = http.get_json(WFIGS_URL, params={"where": "1=1", "outFields": "*", "f": "geojson"}) + if not isinstance(raw, dict): + raise http.ApiError("WFIGS response was not an object") + return raw + + +def normalize_fire_perimeters(raw: dict[str, Any]) -> list[HazardPolygon]: + polygons: list[HazardPolygon] = [] + for index, feature in enumerate(raw.get("features", [])): + if not isinstance(feature, dict): + continue + props = feature.get("properties", {}) + if not isinstance(props, dict): + props = {} + polygons.append( + HazardPolygon( + id=str(props.get("poly_IRWINID") or props.get("OBJECTID") or f"wfigs-{index}"), + source="NIFC WFIGS", + name=str( + props.get("poly_IncidentName") or props.get("IncidentName") or "Fire perimeter" + ), + incident_type="wildfire", + geometry=feature.get("geometry") + if isinstance(feature.get("geometry"), dict) + else None, + properties=props, + ) + ) + return polygons diff --git a/integrations/us_disaster/noaa_nwps.py b/integrations/us_disaster/noaa_nwps.py new file mode 100644 index 0000000..628503e --- /dev/null +++ b/integrations/us_disaster/noaa_nwps.py @@ -0,0 +1,32 @@ +"""NOAA National Water Prediction Service adapter.""" + +from __future__ import annotations + +from typing import Any + +from integrations.common import http + +NWPS_DOCS_URL = "https://api.water.noaa.gov/nwps/v1/docs/" + + +def get_nwps_docs_or_health() -> dict[str, Any]: + try: + raw = http.get_json(NWPS_DOCS_URL) + except http.ApiError: + text = http.get_text(NWPS_DOCS_URL) + return { + "status": "ok", + "content_type": "html", + "bytes": len(text), + "url": NWPS_DOCS_URL, + } + if isinstance(raw, dict): + return raw + return {"status": "ok", "raw": raw} + + +def todo_supported_endpoints() -> list[str]: + return [ + "Inspect NWPS OpenAPI docs for observed/forecast flood endpoints.", + "Add reach/station forecast lookup once stable schema is selected.", + ] diff --git a/integrations/us_disaster/nrel_fuel.py b/integrations/us_disaster/nrel_fuel.py new file mode 100644 index 0000000..ddd0e2a --- /dev/null +++ b/integrations/us_disaster/nrel_fuel.py @@ -0,0 +1,71 @@ +"""NREL Alternative Fuel Stations API adapter.""" + +from __future__ import annotations + +import os +from typing import Any + +from agent.mission_schemas import Coord, FuelStation +from integrations.common import http + +NREL_NEAREST_URL = "https://developer.nrel.gov/api/alt-fuel-stations/v1/nearest.json" + + +def get_nearest_fuel_stations(lat: float, lon: float, radius: int = 50) -> dict[str, Any]: + api_key = http.require_env("NREL_API_KEY", os.getenv("NREL_API_KEY")) + raw = http.get_json( + NREL_NEAREST_URL, + params={ + "api_key": api_key, + "latitude": lat, + "longitude": lon, + "fuel_type": "LNG,CNG,BD,RD,LPG,ELEC", + "radius": radius, + }, + ) + if not isinstance(raw, dict): + raise http.ApiError("NREL response was not an object") + return raw + + +def normalize_fuel_stations(raw: dict[str, Any]) -> list[FuelStation]: + stations: list[FuelStation] = [] + for index, item in enumerate(raw.get("fuel_stations", [])): + if not isinstance(item, dict): + continue + stations.append( + FuelStation( + id=str(item.get("id") or f"nrel-{index}"), + name=str(item.get("station_name") or "Fuel station"), + fuel_types=_fuel_types(item), + address=_optional_str(item.get("street_address")), + coord=_coord_from_item(item), + distance_miles=_optional_float(item.get("distance")), + ) + ) + return stations + + +def _fuel_types(item: dict[str, Any]) -> list[str]: + code = item.get("fuel_type_code") + groups = item.get("groups_with_access_code") + values = [value for value in (code, groups) if value] + return [str(value) for value in values] + + +def _coord_from_item(item: dict[str, Any]) -> Coord | None: + lat = _optional_float(item.get("latitude")) + lon = _optional_float(item.get("longitude")) + if lat is None or lon is None: + return None + return Coord(lat=lat, lon=lon) + + +def _optional_float(value: Any) -> float | None: + if isinstance(value, int | float): + return float(value) + return None + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/nws.py b/integrations/us_disaster/nws.py new file mode 100644 index 0000000..3d168c3 --- /dev/null +++ b/integrations/us_disaster/nws.py @@ -0,0 +1,53 @@ +"""NOAA/NWS Alerts API adapter.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import HazardAlert +from integrations.common import http + +NWS_BASE = "https://api.weather.gov" + + +def get_active_alerts(area: str = "CA") -> dict[str, Any]: + raw = http.get_json(f"{NWS_BASE}/alerts/active", params={"area": area.upper()}) + if not isinstance(raw, dict): + raise http.ApiError("NWS alerts response was not an object") + return raw + + +def get_point_metadata(lat: float, lon: float) -> dict[str, Any]: + raw = http.get_json(f"{NWS_BASE}/points/{lat},{lon}") + if not isinstance(raw, dict): + raise http.ApiError("NWS points response was not an object") + return raw + + +def normalize_alerts(raw: dict[str, Any]) -> list[HazardAlert]: + alerts: list[HazardAlert] = [] + for index, feature in enumerate(raw.get("features", [])): + if not isinstance(feature, dict): + continue + props = feature.get("properties", {}) + if not isinstance(props, dict): + props = {} + alerts.append( + HazardAlert( + id=str(props.get("id") or feature.get("id") or f"nws-{index}"), + event=str(props.get("event") or "Weather alert"), + severity=_string_or_none(props.get("severity")), + urgency=_string_or_none(props.get("urgency")), + certainty=_string_or_none(props.get("certainty")), + area_desc=_string_or_none(props.get("areaDesc")), + instruction=_string_or_none(props.get("instruction")), + geometry=feature.get("geometry") + if isinstance(feature.get("geometry"), dict) + else None, + ) + ) + return alerts + + +def _string_or_none(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/reliefweb.py b/integrations/us_disaster/reliefweb.py new file mode 100644 index 0000000..c15ec12 --- /dev/null +++ b/integrations/us_disaster/reliefweb.py @@ -0,0 +1,70 @@ +"""ReliefWeb humanitarian reports adapter.""" + +from __future__ import annotations + +import os +from typing import Any + +from agent.mission_schemas import HumanitarianReport +from integrations.common import http + +RELIEFWEB_REPORTS_URL = "https://api.reliefweb.int/v2/reports" + + +def get_recent_us_reports(limit: int = 5) -> dict[str, Any]: + appname = os.getenv("RELIEFWEB_APPNAME") + if not appname: + return { + "skipped": "missing_RELIEFWEB_APPNAME", + "data": [], + "note": "ReliefWeb v2 requires a pre-approved appname.", + } + body = { + "limit": limit, + "sort": ["date:desc"], + "filter": {"field": "country.name", "value": "United States of America"}, + "fields": {"include": ["title", "date", "url", "country", "disaster"]}, + } + raw = http.post_json( + f"{RELIEFWEB_REPORTS_URL}?appname={appname}", + json_body=body, + headers={"Content-Type": "application/json"}, + ) + if not isinstance(raw, dict): + raise http.ApiError("ReliefWeb response was not an object") + return raw + + +def normalize_reports(raw: dict[str, Any]) -> list[HumanitarianReport]: + reports: list[HumanitarianReport] = [] + for index, item in enumerate(raw.get("data", [])): + if not isinstance(item, dict): + continue + fields = item.get("fields", {}) + if not isinstance(fields, dict): + fields = {} + reports.append( + HumanitarianReport( + id=str(item.get("id") or f"reliefweb-{index}"), + title=str(fields.get("title") or "Humanitarian report"), + date=_optional_str(fields.get("date")), + url=_optional_str(fields.get("url")), + country=_field_name(fields.get("country")), + disaster=_field_name(fields.get("disaster")), + ) + ) + return reports + + +def _field_name(value: Any) -> str | None: + if isinstance(value, list) and value: + first = value[0] + if isinstance(first, dict): + return _optional_str(first.get("name")) + if isinstance(value, dict): + return _optional_str(value.get("name")) + return _optional_str(value) + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/sf511.py b/integrations/us_disaster/sf511.py new file mode 100644 index 0000000..ffc98ee --- /dev/null +++ b/integrations/us_disaster/sf511.py @@ -0,0 +1,54 @@ +"""SF Bay 511 traffic events adapter.""" + +from __future__ import annotations + +import os +from typing import Any + +from agent.mission_schemas import Coord, TrafficEvent +from integrations.common import http + +SF511_URL = "https://api.511.org/traffic/events" + + +def get_traffic_events() -> dict[str, Any] | list[dict[str, Any]]: + api_key = http.require_env("SF511_API_KEY", os.getenv("SF511_API_KEY")) + raw = http.get_json(SF511_URL, params={"api_key": api_key}) + if not isinstance(raw, dict | list): + raise http.ApiError("SF511 response was not an object or list") + return raw + + +def normalize_traffic_events(raw: dict[str, Any] | list[dict[str, Any]]) -> list[TrafficEvent]: + items: list[Any] + if isinstance(raw, dict): + items = raw.get("events") or raw.get("Events") or raw.get("data") or [] + else: + items = raw + events: list[TrafficEvent] = [] + for index, item in enumerate(items): + if not isinstance(item, dict): + continue + events.append( + TrafficEvent( + id=str(item.get("id") or item.get("ID") or f"sf511-{index}"), + event_type=_optional_str(item.get("event_type") or item.get("EventType")), + description=_optional_str(item.get("description") or item.get("Description")), + severity=_optional_str(item.get("severity") or item.get("Severity")), + coord=_coord_from_item(item), + properties=item, + ) + ) + return events + + +def _coord_from_item(item: dict[str, Any]) -> Coord | None: + lat = item.get("lat") or item.get("Latitude") + lon = item.get("lon") or item.get("Longitude") + if not isinstance(lat, int | float) or not isinstance(lon, int | float): + return None + return Coord(lat=float(lat), lon=float(lon)) + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/usgs_earthquake.py b/integrations/us_disaster/usgs_earthquake.py new file mode 100644 index 0000000..11f0a61 --- /dev/null +++ b/integrations/us_disaster/usgs_earthquake.py @@ -0,0 +1,73 @@ +"""USGS earthquake feed adapter.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import Coord, EarthquakeEvent +from integrations.common import http + +SIGNIFICANT_WEEK_URL = ( + "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/significant_week.geojson" +) + + +def get_significant_earthquakes_week() -> dict[str, Any]: + raw = http.get_json(SIGNIFICANT_WEEK_URL) + if not isinstance(raw, dict): + raise http.ApiError("USGS earthquake response was not an object") + return raw + + +def normalize_earthquakes(raw: dict[str, Any]) -> list[EarthquakeEvent]: + events: list[EarthquakeEvent] = [] + for index, feature in enumerate(raw.get("features", [])): + if not isinstance(feature, dict): + continue + props = feature.get("properties", {}) + if not isinstance(props, dict): + props = {} + coord, depth_km = _coord_from_feature(feature) + events.append( + EarthquakeEvent( + id=str(feature.get("id") or f"usgs-quake-{index}"), + magnitude=_optional_float(props.get("mag")), + place=_optional_str(props.get("place")), + time=_optional_int(props.get("time")), + coord=coord, + depth_km=depth_km, + url=_optional_str(props.get("url")), + ) + ) + return events + + +def _coord_from_feature(feature: dict[str, Any]) -> tuple[Coord | None, float | None]: + geometry = feature.get("geometry") + if not isinstance(geometry, dict): + return None, None + coordinates = geometry.get("coordinates") + if not isinstance(coordinates, list) or len(coordinates) < 2: + return None, None + lon = _optional_float(coordinates[0]) + lat = _optional_float(coordinates[1]) + depth = _optional_float(coordinates[2]) if len(coordinates) > 2 else None + if lat is None or lon is None: + return None, depth + return Coord(lat=lat, lon=lon), depth + + +def _optional_float(value: Any) -> float | None: + if isinstance(value, int | float): + return float(value) + return None + + +def _optional_int(value: Any) -> int | None: + if isinstance(value, int): + return value + return None + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/integrations/us_disaster/usgs_water.py b/integrations/us_disaster/usgs_water.py new file mode 100644 index 0000000..2cbbec4 --- /dev/null +++ b/integrations/us_disaster/usgs_water.py @@ -0,0 +1,84 @@ +"""USGS water services adapter.""" + +from __future__ import annotations + +from typing import Any + +from agent.mission_schemas import WaterObservation +from integrations.common import http + +USGS_WATER_URL = "https://waterservices.usgs.gov/nwis/iv/" + + +def get_streamflow_and_gage_height(state: str = "ca") -> dict[str, Any]: + raw = http.get_json( + USGS_WATER_URL, + params={ + "format": "json", + "stateCd": state.lower(), + "parameterCd": "00060,00065", + "siteStatus": "active", + }, + ) + if not isinstance(raw, dict): + raise http.ApiError("USGS water response was not an object") + return raw + + +def normalize_water_observations(raw: dict[str, Any]) -> list[WaterObservation]: + observations: list[WaterObservation] = [] + time_series = raw.get("value", {}).get("timeSeries", []) + if not isinstance(time_series, list): + return observations + for index, series in enumerate(time_series): + if not isinstance(series, dict): + continue + source_info = series.get("sourceInfo", {}) + variable = series.get("variable", {}) + values = series.get("values", []) + latest_value = _latest_value(values) + if not isinstance(source_info, dict): + source_info = {} + if not isinstance(variable, dict): + variable = {} + observations.append( + WaterObservation( + site_id=str( + source_info.get("siteCode", [{}])[0].get("value") or f"usgs-water-{index}" + ), + site_name=_optional_str(source_info.get("siteName")), + parameter=_optional_str(variable.get("variableName")), + value=_optional_float(latest_value.get("value")) if latest_value else None, + unit=_optional_str(variable.get("unit", {}).get("unitCode")) + if isinstance(variable.get("unit"), dict) + else None, + observed_at=_optional_str(latest_value.get("dateTime")) if latest_value else None, + ) + ) + return observations + + +def _latest_value(values: Any) -> dict[str, Any] | None: + if not isinstance(values, list) or not values: + return None + first_group = values[0] + if not isinstance(first_group, dict): + return None + raw_values = first_group.get("value", []) + if not isinstance(raw_values, list) or not raw_values: + return None + latest = raw_values[-1] + return latest if isinstance(latest, dict) else None + + +def _optional_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/pyproject.toml b/pyproject.toml index cc292f5..116fa5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,13 +54,13 @@ voice = [ ] [tool.setuptools.packages.find] -include = ["agent*", "routing*", "atak*", "voice*", "crypto*", "security*", "eval*", "ontology*"] +include = ["agent*", "routing*", "atak*", "voice*", "crypto*", "security*", "eval*", "ontology*", "integrations*"] exclude = ["tests*", "scripts*", "docs*", "figma*", "hardware*", "deploy*", "models*", "mesh*", "data*", "infra*"] [tool.ruff] line-length = 100 target-version = "py311" -src = ["agent", "routing", "atak", "voice", "crypto", "security", "eval", "ontology", "tests"] +src = ["agent", "routing", "atak", "voice", "crypto", "security", "eval", "ontology", "integrations", "tests"] # Kyle's local LLM dev sandbox + tests of the same. Has its own deps + its own # style; not subject to repo-wide ruff config. Track in GH issue if/when we # merge it into the main app. @@ -95,7 +95,7 @@ ignore = [ "scripts/*" = ["T201"] [tool.ruff.lint.isort] -known-first-party = ["agent", "routing", "atak", "voice", "crypto", "security", "eval", "ontology"] +known-first-party = ["agent", "routing", "atak", "voice", "crypto", "security", "eval", "ontology", "integrations"] [tool.mypy] python_version = "3.11" diff --git a/scripts/test_live_apis.py b/scripts/test_live_apis.py new file mode 100644 index 0000000..e9ef7e1 --- /dev/null +++ b/scripts/test_live_apis.py @@ -0,0 +1,109 @@ +"""Optional live API smoke test for TERA v2. + +Normal unit tests do not call the network. This script is for operator/demo +preflight only. It skips key-based APIs when the relevant environment variable +is missing and keeps running if any single API fails. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Callable +from typing import Any + +from agent.mission_schemas import Coord +from integrations.google import maps_routes +from integrations.security import safe_browsing, urlscan, virustotal +from integrations.us_disaster import ( + airnow, + bridge_inventory, + eonet, + fema, + hifld, + nifc_wfigs, + noaa_nwps, + nrel_fuel, + nws, + reliefweb, + sf511, + usgs_earthquake, + usgs_water, +) + + +def main() -> None: + submit_urlscan = "--submit-urlscan" in sys.argv + checks: list[tuple[str, str | None, Callable[[], Any]]] = [ + ("NOAA/NWS Alerts", None, lambda: nws.get_active_alerts("CA")), + ("NOAA/NWS Points", None, lambda: nws.get_point_metadata(37.7749, -122.4194)), + ("FEMA OpenFEMA", None, lambda: fema.get_recent_disaster_declarations(5)), + ("HIFLD Hospitals", None, lambda: hifld.get_hospitals_by_state("CA")), + ( + "HIFLD Critical Infrastructure", + None, + hifld.get_critical_infrastructure_layers, + ), + ("WFIGS Fire Perimeters", None, nifc_wfigs.get_current_fire_perimeters), + ("USGS Earthquake", None, usgs_earthquake.get_significant_earthquakes_week), + ("USGS Water", None, lambda: usgs_water.get_streamflow_and_gage_height("ca")), + ("NOAA NWPS", None, noaa_nwps.get_nwps_docs_or_health), + ( + "National Bridge Inventory", + None, + lambda: bridge_inventory.get_bridge_inventory_sample(10), + ), + ("NASA EONET", None, lambda: eonet.get_open_events(20)), + ("ReliefWeb", "RELIEFWEB_APPNAME", lambda: reliefweb.get_recent_us_reports(5)), + ( + "Google Routes", + "GOOGLE_MAPS_API_KEY", + lambda: maps_routes.compute_routes( + Coord(lat=37.7749, lon=-122.4194), + Coord(lat=37.6879, lon=-122.4702), + ), + ), + ("AirNow", "AIRNOW_API_KEY", lambda: airnow.get_current_air_quality(37.7749, -122.4194)), + ("SF511", "SF511_API_KEY", sf511.get_traffic_events), + ( + "NREL Fuel", + "NREL_API_KEY", + lambda: nrel_fuel.get_nearest_fuel_stations(37.7749, -122.4194), + ), + ( + "Google Safe Browsing", + "GOOGLE_SAFE_BROWSING_API_KEY", + lambda: safe_browsing.check_url_threats(["https://www.fema.gov/"]), + ), + ( + "VirusTotal URL", + "VT_API_KEY", + lambda: virustotal.get_url_report("https://www.fema.gov/"), + ), + ("VirusTotal Domain", "VT_API_KEY", lambda: virustotal.get_domain_report("fema.gov")), + ] + if submit_urlscan: + checks.append( + ( + "urlscan Submit", + "URLSCAN_API_KEY", + lambda: urlscan.submit_url_scan("https://www.fema.gov/", visibility="unlisted"), + ) + ) + else: + print("urlscan Submit: SKIPPED use --submit-urlscan to consume quota") + + for name, env_var, check in checks: + if env_var and not os.getenv(env_var): + print(f"{name}: SKIPPED missing {env_var}") + continue + try: + check() + except Exception as exc: # noqa: BLE001 + print(f"{name}: WARN {exc}") + continue + print(f"{name}: OK") + + +if __name__ == "__main__": + main() diff --git a/tests/test_hifld_adapter.py b/tests/test_hifld_adapter.py new file mode 100644 index 0000000..2607f5f --- /dev/null +++ b/tests/test_hifld_adapter.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from integrations.us_disaster import hifld + + +def test_normalize_hospitals_geojson() -> None: + raw = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-122.42, 37.77]}, + "properties": { + "OBJECTID": 7, + "NAME": "Hospital B", + "ADDRESS": "1 Mission St", + "CITY": "San Francisco", + "STATE": "CA", + "TYPE": "GENERAL ACUTE CARE", + }, + } + ], + } + + hospitals = hifld.normalize_hospitals(raw) + + assert hospitals[0].name == "Hospital B" + assert hospitals[0].category == "hospital" + assert hospitals[0].coord is not None + assert hospitals[0].coord.lat == 37.77 + + +def test_list_critical_infrastructure_layers(monkeypatch) -> None: + monkeypatch.setattr( + hifld, + "get_critical_infrastructure_layers", + lambda: {"layers": [{"id": 1, "name": "Fire Stations"}, "bad"]}, + ) + + layers = hifld.list_critical_infrastructure_layers() + + assert layers == [{"id": 1, "name": "Fire Stations"}] diff --git a/tests/test_mission_orchestrator_fallback.py b/tests/test_mission_orchestrator_fallback.py new file mode 100644 index 0000000..0e1f72e --- /dev/null +++ b/tests/test_mission_orchestrator_fallback.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from agent.app import app +from agent.mission_orchestrator import demo_bay_area_wildfire, mission_api_status, plan_mission +from agent.mission_schemas import Coord, MissionPlanRequest + + +def test_mission_orchestrator_uses_offline_fallback() -> None: + req = MissionPlanRequest( + prompt="Plan wildfire supplies to cleaner shelter.", + current=Coord(lat=37.7749, lon=-122.4194), + incident_type="wildfire", + area="CA", + ) + + resp = plan_mission(req) + + assert resp.offline_fallback["used"] is True + assert resp.route_candidates[0].provider == "offline_fallback" + assert any("Shelter West" in action.action for action in resp.recommended_actions) + assert resp.resource_allocations + + +def test_mission_demo_endpoint_returns_plan() -> None: + client = TestClient(app) + + r = client.get("/mission/demo/bay-area-wildfire") + + assert r.status_code == 200 + body = r.json() + assert body["incident_summary"]["incident_type"] == "wildfire" + assert body["offline_fallback"]["used"] is True + + +def test_mission_api_status_does_not_expose_values(monkeypatch) -> None: + monkeypatch.setenv("GOOGLE_MAPS_API_KEY", "secret-value") + + status = mission_api_status() + + assert status["GOOGLE_MAPS_API_KEY"] is True + assert "secret-value" not in str(status) + + +def test_demo_bay_area_wildfire_selects_west_shelter() -> None: + resp = demo_bay_area_wildfire() + + assert resp.incident_summary["route_target"] == "Shelter West" diff --git a/tests/test_mission_schemas.py b/tests/test_mission_schemas.py new file mode 100644 index 0000000..9af170d --- /dev/null +++ b/tests/test_mission_schemas.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from agent.mission_schemas import ( + Coord, + MissionPlanRequest, + MissionPlanResponse, + Resource, + Shelter, + SupplyNeed, + Vehicle, +) + + +def test_mission_plan_request_accepts_emergency_payload() -> None: + req = MissionPlanRequest( + prompt="Plan wildfire supply route to the safest shelter.", + current=Coord(lat=37.7749, lon=-122.4194), + incident_type="wildfire", + resources=[Resource(name="water", quantity=100, unit="liters")], + shelters=[ + Shelter( + id="shelter-west", + name="Shelter West", + coord=Coord(lat=37.68, lon=-122.47), + capacity=400, + occupancy=200, + needs=[SupplyNeed(resource="water", quantity=50, unit="liters")], + ) + ], + vehicles=[Vehicle(id="truck-1", name="Truck 1")], + ) + + assert req.incident_type == "wildfire" + assert req.use_live_apis is False + + +def test_mission_plan_response_minimal_shape() -> None: + resp = MissionPlanResponse(explanation="Offline fallback plan ready.") + + assert resp.hazards == [] + assert resp.critical_infrastructure == [] + assert "Offline" in resp.explanation diff --git a/tests/test_nws_adapter.py b/tests/test_nws_adapter.py new file mode 100644 index 0000000..3149be4 --- /dev/null +++ b/tests/test_nws_adapter.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from integrations.us_disaster import nws + + +def test_normalize_alerts() -> None: + raw = { + "features": [ + { + "id": "alert-1", + "geometry": {"type": "Polygon", "coordinates": []}, + "properties": { + "id": "nws-alert-1", + "event": "Red Flag Warning", + "severity": "Severe", + "urgency": "Expected", + "certainty": "Likely", + "areaDesc": "Bay Area", + "instruction": "Avoid exposed ridges.", + }, + } + ] + } + + alerts = nws.normalize_alerts(raw) + + assert len(alerts) == 1 + assert alerts[0].id == "nws-alert-1" + assert alerts[0].event == "Red Flag Warning" + assert alerts[0].severity == "Severe" diff --git a/tests/test_safe_browsing_adapter.py b/tests/test_safe_browsing_adapter.py new file mode 100644 index 0000000..3985455 --- /dev/null +++ b/tests/test_safe_browsing_adapter.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from integrations.security import safe_browsing + + +def test_build_safe_browsing_request() -> None: + req = safe_browsing.build_safe_browsing_request(["https://example.com"]) + + assert req["threatInfo"]["platformTypes"] == ["ANY_PLATFORM"] + assert "SOCIAL_ENGINEERING" in req["threatInfo"]["threatTypes"] + assert req["threatInfo"]["threatEntries"] == [{"url": "https://example.com"}] + + +def test_normalize_safe_browsing_match_response() -> None: + raw = { + "matches": [ + { + "threatType": "SOCIAL_ENGINEERING", + "threat": {"url": "https://fake-fema.example/login"}, + } + ] + } + + results = safe_browsing.normalize_safe_browsing_response( + raw, + ["https://fake-fema.example/login"], + ) + + assert results[0].checked is True + assert results[0].matched is True + assert results[0].threat_types == ["SOCIAL_ENGINEERING"] + + +def test_normalize_safe_browsing_no_match_response() -> None: + results = safe_browsing.normalize_safe_browsing_response({}, ["https://www.fema.gov/"]) + + assert results[0].checked is True + assert results[0].matched is False + + +def test_safe_browsing_missing_key_fallback(monkeypatch) -> None: + monkeypatch.delenv("GOOGLE_SAFE_BROWSING_API_KEY", raising=False) + + results = safe_browsing.check_url_threats(["https://example.com"]) + + assert results[0].checked is False + assert results[0].raw == {"skipped": "missing_api_key"} diff --git a/tests/test_trust_tool_fallback.py b/tests/test_trust_tool_fallback.py new file mode 100644 index 0000000..06ef12a --- /dev/null +++ b/tests/test_trust_tool_fallback.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from agent.app import app +from agent.mission_orchestrator import demo_bay_area_wildfire +from agent.tools import misinformation +from agent.tools.trust import assess_supply_request_trust + + +def test_supply_request_to_unverified_shelter_requires_approval() -> None: + assessment = assess_supply_request_trust( + { + "source": "unknown", + "destination": "Unverified Shelter X", + "requested_items": {"medical_kits": 500}, + "urgency": "critical", + "verified_shelters": ["Shelter West"], + } + ) + + assert assessment.requires_human_approval is True + assert any(signal.code == "UNVERIFIED_DESTINATION" for signal in assessment.signals) + + +def test_message_conflicts_with_official_shelter_list() -> None: + assessment = misinformation.detect_unverified_shelter_claim( + "Go to Unverified Shelter X immediately.", + [{"name": "Shelter West"}], + ) + + assert assessment.requires_human_approval is True + assert assessment.signals[0].code == "UNVERIFIED_SHELTER_CLAIM" + + +def test_trust_endpoint_does_not_expose_secret(monkeypatch) -> None: + monkeypatch.setenv("GOOGLE_SAFE_BROWSING_API_KEY", "secret-key") + client = TestClient(app) + + r = client.get("/trust/api-status") + + assert r.status_code == 200 + assert r.json()["GOOGLE_SAFE_BROWSING_API_KEY"] is True + assert "secret-key" not in r.text + + +def test_mission_demo_includes_trust_shield_findings() -> None: + resp = demo_bay_area_wildfire() + + assert resp.trust_assessments + assert resp.blocked_or_needs_approval + assert any("Unverified Shelter X" in claim for claim in resp.unverified_claims) diff --git a/tests/test_url_risk.py b/tests/test_url_risk.py new file mode 100644 index 0000000..ba58a02 --- /dev/null +++ b/tests/test_url_risk.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from integrations.security.url_risk import assess_url_risk, score_url_heuristics + + +def test_heuristic_flags_gov_impersonation() -> None: + signals = score_url_heuristics("https://fema-aid-claim-example.com/login") + + codes = {signal.code for signal in signals} + assert "GOV_IMPERSONATION" in codes + assert "CRISIS_KEYWORDS" in codes + + +def test_heuristic_flags_url_shortener() -> None: + signals = score_url_heuristics("https://bit.ly/fema-aid") + + assert any(signal.code == "URL_SHORTENER" for signal in signals) + + +def test_assess_url_risk_offline_mode_requires_approval() -> None: + assessment = assess_url_risk( + "https://fema-aid-claim-example.com/login", + "wildfire relief donation link", + use_live_providers=False, + ) + + assert assessment.risk_level in {"high", "critical"} + assert assessment.requires_human_approval is True + assert "safe_browsing_offline_mode" in assessment.skipped_sources