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

View file

@ -484,6 +484,14 @@ class NotificationRuleConfig:
channel_ids: list = field(default_factory=list)
@dataclass
class TogglesConfig:
"""Master toggle filter settings."""
enabled: list[str] = field(default_factory=list) # Toggle names that are enabled (empty = all)
@dataclass
class DigestConfig:
"""Digest scheduler settings."""
@ -500,6 +508,7 @@ class NotificationsConfig:
quiet_hours_enabled: bool = True # Master toggle for quiet hours
quiet_hours_start: str = "22:00"
quiet_hours_end: str = "06:00"
toggles: TogglesConfig = field(default_factory=TogglesConfig)
digest: DigestConfig = field(default_factory=DigestConfig)
rules: list = field(default_factory=list) # List of NotificationRuleConfig
@ -672,6 +681,8 @@ def _dict_to_dataclass(cls, data: dict):
kwargs[key] = _dict_to_dataclass(FIRMSConfig, value)
elif key == "dashboard" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
elif key == "toggles" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(TogglesConfig, value)
elif key == "digest" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DigestConfig, value)
elif key == "notifications" and isinstance(value, dict):

View file

@ -1,13 +1,14 @@
"""Notification pipeline package.
Phase 2.1 + 2.2 + 2.3a + 2.3b:
Phase 2.4:
- EventBus: pub/sub ingress
- Inhibitor: suppresses redundant events by inhibit_keys
- Grouper: coalesces events sharing group_key within a window
- SeverityRouter: forks immediate vs digest
- Dispatcher: routes immediate via channels (existing rules schema)
- DigestAccumulator: tracks priority/routine events for periodic digest
- DigestScheduler: fires digest at configured time (Phase 2.3b)
- 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
@ -29,10 +30,34 @@ from meshai.notifications.pipeline.severity_router import (
from meshai.notifications.pipeline.dispatcher import Dispatcher
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.digest import DigestAccumulator, Digest
from meshai.notifications.pipeline.scheduler import DigestScheduler
def _create_llm_backend(config):
"""Create an LLM backend from config, or return None if unavailable."""
try:
from meshai.backends import OpenAIBackend, AnthropicBackend, GoogleBackend
api_key = config.resolve_api_key()
if not api_key:
return None
backend_name = config.llm.backend.lower()
# Use minimal memory settings for digest summaries
if backend_name == "openai":
return OpenAIBackend(config.llm, api_key, 0, 0)
elif backend_name == "anthropic":
return AnthropicBackend(config.llm, api_key, 0, 0)
elif backend_name == "google":
return GoogleBackend(config.llm, api_key, 0, 0)
else:
return OpenAIBackend(config.llm, api_key, 0, 0)
except Exception:
return None
def build_pipeline(config) -> EventBus:
"""Build the pipeline and return the EventBus.
@ -41,6 +66,9 @@ def build_pipeline(config) -> EventBus:
bus = EventBus()
dispatcher = Dispatcher(config, create_channel)
# Build LLM backend for digest summarization
llm_backend = _create_llm_backend(config)
# Build include_toggles from config
digest_cfg = getattr(config.notifications, "digest", None)
include_toggles = None
@ -49,12 +77,30 @@ def build_pipeline(config) -> EventBus:
if include_list:
include_toggles = list(include_list)
digest = DigestAccumulator(include_toggles=include_toggles)
severity_router = SeverityRouter(
immediate_handler=dispatcher.dispatch,
digest_handler=digest.enqueue,
accumulator = DigestAccumulator(
llm_backend=llm_backend,
include_toggles=include_toggles,
)
grouper = Grouper(next_handler=severity_router.handle)
# Tee closure: events go to BOTH dispatcher and accumulator
def _tee(event):
dispatcher.dispatch(event)
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)
@ -62,9 +108,9 @@ def build_pipeline(config) -> EventBus:
bus._pipeline_components = {
"inhibitor": inhibitor,
"grouper": grouper,
"severity_router": severity_router,
"toggle_filter": toggle_filter,
"dispatcher": dispatcher,
"digest": digest,
"accumulator": accumulator,
}
return bus
@ -73,11 +119,14 @@ def build_pipeline(config) -> EventBus:
def build_pipeline_components(config) -> tuple:
"""Like build_pipeline, but returns all components for tests.
Returns (bus, inhibitor, grouper, severity_router, dispatcher, digest).
Returns (bus, inhibitor, grouper, toggle_filter, dispatcher, accumulator).
"""
bus = EventBus()
dispatcher = Dispatcher(config, create_channel)
# Build LLM backend for digest summarization
llm_backend = _create_llm_backend(config)
# Build include_toggles from config
digest_cfg = getattr(config.notifications, "digest", None)
include_toggles = None
@ -86,15 +135,34 @@ def build_pipeline_components(config) -> tuple:
if include_list:
include_toggles = list(include_list)
digest = DigestAccumulator(include_toggles=include_toggles)
severity_router = SeverityRouter(
immediate_handler=dispatcher.dispatch,
digest_handler=digest.enqueue,
accumulator = DigestAccumulator(
llm_backend=llm_backend,
include_toggles=include_toggles,
)
grouper = Grouper(next_handler=severity_router.handle)
# Tee closure: events go to BOTH dispatcher and accumulator
def _tee(event):
dispatcher.dispatch(event)
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, severity_router, dispatcher, digest
return bus, inhibitor, grouper, toggle_filter, dispatcher, accumulator
async def start_pipeline(bus: EventBus, config) -> DigestScheduler:
@ -111,10 +179,10 @@ async def start_pipeline(bus: EventBus, config) -> DigestScheduler:
if components is None:
raise RuntimeError("bus missing _pipeline_components; use build_pipeline()")
digest = components["digest"]
accumulator = components["accumulator"]
scheduler = DigestScheduler(
accumulator=digest,
accumulator=accumulator,
config=config,
channel_factory=create_channel,
)
@ -143,6 +211,7 @@ __all__ = [
"Dispatcher",
"Inhibitor",
"Grouper",
"ToggleFilter",
"DigestAccumulator",
"Digest",
"DigestScheduler",

View file

@ -1,33 +1,24 @@
"""Digest accumulator and renderer for Phase 2.3a.
"""Digest accumulator and renderer for Phase 2.4.
Holds priority and routine events between digest emissions, tracks
active vs recently-resolved events, and renders the two-section
digest output (ACTIVE NOW + SINCE LAST DIGEST) when called.
Logs all events between digest emissions and renders LLM-summarized
digest output per toggle. No active/resolved tracking just a
chronological log that the LLM summarizes.
No scheduling logic here. render_digest() is called explicitly by
the future scheduler (Phase 2.3b) or by tests.
render_digest() is async and calls the LLM once per non-empty toggle.
"""
import logging
import time
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from meshai.notifications.events import Event
from meshai.notifications.categories import get_toggle
if TYPE_CHECKING:
from meshai.backends.base import LLMBackend
# Lowercase substrings in event.title that indicate the event is
# a resolution of a prior alert. Conservative list — easy to extend.
RESOLUTION_MARKERS = (
"cleared",
"reopened",
"ended",
"resolved",
"back online",
"recovered",
"lifted",
)
# Display labels per toggle (used in rendered output)
TOGGLE_LABELS = {
@ -55,11 +46,23 @@ TOGGLE_ORDER = [
"other",
]
# System prompt for digest summarization
DIGEST_SYSTEM_PROMPT = (
"You are summarizing a category of mesh-network alerts for a "
"morning digest broadcast. Given a list of events in chronological "
"order (immediate severity first, then priority, then routine), "
"produce ONE SHORT LINE summarizing what happened. "
"Be specific about node IDs, places, and counts when present. "
"Aim for 80-140 characters. Do not use markdown. No bullet points. "
"Plain prose. End with a period."
)
@dataclass
class Digest:
"""Result of render_digest(). Carries both sections and metadata."""
"""Result of render_digest(). Carries sections and metadata."""
rendered_at: float
# Keep these fields for type compatibility; populated empty in Phase 2.4+
active: dict[str, list[Event]] = field(default_factory=dict)
since_last: dict[str, list[Event]] = field(default_factory=dict)
mesh_chunks: list[str] = field(default_factory=list)
@ -67,28 +70,31 @@ class Digest:
full: str = ""
def is_empty(self) -> bool:
return not self.active and not self.since_last
return not self.mesh_chunks or (
len(self.mesh_chunks) == 1 and "No alerts" in self.mesh_chunks[0]
)
class DigestAccumulator:
"""Tracks priority/routine events and produces periodic digests.
"""Logs events and produces LLM-summarized periodic digests.
Args:
mesh_char_limit: Maximum characters per mesh chunk (default 200).
llm_backend: LLM backend for generating summaries. If None,
falls back to count-based summaries.
include_toggles: List of toggle names to include in digest output.
If None, defaults to all toggles in TOGGLE_ORDER except
rf_propagation. Unknown toggle names in the list are silently
accepted (TOGGLE_ORDER drives display order, include_toggles
drives which toggles are tracked).
rf_propagation.
mesh_char_limit: Maximum characters per mesh chunk (default 200).
"""
def __init__(
self,
mesh_char_limit: int = 200,
llm_backend: Optional["LLMBackend"] = None,
include_toggles: list[str] | None = None,
mesh_char_limit: int = 200,
):
self._active: dict[str, list[Event]] = {} # toggle -> events
self._since_last: dict[str, list[Event]] = {} # toggle -> events
self._llm = llm_backend
self._events_since_last_digest: dict[str, list[Event]] = {}
self._last_digest_at: float = 0.0
self._mesh_char_limit = mesh_char_limit
# Default: all known toggles except rf_propagation
@ -101,7 +107,7 @@ class DigestAccumulator:
# ---- ingress ----
def enqueue(self, event: Event) -> None:
"""SeverityRouter calls this for priority/routine events."""
"""Log an event for the next digest."""
toggle = get_toggle(event.category) or "other"
# Skip non-included toggles
@ -111,348 +117,201 @@ class DigestAccumulator:
)
return
active_for_toggle = self._active.setdefault(toggle, [])
# Resolution detection
if self._is_resolution(event, self._now()):
self._move_to_since_last_by_group(event, toggle)
return
# In-place update if same id
for i, existing in enumerate(active_for_toggle):
if existing.id == event.id:
active_for_toggle[i] = event
self._logger.debug(
f"UPDATED active event {event.id} in {toggle}"
)
return
# Otherwise it's a new active event
active_for_toggle.append(event)
# Append to the event log
self._events_since_last_digest.setdefault(toggle, []).append(event)
self._logger.debug(
f"ADDED active event {event.id} ({toggle}/{event.category})"
f"LOGGED event {event.id} ({toggle}/{event.category}/{event.severity})"
)
def tick(self, now: Optional[float] = None) -> int:
"""Move expired events from active to since_last.
Returns the number of events moved.
"""
if now is None:
now = self._now()
moved = 0
for toggle in list(self._active.keys()):
still_active = []
for ev in self._active[toggle]:
if ev.expires is not None and ev.expires <= now:
self._since_last.setdefault(toggle, []).append(ev)
moved += 1
else:
still_active.append(ev)
self._active[toggle] = still_active
return moved
"""No-op in Phase 2.4+. Returns 0."""
return 0
# ---- rendering ----
def render_digest(self, now: Optional[float] = None) -> Digest:
"""Produce a Digest of current state, then clear since_last."""
async def render_digest(self, now: Optional[float] = None) -> Digest:
"""Produce a Digest with LLM-summarized lines per toggle.
Calls the LLM once per toggle that had activity. Empty toggles
produce no line. Clears the event log after rendering.
"""
if now is None:
now = self._now()
# tick() first so expired actives roll into since_last
self.tick(now)
digest = Digest(rendered_at=now)
# Defensive: skip non-included toggles when building output
digest.active = {
k: list(v) for k, v in self._active.items()
if v and k in self._included
}
digest.since_last = {
k: list(v) for k, v in self._since_last.items()
if v and k in self._included
}
digest.mesh_chunks = self._render_mesh_chunks(digest, now)
# mesh_compact: join chunks for backward compatibility
time_str = time.strftime('%H%M', time.localtime(now))
# Build summary lines per toggle
summary_lines: list[str] = []
for toggle in TOGGLE_ORDER:
events = self._events_since_last_digest.get(toggle, [])
if not events:
continue
if toggle not in self._included:
continue
label = TOGGLE_LABELS.get(toggle, toggle)
summary = await self._summarize_toggle(toggle, events, now)
summary_lines.append(f"[{label}] {summary}")
# Render outputs
if summary_lines:
digest.mesh_chunks = self._render_mesh_chunks(summary_lines, time_str)
digest.full = self._render_full(summary_lines, time_str)
else:
digest.mesh_chunks = [f"DIGEST {time_str}\nNo alerts since last digest."]
digest.full = f"--- {time_str} Digest ---\n\nNo alerts since last digest.\n"
# mesh_compact for backward compatibility
if len(digest.mesh_chunks) == 1:
digest.mesh_compact = digest.mesh_chunks[0]
else:
digest.mesh_compact = "\n---\n".join(digest.mesh_chunks)
digest.full = self._render_full(digest, now)
# Clear since_last; active stays for the next cycle
self._since_last.clear()
# Clear event log
self._events_since_last_digest.clear()
self._last_digest_at = now
return digest
def _render_mesh_chunks(self, digest: Digest, now: float) -> list[str]:
"""Produce mesh-radio-friendly compact chunks.
Returns a list of strings, each self._mesh_char_limit chars.
Single-chunk output has no "(1/N)" suffix. Multi-chunk output
has "(k/N)" counters and "(cont)" suffixes on section headers
that span chunks.
"""
time_str = time.strftime('%H%M', time.localtime(now))
# Empty digest case
if not digest.active and not digest.since_last:
return [f"DIGEST {time_str}\nNo alerts since last digest."]
# Build logical lines with section markers
# Each item is (section, line) where section is "active", "resolved", or None
logical_lines: list[tuple[str | None, str]] = []
if digest.active:
logical_lines.append(("active", "ACTIVE NOW"))
for toggle in TOGGLE_ORDER:
events = digest.active.get(toggle)
if not events:
continue
logical_lines.append(("active", self._compact_toggle_line(toggle, events)))
if digest.since_last:
logical_lines.append(("resolved", "RESOLVED"))
for toggle in TOGGLE_ORDER:
events = digest.since_last.get(toggle)
if not events:
continue
logical_lines.append(("resolved", self._compact_toggle_line(toggle, events)))
# Pack lines into chunks
return self._pack_lines_into_chunks(logical_lines, time_str)
def _pack_lines_into_chunks(
async def _summarize_toggle(
self,
logical_lines: list[tuple[str | None, str]],
toggle: str,
events: list[Event],
now: float,
) -> str:
"""Generate a one-line summary for a toggle's events."""
# Sort by severity (immediate=0, priority=1, routine=2), then timestamp
severity_rank = {"immediate": 0, "priority": 1, "routine": 2}
sorted_events = sorted(
events,
key=lambda e: (severity_rank.get(e.severity, 3), e.timestamp),
)
# Build LLM input
lines = [f"Category: {toggle}", "Events:"]
for ev in sorted_events:
lines.append(self._format_event_for_llm(ev))
llm_input = "\n".join(lines)
# Try LLM summarization
if self._llm is not None:
try:
response = await self._llm.generate(
messages=[{"role": "user", "content": llm_input}],
system_prompt=DIGEST_SYSTEM_PROMPT,
max_tokens=200,
)
# Take first line only
summary = response.strip().split("\n")[0].strip()
if summary:
return summary
except Exception as e:
self._logger.warning(f"LLM summarization failed for {toggle}: {e}")
# Fallback: count-based summary
return f"{len(events)} event(s) (LLM unavailable)"
def _format_event_for_llm(self, event: Event) -> str:
"""Format one event for LLM input."""
ts = datetime.fromtimestamp(event.timestamp)
time_str = ts.strftime("%H:%M")
severity = event.severity.upper()
# Combine title and summary
text = event.title or ""
if event.summary and event.summary != event.title:
if text:
text = f"{text}{event.summary}"
else:
text = event.summary
if not text:
text = event.category
# Truncate long text
if len(text) > 120:
text = text[:117] + "..."
return f"- [{severity} {time_str}] {text}"
def _render_mesh_chunks(
self,
summary_lines: list[str],
time_str: str,
) -> list[str]:
"""Pack logical lines into chunks respecting char limit.
Args:
logical_lines: List of (section, line) tuples where section
is "active", "resolved", or None for headers.
time_str: Time string for headers (e.g., "0700").
Returns:
List of chunk strings, each self._mesh_char_limit.
"""
if not logical_lines:
return [f"DIGEST {time_str}\nNo alerts since last digest."]
"""Pack summary lines into mesh-friendly chunks."""
limit = self._mesh_char_limit
chunks: list[list[str]] = [] # List of line lists
chunks: list[list[str]] = []
current_chunk: list[str] = []
current_len = 0
last_section_in_chunk: str | None = None
sections_started: set[str] = set()
# Placeholder header - will be fixed up later
header_placeholder = f"DIGEST {time_str}"
# Placeholder header
header = f"DIGEST {time_str}"
def start_new_chunk():
nonlocal current_chunk, current_len, last_section_in_chunk
nonlocal current_chunk, current_len
if current_chunk:
chunks.append(current_chunk)
current_chunk = [header_placeholder]
current_len = len(header_placeholder)
last_section_in_chunk = None
current_chunk = [header]
current_len = len(header)
start_new_chunk()
i = 0
while i < len(logical_lines):
section, line = logical_lines[i]
is_section_header = line in ("ACTIVE NOW", "RESOLVED")
# Check if this is a section header - ensure it has at least one
# toggle line following it in this chunk
if is_section_header:
# Look ahead for the next toggle line
next_toggle_idx = i + 1
if next_toggle_idx < len(logical_lines):
_, next_line = logical_lines[next_toggle_idx]
# Calculate space needed for header + newline + next line
needed = len(line) + 1 + len(next_line)
if current_len + 1 + needed > limit:
# Section header + next line won't fit, start new chunk
start_new_chunk()
sections_started.add(section)
last_section_in_chunk = section
current_chunk.append(line)
current_len += 1 + len(line)
i += 1
continue
# Calculate line length with newline
line_with_newline = 1 + len(line) # newline before line
# Would this line fit?
if current_len + line_with_newline > limit:
# Start new chunk
for line in summary_lines:
line_len = 1 + len(line) # newline + line
if current_len + line_len > limit:
start_new_chunk()
# If continuing a section, add "(cont)" header
if section and section in sections_started and not is_section_header:
cont_header = "ACTIVE NOW (cont)" if section == "active" else "RESOLVED (cont)"
current_chunk.append(cont_header)
current_len += 1 + len(cont_header)
last_section_in_chunk = section
# Add the line
if is_section_header:
sections_started.add(section)
last_section_in_chunk = section
current_chunk.append(line)
current_len += 1 + len(line)
i += 1
current_len += line_len
# Don't forget the last chunk
if current_chunk and len(current_chunk) > 1: # More than just header
if current_chunk and len(current_chunk) > 1:
chunks.append(current_chunk)
elif current_chunk and len(current_chunk) == 1:
# Only header in chunk - shouldn't happen but handle gracefully
if chunks:
# Merge with previous chunk if possible
pass
else:
chunks.append(current_chunk)
# Fix up headers with chunk counts
total_chunks = len(chunks)
total = len(chunks)
result: list[str] = []
for idx, chunk_lines in enumerate(chunks):
# Fix header line
if total_chunks == 1:
if total == 1:
chunk_lines[0] = f"DIGEST {time_str}"
else:
chunk_lines[0] = f"DIGEST {time_str} ({idx + 1}/{total_chunks})"
chunk_lines[0] = f"DIGEST {time_str} ({idx + 1}/{total})"
result.append("\n".join(chunk_lines))
return result if result else [f"DIGEST {time_str}\nNo alerts since last digest."]
def _compact_toggle_line(self, toggle: str, events: list[Event]) -> str:
"""Build one compact line for a toggle: [Label] headline (+N)"""
label = TOGGLE_LABELS.get(toggle, toggle)
sorted_events = self._sort_events(events)
top_event = sorted_events[0]
# Get headline text
headline = top_event.summary or top_event.title or top_event.category
# Truncate headline at ~60 chars to keep lines readable
max_headline = 60
if len(headline) > max_headline:
headline = headline[:max_headline - 1] + ""
# Append (+N) if more than one event
overflow = len(events) - 1
if overflow > 0:
return f"[{label}] {headline} (+{overflow})"
else:
return f"[{label}] {headline}"
def _render_full(self, digest: Digest, now: float) -> str:
"""Produce the full multi-line digest for email/webhook."""
def _render_full(self, summary_lines: list[str], time_str: str) -> str:
"""Produce full multi-line digest for email/webhook."""
lines = [
f"--- {time.strftime('%H%M', time.localtime(now))} Digest ---",
f"--- {time_str} Digest ---",
"",
]
if not digest.active and not digest.since_last:
lines.append("No alerts since last digest.")
lines.append("")
else:
if digest.active:
lines.append("ACTIVE NOW:")
for toggle in TOGGLE_ORDER:
events = digest.active.get(toggle)
if not events:
continue
label = TOGGLE_LABELS.get(toggle, toggle)
for ev in self._sort_events(events):
lines.append(f" [{label}] {self._format_event_line(ev)}")
lines.append("")
if digest.since_last:
lines.append("SINCE LAST DIGEST:")
for toggle in TOGGLE_ORDER:
events = digest.since_last.get(toggle)
if not events:
continue
label = TOGGLE_LABELS.get(toggle, toggle)
for ev in self._sort_events(events):
lines.append(f" [{label}] {self._format_event_line(ev)}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def _format_event_line(self, event: Event) -> str:
"""Single-line summary of an event for digest output."""
# Prefer event.summary if set, else fall back to title, then category
text = event.summary or event.title or event.category
# Trim runaway text — keep digest readable
if len(text) > 140:
text = text[:139] + ""
return text
def _sort_events(self, events: list[Event]) -> list[Event]:
"""Sort within a toggle: immediate first, then priority,
then routine, then by timestamp newest first."""
rank = {"immediate": 0, "priority": 1, "routine": 2}
return sorted(
events,
key=lambda e: (rank.get(e.severity, 3), -e.timestamp),
)
# ---- helpers ----
def _is_resolution(self, event: Event, now: float) -> bool:
if event.expires is not None and event.expires <= now:
return True
title_lc = (event.title or "").lower()
return any(marker in title_lc for marker in RESOLUTION_MARKERS)
def _move_to_since_last_by_group(self, event: Event, toggle: str) -> None:
"""Remove any active event matching event's group_key (or id)
and place this resolution event into since_last.
"""
active_list = self._active.get(toggle, [])
# Match by group_key if set, else by id
match_key = event.group_key
if match_key:
self._active[toggle] = [
e for e in active_list
if e.group_key != match_key
]
else:
self._active[toggle] = [
e for e in active_list if e.id != event.id
]
self._since_last.setdefault(toggle, []).append(event)
self._logger.debug(
f"RESOLVED in {toggle}: {event.id} ({event.title!r})"
)
lines.extend(summary_lines)
lines.append("")
return "\n".join(lines)
def _now(self) -> float:
return time.time()
# ---- inspection (for tests and future scheduler) ----
# ---- inspection (for tests and scheduler) ----
def active_count(self, toggle: Optional[str] = None) -> int:
def event_count(self, toggle: Optional[str] = None) -> int:
"""Count events logged since last digest."""
if toggle is not None:
return len(self._active.get(toggle, []))
return sum(len(v) for v in self._active.values())
def since_last_count(self, toggle: Optional[str] = None) -> int:
if toggle is not None:
return len(self._since_last.get(toggle, []))
return sum(len(v) for v in self._since_last.values())
return len(self._events_since_last_digest.get(toggle, []))
return sum(len(v) for v in self._events_since_last_digest.values())
def last_digest_at(self) -> float:
return self._last_digest_at
def clear(self) -> None:
self._active.clear()
self._since_last.clear()
self._events_since_last_digest.clear()
self._last_digest_at = 0.0
# Legacy compatibility — return 0 for old tests
def active_count(self, toggle: Optional[str] = None) -> int:
return 0
def since_last_count(self, toggle: Optional[str] = None) -> int:
return 0

View file

@ -98,7 +98,8 @@ class DigestScheduler:
async def _fire(self, now: float) -> None:
"""Render and deliver one digest."""
self._logger.info(f"Firing digest at {datetime.fromtimestamp(now):%H:%M}")
digest = self._accumulator.render_digest(now)
# render_digest is now async in Phase 2.4+
digest = await self._accumulator.render_digest(now)
self._last_fire_at = now
rules = self._matching_rules()

View file

@ -0,0 +1,48 @@
"""Master toggle filter.
Drops events whose category maps to a toggle that the operator has
disabled. Distinct from DigestAccumulator.include_toggles, which
only affects the digest recap this filter drops events from the
entire pipeline, so disabled toggles produce no live mesh delivery,
no digest entry, nothing.
"""
import logging
from typing import Callable
from meshai.notifications.events import Event
from meshai.notifications.categories import get_toggle
class ToggleFilter:
"""Drop events whose toggle isn't in the enabled set."""
def __init__(
self,
next_handler: Callable[[Event], None],
enabled_toggles: set[str] | None = None,
):
"""Initialize.
Args:
next_handler: Callable that receives non-dropped events.
enabled_toggles: Set of toggle names that are enabled.
If None, all toggles are enabled (filter is a no-op).
"""
self._next = next_handler
self._enabled = enabled_toggles # None = no-op
self._logger = logging.getLogger("meshai.pipeline.toggle_filter")
def handle(self, event: Event) -> None:
"""Pass the event through, or drop it if its toggle is disabled."""
if self._enabled is None:
self._next(event)
return
toggle = get_toggle(event.category) or "other"
if toggle not in self._enabled:
self._logger.debug(
f"DROPPED event {event.id} — toggle {toggle!r} not enabled"
)
return
self._next(event)