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>
This commit is contained in:
K7ZVX 2026-05-14 06:02:08 +00:00
commit 95ec7d5351
13 changed files with 1145 additions and 1133 deletions

View file

@ -119,18 +119,18 @@ mesh_sources: []
# enabled: true # enabled: true
# region_radius_miles: 40.0 # Radius for region clustering # region_radius_miles: 40.0 # Radius for region clustering
# locality_radius_miles: 8.0 # Radius for locality 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 # 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 # infra_overrides: [] # Node IDs to exclude from infrastructure
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"} # region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
mesh_intelligence: mesh_intelligence:
enabled: false enabled: false
region_radius_miles: 40.0 region_radius_miles: 40.0
locality_radius_miles: 8.0 locality_radius_miles: 8.0
offline_threshold_hours: 24 offline_threshold_hours: 2
packet_threshold: 500 packet_threshold: 500
battery_warning_percent: 20 battery_warning_percent: 30
infra_overrides: [] infra_overrides: []
region_labels: {} region_labels: {}

View file

@ -284,6 +284,7 @@ class MeshIntelligenceConfig:
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
offline_threshold_hours: int = 2 # Hours before node considered offline offline_threshold_hours: int = 2 # Hours before node considered offline
packet_threshold: int = 500 # Non-text packets per 24h to flag 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 battery_warning_percent: int = 30 # Battery level for warnings
# Alert settings # Alert settings

View file

@ -1,305 +1,305 @@
"""Notification API routes with comprehensive testing.""" """Notification API routes with comprehensive testing."""
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
class TestRequest(BaseModel): class TestRequest(BaseModel):
"""Request body for test endpoint.""" """Request body for test endpoint."""
send: bool = False # Legacy: True = send_test send: bool = False # Legacy: True = send_test
action: str = "preview" # "preview", "send_test", "send_status", "send_live" action: str = "preview" # "preview", "send_test", "send_status", "send_live"
class ChannelTestRequest(BaseModel): class ChannelTestRequest(BaseModel):
"""Request body for channel connectivity test.""" """Request body for channel connectivity test."""
type: str # mesh_broadcast, mesh_dm, email, webhook type: str # mesh_broadcast, mesh_dm, email, webhook
# Mesh broadcast # Mesh broadcast
channel_index: Optional[int] = 0 channel_index: Optional[int] = 0
# Mesh DM # Mesh DM
node_ids: Optional[List[str]] = [] node_ids: Optional[List[str]] = []
# Email # Email
smtp_host: Optional[str] = "" smtp_host: Optional[str] = ""
smtp_port: Optional[int] = 587 smtp_port: Optional[int] = 587
smtp_user: Optional[str] = "" smtp_user: Optional[str] = ""
smtp_password: Optional[str] = "" smtp_password: Optional[str] = ""
smtp_tls: Optional[bool] = True smtp_tls: Optional[bool] = True
from_address: Optional[str] = "" from_address: Optional[str] = ""
recipients: Optional[List[str]] = [] recipients: Optional[List[str]] = []
# Webhook # Webhook
url: Optional[str] = "" url: Optional[str] = ""
headers: Optional[Dict[str, str]] = {} headers: Optional[Dict[str, str]] = {}
class RuleSourcesRequest(BaseModel): class RuleSourcesRequest(BaseModel):
"""Request body for rule sources health check.""" """Request body for rule sources health check."""
categories: List[str] = [] categories: List[str] = []
@router.get("/categories") @router.get("/categories")
async def get_categories(): async def get_categories():
"""Get all alert categories with descriptions.""" """Get all alert categories with descriptions."""
try: try:
from ...notifications.categories import list_categories from ...notifications.categories import list_categories
return list_categories() return list_categories()
except ImportError: except ImportError:
return [] return []
@router.get("/rules") @router.get("/rules")
async def get_rules(request: Request): async def get_rules(request: Request):
"""Get configured notification rules with stats.""" """Get configured notification rules with stats."""
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
return [] return []
rules = notification_router.get_rules() rules = notification_router.get_rules()
# Enhance rules with stats # Enhance rules with stats
result = [] result = []
for i, rule in enumerate(rules): for i, rule in enumerate(rules):
rule_copy = dict(rule) rule_copy = dict(rule)
stats = rule_copy.pop("_stats", {}) stats = rule_copy.pop("_stats", {})
rule_copy["stats"] = stats rule_copy["stats"] = stats
rule_copy["index"] = i rule_copy["index"] = i
result.append(rule_copy) result.append(rule_copy)
return result return result
@router.get("/rules/{rule_index}/stats") @router.get("/rules/{rule_index}/stats")
async def get_rule_stats(request: Request, rule_index: int): async def get_rule_stats(request: Request, rule_index: int):
"""Get statistics for a specific rule.""" """Get statistics for a specific rule."""
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
rules_config = getattr(request.app.state, "config", None) rules_config = getattr(request.app.state, "config", None)
if rules_config: if rules_config:
rules_config = getattr(rules_config, "rules", []) rules_config = getattr(rules_config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config): if rule_index < 0 or rule_index >= len(rules_config):
raise HTTPException(status_code=404, detail="Rule not found") raise HTTPException(status_code=404, detail="Rule not found")
rule = rules_config[rule_index] rule = rules_config[rule_index]
if hasattr(rule, "__dict__"): if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")} rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else: else:
rule_dict = dict(rule) rule_dict = dict(rule)
rule_name = rule_dict.get("name", f"Rule {rule_index}") rule_name = rule_dict.get("name", f"Rule {rule_index}")
return notification_router.get_rule_stats(rule_name) return notification_router.get_rule_stats(rule_name)
return {"last_fired": None, "last_test": None, "fire_count": 0} return {"last_fired": None, "last_test": None, "fire_count": 0}
@router.post("/channels/test") @router.post("/channels/test")
async def test_channel(request: Request, body: ChannelTestRequest): async def test_channel(request: Request, body: ChannelTestRequest):
"""Test channel connectivity without sending actual alert content. """Test channel connectivity without sending actual alert content.
Returns: Returns:
{ {
"success": bool, "success": bool,
"message": str, # Human-readable result "message": str, # Human-readable result
"error": str, # Detailed error if failed "error": str, # Detailed error if failed
"details": {} # Channel-specific details "details": {} # Channel-specific details
} }
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
# Build channel config from request # Build channel config from request
channel_config = {"type": body.type} channel_config = {"type": body.type}
if body.type == "mesh_broadcast": if body.type == "mesh_broadcast":
channel_config["channel_index"] = body.channel_index or 0 channel_config["channel_index"] = body.channel_index or 0
elif body.type == "mesh_dm": elif body.type == "mesh_dm":
channel_config["node_ids"] = body.node_ids or [] channel_config["node_ids"] = body.node_ids or []
elif body.type == "email": elif body.type == "email":
channel_config.update({ channel_config.update({
"smtp_host": body.smtp_host or "", "smtp_host": body.smtp_host or "",
"smtp_port": body.smtp_port or 587, "smtp_port": body.smtp_port or 587,
"smtp_user": body.smtp_user or "", "smtp_user": body.smtp_user or "",
"smtp_password": body.smtp_password or "", "smtp_password": body.smtp_password or "",
"smtp_tls": body.smtp_tls if body.smtp_tls is not None else True, "smtp_tls": body.smtp_tls if body.smtp_tls is not None else True,
"from_address": body.from_address or "", "from_address": body.from_address or "",
"recipients": body.recipients or [], "recipients": body.recipients or [],
}) })
elif body.type == "webhook": elif body.type == "webhook":
channel_config.update({ channel_config.update({
"url": body.url or "", "url": body.url or "",
"headers": body.headers or {}, "headers": body.headers or {},
}) })
else: else:
return { return {
"success": False, "success": False,
"message": "Unknown channel type", "message": "Unknown channel type",
"error": f"Channel type '{body.type}' is not supported", "error": f"Channel type '{body.type}' is not supported",
"details": {} "details": {}
} }
result = await notification_router.test_channel(channel_config) result = await notification_router.test_channel(channel_config)
return result return result
@router.post("/rules/{rule_index}/test") @router.post("/rules/{rule_index}/test")
async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None): async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
"""Test a notification rule against current conditions. """Test a notification rule against current conditions.
Returns comprehensive test result including: Returns comprehensive test result including:
- Live data from relevant environmental feeds - Live data from relevant environmental feeds
- Matching alerts (conditions that would fire) - Matching alerts (conditions that would fire)
- Near-misses (filtered by severity threshold) - Near-misses (filtered by severity threshold)
- Preview messages and delivery status - Preview messages and delivery status
- Source health (which feeds are enabled) - Source health (which feeds are enabled)
- Rule statistics (last fired, fire count) - Rule statistics (last fired, fire count)
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
action = body.action if body else "preview" action = body.action if body else "preview"
send = body.send if body else False send = body.send if body else False
# Legacy support # Legacy support
if send and action == "preview": if send and action == "preview":
action = "send_test" action = "send_test"
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action=action, action=action,
) )
return result return result
@router.post("/rules/{rule_index}/preview") @router.post("/rules/{rule_index}/preview")
async def preview_rule(request: Request, rule_index: int): async def preview_rule(request: Request, rule_index: int):
"""Preview what a rule would match right now (without sending).""" """Preview what a rule would match right now (without sending)."""
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action="preview", action="preview",
) )
return result return result
@router.post("/rules/sources") @router.post("/rules/sources")
async def get_rule_sources(request: Request, body: RuleSourcesRequest): async def get_rule_sources(request: Request, body: RuleSourcesRequest):
"""Get data source health for a set of categories. """Get data source health for a set of categories.
Returns per-category source status: Returns per-category source status:
{ {
"category_id": { "category_id": {
"enabled": true/false, "enabled": true/false,
"active_events": number, "active_events": number,
"source": "nws"/"swpc"/etc, "source": "nws"/"swpc"/etc,
"status": "ok"/"disabled"/"no_data" "status": "ok"/"disabled"/"no_data"
} }
} }
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
return notification_router.get_source_health(body.categories, env_store) return notification_router.get_source_health(body.categories, env_store)
@router.post("/rules/{rule_index}/send-status") @router.post("/rules/{rule_index}/send-status")
async def send_rule_status(request: Request, rule_index: int): async def send_rule_status(request: Request, rule_index: int):
"""Send current conditions summary through a rule's channel. """Send current conditions summary through a rule's channel.
Formats current live data as a readable message and delivers Formats current live data as a readable message and delivers
through the rule's configured channel with [STATUS] prefix. through the rule's configured channel with [STATUS] prefix.
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action="send_status", action="send_status",
) )
return result return result
@router.post("/rules/{rule_index}/send-test") @router.post("/rules/{rule_index}/send-test")
async def send_rule_test(request: Request, rule_index: int): async def send_rule_test(request: Request, rule_index: int):
"""Send example alert message through a rule's channel. """Send example alert message through a rule's channel.
Sends the example_message from the rule's first category Sends the example_message from the rule's first category
through the configured channel with [TEST] prefix. through the configured channel with [TEST] prefix.
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action="send_test", action="send_test",
) )
return result return result
@router.post("/rules/{rule_index}/send-live") @router.post("/rules/{rule_index}/send-live")
async def send_rule_live(request: Request, rule_index: int): async def send_rule_live(request: Request, rule_index: int):
"""Send actual live alert through a rule's channel. """Send actual live alert through a rule's channel.
Only available when there are matching conditions. Only available when there are matching conditions.
Sends one of the actual matching alerts with [LIVE TEST] prefix. Sends one of the actual matching alerts with [LIVE TEST] prefix.
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action="send_live", action="send_live",
) )
return result return result

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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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> <script type="module" crossorigin src="/assets/index-Bildyb1E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtFYHJy4.css"> <link rel="stylesheet" crossorigin href="/assets/index-QhNRb-ap.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -2116,7 +2116,7 @@ class MeshDataStore:
infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"} infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"}
return [n for n in self._nodes.values() if n.role in infra_roles] 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.""" """Get nodes with low battery."""
return [ return [
n n

View file

@ -28,6 +28,7 @@ INFRASTRUCTURE_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT"}
DEFAULT_LOCALITY_RADIUS_MILES = 8.0 DEFAULT_LOCALITY_RADIUS_MILES = 8.0
DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline
DEFAULT_PACKET_THRESHOLD = 7200 # Non-text packets per 24h (5/min avg) 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. # NOTE: This is aligned with notification config's packet_flood threshold.
# 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day. # 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day.
# A node averaging 5+ non-text packets/min is misbehaving. # A node averaging 5+ non-text packets/min is misbehaving.

View file

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

View file

@ -1,242 +1,242 @@
"""Alert category registry. """Alert category registry.
Defines all alertable conditions with human-readable names, descriptions, Defines all alertable conditions with human-readable names, descriptions,
and example messages showing what users will receive. and example messages showing what users will receive.
Severity levels (military/intelligence precedence): Severity levels (military/intelligence precedence):
routine - Informational, no time pressure routine - Informational, no time pressure
priority - Needs attention soon priority - Needs attention soon
immediate - Act now, drop everything immediate - Act now, drop everything
""" """
ALERT_CATEGORIES = { ALERT_CATEGORIES = {
# Infrastructure alerts # Infrastructure alerts
"infra_offline": { "infra_offline": {
"name": "Infrastructure Node Offline", "name": "Infrastructure Node Offline",
"description": "An infrastructure node (router/repeater) stopped responding", "description": "An infrastructure node (router/repeater) stopped responding",
"default_severity": "priority", "default_severity": "priority",
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours", "example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
}, },
"critical_node_down": { "critical_node_down": {
"name": "Critical Node Down", "name": "Critical Node Down",
"description": "A node you marked as critical went offline", "description": "A node you marked as critical went offline",
"default_severity": "immediate", "default_severity": "immediate",
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour", "example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
}, },
"infra_recovery": { "infra_recovery": {
"name": "Infrastructure Recovery", "name": "Infrastructure Recovery",
"description": "An offline infrastructure node came back online", "description": "An offline infrastructure node came back online",
"default_severity": "routine", "default_severity": "routine",
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage", "example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
}, },
"new_router": { "new_router": {
"name": "New Router", "name": "New Router",
"description": "A new router appeared on the mesh", "description": "A new router appeared on the mesh",
"default_severity": "routine", "default_severity": "routine",
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley", "example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
}, },
# Power alerts # Power alerts
"battery_warning": { "battery_warning": {
"name": "Battery Warning", "name": "Battery Warning",
"description": "Infrastructure node battery below 30% (3.60V)", "description": "Infrastructure node battery below 30% (3.60V)",
"default_severity": "routine", "default_severity": "routine",
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging", "example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
}, },
"battery_critical": { "battery_critical": {
"name": "Battery Critical", "name": "Battery Critical",
"description": "Infrastructure node battery below 15% (3.50V)", "description": "Infrastructure node battery below 15% (3.50V)",
"default_severity": "priority", "default_severity": "priority",
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours", "example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
}, },
"battery_emergency": { "battery_emergency": {
"name": "Battery Emergency", "name": "Battery Emergency",
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent", "description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
"default_severity": "immediate", "default_severity": "immediate",
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent", "example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
}, },
"battery_trend": { "battery_trend": {
"name": "Battery Declining", "name": "Battery Declining",
"description": "Battery showing declining trend over 7 days — possible solar or charging issue", "description": "Battery showing declining trend over 7 days — possible solar or charging issue",
"default_severity": "routine", "default_severity": "routine",
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)", "example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
}, },
"power_source_change": { "power_source_change": {
"name": "Power Source Change", "name": "Power Source Change",
"description": "Node switched from USB to battery — possible power outage at site", "description": "Node switched from USB to battery — possible power outage at site",
"default_severity": "priority", "default_severity": "priority",
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage", "example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
}, },
"solar_not_charging": { "solar_not_charging": {
"name": "Solar Not Charging", "name": "Solar Not Charging",
"description": "Solar panel not charging during daylight hours — panel issue or obstruction", "description": "Solar panel not charging during daylight hours — panel issue or obstruction",
"default_severity": "priority", "default_severity": "priority",
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)", "example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
}, },
# Utilization alerts # Utilization alerts
"high_utilization": { "high_utilization": {
"name": "Channel Airtime High", "name": "Channel Airtime High",
"description": "LoRa channel airtime exceeding threshold — mesh congestion", "description": "LoRa channel airtime exceeding threshold — mesh congestion",
"default_severity": "routine", "default_severity": "routine",
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.", "example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
}, },
"sustained_high_util": { "sustained_high_util": {
"name": "Sustained High Utilization", "name": "Sustained High Utilization",
"description": "Channel airtime elevated for extended period — ongoing congestion", "description": "Channel airtime elevated for extended period — ongoing congestion",
"default_severity": "priority", "default_severity": "priority",
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.", "example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
}, },
"packet_flood": { "packet_flood": {
"name": "Packet Flood", "name": "Packet Flood",
"description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter", "description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
"default_severity": "priority", "default_severity": "priority",
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?", "example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
}, },
# Coverage alerts # Coverage alerts
"infra_single_gateway": { "infra_single_gateway": {
"name": "Single Gateway", "name": "Single Gateway",
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy", "description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
"default_severity": "priority", "default_severity": "priority",
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.", "example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
}, },
"feeder_offline": { "feeder_offline": {
"name": "Feeder Offline", "name": "Feeder Offline",
"description": "A feeder gateway stopped responding — coverage gap possible", "description": "A feeder gateway stopped responding — coverage gap possible",
"default_severity": "priority", "default_severity": "priority",
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.", "example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
}, },
"region_total_blackout": { "region_total_blackout": {
"name": "Region Blackout", "name": "Region Blackout",
"description": "All infrastructure in a region is offline — complete coverage loss", "description": "All infrastructure in a region is offline — complete coverage loss",
"default_severity": "immediate", "default_severity": "immediate",
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!", "example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
}, },
# Health score alerts # Health score alerts
"mesh_score_low": { "mesh_score_low": {
"name": "Mesh Health Low", "name": "Mesh Health Low",
"description": "Overall mesh health score dropped below threshold — multiple issues likely", "description": "Overall mesh health score dropped below threshold — multiple issues likely",
"default_severity": "priority", "default_severity": "priority",
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.", "example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
}, },
"region_score_low": { "region_score_low": {
"name": "Region Health Low", "name": "Region Health Low",
"description": "A region's health score below threshold — localized issues", "description": "A region's health score below threshold — localized issues",
"default_severity": "priority", "default_severity": "priority",
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.", "example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
}, },
# Environmental - Weather # Environmental - Weather
"weather_warning": { "weather_warning": {
"name": "Severe Weather", "name": "Severe Weather",
"description": "NWS warning or advisory affecting your mesh area", "description": "NWS warning or advisory affecting your mesh area",
"default_severity": "priority", "default_severity": "priority",
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z", "example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
}, },
# Environmental - Space Weather # Environmental - Space Weather
"hf_blackout": { "hf_blackout": {
"name": "HF Radio Blackout", "name": "HF Radio Blackout",
"description": "R3+ solar flare degrading HF propagation on sunlit side", "description": "R3+ solar flare degrading HF propagation on sunlit side",
"default_severity": "priority", "default_severity": "priority",
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.", "example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
}, },
"geomagnetic_storm": { "geomagnetic_storm": {
"name": "Geomagnetic Storm", "name": "Geomagnetic Storm",
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible", "description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
"default_severity": "priority", "default_severity": "priority",
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.", "example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
}, },
# Environmental - Tropospheric # Environmental - Tropospheric
"tropospheric_ducting": { "tropospheric_ducting": {
"name": "Tropospheric Ducting", "name": "Tropospheric Ducting",
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range", "description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
"default_severity": "routine", "default_severity": "routine",
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.", "example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
}, },
# Environmental - Fire # Environmental - Fire
"fire_proximity": { "fire_proximity": {
"name": "Fire Near Mesh", "name": "Fire Near Mesh",
"description": "Active wildfire within alert radius of mesh infrastructure", "description": "Active wildfire within alert radius of mesh infrastructure",
"default_severity": "priority", "default_severity": "priority",
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.", "example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
}, },
"wildfire_proximity": { "wildfire_proximity": {
"name": "Fire Near Mesh", "name": "Fire Near Mesh",
"description": "Active wildfire within alert radius of mesh infrastructure", "description": "Active wildfire within alert radius of mesh infrastructure",
"default_severity": "priority", "default_severity": "priority",
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.", "example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
}, },
"new_ignition": { "new_ignition": {
"name": "New Fire Ignition", "name": "New Fire Ignition",
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire", "description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
"default_severity": "priority", "default_severity": "priority",
"example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.", "example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.",
}, },
# Environmental - Flood # Environmental - Flood
"stream_flood_warning": { "stream_flood_warning": {
"name": "Stream Flood Warning", "name": "Stream Flood Warning",
"description": "River gauge exceeds NWS flood stage threshold", "description": "River gauge exceeds NWS flood stage threshold",
"default_severity": "priority", "default_severity": "priority",
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.", "example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
}, },
"stream_high_water": { "stream_high_water": {
"name": "Stream High Water", "name": "Stream High Water",
"description": "River gauge approaching flood stage — monitoring recommended", "description": "River gauge approaching flood stage — monitoring recommended",
"default_severity": "routine", "default_severity": "routine",
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.", "example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
}, },
# Environmental - Roads # Environmental - Roads
"road_closure": { "road_closure": {
"name": "Road Closure", "name": "Road Closure",
"description": "Full road closure on a monitored corridor", "description": "Full road closure on a monitored corridor",
"default_severity": "priority", "default_severity": "priority",
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.", "example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
}, },
"traffic_congestion": { "traffic_congestion": {
"name": "Traffic Congestion", "name": "Traffic Congestion",
"description": "Traffic speed dropped below congestion threshold on a monitored corridor", "description": "Traffic speed dropped below congestion threshold on a monitored corridor",
"default_severity": "routine", "default_severity": "routine",
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio", "example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
}, },
# Environmental - Avalanche # Environmental - Avalanche
"avalanche_warning": { "avalanche_warning": {
"name": "Avalanche Danger High", "name": "Avalanche Danger High",
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area", "description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
"default_severity": "priority", "default_severity": "priority",
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.", "example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
}, },
"avalanche_considerable": { "avalanche_considerable": {
"name": "Avalanche Danger Considerable", "name": "Avalanche Danger Considerable",
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level", "description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
"default_severity": "routine", "default_severity": "routine",
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.", "example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
}, },
} }
def get_category(category_id: str) -> dict: def get_category(category_id: str) -> dict:
"""Get category info by ID, with fallback for unknown categories.""" """Get category info by ID, with fallback for unknown categories."""
if category_id in ALERT_CATEGORIES: if category_id in ALERT_CATEGORIES:
return ALERT_CATEGORIES[category_id] return ALERT_CATEGORIES[category_id]
return { return {
"name": category_id.replace("_", " ").title(), "name": category_id.replace("_", " ").title(),
"description": f"Alert type: {category_id}", "description": f"Alert type: {category_id}",
"default_severity": "routine", "default_severity": "routine",
"example_message": f"Alert: {category_id}", "example_message": f"Alert: {category_id}",
} }
def list_categories() -> list[dict]: def list_categories() -> list[dict]:
"""List all categories with their IDs.""" """List all categories with their IDs."""
return [ return [
{"id": cat_id, **cat_info} {"id": cat_id, **cat_info}
for cat_id, cat_info in ALERT_CATEGORIES.items() for cat_id, cat_info in ALERT_CATEGORIES.items()
] ]

View file

@ -1,64 +1,64 @@
"""Message summarizer for mesh delivery.""" """Message summarizer for mesh delivery."""
import logging import logging
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from ..backends import LLMBackend from ..backends import LLMBackend
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MessageSummarizer: class MessageSummarizer:
"""Summarizes long messages for mesh delivery. """Summarizes long messages for mesh delivery.
Only used when: Only used when:
- Delivering to mesh channels (broadcast or DM) - Delivering to mesh channels (broadcast or DM)
- Message exceeds max_chars (default 200) - Message exceeds max_chars (default 200)
- LLM backend is available - LLM backend is available
Email and webhook channels receive full messages. Email and webhook channels receive full messages.
""" """
def __init__(self, llm_backend: Optional["LLMBackend"] = None): def __init__(self, llm_backend: Optional["LLMBackend"] = None):
self._llm = llm_backend self._llm = llm_backend
async def summarize(self, message: str, max_chars: int = 195) -> str: async def summarize(self, message: str, max_chars: int = 195) -> str:
"""Summarize a message to fit within max_chars. """Summarize a message to fit within max_chars.
Args: Args:
message: Original message text message: Original message text
max_chars: Maximum characters for summary max_chars: Maximum characters for summary
Returns: Returns:
Summarized message, or truncated original if LLM unavailable Summarized message, or truncated original if LLM unavailable
""" """
if len(message) <= max_chars: if len(message) <= max_chars:
return message return message
if not self._llm: if not self._llm:
return message[:max_chars - 3] + "..." return message[:max_chars - 3] + "..."
prompt = ( prompt = (
"Summarize this alert in under %d characters. " "Summarize this alert in under %d characters. "
"Keep severity, location, and key facts. No preamble, just the summary:\n\n%s" "Keep severity, location, and key facts. No preamble, just the summary:\n\n%s"
% (max_chars, message) % (max_chars, message)
) )
try: try:
# Use the LLM to generate a summary # Use the LLM to generate a summary
response = await self._llm.generate( response = await self._llm.generate(
prompt, prompt,
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.", system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
) )
summary = response.strip() summary = response.strip()
# Ensure it fits # Ensure it fits
if len(summary) <= max_chars: if len(summary) <= max_chars:
return summary return summary
return summary[:max_chars - 3] + "..." return summary[:max_chars - 3] + "..."
except Exception as e: except Exception as e:
logger.debug("LLM summarization failed: %s", e) logger.debug("LLM summarization failed: %s", e)
return message[:max_chars - 3] + "..." return message[:max_chars - 3] + "..."