feat: !health returns list for separate LoRa messages, partial name distance matching

- build_lora_compact returns list[str] instead of str
- Each line is a separate LoRa message (no chunking needed)
- main.py handles list responses from commands
- _try_compute_distance supports partial names (TVM Pearl → TVM Pearl Relay)
- Ambiguous names detected (TVM → asks which node)
- Max message size: 54 bytes (well under 228 byte limit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-06 00:11:57 +00:00
commit 7670b7c0b9
3 changed files with 81 additions and 38 deletions

View file

@ -344,15 +344,17 @@ class MeshAI:
# Determine response # Determine response
if result.route_type == RouteType.COMMAND: if result.route_type == RouteType.COMMAND:
# Chunk command output same as LLM responses if isinstance(result.response, list):
# Command returned pre-split messages — send directly
messages = result.response
else:
# Single string — chunk it
from .chunker import chunk_response from .chunker import chunk_response
raw = result.response if isinstance(result.response, str) else str(result.response)
messages, remaining = chunk_response( messages, remaining = chunk_response(
raw, result.response,
max_chars=self.config.response.max_length, max_chars=self.config.response.max_length,
max_messages=self.config.response.max_messages, max_messages=self.config.response.max_messages,
) )
# Store remaining for continuation
if remaining: if remaining:
self.router.continuations.store(message.sender_id, remaining) self.router.continuations.store(message.sender_id, remaining)
elif result.route_type == RouteType.LLM: elif result.route_type == RouteType.LLM:

View file

@ -1503,24 +1503,24 @@ class MeshReporter:
return recs return recs
def build_lora_compact(self, scope: str, scope_value: str = None) -> str: def build_lora_compact(self, scope: str, scope_value: str = None) -> list[str]:
"""Build LoRa-optimized summary with personality for !health command.""" """Build LoRa-optimized summary. Returns list of messages (one per line)."""
health = self.health_engine.mesh_health health = self.health_engine.mesh_health
if not health: if not health:
return "📡 No mesh data yet." return ["📡 No mesh data yet."]
if scope == "region" and scope_value: if scope == "region" and scope_value:
region = self._find_region(scope_value) region = self._find_region(scope_value)
if not region: if not region:
return f"Region '{scope_value}' not found." return [f"Region '{scope_value}' not found."]
return self._region_lora_compact(region) return self._region_lora_compact(region)
if scope == "node" and scope_value: if scope == "node" and scope_value:
return self.build_node_compact(scope_value) return [self.build_node_compact(scope_value)]
s = health.score s = health.score
# Color dot — no numbers shown # Color dot — no numbers
if s.composite >= 100: if s.composite >= 100:
dot = "🔵" dot = "🔵"
mood = "perfect" mood = "perfect"
@ -1534,8 +1534,7 @@ class MeshReporter:
dot = "🔴" dot = "🔴"
mood = "critical" mood = "critical"
# Each line ends with period so chunker sends each as separate message lines = [f"📡 Mesh {dot} {mood}"]
lines = [f"📡 Mesh {dot} {mood}."]
offline_infra = [n for n in health.nodes.values() if n.is_infrastructure and not n.is_online] offline_infra = [n for n in health.nodes.values() if n.is_infrastructure and not n.is_online]
if offline_infra: if offline_infra:
@ -1543,10 +1542,10 @@ class MeshReporter:
(n.long_name or n.short_name or str(n.node_num)) for n in offline_infra[:3] (n.long_name or n.short_name or str(n.node_num)) for n in offline_infra[:3]
) )
more = f" +{len(offline_infra)-3}" if len(offline_infra) > 3 else "" more = f" +{len(offline_infra)-3}" if len(offline_infra) > 3 else ""
lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up.") lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up")
lines.append(f"❌ Down: {offline_names}{more}.") lines.append(f"❌ Down: {offline_names}{more}")
else: else:
lines.append(f"🏗️ All {s.infra_total} routers up ✅.") lines.append(f"🏗️ All {s.infra_total} routers up ✅")
nodes_with_gw = [n for n in health.nodes.values() if n.avg_gateways is not None] nodes_with_gw = [n for n in health.nodes.values() if n.avg_gateways is not None]
if nodes_with_gw: if nodes_with_gw:
@ -1554,30 +1553,30 @@ class MeshReporter:
full = sum(1 for n in nodes_with_gw if n.avg_gateways >= total_sources) full = sum(1 for n in nodes_with_gw if n.avg_gateways >= total_sources)
single = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.0) single = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.0)
if single > 0: if single > 0:
lines.append(f"📶 {full} nodes full coverage, {single} on thin ice with 1 gw.") lines.append(f"📶 {full} nodes full coverage, {single} on thin ice with 1 gw")
else: else:
lines.append(f"📶 {full} nodes full coverage ✅.") lines.append(f"📶 {full} nodes full coverage ✅")
high_util = [n for n in health.nodes.values() high_util = [n for n in health.nodes.values()
if n.channel_utilization is not None and n.channel_utilization > 15] if n.channel_utilization is not None and n.channel_utilization > 15]
if high_util: if high_util:
worst = max(high_util, key=lambda n: n.channel_utilization) worst = max(high_util, key=lambda n: n.channel_utilization)
worst_name = worst.long_name or worst.short_name or str(worst.node_num) worst_name = worst.long_name or worst.short_name or str(worst.node_num)
lines.append(f"🔥 {worst_name} running hot at {worst.channel_utilization:.0f}% util.") lines.append(f"🔥 {worst_name} running hot at {worst.channel_utilization:.0f}% util")
infra_nodes = [n for n in health.nodes.values() if n.is_infrastructure] infra_nodes = [n for n in health.nodes.values() if n.is_infrastructure]
bat_low = [n for n in infra_nodes if n.battery_percent is not None and 0 < n.battery_percent < 20] bat_low = [n for n in infra_nodes if n.battery_percent is not None and 0 < n.battery_percent < 20]
if bat_low: if bat_low:
low_names = ", ".join(n.long_name or n.short_name or str(n.node_num) for n in bat_low) low_names = ", ".join(n.long_name or n.short_name or str(n.node_num) for n in bat_low)
lines.append(f"🔋 ⚠️ {low_names} low battery.") lines.append(f"🔋 ⚠️ {low_names} low battery")
else: else:
lines.append(f"🔋 All infra powered up ✅.") lines.append(f"🔋 All infra powered up ✅")
env_nodes = [n for n in health.nodes.values() if n.has_environment_sensor] env_nodes = [n for n in health.nodes.values() if n.has_environment_sensor]
if env_nodes: if env_nodes:
temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)] temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)]
if temps: if temps:
lines.append(f"🌡️ {min(temps):.0f}{max(temps):.0f}°C across {len(env_nodes)} sensors.") lines.append(f"🌡️ {min(temps):.0f}{max(temps):.0f}°C across {len(env_nodes)} sensors")
region_parts = [] region_parts = []
for region in health.regions: for region in health.regions:
@ -1595,12 +1594,12 @@ class MeshReporter:
else: else:
region_parts.append(f"{name} 🔴") region_parts.append(f"{name} 🔴")
if region_parts: if region_parts:
lines.append(" | ".join(region_parts) + ".") lines.append(" | ".join(region_parts))
return "\n".join(lines) return lines
def _region_lora_compact(self, region) -> str: def _region_lora_compact(self, region) -> list[str]:
"""Compact region display for !region [name].""" """Compact region display. Returns list of messages."""
rs = region.score rs = region.score
context = self._region_context(region.name) context = self._region_context(region.name)
name = context.split("(")[0].strip() if context else region.name name = context.split("(")[0].strip() if context else region.name
@ -1614,8 +1613,8 @@ class MeshReporter:
else: else:
dot = "🔴" dot = "🔴"
lines = [f"📡 {name} {dot}."] lines = [f"📡 {name} {dot}"]
lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | 📡 {rs.util_percent:.0f}% util.") lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | {rs.util_percent:.0f}% util")
offline = [] offline = []
for nid_str in region.node_ids: for nid_str in region.node_ids:
@ -1627,9 +1626,9 @@ class MeshReporter:
if node and node.is_infrastructure and not node.is_online: if node and node.is_infrastructure and not node.is_online:
offline.append(node.long_name or node.short_name or str(nid)) offline.append(node.long_name or node.short_name or str(nid))
if offline: if offline:
lines.append(f"❌ Down: {', '.join(offline)}.") lines.append(f"❌ Down: {', '.join(offline)}")
return "\n".join(lines) return lines
def list_regions_compact(self) -> str: def list_regions_compact(self) -> str:
"""List all regions with scores for !region command.""" """List all regions with scores for !region command."""

