mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
feat(notifications): add Phase 1.3 + 2.1 pipeline skeleton
Phase 1.3: - events.py: Event dataclass with ID generation and serialization - region_tagger.py: Coordinate/NWS zone region tagging - categories.py: Toggle field mapping for all 31 alert categories Phase 2.1 Pipeline Skeleton: - pipeline/bus.py: EventBus with subscribe/emit pattern - pipeline/severity_router.py: Routes immediate->dispatch, routine->digest - pipeline/dispatcher.py: Delivers immediate events to configured channels - pipeline/__init__.py: build_pipeline() factory and exports All components tested and verified in container. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0703d00d94
commit
4e4a837c5e
4 changed files with 420 additions and 0 deletions
88
meshai/notifications/pipeline/__init__.py
Normal file
88
meshai/notifications/pipeline/__init__.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Notification pipeline package.
|
||||
|
||||
Phase 2.1 provides the bare skeleton:
|
||||
- EventBus: Central pub/sub for all events
|
||||
- SeverityRouter: Routes immediate vs digest events
|
||||
- Dispatcher: Delivers immediate events to channels
|
||||
- StubDigestQueue: Placeholder for Phase 2.3 aggregator
|
||||
|
||||
Usage:
|
||||
from meshai.notifications.pipeline import build_pipeline
|
||||
|
||||
pipeline = build_pipeline(channel_config={
|
||||
"mesh_health": ["discord"],
|
||||
"weather": ["discord", "meshtastic"],
|
||||
})
|
||||
|
||||
# Emit events through the bus
|
||||
pipeline["bus"].emit(event)
|
||||
"""
|
||||
|
||||
from meshai.notifications.pipeline.bus import EventBus, get_bus
|
||||
from meshai.notifications.pipeline.severity_router import (
|
||||
SeverityRouter,
|
||||
StubDigestQueue,
|
||||
)
|
||||
from meshai.notifications.pipeline.dispatcher import (
|
||||
Dispatcher,
|
||||
StubChannelBackend,
|
||||
)
|
||||
|
||||
|
||||
def build_pipeline(
|
||||
channel_config: dict[str, list[str]] | None = None,
|
||||
) -> dict:
|
||||
"""Build and wire up the notification pipeline.
|
||||
|
||||
Creates all pipeline components and connects them:
|
||||
- EventBus receives all events
|
||||
- SeverityRouter subscribes to bus, routes by severity
|
||||
- Dispatcher handles immediate events
|
||||
- StubDigestQueue collects priority/routine events
|
||||
|
||||
Args:
|
||||
channel_config: Mapping of toggle -> channel names for dispatch.
|
||||
Example: {"mesh_health": ["discord"]}
|
||||
|
||||
Returns:
|
||||
Dict with all pipeline components:
|
||||
- bus: EventBus instance
|
||||
- router: SeverityRouter instance
|
||||
- dispatcher: Dispatcher instance
|
||||
- digest_queue: StubDigestQueue instance
|
||||
"""
|
||||
# Create components
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(channel_config)
|
||||
digest_queue = StubDigestQueue()
|
||||
|
||||
# Wire up the router
|
||||
router = SeverityRouter(
|
||||
immediate_handler=dispatcher.dispatch,
|
||||
digest_handler=digest_queue.enqueue,
|
||||
)
|
||||
|
||||
# Subscribe router to bus
|
||||
bus.subscribe(router.handle)
|
||||
|
||||
return {
|
||||
"bus": bus,
|
||||
"router": router,
|
||||
"dispatcher": dispatcher,
|
||||
"digest_queue": digest_queue,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Core classes
|
||||
"EventBus",
|
||||
"SeverityRouter",
|
||||
"Dispatcher",
|
||||
# Stubs for testing/Phase 2.x
|
||||
"StubDigestQueue",
|
||||
"StubChannelBackend",
|
||||
# Factory
|
||||
"build_pipeline",
|
||||
# Singleton accessor
|
||||
"get_bus",
|
||||
]
|
||||
85
meshai/notifications/pipeline/bus.py
Normal file
85
meshai/notifications/pipeline/bus.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Event bus for the notification pipeline.
|
||||
|
||||
The bus is the entry point for all events flowing through the pipeline.
|
||||
Adapters call bus.emit(event) to push Events into the system.
|
||||
|
||||
Usage:
|
||||
from meshai.notifications.pipeline import get_bus
|
||||
from meshai.notifications.events import make_event
|
||||
|
||||
bus = get_bus()
|
||||
event = make_event(source="nws", category="weather_warning", severity="immediate", ...)
|
||||
bus.emit(event)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from meshai.notifications.events import Event
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Central event bus for the notification pipeline.
|
||||
|
||||
Subscribers register handlers that receive every emitted event.
|
||||
Errors in one subscriber do not prevent other subscribers from
|
||||
receiving the event.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: list[Callable[[Event], None]] = []
|
||||
self._logger = logging.getLogger("meshai.pipeline.bus")
|
||||
|
||||
def subscribe(self, handler: Callable[[Event], None]) -> None:
|
||||
"""Register a handler that receives every emitted event.
|
||||
|
||||
Args:
|
||||
handler: Callable that takes an Event and returns None
|
||||
"""
|
||||
self._subscribers.append(handler)
|
||||
self._logger.debug(f"Subscribed handler: {handler}")
|
||||
|
||||
def emit(self, event: Event) -> None:
|
||||
"""Push an event to all subscribers.
|
||||
|
||||
Errors in one subscriber do not stop others from receiving
|
||||
the event. Exceptions are logged but not re-raised.
|
||||
|
||||
Args:
|
||||
event: The Event to deliver to all subscribers
|
||||
"""
|
||||
for handler in self._subscribers:
|
||||
try:
|
||||
handler(event)
|
||||
except Exception:
|
||||
self._logger.exception(
|
||||
f"Subscriber {handler} failed on event {event.id}"
|
||||
)
|
||||
|
||||
def emit_many(self, events: Iterable[Event]) -> None:
|
||||
"""Emit multiple events in sequence.
|
||||
|
||||
Args:
|
||||
events: Iterable of Events to emit
|
||||
"""
|
||||
for event in events:
|
||||
self.emit(event)
|
||||
|
||||
|
||||
# Module-level singleton for application-wide use
|
||||
_bus: EventBus | None = None
|
||||
|
||||
|
||||
def get_bus() -> EventBus:
|
||||
"""Get the global EventBus singleton.
|
||||
|
||||
This is the primary way adapters access the bus. Tests should
|
||||
construct a fresh EventBus() directly to avoid shared state.
|
||||
|
||||
Returns:
|
||||
The global EventBus instance
|
||||
"""
|
||||
global _bus
|
||||
if _bus is None:
|
||||
_bus = EventBus()
|
||||
return _bus
|
||||
143
meshai/notifications/pipeline/dispatcher.py
Normal file
143
meshai/notifications/pipeline/dispatcher.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Immediate event dispatcher.
|
||||
|
||||
The dispatcher routes immediate-severity events to configured delivery
|
||||
channels based on the event's toggle category.
|
||||
|
||||
Phase 2.1 provides a stub that logs dispatch attempts. Phase 2.2 will
|
||||
add real channel backends (Discord webhooks, Meshtastic broadcast, etc.).
|
||||
|
||||
Usage:
|
||||
dispatcher = Dispatcher(channel_config)
|
||||
dispatcher.dispatch(event) # Called by SeverityRouter for immediate events
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
from meshai.notifications.events import Event
|
||||
from meshai.notifications.categories import get_toggle
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
"""Dispatches immediate events to configured channels.
|
||||
|
||||
Each toggle category can have multiple delivery channels configured.
|
||||
The dispatcher looks up the toggle for an event's category and sends
|
||||
to all channels registered for that toggle.
|
||||
|
||||
Phase 2.1: Stub implementation that logs but doesn't actually deliver.
|
||||
Phase 2.2: Will add real channel backends.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
channel_config: Optional[dict[str, list[str]]] = None,
|
||||
):
|
||||
"""Initialize the dispatcher.
|
||||
|
||||
Args:
|
||||
channel_config: Mapping of toggle -> list of channel names.
|
||||
Example: {"mesh_health": ["discord", "meshtastic"]}
|
||||
If None, defaults to empty (no channels configured).
|
||||
"""
|
||||
self._channels = channel_config or {}
|
||||
self._logger = logging.getLogger("meshai.pipeline.dispatcher")
|
||||
self._backends: dict[str, Callable[[Event], None]] = {}
|
||||
|
||||
def register_backend(
|
||||
self,
|
||||
channel_name: str,
|
||||
handler: Callable[[Event], None],
|
||||
) -> None:
|
||||
"""Register a delivery backend for a channel.
|
||||
|
||||
Args:
|
||||
channel_name: Name of the channel (e.g., "discord", "meshtastic")
|
||||
handler: Callable that delivers the event to the channel
|
||||
"""
|
||||
self._backends[channel_name] = handler
|
||||
self._logger.debug(f"Registered backend: {channel_name}")
|
||||
|
||||
def dispatch(self, event: Event) -> None:
|
||||
"""Dispatch an immediate event to configured channels.
|
||||
|
||||
Looks up the toggle for the event's category, then sends to
|
||||
all channels configured for that toggle.
|
||||
|
||||
Args:
|
||||
event: The immediate-severity Event to dispatch
|
||||
"""
|
||||
toggle = get_toggle(event.category)
|
||||
if toggle is None:
|
||||
self._logger.warning(
|
||||
f"Unknown category {event.category!r} for event {event.id}, "
|
||||
"defaulting to mesh_health"
|
||||
)
|
||||
toggle = "mesh_health"
|
||||
|
||||
channels = self._channels.get(toggle, [])
|
||||
if not channels:
|
||||
self._logger.info(
|
||||
f"No channels configured for toggle {toggle!r}, "
|
||||
f"event {event.id} not dispatched"
|
||||
)
|
||||
return
|
||||
|
||||
for channel in channels:
|
||||
self._deliver_to_channel(event, channel, toggle)
|
||||
|
||||
def _deliver_to_channel(
|
||||
self,
|
||||
event: Event,
|
||||
channel: str,
|
||||
toggle: str,
|
||||
) -> None:
|
||||
"""Deliver event to a specific channel.
|
||||
|
||||
Args:
|
||||
event: The Event to deliver
|
||||
channel: Channel name
|
||||
toggle: Toggle category (for logging)
|
||||
"""
|
||||
backend = self._backends.get(channel)
|
||||
if backend is None:
|
||||
# Phase 2.1: Log stub - no real backend yet
|
||||
self._logger.info(
|
||||
f"DISPATCH STUB [{toggle}] -> {channel}: {event.title}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
backend(event)
|
||||
self._logger.info(
|
||||
f"DISPATCHED [{toggle}] -> {channel}: {event.title}"
|
||||
)
|
||||
except Exception:
|
||||
self._logger.exception(
|
||||
f"Failed to dispatch event {event.id} to {channel}"
|
||||
)
|
||||
|
||||
|
||||
class StubChannelBackend:
|
||||
"""Stub channel backend for testing.
|
||||
|
||||
Collects all events "sent" to it for verification in tests.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.events: list[Event] = []
|
||||
self._logger = logging.getLogger(f"meshai.pipeline.stub.{name}")
|
||||
|
||||
def send(self, event: Event) -> None:
|
||||
"""Record an event as sent.
|
||||
|
||||
Args:
|
||||
event: The Event to record
|
||||
"""
|
||||
self.events.append(event)
|
||||
self._logger.info(f"STUB {self.name}: {event.title}")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear recorded events."""
|
||||
self.events = []
|
||||
104
meshai/notifications/pipeline/severity_router.py
Normal file
104
meshai/notifications/pipeline/severity_router.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Severity-based event routing.
|
||||
|
||||
The severity router subscribes to the bus and forks each event into
|
||||
one of two paths based on severity:
|
||||
|
||||
- immediate → immediate_handler (dispatcher for live delivery)
|
||||
- priority/routine → digest_handler (queue for batched summaries)
|
||||
|
||||
Usage:
|
||||
router = SeverityRouter(
|
||||
immediate_handler=dispatcher.dispatch,
|
||||
digest_handler=digest_queue.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from meshai.notifications.events import Event
|
||||
from meshai.notifications.categories import get_toggle
|
||||
|
||||
|
||||
class SeverityRouter:
|
||||
"""Routes events to immediate or digest handlers based on severity.
|
||||
|
||||
Immediate-severity events go directly to live delivery channels.
|
||||
Priority and routine events are queued for periodic digest summaries.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
immediate_handler: Callable[[Event], None],
|
||||
digest_handler: Callable[[Event], None],
|
||||
):
|
||||
"""Initialize the severity router.
|
||||
|
||||
Args:
|
||||
immediate_handler: Called for severity="immediate" events
|
||||
digest_handler: Called for severity in ("priority", "routine")
|
||||
"""
|
||||
self._immediate = immediate_handler
|
||||
self._digest = digest_handler
|
||||
self._logger = logging.getLogger("meshai.pipeline.severity_router")
|
||||
|
||||
def handle(self, event: Event) -> None:
|
||||
"""Route an event based on its severity.
|
||||
|
||||
Args:
|
||||
event: The Event to route
|
||||
"""
|
||||
if event.severity == "immediate":
|
||||
self._logger.info(
|
||||
f"IMMEDIATE: {event.source}/{event.category} {event.title}"
|
||||
)
|
||||
self._immediate(event)
|
||||
elif event.severity in ("priority", "routine"):
|
||||
self._logger.info(
|
||||
f"DIGEST QUEUED [{event.severity}]: {event.title}"
|
||||
)
|
||||
self._digest(event)
|
||||
else:
|
||||
self._logger.warning(
|
||||
f"Unknown severity {event.severity!r} on event {event.id}, dropping"
|
||||
)
|
||||
|
||||
|
||||
class StubDigestQueue:
|
||||
"""Placeholder digest queue for Phase 2.1.
|
||||
|
||||
This is a stub that simply collects events in memory. Phase 2.3
|
||||
will replace this with the real aggregator that renders and
|
||||
delivers periodic digest summaries.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._queue: list[Event] = []
|
||||
self._logger = logging.getLogger("meshai.pipeline.digest_stub")
|
||||
|
||||
def enqueue(self, event: Event) -> None:
|
||||
"""Add an event to the digest queue.
|
||||
|
||||
Args:
|
||||
event: The Event to queue for digest delivery
|
||||
"""
|
||||
self._queue.append(event)
|
||||
toggle = get_toggle(event.category) or "unknown"
|
||||
self._logger.info(f"DIGEST QUEUED [{toggle}]: {event.title}")
|
||||
|
||||
def drain(self) -> list[Event]:
|
||||
"""Return and clear all queued events.
|
||||
|
||||
For tests and the future aggregator. Returns the current
|
||||
queue contents and resets the queue to empty.
|
||||
|
||||
Returns:
|
||||
List of all queued Events
|
||||
"""
|
||||
events, self._queue = self._queue, []
|
||||
return events
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of queued events."""
|
||||
return len(self._queue)
|
||||
Loading…
Add table
Add a link
Reference in a new issue