Compare commits

..

6 commits

Author SHA1 Message Date
344ca0677d fix(notifications): complete severity cleanup to 3-level system
- Replace 11 info fallbacks with routine in router.py + channels.py
- Replace 2 warning min_severity defaults with priority
- Update config.example.yaml rules to use routine/priority/immediate
- Annotate config.example.yaml notifications section as transitional pending v0.3 8-toggle rewrite Phase 1.2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 07:00:58 +00:00
95ec7d5351 fix: notification system improvements and threshold corrections
- Fix leftover severity references (info→routine in filter dropdown)
- Fix node_id int handling in connector and channels (handle both int and string)
- Add LLM-generated reports for notifications (replace raw data dumps)
- Fix health.score.composite attribute path for RF reports
- Add deterministic HF band conditions from SFI/Kp values
- Remove max_tokens from LLM calls (character limits at delivery)
- Weather feed improvements: show event_type + area, local events first
- Fix is_online to use configured offline_threshold_hours in data store
- Update stale defaults: offline 24→2h, battery_warning 20→30%
- Add TODO comments for packet_threshold scale bug

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 06:03:51 +00:00
7a4bd4f38f fix(mesh): use configured offline threshold in data store
- Add offline_threshold_hours parameter to MeshDataStore.__init__
- Compute is_online in _normalize_node using configured threshold
- Pass config.mesh_intelligence.offline_threshold_hours from main.py
- Removes reliance on health engine for initial is_online computation

Verification:
- Unit test confirms 2h threshold marks 3h-old node offline
- Unit test confirms 4h threshold marks same node online
- Container starts healthy with no config errors
- Health engine reports 16/16 infra online
2026-05-13 23:54:20 -06:00
21d6520ffd fix(dashboard): weather feed shows location + hazard, prioritizes local
- Event feed shows event_type + area_desc instead of timestamp headline
- First sentence of description shown as hazard summary
- Local events (matching NWS zones) pinned to top with highlight
- Nearby events grouped below, slightly dimmed
- Dedup by event_id
2026-05-13 20:33:48 -06:00
839bf322d9 fix(notifications): health attribute path + deterministic band conditions
- Fix MeshHealth.score.composite path (was accessing wrong object)
- Add deterministic band condition calculator from SFI/Kp/time
- RF reports use structured band format, not LLM
- Fix LLM prompts for health/weather reports (max_tokens, format)
- Graceful handling when data sources not configured
2026-05-13 20:11:16 -06:00
829ad562e4 feat(notifications): LLM-generated reports replace raw data dumps
- Status/report messages use LLM to generate operator-readable summaries
- RF reports interpret SFI/Kp into which bands are open
- Mesh reports highlight problems, not just numbers
- Remove meaningless [STATUS] prefix
- Alerts stay templated (no LLM, no latency)
- Reports respect 180-char limit for mesh delivery
2026-05-13 19:42:23 -06:00
18 changed files with 1777 additions and 1420 deletions

View file

