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
|
# 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:
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue