Phase 2.4: LLM-summarized digest with master toggle filter

- Remove severity-based fork; tee pattern sends all events to both dispatcher and accumulator
- Add ToggleFilter before tee; drops events for disabled toggles
- Rework DigestAccumulator: event log instead of active/resolved tracking
- render_digest now async, calls LLM once per toggle with severity-ordered events
- Fallback to count-based summary when LLM unavailable
- Add TogglesConfig to config.py for master toggle settings
- Update scheduler to await async render_digest
- 75 tests passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-15 02:37:12 +00:00
commit 9674e94efb
9 changed files with 858 additions and 1023 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,9 @@
"""Tests for DigestScheduler (Phase 2.3b).
"""Tests for DigestScheduler (Phase 2.3b + 2.4).
Uses asyncio.run() since pytest-asyncio is not available in the container.
Updated in Phase 2.4: render_digest is now async, accumulator mocks
must return awaitables.
"""
import asyncio
@ -8,12 +11,12 @@ import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
from unittest.mock import MagicMock, call
from unittest.mock import MagicMock, AsyncMock, call
import pytest
from meshai.notifications.events import make_event
from meshai.notifications.pipeline.digest import DigestAccumulator
from meshai.notifications.pipeline.digest import DigestAccumulator, Digest
from meshai.notifications.pipeline.scheduler import DigestScheduler
@ -61,6 +64,12 @@ class MockChannel:
self.deliveries.append(payload)
class MockLLMBackend:
"""Mock LLM backend for accumulator."""
async def generate(self, messages, system_prompt, max_tokens=200):
return "Mock summary."
def make_scheduler(
schedule: str = "07:00",
rules: Optional[list] = None,
@ -90,7 +99,8 @@ def make_scheduler(
return ch
if accumulator is None:
accumulator = DigestAccumulator()
# Use mock LLM backend for async render_digest
accumulator = DigestAccumulator(llm_backend=MockLLMBackend())
scheduler = DigestScheduler(
accumulator=accumulator,
@ -124,37 +134,31 @@ class TestScheduleComputation:
def test_parse_schedule_invalid_falls_back(self):
"""Invalid schedules fall back to 07:00."""
scheduler, _, _ = make_scheduler()
# Bad format
assert scheduler._parse_schedule("7:00:00") == (7, 0)
assert scheduler._parse_schedule("invalid") == (7, 0)
assert scheduler._parse_schedule("") == (7, 0)
# Out of range
assert scheduler._parse_schedule("25:00") == (7, 0)
assert scheduler._parse_schedule("12:60") == (7, 0)
def test_next_fire_at_future_today(self):
"""If schedule time is later today, returns today's timestamp."""
# Set clock to 06:00 on a known date
base_dt = datetime(2024, 6, 15, 6, 0, 0)
base_ts = base_dt.timestamp()
scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts)
next_fire = scheduler._next_fire_at(base_ts)
# Should be 07:00 same day
expected_dt = datetime(2024, 6, 15, 7, 0, 0)
assert abs(next_fire - expected_dt.timestamp()) < 1
def test_next_fire_at_past_today_schedules_tomorrow(self):
"""If schedule time has passed today, returns tomorrow's timestamp."""
# Set clock to 08:00 on a known date
base_dt = datetime(2024, 6, 15, 8, 0, 0)
base_ts = base_dt.timestamp()
scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts)
next_fire = scheduler._next_fire_at(base_ts)
# Should be 07:00 next day
expected_dt = datetime(2024, 6, 16, 7, 0, 0)
assert abs(next_fire - expected_dt.timestamp()) < 1
@ -166,7 +170,6 @@ class TestScheduleComputation:
scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts)
next_fire = scheduler._next_fire_at(base_ts)
# Should be 07:00 next day
expected_dt = datetime(2024, 6, 16, 7, 0, 0)
assert abs(next_fire - expected_dt.timestamp()) < 1
@ -181,7 +184,7 @@ class TestScheduleComputation:
config.notifications.digest = None
scheduler = DigestScheduler(
accumulator=DigestAccumulator(),
accumulator=DigestAccumulator(llm_backend=MockLLMBackend()),
config=config,
channel_factory=lambda r: MockChannel(),
)
@ -195,8 +198,7 @@ class TestFireBehavior:
def test_fire_delivers_to_matching_rule(self):
"""_fire() delivers digest to rules with schedule_match='digest'."""
accumulator = DigestAccumulator()
# Add an event so digest has content
accumulator = DigestAccumulator(llm_backend=MockLLMBackend())
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
@ -223,7 +225,6 @@ class TestFireBehavior:
payload = ch.deliveries[0]
assert payload["category"] == "digest"
assert payload["severity"] == "routine"
assert "Test alert" in payload["message"] or "Weather" in payload["message"]
def test_fire_skips_disabled_rules(self):
"""Disabled rules are not delivered to."""
@ -236,7 +237,6 @@ class TestFireBehavior:
asyncio.run(run_fire())
# Channel should not be created for disabled rule
assert "disabled" not in channels
def test_fire_skips_non_schedule_rules(self):
@ -265,8 +265,10 @@ class TestFireBehavior:
def test_fire_mesh_delivery_chunks(self):
"""Mesh delivery types get per-chunk delivery."""
accumulator = DigestAccumulator(mesh_char_limit=100)
# Add multiple events to force chunking
accumulator = DigestAccumulator(
llm_backend=MockLLMBackend(),
mesh_char_limit=100,
)
for i in range(5):
accumulator.enqueue(make_event(
source="test",
@ -289,16 +291,14 @@ class TestFireBehavior:
asyncio.run(run_fire())
ch = channels["mesh"]
# Should have multiple deliveries (one per chunk)
assert len(ch.deliveries) >= 1
# Check chunk metadata
for payload in ch.deliveries:
assert "chunk_index" in payload
assert "chunk_total" in payload
def test_fire_email_delivery_full_text(self):
"""Email delivery type gets single full-text delivery."""
accumulator = DigestAccumulator()
accumulator = DigestAccumulator(llm_backend=MockLLMBackend())
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
@ -321,7 +321,7 @@ class TestFireBehavior:
assert len(ch.deliveries) == 1
payload = ch.deliveries[0]
assert "chunk_index" not in payload
assert "--- " in payload["message"] # Full format has header
assert "--- " in payload["message"]
def test_fire_updates_last_fire_at(self):
"""_fire() updates last_fire_at timestamp."""
@ -402,9 +402,7 @@ class TestLifecycle:
scheduler, _, _ = make_scheduler()
async def run_stop():
# Never started
await scheduler.stop()
# Should not raise
asyncio.run(run_stop())
@ -414,10 +412,8 @@ class TestLifecycle:
async def fake_sleep(duration):
sleep_calls.append(duration)
# Actually sleep briefly so we can cancel
await asyncio.sleep(0.01)
# Set clock far from schedule time to get long sleep
base_dt = datetime(2024, 6, 15, 8, 0, 0)
scheduler, _, _ = make_scheduler(
schedule="07:00",
@ -427,14 +423,11 @@ class TestLifecycle:
async def run_test():
await scheduler.start()
# Give task time to enter sleep
await asyncio.sleep(0.05)
await scheduler.stop()
asyncio.run(run_test())
# Task should have exited cleanly
# ---- Integration Tests ----
@ -444,9 +437,8 @@ class TestIntegration:
def test_scheduler_fires_on_schedule(self):
"""Scheduler fires when schedule time arrives."""
fire_times = []
accumulator = DigestAccumulator()
accumulator = DigestAccumulator(llm_backend=MockLLMBackend())
# Start at 06:59:59.95 (50ms before 07:00), delay will be ~50ms
clock_time = [datetime(2024, 6, 15, 6, 59, 59, 950000).timestamp()]
def fake_clock():
@ -458,31 +450,27 @@ class TestIntegration:
accumulator=accumulator,
)
# Track when fire happens
original_fire = scheduler._fire
async def tracking_fire(now):
fire_times.append(now)
await original_fire(now)
# After first fire, advance clock so next cycle has long delay
clock_time[0] = datetime(2024, 6, 15, 8, 0, 0).timestamp()
scheduler._fire = tracking_fire
async def run_test():
await scheduler.start()
# Wait for the ~50ms delay plus some buffer
await asyncio.sleep(0.2)
await scheduler.stop()
asyncio.run(run_test())
# Should have fired once
assert len(fire_times) >= 1
def test_scheduler_multiple_rules(self):
"""Scheduler delivers to multiple matching rules."""
accumulator = DigestAccumulator()
accumulator = DigestAccumulator(llm_backend=MockLLMBackend())
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
@ -507,7 +495,6 @@ class TestIntegration:
asyncio.run(run_fire())
# All three should have received deliveries
assert "mesh1" in channels
assert "mesh2" in channels
assert "email" in channels
@ -517,7 +504,7 @@ class TestIntegration:
def test_scheduler_handles_delivery_error(self):
"""Scheduler continues after delivery error."""
accumulator = DigestAccumulator()
accumulator = DigestAccumulator(llm_backend=MockLLMBackend())
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
@ -554,7 +541,6 @@ class TestIntegration:
asyncio.run(run_fire())
# Both rules should have been attempted
assert "bad" in call_order
assert "good" in call_order

View file

@ -2,6 +2,10 @@
These tests verify the core routing and dispatch behavior of the
notification pipeline without requiring real channel backends.
Updated in Phase 2.4: Events now go to BOTH dispatcher and accumulator
(no severity-based fork). SeverityRouter class kept for backward
compatibility but not used in production wiring.
"""
import pytest
@ -39,6 +43,7 @@ class ConfigStub:
class TestImmediateDispatch:
def test_immediate_event_with_matching_rule_dispatches(self):
"""Immediate events reach the dispatcher and get delivered."""
rule = NotificationRuleConfigStub(
enabled=True,
trigger_type="condition",
@ -74,72 +79,111 @@ class TestImmediateDispatch:
assert alert["message"]
class TestDigestRouting:
class TestTeeRouting:
"""Phase 2.4: Events go to BOTH dispatcher and accumulator."""
def test_routine_event_goes_to_digest_not_dispatcher(self):
def test_routine_event_goes_to_both_dispatcher_and_accumulator(self):
"""Routine events reach both dispatcher and accumulator in Phase 2.4."""
rule = NotificationRuleConfigStub(
enabled=True,
trigger_type="condition",
categories=["test_cat"],
min_severity="routine",
delivery_type="mesh_broadcast",
)
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[rule])
)
mock_factory = Mock()
bus = EventBus()
dispatcher = Dispatcher(config, mock_factory)
digest = StubDigestQueue()
with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch:
router = SeverityRouter(
immediate_handler=mock_dispatch,
digest_handler=digest.enqueue,
)
bus.subscribe(router.handle)
event = make_event(
source="test",
category="test_cat",
severity="routine",
title="Routine Alert",
)
bus.emit(event)
assert len(digest) == 1
mock_dispatch.assert_not_called()
mock_channel = Mock()
mock_factory = Mock(return_value=mock_channel)
def test_priority_event_goes_to_digest_not_dispatcher(self):
# Create dispatcher and track calls
dispatcher = Dispatcher(config, mock_factory)
dispatch_calls = []
original_dispatch = dispatcher.dispatch
def tracking_dispatch(event):
dispatch_calls.append(event)
original_dispatch(event)
dispatcher.dispatch = tracking_dispatch
# Create accumulator mock
accumulator_calls = []
def mock_enqueue(event):
accumulator_calls.append(event)
# Tee closure (Phase 2.4 pattern)
def tee(event):
dispatcher.dispatch(event)
mock_enqueue(event)
bus = EventBus()
bus.subscribe(tee)
event = make_event(
source="test",
category="test_cat",
severity="routine",
title="Routine Alert",
)
bus.emit(event)
# Both paths received the event
assert len(dispatch_calls) == 1
assert len(accumulator_calls) == 1
# Dispatcher found a matching rule and delivered
assert mock_channel.deliver.call_count == 1
def test_priority_event_goes_to_both_dispatcher_and_accumulator(self):
"""Priority events reach both dispatcher and accumulator in Phase 2.4."""
rule = NotificationRuleConfigStub(
enabled=True,
trigger_type="condition",
categories=["test_cat"],
min_severity="routine",
delivery_type="mesh_broadcast",
)
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[rule])
)
mock_factory = Mock()
bus = EventBus()
mock_channel = Mock()
mock_factory = Mock(return_value=mock_channel)
dispatcher = Dispatcher(config, mock_factory)
digest = StubDigestQueue()
with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch:
router = SeverityRouter(
immediate_handler=mock_dispatch,
digest_handler=digest.enqueue,
)
bus.subscribe(router.handle)
event = make_event(
source="test",
category="test_cat",
severity="priority",
title="Priority Alert",
)
bus.emit(event)
assert len(digest) == 1
mock_dispatch.assert_not_called()
dispatch_calls = []
original_dispatch = dispatcher.dispatch
def tracking_dispatch(event):
dispatch_calls.append(event)
original_dispatch(event)
dispatcher.dispatch = tracking_dispatch
accumulator_calls = []
def mock_enqueue(event):
accumulator_calls.append(event)
def tee(event):
dispatcher.dispatch(event)
mock_enqueue(event)
bus = EventBus()
bus.subscribe(tee)
event = make_event(
source="test",
category="test_cat",
severity="priority",
title="Priority Alert",
)
bus.emit(event)
assert len(dispatch_calls) == 1
assert len(accumulator_calls) == 1
assert mock_channel.deliver.call_count == 1
class TestNoMatchingRule:
def test_immediate_event_with_no_matching_rule_skips_silently(self):
"""Events with no matching rules don't crash."""
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[])
)
@ -165,6 +209,7 @@ class TestNoMatchingRule:
class TestSubscriberIsolation:
def test_subscriber_exception_isolation(self):
"""Exceptions in one subscriber don't affect others."""
bus = EventBus()
def failing_handler(event):
@ -186,6 +231,7 @@ class TestSubscriberIsolation:
class TestUnknownSeverity:
def test_unknown_severity_dropped_without_crash(self):
"""Events with unknown severity are dropped gracefully."""
config = ConfigStub(
notifications=NotificationsConfigStub(rules=[])
)

View file

@ -0,0 +1,116 @@
"""Tests for ToggleFilter (Phase 2.4)."""
import pytest
from meshai.notifications.events import make_event
from meshai.notifications.pipeline.toggle_filter import ToggleFilter
from meshai.notifications.pipeline import build_pipeline_components
from meshai.config import Config
class TestToggleFilter:
"""Unit tests for ToggleFilter."""
def test_toggle_filter_passes_through_when_enabled_is_none(self):
"""Filter with enabled_toggles=None passes all events."""
received = []
filter_ = ToggleFilter(
next_handler=received.append,
enabled_toggles=None,
)
event = make_event(
source="test",
category="weather_warning",
severity="priority",
title="Test",
)
filter_.handle(event)
assert len(received) == 1
assert received[0] is event
def test_toggle_filter_drops_event_when_toggle_not_enabled(self):
"""Filter drops events whose toggle isn't in enabled set."""
received = []
filter_ = ToggleFilter(
next_handler=received.append,
enabled_toggles={"weather"},
)
# wildfire_proximity maps to "fire" toggle
event = make_event(
source="test",
category="wildfire_proximity",
severity="priority",
title="Fire",
)
filter_.handle(event)
assert len(received) == 0
def test_toggle_filter_passes_event_when_toggle_enabled(self):
"""Filter passes events whose toggle is in enabled set."""
received = []
filter_ = ToggleFilter(
next_handler=received.append,
enabled_toggles={"weather"},
)
event = make_event(
source="test",
category="weather_warning",
severity="priority",
title="Weather",
)
filter_.handle(event)
assert len(received) == 1
def test_toggle_filter_drops_unknown_category_when_filter_active(self):
"""Unknown category maps to 'other', dropped if 'other' not enabled."""
received = []
filter_ = ToggleFilter(
next_handler=received.append,
enabled_toggles={"weather"},
)
event = make_event(
source="test",
category="bogus_category",
severity="priority",
title="Unknown",
)
filter_.handle(event)
# "bogus_category" has no toggle mapping, falls back to "other"
# "other" is not in enabled set
assert len(received) == 0
def test_toggle_filter_passes_other_when_enabled(self):
"""'other' toggle passes unknown categories when enabled."""
received = []
filter_ = ToggleFilter(
next_handler=received.append,
enabled_toggles={"other"},
)
event = make_event(
source="test",
category="bogus_category",
severity="priority",
title="Unknown",
)
filter_.handle(event)
assert len(received) == 1
class TestToggleFilterPipelineWiring:
"""Integration tests for toggle filter in pipeline."""
def test_toggle_filter_pipeline_drops_disabled_toggle(self):
"""Events for disabled toggles don't reach dispatcher or accumulator."""
# Create config with only weather enabled
config = Config()
# We'll check by using build_pipeline_components and inspecting
# In Phase 2.4, build_pipeline_components returns toggle_filter
# Note: without toggles.enabled set, filter is a no-op
# This test verifies the wiring is correct
bus, inhibitor, grouper, toggle_filter, dispatcher, accumulator = \
build_pipeline_components(config)
# Verify toggle_filter is in the chain
assert toggle_filter is not None
assert hasattr(toggle_filter, 'handle')