From 91e00d28e068e2a3b69c28c926402b84551f43b8 Mon Sep 17 00:00:00 2001 From: malice Date: Tue, 7 Jul 2026 01:16:56 -0600 Subject: [PATCH] feat(notifications): dynamic category/family registry (generic sources routable) (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 4.8 (1M context) --- work/meshai/config.py | 28 ++++ work/meshai/main.py | 28 ++++ work/meshai/notifications/categories.py | 76 +++++++++- work/tests/test_dynamic_family_registry.py | 162 +++++++++++++++++++++ 4 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 work/tests/test_dynamic_family_registry.py diff --git a/work/meshai/config.py b/work/meshai/config.py index 7a8176f..681dd39 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -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.""" diff --git a/work/meshai/main.py b/work/meshai/main.py index 045f180..53442ea 100644 --- a/work/meshai/main.py +++ b/work/meshai/main.py @@ -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=...). diff --git a/work/meshai/notifications/categories.py b/work/meshai/notifications/categories.py index ba9bfc8..8566e6a 100644 --- a/work/meshai/notifications/categories.py +++ b/work/meshai/notifications/categories.py @@ -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") diff --git a/work/tests/test_dynamic_family_registry.py b/work/tests/test_dynamic_family_registry.py new file mode 100644 index 0000000..d29a05d --- /dev/null +++ b/work/tests/test_dynamic_family_registry.py @@ -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