Compare commits

..

No commits in common. "344ca0677ddf22dfebf9d24ddba0e3e94607bfe1" and "32f6a238f8133c718b2fa5faf4a84d5f18d5d85d" have entirely different histories.

18 changed files with 1420 additions and 1777 deletions

View file

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

View file

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

View file

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

View file

@ -21,31 +21,13 @@ async def get_env_status(request: Request):
@router.get("/env/active")
async def get_active_env(request: Request):
"""Get active environmental events with local zone marking."""
"""Get active environmental events."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
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
return env_store.get_active()
@router.get("/env/swpc")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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