mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
Third broadcast type Active: clock-driven re-broadcasts of still-live
events at human-scale cadences. WFIGS fires 8h, itd_511 work zones daily
8 AM Mountain, SWPC G-storms 8h. NWS is NOT a clock reminder -- instead
the per-CAP-id dedup is relaxed to allow re-broadcast if >3h since last.
Schema split first_broadcast_at + last_broadcast_at on all reminder-
eligible tables. Wire prefix logic: New (first sight), Update (WFIGS
material change), Active (clock reminder). All cadences, channels, day-
of-week patterns, timezones, and termination conditions GUI-editable
from day one via the existing adapter_config editor. Termination:
tombstone OR containment_100 OR end_date_passed (no max-count). Quiet
hours not respected -- ripped out in Phase 2.
Schema (v11.sql):
- ALTER TABLE fires|nws_alerts|traffic_events|quake_events|swpc_events|
gauge_readings ADD COLUMN first_broadcast_at REAL
- Backfill: UPDATE ... SET first_broadcast_at = last_broadcast_at
WHERE last_broadcast_at IS NOT NULL
- ALTER TABLE adapter_meta ADD COLUMN reminder_enabled INTEGER NOT NULL
DEFAULT 0
- UPDATE adapter_meta SET reminder_enabled=1 WHERE adapter IN
('wfigs', 'swpc') -- itd_511_work_zone is a new meta row seeded
with reminder_enabled=1
- SCHEMA_VERSION 10 -> 11
Handler commit-callbacks (wfigs/nws/quake/swpc/incident):
- UPDATE ... SET last_broadcast_at=?, first_broadcast_at=COALESCE(
first_broadcast_at, ?) -- first_broadcast_at stamped once, never
overwritten
NWS handler (meshai/central/nws_handler.py):
- _render() gains a prefix kwarg
- After-first-broadcast branch: when (now - last_broadcast_at) >=
adapter_config.nws.duplicate_allowed_after_seconds (default 10800
= 3h), allow the re-broadcast with prefix=Active. Under the
window, suppress as before. The commit callback continues to
update last_broadcast_at.
ReminderScheduler (meshai/notifications/reminders/__init__.py):
- Async loop, ticks every 60s
- Each tick: SELECT adapter FROM adapter_meta WHERE reminder_enabled=1
- Per adapter, load reminders_<adapter> config from adapter_config
(cadence_kind, cadence_value, channels, terminate_when, dow_mask,
timezone)
- Interval cadence: rows where last_broadcast_at <= now - cadence_value
- Clock cadence: localizes now to configured tz, finds slots that
just passed in the last tick window, gated by dow_mask
- Termination conditions checked per adapter:
wfigs.containment_100 -> current_contained_pct >= 100
wfigs.last_event_age_24h -> last_event_at older than 24h
swpc.end_date_passed -> payload_json end_time in past
itd_511_work_zone.end_date_passed -> traffic_events.end_at in past
- Active: prefix on every emitted wire; dispatcher.dispatch_scheduled_
broadcast() honors cold-start grace, bypasses toggle path
- On success, last_broadcast_at = now; first_broadcast_at preserved
Launched from notifications/pipeline/__init__.py:start_pipeline()
alongside BandConditionsScheduler.
adapter_config registry (+15 new keys, 43 -> 58):
- reminders_wfigs.cadence_kind/cadence_value/channels/terminate_when
- reminders_swpc.cadence_kind/cadence_value/channels/terminate_when
- reminders_itd_511_work_zone.cadence_kind/cadence_value/channels/
dow_mask/timezone/terminate_when
- nws.duplicate_allowed_after_seconds
adapter_meta (+4 rows, 15 -> 19):
- reminders_wfigs, reminders_swpc, reminders_itd_511_work_zone
(pseudo-adapters carrying the reminder config)
- itd_511_work_zone (reminder target row; reminder_enabled=1)
- reminder_enabled flag added to wfigs/swpc (existing rows updated by
v11.sql) and to itd_511_work_zone seed.
Tests (tests/test_reminders.py, 10 cases):
- wfigs reminder fires past 8h cadence, stamps last_broadcast_at,
preserves first_broadcast_at
- reminder skipped within cadence
- reminder skipped when containment_100, last_event_age_24h
- swpc reminder fires (interval)
- work_zone clock reminder fires at 08:00 Mountain on enabled DOW
- work_zone reminder skipped when end_date_passed
- work_zone reminder skipped outside slot window
- reminder_enabled=0 suppresses all reminders for that adapter
tests/test_nws_dedup_relaxation.py (5 cases):
- First sighting renders without Active: prefix
- Re-broadcast within 3h suppressed
- Re-broadcast after 3h allowed with Active: prefix
- adapter_config.nws.duplicate_allowed_after_seconds override takes
effect (1h window verified)
- First sighting stamps first_broadcast_at=committed_at,
last_broadcast_at=committed_at; 4h later broadcast stamps
last_broadcast_at only, first_broadcast_at preserved
Test count: 829 -> 844 (+15 new, 0 regressions). Foundation tests
updated for new counts (REGISTRY=58, ADAPTER_META=19, schema=v11).
344 lines
10 KiB
Python
344 lines
10 KiB
Python
"""v0.6-3c API tests for adapter_config + adapter_meta routes.
|
|
|
|
Uses FastAPI TestClient against a tmp DB seeded by the conftest autouse
|
|
fixture. Covers: GET (list, per-adapter, single), PUT (incl. type
|
|
validation), POST reset, GET/PUT meta, cache invalidation propagation
|
|
to the accessor.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from meshai.adapter_config import adapter_config, invalidate_cache
|
|
from meshai.dashboard.api.adapter_config_routes import router
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
app = FastAPI()
|
|
app.include_router(router, prefix="/api")
|
|
return TestClient(app)
|
|
|
|
|
|
# ============================================================================
|
|
# GET /api/adapter-config (grouped)
|
|
# ============================================================================
|
|
|
|
|
|
def test_list_returns_all_58_keys(client):
|
|
r = client.get("/api/adapter-config")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
# 14 adapters with at least one key (itd_511 has zero -- not in the
|
|
# grouped dict because the SQL only returns rows that exist).
|
|
total = sum(len(v) for v in body.values())
|
|
assert total == 58
|
|
|
|
|
|
def test_list_grouped_by_adapter(client):
|
|
r = client.get("/api/adapter-config")
|
|
body = r.json()
|
|
assert "wfigs" in body
|
|
keys = {row["key"] for row in body["wfigs"]}
|
|
assert keys == {"cooldown_seconds", "anchor_max_mi",
|
|
"broadcast_on_acres", "broadcast_on_contained"}
|
|
|
|
|
|
def test_list_includes_type_value_default_description(client):
|
|
r = client.get("/api/adapter-config")
|
|
body = r.json()
|
|
row = next(row for row in body["wfigs"] if row["key"] == "cooldown_seconds")
|
|
assert row["value"] == 28800
|
|
assert row["default"] == 28800
|
|
assert row["type"] == "int"
|
|
assert row["description"]
|
|
|
|
|
|
# ============================================================================
|
|
# GET /api/adapter-config/{adapter}
|
|
# ============================================================================
|
|
|
|
|
|
def test_per_adapter_list(client):
|
|
r = client.get("/api/adapter-config/usgs_quake")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert isinstance(body, list)
|
|
keys = {row["key"] for row in body}
|
|
assert keys == {
|
|
"regional_centroid", "regional_radius_mi",
|
|
"broadcast_pager_alerts", "global_mag_floor",
|
|
"regional_mag_floor", "escalate_mag_floor",
|
|
}
|
|
|
|
|
|
def test_per_adapter_empty_for_itd_511(client):
|
|
"""itd_511 has zero config keys post-3a.1; returns empty list, not 404."""
|
|
r = client.get("/api/adapter-config/itd_511")
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
|
|
# ============================================================================
|
|
# GET /api/adapter-config/{adapter}/{key}
|
|
# ============================================================================
|
|
|
|
|
|
def test_get_single_key(client):
|
|
r = client.get("/api/adapter-config/usgs_quake/global_mag_floor")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["value"] == 3.0
|
|
assert body["default"] == 3.0
|
|
assert body["type"] == "float"
|
|
|
|
|
|
def test_get_unknown_key_404(client):
|
|
r = client.get("/api/adapter-config/wfigs/no_such_key")
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ============================================================================
|
|
# PUT /api/adapter-config/{adapter}/{key}
|
|
# ============================================================================
|
|
|
|
|
|
def test_put_updates_value(client):
|
|
r = client.put(
|
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
|
json={"value": 2.8},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == 2.8
|
|
# GET reflects the new value.
|
|
g = client.get("/api/adapter-config/usgs_quake/global_mag_floor")
|
|
assert g.json()["value"] == 2.8
|
|
|
|
|
|
def test_put_invalidates_accessor_cache(client):
|
|
"""The handler-side accessor reads the new value WITHOUT a restart."""
|
|
# Prime the cache with the default.
|
|
assert adapter_config.usgs_quake.global_mag_floor == 3.0
|
|
# Mutate via API.
|
|
client.put(
|
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
|
json={"value": 2.5},
|
|
)
|
|
# Accessor returns the new value -- cache invalidation worked.
|
|
assert adapter_config.usgs_quake.global_mag_floor == 2.5
|
|
|
|
|
|
def test_put_int_validation(client):
|
|
"""int field rejects string body."""
|
|
r = client.put(
|
|
"/api/adapter-config/wfigs/cooldown_seconds",
|
|
json={"value": "two hours"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_put_int_rejects_float_with_fraction(client):
|
|
r = client.put(
|
|
"/api/adapter-config/wfigs/cooldown_seconds",
|
|
json={"value": 3600.5},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_put_int_accepts_float_with_integer_value(client):
|
|
r = client.put(
|
|
"/api/adapter-config/wfigs/cooldown_seconds",
|
|
json={"value": 3600.0},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == 3600
|
|
|
|
|
|
def test_put_float_accepts_int(client):
|
|
r = client.put(
|
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
|
json={"value": 4},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == 4.0
|
|
|
|
|
|
def test_put_bool_rejects_int(client):
|
|
r = client.put(
|
|
"/api/adapter-config/wfigs/broadcast_on_acres",
|
|
json={"value": 1},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_put_str_validation(client):
|
|
r = client.put(
|
|
"/api/adapter-config/geocoder/photon_url",
|
|
json={"value": 42},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_put_json_accepts_list(client):
|
|
r = client.put(
|
|
"/api/adapter-config/nws/broadcast_severities",
|
|
json={"value": ["Extreme"]},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == ["Extreme"]
|
|
|
|
|
|
def test_put_json_accepts_dict(client):
|
|
r = client.put(
|
|
"/api/adapter-config/central/severity_thresholds",
|
|
json={"value": {"routine_max": 0, "priority_max": 1, "immediate_min": 2}},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_put_json_accepts_none(client):
|
|
r = client.put(
|
|
"/api/adapter-config/firms/bbox",
|
|
json={"value": None},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] is None
|
|
|
|
|
|
def test_put_unknown_key_404(client):
|
|
r = client.put(
|
|
"/api/adapter-config/wfigs/no_such_key",
|
|
json={"value": 1},
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_put_missing_value_field(client):
|
|
r = client.put(
|
|
"/api/adapter-config/wfigs/cooldown_seconds",
|
|
json={},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ============================================================================
|
|
# POST /api/adapter-config/{adapter}/{key}/reset
|
|
# ============================================================================
|
|
|
|
|
|
def test_reset_restores_default(client):
|
|
# Mutate.
|
|
client.put(
|
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
|
json={"value": 2.8},
|
|
)
|
|
assert client.get("/api/adapter-config/usgs_quake/global_mag_floor").json()["value"] == 2.8
|
|
|
|
# Reset.
|
|
r = client.post("/api/adapter-config/usgs_quake/global_mag_floor/reset")
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == 3.0
|
|
# Accessor too.
|
|
invalidate_cache()
|
|
assert adapter_config.usgs_quake.global_mag_floor == 3.0
|
|
|
|
|
|
def test_reset_invalidates_cache(client):
|
|
"""Reset must invalidate the accessor cache the same as PUT."""
|
|
client.put(
|
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
|
json={"value": 2.8},
|
|
)
|
|
# Prime cache with the post-PUT value.
|
|
assert adapter_config.usgs_quake.global_mag_floor == 2.8
|
|
client.post("/api/adapter-config/usgs_quake/global_mag_floor/reset")
|
|
# Cache cleared -- next read returns the default.
|
|
assert adapter_config.usgs_quake.global_mag_floor == 3.0
|
|
|
|
|
|
def test_reset_unknown_key_404(client):
|
|
r = client.post("/api/adapter-config/wfigs/no_such_key/reset")
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ============================================================================
|
|
# GET /api/adapter-meta
|
|
# ============================================================================
|
|
|
|
|
|
def test_list_meta(client):
|
|
r = client.get("/api/adapter-meta")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert "wfigs" in body
|
|
assert body["wfigs"]["include_in_llm_context"] is True
|
|
# central / geocoder default to False
|
|
assert body["central"]["include_in_llm_context"] is False
|
|
assert body["geocoder"]["include_in_llm_context"] is False
|
|
|
|
|
|
# ============================================================================
|
|
# PUT /api/adapter-meta/{adapter}
|
|
# ============================================================================
|
|
|
|
|
|
def test_put_meta_toggles_llm_context(client):
|
|
r = client.put(
|
|
"/api/adapter-meta/itd_511",
|
|
json={"include_in_llm_context": False},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["include_in_llm_context"] is False
|
|
# GET reflects the change.
|
|
g = client.get("/api/adapter-meta").json()
|
|
assert g["itd_511"]["include_in_llm_context"] is False
|
|
|
|
|
|
def test_put_meta_updates_display_name(client):
|
|
r = client.put(
|
|
"/api/adapter-meta/wfigs",
|
|
json={"display_name": "Active wildfires (WFIGS)"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["display_name"] == "Active wildfires (WFIGS)"
|
|
|
|
|
|
def test_put_meta_partial_update(client):
|
|
"""Only the fields in the body change; others survive."""
|
|
original = client.get("/api/adapter-meta").json()["wfigs"]
|
|
r = client.put(
|
|
"/api/adapter-meta/wfigs",
|
|
json={"include_in_llm_context": False},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["display_name"] == original["display_name"] # unchanged
|
|
assert body["include_in_llm_context"] is False
|
|
|
|
|
|
def test_put_meta_rejects_bad_bool(client):
|
|
r = client.put(
|
|
"/api/adapter-meta/wfigs",
|
|
json={"include_in_llm_context": "yes"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_put_meta_rejects_empty_display_name(client):
|
|
r = client.put(
|
|
"/api/adapter-meta/wfigs",
|
|
json={"display_name": " "},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_put_meta_unknown_adapter_404(client):
|
|
r = client.put(
|
|
"/api/adapter-meta/nonexistent_adapter",
|
|
json={"include_in_llm_context": False},
|
|
)
|
|
assert r.status_code == 404
|