feat(notifications): dynamic category/family registry (generic sources routable) (#80)

Categories/families can now be registered at runtime, not just the hardcoded
ALERT_CATEGORIES/VALID_TOGGLES. A generic data source registers its category
as a first-class family with its own (default-disabled) toggle, so its events
resolve to that family instead of being dropped as "other" or buried in
mesh_health — it becomes routable. Existing families/categories unchanged.
Phase A of making custom sources first-class feeds.

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:
malice 2026-07-07 01:16:56 -06:00 committed by GitHub
commit 91e00d28e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 292 additions and 2 deletions

View file

@ -655,6 +655,34 @@ def _default_toggles() -> dict:
}
def ensure_family_toggles(config, families) -> None:
"""Inject a default (disabled) NotificationToggle for each family in
`families` not already present in ``config.notifications.toggles``.
Mirrors ``_default_toggles()`` for a static family (same defaults,
disabled). Existing toggles are NEVER clobbered this only ADDS. Used at
startup so a generic source's dynamically-registered family becomes a real,
operator-configurable (default-disabled) toggle in the routing set.
"""
toggles = getattr(config.notifications, "toggles", None)
if toggles is None:
toggles = {}
config.notifications.toggles = toggles
for fam in families:
if not fam or fam in toggles:
continue
toggles[fam] = NotificationToggle(
name=fam,
enabled=False,
min_severity="priority",
regions=[],
severity_channels={
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast", "mesh_dm"],
},
)
@dataclass
class TogglesConfig:
"""Master toggle filter settings."""

View file

@ -617,6 +617,34 @@ class MeshAI:
)
logger.info("Notification router initialized")
# Integration Phase A: register each enabled generic source's
# category as a first-class routable family with its own
# (default-disabled) toggle, BEFORE build_pipeline reads the toggle
# set. Without this a generic category resolves to "other" in the
# ToggleFilter and is silently dropped. The injected toggles are
# disabled by default (operator opts in via Routing — Phase B).
from .notifications import categories as _categories
from .config import ensure_family_toggles
_generic_families = []
for _src in (self.config.generic_sources or []):
if not isinstance(_src, dict) or not _src.get("enabled", True):
continue
_cat = _src.get("category")
if not _cat:
continue
_fam = _src.get("family") or _cat
_label = _src.get("family_label") or _fam.replace("_", " ").title()
_categories.register_category(
_cat, family=_fam, name=_src.get("name"))
_categories.register_family(_fam, _label)
_generic_families.append(_fam)
if _generic_families:
ensure_family_toggles(self.config, _generic_families)
logger.info(
"Registered generic source families "
"(default-disabled toggles): %s",
sorted(set(_generic_families)))
# Notification pipeline (v0.3 EventBus). Built here so env
# adapters constructed below can emit Events into the live
# pipeline at runtime via EnvironmentalStore(event_bus=...).

View file

@ -35,6 +35,69 @@ VALID_TOGGLES = frozenset({
})
# ---------------------------------------------------------------------------
# Dynamic category/family registry (Integration Phase A)
#
# ALERT_CATEGORIES / VALID_TOGGLES above enumerate the built-in families.
# Generic config-driven data sources (env/generic_http.py) carry an arbitrary
# `category` that is NOT in that static table, so historically get_toggle()
# returned None, the ToggleFilter treated the event as "other", and dropped it.
# These runtime registries let a generic source register its category as a
# first-class family with its own toggle, so its events resolve to that family
# and become routable. Both dicts are EMPTY until something registers, so
# behavior for every built-in category is byte-identical to before.
# ---------------------------------------------------------------------------
_DYNAMIC_CATEGORIES: dict[str, str] = {} # category id -> family
_DYNAMIC_FAMILIES: dict[str, str] = {} # family -> human label
def register_family(family: str, label: Optional[str] = None) -> None:
"""Register a family (toggle) at runtime. Idempotent.
A re-registration with no label leaves any existing label untouched; a
call with a label sets/updates it. When no label is supplied for a
first-time family, a title-cased default is derived from the family id.
"""
if not family:
return
if label is None and family in _DYNAMIC_FAMILIES:
return
_DYNAMIC_FAMILIES[family] = (
label or _DYNAMIC_FAMILIES.get(family) or family.replace("_", " ").title()
)
def register_category(category: str, family: str, name: Optional[str] = None) -> None:
"""Register a category -> family mapping at runtime.
Records category->family and registers the family if not already known.
`name` is an optional display hint for the category (reserved for Phase B
surfacing; not required for routing).
"""
if not category or not family:
return
_DYNAMIC_CATEGORIES[category] = family
if family not in _DYNAMIC_FAMILIES:
register_family(family)
def all_toggles() -> frozenset:
"""Static base VALID_TOGGLES plus any dynamically registered families."""
return VALID_TOGGLES | set(_DYNAMIC_FAMILIES)
def registered_families() -> dict:
"""family -> label for ALL families (static + dynamic).
Static families get a title-cased label; dynamic families use their
registered label. For Phase B's API to enumerate routable families.
"""
fams = {t: t.replace("_", " ").title() for t in VALID_TOGGLES}
fams.update(_DYNAMIC_FAMILIES)
return fams
# Prefix fallback for categories not enumerated in ALERT_CATEGORIES (resolves the
# v0.4 "category -> other" gap for phases 2.7-2.14 emitted categories).
_TOGGLE_PREFIX_FALLBACK = [
@ -566,14 +629,17 @@ def categories_for_toggle(toggle: str) -> list[str]:
Returns:
List of category IDs that have this toggle assigned
"""
if toggle not in VALID_TOGGLES:
if toggle not in all_toggles():
return []
return [
result = [
cat_id
for cat_id, cat_info in ALERT_CATEGORIES.items()
if cat_info.get("toggle") == toggle
]
# Dynamically-registered categories that route to this family.
result.extend(c for c, fam in _DYNAMIC_CATEGORIES.items() if fam == toggle)
return result
def get_toggle(category_name: str) -> Optional[str]:
@ -585,6 +651,12 @@ def get_toggle(category_name: str) -> Optional[str]:
Returns:
Toggle name (e.g., "mesh_health") or None if category unknown
"""
# Dynamic registry first: a registered generic category resolves to its
# own family, NOT the ALERT_CATEGORIES entry or the mesh_health prefix
# fallback. Empty until a generic source registers, so built-ins unchanged.
dynamic = _DYNAMIC_CATEGORIES.get(category_name)
if dynamic:
return dynamic
cat_info = ALERT_CATEGORIES.get(category_name)
if cat_info:
return cat_info.get("toggle")

View file

@ -0,0 +1,162 @@
"""Integration Phase A: dynamic category/family registry.
A generic data source registers its category as a first-class family with its
own (default-disabled) toggle, so its events resolve to that family and become
routable instead of being dropped as "other". Existing built-in categories are
unchanged.
"""
import pytest
from meshai.notifications import categories
from meshai.notifications.categories import (
register_category,
register_family,
get_toggle,
all_toggles,
registered_families,
VALID_TOGGLES,
)
from meshai.notifications.events import make_event
from meshai.notifications.pipeline.toggle_filter import ToggleFilter
from meshai.config import Config, NotificationToggle, ensure_family_toggles
@pytest.fixture(autouse=True)
def _clean_registry():
"""The dynamic registry is module-global; isolate every test."""
categories._DYNAMIC_CATEGORIES.clear()
categories._DYNAMIC_FAMILIES.clear()
yield
categories._DYNAMIC_CATEGORIES.clear()
categories._DYNAMIC_FAMILIES.clear()
class TestRegistry:
def test_registered_category_resolves_to_its_own_family(self):
register_category("power_outage", family="power_outage")
assert get_toggle("power_outage") == "power_outage"
def test_register_category_registers_family(self):
register_category("power_outage", family="power_outage")
assert "power_outage" in all_toggles()
assert "power_outage" in registered_families()
def test_register_family_idempotent_and_label(self):
register_family("power_outage", "Power Outages")
assert registered_families()["power_outage"] == "Power Outages"
# Re-register with no label leaves the label intact.
register_family("power_outage")
assert registered_families()["power_outage"] == "Power Outages"
def test_register_family_default_label_title_cased(self):
register_family("power_outage")
assert registered_families()["power_outage"] == "Power Outage"
def test_category_can_route_to_a_distinct_family(self):
register_category("idaho_power_outage", family="power", name="Idaho Power")
assert get_toggle("idaho_power_outage") == "power"
assert "power" in all_toggles()
def test_unregistered_still_falls_back_as_before(self):
# No registration -> unknown category has no toggle (ToggleFilter -> other).
assert get_toggle("totally_unknown_xyz") is None
def test_prefix_fallback_still_applies_when_unregistered(self):
# weather-prefixed unknown still resolves via _TOGGLE_PREFIX_FALLBACK.
assert get_toggle("weather_special_bulletin") == "weather"
@pytest.mark.parametrize("category,expected", [
("weather_warning", "weather"),
("wildfire_incident", "fire"),
("traffic_congestion", "roads"),
("earthquake_event", "seismic"),
("infra_offline", "mesh_health"),
("avalanche_warning", "avalanche"),
("rf_ducting_enhancement", "rf_propagation"),
])
def test_existing_categories_unchanged(self, category, expected):
# With the dynamic registry populated by an unrelated family, every
# built-in category must still resolve to its original toggle.
register_category("power_outage", family="power_outage")
assert get_toggle(category) == expected
def test_all_toggles_includes_static_base_and_dynamic(self):
register_category("power_outage", family="power_outage")
toggles = all_toggles()
assert VALID_TOGGLES <= toggles # static base preserved
assert "power_outage" in toggles # dynamic added
# VALID_TOGGLES frozenset itself is not mutated.
assert "power_outage" not in VALID_TOGGLES
class TestEnsureFamilyToggles:
def test_injects_disabled_toggle_for_new_family(self):
config = Config()
assert "power_outage" not in config.notifications.toggles
ensure_family_toggles(config, ["power_outage"])
tog = config.notifications.toggles["power_outage"]
assert isinstance(tog, NotificationToggle)
assert tog.name == "power_outage"
assert tog.enabled is False
# Mirrors _default_toggles defaults.
assert tog.min_severity == "priority"
assert tog.severity_channels == {
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast", "mesh_dm"],
}
def test_does_not_clobber_existing_toggle(self):
config = Config()
# Operator has enabled the built-in weather family.
config.notifications.toggles["weather"].enabled = True
ensure_family_toggles(config, ["weather", "power_outage"])
assert config.notifications.toggles["weather"].enabled is True
assert config.notifications.toggles["power_outage"].enabled is False
class TestToggleFilterWithDynamicFamily:
def _generic_event(self):
return make_event(
source="generic:idaho_power",
category="power_outage",
severity="priority",
title="Outage",
)
def test_registered_generic_family_enabled_passes(self):
register_category("power_outage", family="power_outage")
received = []
filt = ToggleFilter(
next_handler=received.append,
enabled_toggles={"power_outage"},
)
filt.handle(self._generic_event())
assert len(received) == 1
def test_registered_generic_family_disabled_drops(self):
register_category("power_outage", family="power_outage")
received = []
# Some other family enabled, but NOT power_outage.
filt = ToggleFilter(
next_handler=received.append,
enabled_toggles={"weather"},
)
filt.handle(self._generic_event())
# Dropped as its own disabled family — NOT the silent "other" path,
# and NOT buried in mesh_health.
assert len(received) == 0
def test_regression_weather_still_routes(self):
# A normal built-in category is unaffected by the dynamic registry.
register_category("power_outage", family="power_outage")
received = []
filt = ToggleFilter(
next_handler=received.append,
enabled_toggles={"weather"},
)
filt.handle(make_event(
source="nws", category="weather_warning",
severity="priority", title="Weather",
))
assert len(received) == 1