From df197cc3951955116c3c1edbf87c324333ecacf5 Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Mon, 4 May 2026 20:56:54 +0000 Subject: [PATCH] fix: Scope detection, follow-up context, utilization calculation, duplicate disambiguation - router.py: Fixed region scope detection to match longest region name first - router.py: Added region abbreviations (SCID, SWID, etc.) for quick matching - router.py: Added city name mapping (Boise -> South Western ID, etc.) - router.py: Fixed node longname matching (case-insensitive substring) - router.py: Added follow-up message context tracking (_user_mesh_context) - router.py: Added more mesh keywords (noisy, traffic, packets, etc.) - mesh_reporter.py: Added disambiguation for duplicate shortnames in region detail - mesh_health.py: Added util_data_available flag to track packet data presence - mesh_health.py: Passes has_packet_data through score computation Co-Authored-By: Claude Opus 4.5 --- meshai/mesh_health.py | 72 ++++++++---- meshai/mesh_reporter.py | 34 +++++- meshai/router.py | 244 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 300 insertions(+), 50 deletions(-) diff --git a/meshai/mesh_health.py b/meshai/mesh_health.py index a9c9aa4..4cdac09 100644 --- a/meshai/mesh_health.py +++ b/meshai/mesh_health.py @@ -59,6 +59,9 @@ class HealthScore: battery_warnings: int = 0 solar_index: float = 100.0 + # Flag to indicate if utilization data is available + util_data_available: bool = False + @property def composite(self) -> float: """Calculate weighted composite score.""" @@ -251,7 +254,7 @@ class MeshHealthEngine: all_telemetry = source_manager.get_all_telemetry() all_packets = [] - # Get packets from MeshMonitor sources + # Get packets from MeshMonitor sources (if available) for status in source_manager.get_status(): if status["type"] == "meshmonitor": src = source_manager.get_source(status["name"]) @@ -261,6 +264,9 @@ class MeshHealthEngine: tagged["_source"] = status["name"] all_packets.append(tagged) + # Track if we have packet data for utilization calculation + has_packet_data = len(all_packets) > 0 + # Build node health records nodes: dict[str, NodeHealth] = {} for node in all_nodes: @@ -486,10 +492,10 @@ class MeshHealthEngine: if n["id"] in nodes: nodes[n["id"]].locality = locality.name - # Compute scores at each level - self._compute_locality_scores(regions, nodes) - self._compute_region_scores(regions, nodes) - mesh_score = self._compute_mesh_score(regions, nodes) + # Compute scores at each level (pass packet data availability flag) + self._compute_locality_scores(regions, nodes, has_packet_data) + self._compute_region_scores(regions, nodes, has_packet_data) + mesh_score = self._compute_mesh_score(regions, nodes, has_packet_data) # Build result mesh_health = MeshHealth( @@ -512,37 +518,45 @@ class MeshHealthEngine: self, regions: list[RegionHealth], nodes: dict[str, NodeHealth], + has_packet_data: bool = False, ) -> None: """Compute health scores for each locality.""" for region in regions: for locality in region.localities: locality_nodes = [nodes[nid] for nid in locality.node_ids if nid in nodes] - locality.score = self._compute_node_group_score(locality_nodes) + locality.score = self._compute_node_group_score(locality_nodes, has_packet_data) def _compute_region_scores( self, regions: list[RegionHealth], nodes: dict[str, NodeHealth], + has_packet_data: bool = False, ) -> None: """Compute health scores for each region.""" for region in regions: region_nodes = [nodes[nid] for nid in region.node_ids if nid in nodes] - region.score = self._compute_node_group_score(region_nodes) + region.score = self._compute_node_group_score(region_nodes, has_packet_data) def _compute_mesh_score( self, regions: list[RegionHealth], nodes: dict[str, NodeHealth], + has_packet_data: bool = False, ) -> HealthScore: """Compute mesh-wide health score.""" all_nodes = list(nodes.values()) - return self._compute_node_group_score(all_nodes) + return self._compute_node_group_score(all_nodes, has_packet_data) - def _compute_node_group_score(self, node_list: list[NodeHealth]) -> HealthScore: + def _compute_node_group_score( + self, + node_list: list[NodeHealth], + has_packet_data: bool = False, + ) -> HealthScore: """Compute health score for a group of nodes. Args: node_list: List of NodeHealth objects + has_packet_data: Whether packet data is available for utilization calc Returns: HealthScore for the group @@ -560,24 +574,30 @@ class MeshHealthEngine: else: infra_score = 100.0 # No infrastructure = not penalized - # Channel utilization (simplified - based on packet counts) - total_packets = sum(n.packet_count_24h for n in node_list) - baseline = len(node_list) * 500 - if baseline > 0: - util_percent = (total_packets / baseline) * 15 - else: - util_percent = 0 + # Channel utilization (based on packet counts if available) + if has_packet_data: + total_packets = sum(n.packet_count_24h for n in node_list) + baseline = len(node_list) * 500 + if baseline > 0: + util_percent = (total_packets / baseline) * 15 + else: + util_percent = 0 - if util_percent < UTIL_HEALTHY: - util_score = 100.0 - elif util_percent < UTIL_CAUTION: - util_score = 75.0 - elif util_percent < UTIL_WARNING: - util_score = 50.0 - elif util_percent < UTIL_UNHEALTHY: - util_score = 25.0 + if util_percent < UTIL_HEALTHY: + util_score = 100.0 + elif util_percent < UTIL_CAUTION: + util_score = 75.0 + elif util_percent < UTIL_WARNING: + util_score = 50.0 + elif util_percent < UTIL_UNHEALTHY: + util_score = 25.0 + else: + util_score = 0.0 else: - util_score = 0.0 + # No packet data available - assume healthy utilization + # This prevents penalizing the score when we simply don't have data + util_percent = 0.0 + util_score = 100.0 # Node behavior (flagged nodes) flagged = [n for n in node_list if n.non_text_packets > self.packet_threshold] @@ -622,6 +642,7 @@ class MeshHealthEngine: flagged_nodes=flagged_count, battery_warnings=battery_warnings, solar_index=solar_index, + util_data_available=has_packet_data, ) def get_region(self, name: str) -> Optional[RegionHealth]: @@ -675,3 +696,4 @@ class MeshHealthEngine: n for n in self._mesh_health.nodes.values() if n.battery_percent is not None and n.battery_percent < self.battery_warning_percent ] + diff --git a/meshai/mesh_reporter.py b/meshai/mesh_reporter.py index 8b7e74c..3203f39 100644 --- a/meshai/mesh_reporter.py +++ b/meshai/mesh_reporter.py @@ -70,7 +70,10 @@ class MeshReporter: # Utilization util = score.util_percent - if util < 15: + util_data_available = getattr(score, 'util_data_available', False) + if not util_data_available: + util_label = "N/A - no packet data" + elif util < 15: util_label = "Low" elif util < 20: util_label = "Moderate" @@ -183,16 +186,36 @@ class MeshReporter: f"Infrastructure ({rs.infra_online}/{rs.infra_total}):", ] - # List infrastructure nodes + # Collect infrastructure nodes and detect duplicate shortnames + infra_nodes = [] for nid in region.node_ids: node = health.nodes.get(nid) - if not node or not node.is_infrastructure: - continue + if node and node.is_infrastructure: + infra_nodes.append((nid, node)) + + # Count shortname occurrences to detect duplicates + shortname_counts: dict[str, int] = {} + for nid, node in infra_nodes: + sn = node.short_name or nid[:4] + shortname_counts[sn] = shortname_counts.get(sn, 0) + 1 + + # List infrastructure nodes with disambiguation for duplicates + for nid, node in infra_nodes: status = "+" if node.is_online else "X" age = _format_age(node.last_seen) bat = f", bat {node.battery_percent:.0f}%" if node.battery_percent else "" role = node.role or "ROUTER" - lines.append(f" {status} {node.short_name or nid[:4]} ({role}) - last seen {age}{bat}") + sn = node.short_name or nid[:4] + + # Disambiguate duplicate shortnames with node ID suffix + if shortname_counts.get(sn, 0) > 1: + # Use last 4 chars of node_id as disambiguator + disambig = f", !{nid[-8:]}" if len(nid) >= 8 else f", {nid}" + name_str = f"{sn} ({role}{disambig})" + else: + name_str = f"{sn} ({role})" + + lines.append(f" {status} {name_str} - last seen {age}{bat}") if not node.is_online: lines[-1] += " <- OFFLINE" @@ -543,3 +566,4 @@ class MeshReporter: lines.append(f" {region.name}: {s.composite:.0f}/100{flag}") return "\n".join(lines) + diff --git a/meshai/router.py b/meshai/router.py index 4bc5315..baf0b22 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -60,6 +60,9 @@ _MESH_KEYWORDS = { "hop", "optimize", "optimization", "infrastructure", "infra", "relay", "repeater", "region", "locality", "congestion", "collision", "airtime", "telemetry", "firmware", "subscribe", "alert", "snr", "rssi", + # Additional keywords for better detection + "noisy", "noisiest", "traffic", "packets", "power", "routers", + "repeaters", "regions", "localities", "score", "status", } # Phrases that indicate mesh questions @@ -73,8 +76,53 @@ _MESH_PHRASES = [ "node status", "network health", "mesh health", + "which node", + "which nodes", + "which infra", + "list nodes", + "list infra", + "tell me about", + "what about", + "how is", + "how are", ] +# City name to region mapping (hardcoded fallback) +_CITY_TO_REGION = { + # Idaho + "twin falls": "South Central ID", + "boise": "South Western ID", + "nampa": "South Western ID", + "meridian": "South Western ID", + "caldwell": "South Western ID", + "idaho falls": "South Eastern ID", + "pocatello": "South Eastern ID", + "coeur d'alene": "Northern ID", + "cda": "Northern ID", + "post falls": "Northern ID", + "moscow": "Northern ID", + "lewiston": "Northern ID", + "salmon": "Central ID", + "sun valley": "Central ID", + "ketchum": "Central ID", + # Utah + "ogden": "Northern UT", + "logan": "Northern UT", + "salt lake": "Central UT", + "salt lake city": "Central UT", + "slc": "Central UT", + "provo": "Central UT", + "orem": "Central UT", + "vernal": "Eastern UT", + "moab": "Eastern UT", + "price": "Eastern UT", + "tooele": "Western UT", + "wendover": "Western UT", + "st george": "Southern UT", + "st. george": "Southern UT", + "cedar city": "Southern UT", +} + # Mesh awareness instruction for LLM _MESH_AWARENESS_PROMPT = """ When the user asks about mesh health, network status, or optimization: @@ -88,6 +136,46 @@ When the user asks about mesh health, network status, or optimization: """ +def _build_region_abbreviations(region_names: list[str]) -> dict[str, str]: + """Build abbreviation to region name mapping. + + Generates abbreviations like: + - "South Central ID" -> "SCID", "SC-ID", "SC ID" + - "South Western ID" -> "SWID", "SW-ID", "SW ID" + + Args: + region_names: List of full region names + + Returns: + Dict mapping lowercase abbreviation to full region name + """ + abbrevs = {} + + for name in region_names: + parts = name.replace("???", "-").replace("???", "-").split() + if not parts: + continue + + # Get first letter of each word (uppercase) + initials = "".join(p[0].upper() for p in parts if p) + abbrevs[initials.lower()] = name + + # If last part is a state abbrev (2 chars), create variants + if len(parts) >= 2: + last = parts[-1] + if len(last) == 2 and last.isupper(): + # "South Central ID" -> prefix is "South Central" + prefix_parts = parts[:-1] + prefix_initials = "".join(p[0].upper() for p in prefix_parts) + + # SC-ID, SC ID, SCID variants + abbrevs[f"{prefix_initials.lower()}-{last.lower()}"] = name + abbrevs[f"{prefix_initials.lower()} {last.lower()}"] = name + abbrevs[f"{prefix_initials.lower()}{last.lower()}"] = name + + return abbrevs + + class MessageRouter: """Routes incoming messages to appropriate handlers.""" @@ -118,6 +206,17 @@ class MessageRouter: self.mesh_reporter = mesh_reporter self.continuations = ContinuationState(max_continuations=3) + # Per-user mesh context tracking for follow-up handling + # Maps user_id -> {"last_was_mesh": bool, "last_scope": (type, value), "non_mesh_count": int} + self._user_mesh_context: dict[str, dict] = {} + + # Build region abbreviation map + self._region_abbrevs: dict[str, str] = {} + if self.health_engine and self.health_engine.regions: + region_names = [r.name for r in self.health_engine.regions] + self._region_abbrevs = _build_region_abbreviations(region_names) + logger.debug(f"Built region abbreviations: {self._region_abbrevs}") + def should_respond(self, message: MeshMessage) -> bool: """Determine if we should respond to this message. @@ -241,38 +340,122 @@ class MessageRouter: """ msg_lower = message.lower() - # Check for node references + # === NODE MATCHING (check first - more specific) === if self.health_engine and self.health_engine.mesh_health: health = self.health_engine.mesh_health - # Look for node shortnames (4 chars, case-insensitive) + # 1. Exact shortname match (case-insensitive, word boundary) for node in health.nodes.values(): if node.short_name: - # Check if shortname appears as a word in message pattern = r'\b' + re.escape(node.short_name.lower()) + r'\b' if re.search(pattern, msg_lower): return ("node", node.short_name) - # Check longname substring - if node.long_name and node.long_name.lower() in msg_lower: - return ("node", node.short_name or node.node_id) + # 2. Longname substring match (case-insensitive) + for node in health.nodes.values(): + if node.long_name and len(node.long_name) > 3: + # Match significant portion of longname + if node.long_name.lower() in msg_lower: + return ("node", node.short_name or node.node_id) + # Also try matching without common suffixes like "Router", "Repeater" + clean_name = node.long_name.lower() + for suffix in [" router", " repeater", " relay", " base", " v2", " - g2"]: + clean_name = clean_name.replace(suffix, "") + if len(clean_name) > 4 and clean_name in msg_lower: + return ("node", node.short_name or node.node_id) - # Check for region references + # 3. NodeId hex match (with or without ! prefix) + hex_pattern = r'!?([0-9a-f]{8})' + hex_match = re.search(hex_pattern, msg_lower) + if hex_match: + hex_id = hex_match.group(1) + for nid, node in health.nodes.items(): + if hex_id in nid.lower(): + return ("node", node.short_name or nid) + + # 4. NodeNum decimal match + num_pattern = r'\b(\d{9,10})\b' + num_match = re.search(num_pattern, message) + if num_match: + node_num = int(num_match.group(1)) + hex_id = format(node_num, 'x') + for nid, node in health.nodes.items(): + if hex_id in nid.lower(): + return ("node", node.short_name or nid) + + # === REGION MATCHING === if self.health_engine: - for anchor in self.health_engine.regions: + # 1. Check abbreviations first (SCID, SWID, etc.) + for abbrev, region_name in self._region_abbrevs.items(): + # Match as word boundary + pattern = r'\b' + re.escape(abbrev) + r'\b' + if re.search(pattern, msg_lower): + return ("region", region_name) + + # 2. Check city names + for city, region_name in _CITY_TO_REGION.items(): + if city in msg_lower: + return ("region", region_name) + + # 3. Full region name matching (SORTED BY LENGTH - longest first) + regions_by_length = sorted( + self.health_engine.regions, + key=lambda r: len(r.name), + reverse=True + ) + + for anchor in regions_by_length: anchor_lower = anchor.name.lower() - # Check region name + # Check full region name if anchor_lower in msg_lower: return ("region", anchor.name) - # Check parts of region name (e.g., "wood river" matches "Wood River - ID") - parts = anchor_lower.replace("-", " ").replace("–", " ").split() - for part in parts: - if len(part) > 3 and part in msg_lower: - return ("region", anchor.name) + # 4. Partial region name matching (also longest first) + for anchor in regions_by_length: + anchor_lower = anchor.name.lower() + # Check significant parts of region name + # Split on common separators + parts = anchor_lower.replace("-", " ").replace("???", " ").replace("???", " ").split() + # Only match on significant words (>3 chars, not state abbrevs) + significant_parts = [p for p in parts if len(p) > 3] + + # Check if ALL significant parts appear in message + if significant_parts and all(p in msg_lower for p in significant_parts): + return ("region", anchor.name) return ("mesh", None) + def _get_user_mesh_context(self, user_id: str) -> dict: + """Get or create mesh context for a user.""" + if user_id not in self._user_mesh_context: + self._user_mesh_context[user_id] = { + "last_was_mesh": False, + "last_scope": ("mesh", None), + "non_mesh_count": 0, + } + return self._user_mesh_context[user_id] + + def _update_user_mesh_context( + self, + user_id: str, + is_mesh: bool, + scope: tuple[str, Optional[str]] = None, + ) -> None: + """Update mesh context tracking for a user.""" + ctx = self._get_user_mesh_context(user_id) + + if is_mesh: + ctx["last_was_mesh"] = True + ctx["non_mesh_count"] = 0 + if scope: + ctx["last_scope"] = scope + else: + ctx["non_mesh_count"] += 1 + # Reset after 2 consecutive non-mesh messages + if ctx["non_mesh_count"] >= 2: + ctx["last_was_mesh"] = False + ctx["last_scope"] = ("mesh", None) + async def generate_llm_response(self, message: MeshMessage, query: str) -> str: """Generate LLM response for a message. @@ -320,7 +503,7 @@ class MessageRouter: "\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same " "meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, " "traceroutes, security scanning, and auto-responder commands. Its trigger " - "commands are listed below — if someone asks what commands are available, " + "commands are listed below ??? if someone asks what commands are available, " "mention both yours and MeshMonitor's. If someone asks where to get " "MeshMonitor, direct them to github.com/Yeraze/meshmonitor" ) @@ -357,13 +540,23 @@ class MessageRouter: ) # 6. Mesh Intelligence (inject health data for mesh questions) - if ( - self.source_manager - and self.mesh_reporter - and self._is_mesh_question(query) - ): + user_ctx = self._get_user_mesh_context(message.sender_id) + is_direct_mesh_question = self._is_mesh_question(query) + is_followup = user_ctx["last_was_mesh"] and not is_direct_mesh_question + + should_inject_mesh = is_direct_mesh_question or is_followup + + if self.source_manager and self.mesh_reporter and should_inject_mesh: + # Detect scope from current message scope_type, scope_value = self._detect_mesh_scope(query) + # For follow-ups with no detected scope, use previous scope + if is_followup and scope_type == "mesh" and scope_value is None: + prev_scope = user_ctx.get("last_scope", ("mesh", None)) + if prev_scope[0] != "mesh" or prev_scope[1] is not None: + scope_type, scope_value = prev_scope + logger.debug(f"Using previous scope for follow-up: {scope_type}, {scope_value}") + # Always include Tier 1 summary for mesh questions tier1 = self.mesh_reporter.build_tier1_summary() system_prompt += "\n\n" + tier1 @@ -384,6 +577,16 @@ class MessageRouter: # Add mesh awareness instructions system_prompt += _MESH_AWARENESS_PROMPT + # Update mesh context tracking + self._update_user_mesh_context( + message.sender_id, + is_mesh=True, + scope=(scope_type, scope_value), + ) + else: + # Not a mesh question + self._update_user_mesh_context(message.sender_id, is_mesh=False) + # DEBUG: Log system prompt status logger.debug(f"System prompt length: {len(system_prompt)} chars") @@ -470,3 +673,4 @@ class MessageRouter: connector=self.connector, history=self.history, ) +