mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
2986dd3fd0
commit
91e00d28e0
4 changed files with 292 additions and 2 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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=...).
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
162
work/tests/test_dynamic_family_registry.py
Normal file
162
work/tests/test_dynamic_family_registry.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue