mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
feat(mesh): config-driven regions with stale purge and coverage fix
- Extended RegionAnchor with local_name, description, aliases, cities - Moved region geographic context from hardcoded Python to config.yaml - Added 7-day stale node purge in _do_refresh (556 → 267 nodes) - Fixed coverage lookup: str(node_num) → node_num (int key) - Added bidirectional neighbor lookup for better region assignment - Dynamic geography building in router from config - Reporter reads region context from config instead of hardcoded dict Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
abcf2d88e2
commit
de400068dd
6 changed files with 141 additions and 108 deletions
|
|
@ -179,11 +179,15 @@ class MeshSourceConfig:
|
|||
|
||||
@dataclass
|
||||
class RegionAnchor:
|
||||
"""A fixed region anchor point."""
|
||||
"""A fixed region anchor point with geographic context."""
|
||||
|
||||
name: str = ""
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
local_name: str = "" # e.g., "Magic Valley"
|
||||
description: str = "" # e.g., "Twin Falls, Burley, Jerome along I-84/US-93"
|
||||
aliases: list[str] = field(default_factory=list) # e.g., ["southern Idaho", "magic valley"]
|
||||
cities: list[str] = field(default_factory=list) # e.g., ["Twin Falls", "Burley", "Jerome"]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -250,7 +250,8 @@ class MeshAI:
|
|||
# Mesh reporter (for LLM prompt injection and commands)
|
||||
if self.health_engine and self.data_store:
|
||||
from .mesh_reporter import MeshReporter
|
||||
self.mesh_reporter = MeshReporter(self.health_engine, self.data_store)
|
||||
mi_regions = self.config.mesh_intelligence.regions if self.config.mesh_intelligence else []
|
||||
self.mesh_reporter = MeshReporter(self.health_engine, self.data_store, region_configs=mi_regions)
|
||||
logger.info("Mesh reporter enabled")
|
||||
else:
|
||||
self.mesh_reporter = None
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ from .sources.meshview import MeshviewSource
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Nodes not heard in this many days are excluded from live model
|
||||
STALE_NODE_THRESHOLD_DAYS = 7
|
||||
|
||||
# Meshtastic role enum mapping (integer -> string)
|
||||
MESHTASTIC_ROLE_MAP = {
|
||||
0: "CLIENT",
|
||||
|
|
@ -351,6 +354,31 @@ class MeshDataStore:
|
|||
# Source Management
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def _purge_stale_nodes(self):
|
||||
"""Remove nodes not heard from in more than 7 days.
|
||||
|
||||
These nodes are stale — they inflate counts, create false
|
||||
coverage gaps, and waste LLM tokens. They still exist in
|
||||
SQLite historical data, just not in the live model.
|
||||
"""
|
||||
import time
|
||||
cutoff = time.time() - (STALE_NODE_THRESHOLD_DAYS * 86400)
|
||||
|
||||
stale_nums = []
|
||||
for node_num, node in self._nodes.items():
|
||||
if node.last_heard and node.last_heard < cutoff:
|
||||
stale_nums.append(node_num)
|
||||
elif not node.last_heard or node.last_heard == 0:
|
||||
# No last_heard data at all — also consider stale
|
||||
stale_nums.append(node_num)
|
||||
|
||||
for num in stale_nums:
|
||||
del self._nodes[num]
|
||||
|
||||
if stale_nums:
|
||||
logger.info(f"Purged {len(stale_nums)} stale nodes (not heard in {STALE_NODE_THRESHOLD_DAYS} days)")
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""Refresh data from all sources if interval has elapsed.
|
||||
|
||||
|
|
@ -403,6 +431,7 @@ class MeshDataStore:
|
|||
|
||||
if refreshed > 0:
|
||||
self._rebuild()
|
||||
self._purge_stale_nodes() # Remove nodes not heard in 7+ days
|
||||
self._store_snapshot()
|
||||
self._purge_old_data()
|
||||
self._last_refresh = time.time()
|
||||
|
|
|
|||
|
|
@ -292,37 +292,57 @@ class MeshHealthEngine:
|
|||
else:
|
||||
unlocated.append(str(node_num))
|
||||
|
||||
# Build neighbor map from edges
|
||||
# First, create a mapping from numeric node_id to hex id
|
||||
numeric_to_hex: dict[str, str] = {}
|
||||
for node in all_nodes:
|
||||
hex_id = node.get("id")
|
||||
num_id = node.get("node_id")
|
||||
if hex_id and num_id:
|
||||
numeric_to_hex[str(num_id)] = str(hex_id)
|
||||
# Build BIDIRECTIONAL neighbor map from ALL sources:
|
||||
# 1. Each node's own neighbor list (from NeighborInfo packets)
|
||||
# 2. REVERSE: if A lists B as neighbor, B also sees A
|
||||
# 3. Edges from traceroutes and other connections
|
||||
all_neighbor_map: dict[int, set[int]] = {}
|
||||
|
||||
# First: add each node's own neighbor list AND reverse relationships
|
||||
for node_num, node in nodes.items():
|
||||
if node.neighbors:
|
||||
if node_num not in all_neighbor_map:
|
||||
all_neighbor_map[node_num] = set()
|
||||
for nb_num in node.neighbors:
|
||||
all_neighbor_map[node_num].add(nb_num)
|
||||
# REVERSE: if this node sees nb_num, nb_num also "sees" this node
|
||||
if nb_num not in all_neighbor_map:
|
||||
all_neighbor_map[nb_num] = set()
|
||||
all_neighbor_map[nb_num].add(node_num)
|
||||
|
||||
# Second: add from edges (connections from traceroutes, etc.)
|
||||
if hasattr(data_store, 'edges'):
|
||||
for edge in data_store.edges:
|
||||
from_num = edge.from_node
|
||||
to_num = edge.to_node
|
||||
if from_num not in all_neighbor_map:
|
||||
all_neighbor_map[from_num] = set()
|
||||
if to_num not in all_neighbor_map:
|
||||
all_neighbor_map[to_num] = set()
|
||||
all_neighbor_map[from_num].add(to_num)
|
||||
all_neighbor_map[to_num].add(from_num)
|
||||
|
||||
# Also add from raw edges API
|
||||
all_edges = source_manager.get_all_edges()
|
||||
neighbors: dict[str, set[str]] = {}
|
||||
for edge in all_edges:
|
||||
# Get edge endpoints (may be numeric)
|
||||
from_raw = edge.get("from") or edge.get("from_node") or edge.get("source")
|
||||
to_raw = edge.get("to") or edge.get("to_node") or edge.get("target")
|
||||
if not from_raw or not to_raw:
|
||||
continue
|
||||
try:
|
||||
from_num = int(from_raw) if not str(from_raw).startswith("!") else int(str(from_raw)[1:], 16)
|
||||
to_num = int(to_raw) if not str(to_raw).startswith("!") else int(str(to_raw)[1:], 16)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if from_num not in all_neighbor_map:
|
||||
all_neighbor_map[from_num] = set()
|
||||
if to_num not in all_neighbor_map:
|
||||
all_neighbor_map[to_num] = set()
|
||||
all_neighbor_map[from_num].add(to_num)
|
||||
all_neighbor_map[to_num].add(from_num)
|
||||
|
||||
# Convert to hex ID format if numeric
|
||||
from_id = numeric_to_hex.get(str(from_raw), str(from_raw))
|
||||
to_id = numeric_to_hex.get(str(to_raw), str(to_raw))
|
||||
|
||||
if from_id not in neighbors:
|
||||
neighbors[from_id] = set()
|
||||
if to_id not in neighbors:
|
||||
neighbors[to_id] = set()
|
||||
neighbors[from_id].add(to_id)
|
||||
neighbors[to_id].add(from_id)
|
||||
|
||||
# Second pass: Assign unlocated nodes based on neighbor regions
|
||||
# Repeat until no more assignments
|
||||
# Second pass: Assign unlocated nodes based on BIDIRECTIONAL neighbor map
|
||||
# This catches nodes that OTHER nodes list as neighbors
|
||||
max_iterations = 10
|
||||
for _ in range(max_iterations):
|
||||
newly_assigned = []
|
||||
|
|
@ -337,19 +357,11 @@ class MeshHealthEngine:
|
|||
if node.region:
|
||||
continue # Already assigned
|
||||
|
||||
# Count neighbor regions
|
||||
neighbor_ids = neighbors.get(node_id_str, set())
|
||||
# Use the BIDIRECTIONAL neighbor map
|
||||
neighbor_nums = all_neighbor_map.get(node_num, set())
|
||||
region_counts: dict[str, int] = {}
|
||||
for nid in neighbor_ids:
|
||||
# Convert string ID to int for nodes lookup
|
||||
try:
|
||||
if nid.startswith("!"):
|
||||
nid_int = int(nid[1:], 16)
|
||||
else:
|
||||
nid_int = int(nid)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
neighbor_node = nodes.get(nid_int)
|
||||
for neighbor_num in neighbor_nums:
|
||||
neighbor_node = nodes.get(neighbor_num)
|
||||
if neighbor_node and neighbor_node.region:
|
||||
r = neighbor_node.region
|
||||
region_counts[r] = region_counts.get(r, 0) + 1
|
||||
|
|
|
|||
|
|
@ -36,21 +36,6 @@ PORTNUM_DISPLAY = {
|
|||
"ATAK_FORWARDER": "ATAK",
|
||||
}
|
||||
|
||||
# Geographic context for region names
|
||||
REGION_CONTEXT = {
|
||||
"South Central ID": "Magic Valley (Twin Falls area)",
|
||||
"South Western ID": "Treasure Valley (Boise metro)",
|
||||
"South Eastern ID": "Eastern Idaho (Idaho Falls area)",
|
||||
"Central ID": "Idaho backcountry (Salmon area)",
|
||||
"Northern ID": "North Idaho (Panhandle)",
|
||||
"Northern UT": "Northern Utah (Ogden/Logan)",
|
||||
"Central UT": "Wasatch Front (Salt Lake City)",
|
||||
"Eastern UT": "Eastern Utah (Vernal/Moab)",
|
||||
"Western UT": "Western Utah (Tooele)",
|
||||
"Southern UT": "Southern Utah / Dixie (St. George)",
|
||||
}
|
||||
|
||||
|
||||
def _clean_portnum(portnum: str) -> str:
|
||||
"""Convert raw portnum to display name."""
|
||||
return PORTNUM_DISPLAY.get(portnum, portnum.replace("_APP", "").replace("_", " ").title())
|
||||
|
|
@ -167,15 +152,28 @@ def _is_valid_temperature(temp_c: Optional[float]) -> bool:
|
|||
class MeshReporter:
|
||||
"""Builds text blocks for mesh health prompt injection."""
|
||||
|
||||
def __init__(self, health_engine: "MeshHealthEngine", data_store: "MeshDataStore"):
|
||||
def __init__(self, health_engine: "MeshHealthEngine", data_store: "MeshDataStore", region_configs=None):
|
||||
"""Initialize reporter.
|
||||
|
||||
Args:
|
||||
health_engine: MeshHealthEngine instance
|
||||
data_store: MeshDataStore instance
|
||||
region_configs: Optional list of RegionAnchor configs for local names
|
||||
"""
|
||||
self.health_engine = health_engine
|
||||
self.data_store = data_store
|
||||
self._region_configs = {r.name: r for r in (region_configs or [])}
|
||||
|
||||
def _region_context(self, region_name: str) -> str:
|
||||
"""Get display context for a region from config."""
|
||||
cfg = self._region_configs.get(region_name)
|
||||
if not cfg:
|
||||
return ""
|
||||
local = getattr(cfg, "local_name", "") or ""
|
||||
desc = getattr(cfg, "description", "") or ""
|
||||
if local and desc:
|
||||
return f"{local} ({desc})"
|
||||
return local or desc
|
||||
|
||||
def build_tier1_summary(self) -> str:
|
||||
"""Build compact mesh summary for LLM injection (~500-800 tokens).
|
||||
|
|
@ -294,7 +292,9 @@ class MeshReporter:
|
|||
lines.append(f"MQTT Uplinks: {health.uplink_node_count} nodes")
|
||||
|
||||
# Coverage by region - show geographic breakdown
|
||||
all_nodes = list(self.data_store.nodes.values())
|
||||
# MUST use health.nodes (not data_store.nodes) because region is set by health engine
|
||||
health = self.health_engine.mesh_health
|
||||
all_nodes = list(health.nodes.values()) if health else []
|
||||
nodes_with_gw = [n for n in all_nodes if n.avg_gateways is not None]
|
||||
if nodes_with_gw:
|
||||
total_sources = len(self.data_store._sources)
|
||||
|
|
@ -306,7 +306,7 @@ class MeshReporter:
|
|||
|
||||
region_coverage = {}
|
||||
for n in nodes_with_gw:
|
||||
health_node = health.nodes.get(str(n.node_num))
|
||||
health_node = health.nodes.get(n.node_num)
|
||||
region = health_node.region if health_node else "Unlocated"
|
||||
if not region:
|
||||
region = "Unlocated"
|
||||
|
|
@ -323,7 +323,7 @@ class MeshReporter:
|
|||
single = sum(1 for c in counts if c <= 1.0)
|
||||
flag = " !!" if avg < 2.0 else ""
|
||||
single_str = f" ({single} 1-gw)" if single > 0 else ""
|
||||
context = REGION_CONTEXT.get(region, "")
|
||||
context = self._region_context(region)
|
||||
context_str = f" ({context})" if context else ""
|
||||
lines.append(f" {region}{context_str}: {len(counts)} nodes, {avg:.1f} avg{single_str}{flag}")
|
||||
# Show unlocated as informational (no coverage recommendations for these)
|
||||
|
|
@ -344,7 +344,7 @@ class MeshReporter:
|
|||
rs = region.score
|
||||
flag = _tier_flag(rs.tier)
|
||||
infra_str = f"{rs.infra_online}/{rs.infra_total} infra"
|
||||
context = REGION_CONTEXT.get(region.name, "")
|
||||
context = self._region_context(region.name)
|
||||
context_str = f" ({context})" if context else ""
|
||||
lines.append(
|
||||
f" {region.name}{context_str}: {rs.composite:.0f}/100 - {infra_str}, {rs.util_percent:.0f}% util{flag}"
|
||||
|
|
@ -556,7 +556,7 @@ class MeshReporter:
|
|||
return f"REGION DETAIL: {region_name}\nRegion not found."
|
||||
|
||||
rs = region.score
|
||||
context = REGION_CONTEXT.get(region.name, "")
|
||||
context = self._region_context(region.name)
|
||||
context_str = f" — {context}" if context else ""
|
||||
lines = [
|
||||
f"REGION DETAIL: {region.name}{context_str}",
|
||||
|
|
|
|||
|
|
@ -123,70 +123,42 @@ _CITY_TO_REGION = {
|
|||
"cedar city": "Southern UT",
|
||||
}
|
||||
|
||||
# Mesh awareness instruction for LLM
|
||||
# Mesh awareness instruction for LLM
|
||||
_MESH_AWARENESS_PROMPT = """
|
||||
MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions):
|
||||
|
||||
RESPONSE STYLE:
|
||||
- Give DETAILED, data-driven responses with actual numbers
|
||||
- Talk in GEOGRAPHIC terms — reference cities, valleys, corridors people know
|
||||
- Name specific infrastructure nodes by their long name, not just shortnames
|
||||
- Talk in GEOGRAPHIC terms — use the local names and cities from REGION GEOGRAPHY below
|
||||
- Name specific infrastructure nodes by their long name
|
||||
- Include scores, percentages, node counts, battery levels, gateway counts
|
||||
- You CAN use 3-5 messages if needed — LoRa chunking handles splitting
|
||||
- No markdown formatting — plain text only
|
||||
- Be specific and data-driven. Do NOT compress to vague one-liners
|
||||
|
||||
REGION GEOGRAPHY — use these local names and landmarks:
|
||||
|
||||
IDAHO:
|
||||
- Northern ID: "North Idaho" or "the Panhandle" — Coeur d'Alene, Moscow, Lewiston, Sandpoint. Along I-90/US-95.
|
||||
- Central ID: Idaho backcountry — Salmon, Stanley, McCall, Challis. Remote, mountainous, very sparse. Frank Church wilderness.
|
||||
- South Western ID: "Treasure Valley" — Boise, Meridian, Nampa, Caldwell, Eagle, Mountain Home. Idaho's most populated region along I-84. Do NOT call this "southern Idaho."
|
||||
- South Central ID: "Magic Valley" — Twin Falls, Burley, Jerome, Hailey, Gooding, Shoshone, Sun Valley. Snake River Canyon corridor along I-84/US-93. When locals say "southern Idaho" they usually mean this area.
|
||||
- South Eastern ID: "Eastern Idaho" — Idaho Falls, Pocatello, Rexburg, Blackfoot. Upper Snake River Plain, gateway to Yellowstone.
|
||||
|
||||
IMPORTANT IDAHO GEOGRAPHY:
|
||||
- "Southern Idaho" is ambiguous. Do NOT lump Treasure Valley (Boise), Magic Valley (Twin Falls), and Eastern Idaho together. They are distinct areas 100+ miles apart.
|
||||
- If someone says "southern Idaho" they most likely mean the Magic Valley (South Central ID — Twin Falls area).
|
||||
- If unclear, describe each region separately rather than grouping.
|
||||
- "Treasure Valley" always means the Boise metro area (South Western ID).
|
||||
- "Magic Valley" always means the Twin Falls area (South Central ID).
|
||||
- "Eastern Idaho" means Idaho Falls/Pocatello area (South Eastern ID).
|
||||
|
||||
UTAH:
|
||||
- Northern UT: Ogden, Logan, Brigham City, Cache Valley. Northern Wasatch Front.
|
||||
- Central UT: "The Wasatch Front" or "Salt Lake" — Salt Lake City, Provo, Park City, Orem. About 2/3 of Utah's population lives here.
|
||||
- Eastern UT: Vernal and the Uinta Basin in the north, Moab and canyon country in the south. Dinosaur NM, Arches, Canyonlands.
|
||||
- Western UT: Tooele, Wendover. West desert, Bonneville Salt Flats. Very sparse.
|
||||
- Southern UT: "Dixie" or "Color Country" — St. George, Cedar City, Hurricane. Zion, Bryce Canyon country. Fastest growing area in Utah.
|
||||
|
||||
ANSWERING COVERAGE QUESTIONS:
|
||||
- Reference the geographic area by local name: "Coverage across the Magic Valley from Burley through Twin Falls to Jerome..."
|
||||
- Name infrastructure nodes by long name: "Mount Harrison Router, Indian Springs Router, Two Towers, and North Shoshone relay provide backbone coverage"
|
||||
- Give avg gateways AND explain what it means: "nodes reach an average of 5.7 gateways — excellent redundancy"
|
||||
- Identify single-gateway nodes as risks: "28 nodes in the Treasure Valley only reach 1 gateway"
|
||||
- For "where do we need more infrastructure" — name the geographic area, explain how many nodes are underserved
|
||||
- Do NOT recommend infrastructure for "Unlocated" nodes — those have no known position
|
||||
- Reference geographic areas by local name from the region config
|
||||
- Name infrastructure nodes by long name
|
||||
- Give avg gateways AND explain meaning
|
||||
- Identify single-gateway nodes as risks
|
||||
- For "where do we need infrastructure" — name the geographic area, not "Unknown"
|
||||
- Do NOT recommend infrastructure for Unlocated nodes
|
||||
|
||||
ANSWERING NODE QUESTIONS:
|
||||
- Include: hardware model, role, region with local name ("in the Magic Valley near Twin Falls"), battery status, channel utilization, TX airtime, coverage (X/Y gateways), neighbor count, recent traffic with packet breakdown
|
||||
- Name neighbors by long name
|
||||
- Compare metrics to thresholds: "18.5% channel utilization is moderate — approaching the 20% caution threshold"
|
||||
- If the node has environmental sensors, include readings
|
||||
- Include: hardware, role, region with local name, battery, channel util, TX airtime, coverage, neighbors, traffic breakdown
|
||||
- Compare metrics to thresholds
|
||||
|
||||
ANSWERING MESH OVERVIEW QUESTIONS:
|
||||
- Lead with composite score and tier
|
||||
- Break down all 5 pillars with actual scores
|
||||
- Highlight problem areas by geographic/local name
|
||||
- Include top senders, coverage gaps, battery warnings by name
|
||||
- Include traffic trends and sensor data if available
|
||||
ANSWERING MESH OVERVIEW:
|
||||
- Lead with composite score and 5 pillars
|
||||
- Break down by region using local names
|
||||
- Highlight problems with specific nodes/areas
|
||||
|
||||
ABOUT UNLOCATED NODES:
|
||||
- About 280 nodes have no GPS position and no neighbor data
|
||||
- These are stale, transient, or from sources that don't track position
|
||||
- Do NOT recommend infrastructure for "Unknown" or "Unlocated" areas
|
||||
- When asked about coverage gaps, focus ONLY on located regions
|
||||
- If asked about unlocated nodes specifically, explain they're nodes without position data that can't be placed on the map
|
||||
- Nodes with no GPS and no neighbor data cannot be placed on the map
|
||||
- Do NOT recommend infrastructure for these areas
|
||||
- Focus coverage gaps on located regions only
|
||||
|
||||
IMPORTANT: Do NOT lump different regions together just because they share directional words. Each region is a distinct geographic area.
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -631,6 +603,21 @@ class MessageRouter:
|
|||
# Add mesh awareness instructions
|
||||
system_prompt += _MESH_AWARENESS_PROMPT
|
||||
|
||||
# Build region geography from config dynamically
|
||||
if self.config.mesh_intelligence and self.config.mesh_intelligence.regions:
|
||||
geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"]
|
||||
for region in self.config.mesh_intelligence.regions:
|
||||
local = getattr(region, "local_name", "") or ""
|
||||
local_str = f' "{local}"' if local else ""
|
||||
desc = getattr(region, "description", "") or ""
|
||||
desc_str = f" — {desc}" if desc else ""
|
||||
aliases = getattr(region, "aliases", []) or []
|
||||
alias_str = ""
|
||||
if aliases:
|
||||
alias_str = f'\n People may call this: {", ".join(aliases)}'
|
||||
geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}")
|
||||
system_prompt += "\n".join(geo_lines)
|
||||
|
||||
# Update mesh context tracking
|
||||
self._update_user_mesh_context(
|
||||
message.sender_id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue