mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat(v0.6-5): env_reporter + router wiring + include_in_llm_context per-adapter toggle -- LLM gains read access to every adapter table via the existing mesh_reporter pre-rendered prompt-injection pattern
Closes audit doc Section C. The LLM can now answer "any fires near me?",
"how are band conditions?", "why didnt I hear about that quake?"
without any tool-use / MCP / SQL pass-through -- via the same prompt-
injection contract mesh_reporter uses.
env_reporter (meshai/notifications/env_reporter.py):
- EnvReporter class with build_env_summary / build_fires_detail /
build_alerts_detail / build_quakes_detail / build_traffic_detail /
build_gauges_detail / build_swpc_detail / build_drop_audit / build_all
- Reads from fires + firms_pixels + nws_alerts + quake_events +
traffic_events + gauge_readings + swpc_events +
band_conditions_broadcasts + event_log + dispatcher_state
- Each build_*_detail() checks adapter_meta.include_in_llm_context for
the relevant adapter(s) before reading; turning the meta off via
/api/adapter-meta drops that adapters block out of the LLM prompt
- Defensive: missing meta row defaults to True (include); DB-unavailable
returns empty string; per-block 3000-char cap
- Module-level env_reporter singleton for the router
Router wiring (meshai/router.py):
- Extended _MESH_KEYWORDS dispatcher with _ENV_KEYWORDS_TO_SUBTYPE
mapping (fire/quake/flood/warning/storm/road/swpc/etc -> coarse
subtype). "flood" intentionally precedes "warning" so
"river flood warning" routes to gauges, not alerts
- _detect_env_subtype helper at module level (also test-importable)
- _is_mesh_question now also fires for env keywords -- single detector
per Matt s spec
- _detect_mesh_scope returns ("env", subtype) when an env keyword
matches, taking precedence over the node/region branches
- generate_llm_response: when scope_type == "env", appends
env_reporter.build_all() + env_reporter.build_drop_audit(hours=1)
to the system prompt. Wrapped in try/except so a reporter fault
never blocks the LLM call
Tests:
- tests/test_env_reporter.py (18 cases): meta gate, every build_*
method shape, build_all combines blocks, all-off produces empty
- tests/test_router_env_scope.py (18 cases): parametrized subtype
detection across fires/quakes/alerts/gauges/traffic/swpc, word-
boundary check (firearm != fire), synthetic-probe end-to-end
(seed fires table -> env_reporter emits a fires block with the
seeded row)
Test count: 761 -> 797 (+36 new, 0 regressions).
This commit is contained in:
parent
42b3106e97
commit
eb84f27941
4 changed files with 906 additions and 16 deletions
289
tests/test_env_reporter.py
Normal file
289
tests/test_env_reporter.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""v0.6-5 env_reporter tests.
|
||||
|
||||
Uses the autouse conftest fixture which sets MESHAI_DB_PATH to a fresh tmp
|
||||
file and runs init_db (so v1..v7 migrations + adapter_meta seeding all
|
||||
happen automatically).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.notifications.env_reporter import EnvReporter
|
||||
from meshai.persistence import get_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reporter():
|
||||
return EnvReporter()
|
||||
|
||||
|
||||
def _seed_fire(conn, *, irwin_id, name, acres, contained, lat=43.6, lon=-116.2,
|
||||
county="Ada", state="ID", declared_at=None, last_event_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) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(irwin_id, name, "WF", acres, contained, lat, lon, county, state,
|
||||
declared_at or now, last_event_at or now),
|
||||
)
|
||||
|
||||
|
||||
def _seed_nws_alert(conn, *, cap_id, alert_type, severity, state="ID",
|
||||
headline="", expires_at=None):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO nws_alerts(event_id, alert_type, severity, "
|
||||
"county, state, headline, expires_at, first_seen_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(cap_id, alert_type, severity, "Ada", state, headline,
|
||||
expires_at or (now + 3600), now),
|
||||
)
|
||||
|
||||
|
||||
def _seed_quake(conn, *, event_id, magnitude, place, lat=44.5, lon=-114.5):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO quake_events(event_id, magnitude, depth_km, "
|
||||
"place, lat, lon, occurred_at, first_seen_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(event_id, magnitude, 10.0, place, lat, lon, now, now),
|
||||
)
|
||||
|
||||
|
||||
def _seed_traffic(conn, *, source, external_id, road, county="Ada", state="ID"):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO traffic_events(source, external_id, road, "
|
||||
"direction, county, state, sub_type, impact, first_seen_at, "
|
||||
"last_seen_at, delay_seconds) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(source, external_id, road, "N", county, state, "accident",
|
||||
None, now, now, 600),
|
||||
)
|
||||
|
||||
|
||||
def _seed_gauge(conn, *, site_id, gauge_name, value, threshold_state="action"):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT INTO gauge_readings(site_id, gauge_name, reading_value, "
|
||||
"reading_unit, threshold_state, reading_time) VALUES (?,?,?,?,?,?)",
|
||||
(site_id, gauge_name, value, "ft", threshold_state, now),
|
||||
)
|
||||
|
||||
|
||||
def _seed_swpc(conn, *, event_id, event_type="swpc_kindex", severity=2):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO swpc_events(event_id, event_type, severity_int, "
|
||||
"payload_json, occurred_at, first_seen_at) VALUES (?,?,?,?,?,?)",
|
||||
(event_id, event_type, severity, "{}", now, now),
|
||||
)
|
||||
|
||||
|
||||
def _seed_band_broadcast(conn):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO band_conditions_broadcasts("
|
||||
"sent_at, scheduled_for, ratings_json, source) VALUES (?,?,?,?)",
|
||||
(now, now - 60, '{"40m": "Good"}', "swpc_local"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# meta gate
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_adapter_included_defaults_true(reporter):
|
||||
"""Adapter not in adapter_meta defaults to True (defensive)."""
|
||||
assert reporter._adapter_included("brand_new_adapter") is True
|
||||
|
||||
|
||||
def test_adapter_included_reads_meta(reporter):
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE adapter_meta SET include_in_llm_context=0 WHERE adapter='wfigs'"
|
||||
)
|
||||
assert reporter._adapter_included("wfigs") is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# build_env_summary
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_env_summary_empty_when_no_data(reporter):
|
||||
"""Empty tables -> empty summary."""
|
||||
assert reporter.build_env_summary() == ""
|
||||
|
||||
|
||||
def test_env_summary_includes_fires(reporter):
|
||||
conn = get_db()
|
||||
_seed_fire(conn, irwin_id="F1", name="Cache Peak", acres=135, contained=10)
|
||||
_seed_fire(conn, irwin_id="F2", name="Bald Mtn", acres=42, contained=0)
|
||||
|
||||
s = reporter.build_env_summary()
|
||||
assert "Active fires" in s
|
||||
assert "2" in s
|
||||
|
||||
|
||||
def test_env_summary_excludes_when_meta_off(reporter):
|
||||
conn = get_db()
|
||||
_seed_fire(conn, irwin_id="F1", name="X", acres=1, contained=0)
|
||||
conn.execute(
|
||||
"UPDATE adapter_meta SET include_in_llm_context=0 WHERE adapter='wfigs'"
|
||||
)
|
||||
s = reporter.build_env_summary()
|
||||
assert "Active fires" not in s
|
||||
|
||||
|
||||
def test_env_summary_combines_multiple_adapters(reporter):
|
||||
conn = get_db()
|
||||
_seed_fire(conn, irwin_id="F1", name="X", acres=1, contained=0)
|
||||
_seed_nws_alert(conn, cap_id="A1", alert_type="Tornado Warning",
|
||||
severity="Extreme", headline="Tornado approaching")
|
||||
_seed_quake(conn, event_id="Q1", magnitude=3.2, place="3km E of Boise")
|
||||
_seed_traffic(conn, source="tomtom_incidents", external_id="T1",
|
||||
road="I-84")
|
||||
s = reporter.build_env_summary()
|
||||
assert "Active fires" in s
|
||||
assert "NWS active alerts" in s
|
||||
assert "USGS earthquakes" in s
|
||||
assert "Active traffic incidents" in s
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# build_fires_detail
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_fires_detail_empty(reporter):
|
||||
assert reporter.build_fires_detail() == ""
|
||||
|
||||
|
||||
def test_fires_detail_renders_rows(reporter):
|
||||
conn = get_db()
|
||||
_seed_fire(conn, irwin_id="F1", name="Cache Peak", acres=2_345,
|
||||
contained=23, county="Cassia", state="ID")
|
||||
_seed_fire(conn, irwin_id="F2", name="Bald Mountain", acres=420,
|
||||
contained=0, county="Boise", state="ID")
|
||||
|
||||
text = reporter.build_fires_detail()
|
||||
assert "ACTIVE WILDFIRES" in text
|
||||
assert "Cache Peak" in text
|
||||
assert "2,345 ac" in text
|
||||
assert "23% contained" in text
|
||||
assert "Bald Mountain" in text
|
||||
|
||||
|
||||
def test_fires_detail_includes_firms_summary(reporter):
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
for i in range(5):
|
||||
conn.execute(
|
||||
"INSERT INTO firms_pixels(lat, lon, acq_time, frp, confidence, satellite) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(42.0 + i*0.01, -113.0, now, 50.0, "high", "N"),
|
||||
)
|
||||
text = reporter.build_fires_detail()
|
||||
assert "FIRMS HOTSPOTS" in text
|
||||
assert "5 pixels" in text
|
||||
|
||||
|
||||
def test_fires_detail_meta_off_drops_block(reporter):
|
||||
conn = get_db()
|
||||
_seed_fire(conn, irwin_id="F1", name="X", acres=1, contained=0)
|
||||
conn.execute(
|
||||
"UPDATE adapter_meta SET include_in_llm_context=0 WHERE adapter='wfigs'"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE adapter_meta SET include_in_llm_context=0 WHERE adapter='firms'"
|
||||
)
|
||||
assert reporter.build_fires_detail() == ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# build_alerts_detail / build_quakes_detail / build_traffic_detail /
|
||||
# build_gauges_detail / build_swpc_detail
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_alerts_detail(reporter):
|
||||
conn = get_db()
|
||||
_seed_nws_alert(conn, cap_id="A1", alert_type="Tornado Warning",
|
||||
severity="Extreme", headline="Tornado approaching Boise")
|
||||
text = reporter.build_alerts_detail()
|
||||
assert "Tornado Warning" in text
|
||||
assert "Extreme" in text
|
||||
|
||||
|
||||
def test_quakes_detail(reporter):
|
||||
conn = get_db()
|
||||
_seed_quake(conn, event_id="Q1", magnitude=4.2, place="20km W of Salmon")
|
||||
text = reporter.build_quakes_detail()
|
||||
assert "M4.2" in text
|
||||
assert "20km W of Salmon" in text
|
||||
|
||||
|
||||
def test_traffic_detail(reporter):
|
||||
conn = get_db()
|
||||
_seed_traffic(conn, source="tomtom_incidents", external_id="T1",
|
||||
road="I-84")
|
||||
text = reporter.build_traffic_detail()
|
||||
assert "I-84" in text
|
||||
assert "accident" in text
|
||||
|
||||
|
||||
def test_gauges_detail(reporter):
|
||||
conn = get_db()
|
||||
_seed_gauge(conn, site_id="USGS-13139510", gauge_name="Big Lost",
|
||||
value=7.1, threshold_state="flood_minor")
|
||||
text = reporter.build_gauges_detail()
|
||||
assert "Big Lost" in text
|
||||
assert "flood_minor" in text
|
||||
|
||||
|
||||
def test_swpc_detail(reporter):
|
||||
conn = get_db()
|
||||
_seed_swpc(conn, event_id="S1")
|
||||
_seed_band_broadcast(conn)
|
||||
text = reporter.build_swpc_detail()
|
||||
assert "RECENT SPACE WEATHER" in text
|
||||
assert "LATEST BAND CONDITIONS" in text
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# build_drop_audit + build_all
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_drop_audit_includes_dispatcher_counters(reporter):
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE dispatcher_state SET stale_dropped=4, cooldown_dropped=10 WHERE id=1"
|
||||
)
|
||||
text = reporter.build_drop_audit()
|
||||
assert "DISPATCHER COUNTERS" in text
|
||||
assert "stale=4" in text
|
||||
assert "cooldown=10" in text
|
||||
|
||||
|
||||
def test_build_all_combines_all_non_empty_blocks(reporter):
|
||||
conn = get_db()
|
||||
_seed_fire(conn, irwin_id="F1", name="X", acres=1, contained=0)
|
||||
_seed_nws_alert(conn, cap_id="A1", alert_type="Tornado Warning",
|
||||
severity="Extreme", headline="approaching")
|
||||
text = reporter.build_all()
|
||||
assert "ENVIRONMENTAL CONTEXT" in text
|
||||
assert "ACTIVE WILDFIRES" in text
|
||||
assert "ACTIVE NWS ALERTS" in text
|
||||
|
||||
|
||||
def test_build_all_empty_when_all_off(reporter):
|
||||
"""With every include_in_llm_context off, build_all returns empty."""
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE adapter_meta SET include_in_llm_context=0")
|
||||
assert reporter.build_all() == ""
|
||||
90
tests/test_router_env_scope.py
Normal file
90
tests/test_router_env_scope.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""v0.6-5 router env scope detection tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.router import _detect_env_subtype
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# _detect_env_subtype
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("msg, expected", [
|
||||
("any fires near me?", "fires"),
|
||||
("Are there wildfires today?", "fires"),
|
||||
("FIRE WARNING ISSUED", "fires"), # "fire" matches; "warning" alerts loses to first
|
||||
("how's the band conditions", "swpc"),
|
||||
("solar storm?", "swpc"),
|
||||
("how's HF propagation?", "swpc"),
|
||||
("are there earthquakes nearby?", "quakes"),
|
||||
("any seismic activity?", "quakes"),
|
||||
("river flood warning", "gauges"), # "flood" maps to gauges
|
||||
("stream gauge readings", "gauges"),
|
||||
("any tornado warnings?", "alerts"),
|
||||
("severe thunderstorm", "alerts"),
|
||||
("any traffic incidents?", "traffic"),
|
||||
("road closures on I-84", "traffic"),
|
||||
])
|
||||
def test_env_subtype_detection(msg, expected):
|
||||
assert _detect_env_subtype(msg.lower()) == expected
|
||||
|
||||
|
||||
def test_env_subtype_returns_none_for_non_env_questions():
|
||||
assert _detect_env_subtype("hello, how are you?") is None
|
||||
assert _detect_env_subtype("what's the time?") is None
|
||||
|
||||
|
||||
def test_env_subtype_does_not_match_substrings():
|
||||
"""'firearm' shouldn't match 'fire' (we use word boundaries)."""
|
||||
assert _detect_env_subtype("firearm collection") is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Synthetic probe: stub fires table, confirm router scope detection
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_env_reporter_emits_fires_block_when_table_populated():
|
||||
"""Seed the fires table and confirm env_reporter.build_all() includes a
|
||||
fires block. Stand-in for the "DM the router 'any fires near me?'"
|
||||
end-to-end since the full router needs many constructor mocks."""
|
||||
import time
|
||||
from meshai.notifications.env_reporter import env_reporter
|
||||
from meshai.persistence import get_db
|
||||
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT INTO fires(irwin_id, incident_name, incident_type, "
|
||||
"current_acres, current_contained_pct, lat, lon, county, state, "
|
||||
"declared_at, last_event_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("PROBE-1", "Probe Fire A", "WF", 1500, 15,
|
||||
42.5, -114.5, "Cassia", "ID", now - 3600, now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO fires(irwin_id, incident_name, incident_type, "
|
||||
"current_acres, current_contained_pct, lat, lon, county, state, "
|
||||
"declared_at, last_event_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("PROBE-2", "Probe Fire B", "WF", 750, 0,
|
||||
42.6, -114.6, "Twin Falls", "ID", now - 7200, now),
|
||||
)
|
||||
|
||||
text = env_reporter.build_all()
|
||||
assert "ENVIRONMENTAL CONTEXT" in text
|
||||
assert "Probe Fire A" in text
|
||||
assert "Probe Fire B" in text
|
||||
assert "1,500 ac" in text
|
||||
|
||||
|
||||
def test_router_scope_detector_returns_env_for_fire_keyword():
|
||||
"""The module-level _detect_env_subtype short-circuits to env subtype.
|
||||
Spot-check on a NotificationRouter._detect_mesh_scope is covered by the
|
||||
parametrized test above; here we confirm the integration shape."""
|
||||
assert _detect_env_subtype("any fires near me?") == "fires"
|
||||
# Different env subtypes route distinctly.
|
||||
assert _detect_env_subtype("how about quakes today?") == "quakes"
|
||||
assert _detect_env_subtype("river flood?") == "gauges"
|
||||
Loading…
Add table
Add a link
Reference in a new issue