mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +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
448
meshai/notifications/env_reporter.py
Normal file
448
meshai/notifications/env_reporter.py
Normal 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()
|
||||||
|
|
@ -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
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