diff --git a/meshai/adapter_config/__init__.py b/meshai/adapter_config/__init__.py index c255bf9..fb6d805 100644 --- a/meshai/adapter_config/__init__.py +++ b/meshai/adapter_config/__init__.py @@ -1,14 +1,24 @@ -"""v0.6-3a meshai/adapter_config package. +"""v0.6-3a.1 meshai/adapter_config package. Public API: - from meshai.adapter_config import adapter_config, invalidate_cache, seed_defaults + from meshai.adapter_config import ( + adapter_config, + invalidate_cache, + seed_defaults, + prune_orphans, + ) -`adapter_config` is the typed accessor singleton. `invalidate_cache()` drops -the read-side cache (called by /api/adapter-config PUT in v0.6-3c). -`seed_defaults(conn)` is called from `meshai.persistence.db.init_db()` after -the migration runner finishes; it INSERT OR IGNOREs one row per registry -entry so first-deploy behavior matches every existing handler constant -exactly. +`adapter_config` is the typed accessor singleton. +`invalidate_cache()` drops the read-side cache (called by /api/adapter-config + PUT in v0.6-3c). +`seed_defaults(conn)` populates adapter_config + adapter_meta from REGISTRY. +`prune_orphans(conn)` deletes adapter_config rows whose (adapter, key) is no + longer in REGISTRY -- the safety net for trimming the registry between + deploys. Every delete is logged at INFO level so docker logs carry a + paper trail of which keys disappeared. + +Both seed and prune are called from meshai.persistence.db.init_db() and are +idempotent. """ from __future__ import annotations @@ -33,6 +43,7 @@ __all__ = [ "adapter_config", "invalidate_cache", "seed_defaults", + "prune_orphans", "REGISTRY", "ADAPTER_META", "all_adapters", @@ -44,18 +55,16 @@ logger = logging.getLogger(__name__) def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]: - """Populate adapter_config + adapter_meta from the defaults registry. + """Populate adapter_config + adapter_meta from REGISTRY + ADAPTER_META. - Idempotent: INSERT OR IGNORE never overwrites a user-edited row. Run - after the v6 migration creates the tables; safe to re-run on every - init_db(). + Idempotent: INSERT OR IGNORE never overwrites a user-edited row. Safe + to re-run on every init_db(). Returns: - (config_rows_inserted, meta_rows_inserted) -- 0/0 when fully seeded. + (config_rows_inserted, meta_rows_inserted) """ now = time.time() - # adapter_config rows. cfg_inserted = 0 for (adapter, key), spec in REGISTRY.items(): default_json = json.dumps(spec["default"]) @@ -69,7 +78,6 @@ def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]: if cur.rowcount > 0: cfg_inserted += 1 - # adapter_meta rows. meta_inserted = 0 for adapter, meta in ADAPTER_META.items(): cur = conn.execute( @@ -89,3 +97,48 @@ def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]: cfg_inserted, meta_inserted, ) return cfg_inserted, meta_inserted + + +def prune_orphans(conn: sqlite3.Connection) -> int: + """Delete adapter_config rows whose (adapter, key) is no longer in REGISTRY. + + Each delete is logged at INFO level with the prefix + 'adapter_config orphan removed:' so the docker log captures a paper + trail. First boot after a registry trim shows N log lines (one per + removed key); every subsequent boot shows zero. + + Idempotent. adapter_meta is intentionally NOT pruned -- meta rows are + cheap and a previously-known adapter dropping all its config keys + still wants the include_in_llm_context toggle preserved (e.g. itd_511 + after v0.6-3a.1). + + Returns: + Count of rows deleted. + """ + valid_keys = set(REGISTRY.keys()) + existing = conn.execute( + "SELECT adapter, key, value_json FROM adapter_config" + ).fetchall() + + removed = 0 + for r in existing: + adapter = r["adapter"] + key = r["key"] + if (adapter, key) in valid_keys: + continue + logger.info( + "adapter_config orphan removed: %s.%s = %s", + adapter, key, r["value_json"], + ) + conn.execute( + "DELETE FROM adapter_config WHERE adapter=? AND key=?", + (adapter, key), + ) + removed += 1 + + if removed > 0: + # The accessor cache may hold a now-deleted key. Invalidating + # forces every next read to round-trip through the DB (cache + # miss -> DB miss -> registry fallback / AttributeError). + invalidate_cache() + return removed diff --git a/meshai/adapter_config/defaults.py b/meshai/adapter_config/defaults.py index 0f061a8..2704cfc 100644 --- a/meshai/adapter_config/defaults.py +++ b/meshai/adapter_config/defaults.py @@ -1,75 +1,53 @@ -"""v0.6-3a single source-of-truth for adapter_config defaults. +"""v0.6-3a.1 trimmed adapter_config defaults registry. -Every module-level magic-number constant flagged by the audit doc -(/mnt/c/Users/mtthw/OneDrive/Documents/Claude/Projects/meshai/v0.6-phase1-audit.md) -Section A.1 through A.12 lives here. The migration runs first (v6.sql -creates the empty tables); init_db() then calls seed_defaults() which -INSERT OR IGNOREs one row per (adapter, key) entry below, copying -value_json = default_json. +Per Matt's locked CONFIG-vs-CODE rule: -**Behavior contract**: every default below MUST match the current -hardcoded value in the corresponding handler EXACTLY. v0.6-3b will -swap the handler's constant for an `adapter_config..` -read. Until that wiring lands, this registry is the documentation of -intent; nothing reads from it at runtime yet. + CONFIG (lives here): + where we send (channels), how often (cadences/schedules), + thresholds (magnitude floors, severity gates, distance radius, + cooldown durations, freshness windows), curation data (which + sites/states/codes), toggles (enabled, include_in_llm_context, + drop_zero_magnitude). -Adding a new tunable in the future: + CODE (stays in the handlers; not surfaced to the GUI): + sentence templates, emoji choices, mapping/translation functions + (TomTom icon_map, ITD sub_type_map, Central adapter_map and + category_map), rendering logic (anchor priority order, + expires-buckets formatting, threshold-state labels), heuristic + logic (band_conditions Kp/SFI -> Good/Fair/Poor function). + +Trimmed from the v0.6-3a draft of 77 keys down to 43. The 34 dropped +keys are removed from the live DB on first boot by prune_orphans(), +which logs each delete at INFO level so docker logs carry a paper trail. + +Adding a new tunable: 1. Add an entry to REGISTRY below with default + type + description. - 2. The next container restart calls seed_defaults() which + 2. Confirm it matches the CONFIG rule (if you're tempted to add a + sentence template, an emoji, or a translation map, STOP -- that's + CODE). + 3. The next container restart calls seed_defaults() which INSERT OR IGNOREs the row. - 3. Wire the handler to read from adapter_config... + 4. Wire the handler to read from adapter_config... """ from __future__ import annotations from typing import Any -# -------- REGISTRY -------------------------------------------------------- -# -# Schema: REGISTRY[(adapter, key)] = { -# "default": , # the seed value -# "type": "int" | "float" | "str" | "bool" | "json", -# "description": "...", -# } -# -# `json` type covers list, dict, and nested structures. Decoded as -# json.loads(value_json) by the accessor. -# -# Citations in comments point at the current source-of-truth in the -# handler code -- these MUST stay in lock-step until v0.6-3b lands. - +# REGISTRY[(adapter, key)] = {"default": ..., "type": ..., "description": ...} +# Type vocabulary: "int" | "float" | "str" | "bool" | "json" REGISTRY: dict[tuple[str, str], dict[str, Any]] = { # ================================================================= - # WFIGS -- central/wfigs_handler.py + # WFIGS -- 4 settings (cooldown, anchor radius, two re-broadcast toggles) # ================================================================= ("wfigs", "cooldown_seconds"): { - "default": 28800, # central/wfigs_handler.py:43 (8*60*60) + "default": 28800, # central/wfigs_handler.py:43 "type": "int", "description": "Per-fire broadcast cooldown in seconds (forward-only Update gate).", }, - ("wfigs", "prefix_new"): { - "default": "New", - "type": "str", - "description": "Wire-string prefix for the first broadcast of a fire.", - }, - ("wfigs", "prefix_update"): { - "default": "Update", - "type": "str", - "description": "Wire-string prefix for subsequent Update broadcasts.", - }, - ("wfigs", "emoji"): { - "default": "đŸ”Ĩ", - "type": "str", - "description": "Wire-string lead emoji.", - }, - ("wfigs", "anchor_priority"): { - "default": ["geocoder_city", "nearest_town", "landclass", "county_state"], - "type": "json", - "description": "Ordered location-anchor fallback chain for the broadcast.", - }, ("wfigs", "anchor_max_mi"): { - "default": 100.0, # central/wfigs_handler.py:322 (nearest_town max_distance_mi) + "default": 100.0, # central/wfigs_handler.py:322 "type": "float", "description": "Max distance (mi) for the nearest_town anchor fallback.", }, @@ -85,528 +63,260 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = { }, # ================================================================= - # NWS -- central/nws_handler.py + # NWS -- 3 settings (severity gate, tombstone msgTypes, suffix-promote toggle) # ================================================================= ("nws", "broadcast_severities"): { - "default": ["Extreme", "Severe"], # nws_handler.py:43 + "default": ["Extreme", "Severe"], # nws_handler.py:43 "type": "json", "description": "CAP severity strings allowed onto the mesh.", }, ("nws", "tombstone_msgtypes"): { - "default": ["Cancel", "Expire"], # nws_handler.py:46 + "default": ["Cancel", "Expire"], # nws_handler.py:46 "type": "json", "description": "CAP msgType values that mark an alert as gone.", }, ("nws", "warning_suffix_promotes"): { - "default": True, # nws_handler.py:172 + "default": True, # nws_handler.py:172 "type": "bool", "description": "Promote category-name-ending-in-_warning to Severe when CAP severity is missing.", }, - ("nws", "event_emoji_map"): { - "default": [ # nws_handler.py:49-68 (ordered substring matches) - ["tornado", "đŸŒĒī¸"], - ["severe thunderstorm", "đŸŒŠī¸"], - ["thunderstorm", "đŸŒŠī¸"], - ["flash flood", "🌊"], - ["flood", "🌊"], - ["winter storm", "â„ī¸"], - ["blizzard", "â„ī¸"], - ["ice storm", "â„ī¸"], - ["ice", "â„ī¸"], - ["excessive heat", "đŸŒĄī¸"], - ["heat", "đŸŒĄī¸"], - ["high wind", "đŸŒŦī¸"], - ["wind", "đŸŒŦī¸"], - ["fire weather", "đŸ”Ĩ"], - ["red flag", "đŸ”Ĩ"], - ["air quality", "😷"], - ["freeze", "đŸĨļ"], - ["frost", "đŸĨļ"], - ], - "type": "json", - "description": "Ordered [substring, emoji] pairs; first case-insensitive substring match wins.", - }, - ("nws", "expires_short_thresholds_s"): { - "default": [21600, 604800], # nws_handler.py:109-113 (6h, 7d) - "type": "json", - "description": "[under_threshold_s, weekly_threshold_s] for 'until