@ -119,18 +119,18 @@ mesh_sources: []
# enabled: true
# region_radius_miles: 40.0 # Radius for region clustering
# locality_radius_miles: 8.0 # Radius for locality clustering
# offline_threshold_hours: 24 # Hours before node considered offline
# offline_threshold_hours: 2 # Hours before node considered offline
# packet_threshold: 500 # Non-text packets per 24h to flag
# battery_warning_percent: 20 # Battery level for warnings
# battery_warning_percent: 30 # Battery level for warnings
# infra_overrides: [] # Node IDs to exclude from infrastructure
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
mesh_intelligence:
enabled: false
region_radius_miles: 40.0
locality_radius_miles: 8.0
offline_threshold_hours: 24
offline_threshold_hours: 2
packet_threshold: 500
battery_warning_percent: 20
battery_warning_percent: 30
infra_overrides: []
region_labels: {}
@ -217,11 +217,13 @@ environmental:
proximity_km: 10.0 # km to match known fire perimeters
# === NOTIFICATION DELIVERY ===
# === NOTIFICATION DELIVERY (TRANSITIONAL) ===
# NOTE: This notifications schema will be replaced in v0.3 by the 8-toggle model.
# These rule examples are transitional until Phase 1.2 lands. Do not extend.
# Severity levels: routine (informational), priority (needs attention), immediate (act now)
#
# Route alerts to channels (mesh, email, webhook) based on rules.
# Categories match alert types from alert_engine.py.
# Severity levels: info, advisory, watch, warning, critical, emergency
#
notifications:
enabled: false
quiet_hours_enabled: true # Master toggle for quiet hours feature
@ -236,7 +238,7 @@ notifications:
enabled: true
trigger_type: condition
categories: [] # Empty = all categories
min_severity: "emergency"
min_severity: "immediate"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 5
@ -247,7 +249,7 @@ notifications:
enabled: true
trigger_type: condition
categories: ["infra_offline", "critical_node_down"]
min_severity: "warning"
min_severity: "priority"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 30
@ -258,7 +260,7 @@ notifications:
enabled: true
trigger_type: condition
categories: ["wildfire_proximity", "new_ignition"]
min_severity: "advisory"
min_severity: "routine"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 60
@ -269,7 +271,7 @@ notifications:
enabled: true
trigger_type: condition
categories: ["weather_warning"]
min_severity: "warning"
min_severity: "priority"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 30
@ -280,7 +282,7 @@ notifications:
# enabled: true
# trigger_type: condition
# categories: ["wildfire_proximity", "new_ignition"]
# min_severity: "advisory"
# min_severity: "routine"
# delivery_type: email
# smtp_host: "smtp.gmail.com"
# smtp_port: 587
@ -296,7 +298,7 @@ notifications:
# enabled: true
# trigger_type: condition
# categories: []
# min_severity: "warning"
# min_severity: "priority"
# delivery_type: webhook
# webhook_url: "https://discord.com/api/webhooks/..."
# cooldown_minutes: 10
@ -316,7 +318,7 @@ notifications:
# enabled: true
# trigger_type: condition
# categories: ["battery_warning"]
# min_severity: "warning"
# min_severity: "priority"
# delivery_type: "" # Empty = no delivery, just tracks matches
# === WEB DASHBOARD ===

View file

