From a9e2ef4ad6c1f0c748e014dd07437e5c39bcf066 Mon Sep 17 00:00:00 2001 From: "Paul J. Swider" Date: Thu, 4 Jun 2026 19:18:52 +0000 Subject: [PATCH] feat(lookout): publish ambient health skill Add the tenant-neutral Lookout skill so main ships the environmental awareness workflow without user-specific location or VM data. --- skills/lookout/README.md | 84 +++++ skills/lookout/SKILL.md | 95 +++++ skills/lookout/schema.sql | 108 ++++++ skills/lookout/scripts/fetch_environment.py | 377 ++++++++++++++++++++ skills/lookout/scripts/init_db.py | 53 +++ 5 files changed, 717 insertions(+) create mode 100644 skills/lookout/README.md create mode 100644 skills/lookout/SKILL.md create mode 100644 skills/lookout/schema.sql create mode 100644 skills/lookout/scripts/fetch_environment.py create mode 100644 skills/lookout/scripts/init_db.py diff --git a/skills/lookout/README.md b/skills/lookout/README.md new file mode 100644 index 0000000..09278e5 --- /dev/null +++ b/skills/lookout/README.md @@ -0,0 +1,84 @@ +# Lookout + +Personalized ambient health awareness for Tula. + +Your health radar. Lookout reads where you are right now and tells you what about your surroundings matters to your health, given your actual conditions, medications, and recent labs. A weather app tells everyone the same AQI. Lookout tells you the part that matters for you, and stays quiet about the rest. + +Trigger: "lookout for me". + +## What it does + +- A daily morning briefing on Telegram: a short, personalized health forecast for where you are. +- On-demand answers: "how's the air today?", "where can I work out right now?", "anything I should know?", "what's [ZIP] like for me?" +- Quietly builds an environmental exposure history in your longitudinal record, and captures the neighborhood layer your EHR never sees. + +## Why it is more than a weather app + +The environmental data is a commodity. The value is the fusion. Lookout reads your local FHIR record (conditions, medications, recent labs) and triages the ambient data against it, surfacing only the relevant signal with an option or a question. Examples: + +- You have asthma and ozone is high: it says so, and offers an indoor option or a rescue-inhaler refill check. +- One of your medications increases sun sensitivity and UV is high: it flags it. +- You take a diuretic and the heat index is climbing: it raises hydration and points to cooling options. +- You have a migraine history and pressure is dropping sharply tonight: it gives you a heads-up. + +It never diagnoses, never recommends treatment, and never adjusts a medication. It surfaces data and a question. + +## The data Lookout pulls + +Endpoints, field names, and key requirements below reflect provider behavior as of early 2026. Verify against current provider documentation at build time. + +Air and atmosphere +- Air quality / AQI: AirNow (US, free, free API key). Optional hyperlocal and global: PurpleAir, OpenAQ. Optional granular: Google Air Quality (keyed, paid). +- UV index: EPA UV Index, data.gov (US, free). +- Pollen (later): Google Pollen or Ambee (keyed, species-level). +- Wildfire smoke (later): NOAA HMS smoke product. + +Weather stress and alerts +- Weather and active alerts: National Weather Service, api.weather.gov (US, free, no key, requires User-Agent). +- Barometric pressure and trend, global fallback: Open-Meteo (free, no key). + +Public-health surveillance (later) +- Respiratory virus activity (flu, COVID, RSV), including wastewater: CDC (free). + +Resources around you +- Gyms, grocery, pharmacy, urgent care, parks: Google Maps Platform Places (keyed, paid). Farmers markets: USDA Local Food Directories (free). Walkability: Walk Score. + +Place-as-health: the SDoH overlay +- CDC PLACES (local chronic-disease and risk estimates), CDC/ATSDR Social Vulnerability Index, Area Deprivation Index, USDA Food Access Atlas, EPA EJScreen and Walkability Index, EPA radon zones. All free and public. +- Captured as SDoH context and fed into Tula's SDoH pipeline. Coded with ICD-10 Z codes where one applies, as clinical context only. + +Free, government-first sourcing keeps Lookout aligned with Tula's open core (Apache 2.0) and avoids per-call cost and vendor lock-in. The paid pieces (Places, optional Google pollen and AQI) are opt-in. + +## Privacy + +Lookout sends only a location (latitude/longitude or ZIP) to the public data providers. Your conditions, medications, and labs never leave your VM to reach those providers. The personalized triage runs through Tula's already-configured model path: a HIPAA-eligible hosted tier, or a local open-weight model for fully air-gapped operation. No environmental provider ever sees PHI. + +## Setup + +1. Initialize the local SQLite store: `python3 scripts/init_db.py`. +2. Configure at least one location (label plus latitude/longitude or ZIP) through onboarding or the user profile. The default location drives the daily briefing. Location is user data and is not stored in the repo. +3. Ensure the Python dependency is installed: `python3 -m pip install httpx` or the package-manager equivalent. +4. Set units (imperial by default for US), the briefing time, and quiet hours. +5. Provide API access: + - AirNow free API key (set `AIRNOW_API_KEY`). + - A descriptive User-Agent for NWS (set `LOOKOUT_NWS_UA`). + - A Google Maps Platform key for the resources layer (optional). + - A free CDC Socrata app token for higher SDoH rate limits (optional). +6. Run the fetch (`scripts/fetch_environment.py`) on a schedule, then let the agent triage and brief. + +## Storage + +- Working store: SQLite (`schema.sql`). Every value carries its source, timestamp, unit, reference range or category band, and a raw source id, so everything Lookout surfaces is traceable. +- Longitudinal record: durable readings are written as FHIR R4 `Observation` resources (environmental exposure, and SDoH as social-history with a Z code where applicable). Patient-generated health data per the ONC definition. + +## Roadmap + +v1: AirNow AQI, NWS weather and alerts, EPA UV, Open-Meteo pressure, Places resources, CDC PLACES and SVI overlay, daily briefing, on-demand queries, FHIR Observation logging. + +Later: pollen species, respiratory-virus surveillance, wildfire smoke, water advisories, radon, polished travel and relocation mode, correlation insight (for example flare days against PM2.5), wearable cross-reference. + +## Safety and scope + +Lookout is not a medical device. It surfaces data and questions. It is health-data only: it never produces billing, payor, prior-auth, EOB, or medical-coding content. SDoH Z codes are the only codes used, and only as clinical context. For potentially urgent conditions, Lookout surfaces the item prominently and points to the care team or appropriate action, the open-core counterpart to Aria's configurable escalation policy. + +Part of Tula. https://github.com/realactivity/tula | https://realactivity.ai diff --git a/skills/lookout/SKILL.md b/skills/lookout/SKILL.md new file mode 100644 index 0000000..f9a65d3 --- /dev/null +++ b/skills/lookout/SKILL.md @@ -0,0 +1,95 @@ +--- +name: lookout +description: "Personalized ambient health awareness. Pulls environmental, public-health, and place-based data for the user's location and triages it against their longitudinal record, surfacing only what matters. USE FOR: 'lookout for me' briefings, ambient/air/UV/weather/pressure checks, place-based resource queries, travel-mode 'what's [ZIP] like for me'. DO NOT USE FOR: diagnosing, recommending or changing treatment, billing/payor/coding content, or sending PHI to any environmental provider." +metadata: + { + "openclaw": + { + "emoji": "🛰️", + "requires": { "bins": ["python3"] } + } + } +--- + +# Lookout + +Ambient health radar. Lookout reads where the user is right now, fuses environmental + place + neighborhood SDoH data with their FHIR record, and surfaces only the items that matter for *them*. A weather app says "AQI 142." Lookout says "ozone is high, you have asthma on file, want an indoor option?" + +## When to Use + +✅ Use when: + +- The user says "lookout for me" or any natural variant ("how's the air today?", "where can I work out right now?", "anything I should know today?", "what's [ZIP] like for me?") +- The configured daily briefing time fires +- The user asks about open-now resources (gym, pharmacy, urgent care, grocery, park) tied to their health +- The user asks about a neighborhood's SDoH overlay for travel or relocation + +## When NOT to Use + +❌ Don't use when: + +- The user wants a clinical interpretation of a PDF → use `med-pdf` +- The user wants to connect/refresh their patient portal → use `health-records` +- The user wants a drafted clinician message → use `epic-note` +- A request would diagnose, recommend treatment, or adjust a medication +- A request would produce billing, payor, prior-auth, EOB, or coding content (CPT/HCPCS/HCC). SDoH Z codes are the only codes used, and only as clinical context. + +## Setup + +1. **Init the store.** `python3 {baseDir}/scripts/init_db.py` + - Creates `~/.openclaw/workspace/.lookout-cache/lookout.db` from `schema.sql`. + - Seeds only tenant-neutral preferences (imperial, 07:30 briefing, 22:00–07:00 quiet hours). Re-running is safe. +2. **Configure location.** Onboarding or the user profile must create one `location` row with `is_default = 1` and latitude/longitude before fetch runs. Location is user data; don't hard-code it in the skill. +3. **Install Python dependency.** Ensure `httpx` is available to Python (`python3 -m pip install httpx` or the distro/package-manager equivalent). +4. **Optional keys.** Lookout runs end-to-end with no keys. For richer data: + - `AIRNOW_API_KEY` — US AQI by lat/lon (free key from AirNow). + - `GOOGLE_PLACES_API_KEY` — open-now resources layer (paid). + - `LOOKOUT_NWS_UA` — descriptive User-Agent for api.weather.gov. + +## Workflow + +1. **Fetch** — `python3 {baseDir}/scripts/fetch_environment.py` + - Reads the default location row, calls NWS (alerts + current weather), Open-Meteo (pressure + trend, temp, humidity), and EPA UV (data.gov). No keys required. + - Inserts rows into `environment_reading` and `alert` with `raw_source_id` for traceability. AirNow + Google Places stay stubbed until keyed. + - Cache dir: `~/.openclaw/workspace/.lookout-cache/`. + +2. **Read the patient's record** from the local FHIR store: + - Active `Condition` (clinical-status active) + - Active `MedicationStatement` + - Recent `Observation` (category laboratory) — most recent values + +3. **Triage.** Map record signals to ambient signals (asthma/COPD → ozone, PM2.5, smoke; photosensitizing meds → UV; HF or diuretic → heat index; migraine → pressure drop; recent low vitamin D → moderate UV; depression/SAD → daylight; OA → cold/damp/falling pressure). If nothing matches a signal, **stay silent about it**. Silence about clean air is a feature. + +4. **Brief.** Lead with what matters for *this* patient. Plain language first, clinical term in parens where it adds clarity. Every health-relevant item ends in an option or a question, never a directive. Tag as informational, not medical advice. Urgent items (severe-asthma + air quality emergency, HF + extreme heat) go first and prominently, with a pointer to the care team. + +5. **Persist.** + - Durable, health-relevant readings → FHIR `Observation` (environmental-exposure category, UCUM unit, `derivedFrom`/`note` back to source). + - SDoH context → `Observation` social-history with the relevant ICD-10 Z code where one applies. Feeds Tula's SDoH pipeline. + - Path: `~/.openclaw/workspace/tula/fhir/Observation/environmental/` and the existing social-history location. + - Briefing summary → `briefing` table with `source_reading_ids` for traceability. + +## Scripts + +- `scripts/init_db.py` — apply `schema.sql` and seed tenant-neutral preferences. Idempotent. It does not seed a location. +- `scripts/fetch_environment.py` — deterministic fetch. No PHI ever leaves the VM — only lat/lng or ZIP reach the providers. Run on a schedule or before a briefing. + +## Privacy + +Lookout sends **only a location** (lat/lng or ZIP) to public data providers. Conditions, medications, labs never leave the VM. The personalized triage runs through Tula's configured model path (HIPAA-eligible hosted tier, or local open-weight model for air-gapped operation). + +- Cache stays under `~/.openclaw/workspace/.lookout-cache/`. Don't copy out. +- No environmental provider ever sees PHI. +- Location is sensitive — stored locally only. + +## Safety + +- Not a medical device. Surfaces data and questions; never names a diagnosis, recommends treatment, or adjusts a medication. +- No hallucinated values. Every value surfaced maps to a row with a `raw_source_id`. If a fetch failed or a value is stale, say so — don't guess. +- Show the full informational-only notice once at onboarding; carry a short standing tag on health-relevant outputs. +- Escalation: urgent items first and prominently, with a pointer to the care team. Open-core counterpart to Aria's configurable escalation policy. + +## Notes + +- v1 scope: NWS, Open-Meteo, EPA UV, AirNow (keyed), Places (keyed), CDC PLACES + SVI overlay, daily briefing, on-demand queries, FHIR Observation logging. +- Later: pollen species, respiratory-virus surveillance, wildfire smoke, water advisories, radon, polished travel/relocation mode, correlation insight, wearable cross-reference. +- Endpoints and field names reflect provider behavior as of early 2026 — verify against current provider docs before depending on a field name. diff --git a/skills/lookout/schema.sql b/skills/lookout/schema.sql new file mode 100644 index 0000000..13b3845 --- /dev/null +++ b/skills/lookout/schema.sql @@ -0,0 +1,108 @@ +-- Lookout skill: SQLite working store +-- Tula health-skill conventions: store units and reference ranges with every value; +-- keep a raw source identifier on every reading for traceability (no hallucinated values). +-- All data is local to the patient's VM. + +PRAGMA foreign_keys = ON; + +-- Configured location(s). Location is sensitive; stored locally only. +CREATE TABLE IF NOT EXISTS location ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, -- e.g. 'home', 'office' + latitude REAL, + longitude REAL, + zip TEXT, + fips_tract TEXT, -- census tract for SDoH joins + is_default INTEGER NOT NULL DEFAULT 0, -- 1 = used for the daily briefing + source TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'device' + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Patient preferences for the skill. +CREATE TABLE IF NOT EXISTS preference ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL UNIQUE, -- 'units' | 'briefing_time' | 'quiet_hours' | 'prioritize_signals' | 'suppress_signals' + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- One row per fetched ambient value. +CREATE TABLE IF NOT EXISTS environment_reading ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + captured_at TEXT NOT NULL DEFAULT (datetime('now')), + source TEXT NOT NULL, -- 'airnow' | 'nws' | 'open-meteo' | 'epa-uv' | ... + metric TEXT NOT NULL, -- 'aqi' | 'pm25' | 'ozone' | 'uv_index' | 'temp' | 'heat_index' | 'humidity' | 'pressure' | 'pressure_trend' | ... + value REAL, + value_text TEXT, -- for non-numeric values (e.g. 'falling') + unit TEXT, -- UCUM where applicable + reference_low REAL, + reference_high REAL, + category_band TEXT, -- 'good' | 'moderate' | 'unhealthy_sensitive' | 'unhealthy' | ... + raw_source_id TEXT, -- provider response id / record key for traceability + notes TEXT +); +CREATE INDEX IF NOT EXISTS idx_reading_loc_time ON environment_reading(location_id, captured_at); +CREATE INDEX IF NOT EXISTS idx_reading_metric ON environment_reading(metric); + +-- Active public alerts (weather, air-quality action days, etc.). +CREATE TABLE IF NOT EXISTS alert ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + captured_at TEXT NOT NULL DEFAULT (datetime('now')), + source TEXT NOT NULL, -- 'nws' | ... + alert_type TEXT, -- e.g. 'heat_advisory', 'air_quality' + severity TEXT, + headline TEXT, + effective_at TEXT, + expires_at TEXT, + raw_source_id TEXT +); +CREATE INDEX IF NOT EXISTS idx_alert_loc ON alert(location_id, expires_at); + +-- Nearby resources (the resources layer; from Places). +CREATE TABLE IF NOT EXISTS place ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + captured_at TEXT NOT NULL DEFAULT (datetime('now')), + place_type TEXT NOT NULL, -- 'gym' | 'grocery' | 'pharmacy' | 'urgent_care' | 'park' | 'farmers_market' + name TEXT NOT NULL, + address TEXT, + latitude REAL, + longitude REAL, + open_now INTEGER, -- 1 | 0 | NULL unknown + hours_json TEXT, + rating REAL, + distance_m INTEGER, + source TEXT NOT NULL DEFAULT 'google_places', + place_ref TEXT -- provider place id +); +CREATE INDEX IF NOT EXISTS idx_place_loc_type ON place(location_id, place_type); + +-- Neighborhood SDoH overlay. Feeds Tula's SDoH pipeline. Z codes permitted as context. +CREATE TABLE IF NOT EXISTS sdoh_context ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + captured_at TEXT NOT NULL DEFAULT (datetime('now')), + indicator TEXT NOT NULL, -- 'svi_overall' | 'food_access_low' | 'adi_national' | 'walkability' | 'places_*' + value REAL, + value_text TEXT, + unit TEXT, + geography_level TEXT, -- 'tract' | 'zip' | 'county' + geography_id TEXT, + z_code TEXT, -- ICD-10 Z code where one applies (context only) + source TEXT NOT NULL -- 'cdc_places' | 'cdc_svi' | 'usda_fara' | ... +); +CREATE INDEX IF NOT EXISTS idx_sdoh_loc ON sdoh_context(location_id, indicator); + +-- Generated daily briefings, with traceability back to the readings used. +CREATE TABLE IF NOT EXISTS briefing ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + generated_at TEXT NOT NULL DEFAULT (datetime('now')), + summary_text TEXT, + items_json TEXT, -- the personalized items surfaced + model_used TEXT, + source_reading_ids TEXT -- comma-separated environment_reading.id values used +); +CREATE INDEX IF NOT EXISTS idx_briefing_loc ON briefing(location_id, generated_at); diff --git a/skills/lookout/scripts/fetch_environment.py b/skills/lookout/scripts/fetch_environment.py new file mode 100644 index 0000000..f2c0981 --- /dev/null +++ b/skills/lookout/scripts/fetch_environment.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +Lookout: deterministic environment fetch for the Tula Lookout skill. + +This script does the non-reasoning work. It calls public data providers for a +given location, normalizes every value into a common shape (value, unit, +reference range, category band, raw source id), and writes the rows to the +skill's SQLite store. The agent then reads those rows and does the +personalized triage against the patient's FHIR record. + +Privacy: this script sends only a location (lat/lng or ZIP) to public +providers. It never reads or transmits the patient's conditions, +medications, or labs. + +Status: v1 implementation for the no-key providers (NWS, Open-Meteo, +EPA UV). AirNow and Google Places remain stubbed until keys are +configured. + +Endpoints and field names reflect provider behavior as of early 2026. +Verify against current provider docs before depending on a field name. +""" + +from __future__ import annotations + +import os +import sqlite3 +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import httpx + +DB_PATH = os.environ.get( + "LOOKOUT_DB", + str(Path.home() / ".openclaw" / "workspace" / ".lookout-cache" / "lookout.db"), +) + +NWS_USER_AGENT = os.environ.get( + "LOOKOUT_NWS_UA", "Tula-Lookout (https://github.com/realactivity/tula)" +) +AIRNOW_API_KEY = os.environ.get("AIRNOW_API_KEY", "") +GOOGLE_PLACES_API_KEY = os.environ.get("GOOGLE_PLACES_API_KEY", "") + + +@dataclass +class Reading: + source: str + metric: str + value: Optional[float] = None + value_text: Optional[str] = None + unit: Optional[str] = None + reference_low: Optional[float] = None + reference_high: Optional[float] = None + category_band: Optional[str] = None + raw_source_id: Optional[str] = None + notes: Optional[str] = None + + +@dataclass +class Alert: + source: str + alert_type: Optional[str] + severity: Optional[str] + headline: Optional[str] + effective_at: Optional[str] + expires_at: Optional[str] + raw_source_id: Optional[str] + + +# --- Fetchers. Each returns normalized objects; none touches the FHIR record. --- + + +def fetch_open_meteo(lat: float, lon: float) -> list[Reading]: + """Open-Meteo: free, no key. Barometric pressure, weather fallback.""" + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": lat, + "longitude": lon, + "current": "surface_pressure,temperature_2m,relative_humidity_2m", + "hourly": "surface_pressure", + "temperature_unit": "fahrenheit", + } + r = httpx.get(url, params=params, timeout=20) + r.raise_for_status() + data = r.json() + cur = data.get("current", {}) or {} + readings: list[Reading] = [] + ts = str(cur.get("time")) + if cur.get("surface_pressure") is not None: + readings.append(Reading( + source="open-meteo", metric="pressure", + value=cur["surface_pressure"], unit="hPa", raw_source_id=ts, + )) + if cur.get("temperature_2m") is not None: + readings.append(Reading( + source="open-meteo", metric="temp", + value=cur["temperature_2m"], unit="degF", raw_source_id=ts, + )) + if cur.get("relative_humidity_2m") is not None: + readings.append(Reading( + source="open-meteo", metric="humidity", + value=cur["relative_humidity_2m"], unit="%", raw_source_id=ts, + )) + + # Pressure trend: change over the next 6 hours. + # Open-Meteo's hourly arrays start at the day boundary, not at "now", + # so locate the current hour in `hourly.time` and offset from there. + hourly = data.get("hourly", {}) or {} + series = hourly.get("surface_pressure") or [] + times = hourly.get("time") or [] + cur_time = cur.get("time") + if (cur_time and cur.get("surface_pressure") is not None + and len(series) == len(times) and times): + # Match by hour prefix (e.g. "2026-06-01T17") since current.time + # carries minutes while hourly.time is whole-hour bucketed. + cur_hour = cur_time[:13] + idx = next( + (i for i, t in enumerate(times) if str(t).startswith(cur_hour)), + None, + ) + if idx is not None and idx + 6 < len(series): + now_p = float(series[idx]) + future_p = float(series[idx + 6]) + delta = future_p - now_p + if delta <= -2.0: + trend = "falling" + elif delta >= 2.0: + trend = "rising" + else: + trend = "steady" + readings.append(Reading( + source="open-meteo", metric="pressure_trend", + value=delta, value_text=trend, unit="hPa/6h", + raw_source_id=f"{times[idx]}|{times[idx + 6]}", + )) + return readings + + +def fetch_nws_current(lat: float, lon: float) -> list[Reading]: + """NWS api.weather.gov: free, no key, requires User-Agent. + + Resolves the gridpoint for the lat/lng, then reads the first hourly + forecast period. Captures temperature and relative humidity. Heat + index can be computed downstream from these values. + """ + headers = {"User-Agent": NWS_USER_AGENT, "Accept": "application/geo+json"} + points = httpx.get( + f"https://api.weather.gov/points/{lat},{lon}", + headers=headers, timeout=20, + ) + points.raise_for_status() + hourly_url = points.json().get("properties", {}).get("forecastHourly") + if not hourly_url: + return [] + forecast = httpx.get(hourly_url, headers=headers, timeout=20) + forecast.raise_for_status() + periods = forecast.json().get("properties", {}).get("periods") or [] + if not periods: + return [] + p = periods[0] + ts = p.get("startTime") or "" + readings: list[Reading] = [] + if p.get("temperature") is not None: + unit = "degF" if p.get("temperatureUnit", "F").upper() == "F" else "degC" + readings.append(Reading( + source="nws", metric="temp", + value=float(p["temperature"]), unit=unit, + raw_source_id=f"nws|{ts}|temp", + notes=p.get("shortForecast"), + )) + rh = p.get("relativeHumidity") or {} + if isinstance(rh, dict) and rh.get("value") is not None: + readings.append(Reading( + source="nws", metric="humidity", + value=float(rh["value"]), unit="%", + raw_source_id=f"nws|{ts}|humidity", + )) + return readings + + +def fetch_nws_alerts(lat: float, lon: float) -> list[Alert]: + """NWS active alerts: free, no key, requires User-Agent.""" + headers = {"User-Agent": NWS_USER_AGENT, "Accept": "application/geo+json"} + r = httpx.get( + "https://api.weather.gov/alerts/active", + params={"point": f"{lat},{lon}"}, + headers=headers, timeout=20, + ) + r.raise_for_status() + alerts: list[Alert] = [] + for feat in r.json().get("features", []): + p = feat.get("properties", {}) or {} + alerts.append(Alert( + source="nws", + alert_type=p.get("event"), + severity=p.get("severity"), + headline=p.get("headline"), + effective_at=p.get("effective"), + expires_at=p.get("expires"), + raw_source_id=feat.get("id"), + )) + return alerts + + +def fetch_epa_uv(zip_code: str) -> list[Reading]: + """EPA UV Index (data.gov Envirofacts): free, no key. Hourly UV by ZIP. + + Captures the peak UV value for the day and the time it occurs. Returns + an empty list if no ZIP is configured (lat/lng alone is not enough for + the EPA endpoint). + """ + if not zip_code: + return [] + url = f"https://data.epa.gov/efservice/getEnvirofactsUVHOURLY/ZIP/{zip_code}/JSON" + r = httpx.get(url, timeout=20) + r.raise_for_status() + rows = r.json() or [] + if not rows: + return [] + peak = max(rows, key=lambda row: row.get("UV_VALUE") or 0) + uv_value = peak.get("UV_VALUE") + if uv_value is None: + return [] + bands = [ + (0, 2, "low"), + (3, 5, "moderate"), + (6, 7, "high"), + (8, 10, "very_high"), + (11, 99, "extreme"), + ] + band = next((name for lo, hi, name in bands if lo <= uv_value <= hi), None) + return [Reading( + source="epa-uv", metric="uv_index", + value=float(uv_value), unit="UV", + category_band=band, + raw_source_id=f"{peak.get('ZIP')}|{peak.get('DATE_TIME')}", + notes=f"daily peak at {peak.get('DATE_TIME')}", + )] + + +# Places (the resources layer) and the SDoH overlay are separate fetchers, +# added per the v1 scope. Stubbed here pending API keys. + +def fetch_places(lat: float, lon: float, place_types: list[str]) -> list[dict]: + """Google Maps Platform Places: keyed, paid. STUBBED until GOOGLE_PLACES_API_KEY set.""" + if not GOOGLE_PLACES_API_KEY: + return [] + # TODO: implement Places Nearby Search once a key is configured. + return [] + + +def fetch_airnow_aqi(lat: float, lon: float) -> list[Reading]: + """AirNow current AQI by lat/lon: free, requires AIRNOW_API_KEY. STUBBED until key set.""" + if not AIRNOW_API_KEY: + return [] + url = "https://www.airnowapi.org/aq/observation/latLong/current/" + params = { + "format": "application/json", + "latitude": lat, "longitude": lon, + "distance": 25, "API_KEY": AIRNOW_API_KEY, + } + r = httpx.get(url, params=params, timeout=20) + r.raise_for_status() + readings: list[Reading] = [] + for obs in r.json(): + param = (obs.get("ParameterName") or "").lower() + metric = {"o3": "ozone", "pm2.5": "pm25", "pm10": "pm10"}.get(param, param) + cat = (obs.get("Category") or {}).get("Name") + readings.append(Reading( + source="airnow", metric=metric, value=obs.get("AQI"), unit="AQI", + category_band=cat, + raw_source_id=f"{obs.get('ReportingArea')}|{obs.get('DateObserved')}|{param}", + )) + return readings + + +def fetch_sdoh(fips_tract: str, zip_code: str) -> list[dict]: + """CDC PLACES + CDC/ATSDR SVI: free. STUBBED — v1 later phase.""" + return [] + + +# --- Persistence. --- + +def write_readings( + conn: sqlite3.Connection, location_id: int, readings: list[Reading] +) -> int: + if not readings: + return 0 + conn.executemany( + """INSERT INTO environment_reading + (location_id, source, metric, value, value_text, unit, + reference_low, reference_high, category_band, raw_source_id, notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + [(location_id, r.source, r.metric, r.value, r.value_text, r.unit, + r.reference_low, r.reference_high, r.category_band, r.raw_source_id, r.notes) + for r in readings], + ) + return len(readings) + + +def write_alerts( + conn: sqlite3.Connection, location_id: int, alerts: list[Alert] +) -> int: + if not alerts: + return 0 + conn.executemany( + """INSERT INTO alert + (location_id, source, alert_type, severity, headline, + effective_at, expires_at, raw_source_id) + VALUES (?,?,?,?,?,?,?,?)""", + [(location_id, a.source, a.alert_type, a.severity, a.headline, + a.effective_at, a.expires_at, a.raw_source_id) for a in alerts], + ) + return len(alerts) + + +def run_for_location( + conn: sqlite3.Connection, + location_id: int, + lat: Optional[float], + lon: Optional[float], + zip_code: str, + fips_tract: str, +) -> tuple[int, int]: + if lat is None or lon is None: + raise ValueError( + "Default Lookout location must include latitude and longitude." + ) + + readings: list[Reading] = [] + readings += fetch_open_meteo(lat, lon) + readings += fetch_nws_current(lat, lon) + readings += fetch_airnow_aqi(lat, lon) + readings += fetch_epa_uv(zip_code) + n_readings = write_readings(conn, location_id, readings) + + n_alerts = write_alerts(conn, location_id, fetch_nws_alerts(lat, lon)) + # Places and SDoH are stubbed; nothing to persist until keys/v-later. + conn.commit() + return n_readings, n_alerts + + +def main() -> int: + db_path = Path(DB_PATH) + if not db_path.exists(): + print( + f"Lookout DB not found at {db_path}. Run init_db.py first.", + file=sys.stderr, + ) + return 1 + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT id, latitude, longitude, zip, fips_tract " + "FROM location WHERE is_default = 1 LIMIT 1" + ).fetchone() + if row is None: + print("No default location configured in the lookout DB.", file=sys.stderr) + return 2 + try: + n_readings, n_alerts = run_for_location( + conn, row["id"], row["latitude"], row["longitude"], + row["zip"] or "", row["fips_tract"] or "", + ) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + finally: + conn.close() + print(f"Lookout fetch complete. readings={n_readings} alerts={n_alerts}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/lookout/scripts/init_db.py b/skills/lookout/scripts/init_db.py new file mode 100644 index 0000000..0d02d8a --- /dev/null +++ b/skills/lookout/scripts/init_db.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Initialize the Lookout SQLite store. + +Creates `~/.openclaw/workspace/.lookout-cache/lookout.db` from schema.sql, +then seeds baseline tenant-neutral preferences. Location is configured by +onboarding or the user profile. Idempotent — safe to re-run. +""" + +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path + +SKILL_DIR = Path(__file__).resolve().parent.parent +DEFAULT_DB_PATH = Path(os.environ.get( + "LOOKOUT_DB", + str(Path.home() / ".openclaw" / "workspace" / ".lookout-cache" / "lookout.db"), +)) + +SCHEMA_PATH = SKILL_DIR / "schema.sql" + +SEED_PREFERENCES = { + "units": "imperial", + "briefing_time": "07:30", + "quiet_hours": "22:00-07:00", +} + + +def init(db_path: Path = DEFAULT_DB_PATH) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + schema_sql = SCHEMA_PATH.read_text() + + conn = sqlite3.connect(db_path) + try: + conn.executescript(schema_sql) + + for key, value in SEED_PREFERENCES.items(): + conn.execute( + """INSERT INTO preference (key, value) VALUES (?, ?) + ON CONFLICT(key) DO NOTHING""", + (key, value), + ) + conn.commit() + finally: + conn.close() + + print(f"Lookout DB ready at {db_path}") + + +if __name__ == "__main__": + init()