View file

@ -476,13 +476,21 @@ class MessageRouter:
query_lower = query.lower() query_lower = query.lower()
# Build name -> node lookup # Build name -> node lookup (include partial long_name matches)
node_names = {} node_names = {}
for node in health.nodes.values(): for node in health.nodes.values():
if node.short_name: if node.short_name:
node_names[node.short_name.lower()] = node node_names[node.short_name.lower()] = node
if node.long_name: if node.long_name:
node_names[node.long_name.lower()] = node full = node.long_name.lower()
node_names[full] = node
# Add partial matches: "TVM Pearl Relay" also matches "TVM Pearl"
words = full.split()
if len(words) >= 2:
for i in range(2, len(words) + 1):
partial = " ".join(words[:i])
if partial not in node_names:
node_names[partial] = node
# AIDA aliases # AIDA aliases
aida_node = health.nodes.get(0x27780c47) aida_node = health.nodes.get(0x27780c47)
@ -492,6 +500,7 @@ class MessageRouter:
# Find mentioned nodes (longest names first) # Find mentioned nodes (longest names first)
found_nodes = [] found_nodes = []
for name in sorted(node_names.keys(), key=len, reverse=True): for name in sorted(node_names.keys(), key=len, reverse=True):
if name in query_lower and len(name) >= 2: if name in query_lower and len(name) >= 2:
node = node_names[name] node = node_names[name]
@ -500,6 +509,38 @@ class MessageRouter:
if len(found_nodes) >= 2: if len(found_nodes) >= 2:
break break
# If we only found one or zero nodes, check for ambiguous short terms
if len(found_nodes) < 2:
query_words = query_lower.replace("?", "").replace("!", "").split()
candidate_terms = list(query_words)
for i in range(len(query_words) - 1):
candidate_terms.append(f"{query_words[i]} {query_words[i+1]}")
skip_words = {"how", "far", "is", "from", "the", "to", "and", "between", "what",
"distance", "away", "are", "apart", "tell", "me", "about", "a", "an"}
for term in candidate_terms:
if term in skip_words or len(term) < 2:
continue
matches = []
seen_nums = set()
for node in health.nodes.values():
if node.node_num in seen_nums:
continue
name_lower = (node.long_name or "").lower()
short_lower = (node.short_name or "").lower()
if term in name_lower or term == short_lower:
matches.append(node)
seen_nums.add(node.node_num)
if len(matches) > 1:
names = [f" - {n.long_name or n.short_name} ({n.short_name})"
for n in matches[:6]]
return (
f"AMBIGUOUS: '{term}' matches multiple nodes. "
f"Ask the user which one they mean:\n" + "\n".join(names)
)
if len(found_nodes) == 2: if len(found_nodes) == 2:
return self.mesh_reporter.build_distance( return self.mesh_reporter.build_distance(
str(found_nodes[0].node_num), str(found_nodes[0].node_num),
@ -513,6 +554,7 @@ class MessageRouter:
return "" return ""
async def generate_llm_response(self, message: MeshMessage, query: str) -> str: async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
"""Generate LLM response for a message. """Generate LLM response for a message.