@ -465,7 +465,7 @@ const SEVERITY_COLORS: Record<string, string> = {
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
}
function EventFeedItem({ event }: { event: EnvEvent }) {
function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean }) {
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
const Icon = sourceConfig.icon
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
@ -483,18 +483,43 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// Build display title: prefer event_type + area_desc, fall back to headline
const eventType = (event as Record<string, unknown>).event_type as string | undefined
const areaDesc = (event as Record<string, unknown>).area_desc as string | undefined
const description = (event as Record<string, unknown>).description as string | undefined
let title = event.headline
if (eventType && areaDesc) {
// Shorten area description (remove "County" repetition)
const shortArea = areaDesc.replace(/ County/g, '').split(';')[0]
title = `${eventType}${shortArea}`
} else if (eventType) {
title = eventType
}
// Get first sentence of description as subtitle
const subtitle = description ? description.split('. ')[0] : null
return (
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
<div className={`flex items-start gap-2 py-2 border-b border-border/50 last:border-0 ${isLocal ? 'border-l-2 border-l-blue-500 pl-2 -ml-2' : ''}`}>
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
{event.severity || 'info'}
</span>
{isLocal && (
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
LOCAL
</span>
)}
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
</div>
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
<div className={`text-sm truncate ${isLocal ? 'text-slate-100' : 'text-slate-300'}`}>{title}</div>
{subtitle && (
<div className="text-xs text-slate-500 truncate mt-0.5">{subtitle}</div>
)}
</div>
</div>
)
@ -502,8 +527,31 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
// Live Event Feed Card
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
// Severity order for sorting
const severityOrder: Record<string, number> = { immediate: 0, priority: 1, routine: 2 }
const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
// Dedup by event_id
const seen = new Set<string>()
const deduped = events.filter(e => {
if (!e.event_id) return true
if (seen.has(e.event_id)) return false
seen.add(e.event_id)
return true
})
// Sort: local first, then by severity, then by time
return deduped.sort((a, b) => {
const aLocal = (a as Record<string, unknown>).is_local ? 1 : 0
const bLocal = (b as Record<string, unknown>).is_local ? 1 : 0
if (aLocal !== bLocal) return bLocal - aLocal // local first
const aSev = severityOrder[a.severity?.toLowerCase() || 'routine'] ?? 2
const bSev = severityOrder[b.severity?.toLowerCase() || 'routine'] ?? 2
if (aSev !== bSev) return aSev - bSev // higher severity first
return (b.fetched_at || 0) - (a.fetched_at || 0) // newest first
})
}, [events])
// Calculate feed health summary
@ -528,7 +576,11 @@ function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: E
{sortedEvents.length > 0 ? (
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
{sortedEvents.map((event, i) => (
<EventFeedItem key={event.event_id || i} event={event} />
<EventFeedItem
key={event.event_id || i}
event={event}
isLocal={(event as Record<string, unknown>).is_local as boolean | undefined}
/>
))}
</div>
) : (

View file

@ -284,6 +284,7 @@ class MeshIntelligenceConfig:
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
offline_threshold_hours: int = 2 # Hours before node considered offline
packet_threshold: int = 500 # Non-text packets per 24h to flag
# TODO: behavior pillar uses wrong scale - see meshai-v03-notification-handoff.md bug #2
battery_warning_percent: int = 30 # Battery level for warnings
# Alert settings
@ -577,7 +578,7 @@ def _migrate_legacy_channels(notifications, data: dict):
enabled=ch.get("enabled", True),
trigger_type="condition",
categories=old_rule.get("categories", []),
min_severity=old_rule.get("min_severity", "warning"),
min_severity=old_rule.get("min_severity", "priority"),
delivery_type=ch.get("type", "mesh_broadcast"),
broadcast_channel=ch.get("channel_index", 0),
node_ids=ch.get("node_ids", []),

View file

@ -21,13 +21,31 @@ async def get_env_status(request: Request):
@router.get("/env/active")
async def get_active_env(request: Request):
"""Get active environmental events."""
"""Get active environmental events with local zone marking."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active()
events = env_store.get_active()
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
# Dedup by event_id and add is_local field
seen_ids = set()
result = []
for event in events:
event_id = event.get("event_id")
if event_id and event_id in seen_ids:
continue
if event_id:
seen_ids.add(event_id)
# Mark as local if event zones overlap with configured mesh zones
event_zones = set(event.get("areas", []))
event["is_local"] = bool(event_zones & mesh_zones)
result.append(event)
return result
@router.get("/env/swpc")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BXyt_EfK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtFYHJy4.css">
<script type="module" crossorigin src="/assets/index-Bildyb1E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QhNRb-ap.css">
</head>
<body>
<div id="root"></div>

View file

@ -265,6 +265,7 @@ class MeshAI:
self.data_store = MeshDataStore(
source_configs=enabled_sources,
db_path="/data/mesh_history.db",
offline_threshold_hours=self.config.mesh_intelligence.offline_threshold_hours,
)
# Initial fetch and backfill
self.data_store.force_refresh()

View file

@ -230,16 +230,19 @@ class MeshDataStore:
self,
source_configs: list[MeshSourceConfig],
db_path: str = "/data/mesh_history.db",
offline_threshold_hours: int = 2,
):
"""Initialize the data store.
Args:
source_configs: List of source configurations
db_path: Path to SQLite database for historical data
offline_threshold_hours: Hours before a node is considered offline
"""
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {}
self._db_path = db_path
self._db: Optional[sqlite3.Connection] = None
self._offline_threshold_hours = offline_threshold_hours
# Live state
self._nodes: dict[int, UnifiedNode] = {}
@ -745,11 +748,13 @@ class MeshDataStore:
node.last_heard = ts or 0.0
# NOTE: is_online is set by MeshHealthEngine.compute() using the
# configured offline_threshold_hours. Don't set it here with a
# hardcoded value - let the health engine determine online status.
# The health engine runs on every refresh cycle and will set is_online
# based on: (now - last_heard) < (offline_threshold_hours * 3600)
# Compute is_online based on configured threshold
# This ensures correct status immediately, before health engine runs
if node.last_heard:
offline_threshold = time.time() - (self._offline_threshold_hours * 3600)
node.is_online = node.last_heard > offline_threshold
else:
node.is_online = False
# Hops, SNR, RSSI (MM)
node.hops_away = raw.get("hopsAway")
@ -2111,7 +2116,7 @@ class MeshDataStore:
infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"}
return [n for n in self._nodes.values() if n.role in infra_roles]
def get_low_battery_nodes(self, threshold: float = 20.0) -> list[UnifiedNode]:
def get_low_battery_nodes(self, threshold: float = 30.0) -> list[UnifiedNode]:
"""Get nodes with low battery."""
return [
n

View file

@ -28,6 +28,7 @@ INFRASTRUCTURE_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT"}
DEFAULT_LOCALITY_RADIUS_MILES = 8.0
DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline
DEFAULT_PACKET_THRESHOLD = 7200 # Non-text packets per 24h (5/min avg)
# TODO: behavior pillar uses wrong scale - see meshai-v03-notification-handoff.md bug #2
# NOTE: This is aligned with notification config's packet_flood threshold.
# 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day.
# A node averaging 5+ non-text packets/min is misbehaving.

View file

@ -630,7 +630,7 @@ class MeshReporter:
usb += 1
elif node.battery_percent >= 50:
ok += 1
elif node.battery_percent >= 20:
elif node.battery_percent >= 30:
low += 1
else:
critical += 1

View file

@ -292,7 +292,7 @@ class EmailChannel(NotificationChannel):
return False
alert_type = alert.get("type", "alert")
severity = alert.get("severity", "info").upper()
severity = alert.get("severity", "routine").upper()
message = alert.get("message", "")
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
@ -518,7 +518,7 @@ class WebhookChannel(NotificationChannel):
"""POST alert to webhook URL."""
payload = {
"type": alert.get("type"),
"severity": alert.get("severity", "info"),
"severity": alert.get("severity", "routine"),
"message": alert.get("message", ""),
"timestamp": time.time(),
"node_name": alert.get("node_name"),
@ -527,7 +527,7 @@ class WebhookChannel(NotificationChannel):
# Discord/Slack format
if "discord.com" in self._url or "slack.com" in self._url:
severity = alert.get("severity", "info")
severity = alert.get("severity", "routine")
color = {
"immediate": 0xFF0000,
"priority": 0xFFAA00,
@ -669,7 +669,7 @@ class WebhookChannel(NotificationChannel):
else:
payload = {
"type": "test",
"severity": "info",
"severity": "routine",
"message": "MeshAI channel connectivity test",
"timestamp": time.time(),
}
@ -730,7 +730,7 @@ class WebhookChannel(NotificationChannel):
async def deliver_test(self, message: str) -> tuple[bool, str]:
"""Deliver a specific test message via webhook."""
try:
test_alert = {"type": "test", "severity": "info", "message": message}
test_alert = {"type": "test", "severity": "routine", "message": message}
success = await self.deliver(test_alert, {})
if success:
try:

View file

@ -40,6 +40,7 @@ class NotificationRouter:
self._timezone = timezone
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
self._llm = llm_backend
self._connector = connector
self._config = config
@ -149,7 +150,7 @@ class NotificationRouter:
async def process_alert(self, alert: dict) -> bool:
"""Route an alert through matching rules."""
category = alert.get("type", "")
severity = alert.get("severity", "info")
severity = alert.get("severity", "routine")
delivered = False
for rule in self._rules:
@ -159,7 +160,7 @@ class NotificationRouter:
if rule_categories and category not in rule_categories:
continue
min_severity = rule.get("min_severity", "info")
min_severity = rule.get("min_severity", "routine")
if not self._severity_meets(severity, min_severity):
continue
@ -391,7 +392,7 @@ class NotificationRouter:
rule_name = rule_dict.get("name", f"Rule {rule_index}")
rule_categories = rule_dict.get("categories", [])
min_severity = rule_dict.get("min_severity", "info")
min_severity = rule_dict.get("min_severity", "routine")
delivery_type = rule_dict.get("delivery_type", "")
# Legacy support
@ -535,7 +536,7 @@ class NotificationRouter:
for alert in alert_engine.get_pending_alerts():
all_events.append({
"type": alert.get("type", ""),
"severity": alert.get("severity", "info"),
"severity": alert.get("severity", "routine"),
"message": alert.get("message", ""),
"headline": alert.get("message", "")[:80],
})
@ -547,7 +548,7 @@ class NotificationRouter:
for event in env_store.get_active():
all_events.append({
"type": event.get("type", event.get("category", "")),
"severity": event.get("severity", "info"),
"severity": event.get("severity", "routine"),
"message": event.get("message", event.get("headline", str(event))),
"headline": event.get("headline", event.get("message", "Event"))[:80],
})
@ -624,12 +625,20 @@ class NotificationRouter:
channel = self._create_channel_for_rule(rule_dict)
if channel:
try:
if action == "send_status" and live_data_lines:
# Filter out the warning line for status message
data_lines = [l for l in live_data_lines if not l.startswith("[!]")]
status_msg = "[STATUS] " + " | ".join(data_lines[:4])
if len(status_msg) > 200:
status_msg = status_msg[:195] + "..."
if action == "send_status":
# Determine report type from rule categories
report_type = "all"
if rule_categories:
if any(c in rule_categories for c in ["hf_blackout", "geomagnetic_storm", "tropospheric_ducting"]):
report_type = "rf_propagation"
elif any(c in rule_categories for c in ["infra_offline", "critical_node_down", "mesh_score_low", "battery_warning"]):
report_type = "mesh_health"
elif any(c in rule_categories for c in ["weather_warning", "fire_proximity", "new_ignition"]):
report_type = "weather_fire"
status_msg = await self.generate_report(report_type, env_store, health_engine)
if len(status_msg) > 195:
status_msg = status_msg[:192] + "..."
success, result = await channel.deliver_test(status_msg)
delivered = success
delivery_result = result if success else f"Failed: {result}"
@ -637,9 +646,9 @@ class NotificationRouter:
delivery_error = result
elif action == "send_live" and matching_alerts:
live_msg = f"[LIVE TEST] {matching_alerts[0].get('message', '')}"
if len(live_msg) > 200:
live_msg = live_msg[:195] + "..."
live_msg = matching_alerts[0].get('message', '')
if len(live_msg) > 195:
live_msg = live_msg[:192] + "..."
success, result = await channel.deliver_test(live_msg)
delivered = success
delivery_result = result if success else f"Failed: {result}"
@ -752,7 +761,7 @@ class NotificationRouter:
"enabled": True,
"trigger_type": "condition",
"categories": categories if categories else [],
"min_severity": "warning",
"min_severity": "priority",
"delivery_type": "mesh_dm",
"node_ids": [node_id],
"cooldown_minutes": 10,
@ -776,6 +785,264 @@ class NotificationRouter:
return categories if categories else ["all"]
return []
async def generate_report(self, report_type: str, env_store, health_engine) -> str:
"""Generate an LLM-summarized report from current data."""
context_parts = []
# For RF propagation, use deterministic formatter
swpc_data = None
ducting_data = None
if report_type in ("rf_propagation", "all"):
if env_store:
adapters = getattr(env_store, '_adapters', {})
if "swpc" in adapters and hasattr(env_store, 'get_swpc_status'):
swpc_data = env_store.get_swpc_status()
if "ducting" in adapters and hasattr(env_store, 'get_ducting_status'):
ducting_data = env_store.get_ducting_status()
# If this is an RF-only report, return deterministic format immediately
if report_type == "rf_propagation" and swpc_data:
sfi = swpc_data.get('sfi', 100)
kp = swpc_data.get('kp_current', 3)
try:
band_conditions = self._compute_band_conditions(float(sfi), float(kp))
return self._format_propagation_report(swpc_data, ducting_data, band_conditions)
except Exception as e:
logger.warning("Band condition calc failed: %s", e)
# Fall through to context-based approach
# For "all" report type, add RF data to context
if swpc_data:
context_parts.append(
f"Solar/Geomagnetic: SFI {swpc_data.get('sfi')}, "
f"Kp {swpc_data.get('kp_current')}, "
f"R{swpc_data.get('r_scale', 0)}/S{swpc_data.get('s_scale', 0)}/G{swpc_data.get('g_scale', 0)}"
)
if ducting_data:
context_parts.append(
f"Tropospheric: {ducting_data.get('condition', 'unknown')}, "
f"dM/dz {ducting_data.get('min_gradient', 'N/A')} M-units/km"
)
if report_type in ("mesh_health", "all"):
if health_engine:
health = getattr(health_engine, 'mesh_health', None)
if health and hasattr(health, 'score'):
score = health.score
context_parts.append(
f"Mesh: score {score.composite:.0f}/100, "
f"tier {score.tier}, "
f"{score.infra_online}/{score.infra_total} infra online, "
f"utilization {score.util_percent:.1f}%"
)
if report_type in ("weather_fire", "all"):
if env_store and hasattr(env_store, 'get_active'):
nws = env_store.get_active(source="nws")
fires = env_store.get_active(source="nifc")
if nws:
headlines = [e.get("headline", "")[:80] for e in nws[:3]]
context_parts.append(f"Weather: {len(nws)} active alerts: {'; '.join(headlines)}")
else:
context_parts.append("Weather: No active alerts")
if fires:
context_parts.append(f"Fires: {len(fires)} active")
else:
context_parts.append("Fires: None active")
if report_type in ("environmental", "all"):
if env_store and hasattr(env_store, 'get_active'):
for source in ["usgs", "traffic", "roads511", "avalanche"]:
events = env_store.get_active(source=source)
if events:
context_parts.append(f"{source.upper()}: {len(events)} events")
if not context_parts:
# Return a graceful message for the specific report type
no_data_messages = {
"rf_propagation": "RF propagation data not available",
"mesh_health": "Mesh health data not available",
"weather_fire": "Weather/fire monitoring not configured",
"environmental": "Environmental monitoring not configured",
"all": "No monitoring data available",
}
return no_data_messages.get(report_type, "No data available")
raw_data = "\n".join(context_parts)
# Generate LLM summary
if self._llm:
prompt = self._build_report_prompt(report_type, raw_data)
try:
messages = [{"role": "user", "content": prompt}]
summary = await self._llm.generate(
messages=messages,
system_prompt="You are a concise infrastructure status reporter. Format data clearly and briefly. Output only the formatted report, no preamble or explanation.",
)
return summary.strip()
except Exception as e:
logger.warning("LLM report generation failed: %s", e)
return raw_data
else:
return raw_data
def _compute_band_conditions(self, sfi: float, kp: float) -> dict:
"""Deterministic band conditions from SFI and Kp."""
from datetime import datetime, timezone
hour_utc = datetime.now(timezone.utc).hour
is_day = 12 <= hour_utc or hour_utc <= 3 # rough UTC daytime for US
bands = {}
# 10m (28 MHz) - needs high SFI, daytime only
if sfi > 140 and kp <= 2:
bands["10m"] = "Good"
elif sfi > 100 and kp <= 4 and is_day:
bands["10m"] = "Fair"
else:
bands["10m"] = "Poor"
# 12m (24 MHz)
if sfi > 120 and kp <= 3:
bands["12m"] = "Good"
elif sfi > 90 and kp <= 4 and is_day:
bands["12m"] = "Fair"
else:
bands["12m"] = "Poor"
# 15m (21 MHz)
if sfi > 100 and kp <= 3:
bands["15m"] = "Good"
elif sfi > 80 and kp <= 5:
bands["15m"] = "Fair"
else:
bands["15m"] = "Poor"
# 17m (18 MHz)
if sfi > 90 and kp <= 3:
bands["17m"] = "Good"
elif sfi > 70 and kp <= 5:
bands["17m"] = "Fair"
else:
bands["17m"] = "Poor"
# 20m (14 MHz) - almost always usable
if sfi > 80 and kp <= 3:
bands["20m"] = "Good"
elif kp <= 6:
bands["20m"] = "Fair"
else:
bands["20m"] = "Poor"
# 30m (10 MHz) - very reliable
if kp <= 4:
bands["30m"] = "Good"
elif kp <= 6:
bands["30m"] = "Fair"
else:
bands["30m"] = "Poor"
# 40m (7 MHz) - reliable, better at night
if kp <= 4:
bands["40m"] = "Good"
elif kp <= 6:
bands["40m"] = "Fair"
else:
bands["40m"] = "Poor"
# 80m (3.5 MHz) - night band
if kp <= 3:
bands["80m"] = "Good"
elif kp <= 5:
bands["80m"] = "Fair"
else:
bands["80m"] = "Poor"
return bands
def _format_propagation_report(self, swpc: dict, ducting: dict, band_conditions: dict) -> str:
"""Format propagation report deterministically."""
sfi = swpc.get('sfi', 'N/A')
kp = swpc.get('kp_current', 'N/A')
lines = [f"Band Conditions (SFI {sfi}, Kp {kp}):"]
for band_list, label in [
(["80m", "40m"], "80-40m"),
(["30m", "20m"], "30-20m"),
(["17m", "15m"], "17-15m"),
(["12m", "10m"], "12-10m"),
]:
# Take the better of the two bands in range
ratings = [band_conditions.get(b, "Poor") for b in band_list]
if "Good" in ratings:
rating = "Good"
elif "Fair" in ratings:
rating = "Fair"
else:
rating = "Poor"
lines.append(f"{label}: {rating}")
if ducting and ducting.get("condition") and ducting.get("condition") != "normal":
cond = ducting["condition"].replace("_", " ").title()
lines.append(f"Tropo: {cond}")
else:
lines.append("Tropo: Normal")
return "\n".join(lines)
def _build_report_prompt(self, report_type: str, raw_data: str) -> str:
"""Build the LLM prompt for report generation."""
prompts = {
"rf_propagation": (
"Format this propagation data as a band-by-band HF report. "
"Output format:\n"
"Band Conditions (SFI X, Kp Y):\n"
"80-40m: [Good/Fair/Poor]\n"
"30-20m: [Good/Fair/Poor]\n"
"17-15m: [Good/Fair/Poor]\n"
"12-10m: [Good/Fair/Poor]\n"
"Tropo: [Normal/Enhanced/Ducting]\n\n"
"Determine ratings from SFI and Kp values. "
"Output ONLY the formatted report.\n\n"
f"Data:\n{raw_data}"
),
"mesh_health": (
"Format this mesh network health data as a brief status. "
"Include: score out of 100, tier name, infrastructure count, "
"and any problems. If healthy, say 'Mesh healthy' with key stats. "
"Example: 'Mesh healthy: 90/100, 16/16 infra online, 20% util'\n"
"Keep it concise. Output ONLY the status line.\n\n"
f"Data:\n{raw_data}"
),
"weather_fire": (
"Format these weather/fire alerts as a brief summary. "
"List count of alerts and most severe conditions. "
"Example: '3 weather alerts: Winter Storm Warning (ID), "
"Wind Advisory (OR). No active fires.'\n"
"Keep it concise. Output ONLY the summary.\n\n"
f"Data:\n{raw_data}"
),
"environmental": (
"Summarize all environmental conditions briefly. "
"Cover weather alerts, fires, streams, roads. "
"Example: 'Weather clear, no fires, 2 streams elevated, "
"roads open.'\n"
"Keep it concise. Output ONLY the summary.\n\n"
f"Data:\n{raw_data}"
),
"all": (
"Summarize all conditions for a mesh network operator. "
"Cover: mesh health score, any infrastructure issues, "
"weather alerts, fire status, propagation conditions. "
"Be brief but complete.\n\n"
f"Data:\n{raw_data}"
),
}
return prompts.get(report_type, prompts["all"])
def cleanup_recent(self, max_age: int = 3600):
"""Clean up old entries from recent alerts cache."""
now = time.time()

View file

@ -50,7 +50,7 @@ class MessageSummarizer:
response = await self._llm.generate(
prompt,
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
max_tokens=100,
)
summary = response.strip()