mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat(v0.6-3a): adapter_config foundation -- migration + defaults registry + typed accessor
Closes the foundation slice of audit doc Section A (Rule 17). Lands two
SQLite tables, the seed routine that populates them from a Python
defaults registry, and a typed accessor that handler code will read in
v0.6-3b. No handler changes in this commit -- ZERO behavior risk, every
existing test still passes (721 / 69 skipped / 0 failed).
v6.sql tables:
- adapter_config(adapter, key, value_json, default_json, type, description,
updated_at) PRIMARY KEY(adapter, key) -- JSON-encoded
values flow through a single column uniformly. CHECK
constraint on `type` closes the vocab (int/float/str/
bool/json).
- adapter_meta(adapter PK, display_name, include_in_llm_context,
description, updated_at) -- per-adapter metadata + the
user-scopable LLM-context toggle (Matt refinement #5).
meshai/adapter_config/ package:
- defaults.py: REGISTRY dict mapping (adapter, key) -> {default, type,
description}. Covers audit doc sections A.1-A.12: wfigs, nws,
usgs_quake, swpc, usgs_nwis, incident family (tomtom_incidents,
state_511_atis, itd_511, shared "incident"), central consumer,
dispatcher, band_conditions, geocoder, firms, pipeline (Inhibitor +
Grouper). ~85 keys total. ADAPTER_META covers 15 adapters with
display_name + include_in_llm_context defaulting to True. Per Matt
refinement #3, every default matches the current handler constant
EXACTLY -- first deploy behavior is unchanged.
- _accessor.py: AdapterConfig class with `adapter_config.<adapter>.<key>`
syntax. Read pipeline: in-memory cache hit -> SQL -> registry
fallback (with WARNING) -> AttributeError. Process-wide cache; PUT
via v0.6-3c REST API calls invalidate_cache() to drop the cache.
GIL-atomic dict reads on the fast path (handlers call this hot).
- __init__.py: seed_defaults(conn) -- INSERT OR IGNOREs one row per
registry entry. Idempotent, never overwrites user edits.
Wiring:
- meshai/persistence/db.py: SCHEMA_VERSION 5 -> 6, and init_db() now
calls seed_defaults() after migrations apply.
- meshai/main.py: _init_components() now calls init_db() FIRST (per
commit #1 lessons-learned: a startup-time migration is required
when handlers will rely on the new schema; lazy-on-first-handler
is fine for v4/v5 but not for v6 where handler reads start in
v0.6-3b).
- tests/conftest.py: autouse fixture now calls init_db() + clears
the accessor cache around each test, so every test gets the v6
seed AND a clean cache without per-test boilerplate.
Tests (tests/test_adapter_config_foundation.py, 24 cases):
- v6 tables exist + schema_meta at 6 + type-vocabulary CHECK enforced
- seed populates every REGISTRY + ADAPTER_META row, value_json ==
default_json on first seed, type matches
- seed is idempotent + does not overwrite user edits
- accessor returns correctly typed values for int/float/str/bool/
json list/json dict/json None
- cache hit: second read does not touch the DB (patched _load_from_db
raises, accessor still succeeds)
- invalidate_cache forces a re-read; mutated DB value wins
- registry fallback path triggers when a row is missing (with WARNING)
- unknown key raises AttributeError
- setattr blocked (writes go via the REST API in 3c)
- every default JSON round-trips cleanly; every type is in vocabulary
- ADAPTER_META covers every adapter in REGISTRY
Test count: 697 -> 721 (+24 new, 0 regressions).
v0.6-3b will wire handlers one at a time (wfigs, nws, quake, swpc, nwis,
incident, central, dispatcher, band_conditions, geocoder, firms). Per
the audit lock, defaults match exactly so each wiring step is a pure
refactor -- bisect-safe.
v0.6-3c lands the /api/adapter-config CRUD + the AdapterConfig.tsx
dashboard editor + cache invalidation on PUT.
Refs audit doc v0.6-phase1-audit.md Section A + finding #4.
This commit is contained in:
parent
c333a97344
commit
cb3c5aec7e
8 changed files with 1360 additions and 4 deletions
91
meshai/adapter_config/__init__.py
Normal file
91
meshai/adapter_config/__init__.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""v0.6-3a meshai/adapter_config package.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
from meshai.adapter_config import adapter_config, invalidate_cache, seed_defaults
|
||||||
|
|
||||||
|
`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.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from meshai.adapter_config._accessor import (
|
||||||
|
adapter_config,
|
||||||
|
invalidate_cache,
|
||||||
|
)
|
||||||
|
from meshai.adapter_config.defaults import (
|
||||||
|
REGISTRY,
|
||||||
|
ADAPTER_META,
|
||||||
|
all_adapters,
|
||||||
|
registry_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"adapter_config",
|
||||||
|
"invalidate_cache",
|
||||||
|
"seed_defaults",
|
||||||
|
"REGISTRY",
|
||||||
|
"ADAPTER_META",
|
||||||
|
"all_adapters",
|
||||||
|
"registry_for",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]:
|
||||||
|
"""Populate adapter_config + adapter_meta from the defaults registry.
|
||||||
|
|
||||||
|
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().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(config_rows_inserted, meta_rows_inserted) -- 0/0 when fully seeded.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# adapter_config rows.
|
||||||
|
cfg_inserted = 0
|
||||||
|
for (adapter, key), spec in REGISTRY.items():
|
||||||
|
default_json = json.dumps(spec["default"])
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO adapter_config("
|
||||||
|
"adapter, key, value_json, default_json, type, description, updated_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(adapter, key, default_json, default_json,
|
||||||
|
spec["type"], spec.get("description") or "", now),
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
cfg_inserted += 1
|
||||||
|
|
||||||
|
# adapter_meta rows.
|
||||||
|
meta_inserted = 0
|
||||||
|
for adapter, meta in ADAPTER_META.items():
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO adapter_meta("
|
||||||
|
"adapter, display_name, include_in_llm_context, description, updated_at) "
|
||||||
|
"VALUES (?,?,?,?,?)",
|
||||||
|
(adapter, meta.get("display_name") or adapter,
|
||||||
|
1 if meta.get("include_in_llm_context", True) else 0,
|
||||||
|
meta.get("description") or "", now),
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
meta_inserted += 1
|
||||||
|
|
||||||
|
if cfg_inserted or meta_inserted:
|
||||||
|
logger.info(
|
||||||
|
"adapter_config: seed_defaults inserted %d config rows + %d meta rows",
|
||||||
|
cfg_inserted, meta_inserted,
|
||||||
|
)
|
||||||
|
return cfg_inserted, meta_inserted
|
||||||
182
meshai/adapter_config/_accessor.py
Normal file
182
meshai/adapter_config/_accessor.py
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
"""v0.6-3a typed accessor over the adapter_config table.
|
||||||
|
|
||||||
|
Handlers use the singleton `adapter_config` from this package:
|
||||||
|
|
||||||
|
from meshai.adapter_config import adapter_config
|
||||||
|
|
||||||
|
cooldown_s = adapter_config.wfigs.cooldown_seconds # int
|
||||||
|
severities = adapter_config.nws.broadcast_severities # list[str]
|
||||||
|
|
||||||
|
Reads are dict-cached. The cache invalidates on `invalidate_cache()`
|
||||||
|
(called from the REST API's PUT handler in v0.6-3c). The cache is
|
||||||
|
per-process; meshai is single-process so there is no cross-process
|
||||||
|
coherence problem.
|
||||||
|
|
||||||
|
Fallback ordering on read:
|
||||||
|
1. In-memory cache hit -> return immediately.
|
||||||
|
2. SQL `SELECT value_json, type FROM adapter_config WHERE adapter=? AND key=?`
|
||||||
|
-> decode + cache + return.
|
||||||
|
3. Registry fallback (defaults.REGISTRY) -> return without caching, log
|
||||||
|
at WARNING level (this shouldn't happen post-seed but defends
|
||||||
|
against schema drift).
|
||||||
|
4. Raise AttributeError -> the key is unknown to both DB and registry.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Process-wide cache. Reads use the GIL-atomic dict get/set; the lock
|
||||||
|
# guards multi-statement sequences (gen bump + clear).
|
||||||
|
_CACHE_LOCK = threading.Lock()
|
||||||
|
_cache: dict[tuple[str, str], Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
"""Drop every cached value. Called by the REST API on PUT/reset."""
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
_cache.clear()
|
||||||
|
logger.debug("adapter_config: cache invalidated")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- internals ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(value_json: str, type_: str) -> Any:
|
||||||
|
"""JSON-decoded value coerced to the declared Python type."""
|
||||||
|
raw = json.loads(value_json)
|
||||||
|
if type_ == "int":
|
||||||
|
if raw is None: return None
|
||||||
|
return int(raw)
|
||||||
|
if type_ == "float":
|
||||||
|
if raw is None: return None
|
||||||
|
return float(raw)
|
||||||
|
if type_ == "str":
|
||||||
|
if raw is None: return None
|
||||||
|
return str(raw)
|
||||||
|
if type_ == "bool":
|
||||||
|
if raw is None: return None
|
||||||
|
return bool(raw)
|
||||||
|
if type_ == "json":
|
||||||
|
return raw
|
||||||
|
# Unknown tag -- return the JSON-decoded form and log.
|
||||||
|
logger.warning("adapter_config: unknown type tag %r; returning raw decoded", type_)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _load_from_db(adapter: str, key: str) -> tuple[bool, Any]:
|
||||||
|
"""Returns (found, value). found=False means no row exists."""
|
||||||
|
try:
|
||||||
|
from meshai.persistence import get_db
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value_json, type FROM adapter_config "
|
||||||
|
"WHERE adapter=? AND key=?",
|
||||||
|
(adapter, key),
|
||||||
|
).fetchone()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("adapter_config: DB read failed for %s.%s", adapter, key)
|
||||||
|
return (False, None)
|
||||||
|
if row is None:
|
||||||
|
return (False, None)
|
||||||
|
return (True, _decode(row["value_json"], row["type"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_from_registry(adapter: str, key: str) -> tuple[bool, Any]:
|
||||||
|
"""Returns (found, default_value) from defaults.REGISTRY."""
|
||||||
|
from meshai.adapter_config.defaults import REGISTRY
|
||||||
|
spec = REGISTRY.get((adapter, key))
|
||||||
|
if spec is None:
|
||||||
|
return (False, None)
|
||||||
|
return (True, spec["default"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- public accessor ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _AdapterSection:
|
||||||
|
"""Returned by `adapter_config.<adapter>`. Resolves attribute access
|
||||||
|
to the typed value via the read pipeline."""
|
||||||
|
|
||||||
|
__slots__ = ("_adapter",)
|
||||||
|
|
||||||
|
def __init__(self, adapter: str):
|
||||||
|
object.__setattr__(self, "_adapter", adapter)
|
||||||
|
|
||||||
|
def __getattr__(self, key: str) -> Any:
|
||||||
|
# Avoid recursion when Python probes for dunder attributes.
|
||||||
|
if key.startswith("__"):
|
||||||
|
raise AttributeError(key)
|
||||||
|
return _resolve(self._adapter, key)
|
||||||
|
|
||||||
|
def __setattr__(self, key: str, value: Any) -> None:
|
||||||
|
raise AttributeError(
|
||||||
|
"adapter_config is read-only at the accessor level; "
|
||||||
|
"use the /api/adapter-config PUT endpoint to mutate."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<adapter_config.{self._adapter}>"
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterConfig:
|
||||||
|
"""Singleton-style accessor: `adapter_config.<adapter>.<key>`."""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def __getattr__(self, adapter: str) -> _AdapterSection:
|
||||||
|
if adapter.startswith("__"):
|
||||||
|
raise AttributeError(adapter)
|
||||||
|
return _AdapterSection(adapter)
|
||||||
|
|
||||||
|
def get(self, adapter: str, key: str) -> Any:
|
||||||
|
"""Programmatic accessor that mirrors `<self>.<adapter>.<key>`."""
|
||||||
|
return _resolve(adapter, key)
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
invalidate_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(adapter: str, key: str) -> Any:
|
||||||
|
"""Read pipeline: cache -> DB -> registry."""
|
||||||
|
cache_key = (adapter, key)
|
||||||
|
cached = _cache.get(cache_key, _SENTINEL)
|
||||||
|
if cached is not _SENTINEL:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# DB.
|
||||||
|
found, value = _load_from_db(adapter, key)
|
||||||
|
if found:
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
_cache[cache_key] = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Registry fallback. Don't cache this -- when the DB catches up
|
||||||
|
# (next seed_defaults / migration / write), we want the DB value to
|
||||||
|
# win without a manual invalidation.
|
||||||
|
found, default = _load_from_registry(adapter, key)
|
||||||
|
if found:
|
||||||
|
logger.warning(
|
||||||
|
"adapter_config: %s.%s missing from DB; using registry default. "
|
||||||
|
"(seed_defaults() should have populated this -- check init_db order.)",
|
||||||
|
adapter, key,
|
||||||
|
)
|
||||||
|
return default
|
||||||
|
|
||||||
|
raise AttributeError(
|
||||||
|
f"adapter_config: unknown key {adapter!r}.{key!r} "
|
||||||
|
f"(not in DB and not in defaults.REGISTRY)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Sentinel to distinguish "cache miss" from "cache hit with value None".
|
||||||
|
_SENTINEL = object()
|
||||||
|
|
||||||
|
|
||||||
|
# The singleton instance used by handler code.
|
||||||
|
adapter_config = AdapterConfig()
|
||||||
711
meshai/adapter_config/defaults.py
Normal file
711
meshai/adapter_config/defaults.py
Normal file
|
|
@ -0,0 +1,711 @@
|
||||||
|
"""v0.6-3a single source-of-truth for adapter_config defaults.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.<adapter>.<key>`
|
||||||
|
read. Until that wiring lands, this registry is the documentation of
|
||||||
|
intent; nothing reads from it at runtime yet.
|
||||||
|
|
||||||
|
Adding a new tunable in the future:
|
||||||
|
1. Add an entry to REGISTRY below with default + type + description.
|
||||||
|
2. The next container restart calls seed_defaults() which
|
||||||
|
INSERT OR IGNOREs the row.
|
||||||
|
3. Wire the handler to read from adapter_config.<adapter>.<key>.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# -------- REGISTRY --------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Schema: REGISTRY[(adapter, key)] = {
|
||||||
|
# "default": <python value>, # 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: dict[tuple[str, str], dict[str, Any]] = {
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# WFIGS -- central/wfigs_handler.py
|
||||||
|
# =================================================================
|
||||||
|
("wfigs", "cooldown_seconds"): {
|
||||||
|
"default": 28800, # central/wfigs_handler.py:43 (8*60*60)
|
||||||
|
"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)
|
||||||
|
"type": "float",
|
||||||
|
"description": "Max distance (mi) for the nearest_town anchor fallback.",
|
||||||
|
},
|
||||||
|
("wfigs", "broadcast_on_acres"): {
|
||||||
|
"default": True,
|
||||||
|
"type": "bool",
|
||||||
|
"description": "Re-broadcast when acres increase (forward-only).",
|
||||||
|
},
|
||||||
|
("wfigs", "broadcast_on_contained"): {
|
||||||
|
"default": True,
|
||||||
|
"type": "bool",
|
||||||
|
"description": "Re-broadcast when containment percent increases (forward-only).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# NWS -- central/nws_handler.py
|
||||||
|
# =================================================================
|
||||||
|
("nws", "broadcast_severities"): {
|
||||||
|
"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
|
||||||
|
"type": "json",
|
||||||
|
"description": "CAP msgType values that mark an alert as gone.",
|
||||||
|
},
|
||||||
|
("nws", "warning_suffix_promotes"): {
|
||||||
|
"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 <time>' rendering buckets.",
|
||||||
|
},
|
||||||
|
("nws", "default_emoji"): {
|
||||||
|
"default": "⚠️", # nws_handler.py:86 fallback
|
||||||
|
"type": "str",
|
||||||
|
"description": "Wire emoji when no event_emoji_map substring matches.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# USGS_QUAKE -- central/quake_handler.py
|
||||||
|
# =================================================================
|
||||||
|
("usgs_quake", "regional_centroid"): {
|
||||||
|
"default": [44.36, -114.61], # quake_handler.py:36-37 (Idaho centroid)
|
||||||
|
"type": "json",
|
||||||
|
"description": "[lat, lon] of the regional gate origin; quakes within regional_radius_mi use regional_mag_floor.",
|
||||||
|
},
|
||||||
|
("usgs_quake", "regional_radius_mi"): {
|
||||||
|
"default": 250, # quake_handler.py:38
|
||||||
|
"type": "int",
|
||||||
|
"description": "Radius (mi) of the regional gate around regional_centroid.",
|
||||||
|
},
|
||||||
|
("usgs_quake", "broadcast_pager_alerts"): {
|
||||||
|
"default": ["orange", "red"], # quake_handler.py:40
|
||||||
|
"type": "json",
|
||||||
|
"description": "USGS PAGER alert levels that broadcast at any magnitude.",
|
||||||
|
},
|
||||||
|
("usgs_quake", "global_mag_floor"): {
|
||||||
|
"default": 3.0, # quake_handler.py:69
|
||||||
|
"type": "float",
|
||||||
|
"description": "Global magnitude floor for unconditional broadcasts.",
|
||||||
|
},
|
||||||
|
("usgs_quake", "regional_mag_floor"): {
|
||||||
|
"default": 2.5, # quake_handler.py:70
|
||||||
|
"type": "float",
|
||||||
|
"description": "Reduced magnitude floor for quakes within regional_radius_mi of centroid.",
|
||||||
|
},
|
||||||
|
("usgs_quake", "escalate_mag_floor"): {
|
||||||
|
"default": 5.0, # quake_handler.py:76
|
||||||
|
"type": "float",
|
||||||
|
"description": "Magnitude floor for the visual ⚠️ escalation emoji.",
|
||||||
|
},
|
||||||
|
("usgs_quake", "emoji_routine"): {
|
||||||
|
"default": "🌐",
|
||||||
|
"type": "str",
|
||||||
|
"description": "Wire emoji for non-escalated quakes.",
|
||||||
|
},
|
||||||
|
("usgs_quake", "emoji_escalate"): {
|
||||||
|
"default": "⚠️",
|
||||||
|
"type": "str",
|
||||||
|
"description": "Wire emoji for quakes at or above escalate_mag_floor.",
|
||||||
|
},
|
||||||
|
("usgs_quake", "emoji_tsunami"): {
|
||||||
|
"default": "🚨",
|
||||||
|
"type": "str",
|
||||||
|
"description": "Wire emoji for any tsunami_warning, overrides other emoji.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# SWPC -- central/swpc_handler.py
|
||||||
|
# =================================================================
|
||||||
|
("swpc", "geomag_kp_floor"): {
|
||||||
|
"default": 7.0, # swpc_handler.py:66-68 (Kp >= 7 = G3)
|
||||||
|
"type": "float",
|
||||||
|
"description": "Kp value at or above which geomagnetic storms broadcast.",
|
||||||
|
},
|
||||||
|
("swpc", "flare_class_floor"): {
|
||||||
|
"default": "X1", # swpc_handler.py:40
|
||||||
|
"type": "str",
|
||||||
|
"description": "Minimum X-ray flare class to broadcast ('X1' = R3).",
|
||||||
|
},
|
||||||
|
("swpc", "proton_pfu_floor"): {
|
||||||
|
"default": 10.0, # swpc_handler.py:48 (S1)
|
||||||
|
"type": "float",
|
||||||
|
"description": "Proton flux floor in pfu (>=10 = S1 minor radiation storm).",
|
||||||
|
},
|
||||||
|
("swpc", "proton_energy_labels"): {
|
||||||
|
"default": ["10", ">=10", ">10", ">=10 MeV", ">=10MeV",
|
||||||
|
"30", ">=30", ">=30 MeV", ">=50 MeV",
|
||||||
|
">=100 MeV", ">=100"], # swpc_handler.py:109-111
|
||||||
|
"type": "json",
|
||||||
|
"description": "Accepted energy-channel labels for proton flux validation.",
|
||||||
|
},
|
||||||
|
("swpc", "emoji_geomag"): {"default": "🌌", "type": "str", "description": "Geomag-storm wire emoji."},
|
||||||
|
("swpc", "emoji_flare"): {"default": "🔆", "type": "str", "description": "Solar-flare wire emoji."},
|
||||||
|
("swpc", "emoji_proton"): {"default": "☢️", "type": "str", "description": "Solar-radiation-storm wire emoji."},
|
||||||
|
("swpc", "wire_geomag_template"): {
|
||||||
|
"default": "{emoji} {label} geomagnetic storm ({code}/{scalar}) -- HF degraded, aurora possible",
|
||||||
|
"type": "str",
|
||||||
|
"description": "Format template for geomag broadcasts. Available fields: emoji, code, label, scalar.",
|
||||||
|
},
|
||||||
|
("swpc", "wire_flare_template"): {
|
||||||
|
"default": "{emoji} Major solar flare ({code}/{scalar}) -- HF radio fading ~30 min, GPS may glitch",
|
||||||
|
"type": "str",
|
||||||
|
"description": "Format template for flare broadcasts.",
|
||||||
|
},
|
||||||
|
("swpc", "wire_proton_template"): {
|
||||||
|
"default": "{emoji} Solar radiation storm ({code}/{scalar}) -- polar HF radio affected",
|
||||||
|
"type": "str",
|
||||||
|
"description": "Format template for proton broadcasts.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# USGS_NWIS -- central/nwis_handler.py + central/idaho_gauge_sites.py
|
||||||
|
# (the 9-site curation table itself is commit #4 -- gauge_sites)
|
||||||
|
# =================================================================
|
||||||
|
("usgs_nwis", "parameter_codes"): {
|
||||||
|
"default": ["00060", "00065"], # nwis_handler.py:57
|
||||||
|
"type": "json",
|
||||||
|
"description": "USGS parameter codes the handler processes (00060=discharge, 00065=gage height).",
|
||||||
|
},
|
||||||
|
("usgs_nwis", "threshold_labels"): {
|
||||||
|
"default": { # nwis_handler.py:60-65
|
||||||
|
"action": "action stage",
|
||||||
|
"flood_minor": "minor flooding",
|
||||||
|
"flood_moderate": "moderate flooding",
|
||||||
|
"flood_major": "major flooding",
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "Human-readable phrase per threshold_state band.",
|
||||||
|
},
|
||||||
|
("usgs_nwis", "emoji"): {
|
||||||
|
"default": "🌊",
|
||||||
|
"type": "str",
|
||||||
|
"description": "Wire emoji.",
|
||||||
|
},
|
||||||
|
("usgs_nwis", "threshold_rank"): {
|
||||||
|
"default": ["normal", "action", "flood_minor", "flood_moderate", "flood_major"], # idaho_gauge_sites.py:110
|
||||||
|
"type": "json",
|
||||||
|
"description": "Threshold rank from low to high (upward-crossing detector).",
|
||||||
|
},
|
||||||
|
("usgs_nwis", "broadcast_on_recede"): {
|
||||||
|
"default": False, # nwis_handler.py:204-209 (silent on recede)
|
||||||
|
"type": "bool",
|
||||||
|
"description": "Broadcast when a gauge transitions DOWN through a threshold band.",
|
||||||
|
},
|
||||||
|
("usgs_nwis", "prefix_new"): {
|
||||||
|
"default": "New",
|
||||||
|
"type": "str",
|
||||||
|
"description": "Wire prefix for the first upward-crossing broadcast.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# INCIDENT -- central/incident_handler.py (shared across 3 sources)
|
||||||
|
# =================================================================
|
||||||
|
("incident", "freshness_seconds"): {
|
||||||
|
"default": 1800, # incident_handler.py:49 + central_normalizer.py:917 default
|
||||||
|
"type": "int",
|
||||||
|
"description": "Drop incidents older than this many seconds.",
|
||||||
|
},
|
||||||
|
("incident", "broadcast_on_update"): {
|
||||||
|
"default": False, # incident_handler.py:594-602 (v0.5.9 REVISED: no Update)
|
||||||
|
"type": "bool",
|
||||||
|
"description": "Re-broadcast on magnitude bump / delay growth / icon flip after first New.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ---- TomTom-specific ----
|
||||||
|
("tomtom_incidents", "drop_zero_magnitude"): {
|
||||||
|
"default": True, # incident_handler.py:250
|
||||||
|
"type": "bool",
|
||||||
|
"description": "Drop envelopes with magnitude_of_delay==0.",
|
||||||
|
},
|
||||||
|
("tomtom_incidents", "drop_non_present"): {
|
||||||
|
"default": True, # incident_handler.py:254
|
||||||
|
"type": "bool",
|
||||||
|
"description": "Drop envelopes whose time_validity != 'present'.",
|
||||||
|
},
|
||||||
|
("tomtom_incidents", "icon_map"): {
|
||||||
|
"default": { # incident_handler.py:64-79 (int keys serialized as str via json)
|
||||||
|
"0": "incident", "1": "accident", "2": "fog", "3": "danger",
|
||||||
|
"4": "rain", "5": "ice", "6": "jam", "7": "lane_closed",
|
||||||
|
"8": "road_closed", "9": "road_works", "10": "wind",
|
||||||
|
"11": "flooding", "12": "broken_down", "14": "incident",
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "TomTom icon_category int -> canonical sub_type mapping.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ---- state_511_atis specific ----
|
||||||
|
("state_511_atis", "skipped_states"): {
|
||||||
|
"default": ["ID"], # incident_handler.py:459-470 (v0.5.9 GAMMA cutover)
|
||||||
|
"type": "json",
|
||||||
|
"description": "States whose state_511_atis envelopes are silently skipped (handled by itd_511 instead).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ---- itd_511 specific ----
|
||||||
|
("itd_511", "sub_type_map"): {
|
||||||
|
"default": { # incident_handler.py:83-115 (shared with state_511)
|
||||||
|
"crash": "accident",
|
||||||
|
"incident": "incident",
|
||||||
|
"debrisOnRoadway": "debris",
|
||||||
|
"disabledVehicle": "disabled_vehicle",
|
||||||
|
"vehicleOnFire": "vehicle_on_fire",
|
||||||
|
"wildfire": "incident",
|
||||||
|
"wildfireInArea": "incident",
|
||||||
|
"leftLaneBlocked": "lane_closed",
|
||||||
|
"onRampBlocked": "ramp_closed",
|
||||||
|
"roadwayBlocked": "road_closed",
|
||||||
|
"roadConstruction": "road_works",
|
||||||
|
"pavementMarkingOperations": "road_works",
|
||||||
|
"pavementMarkingOperations ": "road_works",
|
||||||
|
"utilityWork": "road_works",
|
||||||
|
"singleLineTraffic:AlternatingDirections": "lane_closed",
|
||||||
|
"roadMaintenanceOperations": "road_works",
|
||||||
|
"pavingOperations": "road_works",
|
||||||
|
"bridgeConstruction": "road_works",
|
||||||
|
"bridgeMaintenanceOperations": "road_works",
|
||||||
|
"flaggingOperation": "lane_closed",
|
||||||
|
"brushControl": "road_works",
|
||||||
|
"constructionWork": "road_works",
|
||||||
|
"guardrailRepairs": "road_works",
|
||||||
|
"workOnTheShoulder": "road_works",
|
||||||
|
"nightTimeConstructionWork": "road_works",
|
||||||
|
"bridgeInspectionWork": "road_works",
|
||||||
|
"longTermRoadConstruction": "road_works",
|
||||||
|
"workOnUndergroundServices": "road_works",
|
||||||
|
"roadsideCleanupCrew": "road_works",
|
||||||
|
"RampRestriction": "lane_closed",
|
||||||
|
"parade": "parade",
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "ITD/state_511 event_sub_type -> canonical sub_type mapping.",
|
||||||
|
},
|
||||||
|
("itd_511", "sub_type_emoji"): {
|
||||||
|
"default": { # incident_handler.py:118-139
|
||||||
|
"accident": "🚨", "jam": "🚗", "road_closed": "🚫", "closure": "🚫",
|
||||||
|
"road_works": "🚧", "lane_closed": "🟠", "ramp_closed": "🟠",
|
||||||
|
"debris": "⚠️", "vehicle_on_fire": "🔥", "disabled_vehicle": "🛑",
|
||||||
|
"ice": "⚠️", "fog": "⚠️", "flooding": "🌊", "wind": "🌬️",
|
||||||
|
"broken_down": "🛞", "danger": "⚠️", "rain": "⚠️",
|
||||||
|
"incident": "⚠️", "special_event": "🎪", "parade": "🎪",
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "Canonical sub_type -> emoji.",
|
||||||
|
},
|
||||||
|
("itd_511", "sub_type_phrase"): {
|
||||||
|
"default": { # incident_handler.py:142-163
|
||||||
|
"accident": "crash", "jam": "jam", "road_closed": "road closed",
|
||||||
|
"closure": "closure", "road_works": "road works",
|
||||||
|
"lane_closed": "lane closed", "ramp_closed": "ramp closed",
|
||||||
|
"debris": "debris on roadway", "vehicle_on_fire": "vehicle fire",
|
||||||
|
"disabled_vehicle": "disabled vehicle", "ice": "icy conditions",
|
||||||
|
"fog": "fog", "flooding": "flooding", "wind": "high wind",
|
||||||
|
"broken_down": "broken-down vehicle", "danger": "dangerous conditions",
|
||||||
|
"rain": "heavy rain", "incident": "incident",
|
||||||
|
"special_event": "special event", "parade": "parade",
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "Canonical sub_type -> human-readable noun phrase.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# CENTRAL consumer -- central/consumer.py
|
||||||
|
# =================================================================
|
||||||
|
("central", "adapter_map"): {
|
||||||
|
"default": { # consumer.py:183-199 CENTRAL_ADAPTER_TO_SOURCE
|
||||||
|
"wfigs_incidents": "fires",
|
||||||
|
"wfigs_perimeters": "fires",
|
||||||
|
"nwis": "usgs",
|
||||||
|
"swpc_alerts": "swpc",
|
||||||
|
"swpc_kindex": "swpc",
|
||||||
|
"swpc_protons": "swpc",
|
||||||
|
"wzdx": "traffic",
|
||||||
|
"tomtom_incidents": "traffic",
|
||||||
|
"state_511_atis": "roads511",
|
||||||
|
"itd_511": "roads511",
|
||||||
|
"firms": "firms",
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "Central adapter name -> meshai source name remap.",
|
||||||
|
},
|
||||||
|
("central", "category_map"): {
|
||||||
|
"default": [ # consumer.py:203-228 ordered prefix -> flat
|
||||||
|
["wx.alert", "weather_warning"],
|
||||||
|
["wx.", "weather_statement"],
|
||||||
|
["fire.hotspot", "wildfire_hotspot"],
|
||||||
|
["fire.incident", "wildfire_incident"],
|
||||||
|
["fire.perimeter", "wildfire_incident"],
|
||||||
|
["fire.", "wildfire_incident"],
|
||||||
|
["quake.", "earthquake_event"],
|
||||||
|
["hydro.", "stream_flow"],
|
||||||
|
["space.alert", "rf_propagation_alert"],
|
||||||
|
["space.kindex", "geomagnetic_storm"],
|
||||||
|
["space.proton", "solar_radiation_storm"],
|
||||||
|
["space.", "geomagnetic_storm"],
|
||||||
|
["disaster.", "disaster_event"],
|
||||||
|
["traffic_flow", "traffic_flow"],
|
||||||
|
["traffic_cameras", "traffic_camera"],
|
||||||
|
["work_zone", "work_zone"],
|
||||||
|
["incident", "road_incident"],
|
||||||
|
["closure", "road_closure"],
|
||||||
|
["traffic.", "traffic_congestion"],
|
||||||
|
],
|
||||||
|
"type": "json",
|
||||||
|
"description": "Ordered [prefix, flat_category] pairs; first prefix match wins.",
|
||||||
|
},
|
||||||
|
("central", "severity_thresholds"): {
|
||||||
|
"default": { # consumer.py:264-288 (map_severity)
|
||||||
|
"routine_max": 1,
|
||||||
|
"priority_max": 2,
|
||||||
|
"immediate_min": 3,
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "Central int severity buckets: 0..routine_max -> routine, priority_max -> priority, >= immediate_min -> immediate.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# DISPATCHER -- notifications/pipeline/dispatcher.py
|
||||||
|
# =================================================================
|
||||||
|
("dispatcher", "dedup_lru_max"): {
|
||||||
|
"default": 10000, # pipeline/dispatcher.py:28
|
||||||
|
"type": "int",
|
||||||
|
"description": "In-memory dedup OrderedDict cap. Disk has a 7-day window which may exceed this.",
|
||||||
|
},
|
||||||
|
("dispatcher", "cooldown_prune_size"): {
|
||||||
|
"default": 1024, # pipeline/dispatcher.py:_COOLDOWN_INMEM_PRUNE_THRESHOLD
|
||||||
|
"type": "int",
|
||||||
|
"description": "In-memory cooldown map size that triggers a 2*cooldown_s prune.",
|
||||||
|
},
|
||||||
|
("dispatcher", "cooldown_prune_multiplier"): {
|
||||||
|
"default": 2, # pipeline/dispatcher.py:184 (2 * cooldown_s)
|
||||||
|
"type": "int",
|
||||||
|
"description": "Cooldown-prune cutoff multiplier (rows older than N * cooldown_s deleted).",
|
||||||
|
},
|
||||||
|
("dispatcher", "dedup_db_retention_days"): {
|
||||||
|
"default": 7, # pipeline/dispatcher.py:_DEDUP_DB_RETENTION_S = 7*86400
|
||||||
|
"type": "int",
|
||||||
|
"description": "Days a (source, event_id) dedup row stays on disk before the on-insert cleanup deletes it.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# BAND_CONDITIONS -- notifications/scheduled/band_conditions.py
|
||||||
|
# =================================================================
|
||||||
|
("band_conditions", "swpc_freshness_seconds"): {
|
||||||
|
"default": 21600, # band_conditions.py:45 (_SWPC_FRESHNESS_S)
|
||||||
|
"type": "int",
|
||||||
|
"description": "If swpc_events readings older than this, fall through to HamQSL.",
|
||||||
|
},
|
||||||
|
("band_conditions", "slot_labels"): {
|
||||||
|
"default": { # band_conditions.py:54-58
|
||||||
|
"06:00": ["☀️", "Day Propagation"],
|
||||||
|
"14:00": ["🌞", "Day Propagation"],
|
||||||
|
"22:00": ["🌙", "Night Propagation"],
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "Per-slot [emoji, headline] -- chosen by SLOT time, not actual fire time.",
|
||||||
|
},
|
||||||
|
("band_conditions", "band_order"): {
|
||||||
|
"default": ["80-40m", "30-20m", "17-15m", "12-10m"], # band_conditions.py:62
|
||||||
|
"type": "json",
|
||||||
|
"description": "Wire-string band-row order. Matches HamQSL groupings.",
|
||||||
|
},
|
||||||
|
("band_conditions", "hamqsl_url"): {
|
||||||
|
"default": "https://www.hamqsl.com/solarxml.php", # band_conditions.py:65
|
||||||
|
"type": "str",
|
||||||
|
"description": "HamQSL solarxml fallback URL (public, no auth).",
|
||||||
|
},
|
||||||
|
("band_conditions", "hamqsl_timeout_s"): {
|
||||||
|
"default": 5, # band_conditions.py:66
|
||||||
|
"type": "int",
|
||||||
|
"description": "HamQSL fetch timeout.",
|
||||||
|
},
|
||||||
|
("band_conditions", "rating_emoji"): {
|
||||||
|
"default": { # band_conditions.py:49
|
||||||
|
"Good": "🟢", "Fair": "🟡", "Poor": "🔴",
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "Per-rating colour emoji.",
|
||||||
|
},
|
||||||
|
# Heuristic threshold blob -- one document so the GUI can render it as
|
||||||
|
# a structured editor in v0.6-3c.
|
||||||
|
("band_conditions", "heuristic"): {
|
||||||
|
"default": { # band_conditions.py:194-240 (_heuristic_ratings)
|
||||||
|
# All thresholds map to Good / Fair / Poor.
|
||||||
|
# Format: {band: {day_or_night: {rule_name: value}}}
|
||||||
|
"80-40m": {
|
||||||
|
"night": {"good_kp_lt": 4, "good_sfi_gt": 70, "fair_kp_lt": 5},
|
||||||
|
"day": {"fair_kp_lt": 4},
|
||||||
|
},
|
||||||
|
"30-20m": {
|
||||||
|
"day": {"good_sfi_gt": 120, "good_kp_lt": 4,
|
||||||
|
"fair_sfi_min": 80, "fair_sfi_max": 120, "fair_kp_lt": 6,
|
||||||
|
"poor_sfi_lt": 80, "poor_kp_min": 6},
|
||||||
|
"night": {"good_sfi_gt": 110, "good_kp_lt": 4, "fair_kp_lt": 5},
|
||||||
|
},
|
||||||
|
"17-15m": {
|
||||||
|
"day": {"good_sfi_gt": 120, "fair_sfi_min": 90, "fair_sfi_max": 120, "fair_kp_lt": 5},
|
||||||
|
},
|
||||||
|
"12-10m": {
|
||||||
|
"day": {"good_sfi_gt": 140, "fair_sfi_min": 110, "fair_sfi_max": 140},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"type": "json",
|
||||||
|
"description": "Per-band day/night Good/Fair/Poor thresholds. Edit by hand only.",
|
||||||
|
},
|
||||||
|
("band_conditions", "fallback_kp"): {
|
||||||
|
"default": 9.0, # band_conditions.py:196 (state default when sfi missing)
|
||||||
|
"type": "float",
|
||||||
|
"description": "Default Kp when none persisted (worst-case).",
|
||||||
|
},
|
||||||
|
("band_conditions", "fallback_sfi"): {
|
||||||
|
"default": 50.0, # band_conditions.py:197 (state default)
|
||||||
|
"type": "float",
|
||||||
|
"description": "Default SFI when none persisted (worst-case).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# GEOCODER -- central_normalizer.py (Photon section)
|
||||||
|
# =================================================================
|
||||||
|
("geocoder", "photon_url"): {
|
||||||
|
"default": "http://100.64.0.24:2322", # central_normalizer.py:282
|
||||||
|
"type": "str",
|
||||||
|
"description": "Photon base URL (Tailscale-internal Echo6 instance).",
|
||||||
|
},
|
||||||
|
("geocoder", "photon_timeout_s"): {
|
||||||
|
"default": 2.0, # central_normalizer.py:284
|
||||||
|
"type": "float",
|
||||||
|
"description": "Photon HTTP timeout.",
|
||||||
|
},
|
||||||
|
("geocoder", "photon_radius_km"): {
|
||||||
|
"default": 80, # central_normalizer.py:285
|
||||||
|
"type": "int",
|
||||||
|
"description": "Photon /reverse search radius (~50 mi default).",
|
||||||
|
},
|
||||||
|
("geocoder", "photon_limit"): {
|
||||||
|
"default": 10, # central_normalizer.py:286
|
||||||
|
"type": "int",
|
||||||
|
"description": "Photon /reverse max features per call.",
|
||||||
|
},
|
||||||
|
("geocoder", "town_osm_values"): {
|
||||||
|
"default": ["city", "town", "village", "hamlet", "suburb", "locality"], # central_normalizer.py:289
|
||||||
|
"type": "json",
|
||||||
|
"description": "OSM place classes we accept as 'town' for nearest_town.",
|
||||||
|
},
|
||||||
|
("geocoder", "h3_resolution"): {
|
||||||
|
"default": 7, # central_normalizer.py:296
|
||||||
|
"type": "int",
|
||||||
|
"description": "H3 resolution for the Photon-cache hex grid (~5 km cells).",
|
||||||
|
},
|
||||||
|
("geocoder", "h3_cache_max"): {
|
||||||
|
"default": 10000, # central_normalizer.py:297
|
||||||
|
"type": "int",
|
||||||
|
"description": "Max H3 cache entries before LRU eviction.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# FIRMS -- central/firms_handler.py (v0.6-1)
|
||||||
|
# =================================================================
|
||||||
|
("firms", "confidence_floor"): {
|
||||||
|
"default": "low", # firms_handler.py FIRMS_CONFIDENCE_FLOOR
|
||||||
|
"type": "str",
|
||||||
|
"description": "Min FIRMS confidence to store ('low' = store all).",
|
||||||
|
},
|
||||||
|
("firms", "frp_floor"): {
|
||||||
|
"default": 0.0, # firms_handler.py FIRMS_FRP_FLOOR
|
||||||
|
"type": "float",
|
||||||
|
"description": "Min FRP (MW) to store; 0 = store every detection.",
|
||||||
|
},
|
||||||
|
("firms", "bbox"): {
|
||||||
|
"default": None, # firms_handler.py FIRMS_BBOX_OPTIONAL
|
||||||
|
"type": "json",
|
||||||
|
"description": "Optional [min_lat, min_lon, max_lat, max_lon] spatial filter (null = no filter).",
|
||||||
|
},
|
||||||
|
("firms", "dedup_lat_lon_decimals"): {
|
||||||
|
"default": 5, # firms_handler.py _DEDUP_LAT_LON_DECIMALS
|
||||||
|
"type": "int",
|
||||||
|
"description": "Decimal places for lat/lon dedup-key rounding (5 = ~1.1 m).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# PIPELINE (inhibitor + grouper -- audit doc A.9)
|
||||||
|
# =================================================================
|
||||||
|
("pipeline", "inhibitor_ttl_seconds"): {
|
||||||
|
"default": 1800, # pipeline/inhibitor.py:27 constructor default, never config-driven
|
||||||
|
"type": "int",
|
||||||
|
"description": "How long an inhibit_key remains active after the originating event.",
|
||||||
|
},
|
||||||
|
("pipeline", "grouper_window_seconds"): {
|
||||||
|
"default": 60, # pipeline/grouper.py:27 constructor default
|
||||||
|
"type": "int",
|
||||||
|
"description": "How long to hold a group_key before emitting downstream.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -------- ADAPTER_META ----------------------------------------------------
|
||||||
|
#
|
||||||
|
# One row per adapter the GUI surfaces. include_in_llm_context controls
|
||||||
|
# whether the env_reporter (commit #5) is allowed to read this adapter's
|
||||||
|
# tables when assembling LLM context for a DM. Defaults to True per
|
||||||
|
# Matt's locked refinement.
|
||||||
|
|
||||||
|
ADAPTER_META: dict[str, dict[str, Any]] = {
|
||||||
|
"wfigs": {
|
||||||
|
"display_name": "WFIGS wildfire incidents",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "NIFC-authoritative wildfire registry (named incidents, acres, containment).",
|
||||||
|
},
|
||||||
|
"firms": {
|
||||||
|
"display_name": "FIRMS satellite hotspots",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "NASA VIIRS/MODIS heat-pixel feed. Storage-only (no broadcast).",
|
||||||
|
},
|
||||||
|
"nws": {
|
||||||
|
"display_name": "NWS weather alerts",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "CAP-formatted severe-weather warnings/watches/advisories.",
|
||||||
|
},
|
||||||
|
"usgs_quake": {
|
||||||
|
"display_name": "USGS earthquakes",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "Real-time earthquake feed with Idaho-regional + global tiers.",
|
||||||
|
},
|
||||||
|
"swpc": {
|
||||||
|
"display_name": "SWPC space weather",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "Geomagnetic / flare / proton storm alerts (G/R/S scale).",
|
||||||
|
},
|
||||||
|
"usgs_nwis": {
|
||||||
|
"display_name": "USGS NWIS stream gauges",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "Real-time stream-gauge readings (Idaho curated sites).",
|
||||||
|
},
|
||||||
|
"tomtom_incidents": {
|
||||||
|
"display_name": "TomTom traffic incidents",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "Real-time crashes/jams/closures (TomTom feed).",
|
||||||
|
},
|
||||||
|
"state_511_atis": {
|
||||||
|
"display_name": "Castle Rock state 511 ATIS",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "Multi-state ATIS feed (Idaho cutover to itd_511 in v0.5.9 GAMMA).",
|
||||||
|
},
|
||||||
|
"itd_511": {
|
||||||
|
"display_name": "ITD 511 (Idaho)",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "Idaho Transportation Department incident/closure/work-zone feed.",
|
||||||
|
},
|
||||||
|
"band_conditions": {
|
||||||
|
"display_name": "Band conditions (HF propagation)",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "3x/day scheduled broadcast of HF band ratings (SWPC-local + HamQSL fallback).",
|
||||||
|
},
|
||||||
|
"central": {
|
||||||
|
"display_name": "Central consumer routing",
|
||||||
|
"include_in_llm_context": False,
|
||||||
|
"description": "Adapter <-> source remap, hierarchical category map, severity buckets. Operational, not LLM-relevant.",
|
||||||
|
},
|
||||||
|
"dispatcher": {
|
||||||
|
"display_name": "Dispatcher state",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "Cold-start anchor, cumulative drop counters, cooldown + dedup state. Useful for 'why did we drop X?' answers.",
|
||||||
|
},
|
||||||
|
"geocoder": {
|
||||||
|
"display_name": "Geocoder (Photon + H3)",
|
||||||
|
"include_in_llm_context": False,
|
||||||
|
"description": "Photon-reverse + H3 cache settings. Operational, not LLM-relevant.",
|
||||||
|
},
|
||||||
|
"incident": {
|
||||||
|
"display_name": "Incident pipeline (shared settings)",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "Settings shared across tomtom_incidents / state_511_atis / itd_511.",
|
||||||
|
},
|
||||||
|
"pipeline": {
|
||||||
|
"display_name": "Notification pipeline (Inhibitor + Grouper)",
|
||||||
|
"include_in_llm_context": True,
|
||||||
|
"description": "TTL + window tunables for the Inhibitor and Grouper stages.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience views.
|
||||||
|
|
||||||
|
def all_adapters() -> set[str]:
|
||||||
|
"""Set of every adapter name referenced by REGISTRY or ADAPTER_META."""
|
||||||
|
return {adapter for adapter, _ in REGISTRY} | set(ADAPTER_META)
|
||||||
|
|
||||||
|
|
||||||
|
def registry_for(adapter: str) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Subset of REGISTRY for one adapter, keyed by key only."""
|
||||||
|
return {k: v for (a, k), v in REGISTRY.items() if a == adapter}
|
||||||
|
|
@ -212,6 +212,16 @@ class MeshAI:
|
||||||
|
|
||||||
async def _init_components(self) -> None:
|
async def _init_components(self) -> None:
|
||||||
"""Initialize all components."""
|
"""Initialize all components."""
|
||||||
|
# v0.6-3a: persistence init runs FIRST so any subsequent handler
|
||||||
|
# or dispatcher that calls get_db() finds the v6 schema applied
|
||||||
|
# and adapter_config seeded. The seed (INSERT OR IGNORE) is
|
||||||
|
# idempotent on every restart.
|
||||||
|
try:
|
||||||
|
from meshai.persistence import init_db
|
||||||
|
init_db()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("persistence init_db failed at startup")
|
||||||
|
|
||||||
# Conversation history
|
# Conversation history
|
||||||
self.history = ConversationHistory(self.config.history)
|
self.history = ConversationHistory(self.config.history)
|
||||||
await self.history.initialize()
|
await self.history.initialize()
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
||||||
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
||||||
SCHEMA_VERSION = 5
|
SCHEMA_VERSION = 6
|
||||||
SCHEMA_META_TABLE = "schema_meta"
|
SCHEMA_META_TABLE = "schema_meta"
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
@ -122,8 +122,18 @@ def close_thread_connection() -> None:
|
||||||
|
|
||||||
def init_db(path: Optional[str] = None) -> sqlite3.Connection:
|
def init_db(path: Optional[str] = None) -> sqlite3.Connection:
|
||||||
"""Explicit init entry point (idempotent). Equivalent to get_db()
|
"""Explicit init entry point (idempotent). Equivalent to get_db()
|
||||||
semantically but documents intent at startup. Returns the connection."""
|
semantically but documents intent at startup. Returns the connection.
|
||||||
return get_db(path)
|
|
||||||
|
v0.6-3a: after migrations apply, seed adapter_config + adapter_meta
|
||||||
|
from meshai/adapter_config/defaults.py:REGISTRY. Idempotent via
|
||||||
|
INSERT OR IGNORE so a re-run is a clean no-op."""
|
||||||
|
conn = get_db(path)
|
||||||
|
try:
|
||||||
|
from meshai.adapter_config import seed_defaults
|
||||||
|
seed_defaults(conn)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("init_db: adapter_config seed_defaults failed")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def _read_migration_files() -> list[tuple[int, str, str]]:
|
def _read_migration_files() -> list[tuple[int, str, str]]:
|
||||||
|
|
|
||||||
42
meshai/persistence/migrations/v6.sql
Normal file
42
meshai/persistence/migrations/v6.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
-- v0.6-3a adapter_config foundation (audit doc Section A keystone).
|
||||||
|
--
|
||||||
|
-- Two tables collapse every per-adapter magic number scattered across
|
||||||
|
-- meshai/central/* handlers + meshai/notifications/* into a single
|
||||||
|
-- GUI-editable surface, satisfying Rule 17 ("GUI-editable config").
|
||||||
|
--
|
||||||
|
-- adapter_config holds typed per-(adapter, key) values. The seed routine
|
||||||
|
-- in meshai/adapter_config/__init__.py:seed_defaults() populates one row
|
||||||
|
-- per entry in meshai/adapter_config/defaults.py:REGISTRY, copying
|
||||||
|
-- value_json = default_json so first-deploy behavior matches every
|
||||||
|
-- existing module-level constant exactly (per Matt's v0.6 Phase 1
|
||||||
|
-- refinement: hardcoded values become GUI defaults, no behavior change
|
||||||
|
-- on first deploy).
|
||||||
|
--
|
||||||
|
-- adapter_meta carries per-adapter metadata: a human display_name + the
|
||||||
|
-- include_in_llm_context toggle (per Matt's refinement #5: user picks
|
||||||
|
-- which adapters' tables the LLM may read in DMs).
|
||||||
|
--
|
||||||
|
-- value_json + default_json are TEXT-encoded JSON so int/float/str/bool/
|
||||||
|
-- list/dict all flow through one column uniformly. The accessor decodes
|
||||||
|
-- to the type tagged in `type`; a CHECK keeps that vocabulary closed so
|
||||||
|
-- a stray 'integer' or 'string' never sneaks in.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adapter_config (
|
||||||
|
adapter TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value_json TEXT NOT NULL,
|
||||||
|
default_json TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('int','float','str','bool','json')),
|
||||||
|
description TEXT,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (adapter, key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adapter_config_adapter ON adapter_config(adapter);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adapter_meta (
|
||||||
|
adapter TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT,
|
||||||
|
include_in_llm_context INTEGER NOT NULL DEFAULT 1,
|
||||||
|
description TEXT,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -21,11 +21,32 @@ from meshai.persistence import db as _persistence_db
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _isolate_meshai_db(tmp_path, monkeypatch):
|
def _isolate_meshai_db(tmp_path, monkeypatch):
|
||||||
"""Point MESHAI_DB_PATH at a tmp file per test."""
|
"""Point MESHAI_DB_PATH at a tmp file per test + run init_db so the
|
||||||
|
v6 adapter_config seed lands before any test code runs.
|
||||||
|
|
||||||
|
v0.6-3a: init_db() applies pending migrations AND seeds
|
||||||
|
adapter_config from the defaults registry. Tests that read via
|
||||||
|
need the seed in place.
|
||||||
|
"""
|
||||||
p = str(tmp_path / "meshai-test-isolated.sqlite")
|
p = str(tmp_path / "meshai-test-isolated.sqlite")
|
||||||
monkeypatch.setenv("MESHAI_DB_PATH", p)
|
monkeypatch.setenv("MESHAI_DB_PATH", p)
|
||||||
_persistence_db._initialised.clear()
|
_persistence_db._initialised.clear()
|
||||||
close_thread_connection()
|
close_thread_connection()
|
||||||
|
# Clear the adapter_config accessor cache so the prior tests
|
||||||
|
# in-memory cache does not leak into this test.
|
||||||
|
try:
|
||||||
|
from meshai.adapter_config import invalidate_cache
|
||||||
|
invalidate_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Eager init: applies v1..v6 migrations + seeds adapter_config.
|
||||||
|
from meshai.persistence import init_db
|
||||||
|
init_db()
|
||||||
yield p
|
yield p
|
||||||
close_thread_connection()
|
close_thread_connection()
|
||||||
_persistence_db._initialised.discard(p)
|
_persistence_db._initialised.discard(p)
|
||||||
|
try:
|
||||||
|
from meshai.adapter_config import invalidate_cache
|
||||||
|
invalidate_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
|
||||||
289
tests/test_adapter_config_foundation.py
Normal file
289
tests/test_adapter_config_foundation.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
"""v0.6-3a foundation tests: migration, seed, accessor.
|
||||||
|
|
||||||
|
No handler is wired to adapter_config in 3a (that lands in 3b). These
|
||||||
|
tests verify the foundation in isolation:
|
||||||
|
- v6 migration creates adapter_config + adapter_meta
|
||||||
|
- seed_defaults seeds every REGISTRY + ADAPTER_META row
|
||||||
|
- seed_defaults is idempotent (re-run is a no-op)
|
||||||
|
- accessor returns typed values for int / float / str / bool / json
|
||||||
|
- cache hit: second read does not re-hit the DB
|
||||||
|
- invalidate_cache forces reload
|
||||||
|
- registry fallback when the DB row is missing (defensive)
|
||||||
|
- unknown key raises AttributeError
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meshai.adapter_config import (
|
||||||
|
adapter_config,
|
||||||
|
invalidate_cache,
|
||||||
|
seed_defaults,
|
||||||
|
REGISTRY,
|
||||||
|
ADAPTER_META,
|
||||||
|
)
|
||||||
|
from meshai.adapter_config import _accessor as accessor_mod
|
||||||
|
from meshai.persistence import close_thread_connection, init_db
|
||||||
|
from meshai.persistence import db as persistence_db
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- fixtures ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_db(tmp_path, monkeypatch):
|
||||||
|
"""Forces a fresh DB AND clears the in-memory accessor cache.
|
||||||
|
|
||||||
|
The conftest autouse fixture also redirects MESHAI_DB_PATH, but
|
||||||
|
explicit cleanup here makes the tests robust to ordering.
|
||||||
|
"""
|
||||||
|
p = str(tmp_path / "ac-test.sqlite")
|
||||||
|
monkeypatch.setenv("MESHAI_DB_PATH", p)
|
||||||
|
persistence_db._initialised.clear()
|
||||||
|
close_thread_connection()
|
||||||
|
invalidate_cache()
|
||||||
|
conn = init_db()
|
||||||
|
yield conn
|
||||||
|
close_thread_connection()
|
||||||
|
persistence_db._initialised.discard(p)
|
||||||
|
invalidate_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- schema --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_v6_tables_exist(fresh_db):
|
||||||
|
tables = {r["name"] for r in fresh_db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
assert "adapter_config" in tables
|
||||||
|
assert "adapter_meta" in tables
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_meta_at_v6(fresh_db):
|
||||||
|
v = fresh_db.execute(
|
||||||
|
"SELECT value FROM schema_meta WHERE key='version'"
|
||||||
|
).fetchone()["value"]
|
||||||
|
assert int(v) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_config_type_check_constrains_vocabulary(fresh_db):
|
||||||
|
"""The CHECK constraint on `type` rejects unknown labels."""
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
fresh_db.execute(
|
||||||
|
"INSERT INTO adapter_config(adapter, key, value_json, default_json, "
|
||||||
|
"type, description, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
("x", "y", "1", "1", "integer", "", 0.0), # 'integer' is not in vocab
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- seed ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_populates_every_registry_row(fresh_db):
|
||||||
|
"""Every (adapter, key) in REGISTRY lands in adapter_config."""
|
||||||
|
rows = fresh_db.execute("SELECT adapter, key FROM adapter_config").fetchall()
|
||||||
|
db_keys = {(r["adapter"], r["key"]) for r in rows}
|
||||||
|
assert db_keys == set(REGISTRY.keys()), (
|
||||||
|
f"DB missing {set(REGISTRY) - db_keys}, "
|
||||||
|
f"extra in DB {db_keys - set(REGISTRY)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_value_matches_registry_default(fresh_db):
|
||||||
|
"""value_json == default_json on first seed; both = json.dumps(default)."""
|
||||||
|
for (adapter, key), spec in REGISTRY.items():
|
||||||
|
row = fresh_db.execute(
|
||||||
|
"SELECT value_json, default_json, type FROM adapter_config "
|
||||||
|
"WHERE adapter=? AND key=?",
|
||||||
|
(adapter, key),
|
||||||
|
).fetchone()
|
||||||
|
expected = json.dumps(spec["default"])
|
||||||
|
assert row["value_json"] == expected, f"{adapter}.{key} value drift"
|
||||||
|
assert row["default_json"] == expected, f"{adapter}.{key} default drift"
|
||||||
|
assert row["type"] == spec["type"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_populates_every_adapter_meta_row(fresh_db):
|
||||||
|
rows = fresh_db.execute("SELECT adapter, include_in_llm_context FROM adapter_meta").fetchall()
|
||||||
|
db_adapters = {r["adapter"] for r in rows}
|
||||||
|
assert db_adapters == set(ADAPTER_META.keys())
|
||||||
|
for r in rows:
|
||||||
|
spec = ADAPTER_META[r["adapter"]]
|
||||||
|
expected = 1 if spec.get("include_in_llm_context", True) else 0
|
||||||
|
assert r["include_in_llm_context"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_is_idempotent(fresh_db):
|
||||||
|
"""Re-running seed_defaults inserts zero new rows."""
|
||||||
|
a, b = seed_defaults(fresh_db)
|
||||||
|
assert a == 0 and b == 0
|
||||||
|
# Row counts unchanged.
|
||||||
|
cfg = fresh_db.execute("SELECT COUNT(*) FROM adapter_config").fetchone()[0]
|
||||||
|
meta = fresh_db.execute("SELECT COUNT(*) FROM adapter_meta").fetchone()[0]
|
||||||
|
assert cfg == len(REGISTRY)
|
||||||
|
assert meta == len(ADAPTER_META)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_does_not_overwrite_user_edits(fresh_db):
|
||||||
|
"""A user-edited value_json survives a re-seed (INSERT OR IGNORE)."""
|
||||||
|
fresh_db.execute(
|
||||||
|
"UPDATE adapter_config SET value_json=? WHERE adapter=? AND key=?",
|
||||||
|
("999", "wfigs", "cooldown_seconds"),
|
||||||
|
)
|
||||||
|
seed_defaults(fresh_db)
|
||||||
|
row = fresh_db.execute(
|
||||||
|
"SELECT value_json FROM adapter_config "
|
||||||
|
"WHERE adapter='wfigs' AND key='cooldown_seconds'"
|
||||||
|
).fetchone()
|
||||||
|
assert row["value_json"] == "999", "seed must not overwrite user edits"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- accessor -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_accessor_returns_int(fresh_db):
|
||||||
|
invalidate_cache()
|
||||||
|
v = adapter_config.wfigs.cooldown_seconds
|
||||||
|
assert isinstance(v, int)
|
||||||
|
assert v == 28800
|
||||||
|
|
||||||
|
|
||||||
|
def test_accessor_returns_float(fresh_db):
|
||||||
|
invalidate_cache()
|
||||||
|
v = adapter_config.usgs_quake.global_mag_floor
|
||||||
|
assert isinstance(v, float)
|
||||||
|
assert v == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_accessor_returns_str(fresh_db):
|
||||||
|
invalidate_cache()
|
||||||
|
v = adapter_config.geocoder.photon_url
|
||||||
|
assert isinstance(v, str)
|
||||||
|
assert v == "http://100.64.0.24:2322"
|
||||||
|
|
||||||
|
|
||||||
|
def test_accessor_returns_bool(fresh_db):
|
||||||
|
invalidate_cache()
|
||||||
|
v = adapter_config.tomtom_incidents.drop_zero_magnitude
|
||||||
|
assert isinstance(v, bool)
|
||||||
|
assert v is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_accessor_returns_json_list(fresh_db):
|
||||||
|
invalidate_cache()
|
||||||
|
v = adapter_config.nws.broadcast_severities
|
||||||
|
assert v == ["Extreme", "Severe"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_accessor_returns_json_dict(fresh_db):
|
||||||
|
invalidate_cache()
|
||||||
|
v = adapter_config.usgs_nwis.threshold_labels
|
||||||
|
assert v["action"] == "action stage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_accessor_returns_json_none(fresh_db):
|
||||||
|
"""A registry default of None survives the round-trip."""
|
||||||
|
invalidate_cache()
|
||||||
|
v = adapter_config.firms.bbox
|
||||||
|
assert v is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- cache --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_hits_second_read(fresh_db):
|
||||||
|
"""Second read of the same key bypasses the DB."""
|
||||||
|
invalidate_cache()
|
||||||
|
# Prime the cache.
|
||||||
|
_ = adapter_config.wfigs.cooldown_seconds
|
||||||
|
# Patch _load_from_db to fail; if the cache works we never call it.
|
||||||
|
with patch.object(accessor_mod, "_load_from_db",
|
||||||
|
side_effect=AssertionError("cache miss -- went to DB")):
|
||||||
|
v = adapter_config.wfigs.cooldown_seconds
|
||||||
|
assert v == 28800
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalidate_forces_reload(fresh_db):
|
||||||
|
"""After invalidate_cache the next read hits the DB and sees fresh values."""
|
||||||
|
invalidate_cache()
|
||||||
|
_ = adapter_config.wfigs.cooldown_seconds # prime cache
|
||||||
|
|
||||||
|
fresh_db.execute(
|
||||||
|
"UPDATE adapter_config SET value_json=? WHERE adapter='wfigs' AND key='cooldown_seconds'",
|
||||||
|
("3600",),
|
||||||
|
)
|
||||||
|
# Without invalidation, the cached 28800 is returned.
|
||||||
|
assert adapter_config.wfigs.cooldown_seconds == 28800
|
||||||
|
invalidate_cache()
|
||||||
|
assert adapter_config.wfigs.cooldown_seconds == 3600
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- defensive fallback paths -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_fallback_when_db_row_missing(fresh_db, caplog):
|
||||||
|
"""If the seed somehow missed a key, the accessor still returns the
|
||||||
|
registry default (with a WARNING)."""
|
||||||
|
invalidate_cache()
|
||||||
|
# Delete one row to simulate the missing-seed case.
|
||||||
|
fresh_db.execute(
|
||||||
|
"DELETE FROM adapter_config WHERE adapter='wfigs' AND key='cooldown_seconds'"
|
||||||
|
)
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.WARNING, logger="meshai.adapter_config._accessor")
|
||||||
|
v = adapter_config.wfigs.cooldown_seconds
|
||||||
|
assert v == 28800 # from REGISTRY
|
||||||
|
assert any("missing from DB" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_key_raises(fresh_db):
|
||||||
|
invalidate_cache()
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
_ = adapter_config.wfigs.no_such_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_adapter_section_still_works_until_key_lookup(fresh_db):
|
||||||
|
"""Accessing adapter_config.<unknown> returns a section object;
|
||||||
|
the AttributeError comes on the key dereference."""
|
||||||
|
invalidate_cache()
|
||||||
|
section = adapter_config.nonexistent_adapter
|
||||||
|
assert section is not None # section object exists
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
_ = section.any_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_setattr_blocked(fresh_db):
|
||||||
|
"""Writes via the accessor are forbidden -- use the API."""
|
||||||
|
invalidate_cache()
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
adapter_config.wfigs.cooldown_seconds = 999
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- registry sanity ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_registry_default_round_trips_through_json():
|
||||||
|
"""Every default must json.dumps + json.loads to an equivalent value."""
|
||||||
|
for (adapter, key), spec in REGISTRY.items():
|
||||||
|
encoded = json.dumps(spec["default"])
|
||||||
|
decoded = json.loads(encoded)
|
||||||
|
assert decoded == spec["default"], f"{adapter}.{key}: JSON round-trip drift"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_registry_type_is_in_vocabulary():
|
||||||
|
valid = {"int", "float", "str", "bool", "json"}
|
||||||
|
for (adapter, key), spec in REGISTRY.items():
|
||||||
|
assert spec["type"] in valid, f"{adapter}.{key}: invalid type {spec['type']!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_meta_includes_every_registry_adapter():
|
||||||
|
"""Every adapter referenced in REGISTRY should have a meta row so the
|
||||||
|
GUI can render an include_in_llm_context toggle for it."""
|
||||||
|
reg_adapters = {a for a, _ in REGISTRY}
|
||||||
|
meta_adapters = set(ADAPTER_META)
|
||||||
|
missing = reg_adapters - meta_adapters
|
||||||
|
assert not missing, f"adapters in REGISTRY but missing ADAPTER_META: {missing}"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue