mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
7a4bd4f38f
commit
95ec7d5351
13 changed files with 1145 additions and 1133 deletions
|
|
@ -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: {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
523
meshai/dashboard/static/assets/index-Bildyb1E.js
Normal file
523
meshai/dashboard/static/assets/index-Bildyb1E.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-QhNRb-ap.css
Normal file
1
meshai/dashboard/static/assets/index-QhNRb-ap.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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] + "..."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue