mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fire: remove fire digest feature and drop out-of-coverage fires at ingest (#107)
Two fire-scope cleanups: 1. Remove the fire digest feature entirely -- scheduler (notifications/scheduled/fire_digest.py), pipeline wiring, the fires.digest_* adapter_config key registrations, and the Fire Digest dashboard UI (ScheduledBroadcasts / Environment / Reference / AdapterConfig / ActivityLog). The unrelated generic per-rule notification digest is kept. Orphaned fires.digest_* config rows and the fire_digest_broadcasts table are left as inert data (v16 migration untouched). 2. Add a coverage-scope gate at fire ingest: _ingest_fires now skips any fire whose coordinates fall outside all configured coverage areas (same areas_from_config + classify_geom_areas membership the dispatch-level CoverageFilter uses), so out-of-coverage fires are never stored, tracked, alerted, reminded, or re-ingested. Fails open when coverage is disabled, has no areas, or excludes the fires adapter. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
af826319c8
commit
b0b0697bac
16 changed files with 189 additions and 960 deletions
|
|
@ -129,20 +129,20 @@ def test_wfigs_broadcast_on_acres_bool_roundtrip(client):
|
|||
assert type(val) is bool
|
||||
|
||||
|
||||
def test_fires_digest_enabled_bool_roundtrip(client):
|
||||
"""Third adapter (fires.digest_enabled) -- additional proof of generic handling."""
|
||||
def test_reminders_wfigs_enabled_bool_roundtrip(client):
|
||||
"""Third adapter (reminders_wfigs.enabled) -- additional proof of generic handling."""
|
||||
# Read default
|
||||
default_val = adapter_config.fires.digest_enabled
|
||||
default_val = adapter_config.reminders_wfigs.enabled
|
||||
|
||||
# Flip
|
||||
new_val = not default_val
|
||||
r = client.put(
|
||||
"/api/adapter-config/fires/digest_enabled",
|
||||
"/api/adapter-config/reminders_wfigs/enabled",
|
||||
json={"value": new_val},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["value"] is new_val
|
||||
|
||||
# Accessor returns the correct bool
|
||||
assert adapter_config.fires.digest_enabled is new_val
|
||||
assert type(adapter_config.fires.digest_enabled) is bool
|
||||
assert adapter_config.reminders_wfigs.enabled is new_val
|
||||
assert type(adapter_config.reminders_wfigs.enabled) is bool
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
"""Tests for the fire-digest broadcast kill-switch (fires.digest_broadcast_enabled).
|
||||
|
||||
The scheduler wiring stays intact; only the mesh EMISSION is gated. Disabled by
|
||||
default -> fire_slot() dispatches nothing. Flipping the flag True re-enables it
|
||||
cleanly. Per-fire wfigs alerts are a separate path and are unaffected (covered in
|
||||
test_wfigs_handler.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from meshai.persistence import get_db
|
||||
from meshai.notifications.scheduled.fire_digest import FireDigestScheduler
|
||||
|
||||
|
||||
class _RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def dispatch_scheduled_broadcast(self, *, text, source_event_table,
|
||||
source_event_pk):
|
||||
self.calls.append(
|
||||
{"text": text, "table": source_event_table, "pk": source_event_pk})
|
||||
return True
|
||||
|
||||
|
||||
def _seed_active_fire(conn):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
|
||||
"current_acres, current_contained_pct, lat, lon, county, state, "
|
||||
"declared_at, last_event_at, tombstoned_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("GATE-01", "Gatekeeper Fire", "WF", 900, None, 43.6, -116.2,
|
||||
"Ada", "ID", now, now - 600, None),
|
||||
)
|
||||
|
||||
|
||||
def test_fire_slot_disabled_by_default_dispatches_nothing():
|
||||
"""With defaults (digest_broadcast_enabled=False) fire_slot emits nothing."""
|
||||
conn = get_db()
|
||||
_seed_active_fire(conn) # a broadcast WOULD be produced if the gate were open
|
||||
now = int(time.time())
|
||||
dispatcher = _RecordingDispatcher()
|
||||
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
|
||||
|
||||
result = asyncio.run(sched.fire_slot(now, "06:00"))
|
||||
|
||||
assert result is False
|
||||
assert dispatcher.calls == [], "digest broadcast must NOT be dispatched by default"
|
||||
|
||||
|
||||
def test_fire_slot_dispatches_when_flag_enabled():
|
||||
"""Flipping fires.digest_broadcast_enabled True cleanly re-enables emission."""
|
||||
from meshai.adapter_config import set_runtime_override
|
||||
conn = get_db()
|
||||
_seed_active_fire(conn)
|
||||
now = int(time.time())
|
||||
dispatcher = _RecordingDispatcher()
|
||||
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
|
||||
|
||||
set_runtime_override("fires", "digest_broadcast_enabled", True)
|
||||
try:
|
||||
result = asyncio.run(sched.fire_slot(now, "06:00"))
|
||||
finally:
|
||||
# Reset so the override doesn't leak into other tests in this process.
|
||||
set_runtime_override("fires", "digest_broadcast_enabled", False)
|
||||
|
||||
assert result is True
|
||||
assert len(dispatcher.calls) == 1
|
||||
assert dispatcher.calls[0]["table"] == "fire_digest_broadcasts"
|
||||
assert "Gatekeeper Fire" in dispatcher.calls[0]["text"]
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
"""Tests for the fire digest deterministic renderer.
|
||||
|
||||
Validates recency ordering, contained/tombstoned exclusion, the
|
||||
"Name in County Co, ST" line format, correct tail count after budget
|
||||
trimming, singular/plural grammar, and the 140-byte universal mesh budget.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.persistence import get_db
|
||||
|
||||
|
||||
def _seed_fire(conn, *, irwin_id, name, acres, contained=None, lat=43.6, lon=-116.2,
|
||||
county="Ada", state="ID", declared_at=None, last_event_at=None,
|
||||
tombstoned_at=None):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
|
||||
"current_acres, current_contained_pct, lat, lon, county, state, "
|
||||
"declared_at, last_event_at, tombstoned_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(irwin_id, name, "WF", acres, contained, lat, lon, county, state,
|
||||
declared_at or now, last_event_at or now, tombstoned_at),
|
||||
)
|
||||
|
||||
|
||||
def _seed_scenario(conn):
|
||||
"""Seed fires: 3 active, 1 contained, 1 tombstoned."""
|
||||
now = int(time.time())
|
||||
day = 86400
|
||||
|
||||
_seed_fire(conn, irwin_id="F-01", name="Alpha Fire",
|
||||
acres=500, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county="Ada", state="ID")
|
||||
_seed_fire(conn, irwin_id="F-02", name="Bravo Fire",
|
||||
acres=200, contained=25,
|
||||
last_event_at=now - 7200,
|
||||
county="Boise", state="ID")
|
||||
_seed_fire(conn, irwin_id="F-03", name="Charlie Fire",
|
||||
acres=1000, contained=None,
|
||||
last_event_at=now - 2 * day,
|
||||
county="Elmore", state="ID")
|
||||
|
||||
_seed_fire(conn, irwin_id="F-04", name="Contained Fire",
|
||||
acres=800, contained=100,
|
||||
last_event_at=now - 1800,
|
||||
county="Gem", state="ID")
|
||||
|
||||
_seed_fire(conn, irwin_id="F-05", name="Tombstoned Fire",
|
||||
acres=3000, contained=50,
|
||||
last_event_at=now - day,
|
||||
tombstoned_at=now - 3600,
|
||||
county="Owyhee", state="ID")
|
||||
|
||||
|
||||
class TestFireDigestRecency:
|
||||
"""Deterministic fire digest renderer tests."""
|
||||
|
||||
def test_top_2_listed_in_recency_order(self):
|
||||
"""The most recent active fire is listed first.
|
||||
|
||||
With the universal 140-byte budget, the header + 1 fire line + tail
|
||||
fits; the second fire line does not. Alpha (most recent) must appear;
|
||||
Bravo belongs to the tail count.
|
||||
"""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
assert source == "deterministic"
|
||||
# Most recent fire must appear in the wire body.
|
||||
assert "Alpha Fire" in wire
|
||||
# Bravo does not fit within 140 bytes alongside the header + tail.
|
||||
assert "Bravo Fire" not in wire
|
||||
|
||||
def test_line_format_name_in_county_co_state(self):
|
||||
"""Fire lines render as 'Name in County Co, ST'.
|
||||
|
||||
Only the most recent fire fits within the 140-byte budget.
|
||||
"""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Alpha Fire in Ada Co, ID" in wire
|
||||
|
||||
def test_missing_county_renders_name_in_state(self):
|
||||
"""Fire with no county renders as 'Name in ST'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_fire(conn, irwin_id="NC-01", name="No County Blaze",
|
||||
acres=100, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county=None, state="MT")
|
||||
_seed_fire(conn, irwin_id="NC-02", name="Second Fire",
|
||||
acres=50, contained=None,
|
||||
last_event_at=now - 7200,
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "No County Blaze in MT" in wire
|
||||
|
||||
def test_contained_excluded(self):
|
||||
"""100%-contained fires are excluded from the digest."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Contained Fire" not in wire
|
||||
|
||||
def test_tombstoned_excluded(self):
|
||||
"""Tombstoned fires are excluded from the digest."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Tombstoned Fire" not in wire
|
||||
|
||||
def test_n1_grammar_singular(self):
|
||||
"""N == 1 renders 'There is 1 additional wildfire.'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
# 3 fires; short names + no county → lines are short enough that
|
||||
# 2 fit within the 140-byte budget, leaving 1 in the tail.
|
||||
for irwin, name, offset in [
|
||||
("SG-01", "A", 3600),
|
||||
("SG-02", "B", 7200),
|
||||
("SG-03", "C", 10800),
|
||||
]:
|
||||
_seed_fire(conn, irwin_id=irwin, name=name, acres=100,
|
||||
contained=None, last_event_at=now - offset,
|
||||
county=None, state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
# 3 active total, 2 shown (budget), 1 remaining
|
||||
assert "There is 1 additional wildfire. DM me for the full list." in wire
|
||||
|
||||
def test_n_plural_grammar(self):
|
||||
"""N > 1 renders 'There are N additional wildfires.'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
for i in range(4):
|
||||
_seed_fire(conn, irwin_id=f"PL-{i:02d}", name=f"Fire {i}",
|
||||
acres=100 + i, contained=None,
|
||||
last_event_at=now - 3600 * (i + 1),
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
# 4 active; "Fire N in Ada Co, ID" lines total ~142 bytes for 2 lines +
|
||||
# header + tail → only 1 line fits in the 140-byte budget → 3 remaining.
|
||||
assert "There are 3 additional wildfires. DM me for the full list." in wire
|
||||
|
||||
def test_n_zero_omits_sentence(self):
|
||||
"""When N == 0, the tail sentence is omitted entirely."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_fire(conn, irwin_id="X-01", name="Fire One",
|
||||
acres=100, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county="Ada", state="ID")
|
||||
_seed_fire(conn, irwin_id="X-02", name="Fire Two",
|
||||
acres=50, contained=10,
|
||||
last_event_at=now - 7200,
|
||||
county="Boise", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
assert source == "deterministic"
|
||||
assert "additional" not in wire
|
||||
assert "Fire One" in wire
|
||||
assert "Fire Two" in wire
|
||||
|
||||
def test_tail_count_correct_after_budget_trim(self):
|
||||
"""When budget trims a line, N reflects actual shown count."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
# Long names force the second line to be trimmed
|
||||
long_name_1 = "A" * 60
|
||||
long_name_2 = "B" * 60
|
||||
_seed_fire(conn, irwin_id="LN-01", name=long_name_1,
|
||||
acres=500, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county="Bonneville", state="ID")
|
||||
_seed_fire(conn, irwin_id="LN-02", name=long_name_2,
|
||||
acres=200, contained=None,
|
||||
last_event_at=now - 7200,
|
||||
county="Bannock", state="ID")
|
||||
_seed_fire(conn, irwin_id="LN-03", name="Short Fire",
|
||||
acres=100, contained=None,
|
||||
last_event_at=now - 3 * 86400,
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
assert source == "deterministic"
|
||||
byte_len = len(wire.encode("utf-8"))
|
||||
assert byte_len <= 140, f"Wire is {byte_len} bytes, exceeds 140"
|
||||
# If both long lines fit, remaining = 1; if only one fits, remaining = 2
|
||||
# Either way the tail count must match (total - shown)
|
||||
lines = wire.split("\n")
|
||||
shown_fires = [l for l in lines if long_name_1 in l or long_name_2 in l]
|
||||
remaining = 3 - len(shown_fires)
|
||||
if remaining == 1:
|
||||
assert "There is 1 additional wildfire." in wire
|
||||
elif remaining > 1:
|
||||
assert f"There are {remaining} additional wildfires." in wire
|
||||
|
||||
def test_rendered_within_200_bytes(self):
|
||||
"""Rendered output must be <= 200 bytes for LoRa budget."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
byte_len = len(wire.encode("utf-8"))
|
||||
assert byte_len <= 140, f"Digest is {byte_len} bytes, exceeds 140-byte budget"
|
||||
|
||||
def test_no_fires_returns_empty(self):
|
||||
"""No active fires -> empty wire, 'no_fires' source."""
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest())
|
||||
assert wire == ""
|
||||
assert source == "no_fires"
|
||||
|
|
@ -110,16 +110,27 @@ def _raw_fire(*, name="MORA", irwin=_IRWIN, acres=2410, contained=10,
|
|||
}
|
||||
|
||||
|
||||
def _make_store():
|
||||
def _make_store(coverage_areas=None, coverage_excluded=None):
|
||||
bus = EventBus()
|
||||
captured: list = []
|
||||
bus.subscribe(lambda e: captured.append(e))
|
||||
store = EnvironmentalStore(EnvironmentalConfig(), event_bus=bus)
|
||||
store = EnvironmentalStore(
|
||||
EnvironmentalConfig(), event_bus=bus,
|
||||
coverage_areas=coverage_areas,
|
||||
coverage_excluded=coverage_excluded,
|
||||
)
|
||||
adapter = _FakeFires()
|
||||
store._adapters["nifc"] = adapter
|
||||
return store, adapter, captured
|
||||
|
||||
|
||||
# A coverage area covering SW Idaho (the default _raw_fire lat/lon 44.0,-115.0
|
||||
# sits inside this box; a fire near 0,0 or on the US east coast is outside).
|
||||
_SW_IDAHO_AREA = {
|
||||
"name": "sw-id", "west": -117.0, "south": 42.0, "east": -114.0, "north": 45.0,
|
||||
}
|
||||
|
||||
|
||||
def _seed_row(conn, *, acres, contained, last_bcast_at):
|
||||
"""Manually insert an already-broadcast fires row (skips cold-start)."""
|
||||
conn.execute(
|
||||
|
|
@ -309,3 +320,80 @@ def test_to_event_stamps_canonical_data(env):
|
|||
assert ev.data["contained_pct"] == 30
|
||||
assert ev.data["declared_at_epoch"] == _NOW
|
||||
assert ev.data["lat"] == 44.0 and ev.data["state"] == "US-ID"
|
||||
|
||||
|
||||
# ── 7. Coverage-scope ingest gate ────────────────────────────────────────────
|
||||
# Fires OUTSIDE every configured coverage area are NEVER stored (so they are
|
||||
# never tracked / alerted / reminded / re-ingested). The gate mirrors the
|
||||
# dispatch-level CoverageFilter's set-union membership and applies to BOTH the
|
||||
# cold-start seed and the live INSERT/UPDATE path. Fail-OPEN when coverage is
|
||||
# disabled or has no areas (unchanged current behaviour).
|
||||
|
||||
|
||||
def _fires_row(conn, irwin):
|
||||
return conn.execute(
|
||||
"SELECT irwin_id FROM fires WHERE irwin_id=?", (irwin,)).fetchone()
|
||||
|
||||
|
||||
def test_coverage_gate_inside_area_is_stored(env):
|
||||
"""A fire INSIDE a coverage box is stored (cold-start seed row present)."""
|
||||
conn, _clk = env
|
||||
store, adapter, captured = _make_store(coverage_areas=[_SW_IDAHO_AREA])
|
||||
# Default coords 44.0,-115.0 are inside _SW_IDAHO_AREA.
|
||||
adapter.set_batch([_raw_fire(irwin="IRWIN-IN-1", lat=44.0, lon=-115.0)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert captured == [], "cold-start seed must broadcast nothing"
|
||||
assert _fires_row(conn, "IRWIN-IN-1") is not None, \
|
||||
"in-area fire must be stored"
|
||||
|
||||
|
||||
def test_coverage_gate_outside_area_is_not_stored(env):
|
||||
"""A fire OUTSIDE all coverage boxes is NOT stored (no row) with coverage
|
||||
enabled + areas defined — neither the cold-start seed nor a live upsert."""
|
||||
conn, _clk = env
|
||||
store, adapter, captured = _make_store(coverage_areas=[_SW_IDAHO_AREA])
|
||||
# 0,0 (Gulf of Guinea) is far outside the SW-Idaho box.
|
||||
adapter.set_batch([_raw_fire(irwin="IRWIN-OUT-1", lat=0.0, lon=0.0)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert captured == [], "out-of-area fire must broadcast nothing"
|
||||
assert _fires_row(conn, "IRWIN-OUT-1") is None, \
|
||||
"out-of-area fire must NOT be stored (cold-start seed dropped)"
|
||||
|
||||
# A later (already-seeded) poll must ALSO refuse to INSERT the out-of-area
|
||||
# fire via the live path — it must never latch a row.
|
||||
store._fires_seeded = True
|
||||
store._ingest("nifc", adapter)
|
||||
assert _fires_row(conn, "IRWIN-OUT-1") is None, \
|
||||
"out-of-area fire must NOT be stored on the live upsert path either"
|
||||
|
||||
|
||||
def test_coverage_gate_fail_open_when_no_areas(env):
|
||||
"""Coverage DISABLED / no areas -> an out-of-box fire IS stored (fail-open,
|
||||
unchanged behaviour)."""
|
||||
conn, _clk = env
|
||||
store, adapter, captured = _make_store() # no coverage areas
|
||||
assert store._fire_coverage_areas == [], \
|
||||
"no coverage areas -> gate is a no-op list"
|
||||
adapter.set_batch([_raw_fire(irwin="IRWIN-OPEN-1", lat=0.0, lon=0.0)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert _fires_row(conn, "IRWIN-OPEN-1") is not None, \
|
||||
"with no coverage areas, every fire is stored (fail-open)"
|
||||
|
||||
|
||||
def test_coverage_gate_excluded_adapter_fails_open(env):
|
||||
"""When 'fires' is on the coverage opt-out list, the gate is disabled even
|
||||
with areas configured — mirrors the _coverage_for fetch-scope escape hatch;
|
||||
an out-of-box fire IS stored."""
|
||||
conn, _clk = env
|
||||
store, adapter, captured = _make_store(
|
||||
coverage_areas=[_SW_IDAHO_AREA], coverage_excluded=["fires"])
|
||||
assert store._fire_coverage_areas == [], \
|
||||
"excluded 'fires' adapter -> no gate built"
|
||||
adapter.set_batch([_raw_fire(irwin="IRWIN-EXCL-1", lat=0.0, lon=0.0)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert _fires_row(conn, "IRWIN-EXCL-1") is not None, \
|
||||
"excluded adapter falls back to no coverage gating (fire stored)"
|
||||
|
|
|
|||
|
|
@ -59,79 +59,6 @@ def test_router_scope_type_defined_before_env_check():
|
|||
or "scope_type:" in preceding
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# adapter_config seed + categories registration
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_adapter_config_seeds_digest_keys():
|
||||
from meshai.persistence import get_db
|
||||
rows = {
|
||||
(r["adapter"], r["key"]): r["default_json"]
|
||||
for r in get_db().execute(
|
||||
"SELECT adapter, key, default_json FROM adapter_config "
|
||||
"WHERE adapter='fires' AND key LIKE 'digest%'"
|
||||
)
|
||||
}
|
||||
assert rows[("fires", "digest_enabled")] == "true"
|
||||
assert rows[("fires", "digest_schedule")] == '["06:00", "18:00"]'
|
||||
assert rows[("fires", "digest_timezone")] == '"America/Boise"'
|
||||
assert rows[("fires", "digest_max_chars")] == "140"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Digest renderer
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_render_digest_returns_no_fires_when_table_empty():
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
|
||||
async def _run():
|
||||
return await render_digest(now=None)
|
||||
wire, source = asyncio.run(_run())
|
||||
assert wire == ""
|
||||
assert source == "no_fires"
|
||||
|
||||
|
||||
def test_render_digest_terse_fallback_when_no_llm():
|
||||
_seed_fire(irwin_id="ID-A", name="Cache Peak",
|
||||
lat=42.0, lon=-114.0, acres=1847, contained=23)
|
||||
_seed_fire(irwin_id="ID-B", name="Twin Peaks",
|
||||
lat=43.0, lon=-115.0, acres=320, contained=5)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
|
||||
async def _run():
|
||||
return await render_digest(now=None)
|
||||
wire, source = asyncio.run(_run())
|
||||
assert source == "deterministic"
|
||||
assert wire
|
||||
assert "Cache Peak" in wire
|
||||
assert len(wire) <= 140
|
||||
|
||||
|
||||
def test_render_digest_uses_llm_when_available():
|
||||
"""When the LLM backend returns a string, that string IS the wire."""
|
||||
_seed_fire(irwin_id="ID-A", name="Cache Peak",
|
||||
lat=42.0, lon=-114.0, acres=1847)
|
||||
|
||||
class StubLLM:
|
||||
async def generate(self, *, messages, system_prompt, max_tokens):
|
||||
# The renderer must give us a single-line wire derived from
|
||||
# the LLM output, with markdown stripped + cap applied.
|
||||
return "Cache Peak 1847 ac stable; no spotting today."
|
||||
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
|
||||
async def _run():
|
||||
return await render_digest(now=None)
|
||||
wire, source = asyncio.run(_run())
|
||||
assert source == "deterministic"
|
||||
# render_digest is now fully deterministic (no LLM backend).
|
||||
assert "Cache Peak" in wire
|
||||
assert "1,847 ac" in wire
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Natural-language fire DMs route to the LLM (no ?status fallback)
|
||||
# ===========================================================================
|
||||
|
|
|
|||
|
|
@ -670,16 +670,11 @@ def test_wfigs_discovery_is_date_only():
|
|||
|
||||
|
||||
# ============================================================================
|
||||
# Fire-digest kill-switch does NOT touch the per-fire wfigs path: a new-fire
|
||||
# envelope still produces a broadcast even though digest broadcast is disabled.
|
||||
# A new-fire envelope produces a per-fire wfigs broadcast.
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_per_fire_wfigs_broadcasts_while_digest_disabled(mem_db, no_photon):
|
||||
from meshai.adapter_config import adapter_config
|
||||
# Default posture: the twice-daily digest broadcast is OFF.
|
||||
assert bool(adapter_config.fires.digest_broadcast_enabled) is False
|
||||
# ... yet a new per-fire wfigs alert still broadcasts.
|
||||
def test_per_fire_wfigs_broadcasts_new_fire(mem_db, no_photon):
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
data = {}
|
||||
wire = handle_wfigs(cn.normalize(env), env, env["subject"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue