mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
feat: ACK-based message delivery, markdown stripping, prompt fixes
- Connector: send_and_wait_ack() waits for ACK before returning - Responder: ACK waiting for DMs with retry, delay-based for broadcasts - Chunker: strip_markdown() removes bold/italic/headers/lists from LLM output - Router: applies strip_markdown before chunking - Prompt: stronger no-markdown rules, no manual continuation prompt, gateway explanation - Config: delays 3-5s (was 2.2-3), max_length 200 (was 150), max_messages 3 (was 2) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d5fc69e9a0
commit
b7237469e4
5 changed files with 153 additions and 25 deletions
|
|
@ -13,6 +13,30 @@ import re
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_markdown(text: str) -> str:
|
||||||
|
"""Remove markdown formatting from LLM output.
|
||||||
|
|
||||||
|
LLMs often ignore 'no markdown' instructions.
|
||||||
|
This strips it before sending over LoRa.
|
||||||
|
"""
|
||||||
|
# Remove bold **text**
|
||||||
|
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
|
||||||
|
# Remove italic *text*
|
||||||
|
text = re.sub(r'\*(.*?)\*', r'\1', text)
|
||||||
|
# Remove headers (## Header)
|
||||||
|
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
|
||||||
|
# Remove bullet points at line start (- item or * item)
|
||||||
|
text = re.sub(r'^\s*[-*]\s+', '', text, flags=re.MULTILINE)
|
||||||
|
# Remove numbered lists at line start (1. item)
|
||||||
|
text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
|
||||||
|
# Remove code blocks
|
||||||
|
text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
|
||||||
|
# Remove inline code
|
||||||
|
text = re.sub(r'`(.*?)`', r'\1', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
# Phrases that trigger continuation of a previous response
|
# Phrases that trigger continuation of a previous response
|
||||||
CONTINUE_PHRASES = {
|
CONTINUE_PHRASES = {
|
||||||
"yes", "yeah", "yep", "yea", "sure", "ok", "okay", "go on",
|
"yes", "yeah", "yep", "yea", "sure", "ok", "okay", "go on",
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ class ConnectionConfig:
|
||||||
class ResponseConfig:
|
class ResponseConfig:
|
||||||
"""Response behavior settings."""
|
"""Response behavior settings."""
|
||||||
|
|
||||||
delay_min: float = 2.2
|
delay_min: float = 3.0
|
||||||
delay_max: float = 3.0
|
delay_max: float = 3.0
|
||||||
max_length: int = 150
|
max_length: int = 200
|
||||||
max_messages: int = 2
|
max_messages: int = 3
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -107,7 +107,9 @@ class LLMConfig:
|
||||||
"the command list provided below. Don't dump lists unless asked.\n"
|
"the command list provided below. Don't dump lists unless asked.\n"
|
||||||
"- You are part of the freq51 mesh.\n"
|
"- You are part of the freq51 mesh.\n"
|
||||||
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
|
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
|
||||||
"- You are part of the freq51 mesh in the Twin Falls, Idaho area."
|
"- You are part of the freq51 mesh in the Twin Falls, Idaho area.\n"
|
||||||
|
"- NEVER use markdown formatting (no bold, no asterisks, no bullet points, no numbered lists). Plain text only.\n"
|
||||||
|
"- NEVER say 'Want me to keep going?' -- the system handles continuation prompts automatically."
|
||||||
)
|
)
|
||||||
use_system_prompt: bool = True # Toggle to disable sending system prompt
|
use_system_prompt: bool = True # Toggle to disable sending system prompt
|
||||||
web_search: bool = False # Enable web search (Open WebUI feature)
|
web_search: bool = False # Enable web search (Open WebUI feature)
|
||||||
|
|
|
||||||
|
|
@ -279,3 +279,78 @@ class MeshConnector:
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._node_names.get(node_id, node_id)
|
return self._node_names.get(node_id, node_id)
|
||||||
|
def send_and_wait_ack(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
destination: Optional[str] = None,
|
||||||
|
channel: int = 0,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> bool:
|
||||||
|
"""Send a text message and wait for ACK.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Message text
|
||||||
|
destination: Node ID for DM
|
||||||
|
channel: Channel index
|
||||||
|
timeout: Seconds to wait for ACK
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if ACK received, False if timeout
|
||||||
|
"""
|
||||||
|
if not self._interface:
|
||||||
|
logger.error("Cannot send: not connected")
|
||||||
|
return False
|
||||||
|
|
||||||
|
ack_event = threading.Event()
|
||||||
|
ack_success = [False]
|
||||||
|
|
||||||
|
def on_response(packet):
|
||||||
|
# Check if this is an ACK (not a NACK or error)
|
||||||
|
routing = packet.get("decoded", {}).get("routing", {})
|
||||||
|
error_reason = routing.get("errorReason")
|
||||||
|
if error_reason is None or error_reason == "NONE":
|
||||||
|
ack_success[0] = True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Message NACK: {error_reason}")
|
||||||
|
ack_event.set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if destination:
|
||||||
|
if destination.startswith("!"):
|
||||||
|
dest_num = int(destination[1:], 16)
|
||||||
|
else:
|
||||||
|
dest_num = int(destination, 16)
|
||||||
|
|
||||||
|
self._interface.sendText(
|
||||||
|
text=text,
|
||||||
|
destinationId=dest_num,
|
||||||
|
channelIndex=channel,
|
||||||
|
wantAck=True,
|
||||||
|
onResponse=on_response,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._interface.sendText(
|
||||||
|
text=text,
|
||||||
|
destinationId=BROADCAST_NUM,
|
||||||
|
channelIndex=channel,
|
||||||
|
wantAck=True,
|
||||||
|
onResponse=on_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for ACK or timeout
|
||||||
|
received = ack_event.wait(timeout=timeout)
|
||||||
|
|
||||||
|
if received and ack_success[0]:
|
||||||
|
logger.debug(f"ACK received for message to {destination or 'broadcast'}")
|
||||||
|
return True
|
||||||
|
elif received:
|
||||||
|
logger.warning(f"NACK received for message to {destination or 'broadcast'}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning(f"ACK timeout ({timeout}s) for message to {destination or 'broadcast'}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send message: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,11 @@ class Responder:
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send response messages with human-pacing delays.
|
"""Send response messages with ACK waiting and retry.
|
||||||
|
|
||||||
Args:
|
For DMs: waits for ACK before sending next message, retries once on failure.
|
||||||
messages: Pre-chunked messages list, or single string (legacy)
|
For broadcasts: uses delay-based pacing (no ACK for broadcasts).
|
||||||
destination: Node ID for DM, or None for channel broadcast
|
|
||||||
channel: Channel to send on
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if all messages sent successfully
|
|
||||||
"""
|
"""
|
||||||
# Handle legacy single string
|
|
||||||
if isinstance(messages, str):
|
if isinstance(messages, str):
|
||||||
messages = [messages]
|
messages = [messages]
|
||||||
|
|
||||||
|
|
@ -42,24 +36,50 @@ class Responder:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
success = True
|
success = True
|
||||||
|
is_dm = destination is not None
|
||||||
|
|
||||||
for i, msg in enumerate(messages):
|
for i, msg in enumerate(messages):
|
||||||
# Apply delay before sending (except first message)
|
# Randomized delay before sending (except first message)
|
||||||
if i > 0:
|
if i > 0:
|
||||||
delay = random.uniform(self.config.delay_min, self.config.delay_max)
|
delay = random.uniform(self.config.delay_min, self.config.delay_max)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
# Send message
|
if is_dm and hasattr(self.connector, 'send_and_wait_ack'):
|
||||||
|
# DMs: send and wait for ACK
|
||||||
|
ack = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
self.connector.send_and_wait_ack,
|
||||||
|
msg, destination, channel, 30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ack:
|
||||||
|
# Retry once
|
||||||
|
logger.warning(f"No ACK for msg {i+1}/{len(messages)}, retrying...")
|
||||||
|
await asyncio.sleep(random.uniform(3.0, 5.0))
|
||||||
|
ack = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
self.connector.send_and_wait_ack,
|
||||||
|
msg, destination, channel, 30.0,
|
||||||
|
)
|
||||||
|
if not ack:
|
||||||
|
logger.error(f"No ACK after retry for msg {i+1}/{len(messages)}, skipping remaining")
|
||||||
|
success = False
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.debug(f"Sent+ACK msg {i+1}/{len(messages)}: {msg[:50]}...")
|
||||||
|
else:
|
||||||
|
# Broadcasts or fallback: fire and delay
|
||||||
sent = self.connector.send_message(
|
sent = self.connector.send_message(
|
||||||
text=msg,
|
text=msg,
|
||||||
destination=destination,
|
destination=destination,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not sent:
|
if not sent:
|
||||||
logger.error(f"Failed to send message {i + 1}/{len(messages)}")
|
logger.error(f"Failed to send message {i+1}/{len(messages)}")
|
||||||
success = False
|
success = False
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.debug(f"Sent message {i + 1}/{len(messages)}: {msg[:50]}...")
|
logger.debug(f"Sent msg {i+1}/{len(messages)}: {msg[:50]}...")
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,9 @@ RESPONSE STYLE:
|
||||||
- When discussing problems, name the node and explain the impact
|
- When discussing problems, name the node and explain the impact
|
||||||
- You CAN use 3-5 messages. Keep each sentence under 150 characters.
|
- You CAN use 3-5 messages. Keep each sentence under 150 characters.
|
||||||
- No markdown formatting - plain text only
|
- No markdown formatting - plain text only
|
||||||
|
- ABSOLUTELY NO markdown. No asterisks, no bold, no bullet points with * or -, no numbered lists with 1. 2. 3. Just plain text sentences.
|
||||||
|
- NEVER say "Want me to keep going?" — the message system adds this automatically when needed. If you say it yourself, users see it twice.
|
||||||
|
- When explaining "X/Y gateways" (like 7/7), explain that it means the node is visible to X out of Y data sources (Meshview and MeshMonitor instances that monitor the mesh). It does NOT mean infrastructure routers or regional gateways.
|
||||||
|
|
||||||
QUESTION TYPES:
|
QUESTION TYPES:
|
||||||
- "How's the mesh?" -> Lead with composite score. Highlight 1-2 biggest issues by name. Summarize each region briefly.
|
- "How's the mesh?" -> Lead with composite score. Highlight 1-2 biggest issues by name. Summarize each region briefly.
|
||||||
|
|
@ -758,6 +761,10 @@ class MessageRouter:
|
||||||
# Persist summary if one was created/updated
|
# Persist summary if one was created/updated
|
||||||
await self._persist_summary(message.sender_id)
|
await self._persist_summary(message.sender_id)
|
||||||
|
|
||||||
|
# Strip any markdown the LLM ignored instructions about
|
||||||
|
from .chunker import strip_markdown
|
||||||
|
response = strip_markdown(response)
|
||||||
|
|
||||||
# Chunk the response with sentence awareness
|
# Chunk the response with sentence awareness
|
||||||
messages, remaining = chunk_response(
|
messages, remaining = chunk_response(
|
||||||
response,
|
response,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue