mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +02:00
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:
parent
525da64d85
commit
7670b7c0b9
3 changed files with 81 additions and 38 deletions
|
|
@ -344,15 +344,17 @@ class MeshAI:
|
|||
|
||||
# Determine response
|
||||
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
|
||||
raw = result.response if isinstance(result.response, str) else str(result.response)
|
||||
messages, remaining = chunk_response(
|
||||
raw,
|
||||
result.response,
|
||||
max_chars=self.config.response.max_length,
|
||||
max_messages=self.config.response.max_messages,
|
||||
)
|
||||
# Store remaining for continuation
|
||||
if remaining:
|
||||
self.router.continuations.store(message.sender_id, remaining)
|
||||
elif result.route_type == RouteType.LLM:
|
||||
|
|
|
|||
|
|
@ -1503,24 +1503,24 @@ class MeshReporter:
|
|||
|
||||
return recs
|
||||
|
||||
def build_lora_compact(self, scope: str, scope_value: str = None) -> str:
|
||||
"""Build LoRa-optimized summary with personality for !health command."""
|
||||
def build_lora_compact(self, scope: str, scope_value: str = None) -> list[str]:
|
||||
"""Build LoRa-optimized summary. Returns list of messages (one per line)."""
|
||||
health = self.health_engine.mesh_health
|
||||
if not health:
|
||||
return "📡 No mesh data yet."
|
||||
return ["📡 No mesh data yet."]
|
||||
|
||||
if scope == "region" and scope_value:
|
||||
region = self._find_region(scope_value)
|
||||
if not region:
|
||||
return f"Region '{scope_value}' not found."
|
||||
return [f"Region '{scope_value}' not found."]
|
||||
return self._region_lora_compact(region)
|
||||
|
||||
if scope == "node" and scope_value:
|
||||
return self.build_node_compact(scope_value)
|
||||
return [self.build_node_compact(scope_value)]
|
||||
|
||||
s = health.score
|
||||
|
||||
# Color dot — no numbers shown
|
||||
# Color dot — no numbers
|
||||
if s.composite >= 100:
|
||||
dot = "🔵"
|
||||
mood = "perfect"
|
||||
|
|
@ -1534,8 +1534,7 @@ class MeshReporter:
|
|||
dot = "🔴"
|
||||
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]
|
||||
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]
|
||||
)
|
||||
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"❌ Down: {offline_names}{more}.")
|
||||
lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up")
|
||||
lines.append(f"❌ Down: {offline_names}{more}")
|
||||
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]
|
||||
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)
|
||||
single = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.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:
|
||||
lines.append(f"📶 {full} nodes full coverage ✅.")
|
||||
lines.append(f"📶 {full} nodes full coverage ✅")
|
||||
|
||||
high_util = [n for n in health.nodes.values()
|
||||
if n.channel_utilization is not None and n.channel_utilization > 15]
|
||||
if high_util:
|
||||
worst = max(high_util, key=lambda n: n.channel_utilization)
|
||||
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]
|
||||
bat_low = [n for n in infra_nodes if n.battery_percent is not None and 0 < n.battery_percent < 20]
|
||||
if 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:
|
||||
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]
|
||||
if env_nodes:
|
||||
temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)]
|
||||
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 = []
|
||||
for region in health.regions:
|
||||
|
|
@ -1595,12 +1594,12 @@ class MeshReporter:
|
|||
else:
|
||||
region_parts.append(f"{name} 🔴")
|
||||
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:
|
||||
"""Compact region display for !region [name]."""
|
||||
def _region_lora_compact(self, region) -> list[str]:
|
||||
"""Compact region display. Returns list of messages."""
|
||||
rs = region.score
|
||||
context = self._region_context(region.name)
|
||||
name = context.split("(")[0].strip() if context else region.name
|
||||
|
|
@ -1614,8 +1613,8 @@ class MeshReporter:
|
|||
else:
|
||||
dot = "🔴"
|
||||
|
||||
lines = [f"📡 {name} {dot}."]
|
||||
lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | 📡 {rs.util_percent:.0f}% util.")
|
||||
lines = [f"📡 {name} {dot}"]
|
||||
lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | {rs.util_percent:.0f}% util")
|
||||
|
||||
offline = []
|
||||
for nid_str in region.node_ids:
|
||||
|
|
@ -1627,9 +1626,9 @@ class MeshReporter:
|
|||
if node and node.is_infrastructure and not node.is_online:
|
||||
offline.append(node.long_name or node.short_name or str(nid))
|
||||
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:
|
||||
"""List all regions with scores for !region command."""
|
||||
|
|
|
|||
|
|
@ -476,13 +476,21 @@ class MessageRouter:
|
|||
|
||||
query_lower = query.lower()
|
||||
|
||||
# Build name -> node lookup
|
||||
# Build name -> node lookup (include partial long_name matches)
|
||||
node_names = {}
|
||||
for node in health.nodes.values():
|
||||
if node.short_name:
|
||||
node_names[node.short_name.lower()] = node
|
||||
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_node = health.nodes.get(0x27780c47)
|
||||
|
|
@ -492,6 +500,7 @@ class MessageRouter:
|
|||
|
||||
# Find mentioned nodes (longest names first)
|
||||
found_nodes = []
|
||||
|
||||
for name in sorted(node_names.keys(), key=len, reverse=True):
|
||||
if name in query_lower and len(name) >= 2:
|
||||
node = node_names[name]
|
||||
|
|
@ -500,6 +509,38 @@ class MessageRouter:
|
|||
if len(found_nodes) >= 2:
|
||||
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:
|
||||
return self.mesh_reporter.build_distance(
|
||||
str(found_nodes[0].node_num),
|
||||
|
|
@ -513,6 +554,7 @@ class MessageRouter:
|
|||
|
||||
return ""
|
||||
|
||||
|
||||
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
||||
"""Generate LLM response for a message.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue