mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(meshcore): decouple per-mesh LLM DM gate + mesh-scoped chat context (#19)
- router.should_respond branches on message.transport: MeshCore DMs are
governed solely by meshcore_context.respond_to_dms (enforced at the
transport); Meshtastic solely by bot.respond_to_dms. No global gate.
- MeshObservation tagged per-transport; the LLM "recent traffic" block is
scoped to the originating mesh (keyword override for the other mesh),
labeled by mesh so the model knows which it is describing.
- MeshCore observe_channels is now opt-in (empty = observe none).
- Chat-context retention 30d -> 14d (both meshes).
- Meshtastic integer channel-index filter no longer misapplied to MeshCore
observations (their channel is a companion slot index).
- Frontend: relabel DM toggles per-mesh ("Answer direct messages",
Meshtastic-only / MeshCore-only), remove the false channel-mention
tooltips, opt-in wording for MeshCore observe-channels.
Tests: +tests/test_llm_scoping.py (10), context-filter updated for opt-in;
0 new failures (34 pre-existing).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
284fb5cbf2
commit
a191200a12
11 changed files with 243 additions and 28 deletions
|
|
@ -45,7 +45,7 @@ context:
|
|||
enabled: true # Observe channel traffic for LLM context
|
||||
observe_channels: [] # Channel indices to observe (empty = all)
|
||||
ignore_nodes: [] # Node IDs to exclude from observation
|
||||
max_age: 2592000 # Max age in seconds (default 30 days)
|
||||
max_age: 1209600 # Max age in seconds (default 14 days)
|
||||
max_context_items: 20 # Max observations injected into LLM context
|
||||
|
||||
# === LLM BACKEND ===
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ export default function MeshCoreConnection() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">Channels to monitor (none selected = observe all)</p>
|
||||
<p className="text-xs text-slate-600">Choose which MeshCore channels feed MeshAI's context. Empty = none are watched — pick channels to include their chatter in what the bot knows about the mesh. Leave busy/public channels out to keep them out of context.</p>
|
||||
</div>
|
||||
<ListInput
|
||||
label="Ignore MeshCore Contacts"
|
||||
|
|
@ -313,11 +313,10 @@ export default function MeshCoreConnection() {
|
|||
info="Messages from these MeshCore contacts won't be included in passive context. Enter contact names or public-key prefixes."
|
||||
/>
|
||||
<Toggle
|
||||
label="Respond to DMs"
|
||||
label="Answer direct messages"
|
||||
checked={!!mcContext.respond_to_dms}
|
||||
onChange={(v) => setMcContext({ ...mcContext, respond_to_dms: v })}
|
||||
helper="Reply when someone sends a MeshCore direct message"
|
||||
info="When enabled, the bot responds to MeshCore direct messages. When disabled, it only responds to channel messages that mention its name."
|
||||
helper="When on, MeshAI replies to MeshCore direct messages using the LLM. Applies to MeshCore only."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -235,11 +235,10 @@ export default function MeshtasticConnection() {
|
|||
info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
|
||||
/>
|
||||
<Toggle
|
||||
label="Respond to DMs"
|
||||
label="Answer direct messages"
|
||||
checked={!!bot.respond_to_dms}
|
||||
onChange={(v) => setBot({ ...bot, respond_to_dms: v })}
|
||||
helper="Reply when someone sends a direct message"
|
||||
info="When enabled, the bot responds to direct messages from any node. When disabled, the bot only responds to channel messages that mention its name."
|
||||
helper="When on, MeshAI replies to Meshtastic direct messages using the LLM. Applies to Meshtastic only."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ context:
|
|||
enabled: true
|
||||
observe_channels: []
|
||||
ignore_nodes: []
|
||||
max_age: 2592000
|
||||
max_age: 1209600
|
||||
max_context_items: 20
|
||||
|
||||
llm:
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ class ContextConfig:
|
|||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
observe_channels: list[int] = field(default_factory=list) # Empty = all channels
|
||||
ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore
|
||||
max_age: int = 2_592_000 # 30 days in seconds
|
||||
max_age: int = 1_209_600 # 14 days in seconds
|
||||
max_context_items: int = 20 # Max observations injected into LLM context
|
||||
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ class ContextConfig:
|
|||
class MeshCoreContextConfig:
|
||||
"""MeshCore passive-context / bot-behavior settings (MeshCore-native)."""
|
||||
enable_passive_context: bool = True
|
||||
observe_channels: list[str] = field(default_factory=list) # channel NAMES, empty = all
|
||||
observe_channels: list[str] = field(default_factory=list) # channel NAMES, empty = none (opt-in): only listed channels feed context
|
||||
ignore_contacts: list[str] = field(default_factory=list) # contact names or pubkey prefixes
|
||||
respond_to_dms: bool = True
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class MeshObservation:
|
|||
channel: int
|
||||
is_dm: bool
|
||||
text: str
|
||||
transport: str = "meshtastic"
|
||||
|
||||
|
||||
class MeshContext:
|
||||
|
|
@ -58,6 +59,7 @@ class MeshContext:
|
|||
text: str,
|
||||
channel: int,
|
||||
is_dm: bool,
|
||||
transport: str = "meshtastic",
|
||||
) -> None:
|
||||
"""Record an observed mesh message.
|
||||
|
||||
|
|
@ -67,12 +69,17 @@ class MeshContext:
|
|||
text: Message text
|
||||
channel: Channel index
|
||||
is_dm: Whether this was a DM
|
||||
transport: Origin transport ("meshtastic" or "meshcore")
|
||||
"""
|
||||
# Filter by node
|
||||
if sender_id in self._ignore_nodes:
|
||||
return
|
||||
|
||||
# Filter by channel (None = observe all)
|
||||
# Filter by channel (None = observe all) — applies ONLY to Meshtastic
|
||||
# observations because _observe_channels contains Meshtastic channel
|
||||
# indices which have no relationship to MeshCore slot indices.
|
||||
# MeshCore channel filtering is handled at the transport layer.
|
||||
if transport == "meshtastic":
|
||||
if self._observe_channels is not None and channel not in self._observe_channels:
|
||||
return
|
||||
|
||||
|
|
@ -83,9 +90,10 @@ class MeshContext:
|
|||
channel=channel,
|
||||
is_dm=is_dm,
|
||||
text=text,
|
||||
transport=transport,
|
||||
)
|
||||
self._buffer.append(obs)
|
||||
logger.debug(f"Observed: ch{channel} {sender_name}: {text[:40]}...")
|
||||
logger.debug(f"Observed: ch{channel} {sender_name} [{transport}]: {text[:40]}...")
|
||||
|
||||
def prune(self) -> int:
|
||||
"""Remove observations older than max_age.
|
||||
|
|
@ -107,11 +115,14 @@ class MeshContext:
|
|||
logger.info(f"Pruned {pruned} expired mesh observations ({len(self._buffer)} remaining)")
|
||||
return pruned
|
||||
|
||||
def get_context_block(self, max_items: int = 20) -> str:
|
||||
def get_context_block(self, max_items: int = 20, transport: Optional[str] = None) -> str:
|
||||
"""Format recent observations as a context block for the LLM.
|
||||
|
||||
Args:
|
||||
max_items: Maximum observations to include
|
||||
transport: When provided, include only observations from this
|
||||
transport ("meshtastic" or "meshcore"). When None, include
|
||||
all observations (backward-compatible).
|
||||
|
||||
Returns:
|
||||
Formatted context string, or empty string if no observations
|
||||
|
|
@ -123,6 +134,8 @@ class MeshContext:
|
|||
for obs in reversed(self._buffer):
|
||||
if len(recent) >= max_items:
|
||||
break
|
||||
if transport is not None and obs.transport != transport:
|
||||
continue
|
||||
recent.append(obs)
|
||||
|
||||
if not recent:
|
||||
|
|
|
|||
|
|
@ -636,6 +636,7 @@ class MeshAI:
|
|||
text=message.text,
|
||||
channel=message.channel,
|
||||
is_dm=False,
|
||||
transport=message.transport,
|
||||
)
|
||||
|
||||
# Check if we should respond
|
||||
|
|
|
|||
|
|
@ -341,7 +341,10 @@ class MessageRouter:
|
|||
if not message.is_dm:
|
||||
return False
|
||||
|
||||
if not self.config.bot.respond_to_dms:
|
||||
# bot.respond_to_dms is the Meshtastic-only toggle; MeshCore DMs are
|
||||
# governed solely by meshcore_context.respond_to_dms, enforced at the
|
||||
# transport level before the message ever reaches here.
|
||||
if message.transport != "meshcore" and not self.config.bot.respond_to_dms:
|
||||
return False
|
||||
|
||||
# Ignore advBBS protocol and notification messages
|
||||
|
|
@ -741,18 +744,31 @@ class MessageRouter:
|
|||
if commands_summary:
|
||||
system_prompt += "\n\n" + commands_summary
|
||||
|
||||
# 4. Inject mesh context if available
|
||||
# 4. Inject mesh context if available, scoped to the originating mesh
|
||||
# (with explicit override if the query names the other mesh).
|
||||
if self.context:
|
||||
max_items = getattr(self.config.context, 'max_context_items', 20)
|
||||
context_block = self.context.get_context_block(max_items=max_items)
|
||||
origin = getattr(message, "transport", "meshtastic")
|
||||
# Determine target mesh: default to origin. Override if the query
|
||||
# explicitly names the other mesh and NOT the origin mesh.
|
||||
target = origin
|
||||
q_lower = query.lower() if query else ""
|
||||
names_meshcore = "meshcore" in q_lower or "mc mesh" in q_lower
|
||||
names_meshtastic = "meshtastic" in q_lower or "mt mesh" in q_lower
|
||||
if names_meshcore and not names_meshtastic:
|
||||
target = "meshcore"
|
||||
elif names_meshtastic and not names_meshcore:
|
||||
target = "meshtastic"
|
||||
context_block = self.context.get_context_block(max_items=max_items, transport=target)
|
||||
mesh_label = "MeshCore" if target == "meshcore" else "Meshtastic"
|
||||
if context_block:
|
||||
system_prompt += (
|
||||
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
|
||||
f"\n\n--- Recent {mesh_label} mesh traffic (for context only, not messages to you) ---\n"
|
||||
+ context_block
|
||||
)
|
||||
else:
|
||||
system_prompt += (
|
||||
"\n\n[No recent mesh traffic observed yet.]"
|
||||
f"\n\n[No recent {mesh_label} mesh traffic observed yet.]"
|
||||
)
|
||||
|
||||
# 5. Knowledge base retrieval
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ def mc_context_allows(cfg, msg, idx_to_name):
|
|||
# channel (non-DM) message -> only relevant for passive context
|
||||
if not cfg.enable_passive_context:
|
||||
return False
|
||||
if cfg.observe_channels:
|
||||
# observe_channels is opt-in: empty = observe NONE; a channel message
|
||||
# passes ONLY if observe_channels is non-empty AND the resolved channel
|
||||
# name is in the list.
|
||||
if not cfg.observe_channels:
|
||||
return False
|
||||
name = idx_to_name.get(msg.channel)
|
||||
if name is None or name not in cfg.observe_channels:
|
||||
return False
|
||||
|
|
|
|||
178
work/tests/test_llm_scoping.py
Normal file
178
work/tests/test_llm_scoping.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""Tests for per-mesh LLM scoping changes.
|
||||
|
||||
Covers:
|
||||
(a) should_respond DM gate decoupling (MeshCore vs Meshtastic)
|
||||
(b) get_context_block(transport=...) per-mesh filtering
|
||||
(c) Meshtastic channel-index filter is skipped for MeshCore observations
|
||||
(d) ContextConfig.max_age default is 14 days (1_209_600 seconds)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub optional heavy deps so meshai.router can be imported in this env.
|
||||
# These stubs are set before any meshai.router import in this process;
|
||||
# they are no-ops in production where the real packages are installed.
|
||||
# ---------------------------------------------------------------------------
|
||||
for _mod in ("openai", "aiosqlite", "anthropic", "google", "google.genai"):
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
from meshai.config import ( # noqa: E402
|
||||
BotConfig,
|
||||
Config,
|
||||
ContextConfig,
|
||||
MeshCoreContextConfig,
|
||||
)
|
||||
from meshai.connector import MeshMessage # noqa: E402
|
||||
from meshai.context import MeshContext # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_message(transport="meshtastic", is_dm=True, text="hello"):
|
||||
return MeshMessage(
|
||||
sender_id="abc1",
|
||||
sender_name="Alice",
|
||||
text=text,
|
||||
channel=0,
|
||||
is_dm=is_dm,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
|
||||
def _make_router(respond_to_dms: bool, meshcore_respond_to_dms: bool = True):
|
||||
"""Build a minimal Router with mocked dependencies."""
|
||||
from meshai.router import MessageRouter # noqa: PLC0415
|
||||
|
||||
config = Config()
|
||||
config.bot = BotConfig(respond_to_dms=respond_to_dms)
|
||||
config.meshcore_context = MeshCoreContextConfig(
|
||||
respond_to_dms=meshcore_respond_to_dms
|
||||
)
|
||||
|
||||
connector = MagicMock()
|
||||
connector.my_node_id = "zzzz"
|
||||
|
||||
router = MessageRouter.__new__(MessageRouter)
|
||||
router.config = config
|
||||
router.connector = connector
|
||||
router.meshmonitor_sync = None
|
||||
router.continuations = MagicMock()
|
||||
router.continuations.has_pending.return_value = False
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) should_respond — DM gate decoupling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_meshcore_dm_passes_when_bot_respond_to_dms_false():
|
||||
"""MeshCore DM should reach should_respond=True even if bot.respond_to_dms=False.
|
||||
|
||||
The global bot.respond_to_dms toggle is Meshtastic-only; MeshCore DMs
|
||||
are pre-filtered at the transport by meshcore_context.respond_to_dms.
|
||||
"""
|
||||
router = _make_router(respond_to_dms=False, meshcore_respond_to_dms=True)
|
||||
msg = _make_message(transport="meshcore", is_dm=True)
|
||||
assert router.should_respond(msg) is True
|
||||
|
||||
|
||||
def test_meshtastic_dm_blocked_when_bot_respond_to_dms_false():
|
||||
"""Meshtastic DM must be blocked when bot.respond_to_dms=False."""
|
||||
router = _make_router(respond_to_dms=False)
|
||||
msg = _make_message(transport="meshtastic", is_dm=True)
|
||||
assert router.should_respond(msg) is False
|
||||
|
||||
|
||||
def test_meshtastic_dm_passes_when_bot_respond_to_dms_true():
|
||||
"""Meshtastic DM passes when bot.respond_to_dms=True."""
|
||||
router = _make_router(respond_to_dms=True)
|
||||
msg = _make_message(transport="meshtastic", is_dm=True)
|
||||
assert router.should_respond(msg) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b) get_context_block(transport=...) — per-mesh filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_context_block_no_transport_filter_returns_all():
|
||||
ctx = MeshContext()
|
||||
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
|
||||
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
|
||||
block = ctx.get_context_block(transport=None)
|
||||
assert "hello from MT" in block
|
||||
assert "hello from MC" in block
|
||||
|
||||
|
||||
def test_get_context_block_transport_meshtastic_only():
|
||||
ctx = MeshContext()
|
||||
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
|
||||
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
|
||||
block = ctx.get_context_block(transport="meshtastic")
|
||||
assert "hello from MT" in block
|
||||
assert "hello from MC" not in block
|
||||
|
||||
|
||||
def test_get_context_block_transport_meshcore_only():
|
||||
ctx = MeshContext()
|
||||
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
|
||||
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
|
||||
block = ctx.get_context_block(transport="meshcore")
|
||||
assert "hello from MC" in block
|
||||
assert "hello from MT" not in block
|
||||
|
||||
|
||||
def test_get_context_block_empty_when_no_matching_transport():
|
||||
ctx = MeshContext()
|
||||
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
|
||||
block = ctx.get_context_block(transport="meshcore")
|
||||
assert block == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (c) Meshtastic channel-index filter is skipped for MeshCore observations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_meshtastic_channel_filter_skipped_for_meshcore():
|
||||
"""MeshCore observations bypass the Meshtastic _observe_channels index filter.
|
||||
|
||||
observe_channels=[0] would normally block channel=5 for Meshtastic, but a
|
||||
MeshCore observation with channel=5 must still be recorded.
|
||||
"""
|
||||
ctx = MeshContext(observe_channels=[0]) # only Meshtastic ch0 allowed
|
||||
# MeshCore channel slot 5: should NOT be filtered by Meshtastic indices
|
||||
ctx.observe("MCNode", "mc1", "meshcore msg", channel=5, is_dm=False, transport="meshcore")
|
||||
# Meshtastic ch5: should be filtered out
|
||||
ctx.observe("MTNode", "mt1", "meshtastic msg", channel=5, is_dm=False, transport="meshtastic")
|
||||
|
||||
assert ctx.count == 1
|
||||
block = ctx.get_context_block()
|
||||
assert "meshcore msg" in block
|
||||
assert "meshtastic msg" not in block
|
||||
|
||||
|
||||
def test_meshtastic_channel_filter_still_applies_to_meshtastic():
|
||||
"""Meshtastic channel-index filter still blocks unlisted Meshtastic channels."""
|
||||
ctx = MeshContext(observe_channels=[0])
|
||||
ctx.observe("MTNode", "mt1", "ch0 msg", channel=0, is_dm=False, transport="meshtastic")
|
||||
ctx.observe("MTNode", "mt1", "ch1 msg", channel=1, is_dm=False, transport="meshtastic")
|
||||
assert ctx.count == 1
|
||||
block = ctx.get_context_block()
|
||||
assert "ch0 msg" in block
|
||||
assert "ch1 msg" not in block
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (d) ContextConfig.max_age default is 14 days
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_context_config_max_age_default_is_14_days():
|
||||
cfg = ContextConfig()
|
||||
assert cfg.max_age == 1_209_600, (
|
||||
f"Expected 1_209_600 (14 days) but got {cfg.max_age}"
|
||||
)
|
||||
|
|
@ -39,9 +39,12 @@ def test_cfg_none_always_passes():
|
|||
|
||||
|
||||
def test_defaults_pass_through():
|
||||
# With opt-in semantics: empty observe_channels = observe NONE for channels.
|
||||
# DMs still pass (respond_to_dms=True by default).
|
||||
cfg = MeshCoreContextConfig() # empty lists, passive on, respond_to_dms on
|
||||
idx_to_name = {1: "#general"}
|
||||
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is True
|
||||
# Channel: empty observe_channels now means NONE are observed (opt-in)
|
||||
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is False
|
||||
assert mc_context_allows(cfg, _dm(), idx_to_name) is True
|
||||
|
||||
|
||||
|
|
@ -67,12 +70,14 @@ def test_ignore_contacts_matches_id_or_name():
|
|||
|
||||
|
||||
def test_respond_to_dms_false_drops_dms_only():
|
||||
cfg = MeshCoreContextConfig(respond_to_dms=False)
|
||||
# observe_channels must be non-empty for channel msgs to be forwarded
|
||||
# (opt-in: empty = none). Only DM behavior is being tested here.
|
||||
cfg = MeshCoreContextConfig(respond_to_dms=False, observe_channels=["#general"])
|
||||
idx_to_name = {1: "#general"}
|
||||
# any DM dropped
|
||||
assert mc_context_allows(cfg, _dm(), idx_to_name) is False
|
||||
assert mc_context_allows(cfg, _dm(sender_id="zzzz", sender_name="Zed"), idx_to_name) is False
|
||||
# channel msgs unaffected (passive still on)
|
||||
# channel msgs unaffected (passive still on, channel in observe list)
|
||||
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is True
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue