mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
Compare commits
6 commits
32f6a238f8
...
344ca0677d
| Author | SHA1 | Date | |
|---|---|---|---|
| 344ca0677d | |||
| 95ec7d5351 | |||
| 7a4bd4f38f | |||
| 21d6520ffd | |||
| 839bf322d9 | |||
| 829ad562e4 |
18 changed files with 1777 additions and 1420 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: {}
|
||||||
|
|
||||||
|
|
@ -217,11 +217,13 @@ environmental:
|
||||||
proximity_km: 10.0 # km to match known fire perimeters
|
proximity_km: 10.0 # km to match known fire perimeters
|
||||||
|
|
||||||
|
|
||||||
# === NOTIFICATION DELIVERY ===
|
# === NOTIFICATION DELIVERY (TRANSITIONAL) ===
|
||||||
|
# NOTE: This notifications schema will be replaced in v0.3 by the 8-toggle model.
|
||||||
|
# These rule examples are transitional until Phase 1.2 lands. Do not extend.
|
||||||
|
# Severity levels: routine (informational), priority (needs attention), immediate (act now)
|
||||||
|
#
|
||||||
# Route alerts to channels (mesh, email, webhook) based on rules.
|
# Route alerts to channels (mesh, email, webhook) based on rules.
|
||||||
# Categories match alert types from alert_engine.py.
|
# Categories match alert types from alert_engine.py.
|
||||||
# Severity levels: info, advisory, watch, warning, critical, emergency
|
|
||||||
#
|
|
||||||
notifications:
|
notifications:
|
||||||
enabled: false
|
enabled: false
|
||||||
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
||||||
|
|
@ -236,7 +238,7 @@ notifications:
|
||||||
enabled: true
|
enabled: true
|
||||||
trigger_type: condition
|
trigger_type: condition
|
||||||
categories: [] # Empty = all categories
|
categories: [] # Empty = all categories
|
||||||
min_severity: "emergency"
|
min_severity: "immediate"
|
||||||
delivery_type: mesh_broadcast
|
delivery_type: mesh_broadcast
|
||||||
broadcast_channel: 0
|
broadcast_channel: 0
|
||||||
cooldown_minutes: 5
|
cooldown_minutes: 5
|
||||||
|
|
@ -247,7 +249,7 @@ notifications:
|
||||||
enabled: true
|
enabled: true
|
||||||
trigger_type: condition
|
trigger_type: condition
|
||||||
categories: ["infra_offline", "critical_node_down"]
|
categories: ["infra_offline", "critical_node_down"]
|
||||||
min_severity: "warning"
|
min_severity: "priority"
|
||||||
delivery_type: mesh_broadcast
|
delivery_type: mesh_broadcast
|
||||||
broadcast_channel: 0
|
broadcast_channel: 0
|
||||||
cooldown_minutes: 30
|
cooldown_minutes: 30
|
||||||
|
|
@ -258,7 +260,7 @@ notifications:
|
||||||
enabled: true
|
enabled: true
|
||||||
trigger_type: condition
|
trigger_type: condition
|
||||||
categories: ["wildfire_proximity", "new_ignition"]
|
categories: ["wildfire_proximity", "new_ignition"]
|
||||||
min_severity: "advisory"
|
min_severity: "routine"
|
||||||
delivery_type: mesh_broadcast
|
delivery_type: mesh_broadcast
|
||||||
broadcast_channel: 0
|
broadcast_channel: 0
|
||||||
cooldown_minutes: 60
|
cooldown_minutes: 60
|
||||||
|
|
@ -269,7 +271,7 @@ notifications:
|
||||||
enabled: true
|
enabled: true
|
||||||
trigger_type: condition
|
trigger_type: condition
|
||||||
categories: ["weather_warning"]
|
categories: ["weather_warning"]
|
||||||
min_severity: "warning"
|
min_severity: "priority"
|
||||||
delivery_type: mesh_broadcast
|
delivery_type: mesh_broadcast
|
||||||
broadcast_channel: 0
|
broadcast_channel: 0
|
||||||
cooldown_minutes: 30
|
cooldown_minutes: 30
|
||||||
|
|
@ -280,7 +282,7 @@ notifications:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
# trigger_type: condition
|
# trigger_type: condition
|
||||||
# categories: ["wildfire_proximity", "new_ignition"]
|
# categories: ["wildfire_proximity", "new_ignition"]
|
||||||
# min_severity: "advisory"
|
# min_severity: "routine"
|
||||||
# delivery_type: email
|
# delivery_type: email
|
||||||
# smtp_host: "smtp.gmail.com"
|
# smtp_host: "smtp.gmail.com"
|
||||||
# smtp_port: 587
|
# smtp_port: 587
|
||||||
|
|
@ -296,7 +298,7 @@ notifications:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
# trigger_type: condition
|
# trigger_type: condition
|
||||||
# categories: []
|
# categories: []
|
||||||
# min_severity: "warning"
|
# min_severity: "priority"
|
||||||
# delivery_type: webhook
|
# delivery_type: webhook
|
||||||
# webhook_url: "https://discord.com/api/webhooks/..."
|
# webhook_url: "https://discord.com/api/webhooks/..."
|
||||||
# cooldown_minutes: 10
|
# cooldown_minutes: 10
|
||||||
|
|
@ -316,7 +318,7 @@ notifications:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
# trigger_type: condition
|
# trigger_type: condition
|
||||||
# categories: ["battery_warning"]
|
# categories: ["battery_warning"]
|
||||||
# min_severity: "warning"
|
# min_severity: "priority"
|
||||||
# delivery_type: "" # Empty = no delivery, just tracks matches
|
# delivery_type: "" # Empty = no delivery, just tracks matches
|
||||||
|
|
||||||
# === WEB DASHBOARD ===
|
# === WEB DASHBOARD ===
|
||||||
|
|
|
||||||
|
|
@ -465,7 +465,7 @@ const SEVERITY_COLORS: Record<string, string> = {
|
||||||
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventFeedItem({ event }: { event: EnvEvent }) {
|
function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean }) {
|
||||||
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
|
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
|
||||||
const Icon = sourceConfig.icon
|
const Icon = sourceConfig.icon
|
||||||
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
|
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
|
||||||
|
|
@ -483,18 +483,43 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
|
||||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build display title: prefer event_type + area_desc, fall back to headline
|
||||||
|
const eventType = (event as Record<string, unknown>).event_type as string | undefined
|
||||||
|
const areaDesc = (event as Record<string, unknown>).area_desc as string | undefined
|
||||||
|
const description = (event as Record<string, unknown>).description as string | undefined
|
||||||
|
|
||||||
|
let title = event.headline
|
||||||
|
if (eventType && areaDesc) {
|
||||||
|
// Shorten area description (remove "County" repetition)
|
||||||
|
const shortArea = areaDesc.replace(/ County/g, '').split(';')[0]
|
||||||
|
title = `${eventType} — ${shortArea}`
|
||||||
|
} else if (eventType) {
|
||||||
|
title = eventType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first sentence of description as subtitle
|
||||||
|
const subtitle = description ? description.split('. ')[0] : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
|
<div className={`flex items-start gap-2 py-2 border-b border-border/50 last:border-0 ${isLocal ? 'border-l-2 border-l-blue-500 pl-2 -ml-2' : ''}`}>
|
||||||
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
||||||
{event.severity || 'info'}
|
{event.severity || 'info'}
|
||||||
</span>
|
</span>
|
||||||
|
{isLocal && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||||
|
LOCAL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
||||||
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
|
<div className={`text-sm truncate ${isLocal ? 'text-slate-100' : 'text-slate-300'}`}>{title}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs text-slate-500 truncate mt-0.5">{subtitle}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -502,8 +527,31 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
|
||||||
|
|
||||||
// Live Event Feed Card
|
// Live Event Feed Card
|
||||||
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
||||||
|
// Severity order for sorting
|
||||||
|
const severityOrder: Record<string, number> = { immediate: 0, priority: 1, routine: 2 }
|
||||||
|
|
||||||
const sortedEvents = useMemo(() => {
|
const sortedEvents = useMemo(() => {
|
||||||
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
|
// Dedup by event_id
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const deduped = events.filter(e => {
|
||||||
|
if (!e.event_id) return true
|
||||||
|
if (seen.has(e.event_id)) return false
|
||||||
|
seen.add(e.event_id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort: local first, then by severity, then by time
|
||||||
|
return deduped.sort((a, b) => {
|
||||||
|
const aLocal = (a as Record<string, unknown>).is_local ? 1 : 0
|
||||||
|
const bLocal = (b as Record<string, unknown>).is_local ? 1 : 0
|
||||||
|
if (aLocal !== bLocal) return bLocal - aLocal // local first
|
||||||
|
|
||||||
|
const aSev = severityOrder[a.severity?.toLowerCase() || 'routine'] ?? 2
|
||||||
|
const bSev = severityOrder[b.severity?.toLowerCase() || 'routine'] ?? 2
|
||||||
|
if (aSev !== bSev) return aSev - bSev // higher severity first
|
||||||
|
|
||||||
|
return (b.fetched_at || 0) - (a.fetched_at || 0) // newest first
|
||||||
|
})
|
||||||
}, [events])
|
}, [events])
|
||||||
|
|
||||||
// Calculate feed health summary
|
// Calculate feed health summary
|
||||||
|
|
@ -528,7 +576,11 @@ function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: E
|
||||||
{sortedEvents.length > 0 ? (
|
{sortedEvents.length > 0 ? (
|
||||||
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
||||||
{sortedEvents.map((event, i) => (
|
{sortedEvents.map((event, i) => (
|
||||||
<EventFeedItem key={event.event_id || i} event={event} />
|
<EventFeedItem
|
||||||
|
key={event.event_id || i}
|
||||||
|
event={event}
|
||||||
|
isLocal={(event as Record<string, unknown>).is_local as boolean | undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -577,7 +578,7 @@ def _migrate_legacy_channels(notifications, data: dict):
|
||||||
enabled=ch.get("enabled", True),
|
enabled=ch.get("enabled", True),
|
||||||
trigger_type="condition",
|
trigger_type="condition",
|
||||||
categories=old_rule.get("categories", []),
|
categories=old_rule.get("categories", []),
|
||||||
min_severity=old_rule.get("min_severity", "warning"),
|
min_severity=old_rule.get("min_severity", "priority"),
|
||||||
delivery_type=ch.get("type", "mesh_broadcast"),
|
delivery_type=ch.get("type", "mesh_broadcast"),
|
||||||
broadcast_channel=ch.get("channel_index", 0),
|
broadcast_channel=ch.get("channel_index", 0),
|
||||||
node_ids=ch.get("node_ids", []),
|
node_ids=ch.get("node_ids", []),
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,31 @@ async def get_env_status(request: Request):
|
||||||
|
|
||||||
@router.get("/env/active")
|
@router.get("/env/active")
|
||||||
async def get_active_env(request: Request):
|
async def get_active_env(request: Request):
|
||||||
"""Get active environmental events."""
|
"""Get active environmental events with local zone marking."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return env_store.get_active()
|
events = env_store.get_active()
|
||||||
|
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
|
||||||
|
|
||||||
|
# Dedup by event_id and add is_local field
|
||||||
|
seen_ids = set()
|
||||||
|
result = []
|
||||||
|
for event in events:
|
||||||
|
event_id = event.get("event_id")
|
||||||
|
if event_id and event_id in seen_ids:
|
||||||
|
continue
|
||||||
|
if event_id:
|
||||||
|
seen_ids.add(event_id)
|
||||||
|
|
||||||
|
# Mark as local if event zones overlap with configured mesh zones
|
||||||
|
event_zones = set(event.get("areas", []))
|
||||||
|
event["is_local"] = bool(event_zones & mesh_zones)
|
||||||
|
result.append(event)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/swpc")
|
@router.get("/env/swpc")
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,7 @@ class MeshAI:
|
||||||
self.data_store = MeshDataStore(
|
self.data_store = MeshDataStore(
|
||||||
source_configs=enabled_sources,
|
source_configs=enabled_sources,
|
||||||
db_path="/data/mesh_history.db",
|
db_path="/data/mesh_history.db",
|
||||||
|
offline_threshold_hours=self.config.mesh_intelligence.offline_threshold_hours,
|
||||||
)
|
)
|
||||||
# Initial fetch and backfill
|
# Initial fetch and backfill
|
||||||
self.data_store.force_refresh()
|
self.data_store.force_refresh()
|
||||||
|
|
|
||||||
|
|
@ -230,16 +230,19 @@ class MeshDataStore:
|
||||||
self,
|
self,
|
||||||
source_configs: list[MeshSourceConfig],
|
source_configs: list[MeshSourceConfig],
|
||||||
db_path: str = "/data/mesh_history.db",
|
db_path: str = "/data/mesh_history.db",
|
||||||
|
offline_threshold_hours: int = 2,
|
||||||
):
|
):
|
||||||
"""Initialize the data store.
|
"""Initialize the data store.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_configs: List of source configurations
|
source_configs: List of source configurations
|
||||||
db_path: Path to SQLite database for historical data
|
db_path: Path to SQLite database for historical data
|
||||||
|
offline_threshold_hours: Hours before a node is considered offline
|
||||||
"""
|
"""
|
||||||
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {}
|
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {}
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
self._db: Optional[sqlite3.Connection] = None
|
self._db: Optional[sqlite3.Connection] = None
|
||||||
|
self._offline_threshold_hours = offline_threshold_hours
|
||||||
|
|
||||||
# Live state
|
# Live state
|
||||||
self._nodes: dict[int, UnifiedNode] = {}
|
self._nodes: dict[int, UnifiedNode] = {}
|
||||||
|
|
@ -745,11 +748,13 @@ class MeshDataStore:
|
||||||
|
|
||||||
node.last_heard = ts or 0.0
|
node.last_heard = ts or 0.0
|
||||||
|
|
||||||
# NOTE: is_online is set by MeshHealthEngine.compute() using the
|
# Compute is_online based on configured threshold
|
||||||
# configured offline_threshold_hours. Don't set it here with a
|
# This ensures correct status immediately, before health engine runs
|
||||||
# hardcoded value - let the health engine determine online status.
|
if node.last_heard:
|
||||||
# The health engine runs on every refresh cycle and will set is_online
|
offline_threshold = time.time() - (self._offline_threshold_hours * 3600)
|
||||||
# based on: (now - last_heard) < (offline_threshold_hours * 3600)
|
node.is_online = node.last_heard > offline_threshold
|
||||||
|
else:
|
||||||
|
node.is_online = False
|
||||||
|
|
||||||
# Hops, SNR, RSSI (MM)
|
# Hops, SNR, RSSI (MM)
|
||||||
node.hops_away = raw.get("hopsAway")
|
node.hops_away = raw.get("hopsAway")
|
||||||
|
|
@ -2111,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
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ class EmailChannel(NotificationChannel):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
alert_type = alert.get("type", "alert")
|
alert_type = alert.get("type", "alert")
|
||||||
severity = alert.get("severity", "info").upper()
|
severity = alert.get("severity", "routine").upper()
|
||||||
message = alert.get("message", "")
|
message = alert.get("message", "")
|
||||||
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
|
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
|
||||||
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
|
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
|
||||||
|
|
@ -518,7 +518,7 @@ class WebhookChannel(NotificationChannel):
|
||||||
"""POST alert to webhook URL."""
|
"""POST alert to webhook URL."""
|
||||||
payload = {
|
payload = {
|
||||||
"type": alert.get("type"),
|
"type": alert.get("type"),
|
||||||
"severity": alert.get("severity", "info"),
|
"severity": alert.get("severity", "routine"),
|
||||||
"message": alert.get("message", ""),
|
"message": alert.get("message", ""),
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"node_name": alert.get("node_name"),
|
"node_name": alert.get("node_name"),
|
||||||
|
|
@ -527,7 +527,7 @@ class WebhookChannel(NotificationChannel):
|
||||||
|
|
||||||
# Discord/Slack format
|
# Discord/Slack format
|
||||||
if "discord.com" in self._url or "slack.com" in self._url:
|
if "discord.com" in self._url or "slack.com" in self._url:
|
||||||
severity = alert.get("severity", "info")
|
severity = alert.get("severity", "routine")
|
||||||
color = {
|
color = {
|
||||||
"immediate": 0xFF0000,
|
"immediate": 0xFF0000,
|
||||||
"priority": 0xFFAA00,
|
"priority": 0xFFAA00,
|
||||||
|
|
@ -669,7 +669,7 @@ class WebhookChannel(NotificationChannel):
|
||||||
else:
|
else:
|
||||||
payload = {
|
payload = {
|
||||||
"type": "test",
|
"type": "test",
|
||||||
"severity": "info",
|
"severity": "routine",
|
||||||
"message": "MeshAI channel connectivity test",
|
"message": "MeshAI channel connectivity test",
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
|
|
@ -730,7 +730,7 @@ class WebhookChannel(NotificationChannel):
|
||||||
async def deliver_test(self, message: str) -> tuple[bool, str]:
|
async def deliver_test(self, message: str) -> tuple[bool, str]:
|
||||||
"""Deliver a specific test message via webhook."""
|
"""Deliver a specific test message via webhook."""
|
||||||
try:
|
try:
|
||||||
test_alert = {"type": "test", "severity": "info", "message": message}
|
test_alert = {"type": "test", "severity": "routine", "message": message}
|
||||||
success = await self.deliver(test_alert, {})
|
success = await self.deliver(test_alert, {})
|
||||||
if success:
|
if success:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class NotificationRouter:
|
||||||
self._timezone = timezone
|
self._timezone = timezone
|
||||||
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
|
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
|
||||||
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
||||||
|
self._llm = llm_backend
|
||||||
self._connector = connector
|
self._connector = connector
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
||||||
|
|
@ -149,7 +150,7 @@ class NotificationRouter:
|
||||||
async def process_alert(self, alert: dict) -> bool:
|
async def process_alert(self, alert: dict) -> bool:
|
||||||
"""Route an alert through matching rules."""
|
"""Route an alert through matching rules."""
|
||||||
category = alert.get("type", "")
|
category = alert.get("type", "")
|
||||||
severity = alert.get("severity", "info")
|
severity = alert.get("severity", "routine")
|
||||||
delivered = False
|
delivered = False
|
||||||
|
|
||||||
for rule in self._rules:
|
for rule in self._rules:
|
||||||
|
|
@ -159,7 +160,7 @@ class NotificationRouter:
|
||||||
if rule_categories and category not in rule_categories:
|
if rule_categories and category not in rule_categories:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
min_severity = rule.get("min_severity", "info")
|
min_severity = rule.get("min_severity", "routine")
|
||||||
if not self._severity_meets(severity, min_severity):
|
if not self._severity_meets(severity, min_severity):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -391,7 +392,7 @@ class NotificationRouter:
|
||||||
|
|
||||||
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
||||||
rule_categories = rule_dict.get("categories", [])
|
rule_categories = rule_dict.get("categories", [])
|
||||||
min_severity = rule_dict.get("min_severity", "info")
|
min_severity = rule_dict.get("min_severity", "routine")
|
||||||
delivery_type = rule_dict.get("delivery_type", "")
|
delivery_type = rule_dict.get("delivery_type", "")
|
||||||
|
|
||||||
# Legacy support
|
# Legacy support
|
||||||
|
|
@ -535,7 +536,7 @@ class NotificationRouter:
|
||||||
for alert in alert_engine.get_pending_alerts():
|
for alert in alert_engine.get_pending_alerts():
|
||||||
all_events.append({
|
all_events.append({
|
||||||
"type": alert.get("type", ""),
|
"type": alert.get("type", ""),
|
||||||
"severity": alert.get("severity", "info"),
|
"severity": alert.get("severity", "routine"),
|
||||||
"message": alert.get("message", ""),
|
"message": alert.get("message", ""),
|
||||||
"headline": alert.get("message", "")[:80],
|
"headline": alert.get("message", "")[:80],
|
||||||
})
|
})
|
||||||
|
|
@ -547,7 +548,7 @@ class NotificationRouter:
|
||||||
for event in env_store.get_active():
|
for event in env_store.get_active():
|
||||||
all_events.append({
|
all_events.append({
|
||||||
"type": event.get("type", event.get("category", "")),
|
"type": event.get("type", event.get("category", "")),
|
||||||
"severity": event.get("severity", "info"),
|
"severity": event.get("severity", "routine"),
|
||||||
"message": event.get("message", event.get("headline", str(event))),
|
"message": event.get("message", event.get("headline", str(event))),
|
||||||
"headline": event.get("headline", event.get("message", "Event"))[:80],
|
"headline": event.get("headline", event.get("message", "Event"))[:80],
|
||||||
})
|
})
|
||||||
|
|
@ -624,12 +625,20 @@ class NotificationRouter:
|
||||||
channel = self._create_channel_for_rule(rule_dict)
|
channel = self._create_channel_for_rule(rule_dict)
|
||||||
if channel:
|
if channel:
|
||||||
try:
|
try:
|
||||||
if action == "send_status" and live_data_lines:
|
if action == "send_status":
|
||||||
# Filter out the warning line for status message
|
# Determine report type from rule categories
|
||||||
data_lines = [l for l in live_data_lines if not l.startswith("[!]")]
|
report_type = "all"
|
||||||
status_msg = "[STATUS] " + " | ".join(data_lines[:4])
|
if rule_categories:
|
||||||
if len(status_msg) > 200:
|
if any(c in rule_categories for c in ["hf_blackout", "geomagnetic_storm", "tropospheric_ducting"]):
|
||||||
status_msg = status_msg[:195] + "..."
|
report_type = "rf_propagation"
|
||||||
|
elif any(c in rule_categories for c in ["infra_offline", "critical_node_down", "mesh_score_low", "battery_warning"]):
|
||||||
|
report_type = "mesh_health"
|
||||||
|
elif any(c in rule_categories for c in ["weather_warning", "fire_proximity", "new_ignition"]):
|
||||||
|
report_type = "weather_fire"
|
||||||
|
|
||||||
|
status_msg = await self.generate_report(report_type, env_store, health_engine)
|
||||||
|
if len(status_msg) > 195:
|
||||||
|
status_msg = status_msg[:192] + "..."
|
||||||
success, result = await channel.deliver_test(status_msg)
|
success, result = await channel.deliver_test(status_msg)
|
||||||
delivered = success
|
delivered = success
|
||||||
delivery_result = result if success else f"Failed: {result}"
|
delivery_result = result if success else f"Failed: {result}"
|
||||||
|
|
@ -637,9 +646,9 @@ class NotificationRouter:
|
||||||
delivery_error = result
|
delivery_error = result
|
||||||
|
|
||||||
elif action == "send_live" and matching_alerts:
|
elif action == "send_live" and matching_alerts:
|
||||||
live_msg = f"[LIVE TEST] {matching_alerts[0].get('message', '')}"
|
live_msg = matching_alerts[0].get('message', '')
|
||||||
if len(live_msg) > 200:
|
if len(live_msg) > 195:
|
||||||
live_msg = live_msg[:195] + "..."
|
live_msg = live_msg[:192] + "..."
|
||||||
success, result = await channel.deliver_test(live_msg)
|
success, result = await channel.deliver_test(live_msg)
|
||||||
delivered = success
|
delivered = success
|
||||||
delivery_result = result if success else f"Failed: {result}"
|
delivery_result = result if success else f"Failed: {result}"
|
||||||
|
|
@ -752,7 +761,7 @@ class NotificationRouter:
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"trigger_type": "condition",
|
"trigger_type": "condition",
|
||||||
"categories": categories if categories else [],
|
"categories": categories if categories else [],
|
||||||
"min_severity": "warning",
|
"min_severity": "priority",
|
||||||
"delivery_type": "mesh_dm",
|
"delivery_type": "mesh_dm",
|
||||||
"node_ids": [node_id],
|
"node_ids": [node_id],
|
||||||
"cooldown_minutes": 10,
|
"cooldown_minutes": 10,
|
||||||
|
|
@ -776,6 +785,264 @@ class NotificationRouter:
|
||||||
return categories if categories else ["all"]
|
return categories if categories else ["all"]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def generate_report(self, report_type: str, env_store, health_engine) -> str:
|
||||||
|
"""Generate an LLM-summarized report from current data."""
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
# For RF propagation, use deterministic formatter
|
||||||
|
swpc_data = None
|
||||||
|
ducting_data = None
|
||||||
|
|
||||||
|
if report_type in ("rf_propagation", "all"):
|
||||||
|
if env_store:
|
||||||
|
adapters = getattr(env_store, '_adapters', {})
|
||||||
|
if "swpc" in adapters and hasattr(env_store, 'get_swpc_status'):
|
||||||
|
swpc_data = env_store.get_swpc_status()
|
||||||
|
if "ducting" in adapters and hasattr(env_store, 'get_ducting_status'):
|
||||||
|
ducting_data = env_store.get_ducting_status()
|
||||||
|
|
||||||
|
# If this is an RF-only report, return deterministic format immediately
|
||||||
|
if report_type == "rf_propagation" and swpc_data:
|
||||||
|
sfi = swpc_data.get('sfi', 100)
|
||||||
|
kp = swpc_data.get('kp_current', 3)
|
||||||
|
try:
|
||||||
|
band_conditions = self._compute_band_conditions(float(sfi), float(kp))
|
||||||
|
return self._format_propagation_report(swpc_data, ducting_data, band_conditions)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Band condition calc failed: %s", e)
|
||||||
|
# Fall through to context-based approach
|
||||||
|
|
||||||
|
# For "all" report type, add RF data to context
|
||||||
|
if swpc_data:
|
||||||
|
context_parts.append(
|
||||||
|
f"Solar/Geomagnetic: SFI {swpc_data.get('sfi')}, "
|
||||||
|
f"Kp {swpc_data.get('kp_current')}, "
|
||||||
|
f"R{swpc_data.get('r_scale', 0)}/S{swpc_data.get('s_scale', 0)}/G{swpc_data.get('g_scale', 0)}"
|
||||||
|
)
|
||||||
|
if ducting_data:
|
||||||
|
context_parts.append(
|
||||||
|
f"Tropospheric: {ducting_data.get('condition', 'unknown')}, "
|
||||||
|
f"dM/dz {ducting_data.get('min_gradient', 'N/A')} M-units/km"
|
||||||
|
)
|
||||||
|
|
||||||
|
if report_type in ("mesh_health", "all"):
|
||||||
|
if health_engine:
|
||||||
|
health = getattr(health_engine, 'mesh_health', None)
|
||||||
|
if health and hasattr(health, 'score'):
|
||||||
|
score = health.score
|
||||||
|
context_parts.append(
|
||||||
|
f"Mesh: score {score.composite:.0f}/100, "
|
||||||
|
f"tier {score.tier}, "
|
||||||
|
f"{score.infra_online}/{score.infra_total} infra online, "
|
||||||
|
f"utilization {score.util_percent:.1f}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
if report_type in ("weather_fire", "all"):
|
||||||
|
if env_store and hasattr(env_store, 'get_active'):
|
||||||
|
nws = env_store.get_active(source="nws")
|
||||||
|
fires = env_store.get_active(source="nifc")
|
||||||
|
if nws:
|
||||||
|
headlines = [e.get("headline", "")[:80] for e in nws[:3]]
|
||||||
|
context_parts.append(f"Weather: {len(nws)} active alerts: {'; '.join(headlines)}")
|
||||||
|
else:
|
||||||
|
context_parts.append("Weather: No active alerts")
|
||||||
|
if fires:
|
||||||
|
context_parts.append(f"Fires: {len(fires)} active")
|
||||||
|
else:
|
||||||
|
context_parts.append("Fires: None active")
|
||||||
|
|
||||||
|
if report_type in ("environmental", "all"):
|
||||||
|
if env_store and hasattr(env_store, 'get_active'):
|
||||||
|
for source in ["usgs", "traffic", "roads511", "avalanche"]:
|
||||||
|
events = env_store.get_active(source=source)
|
||||||
|
if events:
|
||||||
|
context_parts.append(f"{source.upper()}: {len(events)} events")
|
||||||
|
|
||||||
|
if not context_parts:
|
||||||
|
# Return a graceful message for the specific report type
|
||||||
|
no_data_messages = {
|
||||||
|
"rf_propagation": "RF propagation data not available",
|
||||||
|
"mesh_health": "Mesh health data not available",
|
||||||
|
"weather_fire": "Weather/fire monitoring not configured",
|
||||||
|
"environmental": "Environmental monitoring not configured",
|
||||||
|
"all": "No monitoring data available",
|
||||||
|
}
|
||||||
|
return no_data_messages.get(report_type, "No data available")
|
||||||
|
|
||||||
|
raw_data = "\n".join(context_parts)
|
||||||
|
|
||||||
|
# Generate LLM summary
|
||||||
|
if self._llm:
|
||||||
|
prompt = self._build_report_prompt(report_type, raw_data)
|
||||||
|
try:
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
summary = await self._llm.generate(
|
||||||
|
messages=messages,
|
||||||
|
system_prompt="You are a concise infrastructure status reporter. Format data clearly and briefly. Output only the formatted report, no preamble or explanation.",
|
||||||
|
|
||||||
|
)
|
||||||
|
return summary.strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("LLM report generation failed: %s", e)
|
||||||
|
return raw_data
|
||||||
|
else:
|
||||||
|
return raw_data
|
||||||
|
|
||||||
|
def _compute_band_conditions(self, sfi: float, kp: float) -> dict:
|
||||||
|
"""Deterministic band conditions from SFI and Kp."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
hour_utc = datetime.now(timezone.utc).hour
|
||||||
|
is_day = 12 <= hour_utc or hour_utc <= 3 # rough UTC daytime for US
|
||||||
|
|
||||||
|
bands = {}
|
||||||
|
|
||||||
|
# 10m (28 MHz) - needs high SFI, daytime only
|
||||||
|
if sfi > 140 and kp <= 2:
|
||||||
|
bands["10m"] = "Good"
|
||||||
|
elif sfi > 100 and kp <= 4 and is_day:
|
||||||
|
bands["10m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["10m"] = "Poor"
|
||||||
|
|
||||||
|
# 12m (24 MHz)
|
||||||
|
if sfi > 120 and kp <= 3:
|
||||||
|
bands["12m"] = "Good"
|
||||||
|
elif sfi > 90 and kp <= 4 and is_day:
|
||||||
|
bands["12m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["12m"] = "Poor"
|
||||||
|
|
||||||
|
# 15m (21 MHz)
|
||||||
|
if sfi > 100 and kp <= 3:
|
||||||
|
bands["15m"] = "Good"
|
||||||
|
elif sfi > 80 and kp <= 5:
|
||||||
|
bands["15m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["15m"] = "Poor"
|
||||||
|
|
||||||
|
# 17m (18 MHz)
|
||||||
|
if sfi > 90 and kp <= 3:
|
||||||
|
bands["17m"] = "Good"
|
||||||
|
elif sfi > 70 and kp <= 5:
|
||||||
|
bands["17m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["17m"] = "Poor"
|
||||||
|
|
||||||
|
# 20m (14 MHz) - almost always usable
|
||||||
|
if sfi > 80 and kp <= 3:
|
||||||
|
bands["20m"] = "Good"
|
||||||
|
elif kp <= 6:
|
||||||
|
bands["20m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["20m"] = "Poor"
|
||||||
|
|
||||||
|
# 30m (10 MHz) - very reliable
|
||||||
|
if kp <= 4:
|
||||||
|
bands["30m"] = "Good"
|
||||||
|
elif kp <= 6:
|
||||||
|
bands["30m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["30m"] = "Poor"
|
||||||
|
|
||||||
|
# 40m (7 MHz) - reliable, better at night
|
||||||
|
if kp <= 4:
|
||||||
|
bands["40m"] = "Good"
|
||||||
|
elif kp <= 6:
|
||||||
|
bands["40m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["40m"] = "Poor"
|
||||||
|
|
||||||
|
# 80m (3.5 MHz) - night band
|
||||||
|
if kp <= 3:
|
||||||
|
bands["80m"] = "Good"
|
||||||
|
elif kp <= 5:
|
||||||
|
bands["80m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["80m"] = "Poor"
|
||||||
|
|
||||||
|
return bands
|
||||||
|
|
||||||
|
def _format_propagation_report(self, swpc: dict, ducting: dict, band_conditions: dict) -> str:
|
||||||
|
"""Format propagation report deterministically."""
|
||||||
|
sfi = swpc.get('sfi', 'N/A')
|
||||||
|
kp = swpc.get('kp_current', 'N/A')
|
||||||
|
|
||||||
|
lines = [f"Band Conditions (SFI {sfi}, Kp {kp}):"]
|
||||||
|
|
||||||
|
for band_list, label in [
|
||||||
|
(["80m", "40m"], "80-40m"),
|
||||||
|
(["30m", "20m"], "30-20m"),
|
||||||
|
(["17m", "15m"], "17-15m"),
|
||||||
|
(["12m", "10m"], "12-10m"),
|
||||||
|
]:
|
||||||
|
# Take the better of the two bands in range
|
||||||
|
ratings = [band_conditions.get(b, "Poor") for b in band_list]
|
||||||
|
if "Good" in ratings:
|
||||||
|
rating = "Good"
|
||||||
|
elif "Fair" in ratings:
|
||||||
|
rating = "Fair"
|
||||||
|
else:
|
||||||
|
rating = "Poor"
|
||||||
|
lines.append(f"{label}: {rating}")
|
||||||
|
|
||||||
|
if ducting and ducting.get("condition") and ducting.get("condition") != "normal":
|
||||||
|
cond = ducting["condition"].replace("_", " ").title()
|
||||||
|
lines.append(f"Tropo: {cond}")
|
||||||
|
else:
|
||||||
|
lines.append("Tropo: Normal")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _build_report_prompt(self, report_type: str, raw_data: str) -> str:
|
||||||
|
"""Build the LLM prompt for report generation."""
|
||||||
|
prompts = {
|
||||||
|
"rf_propagation": (
|
||||||
|
"Format this propagation data as a band-by-band HF report. "
|
||||||
|
"Output format:\n"
|
||||||
|
"Band Conditions (SFI X, Kp Y):\n"
|
||||||
|
"80-40m: [Good/Fair/Poor]\n"
|
||||||
|
"30-20m: [Good/Fair/Poor]\n"
|
||||||
|
"17-15m: [Good/Fair/Poor]\n"
|
||||||
|
"12-10m: [Good/Fair/Poor]\n"
|
||||||
|
"Tropo: [Normal/Enhanced/Ducting]\n\n"
|
||||||
|
"Determine ratings from SFI and Kp values. "
|
||||||
|
"Output ONLY the formatted report.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
"mesh_health": (
|
||||||
|
"Format this mesh network health data as a brief status. "
|
||||||
|
"Include: score out of 100, tier name, infrastructure count, "
|
||||||
|
"and any problems. If healthy, say 'Mesh healthy' with key stats. "
|
||||||
|
"Example: 'Mesh healthy: 90/100, 16/16 infra online, 20% util'\n"
|
||||||
|
"Keep it concise. Output ONLY the status line.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
"weather_fire": (
|
||||||
|
"Format these weather/fire alerts as a brief summary. "
|
||||||
|
"List count of alerts and most severe conditions. "
|
||||||
|
"Example: '3 weather alerts: Winter Storm Warning (ID), "
|
||||||
|
"Wind Advisory (OR). No active fires.'\n"
|
||||||
|
"Keep it concise. Output ONLY the summary.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
"environmental": (
|
||||||
|
"Summarize all environmental conditions briefly. "
|
||||||
|
"Cover weather alerts, fires, streams, roads. "
|
||||||
|
"Example: 'Weather clear, no fires, 2 streams elevated, "
|
||||||
|
"roads open.'\n"
|
||||||
|
"Keep it concise. Output ONLY the summary.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
"all": (
|
||||||
|
"Summarize all conditions for a mesh network operator. "
|
||||||
|
"Cover: mesh health score, any infrastructure issues, "
|
||||||
|
"weather alerts, fire status, propagation conditions. "
|
||||||
|
"Be brief but complete.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return prompts.get(report_type, prompts["all"])
|
||||||
|
|
||||||
def cleanup_recent(self, max_age: int = 3600):
|
def cleanup_recent(self, max_age: int = 3600):
|
||||||
"""Clean up old entries from recent alerts cache."""
|
"""Clean up old entries from recent alerts cache."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class MessageSummarizer:
|
||||||
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.",
|
||||||
max_tokens=100,
|
|
||||||
)
|
)
|
||||||
summary = response.strip()
|
summary = response.strip()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue