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:
K7ZVX 2026-05-06 04:17:00 +00:00
commit b7237469e4
5 changed files with 153 additions and 25 deletions

View file

@ -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",

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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,