mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
feat: Phase 3 - LLM mesh health integration, recommendations, and health commands
New files: - mesh_reporter.py: MeshReporter class for prompt injection - build_tier1_summary(): ~500-800 token mesh health summary - build_region_detail(): Detailed region breakdown - build_node_detail(): Single node info with recommendations - build_recommendations(): Optimization suggestions - build_lora_compact(): Short format for LoRa messages - list_regions_compact(): Region list with scores - commands/health.py: !health and !region commands - !health: Quick mesh summary (no LLM) - !region [name]: Region info or list all regions Modified files: - router.py: Mesh question detection and prompt injection - _is_mesh_question(): Keyword/phrase matching - _detect_mesh_scope(): Node/region/mesh scope detection - Inject Tier 1/2 data for mesh questions - Add mesh awareness instructions to LLM - main.py: Create MeshReporter, pass to dispatcher/router - commands/dispatcher.py: Register health/region commands - mesh_health.py: Fix role type (int -> str) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
89315a8008
commit
44c74ccfd4
6 changed files with 1546 additions and 784 deletions
816
meshai/router.py
816
meshai/router.py
|
|
@ -1,344 +1,472 @@
|
|||
"""Message routing logic for MeshAI."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
from .backends.base import LLMBackend
|
||||
from .commands import CommandContext, CommandDispatcher
|
||||
from .config import Config
|
||||
from .connector import MeshConnector, MeshMessage
|
||||
from .context import MeshContext
|
||||
from .history import ConversationHistory
|
||||
from .chunker import chunk_response, ContinuationState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RouteType(Enum):
|
||||
"""Type of message routing."""
|
||||
|
||||
IGNORE = auto() # Don't respond
|
||||
COMMAND = auto() # Bang command
|
||||
LLM = auto() # Route to LLM
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteResult:
|
||||
"""Result of routing decision."""
|
||||
|
||||
route_type: RouteType
|
||||
response: Optional[str] = None # For commands, the response
|
||||
query: Optional[str] = None # For LLM, the cleaned query
|
||||
|
||||
|
||||
# advBBS protocol and notification prefixes to ignore
|
||||
ADVBBS_PREFIXES = (
|
||||
"MAILREQ|", "MAILACK|", "MAILNAK|", "MAILDAT|", "MAILDLV|",
|
||||
"BOARDREQ|", "BOARDACK|", "BOARDNAK|", "BOARDDAT|", "BOARDDLV|",
|
||||
"advBBS|",
|
||||
"[MAIL]",
|
||||
)
|
||||
|
||||
# Patterns that suggest prompt injection attempts
|
||||
_INJECTION_PATTERNS = [
|
||||
re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE),
|
||||
re.compile(r"ignore\s+your\s+instructions", re.IGNORECASE),
|
||||
re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE),
|
||||
re.compile(r"you\s+are\s+now\b", re.IGNORECASE),
|
||||
re.compile(r"new\s+instructions?\s*:", re.IGNORECASE),
|
||||
re.compile(r"system\s*prompt\s*:", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
class MessageRouter:
|
||||
"""Routes incoming messages to appropriate handlers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
connector: MeshConnector,
|
||||
history: ConversationHistory,
|
||||
dispatcher: CommandDispatcher,
|
||||
llm_backend: LLMBackend,
|
||||
context: MeshContext = None,
|
||||
meshmonitor_sync=None,
|
||||
knowledge=None,
|
||||
source_manager=None,
|
||||
health_engine=None,
|
||||
):
|
||||
self.config = config
|
||||
self.connector = connector
|
||||
self.history = history
|
||||
self.dispatcher = dispatcher
|
||||
self.llm = llm_backend
|
||||
self.context = context
|
||||
self.meshmonitor_sync = meshmonitor_sync
|
||||
self.knowledge = knowledge
|
||||
self.source_manager = source_manager
|
||||
self.health_engine = health_engine
|
||||
self.continuations = ContinuationState(max_continuations=3)
|
||||
|
||||
|
||||
def should_respond(self, message: MeshMessage) -> bool:
|
||||
"""Determine if we should respond to this message.
|
||||
|
||||
DM-only bot: ignores all public channel messages.
|
||||
Commands and conversational LLM responses both work in DMs.
|
||||
|
||||
Args:
|
||||
message: Incoming message
|
||||
|
||||
Returns:
|
||||
True if we should process this message
|
||||
"""
|
||||
# Always ignore our own messages
|
||||
if message.sender_id == self.connector.my_node_id:
|
||||
return False
|
||||
|
||||
# Only respond to DMs
|
||||
if not message.is_dm:
|
||||
return False
|
||||
|
||||
if not self.config.bot.respond_to_dms:
|
||||
return False
|
||||
|
||||
# Ignore advBBS protocol and notification messages
|
||||
if self.config.bot.filter_bbs_protocols:
|
||||
if any(message.text.startswith(p) for p in ADVBBS_PREFIXES):
|
||||
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
|
||||
return False
|
||||
|
||||
# Ignore messages that MeshMonitor will handle
|
||||
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
|
||||
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def check_continuation(self, message) -> list[str] | None:
|
||||
"""Check if this is a continuation request and return messages if so.
|
||||
|
||||
Returns:
|
||||
List of messages to send, or None if not a continuation
|
||||
"""
|
||||
user_id = message.sender_id
|
||||
text = message.text.strip()
|
||||
|
||||
logger.info(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}")
|
||||
|
||||
if self.continuations.has_pending(user_id):
|
||||
if self.continuations.is_continuation_request(text):
|
||||
result = self.continuations.get_continuation(user_id)
|
||||
if result:
|
||||
messages, _ = result
|
||||
return messages
|
||||
# Max continuations reached, return None to fall through
|
||||
else:
|
||||
# User asked something new, clear pending continuation
|
||||
self.continuations.clear(user_id)
|
||||
|
||||
return None
|
||||
|
||||
async def route(self, message: MeshMessage) -> RouteResult:
|
||||
"""Route a message and generate response.
|
||||
|
||||
Args:
|
||||
message: Incoming message to route
|
||||
|
||||
Returns:
|
||||
RouteResult with routing decision and any response
|
||||
"""
|
||||
text = message.text.strip()
|
||||
|
||||
# Check for bang command first
|
||||
if self.dispatcher.is_command(text):
|
||||
context = self._make_command_context(message)
|
||||
response = await self.dispatcher.dispatch(text, context)
|
||||
return RouteResult(RouteType.COMMAND, response=response)
|
||||
|
||||
# Clean up the message (remove @mention)
|
||||
query = self._clean_query(text)
|
||||
|
||||
if not query:
|
||||
return RouteResult(RouteType.IGNORE)
|
||||
|
||||
# Route to LLM
|
||||
return RouteResult(RouteType.LLM, query=query)
|
||||
|
||||
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
||||
"""Generate LLM response for a message.
|
||||
|
||||
Args:
|
||||
message: Original message
|
||||
query: Cleaned query text
|
||||
|
||||
Returns:
|
||||
Generated response
|
||||
"""
|
||||
# Add user message to history
|
||||
await self.history.add_message(message.sender_id, "user", query)
|
||||
|
||||
# Get conversation history
|
||||
history = await self.history.get_history_for_llm(message.sender_id)
|
||||
|
||||
# Build system prompt in order: identity -> static -> meshmonitor -> context
|
||||
|
||||
# 1. Dynamic identity from bot config
|
||||
bot_name = self.config.bot.name or "MeshAI"
|
||||
bot_owner = self.config.bot.owner or "Unknown"
|
||||
|
||||
identity = (
|
||||
f"You are {bot_name}, an LLM-powered conversational assistant running on a "
|
||||
f"Meshtastic mesh network. Your managing operator is {bot_owner}. "
|
||||
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
||||
f"IDENTITY: Your name is {bot_name}. You respond to DMs only. You connect "
|
||||
f"to a Meshtastic node via TCP through meshtasticd.\n\n"
|
||||
)
|
||||
|
||||
# 2. Static system prompt from config
|
||||
static_prompt = ""
|
||||
if getattr(self.config.llm, 'use_system_prompt', True):
|
||||
static_prompt = self.config.llm.system_prompt
|
||||
|
||||
system_prompt = identity + static_prompt
|
||||
|
||||
# 3. MeshMonitor info (only when enabled)
|
||||
if (
|
||||
self.meshmonitor_sync
|
||||
and self.config.meshmonitor.enabled
|
||||
and self.config.meshmonitor.inject_into_prompt
|
||||
):
|
||||
meshmonitor_intro = (
|
||||
"\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, "
|
||||
"mention both yours and MeshMonitor's. If someone asks where to get "
|
||||
"MeshMonitor, direct them to github.com/Yeraze/meshmonitor"
|
||||
)
|
||||
system_prompt += meshmonitor_intro
|
||||
|
||||
commands_summary = self.meshmonitor_sync.get_commands_summary()
|
||||
if commands_summary:
|
||||
system_prompt += "\n\n" + commands_summary
|
||||
|
||||
# 4. Inject mesh context if available
|
||||
if self.context:
|
||||
max_items = getattr(self.config.context, 'max_context_items', 20)
|
||||
context_block = self.context.get_context_block(max_items=max_items)
|
||||
if context_block:
|
||||
system_prompt += (
|
||||
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
|
||||
+ context_block
|
||||
)
|
||||
else:
|
||||
system_prompt += (
|
||||
"\n\n[No recent mesh traffic observed yet.]"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 5. Knowledge base retrieval
|
||||
if self.knowledge and query:
|
||||
results = self.knowledge.search(query)
|
||||
if results:
|
||||
chunks = "\n\n".join(
|
||||
f"[{r['title']}]: {r['excerpt']}" for r in results
|
||||
)
|
||||
system_prompt += (
|
||||
"\n\nREFERENCE KNOWLEDGE - Answer using this information:\n"
|
||||
+ chunks
|
||||
)
|
||||
|
||||
# DEBUG: Log system prompt status
|
||||
logger.warning(f"SYSTEM PROMPT LENGTH: {len(system_prompt)} chars")
|
||||
logger.warning(f"HAS REFERENCE KNOWLEDGE: {'REFERENCE KNOWLEDGE' in system_prompt}")
|
||||
try:
|
||||
response = await self.llm.generate(
|
||||
messages=history,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=500,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("LLM request timed out")
|
||||
response = "Sorry, request timed out. Try again."
|
||||
except Exception as e:
|
||||
logger.error(f"LLM generation error: {e}")
|
||||
response = "Sorry, I encountered an error. Please try again."
|
||||
|
||||
# Add assistant response to history
|
||||
await self.history.add_message(message.sender_id, "assistant", response)
|
||||
|
||||
# Persist summary if one was created/updated
|
||||
await self._persist_summary(message.sender_id)
|
||||
|
||||
# Chunk the response with sentence awareness
|
||||
messages, remaining = chunk_response(
|
||||
response,
|
||||
max_chars=self.config.response.max_length,
|
||||
max_messages=self.config.response.max_messages,
|
||||
)
|
||||
|
||||
# Store remaining content for continuation
|
||||
if remaining:
|
||||
logger.info(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining")
|
||||
self.continuations.store(message.sender_id, remaining)
|
||||
else:
|
||||
logger.info(f"No remaining content for {message.sender_id}")
|
||||
|
||||
return messages
|
||||
|
||||
async def _persist_summary(self, user_id: str) -> None:
|
||||
"""Persist any cached summary to the database.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
"""
|
||||
memory = self.llm.get_memory()
|
||||
if not memory:
|
||||
return
|
||||
|
||||
summary = memory.get_cached_summary(user_id)
|
||||
if summary:
|
||||
await self.history.store_summary(
|
||||
user_id,
|
||||
summary.summary,
|
||||
summary.message_count,
|
||||
)
|
||||
logger.debug(f"Persisted summary for {user_id}")
|
||||
|
||||
def _clean_query(self, text: str) -> str:
|
||||
"""Clean up query text and check for prompt injection."""
|
||||
cleaned = " ".join(text.split())
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
# Check for prompt injection
|
||||
for pattern in _INJECTION_PATTERNS:
|
||||
if pattern.search(cleaned):
|
||||
logger.warning(
|
||||
f"Possible prompt injection detected: {cleaned[:80]}..."
|
||||
)
|
||||
match = pattern.search(cleaned)
|
||||
cleaned = cleaned[:match.start()].strip()
|
||||
if not cleaned:
|
||||
cleaned = "Hello"
|
||||
break
|
||||
|
||||
return cleaned
|
||||
|
||||
def _make_command_context(self, message: MeshMessage) -> CommandContext:
|
||||
"""Create command context from message."""
|
||||
return CommandContext(
|
||||
sender_id=message.sender_id,
|
||||
sender_name=message.sender_name,
|
||||
channel=message.channel,
|
||||
is_dm=message.is_dm,
|
||||
position=message.sender_position,
|
||||
config=self.config,
|
||||
connector=self.connector,
|
||||
history=self.history,
|
||||
)
|
||||
"""Message routing logic for MeshAI."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
from .backends.base import LLMBackend
|
||||
from .commands import CommandContext, CommandDispatcher
|
||||
from .config import Config
|
||||
from .connector import MeshConnector, MeshMessage
|
||||
from .context import MeshContext
|
||||
from .history import ConversationHistory
|
||||
from .chunker import chunk_response, ContinuationState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RouteType(Enum):
|
||||
"""Type of message routing."""
|
||||
|
||||
IGNORE = auto() # Don't respond
|
||||
COMMAND = auto() # Bang command
|
||||
LLM = auto() # Route to LLM
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteResult:
|
||||
"""Result of routing decision."""
|
||||
|
||||
route_type: RouteType
|
||||
response: Optional[str] = None # For commands, the response
|
||||
query: Optional[str] = None # For LLM, the cleaned query
|
||||
|
||||
|
||||
# advBBS protocol and notification prefixes to ignore
|
||||
ADVBBS_PREFIXES = (
|
||||
"MAILREQ|", "MAILACK|", "MAILNAK|", "MAILDAT|", "MAILDLV|",
|
||||
"BOARDREQ|", "BOARDACK|", "BOARDNAK|", "BOARDDAT|", "BOARDDLV|",
|
||||
"advBBS|",
|
||||
"[MAIL]",
|
||||
)
|
||||
|
||||
# Patterns that suggest prompt injection attempts
|
||||
_INJECTION_PATTERNS = [
|
||||
re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE),
|
||||
re.compile(r"ignore\s+your\s+instructions", re.IGNORECASE),
|
||||
re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE),
|
||||
re.compile(r"you\s+are\s+now\b", re.IGNORECASE),
|
||||
re.compile(r"new\s+instructions?\s*:", re.IGNORECASE),
|
||||
re.compile(r"system\s*prompt\s*:", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# Keywords that indicate mesh-related questions
|
||||
_MESH_KEYWORDS = {
|
||||
"mesh", "network", "health", "nodes", "node", "utilization", "signal",
|
||||
"coverage", "battery", "solar", "offline", "router", "channel", "packet",
|
||||
"hop", "optimize", "optimization", "infrastructure", "infra", "relay",
|
||||
"repeater", "region", "locality", "congestion", "collision", "airtime",
|
||||
"telemetry", "firmware", "subscribe", "alert", "snr", "rssi",
|
||||
}
|
||||
|
||||
# Phrases that indicate mesh questions
|
||||
_MESH_PHRASES = [
|
||||
"how's the mesh",
|
||||
"hows the mesh",
|
||||
"mesh status",
|
||||
"what's wrong",
|
||||
"whats wrong",
|
||||
"check node",
|
||||
"node status",
|
||||
"network health",
|
||||
"mesh health",
|
||||
]
|
||||
|
||||
# Mesh awareness instruction for LLM
|
||||
_MESH_AWARENESS_PROMPT = """
|
||||
When the user asks about mesh health, network status, or optimization:
|
||||
- Use the LIVE MESH HEALTH DATA injected above to answer with real numbers
|
||||
- Be specific: name nodes, cite utilization percentages, reference actual scores
|
||||
- Give actionable recommendations based on the data
|
||||
- If asked about a region or node you have detail for, use that detail
|
||||
- If asked about something the data doesn't cover, say so - don't fabricate
|
||||
- Keep responses concise - these go over LoRa with limited message size
|
||||
- Users can run !health for a quick mesh summary or !region [name] for regional info
|
||||
"""
|
||||
|
||||
|
||||
class MessageRouter:
|
||||
"""Routes incoming messages to appropriate handlers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
connector: MeshConnector,
|
||||
history: ConversationHistory,
|
||||
dispatcher: CommandDispatcher,
|
||||
llm_backend: LLMBackend,
|
||||
context: MeshContext = None,
|
||||
meshmonitor_sync=None,
|
||||
knowledge=None,
|
||||
source_manager=None,
|
||||
health_engine=None,
|
||||
mesh_reporter=None,
|
||||
):
|
||||
self.config = config
|
||||
self.connector = connector
|
||||
self.history = history
|
||||
self.dispatcher = dispatcher
|
||||
self.llm = llm_backend
|
||||
self.context = context
|
||||
self.meshmonitor_sync = meshmonitor_sync
|
||||
self.knowledge = knowledge
|
||||
self.source_manager = source_manager
|
||||
self.health_engine = health_engine
|
||||
self.mesh_reporter = mesh_reporter
|
||||
self.continuations = ContinuationState(max_continuations=3)
|
||||
|
||||
def should_respond(self, message: MeshMessage) -> bool:
|
||||
"""Determine if we should respond to this message.
|
||||
|
||||
DM-only bot: ignores all public channel messages.
|
||||
Commands and conversational LLM responses both work in DMs.
|
||||
|
||||
Args:
|
||||
message: Incoming message
|
||||
|
||||
Returns:
|
||||
True if we should process this message
|
||||
"""
|
||||
# Always ignore our own messages
|
||||
if message.sender_id == self.connector.my_node_id:
|
||||
return False
|
||||
|
||||
# Only respond to DMs
|
||||
if not message.is_dm:
|
||||
return False
|
||||
|
||||
if not self.config.bot.respond_to_dms:
|
||||
return False
|
||||
|
||||
# Ignore advBBS protocol and notification messages
|
||||
if self.config.bot.filter_bbs_protocols:
|
||||
if any(message.text.startswith(p) for p in ADVBBS_PREFIXES):
|
||||
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
|
||||
return False
|
||||
|
||||
# Ignore messages that MeshMonitor will handle
|
||||
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
|
||||
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def check_continuation(self, message) -> list[str] | None:
|
||||
"""Check if this is a continuation request and return messages if so.
|
||||
|
||||
Returns:
|
||||
List of messages to send, or None if not a continuation
|
||||
"""
|
||||
user_id = message.sender_id
|
||||
text = message.text.strip()
|
||||
|
||||
logger.debug(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}")
|
||||
|
||||
if self.continuations.has_pending(user_id):
|
||||
if self.continuations.is_continuation_request(text):
|
||||
result = self.continuations.get_continuation(user_id)
|
||||
if result:
|
||||
messages, _ = result
|
||||
return messages
|
||||
# Max continuations reached, return None to fall through
|
||||
else:
|
||||
# User asked something new, clear pending continuation
|
||||
self.continuations.clear(user_id)
|
||||
|
||||
return None
|
||||
|
||||
async def route(self, message: MeshMessage) -> RouteResult:
|
||||
"""Route a message and generate response.
|
||||
|
||||
Args:
|
||||
message: Incoming message to route
|
||||
|
||||
Returns:
|
||||
RouteResult with routing decision and any response
|
||||
"""
|
||||
text = message.text.strip()
|
||||
|
||||
# Check for bang command first
|
||||
if self.dispatcher.is_command(text):
|
||||
context = self._make_command_context(message)
|
||||
response = await self.dispatcher.dispatch(text, context)
|
||||
return RouteResult(RouteType.COMMAND, response=response)
|
||||
|
||||
# Clean up the message (remove @mention)
|
||||
query = self._clean_query(text)
|
||||
|
||||
if not query:
|
||||
return RouteResult(RouteType.IGNORE)
|
||||
|
||||
# Route to LLM
|
||||
return RouteResult(RouteType.LLM, query=query)
|
||||
|
||||
def _is_mesh_question(self, message: str) -> bool:
|
||||
"""Check if message is asking about mesh health/status.
|
||||
|
||||
Args:
|
||||
message: User message text
|
||||
|
||||
Returns:
|
||||
True if this is a mesh-related question
|
||||
"""
|
||||
msg_lower = message.lower()
|
||||
|
||||
# Check for mesh phrases
|
||||
for phrase in _MESH_PHRASES:
|
||||
if phrase in msg_lower:
|
||||
return True
|
||||
|
||||
# Check for mesh keywords
|
||||
words = set(re.findall(r'\b\w+\b', msg_lower))
|
||||
if words & _MESH_KEYWORDS:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _detect_mesh_scope(self, message: str) -> tuple[str, Optional[str]]:
|
||||
"""Detect the scope of a mesh question.
|
||||
|
||||
Args:
|
||||
message: User message text
|
||||
|
||||
Returns:
|
||||
Tuple of (scope_type, scope_value):
|
||||
- ("node", "{identifier}") if asking about specific node
|
||||
- ("region", "{region_name}") if asking about specific region
|
||||
- ("mesh", None) for general mesh questions
|
||||
"""
|
||||
msg_lower = message.lower()
|
||||
|
||||
# Check for node references
|
||||
if self.health_engine and self.health_engine.mesh_health:
|
||||
health = self.health_engine.mesh_health
|
||||
|
||||
# Look for node shortnames (4 chars, case-insensitive)
|
||||
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)
|
||||
|
||||
# Check for region references
|
||||
if self.health_engine:
|
||||
for anchor in self.health_engine.regions:
|
||||
anchor_lower = anchor.name.lower()
|
||||
# Check 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)
|
||||
|
||||
return ("mesh", None)
|
||||
|
||||
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
||||
"""Generate LLM response for a message.
|
||||
|
||||
Args:
|
||||
message: Original message
|
||||
query: Cleaned query text
|
||||
|
||||
Returns:
|
||||
Generated response
|
||||
"""
|
||||
# Add user message to history
|
||||
await self.history.add_message(message.sender_id, "user", query)
|
||||
|
||||
# Get conversation history
|
||||
history = await self.history.get_history_for_llm(message.sender_id)
|
||||
|
||||
# Build system prompt in order: identity -> static -> meshmonitor -> context -> knowledge -> mesh
|
||||
|
||||
# 1. Dynamic identity from bot config
|
||||
bot_name = self.config.bot.name or "MeshAI"
|
||||
bot_owner = self.config.bot.owner or "Unknown"
|
||||
|
||||
identity = (
|
||||
f"You are {bot_name}, an LLM-powered conversational assistant running on a "
|
||||
f"Meshtastic mesh network. Your managing operator is {bot_owner}. "
|
||||
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
||||
f"IDENTITY: Your name is {bot_name}. You respond to DMs only. You connect "
|
||||
f"to a Meshtastic node via TCP through meshtasticd.\n\n"
|
||||
)
|
||||
|
||||
# 2. Static system prompt from config
|
||||
static_prompt = ""
|
||||
if getattr(self.config.llm, 'use_system_prompt', True):
|
||||
static_prompt = self.config.llm.system_prompt
|
||||
|
||||
system_prompt = identity + static_prompt
|
||||
|
||||
# 3. MeshMonitor info (only when enabled)
|
||||
if (
|
||||
self.meshmonitor_sync
|
||||
and self.config.meshmonitor.enabled
|
||||
and self.config.meshmonitor.inject_into_prompt
|
||||
):
|
||||
meshmonitor_intro = (
|
||||
"\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, "
|
||||
"mention both yours and MeshMonitor's. If someone asks where to get "
|
||||
"MeshMonitor, direct them to github.com/Yeraze/meshmonitor"
|
||||
)
|
||||
system_prompt += meshmonitor_intro
|
||||
|
||||
commands_summary = self.meshmonitor_sync.get_commands_summary()
|
||||
if commands_summary:
|
||||
system_prompt += "\n\n" + commands_summary
|
||||
|
||||
# 4. Inject mesh context if available
|
||||
if self.context:
|
||||
max_items = getattr(self.config.context, 'max_context_items', 20)
|
||||
context_block = self.context.get_context_block(max_items=max_items)
|
||||
if context_block:
|
||||
system_prompt += (
|
||||
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
|
||||
+ context_block
|
||||
)
|
||||
else:
|
||||
system_prompt += (
|
||||
"\n\n[No recent mesh traffic observed yet.]"
|
||||
)
|
||||
|
||||
# 5. Knowledge base retrieval
|
||||
if self.knowledge and query:
|
||||
results = self.knowledge.search(query)
|
||||
if results:
|
||||
chunks = "\n\n".join(
|
||||
f"[{r['title']}]: {r['excerpt']}" for r in results
|
||||
)
|
||||
system_prompt += (
|
||||
"\n\nREFERENCE KNOWLEDGE - Answer using this information:\n"
|
||||
+ chunks
|
||||
)
|
||||
|
||||
# 6. Mesh Intelligence (inject health data for mesh questions)
|
||||
if (
|
||||
self.source_manager
|
||||
and self.mesh_reporter
|
||||
and self._is_mesh_question(query)
|
||||
):
|
||||
scope_type, scope_value = self._detect_mesh_scope(query)
|
||||
|
||||
# Always include Tier 1 summary for mesh questions
|
||||
tier1 = self.mesh_reporter.build_tier1_summary()
|
||||
system_prompt += "\n\n" + tier1
|
||||
|
||||
# Add Tier 2 detail if scoped
|
||||
if scope_type == "region" and scope_value:
|
||||
region_detail = self.mesh_reporter.build_region_detail(scope_value)
|
||||
system_prompt += "\n\n" + region_detail
|
||||
elif scope_type == "node" and scope_value:
|
||||
node_detail = self.mesh_reporter.build_node_detail(scope_value)
|
||||
system_prompt += "\n\n" + node_detail
|
||||
|
||||
# Always include relevant recommendations
|
||||
recommendations = self.mesh_reporter.build_recommendations(scope_type, scope_value)
|
||||
if recommendations:
|
||||
system_prompt += "\n\n" + recommendations
|
||||
|
||||
# Add mesh awareness instructions
|
||||
system_prompt += _MESH_AWARENESS_PROMPT
|
||||
|
||||
# DEBUG: Log system prompt status
|
||||
logger.debug(f"System prompt length: {len(system_prompt)} chars")
|
||||
|
||||
try:
|
||||
response = await self.llm.generate(
|
||||
messages=history,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=500,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("LLM request timed out")
|
||||
response = "Sorry, request timed out. Try again."
|
||||
except Exception as e:
|
||||
logger.error(f"LLM generation error: {e}")
|
||||
response = "Sorry, I encountered an error. Please try again."
|
||||
|
||||
# Add assistant response to history
|
||||
await self.history.add_message(message.sender_id, "assistant", response)
|
||||
|
||||
# Persist summary if one was created/updated
|
||||
await self._persist_summary(message.sender_id)
|
||||
|
||||
# Chunk the response with sentence awareness
|
||||
messages, remaining = chunk_response(
|
||||
response,
|
||||
max_chars=self.config.response.max_length,
|
||||
max_messages=self.config.response.max_messages,
|
||||
)
|
||||
|
||||
# Store remaining content for continuation
|
||||
if remaining:
|
||||
logger.debug(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining")
|
||||
self.continuations.store(message.sender_id, remaining)
|
||||
|
||||
return messages
|
||||
|
||||
async def _persist_summary(self, user_id: str) -> None:
|
||||
"""Persist any cached summary to the database.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
"""
|
||||
memory = self.llm.get_memory()
|
||||
if not memory:
|
||||
return
|
||||
|
||||
summary = memory.get_cached_summary(user_id)
|
||||
if summary:
|
||||
await self.history.store_summary(
|
||||
user_id,
|
||||
summary.summary,
|
||||
summary.message_count,
|
||||
)
|
||||
logger.debug(f"Persisted summary for {user_id}")
|
||||
|
||||
def _clean_query(self, text: str) -> str:
|
||||
"""Clean up query text and check for prompt injection."""
|
||||
cleaned = " ".join(text.split())
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
# Check for prompt injection
|
||||
for pattern in _INJECTION_PATTERNS:
|
||||
if pattern.search(cleaned):
|
||||
logger.warning(
|
||||
f"Possible prompt injection detected: {cleaned[:80]}..."
|
||||
)
|
||||
match = pattern.search(cleaned)
|
||||
cleaned = cleaned[:match.start()].strip()
|
||||
if not cleaned:
|
||||
cleaned = "Hello"
|
||||
break
|
||||
|
||||
return cleaned
|
||||
|
||||
def _make_command_context(self, message: MeshMessage) -> CommandContext:
|
||||
"""Create command context from message."""
|
||||
return CommandContext(
|
||||
sender_id=message.sender_id,
|
||||
sender_name=message.sender_name,
|
||||
channel=message.channel,
|
||||
is_dm=message.is_dm,
|
||||
position=message.sender_position,
|
||||
config=self.config,
|
||||
connector=self.connector,
|
||||
history=self.history,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue