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:
Matt Johnson (via Claude) 2026-06-05 20:11:40 +00:00
commit eb84f27941
4 changed files with 906 additions and 16 deletions

View file

@ -0,0 +1,448 @@
"""v0.6-5 env_reporter -- LLM-facing summaries of every adapter table.
Mirrors the mesh_reporter pattern: pre-renders text blocks that
router.generate_llm_response() appends to the system prompt. The LLM
consumes the text; there is no tool-use / MCP / SQL pass-through (per
audit doc Section C: keep the existing prompt-injection contract).
Every build_*_detail() method honors adapter_meta.include_in_llm_context.
When a user (via /api/adapter-meta PUT) turns that off for an adapter,
its data drops out of build_env_summary AND its own detail block
returns an empty string -- the LLM never sees rows from that adapter.
Read-only library; no writes to any table.
"""
from __future__ import annotations
import logging
import sqlite3
import time
from datetime import datetime, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# Length budget per block. The LLM has a finite context window; we cap each
# block at this many characters to keep the assembled prompt sane.
_BLOCK_MAX_CHARS = 3000
# ============================================================================
# Reporter
# ============================================================================
class EnvReporter:
"""Build pre-rendered LLM context blocks from the adapter tables."""
def __init__(self, conn_factory=None):
"""Args:
conn_factory: callable returning a sqlite3.Connection. Defaults
to meshai.persistence.get_db. Tests can pass an in-memory
connection.
"""
if conn_factory is None:
from meshai.persistence import get_db
self._conn_factory = get_db
else:
self._conn_factory = conn_factory
# ---------- meta gate ---------------------------------------------------
def _adapter_included(self, adapter: str) -> bool:
"""Read adapter_meta.include_in_llm_context. Missing meta defaults to True
(defensive: don't silently exclude a new adapter)."""
try:
conn = self._conn_factory()
r = conn.execute(
"SELECT include_in_llm_context FROM adapter_meta WHERE adapter=?",
(adapter,),
).fetchone()
if r is None:
return True
return bool(r["include_in_llm_context"])
except Exception:
logger.exception("env_reporter: meta lookup failed for %s", adapter)
return True
# ---------- top-level summary -------------------------------------------
def build_env_summary(self, *, now: Optional[int] = None) -> str:
"""One-line-per-adapter top-line: counts + the most-active item per
included adapter. Skips adapters with include_in_llm_context=False
AND skips adapters whose tables are empty.
"""
now = now if now is not None else int(time.time())
try:
conn = self._conn_factory()
except Exception:
logger.exception("env_reporter: db unavailable")
return ""
lines: list[str] = ["ENVIRONMENTAL CONTEXT (compiled from local SQLite tables):"]
if self._adapter_included("wfigs"):
n_fires = self._scalar(conn, "SELECT COUNT(*) FROM fires WHERE last_event_at >= ?",
(now - 7 * 86400,))
if n_fires:
lines.append(f" Active fires (WFIGS, last 7d): {n_fires}")
if self._adapter_included("firms"):
n_pixels = self._scalar(conn,
"SELECT COUNT(*) FROM firms_pixels WHERE acq_time >= ?",
(now - 24 * 3600,))
if n_pixels:
lines.append(f" FIRMS hotspots (last 24h): {n_pixels}")
if self._adapter_included("nws"):
n_alerts = self._scalar(conn,
"SELECT COUNT(*) FROM nws_alerts WHERE expires_at IS NULL OR expires_at >= ?",
(now,))
if n_alerts:
lines.append(f" NWS active alerts: {n_alerts}")
if self._adapter_included("usgs_quake"):
n_q = self._scalar(conn,
"SELECT COUNT(*) FROM quake_events WHERE occurred_at >= ?",
(now - 24 * 3600,))
if n_q:
lines.append(f" USGS earthquakes (last 24h): {n_q}")
if self._adapter_included("usgs_nwis"):
n_g = self._scalar(conn,
"SELECT COUNT(DISTINCT site_id) FROM gauge_readings "
"WHERE reading_time >= ? AND threshold_state != 'normal'",
(now - 24 * 3600,))
if n_g:
lines.append(f" Stream gauges above action stage (24h): {n_g}")
if self._adapter_included("swpc"):
r = conn.execute(
"SELECT event_type, occurred_at FROM swpc_events "
"WHERE occurred_at >= ? ORDER BY occurred_at DESC LIMIT 1",
(now - 24 * 3600,)
).fetchone()
if r:
lines.append(f" Latest SWPC event (24h): {r['event_type']}")
# Band conditions latest broadcast
r2 = conn.execute(
"SELECT scheduled_for FROM band_conditions_broadcasts "
"WHERE sent_at IS NOT NULL ORDER BY scheduled_for DESC LIMIT 1"
).fetchone()
if r2 and self._adapter_included("band_conditions"):
ts = _fmt_epoch(r2["scheduled_for"])
lines.append(f" Last band-conditions broadcast: {ts}")
if self._adapter_included("tomtom_incidents") or \
self._adapter_included("itd_511") or \
self._adapter_included("state_511_atis"):
n_t = self._scalar(conn,
"SELECT COUNT(*) FROM traffic_events WHERE last_seen_at >= ?",
(now - 2 * 3600,))
if n_t:
lines.append(f" Active traffic incidents (last 2h): {n_t}")
if len(lines) == 1:
# Only the header; nothing to report.
return ""
return "\n".join(lines)[:_BLOCK_MAX_CHARS]
# ---------- per-adapter detail blocks ----------------------------------
def build_fires_detail(self, *, near: Optional[tuple[float, float]] = None,
radius_mi: float = 100.0,
limit: int = 10,
now: Optional[int] = None) -> str:
if not (self._adapter_included("wfigs") or self._adapter_included("firms")):
return ""
now = now if now is not None else int(time.time())
try: conn = self._conn_factory()
except Exception: return ""
lines: list[str] = []
if self._adapter_included("wfigs"):
rows = conn.execute(
"SELECT incident_name, current_acres, current_contained_pct, "
"lat, lon, county, state, declared_at "
"FROM fires WHERE last_event_at >= ? "
"ORDER BY current_acres DESC NULLS LAST LIMIT ?",
(now - 7 * 86400, limit),
).fetchall()
if rows:
lines.append("ACTIVE WILDFIRES (WFIGS, last 7d):")
for r in rows:
name = r["incident_name"] or "(unnamed)"
acres = "?" if r["current_acres"] is None else f"{int(r['current_acres']):,} ac"
cont = "?" if r["current_contained_pct"] is None else f"{r['current_contained_pct']}%"
loc_parts = [p for p in (r["county"], r["state"]) if p]
loc = " / ".join(loc_parts) if loc_parts else (
f"{r['lat']:.3f},{r['lon']:.3f}" if r["lat"] is not None else "loc?")
declared = _fmt_epoch(r["declared_at"]) if r["declared_at"] else "?"
lines.append(f" - {name}: {acres}, {cont} contained, {loc}, declared {declared}")
if self._adapter_included("firms"):
n_pixels = self._scalar(conn,
"SELECT COUNT(*) FROM firms_pixels WHERE acq_time >= ?",
(now - 24 * 3600,))
n_high = self._scalar(conn,
"SELECT COUNT(*) FROM firms_pixels WHERE acq_time >= ? AND confidence='high'",
(now - 24 * 3600,))
if n_pixels:
lines.append(
f"FIRMS HOTSPOTS (NASA satellite, last 24h): "
f"{n_pixels} pixels total, {n_high} high-confidence"
)
return ("\n".join(lines) if lines else "")[:_BLOCK_MAX_CHARS]
def build_alerts_detail(self, *, region: Optional[str] = None,
limit: int = 10,
now: Optional[int] = None) -> str:
if not self._adapter_included("nws"):
return ""
now = now if now is not None else int(time.time())
try: conn = self._conn_factory()
except Exception: return ""
q = ("SELECT alert_type, severity, headline, county, state, expires_at "
"FROM nws_alerts WHERE expires_at IS NULL OR expires_at >= ?")
args: list = [now]
if region:
q += " AND state = ?"
args.append(region.upper().lstrip("US-"))
q += " ORDER BY expires_at DESC LIMIT ?"
args.append(limit)
rows = conn.execute(q, tuple(args)).fetchall()
if not rows:
return ""
lines = ["ACTIVE NWS ALERTS:"]
for r in rows:
kind = r["alert_type"] or "Alert"
sev = r["severity"] or "?"
loc = " / ".join(p for p in (r["county"], r["state"]) if p) or "?"
expires = _fmt_epoch(r["expires_at"]) if r["expires_at"] else "no expiry"
head = (r["headline"] or "")[:90]
lines.append(f" - [{sev}] {kind} ({loc}): {head} -- until {expires}")
return "\n".join(lines)[:_BLOCK_MAX_CHARS]
def build_quakes_detail(self, *, hours: int = 24,
limit: int = 10,
now: Optional[int] = None) -> str:
if not self._adapter_included("usgs_quake"):
return ""
now = now if now is not None else int(time.time())
try: conn = self._conn_factory()
except Exception: return ""
rows = conn.execute(
"SELECT magnitude, depth_km, place, lat, lon, occurred_at, tsunami_warning "
"FROM quake_events WHERE occurred_at >= ? "
"ORDER BY occurred_at DESC LIMIT ?",
(now - hours * 3600, limit),
).fetchall()
if not rows:
return ""
lines = [f"RECENT EARTHQUAKES (last {hours}h):"]
for r in rows:
mag = f"M{r['magnitude']:.1f}" if r["magnitude"] is not None else "M?"
depth = f"{int(r['depth_km'])}km" if r["depth_km"] is not None else "?"
place = r["place"] or f"{r['lat']:.2f},{r['lon']:.2f}" if r["lat"] else "?"
when = _fmt_epoch(r["occurred_at"]) if r["occurred_at"] else "?"
ts = " TSUNAMI" if r["tsunami_warning"] else ""
lines.append(f" - {mag} {place}, {depth} depth, {when}{ts}")
return "\n".join(lines)[:_BLOCK_MAX_CHARS]
def build_traffic_detail(self, *, state: Optional[str] = "ID",
hours: int = 2,
limit: int = 10,
now: Optional[int] = None) -> str:
if not any(self._adapter_included(a) for a in
("tomtom_incidents", "itd_511", "state_511_atis")):
return ""
now = now if now is not None else int(time.time())
try: conn = self._conn_factory()
except Exception: return ""
q = ("SELECT source, road, direction, sub_type, impact, county, "
"delay_seconds, last_seen_at FROM traffic_events "
"WHERE last_seen_at >= ?")
args: list = [now - hours * 3600]
if state:
q += " AND state = ?"
args.append(state)
q += " ORDER BY last_seen_at DESC LIMIT ?"
args.append(limit)
rows = conn.execute(q, tuple(args)).fetchall()
if not rows:
return ""
lines = [f"ACTIVE TRAFFIC INCIDENTS (last {hours}h):"]
for r in rows:
road = r["road"] or "road?"
direction = r["direction"] or ""
sub = r["sub_type"] or "incident"
county = r["county"] or "?"
impact = f", {r['impact']}" if r["impact"] else ""
delay = ""
if r["delay_seconds"] and r["delay_seconds"] > 0:
delay = f", {int(r['delay_seconds']/60)} min delay"
when = _fmt_epoch(r["last_seen_at"])
lines.append(f" - {road} {direction} ({county}): {sub}{impact}{delay}, seen {when}")
return "\n".join(lines)[:_BLOCK_MAX_CHARS]
def build_gauges_detail(self, *, limit: int = 10,
now: Optional[int] = None) -> str:
if not self._adapter_included("usgs_nwis"):
return ""
now = now if now is not None else int(time.time())
try: conn = self._conn_factory()
except Exception: return ""
# Most-recent reading per site, then filter to above-action sites.
rows = conn.execute(
"SELECT site_id, gauge_name, reading_value, reading_unit, "
"threshold_state, flow_cfs, reading_time, lat, lon "
"FROM gauge_readings "
"WHERE reading_time >= ? "
"GROUP BY site_id "
"HAVING MAX(reading_time) "
"ORDER BY reading_time DESC LIMIT ?",
(now - 24 * 3600, limit),
).fetchall()
if not rows:
return ""
lines = ["LATEST STREAM GAUGE READINGS (24h):"]
for r in rows:
ts_state = r["threshold_state"] or "?"
value = (f"{r['reading_value']:.1f} {r['reading_unit']}"
if r['reading_value'] is not None else "?")
flow = (f", flow {int(r['flow_cfs']):,} cfs"
if r['flow_cfs'] is not None else "")
name = r["gauge_name"] or r["site_id"]
lines.append(f" - {name}: {value} ({ts_state}){flow}")
return "\n".join(lines)[:_BLOCK_MAX_CHARS]
def build_swpc_detail(self, *, hours: int = 24,
now: Optional[int] = None) -> str:
included_swpc = self._adapter_included("swpc")
included_bc = self._adapter_included("band_conditions")
if not (included_swpc or included_bc):
return ""
now = now if now is not None else int(time.time())
try: conn = self._conn_factory()
except Exception: return ""
lines: list[str] = []
if included_swpc:
rows = conn.execute(
"SELECT event_type, severity_int, occurred_at FROM swpc_events "
"WHERE occurred_at >= ? "
"ORDER BY occurred_at DESC LIMIT 5",
(now - hours * 3600,),
).fetchall()
if rows:
lines.append(f"RECENT SPACE WEATHER (last {hours}h):")
for r in rows:
when = _fmt_epoch(r["occurred_at"])
sev = f", severity {r['severity_int']}" if r["severity_int"] is not None else ""
lines.append(f" - {r['event_type']}{sev}, {when}")
if included_bc:
r = conn.execute(
"SELECT scheduled_for, ratings_json, source FROM band_conditions_broadcasts "
"WHERE sent_at IS NOT NULL ORDER BY scheduled_for DESC LIMIT 1"
).fetchone()
if r:
lines.append(f"LATEST BAND CONDITIONS:")
lines.append(f" Scheduled for {_fmt_epoch(r['scheduled_for'])} "
f"(source: {r['source']})")
if r["ratings_json"]:
lines.append(f" Ratings: {r['ratings_json']}")
return ("\n".join(lines) if lines else "")[:_BLOCK_MAX_CHARS]
def build_drop_audit(self, *, hours: int = 1) -> str:
"""Why-was-X-dropped: event_log handled=0 grouped by source+reason
+ the dispatcher_state cumulative counters."""
now = int(time.time())
try: conn = self._conn_factory()
except Exception: return ""
lines = []
try:
rows = conn.execute(
"SELECT source, category, COUNT(*) AS n FROM event_log "
"WHERE handled=0 AND received_at >= ? "
"GROUP BY source, category ORDER BY n DESC LIMIT 15",
(now - hours * 3600,),
).fetchall()
if rows:
lines.append(f"FILTERED ENVELOPES (event_log handled=0, last {hours}h):")
for r in rows:
lines.append(f" - {r['source']:<18s} {r['category']:<40s} n={r['n']}")
except Exception:
pass
# Dispatcher cumulative counters.
try:
r = conn.execute(
"SELECT stale_dropped, cooldown_dropped, dedup_dropped, "
"cold_start_dropped, cold_start_anchor FROM dispatcher_state WHERE id=1"
).fetchone()
if r:
lines.append(
f"DISPATCHER COUNTERS (cumulative since install): "
f"stale={r['stale_dropped']} cooldown={r['cooldown_dropped']} "
f"dedup={r['dedup_dropped']} cold_start={r['cold_start_dropped']}"
)
except Exception:
pass
return ("\n".join(lines) if lines else "")[:_BLOCK_MAX_CHARS]
def build_all(self, *, now: Optional[int] = None) -> str:
"""Convenience: summary + every detail block. Used by router when the
env scope is detected. Empty blocks are dropped."""
parts = [
self.build_env_summary(now=now),
self.build_fires_detail(now=now),
self.build_alerts_detail(now=now),
self.build_quakes_detail(now=now),
self.build_traffic_detail(now=now),
self.build_gauges_detail(now=now),
self.build_swpc_detail(now=now),
]
return "\n\n".join(p for p in parts if p)
# ---------- helpers -----------------------------------------------------
@staticmethod
def _scalar(conn, sql, args=()) -> int:
try:
r = conn.execute(sql, args).fetchone()
if r is None: return 0
val = r[0]
return int(val) if val is not None else 0
except Exception:
return 0
# ---------- module-level helpers -----------------------------------------
def _fmt_epoch(epoch) -> str:
"""Format an epoch second as ISO-ish UTC for the LLM prompt."""
if not epoch:
return "?"
try:
dt = datetime.fromtimestamp(int(epoch), tz=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M UTC")
except Exception:
return "?"
# Module-level singleton for the router to use.
env_reporter = EnvReporter()

View file

@ -65,6 +65,50 @@ _MESH_KEYWORDS = {
"repeaters", "regions", "localities", "score", "status", "repeaters", "regions", "localities", "score", "status",
} }
# v0.6-5: env keywords expand the mesh-question detector so the LLM gets
# env_reporter blocks when the user asks about fires/quakes/weather/etc.
# Each keyword maps to a coarse subtype used by _detect_env_subtype.
_ENV_KEYWORDS_TO_SUBTYPE: dict[str, str] = {
# fires
"fire": "fires", "fires": "fires", "wildfire": "fires",
"wildfires": "fires", "hotspot": "fires", "hotspots": "fires",
"burning": "fires", "smoke": "fires",
# quakes
"quake": "quakes", "quakes": "quakes", "earthquake": "quakes",
"earthquakes": "quakes", "seismic": "quakes", "tsunami": "quakes",
# gauges (placed BEFORE weather alerts so "flood" wins over "warning"
# in cases like "river flood warning")
"flood": "gauges", "flooding": "gauges",
"gauge": "gauges", "river": "gauges", "stream": "gauges",
# weather alerts
"warning": "alerts", "watch": "alerts", "advisory": "alerts",
"tornado": "alerts", "thunderstorm": "alerts", "blizzard": "alerts",
# space weather + band conditions
"swpc": "swpc", "geomag": "swpc", "solar": "swpc", "kp": "swpc",
"propagation": "swpc", "aurora": "swpc",
"band": "swpc", "bands": "swpc", "hf": "swpc",
# traffic / roads
"road": "traffic", "roads": "traffic", "jam": "traffic",
"crash": "traffic", "closure": "traffic", "511": "traffic",
"incident": "traffic", "incidents": "traffic",
# generic
"storm": "alerts", "weather": "alerts",
}
def _detect_env_subtype(message_lower: str) -> Optional[str]:
"""Return the env subtype matched by the first env keyword in the message.
`None` when no env keyword matches. Uses set intersection on tokenized
words so partial-word collisions (e.g. "firearm" / "fire") don\'t fire."""
if not message_lower:
return None
words = set(re.findall(r"\b\w+\b", message_lower))
for kw, subtype in _ENV_KEYWORDS_TO_SUBTYPE.items():
if kw in words:
return subtype
return None
# Phrases that indicate mesh questions # Phrases that indicate mesh questions
_MESH_PHRASES = [ _MESH_PHRASES = [
"how's the mesh", "how's the mesh",
@ -333,42 +377,44 @@ class MessageRouter:
return RouteResult(RouteType.LLM, query=query) return RouteResult(RouteType.LLM, query=query)
def _is_mesh_question(self, message: str) -> bool: def _is_mesh_question(self, message: str) -> bool:
"""Check if message is asking about mesh health/status. """Check if message is asking about mesh health/status OR env state.
Args: v0.6-5: env keywords (fire/quake/flood/etc.) also trigger the
message: User message text mesh-question path so the env_reporter blocks land in the system
prompt. Single detector per Matt\'s spec.
Returns:
True if this is a mesh-related question
""" """
msg_lower = message.lower() msg_lower = message.lower()
# Check for mesh phrases # Mesh phrases.
for phrase in _MESH_PHRASES: for phrase in _MESH_PHRASES:
if phrase in msg_lower: if phrase in msg_lower:
return True return True
# Check for mesh keywords # Mesh keywords + env keywords.
words = set(re.findall(r'\b\w+\b', msg_lower)) words = set(re.findall(r'\b\w+\b', msg_lower))
if words & _MESH_KEYWORDS: if words & _MESH_KEYWORDS:
return True return True
if _detect_env_subtype(msg_lower) is not None:
return True
return False return False
def _detect_mesh_scope(self, message: str) -> tuple[str, Optional[str]]: def _detect_mesh_scope(self, message: str) -> tuple[str, Optional[str]]:
"""Detect the scope of a mesh question. """Detect the scope of a mesh question.
Args: Returns one of:
message: User message text - ("env", subtype) : fires/quakes/alerts/gauges/traffic/swpc
- ("node", id) : specific node
Returns: - ("region", name) : specific region
Tuple of (scope_type, scope_value): - ("mesh", None) : general mesh question
- ("node", "{identifier}") if asking about specific node
- ("region", "{region_name}") if asking about specific region
- ("mesh", None) for general mesh questions
""" """
msg_lower = message.lower() msg_lower = message.lower()
# === ENV (v0.6-5: check first; env scope routes through env_reporter) ===
env_subtype = _detect_env_subtype(msg_lower)
if env_subtype is not None:
return ("env", env_subtype)
# === NODE MATCHING (check first - more specific) === # === NODE MATCHING (check first - more specific) ===
if self.health_engine and self.health_engine.mesh_health: if self.health_engine and self.health_engine.mesh_health:
health = self.health_engine.mesh_health health = self.health_engine.mesh_health
@ -693,6 +739,23 @@ class MessageRouter:
should_inject_mesh = is_direct_mesh_question or is_followup should_inject_mesh = is_direct_mesh_question or is_followup
# v0.6-5 env_reporter: when scope is "env" OR when injecting mesh
# context, append the env_reporter blocks. The reporter itself gates
# per-adapter via adapter_meta.include_in_llm_context.
if should_inject_mesh and scope_type == "env":
try:
from meshai.notifications.env_reporter import env_reporter
env_block = env_reporter.build_all()
if env_block:
system_prompt += "\n\n" + env_block
# Drop audit is useful for "why didn\'t I hear about X?" --
# always include the most-recent hour when env scope.
drop_block = env_reporter.build_drop_audit(hours=1)
if drop_block:
system_prompt += "\n\n" + drop_block
except Exception:
logger.exception("env_reporter injection failed")
if self.source_manager and self.mesh_reporter and should_inject_mesh: if self.source_manager and self.mesh_reporter and should_inject_mesh:
# Detect scope from current message # Detect scope from current message
scope_type, scope_value = self._detect_mesh_scope(query) scope_type, scope_value = self._detect_mesh_scope(query)

289
tests/test_env_reporter.py Normal file
View 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() == ""

View 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"