mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +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
|
@dataclass
|
||||||
class RegionAnchor:
|
class RegionAnchor:
|
||||||
"""A fixed region anchor point."""
|
"""A fixed region anchor point with geographic context."""
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
lon: 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
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,8 @@ class MeshAI:
|
||||||
# Mesh reporter (for LLM prompt injection and commands)
|
# Mesh reporter (for LLM prompt injection and commands)
|
||||||
if self.health_engine and self.data_store:
|
if self.health_engine and self.data_store:
|
||||||
from .mesh_reporter import MeshReporter
|
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")
|
logger.info("Mesh reporter enabled")
|
||||||
else:
|
else:
|
||||||
self.mesh_reporter = None
|
self.mesh_reporter = None
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ from .sources.meshview import MeshviewSource
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 enum mapping (integer -> string)
|
||||||
MESHTASTIC_ROLE_MAP = {
|
MESHTASTIC_ROLE_MAP = {
|
||||||
0: "CLIENT",
|
0: "CLIENT",
|
||||||
|
|
@ -351,6 +354,31 @@ class MeshDataStore:
|
||||||
# Source Management
|
# 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:
|
def refresh(self) -> bool:
|
||||||
"""Refresh data from all sources if interval has elapsed.
|
"""Refresh data from all sources if interval has elapsed.
|
||||||
|
|
||||||
|
|
@ -403,6 +431,7 @@ class MeshDataStore:
|
||||||
|
|
||||||
if refreshed > 0:
|
if refreshed > 0:
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
|
self._purge_stale_nodes() # Remove nodes not heard in 7+ days
|
||||||
self._store_snapshot()
|
self._store_snapshot()
|
||||||
self._purge_old_data()
|
self._purge_old_data()
|
||||||
self._last_refresh = time.time()
|
self._last_refresh = time.time()
|
||||||
|
|
|
||||||
|
|
@ -292,37 +292,57 @@ class MeshHealthEngine:
|
||||||
else:
|
else:
|
||||||
unlocated.append(str(node_num))
|
unlocated.append(str(node_num))
|
||||||
|
|
||||||
# Build neighbor map from edges
|
# Build BIDIRECTIONAL neighbor map from ALL sources:
|
||||||
# First, create a mapping from numeric node_id to hex id
|
# 1. Each node's own neighbor list (from NeighborInfo packets)
|
||||||
numeric_to_hex: dict[str, str] = {}
|
# 2. REVERSE: if A lists B as neighbor, B also sees A
|
||||||
for node in all_nodes:
|
# 3. Edges from traceroutes and other connections
|
||||||
hex_id = node.get("id")
|
all_neighbor_map: dict[int, set[int]] = {}
|
||||||
num_id = node.get("node_id")
|
|
||||||
if hex_id and num_id:
|
|
||||||
numeric_to_hex[str(num_id)] = str(hex_id)
|
|
||||||
|
|
||||||
|
# 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()
|
all_edges = source_manager.get_all_edges()
|
||||||
neighbors: dict[str, set[str]] = {}
|
|
||||||
for edge in all_edges:
|
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")
|
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")
|
to_raw = edge.get("to") or edge.get("to_node") or edge.get("target")
|
||||||
if not from_raw or not to_raw:
|
if not from_raw or not to_raw:
|
||||||
continue
|
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
|
# Second pass: Assign unlocated nodes based on BIDIRECTIONAL neighbor map
|
||||||
from_id = numeric_to_hex.get(str(from_raw), str(from_raw))
|
# This catches nodes that OTHER nodes list as neighbors
|
||||||
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
|
|
||||||
max_iterations = 10
|
max_iterations = 10
|
||||||
for _ in range(max_iterations):
|
for _ in range(max_iterations):
|
||||||
newly_assigned = []
|
newly_assigned = []
|
||||||
|
|
@ -337,19 +357,11 @@ class MeshHealthEngine:
|
||||||
if node.region:
|
if node.region:
|
||||||
continue # Already assigned
|
continue # Already assigned
|
||||||
|
|
||||||
# Count neighbor regions
|
# Use the BIDIRECTIONAL neighbor map
|
||||||
neighbor_ids = neighbors.get(node_id_str, set())
|
neighbor_nums = all_neighbor_map.get(node_num, set())
|
||||||
region_counts: dict[str, int] = {}
|
region_counts: dict[str, int] = {}
|
||||||
for nid in neighbor_ids:
|
for neighbor_num in neighbor_nums:
|
||||||
# Convert string ID to int for nodes lookup
|
neighbor_node = nodes.get(neighbor_num)
|
||||||
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)
|
|
||||||
if neighbor_node and neighbor_node.region:
|
if neighbor_node and neighbor_node.region:
|
||||||
r = neighbor_node.region
|
r = neighbor_node.region
|
||||||
region_counts[r] = region_counts.get(r, 0) + 1
|
region_counts[r] = region_counts.get(r, 0) + 1
|
||||||
|
|
|
||||||
|
|
@ -36,21 +36,6 @@ PORTNUM_DISPLAY = {
|
||||||
"ATAK_FORWARDER": "ATAK",
|
"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:
|
def _clean_portnum(portnum: str) -> str:
|
||||||
"""Convert raw portnum to display name."""
|
"""Convert raw portnum to display name."""
|
||||||
return PORTNUM_DISPLAY.get(portnum, portnum.replace("_APP", "").replace("_", " ").title())
|
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:
|
class MeshReporter:
|
||||||
"""Builds text blocks for mesh health prompt injection."""
|
"""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.
|
"""Initialize reporter.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
health_engine: MeshHealthEngine instance
|
health_engine: MeshHealthEngine instance
|
||||||
data_store: MeshDataStore instance
|
data_store: MeshDataStore instance
|
||||||
|
region_configs: Optional list of RegionAnchor configs for local names
|
||||||
"""
|
"""
|
||||||
self.health_engine = health_engine
|
self.health_engine = health_engine
|
||||||
self.data_store = data_store
|
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:
|
def build_tier1_summary(self) -> str:
|
||||||
"""Build compact mesh summary for LLM injection (~500-800 tokens).
|
"""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")
|
lines.append(f"MQTT Uplinks: {health.uplink_node_count} nodes")
|
||||||
|
|
||||||
# Coverage by region - show geographic breakdown
|
# 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]
|
nodes_with_gw = [n for n in all_nodes if n.avg_gateways is not None]
|
||||||
if nodes_with_gw:
|
if nodes_with_gw:
|
||||||
total_sources = len(self.data_store._sources)
|
total_sources = len(self.data_store._sources)
|
||||||
|
|
@ -306,7 +306,7 @@ class MeshReporter:
|
||||||
|
|
||||||
region_coverage = {}
|
region_coverage = {}
|
||||||
for n in nodes_with_gw:
|
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"
|
region = health_node.region if health_node else "Unlocated"
|
||||||
if not region:
|
if not region:
|
||||||
region = "Unlocated"
|
region = "Unlocated"
|
||||||
|
|
@ -323,7 +323,7 @@ class MeshReporter:
|
||||||
single = sum(1 for c in counts if c <= 1.0)
|
single = sum(1 for c in counts if c <= 1.0)
|
||||||
flag = " !!" if avg < 2.0 else ""
|
flag = " !!" if avg < 2.0 else ""
|
||||||
single_str = f" ({single} 1-gw)" if single > 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 ""
|
context_str = f" ({context})" if context else ""
|
||||||
lines.append(f" {region}{context_str}: {len(counts)} nodes, {avg:.1f} avg{single_str}{flag}")
|
lines.append(f" {region}{context_str}: {len(counts)} nodes, {avg:.1f} avg{single_str}{flag}")
|
||||||
# Show unlocated as informational (no coverage recommendations for these)
|
# Show unlocated as informational (no coverage recommendations for these)
|
||||||
|
|
@ -344,7 +344,7 @@ class MeshReporter:
|
||||||
rs = region.score
|
rs = region.score
|
||||||
flag = _tier_flag(rs.tier)
|
flag = _tier_flag(rs.tier)
|
||||||
infra_str = f"{rs.infra_online}/{rs.infra_total} infra"
|
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 ""
|
context_str = f" ({context})" if context else ""
|
||||||
lines.append(
|
lines.append(
|
||||||
f" {region.name}{context_str}: {rs.composite:.0f}/100 - {infra_str}, {rs.util_percent:.0f}% util{flag}"
|
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."
|
return f"REGION DETAIL: {region_name}\nRegion not found."
|
||||||
|
|
||||||
rs = region.score
|
rs = region.score
|
||||||
context = REGION_CONTEXT.get(region.name, "")
|
context = self._region_context(region.name)
|
||||||
context_str = f" — {context}" if context else ""
|
context_str = f" — {context}" if context else ""
|
||||||
lines = [
|
lines = [
|
||||||
f"REGION DETAIL: {region.name}{context_str}",
|
f"REGION DETAIL: {region.name}{context_str}",
|
||||||
|
|
|
||||||
|
|
@ -123,70 +123,42 @@ _CITY_TO_REGION = {
|
||||||
"cedar city": "Southern UT",
|
"cedar city": "Southern UT",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mesh awareness instruction for LLM
|
||||||
# Mesh awareness instruction for LLM
|
# Mesh awareness instruction for LLM
|
||||||
_MESH_AWARENESS_PROMPT = """
|
_MESH_AWARENESS_PROMPT = """
|
||||||
MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions):
|
MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions):
|
||||||
|
|
||||||
RESPONSE STYLE:
|
RESPONSE STYLE:
|
||||||
- Give DETAILED, data-driven responses with actual numbers
|
- Give DETAILED, data-driven responses with actual numbers
|
||||||
- Talk in GEOGRAPHIC terms — reference cities, valleys, corridors people know
|
- Talk in GEOGRAPHIC terms — use the local names and cities from REGION GEOGRAPHY below
|
||||||
- Name specific infrastructure nodes by their long name, not just shortnames
|
- Name specific infrastructure nodes by their long name
|
||||||
- Include scores, percentages, node counts, battery levels, gateway counts
|
- Include scores, percentages, node counts, battery levels, gateway counts
|
||||||
- You CAN use 3-5 messages if needed — LoRa chunking handles splitting
|
- You CAN use 3-5 messages if needed — LoRa chunking handles splitting
|
||||||
- No markdown formatting — plain text only
|
- 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:
|
ANSWERING COVERAGE QUESTIONS:
|
||||||
- Reference the geographic area by local name: "Coverage across the Magic Valley from Burley through Twin Falls to Jerome..."
|
- Reference geographic areas by local name from the region config
|
||||||
- Name infrastructure nodes by long name: "Mount Harrison Router, Indian Springs Router, Two Towers, and North Shoshone relay provide backbone coverage"
|
- Name infrastructure nodes by long name
|
||||||
- Give avg gateways AND explain what it means: "nodes reach an average of 5.7 gateways — excellent redundancy"
|
- Give avg gateways AND explain meaning
|
||||||
- Identify single-gateway nodes as risks: "28 nodes in the Treasure Valley only reach 1 gateway"
|
- Identify single-gateway nodes as risks
|
||||||
- For "where do we need more infrastructure" — name the geographic area, explain how many nodes are underserved
|
- For "where do we need infrastructure" — name the geographic area, not "Unknown"
|
||||||
- Do NOT recommend infrastructure for "Unlocated" nodes — those have no known position
|
- Do NOT recommend infrastructure for Unlocated nodes
|
||||||
|
|
||||||
ANSWERING NODE QUESTIONS:
|
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
|
- Include: hardware, role, region with local name, battery, channel util, TX airtime, coverage, neighbors, traffic breakdown
|
||||||
- Name neighbors by long name
|
- Compare metrics to thresholds
|
||||||
- Compare metrics to thresholds: "18.5% channel utilization is moderate — approaching the 20% caution threshold"
|
|
||||||
- If the node has environmental sensors, include readings
|
|
||||||
|
|
||||||
ANSWERING MESH OVERVIEW QUESTIONS:
|
ANSWERING MESH OVERVIEW:
|
||||||
- Lead with composite score and tier
|
- Lead with composite score and 5 pillars
|
||||||
- Break down all 5 pillars with actual scores
|
- Break down by region using local names
|
||||||
- Highlight problem areas by geographic/local name
|
- Highlight problems with specific nodes/areas
|
||||||
- Include top senders, coverage gaps, battery warnings by name
|
|
||||||
- Include traffic trends and sensor data if available
|
|
||||||
|
|
||||||
ABOUT UNLOCATED NODES:
|
ABOUT UNLOCATED NODES:
|
||||||
- About 280 nodes have no GPS position and no neighbor data
|
- Nodes with no GPS and no neighbor data cannot be placed on the map
|
||||||
- These are stale, transient, or from sources that don't track position
|
- Do NOT recommend infrastructure for these areas
|
||||||
- Do NOT recommend infrastructure for "Unknown" or "Unlocated" areas
|
- Focus coverage gaps on located regions only
|
||||||
- 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
|
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
|
# Add mesh awareness instructions
|
||||||
system_prompt += _MESH_AWARENESS_PROMPT
|
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
|
# Update mesh context tracking
|
||||||
self._update_user_mesh_context(
|
self._update_user_mesh_context(
|
||||||
message.sender_id,
|
message.sender_id,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue