mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 09:21:33 +00:00
Free-text broadcasts the owner types straight into the GUI on a clock-slot schedule (daily / interval_days / weekly / monthly) -- no placeholders, no data sources, no templating, no SQL from the user. - v30 migration: custom_announcements table (own explicit channel list per row, new rows start disabled). - CustomAnnouncementScheduler (notifications/scheduled/custom_announcements.py): 60s tick modelled on ReminderScheduler; monthly day-of-month clamped via calendar.monthrange; restart-safe dedup keyed on the local calendar date of last_sent_at; spacing_seconds roll-call pacing between announcements firing in the same tick. - Dispatcher.dispatch_scheduled_custom_broadcast(): delivers to the announcement's own channel list (no toggle/region_routes matrix), one mesh_broadcasts_out audit row per target, cold-start grace. - New announcement_routes.py router: GET/POST/PUT/DELETE /api/announcements + POST .../preview (wire text + char/byte count, never sends). No send-now endpoint anywhere. - Wired into notifications/pipeline/__init__.py (alongside ReminderScheduler) and dashboard/server.py; single_packet_max_chars runtime override added in main.py's budget list. 57 new tests (recurrence kinds, Feb-29/28 clamp, restart-safe dedup, pacing, budget truncation, full input validation, multi-target audit rows, no send-anywhere guarantee). Full suite: 2090 passed, 0 failed (up from 2033 baseline), 9 pre-existing warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""Notification pipeline package.
|
|
|
|
Phase 2.4:
|
|
- EventBus: pub/sub ingress
|
|
- Inhibitor: suppresses redundant events by inhibit_keys
|
|
- Grouper: coalesces events sharing group_key within a window
|
|
- ToggleFilter: drops events whose toggle isn't enabled
|
|
- Tee: sends events to both dispatcher and accumulator
|
|
- Dispatcher: routes to channels based on rules
|
|
- DigestAccumulator: logs events for LLM-summarized periodic digest
|
|
- DigestScheduler: fires digest at configured time
|
|
|
|
Usage:
|
|
from meshai.notifications.pipeline import build_pipeline, start_pipeline, stop_pipeline
|
|
bus = build_pipeline(config, llm_backend) # llm_backend from main.py
|
|
bus.emit(event)
|
|
|
|
# Async lifecycle
|
|
scheduler = await start_pipeline(bus, config)
|
|
...
|
|
await stop_pipeline(scheduler)
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from meshai.notifications.channels import create_channel
|
|
from meshai.notifications.pipeline.bus import EventBus, get_bus
|
|
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
|
try:
|
|
from meshai.notifications.scheduled.band_conditions import (
|
|
BandConditionsScheduler,
|
|
)
|
|
except ImportError:
|
|
BandConditionsScheduler = None
|
|
try:
|
|
from meshai.notifications.scheduled.wzdx_summary import (
|
|
WZDxSummaryScheduler,
|
|
)
|
|
except ImportError:
|
|
WZDxSummaryScheduler = None
|
|
try:
|
|
from meshai.notifications.reminders import ReminderScheduler
|
|
except ImportError:
|
|
ReminderScheduler = None
|
|
try:
|
|
from meshai.notifications.scheduled.custom_announcements import (
|
|
CustomAnnouncementScheduler,
|
|
)
|
|
except ImportError:
|
|
CustomAnnouncementScheduler = None
|
|
from meshai.notifications.pipeline.inhibitor import Inhibitor
|
|
from meshai.notifications.pipeline.grouper import Grouper
|
|
from meshai.notifications.pipeline.toggle_filter import ToggleFilter
|
|
from meshai.notifications.pipeline.coverage_filter import CoverageFilter
|
|
from meshai.coverage_area import areas_from_config
|
|
from meshai.notifications.pipeline.digest import DigestAccumulator, Digest
|
|
from meshai.notifications.pipeline.scheduler import DigestScheduler
|
|
|
|
|
|
_logger = logging.getLogger("meshai.pipeline")
|
|
|
|
|
|
def build_pipeline(config, llm_backend, connector=None) -> EventBus:
|
|
"""Build the pipeline and return the EventBus.
|
|
|
|
Args:
|
|
config: Full Config object.
|
|
llm_backend: An already-constructed LLMBackend instance
|
|
(from main.py or a test). Pipeline components share
|
|
this single instance. May be None for fallback behavior.
|
|
connector: Optional MeshtasticConnector for mesh channels.
|
|
|
|
Components are stashed on bus._pipeline_components for lifecycle use.
|
|
"""
|
|
bus = EventBus()
|
|
dispatcher = Dispatcher(config, create_channel, connector=connector)
|
|
|
|
# Build include_toggles from config
|
|
digest_cfg = getattr(config.notifications, "digest", None)
|
|
include_toggles = None
|
|
if digest_cfg is not None:
|
|
include_list = getattr(digest_cfg, "include", None)
|
|
if include_list:
|
|
include_toggles = list(include_list)
|
|
|
|
accumulator = DigestAccumulator(
|
|
llm_backend=llm_backend,
|
|
include_toggles=include_toggles,
|
|
mesh_char_limit=connector.max_chars if connector is not None else 140,
|
|
)
|
|
|
|
# Tee closure: events go to BOTH dispatcher and accumulator
|
|
# dispatcher.dispatch() is async, so fire-and-forget with create_task
|
|
def _tee(event):
|
|
try:
|
|
asyncio.create_task(dispatcher.dispatch(event))
|
|
except RuntimeError:
|
|
# No running event loop (e.g. sync tests) - skip async dispatch
|
|
pass
|
|
accumulator.enqueue(event)
|
|
|
|
# v0.5.13 toggle-enable read: iterate the family->NotificationToggle
|
|
# dict and collect family names whose .enabled is True. The old
|
|
# code did getattr(dict, "enabled", None) which is always None ->
|
|
# ToggleFilter passed everything through, allowing the v0.5.7-regression
|
|
# leak. The PRIMARY broadcast gate is now consumer._normalize()'s
|
|
# default-deny rule; this ToggleFilter is a secondary user-pref filter.
|
|
toggles_cfg = getattr(config.notifications, "toggles", None) or {}
|
|
enabled_toggles = set()
|
|
if isinstance(toggles_cfg, dict):
|
|
for fam_name, tog in toggles_cfg.items():
|
|
if getattr(tog, "enabled", False):
|
|
enabled_toggles.add(str(fam_name))
|
|
|
|
if not enabled_toggles:
|
|
_logger.warning(
|
|
"v0.5.13: zero toggle families are enabled -- ToggleFilter"
|
|
" will drop everything (user disabled all families)."
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"v0.5.13: ToggleFilter enabled families: %s",
|
|
sorted(enabled_toggles),
|
|
)
|
|
|
|
# Coverage filter: Shapely bbox geometry gate (set-union over all areas).
|
|
# Inserted between toggle_filter and _tee so geographic gating runs on
|
|
# every event that passes the user-preference toggle check.
|
|
# Chain: inhibitor → grouper → toggle_filter → coverage_filter → _tee
|
|
_coverage_areas = areas_from_config(config.coverage)
|
|
coverage_filter = CoverageFilter(
|
|
next_handler=_tee,
|
|
areas=_coverage_areas,
|
|
enabled=getattr(config.coverage, "enabled", True),
|
|
excluded_adapters=set(getattr(config.coverage, "excluded_adapters", None) or []),
|
|
region_tagging=getattr(config.coverage, "region_tagging", False),
|
|
)
|
|
|
|
toggle_filter = ToggleFilter(
|
|
next_handler=coverage_filter.handle,
|
|
enabled_toggles=enabled_toggles,
|
|
)
|
|
|
|
grouper = Grouper(next_handler=toggle_filter.handle)
|
|
inhibitor = Inhibitor(next_handler=grouper.handle)
|
|
bus.subscribe(inhibitor.handle)
|
|
|
|
# Stash components for lifecycle management
|
|
bus._pipeline_components = {
|
|
"inhibitor": inhibitor,
|
|
"grouper": grouper,
|
|
"toggle_filter": toggle_filter,
|
|
"coverage_filter": coverage_filter,
|
|
"dispatcher": dispatcher,
|
|
"accumulator": accumulator,
|
|
"connector": connector,
|
|
}
|
|
|
|
return bus
|
|
|
|
|
|
def build_pipeline_components(config, llm_backend, connector=None) -> tuple:
|
|
"""Like build_pipeline, but returns all components for tests.
|
|
|
|
Args:
|
|
config: Full Config object.
|
|
llm_backend: An already-constructed LLMBackend instance
|
|
(from main.py or a test). Pipeline components share
|
|
this single instance. May be None for fallback behavior.
|
|
connector: Optional MeshtasticConnector for mesh channels.
|
|
|
|
Returns:
|
|
(bus, inhibitor, grouper, toggle_filter, dispatcher, accumulator).
|
|
"""
|
|
bus = EventBus()
|
|
dispatcher = Dispatcher(config, create_channel, connector=connector)
|
|
|
|
# Build include_toggles from config
|
|
digest_cfg = getattr(config.notifications, "digest", None)
|
|
include_toggles = None
|
|
if digest_cfg is not None:
|
|
include_list = getattr(digest_cfg, "include", None)
|
|
if include_list:
|
|
include_toggles = list(include_list)
|
|
|
|
accumulator = DigestAccumulator(
|
|
llm_backend=llm_backend,
|
|
include_toggles=include_toggles,
|
|
)
|
|
|
|
# Tee closure: events go to BOTH dispatcher and accumulator
|
|
# dispatcher.dispatch() is async, so fire-and-forget with create_task
|
|
def _tee(event):
|
|
try:
|
|
asyncio.create_task(dispatcher.dispatch(event))
|
|
except RuntimeError:
|
|
# No running event loop (e.g. sync tests) - skip async dispatch
|
|
pass
|
|
accumulator.enqueue(event)
|
|
|
|
# Build enabled toggles set from config
|
|
toggles_cfg = getattr(config.notifications, "toggles", None)
|
|
enabled_toggles = None
|
|
if toggles_cfg is not None:
|
|
enabled_list = getattr(toggles_cfg, "enabled", None)
|
|
if enabled_list:
|
|
enabled_toggles = set(enabled_list)
|
|
|
|
toggle_filter = ToggleFilter(
|
|
next_handler=_tee,
|
|
enabled_toggles=enabled_toggles,
|
|
)
|
|
|
|
grouper = Grouper(next_handler=toggle_filter.handle)
|
|
inhibitor = Inhibitor(next_handler=grouper.handle)
|
|
bus.subscribe(inhibitor.handle)
|
|
|
|
return bus, inhibitor, grouper, toggle_filter, dispatcher, accumulator
|
|
|
|
|
|
async def start_pipeline(bus: EventBus, config) -> DigestScheduler:
|
|
"""Start the pipeline's async components (scheduler).
|
|
|
|
Args:
|
|
bus: EventBus returned by build_pipeline()
|
|
config: Config object with notifications.digest settings
|
|
|
|
Returns:
|
|
DigestScheduler instance (running). Call stop_pipeline() to stop.
|
|
"""
|
|
components = getattr(bus, "_pipeline_components", None)
|
|
if components is None:
|
|
raise RuntimeError("bus missing _pipeline_components; use build_pipeline()")
|
|
|
|
accumulator = components["accumulator"]
|
|
|
|
connector = components.get("connector")
|
|
scheduler = DigestScheduler(
|
|
accumulator=accumulator,
|
|
config=config,
|
|
channel_factory=create_channel,
|
|
connector=connector,
|
|
)
|
|
await scheduler.start()
|
|
# v0.5.11 band-conditions scheduler -- spawn alongside the
|
|
# digest scheduler. Best-effort: failures in the scheduled
|
|
# broadcaster must NOT break notifications pipeline startup.
|
|
if BandConditionsScheduler is not None:
|
|
try:
|
|
comps = getattr(bus, "_pipeline_components", {}) or {}
|
|
disp = comps.get("dispatcher")
|
|
if disp is not None:
|
|
bc_sched = BandConditionsScheduler(config, disp)
|
|
await bc_sched.start()
|
|
comps["band_conditions_scheduler"] = bc_sched
|
|
bus._pipeline_components = comps
|
|
except Exception:
|
|
import logging as _lg
|
|
_lg.getLogger("meshai.pipeline").exception(
|
|
"band_conditions scheduler failed to start")
|
|
|
|
# Part 3: wzdx per-region daily work-zone count summary scheduler --
|
|
# spawn alongside band_conditions. Best-effort: failures must NOT break
|
|
# notifications pipeline startup. Respects adapter_config.wzdx.
|
|
# summary_enabled at fire time (the scheduler itself checks it every
|
|
# loop iteration); the try/except here only guards start()/construction.
|
|
if WZDxSummaryScheduler is not None:
|
|
try:
|
|
comps = getattr(bus, "_pipeline_components", {}) or {}
|
|
disp = comps.get("dispatcher")
|
|
if disp is not None:
|
|
wz_sched = WZDxSummaryScheduler(config, disp)
|
|
await wz_sched.start()
|
|
comps["wzdx_summary_scheduler"] = wz_sched
|
|
bus._pipeline_components = comps
|
|
except Exception:
|
|
import logging as _lg
|
|
_lg.getLogger("meshai.pipeline").exception(
|
|
"wzdx_summary scheduler failed to start")
|
|
|
|
# v0.6-phase3 ReminderScheduler -- runs alongside band_conditions.
|
|
if ReminderScheduler is not None:
|
|
try:
|
|
comps = getattr(bus, "_pipeline_components", {}) or {}
|
|
disp = comps.get("dispatcher")
|
|
if disp is not None:
|
|
rem_sched = ReminderScheduler(disp)
|
|
await rem_sched.start()
|
|
comps["reminder_scheduler"] = rem_sched
|
|
bus._pipeline_components = comps
|
|
except Exception:
|
|
import logging as _lg
|
|
_lg.getLogger("meshai.pipeline").exception(
|
|
"reminder scheduler failed to start")
|
|
|
|
# Custom announcements scheduler -- runs alongside the reminder
|
|
# scheduler. Best-effort: failures must NOT break notifications
|
|
# pipeline startup.
|
|
if CustomAnnouncementScheduler is not None:
|
|
try:
|
|
comps = getattr(bus, "_pipeline_components", {}) or {}
|
|
disp = comps.get("dispatcher")
|
|
if disp is not None:
|
|
ca_sched = CustomAnnouncementScheduler(disp)
|
|
await ca_sched.start()
|
|
comps["custom_announcement_scheduler"] = ca_sched
|
|
bus._pipeline_components = comps
|
|
except Exception:
|
|
import logging as _lg
|
|
_lg.getLogger("meshai.pipeline").exception(
|
|
"custom announcement scheduler failed to start")
|
|
|
|
# Phase 2.16.1: periodically flush the grouper so coalesced events are
|
|
# delivered within the window even when poll cadence is sparse.
|
|
#
|
|
# NOTE: this used to say "Immediate events bypass the grouper and don't
|
|
# need this." That is FALSE as of commit 85d48ce3 ("fix(fire): remove
|
|
# immediate-severity exemption from grouper + cooldown"), which deleted
|
|
# the severity check from Grouper.handle() (and the matching cooldown
|
|
# exemption in Dispatcher) on purpose: fire events carry
|
|
# _severity_override="immediate", which was zeroing the dispatcher
|
|
# cooldown and skipping the coalescer, leaving fire with NO rate control
|
|
# in normal live operation. EVERY severity -- immediate included -- is now
|
|
# held by the grouper when it has a group_key, so this periodic flush is
|
|
# what delivers them. Do not re-add an immediate-severity bypass.
|
|
grouper = components["grouper"]
|
|
flush_interval = getattr(config.notifications, "grouper_flush_seconds", 5.0) or 5.0
|
|
flush_stop = asyncio.Event()
|
|
|
|
async def _grouper_flush_loop():
|
|
while not flush_stop.is_set():
|
|
try:
|
|
await asyncio.wait_for(flush_stop.wait(), timeout=flush_interval)
|
|
return
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
try:
|
|
grouper.tick()
|
|
except Exception:
|
|
_logger.exception("Grouper flush tick failed")
|
|
|
|
flush_task = asyncio.create_task(_grouper_flush_loop(), name="grouper-flush")
|
|
scheduler._grouper_flush_task = flush_task
|
|
scheduler._grouper_flush_stop = flush_stop
|
|
_logger.info(f"Grouper flush task started (every {flush_interval:.0f}s)")
|
|
|
|
# Stash scheduler for stop_pipeline
|
|
bus._pipeline_scheduler = scheduler
|
|
|
|
return scheduler
|
|
|
|
|
|
async def stop_pipeline(scheduler: DigestScheduler) -> None:
|
|
"""Stop the pipeline's async components.
|
|
|
|
Args:
|
|
scheduler: DigestScheduler returned by start_pipeline()
|
|
"""
|
|
if scheduler is not None:
|
|
flush_stop = getattr(scheduler, "_grouper_flush_stop", None)
|
|
flush_task = getattr(scheduler, "_grouper_flush_task", None)
|
|
if flush_stop is not None:
|
|
flush_stop.set()
|
|
if flush_task is not None:
|
|
flush_task.cancel()
|
|
try:
|
|
await flush_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
await scheduler.stop()
|
|
|
|
|
|
__all__ = [
|
|
"EventBus",
|
|
"Dispatcher",
|
|
"Inhibitor",
|
|
"Grouper",
|
|
"ToggleFilter",
|
|
"CoverageFilter",
|
|
"DigestAccumulator",
|
|
"Digest",
|
|
"DigestScheduler",
|
|
"build_pipeline",
|
|
"build_pipeline_components",
|
|
"start_pipeline",
|
|
"stop_pipeline",
|
|
"get_bus",
|
|
]
|